hivekit 0.8.2__tar.gz → 0.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {hivekit-0.8.2 → hivekit-0.9.1}/PKG-INFO +1 -1
  2. {hivekit-0.8.2 → hivekit-0.9.1}/pyproject.toml +1 -1
  3. hivekit-0.9.1/src/cli/auth/device_flow.py +204 -0
  4. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/session_manager.py +41 -3
  5. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/config.py +76 -9
  6. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/experiment.py +2 -2
  7. hivekit-0.9.1/src/cli/experiment_download.py +131 -0
  8. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/http_client.py +107 -85
  9. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/main.py +116 -14
  10. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/skills/hive-setup/SKILL.md +2 -4
  11. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/skills/hive-setup/references/configuration.md +8 -7
  12. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/skills/hive-setup/references/gpu-hardware.md +4 -15
  13. hivekit-0.9.1/src/cli/utils/environment.py +29 -0
  14. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/url_utils.py +11 -8
  15. {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/PKG-INFO +1 -1
  16. {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/SOURCES.txt +3 -0
  17. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_config.py +54 -15
  18. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_experiment.py +9 -4
  19. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_http_client.py +28 -0
  20. hivekit-0.9.1/tests/test_login.py +255 -0
  21. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_main.py +398 -7
  22. hivekit-0.8.2/tests/test_login.py +0 -121
  23. {hivekit-0.8.2 → hivekit-0.9.1}/LICENSE +0 -0
  24. {hivekit-0.8.2 → hivekit-0.9.1}/README.md +0 -0
  25. {hivekit-0.8.2 → hivekit-0.9.1}/setup.cfg +0 -0
  26. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/__init__.py +0 -0
  27. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/__init__.py +0 -0
  28. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/auth_utils.py +0 -0
  29. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/credential_store.py +0 -0
  30. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/login_page.py +0 -0
  31. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/logo.svg +0 -0
  32. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/oidc_flow.py +0 -0
  33. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/auth/token_revoker.py +0 -0
  34. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/completers.py +0 -0
  35. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/skills/hive-setup/references/multi-evaluator.md +0 -0
  36. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/skills_install.py +0 -0
  37. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/update_check.py +0 -0
  38. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/__init__.py +0 -0
  39. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/archive.py +0 -0
  40. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/attachments.py +0 -0
  41. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/config_paths.py +0 -0
  42. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/config_sync.py +0 -0
  43. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/docker.py +0 -0
  44. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/logger.py +0 -0
  45. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/terminal.py +0 -0
  46. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/time.py +0 -0
  47. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/utils/upload.py +0 -0
  48. {hivekit-0.8.2 → hivekit-0.9.1}/src/cli/version.py +0 -0
  49. {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/dependency_links.txt +0 -0
  50. {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/entry_points.txt +0 -0
  51. {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/requires.txt +0 -0
  52. {hivekit-0.8.2 → hivekit-0.9.1}/src/hivekit.egg-info/top_level.txt +0 -0
  53. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_attachments.py +0 -0
  54. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_completers.py +0 -0
  55. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_config_sync.py +0 -0
  56. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_logout.py +0 -0
  57. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_overrides.py +0 -0
  58. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_push_image.py +0 -0
  59. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_time_utils.py +0 -0
  60. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_update_check.py +0 -0
  61. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_upload.py +0 -0
  62. {hivekit-0.8.2 → hivekit-0.9.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivekit
3
- Version: 0.8.2
3
+ Version: 0.9.1
4
4
  Summary: Toolkits for using hive agents, including CLI and SDK.
5
5
  License:
6
6
  Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hivekit"
7
- version = "0.8.2"
7
+ version = "0.9.1"
8
8
  description = "Toolkits for using hive agents, including CLI and SDK."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,204 @@
1
+ # Copyright (C) 2026 Hiverge
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """
6
+ OIDC device authorization login flow (RFC 8628).
7
+
8
+ Used on headless machines (such as VMs) where no browser is
9
+ available to receive a redirect. The user is shown a URL containing a code
10
+ and the CLI polls the token endpoint until they have authorized.
11
+ """
12
+
13
+ import logging
14
+ import time
15
+ from typing import Any, Callable, Dict, Optional, Protocol
16
+
17
+ import requests
18
+ from rich.console import Console
19
+
20
+ from cli.utils.url_utils import build_oidc_endpoints
21
+
22
+ logger = logging.getLogger("hivekit")
23
+
24
+ _OIDC_CLIENT_ID = "hiverge"
25
+ _SCOPE = "openid offline_access"
26
+ _DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
27
+ _REQUEST_TIMEOUT = 30.0
28
+
29
+ # RFC 8628 section 3.5: on a slow_down response the client must increase the
30
+ # polling interval by five seconds.
31
+ _SLOW_DOWN_INCREMENT = 5
32
+
33
+
34
+ class DeviceAuthorizationError(Exception):
35
+ """
36
+ Raised when the device authorization flow fails or is denied.
37
+ """
38
+
39
+
40
+ class _PostCallable(Protocol):
41
+ """
42
+ Protocol matching the signature of `requests.post` for dependency injection.
43
+ """
44
+
45
+ def __call__(
46
+ self,
47
+ url: str,
48
+ data: Dict[str, str],
49
+ timeout: float,
50
+ verify: bool,
51
+ ) -> requests.Response:
52
+ """
53
+ Make an HTTP POST request and return the response.
54
+ """
55
+
56
+
57
+ class OidcDeviceLoginFlow:
58
+ """
59
+ Orchestrates the OIDC device authorization login flow.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ identity_base_url: str,
65
+ organization_id: str,
66
+ post: _PostCallable = requests.post,
67
+ sleep: Callable[[float], None] = time.sleep,
68
+ console: Optional[Console] = None,
69
+ insecure: bool = False,
70
+ ) -> None:
71
+ """
72
+ Initialize the device authorization login flow.
73
+
74
+ Args:
75
+ identity_base_url: The base URL for the identity provider.
76
+ organization_id: The organization (Keycloak realm) to authenticate against.
77
+ post: Callable used to make HTTP POST requests (injected for testability).
78
+ sleep: Callable used to wait between polls (injected for testability).
79
+ console: Rich console for output (defaults to a new Console).
80
+ insecure: If True, disable SSL certificate verification on requests.
81
+ """
82
+ self._identity_base_url = identity_base_url
83
+ self._organization_id = organization_id
84
+ self._post = post
85
+ self._sleep = sleep
86
+ self._console = console or Console()
87
+ self._insecure = insecure
88
+
89
+ def login(self) -> Dict[str, Any]:
90
+ """
91
+ Run the full device authorization login flow.
92
+
93
+ Requests a device code, prompts the user to visit the verification URL
94
+ and enter the user code, then polls the token endpoint until the user
95
+ authorizes the device and returns the token dict.
96
+
97
+ Raises:
98
+ DeviceAuthorizationError: If the request is denied, the device code
99
+ expires, or the identity provider returns an unexpected error.
100
+ """
101
+ endpoints = build_oidc_endpoints(
102
+ identity_base_url=self._identity_base_url,
103
+ organization_id=self._organization_id,
104
+ )
105
+ authorization = self._request_device_code(
106
+ device_authorization_endpoint=endpoints["device_authorization_endpoint"],
107
+ )
108
+ self._display_instructions(authorization=authorization)
109
+ return self._poll_for_token(
110
+ token_endpoint=endpoints["token_endpoint"],
111
+ authorization=authorization,
112
+ )
113
+
114
+ def _request_device_code(self, device_authorization_endpoint: str) -> Dict[str, Any]:
115
+ """
116
+ Request a device and user code from the device authorization endpoint.
117
+ """
118
+ response = self._post(
119
+ url=device_authorization_endpoint,
120
+ data={"client_id": _OIDC_CLIENT_ID, "scope": _SCOPE},
121
+ timeout=_REQUEST_TIMEOUT,
122
+ verify=not self._insecure,
123
+ )
124
+ if not response.ok:
125
+ raise DeviceAuthorizationError(
126
+ f"Device authorization request to {device_authorization_endpoint} failed "
127
+ f"with status {response.status_code}: {response.text}"
128
+ )
129
+ return response.json()
130
+
131
+ def _display_instructions(self, authorization: Dict[str, Any]) -> None:
132
+ """
133
+ Print the verification URL and user code with instructions for the user.
134
+
135
+ When the identity provider supplies ``verification_uri_complete`` the URL
136
+ embeds the user code, so the user only needs to visit it and the code is
137
+ not shown separately. Otherwise the user is asked to enter the code on
138
+ the plain verification page.
139
+ """
140
+ verification_uri_complete = authorization.get("verification_uri_complete")
141
+ if verification_uri_complete is not None:
142
+ self._console.print(
143
+ "\n[yellow]To log in, visit the following URL on any device:[/yellow]"
144
+ )
145
+ self._console.print(
146
+ verification_uri_complete,
147
+ style=f"link {verification_uri_complete}",
148
+ soft_wrap=True,
149
+ )
150
+ else:
151
+ verification_uri = authorization["verification_uri"]
152
+ self._console.print(
153
+ "\n[yellow]To log in, visit the following URL on any device and enter the "
154
+ "code below:[/yellow]"
155
+ )
156
+ self._console.print(verification_uri, style=f"link {verification_uri}", soft_wrap=True)
157
+ self._console.print(f"[bold]Code:[/bold] {authorization['user_code']}")
158
+
159
+ def _poll_for_token(self, token_endpoint: str, authorization: Dict[str, Any]) -> Dict[str, Any]:
160
+ """
161
+ Poll the token endpoint until the user authorizes the device.
162
+ """
163
+ device_code = authorization["device_code"]
164
+ interval = authorization.get("interval", 5)
165
+ while True:
166
+ self._sleep(interval)
167
+ response = self._post(
168
+ url=token_endpoint,
169
+ data={
170
+ "grant_type": _DEVICE_CODE_GRANT_TYPE,
171
+ "device_code": device_code,
172
+ "client_id": _OIDC_CLIENT_ID,
173
+ },
174
+ timeout=_REQUEST_TIMEOUT,
175
+ verify=not self._insecure,
176
+ )
177
+ if response.ok:
178
+ return response.json()
179
+ interval = self._handle_poll_error(response=response, interval=interval)
180
+
181
+ def _handle_poll_error(self, response: requests.Response, interval: int) -> int:
182
+ """
183
+ Interpret an error response while polling and return the next interval.
184
+
185
+ Returns the (possibly increased) polling interval when the user has not
186
+ yet authorized. Raises when the authorization is denied, has expired, or
187
+ the error is otherwise unrecoverable.
188
+ """
189
+ error = response.json().get("error")
190
+ if error == "authorization_pending":
191
+ return interval
192
+ if error == "slow_down":
193
+ return interval + _SLOW_DOWN_INCREMENT
194
+ if error == "access_denied":
195
+ raise DeviceAuthorizationError(
196
+ "Access was denied. Please contact your Hiverge administrator."
197
+ )
198
+ if error == "expired_token":
199
+ raise DeviceAuthorizationError(
200
+ "The login code expired before it was used. Please run `hive login` again."
201
+ )
202
+ raise DeviceAuthorizationError(
203
+ f"Device authorization failed with status {response.status_code}: {response.text}"
204
+ )
@@ -11,12 +11,13 @@ and re-authenticating when tokens expire.
11
11
 
12
12
  import logging
13
13
  import os
14
- from typing import Any, Dict
14
+ from typing import Any, Dict, Protocol
15
15
 
16
16
  from authlib.integrations.requests_client import OAuth2Session
17
17
 
18
18
  from cli.auth.auth_utils import get_machine_id
19
19
  from cli.auth.credential_store import CredentialStore, create_credential_store
20
+ from cli.auth.device_flow import OidcDeviceLoginFlow
20
21
  from cli.auth.oidc_flow import OidcLoginFlow
21
22
  from cli.utils.config_paths import get_config_dir
22
23
  from cli.utils.url_utils import build_oidc_endpoints, derive_identity_base_url
@@ -26,6 +27,17 @@ logger = logging.getLogger("hivekit")
26
27
  _OIDC_CLIENT_ID = "hiverge"
27
28
 
28
29
 
30
+ class LoginFlow(Protocol):
31
+ """
32
+ Interface for an interactive OIDC login flow that yields a token.
33
+ """
34
+
35
+ def login(self) -> Dict[str, Any]:
36
+ """
37
+ Run the login flow and return the resulting token dict.
38
+ """
39
+
40
+
29
41
  class OidcSessionManager:
30
42
  """
31
43
  Creates and refreshes OAuth2 sessions using OIDC.
@@ -35,7 +47,7 @@ class OidcSessionManager:
35
47
  self,
36
48
  organization_id: str,
37
49
  credential_store: CredentialStore,
38
- login_flow: OidcLoginFlow,
50
+ login_flow: LoginFlow,
39
51
  token_endpoint: str,
40
52
  insecure: bool = False,
41
53
  ) -> None:
@@ -114,6 +126,7 @@ def create_session_manager(
114
126
  organization_id: str,
115
127
  base_url: str,
116
128
  insecure: bool = False,
129
+ use_device_flow: bool = False,
117
130
  ) -> OidcSessionManager:
118
131
  """
119
132
  Create an OidcSessionManager with all required dependencies.
@@ -125,6 +138,8 @@ def create_session_manager(
125
138
  organization_id: The organization (Keycloak realm) to authenticate against.
126
139
  base_url: The API base URL, used to derive the identity provider URL.
127
140
  insecure: If True, disable SSL certificate verification.
141
+ use_device_flow: If True, use the device authorization flow (for headless
142
+ machines) instead of the browser-based flow.
128
143
 
129
144
  Returns:
130
145
  A configured OidcSessionManager.
@@ -134,10 +149,11 @@ def create_session_manager(
134
149
  clients_dir=os.path.join(get_config_dir(), "clients"),
135
150
  machine_id_func=get_machine_id,
136
151
  )
137
- login_flow = OidcLoginFlow(
152
+ login_flow = _create_login_flow(
138
153
  identity_base_url=identity_base_url,
139
154
  organization_id=organization_id,
140
155
  insecure=insecure,
156
+ use_device_flow=use_device_flow,
141
157
  )
142
158
  endpoints = build_oidc_endpoints(
143
159
  identity_base_url=identity_base_url,
@@ -150,3 +166,25 @@ def create_session_manager(
150
166
  token_endpoint=endpoints["token_endpoint"],
151
167
  insecure=insecure,
152
168
  )
169
+
170
+
171
+ def _create_login_flow(
172
+ identity_base_url: str,
173
+ organization_id: str,
174
+ insecure: bool,
175
+ use_device_flow: bool,
176
+ ) -> LoginFlow:
177
+ """
178
+ Create the browser-based or device authorization login flow.
179
+ """
180
+ if use_device_flow:
181
+ return OidcDeviceLoginFlow(
182
+ identity_base_url=identity_base_url,
183
+ organization_id=organization_id,
184
+ insecure=insecure,
185
+ )
186
+ return OidcLoginFlow(
187
+ identity_base_url=identity_base_url,
188
+ organization_id=organization_id,
189
+ insecure=insecure,
190
+ )
@@ -30,12 +30,30 @@ class BaseModel(pydantic.BaseModel):
30
30
  model_config = ConfigDict(extra="forbid", coerce_numbers_to_str=True)
31
31
 
32
32
 
33
- # Kubernetes caps DNS labels and label values at 63 bytes. The operator derives
34
- # object names/labels by appending suffixes to the experiment name (the longest
35
- # is "-coordinator"), so reserve that headroom to keep the derived names valid.
33
+ # Pods created by a Job or Deployment are named "<prefix><5 random chars>".
34
+ # Our pod prefixes are:
35
+ # coordinator: "<name>-coordinator-"
36
+ # sandbox: "<name>-sandbox-<rs-hash>-"
37
+ #
38
+ # The AWS network policy agent identifies a pod by its name minus the last
39
+ # '-' segment. If the coordinator and sandbox pods end up with the same
40
+ # identity, the coordinator will inherit the sandbox's internet-only
41
+ # egress policy (so it cannot reach any in-cluster service, like the database).
42
+ # Reserving room for "-coordinator-", the longer of the two, keeps every pod's
43
+ # role intact.
44
+ #
45
+ # For example, a 51-char <name> leaves only 7 chars for the role:
46
+ # coordinator: "<name>-coordi" + "6sftc" -> identity "<name>"
47
+ # sandbox: "<name>-sandbo" + "4nxt7" -> identity "<name>" (same, broken)
48
+ # while a 45-char <name> keeps both roles:
49
+ # coordinator: "<name>-coordinator-" + "abcde" -> identity "<name>-coordinator"
50
+ # sandbox: "<name>-sandbox-69df" + "abcde" -> identity "<name>-sandbox"
36
51
  K8S_LABEL_MAX_LENGTH = 63
37
- RESERVED_NAME_SUFFIX_LENGTH = len("-coordinator")
38
- MAX_EXPERIMENT_NAME_LENGTH = K8S_LABEL_MAX_LENGTH - RESERVED_NAME_SUFFIX_LENGTH
52
+ K8S_GENERATED_NAME_SUFFIX_LENGTH = 5
53
+ RESERVED_NAME_SUFFIX_LENGTH = len("-coordinator-")
54
+ MAX_EXPERIMENT_NAME_LENGTH = (
55
+ K8S_LABEL_MAX_LENGTH - K8S_GENERATED_NAME_SUFFIX_LENGTH - RESERVED_NAME_SUFFIX_LENGTH
56
+ )
39
57
  # A name ending in '-' gets a random uniqueness suffix appended (see
40
58
  # unique_suffix), which eats into the budget the user actually controls (their
41
59
  # typed base name).
@@ -77,11 +95,14 @@ KNOWN_MODELS = frozenset(
77
95
  "anthropic.claude-opus-4-7",
78
96
  "anthropic.claude-opus-4-8",
79
97
  "anthropic.claude-opus-5",
98
+ "anthropic.claude-opus-5-5",
80
99
  "anthropic.claude-sonnet-4-6",
81
100
  "anthropic.claude-sonnet-5",
82
101
  "moonshotai.kimi-k2.5",
83
102
  "nvidia.nemotron-super-3-120b",
84
103
  "openai.gpt-6-astra",
104
+ "openai.gpt-6-luna",
105
+ "openai.gpt-6-sol",
85
106
  "openai.gpt-5.6-luna",
86
107
  "openai.gpt-5.6-sol",
87
108
  "openai.gpt-5.6-terra",
@@ -244,10 +265,6 @@ class SandboxConfig(BaseModel):
244
265
 
245
266
 
246
267
  class PromptConfig(BaseModel):
247
- enable_evolution: bool = Field(
248
- default=False,
249
- description="Whether to enable evolution for the experiment. Default to False.",
250
- )
251
268
  context: Optional[str] = Field(
252
269
  default=None,
253
270
  description="Some useful experiment-specific context to provide to the Hive.",
@@ -266,6 +283,20 @@ class PromptConfig(BaseModel):
266
283
  ),
267
284
  )
268
285
 
286
+ @model_validator(mode="before")
287
+ @classmethod
288
+ def drop_enable_evolution(cls, values):
289
+ """Accept and ignore the removed enable_evolution field without mutating the input."""
290
+ if not isinstance(values, dict) or "enable_evolution" not in values:
291
+ return values
292
+ values = dict(values)
293
+ values.pop("enable_evolution")
294
+ logger.warning(
295
+ "prompt.enable_evolution is deprecated and has no effect. "
296
+ "Remove it from your config to silence this warning."
297
+ )
298
+ return values
299
+
269
300
  @model_validator(mode="before")
270
301
  @classmethod
271
302
  def migrate_arxiv_ids(cls, values):
@@ -513,6 +544,42 @@ class ProviderConfig(BaseModel):
513
544
  "non-interruptible capacity only. Unset (the default): no constraint, "
514
545
  "the cheapest available capacity is used.",
515
546
  )
547
+ regions: Optional[list[str]] = Field(
548
+ default=None,
549
+ description="The regions the sandbox may be placed in: us, or all for "
550
+ "every region the platform offers, given as a single value or a list. "
551
+ "Unset (the default): your default regions.",
552
+ )
553
+
554
+ @field_validator("regions", mode="before")
555
+ @classmethod
556
+ def wrap_single_region(cls, v):
557
+ # `regions: all` or `regions: us` reads better than a one-item list.
558
+ if isinstance(v, str):
559
+ return [v]
560
+ return v
561
+
562
+ @field_validator("regions")
563
+ @classmethod
564
+ def validate_regions(cls, v):
565
+ # Only the format is checked here; which regions exist is up to the
566
+ # backend, so the list can grow without a CLI release.
567
+ if v is None:
568
+ return v
569
+ if not v:
570
+ raise ValueError("regions must not be empty; omit it to allow any region")
571
+ regions = []
572
+ for region in v:
573
+ region = region.strip().lower()
574
+ if not re.fullmatch(r"[a-z0-9_-]+", region):
575
+ raise ValueError(
576
+ f"invalid region '{region}', must contain only letters, digits, '-' and '_'"
577
+ )
578
+ # The backend treats regions as a set, so drop repeats rather than
579
+ # have it reject them.
580
+ if region not in regions:
581
+ regions.append(region)
582
+ return regions
516
583
 
517
584
 
518
585
  class AgentModelConfig(BaseModel):
@@ -114,6 +114,8 @@ def build_experiment_crd(config: HiveConfig) -> Dict[str, Any]:
114
114
  provider["name"] = config.provider.name
115
115
  if config.provider.spot is not None:
116
116
  provider["spot"] = config.provider.spot
117
+ if config.provider.regions:
118
+ provider["regions"] = config.provider.regions
117
119
  if provider:
118
120
  experiment["spec"]["provider"] = provider
119
121
 
@@ -124,8 +126,6 @@ def build_experiment_crd(config: HiveConfig) -> Dict[str, Any]:
124
126
  experiment["spec"]["prompt"]["context"] = config.prompt.context
125
127
  if config.prompt.ideas:
126
128
  experiment["spec"]["prompt"]["ideas"] = config.prompt.ideas
127
- if config.prompt.enable_evolution:
128
- experiment["spec"]["prompt"]["enableEvolution"] = config.prompt.enable_evolution
129
129
 
130
130
  # Add optional agent selection
131
131
  if config.agents:
@@ -0,0 +1,131 @@
1
+ # Copyright (C) 2026 Hiverge
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Choose export destinations and save complete, validated downloads using bounded memory."""
6
+
7
+ import gzip
8
+ import os
9
+ import tempfile
10
+ from dataclasses import dataclass
11
+ from email.message import Message
12
+ from pathlib import Path
13
+
14
+ import requests
15
+
16
+ from cli.http_client import DOWNLOAD_SYSTEM_ERROR, DownloadError
17
+
18
+ _CHUNK_SIZE = 1 << 16
19
+
20
+
21
+ def resolve_download_output(
22
+ output: str | None, *, directory_only: bool = False
23
+ ) -> tuple[Path, str | None]:
24
+ """Return the destination directory and optional filename override."""
25
+ if output is None:
26
+ return Path.cwd(), None
27
+ if not output:
28
+ raise DownloadError("--output must name a file or directory.")
29
+ path = Path(output)
30
+ if path.is_dir():
31
+ return path, None
32
+ if output.endswith(("/", os.sep)):
33
+ if path.exists():
34
+ raise DownloadError(f"Output is not a directory: {path}")
35
+ path.mkdir(parents=True, exist_ok=True)
36
+ return path, None
37
+ if directory_only:
38
+ raise DownloadError("Full Export requires a directory. Use -o 'DIRECTORY/'.")
39
+ if output.replace("\\", "/").rsplit("/", 1)[-1] in {".", ".."}:
40
+ raise DownloadError("--output must name a file or directory.")
41
+ _check_destination(path)
42
+ return path.parent, path.name
43
+
44
+
45
+ def _check_distinct(main: Path, reports: Path) -> None:
46
+ resolved_main, resolved_reports = main.resolve(), reports.resolve()
47
+ same = resolved_main == resolved_reports or (
48
+ main.exists() and reports.exists() and main.samefile(reports)
49
+ )
50
+ if same:
51
+ raise DownloadError("The main output and evolution reports must have different filenames.")
52
+
53
+
54
+ def _check_destination(destination: Path) -> None:
55
+ if destination.is_dir():
56
+ raise DownloadError(f"Output is a directory: {destination}")
57
+ if not destination.parent.is_dir():
58
+ raise DownloadError(f"Output directory does not exist: {destination.parent}")
59
+
60
+
61
+ def _response_filename(response: requests.Response) -> str:
62
+ disposition = Message()
63
+ disposition["Content-Disposition"] = response.headers.get("Content-Disposition", "")
64
+ filename = disposition.get_filename()
65
+ # AlphaTrion owns sanitization. Reject malformed filenames rather than altering them.
66
+ if (
67
+ not filename
68
+ or filename in {".", ".."}
69
+ or "/" in filename
70
+ or "\\" in filename
71
+ or any(ord(char) < 32 or ord(char) == 127 for char in filename)
72
+ ):
73
+ raise DownloadError(DOWNLOAD_SYSTEM_ERROR)
74
+ return filename
75
+
76
+
77
+ @dataclass
78
+ class ExportOutput:
79
+ directory: Path
80
+ output: str | None
81
+
82
+ @classmethod
83
+ def prepare(cls, name: str, full_export: bool, output: str | None) -> "ExportOutput":
84
+ """Validate the output path before authentication or download."""
85
+ if not name:
86
+ raise DownloadError("Experiment name must be non-empty.")
87
+ directory, filename = resolve_download_output(output, directory_only=full_export)
88
+ return cls(directory, filename)
89
+
90
+ def destination(self, response: requests.Response, *, source: Path | None = None) -> Path:
91
+ """Honor server filenames and check actual destinations before consuming bytes."""
92
+ filename = _response_filename(response)
93
+ destination = self.directory / (self.output if source is None and self.output else filename)
94
+ _check_destination(destination)
95
+ if source is not None:
96
+ _check_distinct(source, destination)
97
+ return destination
98
+
99
+
100
+ def save_export(
101
+ response: requests.Response, destination: Path, *, suppress_empty: bool = False
102
+ ) -> bool:
103
+ """Publish only a complete gzip file; return False for suppressed empty reports.
104
+
105
+ The caller owns the response context. Temporary output lives beside the
106
+ destination so replacement cannot cross filesystem boundaries.
107
+ """
108
+ temporary: Path | None = None
109
+ try:
110
+ with tempfile.NamedTemporaryFile(
111
+ mode="wb", dir=destination.parent, prefix=".hive-export-", suffix=".tmp", delete=False
112
+ ) as output:
113
+ temporary = Path(output.name)
114
+ # Preserve AlphaTrion's compressed bytes, even if a proxy adds Content-Encoding.
115
+ for chunk in response.raw.stream(_CHUNK_SIZE, decode_content=False):
116
+ output.write(chunk)
117
+ with temporary.open("rb") as compressed:
118
+ # gzip accepts a zero-byte input, but a completed export must contain a frame.
119
+ if compressed.read(2) != b"\x1f\x8b":
120
+ raise DownloadError(DOWNLOAD_SYSTEM_ERROR)
121
+ has_content = False
122
+ with gzip.open(temporary, "rb") as decoded:
123
+ while decoded.read(_CHUNK_SIZE):
124
+ has_content = True
125
+ if suppress_empty and not has_content:
126
+ return False
127
+ os.replace(temporary, destination)
128
+ return True
129
+ finally:
130
+ if temporary is not None:
131
+ temporary.unlink(missing_ok=True)