create-workframe 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/NOTICE +12 -12
- package/README.md +8 -92
- package/SECURITY.md +40 -40
- package/bin/workframe.js +329 -329
- package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +1 -1
- package/docs/workspace-instructions/WORKFRAME_ROUTING.md +8 -8
- package/package.json +3 -6
- package/profiles/architect/AGENTS.md +29 -29
- package/profiles/architect/SOUL.md +2 -2
- package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/designer/AGENTS.md +26 -26
- package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/dev/AGENTS.md +28 -28
- package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/docs/AGENTS.md +27 -27
- package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/research/AGENTS.md +26 -26
- package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/visionary/AGENTS.md +25 -25
- package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/workframe-agent/AGENTS.md +37 -37
- package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
- package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
- package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
- package/rules/workspace-README.md +5 -5
- package/scripts/apply-update-hermes.sh +17 -17
- package/scripts/apply-update-workframe.sh +77 -77
- package/scripts/bootstrap-workspace-link.sh +8 -8
- package/scripts/bundle-workframe-ui.mjs +3 -3
- package/scripts/compose-docker-host.sh +37 -37
- package/scripts/ensure-compose-host-paths.mjs +51 -51
- package/scripts/fix-zk-encryption-key.sh +35 -35
- package/scripts/lib/install-identity.mjs +212 -212
- package/scripts/restart-gateway-hermes.sh +12 -12
- package/scripts/set-compose-public-url.mjs +92 -92
- package/scripts/setup-stack-secrets.sh +50 -50
- package/scripts/sync-canonical-to-package.mjs +8 -7
- package/scripts/verify-public-deploy.sh +105 -105
- package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
- package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
- package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
- package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
- package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
- package/workframe-api/README.md +28 -28
- package/workframe-api/action_proxy.py +131 -131
- package/workframe-api/auth_rate_limit.py +49 -49
- package/workframe-api/credential_vault.py +445 -445
- package/workframe-api/data/avatar-catalog.json +41 -41
- package/workframe-api/email_sender.py +220 -220
- package/workframe-api/google_auth.py +90 -90
- package/workframe-api/install_api.py +359 -359
- package/workframe-api/internal_proxy_auth.py +150 -150
- package/workframe-api/llm_proxy.py +277 -277
- package/workframe-api/oidc_jwt.py +108 -108
- package/workframe-api/package.json +12 -13
- package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
- package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
- package/workframe-api/requirements.txt +2 -2
- package/workframe-api/site_meta.py +271 -271
- package/workframe-api/stack_config.py +427 -427
- package/workframe-api/time-bind-chat.py +99 -99
- package/workframe-api/turn_credentials.py +226 -226
- package/workframe-api/updates.py +417 -417
- package/workframe-api/vault_kek.py +159 -159
- package/workframe-api/zk_auth.py +633 -633
- package/workframe-supervisor/Dockerfile +11 -11
- package/workframe-supervisor/server.py +787 -787
- package/workframe-ui/docker/nginx.conf +85 -85
- package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
- package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
- package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
- package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
- package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
- package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
- package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
- package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
- package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
- package/workframe-ui/public/favicon.svg +7 -7
- package/workframe-ui/public/index.html +50 -50
- package/workframe-ui/public/workframe-config.json +3 -3
- package/scripts/security_audit.py +0 -156
- package/scripts/test-scaffold.mjs +0 -390
- package/workframe-api/tests/__init__.py +0 -0
- package/workframe-api/tests/db_setup.py +0 -13
- package/workframe-api/tests/test_admin_updates_gated.py +0 -30
- package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
- package/workframe-api/tests/test_agent_profile_sync.py +0 -76
- package/workframe-api/tests/test_auth_email.py +0 -222
- package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
- package/workframe-api/tests/test_auth_rate_limit.py +0 -19
- package/workframe-api/tests/test_avatar_resolve.py +0 -77
- package/workframe-api/tests/test_child_soul_template.py +0 -71
- package/workframe-api/tests/test_credential_canary.py +0 -135
- package/workframe-api/tests/test_credential_isolation.py +0 -448
- package/workframe-api/tests/test_credential_resolution.py +0 -206
- package/workframe-api/tests/test_device_oauth.py +0 -108
- package/workframe-api/tests/test_doctor_repair.py +0 -103
- package/workframe-api/tests/test_ensure_profile_api.py +0 -77
- package/workframe-api/tests/test_gateway_compose_security.py +0 -136
- package/workframe-api/tests/test_install_secure_host.py +0 -39
- package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
- package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
- package/workframe-api/tests/test_kanban_delegation.py +0 -185
- package/workframe-api/tests/test_llm_proxy.py +0 -155
- package/workframe-api/tests/test_login_access_policy.py +0 -183
- package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
- package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
- package/workframe-api/tests/test_platform_auth.py +0 -47
- package/workframe-api/tests/test_profile_config_path.py +0 -56
- package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
- package/workframe-api/tests/test_profile_create.py +0 -72
- package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
- package/workframe-api/tests/test_profile_install_health.py +0 -45
- package/workframe-api/tests/test_profile_secret_policy.py +0 -57
- package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
- package/workframe-api/tests/test_provider_bootstrap.py +0 -75
- package/workframe-api/tests/test_provider_connect.py +0 -54
- package/workframe-api/tests/test_room_crud.py +0 -192
- package/workframe-api/tests/test_room_tenancy.py +0 -701
- package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
- package/workframe-api/tests/test_site_meta.py +0 -81
- package/workframe-api/tests/test_soul_stub.py +0 -42
- package/workframe-api/tests/test_space_member_sync.py +0 -99
- package/workframe-api/tests/test_stripe_stack_config.py +0 -37
- package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
- package/workframe-api/tests/test_turn_credential_vault.py +0 -125
- package/workframe-api/tests/test_updates.py +0 -176
- package/workframe-api/tests/test_user_cohort.py +0 -113
- package/workframe-api/tests/test_vault_envelope.py +0 -110
- package/workframe-api/tests/test_workspace_members.py +0 -183
- package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
- package/workframe-api/tests/test_workspace_provider_list.py +0 -57
- package/workframe-supervisor/tests/test_exec_guard.py +0 -42
- package/workframe-supervisor/tests/test_server_import.py +0 -21
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
- package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
- package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B89jAMFF.js +0 -1
package/workframe-api/updates.py
CHANGED
|
@@ -1,417 +1,417 @@
|
|
|
1
|
-
"""Admin stack updates — version checks + safe in-place apply (preserves runtime/DB)."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import re
|
|
7
|
-
import subprocess
|
|
8
|
-
import urllib.error
|
|
9
|
-
import urllib.parse
|
|
10
|
-
import urllib.request
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Any
|
|
13
|
-
|
|
14
|
-
HERMES_IMAGE = os.environ.get("WORKFRAME_HERMES_IMAGE", "nousresearch/hermes-agent")
|
|
15
|
-
HERMES_TAG = os.environ.get("WORKFRAME_HERMES_TAG", "latest")
|
|
16
|
-
NPM_PACKAGE = os.environ.get("WORKFRAME_NPM_PACKAGE", "create-workframe")
|
|
17
|
-
RELEASES_URL = str(os.environ.get("WORKFRAME_RELEASES_URL", "")).strip()
|
|
18
|
-
DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
|
|
19
|
-
GATEWAY_CONTAINER = os.environ.get("WORKFRAME_GATEWAY_CONTAINER", "workframe-gateway")
|
|
20
|
-
API_VERSION = str(os.environ.get("WORKFRAME_API_VERSION", "")).strip()
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _version_tuple(raw: str) -> tuple[int, ...]:
|
|
24
|
-
text = re.sub(r"^workframe-api-", "", str(raw or "").strip())
|
|
25
|
-
nums: list[int] = []
|
|
26
|
-
for part in re.split(r"[.+_-]", text):
|
|
27
|
-
if part.isdigit():
|
|
28
|
-
nums.append(int(part))
|
|
29
|
-
elif nums:
|
|
30
|
-
break
|
|
31
|
-
return tuple(nums)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _version_lt(current: str, latest: str) -> bool:
|
|
35
|
-
cur = str(current or "").strip()
|
|
36
|
-
lat = str(latest or "").strip()
|
|
37
|
-
if not lat:
|
|
38
|
-
return False
|
|
39
|
-
if not cur:
|
|
40
|
-
return True
|
|
41
|
-
return _version_tuple(cur) < _version_tuple(lat)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _http_json(url: str, timeout: float = 12.0) -> dict[str, Any]:
|
|
45
|
-
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "workframe-api"})
|
|
46
|
-
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
47
|
-
data = json.loads(resp.read().decode("utf-8"))
|
|
48
|
-
return data if isinstance(data, dict) else {}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _npm_latest_version() -> str:
|
|
52
|
-
data = _http_json(f"https://registry.npmjs.org/{urllib.parse.quote(NPM_PACKAGE)}/latest")
|
|
53
|
-
return str(data.get("version") or "").strip()
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _docker_hub_digest(repo: str, tag: str) -> str:
|
|
57
|
-
url = f"https://hub.docker.com/v2/repositories/{repo}/tags/{urllib.parse.quote(tag)}"
|
|
58
|
-
data = _http_json(url)
|
|
59
|
-
# ponytail: tag digest matches docker pull :tag RepoDigests; images[0] may be arm64 on multi-arch repos
|
|
60
|
-
top = str(data.get("digest") or "").strip()
|
|
61
|
-
if top:
|
|
62
|
-
return top
|
|
63
|
-
for entry in data.get("images") or []:
|
|
64
|
-
if not isinstance(entry, dict) or not entry.get("digest"):
|
|
65
|
-
continue
|
|
66
|
-
if entry.get("architecture") == "amd64" and entry.get("os") == "linux":
|
|
67
|
-
return str(entry["digest"]).strip()
|
|
68
|
-
for entry in data.get("images") or []:
|
|
69
|
-
if isinstance(entry, dict) and entry.get("digest"):
|
|
70
|
-
return str(entry["digest"]).strip()
|
|
71
|
-
return ""
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _docker_sock_request(method: str, path: str, body: bytes | None = None) -> tuple[int, Any]:
|
|
75
|
-
import http.client
|
|
76
|
-
import socket as pysocket
|
|
77
|
-
|
|
78
|
-
if not Path(DOCKER_SOCK).exists():
|
|
79
|
-
return 0, {"error": "docker_socket_missing"}
|
|
80
|
-
conn = http.client.HTTPConnection("localhost", timeout=120)
|
|
81
|
-
conn.sock = pysocket.socket(pysocket.AF_UNIX, pysocket.SOCK_STREAM)
|
|
82
|
-
conn.sock.connect(DOCKER_SOCK)
|
|
83
|
-
headers = {"Content-Type": "application/json"} if body else {}
|
|
84
|
-
conn.request(method, path, body=body, headers=headers)
|
|
85
|
-
resp = conn.getresponse()
|
|
86
|
-
raw = resp.read()
|
|
87
|
-
conn.close()
|
|
88
|
-
if not raw:
|
|
89
|
-
return resp.status, {}
|
|
90
|
-
try:
|
|
91
|
-
return resp.status, json.loads(raw.decode("utf-8"))
|
|
92
|
-
except json.JSONDecodeError:
|
|
93
|
-
return resp.status, raw.decode("utf-8", errors="replace")
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _container_image_digest(name: str) -> tuple[str, str]:
|
|
97
|
-
status, data = _docker_sock_request("GET", f"/containers/{name}/json")
|
|
98
|
-
if status != 200 or not isinstance(data, dict):
|
|
99
|
-
return "", ""
|
|
100
|
-
image_id = str(data.get("Image") or "")
|
|
101
|
-
ist, idata = _docker_sock_request("GET", f"/images/{image_id}/json")
|
|
102
|
-
digest = ""
|
|
103
|
-
ref = HERMES_IMAGE
|
|
104
|
-
if ist == 200 and isinstance(idata, dict):
|
|
105
|
-
digests = idata.get("RepoDigests") or []
|
|
106
|
-
if digests:
|
|
107
|
-
digest = str(digests[0]).split("@")[-1]
|
|
108
|
-
tags = idata.get("RepoTags") or []
|
|
109
|
-
if tags:
|
|
110
|
-
ref = str(tags[0])
|
|
111
|
-
return digest, ref
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _read_installed_workframe_version(project_root: Path) -> dict[str, str]:
|
|
115
|
-
out = {"api": API_VERSION, "package": "", "manifest_generator": ""}
|
|
116
|
-
manifest = project_root / "workframe-manifest.json"
|
|
117
|
-
if manifest.is_file():
|
|
118
|
-
try:
|
|
119
|
-
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
120
|
-
out["package"] = str(data.get("package_version") or "")
|
|
121
|
-
out["manifest_generator"] = str(data.get("generator") or "")
|
|
122
|
-
except Exception: # noqa: BLE001
|
|
123
|
-
pass
|
|
124
|
-
if not out["api"]:
|
|
125
|
-
try:
|
|
126
|
-
import server as _server # noqa: WPS433
|
|
127
|
-
|
|
128
|
-
out["api"] = str(getattr(_server, "VERSION", ""))
|
|
129
|
-
except Exception: # noqa: BLE001
|
|
130
|
-
pass
|
|
131
|
-
if not out["package"]:
|
|
132
|
-
out["package"] = out["api"]
|
|
133
|
-
return out
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _compose_dir() -> Path:
|
|
137
|
-
for raw in (
|
|
138
|
-
os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", ""),
|
|
139
|
-
os.environ.get("WORKFRAME_COMPOSE_DIR", ""),
|
|
140
|
-
os.environ.get("WORKFRAME_PROJECT_ROOT", ""),
|
|
141
|
-
"/compose",
|
|
142
|
-
"/project",
|
|
143
|
-
):
|
|
144
|
-
p = Path(str(raw or "").strip())
|
|
145
|
-
if p.is_dir() and (p / "docker-compose.yml").is_file():
|
|
146
|
-
return p
|
|
147
|
-
return Path(".")
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def _project_root() -> Path:
|
|
151
|
-
for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
|
|
152
|
-
p = Path(str(raw or "").strip())
|
|
153
|
-
if p.is_dir() and (p / "workframe-manifest.json").is_file():
|
|
154
|
-
return p
|
|
155
|
-
for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
|
|
156
|
-
p = Path(str(raw or "").strip())
|
|
157
|
-
if p.is_dir() and (p / "docker-compose.yml").is_file():
|
|
158
|
-
return p
|
|
159
|
-
return _compose_dir()
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _script_path(name: str) -> Path | None:
|
|
163
|
-
roots = [
|
|
164
|
-
Path(f"/opt/install/scripts/{name}"),
|
|
165
|
-
Path(f"/opt/install/scripts/workframe/{name}"),
|
|
166
|
-
]
|
|
167
|
-
mode = str(os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "trusted_team").strip().lower()
|
|
168
|
-
if mode == "single_user_local":
|
|
169
|
-
roots.extend(
|
|
170
|
-
[
|
|
171
|
-
_project_root() / "scripts" / "workframe" / name,
|
|
172
|
-
_project_root() / "scripts" / name,
|
|
173
|
-
],
|
|
174
|
-
)
|
|
175
|
-
for path in roots:
|
|
176
|
-
if path.is_file():
|
|
177
|
-
return path
|
|
178
|
-
return None
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def _host_compose_ready() -> bool:
|
|
182
|
-
host_raw = str(os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", "")).strip()
|
|
183
|
-
if not host_raw:
|
|
184
|
-
return False
|
|
185
|
-
host = Path(host_raw)
|
|
186
|
-
if host.is_dir() and (host / "docker-compose.yml").is_file():
|
|
187
|
-
return True
|
|
188
|
-
# ponytail: Windows host paths are not visible inside the API container — trust /compose mount
|
|
189
|
-
compose = _compose_dir()
|
|
190
|
-
return compose.joinpath("docker-compose.yml").is_file()
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def _docker_apply_ready() -> tuple[bool, str | None]:
|
|
194
|
-
if not Path(DOCKER_SOCK).exists():
|
|
195
|
-
return False, "Docker socket is not available to the API container."
|
|
196
|
-
if not _compose_dir().joinpath("docker-compose.yml").is_file():
|
|
197
|
-
return False, "docker-compose.yml was not found for this stack."
|
|
198
|
-
if not _host_compose_ready():
|
|
199
|
-
return False, (
|
|
200
|
-
"Set WORKFRAME_HOST_COMPOSE_DIR to the host compose folder so updates run on the Docker host."
|
|
201
|
-
)
|
|
202
|
-
return True, None
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def _product_state(*, update_available: bool, can_update: bool) -> str:
|
|
206
|
-
if update_available and can_update:
|
|
207
|
-
return "available"
|
|
208
|
-
if update_available:
|
|
209
|
-
return "blocked"
|
|
210
|
-
return "current"
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
def parse_hermes_version_output(text: str) -> str:
|
|
214
|
-
"""Extract semver from `hermes --version` stdout."""
|
|
215
|
-
match = re.search(r"Hermes Agent v(\d+\.\d+\.\d+)", str(text or ""))
|
|
216
|
-
return match.group(1) if match else ""
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def _read_hermes_agent_version() -> str:
|
|
220
|
-
"""Native Hermes semver via gateway exec (lazy import avoids server load cycle)."""
|
|
221
|
-
try:
|
|
222
|
-
import server as _server # noqa: WPS433
|
|
223
|
-
|
|
224
|
-
return _server._hermes_agent_version()
|
|
225
|
-
except Exception: # noqa: BLE001
|
|
226
|
-
return ""
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def _releases_manifest() -> dict[str, Any]:
|
|
230
|
-
if not RELEASES_URL:
|
|
231
|
-
return {}
|
|
232
|
-
try:
|
|
233
|
-
return _http_json(RELEASES_URL)
|
|
234
|
-
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
|
|
235
|
-
return {}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def updates_available(*, desktop_version: str = "", hermes_agent_version: str = "") -> dict[str, Any]:
|
|
239
|
-
compose_dir = _compose_dir()
|
|
240
|
-
project_root = _project_root()
|
|
241
|
-
docker_ok = Path(DOCKER_SOCK).exists()
|
|
242
|
-
installed = _read_installed_workframe_version(project_root)
|
|
243
|
-
|
|
244
|
-
npm_latest = ""
|
|
245
|
-
try:
|
|
246
|
-
npm_latest = _npm_latest_version()
|
|
247
|
-
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
|
248
|
-
pass
|
|
249
|
-
|
|
250
|
-
releases = _releases_manifest()
|
|
251
|
-
workframe_latest = str(releases.get("workframe") or releases.get("create_workframe") or npm_latest or "")
|
|
252
|
-
desktop_latest = str(releases.get("desktop") or os.environ.get("WORKFRAME_DESKTOP_LATEST", "0.1.0"))
|
|
253
|
-
|
|
254
|
-
installed_pkg = installed.get("package") or installed.get("api") or ""
|
|
255
|
-
workframe_update = bool(workframe_latest and _version_lt(installed_pkg, workframe_latest))
|
|
256
|
-
|
|
257
|
-
hermes_digest, hermes_ref = _container_image_digest(GATEWAY_CONTAINER)
|
|
258
|
-
hermes_tag = hermes_ref.rsplit(":", 1)[-1] if hermes_ref and ":" in hermes_ref else HERMES_TAG
|
|
259
|
-
hermes_latest_digest = ""
|
|
260
|
-
try:
|
|
261
|
-
hermes_latest_digest = _docker_hub_digest(HERMES_IMAGE, HERMES_TAG)
|
|
262
|
-
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
|
263
|
-
pass
|
|
264
|
-
hermes_update = bool(
|
|
265
|
-
docker_ok
|
|
266
|
-
and hermes_latest_digest
|
|
267
|
-
and hermes_digest
|
|
268
|
-
and hermes_digest != hermes_latest_digest,
|
|
269
|
-
)
|
|
270
|
-
|
|
271
|
-
desktop_installed = str(desktop_version or "").strip()
|
|
272
|
-
desktop_update = bool(desktop_latest and desktop_installed and _version_lt(desktop_installed, desktop_latest))
|
|
273
|
-
|
|
274
|
-
digest_short = hermes_latest_digest
|
|
275
|
-
if len(digest_short) > 28:
|
|
276
|
-
digest_short = digest_short[:28] + "…"
|
|
277
|
-
|
|
278
|
-
docker_apply_ok, docker_apply_reason = _docker_apply_ready()
|
|
279
|
-
hermes_script_ok = _script_path("apply-update-hermes.sh") is not None
|
|
280
|
-
workframe_script_ok = _script_path("apply-update-workframe.sh") is not None
|
|
281
|
-
hermes_can_update = bool(docker_apply_ok and hermes_script_ok)
|
|
282
|
-
workframe_can_update = bool(docker_apply_ok and workframe_script_ok)
|
|
283
|
-
hermes_reason = docker_apply_reason
|
|
284
|
-
if not hermes_reason and hermes_update and not hermes_script_ok:
|
|
285
|
-
hermes_reason = "Hermes update script is missing from this install."
|
|
286
|
-
workframe_reason = docker_apply_reason
|
|
287
|
-
if not workframe_reason and workframe_update and not workframe_script_ok:
|
|
288
|
-
workframe_reason = "Workframe update script is missing from this install."
|
|
289
|
-
if not workframe_reason and workframe_update and not workframe_latest:
|
|
290
|
-
workframe_reason = "No published npm release to update to yet."
|
|
291
|
-
|
|
292
|
-
agent_version = str(hermes_agent_version or "").strip() or _read_hermes_agent_version()
|
|
293
|
-
hermes_current = agent_version or hermes_tag
|
|
294
|
-
|
|
295
|
-
return {
|
|
296
|
-
"ok": True,
|
|
297
|
-
"docker_available": docker_ok,
|
|
298
|
-
"compose_dir": str(compose_dir),
|
|
299
|
-
"project_root": str(project_root),
|
|
300
|
-
"workframe": {
|
|
301
|
-
"current": installed_pkg,
|
|
302
|
-
"latest": workframe_latest,
|
|
303
|
-
"update_available": workframe_update,
|
|
304
|
-
"can_update": workframe_can_update,
|
|
305
|
-
"state": _product_state(update_available=workframe_update, can_update=workframe_can_update),
|
|
306
|
-
"reason": workframe_reason,
|
|
307
|
-
"update_mode": "docker-compose-rebuild",
|
|
308
|
-
"install_kind": "docker",
|
|
309
|
-
"components": ["ui", "api", "supervisor"],
|
|
310
|
-
},
|
|
311
|
-
"hermes": {
|
|
312
|
-
"current": hermes_current,
|
|
313
|
-
"agent_version": agent_version,
|
|
314
|
-
"image_tag": hermes_tag,
|
|
315
|
-
"latest": "",
|
|
316
|
-
"current_image": hermes_ref,
|
|
317
|
-
"current_digest": hermes_digest[:28] + "…" if len(hermes_digest) > 28 else hermes_digest,
|
|
318
|
-
"latest_digest": digest_short,
|
|
319
|
-
"image": f"{HERMES_IMAGE}:{HERMES_TAG}",
|
|
320
|
-
"update_available": hermes_update,
|
|
321
|
-
"can_update": hermes_can_update,
|
|
322
|
-
"state": _product_state(update_available=hermes_update, can_update=hermes_can_update),
|
|
323
|
-
"reason": hermes_reason,
|
|
324
|
-
"update_mode": "docker-compose-pull",
|
|
325
|
-
"install_kind": "docker",
|
|
326
|
-
"can_restart_gateway": bool(docker_apply_ok and _script_path("restart-gateway-hermes.sh") is not None),
|
|
327
|
-
},
|
|
328
|
-
"desktop": {
|
|
329
|
-
"current": desktop_installed,
|
|
330
|
-
"latest": desktop_latest,
|
|
331
|
-
"update_available": desktop_update,
|
|
332
|
-
"can_update": False,
|
|
333
|
-
"state": "available" if desktop_update else "current",
|
|
334
|
-
"reason": "Desktop updates are distributed separately from the Docker stack.",
|
|
335
|
-
"update_mode": "manual-download",
|
|
336
|
-
"install_kind": "desktop",
|
|
337
|
-
"download_url": str(releases.get("desktop_download_url") or ""),
|
|
338
|
-
},
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def apply_update(target: str) -> dict[str, Any]:
|
|
343
|
-
if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") != "1":
|
|
344
|
-
raise ValueError("admin_updates_disabled")
|
|
345
|
-
target = str(target or "all").strip().lower()
|
|
346
|
-
if target not in {"hermes", "workframe", "all"}:
|
|
347
|
-
raise ValueError("invalid_update_target")
|
|
348
|
-
if not Path(DOCKER_SOCK).exists():
|
|
349
|
-
raise ValueError("docker_unavailable")
|
|
350
|
-
|
|
351
|
-
scripts: list[str] = []
|
|
352
|
-
if target in {"hermes", "all"}:
|
|
353
|
-
script = _script_path("apply-update-hermes.sh")
|
|
354
|
-
if not script:
|
|
355
|
-
raise ValueError("update_script_missing:hermes")
|
|
356
|
-
scripts.append(str(script))
|
|
357
|
-
if target in {"workframe", "all"}:
|
|
358
|
-
script = _script_path("apply-update-workframe.sh")
|
|
359
|
-
if not script:
|
|
360
|
-
raise ValueError("update_script_missing:workframe")
|
|
361
|
-
scripts.append(str(script))
|
|
362
|
-
|
|
363
|
-
env = os.environ.copy()
|
|
364
|
-
env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
|
|
365
|
-
env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
|
|
366
|
-
|
|
367
|
-
logs: list[str] = []
|
|
368
|
-
for script in scripts:
|
|
369
|
-
proc = subprocess.run(
|
|
370
|
-
["bash", script],
|
|
371
|
-
capture_output=True,
|
|
372
|
-
text=True,
|
|
373
|
-
timeout=900,
|
|
374
|
-
env=env,
|
|
375
|
-
cwd=env["WORKFRAME_COMPOSE_DIR"],
|
|
376
|
-
)
|
|
377
|
-
logs.append(f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}")
|
|
378
|
-
if proc.returncode != 0:
|
|
379
|
-
raise ValueError(f"update_failed:{Path(script).name}")
|
|
380
|
-
|
|
381
|
-
return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
def restart_gateway() -> dict[str, Any]:
|
|
385
|
-
if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") != "1":
|
|
386
|
-
raise ValueError("admin_updates_disabled")
|
|
387
|
-
if not Path(DOCKER_SOCK).exists():
|
|
388
|
-
raise ValueError("docker_unavailable")
|
|
389
|
-
docker_apply_ok, docker_apply_reason = _docker_apply_ready()
|
|
390
|
-
if not docker_apply_ok:
|
|
391
|
-
raise ValueError(str(docker_apply_reason or "docker_apply_unavailable"))
|
|
392
|
-
script = _script_path("restart-gateway-hermes.sh")
|
|
393
|
-
if not script:
|
|
394
|
-
raise ValueError("restart_script_missing:gateway")
|
|
395
|
-
|
|
396
|
-
env = os.environ.copy()
|
|
397
|
-
env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
|
|
398
|
-
env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
|
|
399
|
-
proc = subprocess.run(
|
|
400
|
-
["bash", str(script)],
|
|
401
|
-
capture_output=True,
|
|
402
|
-
text=True,
|
|
403
|
-
timeout=300,
|
|
404
|
-
env=env,
|
|
405
|
-
cwd=env["WORKFRAME_COMPOSE_DIR"],
|
|
406
|
-
)
|
|
407
|
-
log = f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
|
|
408
|
-
if proc.returncode != 0:
|
|
409
|
-
raise ValueError("restart_failed:gateway")
|
|
410
|
-
return {"ok": True, "target": "gateway", "log": log[-12000:]}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if __name__ == "__main__":
|
|
414
|
-
assert _version_lt("0.1.0", "0.1.1")
|
|
415
|
-
assert not _version_lt("0.1.0", "0.1.0")
|
|
416
|
-
assert parse_hermes_version_output("Hermes Agent v0.17.0 (2026.6.19)") == "0.17.0"
|
|
417
|
-
print("updates module ok")
|
|
1
|
+
"""Admin stack updates — version checks + safe in-place apply (preserves runtime/DB)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
HERMES_IMAGE = os.environ.get("WORKFRAME_HERMES_IMAGE", "nousresearch/hermes-agent")
|
|
15
|
+
HERMES_TAG = os.environ.get("WORKFRAME_HERMES_TAG", "latest")
|
|
16
|
+
NPM_PACKAGE = os.environ.get("WORKFRAME_NPM_PACKAGE", "create-workframe")
|
|
17
|
+
RELEASES_URL = str(os.environ.get("WORKFRAME_RELEASES_URL", "")).strip()
|
|
18
|
+
DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
|
|
19
|
+
GATEWAY_CONTAINER = os.environ.get("WORKFRAME_GATEWAY_CONTAINER", "workframe-gateway")
|
|
20
|
+
API_VERSION = str(os.environ.get("WORKFRAME_API_VERSION", "")).strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _version_tuple(raw: str) -> tuple[int, ...]:
|
|
24
|
+
text = re.sub(r"^workframe-api-", "", str(raw or "").strip())
|
|
25
|
+
nums: list[int] = []
|
|
26
|
+
for part in re.split(r"[.+_-]", text):
|
|
27
|
+
if part.isdigit():
|
|
28
|
+
nums.append(int(part))
|
|
29
|
+
elif nums:
|
|
30
|
+
break
|
|
31
|
+
return tuple(nums)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _version_lt(current: str, latest: str) -> bool:
|
|
35
|
+
cur = str(current or "").strip()
|
|
36
|
+
lat = str(latest or "").strip()
|
|
37
|
+
if not lat:
|
|
38
|
+
return False
|
|
39
|
+
if not cur:
|
|
40
|
+
return True
|
|
41
|
+
return _version_tuple(cur) < _version_tuple(lat)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _http_json(url: str, timeout: float = 12.0) -> dict[str, Any]:
|
|
45
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "workframe-api"})
|
|
46
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
47
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
48
|
+
return data if isinstance(data, dict) else {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _npm_latest_version() -> str:
|
|
52
|
+
data = _http_json(f"https://registry.npmjs.org/{urllib.parse.quote(NPM_PACKAGE)}/latest")
|
|
53
|
+
return str(data.get("version") or "").strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _docker_hub_digest(repo: str, tag: str) -> str:
|
|
57
|
+
url = f"https://hub.docker.com/v2/repositories/{repo}/tags/{urllib.parse.quote(tag)}"
|
|
58
|
+
data = _http_json(url)
|
|
59
|
+
# ponytail: tag digest matches docker pull :tag RepoDigests; images[0] may be arm64 on multi-arch repos
|
|
60
|
+
top = str(data.get("digest") or "").strip()
|
|
61
|
+
if top:
|
|
62
|
+
return top
|
|
63
|
+
for entry in data.get("images") or []:
|
|
64
|
+
if not isinstance(entry, dict) or not entry.get("digest"):
|
|
65
|
+
continue
|
|
66
|
+
if entry.get("architecture") == "amd64" and entry.get("os") == "linux":
|
|
67
|
+
return str(entry["digest"]).strip()
|
|
68
|
+
for entry in data.get("images") or []:
|
|
69
|
+
if isinstance(entry, dict) and entry.get("digest"):
|
|
70
|
+
return str(entry["digest"]).strip()
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _docker_sock_request(method: str, path: str, body: bytes | None = None) -> tuple[int, Any]:
|
|
75
|
+
import http.client
|
|
76
|
+
import socket as pysocket
|
|
77
|
+
|
|
78
|
+
if not Path(DOCKER_SOCK).exists():
|
|
79
|
+
return 0, {"error": "docker_socket_missing"}
|
|
80
|
+
conn = http.client.HTTPConnection("localhost", timeout=120)
|
|
81
|
+
conn.sock = pysocket.socket(pysocket.AF_UNIX, pysocket.SOCK_STREAM)
|
|
82
|
+
conn.sock.connect(DOCKER_SOCK)
|
|
83
|
+
headers = {"Content-Type": "application/json"} if body else {}
|
|
84
|
+
conn.request(method, path, body=body, headers=headers)
|
|
85
|
+
resp = conn.getresponse()
|
|
86
|
+
raw = resp.read()
|
|
87
|
+
conn.close()
|
|
88
|
+
if not raw:
|
|
89
|
+
return resp.status, {}
|
|
90
|
+
try:
|
|
91
|
+
return resp.status, json.loads(raw.decode("utf-8"))
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
return resp.status, raw.decode("utf-8", errors="replace")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _container_image_digest(name: str) -> tuple[str, str]:
|
|
97
|
+
status, data = _docker_sock_request("GET", f"/containers/{name}/json")
|
|
98
|
+
if status != 200 or not isinstance(data, dict):
|
|
99
|
+
return "", ""
|
|
100
|
+
image_id = str(data.get("Image") or "")
|
|
101
|
+
ist, idata = _docker_sock_request("GET", f"/images/{image_id}/json")
|
|
102
|
+
digest = ""
|
|
103
|
+
ref = HERMES_IMAGE
|
|
104
|
+
if ist == 200 and isinstance(idata, dict):
|
|
105
|
+
digests = idata.get("RepoDigests") or []
|
|
106
|
+
if digests:
|
|
107
|
+
digest = str(digests[0]).split("@")[-1]
|
|
108
|
+
tags = idata.get("RepoTags") or []
|
|
109
|
+
if tags:
|
|
110
|
+
ref = str(tags[0])
|
|
111
|
+
return digest, ref
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _read_installed_workframe_version(project_root: Path) -> dict[str, str]:
|
|
115
|
+
out = {"api": API_VERSION, "package": "", "manifest_generator": ""}
|
|
116
|
+
manifest = project_root / "workframe-manifest.json"
|
|
117
|
+
if manifest.is_file():
|
|
118
|
+
try:
|
|
119
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
120
|
+
out["package"] = str(data.get("package_version") or "")
|
|
121
|
+
out["manifest_generator"] = str(data.get("generator") or "")
|
|
122
|
+
except Exception: # noqa: BLE001
|
|
123
|
+
pass
|
|
124
|
+
if not out["api"]:
|
|
125
|
+
try:
|
|
126
|
+
import server as _server # noqa: WPS433
|
|
127
|
+
|
|
128
|
+
out["api"] = str(getattr(_server, "VERSION", ""))
|
|
129
|
+
except Exception: # noqa: BLE001
|
|
130
|
+
pass
|
|
131
|
+
if not out["package"]:
|
|
132
|
+
out["package"] = out["api"]
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _compose_dir() -> Path:
|
|
137
|
+
for raw in (
|
|
138
|
+
os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", ""),
|
|
139
|
+
os.environ.get("WORKFRAME_COMPOSE_DIR", ""),
|
|
140
|
+
os.environ.get("WORKFRAME_PROJECT_ROOT", ""),
|
|
141
|
+
"/compose",
|
|
142
|
+
"/project",
|
|
143
|
+
):
|
|
144
|
+
p = Path(str(raw or "").strip())
|
|
145
|
+
if p.is_dir() and (p / "docker-compose.yml").is_file():
|
|
146
|
+
return p
|
|
147
|
+
return Path(".")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _project_root() -> Path:
|
|
151
|
+
for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
|
|
152
|
+
p = Path(str(raw or "").strip())
|
|
153
|
+
if p.is_dir() and (p / "workframe-manifest.json").is_file():
|
|
154
|
+
return p
|
|
155
|
+
for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
|
|
156
|
+
p = Path(str(raw or "").strip())
|
|
157
|
+
if p.is_dir() and (p / "docker-compose.yml").is_file():
|
|
158
|
+
return p
|
|
159
|
+
return _compose_dir()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _script_path(name: str) -> Path | None:
|
|
163
|
+
roots = [
|
|
164
|
+
Path(f"/opt/install/scripts/{name}"),
|
|
165
|
+
Path(f"/opt/install/scripts/workframe/{name}"),
|
|
166
|
+
]
|
|
167
|
+
mode = str(os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "trusted_team").strip().lower()
|
|
168
|
+
if mode == "single_user_local":
|
|
169
|
+
roots.extend(
|
|
170
|
+
[
|
|
171
|
+
_project_root() / "scripts" / "workframe" / name,
|
|
172
|
+
_project_root() / "scripts" / name,
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
for path in roots:
|
|
176
|
+
if path.is_file():
|
|
177
|
+
return path
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _host_compose_ready() -> bool:
|
|
182
|
+
host_raw = str(os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", "")).strip()
|
|
183
|
+
if not host_raw:
|
|
184
|
+
return False
|
|
185
|
+
host = Path(host_raw)
|
|
186
|
+
if host.is_dir() and (host / "docker-compose.yml").is_file():
|
|
187
|
+
return True
|
|
188
|
+
# ponytail: Windows host paths are not visible inside the API container — trust /compose mount
|
|
189
|
+
compose = _compose_dir()
|
|
190
|
+
return compose.joinpath("docker-compose.yml").is_file()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _docker_apply_ready() -> tuple[bool, str | None]:
|
|
194
|
+
if not Path(DOCKER_SOCK).exists():
|
|
195
|
+
return False, "Docker socket is not available to the API container."
|
|
196
|
+
if not _compose_dir().joinpath("docker-compose.yml").is_file():
|
|
197
|
+
return False, "docker-compose.yml was not found for this stack."
|
|
198
|
+
if not _host_compose_ready():
|
|
199
|
+
return False, (
|
|
200
|
+
"Set WORKFRAME_HOST_COMPOSE_DIR to the host compose folder so updates run on the Docker host."
|
|
201
|
+
)
|
|
202
|
+
return True, None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _product_state(*, update_available: bool, can_update: bool) -> str:
|
|
206
|
+
if update_available and can_update:
|
|
207
|
+
return "available"
|
|
208
|
+
if update_available:
|
|
209
|
+
return "blocked"
|
|
210
|
+
return "current"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def parse_hermes_version_output(text: str) -> str:
|
|
214
|
+
"""Extract semver from `hermes --version` stdout."""
|
|
215
|
+
match = re.search(r"Hermes Agent v(\d+\.\d+\.\d+)", str(text or ""))
|
|
216
|
+
return match.group(1) if match else ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _read_hermes_agent_version() -> str:
|
|
220
|
+
"""Native Hermes semver via gateway exec (lazy import avoids server load cycle)."""
|
|
221
|
+
try:
|
|
222
|
+
import server as _server # noqa: WPS433
|
|
223
|
+
|
|
224
|
+
return _server._hermes_agent_version()
|
|
225
|
+
except Exception: # noqa: BLE001
|
|
226
|
+
return ""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _releases_manifest() -> dict[str, Any]:
|
|
230
|
+
if not RELEASES_URL:
|
|
231
|
+
return {}
|
|
232
|
+
try:
|
|
233
|
+
return _http_json(RELEASES_URL)
|
|
234
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
|
|
235
|
+
return {}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def updates_available(*, desktop_version: str = "", hermes_agent_version: str = "") -> dict[str, Any]:
|
|
239
|
+
compose_dir = _compose_dir()
|
|
240
|
+
project_root = _project_root()
|
|
241
|
+
docker_ok = Path(DOCKER_SOCK).exists()
|
|
242
|
+
installed = _read_installed_workframe_version(project_root)
|
|
243
|
+
|
|
244
|
+
npm_latest = ""
|
|
245
|
+
try:
|
|
246
|
+
npm_latest = _npm_latest_version()
|
|
247
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
releases = _releases_manifest()
|
|
251
|
+
workframe_latest = str(releases.get("workframe") or releases.get("create_workframe") or npm_latest or "")
|
|
252
|
+
desktop_latest = str(releases.get("desktop") or os.environ.get("WORKFRAME_DESKTOP_LATEST", "0.1.0"))
|
|
253
|
+
|
|
254
|
+
installed_pkg = installed.get("package") or installed.get("api") or ""
|
|
255
|
+
workframe_update = bool(workframe_latest and _version_lt(installed_pkg, workframe_latest))
|
|
256
|
+
|
|
257
|
+
hermes_digest, hermes_ref = _container_image_digest(GATEWAY_CONTAINER)
|
|
258
|
+
hermes_tag = hermes_ref.rsplit(":", 1)[-1] if hermes_ref and ":" in hermes_ref else HERMES_TAG
|
|
259
|
+
hermes_latest_digest = ""
|
|
260
|
+
try:
|
|
261
|
+
hermes_latest_digest = _docker_hub_digest(HERMES_IMAGE, HERMES_TAG)
|
|
262
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
|
|
263
|
+
pass
|
|
264
|
+
hermes_update = bool(
|
|
265
|
+
docker_ok
|
|
266
|
+
and hermes_latest_digest
|
|
267
|
+
and hermes_digest
|
|
268
|
+
and hermes_digest != hermes_latest_digest,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
desktop_installed = str(desktop_version or "").strip()
|
|
272
|
+
desktop_update = bool(desktop_latest and desktop_installed and _version_lt(desktop_installed, desktop_latest))
|
|
273
|
+
|
|
274
|
+
digest_short = hermes_latest_digest
|
|
275
|
+
if len(digest_short) > 28:
|
|
276
|
+
digest_short = digest_short[:28] + "…"
|
|
277
|
+
|
|
278
|
+
docker_apply_ok, docker_apply_reason = _docker_apply_ready()
|
|
279
|
+
hermes_script_ok = _script_path("apply-update-hermes.sh") is not None
|
|
280
|
+
workframe_script_ok = _script_path("apply-update-workframe.sh") is not None
|
|
281
|
+
hermes_can_update = bool(docker_apply_ok and hermes_script_ok)
|
|
282
|
+
workframe_can_update = bool(docker_apply_ok and workframe_script_ok)
|
|
283
|
+
hermes_reason = docker_apply_reason
|
|
284
|
+
if not hermes_reason and hermes_update and not hermes_script_ok:
|
|
285
|
+
hermes_reason = "Hermes update script is missing from this install."
|
|
286
|
+
workframe_reason = docker_apply_reason
|
|
287
|
+
if not workframe_reason and workframe_update and not workframe_script_ok:
|
|
288
|
+
workframe_reason = "Workframe update script is missing from this install."
|
|
289
|
+
if not workframe_reason and workframe_update and not workframe_latest:
|
|
290
|
+
workframe_reason = "No published npm release to update to yet."
|
|
291
|
+
|
|
292
|
+
agent_version = str(hermes_agent_version or "").strip() or _read_hermes_agent_version()
|
|
293
|
+
hermes_current = agent_version or hermes_tag
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
"ok": True,
|
|
297
|
+
"docker_available": docker_ok,
|
|
298
|
+
"compose_dir": str(compose_dir),
|
|
299
|
+
"project_root": str(project_root),
|
|
300
|
+
"workframe": {
|
|
301
|
+
"current": installed_pkg,
|
|
302
|
+
"latest": workframe_latest,
|
|
303
|
+
"update_available": workframe_update,
|
|
304
|
+
"can_update": workframe_can_update,
|
|
305
|
+
"state": _product_state(update_available=workframe_update, can_update=workframe_can_update),
|
|
306
|
+
"reason": workframe_reason,
|
|
307
|
+
"update_mode": "docker-compose-rebuild",
|
|
308
|
+
"install_kind": "docker",
|
|
309
|
+
"components": ["ui", "api", "supervisor"],
|
|
310
|
+
},
|
|
311
|
+
"hermes": {
|
|
312
|
+
"current": hermes_current,
|
|
313
|
+
"agent_version": agent_version,
|
|
314
|
+
"image_tag": hermes_tag,
|
|
315
|
+
"latest": "",
|
|
316
|
+
"current_image": hermes_ref,
|
|
317
|
+
"current_digest": hermes_digest[:28] + "…" if len(hermes_digest) > 28 else hermes_digest,
|
|
318
|
+
"latest_digest": digest_short,
|
|
319
|
+
"image": f"{HERMES_IMAGE}:{HERMES_TAG}",
|
|
320
|
+
"update_available": hermes_update,
|
|
321
|
+
"can_update": hermes_can_update,
|
|
322
|
+
"state": _product_state(update_available=hermes_update, can_update=hermes_can_update),
|
|
323
|
+
"reason": hermes_reason,
|
|
324
|
+
"update_mode": "docker-compose-pull",
|
|
325
|
+
"install_kind": "docker",
|
|
326
|
+
"can_restart_gateway": bool(docker_apply_ok and _script_path("restart-gateway-hermes.sh") is not None),
|
|
327
|
+
},
|
|
328
|
+
"desktop": {
|
|
329
|
+
"current": desktop_installed,
|
|
330
|
+
"latest": desktop_latest,
|
|
331
|
+
"update_available": desktop_update,
|
|
332
|
+
"can_update": False,
|
|
333
|
+
"state": "available" if desktop_update else "current",
|
|
334
|
+
"reason": "Desktop updates are distributed separately from the Docker stack.",
|
|
335
|
+
"update_mode": "manual-download",
|
|
336
|
+
"install_kind": "desktop",
|
|
337
|
+
"download_url": str(releases.get("desktop_download_url") or ""),
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def apply_update(target: str) -> dict[str, Any]:
|
|
343
|
+
if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") != "1":
|
|
344
|
+
raise ValueError("admin_updates_disabled")
|
|
345
|
+
target = str(target or "all").strip().lower()
|
|
346
|
+
if target not in {"hermes", "workframe", "all"}:
|
|
347
|
+
raise ValueError("invalid_update_target")
|
|
348
|
+
if not Path(DOCKER_SOCK).exists():
|
|
349
|
+
raise ValueError("docker_unavailable")
|
|
350
|
+
|
|
351
|
+
scripts: list[str] = []
|
|
352
|
+
if target in {"hermes", "all"}:
|
|
353
|
+
script = _script_path("apply-update-hermes.sh")
|
|
354
|
+
if not script:
|
|
355
|
+
raise ValueError("update_script_missing:hermes")
|
|
356
|
+
scripts.append(str(script))
|
|
357
|
+
if target in {"workframe", "all"}:
|
|
358
|
+
script = _script_path("apply-update-workframe.sh")
|
|
359
|
+
if not script:
|
|
360
|
+
raise ValueError("update_script_missing:workframe")
|
|
361
|
+
scripts.append(str(script))
|
|
362
|
+
|
|
363
|
+
env = os.environ.copy()
|
|
364
|
+
env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
|
|
365
|
+
env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
|
|
366
|
+
|
|
367
|
+
logs: list[str] = []
|
|
368
|
+
for script in scripts:
|
|
369
|
+
proc = subprocess.run(
|
|
370
|
+
["bash", script],
|
|
371
|
+
capture_output=True,
|
|
372
|
+
text=True,
|
|
373
|
+
timeout=900,
|
|
374
|
+
env=env,
|
|
375
|
+
cwd=env["WORKFRAME_COMPOSE_DIR"],
|
|
376
|
+
)
|
|
377
|
+
logs.append(f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}")
|
|
378
|
+
if proc.returncode != 0:
|
|
379
|
+
raise ValueError(f"update_failed:{Path(script).name}")
|
|
380
|
+
|
|
381
|
+
return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def restart_gateway() -> dict[str, Any]:
|
|
385
|
+
if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") != "1":
|
|
386
|
+
raise ValueError("admin_updates_disabled")
|
|
387
|
+
if not Path(DOCKER_SOCK).exists():
|
|
388
|
+
raise ValueError("docker_unavailable")
|
|
389
|
+
docker_apply_ok, docker_apply_reason = _docker_apply_ready()
|
|
390
|
+
if not docker_apply_ok:
|
|
391
|
+
raise ValueError(str(docker_apply_reason or "docker_apply_unavailable"))
|
|
392
|
+
script = _script_path("restart-gateway-hermes.sh")
|
|
393
|
+
if not script:
|
|
394
|
+
raise ValueError("restart_script_missing:gateway")
|
|
395
|
+
|
|
396
|
+
env = os.environ.copy()
|
|
397
|
+
env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
|
|
398
|
+
env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
|
|
399
|
+
proc = subprocess.run(
|
|
400
|
+
["bash", str(script)],
|
|
401
|
+
capture_output=True,
|
|
402
|
+
text=True,
|
|
403
|
+
timeout=300,
|
|
404
|
+
env=env,
|
|
405
|
+
cwd=env["WORKFRAME_COMPOSE_DIR"],
|
|
406
|
+
)
|
|
407
|
+
log = f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
|
|
408
|
+
if proc.returncode != 0:
|
|
409
|
+
raise ValueError("restart_failed:gateway")
|
|
410
|
+
return {"ok": True, "target": "gateway", "log": log[-12000:]}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
if __name__ == "__main__":
|
|
414
|
+
assert _version_lt("0.1.0", "0.1.1")
|
|
415
|
+
assert not _version_lt("0.1.0", "0.1.0")
|
|
416
|
+
assert parse_hermes_version_output("Hermes Agent v0.17.0 (2026.6.19)") == "0.17.0"
|
|
417
|
+
print("updates module ok")
|