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.
@@ -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
- if self._local_manifest is not None:
110
- try:
111
- await self._upload_and_materialize_local_source(environment, self._local_manifest)
112
- except Exception as error:
113
- raise RuntimeError(f"hitch-local-source-materialize: {error}") from error
114
- entry = self._remote_entry(entrypoint)
115
- version = await self._exec(environment, f"{self._node_prefix()} node {entry} --version")
116
- self._hitch_version = (version.stdout or "").strip() or None
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=/tmp/hitch-state",
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"):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hitch",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Content-addressed version control and evidence storage for agent harnesses",
5
5
  "keywords": [
6
6
  "ai-agents",