flashnode 0.2.0__py3-none-any.whl

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,290 @@
1
+ """Unpacking an untrusted archive onto a volunteer's machine.
2
+
3
+ This is the one place in flashnode that writes attacker-chosen *paths* to a
4
+ host owner's disk. The bytes arrive as a task input: any signed-in user
5
+ points the cloud API at any repo they like, the API stages the original
6
+ archive as an ``artifact://`` input, and this agent downloads it. Member
7
+ names, symlink targets, member types and declared sizes are all chosen by
8
+ whoever submitted the job — never by us.
9
+
10
+ The cloud API has an equivalent guard on its own copy of the same bytes
11
+ (``flashml_cloud_api.repo.extract_safely``). That one is *not* reusable
12
+ here: flashnode may import ``flashruntime.protocol`` and nothing else (see
13
+ AGENTS.md hard rule 2), and a private control-plane module must never be a
14
+ dependency of the public host agent. So the guard is deliberately
15
+ duplicated, and the drift that duplication invites is caught by a parity
16
+ test in the workspace ``e2e/`` suite — the only place allowed to import
17
+ both repos — which feeds one attack corpus to both extractors.
18
+
19
+ What is refused, and why each one matters on someone else's laptop:
20
+
21
+ - **zip-slip**, relative (``../../.ssh/authorized_keys``) or absolute
22
+ (``/etc/cron.d/evil``). Both are caught by the same check rather than
23
+ special-cased, because a naive join-then-compare misses the absolute
24
+ form entirely: ``Path("/work/inputs/code") / "/etc/evil"`` is
25
+ ``/etc/evil`` — pathlib silently discards the left operand when the
26
+ right side is absolute. Resolving first and then requiring containment
27
+ via ``relative_to`` has no such blind spot.
28
+ - **escaping symlinks**. A member linking to ``/`` or ``../..`` turns a
29
+ later, individually-innocent write into an escape. Contained symlinks
30
+ are validated and then *not created at all*: nothing downstream needs a
31
+ link, and not materialising one removes the whole TOCTOU class (member
32
+ ``a`` is a symlink to a safe dir; member ``a/b`` then resolves through
33
+ it) for free.
34
+ - **decompression bombs**, checked *during* extraction and in bounded
35
+ chunks. A 40 KB archive can hold petabytes; a total measured after the
36
+ fact has already filled the disk it was meant to protect.
37
+ - **member count**, because millions of empty files exhaust inodes without
38
+ ever tripping a byte cap.
39
+ - **device, fifo and hard-link members**, which have no legitimate place in
40
+ a code archive and every place in an attack.
41
+
42
+ Nothing is executed, nothing is made executable (members are rewritten as
43
+ plain files with the process umask, so an archive cannot smuggle in a
44
+ setuid bit), and on any refusal everything already written is removed —
45
+ a rejected archive must not leave half a payload behind for the next stage
46
+ to trip over.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import shutil
52
+ import stat
53
+ import tarfile
54
+ import zipfile
55
+ from pathlib import Path, PurePosixPath
56
+
57
+ #: Read/write in bounded chunks so an oversized member is caught partway
58
+ #: through rather than only once it is fully on disk.
59
+ _CHUNK_SIZE = 1 << 16 # 64 KiB
60
+
61
+ #: Defaults for a task input. Generous enough for a real repo (the cloud
62
+ #: API caps an extracted repo at 128 MiB), small enough that a hostile job
63
+ #: cannot fill a volunteer's disk before the cap fires.
64
+ DEFAULT_MAX_BYTES = 128 * 1024 * 1024
65
+ DEFAULT_MAX_MEMBERS = 20_000
66
+
67
+
68
+ class ArchiveError(Exception):
69
+ """Raised for any archive this agent refuses to unpack: a malformed
70
+ container, a member whose path or symlink target escapes the
71
+ destination, an unsupported member type, or a cap (bytes, members)
72
+ exceeded. The executor turns it into a task failure, never into a
73
+ crashed agent — a hostile input must cost the submitter their task, not
74
+ the volunteer their node."""
75
+
76
+
77
+ def _is_within(path: Path, root: Path) -> bool:
78
+ try:
79
+ path.relative_to(root)
80
+ return True
81
+ except ValueError:
82
+ return False
83
+
84
+
85
+ def _safe_target(name: str, dest_root: Path) -> Path:
86
+ """Resolve a member name against the extraction root, refusing anything
87
+ that lands outside it. Handles the relative and the absolute escape with
88
+ one check — see the module docstring on why joining first is not enough
89
+ on its own but resolving after it is."""
90
+ resolved = (dest_root / name).resolve()
91
+ if not _is_within(resolved, dest_root):
92
+ raise ArchiveError(f"refusing archive member with unsafe path: {name!r}")
93
+ return resolved
94
+
95
+
96
+ def _check_symlink(member_path: Path, link_name: str, dest_root: Path) -> None:
97
+ """A symlink target resolves against the link's own directory unless it
98
+ is absolute — standard symlink semantics, and the semantics an attacker
99
+ is counting on."""
100
+ if PurePosixPath(link_name).is_absolute():
101
+ target = Path(link_name).resolve()
102
+ else:
103
+ target = (member_path.parent / link_name).resolve()
104
+ if not _is_within(target, dest_root):
105
+ raise ArchiveError(
106
+ f"refusing symlink member {member_path.name!r} whose target "
107
+ f"{link_name!r} escapes the destination"
108
+ )
109
+
110
+
111
+ def _top_level(name: str) -> str | None:
112
+ """First path component of a member name, or None for a name that has
113
+ none (``.``/``./``, which GNU tar emits for the archive root). The
114
+ None case is not hypothetical: indexing ``parts[0]`` unguarded turns a
115
+ one-member archive into an IndexError instead of a clean refusal."""
116
+ parts = PurePosixPath(name).parts
117
+ return parts[0] if parts else None
118
+
119
+
120
+ def _write_stream(src, out_path: Path, budget: list[int]) -> None:
121
+ """Copy a member's bytes in chunks, decrementing a shared budget as we
122
+ go. ``budget`` is a one-element list so the running total survives
123
+ across members without a class or a global."""
124
+ out_path.parent.mkdir(parents=True, exist_ok=True)
125
+ with open(out_path, "wb") as out:
126
+ while True:
127
+ chunk = src.read(_CHUNK_SIZE)
128
+ if not chunk:
129
+ return
130
+ budget[0] -= len(chunk)
131
+ if budget[0] < 0:
132
+ raise ArchiveError(
133
+ "archive exceeds the uncompressed size cap during "
134
+ "extraction — refusing the rest"
135
+ )
136
+ out.write(chunk)
137
+
138
+
139
+ def _extract_tar(src: Path, dest_root: Path, budget: list[int], max_members: int) -> set[str]:
140
+ try:
141
+ tar = tarfile.open(src, mode="r:*")
142
+ except tarfile.TarError as exc:
143
+ raise ArchiveError(f"not a valid tar archive: {exc}") from exc
144
+
145
+ tops: set[str] = set()
146
+ seen = 0
147
+ with tar:
148
+ for member in tar:
149
+ seen += 1
150
+ if seen > max_members:
151
+ raise ArchiveError(
152
+ f"archive has more than {max_members} members — refusing the rest"
153
+ )
154
+ top = _top_level(member.name)
155
+ if top is not None:
156
+ tops.add(top)
157
+ member_path = _safe_target(member.name, dest_root)
158
+
159
+ if member.issym():
160
+ _check_symlink(member_path, member.linkname, dest_root)
161
+ continue # validated, deliberately not created — see docstring
162
+ if member.islnk():
163
+ raise ArchiveError(
164
+ f"refusing hard-link member: {member.name!r}"
165
+ )
166
+ if member.isdir():
167
+ member_path.mkdir(parents=True, exist_ok=True)
168
+ continue
169
+ if not member.isfile():
170
+ # chr/blk/fifo — nothing a code archive needs.
171
+ raise ArchiveError(
172
+ f"refusing archive member of unsupported type: {member.name!r}"
173
+ )
174
+
175
+ stream = tar.extractfile(member)
176
+ if stream is None:
177
+ continue
178
+ _write_stream(stream, member_path, budget)
179
+ return tops
180
+
181
+
182
+ def _extract_zip(src: Path, dest_root: Path, budget: list[int], max_members: int) -> set[str]:
183
+ try:
184
+ zf = zipfile.ZipFile(src)
185
+ except zipfile.BadZipFile as exc:
186
+ raise ArchiveError(f"not a valid zip archive: {exc}") from exc
187
+
188
+ tops: set[str] = set()
189
+ with zf:
190
+ infos = zf.infolist()
191
+ if len(infos) > max_members:
192
+ raise ArchiveError(
193
+ f"archive has more than {max_members} members — refusing it"
194
+ )
195
+ for info in infos:
196
+ top = _top_level(info.filename)
197
+ if top is not None:
198
+ tops.add(top)
199
+ member_path = _safe_target(info.filename, dest_root)
200
+
201
+ # Zip stores unix mode in the high 16 bits of external_attr, but
202
+ # only when it was created on a unix host AND the writer bothered
203
+ # to set the file-type bits — plenty of legitimate zips carry
204
+ # bare permission bits with no S_IFREG. Treating a missing type
205
+ # as "unsupported" would refuse ordinary archives, so the type
206
+ # check only runs when there is a type to check.
207
+ mode = info.external_attr >> 16
208
+ if info.create_system == 3 and stat.S_IFMT(mode):
209
+ if stat.S_ISLNK(mode):
210
+ # A zip symlink stores its target as the member's own
211
+ # content, so reading it is a read of attacker bytes:
212
+ # cap it rather than pulling an arbitrary "target" into
213
+ # memory to validate a link we are not going to create.
214
+ if info.file_size > 4096:
215
+ raise ArchiveError(
216
+ f"refusing symlink member {info.filename!r} with an "
217
+ f"implausible {info.file_size}-byte target"
218
+ )
219
+ _check_symlink(
220
+ member_path,
221
+ zf.read(info).decode("utf-8", errors="replace"),
222
+ dest_root,
223
+ )
224
+ continue
225
+ if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
226
+ raise ArchiveError(
227
+ f"refusing archive member of unsupported type: "
228
+ f"{info.filename!r}"
229
+ )
230
+ if info.is_dir():
231
+ member_path.mkdir(parents=True, exist_ok=True)
232
+ continue
233
+ with zf.open(info) as stream:
234
+ _write_stream(stream, member_path, budget)
235
+ return tops
236
+
237
+
238
+ def extract_archive_safely(
239
+ src: Path,
240
+ dest: Path,
241
+ max_bytes: int = DEFAULT_MAX_BYTES,
242
+ max_members: int = DEFAULT_MAX_MEMBERS,
243
+ ) -> Path:
244
+ """Unpack the archive at ``src`` under ``dest`` and return the path the
245
+ *content* actually lives at.
246
+
247
+ That return value is not always ``dest``: an archive whose members all
248
+ sit under a single top-level directory — which is exactly what GitHub's
249
+ ``codeload`` tarballs are, wrapping every repo in ``owner-name-<sha>/``
250
+ — has that wrapper as its content root. Returning it is what lets the
251
+ caller present the repo at the path the job's argv was compiled
252
+ against; returning ``dest`` blindly would leave every such job pointing
253
+ one directory too high, which is the same "file not found" the missing
254
+ unpacking caused in the first place.
255
+
256
+ The container format is detected from the bytes, never from the file
257
+ name: an attacker supplies the name, so trusting it would let them pick
258
+ which parser reads their bytes. Supported: tar with any of gzip/bzip2/xz
259
+ compression or none, and zip.
260
+
261
+ Raises ``ArchiveError`` for every refusal. On refusal ``dest`` is
262
+ removed entirely.
263
+ """
264
+ src = Path(src)
265
+ dest = Path(dest)
266
+ dest.mkdir(parents=True, exist_ok=True)
267
+ dest_root = dest.resolve()
268
+ budget = [int(max_bytes)]
269
+
270
+ try:
271
+ if zipfile.is_zipfile(src):
272
+ tops = _extract_zip(src, dest_root, budget, max_members)
273
+ else:
274
+ tops = _extract_tar(src, dest_root, budget, max_members)
275
+ except ArchiveError:
276
+ shutil.rmtree(dest_root, ignore_errors=True)
277
+ raise
278
+ except Exception as exc:
279
+ # Anything the stdlib raises from hostile bytes (an OSError on an
280
+ # absurd name, a zlib error mid-stream) becomes the same typed
281
+ # refusal: the caller must never have to catch the union of every
282
+ # decompressor's private exception tree.
283
+ shutil.rmtree(dest_root, ignore_errors=True)
284
+ raise ArchiveError(f"could not extract archive: {exc}") from exc
285
+
286
+ if len(tops) == 1:
287
+ only = dest_root / next(iter(tops))
288
+ if only.is_dir():
289
+ return only
290
+ return dest_root
@@ -0,0 +1,114 @@
1
+ """Tier-2 argv runner: run the user's own command inside a hardened container.
2
+
3
+ The module runners execute an allowlisted `python -m <module>`. This runner
4
+ executes whatever argv the job carried, which is what makes an arbitrary
5
+ machine a useful compute resource — and is why it is container-only. There
6
+ is no unsandboxed argv path here, by default or otherwise.
7
+
8
+ The security control is the isolation tier plus the operator's IMAGE
9
+ allowlist (what this volunteer consents to run), not a code allowlist: the
10
+ user's code inside a permitted image is unrestricted.
11
+
12
+ The task sees exactly one directory: its workdir bound at /work. Inputs are
13
+ pre-staged by the agent at /work/inputs; outputs are written to /work/out.
14
+ With --network none the job cannot fetch anything itself.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ from flashnode.executor.hardening import container_name, harden_args
24
+ from flashnode.executor.images import DEFAULT_ALLOWED_IMAGE_PREFIXES, image_is_allowed
25
+ from flashnode.executor.runner import TaskExecutionError
26
+
27
+ _ENV_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
28
+
29
+
30
+ class ArgvDockerRunner:
31
+ def __init__(
32
+ self,
33
+ allowed_images: frozenset[str] = frozenset(),
34
+ cpus: float = 2.0,
35
+ memory_gb: float = 2.0,
36
+ timeout_seconds: float = 3600.0,
37
+ max_output_bytes: int = 2 * 1024**3,
38
+ allowed_image_prefixes: frozenset[str] = DEFAULT_ALLOWED_IMAGE_PREFIXES,
39
+ ):
40
+ self.allowed_images = allowed_images
41
+ self.allowed_image_prefixes = allowed_image_prefixes
42
+ self.cpus = cpus
43
+ self.memory_gb = memory_gb
44
+ self.timeout_seconds = timeout_seconds
45
+ self.max_output_bytes = max_output_bytes
46
+
47
+ def run(self, payload: dict, workdir: Path, inputs: dict[str, Path]) -> Path:
48
+ argv = payload.get("argv")
49
+ if not argv or not isinstance(argv, list) or not all(isinstance(t, str) for t in argv):
50
+ raise TaskExecutionError("payload 'argv' must be a non-empty list of strings")
51
+
52
+ # Checked BEFORE any subprocess call, so a hostile value such as
53
+ # "--privileged" can never reach docker's flag parser.
54
+ image = payload.get("image")
55
+ if not image or not image_is_allowed(
56
+ image, self.allowed_images, self.allowed_image_prefixes
57
+ ):
58
+ raise TaskExecutionError(f"image {image!r} is not allowlisted — refusing to run")
59
+
60
+ env_args: list[str] = []
61
+ for key, value in (payload.get("env") or {}).items():
62
+ if not _ENV_KEY.match(str(key)):
63
+ raise TaskExecutionError(f"illegal env key {key!r} — refusing to run")
64
+ env_args += ["--env", f"{key}={value}"]
65
+
66
+ workdir = Path(workdir)
67
+ outdir = workdir / "out"
68
+ outdir.mkdir(parents=True, exist_ok=True)
69
+
70
+ name = container_name(payload.get("task_id"))
71
+ command = [
72
+ "docker", "run", "--rm", "--name", name,
73
+ *harden_args(workdir, cpus=self.cpus, memory_gb=self.memory_gb),
74
+ *env_args,
75
+ image, # argv follows the image, where docker treats it
76
+ *argv, # as the container command: leading '-' is inert
77
+ ]
78
+ try:
79
+ proc = subprocess.run(
80
+ command, capture_output=True, timeout=self.timeout_seconds, check=False
81
+ )
82
+ except subprocess.TimeoutExpired:
83
+ # subprocess.run's timeout kills the docker CLIENT process, not
84
+ # the daemon-side container — it keeps running unless we kill it
85
+ # by name ourselves. Best-effort: the container may already be
86
+ # gone by the time we ask, and either way the wall-clock error
87
+ # below is what the caller needs to see, not a kill failure.
88
+ try:
89
+ subprocess.run(["docker", "kill", name], capture_output=True, timeout=10, check=False)
90
+ except Exception:
91
+ pass
92
+ raise TaskExecutionError(f"task exceeded {self.timeout_seconds}s wall clock")
93
+ except OSError as exc:
94
+ # `docker` missing/removed mid-run raises FileNotFoundError here
95
+ # (a subclass of OSError). Degrade to a failed task, not a dead
96
+ # agent — execute_one only catches TaskExecutionError/LeaseLost.
97
+ raise TaskExecutionError(f"docker is unavailable: {exc}") from exc
98
+ if proc.returncode != 0:
99
+ tail = proc.stderr.decode(errors="replace")[-800:]
100
+ raise TaskExecutionError(f"task exited {proc.returncode}: {tail}")
101
+
102
+ # metrics.json is load-bearing, not a preference: CommandRecipe sets
103
+ # commit_key to <prefix>/metrics.json and the coordinator validates
104
+ # the artifact at that key by sha256. Failing here turns an opaque
105
+ # commit rejection into a clear task error.
106
+ if not (outdir / "metrics.json").is_file():
107
+ raise TaskExecutionError("task produced no metrics.json — nothing to commit")
108
+
109
+ total = sum(p.stat().st_size for p in outdir.rglob("*") if p.is_file())
110
+ if total > self.max_output_bytes:
111
+ raise TaskExecutionError(
112
+ f"task output {total} B exceeds the {self.max_output_bytes} B cap"
113
+ )
114
+ return outdir
@@ -0,0 +1,201 @@
1
+ """Outbound-only HTTP client for the FlashRuntime coordinator.
2
+
3
+ stdlib `urllib` on purpose: this code runs on strangers' machines, and every
4
+ dependency is attack surface (AGENTS.md). All calls are *outbound* to the
5
+ coordinator — the device never listens on anything.
6
+
7
+ Two heartbeats, two endpoints, never merged:
8
+ - node heartbeat → /v1alpha1/nodes/{id}/heartbeat ("this machine is up")
9
+ - attempt heartbeat→ /v1alpha1/attempts/{id}/heartbeat ("this task is alive")
10
+ A 410 on the attempt heartbeat means the lease is dead: the worker must stop
11
+ work on that task (`LeaseLost`).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import urllib.error
19
+ import urllib.request
20
+ from pathlib import Path
21
+
22
+ from flashruntime.protocol.v1alpha1 import Lease, NodeHeartbeat, NodeRegistration
23
+
24
+
25
+ class LeaseLost(Exception):
26
+ """The coordinator refused the attempt heartbeat — stop working."""
27
+
28
+
29
+ class CoordinatorClient:
30
+ def __init__(
31
+ self,
32
+ base_url: str,
33
+ timeout: float = 15.0,
34
+ join_code: str | None = None,
35
+ token: str | None = None,
36
+ ):
37
+ self.base_url = base_url.rstrip("/")
38
+ self.timeout = timeout
39
+ self.join_code = join_code # sent on register when the pool requires one
40
+ self._token = token # bearer credential for every authenticated call
41
+
42
+ # -- auth -----------------------------------------------------------------
43
+
44
+ def _headers(self) -> dict[str, str]:
45
+ """Auth header merged into every request this class makes.
46
+
47
+ Empty when no token is configured — correct for the self-hosted open
48
+ profile, where absence of a credential simply means no header.
49
+ """
50
+ if self._token:
51
+ return {"Authorization": f"Bearer {self._token}"}
52
+ return {}
53
+
54
+ # -- transport (patchable in tests) -------------------------------------
55
+
56
+ def _request(
57
+ self,
58
+ method: str,
59
+ path: str,
60
+ body: bytes | None = None,
61
+ content_type: str = "application/json",
62
+ headers: dict[str, str] | None = None,
63
+ ) -> tuple[int, bytes]:
64
+ all_headers = self._headers()
65
+ all_headers.update(headers or {})
66
+ if body is not None:
67
+ all_headers["Content-Type"] = content_type
68
+ req = urllib.request.Request(
69
+ f"{self.base_url}{path}",
70
+ data=body,
71
+ method=method,
72
+ headers=all_headers,
73
+ )
74
+ try:
75
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
76
+ return resp.status, resp.read()
77
+ except urllib.error.HTTPError as exc:
78
+ return exc.code, exc.read()
79
+
80
+ def _json(self, method: str, path: str, payload: dict | str | None = None) -> tuple[int, dict]:
81
+ body = None
82
+ if payload is not None:
83
+ body = (payload if isinstance(payload, str) else json.dumps(payload)).encode()
84
+ status, raw = self._request(method, path, body)
85
+ return status, (json.loads(raw) if raw else {})
86
+
87
+ # -- node lifecycle -----------------------------------------------------
88
+
89
+ def register(self, registration: NodeRegistration) -> None:
90
+ headers = {"X-FlashML-Join-Code": self.join_code} if self.join_code else None
91
+ status, raw = self._request(
92
+ "POST",
93
+ "/v1alpha1/nodes/register",
94
+ registration.model_dump_json().encode(),
95
+ headers=headers,
96
+ )
97
+ if status != 200:
98
+ raise RuntimeError(f"registration failed ({status}): {raw.decode(errors='replace')}")
99
+
100
+ def node_heartbeat(self, node_id: str) -> bool:
101
+ status, _ = self._json(
102
+ "POST",
103
+ f"/v1alpha1/nodes/{node_id}/heartbeat",
104
+ NodeHeartbeat(node_id=node_id).model_dump_json(),
105
+ )
106
+ return status == 200
107
+
108
+ # -- lease lifecycle ----------------------------------------------------
109
+
110
+ def claim(self, node_id: str) -> Lease | None:
111
+ status, body = self._json("POST", "/v1alpha1/leases/claim", {"node_id": node_id})
112
+ if status == 204:
113
+ return None
114
+ if status != 200:
115
+ raise RuntimeError(f"claim failed ({status}): {body}")
116
+ return Lease.model_validate(body)
117
+
118
+ def attempt_heartbeat(self, lease_id: str) -> None:
119
+ status, body = self._json("POST", f"/v1alpha1/attempts/{lease_id}/heartbeat")
120
+ if status == 410:
121
+ raise LeaseLost(str(body))
122
+ if status != 200:
123
+ raise RuntimeError(f"attempt heartbeat failed ({status}): {body}")
124
+
125
+ def complete(self, lease_id: str, output_sha256: str) -> bool:
126
+ status, body = self._json(
127
+ "POST",
128
+ f"/v1alpha1/attempts/{lease_id}/complete",
129
+ {"output_sha256": output_sha256},
130
+ )
131
+ if status != 200:
132
+ raise RuntimeError(f"complete failed ({status}): {body}")
133
+ return bool(body.get("accepted"))
134
+
135
+ def fail(self, lease_id: str, reason: str) -> None:
136
+ self._json("POST", f"/v1alpha1/attempts/{lease_id}/fail", {"reason": reason})
137
+
138
+ # -- checkpoints (the agent is the courier; tasks stay offline) ----------
139
+
140
+ def checkpoint_latest(self, job_id: str, task_id: str) -> dict | None:
141
+ status, body = self._json(
142
+ "GET", f"/v1alpha1/jobs/{job_id}/tasks/{task_id}/checkpoints/latest"
143
+ )
144
+ if status == 404:
145
+ return None
146
+ if status != 200:
147
+ raise RuntimeError(f"checkpoint latest failed ({status}): {body}")
148
+ return body
149
+
150
+ def checkpoint_register_part(
151
+ self, job_id: str, task_id: str, attempt_id: str, step: int, part: dict
152
+ ) -> None:
153
+ status, body = self._json(
154
+ "POST",
155
+ f"/v1alpha1/jobs/{job_id}/tasks/{task_id}/checkpoints/parts",
156
+ {"attempt_id": attempt_id, "step": step, "part": part},
157
+ )
158
+ if status != 200:
159
+ raise RuntimeError(f"checkpoint part registration failed ({status}): {body}")
160
+
161
+ def checkpoint_commit(
162
+ self,
163
+ job_id: str,
164
+ task_id: str,
165
+ attempt_id: str,
166
+ step: int,
167
+ parts: list[dict],
168
+ storage_prefix: str,
169
+ ) -> dict:
170
+ status, body = self._json(
171
+ "POST",
172
+ f"/v1alpha1/jobs/{job_id}/tasks/{task_id}/checkpoints/commit",
173
+ {
174
+ "attempt_id": attempt_id,
175
+ "step": step,
176
+ "expected_parts": parts,
177
+ "storage_prefix": storage_prefix,
178
+ },
179
+ )
180
+ if status != 200:
181
+ raise RuntimeError(f"checkpoint commit refused ({status}): {body}")
182
+ return body
183
+
184
+ # -- artifacts (coordinator-hosted shared data) --------------------------
185
+
186
+ def download_artifact(self, key: str, destination: Path) -> Path:
187
+ status, raw = self._request("GET", f"/v1alpha1/artifacts/{key}")
188
+ if status != 200:
189
+ raise RuntimeError(f"artifact download failed ({status}): {key}")
190
+ destination.parent.mkdir(parents=True, exist_ok=True)
191
+ destination.write_bytes(raw)
192
+ return destination
193
+
194
+ def upload_artifact(self, local_path: Path, key: str) -> str:
195
+ data = Path(local_path).read_bytes()
196
+ status, _ = self._request(
197
+ "PUT", f"/v1alpha1/artifacts/{key}", data, content_type="application/octet-stream"
198
+ )
199
+ if status != 200:
200
+ raise RuntimeError(f"artifact upload failed ({status}): {key}")
201
+ return hashlib.sha256(data).hexdigest()