create-workframe 0.1.5 → 0.1.7

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 (185) hide show
  1. package/.dockerignore +22 -22
  2. package/.gitignore +73 -73
  3. package/LICENSE +201 -201
  4. package/NOTICE +12 -12
  5. package/README.md +40 -40
  6. package/SECURITY.md +38 -38
  7. package/bin/create-workframe.js +2816 -2814
  8. package/bin/workframe.js +329 -329
  9. package/docs/workspace-instructions/WORKFRAME_DISCORD.md +20 -20
  10. package/docs/workspace-instructions/WORKFRAME_DOCUMENTS_AND_ARTIFACTS.md +20 -20
  11. package/docs/workspace-instructions/WORKFRAME_KANBAN.md +20 -20
  12. package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +21 -21
  13. package/docs/workspace-instructions/WORKFRAME_ROUTING.md +29 -29
  14. package/docs/workspace-instructions/WORKFRAME_TELEGRAM.md +19 -19
  15. package/package.json +64 -64
  16. package/profiles/README.md +15 -15
  17. package/profiles/architect/AGENTS.md +29 -29
  18. package/profiles/architect/SOUL.md +44 -44
  19. package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
  20. package/profiles/designer/AGENTS.md +26 -26
  21. package/profiles/designer/SOUL.md +31 -31
  22. package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
  23. package/profiles/dev/AGENTS.md +28 -28
  24. package/profiles/dev/SOUL.md +31 -31
  25. package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
  26. package/profiles/docs/AGENTS.md +27 -27
  27. package/profiles/docs/SOUL.md +30 -30
  28. package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
  29. package/profiles/research/AGENTS.md +26 -26
  30. package/profiles/research/SOUL.md +31 -31
  31. package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
  32. package/profiles/visionary/AGENTS.md +25 -25
  33. package/profiles/visionary/SOUL.md +31 -31
  34. package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
  35. package/profiles/workframe-agent/AGENTS.md +37 -37
  36. package/profiles/workframe-agent/SETUP.md +185 -185
  37. package/profiles/workframe-agent/SOUL.md +61 -61
  38. package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
  39. package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
  40. package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
  41. package/prompts/WORKFRAME_PROMPT_TEMPLATES.md +16 -16
  42. package/rules/.hermes.md +11 -11
  43. package/rules/AGENTS.md +22 -22
  44. package/rules/workspace-README.md +5 -5
  45. package/scripts/apply-update-workframe.sh +91 -21
  46. package/scripts/bundle-workframe-ui.mjs +83 -83
  47. package/scripts/create_workframe_scaffold.py +648 -648
  48. package/scripts/ensure-compose-host-paths.mjs +51 -51
  49. package/scripts/lib/install-identity.mjs +212 -212
  50. package/scripts/lib/workframe-registry.mjs +290 -290
  51. package/scripts/new-project.mjs +68 -68
  52. package/scripts/select_agent_pack.py +31 -31
  53. package/scripts/set-compose-public-url.mjs +92 -92
  54. package/scripts/sync-canonical-to-package.mjs +164 -164
  55. package/shared/WORKFRAME_AGENT_LIBRARY.md +31 -31
  56. package/shared/WORKFRAME_AGENT_OPERATIONS.md +29 -29
  57. package/shared/WORKFRAME_AGENT_PACKS.json +64 -64
  58. package/shared/WORKFRAME_AGENT_PACKS.yaml +20 -20
  59. package/shared/WORKFRAME_CHAT_PERMISSION_MODEL.md +20 -20
  60. package/shared/WORKFRAME_HANDOFF_SCHEMA.md +25 -25
  61. package/shared/WORKFRAME_SKILL_CURATION.md +27 -27
  62. package/workframe-api/Dockerfile +14 -14
  63. package/workframe-api/README.md +24 -24
  64. package/workframe-api/action_proxy.py +131 -131
  65. package/workframe-api/auth_rate_limit.py +49 -49
  66. package/workframe-api/credential_vault.py +445 -445
  67. package/workframe-api/data/avatar-catalog.json +41 -41
  68. package/workframe-api/data/logo-catalog.json +14 -14
  69. package/workframe-api/data/user-avatar-catalog.json +18 -18
  70. package/workframe-api/email_sender.py +220 -220
  71. package/workframe-api/google_auth.py +90 -90
  72. package/workframe-api/install_api.py +359 -359
  73. package/workframe-api/internal_proxy_auth.py +150 -150
  74. package/workframe-api/llm_proxy.py +280 -280
  75. package/workframe-api/oidc_jwt.py +108 -108
  76. package/workframe-api/package.json +12 -12
  77. package/workframe-api/platform_auth.py +194 -194
  78. package/workframe-api/profile_secret_policy.py +86 -86
  79. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  80. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  81. package/workframe-api/public/assets/index-DglUqFB_.js +8 -8
  82. package/workframe-api/public/index.html +12 -12
  83. package/workframe-api/requirements.txt +2 -2
  84. package/workframe-api/server.py +20295 -19866
  85. package/workframe-api/site_meta.py +271 -271
  86. package/workframe-api/stack_config.py +435 -427
  87. package/workframe-api/test_ensure_native_profile.py +29 -0
  88. package/workframe-api/test_oauth_llm.py +58 -0
  89. package/workframe-api/test_provider_model_surface.py +31 -0
  90. package/workframe-api/test_stack_config_install_smtp.py +51 -0
  91. package/workframe-api/time-bind-chat.py +99 -99
  92. package/workframe-api/turn_credentials.py +226 -226
  93. package/workframe-api/updates.py +506 -506
  94. package/workframe-api/vault_kek.py +159 -159
  95. package/workframe-api/zk_auth.py +633 -633
  96. package/workframe-supervisor/Dockerfile +11 -11
  97. package/workframe-supervisor/profile_secret_policy.py +76 -76
  98. package/workframe-supervisor/server.py +860 -860
  99. package/workframe-ui/docker/nginx.conf +85 -85
  100. package/workframe-ui/public/assets/{arc-CnriPN3G.js → arc-Cn-lt9fT.js} +1 -1
  101. package/workframe-ui/public/assets/architecture-7EHR7CIX-CCMNXHod.js +1 -0
  102. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DYYB1Ic2.js → architectureDiagram-3BPJPVTR-qXoGCqfH.js} +1 -1
  103. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bn-fFOyj.js → blockDiagram-GPEHLZMM-DoopFlWj.js} +1 -1
  104. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BPPDtAnD.js → c4Diagram-AAUBKEIU-BRa7hfzE.js} +1 -1
  105. package/workframe-ui/public/assets/channel-CtFV3sE3.js +1 -0
  106. package/workframe-ui/public/assets/{chunk-2J33WTMH-Bz2CJiC3.js → chunk-2J33WTMH-Cl33E2hh.js} +1 -1
  107. package/workframe-ui/public/assets/{chunk-3OPIFGDE-B8aMeE1B.js → chunk-3OPIFGDE-BRywYZQO.js} +1 -1
  108. package/workframe-ui/public/assets/{chunk-4BX2VUAB-bLFHQTuT.js → chunk-4BX2VUAB-CI3xzp-0.js} +1 -1
  109. package/workframe-ui/public/assets/{chunk-55IACEB6-BRxhx-lG.js → chunk-55IACEB6-Bkf3Ye3F.js} +1 -1
  110. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BLyOgEJ8.js → chunk-5ZQYHXKU-C5RN3hUT.js} +1 -1
  111. package/workframe-ui/public/assets/{chunk-727SXJPM-DiuvBl7G.js → chunk-727SXJPM-BaY0yDwt.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-BE1_aBdC.js → chunk-AQP2D5EJ-VMMBaDqd.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-BSJP7CBP-BicGsmBs.js → chunk-BSJP7CBP-DlNZ1UV3.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-C3VTns3d.js → chunk-CSCIHK7Q-tT2HyVy0.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DSiXY3bY.js → chunk-FMBD7UC4-CyACRaUT.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-KSCS5N6A-Cvw5s3fR.js → chunk-KSCS5N6A-seK5WAZs.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-jJifGdIQ.js → chunk-L5ZTLDWV-r5cyG5AH.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Maee1aqo.js → chunk-LZXEDZCA-SnbeVxsJ.js} +2 -2
  119. package/workframe-ui/public/assets/{chunk-ND2GUHAM-C77N3VAM.js → chunk-ND2GUHAM-zbFgEXSJ.js} +1 -1
  120. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BY68lrPB.js → chunk-NZK2D7GU-UhZc8kOk.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-O5CBEL6O-D_c4kWzh.js → chunk-O5CBEL6O-DHvu2iZ-.js} +1 -1
  122. package/workframe-ui/public/assets/chunk-QZHKN3VN-B1g7l1_B.js +1 -0
  123. package/workframe-ui/public/assets/chunk-WU5MYG2G-B_qfZ6QP.js +1 -0
  124. package/workframe-ui/public/assets/{chunk-XPW4576I-DlQgvqBp.js → chunk-XPW4576I-CCPNKbzG.js} +1 -1
  125. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CJoeCNq6.js +1 -0
  126. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CJoeCNq6.js +1 -0
  127. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CHCOHDSd.js → cose-bilkent-S5V4N54A-D1CLe6Y0.js} +1 -1
  128. package/workframe-ui/public/assets/{dagre-BM42HDAG-CKPcSYwU.js → dagre-BM42HDAG-DYHcGVs_.js} +1 -1
  129. package/workframe-ui/public/assets/{diagram-2AECGRRQ-C6qe0jP-.js → diagram-2AECGRRQ-DVUkJ-II.js} +1 -1
  130. package/workframe-ui/public/assets/{diagram-5GNKFQAL-DhQhrieU.js → diagram-5GNKFQAL-C9Y1Acc2.js} +1 -1
  131. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Bqf6Iw1r.js → diagram-KO2AKTUF-Db3zez-n.js} +1 -1
  132. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt1ms2D2.js → diagram-LMA3HP47-BaOR5sIr.js} +1 -1
  133. package/workframe-ui/public/assets/{diagram-OG6HWLK6-C0Rrxe1i.js → diagram-OG6HWLK6-fLBzMY5W.js} +1 -1
  134. package/workframe-ui/public/assets/{dist-BLcLhTn6.js → dist-uZYeD3o1.js} +1 -1
  135. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Cy1GbZVu.js → erDiagram-TEJ5UH35-BoCxDSyE.js} +1 -1
  136. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bw910jho.js +1 -0
  137. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-DL3ALqDG.js → flowDiagram-I6XJVG4X-BnYa3CAj.js} +1 -1
  138. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-CMpwBhXQ.js → ganttDiagram-6RSMTGT7-Dm_e_Kaa.js} +1 -1
  139. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-C0mwHUXL.js → gitGraph-WXDBUCRP-CBAj8ntv.js} +1 -1
  140. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-6gzhWaF0.js → gitGraphDiagram-PVQCEYII-D5auJjJh.js} +1 -1
  141. package/workframe-ui/public/assets/index-DHLQNAXd.js +129 -0
  142. package/workframe-ui/public/assets/index-auJ0bLMU.css +1 -0
  143. package/workframe-ui/public/assets/{info-J43DQDTF-Bb_wZTxU.js → info-J43DQDTF-BNMADxE2.js} +1 -1
  144. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-601evYFV.js → infoDiagram-5YYISTIA-C8c45T4J.js} +1 -1
  145. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BYtF-wHm.js → ishikawaDiagram-YF4QCWOH-CyWuHB4e.js} +1 -1
  146. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BMizyHzC.js → journeyDiagram-JHISSGLW-B43pCuDT.js} +1 -1
  147. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CLLJsdnh.js → kanban-definition-UN3LZRKU-Dyn480BI.js} +1 -1
  148. package/workframe-ui/public/assets/{line-BFiSvu-q.js → line-CNtIbnmt.js} +1 -1
  149. package/workframe-ui/public/assets/{linear-D6HYZ18_.js → linear-pmSg_4uV.js} +1 -1
  150. package/workframe-ui/public/assets/{mermaid-parser.core-Cid5yDBQ.js → mermaid-parser.core-BX4tnojE.js} +2 -2
  151. package/workframe-ui/public/assets/{mermaid.core-DqIiju5Z.js → mermaid.core-BtpBva5o.js} +3 -3
  152. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Bg7xJdsz.js → mindmap-definition-RKZ34NQL-DcMcB_69.js} +1 -1
  153. package/workframe-ui/public/assets/{packet-YPE3B663-1ebGt86D.js → packet-YPE3B663-KSzK9WlD.js} +1 -1
  154. package/workframe-ui/public/assets/{pie-LRSECV5Y-DJ9wOBC_.js → pie-LRSECV5Y-DLOH6Mg8.js} +1 -1
  155. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-BmK96Aus.js → pieDiagram-4H26LBE5-BhrX2aGk.js} +1 -1
  156. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-ByUqtWPZ.js → quadrantDiagram-W4KKPZXB-B5dDjbTX.js} +1 -1
  157. package/workframe-ui/public/assets/{radar-GUYGQ44K-DN-Hkt96.js → radar-GUYGQ44K-BPdjqZiu.js} +1 -1
  158. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ByU2r8gb.js → requirementDiagram-4Y6WPE33-21Dq5e0N.js} +1 -1
  159. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-elyWa0GK.js → sankeyDiagram-5OEKKPKP-CiA99dbO.js} +1 -1
  160. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-CzZwsAiK.js → sequenceDiagram-3UESZ5HK-Bwpui7_x.js} +1 -1
  161. package/workframe-ui/public/assets/{src-5vgsM_dl.js → src-B3Axsi1U.js} +1 -1
  162. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-B0O5w5lJ.js → stateDiagram-AJRCARHV-C76qwtE8.js} +1 -1
  163. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-Dn0CDWJD.js +1 -0
  164. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-oyqa5w5y.js → timeline-definition-PNZ67QCA-CufInOyk.js} +1 -1
  165. package/workframe-ui/public/assets/{treeView-BLDUP644-MCV12-eP.js → treeView-BLDUP644-C4sxh0R-.js} +1 -1
  166. package/workframe-ui/public/assets/{treemap-LRROVOQU-DiA1P9n5.js → treemap-LRROVOQU-BjV72ANV.js} +1 -1
  167. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CO_4x7OR.js → vennDiagram-CIIHVFJN-DgsdR0Rl.js} +1 -1
  168. package/workframe-ui/public/assets/{wardley-L42UT6IY-CmQNv6Xv.js → wardley-L42UT6IY-DZ279G_t.js} +1 -1
  169. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CJxDkbIj.js → wardleyDiagram-YWT4CUSO-Bde25kvz.js} +1 -1
  170. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-B02MxgzG.js → xychartDiagram-2RQKCTM6-B8VnS2Pb.js} +1 -1
  171. package/workframe-ui/public/favicon.svg +7 -7
  172. package/workframe-ui/public/icons.svg +24 -24
  173. package/workframe-ui/public/index.html +47 -47
  174. package/workframe-ui/public/manifest.webmanifest +18 -18
  175. package/workframe-ui/public/workframe-config.json +3 -3
  176. package/workframe-ui/public/assets/architecture-7EHR7CIX-CFhr7xAe.js +0 -1
  177. package/workframe-ui/public/assets/channel-Dok_-T0o.js +0 -1
  178. package/workframe-ui/public/assets/chunk-QZHKN3VN-C3wwLrTa.js +0 -1
  179. package/workframe-ui/public/assets/chunk-WU5MYG2G-GFFSg2kf.js +0 -1
  180. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DKbGfT5Z.js +0 -1
  181. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DKbGfT5Z.js +0 -1
  182. package/workframe-ui/public/assets/eventmodeling-FCH6USID-C_PrceHe.js +0 -1
  183. package/workframe-ui/public/assets/index-DyO6K0TD.css +0 -1
  184. package/workframe-ui/public/assets/index-yvpSUs8T.js +0 -133
  185. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-RXZRaSgT.js +0 -1
@@ -1,860 +1,860 @@
1
- #!/usr/bin/env python3
2
- """workframe-supervisor — token-gated Docker exec for Hermes profile lifecycle."""
3
-
4
- from __future__ import annotations
5
-
6
- import http.client
7
- import json
8
- import os
9
- import re
10
- import shlex
11
- import socket
12
- import subprocess
13
- import time
14
- import urllib.error
15
- import urllib.parse
16
- import urllib.request
17
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
- from pathlib import Path
19
- from typing import Any
20
-
21
- HERMES_DATA = Path(os.environ.get("HERMES_DATA", "/opt/data"))
22
- HOST = os.environ.get("HOST", "127.0.0.1")
23
- PORT = int(os.environ.get("PORT", "8090"))
24
- VERSION = "0.1.0"
25
- NATIVE_PROFILE = os.environ.get("WORKFRAME_NATIVE_PROFILE", "").strip()
26
- DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
27
- GATEWAY_CONTAINER_NAME = os.environ.get("WORKFRAME_GATEWAY_CONTAINER", "workframe-gateway")
28
- SUPERVISOR_TOKEN = (
29
- os.environ.get("WORKFRAME_SUPERVISOR_TOKEN")
30
- or os.environ.get("SUPERVISOR_TOKEN")
31
- or ""
32
- ).strip()
33
- DEPLOYMENT_MODE = (os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "trusted_team").strip().lower()
34
- ROUTES_JSON = HERMES_DATA / "workframe" / "routes.json"
35
- SCRIPTS_DIR = Path(os.environ.get("WORKFRAME_SCRIPTS_DIR", "/opt/install/scripts"))
36
- COMPOSE_DIR = Path(os.environ.get("WORKFRAME_COMPOSE_DIR", "/compose"))
37
-
38
-
39
- def _compose_file_args() -> list[str]:
40
- """Absolute host bind paths when compose CLI runs inside supervisor (relative paths break)."""
41
- args = ["-f", str(COMPOSE_DIR / "docker-compose.yml")]
42
- host_root = os.environ.get("WORKFRAME_HOST_PROJECT_ROOT", "").strip()
43
- bindings = COMPOSE_DIR / "docker-compose.host-bindings.yml"
44
- if host_root and bindings.is_file():
45
- args.extend(["-f", str(bindings)])
46
- return args
47
-
48
-
49
- def _compose_run(argv: list[str], *, timeout: float = 120.0) -> subprocess.CompletedProcess[str]:
50
- cmd = ["docker", "compose", *_compose_file_args(), *argv]
51
- return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=str(COMPOSE_DIR))
52
-
53
-
54
- from profile_secret_policy import exec_blocked_for_profile
55
-
56
-
57
- def _exec_targets_runtime_profile_secrets(cmd: list[str], acting_profile: str = "") -> bool:
58
- if DEPLOYMENT_MODE == "single_user_local":
59
- return False
60
- return exec_blocked_for_profile(cmd, acting_profile)
61
-
62
-
63
- def _stack_apply(target: str, *, workframe_version: str = "") -> dict[str, Any]:
64
- target = str(target or "all").strip().lower()
65
- if target == "gateway-restart":
66
- script = SCRIPTS_DIR / "restart-gateway-hermes.sh"
67
- if not script.is_file():
68
- raise ValueError("restart_script_missing")
69
- proc = subprocess.run(
70
- ["bash", str(script)],
71
- capture_output=True,
72
- text=True,
73
- timeout=300,
74
- cwd=str(COMPOSE_DIR) if COMPOSE_DIR.is_dir() else None,
75
- )
76
- log = f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
77
- if proc.returncode != 0:
78
- raise ValueError("restart_failed:gateway")
79
- return {"ok": True, "target": "gateway", "log": log[-12000:]}
80
- if target not in {"hermes", "workframe", "all"}:
81
- raise ValueError("invalid_update_target")
82
- scripts: list[Path] = []
83
- if target in {"hermes", "all"}:
84
- p = SCRIPTS_DIR / "apply-update-hermes.sh"
85
- if not p.is_file():
86
- raise ValueError("update_script_missing:hermes")
87
- scripts.append(p)
88
- if target in {"workframe", "all"}:
89
- p = SCRIPTS_DIR / "apply-update-workframe.sh"
90
- if not p.is_file():
91
- raise ValueError("update_script_missing:workframe")
92
- scripts.append(p)
93
- env = os.environ.copy()
94
- version = str(workframe_version or "").strip()
95
- if version and target in {"workframe", "all"}:
96
- env["WORKFRAME_UPDATE_ALLOW_NPM"] = "1"
97
- env["WORKFRAME_UPDATE_VERSION"] = version
98
- logs: list[str] = []
99
- for script in scripts:
100
- proc = subprocess.run(
101
- ["bash", str(script)],
102
- capture_output=True,
103
- text=True,
104
- timeout=900,
105
- env=env,
106
- cwd=str(COMPOSE_DIR) if COMPOSE_DIR.is_dir() else None,
107
- )
108
- logs.append(f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}")
109
- if proc.returncode != 0:
110
- raise ValueError(f"update_failed:{script.name}")
111
- return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
112
-
113
-
114
- _DEVICE_OAUTH_AUTH_IDS = frozenset({"openai-codex", "nous"})
115
-
116
-
117
- def _gateway_image_digest() -> tuple[str, str]:
118
- status, data = _docker_request("GET", f"/containers/{urllib.parse.quote(GATEWAY_CONTAINER_NAME, safe='')}/json")
119
- if status != 200 or not isinstance(data, dict):
120
- return "", ""
121
- image_id = str(data.get("Image") or "")
122
- ist, idata = _docker_request("GET", f"/images/{image_id}/json")
123
- digest = ""
124
- ref = os.environ.get("WORKFRAME_HERMES_IMAGE", "nousresearch/hermes-agent")
125
- if ist == 200 and isinstance(idata, dict):
126
- digests = idata.get("RepoDigests") or []
127
- if digests:
128
- digest = str(digests[0]).split("@")[-1]
129
- tags = idata.get("RepoTags") or []
130
- if tags:
131
- ref = str(tags[0])
132
- return digest, ref
133
-
134
-
135
- def _hermes_device_oauth_start(home: str, hermes_auth_id: str, log_path: str) -> tuple[int, str]:
136
- home = str(home or "").strip()
137
- hermes_auth_id = str(hermes_auth_id or "").strip()
138
- log_path = str(log_path or "").strip()
139
- if not home.startswith("/opt/data/profiles/") or not log_path.startswith("/opt/data/profiles/"):
140
- raise ValueError("invalid profile home")
141
- if hermes_auth_id not in _DEVICE_OAUTH_AUTH_IDS:
142
- raise ValueError("invalid_device_oauth_provider")
143
- auth_cmd = " ".join(shlex.quote(part) for part in ["auth", "add", hermes_auth_id])
144
- shell = (
145
- f"mkdir -p {shlex.quote(home)}; "
146
- f"chown -R hermes:hermes {shlex.quote(home)}; "
147
- f"su -s /bin/sh hermes -c "
148
- f"'export HERMES_HOME={shlex.quote(home)} HOME={shlex.quote(home)}; "
149
- f"cd {shlex.quote(home)}; "
150
- f"/opt/hermes/bin/hermes {auth_cmd} >> {shlex.quote(log_path)} 2>&1'"
151
- )
152
- return _docker_exec_detached(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell])
153
-
154
-
155
- def _host_setup_public_https(host: str, port: int) -> dict[str, Any]:
156
- host = str(host or "").strip().lower()
157
- if not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", host, re.IGNORECASE):
158
- raise ValueError("invalid host")
159
- port = int(port)
160
- if port < 1 or port > 65535:
161
- raise ValueError("invalid port")
162
- root = os.environ.get("WORKFRAME_HOST_PROJECT_ROOT", "").strip() or "/opt/workframe/repo"
163
- script = f"{root}/scripts/workframe/setup-public-https.sh"
164
- # ponytail: chroot on host via docker.sock — API container cannot run apt/systemctl on host.
165
- cmd = [
166
- "docker",
167
- "run",
168
- "--rm",
169
- "--pull=missing",
170
- "--privileged",
171
- "--pid=host",
172
- "-v",
173
- "/:/host",
174
- "debian:bookworm-slim",
175
- "chroot",
176
- "/host",
177
- "bash",
178
- script,
179
- host,
180
- str(port),
181
- ]
182
- proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
183
- log = f"=== setup-public-https {host}:{port} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
184
- if proc.returncode != 0:
185
- raise ValueError(log[-2000:] or "setup_public_https_failed")
186
- return {"ok": True, "host": host, "port": port, "log": log[-8000:]}
187
-
188
-
189
- def _host_set_compose_public_url(public_url: str, *, restart: bool = True) -> dict[str, Any]:
190
- public_url = str(public_url or "").strip()
191
- if not public_url:
192
- raise ValueError("url required")
193
- compose_dir = Path(os.environ.get("WORKFRAME_COMPOSE_DIR", "/compose"))
194
- env_path = compose_dir / ".env"
195
- scripts = Path(os.environ.get("WORKFRAME_SCRIPTS_DIR", "/opt/install/scripts"))
196
- script = scripts / "set-compose-public-url.mjs"
197
- if not script.is_file():
198
- raise ValueError("set-compose_public_url_script_missing")
199
- proc = subprocess.run(
200
- ["node", str(script), public_url, "--env", str(env_path)],
201
- capture_output=True,
202
- text=True,
203
- timeout=60,
204
- cwd=str(compose_dir),
205
- )
206
- log = f"=== set-compose-public-url (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
207
- if proc.returncode != 0:
208
- raise ValueError(log[-2000:] or "set_compose_public_url_failed")
209
- if restart:
210
- # ponytail: --no-deps — never touch workframe-supervisor (partial up was killing it mid-install).
211
- restart_proc = _compose_run(["up", "-d", "--no-deps", "workframe-api", "gateway"], timeout=120)
212
- log += (
213
- f"\n=== compose up --no-deps workframe-api gateway (exit {restart_proc.returncode}) ===\n"
214
- f"{restart_proc.stdout}\n{restart_proc.stderr}"
215
- )
216
- if restart_proc.returncode != 0:
217
- raise ValueError(log[-2000:] or "compose_restart_failed")
218
- try:
219
- payload = json.loads(proc.stdout.strip())
220
- except json.JSONDecodeError:
221
- payload = {"ok": True}
222
- payload["log"] = log[-8000:]
223
- payload["restarted"] = restart
224
- return payload
225
-
226
-
227
- def _primary_profile() -> str:
228
- if NATIVE_PROFILE and (HERMES_DATA / "profiles" / NATIVE_PROFILE).is_dir():
229
- return NATIVE_PROFILE
230
- root = HERMES_DATA / "profiles"
231
- if not root.is_dir():
232
- return ""
233
- names = sorted(p.name for p in root.iterdir() if p.is_dir())
234
- return names[0] if names else ""
235
-
236
-
237
- def _profile_dir(profile: str) -> Path:
238
- return HERMES_DATA / "profiles" / profile
239
-
240
-
241
- def safe_profile_slug(value: str) -> str:
242
- slug = (value or "").strip()
243
- if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", slug):
244
- raise ValueError("invalid profile")
245
- return slug
246
-
247
-
248
- def profile_exists(profile: str) -> bool:
249
- return _profile_dir(profile).is_dir()
250
-
251
-
252
- def load_routes() -> dict[str, Any]:
253
- default_profile = _primary_profile()
254
- raw_routes: list[dict[str, Any]] = []
255
- if ROUTES_JSON.is_file():
256
- try:
257
- data = json.loads(ROUTES_JSON.read_text(encoding="utf-8"))
258
- if isinstance(data, dict) and isinstance(data.get("routes"), list):
259
- raw_routes = [r for r in data["routes"] if isinstance(r, dict)]
260
- except (OSError, json.JSONDecodeError):
261
- pass
262
- if not raw_routes:
263
- root = HERMES_DATA / "profiles"
264
- if root.is_dir():
265
- for p in sorted(root.iterdir()):
266
- if p.is_dir():
267
- raw_routes.append({"profile": p.name})
268
- routes = []
269
- for row in raw_routes:
270
- slug = str(row.get("profile") or "").strip()
271
- if slug and profile_exists(slug):
272
- routes.append({"profile": slug})
273
- return {"default_profile": default_profile, "routes": routes}
274
-
275
-
276
- def resolve_validated_profile(profile: str) -> str:
277
- slug = safe_profile_slug(str(profile or _primary_profile()).strip())
278
- allowed = {r["profile"] for r in load_routes()["routes"]}
279
- if slug not in allowed:
280
- raise ValueError(f"unknown profile: {slug}")
281
- if not profile_exists(slug):
282
- raise ValueError(f"profile not installed: {slug}")
283
- return slug
284
-
285
-
286
- _RUNTIME_PROFILE_RE = re.compile(r"^u-[a-z0-9][a-z0-9-]{0,62}$")
287
-
288
-
289
- def _is_runtime_profile_slug(slug: str) -> bool:
290
- return bool(_RUNTIME_PROFILE_RE.fullmatch(str(slug or "").strip()))
291
-
292
-
293
- def resolve_hermes_profile(profile: str) -> str:
294
- """Workspace routes or per-user runtime u-* dirs on disk."""
295
- slug = safe_profile_slug(str(profile or _primary_profile()).strip())
296
- if _is_runtime_profile_slug(slug) and profile_exists(slug):
297
- return slug
298
- return resolve_validated_profile(slug)
299
-
300
-
301
- def gateway_data(profile: str) -> dict[str, Any]:
302
- path = _profile_dir(profile) / "gateway_state.json"
303
- base: dict[str, Any] = {
304
- "ok": False,
305
- "exists": path.is_file(),
306
- "state": "unknown",
307
- "platforms": {},
308
- "updated_at": None,
309
- "uptime_seconds": None,
310
- }
311
- if not path.is_file():
312
- return base
313
- try:
314
- raw = json.loads(path.read_text(encoding="utf-8"))
315
- except (OSError, json.JSONDecodeError):
316
- return base
317
- start = raw.get("start_time")
318
- uptime = None
319
- if isinstance(start, (int, float)) and float(start) > 1_000_000_000:
320
- uptime = max(0.0, time.time() - float(start))
321
- base.update(
322
- {
323
- "ok": True,
324
- "state": raw.get("gateway_state") or raw.get("state") or "unknown",
325
- "pid": raw.get("pid"),
326
- "platforms": raw.get("platforms") or {},
327
- "updated_at": raw.get("updated_at"),
328
- "uptime_seconds": uptime,
329
- }
330
- )
331
- return base
332
-
333
-
334
- def _profile_api_port(profile: str) -> int:
335
- if profile == _primary_profile():
336
- return 8642
337
- base = 18610
338
- span = 100
339
- h = sum(ord(c) for c in profile) % span
340
- return base + h
341
-
342
-
343
- class _UnixHTTPConnection(http.client.HTTPConnection):
344
- def __init__(self, unix_path: str):
345
- super().__init__("localhost")
346
- self.unix_path = unix_path
347
-
348
- def connect(self) -> None:
349
- sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
350
- sock.connect(self.unix_path)
351
- self.sock = sock
352
-
353
-
354
- def _docker_request(method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, Any]:
355
- conn = _UnixHTTPConnection(DOCKER_SOCK)
356
- payload = json.dumps(body).encode("utf-8") if body is not None else None
357
- headers = {"Content-Type": "application/json"} if payload is not None else {}
358
- conn.request(method, f"/v1.41{path}", body=payload, headers=headers)
359
- resp = conn.getresponse()
360
- raw = resp.read()
361
- conn.close()
362
- if not raw:
363
- return resp.status, None
364
- try:
365
- return resp.status, json.loads(raw.decode("utf-8", errors="replace"))
366
- except json.JSONDecodeError:
367
- return resp.status, raw.decode("utf-8", errors="replace")
368
-
369
-
370
- def _docker_exec(container: str, cmd: list[str], acting_profile: str = "") -> tuple[int, str]:
371
- if _exec_targets_runtime_profile_secrets(cmd, acting_profile):
372
- return 1, "blocked: runtime profile credential paths are not readable via gateway exec"
373
- create_status, create_data = _docker_request(
374
- "POST",
375
- f"/containers/{urllib.parse.quote(container, safe='')}/exec",
376
- {
377
- "AttachStdout": True,
378
- "AttachStderr": True,
379
- "Tty": False,
380
- "Cmd": cmd,
381
- },
382
- )
383
- if create_status >= 300 or not isinstance(create_data, dict) or not create_data.get("Id"):
384
- return create_status, f"exec create failed: {create_data}"
385
- exec_id = str(create_data["Id"])
386
- start_status, start_data = _docker_request(
387
- "POST",
388
- f"/exec/{urllib.parse.quote(exec_id, safe='')}/start",
389
- {"Detach": False, "Tty": False},
390
- )
391
- out = start_data if isinstance(start_data, str) else json.dumps(start_data or {})
392
- if isinstance(out, str) and out:
393
- try:
394
- raw = out.encode("latin-1", errors="ignore")
395
- buf = bytearray()
396
- i = 0
397
- while i + 8 <= len(raw):
398
- size = int.from_bytes(raw[i + 4 : i + 8], "big")
399
- i += 8
400
- if size < 0 or i + size > len(raw):
401
- break
402
- buf.extend(raw[i : i + size])
403
- i += size
404
- if buf:
405
- out = buf.decode("utf-8", errors="replace")
406
- except Exception:
407
- pass
408
- inspect_status, inspect_data = _docker_request("GET", f"/exec/{urllib.parse.quote(exec_id, safe='')}/json")
409
- if inspect_status >= 300 or not isinstance(inspect_data, dict):
410
- return start_status, out
411
- exit_raw = inspect_data.get("ExitCode")
412
- exit_code = 0 if exit_raw in (0, "0") else int(exit_raw or 1)
413
- return exit_code, out
414
-
415
-
416
- def _docker_exec_detached(container: str, cmd: list[str], acting_profile: str = "") -> tuple[int, str]:
417
- if _exec_targets_runtime_profile_secrets(cmd, acting_profile):
418
- return 1, "blocked: runtime profile credential paths are not readable via gateway exec"
419
- create_status, create_data = _docker_request(
420
- "POST",
421
- f"/containers/{urllib.parse.quote(container, safe='')}/exec",
422
- {
423
- "AttachStdout": True,
424
- "AttachStderr": True,
425
- "Tty": False,
426
- "Cmd": cmd,
427
- },
428
- )
429
- if create_status >= 300 or not isinstance(create_data, dict) or not create_data.get("Id"):
430
- return create_status, f"exec create failed: {create_data}"
431
- exec_id = str(create_data["Id"])
432
- start_status, _start_data = _docker_request(
433
- "POST",
434
- f"/exec/{urllib.parse.quote(exec_id, safe='')}/start",
435
- {"Detach": True, "Tty": False},
436
- )
437
- if start_status >= 300:
438
- return start_status, "exec start failed"
439
- return 0, ""
440
-
441
-
442
- def _profile_home_container(profile: str) -> str:
443
- return f"/opt/data/profiles/{safe_profile_slug(profile)}"
444
-
445
-
446
- def _gateway_exec(profile: str, args: list[str]) -> tuple[int, str]:
447
- prof = resolve_hermes_profile(profile)
448
- cli = ["/opt/hermes/bin/hermes", "-p", prof, *args]
449
- if _exec_targets_runtime_profile_secrets(cli, prof):
450
- return 1, "blocked: runtime profile credential paths are not readable via gateway exec"
451
- if prof == _primary_profile():
452
- return _docker_exec(GATEWAY_CONTAINER_NAME, cli, acting_profile=prof)
453
- home = _profile_home_container(prof)
454
- inner = " ".join(shlex.quote(part) for part in cli)
455
- shell = (
456
- f"export HERMES_HOME={shlex.quote(home)} HOME={shlex.quote(home)}; "
457
- f"cd {shlex.quote(home)}; {inner}"
458
- )
459
- return _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=prof)
460
-
461
-
462
- def _configure_profile_api(profile: str) -> tuple[bool, str, int]:
463
- port = _profile_api_port(profile)
464
- script = (
465
- "import yaml\n"
466
- "from pathlib import Path\n"
467
- f"d=Path('/opt/data/profiles/{profile}')\n"
468
- "p=d/'config.yaml'\n"
469
- "cfg={}\n"
470
- "if p.exists():\n"
471
- " cfg=yaml.safe_load(p.read_text(encoding='utf-8')) or {}\n"
472
- "plats=cfg.setdefault('platforms',{})\n"
473
- f"native={'True' if profile == _primary_profile() else 'False'}\n"
474
- "if not native:\n"
475
- " api_only=plats.get('api_server', {}) if isinstance(plats.get('api_server', {}), dict) else {}\n"
476
- " plats={'api_server': api_only}\n"
477
- " for name in ('discord','telegram','slack','whatsapp','webhook','cron'):\n"
478
- " plats[name]={'enabled': False}\n"
479
- " cfg['platforms']=plats\n"
480
- "api=plats.setdefault('api_server',{})\n"
481
- "api['enabled']=True\n"
482
- "extra=api.setdefault('extra',{})\n"
483
- "extra['host']='0.0.0.0'\n"
484
- f"extra['port']={port}\n"
485
- "if not extra.get('key'):\n"
486
- " extra['key']='workframe-local-key'\n"
487
- "p.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding='utf-8')\n"
488
- "print('ok')\n"
489
- )
490
- code, out = _docker_exec(GATEWAY_CONTAINER_NAME, ["/opt/hermes/.venv/bin/python", "-c", script])
491
- return code == 0, out, port
492
-
493
-
494
- def _patch_profile_gateway_run_script(profile: str) -> tuple[bool, str]:
495
- if profile == _primary_profile():
496
- return True, "native profile unchanged"
497
- profile_home = f"/opt/data/profiles/{profile}"
498
- script = (
499
- "from pathlib import Path\n"
500
- f"p=Path('/run/service/gateway-{profile}/run')\n"
501
- "if not p.exists():\n"
502
- " print('missing run script')\n"
503
- " raise SystemExit(1)\n"
504
- "text=p.read_text(encoding='utf-8')\n"
505
- "needle='export HERMES_S6_SUPERVISED_CHILD=1\\n'\n"
506
- f"inject=('export HERMES_S6_SUPERVISED_CHILD=1\\n'"
507
- f"'export HERMES_HOME={profile_home}\\n'"
508
- f"'export HOME={profile_home}\\n'"
509
- f"'cd {profile_home}\\n'"
510
- "'if [ -z \"$WORKFRAME_PROXY_TOKEN\" ] && [ -f /run/workframe-proxy/token ]; then '\n"
511
- "'export WORKFRAME_PROXY_TOKEN=\"$(tr -d \\'\\r\\n\\' < /run/workframe-proxy/token)\"; fi\\n'"
512
- "'unset DISCORD_BOT_TOKEN DISCORD_ALLOWED_USERS DISCORD_HOME_CHANNEL\\n'"
513
- "'unset TELEGRAM_BOT_TOKEN TELEGRAM_ALLOWED_USERS TELEGRAM_HOME_CHANNEL\\n'"
514
- "'unset SLACK_BOT_TOKEN SLACK_APP_TOKEN WHATSAPP_MODE\\n'"
515
- "'unset HERMES_DASHBOARD HERMES_DASHBOARD_HOST HERMES_DASHBOARD_PORT HERMES_DASHBOARD_INSECURE HERMES_DASHBOARD_TUI\\n')\n"
516
- "if inject not in text:\n"
517
- " text=text.replace(needle, inject)\n"
518
- " p.write_text(text, encoding='utf-8')\n"
519
- "print('ok')\n"
520
- )
521
- code, out = _docker_exec(GATEWAY_CONTAINER_NAME, ["/opt/hermes/.venv/bin/python", "-c", script])
522
- return code == 0, out
523
-
524
-
525
- def _profile_api_healthy(profile: str, timeout: float = 1.5) -> bool:
526
- """Check profile API from inside the gateway container.
527
-
528
- ponytail: supervisor stays on control-net only; gateway DNS lives on workframe-net.
529
- """
530
- port = _profile_api_port(profile)
531
- wait = max(1.0, float(timeout))
532
- script = (
533
- "import sys,urllib.request\n"
534
- f"u='http://127.0.0.1:{port}/v1/health'\n"
535
- "r=urllib.request.Request(u,headers={'Authorization':'Bearer workframe-local-key'},method='GET')\n"
536
- "try:\n"
537
- f" urllib.request.urlopen(r,timeout={wait}); print('ok')\n"
538
- "except Exception as e:\n"
539
- " print(e,file=sys.stderr); sys.exit(1)\n"
540
- )
541
- code, _ = _docker_exec(
542
- GATEWAY_CONTAINER_NAME,
543
- ["/opt/hermes/.venv/bin/python", "-c", script],
544
- )
545
- return code == 0
546
-
547
-
548
- def profile_gateway_lifecycle(profile: str, action: str) -> dict[str, Any]:
549
- prof = resolve_hermes_profile(profile)
550
- if action not in {"start", "stop", "status", "disable"}:
551
- raise ValueError("invalid action")
552
- port = _profile_api_port(prof)
553
- primary = _primary_profile()
554
- if prof == primary:
555
- if action == "disable":
556
- raise ValueError("cannot disable the native profile")
557
- if action == "stop":
558
- raise ValueError("cannot stop the native profile")
559
- state = gateway_data(prof)
560
- ok = bool(state.get("ok")) and str(state.get("state") or "").lower() == "running"
561
- return {
562
- "ok": ok,
563
- "profile": prof,
564
- "action": action,
565
- "api_port": port,
566
- "state": state.get("state") or "unknown",
567
- "details": state,
568
- }
569
- if action == "status":
570
- state = gateway_data(prof)
571
- ok = bool(state.get("ok")) and str(state.get("state") or "").lower() == "running"
572
- return {
573
- "ok": ok,
574
- "profile": prof,
575
- "action": action,
576
- "api_port": port,
577
- "state": state.get("state") or "unknown",
578
- "pid": state.get("pid"),
579
- "uptime_seconds": state.get("uptime_seconds"),
580
- "updated_at": state.get("updated_at"),
581
- "platforms": state.get("platforms") or {},
582
- }
583
- if action == "disable":
584
- state = gateway_data(prof)
585
- if str(state.get("state") or "").lower() in {"running", "starting"}:
586
- code, out = _gateway_exec(prof, ["gateway", "stop"])
587
- if code != 0:
588
- raise ValueError(f"gateway stop failed: {out}")
589
- script = (
590
- "import yaml\n"
591
- "from pathlib import Path\n"
592
- f"d=Path('/opt/data/profiles/{prof}')\n"
593
- "p=d/'config.yaml'\n"
594
- "cfg=yaml.safe_load(p.read_text(encoding='utf-8')) if p.exists() else {}\n"
595
- "plats=cfg.setdefault('platforms',{})\n"
596
- "for name, value in list(plats.items()):\n"
597
- " if isinstance(value, dict):\n"
598
- " value['enabled']=False\n"
599
- " else:\n"
600
- " plats[name]={'enabled': False}\n"
601
- "cfg['platforms']=plats\n"
602
- "p.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding='utf-8')\n"
603
- f"Path('/opt/data/profiles/{prof}/.disabled').write_text('disabled by supervisor\n', encoding='utf-8')\n"
604
- "print('ok')\n"
605
- )
606
- code, out = _docker_exec(GATEWAY_CONTAINER_NAME, ["/opt/hermes/.venv/bin/python", "-c", script])
607
- if code != 0:
608
- raise ValueError(f"disable profile config failed: {out}")
609
- return {"ok": True, "profile": prof, "action": "disable", "state": "disabled", "api_port": port}
610
- if action == "start":
611
- if _profile_api_healthy(prof):
612
- return {
613
- "ok": True,
614
- "profile": prof,
615
- "action": "start",
616
- "state": "running",
617
- "api_port": port,
618
- "detail": "already running",
619
- }
620
- ok, out, port = _configure_profile_api(prof)
621
- if not ok:
622
- raise ValueError(f"profile api config failed: {out}")
623
- ok, out = _patch_profile_gateway_run_script(prof)
624
- if not ok:
625
- raise ValueError(f"profile api run patch failed: {out}")
626
- state = gateway_data(prof)
627
- if str(state.get("state") or "").lower() in {"running", "starting"}:
628
- code, out = _gateway_exec(prof, ["gateway", "stop"])
629
- if code != 0:
630
- raise ValueError(f"gateway stop failed: {out}")
631
- time.sleep(1.0)
632
- code, out = _gateway_exec(prof, ["gateway", action])
633
- if code != 0:
634
- raise ValueError(f"gateway {action} failed: {out}")
635
- if action == "start":
636
- for _ in range(60):
637
- if _profile_api_healthy(prof):
638
- break
639
- time.sleep(0.5)
640
- else:
641
- raise ValueError(f"profile api did not become healthy: {prof}")
642
- state = gateway_data(prof).get("state") or action
643
- return {"ok": True, "profile": prof, "action": action, "state": state, "api_port": port, "output": out.strip()}
644
-
645
-
646
- def _auth_ok(handler: BaseHTTPRequestHandler) -> bool:
647
- if not SUPERVISOR_TOKEN:
648
- return False
649
- auth = handler.headers.get("Authorization", "")
650
- if auth.startswith("Bearer "):
651
- return secrets_compare(auth[7:].strip(), SUPERVISOR_TOKEN)
652
- return False
653
-
654
-
655
- def secrets_compare(a: str, b: str) -> bool:
656
- import hmac
657
-
658
- return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
659
-
660
-
661
- class Handler(BaseHTTPRequestHandler):
662
- server_version = "workframe-supervisor/0.1.0"
663
-
664
- def log_message(self, fmt: str, *args: Any) -> None:
665
- return
666
-
667
- def _json(self, status: int, payload: Any) -> None:
668
- raw = json.dumps(payload).encode("utf-8")
669
- self.send_response(status)
670
- self.send_header("Content-Type", "application/json; charset=utf-8")
671
- self.send_header("Content-Length", str(len(raw)))
672
- self.end_headers()
673
- self.wfile.write(raw)
674
-
675
- def _read_json(self) -> dict[str, Any]:
676
- length = int(self.headers.get("Content-Length") or 0)
677
- if length <= 0:
678
- return {}
679
- raw = self.rfile.read(length)
680
- data = json.loads(raw.decode("utf-8"))
681
- return data if isinstance(data, dict) else {}
682
-
683
- def do_GET(self) -> None:
684
- parsed = urllib.parse.urlparse(self.path)
685
- path = parsed.path
686
- if path == "/health":
687
- return self._json(200, {"ok": True, "service": "workframe-supervisor", "version": VERSION})
688
- if not _auth_ok(self):
689
- return self._json(401, {"ok": False, "error": "unauthorized"})
690
- if path == "/v1/profile.status":
691
- qs = urllib.parse.parse_qs(parsed.query)
692
- profile = (qs.get("profile") or [""])[0]
693
- if not profile:
694
- return self._json(400, {"ok": False, "error": "profile required"})
695
- try:
696
- return self._json(200, profile_gateway_lifecycle(profile, "status"))
697
- except ValueError as exc:
698
- return self._json(400, {"ok": False, "error": str(exc)})
699
- if path == "/v1/stack.status":
700
- profiles = []
701
- for row in load_routes()["routes"]:
702
- slug = row["profile"]
703
- state = gateway_data(slug)
704
- profiles.append(
705
- {
706
- "profile": slug,
707
- "native": slug == _primary_profile(),
708
- "state": state.get("state") or "unknown",
709
- "api_port": _profile_api_port(slug),
710
- }
711
- )
712
- return self._json(
713
- 200,
714
- {"ok": True, "profiles": profiles, "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())},
715
- )
716
- if path == "/v1/gateway.image":
717
- digest, ref = _gateway_image_digest()
718
- return self._json(
719
- 200,
720
- {
721
- "ok": bool(digest or ref),
722
- "digest": digest,
723
- "ref": ref,
724
- "container": GATEWAY_CONTAINER_NAME,
725
- },
726
- )
727
- return self._json(404, {"ok": False, "error": "not found"})
728
-
729
- def do_POST(self) -> None:
730
- path = urllib.parse.urlparse(self.path).path
731
- if not _auth_ok(self):
732
- return self._json(401, {"ok": False, "error": "unauthorized"})
733
- try:
734
- body = self._read_json()
735
- except json.JSONDecodeError:
736
- return self._json(400, {"ok": False, "error": "invalid json"})
737
- action_map = {
738
- "/v1/profile.start": "start",
739
- "/v1/profile.stop": "stop",
740
- "/v1/profile.disable": "disable",
741
- }
742
- if path not in action_map:
743
- if path == "/v1/gateway.exec":
744
- profile = str(body.get("profile") or "").strip()
745
- args = body.get("args")
746
- if not profile:
747
- return self._json(400, {"ok": False, "error": "profile required"})
748
- if not isinstance(args, list) or not args:
749
- return self._json(400, {"ok": False, "error": "args required"})
750
- try:
751
- prof = resolve_hermes_profile(profile)
752
- cli_args = [str(part) for part in args]
753
- code, out = _gateway_exec(prof, cli_args)
754
- return self._json(
755
- 200,
756
- {"ok": code == 0, "profile": prof, "exit_code": code, "output": out},
757
- )
758
- except ValueError as exc:
759
- return self._json(400, {"ok": False, "error": str(exc)})
760
- if path == "/v1/gateway.container_exec":
761
- if os.environ.get("WORKFRAME_SUPERVISOR_ALLOW_RAW_EXEC", "0") != "1":
762
- return self._json(403, {"ok": False, "error": "raw_container_exec_disabled"})
763
- # ponytail: opt-in only (WORKFRAME_SUPERVISOR_ALLOW_RAW_EXEC=1); default off (0022 N1)
764
- args = body.get("args")
765
- if not isinstance(args, list) or not args:
766
- return self._json(400, {"ok": False, "error": "args required"})
767
- detach = bool(body.get("detach"))
768
- try:
769
- cli_args = [str(part) for part in args]
770
- if detach:
771
- code, out = _docker_exec_detached(GATEWAY_CONTAINER_NAME, cli_args)
772
- else:
773
- code, out = _docker_exec(GATEWAY_CONTAINER_NAME, cli_args)
774
- return self._json(
775
- 200,
776
- {"ok": code == 0, "exit_code": code, "output": out, "detached": detach},
777
- )
778
- except Exception as exc: # noqa: BLE001
779
- return self._json(500, {"ok": False, "error": str(exc)})
780
- if path == "/v1/stack.apply":
781
- target = str(body.get("target") or "all").strip().lower()
782
- workframe_version = str(body.get("workframe_version") or "").strip()
783
- try:
784
- return self._json(200, _stack_apply(target, workframe_version=workframe_version))
785
- except ValueError as exc:
786
- return self._json(400, {"ok": False, "error": str(exc)})
787
- except Exception as exc: # noqa: BLE001
788
- return self._json(500, {"ok": False, "error": str(exc)})
789
- if path == "/v1/hermes.device_oauth_start":
790
- home = str(body.get("home") or "").strip()
791
- hermes_auth_id = str(body.get("hermes_auth_id") or "").strip()
792
- log_path = str(body.get("log_path") or "").strip()
793
- try:
794
- code, out = _hermes_device_oauth_start(home, hermes_auth_id, log_path)
795
- return self._json(
796
- 200,
797
- {"ok": code == 0, "exit_code": code, "output": out, "detached": True},
798
- )
799
- except ValueError as exc:
800
- return self._json(400, {"ok": False, "error": str(exc)})
801
- except Exception as exc: # noqa: BLE001
802
- return self._json(500, {"ok": False, "error": str(exc)})
803
- if path == "/v1/host.setup_public_https":
804
- host = str(body.get("host") or "").strip()
805
- try:
806
- port = int(body.get("port") or os.environ.get("WORKFRAME_UI_PORT", "18644"))
807
- except (TypeError, ValueError):
808
- return self._json(400, {"ok": False, "error": "invalid port"})
809
- try:
810
- return self._json(200, _host_setup_public_https(host, port))
811
- except ValueError as exc:
812
- return self._json(400, {"ok": False, "error": str(exc)})
813
- except Exception as exc: # noqa: BLE001
814
- return self._json(500, {"ok": False, "error": str(exc)})
815
- if path == "/v1/host.set_compose_public_url":
816
- public_url = str(body.get("url") or body.get("app_base_url") or "").strip()
817
- restart = body.get("restart", True) is not False
818
- try:
819
- return self._json(200, _host_set_compose_public_url(public_url, restart=restart))
820
- except ValueError as exc:
821
- return self._json(400, {"ok": False, "error": str(exc)})
822
- except Exception as exc: # noqa: BLE001
823
- return self._json(500, {"ok": False, "error": str(exc)})
824
- if path == "/v1/hermes.user_exec":
825
- home = str(body.get("home") or "").strip()
826
- args = body.get("args")
827
- if not home or not home.startswith("/opt/data/profiles/"):
828
- return self._json(400, {"ok": False, "error": "home required"})
829
- slug = safe_profile_slug(home.rsplit("/", 1)[-1])
830
- home = f"/opt/data/profiles/{slug}"
831
- if not isinstance(args, list) or not args:
832
- return self._json(400, {"ok": False, "error": "args required"})
833
- inner = " ".join(shlex.quote(str(part)) for part in args)
834
- shell = (
835
- f"export HERMES_HOME={shlex.quote(home)} HOME={shlex.quote(home)}; "
836
- f"mkdir -p {shlex.quote(home)}; cd {shlex.quote(home)}; "
837
- f"/opt/hermes/bin/hermes {inner}"
838
- )
839
- code, out = _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=slug)
840
- return self._json(200, {"ok": code == 0, "exit_code": code, "output": out})
841
- return self._json(404, {"ok": False, "error": "not found"})
842
- profile = str(body.get("profile") or "").strip()
843
- if not profile:
844
- return self._json(400, {"ok": False, "error": "profile required"})
845
- try:
846
- return self._json(200, profile_gateway_lifecycle(profile, action_map[path]))
847
- except ValueError as exc:
848
- return self._json(400, {"ok": False, "error": str(exc)})
849
-
850
-
851
- def main() -> None:
852
- if not SUPERVISOR_TOKEN:
853
- raise SystemExit("WORKFRAME_SUPERVISOR_TOKEN is required")
854
- server = ThreadingHTTPServer((HOST, PORT), Handler)
855
- print(f"workframe-supervisor listening on {HOST}:{PORT}", flush=True)
856
- server.serve_forever()
857
-
858
-
859
- if __name__ == "__main__":
860
- main()
1
+ #!/usr/bin/env python3
2
+ """workframe-supervisor — token-gated Docker exec for Hermes profile lifecycle."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import http.client
7
+ import json
8
+ import os
9
+ import re
10
+ import shlex
11
+ import socket
12
+ import subprocess
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ HERMES_DATA = Path(os.environ.get("HERMES_DATA", "/opt/data"))
22
+ HOST = os.environ.get("HOST", "127.0.0.1")
23
+ PORT = int(os.environ.get("PORT", "8090"))
24
+ VERSION = "0.1.0"
25
+ NATIVE_PROFILE = os.environ.get("WORKFRAME_NATIVE_PROFILE", "").strip()
26
+ DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
27
+ GATEWAY_CONTAINER_NAME = os.environ.get("WORKFRAME_GATEWAY_CONTAINER", "workframe-gateway")
28
+ SUPERVISOR_TOKEN = (
29
+ os.environ.get("WORKFRAME_SUPERVISOR_TOKEN")
30
+ or os.environ.get("SUPERVISOR_TOKEN")
31
+ or ""
32
+ ).strip()
33
+ DEPLOYMENT_MODE = (os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "trusted_team").strip().lower()
34
+ ROUTES_JSON = HERMES_DATA / "workframe" / "routes.json"
35
+ SCRIPTS_DIR = Path(os.environ.get("WORKFRAME_SCRIPTS_DIR", "/opt/install/scripts"))
36
+ COMPOSE_DIR = Path(os.environ.get("WORKFRAME_COMPOSE_DIR", "/compose"))
37
+
38
+
39
+ def _compose_file_args() -> list[str]:
40
+ """Absolute host bind paths when compose CLI runs inside supervisor (relative paths break)."""
41
+ args = ["-f", str(COMPOSE_DIR / "docker-compose.yml")]
42
+ host_root = os.environ.get("WORKFRAME_HOST_PROJECT_ROOT", "").strip()
43
+ bindings = COMPOSE_DIR / "docker-compose.host-bindings.yml"
44
+ if host_root and bindings.is_file():
45
+ args.extend(["-f", str(bindings)])
46
+ return args
47
+
48
+
49
+ def _compose_run(argv: list[str], *, timeout: float = 120.0) -> subprocess.CompletedProcess[str]:
50
+ cmd = ["docker", "compose", *_compose_file_args(), *argv]
51
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=str(COMPOSE_DIR))
52
+
53
+
54
+ from profile_secret_policy import exec_blocked_for_profile
55
+
56
+
57
+ def _exec_targets_runtime_profile_secrets(cmd: list[str], acting_profile: str = "") -> bool:
58
+ if DEPLOYMENT_MODE == "single_user_local":
59
+ return False
60
+ return exec_blocked_for_profile(cmd, acting_profile)
61
+
62
+
63
+ def _stack_apply(target: str, *, workframe_version: str = "") -> dict[str, Any]:
64
+ target = str(target or "all").strip().lower()
65
+ if target == "gateway-restart":
66
+ script = SCRIPTS_DIR / "restart-gateway-hermes.sh"
67
+ if not script.is_file():
68
+ raise ValueError("restart_script_missing")
69
+ proc = subprocess.run(
70
+ ["bash", str(script)],
71
+ capture_output=True,
72
+ text=True,
73
+ timeout=300,
74
+ cwd=str(COMPOSE_DIR) if COMPOSE_DIR.is_dir() else None,
75
+ )
76
+ log = f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
77
+ if proc.returncode != 0:
78
+ raise ValueError("restart_failed:gateway")
79
+ return {"ok": True, "target": "gateway", "log": log[-12000:]}
80
+ if target not in {"hermes", "workframe", "all"}:
81
+ raise ValueError("invalid_update_target")
82
+ scripts: list[Path] = []
83
+ if target in {"hermes", "all"}:
84
+ p = SCRIPTS_DIR / "apply-update-hermes.sh"
85
+ if not p.is_file():
86
+ raise ValueError("update_script_missing:hermes")
87
+ scripts.append(p)
88
+ if target in {"workframe", "all"}:
89
+ p = SCRIPTS_DIR / "apply-update-workframe.sh"
90
+ if not p.is_file():
91
+ raise ValueError("update_script_missing:workframe")
92
+ scripts.append(p)
93
+ env = os.environ.copy()
94
+ version = str(workframe_version or "").strip()
95
+ if version and target in {"workframe", "all"}:
96
+ env["WORKFRAME_UPDATE_ALLOW_NPM"] = "1"
97
+ env["WORKFRAME_UPDATE_VERSION"] = version
98
+ logs: list[str] = []
99
+ for script in scripts:
100
+ proc = subprocess.run(
101
+ ["bash", str(script)],
102
+ capture_output=True,
103
+ text=True,
104
+ timeout=900,
105
+ env=env,
106
+ cwd=str(COMPOSE_DIR) if COMPOSE_DIR.is_dir() else None,
107
+ )
108
+ logs.append(f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}")
109
+ if proc.returncode != 0:
110
+ raise ValueError(f"update_failed:{script.name}")
111
+ return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
112
+
113
+
114
+ _DEVICE_OAUTH_AUTH_IDS = frozenset({"openai-codex", "nous"})
115
+
116
+
117
+ def _gateway_image_digest() -> tuple[str, str]:
118
+ status, data = _docker_request("GET", f"/containers/{urllib.parse.quote(GATEWAY_CONTAINER_NAME, safe='')}/json")
119
+ if status != 200 or not isinstance(data, dict):
120
+ return "", ""
121
+ image_id = str(data.get("Image") or "")
122
+ ist, idata = _docker_request("GET", f"/images/{image_id}/json")
123
+ digest = ""
124
+ ref = os.environ.get("WORKFRAME_HERMES_IMAGE", "nousresearch/hermes-agent")
125
+ if ist == 200 and isinstance(idata, dict):
126
+ digests = idata.get("RepoDigests") or []
127
+ if digests:
128
+ digest = str(digests[0]).split("@")[-1]
129
+ tags = idata.get("RepoTags") or []
130
+ if tags:
131
+ ref = str(tags[0])
132
+ return digest, ref
133
+
134
+
135
+ def _hermes_device_oauth_start(home: str, hermes_auth_id: str, log_path: str) -> tuple[int, str]:
136
+ home = str(home or "").strip()
137
+ hermes_auth_id = str(hermes_auth_id or "").strip()
138
+ log_path = str(log_path or "").strip()
139
+ if not home.startswith("/opt/data/profiles/") or not log_path.startswith("/opt/data/profiles/"):
140
+ raise ValueError("invalid profile home")
141
+ if hermes_auth_id not in _DEVICE_OAUTH_AUTH_IDS:
142
+ raise ValueError("invalid_device_oauth_provider")
143
+ auth_cmd = " ".join(shlex.quote(part) for part in ["auth", "add", hermes_auth_id])
144
+ shell = (
145
+ f"mkdir -p {shlex.quote(home)}; "
146
+ f"chown -R hermes:hermes {shlex.quote(home)}; "
147
+ f"su -s /bin/sh hermes -c "
148
+ f"'export HERMES_HOME={shlex.quote(home)} HOME={shlex.quote(home)}; "
149
+ f"cd {shlex.quote(home)}; "
150
+ f"/opt/hermes/bin/hermes {auth_cmd} >> {shlex.quote(log_path)} 2>&1'"
151
+ )
152
+ return _docker_exec_detached(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell])
153
+
154
+
155
+ def _host_setup_public_https(host: str, port: int) -> dict[str, Any]:
156
+ host = str(host or "").strip().lower()
157
+ if not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", host, re.IGNORECASE):
158
+ raise ValueError("invalid host")
159
+ port = int(port)
160
+ if port < 1 or port > 65535:
161
+ raise ValueError("invalid port")
162
+ root = os.environ.get("WORKFRAME_HOST_PROJECT_ROOT", "").strip() or "/opt/workframe/repo"
163
+ script = f"{root}/scripts/workframe/setup-public-https.sh"
164
+ # ponytail: chroot on host via docker.sock — API container cannot run apt/systemctl on host.
165
+ cmd = [
166
+ "docker",
167
+ "run",
168
+ "--rm",
169
+ "--pull=missing",
170
+ "--privileged",
171
+ "--pid=host",
172
+ "-v",
173
+ "/:/host",
174
+ "debian:bookworm-slim",
175
+ "chroot",
176
+ "/host",
177
+ "bash",
178
+ script,
179
+ host,
180
+ str(port),
181
+ ]
182
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
183
+ log = f"=== setup-public-https {host}:{port} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
184
+ if proc.returncode != 0:
185
+ raise ValueError(log[-2000:] or "setup_public_https_failed")
186
+ return {"ok": True, "host": host, "port": port, "log": log[-8000:]}
187
+
188
+
189
+ def _host_set_compose_public_url(public_url: str, *, restart: bool = True) -> dict[str, Any]:
190
+ public_url = str(public_url or "").strip()
191
+ if not public_url:
192
+ raise ValueError("url required")
193
+ compose_dir = Path(os.environ.get("WORKFRAME_COMPOSE_DIR", "/compose"))
194
+ env_path = compose_dir / ".env"
195
+ scripts = Path(os.environ.get("WORKFRAME_SCRIPTS_DIR", "/opt/install/scripts"))
196
+ script = scripts / "set-compose-public-url.mjs"
197
+ if not script.is_file():
198
+ raise ValueError("set-compose_public_url_script_missing")
199
+ proc = subprocess.run(
200
+ ["node", str(script), public_url, "--env", str(env_path)],
201
+ capture_output=True,
202
+ text=True,
203
+ timeout=60,
204
+ cwd=str(compose_dir),
205
+ )
206
+ log = f"=== set-compose-public-url (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
207
+ if proc.returncode != 0:
208
+ raise ValueError(log[-2000:] or "set_compose_public_url_failed")
209
+ if restart:
210
+ # ponytail: --no-deps — never touch workframe-supervisor (partial up was killing it mid-install).
211
+ restart_proc = _compose_run(["up", "-d", "--no-deps", "workframe-api", "gateway"], timeout=120)
212
+ log += (
213
+ f"\n=== compose up --no-deps workframe-api gateway (exit {restart_proc.returncode}) ===\n"
214
+ f"{restart_proc.stdout}\n{restart_proc.stderr}"
215
+ )
216
+ if restart_proc.returncode != 0:
217
+ raise ValueError(log[-2000:] or "compose_restart_failed")
218
+ try:
219
+ payload = json.loads(proc.stdout.strip())
220
+ except json.JSONDecodeError:
221
+ payload = {"ok": True}
222
+ payload["log"] = log[-8000:]
223
+ payload["restarted"] = restart
224
+ return payload
225
+
226
+
227
+ def _primary_profile() -> str:
228
+ if NATIVE_PROFILE and (HERMES_DATA / "profiles" / NATIVE_PROFILE).is_dir():
229
+ return NATIVE_PROFILE
230
+ root = HERMES_DATA / "profiles"
231
+ if not root.is_dir():
232
+ return ""
233
+ names = sorted(p.name for p in root.iterdir() if p.is_dir())
234
+ return names[0] if names else ""
235
+
236
+
237
+ def _profile_dir(profile: str) -> Path:
238
+ return HERMES_DATA / "profiles" / profile
239
+
240
+
241
+ def safe_profile_slug(value: str) -> str:
242
+ slug = (value or "").strip()
243
+ if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", slug):
244
+ raise ValueError("invalid profile")
245
+ return slug
246
+
247
+
248
+ def profile_exists(profile: str) -> bool:
249
+ return _profile_dir(profile).is_dir()
250
+
251
+
252
+ def load_routes() -> dict[str, Any]:
253
+ default_profile = _primary_profile()
254
+ raw_routes: list[dict[str, Any]] = []
255
+ if ROUTES_JSON.is_file():
256
+ try:
257
+ data = json.loads(ROUTES_JSON.read_text(encoding="utf-8"))
258
+ if isinstance(data, dict) and isinstance(data.get("routes"), list):
259
+ raw_routes = [r for r in data["routes"] if isinstance(r, dict)]
260
+ except (OSError, json.JSONDecodeError):
261
+ pass
262
+ if not raw_routes:
263
+ root = HERMES_DATA / "profiles"
264
+ if root.is_dir():
265
+ for p in sorted(root.iterdir()):
266
+ if p.is_dir():
267
+ raw_routes.append({"profile": p.name})
268
+ routes = []
269
+ for row in raw_routes:
270
+ slug = str(row.get("profile") or "").strip()
271
+ if slug and profile_exists(slug):
272
+ routes.append({"profile": slug})
273
+ return {"default_profile": default_profile, "routes": routes}
274
+
275
+
276
+ def resolve_validated_profile(profile: str) -> str:
277
+ slug = safe_profile_slug(str(profile or _primary_profile()).strip())
278
+ allowed = {r["profile"] for r in load_routes()["routes"]}
279
+ if slug not in allowed:
280
+ raise ValueError(f"unknown profile: {slug}")
281
+ if not profile_exists(slug):
282
+ raise ValueError(f"profile not installed: {slug}")
283
+ return slug
284
+
285
+
286
+ _RUNTIME_PROFILE_RE = re.compile(r"^u-[a-z0-9][a-z0-9-]{0,62}$")
287
+
288
+
289
+ def _is_runtime_profile_slug(slug: str) -> bool:
290
+ return bool(_RUNTIME_PROFILE_RE.fullmatch(str(slug or "").strip()))
291
+
292
+
293
+ def resolve_hermes_profile(profile: str) -> str:
294
+ """Workspace routes or per-user runtime u-* dirs on disk."""
295
+ slug = safe_profile_slug(str(profile or _primary_profile()).strip())
296
+ if _is_runtime_profile_slug(slug) and profile_exists(slug):
297
+ return slug
298
+ return resolve_validated_profile(slug)
299
+
300
+
301
+ def gateway_data(profile: str) -> dict[str, Any]:
302
+ path = _profile_dir(profile) / "gateway_state.json"
303
+ base: dict[str, Any] = {
304
+ "ok": False,
305
+ "exists": path.is_file(),
306
+ "state": "unknown",
307
+ "platforms": {},
308
+ "updated_at": None,
309
+ "uptime_seconds": None,
310
+ }
311
+ if not path.is_file():
312
+ return base
313
+ try:
314
+ raw = json.loads(path.read_text(encoding="utf-8"))
315
+ except (OSError, json.JSONDecodeError):
316
+ return base
317
+ start = raw.get("start_time")
318
+ uptime = None
319
+ if isinstance(start, (int, float)) and float(start) > 1_000_000_000:
320
+ uptime = max(0.0, time.time() - float(start))
321
+ base.update(
322
+ {
323
+ "ok": True,
324
+ "state": raw.get("gateway_state") or raw.get("state") or "unknown",
325
+ "pid": raw.get("pid"),
326
+ "platforms": raw.get("platforms") or {},
327
+ "updated_at": raw.get("updated_at"),
328
+ "uptime_seconds": uptime,
329
+ }
330
+ )
331
+ return base
332
+
333
+
334
+ def _profile_api_port(profile: str) -> int:
335
+ if profile == _primary_profile():
336
+ return 8642
337
+ base = 18610
338
+ span = 100
339
+ h = sum(ord(c) for c in profile) % span
340
+ return base + h
341
+
342
+
343
+ class _UnixHTTPConnection(http.client.HTTPConnection):
344
+ def __init__(self, unix_path: str):
345
+ super().__init__("localhost")
346
+ self.unix_path = unix_path
347
+
348
+ def connect(self) -> None:
349
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
350
+ sock.connect(self.unix_path)
351
+ self.sock = sock
352
+
353
+
354
+ def _docker_request(method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, Any]:
355
+ conn = _UnixHTTPConnection(DOCKER_SOCK)
356
+ payload = json.dumps(body).encode("utf-8") if body is not None else None
357
+ headers = {"Content-Type": "application/json"} if payload is not None else {}
358
+ conn.request(method, f"/v1.41{path}", body=payload, headers=headers)
359
+ resp = conn.getresponse()
360
+ raw = resp.read()
361
+ conn.close()
362
+ if not raw:
363
+ return resp.status, None
364
+ try:
365
+ return resp.status, json.loads(raw.decode("utf-8", errors="replace"))
366
+ except json.JSONDecodeError:
367
+ return resp.status, raw.decode("utf-8", errors="replace")
368
+
369
+
370
+ def _docker_exec(container: str, cmd: list[str], acting_profile: str = "") -> tuple[int, str]:
371
+ if _exec_targets_runtime_profile_secrets(cmd, acting_profile):
372
+ return 1, "blocked: runtime profile credential paths are not readable via gateway exec"
373
+ create_status, create_data = _docker_request(
374
+ "POST",
375
+ f"/containers/{urllib.parse.quote(container, safe='')}/exec",
376
+ {
377
+ "AttachStdout": True,
378
+ "AttachStderr": True,
379
+ "Tty": False,
380
+ "Cmd": cmd,
381
+ },
382
+ )
383
+ if create_status >= 300 or not isinstance(create_data, dict) or not create_data.get("Id"):
384
+ return create_status, f"exec create failed: {create_data}"
385
+ exec_id = str(create_data["Id"])
386
+ start_status, start_data = _docker_request(
387
+ "POST",
388
+ f"/exec/{urllib.parse.quote(exec_id, safe='')}/start",
389
+ {"Detach": False, "Tty": False},
390
+ )
391
+ out = start_data if isinstance(start_data, str) else json.dumps(start_data or {})
392
+ if isinstance(out, str) and out:
393
+ try:
394
+ raw = out.encode("latin-1", errors="ignore")
395
+ buf = bytearray()
396
+ i = 0
397
+ while i + 8 <= len(raw):
398
+ size = int.from_bytes(raw[i + 4 : i + 8], "big")
399
+ i += 8
400
+ if size < 0 or i + size > len(raw):
401
+ break
402
+ buf.extend(raw[i : i + size])
403
+ i += size
404
+ if buf:
405
+ out = buf.decode("utf-8", errors="replace")
406
+ except Exception:
407
+ pass
408
+ inspect_status, inspect_data = _docker_request("GET", f"/exec/{urllib.parse.quote(exec_id, safe='')}/json")
409
+ if inspect_status >= 300 or not isinstance(inspect_data, dict):
410
+ return start_status, out
411
+ exit_raw = inspect_data.get("ExitCode")
412
+ exit_code = 0 if exit_raw in (0, "0") else int(exit_raw or 1)
413
+ return exit_code, out
414
+
415
+
416
+ def _docker_exec_detached(container: str, cmd: list[str], acting_profile: str = "") -> tuple[int, str]:
417
+ if _exec_targets_runtime_profile_secrets(cmd, acting_profile):
418
+ return 1, "blocked: runtime profile credential paths are not readable via gateway exec"
419
+ create_status, create_data = _docker_request(
420
+ "POST",
421
+ f"/containers/{urllib.parse.quote(container, safe='')}/exec",
422
+ {
423
+ "AttachStdout": True,
424
+ "AttachStderr": True,
425
+ "Tty": False,
426
+ "Cmd": cmd,
427
+ },
428
+ )
429
+ if create_status >= 300 or not isinstance(create_data, dict) or not create_data.get("Id"):
430
+ return create_status, f"exec create failed: {create_data}"
431
+ exec_id = str(create_data["Id"])
432
+ start_status, _start_data = _docker_request(
433
+ "POST",
434
+ f"/exec/{urllib.parse.quote(exec_id, safe='')}/start",
435
+ {"Detach": True, "Tty": False},
436
+ )
437
+ if start_status >= 300:
438
+ return start_status, "exec start failed"
439
+ return 0, ""
440
+
441
+
442
+ def _profile_home_container(profile: str) -> str:
443
+ return f"/opt/data/profiles/{safe_profile_slug(profile)}"
444
+
445
+
446
+ def _gateway_exec(profile: str, args: list[str]) -> tuple[int, str]:
447
+ prof = resolve_hermes_profile(profile)
448
+ cli = ["/opt/hermes/bin/hermes", "-p", prof, *args]
449
+ if _exec_targets_runtime_profile_secrets(cli, prof):
450
+ return 1, "blocked: runtime profile credential paths are not readable via gateway exec"
451
+ if prof == _primary_profile():
452
+ return _docker_exec(GATEWAY_CONTAINER_NAME, cli, acting_profile=prof)
453
+ home = _profile_home_container(prof)
454
+ inner = " ".join(shlex.quote(part) for part in cli)
455
+ shell = (
456
+ f"export HERMES_HOME={shlex.quote(home)} HOME={shlex.quote(home)}; "
457
+ f"cd {shlex.quote(home)}; {inner}"
458
+ )
459
+ return _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=prof)
460
+
461
+
462
+ def _configure_profile_api(profile: str) -> tuple[bool, str, int]:
463
+ port = _profile_api_port(profile)
464
+ script = (
465
+ "import yaml\n"
466
+ "from pathlib import Path\n"
467
+ f"d=Path('/opt/data/profiles/{profile}')\n"
468
+ "p=d/'config.yaml'\n"
469
+ "cfg={}\n"
470
+ "if p.exists():\n"
471
+ " cfg=yaml.safe_load(p.read_text(encoding='utf-8')) or {}\n"
472
+ "plats=cfg.setdefault('platforms',{})\n"
473
+ f"native={'True' if profile == _primary_profile() else 'False'}\n"
474
+ "if not native:\n"
475
+ " api_only=plats.get('api_server', {}) if isinstance(plats.get('api_server', {}), dict) else {}\n"
476
+ " plats={'api_server': api_only}\n"
477
+ " for name in ('discord','telegram','slack','whatsapp','webhook','cron'):\n"
478
+ " plats[name]={'enabled': False}\n"
479
+ " cfg['platforms']=plats\n"
480
+ "api=plats.setdefault('api_server',{})\n"
481
+ "api['enabled']=True\n"
482
+ "extra=api.setdefault('extra',{})\n"
483
+ "extra['host']='0.0.0.0'\n"
484
+ f"extra['port']={port}\n"
485
+ "if not extra.get('key'):\n"
486
+ " extra['key']='workframe-local-key'\n"
487
+ "p.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding='utf-8')\n"
488
+ "print('ok')\n"
489
+ )
490
+ code, out = _docker_exec(GATEWAY_CONTAINER_NAME, ["/opt/hermes/.venv/bin/python", "-c", script])
491
+ return code == 0, out, port
492
+
493
+
494
+ def _patch_profile_gateway_run_script(profile: str) -> tuple[bool, str]:
495
+ if profile == _primary_profile():
496
+ return True, "native profile unchanged"
497
+ profile_home = f"/opt/data/profiles/{profile}"
498
+ script = (
499
+ "from pathlib import Path\n"
500
+ f"p=Path('/run/service/gateway-{profile}/run')\n"
501
+ "if not p.exists():\n"
502
+ " print('missing run script')\n"
503
+ " raise SystemExit(1)\n"
504
+ "text=p.read_text(encoding='utf-8')\n"
505
+ "needle='export HERMES_S6_SUPERVISED_CHILD=1\\n'\n"
506
+ f"inject=('export HERMES_S6_SUPERVISED_CHILD=1\\n'"
507
+ f"'export HERMES_HOME={profile_home}\\n'"
508
+ f"'export HOME={profile_home}\\n'"
509
+ f"'cd {profile_home}\\n'"
510
+ "'if [ -z \"$WORKFRAME_PROXY_TOKEN\" ] && [ -f /run/workframe-proxy/token ]; then '\n"
511
+ "'export WORKFRAME_PROXY_TOKEN=\"$(tr -d \\'\\r\\n\\' < /run/workframe-proxy/token)\"; fi\\n'"
512
+ "'unset DISCORD_BOT_TOKEN DISCORD_ALLOWED_USERS DISCORD_HOME_CHANNEL\\n'"
513
+ "'unset TELEGRAM_BOT_TOKEN TELEGRAM_ALLOWED_USERS TELEGRAM_HOME_CHANNEL\\n'"
514
+ "'unset SLACK_BOT_TOKEN SLACK_APP_TOKEN WHATSAPP_MODE\\n'"
515
+ "'unset HERMES_DASHBOARD HERMES_DASHBOARD_HOST HERMES_DASHBOARD_PORT HERMES_DASHBOARD_INSECURE HERMES_DASHBOARD_TUI\\n')\n"
516
+ "if inject not in text:\n"
517
+ " text=text.replace(needle, inject)\n"
518
+ " p.write_text(text, encoding='utf-8')\n"
519
+ "print('ok')\n"
520
+ )
521
+ code, out = _docker_exec(GATEWAY_CONTAINER_NAME, ["/opt/hermes/.venv/bin/python", "-c", script])
522
+ return code == 0, out
523
+
524
+
525
+ def _profile_api_healthy(profile: str, timeout: float = 1.5) -> bool:
526
+ """Check profile API from inside the gateway container.
527
+
528
+ ponytail: supervisor stays on control-net only; gateway DNS lives on workframe-net.
529
+ """
530
+ port = _profile_api_port(profile)
531
+ wait = max(1.0, float(timeout))
532
+ script = (
533
+ "import sys,urllib.request\n"
534
+ f"u='http://127.0.0.1:{port}/v1/health'\n"
535
+ "r=urllib.request.Request(u,headers={'Authorization':'Bearer workframe-local-key'},method='GET')\n"
536
+ "try:\n"
537
+ f" urllib.request.urlopen(r,timeout={wait}); print('ok')\n"
538
+ "except Exception as e:\n"
539
+ " print(e,file=sys.stderr); sys.exit(1)\n"
540
+ )
541
+ code, _ = _docker_exec(
542
+ GATEWAY_CONTAINER_NAME,
543
+ ["/opt/hermes/.venv/bin/python", "-c", script],
544
+ )
545
+ return code == 0
546
+
547
+
548
+ def profile_gateway_lifecycle(profile: str, action: str) -> dict[str, Any]:
549
+ prof = resolve_hermes_profile(profile)
550
+ if action not in {"start", "stop", "status", "disable"}:
551
+ raise ValueError("invalid action")
552
+ port = _profile_api_port(prof)
553
+ primary = _primary_profile()
554
+ if prof == primary:
555
+ if action == "disable":
556
+ raise ValueError("cannot disable the native profile")
557
+ if action == "stop":
558
+ raise ValueError("cannot stop the native profile")
559
+ state = gateway_data(prof)
560
+ ok = bool(state.get("ok")) and str(state.get("state") or "").lower() == "running"
561
+ return {
562
+ "ok": ok,
563
+ "profile": prof,
564
+ "action": action,
565
+ "api_port": port,
566
+ "state": state.get("state") or "unknown",
567
+ "details": state,
568
+ }
569
+ if action == "status":
570
+ state = gateway_data(prof)
571
+ ok = bool(state.get("ok")) and str(state.get("state") or "").lower() == "running"
572
+ return {
573
+ "ok": ok,
574
+ "profile": prof,
575
+ "action": action,
576
+ "api_port": port,
577
+ "state": state.get("state") or "unknown",
578
+ "pid": state.get("pid"),
579
+ "uptime_seconds": state.get("uptime_seconds"),
580
+ "updated_at": state.get("updated_at"),
581
+ "platforms": state.get("platforms") or {},
582
+ }
583
+ if action == "disable":
584
+ state = gateway_data(prof)
585
+ if str(state.get("state") or "").lower() in {"running", "starting"}:
586
+ code, out = _gateway_exec(prof, ["gateway", "stop"])
587
+ if code != 0:
588
+ raise ValueError(f"gateway stop failed: {out}")
589
+ script = (
590
+ "import yaml\n"
591
+ "from pathlib import Path\n"
592
+ f"d=Path('/opt/data/profiles/{prof}')\n"
593
+ "p=d/'config.yaml'\n"
594
+ "cfg=yaml.safe_load(p.read_text(encoding='utf-8')) if p.exists() else {}\n"
595
+ "plats=cfg.setdefault('platforms',{})\n"
596
+ "for name, value in list(plats.items()):\n"
597
+ " if isinstance(value, dict):\n"
598
+ " value['enabled']=False\n"
599
+ " else:\n"
600
+ " plats[name]={'enabled': False}\n"
601
+ "cfg['platforms']=plats\n"
602
+ "p.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding='utf-8')\n"
603
+ f"Path('/opt/data/profiles/{prof}/.disabled').write_text('disabled by supervisor\n', encoding='utf-8')\n"
604
+ "print('ok')\n"
605
+ )
606
+ code, out = _docker_exec(GATEWAY_CONTAINER_NAME, ["/opt/hermes/.venv/bin/python", "-c", script])
607
+ if code != 0:
608
+ raise ValueError(f"disable profile config failed: {out}")
609
+ return {"ok": True, "profile": prof, "action": "disable", "state": "disabled", "api_port": port}
610
+ if action == "start":
611
+ if _profile_api_healthy(prof):
612
+ return {
613
+ "ok": True,
614
+ "profile": prof,
615
+ "action": "start",
616
+ "state": "running",
617
+ "api_port": port,
618
+ "detail": "already running",
619
+ }
620
+ ok, out, port = _configure_profile_api(prof)
621
+ if not ok:
622
+ raise ValueError(f"profile api config failed: {out}")
623
+ ok, out = _patch_profile_gateway_run_script(prof)
624
+ if not ok:
625
+ raise ValueError(f"profile api run patch failed: {out}")
626
+ state = gateway_data(prof)
627
+ if str(state.get("state") or "").lower() in {"running", "starting"}:
628
+ code, out = _gateway_exec(prof, ["gateway", "stop"])
629
+ if code != 0:
630
+ raise ValueError(f"gateway stop failed: {out}")
631
+ time.sleep(1.0)
632
+ code, out = _gateway_exec(prof, ["gateway", action])
633
+ if code != 0:
634
+ raise ValueError(f"gateway {action} failed: {out}")
635
+ if action == "start":
636
+ for _ in range(60):
637
+ if _profile_api_healthy(prof):
638
+ break
639
+ time.sleep(0.5)
640
+ else:
641
+ raise ValueError(f"profile api did not become healthy: {prof}")
642
+ state = gateway_data(prof).get("state") or action
643
+ return {"ok": True, "profile": prof, "action": action, "state": state, "api_port": port, "output": out.strip()}
644
+
645
+
646
+ def _auth_ok(handler: BaseHTTPRequestHandler) -> bool:
647
+ if not SUPERVISOR_TOKEN:
648
+ return False
649
+ auth = handler.headers.get("Authorization", "")
650
+ if auth.startswith("Bearer "):
651
+ return secrets_compare(auth[7:].strip(), SUPERVISOR_TOKEN)
652
+ return False
653
+
654
+
655
+ def secrets_compare(a: str, b: str) -> bool:
656
+ import hmac
657
+
658
+ return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
659
+
660
+
661
+ class Handler(BaseHTTPRequestHandler):
662
+ server_version = "workframe-supervisor/0.1.0"
663
+
664
+ def log_message(self, fmt: str, *args: Any) -> None:
665
+ return
666
+
667
+ def _json(self, status: int, payload: Any) -> None:
668
+ raw = json.dumps(payload).encode("utf-8")
669
+ self.send_response(status)
670
+ self.send_header("Content-Type", "application/json; charset=utf-8")
671
+ self.send_header("Content-Length", str(len(raw)))
672
+ self.end_headers()
673
+ self.wfile.write(raw)
674
+
675
+ def _read_json(self) -> dict[str, Any]:
676
+ length = int(self.headers.get("Content-Length") or 0)
677
+ if length <= 0:
678
+ return {}
679
+ raw = self.rfile.read(length)
680
+ data = json.loads(raw.decode("utf-8"))
681
+ return data if isinstance(data, dict) else {}
682
+
683
+ def do_GET(self) -> None:
684
+ parsed = urllib.parse.urlparse(self.path)
685
+ path = parsed.path
686
+ if path == "/health":
687
+ return self._json(200, {"ok": True, "service": "workframe-supervisor", "version": VERSION})
688
+ if not _auth_ok(self):
689
+ return self._json(401, {"ok": False, "error": "unauthorized"})
690
+ if path == "/v1/profile.status":
691
+ qs = urllib.parse.parse_qs(parsed.query)
692
+ profile = (qs.get("profile") or [""])[0]
693
+ if not profile:
694
+ return self._json(400, {"ok": False, "error": "profile required"})
695
+ try:
696
+ return self._json(200, profile_gateway_lifecycle(profile, "status"))
697
+ except ValueError as exc:
698
+ return self._json(400, {"ok": False, "error": str(exc)})
699
+ if path == "/v1/stack.status":
700
+ profiles = []
701
+ for row in load_routes()["routes"]:
702
+ slug = row["profile"]
703
+ state = gateway_data(slug)
704
+ profiles.append(
705
+ {
706
+ "profile": slug,
707
+ "native": slug == _primary_profile(),
708
+ "state": state.get("state") or "unknown",
709
+ "api_port": _profile_api_port(slug),
710
+ }
711
+ )
712
+ return self._json(
713
+ 200,
714
+ {"ok": True, "profiles": profiles, "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())},
715
+ )
716
+ if path == "/v1/gateway.image":
717
+ digest, ref = _gateway_image_digest()
718
+ return self._json(
719
+ 200,
720
+ {
721
+ "ok": bool(digest or ref),
722
+ "digest": digest,
723
+ "ref": ref,
724
+ "container": GATEWAY_CONTAINER_NAME,
725
+ },
726
+ )
727
+ return self._json(404, {"ok": False, "error": "not found"})
728
+
729
+ def do_POST(self) -> None:
730
+ path = urllib.parse.urlparse(self.path).path
731
+ if not _auth_ok(self):
732
+ return self._json(401, {"ok": False, "error": "unauthorized"})
733
+ try:
734
+ body = self._read_json()
735
+ except json.JSONDecodeError:
736
+ return self._json(400, {"ok": False, "error": "invalid json"})
737
+ action_map = {
738
+ "/v1/profile.start": "start",
739
+ "/v1/profile.stop": "stop",
740
+ "/v1/profile.disable": "disable",
741
+ }
742
+ if path not in action_map:
743
+ if path == "/v1/gateway.exec":
744
+ profile = str(body.get("profile") or "").strip()
745
+ args = body.get("args")
746
+ if not profile:
747
+ return self._json(400, {"ok": False, "error": "profile required"})
748
+ if not isinstance(args, list) or not args:
749
+ return self._json(400, {"ok": False, "error": "args required"})
750
+ try:
751
+ prof = resolve_hermes_profile(profile)
752
+ cli_args = [str(part) for part in args]
753
+ code, out = _gateway_exec(prof, cli_args)
754
+ return self._json(
755
+ 200,
756
+ {"ok": code == 0, "profile": prof, "exit_code": code, "output": out},
757
+ )
758
+ except ValueError as exc:
759
+ return self._json(400, {"ok": False, "error": str(exc)})
760
+ if path == "/v1/gateway.container_exec":
761
+ if os.environ.get("WORKFRAME_SUPERVISOR_ALLOW_RAW_EXEC", "0") != "1":
762
+ return self._json(403, {"ok": False, "error": "raw_container_exec_disabled"})
763
+ # ponytail: opt-in only (WORKFRAME_SUPERVISOR_ALLOW_RAW_EXEC=1); default off (0022 N1)
764
+ args = body.get("args")
765
+ if not isinstance(args, list) or not args:
766
+ return self._json(400, {"ok": False, "error": "args required"})
767
+ detach = bool(body.get("detach"))
768
+ try:
769
+ cli_args = [str(part) for part in args]
770
+ if detach:
771
+ code, out = _docker_exec_detached(GATEWAY_CONTAINER_NAME, cli_args)
772
+ else:
773
+ code, out = _docker_exec(GATEWAY_CONTAINER_NAME, cli_args)
774
+ return self._json(
775
+ 200,
776
+ {"ok": code == 0, "exit_code": code, "output": out, "detached": detach},
777
+ )
778
+ except Exception as exc: # noqa: BLE001
779
+ return self._json(500, {"ok": False, "error": str(exc)})
780
+ if path == "/v1/stack.apply":
781
+ target = str(body.get("target") or "all").strip().lower()
782
+ workframe_version = str(body.get("workframe_version") or "").strip()
783
+ try:
784
+ return self._json(200, _stack_apply(target, workframe_version=workframe_version))
785
+ except ValueError as exc:
786
+ return self._json(400, {"ok": False, "error": str(exc)})
787
+ except Exception as exc: # noqa: BLE001
788
+ return self._json(500, {"ok": False, "error": str(exc)})
789
+ if path == "/v1/hermes.device_oauth_start":
790
+ home = str(body.get("home") or "").strip()
791
+ hermes_auth_id = str(body.get("hermes_auth_id") or "").strip()
792
+ log_path = str(body.get("log_path") or "").strip()
793
+ try:
794
+ code, out = _hermes_device_oauth_start(home, hermes_auth_id, log_path)
795
+ return self._json(
796
+ 200,
797
+ {"ok": code == 0, "exit_code": code, "output": out, "detached": True},
798
+ )
799
+ except ValueError as exc:
800
+ return self._json(400, {"ok": False, "error": str(exc)})
801
+ except Exception as exc: # noqa: BLE001
802
+ return self._json(500, {"ok": False, "error": str(exc)})
803
+ if path == "/v1/host.setup_public_https":
804
+ host = str(body.get("host") or "").strip()
805
+ try:
806
+ port = int(body.get("port") or os.environ.get("WORKFRAME_UI_PORT", "18644"))
807
+ except (TypeError, ValueError):
808
+ return self._json(400, {"ok": False, "error": "invalid port"})
809
+ try:
810
+ return self._json(200, _host_setup_public_https(host, port))
811
+ except ValueError as exc:
812
+ return self._json(400, {"ok": False, "error": str(exc)})
813
+ except Exception as exc: # noqa: BLE001
814
+ return self._json(500, {"ok": False, "error": str(exc)})
815
+ if path == "/v1/host.set_compose_public_url":
816
+ public_url = str(body.get("url") or body.get("app_base_url") or "").strip()
817
+ restart = body.get("restart", True) is not False
818
+ try:
819
+ return self._json(200, _host_set_compose_public_url(public_url, restart=restart))
820
+ except ValueError as exc:
821
+ return self._json(400, {"ok": False, "error": str(exc)})
822
+ except Exception as exc: # noqa: BLE001
823
+ return self._json(500, {"ok": False, "error": str(exc)})
824
+ if path == "/v1/hermes.user_exec":
825
+ home = str(body.get("home") or "").strip()
826
+ args = body.get("args")
827
+ if not home or not home.startswith("/opt/data/profiles/"):
828
+ return self._json(400, {"ok": False, "error": "home required"})
829
+ slug = safe_profile_slug(home.rsplit("/", 1)[-1])
830
+ home = f"/opt/data/profiles/{slug}"
831
+ if not isinstance(args, list) or not args:
832
+ return self._json(400, {"ok": False, "error": "args required"})
833
+ inner = " ".join(shlex.quote(str(part)) for part in args)
834
+ shell = (
835
+ f"export HERMES_HOME={shlex.quote(home)} HOME={shlex.quote(home)}; "
836
+ f"mkdir -p {shlex.quote(home)}; cd {shlex.quote(home)}; "
837
+ f"/opt/hermes/bin/hermes {inner}"
838
+ )
839
+ code, out = _docker_exec(GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell], acting_profile=slug)
840
+ return self._json(200, {"ok": code == 0, "exit_code": code, "output": out})
841
+ return self._json(404, {"ok": False, "error": "not found"})
842
+ profile = str(body.get("profile") or "").strip()
843
+ if not profile:
844
+ return self._json(400, {"ok": False, "error": "profile required"})
845
+ try:
846
+ return self._json(200, profile_gateway_lifecycle(profile, action_map[path]))
847
+ except ValueError as exc:
848
+ return self._json(400, {"ok": False, "error": str(exc)})
849
+
850
+
851
+ def main() -> None:
852
+ if not SUPERVISOR_TOKEN:
853
+ raise SystemExit("WORKFRAME_SUPERVISOR_TOKEN is required")
854
+ server = ThreadingHTTPServer((HOST, PORT), Handler)
855
+ print(f"workframe-supervisor listening on {HOST}:{PORT}", flush=True)
856
+ server.serve_forever()
857
+
858
+
859
+ if __name__ == "__main__":
860
+ main()