create-workframe 0.1.0 → 0.1.2

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 (198) hide show
  1. package/LICENSE +201 -201
  2. package/NOTICE +12 -12
  3. package/README.md +8 -92
  4. package/SECURITY.md +38 -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/bundle-workframe-ui.mjs +3 -3
  28. package/scripts/ensure-compose-host-paths.mjs +51 -51
  29. package/scripts/lib/install-identity.mjs +212 -212
  30. package/scripts/set-compose-public-url.mjs +92 -92
  31. package/scripts/sync-canonical-to-package.mjs +27 -9
  32. package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
  33. package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
  34. package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
  35. package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
  36. package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
  37. package/workframe-api/README.md +26 -28
  38. package/workframe-api/action_proxy.py +131 -131
  39. package/workframe-api/auth_rate_limit.py +49 -49
  40. package/workframe-api/credential_vault.py +445 -445
  41. package/workframe-api/data/avatar-catalog.json +41 -41
  42. package/workframe-api/email_sender.py +220 -220
  43. package/workframe-api/google_auth.py +90 -90
  44. package/workframe-api/install_api.py +359 -359
  45. package/workframe-api/internal_proxy_auth.py +150 -150
  46. package/workframe-api/llm_proxy.py +277 -277
  47. package/workframe-api/oidc_jwt.py +108 -108
  48. package/workframe-api/package.json +12 -13
  49. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  50. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  51. package/workframe-api/requirements.txt +2 -2
  52. package/workframe-api/site_meta.py +271 -271
  53. package/workframe-api/stack_config.py +427 -427
  54. package/workframe-api/time-bind-chat.py +99 -99
  55. package/workframe-api/turn_credentials.py +226 -226
  56. package/workframe-api/updates.py +417 -417
  57. package/workframe-api/vault_kek.py +159 -159
  58. package/workframe-api/zk_auth.py +633 -633
  59. package/workframe-supervisor/Dockerfile +11 -11
  60. package/workframe-supervisor/server.py +787 -787
  61. package/workframe-ui/docker/nginx.conf +85 -85
  62. package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
  63. package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
  64. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
  65. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
  66. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
  67. package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
  68. package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
  72. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
  73. package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
  74. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
  75. package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
  76. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
  77. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
  78. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
  79. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
  80. package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
  81. package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
  82. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
  83. package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
  84. package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
  85. package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
  86. package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
  87. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
  88. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
  89. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
  90. package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
  91. package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
  92. package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
  93. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
  94. package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
  95. package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
  96. package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
  97. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
  98. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
  99. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
  100. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
  101. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
  102. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
  103. package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
  104. package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
  105. package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
  106. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
  107. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
  108. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
  109. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
  110. package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
  111. package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
  112. package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
  113. package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
  114. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
  115. package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
  116. package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
  117. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
  118. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
  119. package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
  120. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
  121. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
  122. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
  123. package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
  124. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
  125. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
  126. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
  127. package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
  128. package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
  129. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
  130. package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
  131. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
  132. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
  133. package/workframe-ui/public/favicon.svg +7 -7
  134. package/workframe-ui/public/index.html +50 -50
  135. package/workframe-ui/public/workframe-config.json +3 -3
  136. package/scripts/security_audit.py +0 -156
  137. package/scripts/test-scaffold.mjs +0 -390
  138. package/workframe-api/tests/__init__.py +0 -0
  139. package/workframe-api/tests/db_setup.py +0 -13
  140. package/workframe-api/tests/test_admin_updates_gated.py +0 -30
  141. package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
  142. package/workframe-api/tests/test_agent_profile_sync.py +0 -76
  143. package/workframe-api/tests/test_auth_email.py +0 -222
  144. package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
  145. package/workframe-api/tests/test_auth_rate_limit.py +0 -19
  146. package/workframe-api/tests/test_avatar_resolve.py +0 -77
  147. package/workframe-api/tests/test_child_soul_template.py +0 -71
  148. package/workframe-api/tests/test_credential_canary.py +0 -135
  149. package/workframe-api/tests/test_credential_isolation.py +0 -448
  150. package/workframe-api/tests/test_credential_resolution.py +0 -206
  151. package/workframe-api/tests/test_device_oauth.py +0 -108
  152. package/workframe-api/tests/test_doctor_repair.py +0 -103
  153. package/workframe-api/tests/test_ensure_profile_api.py +0 -77
  154. package/workframe-api/tests/test_gateway_compose_security.py +0 -136
  155. package/workframe-api/tests/test_install_secure_host.py +0 -39
  156. package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
  157. package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
  158. package/workframe-api/tests/test_kanban_delegation.py +0 -185
  159. package/workframe-api/tests/test_llm_proxy.py +0 -155
  160. package/workframe-api/tests/test_login_access_policy.py +0 -183
  161. package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
  162. package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
  163. package/workframe-api/tests/test_platform_auth.py +0 -47
  164. package/workframe-api/tests/test_profile_config_path.py +0 -56
  165. package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
  166. package/workframe-api/tests/test_profile_create.py +0 -72
  167. package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
  168. package/workframe-api/tests/test_profile_install_health.py +0 -45
  169. package/workframe-api/tests/test_profile_secret_policy.py +0 -57
  170. package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
  171. package/workframe-api/tests/test_provider_bootstrap.py +0 -75
  172. package/workframe-api/tests/test_provider_connect.py +0 -54
  173. package/workframe-api/tests/test_room_crud.py +0 -192
  174. package/workframe-api/tests/test_room_tenancy.py +0 -701
  175. package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
  176. package/workframe-api/tests/test_site_meta.py +0 -81
  177. package/workframe-api/tests/test_soul_stub.py +0 -42
  178. package/workframe-api/tests/test_space_member_sync.py +0 -99
  179. package/workframe-api/tests/test_stripe_stack_config.py +0 -37
  180. package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
  181. package/workframe-api/tests/test_turn_credential_vault.py +0 -125
  182. package/workframe-api/tests/test_updates.py +0 -176
  183. package/workframe-api/tests/test_user_cohort.py +0 -113
  184. package/workframe-api/tests/test_vault_envelope.py +0 -110
  185. package/workframe-api/tests/test_workspace_members.py +0 -183
  186. package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
  187. package/workframe-api/tests/test_workspace_provider_list.py +0 -57
  188. package/workframe-supervisor/tests/test_exec_guard.py +0 -42
  189. package/workframe-supervisor/tests/test_server_import.py +0 -21
  190. package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
  191. package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
  192. package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
  193. package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
  194. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
  195. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
  196. package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
  197. package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
  198. 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