create-workframe 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/LICENSE +201 -201
  2. package/NOTICE +12 -12
  3. package/README.md +8 -92
  4. package/SECURITY.md +40 -40
  5. package/bin/workframe.js +329 -329
  6. package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +1 -1
  7. package/docs/workspace-instructions/WORKFRAME_ROUTING.md +8 -8
  8. package/package.json +3 -6
  9. package/profiles/architect/AGENTS.md +29 -29
  10. package/profiles/architect/SOUL.md +2 -2
  11. package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
  12. package/profiles/designer/AGENTS.md +26 -26
  13. package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
  14. package/profiles/dev/AGENTS.md +28 -28
  15. package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
  16. package/profiles/docs/AGENTS.md +27 -27
  17. package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
  18. package/profiles/research/AGENTS.md +26 -26
  19. package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
  20. package/profiles/visionary/AGENTS.md +25 -25
  21. package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
  22. package/profiles/workframe-agent/AGENTS.md +37 -37
  23. package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
  24. package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
  25. package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
  26. package/rules/workspace-README.md +5 -5
  27. package/scripts/apply-update-hermes.sh +17 -17
  28. package/scripts/apply-update-workframe.sh +77 -77
  29. package/scripts/bootstrap-workspace-link.sh +8 -8
  30. package/scripts/bundle-workframe-ui.mjs +3 -3
  31. package/scripts/compose-docker-host.sh +37 -37
  32. package/scripts/ensure-compose-host-paths.mjs +51 -51
  33. package/scripts/fix-zk-encryption-key.sh +35 -35
  34. package/scripts/lib/install-identity.mjs +212 -212
  35. package/scripts/restart-gateway-hermes.sh +12 -12
  36. package/scripts/set-compose-public-url.mjs +92 -92
  37. package/scripts/setup-stack-secrets.sh +50 -50
  38. package/scripts/sync-canonical-to-package.mjs +8 -7
  39. package/scripts/verify-public-deploy.sh +105 -105
  40. package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
  41. package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
  42. package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
  43. package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
  44. package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
  45. package/workframe-api/README.md +28 -28
  46. package/workframe-api/action_proxy.py +131 -131
  47. package/workframe-api/auth_rate_limit.py +49 -49
  48. package/workframe-api/credential_vault.py +445 -445
  49. package/workframe-api/data/avatar-catalog.json +41 -41
  50. package/workframe-api/email_sender.py +220 -220
  51. package/workframe-api/google_auth.py +90 -90
  52. package/workframe-api/install_api.py +359 -359
  53. package/workframe-api/internal_proxy_auth.py +150 -150
  54. package/workframe-api/llm_proxy.py +277 -277
  55. package/workframe-api/oidc_jwt.py +108 -108
  56. package/workframe-api/package.json +12 -13
  57. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  58. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  59. package/workframe-api/requirements.txt +2 -2
  60. package/workframe-api/site_meta.py +271 -271
  61. package/workframe-api/stack_config.py +427 -427
  62. package/workframe-api/time-bind-chat.py +99 -99
  63. package/workframe-api/turn_credentials.py +226 -226
  64. package/workframe-api/updates.py +417 -417
  65. package/workframe-api/vault_kek.py +159 -159
  66. package/workframe-api/zk_auth.py +633 -633
  67. package/workframe-supervisor/Dockerfile +11 -11
  68. package/workframe-supervisor/server.py +787 -787
  69. package/workframe-ui/docker/nginx.conf +85 -85
  70. package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
  71. package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
  72. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
  73. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
  74. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
  75. package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
  76. package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
  77. package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
  78. package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
  79. package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
  80. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
  81. package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
  82. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
  83. package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
  84. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
  85. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
  86. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
  87. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
  88. package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
  89. package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
  90. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
  91. package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
  92. package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
  93. package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
  94. package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
  95. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
  96. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
  97. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
  98. package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
  99. package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
  100. package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
  101. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
  102. package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
  103. package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
  104. package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
  105. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
  106. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
  107. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
  108. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
  109. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
  110. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
  111. package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
  112. package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
  113. package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
  114. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
  115. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
  116. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
  117. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
  118. package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
  119. package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
  120. package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
  121. package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
  122. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
  123. package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
  124. package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
  125. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
  126. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
  127. package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
  128. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
  129. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
  130. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
  131. package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
  132. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
  133. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
  134. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
  135. package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
  136. package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
  137. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
  138. package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
  139. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
  140. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
  141. package/workframe-ui/public/favicon.svg +7 -7
  142. package/workframe-ui/public/index.html +50 -50
  143. package/workframe-ui/public/workframe-config.json +3 -3
  144. package/scripts/security_audit.py +0 -156
  145. package/scripts/test-scaffold.mjs +0 -390
  146. package/workframe-api/tests/__init__.py +0 -0
  147. package/workframe-api/tests/db_setup.py +0 -13
  148. package/workframe-api/tests/test_admin_updates_gated.py +0 -30
  149. package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
  150. package/workframe-api/tests/test_agent_profile_sync.py +0 -76
  151. package/workframe-api/tests/test_auth_email.py +0 -222
  152. package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
  153. package/workframe-api/tests/test_auth_rate_limit.py +0 -19
  154. package/workframe-api/tests/test_avatar_resolve.py +0 -77
  155. package/workframe-api/tests/test_child_soul_template.py +0 -71
  156. package/workframe-api/tests/test_credential_canary.py +0 -135
  157. package/workframe-api/tests/test_credential_isolation.py +0 -448
  158. package/workframe-api/tests/test_credential_resolution.py +0 -206
  159. package/workframe-api/tests/test_device_oauth.py +0 -108
  160. package/workframe-api/tests/test_doctor_repair.py +0 -103
  161. package/workframe-api/tests/test_ensure_profile_api.py +0 -77
  162. package/workframe-api/tests/test_gateway_compose_security.py +0 -136
  163. package/workframe-api/tests/test_install_secure_host.py +0 -39
  164. package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
  165. package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
  166. package/workframe-api/tests/test_kanban_delegation.py +0 -185
  167. package/workframe-api/tests/test_llm_proxy.py +0 -155
  168. package/workframe-api/tests/test_login_access_policy.py +0 -183
  169. package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
  170. package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
  171. package/workframe-api/tests/test_platform_auth.py +0 -47
  172. package/workframe-api/tests/test_profile_config_path.py +0 -56
  173. package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
  174. package/workframe-api/tests/test_profile_create.py +0 -72
  175. package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
  176. package/workframe-api/tests/test_profile_install_health.py +0 -45
  177. package/workframe-api/tests/test_profile_secret_policy.py +0 -57
  178. package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
  179. package/workframe-api/tests/test_provider_bootstrap.py +0 -75
  180. package/workframe-api/tests/test_provider_connect.py +0 -54
  181. package/workframe-api/tests/test_room_crud.py +0 -192
  182. package/workframe-api/tests/test_room_tenancy.py +0 -701
  183. package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
  184. package/workframe-api/tests/test_site_meta.py +0 -81
  185. package/workframe-api/tests/test_soul_stub.py +0 -42
  186. package/workframe-api/tests/test_space_member_sync.py +0 -99
  187. package/workframe-api/tests/test_stripe_stack_config.py +0 -37
  188. package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
  189. package/workframe-api/tests/test_turn_credential_vault.py +0 -125
  190. package/workframe-api/tests/test_updates.py +0 -176
  191. package/workframe-api/tests/test_user_cohort.py +0 -113
  192. package/workframe-api/tests/test_vault_envelope.py +0 -110
  193. package/workframe-api/tests/test_workspace_members.py +0 -183
  194. package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
  195. package/workframe-api/tests/test_workspace_provider_list.py +0 -57
  196. package/workframe-supervisor/tests/test_exec_guard.py +0 -42
  197. package/workframe-supervisor/tests/test_server_import.py +0 -21
  198. package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
  199. package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
  200. package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
  201. package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
  202. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
  203. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
  204. package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
  205. package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
  206. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B89jAMFF.js +0 -1
@@ -1,277 +1,277 @@
1
- """Internal LLM proxy — Hermes sends lease tokens; API vault supplies upstream keys."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- import re
8
- import socket
9
- import ssl
10
- import urllib.error
11
- import urllib.request
12
- from typing import Any, Callable
13
- from http.server import BaseHTTPRequestHandler
14
-
15
- import internal_proxy_auth
16
- import turn_credentials
17
-
18
- LEASE_PREFIX = turn_credentials.LEASE_PREFIX
19
-
20
- UPSTREAM_BASE: dict[str, str] = {
21
- "openrouter": "https://openrouter.ai/api/v1",
22
- "openai": "https://api.openai.com/v1",
23
- "anthropic": "https://api.anthropic.com",
24
- "google": "https://generativelanguage.googleapis.com/v1beta",
25
- "deepseek": "https://api.deepseek.com/v1",
26
- }
27
-
28
- PROXY_PATH_RE = re.compile(r"^/internal/llm/([a-z0-9_-]+)(/.*)?$", re.IGNORECASE)
29
-
30
-
31
- def normalize_upstream_path(base: str, subpath: str) -> str:
32
- """Drop a leading /v1 when upstream base already ends with /v1.
33
-
34
- Hermes uses model.base_url …/internal/llm/openrouter/v1; the OpenAI client
35
- then requests …/v1/chat/completions, which we forward as subpath /v1/….
36
- """
37
- path = subpath if str(subpath or "").startswith("/") else f"/{subpath or ''}"
38
- base_norm = str(base or "").rstrip("/")
39
- if path == "/v1":
40
- return ""
41
- if path.startswith("/v1/") and base_norm.endswith("/v1"):
42
- return path[3:]
43
- return path
44
-
45
-
46
- def is_internal_client(host: str) -> bool:
47
- """Allow docker/private callers only — not public browser origins."""
48
- return internal_proxy_auth.is_internal_client(host)
49
-
50
-
51
- def authorize_internal_proxy(handler: BaseHTTPRequestHandler) -> tuple[bool, str]:
52
- return internal_proxy_auth.authorize_internal_proxy(handler)
53
-
54
-
55
- def extract_profile_slug(headers: dict[str, str]) -> str:
56
- return str(
57
- headers.get(internal_proxy_auth.PROFILE_HEADER)
58
- or headers.get("x-workframe-profile")
59
- or ""
60
- ).strip()
61
-
62
-
63
- def validate_lease_profile(
64
- lease: dict[str, Any],
65
- headers: dict[str, str],
66
- ) -> tuple[bool, str, int]:
67
- """Bind bearer lease to calling Hermes profile (0022 N2 / 0023 C1)."""
68
- want = str(lease.get("profile_slug") or "").strip()
69
- if not want:
70
- return True, "", 0
71
- got = extract_profile_slug(headers)
72
- if not got:
73
- return False, "profile header required", 403
74
- if got != want:
75
- return False, "profile mismatch", 403
76
- return True, "", 0
77
-
78
-
79
- def extract_bearer(headers: dict[str, str]) -> str:
80
- auth = str(headers.get("Authorization") or headers.get("authorization") or "").strip()
81
- if auth.lower().startswith("bearer "):
82
- return auth[7:].strip()
83
- api_key = str(headers.get("X-Api-Key") or headers.get("x-api-key") or "").strip()
84
- if api_key:
85
- return api_key
86
- return ""
87
-
88
-
89
- def upstream_auth_header(provider: str, secret: str) -> dict[str, str]:
90
- provider = str(provider or "").strip().lower()
91
- secret = str(secret or "").strip()
92
- if provider == "anthropic":
93
- return {"x-api-key": secret, "anthropic-version": "2023-06-01"}
94
- if provider == "google":
95
- return {"x-goog-api-key": secret}
96
- return {"Authorization": f"Bearer {secret}"}
97
-
98
-
99
- def _error_response(status: int, error: str) -> tuple[int, dict[str, str], bytes]:
100
- return status, {"Content-Type": "application/json"}, json.dumps({"error": error}).encode()
101
-
102
-
103
- def _build_upstream_request(
104
- provider: str,
105
- subpath: str,
106
- method: str,
107
- headers: dict[str, str],
108
- body: bytes | None,
109
- *,
110
- resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
111
- ) -> tuple[urllib.request.Request | None, tuple[int, dict[str, str], bytes] | None]:
112
- provider = str(provider or "").strip().lower()
113
- base = UPSTREAM_BASE.get(provider)
114
- if not base:
115
- return None, _error_response(404, "unknown provider")
116
-
117
- token = extract_bearer(headers)
118
- lease = turn_credentials.validate_lease(token)
119
- if not lease:
120
- return None, _error_response(401, "invalid lease")
121
- if str(lease.get("provider") or "").lower() != provider:
122
- return None, _error_response(403, "provider mismatch")
123
-
124
- ok_profile, profile_err, profile_status = validate_lease_profile(lease, headers)
125
- if not ok_profile:
126
- return None, _error_response(profile_status, profile_err)
127
-
128
- _env_var, secret = turn_credentials.resolve_lease_secret(lease, resolve_secret)
129
- if not secret:
130
- return None, _error_response(402, "no credential")
131
-
132
- path = normalize_upstream_path(base, subpath)
133
- url = f"{base.rstrip('/')}{path}"
134
- upstream_headers = {
135
- k: v
136
- for k, v in headers.items()
137
- if k.lower() not in {
138
- "host",
139
- "connection",
140
- "content-length",
141
- "authorization",
142
- "x-api-key",
143
- "x-goog-api-key",
144
- }
145
- }
146
- upstream_headers.update(upstream_auth_header(provider, secret))
147
-
148
- req = urllib.request.Request(url, data=body, headers=upstream_headers, method=method.upper())
149
- return req, None
150
-
151
-
152
- def forward_request(
153
- provider: str,
154
- subpath: str,
155
- method: str,
156
- headers: dict[str, str],
157
- body: bytes | None,
158
- *,
159
- resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
160
- ) -> tuple[int, dict[str, str], bytes]:
161
- req, error = _build_upstream_request(
162
- provider,
163
- subpath,
164
- method,
165
- headers,
166
- body,
167
- resolve_secret=resolve_secret,
168
- )
169
- if error:
170
- return error
171
- assert req is not None
172
- try:
173
- with urllib.request.urlopen(req, timeout=600) as resp:
174
- resp_body = resp.read()
175
- out_headers = {"Content-Type": resp.headers.get("Content-Type", "application/octet-stream")}
176
- return resp.status, out_headers, resp_body
177
- except urllib.error.HTTPError as exc:
178
- raw = exc.read()
179
- out_headers = {"Content-Type": exc.headers.get("Content-Type", "application/json")}
180
- return exc.code, out_headers, raw
181
-
182
-
183
- def stream_request_to_handler(
184
- handler: BaseHTTPRequestHandler,
185
- provider: str,
186
- subpath: str,
187
- method: str,
188
- headers: dict[str, str],
189
- body: bytes | None,
190
- *,
191
- resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
192
- ) -> None:
193
- req, error = _build_upstream_request(
194
- provider,
195
- subpath,
196
- method,
197
- headers,
198
- body,
199
- resolve_secret=resolve_secret,
200
- )
201
- if error:
202
- status, out_headers, resp_body = error
203
- handler.send_response(status)
204
- for key, value in out_headers.items():
205
- handler.send_header(key, value)
206
- handler.end_headers()
207
- handler.wfile.write(resp_body)
208
- return
209
-
210
- assert req is not None
211
- try:
212
- with urllib.request.urlopen(req, timeout=600) as resp:
213
- content_type = resp.headers.get("Content-Type", "application/octet-stream")
214
- handler.send_response(resp.status)
215
- handler.send_header("Content-Type", content_type)
216
- handler.end_headers()
217
- if "text/event-stream" in content_type.lower():
218
- while True:
219
- line = resp.readline()
220
- if not line:
221
- break
222
- handler.wfile.write(line)
223
- handler.wfile.flush()
224
- else:
225
- while True:
226
- chunk = resp.read(8192)
227
- if not chunk:
228
- break
229
- handler.wfile.write(chunk)
230
- handler.wfile.flush()
231
- except urllib.error.HTTPError as exc:
232
- raw = exc.read()
233
- handler.send_response(exc.code)
234
- handler.send_header("Content-Type", exc.headers.get("Content-Type", "application/json"))
235
- handler.end_headers()
236
- handler.wfile.write(raw)
237
- except urllib.error.URLError as exc:
238
- handler.send_response(502)
239
- handler.send_header("Content-Type", "application/json")
240
- handler.end_headers()
241
- handler.wfile.write(json.dumps({"error": f"upstream unavailable: {exc}"}).encode())
242
-
243
-
244
- def handle_proxy_request(
245
- handler: BaseHTTPRequestHandler,
246
- path: str,
247
- method: str,
248
- body: bytes | None,
249
- *,
250
- resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
251
- ) -> bool:
252
- """Return True if handled."""
253
- ok, err = authorize_internal_proxy(handler)
254
- if not ok:
255
- handler.send_response(403)
256
- handler.send_header("Content-Type", "application/json")
257
- handler.end_headers()
258
- handler.wfile.write(json.dumps({"error": err}).encode())
259
- return True
260
-
261
- match = PROXY_PATH_RE.match(path)
262
- if not match:
263
- return False
264
-
265
- provider = match.group(1).lower()
266
- subpath = match.group(2) or "/"
267
- headers = {k: v for k, v in handler.headers.items()}
268
- stream_request_to_handler(
269
- handler,
270
- provider,
271
- subpath,
272
- method,
273
- headers,
274
- body,
275
- resolve_secret=resolve_secret,
276
- )
277
- return True
1
+ """Internal LLM proxy — Hermes sends lease tokens; API vault supplies upstream keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import socket
9
+ import ssl
10
+ import urllib.error
11
+ import urllib.request
12
+ from typing import Any, Callable
13
+ from http.server import BaseHTTPRequestHandler
14
+
15
+ import internal_proxy_auth
16
+ import turn_credentials
17
+
18
+ LEASE_PREFIX = turn_credentials.LEASE_PREFIX
19
+
20
+ UPSTREAM_BASE: dict[str, str] = {
21
+ "openrouter": "https://openrouter.ai/api/v1",
22
+ "openai": "https://api.openai.com/v1",
23
+ "anthropic": "https://api.anthropic.com",
24
+ "google": "https://generativelanguage.googleapis.com/v1beta",
25
+ "deepseek": "https://api.deepseek.com/v1",
26
+ }
27
+
28
+ PROXY_PATH_RE = re.compile(r"^/internal/llm/([a-z0-9_-]+)(/.*)?$", re.IGNORECASE)
29
+
30
+
31
+ def normalize_upstream_path(base: str, subpath: str) -> str:
32
+ """Drop a leading /v1 when upstream base already ends with /v1.
33
+
34
+ Hermes uses model.base_url …/internal/llm/openrouter/v1; the OpenAI client
35
+ then requests …/v1/chat/completions, which we forward as subpath /v1/….
36
+ """
37
+ path = subpath if str(subpath or "").startswith("/") else f"/{subpath or ''}"
38
+ base_norm = str(base or "").rstrip("/")
39
+ if path == "/v1":
40
+ return ""
41
+ if path.startswith("/v1/") and base_norm.endswith("/v1"):
42
+ return path[3:]
43
+ return path
44
+
45
+
46
+ def is_internal_client(host: str) -> bool:
47
+ """Allow docker/private callers only — not public browser origins."""
48
+ return internal_proxy_auth.is_internal_client(host)
49
+
50
+
51
+ def authorize_internal_proxy(handler: BaseHTTPRequestHandler) -> tuple[bool, str]:
52
+ return internal_proxy_auth.authorize_internal_proxy(handler)
53
+
54
+
55
+ def extract_profile_slug(headers: dict[str, str]) -> str:
56
+ return str(
57
+ headers.get(internal_proxy_auth.PROFILE_HEADER)
58
+ or headers.get("x-workframe-profile")
59
+ or ""
60
+ ).strip()
61
+
62
+
63
+ def validate_lease_profile(
64
+ lease: dict[str, Any],
65
+ headers: dict[str, str],
66
+ ) -> tuple[bool, str, int]:
67
+ """Bind bearer lease to calling Hermes profile (0022 N2 / 0023 C1)."""
68
+ want = str(lease.get("profile_slug") or "").strip()
69
+ if not want:
70
+ return True, "", 0
71
+ got = extract_profile_slug(headers)
72
+ if not got:
73
+ return False, "profile header required", 403
74
+ if got != want:
75
+ return False, "profile mismatch", 403
76
+ return True, "", 0
77
+
78
+
79
+ def extract_bearer(headers: dict[str, str]) -> str:
80
+ auth = str(headers.get("Authorization") or headers.get("authorization") or "").strip()
81
+ if auth.lower().startswith("bearer "):
82
+ return auth[7:].strip()
83
+ api_key = str(headers.get("X-Api-Key") or headers.get("x-api-key") or "").strip()
84
+ if api_key:
85
+ return api_key
86
+ return ""
87
+
88
+
89
+ def upstream_auth_header(provider: str, secret: str) -> dict[str, str]:
90
+ provider = str(provider or "").strip().lower()
91
+ secret = str(secret or "").strip()
92
+ if provider == "anthropic":
93
+ return {"x-api-key": secret, "anthropic-version": "2023-06-01"}
94
+ if provider == "google":
95
+ return {"x-goog-api-key": secret}
96
+ return {"Authorization": f"Bearer {secret}"}
97
+
98
+
99
+ def _error_response(status: int, error: str) -> tuple[int, dict[str, str], bytes]:
100
+ return status, {"Content-Type": "application/json"}, json.dumps({"error": error}).encode()
101
+
102
+
103
+ def _build_upstream_request(
104
+ provider: str,
105
+ subpath: str,
106
+ method: str,
107
+ headers: dict[str, str],
108
+ body: bytes | None,
109
+ *,
110
+ resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
111
+ ) -> tuple[urllib.request.Request | None, tuple[int, dict[str, str], bytes] | None]:
112
+ provider = str(provider or "").strip().lower()
113
+ base = UPSTREAM_BASE.get(provider)
114
+ if not base:
115
+ return None, _error_response(404, "unknown provider")
116
+
117
+ token = extract_bearer(headers)
118
+ lease = turn_credentials.validate_lease(token)
119
+ if not lease:
120
+ return None, _error_response(401, "invalid lease")
121
+ if str(lease.get("provider") or "").lower() != provider:
122
+ return None, _error_response(403, "provider mismatch")
123
+
124
+ ok_profile, profile_err, profile_status = validate_lease_profile(lease, headers)
125
+ if not ok_profile:
126
+ return None, _error_response(profile_status, profile_err)
127
+
128
+ _env_var, secret = turn_credentials.resolve_lease_secret(lease, resolve_secret)
129
+ if not secret:
130
+ return None, _error_response(402, "no credential")
131
+
132
+ path = normalize_upstream_path(base, subpath)
133
+ url = f"{base.rstrip('/')}{path}"
134
+ upstream_headers = {
135
+ k: v
136
+ for k, v in headers.items()
137
+ if k.lower() not in {
138
+ "host",
139
+ "connection",
140
+ "content-length",
141
+ "authorization",
142
+ "x-api-key",
143
+ "x-goog-api-key",
144
+ }
145
+ }
146
+ upstream_headers.update(upstream_auth_header(provider, secret))
147
+
148
+ req = urllib.request.Request(url, data=body, headers=upstream_headers, method=method.upper())
149
+ return req, None
150
+
151
+
152
+ def forward_request(
153
+ provider: str,
154
+ subpath: str,
155
+ method: str,
156
+ headers: dict[str, str],
157
+ body: bytes | None,
158
+ *,
159
+ resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
160
+ ) -> tuple[int, dict[str, str], bytes]:
161
+ req, error = _build_upstream_request(
162
+ provider,
163
+ subpath,
164
+ method,
165
+ headers,
166
+ body,
167
+ resolve_secret=resolve_secret,
168
+ )
169
+ if error:
170
+ return error
171
+ assert req is not None
172
+ try:
173
+ with urllib.request.urlopen(req, timeout=600) as resp:
174
+ resp_body = resp.read()
175
+ out_headers = {"Content-Type": resp.headers.get("Content-Type", "application/octet-stream")}
176
+ return resp.status, out_headers, resp_body
177
+ except urllib.error.HTTPError as exc:
178
+ raw = exc.read()
179
+ out_headers = {"Content-Type": exc.headers.get("Content-Type", "application/json")}
180
+ return exc.code, out_headers, raw
181
+
182
+
183
+ def stream_request_to_handler(
184
+ handler: BaseHTTPRequestHandler,
185
+ provider: str,
186
+ subpath: str,
187
+ method: str,
188
+ headers: dict[str, str],
189
+ body: bytes | None,
190
+ *,
191
+ resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
192
+ ) -> None:
193
+ req, error = _build_upstream_request(
194
+ provider,
195
+ subpath,
196
+ method,
197
+ headers,
198
+ body,
199
+ resolve_secret=resolve_secret,
200
+ )
201
+ if error:
202
+ status, out_headers, resp_body = error
203
+ handler.send_response(status)
204
+ for key, value in out_headers.items():
205
+ handler.send_header(key, value)
206
+ handler.end_headers()
207
+ handler.wfile.write(resp_body)
208
+ return
209
+
210
+ assert req is not None
211
+ try:
212
+ with urllib.request.urlopen(req, timeout=600) as resp:
213
+ content_type = resp.headers.get("Content-Type", "application/octet-stream")
214
+ handler.send_response(resp.status)
215
+ handler.send_header("Content-Type", content_type)
216
+ handler.end_headers()
217
+ if "text/event-stream" in content_type.lower():
218
+ while True:
219
+ line = resp.readline()
220
+ if not line:
221
+ break
222
+ handler.wfile.write(line)
223
+ handler.wfile.flush()
224
+ else:
225
+ while True:
226
+ chunk = resp.read(8192)
227
+ if not chunk:
228
+ break
229
+ handler.wfile.write(chunk)
230
+ handler.wfile.flush()
231
+ except urllib.error.HTTPError as exc:
232
+ raw = exc.read()
233
+ handler.send_response(exc.code)
234
+ handler.send_header("Content-Type", exc.headers.get("Content-Type", "application/json"))
235
+ handler.end_headers()
236
+ handler.wfile.write(raw)
237
+ except urllib.error.URLError as exc:
238
+ handler.send_response(502)
239
+ handler.send_header("Content-Type", "application/json")
240
+ handler.end_headers()
241
+ handler.wfile.write(json.dumps({"error": f"upstream unavailable: {exc}"}).encode())
242
+
243
+
244
+ def handle_proxy_request(
245
+ handler: BaseHTTPRequestHandler,
246
+ path: str,
247
+ method: str,
248
+ body: bytes | None,
249
+ *,
250
+ resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
251
+ ) -> bool:
252
+ """Return True if handled."""
253
+ ok, err = authorize_internal_proxy(handler)
254
+ if not ok:
255
+ handler.send_response(403)
256
+ handler.send_header("Content-Type", "application/json")
257
+ handler.end_headers()
258
+ handler.wfile.write(json.dumps({"error": err}).encode())
259
+ return True
260
+
261
+ match = PROXY_PATH_RE.match(path)
262
+ if not match:
263
+ return False
264
+
265
+ provider = match.group(1).lower()
266
+ subpath = match.group(2) or "/"
267
+ headers = {k: v for k, v in handler.headers.items()}
268
+ stream_request_to_handler(
269
+ handler,
270
+ provider,
271
+ subpath,
272
+ method,
273
+ headers,
274
+ body,
275
+ resolve_secret=resolve_secret,
276
+ )
277
+ return True