hyperagents 1.2.3rc5.post3__py3-none-any.whl
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.
- agents/__init__.py +31 -0
- agents/background_monitor.py +453 -0
- agents/learning.py +460 -0
- agents/planner.py +421 -0
- agents/transcriber.py +341 -0
- civilization/__init__.py +51 -0
- civilization/api/__init__.py +7 -0
- civilization/api/civilization_api.py +361 -0
- civilization/graph/__init__.py +19 -0
- civilization/graph/knowledge_linker.py +348 -0
- civilization/graph/org_graph.py +348 -0
- civilization/graph/process_graph.py +386 -0
- civilization/ingestion/__init__.py +20 -0
- civilization/ingestion/chunker.py +273 -0
- civilization/ingestion/document_ingestor.py +358 -0
- civilization/ingestion/embedder.py +262 -0
- civilization/ingestion/extractor.py +224 -0
- civilization/interview/__init__.py +19 -0
- civilization/interview/gap_detector.py +380 -0
- civilization/interview/interviewer.py +323 -0
- civilization/interview/questioner.py +363 -0
- civilization/retrieval/__init__.py +17 -0
- civilization/retrieval/civilization_rag.py +316 -0
- civilization/retrieval/context_injector.py +342 -0
- civilization/retrieval/relevance_ranker.py +287 -0
- civilization/schema.py +299 -0
- civilization/store.py +177 -0
- civilization/sync/__init__.py +16 -0
- civilization/sync/confluence_sync.py +317 -0
- civilization/sync/gdrive_sync.py +262 -0
- civilization/sync/notion_sync.py +237 -0
- civilization/versioning/__init__.py +19 -0
- civilization/versioning/diff_engine.py +351 -0
- civilization/versioning/staleness_detector.py +303 -0
- civilization/versioning/version_manager.py +293 -0
- cli/__init__.py +1 -0
- cli/doctor.py +334 -0
- cli/hyperclaw.py +1357 -0
- cli/onboarding.py +469 -0
- core/__init__.py +1 -0
- core/connectors/__init__.py +119 -0
- core/connectors/infrastructure.py +772 -0
- core/connectors/messaging.py +586 -0
- core/connectors/productivity.py +764 -0
- core/connectors/smarthome.py +528 -0
- core/connectors/social.py +730 -0
- core/hyperrouter/__init__.py +23 -0
- core/hyperrouter/bandit.py +218 -0
- core/hyperrouter/fast_loop.py +68 -0
- core/hyperrouter/slow_loop.py +143 -0
- core/hyperstate/__init__.py +24 -0
- core/hyperstate/certifier.py +154 -0
- core/hyperstate/schema.py +160 -0
- core/hyperstate/state_manager.py +103 -0
- core/hyperstate/store.py +163 -0
- core/providers/__init__.py +121 -0
- core/providers/embeddings.py +389 -0
- core/providers/image.py +534 -0
- core/providers/llm.py +1935 -0
- core/providers/search.py +608 -0
- core/providers/voice.py +474 -0
- hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
- hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
- hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
- hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
- hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
- hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
- hyperclaw/__init__.py +67 -0
- hyperclaw/__main__.py +220 -0
- hyperclaw/agent.py +775 -0
- hyperclaw/agent_coordinator.py +577 -0
- hyperclaw/anthropic_params.py +225 -0
- hyperclaw/api_utils.py +85 -0
- hyperclaw/approval_telegram.py +209 -0
- hyperclaw/approval_terminal.py +80 -0
- hyperclaw/attachments.py +177 -0
- hyperclaw/branding.py +55 -0
- hyperclaw/brief/__init__.py +11 -0
- hyperclaw/brief/cli.py +204 -0
- hyperclaw/brief/collect.py +146 -0
- hyperclaw/brief/compose.py +333 -0
- hyperclaw/brief/diff.py +110 -0
- hyperclaw/brief/errors.py +99 -0
- hyperclaw/brief/google.py +269 -0
- hyperclaw/brief/google_auth.py +294 -0
- hyperclaw/brief/links.py +124 -0
- hyperclaw/brief/service.py +424 -0
- hyperclaw/brief/store.py +205 -0
- hyperclaw/brief/tasks.py +200 -0
- hyperclaw/chat_scope.py +115 -0
- hyperclaw/cli_tui.py +497 -0
- hyperclaw/client_guard.py +717 -0
- hyperclaw/connection_check.py +113 -0
- hyperclaw/cost_guard.py +1728 -0
- hyperclaw/default_config/agents.yaml +534 -0
- hyperclaw/default_config/hyperclaw.yaml +27 -0
- hyperclaw/default_config/models.yaml +161 -0
- hyperclaw/deployment.py +39 -0
- hyperclaw/digest/__init__.py +9 -0
- hyperclaw/digest/cli.py +117 -0
- hyperclaw/digest/errors.py +100 -0
- hyperclaw/digest/gitlab.py +187 -0
- hyperclaw/digest/http.py +113 -0
- hyperclaw/digest/redact.py +32 -0
- hyperclaw/digest/service.py +271 -0
- hyperclaw/digest/slack.py +223 -0
- hyperclaw/digest/state.py +124 -0
- hyperclaw/digest/summarize.py +198 -0
- hyperclaw/digest/tokens.py +143 -0
- hyperclaw/distribution.py +46 -0
- hyperclaw/documents.py +351 -0
- hyperclaw/feeds.py +339 -0
- hyperclaw/google_workspace/__init__.py +5 -0
- hyperclaw/google_workspace/api.py +151 -0
- hyperclaw/google_workspace/auth.py +397 -0
- hyperclaw/google_workspace/calendar.py +157 -0
- hyperclaw/google_workspace/cli.py +72 -0
- hyperclaw/google_workspace/drive.py +236 -0
- hyperclaw/google_workspace/errors.py +58 -0
- hyperclaw/google_workspace/gmail.py +295 -0
- hyperclaw/google_workspace/meet.py +122 -0
- hyperclaw/google_workspace/tools.py +319 -0
- hyperclaw/hyperspeed.py +57 -0
- hyperclaw/integrations_layer.py +1427 -0
- hyperclaw/live_state.py +188 -0
- hyperclaw/managed_config.py +245 -0
- hyperclaw/media_hub.py +179 -0
- hyperclaw/memory_manager.py +584 -0
- hyperclaw/model_catalog.py +551 -0
- hyperclaw/model_policy.py +211 -0
- hyperclaw/model_router.py +595 -0
- hyperclaw/model_selector.py +72 -0
- hyperclaw/naming.py +284 -0
- hyperclaw/notices.py +65 -0
- hyperclaw/oai_stream.py +106 -0
- hyperclaw/orchestrator.py +591 -0
- hyperclaw/outbox.py +82 -0
- hyperclaw/persona.py +367 -0
- hyperclaw/prometheus.py +219 -0
- hyperclaw/prompt_assembly.py +157 -0
- hyperclaw/provider_setup.py +146 -0
- hyperclaw/providers.py +340 -0
- hyperclaw/qualification.py +341 -0
- hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
- hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
- hyperclaw/reasoning_display.py +35 -0
- hyperclaw/scheduler.py +454 -0
- hyperclaw/server.py +847 -0
- hyperclaw/setup.py +627 -0
- hyperclaw/skills.py +717 -0
- hyperclaw/solomon.py +442 -0
- hyperclaw/swarm.py +533 -0
- hyperclaw/taint.py +832 -0
- hyperclaw/team/__init__.py +28 -0
- hyperclaw/team/dispatcher.py +431 -0
- hyperclaw/team/genesis.py +106 -0
- hyperclaw/team/lead.py +110 -0
- hyperclaw/team/model.py +135 -0
- hyperclaw/team/morning.py +238 -0
- hyperclaw/team/repos.py +66 -0
- hyperclaw/team/roster.py +264 -0
- hyperclaw/team/specialist.py +235 -0
- hyperclaw/team/status.py +163 -0
- hyperclaw/team/watchers.py +112 -0
- hyperclaw/teamsetup/__init__.py +17 -0
- hyperclaw/teamsetup/checks.py +208 -0
- hyperclaw/teamsetup/item.py +391 -0
- hyperclaw/teamsetup/oauth_loopback.py +84 -0
- hyperclaw/teamsetup/onboard.py +550 -0
- hyperclaw/teamsetup/profile.py +82 -0
- hyperclaw/teamsetup/secrets.py +313 -0
- hyperclaw/teamsetup/selfcheck.py +295 -0
- hyperclaw/teamsetup/service.py +495 -0
- hyperclaw/teamsetup/slack_oauth.py +442 -0
- hyperclaw/teamsetup/tty.py +62 -0
- hyperclaw/telegram_bot.py +1106 -0
- hyperclaw/telegram_intake.py +126 -0
- hyperclaw/telegram_pairing.py +360 -0
- hyperclaw/terminal_provider.py +323 -0
- hyperclaw/tools/__init__.py +1 -0
- hyperclaw/tools/approval.py +949 -0
- hyperclaw/tools/args.py +212 -0
- hyperclaw/tools/catalog.py +244 -0
- hyperclaw/tools/classify.py +266 -0
- hyperclaw/tools/effects.py +724 -0
- hyperclaw/tools/egress.py +210 -0
- hyperclaw/tools/envelope.py +188 -0
- hyperclaw/tools/guard.py +367 -0
- hyperclaw/tools/ledger.py +339 -0
- hyperclaw/tools/receipts.py +70 -0
- hyperclaw/tools/registry.py +318 -0
- hyperclaw/tools/service_guard.py +151 -0
- hyperclaw/tools/surfaces.py +54 -0
- hyperclaw/tools/workspace.py +87 -0
- hyperclaw/trading/__init__.py +77 -0
- hyperclaw/truth_guard.py +791 -0
- hyperclaw/tts.py +124 -0
- hyperclaw/tui.py +4255 -0
- hyperclaw/tui_bridge.py +938 -0
- hyperclaw/unattended.py +60 -0
- hyperclaw/updates/__init__.py +54 -0
- hyperclaw/updates/check.py +131 -0
- hyperclaw/updates/index.py +178 -0
- hyperclaw/updates/install.py +110 -0
- hyperclaw/updates/managed.py +161 -0
- hyperclaw/updates/manifest.py +138 -0
- hyperclaw/updates/notices.py +86 -0
- hyperclaw/updates/paths.py +53 -0
- hyperclaw/updates/release.py +146 -0
- hyperclaw/updates/upgrade.py +253 -0
- hyperclaw/updates/versioning.py +104 -0
- hyperclaw/userconfig.py +119 -0
- hyperclaw/xai.py +83 -0
- integrations/__init__.py +31 -0
- integrations/automation/__init__.py +1 -0
- integrations/automation/make.py +87 -0
- integrations/automation/zapier.py +87 -0
- integrations/base.py +476 -0
- integrations/communication/__init__.py +1 -0
- integrations/communication/twilio.py +279 -0
- integrations/communication/zoom.py +290 -0
- integrations/data/__init__.py +1 -0
- integrations/data/google_analytics.py +135 -0
- integrations/data/postgres.py +182 -0
- integrations/data/supabase_connector.py +202 -0
- integrations/data/weather_feeds.py +450 -0
- integrations/developer/__init__.py +9 -0
- integrations/developer/github.py +406 -0
- integrations/developer/gitlab.py +298 -0
- integrations/enterprise/__init__.py +13 -0
- integrations/enterprise/confluence.py +266 -0
- integrations/enterprise/hubspot.py +293 -0
- integrations/enterprise/jira.py +314 -0
- integrations/enterprise/salesforce.py +286 -0
- integrations/finance/__init__.py +1 -0
- integrations/finance/polymarket_client.py +247 -0
- integrations/finance/quickbooks.py +281 -0
- integrations/finance/stripe.py +261 -0
- integrations/gateway.py +238 -0
- integrations/messaging/__init__.py +32 -0
- integrations/messaging/discord.py +159 -0
- integrations/messaging/email.py +401 -0
- integrations/messaging/imessage.py +443 -0
- integrations/messaging/signal.py +175 -0
- integrations/messaging/slack.py +217 -0
- integrations/messaging/sms.py +175 -0
- integrations/messaging/teams.py +208 -0
- integrations/messaging/telegram.py +260 -0
- integrations/messaging/whatsapp.py +203 -0
- integrations/productivity/__init__.py +17 -0
- integrations/productivity/airtable.py +217 -0
- integrations/productivity/asana.py +248 -0
- integrations/productivity/google/__init__.py +19 -0
- integrations/productivity/google/base.py +89 -0
- integrations/productivity/google/calendar.py +272 -0
- integrations/productivity/google/docs.py +202 -0
- integrations/productivity/google/drive.py +268 -0
- integrations/productivity/google/gmail.py +279 -0
- integrations/productivity/google/meet.py +218 -0
- integrations/productivity/google/sheets.py +222 -0
- integrations/productivity/google/tasks.py +199 -0
- integrations/productivity/linear.py +279 -0
- integrations/productivity/microsoft/__init__.py +13 -0
- integrations/productivity/microsoft/base.py +93 -0
- integrations/productivity/microsoft/calendar.py +207 -0
- integrations/productivity/microsoft/onedrive.py +222 -0
- integrations/productivity/microsoft/outlook.py +241 -0
- integrations/productivity/microsoft/sharepoint.py +211 -0
- integrations/productivity/notion.py +243 -0
- integrations/productivity/todoist.py +250 -0
- integrations/productivity/trello.py +244 -0
- integrations/storage/__init__.py +1 -0
- integrations/storage/box.py +279 -0
- integrations/storage/dropbox.py +215 -0
- integrations/telegram_inbound.py +179 -0
- integrations/telegram_poller.py +151 -0
- memory/__init__.py +1 -0
- memory/causal_graph.py +125 -0
- memory/impact_tracker.py +88 -0
- memory/vector_memory.py +436 -0
- models/__init__.py +12 -0
- models/chatjimmy_client.py +210 -0
- models/claude_client.py +235 -0
- models/claude_code_subagent.py +192 -0
- models/openai_client.py +120 -0
- models/router.py +162 -0
- recursive/discovery_loop.py +118 -0
- recursive/optimization_loop.py +135 -0
- recursive/validation_loop.py +38 -0
- security/__init__.py +1 -0
- security/audit_logger.py +106 -0
- security/filesystem_guard.py +100 -0
- security/hypershield.py +99 -0
- security/network_guard.py +97 -0
- security/policy_engine.py +198 -0
- swarm/__init__.py +1 -0
- swarm/agents/__init__.py +0 -0
- swarm/agents/base.py +230 -0
- swarm/agents/business/__init__.py +0 -0
- swarm/agents/business/counsel.py +31 -0
- swarm/agents/business/herald.py +32 -0
- swarm/agents/business/ledger.py +26 -0
- swarm/agents/business/nexus.py +30 -0
- swarm/agents/business/ops.py +30 -0
- swarm/agents/business/pipeline.py +28 -0
- swarm/agents/business/revenue.py +30 -0
- swarm/agents/business/sovereign.py +31 -0
- swarm/agents/business/strategos.py +27 -0
- swarm/agents/business/talent.py +27 -0
- swarm/agents/business/venture.py +32 -0
- swarm/agents/comms/__init__.py +1 -0
- swarm/agents/comms/cipher.py +32 -0
- swarm/agents/comms/echo_comms.py +31 -0
- swarm/agents/comms/envoy.py +31 -0
- swarm/agents/comms/herald_scheduler.py +33 -0
- swarm/agents/comms/pulse.py +29 -0
- swarm/agents/creative/__init__.py +0 -0
- swarm/agents/creative/author.py +27 -0
- swarm/agents/creative/lens.py +25 -0
- swarm/agents/executive/__init__.py +1 -0
- swarm/agents/intelligence/__init__.py +1 -0
- swarm/agents/intelligence/arbiter.py +30 -0
- swarm/agents/intelligence/sentinel.py +30 -0
- swarm/agents/media/__init__.py +0 -0
- swarm/agents/personal/__init__.py +0 -0
- swarm/agents/personal/atlas.py +27 -0
- swarm/agents/personal/hearth.py +26 -0
- swarm/agents/personal/midas.py +27 -0
- swarm/agents/personal/navigator.py +27 -0
- swarm/agents/personal/nourish.py +31 -0
- swarm/agents/personal/vitals.py +33 -0
- swarm/agents/recursive/__init__.py +0 -0
- swarm/agents/recursive/alchemist.py +73 -0
- swarm/agents/recursive/calibrator.py +45 -0
- swarm/agents/recursive/scout.py +97 -0
- swarm/agents/scientific/__init__.py +0 -0
- swarm/agents/scientific/cosmos.py +36 -0
- swarm/agents/scientific/gaia.py +26 -0
- swarm/agents/scientific/medicus.py +33 -0
- swarm/agents/scientific/oracle_agent.py +39 -0
- swarm/agents/scientific/scribe.py +39 -0
- swarm/agents/talent/__init__.py +0 -0
- swarm/agents/talent/deal.py +28 -0
- swarm/agents/talent/roster.py +29 -0
- swarm/agents/talent/scout.py +29 -0
- swarm/agents/talent/stage.py +27 -0
- swarm/agents/tech/__init__.py +0 -0
- swarm/agents/tech/aegis.py +28 -0
- swarm/agents/tech/bridge.py +28 -0
- swarm/agents/tech/forge.py +28 -0
- swarm/agents/trading/__init__.py +1 -0
- swarm/agents/trading/global_prediction_engine.py +611 -0
- swarm/agents/trading/polymarket_trader.py +165 -0
- swarm/agents/trading/prediction_strategist.py +218 -0
- swarm/autogen_bridge.py +68 -0
- swarm/bid_protocol.py +196 -0
- swarm/dispatcher.py +527 -0
- swarm/nexus.py +181 -0
- swarm/registry.py +112 -0
- telegram_listener.py +403 -0
- ui/tui/app.py +214 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Managed push for Hyper-deployed installs (docs/UPDATES.md, "Managed push").
|
|
2
|
+
|
|
3
|
+
Unattended apply happens only when ALL hold: HYPERCLAW_DEPLOYMENT=hyper,
|
|
4
|
+
channel ``managed`` (explicit opt-in), HYPERCLAW_UNATTENDED=1, and the update
|
|
5
|
+
check is not opted out. It runs inside the configured quiet hour (local time),
|
|
6
|
+
at most once per local day, through the same signed-only upgrade path, and
|
|
7
|
+
the owner is notified of the outcome.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as _dt
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Callable, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from hyperclaw.updates import notices
|
|
18
|
+
from hyperclaw.updates.paths import opted_out, read_json, updates_dir, write_json
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("hyperclaw.updates")
|
|
21
|
+
|
|
22
|
+
CHANNEL_ENV = "HYPERCLAW_UPDATE_CHANNEL"
|
|
23
|
+
QUIET_HOUR_ENV = "HYPERCLAW_UPDATE_QUIET_HOUR"
|
|
24
|
+
MANAGED = "managed"
|
|
25
|
+
NOTIFY = "notify"
|
|
26
|
+
DEFAULT_QUIET_HOUR = 3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _config_updates() -> dict:
|
|
30
|
+
try:
|
|
31
|
+
from hyperclaw.userconfig import load_config
|
|
32
|
+
u = load_config().get("updates")
|
|
33
|
+
return u if isinstance(u, dict) else {}
|
|
34
|
+
except Exception:
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def channel() -> str:
|
|
39
|
+
v = os.environ.get(CHANNEL_ENV, "").strip().lower() or \
|
|
40
|
+
str(_config_updates().get("channel", "")).strip().lower()
|
|
41
|
+
return MANAGED if v == MANAGED else NOTIFY
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def quiet_hour() -> int:
|
|
45
|
+
raw = os.environ.get(QUIET_HOUR_ENV, "").strip()
|
|
46
|
+
if not raw:
|
|
47
|
+
raw = str(_config_updates().get("quiet_hour", "")).strip()
|
|
48
|
+
try:
|
|
49
|
+
h = int(raw)
|
|
50
|
+
return h if 0 <= h <= 23 else DEFAULT_QUIET_HOUR
|
|
51
|
+
except ValueError:
|
|
52
|
+
return DEFAULT_QUIET_HOUR
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def gate() -> Tuple[bool, str]:
|
|
56
|
+
"""(allowed, reason) for an UNATTENDED apply."""
|
|
57
|
+
from hyperclaw import unattended
|
|
58
|
+
from hyperclaw.deployment import is_hyper
|
|
59
|
+
if opted_out():
|
|
60
|
+
return False, "update checks are opted out (HYPERCLAW_NO_UPDATE_CHECK)"
|
|
61
|
+
if not is_hyper():
|
|
62
|
+
return False, "not a Hyper-deployed install (HYPERCLAW_DEPLOYMENT=hyper)"
|
|
63
|
+
if channel() != MANAGED:
|
|
64
|
+
return False, f"managed channel not opted in ({CHANNEL_ENV}=managed)"
|
|
65
|
+
if not unattended.enabled():
|
|
66
|
+
return False, "unattended mode is off (HYPERCLAW_UNATTENDED=1)"
|
|
67
|
+
return True, "ok"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def in_quiet_window(now_local: _dt.datetime, hour: Optional[int] = None) -> bool:
|
|
71
|
+
return now_local.hour == (quiet_hour() if hour is None else hour)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def next_quiet_time(now_local: _dt.datetime, hour: Optional[int] = None) -> _dt.datetime:
|
|
75
|
+
"""Start of the next quiet window at or after ``now_local`` (now if inside it)."""
|
|
76
|
+
h = quiet_hour() if hour is None else hour
|
|
77
|
+
start = now_local.replace(hour=h, minute=0, second=0, microsecond=0)
|
|
78
|
+
if now_local.hour == h:
|
|
79
|
+
return now_local
|
|
80
|
+
if start <= now_local:
|
|
81
|
+
start += _dt.timedelta(days=1)
|
|
82
|
+
return start
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _state_path():
|
|
86
|
+
return updates_dir() / "managed-tick.json"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def tick(now_local: Optional[_dt.datetime] = None, *,
|
|
90
|
+
upgrade_fn: Optional[Callable] = None,
|
|
91
|
+
notify_fn: Callable[[str], None] = notices.notify) -> str:
|
|
92
|
+
"""One managed-channel check. Returns a short status word."""
|
|
93
|
+
ok, reason = gate()
|
|
94
|
+
if not ok:
|
|
95
|
+
log.info("managed update: not applying (%s)", reason)
|
|
96
|
+
return "gated"
|
|
97
|
+
now_local = now_local or _dt.datetime.now().astimezone()
|
|
98
|
+
if not in_quiet_window(now_local):
|
|
99
|
+
return "not-due"
|
|
100
|
+
day = now_local.date().isoformat()
|
|
101
|
+
st = read_json(_state_path(), {})
|
|
102
|
+
if st.get("last_attempt_day") == day:
|
|
103
|
+
return "already-ran"
|
|
104
|
+
st["last_attempt_day"] = day
|
|
105
|
+
write_json(_state_path(), st)
|
|
106
|
+
|
|
107
|
+
if upgrade_fn is None:
|
|
108
|
+
from hyperclaw.updates.upgrade import upgrade as upgrade_fn
|
|
109
|
+
lines = []
|
|
110
|
+
res = upgrade_fn(yes=True, source="managed", out=lines.append)
|
|
111
|
+
st["last_result"] = {"day": day, "status": res.status, "message": res.message}
|
|
112
|
+
write_json(_state_path(), st)
|
|
113
|
+
if res.status == "up-to-date":
|
|
114
|
+
return res.status
|
|
115
|
+
if res.status == "upgraded":
|
|
116
|
+
notify_fn(f"HyperClaw was updated {res.before} -> {res.after} at the quiet hour "
|
|
117
|
+
f"(managed channel). Restart HyperClaw services to run it; "
|
|
118
|
+
f"'hyperclaw upgrade --rollback' restores {res.before}.")
|
|
119
|
+
else:
|
|
120
|
+
notify_fn(f"HyperClaw managed update did not apply ({res.status}): {res.message}")
|
|
121
|
+
return res.status
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_thread: Optional[threading.Thread] = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _loop(stop: threading.Event, sleep_cap: float = 900.0) -> None:
|
|
128
|
+
while not stop.is_set():
|
|
129
|
+
try:
|
|
130
|
+
now = _dt.datetime.now().astimezone()
|
|
131
|
+
if in_quiet_window(now):
|
|
132
|
+
tick(now)
|
|
133
|
+
now = _dt.datetime.now().astimezone()
|
|
134
|
+
nxt = next_quiet_time(now + _dt.timedelta(minutes=1))
|
|
135
|
+
wait = max(30.0, min(sleep_cap, (nxt - now).total_seconds()))
|
|
136
|
+
except Exception:
|
|
137
|
+
log.exception("managed update scheduler")
|
|
138
|
+
wait = sleep_cap
|
|
139
|
+
stop.wait(wait)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def start_scheduler(stop: Optional[threading.Event] = None) -> Optional[threading.Thread]:
|
|
143
|
+
"""Start the daemon scheduler once per process, only when the gate passes."""
|
|
144
|
+
global _thread
|
|
145
|
+
ok, reason = gate()
|
|
146
|
+
if not ok:
|
|
147
|
+
return None
|
|
148
|
+
if _thread is not None and _thread.is_alive():
|
|
149
|
+
return _thread
|
|
150
|
+
_thread = threading.Thread(target=_loop, args=(stop or threading.Event(),),
|
|
151
|
+
name="hyperclaw-managed-updates", daemon=True)
|
|
152
|
+
_thread.start()
|
|
153
|
+
log.info("managed updates: on (quiet hour %02d:00 local)", quiet_hour())
|
|
154
|
+
return _thread
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def describe() -> str:
|
|
158
|
+
ok, reason = gate()
|
|
159
|
+
if ok:
|
|
160
|
+
return f"managed channel: ON, applies at {quiet_hour():02d}:00 local"
|
|
161
|
+
return f"managed channel: off ({reason})"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Hyper-signed release manifests (docs/UPDATES.md, "Trust model").
|
|
2
|
+
|
|
3
|
+
Envelope::
|
|
4
|
+
|
|
5
|
+
{"manifest": {"type": "hyperclaw-release-manifest", "package": "hyperagents",
|
|
6
|
+
"version": "1.2.4", "wheel": "hyperagents-1.2.4-py3-none-any.whl",
|
|
7
|
+
"sha256": "<hex>", "size": 123, "release_notes": "...",
|
|
8
|
+
"min_upgrade_from": "1.2.3rc3", "issued_at": "...Z",
|
|
9
|
+
"key_id": "hyper-<16 hex>"},
|
|
10
|
+
"signature": "<base64 Ed25519 over canonical(manifest)>"}
|
|
11
|
+
|
|
12
|
+
Keys, canonical form and key ids are shared with ``hyperclaw.qualification``
|
|
13
|
+
(public keys shipped in ``hyperclaw/qualification_keys``; private key in the
|
|
14
|
+
maintainer keychain). The ``type`` field keeps a qualification record from
|
|
15
|
+
ever passing as a release manifest.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import datetime as _dt
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from hyperclaw import qualification as _q
|
|
28
|
+
from hyperclaw.updates import versioning
|
|
29
|
+
|
|
30
|
+
TYPE = "hyperclaw-release-manifest"
|
|
31
|
+
from hyperclaw.distribution import DIST_NAME as PACKAGE # "hyperagents" (wheel prefix + index project)
|
|
32
|
+
FIELDS_REQUIRED = ("type", "package", "version", "wheel", "sha256", "size",
|
|
33
|
+
"release_notes", "min_upgrade_from", "issued_at", "key_id")
|
|
34
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ManifestError(Exception):
|
|
38
|
+
"""The manifest is unsigned, malformed or does not verify. Never install."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def manifest_filename(version: str) -> str:
|
|
42
|
+
return f"{PACKAGE}-{versioning.normalize(version)}.manifest.json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def wheel_version(filename: str) -> Optional[str]:
|
|
46
|
+
"""Version field of a wheel filename (``name-ver-...-....whl``)."""
|
|
47
|
+
name = Path(filename).name
|
|
48
|
+
if not name.endswith(".whl"):
|
|
49
|
+
return None
|
|
50
|
+
parts = name[:-4].split("-")
|
|
51
|
+
if len(parts) < 5:
|
|
52
|
+
return None
|
|
53
|
+
return parts[1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sha256_file(path: Path) -> str:
|
|
57
|
+
h = hashlib.sha256()
|
|
58
|
+
with open(path, "rb") as f:
|
|
59
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
60
|
+
h.update(chunk)
|
|
61
|
+
return h.hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def make_manifest(*, version: str, wheel: str, sha256: str, size: int,
|
|
65
|
+
release_notes: str, min_upgrade_from: str) -> dict:
|
|
66
|
+
return {"type": TYPE, "package": PACKAGE, "version": versioning.normalize(version),
|
|
67
|
+
"wheel": wheel, "sha256": sha256.lower(), "size": int(size),
|
|
68
|
+
"release_notes": release_notes,
|
|
69
|
+
"min_upgrade_from": versioning.normalize(min_upgrade_from),
|
|
70
|
+
"issued_at": _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0)
|
|
71
|
+
.isoformat().replace("+00:00", "Z")}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sign_manifest(manifest: dict, private_raw: bytes) -> dict:
|
|
75
|
+
m = dict(manifest)
|
|
76
|
+
m["key_id"] = _q.key_id_for(_q._public_raw(private_raw))
|
|
77
|
+
sig = _q._private_key(private_raw).sign(_q.canonical(m))
|
|
78
|
+
return {"manifest": m, "signature": base64.b64encode(sig).decode("ascii")}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def verify_manifest(env, *, expect_version: Optional[str] = None) -> dict:
|
|
82
|
+
"""Return the verified manifest dict or raise ManifestError(reason)."""
|
|
83
|
+
from cryptography.exceptions import InvalidSignature
|
|
84
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
85
|
+
|
|
86
|
+
if not isinstance(env, dict) or not isinstance(env.get("manifest"), dict):
|
|
87
|
+
raise ManifestError("unsigned or malformed manifest")
|
|
88
|
+
m, sig = env["manifest"], env.get("signature")
|
|
89
|
+
if not isinstance(sig, str) or not sig:
|
|
90
|
+
raise ManifestError("manifest is unsigned")
|
|
91
|
+
missing = [f for f in FIELDS_REQUIRED if m.get(f) in (None, "")]
|
|
92
|
+
if missing:
|
|
93
|
+
raise ManifestError(f"manifest missing fields: {', '.join(missing)}")
|
|
94
|
+
raw = _q.trusted_keys().get(m.get("key_id"))
|
|
95
|
+
if raw is None:
|
|
96
|
+
raise ManifestError("manifest signed by an unknown key")
|
|
97
|
+
try:
|
|
98
|
+
Ed25519PublicKey.from_public_bytes(raw).verify(
|
|
99
|
+
base64.b64decode(sig, validate=True), _q.canonical(m))
|
|
100
|
+
except (InvalidSignature, ValueError, TypeError):
|
|
101
|
+
raise ManifestError("manifest signature does not verify")
|
|
102
|
+
if m["type"] != TYPE or m["package"] != PACKAGE:
|
|
103
|
+
raise ManifestError("not a HyperClaw release manifest")
|
|
104
|
+
v = versioning.parse(m["version"])
|
|
105
|
+
if v is None or v.local:
|
|
106
|
+
raise ManifestError("manifest version is invalid")
|
|
107
|
+
if versioning.parse(m["min_upgrade_from"]) is None:
|
|
108
|
+
raise ManifestError("manifest min_upgrade_from is invalid")
|
|
109
|
+
if not isinstance(m["sha256"], str) or not _HEX64.match(m["sha256"]):
|
|
110
|
+
raise ManifestError("manifest sha256 is invalid")
|
|
111
|
+
if not isinstance(m["size"], int) or m["size"] <= 0:
|
|
112
|
+
raise ManifestError("manifest size is invalid")
|
|
113
|
+
wv = wheel_version(m["wheel"])
|
|
114
|
+
if ("/" in m["wheel"] or "\\" in m["wheel"] or not m["wheel"].startswith(PACKAGE + "-")
|
|
115
|
+
or versioning.parse(wv) != v):
|
|
116
|
+
raise ManifestError("manifest wheel filename does not match its version")
|
|
117
|
+
if expect_version is not None and versioning.parse(expect_version) != v:
|
|
118
|
+
raise ManifestError(f"manifest is for {m['version']}, not {expect_version}")
|
|
119
|
+
return m
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_and_verify(path: Path, *, expect_version: Optional[str] = None) -> dict:
|
|
123
|
+
try:
|
|
124
|
+
env = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
125
|
+
except Exception as e:
|
|
126
|
+
raise ManifestError(f"cannot read manifest: {type(e).__name__}")
|
|
127
|
+
return verify_manifest(env, expect_version=expect_version)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def verify_wheel_file(path: Path, manifest: dict) -> None:
|
|
131
|
+
"""Raise ManifestError unless the file is exactly the signed wheel."""
|
|
132
|
+
p = Path(path)
|
|
133
|
+
if not p.is_file():
|
|
134
|
+
raise ManifestError("wheel file missing")
|
|
135
|
+
if p.stat().st_size != manifest["size"]:
|
|
136
|
+
raise ManifestError("wheel size does not match the signed manifest")
|
|
137
|
+
if sha256_file(p) != manifest["sha256"]:
|
|
138
|
+
raise ManifestError("wheel sha256 does not match the signed manifest")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Owner notices: managed-update outcomes waiting to be shown.
|
|
2
|
+
|
|
3
|
+
Appended to ``updates/notices.jsonl``; the next interactive start shows the
|
|
4
|
+
unread ones once. Optionally also sent to the owner on Telegram.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime as _dt
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Callable, List, Optional
|
|
14
|
+
|
|
15
|
+
from hyperclaw.updates.paths import read_json, updates_dir, write_json
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("hyperclaw.updates")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _path():
|
|
21
|
+
return updates_dir() / "notices.jsonl"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cursor_path():
|
|
25
|
+
return updates_dir() / "notices.read.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def owner_chat_id() -> Optional[str]:
|
|
29
|
+
"""The owner chat: TELEGRAM_OWNER_CHAT_ID (written by owner pairing),
|
|
30
|
+
else HYPERCLAW_OWNER_CHAT_ID, else the first TELEGRAM_CHAT_ID."""
|
|
31
|
+
cid = (os.environ.get("TELEGRAM_OWNER_CHAT_ID", "").strip()
|
|
32
|
+
or os.environ.get("HYPERCLAW_OWNER_CHAT_ID", "").strip())
|
|
33
|
+
if not cid:
|
|
34
|
+
cid = os.environ.get("TELEGRAM_CHAT_ID", "").split(",")[0].strip()
|
|
35
|
+
return cid or None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def telegram_send(text: str) -> bool:
|
|
39
|
+
"""Send to the owner chat if a bot token + chat are configured. The token
|
|
40
|
+
is part of the URL, so neither the URL nor the exception is logged."""
|
|
41
|
+
token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
|
|
42
|
+
chat = owner_chat_id()
|
|
43
|
+
if not token or not chat:
|
|
44
|
+
return False
|
|
45
|
+
body = json.dumps({"chat_id": chat, "text": text,
|
|
46
|
+
"link_preview_options": {"is_disabled": True}}).encode()
|
|
47
|
+
try:
|
|
48
|
+
req = urllib.request.Request(f"https://api.telegram.org/bot{token}/sendMessage",
|
|
49
|
+
data=body, method="POST",
|
|
50
|
+
headers={"Content-Type": "application/json"})
|
|
51
|
+
with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310
|
|
52
|
+
return 200 <= r.status < 300
|
|
53
|
+
except Exception:
|
|
54
|
+
log.warning("update notice: Telegram send failed (notice kept for next start)")
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def notify(text: str, *, sender: Optional[Callable[[str], bool]] = telegram_send) -> None:
|
|
59
|
+
p = _path()
|
|
60
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
row = {"at": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"), "text": text}
|
|
62
|
+
with open(p, "a", encoding="utf-8") as f:
|
|
63
|
+
f.write(json.dumps(row) + "\n")
|
|
64
|
+
log.warning("update notice: %s", text)
|
|
65
|
+
if sender is not None:
|
|
66
|
+
try:
|
|
67
|
+
sender(text)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def unread(mark_read: bool = True) -> List[str]:
|
|
73
|
+
p = _path()
|
|
74
|
+
if not p.exists():
|
|
75
|
+
return []
|
|
76
|
+
lines = p.read_text(encoding="utf-8").splitlines()
|
|
77
|
+
seen = int(read_json(_cursor_path(), {}).get("lines", 0))
|
|
78
|
+
out = []
|
|
79
|
+
for line in lines[seen:]:
|
|
80
|
+
try:
|
|
81
|
+
out.append(json.loads(line)["text"])
|
|
82
|
+
except Exception:
|
|
83
|
+
continue
|
|
84
|
+
if mark_read and len(lines) != seen:
|
|
85
|
+
write_json(_cursor_path(), {"lines": len(lines)})
|
|
86
|
+
return out
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Where the updater keeps its files: ``$HYPERCLAW_ROOT/updates/`` only.
|
|
2
|
+
Config and ``.env`` are never written by anything in this package."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
OPT_OUT_ENV = "HYPERCLAW_NO_UPDATE_CHECK"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def opted_out() -> bool:
|
|
14
|
+
return os.environ.get(OPT_OUT_ENV, "").strip().lower() in ("1", "true", "yes", "on")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def updates_dir() -> Path:
|
|
18
|
+
from hyperclaw.userconfig import root
|
|
19
|
+
return root() / "updates"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def releases_dir() -> Path:
|
|
23
|
+
return updates_dir() / "releases"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_json(path: Path, default):
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
29
|
+
except Exception:
|
|
30
|
+
return default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_json(path: Path, data) -> None:
|
|
34
|
+
"""Atomic write (a daemon thread may be cut off at process exit)."""
|
|
35
|
+
path = Path(path)
|
|
36
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
fd, tmp = tempfile.mkstemp(prefix=path.name, suffix=".tmp", dir=str(path.parent))
|
|
38
|
+
try:
|
|
39
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
40
|
+
json.dump(data, f, indent=2, sort_keys=True)
|
|
41
|
+
f.write("\n")
|
|
42
|
+
os.replace(tmp, path)
|
|
43
|
+
except Exception:
|
|
44
|
+
try:
|
|
45
|
+
os.unlink(tmp)
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def current_version() -> str:
|
|
52
|
+
from hyperclaw import __version__
|
|
53
|
+
return __version__
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Maintainer command: sign a release manifest for a built wheel.
|
|
2
|
+
|
|
3
|
+
python -m hyperclaw.updates.release sign WHEEL --notes NOTES.md \\
|
|
4
|
+
--min-upgrade-from 1.2.3rc3 [--out DIR]
|
|
5
|
+
python -m hyperclaw.updates.release verify MANIFEST [--wheel WHEEL]
|
|
6
|
+
|
|
7
|
+
The private key is read from the macOS keychain (the same item as
|
|
8
|
+
qualification signing, ``hc-qual-signing-key``) and is never printed or
|
|
9
|
+
logged. Mock inputs are refused, and so is a key that is not one of the
|
|
10
|
+
package's shipped trusted keys (the result could never verify on a client).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import zipfile
|
|
19
|
+
from email.parser import Parser
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from hyperclaw.updates import manifest as _m
|
|
23
|
+
from hyperclaw.updates import versioning
|
|
24
|
+
|
|
25
|
+
_PLACEHOLDER = re.compile(r"\b(mock|tbd|lorem|placeholder)\b", re.IGNORECASE)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Refused(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def inspect_wheel(path: Path) -> str:
|
|
33
|
+
"""Return the wheel's version after checking it is a real HyperClaw wheel."""
|
|
34
|
+
p = Path(path)
|
|
35
|
+
if not p.is_file() or p.suffix != ".whl":
|
|
36
|
+
raise Refused(f"not a wheel file: {p.name}")
|
|
37
|
+
fv = _m.wheel_version(p.name)
|
|
38
|
+
if not p.name.startswith(_m.PACKAGE + "-") or fv is None:
|
|
39
|
+
raise Refused(f"not a {_m.PACKAGE} wheel: {p.name}")
|
|
40
|
+
try:
|
|
41
|
+
with zipfile.ZipFile(p) as z:
|
|
42
|
+
names = z.namelist()
|
|
43
|
+
meta = [n for n in names if n.endswith(".dist-info/METADATA")]
|
|
44
|
+
rec = [n for n in names if n.endswith(".dist-info/RECORD")]
|
|
45
|
+
if len(meta) != 1 or len(rec) != 1:
|
|
46
|
+
raise Refused("wheel has no single dist-info METADATA/RECORD (not a built wheel)")
|
|
47
|
+
md = Parser().parsestr(z.read(meta[0]).decode("utf-8", "replace"))
|
|
48
|
+
if not any(n.startswith("hyperclaw/") for n in names):
|
|
49
|
+
raise Refused("wheel does not contain the hyperclaw package")
|
|
50
|
+
except zipfile.BadZipFile:
|
|
51
|
+
raise Refused("wheel is not a valid zip archive")
|
|
52
|
+
name = (md.get("Name") or "").strip().lower().replace("_", "-")
|
|
53
|
+
version = (md.get("Version") or "").strip()
|
|
54
|
+
if name != _m.PACKAGE:
|
|
55
|
+
raise Refused(f"wheel metadata names {name!r}, not {_m.PACKAGE!r}")
|
|
56
|
+
v = versioning.parse(version)
|
|
57
|
+
if v is None:
|
|
58
|
+
raise Refused(f"wheel version {version!r} is not a valid version")
|
|
59
|
+
if v.local or "mock" in version.lower() or "mock" in p.name.lower():
|
|
60
|
+
raise Refused(f"wheel version {version!r} is a local/mock build")
|
|
61
|
+
if versioning.parse(fv) != v:
|
|
62
|
+
raise Refused(f"filename version {fv} does not match metadata version {version}")
|
|
63
|
+
return str(v)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_signed(wheel: Path, notes: str, min_upgrade_from: str, private_raw: bytes) -> dict:
|
|
67
|
+
version = inspect_wheel(wheel)
|
|
68
|
+
notes = (notes or "").strip()
|
|
69
|
+
if len(notes) < 10 or _PLACEHOLDER.search(notes):
|
|
70
|
+
raise Refused("release notes are empty or placeholder text")
|
|
71
|
+
mn = versioning.parse(min_upgrade_from)
|
|
72
|
+
if mn is None or mn.local or mn > versioning.parse(version):
|
|
73
|
+
raise Refused(f"--min-upgrade-from {min_upgrade_from!r} is invalid for {version}")
|
|
74
|
+
m = _m.make_manifest(version=version, wheel=Path(wheel).name,
|
|
75
|
+
sha256=_m.sha256_file(Path(wheel)), size=Path(wheel).stat().st_size,
|
|
76
|
+
release_notes=notes, min_upgrade_from=str(mn))
|
|
77
|
+
env = _m.sign_manifest(m, private_raw)
|
|
78
|
+
try:
|
|
79
|
+
_m.verify_manifest(env, expect_version=version)
|
|
80
|
+
except _m.ManifestError as e:
|
|
81
|
+
raise Refused(f"signed manifest does not verify with the shipped keys ({e})")
|
|
82
|
+
_m.verify_wheel_file(Path(wheel), env["manifest"])
|
|
83
|
+
return env
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _keychain_key() -> bytes:
|
|
87
|
+
from hyperclaw.qualification import keychain_private_key
|
|
88
|
+
return keychain_private_key()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def sign_main(argv=None, *, private_key_loader=_keychain_key) -> int:
|
|
92
|
+
ap = argparse.ArgumentParser(prog="python -m hyperclaw.updates.release sign",
|
|
93
|
+
description="Write a Hyper-signed release manifest for a "
|
|
94
|
+
"built wheel (maintainers only).")
|
|
95
|
+
ap.add_argument("wheel", type=Path)
|
|
96
|
+
ap.add_argument("--notes", type=Path, required=True, help="release notes file")
|
|
97
|
+
ap.add_argument("--min-upgrade-from", required=True)
|
|
98
|
+
ap.add_argument("--out", type=Path, default=Path("dist"))
|
|
99
|
+
a = ap.parse_args(argv)
|
|
100
|
+
try:
|
|
101
|
+
notes = a.notes.read_text(encoding="utf-8")
|
|
102
|
+
inspect_wheel(a.wheel) # refuse before touching the keychain
|
|
103
|
+
env = build_signed(a.wheel, notes, a.min_upgrade_from, private_key_loader())
|
|
104
|
+
except Refused as e:
|
|
105
|
+
print(f"refused: {e}", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
except OSError as e:
|
|
108
|
+
print(f"refused: {e.__class__.__name__}: {e.filename}", file=sys.stderr)
|
|
109
|
+
return 2
|
|
110
|
+
m = env["manifest"]
|
|
111
|
+
a.out.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
out = a.out / _m.manifest_filename(m["version"])
|
|
113
|
+
out.write_text(json.dumps(env, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
114
|
+
print(f"signed {m['wheel']} sha256 {m['sha256']} -> {out} (key {m['key_id']})")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def verify_main(argv=None) -> int:
|
|
119
|
+
ap = argparse.ArgumentParser(prog="python -m hyperclaw.updates.release verify")
|
|
120
|
+
ap.add_argument("manifest", type=Path)
|
|
121
|
+
ap.add_argument("--wheel", type=Path)
|
|
122
|
+
a = ap.parse_args(argv)
|
|
123
|
+
try:
|
|
124
|
+
m = _m.load_and_verify(a.manifest)
|
|
125
|
+
if a.wheel:
|
|
126
|
+
_m.verify_wheel_file(a.wheel, m)
|
|
127
|
+
except _m.ManifestError as e:
|
|
128
|
+
print(f"INVALID: {e}", file=sys.stderr)
|
|
129
|
+
return 1
|
|
130
|
+
print(f"VALID {m['version']} {m['wheel']} (key {m['key_id']})"
|
|
131
|
+
+ (" + wheel sha256 matches" if a.wheel else ""))
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main(argv=None) -> int:
|
|
136
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
137
|
+
if argv[:1] == ["sign"]:
|
|
138
|
+
return sign_main(argv[1:])
|
|
139
|
+
if argv[:1] == ["verify"]:
|
|
140
|
+
return verify_main(argv[1:])
|
|
141
|
+
print(__doc__, file=sys.stderr)
|
|
142
|
+
return 2
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
raise SystemExit(main())
|