harbor-hypeman 0.1.0__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.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: harbor-hypeman
3
+ Version: 0.1.0
4
+ Summary: Hypeman environment backend for Harbor Framework
5
+ Author: Kernel
6
+ License-Expression: Apache-2.0
7
+ Requires-Dist: dockerfile-parse>=2.0.1
8
+ Requires-Dist: harbor>=0.21.0,<0.22
9
+ Requires-Dist: hypeman>=0.1.0,<0.2
10
+ Requires-Dist: pathspec>=1.0.3
11
+ Requires-Python: >=3.12
12
+ Project-URL: Changelog, https://github.com/kernel/harbor-hypeman/blob/main/CHANGELOG.md
13
+ Project-URL: Repository, https://github.com/kernel/harbor-hypeman
14
+ Description-Content-Type: text/markdown
15
+
16
+ # harbor-hypeman
17
+
18
+ Run [Harbor Framework](https://github.com/harbor-framework/harbor) evaluations in [Hypeman](https://github.com/kernel/hypeman) sandboxes.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ uv tool install harbor --with harbor-hypeman
24
+ ```
25
+
26
+ Set the Hypeman credentials used by the generated Python SDK:
27
+
28
+ ```bash
29
+ export HYPEMAN_API_KEY=...
30
+ export HYPEMAN_BASE_URL=https://hypeman.example.com
31
+ ```
32
+
33
+ `HYPEMAN_BASE_URL` is optional when Hypeman is available at the SDK default, `http://localhost:4973`.
34
+
35
+ ## Run
36
+
37
+ Pass the third-party environment import path to Harbor:
38
+
39
+ ```bash
40
+ harbor run \
41
+ --dataset terminal-bench@2.0 \
42
+ --agent codex \
43
+ --model openai/gpt-5.6 \
44
+ --env harbor_hypeman:HypemanEnvironment
45
+ ```
46
+
47
+ The backend supports task environments defined by either:
48
+
49
+ - `[environment].docker_image` in `task.toml`
50
+ - `environment/Dockerfile`
51
+
52
+ CPU, memory, and storage values map to Hypeman vCPUs, base memory, and writable overlay size. Harbor `public` and `no-network` modes map to attached and detached Hypeman networking.
53
+
54
+ ## Behavior
55
+
56
+ - Dockerfile builds are cached by Harbor environment content hash and rebuilt with `--force-build`.
57
+ - Commands execute once through Hypeman's WebSocket API; transport failures after dispatch are not retried.
58
+ - Hypeman currently returns merged stdout/stderr. Harbor receives that output as `stdout` and `stderr=None`.
59
+ - Uploads and downloads use Hypeman's archive-aware WebSocket copy API.
60
+ - `stop(delete=False)` stops and preserves the instance; `stop(delete=True)` deletes it.
61
+
62
+ ## Not supported
63
+
64
+ - Docker Compose or sidecar services
65
+ - network allowlists or runtime network-policy changes
66
+ - GPUs, TPUs, and Windows containers
67
+ - interactive `harbor ... --attach`
@@ -0,0 +1,52 @@
1
+ # harbor-hypeman
2
+
3
+ Run [Harbor Framework](https://github.com/harbor-framework/harbor) evaluations in [Hypeman](https://github.com/kernel/hypeman) sandboxes.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ uv tool install harbor --with harbor-hypeman
9
+ ```
10
+
11
+ Set the Hypeman credentials used by the generated Python SDK:
12
+
13
+ ```bash
14
+ export HYPEMAN_API_KEY=...
15
+ export HYPEMAN_BASE_URL=https://hypeman.example.com
16
+ ```
17
+
18
+ `HYPEMAN_BASE_URL` is optional when Hypeman is available at the SDK default, `http://localhost:4973`.
19
+
20
+ ## Run
21
+
22
+ Pass the third-party environment import path to Harbor:
23
+
24
+ ```bash
25
+ harbor run \
26
+ --dataset terminal-bench@2.0 \
27
+ --agent codex \
28
+ --model openai/gpt-5.6 \
29
+ --env harbor_hypeman:HypemanEnvironment
30
+ ```
31
+
32
+ The backend supports task environments defined by either:
33
+
34
+ - `[environment].docker_image` in `task.toml`
35
+ - `environment/Dockerfile`
36
+
37
+ CPU, memory, and storage values map to Hypeman vCPUs, base memory, and writable overlay size. Harbor `public` and `no-network` modes map to attached and detached Hypeman networking.
38
+
39
+ ## Behavior
40
+
41
+ - Dockerfile builds are cached by Harbor environment content hash and rebuilt with `--force-build`.
42
+ - Commands execute once through Hypeman's WebSocket API; transport failures after dispatch are not retried.
43
+ - Hypeman currently returns merged stdout/stderr. Harbor receives that output as `stdout` and `stderr=None`.
44
+ - Uploads and downloads use Hypeman's archive-aware WebSocket copy API.
45
+ - `stop(delete=False)` stops and preserves the instance; `stop(delete=True)` deletes it.
46
+
47
+ ## Not supported
48
+
49
+ - Docker Compose or sidecar services
50
+ - network allowlists or runtime network-policy changes
51
+ - GPUs, TPUs, and Windows containers
52
+ - interactive `harbor ... --attach`
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "harbor-hypeman"
3
+ version = "0.1.0"
4
+ description = "Hypeman environment backend for Harbor Framework"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "Kernel" }]
9
+ dependencies = [
10
+ "dockerfile-parse>=2.0.1",
11
+ "harbor>=0.21.0,<0.22",
12
+ "hypeman>=0.1.0,<0.2",
13
+ "pathspec>=1.0.3",
14
+ ]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/kernel/harbor-hypeman"
18
+ Changelog = "https://github.com/kernel/harbor-hypeman/blob/main/CHANGELOG.md"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "pytest>=8.4.2",
23
+ "pytest-asyncio>=1.2.0",
24
+ "ruff>=0.15.0",
25
+ "ty>=0.0.49",
26
+ ]
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.8.4,<0.9.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+ asyncio_mode = "auto"
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "I", "UP", "B"]
38
+
39
+ [tool.ty.environment]
40
+ python = ".venv"
@@ -0,0 +1,3 @@
1
+ from .environment import HypemanEnvironment
2
+
3
+ __all__ = ["HypemanEnvironment"]
@@ -0,0 +1,392 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import tarfile
10
+ import tempfile
11
+ from pathlib import Path, PurePosixPath
12
+ from typing import Any, override
13
+
14
+ from harbor.environments.base import (
15
+ BaseEnvironment,
16
+ ExecResult,
17
+ SandboxBuildFailedError,
18
+ )
19
+ from harbor.environments.capabilities import (
20
+ EnvironmentCapabilities,
21
+ EnvironmentResourceCapabilities,
22
+ )
23
+ from harbor.environments.definition import (
24
+ effective_exec_cwd,
25
+ parse_dockerfile_workdir,
26
+ require_agent_environment_definition,
27
+ should_use_prebuilt_docker_image,
28
+ )
29
+ from harbor.models.task.config import EnvironmentConfig, NetworkMode
30
+ from harbor.models.trial.paths import TrialPaths
31
+ from hypeman import AsyncHypeman, NotFoundError, omit
32
+ from hypeman.lib import (
33
+ cp_from_instance_async,
34
+ cp_to_instance_async,
35
+ exec_async,
36
+ )
37
+ from pathspec import GitIgnoreSpec
38
+
39
+ _BUILD_TAG = "harbor.environment_id"
40
+ _TERMINAL_BUILD_STATES = frozenset({"failed", "cancelled"})
41
+
42
+
43
+ class HypemanEnvironment(BaseEnvironment):
44
+ """Run a single-container Harbor environment on Hypeman."""
45
+
46
+ @classmethod
47
+ @override
48
+ def preflight(cls) -> None:
49
+ if not os.environ.get("HYPEMAN_API_KEY"):
50
+ raise SystemExit(
51
+ "Hypeman requires HYPEMAN_API_KEY to be set. "
52
+ "Set HYPEMAN_BASE_URL as well when the API is not on localhost."
53
+ )
54
+
55
+ def __init__(
56
+ self,
57
+ environment_dir: Path,
58
+ environment_name: str,
59
+ session_id: str,
60
+ trial_paths: TrialPaths,
61
+ task_env_config: EnvironmentConfig,
62
+ *args: object,
63
+ hypeman_client: AsyncHypeman | None = None,
64
+ **kwargs: Any,
65
+ ) -> None:
66
+ self._client = hypeman_client or AsyncHypeman(max_retries=0)
67
+ self._instance_id: str | None = None
68
+ self._dockerfile_workdir = parse_dockerfile_workdir(
69
+ environment_dir / "Dockerfile"
70
+ )
71
+ super().__init__(
72
+ environment_dir=environment_dir,
73
+ environment_name=environment_name,
74
+ session_id=session_id,
75
+ trial_paths=trial_paths,
76
+ task_env_config=task_env_config,
77
+ **kwargs,
78
+ )
79
+
80
+ @staticmethod
81
+ @override
82
+ def type() -> str:
83
+ return "hypeman"
84
+
85
+ @classmethod
86
+ @override
87
+ def resource_capabilities(cls) -> EnvironmentResourceCapabilities:
88
+ return EnvironmentResourceCapabilities(cpu_limit=True, memory_limit=True)
89
+
90
+ @property
91
+ @override
92
+ def capabilities(self) -> EnvironmentCapabilities:
93
+ return EnvironmentCapabilities(disable_internet=True)
94
+
95
+ @override
96
+ def _validate_definition(self) -> None:
97
+ require_agent_environment_definition(
98
+ self.environment_dir,
99
+ docker_image=self.task_env_config.docker_image,
100
+ )
101
+ if (self.environment_dir / "docker-compose.yaml").exists():
102
+ raise ValueError(
103
+ "Hypeman supports Dockerfile and prebuilt-image environments, "
104
+ "not Docker Compose."
105
+ )
106
+
107
+ @override
108
+ async def start(self, force_build: bool) -> None:
109
+ if self._instance_id is not None:
110
+ raise RuntimeError("Hypeman environment is already started.")
111
+
112
+ image = await self._prepare_image(force_build)
113
+ instance = await self._client.instances.create(
114
+ image=image,
115
+ name=self._instance_name(),
116
+ entrypoint=["/bin/sh", "-c"],
117
+ cmd=["while true; do sleep 3600; done"],
118
+ env=self._startup_env(),
119
+ network={"enabled": self.network_policy.network_mode == NetworkMode.PUBLIC},
120
+ tags={
121
+ "harbor.managed": "true",
122
+ "harbor.environment_id": self.environment_id,
123
+ "harbor.session_id": self.session_id,
124
+ },
125
+ vcpus=self._effective_cpus if self._effective_cpus is not None else omit,
126
+ size=(
127
+ f"{self._effective_memory_mb}MB"
128
+ if self._effective_memory_mb is not None
129
+ else omit
130
+ ),
131
+ overlay_size=(
132
+ f"{self._effective_storage_mb}MB"
133
+ if self._effective_storage_mb is not None
134
+ else omit
135
+ ),
136
+ )
137
+ self._instance_id = instance.id
138
+
139
+ try:
140
+ await self._client.instances.wait(
141
+ instance.id,
142
+ state="Running",
143
+ api_timeout="5m",
144
+ timeout=310,
145
+ )
146
+ await self._ensure_workdir()
147
+ await self.ensure_dirs(self._mount_targets(writable_only=True))
148
+ await self._upload_environment_dir_after_start()
149
+ except BaseException:
150
+ try:
151
+ await self._client.instances.delete(instance.id)
152
+ except Exception as cleanup_error:
153
+ self.logger.warning(
154
+ "Failed to delete Hypeman instance %s after startup failed: %s",
155
+ instance.id,
156
+ cleanup_error,
157
+ )
158
+ finally:
159
+ self._instance_id = None
160
+ raise
161
+
162
+ async def _ensure_workdir(self) -> None:
163
+ workdir = self.task_env_config.workdir
164
+ if workdir is None:
165
+ return
166
+ result = await self.exec(
167
+ f"mkdir -p {shlex.quote(workdir)}",
168
+ cwd="/",
169
+ user="root",
170
+ )
171
+ if result.return_code != 0:
172
+ raise RuntimeError(
173
+ f"Failed to create Hypeman workdir {workdir!r}: "
174
+ f"{result.stdout or 'no output'}"
175
+ )
176
+
177
+ async def _prepare_image(self, force_build: bool) -> str:
178
+ docker_image = self.task_env_config.docker_image
179
+ if should_use_prebuilt_docker_image(
180
+ self.environment_dir,
181
+ docker_image=docker_image,
182
+ force_build=force_build,
183
+ ):
184
+ if docker_image is None:
185
+ raise RuntimeError("Prebuilt image selection requires docker_image.")
186
+ image = await self._client.images.create(
187
+ name=docker_image,
188
+ tags={_BUILD_TAG: self.environment_id},
189
+ )
190
+ return image.name
191
+
192
+ if not force_build:
193
+ cached_image = await self._cached_build_image()
194
+ if cached_image is not None:
195
+ return cached_image
196
+
197
+ return await self._build_image()
198
+
199
+ async def _cached_build_image(self) -> str | None:
200
+ builds = await self._client.builds.list(tags={_BUILD_TAG: self.environment_id})
201
+ for build in builds:
202
+ if build.status != "ready" or build.image_ref is None:
203
+ continue
204
+ try:
205
+ await self._client.images.get(build.image_ref)
206
+ except NotFoundError:
207
+ continue
208
+ return build.image_ref
209
+ return None
210
+
211
+ async def _build_image(self) -> str:
212
+ with tempfile.TemporaryDirectory() as temp_dir:
213
+ archive_path = Path(temp_dir) / "environment.tar.gz"
214
+ await asyncio.to_thread(
215
+ self._write_build_archive,
216
+ self.environment_dir,
217
+ archive_path,
218
+ )
219
+ with archive_path.open("rb") as source:
220
+ build = await self._client.builds.create(
221
+ source=source,
222
+ tags=json.dumps({_BUILD_TAG: self.environment_id}),
223
+ timeout_seconds=int(self.task_env_config.build_timeout_sec),
224
+ )
225
+
226
+ deadline = (
227
+ asyncio.get_running_loop().time() + self.task_env_config.build_timeout_sec
228
+ )
229
+ while build.status != "ready":
230
+ if build.status in _TERMINAL_BUILD_STATES:
231
+ raise SandboxBuildFailedError(
232
+ f"Hypeman build {build.id} ended in {build.status}: "
233
+ f"{build.error or 'no error details'}"
234
+ )
235
+ if asyncio.get_running_loop().time() >= deadline:
236
+ await self._client.builds.cancel(build.id)
237
+ raise TimeoutError(
238
+ f"Hypeman build {build.id} did not finish within "
239
+ f"{self.task_env_config.build_timeout_sec:g} seconds."
240
+ )
241
+ await asyncio.sleep(1)
242
+ build = await self._client.builds.get(build.id)
243
+
244
+ if build.image_ref is None:
245
+ raise SandboxBuildFailedError(
246
+ f"Hypeman build {build.id} completed without an image reference."
247
+ )
248
+ return build.image_ref
249
+
250
+ @staticmethod
251
+ def _write_build_archive(source_dir: Path, archive_path: Path) -> None:
252
+ dockerignore_path = source_dir / ".dockerignore"
253
+ dockerignore = (
254
+ GitIgnoreSpec.from_lines(dockerignore_path.read_text().splitlines())
255
+ if dockerignore_path.is_file()
256
+ else None
257
+ )
258
+
259
+ with tarfile.open(archive_path, "w:gz") as archive:
260
+ for path in sorted(source_dir.rglob("*")):
261
+ relative = path.relative_to(source_dir)
262
+ if {".git", "__pycache__"} & set(relative.parts):
263
+ continue
264
+ archive_name = relative.as_posix()
265
+ if (
266
+ dockerignore is not None
267
+ and archive_name not in {"Dockerfile", ".dockerignore"}
268
+ and dockerignore.match_file(
269
+ f"{archive_name}/" if path.is_dir() else archive_name
270
+ )
271
+ ):
272
+ continue
273
+ archive.add(path, arcname=archive_name, recursive=False)
274
+
275
+ def _instance_name(self) -> str:
276
+ session = re.sub(r"[^a-z0-9]+", "-", self.session_id.lower()).strip("-")
277
+ if not session:
278
+ session = "session"
279
+ suffix = self.environment_id[:8]
280
+ return f"harbor-{session[:46]}-{suffix}".strip("-")
281
+
282
+ @override
283
+ async def stop(self, delete: bool) -> None:
284
+ if self._instance_id is None:
285
+ return
286
+ instance_id = self._instance_id
287
+ if delete:
288
+ await self._client.instances.delete(instance_id)
289
+ self._instance_id = None
290
+ else:
291
+ await self._client.instances.stop(instance_id)
292
+
293
+ def _require_instance(self) -> str:
294
+ if self._instance_id is None:
295
+ raise RuntimeError("Hypeman environment has not been started.")
296
+ return self._instance_id
297
+
298
+ @override
299
+ async def exec(
300
+ self,
301
+ command: str,
302
+ cwd: str | None = None,
303
+ env: dict[str, str] | None = None,
304
+ timeout_sec: int | None = None,
305
+ user: str | int | None = None,
306
+ ) -> ExecResult:
307
+ user = self._resolve_user(user)
308
+ if user not in (None, "root", 0):
309
+ if isinstance(user, int):
310
+ user_name = f"$(getent passwd {user} | cut -d: -f1)"
311
+ else:
312
+ user_name = shlex.quote(user)
313
+ command = f"su {user_name} -s /bin/sh -c {shlex.quote(command)}"
314
+
315
+ result = await exec_async(
316
+ self._client,
317
+ self._require_instance(),
318
+ ["/bin/bash", "-lc", command],
319
+ cwd=effective_exec_cwd(
320
+ cwd,
321
+ self.task_env_config.workdir,
322
+ self._dockerfile_workdir,
323
+ ),
324
+ env=self._merge_env(env),
325
+ timeout=timeout_sec,
326
+ )
327
+ output = result.output.decode("utf-8", errors="replace")
328
+ callback = self._output_callback()
329
+ if callback is not None and output:
330
+ await callback(output, "stdout")
331
+ return ExecResult(stdout=output, stderr=None, return_code=result.exit_code)
332
+
333
+ @override
334
+ async def upload_file(self, source_path: Path | str, target_path: str) -> None:
335
+ await cp_to_instance_async(
336
+ self._client,
337
+ self._require_instance(),
338
+ source_path,
339
+ target_path,
340
+ archive=True,
341
+ )
342
+
343
+ @override
344
+ async def upload_dir(self, source_dir: Path | str, target_dir: str) -> None:
345
+ await cp_to_instance_async(
346
+ self._client,
347
+ self._require_instance(),
348
+ source_dir,
349
+ target_dir,
350
+ archive=True,
351
+ )
352
+
353
+ @override
354
+ async def download_file(self, source_path: str, target_path: Path | str) -> None:
355
+ target = Path(target_path)
356
+ target.parent.mkdir(parents=True, exist_ok=True)
357
+ with tempfile.TemporaryDirectory() as temp_dir:
358
+ temp = Path(temp_dir)
359
+ await cp_from_instance_async(
360
+ self._client,
361
+ self._require_instance(),
362
+ source_path,
363
+ temp,
364
+ archive=True,
365
+ )
366
+ downloaded = temp / PurePosixPath(source_path).name
367
+ if not downloaded.is_file():
368
+ raise RuntimeError(
369
+ f"Hypeman copy did not return the requested file {source_path!r}."
370
+ )
371
+ shutil.move(downloaded, target)
372
+
373
+ @override
374
+ async def download_dir(self, source_dir: str, target_dir: Path | str) -> None:
375
+ target = Path(target_dir)
376
+ target.mkdir(parents=True, exist_ok=True)
377
+ with tempfile.TemporaryDirectory() as temp_dir:
378
+ temp = Path(temp_dir)
379
+ await cp_from_instance_async(
380
+ self._client,
381
+ self._require_instance(),
382
+ source_dir,
383
+ temp,
384
+ archive=True,
385
+ )
386
+ downloaded = temp / PurePosixPath(source_dir.rstrip("/")).name
387
+ if not downloaded.is_dir():
388
+ raise RuntimeError(
389
+ "Hypeman copy did not return the requested directory "
390
+ f"{source_dir!r}."
391
+ )
392
+ shutil.copytree(downloaded, target, dirs_exist_ok=True)