agent-hitch 0.2.2 → 0.2.4
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/dist/src/backends/harbor/backend.js +91 -36
- package/dist/src/backends/harbor/backend.js.map +1 -1
- package/dist/src/backends/harbor/dataset-config.js +36 -0
- package/dist/src/backends/harbor/dataset-config.js.map +1 -0
- package/dist/src/cli/commands/eval.js +39 -3
- package/dist/src/cli/commands/eval.js.map +1 -1
- package/dist/src/cli/output.js +2 -1
- package/dist/src/cli/output.js.map +1 -1
- package/dist/src/evals/index.js +4 -2
- package/dist/src/evals/index.js.map +1 -1
- package/dist/src/evals/progress.js +195 -0
- package/dist/src/evals/progress.js.map +1 -0
- package/dist/src/evals/request.js +37 -1
- package/dist/src/evals/request.js.map +1 -1
- package/dist/src/evals/rerun.js +398 -0
- package/dist/src/evals/rerun.js.map +1 -0
- package/dist/src/evals/service.js +101 -6
- package/dist/src/evals/service.js.map +1 -1
- package/dist/src/evals/trial-import.js +113 -59
- package/dist/src/evals/trial-import.js.map +1 -1
- package/docs/schemas/eval-progress.schema.json +61 -0
- package/integrations/harbor/hitch_harbor_agent.py +363 -36
- package/package.json +1 -1
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
6
|
+
import errno
|
|
5
7
|
import hashlib
|
|
6
8
|
import json
|
|
9
|
+
import os
|
|
7
10
|
import re
|
|
8
11
|
import shlex
|
|
12
|
+
import shutil
|
|
9
13
|
import stat as stat_module
|
|
10
14
|
import tempfile
|
|
11
15
|
import uuid
|
|
12
|
-
from datetime import datetime
|
|
13
|
-
from pathlib import Path
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
14
18
|
from typing import Any
|
|
15
19
|
|
|
16
20
|
from harbor.agents.base import BaseAgent
|
|
@@ -19,9 +23,11 @@ from harbor.models.agent.context import AgentContext
|
|
|
19
23
|
|
|
20
24
|
CONTROLLER_RUNTIME_MANIFEST_VERSION = "2"
|
|
21
25
|
LOCAL_GIT_TRANSPORT_MANIFEST_VERSION = "1"
|
|
26
|
+
HARNESS_ARTIFACT_MANIFEST_VERSION = "1"
|
|
22
27
|
LOCAL_GIT_TRANSPORT_MAX_BYTES = 512 * 1024 * 1024
|
|
23
28
|
LOCAL_GIT_REMOTE_ROOT = "/opt/hitch-local-source"
|
|
24
29
|
HARNESS_ARTIFACT_REMOTE_ROOT = "/opt/hitch-harness-artifact"
|
|
30
|
+
HITCH_CONTAINER_STATE_ROOT = "/tmp/hitch-state"
|
|
25
31
|
|
|
26
32
|
|
|
27
33
|
class HitchHarborAgent(BaseAgent):
|
|
@@ -36,6 +42,7 @@ class HitchHarborAgent(BaseAgent):
|
|
|
36
42
|
candidate_id: str = "candidate-1",
|
|
37
43
|
controller_runtime_id: str | None = None,
|
|
38
44
|
harness_artifact: dict[str, Any] | None = None,
|
|
45
|
+
harness_artifact_cache_dir: str | None = None,
|
|
39
46
|
local_source_transport: dict[str, Any] | None = None,
|
|
40
47
|
hitch_timeout_ms: int = 900_000,
|
|
41
48
|
agent_args: list[str] | None = None,
|
|
@@ -52,6 +59,7 @@ class HitchHarborAgent(BaseAgent):
|
|
|
52
59
|
self.hitch_runtime_dir = Path(hitch_runtime_dir)
|
|
53
60
|
self.controller_runtime_id = controller_runtime_id
|
|
54
61
|
self.harness_artifact = dict(harness_artifact) if harness_artifact else None
|
|
62
|
+
self.harness_artifact_cache_dir = Path(harness_artifact_cache_dir) if harness_artifact_cache_dir else None
|
|
55
63
|
self.local_source_transport = dict(local_source_transport) if local_source_transport else None
|
|
56
64
|
self.candidate_id = candidate_id
|
|
57
65
|
self.hitch_timeout_ms = int(hitch_timeout_ms)
|
|
@@ -64,7 +72,9 @@ class HitchHarborAgent(BaseAgent):
|
|
|
64
72
|
self._hitch_version: str | None = None
|
|
65
73
|
self._entrypoint: str | None = None
|
|
66
74
|
self._artifact_manifest: dict[str, Any] | None = None
|
|
75
|
+
self._artifact_host_directory: Path | None = None
|
|
67
76
|
self._artifact_uploaded = False
|
|
77
|
+
self._artifact_transport_status: str | None = None
|
|
68
78
|
self._local_manifest: dict[str, Any] | None = None
|
|
69
79
|
|
|
70
80
|
@staticmethod
|
|
@@ -101,8 +111,14 @@ class HitchHarborAgent(BaseAgent):
|
|
|
101
111
|
if self.harness_artifact is not None:
|
|
102
112
|
try:
|
|
103
113
|
self._artifact_manifest = self._verify_harness_artifact_host()
|
|
114
|
+
self._artifact_host_directory = Path(str(self.harness_artifact["directory"]))
|
|
104
115
|
except Exception as error:
|
|
105
116
|
raise RuntimeError(f"hitch-artifact-materialize: {error}") from error
|
|
117
|
+
if self.harness_artifact_cache_dir is not None:
|
|
118
|
+
try:
|
|
119
|
+
self._verify_harness_artifact_cache_host()
|
|
120
|
+
except Exception as error:
|
|
121
|
+
raise RuntimeError(f"hitch-artifact-cache: {error}") from error
|
|
106
122
|
if self.local_source_transport is not None:
|
|
107
123
|
try:
|
|
108
124
|
self._local_manifest = self._verify_local_source_host()
|
|
@@ -116,42 +132,56 @@ class HitchHarborAgent(BaseAgent):
|
|
|
116
132
|
# bookkeeping and is not identity (spec §4.2).
|
|
117
133
|
await environment.upload_dir(payload_dir, "/opt/hitch")
|
|
118
134
|
await self._ensure_node(environment)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
self.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
await self.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
135
|
+
platform = await self._container_platform(environment)
|
|
136
|
+
node_version = await self._container_node_version(environment)
|
|
137
|
+
cache_lock = None
|
|
138
|
+
try:
|
|
139
|
+
if self._artifact_manifest is not None and self._artifact_compatible(
|
|
140
|
+
self._artifact_manifest, platform, node_version
|
|
141
|
+
):
|
|
142
|
+
await self._upload_harness_artifact(environment, self._artifact_host_directory)
|
|
143
|
+
self._artifact_transport_status = "uploaded"
|
|
144
|
+
elif self.harness_artifact_cache_dir is not None:
|
|
145
|
+
cache_lock = await self._acquire_artifact_cache_lock(platform, node_version)
|
|
146
|
+
cached = self._find_cached_harness_artifact(platform, node_version)
|
|
147
|
+
if cached is not None:
|
|
148
|
+
self._artifact_manifest, self._artifact_host_directory = cached
|
|
149
|
+
await self._release_artifact_cache_lock(cache_lock)
|
|
150
|
+
cache_lock = None
|
|
151
|
+
await self._upload_harness_artifact(environment, self._artifact_host_directory)
|
|
152
|
+
self._artifact_transport_status = "host_cache_hit"
|
|
153
|
+
|
|
154
|
+
if self._local_manifest is not None and not self._artifact_uploaded:
|
|
155
|
+
try:
|
|
156
|
+
await self._upload_and_materialize_local_source(environment, self._local_manifest)
|
|
157
|
+
except Exception as error:
|
|
158
|
+
raise RuntimeError(f"hitch-local-source-materialize: {error}") from error
|
|
159
|
+
entry = self._remote_entry(entrypoint)
|
|
160
|
+
version = await self._exec(environment, f"{self._node_prefix()} node {entry} --version")
|
|
161
|
+
self._hitch_version = (version.stdout or "").strip() or None
|
|
162
|
+
if not self._artifact_uploaded:
|
|
163
|
+
prepared = await self._prepare_harness_in_container(environment, entry)
|
|
164
|
+
if cache_lock is not None:
|
|
165
|
+
try:
|
|
166
|
+
self._artifact_manifest, self._artifact_host_directory = await self._cache_container_artifact(
|
|
167
|
+
environment, prepared, platform, node_version
|
|
168
|
+
)
|
|
169
|
+
self._artifact_transport_status = "host_cache_populated"
|
|
170
|
+
except Exception:
|
|
171
|
+
self._artifact_transport_status = "host_cache_write_failed"
|
|
172
|
+
else:
|
|
173
|
+
self._artifact_transport_status = "container_prepare"
|
|
174
|
+
finally:
|
|
175
|
+
if cache_lock is not None:
|
|
176
|
+
await self._release_artifact_cache_lock(cache_lock)
|
|
148
177
|
|
|
149
178
|
def _verify_harness_artifact_host(self) -> dict[str, Any]:
|
|
150
179
|
"""Pin host artifact metadata before Harbor copies the directory."""
|
|
151
180
|
transport = self.harness_artifact or {}
|
|
152
181
|
required = {
|
|
153
182
|
"directory", "artifact_id", "artifact_integrity", "entrypoint_integrity",
|
|
154
|
-
"harness_id", "revision_identity", "
|
|
183
|
+
"harness_id", "revision_identity", "adapter_version", "recipe_version",
|
|
184
|
+
"platform", "node_version", "source_type",
|
|
155
185
|
}
|
|
156
186
|
if set(transport) != required:
|
|
157
187
|
raise RuntimeError("prepared artifact handoff metadata fields are invalid")
|
|
@@ -176,7 +206,10 @@ class HitchHarborAgent(BaseAgent):
|
|
|
176
206
|
"entrypoint_integrity": manifest.get("entrypoint_integrity"),
|
|
177
207
|
"harness_id": manifest.get("harness_id"),
|
|
178
208
|
"revision_identity": manifest.get("revision_identity"),
|
|
209
|
+
"adapter_version": manifest.get("adapter_version"),
|
|
210
|
+
"recipe_version": manifest.get("recipe_version"),
|
|
179
211
|
"platform": manifest.get("platform"),
|
|
212
|
+
"node_version": manifest.get("toolchain", {}).get("node"),
|
|
180
213
|
"source_type": manifest.get("source_type"),
|
|
181
214
|
}
|
|
182
215
|
if any(transport.get(key) != value for key, value in pinned.items()):
|
|
@@ -192,6 +225,8 @@ class HitchHarborAgent(BaseAgent):
|
|
|
192
225
|
raise RuntimeError(f"prepared artifact {field} is invalid")
|
|
193
226
|
if not re.fullmatch(r"(?:linux|darwin|win32)-[a-z0-9_]+", str(manifest.get("platform", ""))):
|
|
194
227
|
raise RuntimeError("prepared artifact platform is invalid")
|
|
228
|
+
if not re.fullmatch(r"v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", str(transport.get("node_version", ""))):
|
|
229
|
+
raise RuntimeError("prepared artifact Node.js version is invalid")
|
|
195
230
|
return manifest
|
|
196
231
|
|
|
197
232
|
async def _container_platform(self, environment: BaseEnvironment) -> str:
|
|
@@ -204,6 +239,285 @@ class HitchHarborAgent(BaseAgent):
|
|
|
204
239
|
raise RuntimeError("container returned an invalid Node.js platform identity")
|
|
205
240
|
return platform
|
|
206
241
|
|
|
242
|
+
async def _container_node_version(self, environment: BaseEnvironment) -> str:
|
|
243
|
+
result = await self._exec(environment, f'{self._node_prefix()} node -p "process.version"')
|
|
244
|
+
version = (result.stdout or "").strip()
|
|
245
|
+
if not re.fullmatch(r"v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
|
|
246
|
+
raise RuntimeError("container returned an invalid Node.js version")
|
|
247
|
+
return version
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _artifact_compatible(manifest: dict[str, Any], platform: str, node_version: str) -> bool:
|
|
251
|
+
return (
|
|
252
|
+
manifest.get("platform") == platform
|
|
253
|
+
and manifest.get("toolchain", {}).get("node") == node_version
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
async def _upload_harness_artifact(
|
|
257
|
+
self,
|
|
258
|
+
environment: BaseEnvironment,
|
|
259
|
+
directory: Path | None,
|
|
260
|
+
) -> None:
|
|
261
|
+
if directory is None:
|
|
262
|
+
raise RuntimeError("prepared artifact host directory is unavailable")
|
|
263
|
+
await environment.upload_dir(directory, HARNESS_ARTIFACT_REMOTE_ROOT)
|
|
264
|
+
self._artifact_uploaded = True
|
|
265
|
+
|
|
266
|
+
async def _prepare_harness_in_container(
|
|
267
|
+
self,
|
|
268
|
+
environment: BaseEnvironment,
|
|
269
|
+
entry: str,
|
|
270
|
+
) -> ExecResult:
|
|
271
|
+
prepare = " ".join(
|
|
272
|
+
[
|
|
273
|
+
self._node_prefix(),
|
|
274
|
+
f"HITCH_ROOT={HITCH_CONTAINER_STATE_ROOT}",
|
|
275
|
+
*(["HITCH_HARBOR_INTERNAL=1"] if self._local_manifest is not None else []),
|
|
276
|
+
f"node {entry} prepare",
|
|
277
|
+
shlex.quote(self.harness_ref),
|
|
278
|
+
*self._local_source_cli_args(),
|
|
279
|
+
"--json",
|
|
280
|
+
]
|
|
281
|
+
)
|
|
282
|
+
return await self._exec(environment, prepare)
|
|
283
|
+
|
|
284
|
+
def _verify_harness_artifact_cache_host(self) -> None:
|
|
285
|
+
cache = self.harness_artifact_cache_dir
|
|
286
|
+
if cache is None or not cache.is_absolute():
|
|
287
|
+
raise RuntimeError("prepared artifact cache path must be absolute")
|
|
288
|
+
try:
|
|
289
|
+
info = cache.lstat()
|
|
290
|
+
except OSError as error:
|
|
291
|
+
raise RuntimeError("prepared artifact cache is missing or unreadable") from error
|
|
292
|
+
if not stat_module.S_ISDIR(info.st_mode) or cache.is_symlink():
|
|
293
|
+
raise RuntimeError("prepared artifact cache must be a regular directory")
|
|
294
|
+
for name in ("artifacts", "locks", "tmp", "invalid"):
|
|
295
|
+
child = cache / name
|
|
296
|
+
child.mkdir(mode=0o700, exist_ok=True)
|
|
297
|
+
child_info = child.lstat()
|
|
298
|
+
if not stat_module.S_ISDIR(child_info.st_mode) or child.is_symlink():
|
|
299
|
+
raise RuntimeError(f"prepared artifact cache {name} path must be a regular directory")
|
|
300
|
+
|
|
301
|
+
def _artifact_cache_key(self, platform: str, node_version: str) -> str:
|
|
302
|
+
payload = json.dumps(
|
|
303
|
+
[
|
|
304
|
+
self.revision_identity,
|
|
305
|
+
self.harness_artifact.get("adapter_version") if self.harness_artifact else None,
|
|
306
|
+
self.harness_artifact.get("recipe_version") if self.harness_artifact else None,
|
|
307
|
+
platform,
|
|
308
|
+
node_version,
|
|
309
|
+
],
|
|
310
|
+
separators=(",", ":"),
|
|
311
|
+
).encode("utf-8")
|
|
312
|
+
return hashlib.sha256(payload).hexdigest()
|
|
313
|
+
|
|
314
|
+
async def _acquire_artifact_cache_lock(self, platform: str, node_version: str) -> Any:
|
|
315
|
+
cache = self.harness_artifact_cache_dir
|
|
316
|
+
if cache is None:
|
|
317
|
+
raise RuntimeError("prepared artifact cache is unavailable")
|
|
318
|
+
handle = (cache / "locks" / f"{self._artifact_cache_key(platform, node_version)}.lock").open("a+b")
|
|
319
|
+
try:
|
|
320
|
+
while True:
|
|
321
|
+
try:
|
|
322
|
+
self._try_lock_file(handle)
|
|
323
|
+
return handle
|
|
324
|
+
except OSError as error:
|
|
325
|
+
if error.errno not in (errno.EACCES, errno.EAGAIN):
|
|
326
|
+
raise
|
|
327
|
+
await asyncio.sleep(0.1)
|
|
328
|
+
except BaseException:
|
|
329
|
+
handle.close()
|
|
330
|
+
raise
|
|
331
|
+
|
|
332
|
+
@staticmethod
|
|
333
|
+
def _try_lock_file(handle: Any) -> None:
|
|
334
|
+
if os.name == "nt":
|
|
335
|
+
import msvcrt
|
|
336
|
+
handle.seek(0)
|
|
337
|
+
if not handle.read(1):
|
|
338
|
+
handle.write(b"\0")
|
|
339
|
+
handle.flush()
|
|
340
|
+
handle.seek(0)
|
|
341
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
342
|
+
return
|
|
343
|
+
import fcntl
|
|
344
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
async def _release_artifact_cache_lock(handle: Any) -> None:
|
|
348
|
+
try:
|
|
349
|
+
if os.name == "nt":
|
|
350
|
+
import msvcrt
|
|
351
|
+
handle.seek(0)
|
|
352
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
353
|
+
else:
|
|
354
|
+
import fcntl
|
|
355
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
356
|
+
finally:
|
|
357
|
+
handle.close()
|
|
358
|
+
|
|
359
|
+
def _find_cached_harness_artifact(
|
|
360
|
+
self,
|
|
361
|
+
platform: str,
|
|
362
|
+
node_version: str,
|
|
363
|
+
) -> tuple[dict[str, Any], Path] | None:
|
|
364
|
+
cache = self.harness_artifact_cache_dir
|
|
365
|
+
if cache is None:
|
|
366
|
+
return None
|
|
367
|
+
requested = self.harness_artifact or {}
|
|
368
|
+
for directory in sorted((cache / "artifacts").iterdir(), key=lambda item: item.name):
|
|
369
|
+
if not re.fullmatch(r"[0-9a-f]{64}", directory.name):
|
|
370
|
+
continue
|
|
371
|
+
try:
|
|
372
|
+
manifest = json.loads((directory / "artifact.json").read_text(encoding="utf-8"))
|
|
373
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
374
|
+
continue
|
|
375
|
+
if not isinstance(manifest, dict) or (
|
|
376
|
+
manifest.get("revision_identity") != self.revision_identity
|
|
377
|
+
or manifest.get("adapter_version") != requested.get("adapter_version")
|
|
378
|
+
or manifest.get("recipe_version") != requested.get("recipe_version")
|
|
379
|
+
or not self._artifact_compatible(manifest, platform, node_version)
|
|
380
|
+
):
|
|
381
|
+
continue
|
|
382
|
+
try:
|
|
383
|
+
return self._verify_cached_harness_artifact(directory, platform, node_version), directory
|
|
384
|
+
except Exception:
|
|
385
|
+
self._quarantine_cached_artifact(directory)
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
async def _cache_container_artifact(
|
|
389
|
+
self,
|
|
390
|
+
environment: BaseEnvironment,
|
|
391
|
+
prepared: ExecResult,
|
|
392
|
+
platform: str,
|
|
393
|
+
node_version: str,
|
|
394
|
+
) -> tuple[dict[str, Any], Path]:
|
|
395
|
+
try:
|
|
396
|
+
payload = json.loads(prepared.stdout or "")
|
|
397
|
+
artifact = payload["artifact"]
|
|
398
|
+
except (KeyError, TypeError, json.JSONDecodeError) as error:
|
|
399
|
+
raise RuntimeError("container prepare returned invalid artifact metadata") from error
|
|
400
|
+
artifact_id = artifact.get("artifact_id") if isinstance(artifact, dict) else None
|
|
401
|
+
if not isinstance(artifact_id, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", artifact_id):
|
|
402
|
+
raise RuntimeError("container prepare returned an invalid artifact ID")
|
|
403
|
+
if (
|
|
404
|
+
artifact.get("revision_identity") != self.revision_identity
|
|
405
|
+
or not self._artifact_compatible(artifact, platform, node_version)
|
|
406
|
+
):
|
|
407
|
+
raise RuntimeError("container prepare returned an incompatible artifact")
|
|
408
|
+
cache = self.harness_artifact_cache_dir
|
|
409
|
+
if cache is None:
|
|
410
|
+
raise RuntimeError("prepared artifact cache is unavailable")
|
|
411
|
+
staging = cache / "tmp" / f"artifact-{uuid.uuid4().hex}"
|
|
412
|
+
staging.mkdir(mode=0o700)
|
|
413
|
+
try:
|
|
414
|
+
remote = f'{HITCH_CONTAINER_STATE_ROOT}/store/artifacts/{artifact_id.removeprefix("sha256:")}'
|
|
415
|
+
await environment.download_dir(remote, staging)
|
|
416
|
+
manifest = self._verify_cached_harness_artifact(staging, platform, node_version)
|
|
417
|
+
if manifest.get("artifact_id") != artifact_id:
|
|
418
|
+
raise RuntimeError("downloaded artifact ID differs from container prepare output")
|
|
419
|
+
destination = cache / "artifacts" / artifact_id.removeprefix("sha256:")
|
|
420
|
+
if destination.exists():
|
|
421
|
+
existing = self._verify_cached_harness_artifact(destination, platform, node_version)
|
|
422
|
+
shutil.rmtree(staging)
|
|
423
|
+
return existing, destination
|
|
424
|
+
staging.rename(destination)
|
|
425
|
+
return manifest, destination
|
|
426
|
+
finally:
|
|
427
|
+
if staging.exists():
|
|
428
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
429
|
+
|
|
430
|
+
def _verify_cached_harness_artifact(
|
|
431
|
+
self,
|
|
432
|
+
directory: Path,
|
|
433
|
+
platform: str,
|
|
434
|
+
node_version: str,
|
|
435
|
+
) -> dict[str, Any]:
|
|
436
|
+
info = directory.lstat()
|
|
437
|
+
if not stat_module.S_ISDIR(info.st_mode) or directory.is_symlink():
|
|
438
|
+
raise RuntimeError("cached harness artifact is not a regular directory")
|
|
439
|
+
artifact_file = directory / "artifact.json"
|
|
440
|
+
self._assert_regular_host_file(artifact_file, "cached harness artifact manifest", 1024 * 1024)
|
|
441
|
+
manifest = json.loads(artifact_file.read_text(encoding="utf-8"))
|
|
442
|
+
if not isinstance(manifest, dict) or manifest.get("schema_version") != HARNESS_ARTIFACT_MANIFEST_VERSION:
|
|
443
|
+
raise RuntimeError("cached harness artifact manifest schema is invalid")
|
|
444
|
+
for field in ("artifact_id", "artifact_integrity", "entrypoint_integrity", "revision_identity"):
|
|
445
|
+
if not re.fullmatch(r"sha256:[0-9a-f]{64}", str(manifest.get(field, ""))):
|
|
446
|
+
raise RuntimeError(f"cached harness artifact {field} is invalid")
|
|
447
|
+
if (
|
|
448
|
+
manifest.get("artifact_id") != f"sha256:{directory.name}" and directory.parent.name == "artifacts"
|
|
449
|
+
):
|
|
450
|
+
raise RuntimeError("cached harness artifact directory does not match its ID")
|
|
451
|
+
if (
|
|
452
|
+
manifest.get("revision_identity") != self.revision_identity
|
|
453
|
+
or manifest.get("resolved_revision", {}).get("identity") != self.revision_identity
|
|
454
|
+
or manifest.get("harness_id") != self.harness_ref.split("@", 1)[0]
|
|
455
|
+
or manifest.get("adapter_version") != (self.harness_artifact or {}).get("adapter_version")
|
|
456
|
+
or manifest.get("recipe_version") != (self.harness_artifact or {}).get("recipe_version")
|
|
457
|
+
or not self._artifact_compatible(manifest, platform, node_version)
|
|
458
|
+
or manifest.get("source_type") == "installed"
|
|
459
|
+
):
|
|
460
|
+
raise RuntimeError("cached harness artifact identity is incompatible")
|
|
461
|
+
entrypoint = manifest.get("entrypoint")
|
|
462
|
+
if not isinstance(entrypoint, str) or not entrypoint or PurePosixPath(entrypoint).is_absolute():
|
|
463
|
+
raise RuntimeError("cached harness artifact entrypoint is invalid")
|
|
464
|
+
parts = PurePosixPath(entrypoint).parts
|
|
465
|
+
if any(part in ("", ".", "..") for part in parts):
|
|
466
|
+
raise RuntimeError("cached harness artifact entrypoint escapes its directory")
|
|
467
|
+
executable = directory.joinpath(*parts)
|
|
468
|
+
if not executable.is_file():
|
|
469
|
+
raise RuntimeError("cached harness artifact entrypoint is missing")
|
|
470
|
+
if self._sha256_file(executable) != manifest["entrypoint_integrity"]:
|
|
471
|
+
raise RuntimeError("cached harness artifact entrypoint integrity mismatch")
|
|
472
|
+
if self._artifact_directory_integrity(directory) != manifest["artifact_integrity"]:
|
|
473
|
+
raise RuntimeError("cached harness artifact content integrity mismatch")
|
|
474
|
+
return manifest
|
|
475
|
+
|
|
476
|
+
@classmethod
|
|
477
|
+
def _artifact_directory_integrity(cls, root: Path) -> str:
|
|
478
|
+
digest = hashlib.sha256()
|
|
479
|
+
resolved_root = root.resolve()
|
|
480
|
+
|
|
481
|
+
def walk(directory: Path, relative: PurePosixPath, top_level: bool) -> None:
|
|
482
|
+
for entry in sorted(os.scandir(directory), key=lambda item: item.name):
|
|
483
|
+
if top_level and entry.name == "artifact.json":
|
|
484
|
+
continue
|
|
485
|
+
child_relative = relative / entry.name
|
|
486
|
+
child = Path(entry.path)
|
|
487
|
+
info = child.lstat()
|
|
488
|
+
mode = info.st_mode & 0o7777
|
|
489
|
+
encoded_relative = child_relative.as_posix()
|
|
490
|
+
if stat_module.S_ISDIR(info.st_mode):
|
|
491
|
+
digest.update(f"d\0{encoded_relative}\0{mode}\0".encode("utf-8"))
|
|
492
|
+
walk(child, child_relative, False)
|
|
493
|
+
elif stat_module.S_ISREG(info.st_mode):
|
|
494
|
+
digest.update(f"f\0{encoded_relative}\0{mode}\0{info.st_size}\0".encode("utf-8"))
|
|
495
|
+
with child.open("rb") as handle:
|
|
496
|
+
while chunk := handle.read(1024 * 1024):
|
|
497
|
+
digest.update(chunk)
|
|
498
|
+
digest.update(b"\0")
|
|
499
|
+
elif stat_module.S_ISLNK(info.st_mode):
|
|
500
|
+
target = os.readlink(child)
|
|
501
|
+
resolved_target = (child.parent / target).resolve()
|
|
502
|
+
if resolved_target != resolved_root and resolved_root not in resolved_target.parents:
|
|
503
|
+
raise RuntimeError("cached harness artifact symlink escapes its directory")
|
|
504
|
+
digest.update(f"l\0{encoded_relative}\0{target}\0".encode("utf-8"))
|
|
505
|
+
else:
|
|
506
|
+
raise RuntimeError("cached harness artifact contains a special file")
|
|
507
|
+
|
|
508
|
+
walk(root, PurePosixPath(), True)
|
|
509
|
+
return "sha256:" + digest.hexdigest()
|
|
510
|
+
|
|
511
|
+
def _quarantine_cached_artifact(self, directory: Path) -> None:
|
|
512
|
+
cache = self.harness_artifact_cache_dir
|
|
513
|
+
if cache is None or not directory.exists():
|
|
514
|
+
return
|
|
515
|
+
target = cache / "invalid" / f"{directory.name}-{uuid.uuid4().hex}"
|
|
516
|
+
try:
|
|
517
|
+
directory.rename(target)
|
|
518
|
+
except OSError:
|
|
519
|
+
pass
|
|
520
|
+
|
|
207
521
|
def _verify_local_source_host(self) -> dict[str, Any]:
|
|
208
522
|
"""Validate the independent local-source handoff immediately before upload."""
|
|
209
523
|
transport = self.local_source_transport or {}
|
|
@@ -617,16 +931,28 @@ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commi
|
|
|
617
931
|
)
|
|
618
932
|
if result.return_code == 0 and result.stdout:
|
|
619
933
|
hitch_result = json.loads(result.stdout)
|
|
934
|
+
bundle_stage = f"/logs/agent/.hitch-run-bundle.{uuid.uuid4().hex}"
|
|
935
|
+
bundle_marker = json.dumps({
|
|
936
|
+
"schema_version": "1",
|
|
937
|
+
"run_id": run_id,
|
|
938
|
+
"eval_id": self.eval_id,
|
|
939
|
+
"trial_id": trial_id,
|
|
940
|
+
"completed_at": datetime.now(timezone.utc).isoformat(),
|
|
941
|
+
}, separators=(",", ":"))
|
|
620
942
|
export = await environment.exec(
|
|
621
943
|
f"""
|
|
622
944
|
set -eu
|
|
623
945
|
source_dir={shlex.quote(f'/tmp/hitch-state/runs/{run_id}')}
|
|
624
946
|
target_dir=/logs/agent/hitch-run-bundle
|
|
625
|
-
|
|
626
|
-
|
|
947
|
+
stage_dir={shlex.quote(bundle_stage)}
|
|
948
|
+
rm -rf "$stage_dir"
|
|
949
|
+
mkdir -p "$stage_dir"
|
|
627
950
|
for name in request.json resolution.json manifest.json result.json events.jsonl stdout.log stderr.log trajectory.ref.json trajectory; do
|
|
628
|
-
if [ -e "$source_dir/$name" ]; then cp -a "$source_dir/$name" "$
|
|
951
|
+
if [ -e "$source_dir/$name" ]; then cp -a "$source_dir/$name" "$stage_dir/$name"; fi
|
|
629
952
|
done
|
|
953
|
+
printf '%s\n' {shlex.quote(bundle_marker)} > "$stage_dir/bundle.complete.json"
|
|
954
|
+
rm -rf "$target_dir"
|
|
955
|
+
mv "$stage_dir" "$target_dir"
|
|
630
956
|
""".strip()
|
|
631
957
|
)
|
|
632
958
|
if export.return_code != 0:
|
|
@@ -660,7 +986,8 @@ done
|
|
|
660
986
|
"artifact_id": self._artifact_manifest["artifact_id"],
|
|
661
987
|
"artifact_integrity": self._artifact_manifest["artifact_integrity"],
|
|
662
988
|
"platform": self._artifact_manifest["platform"],
|
|
663
|
-
"
|
|
989
|
+
"node_version": self._artifact_manifest.get("toolchain", {}).get("node"),
|
|
990
|
+
"status": self._artifact_transport_status or "container_prepare",
|
|
664
991
|
}
|
|
665
992
|
if execution.return_code != 0:
|
|
666
993
|
message = (execution.stderr or "").strip()
|