agent-hitch 0.2.1 → 0.2.3
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/README.md +88 -256
- package/README.zh-CN.md +84 -235
- package/dist/src/artifacts/handoff.js +12 -0
- package/dist/src/artifacts/handoff.js.map +1 -0
- package/dist/src/artifacts/index.js +2 -0
- package/dist/src/artifacts/index.js.map +1 -1
- package/dist/src/artifacts/store.js +72 -2
- package/dist/src/artifacts/store.js.map +1 -1
- package/dist/src/backends/harbor/backend.js +22 -3
- package/dist/src/backends/harbor/backend.js.map +1 -1
- package/dist/src/cli/commands/run.js +65 -3
- package/dist/src/cli/commands/run.js.map +1 -1
- package/dist/src/evals/service.js +57 -1
- package/dist/src/evals/service.js.map +1 -1
- package/dist/src/runs/executor.js +10 -4
- package/dist/src/runs/executor.js.map +1 -1
- package/docs/schemas/eval-result.schema.json +14 -0
- package/integrations/harbor/hitch_harbor_agent.py +426 -13
- 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
16
|
from datetime import datetime
|
|
13
|
-
from pathlib import Path
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
14
18
|
from typing import Any
|
|
15
19
|
|
|
16
20
|
from harbor.agents.base import BaseAgent
|
|
@@ -19,8 +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"
|
|
29
|
+
HARNESS_ARTIFACT_REMOTE_ROOT = "/opt/hitch-harness-artifact"
|
|
30
|
+
HITCH_CONTAINER_STATE_ROOT = "/tmp/hitch-state"
|
|
24
31
|
|
|
25
32
|
|
|
26
33
|
class HitchHarborAgent(BaseAgent):
|
|
@@ -34,6 +41,8 @@ class HitchHarborAgent(BaseAgent):
|
|
|
34
41
|
hitch_runtime_dir: str,
|
|
35
42
|
candidate_id: str = "candidate-1",
|
|
36
43
|
controller_runtime_id: str | None = None,
|
|
44
|
+
harness_artifact: dict[str, Any] | None = None,
|
|
45
|
+
harness_artifact_cache_dir: str | None = None,
|
|
37
46
|
local_source_transport: dict[str, Any] | None = None,
|
|
38
47
|
hitch_timeout_ms: int = 900_000,
|
|
39
48
|
agent_args: list[str] | None = None,
|
|
@@ -49,6 +58,8 @@ class HitchHarborAgent(BaseAgent):
|
|
|
49
58
|
self.revision_identity = revision_identity
|
|
50
59
|
self.hitch_runtime_dir = Path(hitch_runtime_dir)
|
|
51
60
|
self.controller_runtime_id = controller_runtime_id
|
|
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
|
|
52
63
|
self.local_source_transport = dict(local_source_transport) if local_source_transport else None
|
|
53
64
|
self.candidate_id = candidate_id
|
|
54
65
|
self.hitch_timeout_ms = int(hitch_timeout_ms)
|
|
@@ -60,6 +71,10 @@ class HitchHarborAgent(BaseAgent):
|
|
|
60
71
|
self.verifier_identity = verifier_identity
|
|
61
72
|
self._hitch_version: str | None = None
|
|
62
73
|
self._entrypoint: str | None = None
|
|
74
|
+
self._artifact_manifest: dict[str, Any] | None = None
|
|
75
|
+
self._artifact_host_directory: Path | None = None
|
|
76
|
+
self._artifact_uploaded = False
|
|
77
|
+
self._artifact_transport_status: str | None = None
|
|
63
78
|
self._local_manifest: dict[str, Any] | None = None
|
|
64
79
|
|
|
65
80
|
@staticmethod
|
|
@@ -93,6 +108,17 @@ class HitchHarborAgent(BaseAgent):
|
|
|
93
108
|
# and the actual container upload, spec §4.6).
|
|
94
109
|
self._verify_manifest_identity(manifest)
|
|
95
110
|
self._verify_payload(manifest)
|
|
111
|
+
if self.harness_artifact is not None:
|
|
112
|
+
try:
|
|
113
|
+
self._artifact_manifest = self._verify_harness_artifact_host()
|
|
114
|
+
self._artifact_host_directory = Path(str(self.harness_artifact["directory"]))
|
|
115
|
+
except Exception as error:
|
|
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
|
|
96
122
|
if self.local_source_transport is not None:
|
|
97
123
|
try:
|
|
98
124
|
self._local_manifest = self._verify_local_source_host()
|
|
@@ -106,18 +132,146 @@ class HitchHarborAgent(BaseAgent):
|
|
|
106
132
|
# bookkeeping and is not identity (spec §4.2).
|
|
107
133
|
await environment.upload_dir(payload_dir, "/opt/hitch")
|
|
108
134
|
await self._ensure_node(environment)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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)
|
|
177
|
+
|
|
178
|
+
def _verify_harness_artifact_host(self) -> dict[str, Any]:
|
|
179
|
+
"""Pin host artifact metadata before Harbor copies the directory."""
|
|
180
|
+
transport = self.harness_artifact or {}
|
|
181
|
+
required = {
|
|
182
|
+
"directory", "artifact_id", "artifact_integrity", "entrypoint_integrity",
|
|
183
|
+
"harness_id", "revision_identity", "adapter_version", "recipe_version",
|
|
184
|
+
"platform", "node_version", "source_type",
|
|
185
|
+
}
|
|
186
|
+
if set(transport) != required:
|
|
187
|
+
raise RuntimeError("prepared artifact handoff metadata fields are invalid")
|
|
188
|
+
directory = Path(str(transport["directory"]))
|
|
189
|
+
if not directory.is_absolute():
|
|
190
|
+
raise RuntimeError("prepared artifact host path must be absolute")
|
|
191
|
+
try:
|
|
192
|
+
directory_info = directory.lstat()
|
|
193
|
+
except OSError as error:
|
|
194
|
+
raise RuntimeError("prepared artifact directory is missing or unreadable") from error
|
|
195
|
+
if not stat_module.S_ISDIR(directory_info.st_mode) or directory.is_symlink():
|
|
196
|
+
raise RuntimeError("prepared artifact handoff must be a regular directory")
|
|
197
|
+
artifact_file = directory / "artifact.json"
|
|
198
|
+
self._assert_regular_host_file(artifact_file, "prepared artifact manifest", 1024 * 1024)
|
|
199
|
+
try:
|
|
200
|
+
manifest = json.loads(artifact_file.read_text(encoding="utf-8"))
|
|
201
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
202
|
+
raise RuntimeError("prepared artifact manifest is unreadable") from error
|
|
203
|
+
pinned = {
|
|
204
|
+
"artifact_id": manifest.get("artifact_id"),
|
|
205
|
+
"artifact_integrity": manifest.get("artifact_integrity"),
|
|
206
|
+
"entrypoint_integrity": manifest.get("entrypoint_integrity"),
|
|
207
|
+
"harness_id": manifest.get("harness_id"),
|
|
208
|
+
"revision_identity": manifest.get("revision_identity"),
|
|
209
|
+
"adapter_version": manifest.get("adapter_version"),
|
|
210
|
+
"recipe_version": manifest.get("recipe_version"),
|
|
211
|
+
"platform": manifest.get("platform"),
|
|
212
|
+
"node_version": manifest.get("toolchain", {}).get("node"),
|
|
213
|
+
"source_type": manifest.get("source_type"),
|
|
214
|
+
}
|
|
215
|
+
if any(transport.get(key) != value for key, value in pinned.items()):
|
|
216
|
+
raise RuntimeError("prepared artifact metadata does not match its manifest")
|
|
217
|
+
if manifest.get("revision_identity") != self.revision_identity:
|
|
218
|
+
raise RuntimeError("prepared artifact revision does not match the job-pinned identity")
|
|
219
|
+
if manifest.get("source_type") == "installed":
|
|
220
|
+
raise RuntimeError("installed harness artifacts cannot cross the container boundary")
|
|
221
|
+
if not re.fullmatch(r"sha256:[0-9a-f]{64}", str(manifest.get("artifact_id", ""))):
|
|
222
|
+
raise RuntimeError("prepared artifact ID is invalid")
|
|
223
|
+
for field in ("artifact_integrity", "entrypoint_integrity", "revision_identity"):
|
|
224
|
+
if not re.fullmatch(r"sha256:[0-9a-f]{64}", str(manifest.get(field, ""))):
|
|
225
|
+
raise RuntimeError(f"prepared artifact {field} is invalid")
|
|
226
|
+
if not re.fullmatch(r"(?:linux|darwin|win32)-[a-z0-9_]+", str(manifest.get("platform", ""))):
|
|
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")
|
|
230
|
+
return manifest
|
|
231
|
+
|
|
232
|
+
async def _container_platform(self, environment: BaseEnvironment) -> str:
|
|
233
|
+
result = await self._exec(
|
|
234
|
+
environment,
|
|
235
|
+
f'{self._node_prefix()} node -p "process.platform + \'-\' + process.arch"',
|
|
236
|
+
)
|
|
237
|
+
platform = (result.stdout or "").strip()
|
|
238
|
+
if not re.fullmatch(r"(?:linux|darwin|win32)-[a-z0-9_]+", platform):
|
|
239
|
+
raise RuntimeError("container returned an invalid Node.js platform identity")
|
|
240
|
+
return platform
|
|
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:
|
|
117
271
|
prepare = " ".join(
|
|
118
272
|
[
|
|
119
273
|
self._node_prefix(),
|
|
120
|
-
"HITCH_ROOT
|
|
274
|
+
f"HITCH_ROOT={HITCH_CONTAINER_STATE_ROOT}",
|
|
121
275
|
*(["HITCH_HARBOR_INTERNAL=1"] if self._local_manifest is not None else []),
|
|
122
276
|
f"node {entry} prepare",
|
|
123
277
|
shlex.quote(self.harness_ref),
|
|
@@ -125,7 +279,244 @@ class HitchHarborAgent(BaseAgent):
|
|
|
125
279
|
"--json",
|
|
126
280
|
]
|
|
127
281
|
)
|
|
128
|
-
await self._exec(environment, prepare)
|
|
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
|
|
129
520
|
|
|
130
521
|
def _verify_local_source_host(self) -> dict[str, Any]:
|
|
131
522
|
"""Validate the independent local-source handoff immediately before upload."""
|
|
@@ -306,7 +697,7 @@ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commi
|
|
|
306
697
|
await self._exec(environment, materialize)
|
|
307
698
|
|
|
308
699
|
def _local_source_cli_args(self) -> list[str]:
|
|
309
|
-
if self._local_manifest is None:
|
|
700
|
+
if self._local_manifest is None or self._artifact_uploaded:
|
|
310
701
|
return []
|
|
311
702
|
return [
|
|
312
703
|
"--internal-locked-resolution", f"{LOCAL_GIT_REMOTE_ROOT}/resolution.json",
|
|
@@ -314,6 +705,19 @@ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commi
|
|
|
314
705
|
"--internal-local-git-source", f"{LOCAL_GIT_REMOTE_ROOT}/repo.git",
|
|
315
706
|
]
|
|
316
707
|
|
|
708
|
+
def _artifact_cli_args(self) -> list[str]:
|
|
709
|
+
if not self._artifact_uploaded or self._artifact_manifest is None:
|
|
710
|
+
return []
|
|
711
|
+
manifest = self._artifact_manifest
|
|
712
|
+
return [
|
|
713
|
+
"--internal-prepared-artifact", HARNESS_ARTIFACT_REMOTE_ROOT,
|
|
714
|
+
"--internal-artifact-id", str(manifest["artifact_id"]),
|
|
715
|
+
"--internal-artifact-integrity", str(manifest["artifact_integrity"]),
|
|
716
|
+
"--internal-artifact-entrypoint-integrity", str(manifest["entrypoint_integrity"]),
|
|
717
|
+
"--internal-artifact-revision-identity", str(manifest["revision_identity"]),
|
|
718
|
+
"--internal-artifact-platform", str(manifest["platform"]),
|
|
719
|
+
]
|
|
720
|
+
|
|
317
721
|
@staticmethod
|
|
318
722
|
def _remote_entry(entrypoint: str) -> str:
|
|
319
723
|
"""Shell-quote the full remote path so the entrypoint is always a
|
|
@@ -479,11 +883,12 @@ git -C {LOCAL_GIT_REMOTE_ROOT}/repo.git update-ref refs/heads/hitch-local {commi
|
|
|
479
883
|
arguments = [
|
|
480
884
|
self._node_prefix(),
|
|
481
885
|
"HITCH_ROOT=/tmp/hitch-state",
|
|
482
|
-
*(["HITCH_HARBOR_INTERNAL=1"] if self._local_manifest is not None or parent_payload is not None else []),
|
|
886
|
+
*(["HITCH_HARBOR_INTERNAL=1"] if self._local_manifest is not None or self._artifact_uploaded or parent_payload is not None else []),
|
|
483
887
|
f"node {entry} run",
|
|
484
888
|
"--harness",
|
|
485
889
|
shlex.quote(self.harness_ref),
|
|
486
890
|
*self._local_source_cli_args(),
|
|
891
|
+
*self._artifact_cli_args(),
|
|
487
892
|
"--cwd",
|
|
488
893
|
shlex.quote(self.workdir),
|
|
489
894
|
"--workspace-mode",
|
|
@@ -564,6 +969,14 @@ done
|
|
|
564
969
|
"payload_bytes": self._local_manifest["payload_bytes"],
|
|
565
970
|
"status": "verified",
|
|
566
971
|
}
|
|
972
|
+
if self._artifact_manifest is not None:
|
|
973
|
+
context.metadata["harness_artifact_transport"] = {
|
|
974
|
+
"artifact_id": self._artifact_manifest["artifact_id"],
|
|
975
|
+
"artifact_integrity": self._artifact_manifest["artifact_integrity"],
|
|
976
|
+
"platform": self._artifact_manifest["platform"],
|
|
977
|
+
"node_version": self._artifact_manifest.get("toolchain", {}).get("node"),
|
|
978
|
+
"status": self._artifact_transport_status or "container_prepare",
|
|
979
|
+
}
|
|
567
980
|
if execution.return_code != 0:
|
|
568
981
|
message = (execution.stderr or "").strip()
|
|
569
982
|
if hitch_result and hitch_result.get("error", {}).get("message"):
|