ctfy.sdk.node_client

HTTP client for platform → node machine calls.

Every call is authenticated with the target node's bearer token — minted per node at registration and used in both directions. End users never touch this client; they talk to the platform, which proxies to the node. Node endpoints are defined in server/routes/node_instances.py.

   1"""HTTP client for platform → node machine calls.
   2
   3Every call is authenticated with the target node's bearer token —
   4minted per node at registration and used in both directions. End users
   5never touch this client; they talk to the platform, which proxies to
   6the node. Node endpoints are defined in
   7``server/routes/node_instances.py``.
   8"""
   9
  10from __future__ import annotations
  11
  12import base64
  13import dataclasses
  14import json
  15from collections.abc import Iterable, Mapping, Sequence
  16from typing import Any, TypeVar
  17from urllib.parse import quote
  18
  19import httpx
  20from pydantic import BaseModel, Field, ValidationError
  21
  22from ctfy.challenge.deception import DeceptionSummary
  23from ctfy.core.agent_log import NodeAgentLog
  24from ctfy.core.attachment_layout import (
  25    InvalidAttachmentName,
  26    attachment_url_path,
  27    component_refusal,
  28)
  29from ctfy.core.awd_network import MatchNetwork
  30from ctfy.core.challenge import ChallengeReloadResult
  31from ctfy.core.constants import (
  32    DEFAULT_CLIENT_TIMEOUT,
  33    HARNESS_CREATE_TIMEOUT_S,
  34    HARNESS_IMAGE_INSPECT_TIMEOUT_S,
  35    IMAGE_PULL_TIMEOUT_S,
  36)
  37from ctfy.core.enums import InternetAccess, TcpAccess
  38from ctfy.core.exceptions import NodeRequestError
  39from ctfy.core.failure_text import tail
  40from ctfy.core.freeze_budget import freeze_client_wait_s, freeze_deadline_s
  41from ctfy.core.instance_disk_retention import (
  42    CAPTURE_DROPPED_BYTES_HEADER,
  43    CAPTURE_DROPPED_SEGMENTS_HEADER,
  44)
  45from ctfy.core.judge_budget import DEFAULT_JUDGE_TIMEOUT_S, judge_exec_s
  46from ctfy.core.judge_budget import platform_wait_s as judge_platform_wait_s
  47from ctfy.core.log import log
  48from ctfy.core.node_features import CAPABILITY_FEATURES, feature_enabled
  49from ctfy.core.observed_contact import ContactReading
  50from ctfy.core.patch_budget import patch_client_wait_s
  51from ctfy.core.prebuild_tag import ChallengeFreezeResult
  52from ctfy.core.resource_budget import ResourceBudget
  53from ctfy.core.smoke_budget import SOLUTION_BUILD_TIMEOUT_S
  54from ctfy.core.smoke_live import NodeSmokeProgress
  55from ctfy.sdk.base import RETRY_UNSENT, BaseHttpClient
  56from ctfy.server.models.challenge import AttachmentList
  57from ctfy.server.models.instance import InstanceTrafficPage, InstanceTrafficWitnesses
  58from ctfy.server.models.node import (
  59    AgentIngressPeer,
  60    AgentIngressPeerRemoved,
  61    AgentIngressPeerRequest,
  62    AgentRuntime,
  63    AwdAttackResult,
  64    BuildAllQueued,
  65    BuildPushCancelled,
  66    BuildPushProgress,
  67    BuildStateRows,
  68    CheckResult,
  69    ComposeContainer,
  70    FabricProbeReport,
  71    FabricStatus,
  72    HarnessImagePolicy,
  73    HarnessLimits,
  74    HarnessRegistryAuth,
  75    HarnessRun,
  76    HarnessRunResult,
  77    InstanceHeld,
  78    InstanceInternetChange,
  79    InstanceInternetChanged,
  80    InstanceStarted,
  81    InstanceStatusReport,
  82    NodeAuthoredPush,
  83    NodeAuthoredResult,
  84    NodeAuthoredSmokePush,
  85    NodeAuthoredSmokeResult,
  86    NodeCapabilities,
  87    NodeDiskFree,
  88    NodeInstanceList,
  89    NodeInstanceSummary,
  90    NodeRangePush,
  91    NodeRangeResult,
  92    NodeRangeVerifyPush,
  93    NodeRangeVerifyResult,
  94    NodeSmokeEntrance,
  95    NodeSmokeExploitOutcome,
  96    PatchVerifyResult,
  97    PullAllQueued,
  98    PullStateRows,
  99)
 100from ctfy.server.node_state import ChallengeBuildState, ChallengePullState
 101from ctfy.server.traffic_tail import coerce_page
 102
 103
 104@dataclasses.dataclass(frozen=True)
 105class PcapCapture:
 106    """A node's merged capture, and what the cap dropped from it (#1714)."""
 107
 108    data: bytes
 109    #: ``None`` = the node did not say (older build); ``0`` = nothing.
 110    dropped_bytes: int | None = None
 111    dropped_segments: int | None = None
 112
 113
 114def _header_int(resp: httpx.Response, name: str) -> int | None:
 115    raw = resp.headers.get(name)
 116    if raw is None:
 117        return None
 118    try:
 119        return max(0, int(raw))
 120    except ValueError:
 121        return None
 122
 123
 124def _raise_for_node_status(resp: httpx.Response) -> None:
 125    """Like ``resp.raise_for_status()`` but preserves the node's error body.
 126
 127    On a 4xx/5xx, pull the FastAPI ``{"detail": ...}`` field (or the raw
 128    body as a fallback) and raise :class:`NodeRequestError` so the real
 129    failure reaches the caller instead of httpx's generic status string.
 130    """
 131    if not resp.is_error:
 132        return
 133    detail = ""
 134    code = ""
 135    try:
 136        body = resp.json()
 137    except (json.JSONDecodeError, ValueError):
 138        body = None
 139    if isinstance(body, dict):
 140        d = body.get("detail")
 141        if isinstance(d, str):
 142            detail = d
 143        elif isinstance(d, dict) and isinstance(d.get("code"), str):
 144            # A node naming its failure (#1835): keep the name as a field
 145            # and the sentence as the detail, so neither a reader of the
 146            # code nor a human reading the detail meets JSON.
 147            code = str(d["code"])
 148            message = d.get("message")
 149            detail = message if isinstance(message, str) else json.dumps(d)
 150        elif d is not None:
 151            detail = json.dumps(d)
 152    if not detail:
 153        detail = (resp.text or "").strip()
 154    raise NodeRequestError(resp.status_code, detail, code=code)
 155
 156
 157_M = TypeVar("_M", bound=BaseModel)
 158
 159
 160def _parsed(model: type[_M], resp: httpx.Response) -> _M:
 161    """The node's answer, as the model the node itself declares.
 162
 163    ⚠️ **A parse failure raises rather than defaulting.** Its neighbour
 164    ``instance_health`` falls back to an empty ``DeceptionSummary``, and
 165    that is right there: the summary is decoration on a liveness answer
 166    the caller already has. These carry the *verdict* — whether a checker
 167    passed, what an attack read — and a default is a verdict nobody
 168    reached. The checker's own fail direction is closed, and an answer
 169    this client cannot parse is not an answer.
 170
 171    ⚠️ Unknown fields are ignored (pydantic's default), which is the
 172    forward-compatible half: the node and the platform are deployed
 173    separately, ``ctfy/core/version.py`` exists because they skew, and a
 174    newer node sending a field this platform has never heard of must not
 175    fail the call.
 176    """
 177    try:
 178        return model.model_validate(resp.json())
 179    except (ValidationError, ValueError) as exc:
 180        # ⚠️ ``resp.status_code``, not a synthetic one: the node
 181        # *answered*, and the failure is that its body does not fit the
 182        # contract. Reporting a 502 here would say the node was
 183        # unreachable, which sends whoever reads it to the wrong host.
 184        raise NodeRequestError(
 185            resp.status_code,
 186            f"answered something this platform cannot read as {model.__name__}: {exc}",
 187        ) from exc
 188
 189
 190def _parsed_list(model: type[_M], resp: httpx.Response) -> list[_M]:
 191    """A JSON array of *model*, with the same failure direction as
 192    :func:`_parsed` — one unreadable element fails the call rather than
 193    being dropped, because a partial list read as a whole one is how a
 194    re-adoption pass decides an instance is gone."""
 195    body = resp.json()
 196    if not isinstance(body, list):
 197        raise NodeRequestError(
 198            resp.status_code, f"expected a list of {model.__name__}, got {type(body).__name__}"
 199        )
 200    try:
 201        return [model.model_validate(item) for item in body]
 202    except (ValidationError, ValueError) as exc:
 203        raise NodeRequestError(
 204            resp.status_code,
 205            f"answered something this platform cannot read as a list of {model.__name__}: {exc}",
 206        ) from exc
 207
 208
 209class HealthReading(BaseModel):
 210    """One health poll's three answers.
 211
 212    ⚠️ **A record rather than a tuple, and the reason is recorded one
 213    subsystem over.** ``competition_results`` lost ``attempts`` and
 214    ``last_solve_at`` to a hand-made ``(rank, flags, solved)`` tuple
 215    sitting between a board that produced them and a template that
 216    consumed them; this poll had two answers and now has three, so the
 217    tuple was one field away from the same fate. Growing a record is a
 218    field; growing a tuple is every call site, silently re-bound.
 219    """
 220
 221    healthy: bool = False
 222    deception: DeceptionSummary = Field(default_factory=DeceptionSummary)
 223    contact: ContactReading = Field(default_factory=ContactReading)
 224    new_processes: dict[str, list[str]] = Field(default_factory=dict)
 225
 226
 227def masked_capabilities(caps: NodeCapabilities, disabled: Iterable[str]) -> NodeCapabilities:
 228    """*caps* with every feature in *disabled* answered ``False`` (#3037).
 229
 230    The effective value is *supported by the node* AND *enabled by the
 231    admin*. ⚠️ Only names that are features are masked
 232    (`ctfy.core.node_features.CAPABILITY_FEATURES`), so an unknown or stale name
 233    in the row turns nothing off — the switch fails open. A placement switch
 234    (``public_ports``) is not a capability and is asked by placement instead.
 235    """
 236    off = [f for f in CAPABILITY_FEATURES if not feature_enabled(disabled, f)]
 237    if not off:
 238        return caps
 239    update: dict[str, object] = dict.fromkeys(off, False)
 240    for flag in off:
 241        reason = f"{flag}_reason"
 242        if reason in NodeCapabilities.model_fields:
 243            update[reason] = "switched off by an admin on this node"
 244    return caps.model_copy(update=update)
 245
 246
 247class NodeClient(BaseHttpClient):
 248    """Thin sync HTTP client; one instance per node URL."""
 249
 250    def __init__(
 251        self,
 252        node_url: str,
 253        token: str,
 254        *,
 255        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 256        max_retries: int = 2,
 257        transport: httpx.BaseTransport | None = None,
 258        disabled_features: Iterable[str] = (),
 259    ) -> None:
 260        """*disabled_features* are the features an admin switched off on this
 261        node (#3037, `ctfy.core.node_features`): :meth:`capabilities` answers
 262        ``False`` for each, whatever the node says, so a caller reads the
 263        effective value and cannot forget the switch. ``()`` is the raw answer
 264        — every platform construction passes the node row's set
 265        (`test_every_node_client_carries_the_switches`).
 266
 267        *transport* is `BaseHttpClient`'s own seam, passed through so a
 268        test can drive a real node app over the real client — the hop the
 269        fakes on either side of it cannot see.
 270
 271        *max_retries* is passed through too: a best-effort read or a stop
 272        is tried once (#2812), because a retry on a timeout multiplies the
 273        one number that has to stay small.
 274
 275        ⚠️ **A call that starts long work retries only a request that never
 276        left** (#3162, :data:`~ctfy.sdk.base.RETRY_UNSENT`): every POST here
 277        that names its own HTTP timeout says so per call, because a
 278        ``ReadTimeout`` is a request the node has and is still running.
 279        ``start_instance`` keeps the default — the node replays it from the
 280        instance id. Guarded by `ctfy/tests/test_long_node_work_is_sent_once.py`."""
 281        super().__init__(
 282            f"{node_url.rstrip('/')}/api/v1",
 283            token,
 284            timeout=timeout,
 285            max_retries=max_retries,
 286            transport=transport,
 287        )
 288        self.disabled_features = frozenset(disabled_features)
 289
 290    # -- lifecycle ----------------------------------------------------------
 291
 292    def start_instance(
 293        self,
 294        *,
 295        challenge_id: str,
 296        instance_id: str,
 297        ttl: int,
 298        answers: dict[str, str],
 299        proxy_output_dir: str | None = None,
 300        env: dict[str, str] | None = None,
 301        publish_gamebox: bool = False,
 302        match_network: MatchNetwork | None = None,
 303        open_shell_password: str = "",
 304        authored_revision: str = "",
 305        internet: InternetAccess = InternetAccess.ALLOW,
 306        tcp_access: TcpAccess = TcpAccess.TLS_SNI,
 307        egress_allow_hosts: list[str] | None = None,
 308        resource_budget: ResourceBudget | None = None,
 309        compose_up_timeout_s: int = 0,
 310    ) -> InstanceStarted:
 311        resp = self.request(
 312            "POST",
 313            "/instances",
 314            json={
 315                # #1835: the authored tree's revision this start must run;
 316                # a node holding another (or none) fetches it first.
 317                "authored_revision": authored_revision,
 318                "challenge_id": challenge_id,
 319                "instance_id": instance_id,
 320                "ttl": ttl,
 321                "answers": answers,
 322                "proxy_output_dir": proxy_output_dir,
 323                "env": env or {},
 324                "publish_gamebox": publish_gamebox,
 325                # ``None`` rather than an omitted key: the node's model
 326                # defaults to None either way, and sending it explicitly
 327                # keeps the body's shape stable across both branches.
 328                "match_network": (dataclasses.asdict(match_network) if match_network else None),
 329                # Classic-AWD open shell (#1725): the box's starting root
 330                # password, empty for every managed / non-AWD start.
 331                "open_shell_password": open_shell_password,
 332                # #1969: ``deny`` holds the box off the internet; the hosts
 333                # are the platform-internal ones it still reaches.
 334                "internet": str(internet),
 335                # #2082: how a player reaches the box's TCP services.
 336                "tcp_access": str(tcp_access),
 337                "egress_allow_hosts": list(egress_allow_hosts or []),
 338                # The CPU / memory a player-authored box is held to; ``None``
 339                # for the corpus and from an older platform.
 340                "resource_budget": (
 341                    resource_budget.model_dump() if resource_budget is not None else None
 342                ),
 343                # #3165: the platform's ``compose_up_timeout_s``, so the node
 344                # spends the budget the platform waits on; ``0`` = the node's own.
 345                "compose_up_timeout_s": compose_up_timeout_s,
 346            },
 347        )
 348        _raise_for_node_status(resp)
 349        return _parsed(InstanceStarted, resp)
 350
 351    def stop_instance(self, instance_id: str) -> None:
 352        resp = self.request("DELETE", f"/instances/{instance_id}")
 353        resp.raise_for_status()
 354
 355    def apply_fabric(
 356        self,
 357        *,
 358        index: int,
 359        interface: dict[str, Any],
 360        match_network: str = "",
 361    ) -> FabricStatus:
 362        """Put this node on one match's WireGuard fabric.
 363
 364        ⚠️ **Returns the model, never ``resp.json()``.** The platform
 365        reads the verdict field by field, and on a dict a renamed field
 366        is ``None`` — falsy — which reads as "the arena did not open",
 367        so a wire change would quietly report every node as broken.
 368
 369        Returns the node's verdict rather than raising on a refusal: the
 370        route answers **200 with a body** precisely so that "this node is
 371        unreachable" and "this node rejected the plan" stay different
 372        facts, and a caller fanning out over an arena needs to tell them
 373        apart — one is waited on, the other is looked at.
 374
 375        *match_network* lets the node check the tunnel landed in the
 376        namespace its boxes are in, and open the bridge's one hole. It is
 377        the platform's own fact (`awd_network.network_name`), so the node
 378        is told rather than guessing.
 379        """
 380        resp = self.request(
 381            "POST",
 382            "/fabric",
 383            json={
 384                "index": index,
 385                "interface": interface,
 386                "match_network": match_network,
 387            },
 388        )
 389        resp.raise_for_status()
 390        return _parsed(FabricStatus, resp)
 391
 392    def probe_fabric(
 393        self,
 394        *,
 395        index: int,
 396        peers: list[dict[str, str]],
 397        mtu: int = 0,
 398    ) -> FabricProbeReport:
 399        """Ask named peers the three link questions. Applies nothing.
 400
 401        ⚠️ **Returns the model, never ``resp.json()``** — same reason
 402        `apply_fabric` gives: on a dict a renamed field reads as falsy,
 403        which here means "your tunnel is down" about a wire change.
 404        """
 405        resp = self.request(
 406            "POST",
 407            "/fabric/probe",
 408            json={"index": index, "peers": peers, "mtu": mtu},
 409        )
 410        resp.raise_for_status()
 411        return _parsed(FabricProbeReport, resp)
 412
 413    def remove_fabric(self, *, index: int, match_network: str = "") -> FabricStatus:
 414        """Take this node off one match's fabric and close its hole.
 415
 416        ⚠️ *match_network* is what lets the node resolve the bridge and
 417        delete the ``DOCKER-USER`` rules by name. Without it the tunnel
 418        goes and the hole stays — open on whatever docker gives that
 419        bridge name next.
 420        """
 421        resp = self.request(
 422            "DELETE",
 423            f"/fabric/{index}",
 424            params={"match_network": match_network},
 425        )
 426        resp.raise_for_status()
 427        return _parsed(FabricStatus, resp)
 428
 429    def stop_instance_service(self, instance_id: str, service: str) -> int:
 430        """Stop one compose service, leaving the instance running.
 431
 432        The exercise format's session close (§4b.4). Returns how many
 433        containers the node stopped — 0 means the challenge declares no
 434        such service, which is a real answer rather than a failure, so
 435        the caller can distinguish it from a cut tunnel.
 436        """
 437        resp = self.request(
 438            "POST",
 439            f"/instances/{instance_id}/stop-service",
 440            json={"service": service},
 441        )
 442        resp.raise_for_status()
 443        body = resp.json()
 444        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0
 445
 446    def capabilities(
 447        self, *, harness_admission: bool = False, agent_ingress: bool = False
 448    ) -> NodeCapabilities:
 449        """What this node can do beyond the original verbs.
 450
 451        ⚠️ **A 404 is an answer, not an error**: a node from before hold /
 452        resume has no such route, and "cannot hold" is exactly what the
 453        platform needs to hear -- the caller then falls back to deleting.
 454        Any other failure raises, because an unreachable node is not a node
 455        that cannot hold.
 456        """
 457        # ⚠️ #2356: ``harness_admission`` makes the node prove it can hold
 458        # an admitted run, which writes its host firewall — asked only by
 459        # the admission paths, never by hold / internet.
 460        # #3020: ``agent_ingress`` makes the node converge its listener,
 461        # asked only before handing a published target over WireGuard.
 462        params = {
 463            **({"harness_admission": "true"} if harness_admission else {}),
 464            **({"agent_ingress": "true"} if agent_ingress else {}),
 465        }
 466        resp = self.request("GET", "/capabilities", params=params or None)
 467        if resp.status_code == 404:
 468            return NodeCapabilities()
 469        resp.raise_for_status()
 470        return masked_capabilities(_parsed(NodeCapabilities, resp), self.disabled_features)
 471
 472    def create_agent_ingress_peer(
 473        self, *, run_id: str, public_key: str, ports: list[int], ttl_s: int
 474    ) -> AgentIngressPeer:
 475        """Add *run_id*'s peer to the node's agent-ingress listener (#3020).
 476
 477        Only the public key crosses; the caller keeps the private half.
 478        Raises on any non-2xx (the node's refusal is in the detail).
 479        """
 480        body = AgentIngressPeerRequest(
 481            run_id=run_id, public_key=public_key, ports=ports, ttl_s=ttl_s
 482        )
 483        resp = self.request("POST", "/agent-ingress/peers", json=body.model_dump(mode="json"))
 484        resp.raise_for_status()
 485        return _parsed(AgentIngressPeer, resp)
 486
 487    def delete_agent_ingress_peer(self, run_id: str) -> bool:
 488        """Revoke *run_id*'s peer; ``False`` when the node held none."""
 489        resp = self.request("DELETE", f"/agent-ingress/peers/{run_id}")
 490        resp.raise_for_status()
 491        return _parsed(AgentIngressPeerRemoved, resp).removed
 492
 493    def hold_instance(self, instance_id: str, mode: str) -> int:
 494        """Pause or stop an instance, removing nothing. Returns containers held."""
 495        resp = self.request("POST", f"/instances/{instance_id}/hold", json={"mode": mode})
 496        resp.raise_for_status()
 497        return _parsed(InstanceHeld, resp).count
 498
 499    def resume_instance(self, instance_id: str) -> int:
 500        """Bring a held instance back. Returns containers brought back."""
 501        resp = self.request("POST", f"/instances/{instance_id}/resume")
 502        resp.raise_for_status()
 503        return _parsed(InstanceHeld, resp).count
 504
 505    def change_instance_internet(
 506        self, instance_id: str, change: InstanceInternetChange
 507    ) -> InstanceInternetChanged:
 508        """Change a running box's internet verdict (#2067).
 509
 510        Raises :class:`NodeRequestError` on any refusal, carrying the node's
 511        ``code`` (:data:`~ctfy.core.exceptions.NODE_INTERNET_CHANGE_FAILED`
 512        when its firewall could not converge — the node has then put its
 513        record back, so nothing changed).
 514        """
 515        resp = self.request(
 516            "POST", f"/instances/{instance_id}/internet", json=change.model_dump(mode="json")
 517        )
 518        _raise_for_node_status(resp)
 519        return _parsed(InstanceInternetChanged, resp)
 520
 521    def stop_all(self) -> None:
 522        resp = self.request("POST", "/admin/stop-all")
 523        resp.raise_for_status()
 524
 525    def put_authored_challenge(self, push: NodeAuthoredPush) -> NodeAuthoredResult:
 526        """Ask the node to write an approved player upload into its catalog.
 527
 528        ⚠️ **The bytes travel here, unlike `put_generated_range`.** That
 529        one sends a scenario because the node can compile it; a
 530        player-authored tree has no compiler and no corpus entry, so the
 531        archive is what there is to send.
 532
 533        The node answers **200 with ``ok: false``** for a refusal, so a
 534        node that declined the archive and a node that could not be
 535        reached stay different facts; only the second raises here.
 536        """
 537        resp = self.request("POST", "/admin/authored", json=push.model_dump(mode="json"))
 538        resp.raise_for_status()
 539        return _parsed(NodeAuthoredResult, resp)
 540
 541    def smoke_authored_challenge(
 542        self, push: NodeAuthoredSmokePush, *, timeout: int | None = None
 543    ) -> NodeAuthoredSmokeResult:
 544        """Ask the node to run an authored challenge's own reference exploit.
 545
 546        ⚠️ **Takes its own timeout.** A smoke run brings a whole estate
 547        up; the client default is sized for a status call and would give
 548        up while the node is still doing what it was asked.
 549        """
 550        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
 551        if timeout is not None:
 552            kwargs["timeout"] = timeout
 553        resp = self.request("POST", "/admin/authored/smoke", retry_on=RETRY_UNSENT, **kwargs)
 554        resp.raise_for_status()
 555        return _parsed(NodeAuthoredSmokeResult, resp)
 556
 557    def smoke_progress(self, run_id: str, since: int = 0) -> NodeSmokeProgress | None:
 558        """The steps and lines a running smoke has produced since *since* (#2145).
 559
 560        ``None`` when the node holds no feed for *run_id* — a node older than
 561        the feed, or a run that never started — which a watcher reads as
 562        *unwatched*. Any other failure raises: the caller decides that a
 563        watcher's trouble must never fail the run it watches.
 564        """
 565        # ⚠️ A short timeout of its own: this is read once a second beside a
 566        # run that may be minutes long, and a read that hangs on a busy worker
 567        # must not hold the watcher past the run it watches.
 568        resp = self.request(
 569            "GET",
 570            f"/admin/authored/smoke/{run_id}/progress",
 571            params={"since": since},
 572            timeout=10,
 573        )
 574        if resp.status_code == 404:
 575            return None
 576        resp.raise_for_status()
 577        return _parsed(NodeSmokeProgress, resp)
 578
 579    def smoke_entrance(self, run_id: str) -> NodeSmokeEntrance:
 580        """The player's entrance to the smoke box held for *run_id* (#2819 W2).
 581
 582        ``pending`` / ``ready`` / ``gone``; a 404 (a node older than the
 583        route) raises, which the caller reads as *this node cannot hold*.
 584        """
 585        resp = self.request("GET", f"/admin/authored/smoke/{run_id}/entrance", timeout=15)
 586        resp.raise_for_status()
 587        return _parsed(NodeSmokeEntrance, resp)
 588
 589    def post_smoke_exploit(self, run_id: str, outcome: NodeSmokeExploitOutcome) -> bool:
 590        """Hand the held smoke box its exploit outcome (#2819 W2).
 591
 592        ``True`` when the node took it; ``False`` when it holds no box for
 593        *run_id* any more (it tore down on its own timeout) or had one.
 594        """
 595        resp = self.request(
 596            "POST",
 597            f"/admin/authored/smoke/{run_id}/exploit",
 598            json=outcome.model_dump(mode="json"),
 599            timeout=30,
 600            # First outcome wins on the node, so a resend is harmless; but a
 601            # read timeout is a post that may have landed (#3162).
 602            retry_on=RETRY_UNSENT,
 603        )
 604        if resp.status_code == 404:
 605            return False
 606        resp.raise_for_status()
 607        return bool(resp.json().get("taken"))
 608
 609    def harness_log(self, run_id: str, after: int = 0) -> NodeAgentLog | None:
 610        """An agent run's console lines numbered above *after* (#2401).
 611
 612        ``None`` when the node holds no console for *run_id* — a node older
 613        than the feed, or a run that never started there — which a watcher
 614        reads as *unwatched*. Any other failure raises: the caller decides
 615        that a watcher's trouble must never fail the run it watches.
 616        """
 617        # ⚠️ A short timeout of its own, as ``smoke_progress``: read once a
 618        # second beside a run that may be hours long.
 619        resp = self.request(
 620            "GET", f"/harness/runs/{run_id}/log", params={"after": after}, timeout=10
 621        )
 622        if resp.status_code == 404:
 623            return None
 624        resp.raise_for_status()
 625        return _parsed(NodeAgentLog, resp)
 626
 627    def harness_result(self, run_id: str) -> HarnessRunResult | None:
 628        """What the node knows of a run's harness call (#3021).
 629
 630        ``None`` when the node has no such route — a node older than the
 631        result store, which can never return a run's outcome after its call
 632        is gone. ``state="unknown"`` is the node answering that it holds
 633        nothing for *run_id*. Any other failure raises: an unreachable node
 634        is neither of those, and the caller bounds how long it waits on one.
 635        """
 636        resp = self.request("GET", f"/harness/runs/{run_id}/result", timeout=30)
 637        if resp.status_code == 404:
 638            return None
 639        resp.raise_for_status()
 640        return _parsed(HarnessRunResult, resp)
 641
 642    def put_generated_range(self, push: NodeRangePush) -> NodeRangeResult:
 643        """Ask the node to build a generated range from its scenario.
 644
 645        ⚠️ **The scenario travels, not the estate.** The corpus is
 646        mounted on the node and the compiler ships inside it, so 70-odd
 647        rendered files never cross this API — and the corpus commit that
 648        rides along is what stops one scenario becoming two different
 649        estates under one id across a fleet.
 650
 651        The node answers **200 with ``ok: false``** for a refusal, so a
 652        node that declined and a node that could not be reached stay
 653        different facts; only the second raises here.
 654        """
 655        resp = self.request("POST", "/admin/ranges", json=push.model_dump(mode="json"))
 656        resp.raise_for_status()
 657        return _parsed(NodeRangeResult, resp)
 658
 659    def verify_generated_range(
 660        self, push: NodeRangeVerifyPush, *, timeout: int | None = None
 661    ) -> NodeRangeVerifyResult:
 662        """Ask the node to build a scenario, solve it, and take it down.
 663
 664        ⚠️ **The longest call in this API**, so the caller passes its own
 665        timeout: the node's budget is ``compose_timeout_s +
 666        solve_timeout_s`` and a client that used the default would give
 667        up while the node was still building — then retry, and start a
 668        second estate beside the first.
 669
 670        ⚠️ A client timeout does **not** leave a range running: the node
 671        tears down in its own ``finally``. What is lost is the answer,
 672        not the host.
 673
 674        The node answers **200 with an ``error``** for a refusal, the
 675        same split ``put_generated_range`` uses; only an unreachable node
 676        raises.
 677        """
 678        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
 679        if timeout is not None:
 680            # httpx takes a per-request override; the client-wide value
 681            # stays whatever the caller constructed this with.
 682            kwargs["timeout"] = timeout
 683        resp = self.request("POST", "/admin/ranges/verify", retry_on=RETRY_UNSENT, **kwargs)
 684        resp.raise_for_status()
 685        return _parsed(NodeRangeVerifyResult, resp)
 686
 687    def cancel_generated_range_verify(self, run_id: str) -> bool:
 688        """Tell the node to stop a verification it is running.
 689
 690        ⚠️ **A miss is success, not a 404 to report.** The cancel races
 691        the run's own finish, so "no such run" means the run already
 692        ended — which is what the operator asked for. The node answers
 693        ``{"cancelled": false}`` for that case rather than raising, and
 694        this returns it so a caller that wants to say "it had already
 695        finished" can.
 696        """
 697        resp = self.request("POST", f"/admin/ranges/verify/{run_id}/cancel")
 698        resp.raise_for_status()
 699        body = resp.json()
 700        return bool(body.get("cancelled"))
 701
 702    def rescan_challenges(self) -> ChallengeReloadResult:
 703        """Tell the node to drop its spec cache and re-scan challenges_dir.
 704
 705        Returns ``{total, added, removed}`` so the platform can report
 706        the per-node outcome of a cluster-wide rescan."""
 707        resp = self.request("POST", "/admin/rescan-challenges")
 708        resp.raise_for_status()
 709        return _parsed(ChallengeReloadResult, resp)
 710
 711    # -- admin pre-build (image cache warming) ------------------------------
 712
 713    def build_challenge(
 714        self,
 715        challenge_id: str,
 716        *,
 717        authored_revision: str = "",
 718        resource_budget: ResourceBudget | None = None,
 719    ) -> ChallengeBuildState:
 720        """Ask the node to pre-build images for *challenge_id*.
 721
 722        Fires-and-returns: the node persists ``status="building"`` and
 723        spawns a daemon thread; the body returned here is that initial
 724        state row. Polling :meth:`get_build_state` is how the platform
 725        learns when it lands on ``built`` / ``failed``. *authored_revision*
 726        is #1835's: the tree the build must be of, fetched if not held.
 727        """
 728        params = {"authored_revision": authored_revision} if authored_revision else None
 729        # #2504: an authored tree pre-builds under this budget, or not at all.
 730        kwargs: dict[str, Any] = {}
 731        if resource_budget is not None:
 732            kwargs["json"] = {"resource_budget": resource_budget.model_dump()}
 733        resp = self.request(
 734            "POST", f"/admin/challenges/{challenge_id}/build", params=params, **kwargs
 735        )
 736        _raise_for_node_status(resp)
 737        return _parsed(ChallengeBuildState, resp)
 738
 739    def build_all_challenges(
 740        self,
 741        challenge_ids: list[str] | None = None,
 742        *,
 743        resource_budget: ResourceBudget | None = None,
 744    ) -> BuildAllQueued:
 745        """Queue background pre-build on the node.
 746
 747        With ``challenge_ids=None`` the node builds every spec it knows.
 748        With an explicit list (the platform scoping a bulk build to a
 749        competition's challenge set) only those are built. Returns
 750        ``{queued, skipped_built, skipped_in_progress}`` so the platform
 751        can report per-node what got picked up. Sequential on the node
 752        side — no fan-out across the corpus.
 753        """
 754        kwargs: dict[str, Any] = {}
 755        body: dict[str, Any] = {}
 756        if challenge_ids is not None:
 757            body["challenge_ids"] = challenge_ids
 758        if resource_budget is not None:
 759            # #2504: what an authored id in the scope pre-builds under.
 760            body["resource_budget"] = resource_budget.model_dump()
 761        if body:
 762            kwargs["json"] = body
 763        resp = self.request("POST", "/admin/challenges/build-all", **kwargs)
 764        _raise_for_node_status(resp)
 765        return _parsed(BuildAllQueued, resp)
 766
 767    def build_push_challenge(
 768        self,
 769        challenge_id: str,
 770        *,
 771        push_ticket: str = "",
 772        deadline_s: int = 0,
 773        cleanup: bool = False,
 774        cache_max_bytes: int = 0,
 775        confine_network: bool = False,
 776        authored_revision: str = "",
 777        run_id: str = "",
 778        resource_budget: ResourceBudget | None = None,
 779    ) -> ChallengeFreezeResult:
 780        """Build one challenge's ``build:`` services and freeze them to the registry.
 781
 782        Layer 2 producer (P0). **Blocks** until the challenge is built +
 783        pushed (or skipped as already frozen), unlike the fire-and-return
 784        :meth:`build_challenge`. ⚠️ *deadline_s* bounds the node's whole
 785        freeze (#3167, `ctfy.core.freeze_budget`; ``0`` sends the node's own
 786        default), and the HTTP wait is derived from it —
 787        `freeze_client_wait_s`, past the node's own answer — so the client
 788        never abandons a freeze the node is still running. Sent once: a
 789        ``ReadTimeout`` is a request the node has (#3162). Returns the per-service freeze result;
 790        the platform's ``challenge_build_push`` task calls this one
 791        challenge at a time. *push_ticket* is the platform's short-lived
 792        registry credential the node's ``docker push`` authenticates with
 793        — the node runs it in a container with no ambient login.
 794        *cleanup* asks the node to remove what the build left and prune its
 795        build cache to *cache_max_bytes* afterwards, and *confine_network*
 796        keeps every ``RUN`` off the host's network (both the freeze pool, #1827).
 797        *run_id* names this call for :meth:`cancel_build_push` and
 798        :meth:`build_push_progress` while it is in flight (#1949).
 799        """
 800        resp = self.request(
 801            "POST",
 802            f"/admin/challenges/{challenge_id}/build-push",
 803            json={
 804                "push_ticket": push_ticket,
 805                "cleanup": cleanup,
 806                "cache_max_bytes": cache_max_bytes,
 807                "confine_network": confine_network,
 808                "authored_revision": authored_revision,
 809                "run_id": run_id,
 810                "deadline_s": deadline_s,
 811                # #2504: the build limits an authored tree is frozen under;
 812                # a node refuses one that arrives without them.
 813                "resource_budget": (
 814                    resource_budget.model_dump() if resource_budget is not None else None
 815                ),
 816            },
 817            timeout=freeze_client_wait_s(deadline_s or freeze_deadline_s()),
 818            retry_on=RETRY_UNSENT,
 819        )
 820        _raise_for_node_status(resp)
 821        return _parsed(ChallengeFreezeResult, resp)
 822
 823    def cancel_build_push(self, run_id: str) -> BuildPushCancelled:
 824        """Cancel the freeze :meth:`build_push_challenge` started under *run_id* (#1949).
 825
 826        Idempotent; ``found`` false means the node holds no such run.
 827        """
 828        resp = self.request("POST", "/admin/build-push/cancel", json={"run_id": run_id})
 829        _raise_for_node_status(resp)
 830        return _parsed(BuildPushCancelled, resp)
 831
 832    def build_push_progress(self, run_id: str) -> BuildPushProgress:
 833        """What the freeze running under *run_id* is doing now (#1949)."""
 834        resp = self.request("GET", f"/admin/build-push/{run_id}/progress")
 835        _raise_for_node_status(resp)
 836        return _parsed(BuildPushProgress, resp)
 837
 838    def get_disk_free(self) -> NodeDiskFree:
 839        """Free space on the node's data filesystem, read now (#1827).
 840
 841        Raises on any failure — including a node predating the route, which
 842        answers 404 — and the freeze reads every failure as *cannot tell*,
 843        which stops it.
 844        """
 845        resp = self.request("GET", "/admin/disk")
 846        _raise_for_node_status(resp)
 847        return _parsed(NodeDiskFree, resp)
 848
 849    def get_build_state(self) -> BuildStateRows:
 850        """Fetch every per-challenge build-state row on this node.
 851
 852        Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``.
 853        ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
 854        ``failed``; ``unbuilt`` placeholders are synthesised for specs
 855        the node has seen but never been asked to build.
 856        """
 857        resp = self.request("GET", "/admin/challenges/build-state")
 858        _raise_for_node_status(resp)
 859        return _parsed(BuildStateRows, resp)
 860
 861    def pull_challenge(self, challenge_id: str) -> ChallengePullState:
 862        """Ask the node to pre-pull registry images for *challenge_id*.
 863
 864        Pull-side twin of :meth:`build_challenge`: the node persists
 865        ``status="pulling"`` and spawns a daemon thread; poll
 866        :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing.
 867        """
 868        resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull")
 869        _raise_for_node_status(resp)
 870        return _parsed(ChallengePullState, resp)
 871
 872    def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> PullAllQueued:
 873        """Queue background pre-pull on the node.
 874
 875        With ``challenge_ids=None`` the node pulls every spec it knows;
 876        with an explicit list only those (the platform scoping to a
 877        competition). Returns ``{queued, skipped_pulled,
 878        skipped_in_progress}``.
 879        """
 880        kwargs: dict[str, Any] = {}
 881        if challenge_ids is not None:
 882            kwargs["json"] = {"challenge_ids": challenge_ids}
 883        resp = self.request("POST", "/admin/challenges/pull-all", **kwargs)
 884        _raise_for_node_status(resp)
 885        return _parsed(PullAllQueued, resp)
 886
 887    def get_pull_state(self) -> PullStateRows:
 888        """Fetch every per-challenge pull-state row on this node.
 889
 890        Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``.
 891        ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
 892        ``failed``; ``unpulled`` placeholders are synthesised for specs
 893        the node has seen but never been asked to pull.
 894        """
 895        resp = self.request("GET", "/admin/challenges/pull-state")
 896        _raise_for_node_status(resp)
 897        return _parsed(PullStateRows, resp)
 898
 899    # -- status / health ----------------------------------------------------
 900
 901    def get_status(self, instance_id: str) -> InstanceStatusReport:
 902        resp = self.request("GET", f"/instances/{instance_id}/status")
 903        resp.raise_for_status()
 904        return _parsed(InstanceStatusReport, resp)
 905
 906    def list_instances(self) -> list[NodeInstanceSummary]:
 907        """Every instance the node still has bookkeeping for.
 908
 909        Used by the platform's boot re-adoption pass to decide which
 910        durable claims still have containers behind them. ``get_status``
 911        answers the same question one id at a time; asking it per claim
 912        would cost one round trip per instance to a node that may be
 913        slow or down, on the path that gates startup.
 914        """
 915        resp = self.request("GET", "/instances")
 916        resp.raise_for_status()
 917        return _parsed(NodeInstanceList, resp).items
 918
 919    def check_health(self, instance_id: str) -> bool:
 920        return self.check_health_verbose(instance_id).healthy
 921
 922    def check_health_verbose(self, instance_id: str) -> HealthReading:
 923        """Health plus whatever agent-deception telemetry the node read.
 924
 925        One call, because the platform already polls this endpoint for
 926        every live instance every tick and a second round trip per
 927        instance would be the whole cost of the feature. ``check_health``
 928        stays a bool for the callers that only ever wanted one.
 929
 930        Never an error, whatever arrives: this rides the health poll,
 931        and telemetry must not be able to mark a healthy instance
 932        unhealthy.
 933
 934        ⚠️ **The two ways this can come back empty are different facts
 935        and used to be one.** An older node sends no ``deception`` key
 936        at all — it *did not say*, which is ``channel=None`` and
 937        authorises nothing. A body this platform cannot parse is
 938        something else: the node did say, and we could not read it. That
 939        is ``unreadable``, the same value the reader gives for a missing
 940        file, because it is the same fact — there is no usable channel
 941        here — arriving through a different door.
 942
 943        The parse failure that matters is version skew in the other
 944        direction: a **newer** node reporting an ``ObservationKind`` this
 945        platform does not have. ``Observation.kind`` is typed, so one
 946        unknown member fails the whole model rather than dropping one
 947        row — which is the right refusal (the node's word is not ours to
 948        edit) and exactly why it must not land as "reported nothing".
 949        """
 950        resp = self.request("GET", f"/instances/{instance_id}/health")
 951        resp.raise_for_status()
 952        body = resp.json()
 953        raw = body.get("deception")
 954        if not raw:
 955            summary = DeceptionSummary()
 956        else:
 957            try:
 958                summary = DeceptionSummary.model_validate(raw)
 959            except ValidationError as exc:
 960                log.info(
 961                    "unreadable deception telemetry from node",
 962                    instance_id=instance_id,
 963                    error=tail(str(exc), limit=200),
 964                )
 965                summary = DeceptionSummary(channel="unreadable")
 966        raw_contact = body.get("contact")
 967        if not raw_contact:
 968            contact = ContactReading()
 969        else:
 970            try:
 971                contact = ContactReading.model_validate(raw_contact)
 972            except ValidationError as exc:
 973                log.info(
 974                    "unreadable contact telemetry from node",
 975                    instance_id=instance_id,
 976                    error=tail(str(exc), limit=200),
 977                )
 978                contact = ContactReading(channel="unreadable")
 979        raw_procs = body.get("new_processes")
 980        # ⚠️ Shape-checked rather than trusted: an older node sends
 981        # nothing and a newer one could send a shape this platform does
 982        # not model, and neither may reach a reader as *a container full
 983        # of intruders*. Anything unreadable is silence, which for a
 984        # signal built to under-report is the honest reading.
 985        procs: dict[str, list[str]] = {}
 986        if isinstance(raw_procs, dict):
 987            for container, names in raw_procs.items():
 988                if isinstance(container, str) and isinstance(names, list):
 989                    procs[container] = [str(n) for n in names]
 990        return HealthReading(
 991            healthy=bool(body.get("is_healthy")),
 992            deception=summary,
 993            contact=contact,
 994            new_processes=procs,
 995        )
 996
 997    def node_health(self) -> dict[str, Any]:
 998        """Liveness + ``{running, capacity}`` for heartbeat."""
 999        resp = self.request("GET", "/health")
1000        resp.raise_for_status()
1001        body: dict[str, Any] = resp.json()
1002        return body
1003
1004    # -- traffic / runtime --------------------------------------------------
1005
1006    def get_traffic_page(
1007        self, instance_id: str, *, cursor: str = "", limit: int = 0
1008    ) -> InstanceTrafficPage:
1009        """One page of mitmproxy flows; the capture lives on the node's FS.
1010
1011        ⚠️ **The one method that must not use ``_parsed``.** A node from
1012        before paging answers with a bare *list* — it sent the whole
1013        capture and understands no cursor — and a strict
1014        ``model_validate`` turns that supported skew into a raised
1015        request. So the body goes through ``coerce_page``, which already
1016        owns every decision about an unexpected shape, and the caller
1017        gets the model.
1018
1019        This is the same move ``coerce_page`` was written for, one layer
1020        down. Its docstring used to say the parsing belonged
1021        "platform-side … so a route and this client cannot come to
1022        disagree" — and there were *three* platform-side calls doing it.
1023        There is one call now, at the seam that knows the request
1024        cursor, which is what the fallback needs; the routes hold the
1025        type instead of re-deciding its shape.
1026
1027        ⚠️ **An empty ``cursor`` means "from the beginning".** Passing
1028        one on every poll re-reads the whole capture, which is the cost
1029        the paged read exists to remove — arriving through the caller
1030        rather than through the node.
1031        """
1032        params: dict[str, Any] = {}
1033        if cursor:
1034            params["cursor"] = cursor
1035        if limit > 0:
1036            params["limit"] = limit
1037        resp = self.request("GET", f"/instances/{instance_id}/traffic", params=params)
1038        resp.raise_for_status()
1039        page = coerce_page(resp.json(), cursor)
1040        return InstanceTrafficPage(
1041            flows=page.flows,
1042            cursor=page.cursor,
1043            truncated=page.truncated,
1044            capture=page.capture,
1045        )
1046
1047    def get_traffic_witnesses(
1048        self, instance_id: str, *, cursor: str = "", limit: int = 0
1049    ) -> InstanceTrafficWitnesses:
1050        """Who spoke to this box since *cursor*, with no bodies moved.
1051
1052        §7.1's correlation reads one gamebox per team per round. Through
1053        :meth:`get_traffic_page` that is every flow, both bodies
1054        included (~100 kB apiece by ``CLAUDE.md``'s measurement),
1055        pulled across the wire to extract a handful of addresses; the
1056        node reduces instead and this carries the result.
1057
1058        ⚠️ **Strict parsing here, unlike its neighbour, and that is the
1059        difference between an old field and an old route.**
1060        ``get_traffic_page`` is lenient because a node from before
1061        paging answers the *same* path with a bare list, which is
1062        supported skew. This route did not exist on such a node at all,
1063        so the honest answer from one is a 404 — and a lenient parse of
1064        an error body would hand the round loop an empty witness set,
1065        which reads as *nobody was there*. The caller catches the
1066        request error and records the round as unknown.
1067        """
1068        params: dict[str, Any] = {}
1069        if cursor:
1070            params["cursor"] = cursor
1071        if limit > 0:
1072            params["limit"] = limit
1073        resp = self.request("GET", f"/instances/{instance_id}/traffic-witnesses", params=params)
1074        resp.raise_for_status()
1075        return _parsed(InstanceTrafficWitnesses, resp)
1076
1077    def list_containers(self, instance_id: str) -> list[ComposeContainer]:
1078        """Enumerate every container in an instance's compose project.
1079
1080        Backs the admin-shell feature: the platform forwards a
1081        ``GET /admin/instances/{id}/containers`` to the assigned node,
1082        which returns one row per container (challenge services and
1083        platform-injected sidecars alike). The WebSocket reverse-proxy
1084        is opened separately and does not go through ``NodeClient``.
1085        """
1086        resp = self.request("GET", f"/instances/{instance_id}/containers")
1087        _raise_for_node_status(resp)
1088        return _parsed_list(ComposeContainer, resp)
1089
1090    def run_checker(
1091        self,
1092        instance_id: str,
1093        *,
1094        service: str,
1095        cmd: list[str],
1096        timeout_s: int = DEFAULT_JUDGE_TIMEOUT_S,
1097    ) -> CheckResult:
1098        """Exec an author-supplied checker in a trusted judge sidecar.
1099
1100        Backs the checker / exec-judge feature: the platform forwards a
1101        verify request to the assigned node, which runs ``cmd`` inside the
1102        named sidecar and returns
1103        ``{exit_code, stdout, stderr, timed_out, error}``.
1104
1105        ⚠️ *timeout_s* is the author's, and the node clamps it (#3169): the
1106        clamped budget is what is sent and what the wait is derived from
1107        (`core.judge_budget.platform_wait_s`), so ``0`` no longer waits 10 s
1108        on a node running 30, and ``600`` no longer holds a threadpool slot
1109        for 610 s on a node that stops at 120. Sent once (``RETRY_UNSENT``).
1110        """
1111        exec_s = judge_exec_s(timeout_s)
1112        resp = self.request(
1113            "POST",
1114            f"/instances/{instance_id}/check",
1115            json={"service": service, "cmd": cmd, "timeout_s": exec_s},
1116            timeout=judge_platform_wait_s(exec_s),
1117            retry_on=RETRY_UNSENT,
1118        )
1119        _raise_for_node_status(resp)
1120        return _parsed(CheckResult, resp)
1121
1122    def run_harness(
1123        self,
1124        *,
1125        image: str,
1126        run_id: str = "",
1127        env: dict[str, str],
1128        timeout_s: int = 1800,
1129        untrusted: bool = False,
1130        network: str = "",
1131        own_network: bool = False,
1132        match_network: str = "",
1133        match_address: str = "",
1134        brief: dict[str, Any] | None = None,
1135        target_instance: str = "",
1136        target_aliases: dict[str, str] | None = None,
1137        target_access: str = "",
1138        vpn_config: str = "",
1139        estate_zones: Sequence[str] = (),
1140        target_endpoints: Sequence[str] = (),
1141        target_hosts: Mapping[str, str] | None = None,
1142        target_datagram_endpoints: Sequence[str] = (),
1143        pull_ticket: str = "",
1144        registry_auth: HarnessRegistryAuth | None = None,
1145        limits: HarnessLimits | None = None,
1146        admission_profile: bool = False,
1147        egress_allow: list[str] | None = None,
1148        internet: bool = True,
1149        rootfs_budget_bytes: int = 0,
1150        unpack_budget_bytes: int = 0,
1151        disk_low_watermark_bytes: int = 0,
1152        ingress: bool = True,
1153        local_ingress: bool = False,
1154        platform_image: bool = False,
1155        image_policy: HarnessImagePolicy | None,
1156        attachments: dict[str, str] | None = None,
1157        keep_work: bool = False,
1158        resume_work: bool = False,
1159        exploit: dict[str, Any] | None = None,
1160    ) -> HarnessRun:
1161        """Run a one-shot evaluation-harness container on the node.
1162
1163        ``exploit`` (#2819 W2) runs a smoke's reference exploit in the fence
1164        instead of an agent (``HarnessExploit``'s fields); sent only when set.
1165
1166        ``image_policy`` (#2608) is **required, with no default**: every
1167        caller says whether this image is a user's (the registry rules the
1168        node re-checks before pulling) or the platform's own (``None``). An
1169        untrusted run without one is refused by the node.
1170
1171        The node ``docker run``s ``image`` with ``env`` injected, waits for it
1172        to exit (bounded by ``timeout_s``), and returns
1173        ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as
1174        :meth:`run_checker`. The harness writes its JSON rollup to stdout. The
1175        per-call HTTP timeout sits above the node's run budget so the client
1176        doesn't abandon the request before the (long-running) harness returns.
1177
1178        ``untrusted`` runs the image under the node's isolation profile —
1179        off host networking, bounded resources, no capabilities — for an
1180        image the platform did not build. It requires a network: the
1181        node answers 400 without one, because "isolate this" and "I will
1182        not say where the target lives" do not compose.
1183
1184        Any of ``network`` (the target instance's own, when the caller
1185        knows it), ``own_network`` or ``target_instance`` satisfies that.
1186        ⚠️ Most eval dispatches need the second: there the harness
1187        *starts* the target partway through its own run, so at ``docker
1188        run`` time no target network exists and there is no name to
1189        send. The node creates a fresh bridge and removes it afterwards.
1190
1191        ``target_instance`` reverses that ordering, and is what an
1192        `engagement` uses: the platform starts the estate first and names
1193        it here, and the node wires its published containers to the
1194        agent's bridge. ⚠️ An **id**, never a network name — the name is
1195        a fact only the node's daemon can read, and ``docker run``
1196        *creates* whatever string it is handed, so a platform guessing
1197        one starts a run that reaches nothing.
1198        """
1199        resp = self.request(
1200            "POST",
1201            "/harness/run",
1202            json={
1203                "image": image,
1204                "env": env,
1205                "timeout_s": timeout_s,
1206                "untrusted": untrusted,
1207                "network": network,
1208                "own_network": own_network,
1209                "match_network": match_network,
1210                # ⚠️ **The platform-assigned arena address.** The node
1211                # connects the agent to the match network with this
1212                # ``--ip``, so the address is known before the round loop
1213                # asks (§7.1). Empty means "let docker's IPAM pick" — an
1214                # older platform's behaviour, and what keeps a node reading
1215                # this field unchanged for every non-arena dispatch.
1216                "match_address": match_address,
1217                # ⚠️ Sent even when empty, so a node reading it gets the
1218                # same shape either way; the node's default is what makes
1219                # an older platform's dispatch unchanged.
1220                "pull_ticket": pull_ticket,
1221                # #3475: the owner's registry login, sent **only when set**,
1222                # so a node that predates the field sees the body it always did.
1223                **(
1224                    {"registry_auth": registry_auth.model_dump()}
1225                    if registry_auth is not None
1226                    else {}
1227                ),
1228                # ⚠️ ``None`` leaves the node's own defaults in force, which is
1229                # what a dispatch said before the ceilings were settings.
1230                "limits": limits.model_dump() if limits is not None else None,
1231                # ⚠️ Same rule, and empty is a real answer here too: a
1232                # dispatch with no run row (the conformance probe, a dry
1233                # run) has nothing to label, and a label with an empty
1234                # value would make every one of them answer one query.
1235                "run_id": run_id,
1236                # ⚠️ Sent as the already-built manifest. The platform is
1237                # the side that knows the challenge, the competition and
1238                # the run mode; a node deriving its own would be a
1239                # second implementation of a published contract.
1240                "brief": brief,
1241                "target_instance": target_instance,
1242                # ⚠️ **The names the brief published, so they resolve on
1243                # the agent's bridge too.** An estate's hosts answer to a
1244                # per-instance FQDN minted by its own resolver; joined to
1245                # a new network a container answers only to its compose
1246                # *service* name. Sending the mapping is what keeps the
1247                # address the agent was told about true under either
1248                # placement — without it the agent is handed a hostname
1249                # that does not resolve, reaches nothing, and reports an
1250                # empty result, which reads as a model that gave up.
1251                #
1252                # ⚠️ The platform's to send: it computed the surface. A
1253                # node re-deriving it from the rendered compose would be
1254                # a second implementation of the address the brief has
1255                # already promised.
1256                "target_aliases": target_aliases or {},
1257                # #2550: ``{name: base64}`` for ``/ctfy/work/attachments/``.
1258                # ⚠️ Sent only when there are some, so every other dispatch
1259                # is byte-identical to before.
1260                **({"attachments": attachments} if attachments else {}),
1261                # #2550 restarts: sent only when asked for, like the files.
1262                **({"keep_work": True} if keep_work else {}),
1263                **({"resume_work": True} if resume_work else {}),
1264                # #2861 slice B: the target access the platform read off the
1265                # target's node. ⚠️ Sent only when there is one, so every
1266                # other dispatch is byte-identical to before; and only to a
1267                # node whose ``capabilities().harness_access`` said it reads
1268                # them, or to the target's own node (where an older node's
1269                # local read is still right).
1270                **(
1271                    {
1272                        "target_access": target_access,
1273                        "vpn_config": vpn_config,
1274                        "estate_zones": list(estate_zones),
1275                        "target_endpoints": list(target_endpoints),
1276                    }
1277                    if target_access
1278                    else {}
1279                ),
1280                # #3020: names → the target node's ingress address, for a
1281                # published target handed over the agent-ingress tunnel. Sent
1282                # only when there are some, and only to a node whose
1283                # ``capabilities().harness_ingress_access`` said it reads them.
1284                **({"target_hosts": dict(target_hosts)} if target_hosts else {}),
1285                # #3446: the published endpoints that are UDP, which the node
1286                # never TCP-probes. Sent only when there are some.
1287                **(
1288                    {"target_datagram_endpoints": list(target_datagram_endpoints)}
1289                    if target_datagram_endpoints
1290                    else {}
1291                ),
1292                # #2356: ⚠️ sent only when asked for, so a dispatch that does
1293                # not ask is byte-identical to an older platform's — and an
1294                # older node, which would ignore the fields, is never sent
1295                # them: the caller checks ``capabilities().harness_admission``.
1296                **(
1297                    {
1298                        "admission_profile": True,
1299                        "egress_allow": list(egress_allow or []),
1300                        "internet": internet,
1301                        "rootfs_budget_bytes": rootfs_budget_bytes,
1302                        "unpack_budget_bytes": unpack_budget_bytes,
1303                        "disk_low_watermark_bytes": disk_low_watermark_bytes,
1304                        "ingress": ingress,
1305                        # #2819 W1, F2: sent only when asked for.
1306                        **({"local_ingress": True} if local_ingress else {}),
1307                    }
1308                    if admission_profile
1309                    else {}
1310                ),
1311                "image_policy": image_policy.model_dump() if image_policy is not None else None,
1312                # #2608 N4: only for a row with no owner; see the node's request.
1313                **({"platform_image": True} if platform_image else {}),
1314                **({"exploit": exploit} if exploit is not None else {}),
1315            },
1316            timeout=(
1317                timeout_s
1318                # #2819 W2: the exploit's `solution/Dockerfile` builds first.
1319                + (SOLUTION_BUILD_TIMEOUT_S if exploit is not None else 0)
1320                + IMAGE_PULL_TIMEOUT_S
1321                + HARNESS_IMAGE_INSPECT_TIMEOUT_S
1322                + HARNESS_CREATE_TIMEOUT_S
1323                + 30
1324            ),
1325            retry_on=RETRY_UNSENT,
1326        )
1327        _raise_for_node_status(resp)
1328        return _parsed(HarnessRun, resp)
1329
1330    def cancel_harness(self, *, run_id: str) -> int:
1331        """Stop whatever this node is running for ``run_id``; returns how many.
1332
1333        Until this existed a dispatched container was reclaimed by
1334        **nothing but its own timeout** — ``_harness_timeout``'s docstring
1335        says exactly that — so an operator pressing cancel on a
1336        match-long run was asking for something the platform had no way
1337        to deliver.
1338
1339        ⚠️ **Zero is an ordinary answer, not a failure.** The run may have
1340        finished on its own, been dispatched to a different node, or been
1341        lost to a node restart. A caller that read zero as an error would
1342        report a broken platform every time a cancel arrived a second
1343        late.
1344        """
1345        resp = self.request("POST", "/harness/cancel", json={"run_id": run_id})
1346        body = resp.json()
1347        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0
1348
1349    def verify_patch(
1350        self, challenge_id: str, files: dict[str, bytes], *, deadline_s: int
1351    ) -> PatchVerifyResult:
1352        """Build a submitted patch on the node and return its verdict.
1353
1354        ``files`` maps a challenge-relative path to its new bytes; base64
1355        is applied here because a patch target may legitimately be
1356        binary. Returns ``{verdict, detail, applied, steps, error}``.
1357
1358        **An empty ``verdict`` with a populated ``error`` is not a
1359        judgement** — it means the node refused the submission, the
1360        verifier crashed, or the run's deadline ran out. The caller must
1361        retry or retire the lease rather than write a score, since failing
1362        to judge a patch is not the same as judging it unfixed.
1363
1364        ⚠️ *deadline_s* bounds the node's whole run (#3164,
1365        `ctfy.core.patch_budget`), and the HTTP wait is derived from it —
1366        `patch_client_wait_s`, past the node's own answer — so the client
1367        never abandons a request the node is still working on. Sent once:
1368        a ``ReadTimeout`` is a request the node has (#3162).
1369        """
1370        resp = self.request(
1371            "POST",
1372            "/patches/verify",
1373            json={
1374                "challenge_id": challenge_id,
1375                "files": {
1376                    path: base64.b64encode(content).decode() for path, content in files.items()
1377                },
1378                "deadline_s": deadline_s,
1379            },
1380            timeout=patch_client_wait_s(deadline_s),
1381            retry_on=RETRY_UNSENT,
1382        )
1383        _raise_for_node_status(resp)
1384        return _parsed(PatchVerifyResult, resp)
1385
1386    def collect_patch_files(
1387        self, instance_id: str, service: str, paths: list[str]
1388    ) -> dict[str, bytes]:
1389        """Read a player's SSH edits back out of their running box.
1390
1391        ``paths`` are absolute in-container paths the *platform* derived
1392        from the pristine source tree. Returns only what could be read:
1393        a path missing from the reply means the player did not change it,
1394        and the caller falls back to the shipped file rather than
1395        recording a deletion they never made.
1396        """
1397        resp = self.request(
1398            "POST",
1399            f"/instances/{instance_id}/patch-collect",
1400            json={"service": service, "paths": paths},
1401        )
1402        _raise_for_node_status(resp)
1403        body: dict[str, str] = resp.json()
1404        return {path: base64.b64decode(blob) for path, blob in body.items()}
1405
1406    def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
1407        """Rotate a round's flags into every box of this node, in one call.
1408
1409        ⚠️ **One call for the whole node, never one per box.** §5.2's
1410        capacity red line is 1000 teams x 3 services injected inside
1411        120 s; per-box that is 3000 round trips, batched it is about
1412        fourteen. The signature is the enforcement — a caller cannot
1413        accidentally loop.
1414
1415        Returns ``{instance_id: ""}`` for the boxes written and a reason
1416        for the ones that were not, because one team's unreachable box
1417        must not cost every other team on the node its rotation. An
1418        unrotated box keeps serving last round's flag, which anyone
1419        holding the old value can replay.
1420        """
1421        resp = self.request("POST", "/awd/answers", json={"boxes": boxes})
1422        _raise_for_node_status(resp)
1423        result: dict[str, str] = resp.json()
1424        return result
1425
1426    def probe_awd_service(
1427        self,
1428        *,
1429        instance_id: str,
1430        service_id: str,
1431        tick: int,
1432        flag: str,
1433        previous_flag: str = "",
1434        previous_flag_id: str = "",
1435    ) -> dict[str, str]:
1436        """Run one round's SLA probes against one box, from outside it.
1437
1438        The flags travel *to* the node: a checker that sourced its
1439        expected value from the box it is checking would pass on any box
1440        that returns whatever it was handed.
1441        """
1442        resp = self.request(
1443            "POST",
1444            "/awd/probe",
1445            json={
1446                "instance_id": instance_id,
1447                "service_id": service_id,
1448                "tick": tick,
1449                "flag": flag,
1450                "previous_flag": previous_flag,
1451                "previous_flag_id": previous_flag_id,
1452            },
1453        )
1454        _raise_for_node_status(resp)
1455        result: dict[str, str] = resp.json()
1456        return result
1457
1458    def attack_awd_box(
1459        self,
1460        *,
1461        instance_id: str,
1462        tick: int,
1463        timeout_s: int = 60,
1464        agent_image: str = "",
1465        counter_flag: str = "",
1466        forensic_marker: str = "",
1467        llm_api_key: str = "",
1468    ) -> AwdAttackResult:
1469        """Run this box's own reference exploit against it, once.
1470
1471        Per **box**, not per service: one `run_exploit` discovers every
1472        answer the challenge declares and prints them all.
1473
1474        ``timeout_s`` is sent because the platform is what knows how long
1475        a round is; the node clamps it, because a container held open for
1476        an hour is the node's problem whoever asked for it.
1477
1478        ``forensic_marker`` is §4.3.1's, carried only by the agent
1479        branch: `build_attack_argv` passes no environment at all, so a
1480        marker on the reference exploit would be the one value that
1481        voids `EXPLOIT007`'s guarantee.
1482
1483        ``agent_image`` + ``counter_flag`` switch the node to §4.2b's
1484        agent attacker. ⚠️ **Both or neither** — the node refuses a half
1485        request rather than falling back to the reference exploit, which
1486        would run a different format than the one configured while the
1487        board looked entirely normal.
1488        """
1489        resp = self.request(
1490            "POST",
1491            "/awd/attack",
1492            json={
1493                "instance_id": instance_id,
1494                "tick": tick,
1495                "timeout_s": timeout_s,
1496                "agent_image": agent_image,
1497                "counter_flag": counter_flag,
1498                "forensic_marker": forensic_marker,
1499                "llm_api_key": llm_api_key,
1500            },
1501        )
1502        _raise_for_node_status(resp)
1503        return _parsed(AwdAttackResult, resp)
1504
1505    def get_container_logs(self, instance_id: str) -> str:
1506        """Fetch combined container stdout/stderr for archival."""
1507        resp = self.request("GET", f"/instances/{instance_id}/container-logs")
1508        resp.raise_for_status()
1509        return str(resp.json().get("logs") or "")
1510
1511    def get_pcap(self, instance_id: str) -> PcapCapture:
1512        """Fetch the merged tcpdump capture for *instance_id*.
1513
1514        ``data`` is empty when no capture exists (sidecar disabled,
1515        instance never reached the running state, etc.) so callers can
1516        persist conditionally without try/except.
1517
1518        ⚠️ **It also says what the per-instance cap already dropped**
1519        (#1714), off the node's response headers, and ``None`` when the
1520        node did not say — an older node, which is *not reported*, never
1521        a clean capture.
1522        """
1523        resp = self.request("GET", f"/instances/{instance_id}/pcap")
1524        if resp.status_code == 404:
1525            return PcapCapture(data=b"")
1526        resp.raise_for_status()
1527        return PcapCapture(
1528            data=resp.content,
1529            dropped_bytes=_header_int(resp, CAPTURE_DROPPED_BYTES_HEADER),
1530            dropped_segments=_header_int(resp, CAPTURE_DROPPED_SEGMENTS_HEADER),
1531        )
1532
1533    def get_harness_artifact(self, artifact_id: str) -> bytes:
1534        """One finished run's collected archive, or ``b""``.
1535
1536        ⚠️ **Empty rather than an exception for a 404**, following
1537        :meth:`get_pcap`. The node reaps uncollected archives, and a
1538        platform that comes back after a restart is exactly the caller
1539        that meets one that is gone — a state its own record already
1540        describes, not a transport failure.
1541        """
1542        resp = self.request("GET", f"/harness/artifacts/{artifact_id}")
1543        if resp.status_code == 404:
1544            return b""
1545        resp.raise_for_status()
1546        return resp.content
1547
1548    def delete_harness_artifact(self, artifact_id: str) -> bool:
1549        """Tell the node the archive has been stored and can go.
1550
1551        ⚠️ **Never raises on a transport failure**, because it is called
1552        after the bytes are safely in object storage: turning a cleanup
1553        error into the run's error would replace a success with a
1554        janitorial complaint. The node's reaper collects whatever this
1555        misses.
1556        """
1557        try:
1558            resp = self.request("DELETE", f"/harness/artifacts/{artifact_id}")
1559        except Exception:
1560            return False
1561        return resp.status_code == 200
1562
1563    def get_agent_runtime(self, instance_id: str) -> AgentRuntime:
1564        """Provider-agnostic runtime hints for attaching an agent (proxy URL,
1565        CA PEM, optional Docker-specific names). Replaces the old
1566        sandbox-network shape."""
1567        resp = self.request("GET", f"/instances/{instance_id}/agent-runtime")
1568        resp.raise_for_status()
1569        return _parsed(AgentRuntime, resp)
1570
1571    # -- per-instance rendered attachments ---------------------------------
1572
1573    def list_instance_attachments(self, instance_id: str) -> AttachmentList:
1574        """List per-instance attachments rendered into the node's workdir.
1575
1576        Backs the platform's per-instance attachment endpoint, which
1577        surfaces the team-specific rendered file listing.
1578
1579        ⚠️ This used to hand back the raw dict, under a docstring saying
1580        "caller projects through :class:`AttachmentList` for typing" —
1581        the node's route already declared that model, so the type existed
1582        and this method's signature was the one place it was dropped.
1583        Projecting *here* means there is one place a malformed answer is
1584        caught rather than one per caller."""
1585        resp = self.request("GET", f"/instances/{instance_id}/attachments")
1586        resp.raise_for_status()
1587        return _parsed(AttachmentList, resp)
1588
1589    def get_vpn_config(self, instance_id: str) -> bytes:
1590        """Fetch the per-instance VPN client config from the node.
1591
1592        The node reads the backend's client config out of the
1593        instance's VPN service container (generated on first boot by
1594        the base image's bootstrap) and rewrites its endpoint to the
1595        node's public host:port. Returns the body verbatim — the
1596        platform-side route adds the ``Content-Disposition`` header
1597        before re-emitting to the player, because only it knows the
1598        backend and therefore the filename the player needs.
1599
1600        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1601        proxy route catches 404 and re-emits to the player, anything
1602        else is treated as a 502.
1603        """
1604        resp = self.request("GET", f"/instances/{instance_id}/vpn-config")
1605        resp.raise_for_status()
1606        return resp.content
1607
1608    def get_vpn_access(self, instance_id: str) -> tuple[bytes, tuple[str, ...] | None]:
1609        """The player's VPN config plus the estate's resolver zones (#2861 B).
1610
1611        The config is :meth:`get_vpn_config`'s, byte for byte; the zones ride
1612        a response header the node adds only when asked. ``None`` for the
1613        zones is a node that did not say (older, or docker unreadable there)
1614        — never "no zones". Raises like :meth:`get_vpn_config`.
1615        """
1616        resp = self.request(
1617            "GET", f"/instances/{instance_id}/vpn-config", params={"estate_zones": "true"}
1618        )
1619        resp.raise_for_status()
1620        raw = resp.headers.get("X-Ctfy-Estate-Zones")
1621        return resp.content, (tuple(raw.split()) if raw is not None else None)
1622
1623    def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
1624        """Download one per-instance attachment as ``(bytes, content_type)``.
1625
1626        Buffers the whole body — attachments are bounded (typical case
1627        is a 10 KB binary or a small text file). For huge captures the
1628        caller should hand the player a CDN URL instead.
1629
1630        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1631        proxy route catches 404 and re-emits to the player, anything
1632        else is a 502. Invalid names or instance keys raise
1633        ``InvalidAttachmentName`` before any request is sent."""
1634        # The instance is a single route key, never part of the file name.
1635        if component_refusal(instance_id) is not None:
1636            raise InvalidAttachmentName("Invalid attachment instance")
1637        resp = self.request(
1638            "GET",
1639            f"/instances/{quote(instance_id, safe='')}/attachments/{attachment_url_path(filename)}",
1640        )
1641        resp.raise_for_status()
1642        return resp.content, resp.headers.get("content-type", "application/octet-stream")
@dataclasses.dataclass(frozen=True)
class PcapCapture:
105@dataclasses.dataclass(frozen=True)
106class PcapCapture:
107    """A node's merged capture, and what the cap dropped from it (#1714)."""
108
109    data: bytes
110    #: ``None`` = the node did not say (older build); ``0`` = nothing.
111    dropped_bytes: int | None = None
112    dropped_segments: int | None = None

A node's merged capture, and what the cap dropped from it (#1714).

PcapCapture( data: bytes, dropped_bytes: int | None = None, dropped_segments: int | None = None)
data: bytes
dropped_bytes: int | None = None
dropped_segments: int | None = None
class HealthReading(pydantic.main.BaseModel):
210class HealthReading(BaseModel):
211    """One health poll's three answers.
212
213    ⚠️ **A record rather than a tuple, and the reason is recorded one
214    subsystem over.** ``competition_results`` lost ``attempts`` and
215    ``last_solve_at`` to a hand-made ``(rank, flags, solved)`` tuple
216    sitting between a board that produced them and a template that
217    consumed them; this poll had two answers and now has three, so the
218    tuple was one field away from the same fate. Growing a record is a
219    field; growing a tuple is every call site, silently re-bound.
220    """
221
222    healthy: bool = False
223    deception: DeceptionSummary = Field(default_factory=DeceptionSummary)
224    contact: ContactReading = Field(default_factory=ContactReading)
225    new_processes: dict[str, list[str]] = Field(default_factory=dict)

One health poll's three answers.

⚠️ A record rather than a tuple, and the reason is recorded one subsystem over. competition_results lost attempts and last_solve_at to a hand-made (rank, flags, solved) tuple sitting between a board that produced them and a template that consumed them; this poll had two answers and now has three, so the tuple was one field away from the same fate. Growing a record is a field; growing a tuple is every call site, silently re-bound.

healthy: bool = False
deception: ctfy.core.observation.DeceptionSummary = PydanticUndefined
contact: ctfy.core.observed_contact.ContactReading = PydanticUndefined
new_processes: dict[str, list[str]] = PydanticUndefined
def masked_capabilities( caps: ctfy.server.models.NodeCapabilities, disabled: Iterable[str]) -> ctfy.server.models.NodeCapabilities:
228def masked_capabilities(caps: NodeCapabilities, disabled: Iterable[str]) -> NodeCapabilities:
229    """*caps* with every feature in *disabled* answered ``False`` (#3037).
230
231    The effective value is *supported by the node* AND *enabled by the
232    admin*. ⚠️ Only names that are features are masked
233    (`ctfy.core.node_features.CAPABILITY_FEATURES`), so an unknown or stale name
234    in the row turns nothing off — the switch fails open. A placement switch
235    (``public_ports``) is not a capability and is asked by placement instead.
236    """
237    off = [f for f in CAPABILITY_FEATURES if not feature_enabled(disabled, f)]
238    if not off:
239        return caps
240    update: dict[str, object] = dict.fromkeys(off, False)
241    for flag in off:
242        reason = f"{flag}_reason"
243        if reason in NodeCapabilities.model_fields:
244            update[reason] = "switched off by an admin on this node"
245    return caps.model_copy(update=update)

caps with every feature in disabled answered False (#3037).

The effective value is supported by the node AND enabled by the admin. ⚠️ Only names that are features are masked (ctfy.core.node_features.CAPABILITY_FEATURES), so an unknown or stale name in the row turns nothing off — the switch fails open. A placement switch (public_ports) is not a capability and is asked by placement instead.

class NodeClient(ctfy.sdk.base.BaseHttpClient):
 248class NodeClient(BaseHttpClient):
 249    """Thin sync HTTP client; one instance per node URL."""
 250
 251    def __init__(
 252        self,
 253        node_url: str,
 254        token: str,
 255        *,
 256        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 257        max_retries: int = 2,
 258        transport: httpx.BaseTransport | None = None,
 259        disabled_features: Iterable[str] = (),
 260    ) -> None:
 261        """*disabled_features* are the features an admin switched off on this
 262        node (#3037, `ctfy.core.node_features`): :meth:`capabilities` answers
 263        ``False`` for each, whatever the node says, so a caller reads the
 264        effective value and cannot forget the switch. ``()`` is the raw answer
 265        — every platform construction passes the node row's set
 266        (`test_every_node_client_carries_the_switches`).
 267
 268        *transport* is `BaseHttpClient`'s own seam, passed through so a
 269        test can drive a real node app over the real client — the hop the
 270        fakes on either side of it cannot see.
 271
 272        *max_retries* is passed through too: a best-effort read or a stop
 273        is tried once (#2812), because a retry on a timeout multiplies the
 274        one number that has to stay small.
 275
 276        ⚠️ **A call that starts long work retries only a request that never
 277        left** (#3162, :data:`~ctfy.sdk.base.RETRY_UNSENT`): every POST here
 278        that names its own HTTP timeout says so per call, because a
 279        ``ReadTimeout`` is a request the node has and is still running.
 280        ``start_instance`` keeps the default — the node replays it from the
 281        instance id. Guarded by `ctfy/tests/test_long_node_work_is_sent_once.py`."""
 282        super().__init__(
 283            f"{node_url.rstrip('/')}/api/v1",
 284            token,
 285            timeout=timeout,
 286            max_retries=max_retries,
 287            transport=transport,
 288        )
 289        self.disabled_features = frozenset(disabled_features)
 290
 291    # -- lifecycle ----------------------------------------------------------
 292
 293    def start_instance(
 294        self,
 295        *,
 296        challenge_id: str,
 297        instance_id: str,
 298        ttl: int,
 299        answers: dict[str, str],
 300        proxy_output_dir: str | None = None,
 301        env: dict[str, str] | None = None,
 302        publish_gamebox: bool = False,
 303        match_network: MatchNetwork | None = None,
 304        open_shell_password: str = "",
 305        authored_revision: str = "",
 306        internet: InternetAccess = InternetAccess.ALLOW,
 307        tcp_access: TcpAccess = TcpAccess.TLS_SNI,
 308        egress_allow_hosts: list[str] | None = None,
 309        resource_budget: ResourceBudget | None = None,
 310        compose_up_timeout_s: int = 0,
 311    ) -> InstanceStarted:
 312        resp = self.request(
 313            "POST",
 314            "/instances",
 315            json={
 316                # #1835: the authored tree's revision this start must run;
 317                # a node holding another (or none) fetches it first.
 318                "authored_revision": authored_revision,
 319                "challenge_id": challenge_id,
 320                "instance_id": instance_id,
 321                "ttl": ttl,
 322                "answers": answers,
 323                "proxy_output_dir": proxy_output_dir,
 324                "env": env or {},
 325                "publish_gamebox": publish_gamebox,
 326                # ``None`` rather than an omitted key: the node's model
 327                # defaults to None either way, and sending it explicitly
 328                # keeps the body's shape stable across both branches.
 329                "match_network": (dataclasses.asdict(match_network) if match_network else None),
 330                # Classic-AWD open shell (#1725): the box's starting root
 331                # password, empty for every managed / non-AWD start.
 332                "open_shell_password": open_shell_password,
 333                # #1969: ``deny`` holds the box off the internet; the hosts
 334                # are the platform-internal ones it still reaches.
 335                "internet": str(internet),
 336                # #2082: how a player reaches the box's TCP services.
 337                "tcp_access": str(tcp_access),
 338                "egress_allow_hosts": list(egress_allow_hosts or []),
 339                # The CPU / memory a player-authored box is held to; ``None``
 340                # for the corpus and from an older platform.
 341                "resource_budget": (
 342                    resource_budget.model_dump() if resource_budget is not None else None
 343                ),
 344                # #3165: the platform's ``compose_up_timeout_s``, so the node
 345                # spends the budget the platform waits on; ``0`` = the node's own.
 346                "compose_up_timeout_s": compose_up_timeout_s,
 347            },
 348        )
 349        _raise_for_node_status(resp)
 350        return _parsed(InstanceStarted, resp)
 351
 352    def stop_instance(self, instance_id: str) -> None:
 353        resp = self.request("DELETE", f"/instances/{instance_id}")
 354        resp.raise_for_status()
 355
 356    def apply_fabric(
 357        self,
 358        *,
 359        index: int,
 360        interface: dict[str, Any],
 361        match_network: str = "",
 362    ) -> FabricStatus:
 363        """Put this node on one match's WireGuard fabric.
 364
 365        ⚠️ **Returns the model, never ``resp.json()``.** The platform
 366        reads the verdict field by field, and on a dict a renamed field
 367        is ``None`` — falsy — which reads as "the arena did not open",
 368        so a wire change would quietly report every node as broken.
 369
 370        Returns the node's verdict rather than raising on a refusal: the
 371        route answers **200 with a body** precisely so that "this node is
 372        unreachable" and "this node rejected the plan" stay different
 373        facts, and a caller fanning out over an arena needs to tell them
 374        apart — one is waited on, the other is looked at.
 375
 376        *match_network* lets the node check the tunnel landed in the
 377        namespace its boxes are in, and open the bridge's one hole. It is
 378        the platform's own fact (`awd_network.network_name`), so the node
 379        is told rather than guessing.
 380        """
 381        resp = self.request(
 382            "POST",
 383            "/fabric",
 384            json={
 385                "index": index,
 386                "interface": interface,
 387                "match_network": match_network,
 388            },
 389        )
 390        resp.raise_for_status()
 391        return _parsed(FabricStatus, resp)
 392
 393    def probe_fabric(
 394        self,
 395        *,
 396        index: int,
 397        peers: list[dict[str, str]],
 398        mtu: int = 0,
 399    ) -> FabricProbeReport:
 400        """Ask named peers the three link questions. Applies nothing.
 401
 402        ⚠️ **Returns the model, never ``resp.json()``** — same reason
 403        `apply_fabric` gives: on a dict a renamed field reads as falsy,
 404        which here means "your tunnel is down" about a wire change.
 405        """
 406        resp = self.request(
 407            "POST",
 408            "/fabric/probe",
 409            json={"index": index, "peers": peers, "mtu": mtu},
 410        )
 411        resp.raise_for_status()
 412        return _parsed(FabricProbeReport, resp)
 413
 414    def remove_fabric(self, *, index: int, match_network: str = "") -> FabricStatus:
 415        """Take this node off one match's fabric and close its hole.
 416
 417        ⚠️ *match_network* is what lets the node resolve the bridge and
 418        delete the ``DOCKER-USER`` rules by name. Without it the tunnel
 419        goes and the hole stays — open on whatever docker gives that
 420        bridge name next.
 421        """
 422        resp = self.request(
 423            "DELETE",
 424            f"/fabric/{index}",
 425            params={"match_network": match_network},
 426        )
 427        resp.raise_for_status()
 428        return _parsed(FabricStatus, resp)
 429
 430    def stop_instance_service(self, instance_id: str, service: str) -> int:
 431        """Stop one compose service, leaving the instance running.
 432
 433        The exercise format's session close (§4b.4). Returns how many
 434        containers the node stopped — 0 means the challenge declares no
 435        such service, which is a real answer rather than a failure, so
 436        the caller can distinguish it from a cut tunnel.
 437        """
 438        resp = self.request(
 439            "POST",
 440            f"/instances/{instance_id}/stop-service",
 441            json={"service": service},
 442        )
 443        resp.raise_for_status()
 444        body = resp.json()
 445        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0
 446
 447    def capabilities(
 448        self, *, harness_admission: bool = False, agent_ingress: bool = False
 449    ) -> NodeCapabilities:
 450        """What this node can do beyond the original verbs.
 451
 452        ⚠️ **A 404 is an answer, not an error**: a node from before hold /
 453        resume has no such route, and "cannot hold" is exactly what the
 454        platform needs to hear -- the caller then falls back to deleting.
 455        Any other failure raises, because an unreachable node is not a node
 456        that cannot hold.
 457        """
 458        # ⚠️ #2356: ``harness_admission`` makes the node prove it can hold
 459        # an admitted run, which writes its host firewall — asked only by
 460        # the admission paths, never by hold / internet.
 461        # #3020: ``agent_ingress`` makes the node converge its listener,
 462        # asked only before handing a published target over WireGuard.
 463        params = {
 464            **({"harness_admission": "true"} if harness_admission else {}),
 465            **({"agent_ingress": "true"} if agent_ingress else {}),
 466        }
 467        resp = self.request("GET", "/capabilities", params=params or None)
 468        if resp.status_code == 404:
 469            return NodeCapabilities()
 470        resp.raise_for_status()
 471        return masked_capabilities(_parsed(NodeCapabilities, resp), self.disabled_features)
 472
 473    def create_agent_ingress_peer(
 474        self, *, run_id: str, public_key: str, ports: list[int], ttl_s: int
 475    ) -> AgentIngressPeer:
 476        """Add *run_id*'s peer to the node's agent-ingress listener (#3020).
 477
 478        Only the public key crosses; the caller keeps the private half.
 479        Raises on any non-2xx (the node's refusal is in the detail).
 480        """
 481        body = AgentIngressPeerRequest(
 482            run_id=run_id, public_key=public_key, ports=ports, ttl_s=ttl_s
 483        )
 484        resp = self.request("POST", "/agent-ingress/peers", json=body.model_dump(mode="json"))
 485        resp.raise_for_status()
 486        return _parsed(AgentIngressPeer, resp)
 487
 488    def delete_agent_ingress_peer(self, run_id: str) -> bool:
 489        """Revoke *run_id*'s peer; ``False`` when the node held none."""
 490        resp = self.request("DELETE", f"/agent-ingress/peers/{run_id}")
 491        resp.raise_for_status()
 492        return _parsed(AgentIngressPeerRemoved, resp).removed
 493
 494    def hold_instance(self, instance_id: str, mode: str) -> int:
 495        """Pause or stop an instance, removing nothing. Returns containers held."""
 496        resp = self.request("POST", f"/instances/{instance_id}/hold", json={"mode": mode})
 497        resp.raise_for_status()
 498        return _parsed(InstanceHeld, resp).count
 499
 500    def resume_instance(self, instance_id: str) -> int:
 501        """Bring a held instance back. Returns containers brought back."""
 502        resp = self.request("POST", f"/instances/{instance_id}/resume")
 503        resp.raise_for_status()
 504        return _parsed(InstanceHeld, resp).count
 505
 506    def change_instance_internet(
 507        self, instance_id: str, change: InstanceInternetChange
 508    ) -> InstanceInternetChanged:
 509        """Change a running box's internet verdict (#2067).
 510
 511        Raises :class:`NodeRequestError` on any refusal, carrying the node's
 512        ``code`` (:data:`~ctfy.core.exceptions.NODE_INTERNET_CHANGE_FAILED`
 513        when its firewall could not converge — the node has then put its
 514        record back, so nothing changed).
 515        """
 516        resp = self.request(
 517            "POST", f"/instances/{instance_id}/internet", json=change.model_dump(mode="json")
 518        )
 519        _raise_for_node_status(resp)
 520        return _parsed(InstanceInternetChanged, resp)
 521
 522    def stop_all(self) -> None:
 523        resp = self.request("POST", "/admin/stop-all")
 524        resp.raise_for_status()
 525
 526    def put_authored_challenge(self, push: NodeAuthoredPush) -> NodeAuthoredResult:
 527        """Ask the node to write an approved player upload into its catalog.
 528
 529        ⚠️ **The bytes travel here, unlike `put_generated_range`.** That
 530        one sends a scenario because the node can compile it; a
 531        player-authored tree has no compiler and no corpus entry, so the
 532        archive is what there is to send.
 533
 534        The node answers **200 with ``ok: false``** for a refusal, so a
 535        node that declined the archive and a node that could not be
 536        reached stay different facts; only the second raises here.
 537        """
 538        resp = self.request("POST", "/admin/authored", json=push.model_dump(mode="json"))
 539        resp.raise_for_status()
 540        return _parsed(NodeAuthoredResult, resp)
 541
 542    def smoke_authored_challenge(
 543        self, push: NodeAuthoredSmokePush, *, timeout: int | None = None
 544    ) -> NodeAuthoredSmokeResult:
 545        """Ask the node to run an authored challenge's own reference exploit.
 546
 547        ⚠️ **Takes its own timeout.** A smoke run brings a whole estate
 548        up; the client default is sized for a status call and would give
 549        up while the node is still doing what it was asked.
 550        """
 551        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
 552        if timeout is not None:
 553            kwargs["timeout"] = timeout
 554        resp = self.request("POST", "/admin/authored/smoke", retry_on=RETRY_UNSENT, **kwargs)
 555        resp.raise_for_status()
 556        return _parsed(NodeAuthoredSmokeResult, resp)
 557
 558    def smoke_progress(self, run_id: str, since: int = 0) -> NodeSmokeProgress | None:
 559        """The steps and lines a running smoke has produced since *since* (#2145).
 560
 561        ``None`` when the node holds no feed for *run_id* — a node older than
 562        the feed, or a run that never started — which a watcher reads as
 563        *unwatched*. Any other failure raises: the caller decides that a
 564        watcher's trouble must never fail the run it watches.
 565        """
 566        # ⚠️ A short timeout of its own: this is read once a second beside a
 567        # run that may be minutes long, and a read that hangs on a busy worker
 568        # must not hold the watcher past the run it watches.
 569        resp = self.request(
 570            "GET",
 571            f"/admin/authored/smoke/{run_id}/progress",
 572            params={"since": since},
 573            timeout=10,
 574        )
 575        if resp.status_code == 404:
 576            return None
 577        resp.raise_for_status()
 578        return _parsed(NodeSmokeProgress, resp)
 579
 580    def smoke_entrance(self, run_id: str) -> NodeSmokeEntrance:
 581        """The player's entrance to the smoke box held for *run_id* (#2819 W2).
 582
 583        ``pending`` / ``ready`` / ``gone``; a 404 (a node older than the
 584        route) raises, which the caller reads as *this node cannot hold*.
 585        """
 586        resp = self.request("GET", f"/admin/authored/smoke/{run_id}/entrance", timeout=15)
 587        resp.raise_for_status()
 588        return _parsed(NodeSmokeEntrance, resp)
 589
 590    def post_smoke_exploit(self, run_id: str, outcome: NodeSmokeExploitOutcome) -> bool:
 591        """Hand the held smoke box its exploit outcome (#2819 W2).
 592
 593        ``True`` when the node took it; ``False`` when it holds no box for
 594        *run_id* any more (it tore down on its own timeout) or had one.
 595        """
 596        resp = self.request(
 597            "POST",
 598            f"/admin/authored/smoke/{run_id}/exploit",
 599            json=outcome.model_dump(mode="json"),
 600            timeout=30,
 601            # First outcome wins on the node, so a resend is harmless; but a
 602            # read timeout is a post that may have landed (#3162).
 603            retry_on=RETRY_UNSENT,
 604        )
 605        if resp.status_code == 404:
 606            return False
 607        resp.raise_for_status()
 608        return bool(resp.json().get("taken"))
 609
 610    def harness_log(self, run_id: str, after: int = 0) -> NodeAgentLog | None:
 611        """An agent run's console lines numbered above *after* (#2401).
 612
 613        ``None`` when the node holds no console for *run_id* — a node older
 614        than the feed, or a run that never started there — which a watcher
 615        reads as *unwatched*. Any other failure raises: the caller decides
 616        that a watcher's trouble must never fail the run it watches.
 617        """
 618        # ⚠️ A short timeout of its own, as ``smoke_progress``: read once a
 619        # second beside a run that may be hours long.
 620        resp = self.request(
 621            "GET", f"/harness/runs/{run_id}/log", params={"after": after}, timeout=10
 622        )
 623        if resp.status_code == 404:
 624            return None
 625        resp.raise_for_status()
 626        return _parsed(NodeAgentLog, resp)
 627
 628    def harness_result(self, run_id: str) -> HarnessRunResult | None:
 629        """What the node knows of a run's harness call (#3021).
 630
 631        ``None`` when the node has no such route — a node older than the
 632        result store, which can never return a run's outcome after its call
 633        is gone. ``state="unknown"`` is the node answering that it holds
 634        nothing for *run_id*. Any other failure raises: an unreachable node
 635        is neither of those, and the caller bounds how long it waits on one.
 636        """
 637        resp = self.request("GET", f"/harness/runs/{run_id}/result", timeout=30)
 638        if resp.status_code == 404:
 639            return None
 640        resp.raise_for_status()
 641        return _parsed(HarnessRunResult, resp)
 642
 643    def put_generated_range(self, push: NodeRangePush) -> NodeRangeResult:
 644        """Ask the node to build a generated range from its scenario.
 645
 646        ⚠️ **The scenario travels, not the estate.** The corpus is
 647        mounted on the node and the compiler ships inside it, so 70-odd
 648        rendered files never cross this API — and the corpus commit that
 649        rides along is what stops one scenario becoming two different
 650        estates under one id across a fleet.
 651
 652        The node answers **200 with ``ok: false``** for a refusal, so a
 653        node that declined and a node that could not be reached stay
 654        different facts; only the second raises here.
 655        """
 656        resp = self.request("POST", "/admin/ranges", json=push.model_dump(mode="json"))
 657        resp.raise_for_status()
 658        return _parsed(NodeRangeResult, resp)
 659
 660    def verify_generated_range(
 661        self, push: NodeRangeVerifyPush, *, timeout: int | None = None
 662    ) -> NodeRangeVerifyResult:
 663        """Ask the node to build a scenario, solve it, and take it down.
 664
 665        ⚠️ **The longest call in this API**, so the caller passes its own
 666        timeout: the node's budget is ``compose_timeout_s +
 667        solve_timeout_s`` and a client that used the default would give
 668        up while the node was still building — then retry, and start a
 669        second estate beside the first.
 670
 671        ⚠️ A client timeout does **not** leave a range running: the node
 672        tears down in its own ``finally``. What is lost is the answer,
 673        not the host.
 674
 675        The node answers **200 with an ``error``** for a refusal, the
 676        same split ``put_generated_range`` uses; only an unreachable node
 677        raises.
 678        """
 679        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
 680        if timeout is not None:
 681            # httpx takes a per-request override; the client-wide value
 682            # stays whatever the caller constructed this with.
 683            kwargs["timeout"] = timeout
 684        resp = self.request("POST", "/admin/ranges/verify", retry_on=RETRY_UNSENT, **kwargs)
 685        resp.raise_for_status()
 686        return _parsed(NodeRangeVerifyResult, resp)
 687
 688    def cancel_generated_range_verify(self, run_id: str) -> bool:
 689        """Tell the node to stop a verification it is running.
 690
 691        ⚠️ **A miss is success, not a 404 to report.** The cancel races
 692        the run's own finish, so "no such run" means the run already
 693        ended — which is what the operator asked for. The node answers
 694        ``{"cancelled": false}`` for that case rather than raising, and
 695        this returns it so a caller that wants to say "it had already
 696        finished" can.
 697        """
 698        resp = self.request("POST", f"/admin/ranges/verify/{run_id}/cancel")
 699        resp.raise_for_status()
 700        body = resp.json()
 701        return bool(body.get("cancelled"))
 702
 703    def rescan_challenges(self) -> ChallengeReloadResult:
 704        """Tell the node to drop its spec cache and re-scan challenges_dir.
 705
 706        Returns ``{total, added, removed}`` so the platform can report
 707        the per-node outcome of a cluster-wide rescan."""
 708        resp = self.request("POST", "/admin/rescan-challenges")
 709        resp.raise_for_status()
 710        return _parsed(ChallengeReloadResult, resp)
 711
 712    # -- admin pre-build (image cache warming) ------------------------------
 713
 714    def build_challenge(
 715        self,
 716        challenge_id: str,
 717        *,
 718        authored_revision: str = "",
 719        resource_budget: ResourceBudget | None = None,
 720    ) -> ChallengeBuildState:
 721        """Ask the node to pre-build images for *challenge_id*.
 722
 723        Fires-and-returns: the node persists ``status="building"`` and
 724        spawns a daemon thread; the body returned here is that initial
 725        state row. Polling :meth:`get_build_state` is how the platform
 726        learns when it lands on ``built`` / ``failed``. *authored_revision*
 727        is #1835's: the tree the build must be of, fetched if not held.
 728        """
 729        params = {"authored_revision": authored_revision} if authored_revision else None
 730        # #2504: an authored tree pre-builds under this budget, or not at all.
 731        kwargs: dict[str, Any] = {}
 732        if resource_budget is not None:
 733            kwargs["json"] = {"resource_budget": resource_budget.model_dump()}
 734        resp = self.request(
 735            "POST", f"/admin/challenges/{challenge_id}/build", params=params, **kwargs
 736        )
 737        _raise_for_node_status(resp)
 738        return _parsed(ChallengeBuildState, resp)
 739
 740    def build_all_challenges(
 741        self,
 742        challenge_ids: list[str] | None = None,
 743        *,
 744        resource_budget: ResourceBudget | None = None,
 745    ) -> BuildAllQueued:
 746        """Queue background pre-build on the node.
 747
 748        With ``challenge_ids=None`` the node builds every spec it knows.
 749        With an explicit list (the platform scoping a bulk build to a
 750        competition's challenge set) only those are built. Returns
 751        ``{queued, skipped_built, skipped_in_progress}`` so the platform
 752        can report per-node what got picked up. Sequential on the node
 753        side — no fan-out across the corpus.
 754        """
 755        kwargs: dict[str, Any] = {}
 756        body: dict[str, Any] = {}
 757        if challenge_ids is not None:
 758            body["challenge_ids"] = challenge_ids
 759        if resource_budget is not None:
 760            # #2504: what an authored id in the scope pre-builds under.
 761            body["resource_budget"] = resource_budget.model_dump()
 762        if body:
 763            kwargs["json"] = body
 764        resp = self.request("POST", "/admin/challenges/build-all", **kwargs)
 765        _raise_for_node_status(resp)
 766        return _parsed(BuildAllQueued, resp)
 767
 768    def build_push_challenge(
 769        self,
 770        challenge_id: str,
 771        *,
 772        push_ticket: str = "",
 773        deadline_s: int = 0,
 774        cleanup: bool = False,
 775        cache_max_bytes: int = 0,
 776        confine_network: bool = False,
 777        authored_revision: str = "",
 778        run_id: str = "",
 779        resource_budget: ResourceBudget | None = None,
 780    ) -> ChallengeFreezeResult:
 781        """Build one challenge's ``build:`` services and freeze them to the registry.
 782
 783        Layer 2 producer (P0). **Blocks** until the challenge is built +
 784        pushed (or skipped as already frozen), unlike the fire-and-return
 785        :meth:`build_challenge`. ⚠️ *deadline_s* bounds the node's whole
 786        freeze (#3167, `ctfy.core.freeze_budget`; ``0`` sends the node's own
 787        default), and the HTTP wait is derived from it —
 788        `freeze_client_wait_s`, past the node's own answer — so the client
 789        never abandons a freeze the node is still running. Sent once: a
 790        ``ReadTimeout`` is a request the node has (#3162). Returns the per-service freeze result;
 791        the platform's ``challenge_build_push`` task calls this one
 792        challenge at a time. *push_ticket* is the platform's short-lived
 793        registry credential the node's ``docker push`` authenticates with
 794        — the node runs it in a container with no ambient login.
 795        *cleanup* asks the node to remove what the build left and prune its
 796        build cache to *cache_max_bytes* afterwards, and *confine_network*
 797        keeps every ``RUN`` off the host's network (both the freeze pool, #1827).
 798        *run_id* names this call for :meth:`cancel_build_push` and
 799        :meth:`build_push_progress` while it is in flight (#1949).
 800        """
 801        resp = self.request(
 802            "POST",
 803            f"/admin/challenges/{challenge_id}/build-push",
 804            json={
 805                "push_ticket": push_ticket,
 806                "cleanup": cleanup,
 807                "cache_max_bytes": cache_max_bytes,
 808                "confine_network": confine_network,
 809                "authored_revision": authored_revision,
 810                "run_id": run_id,
 811                "deadline_s": deadline_s,
 812                # #2504: the build limits an authored tree is frozen under;
 813                # a node refuses one that arrives without them.
 814                "resource_budget": (
 815                    resource_budget.model_dump() if resource_budget is not None else None
 816                ),
 817            },
 818            timeout=freeze_client_wait_s(deadline_s or freeze_deadline_s()),
 819            retry_on=RETRY_UNSENT,
 820        )
 821        _raise_for_node_status(resp)
 822        return _parsed(ChallengeFreezeResult, resp)
 823
 824    def cancel_build_push(self, run_id: str) -> BuildPushCancelled:
 825        """Cancel the freeze :meth:`build_push_challenge` started under *run_id* (#1949).
 826
 827        Idempotent; ``found`` false means the node holds no such run.
 828        """
 829        resp = self.request("POST", "/admin/build-push/cancel", json={"run_id": run_id})
 830        _raise_for_node_status(resp)
 831        return _parsed(BuildPushCancelled, resp)
 832
 833    def build_push_progress(self, run_id: str) -> BuildPushProgress:
 834        """What the freeze running under *run_id* is doing now (#1949)."""
 835        resp = self.request("GET", f"/admin/build-push/{run_id}/progress")
 836        _raise_for_node_status(resp)
 837        return _parsed(BuildPushProgress, resp)
 838
 839    def get_disk_free(self) -> NodeDiskFree:
 840        """Free space on the node's data filesystem, read now (#1827).
 841
 842        Raises on any failure — including a node predating the route, which
 843        answers 404 — and the freeze reads every failure as *cannot tell*,
 844        which stops it.
 845        """
 846        resp = self.request("GET", "/admin/disk")
 847        _raise_for_node_status(resp)
 848        return _parsed(NodeDiskFree, resp)
 849
 850    def get_build_state(self) -> BuildStateRows:
 851        """Fetch every per-challenge build-state row on this node.
 852
 853        Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``.
 854        ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
 855        ``failed``; ``unbuilt`` placeholders are synthesised for specs
 856        the node has seen but never been asked to build.
 857        """
 858        resp = self.request("GET", "/admin/challenges/build-state")
 859        _raise_for_node_status(resp)
 860        return _parsed(BuildStateRows, resp)
 861
 862    def pull_challenge(self, challenge_id: str) -> ChallengePullState:
 863        """Ask the node to pre-pull registry images for *challenge_id*.
 864
 865        Pull-side twin of :meth:`build_challenge`: the node persists
 866        ``status="pulling"`` and spawns a daemon thread; poll
 867        :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing.
 868        """
 869        resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull")
 870        _raise_for_node_status(resp)
 871        return _parsed(ChallengePullState, resp)
 872
 873    def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> PullAllQueued:
 874        """Queue background pre-pull on the node.
 875
 876        With ``challenge_ids=None`` the node pulls every spec it knows;
 877        with an explicit list only those (the platform scoping to a
 878        competition). Returns ``{queued, skipped_pulled,
 879        skipped_in_progress}``.
 880        """
 881        kwargs: dict[str, Any] = {}
 882        if challenge_ids is not None:
 883            kwargs["json"] = {"challenge_ids": challenge_ids}
 884        resp = self.request("POST", "/admin/challenges/pull-all", **kwargs)
 885        _raise_for_node_status(resp)
 886        return _parsed(PullAllQueued, resp)
 887
 888    def get_pull_state(self) -> PullStateRows:
 889        """Fetch every per-challenge pull-state row on this node.
 890
 891        Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``.
 892        ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
 893        ``failed``; ``unpulled`` placeholders are synthesised for specs
 894        the node has seen but never been asked to pull.
 895        """
 896        resp = self.request("GET", "/admin/challenges/pull-state")
 897        _raise_for_node_status(resp)
 898        return _parsed(PullStateRows, resp)
 899
 900    # -- status / health ----------------------------------------------------
 901
 902    def get_status(self, instance_id: str) -> InstanceStatusReport:
 903        resp = self.request("GET", f"/instances/{instance_id}/status")
 904        resp.raise_for_status()
 905        return _parsed(InstanceStatusReport, resp)
 906
 907    def list_instances(self) -> list[NodeInstanceSummary]:
 908        """Every instance the node still has bookkeeping for.
 909
 910        Used by the platform's boot re-adoption pass to decide which
 911        durable claims still have containers behind them. ``get_status``
 912        answers the same question one id at a time; asking it per claim
 913        would cost one round trip per instance to a node that may be
 914        slow or down, on the path that gates startup.
 915        """
 916        resp = self.request("GET", "/instances")
 917        resp.raise_for_status()
 918        return _parsed(NodeInstanceList, resp).items
 919
 920    def check_health(self, instance_id: str) -> bool:
 921        return self.check_health_verbose(instance_id).healthy
 922
 923    def check_health_verbose(self, instance_id: str) -> HealthReading:
 924        """Health plus whatever agent-deception telemetry the node read.
 925
 926        One call, because the platform already polls this endpoint for
 927        every live instance every tick and a second round trip per
 928        instance would be the whole cost of the feature. ``check_health``
 929        stays a bool for the callers that only ever wanted one.
 930
 931        Never an error, whatever arrives: this rides the health poll,
 932        and telemetry must not be able to mark a healthy instance
 933        unhealthy.
 934
 935        ⚠️ **The two ways this can come back empty are different facts
 936        and used to be one.** An older node sends no ``deception`` key
 937        at all — it *did not say*, which is ``channel=None`` and
 938        authorises nothing. A body this platform cannot parse is
 939        something else: the node did say, and we could not read it. That
 940        is ``unreadable``, the same value the reader gives for a missing
 941        file, because it is the same fact — there is no usable channel
 942        here — arriving through a different door.
 943
 944        The parse failure that matters is version skew in the other
 945        direction: a **newer** node reporting an ``ObservationKind`` this
 946        platform does not have. ``Observation.kind`` is typed, so one
 947        unknown member fails the whole model rather than dropping one
 948        row — which is the right refusal (the node's word is not ours to
 949        edit) and exactly why it must not land as "reported nothing".
 950        """
 951        resp = self.request("GET", f"/instances/{instance_id}/health")
 952        resp.raise_for_status()
 953        body = resp.json()
 954        raw = body.get("deception")
 955        if not raw:
 956            summary = DeceptionSummary()
 957        else:
 958            try:
 959                summary = DeceptionSummary.model_validate(raw)
 960            except ValidationError as exc:
 961                log.info(
 962                    "unreadable deception telemetry from node",
 963                    instance_id=instance_id,
 964                    error=tail(str(exc), limit=200),
 965                )
 966                summary = DeceptionSummary(channel="unreadable")
 967        raw_contact = body.get("contact")
 968        if not raw_contact:
 969            contact = ContactReading()
 970        else:
 971            try:
 972                contact = ContactReading.model_validate(raw_contact)
 973            except ValidationError as exc:
 974                log.info(
 975                    "unreadable contact telemetry from node",
 976                    instance_id=instance_id,
 977                    error=tail(str(exc), limit=200),
 978                )
 979                contact = ContactReading(channel="unreadable")
 980        raw_procs = body.get("new_processes")
 981        # ⚠️ Shape-checked rather than trusted: an older node sends
 982        # nothing and a newer one could send a shape this platform does
 983        # not model, and neither may reach a reader as *a container full
 984        # of intruders*. Anything unreadable is silence, which for a
 985        # signal built to under-report is the honest reading.
 986        procs: dict[str, list[str]] = {}
 987        if isinstance(raw_procs, dict):
 988            for container, names in raw_procs.items():
 989                if isinstance(container, str) and isinstance(names, list):
 990                    procs[container] = [str(n) for n in names]
 991        return HealthReading(
 992            healthy=bool(body.get("is_healthy")),
 993            deception=summary,
 994            contact=contact,
 995            new_processes=procs,
 996        )
 997
 998    def node_health(self) -> dict[str, Any]:
 999        """Liveness + ``{running, capacity}`` for heartbeat."""
1000        resp = self.request("GET", "/health")
1001        resp.raise_for_status()
1002        body: dict[str, Any] = resp.json()
1003        return body
1004
1005    # -- traffic / runtime --------------------------------------------------
1006
1007    def get_traffic_page(
1008        self, instance_id: str, *, cursor: str = "", limit: int = 0
1009    ) -> InstanceTrafficPage:
1010        """One page of mitmproxy flows; the capture lives on the node's FS.
1011
1012        ⚠️ **The one method that must not use ``_parsed``.** A node from
1013        before paging answers with a bare *list* — it sent the whole
1014        capture and understands no cursor — and a strict
1015        ``model_validate`` turns that supported skew into a raised
1016        request. So the body goes through ``coerce_page``, which already
1017        owns every decision about an unexpected shape, and the caller
1018        gets the model.
1019
1020        This is the same move ``coerce_page`` was written for, one layer
1021        down. Its docstring used to say the parsing belonged
1022        "platform-side … so a route and this client cannot come to
1023        disagree" — and there were *three* platform-side calls doing it.
1024        There is one call now, at the seam that knows the request
1025        cursor, which is what the fallback needs; the routes hold the
1026        type instead of re-deciding its shape.
1027
1028        ⚠️ **An empty ``cursor`` means "from the beginning".** Passing
1029        one on every poll re-reads the whole capture, which is the cost
1030        the paged read exists to remove — arriving through the caller
1031        rather than through the node.
1032        """
1033        params: dict[str, Any] = {}
1034        if cursor:
1035            params["cursor"] = cursor
1036        if limit > 0:
1037            params["limit"] = limit
1038        resp = self.request("GET", f"/instances/{instance_id}/traffic", params=params)
1039        resp.raise_for_status()
1040        page = coerce_page(resp.json(), cursor)
1041        return InstanceTrafficPage(
1042            flows=page.flows,
1043            cursor=page.cursor,
1044            truncated=page.truncated,
1045            capture=page.capture,
1046        )
1047
1048    def get_traffic_witnesses(
1049        self, instance_id: str, *, cursor: str = "", limit: int = 0
1050    ) -> InstanceTrafficWitnesses:
1051        """Who spoke to this box since *cursor*, with no bodies moved.
1052
1053        §7.1's correlation reads one gamebox per team per round. Through
1054        :meth:`get_traffic_page` that is every flow, both bodies
1055        included (~100 kB apiece by ``CLAUDE.md``'s measurement),
1056        pulled across the wire to extract a handful of addresses; the
1057        node reduces instead and this carries the result.
1058
1059        ⚠️ **Strict parsing here, unlike its neighbour, and that is the
1060        difference between an old field and an old route.**
1061        ``get_traffic_page`` is lenient because a node from before
1062        paging answers the *same* path with a bare list, which is
1063        supported skew. This route did not exist on such a node at all,
1064        so the honest answer from one is a 404 — and a lenient parse of
1065        an error body would hand the round loop an empty witness set,
1066        which reads as *nobody was there*. The caller catches the
1067        request error and records the round as unknown.
1068        """
1069        params: dict[str, Any] = {}
1070        if cursor:
1071            params["cursor"] = cursor
1072        if limit > 0:
1073            params["limit"] = limit
1074        resp = self.request("GET", f"/instances/{instance_id}/traffic-witnesses", params=params)
1075        resp.raise_for_status()
1076        return _parsed(InstanceTrafficWitnesses, resp)
1077
1078    def list_containers(self, instance_id: str) -> list[ComposeContainer]:
1079        """Enumerate every container in an instance's compose project.
1080
1081        Backs the admin-shell feature: the platform forwards a
1082        ``GET /admin/instances/{id}/containers`` to the assigned node,
1083        which returns one row per container (challenge services and
1084        platform-injected sidecars alike). The WebSocket reverse-proxy
1085        is opened separately and does not go through ``NodeClient``.
1086        """
1087        resp = self.request("GET", f"/instances/{instance_id}/containers")
1088        _raise_for_node_status(resp)
1089        return _parsed_list(ComposeContainer, resp)
1090
1091    def run_checker(
1092        self,
1093        instance_id: str,
1094        *,
1095        service: str,
1096        cmd: list[str],
1097        timeout_s: int = DEFAULT_JUDGE_TIMEOUT_S,
1098    ) -> CheckResult:
1099        """Exec an author-supplied checker in a trusted judge sidecar.
1100
1101        Backs the checker / exec-judge feature: the platform forwards a
1102        verify request to the assigned node, which runs ``cmd`` inside the
1103        named sidecar and returns
1104        ``{exit_code, stdout, stderr, timed_out, error}``.
1105
1106        ⚠️ *timeout_s* is the author's, and the node clamps it (#3169): the
1107        clamped budget is what is sent and what the wait is derived from
1108        (`core.judge_budget.platform_wait_s`), so ``0`` no longer waits 10 s
1109        on a node running 30, and ``600`` no longer holds a threadpool slot
1110        for 610 s on a node that stops at 120. Sent once (``RETRY_UNSENT``).
1111        """
1112        exec_s = judge_exec_s(timeout_s)
1113        resp = self.request(
1114            "POST",
1115            f"/instances/{instance_id}/check",
1116            json={"service": service, "cmd": cmd, "timeout_s": exec_s},
1117            timeout=judge_platform_wait_s(exec_s),
1118            retry_on=RETRY_UNSENT,
1119        )
1120        _raise_for_node_status(resp)
1121        return _parsed(CheckResult, resp)
1122
1123    def run_harness(
1124        self,
1125        *,
1126        image: str,
1127        run_id: str = "",
1128        env: dict[str, str],
1129        timeout_s: int = 1800,
1130        untrusted: bool = False,
1131        network: str = "",
1132        own_network: bool = False,
1133        match_network: str = "",
1134        match_address: str = "",
1135        brief: dict[str, Any] | None = None,
1136        target_instance: str = "",
1137        target_aliases: dict[str, str] | None = None,
1138        target_access: str = "",
1139        vpn_config: str = "",
1140        estate_zones: Sequence[str] = (),
1141        target_endpoints: Sequence[str] = (),
1142        target_hosts: Mapping[str, str] | None = None,
1143        target_datagram_endpoints: Sequence[str] = (),
1144        pull_ticket: str = "",
1145        registry_auth: HarnessRegistryAuth | None = None,
1146        limits: HarnessLimits | None = None,
1147        admission_profile: bool = False,
1148        egress_allow: list[str] | None = None,
1149        internet: bool = True,
1150        rootfs_budget_bytes: int = 0,
1151        unpack_budget_bytes: int = 0,
1152        disk_low_watermark_bytes: int = 0,
1153        ingress: bool = True,
1154        local_ingress: bool = False,
1155        platform_image: bool = False,
1156        image_policy: HarnessImagePolicy | None,
1157        attachments: dict[str, str] | None = None,
1158        keep_work: bool = False,
1159        resume_work: bool = False,
1160        exploit: dict[str, Any] | None = None,
1161    ) -> HarnessRun:
1162        """Run a one-shot evaluation-harness container on the node.
1163
1164        ``exploit`` (#2819 W2) runs a smoke's reference exploit in the fence
1165        instead of an agent (``HarnessExploit``'s fields); sent only when set.
1166
1167        ``image_policy`` (#2608) is **required, with no default**: every
1168        caller says whether this image is a user's (the registry rules the
1169        node re-checks before pulling) or the platform's own (``None``). An
1170        untrusted run without one is refused by the node.
1171
1172        The node ``docker run``s ``image`` with ``env`` injected, waits for it
1173        to exit (bounded by ``timeout_s``), and returns
1174        ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as
1175        :meth:`run_checker`. The harness writes its JSON rollup to stdout. The
1176        per-call HTTP timeout sits above the node's run budget so the client
1177        doesn't abandon the request before the (long-running) harness returns.
1178
1179        ``untrusted`` runs the image under the node's isolation profile —
1180        off host networking, bounded resources, no capabilities — for an
1181        image the platform did not build. It requires a network: the
1182        node answers 400 without one, because "isolate this" and "I will
1183        not say where the target lives" do not compose.
1184
1185        Any of ``network`` (the target instance's own, when the caller
1186        knows it), ``own_network`` or ``target_instance`` satisfies that.
1187        ⚠️ Most eval dispatches need the second: there the harness
1188        *starts* the target partway through its own run, so at ``docker
1189        run`` time no target network exists and there is no name to
1190        send. The node creates a fresh bridge and removes it afterwards.
1191
1192        ``target_instance`` reverses that ordering, and is what an
1193        `engagement` uses: the platform starts the estate first and names
1194        it here, and the node wires its published containers to the
1195        agent's bridge. ⚠️ An **id**, never a network name — the name is
1196        a fact only the node's daemon can read, and ``docker run``
1197        *creates* whatever string it is handed, so a platform guessing
1198        one starts a run that reaches nothing.
1199        """
1200        resp = self.request(
1201            "POST",
1202            "/harness/run",
1203            json={
1204                "image": image,
1205                "env": env,
1206                "timeout_s": timeout_s,
1207                "untrusted": untrusted,
1208                "network": network,
1209                "own_network": own_network,
1210                "match_network": match_network,
1211                # ⚠️ **The platform-assigned arena address.** The node
1212                # connects the agent to the match network with this
1213                # ``--ip``, so the address is known before the round loop
1214                # asks (§7.1). Empty means "let docker's IPAM pick" — an
1215                # older platform's behaviour, and what keeps a node reading
1216                # this field unchanged for every non-arena dispatch.
1217                "match_address": match_address,
1218                # ⚠️ Sent even when empty, so a node reading it gets the
1219                # same shape either way; the node's default is what makes
1220                # an older platform's dispatch unchanged.
1221                "pull_ticket": pull_ticket,
1222                # #3475: the owner's registry login, sent **only when set**,
1223                # so a node that predates the field sees the body it always did.
1224                **(
1225                    {"registry_auth": registry_auth.model_dump()}
1226                    if registry_auth is not None
1227                    else {}
1228                ),
1229                # ⚠️ ``None`` leaves the node's own defaults in force, which is
1230                # what a dispatch said before the ceilings were settings.
1231                "limits": limits.model_dump() if limits is not None else None,
1232                # ⚠️ Same rule, and empty is a real answer here too: a
1233                # dispatch with no run row (the conformance probe, a dry
1234                # run) has nothing to label, and a label with an empty
1235                # value would make every one of them answer one query.
1236                "run_id": run_id,
1237                # ⚠️ Sent as the already-built manifest. The platform is
1238                # the side that knows the challenge, the competition and
1239                # the run mode; a node deriving its own would be a
1240                # second implementation of a published contract.
1241                "brief": brief,
1242                "target_instance": target_instance,
1243                # ⚠️ **The names the brief published, so they resolve on
1244                # the agent's bridge too.** An estate's hosts answer to a
1245                # per-instance FQDN minted by its own resolver; joined to
1246                # a new network a container answers only to its compose
1247                # *service* name. Sending the mapping is what keeps the
1248                # address the agent was told about true under either
1249                # placement — without it the agent is handed a hostname
1250                # that does not resolve, reaches nothing, and reports an
1251                # empty result, which reads as a model that gave up.
1252                #
1253                # ⚠️ The platform's to send: it computed the surface. A
1254                # node re-deriving it from the rendered compose would be
1255                # a second implementation of the address the brief has
1256                # already promised.
1257                "target_aliases": target_aliases or {},
1258                # #2550: ``{name: base64}`` for ``/ctfy/work/attachments/``.
1259                # ⚠️ Sent only when there are some, so every other dispatch
1260                # is byte-identical to before.
1261                **({"attachments": attachments} if attachments else {}),
1262                # #2550 restarts: sent only when asked for, like the files.
1263                **({"keep_work": True} if keep_work else {}),
1264                **({"resume_work": True} if resume_work else {}),
1265                # #2861 slice B: the target access the platform read off the
1266                # target's node. ⚠️ Sent only when there is one, so every
1267                # other dispatch is byte-identical to before; and only to a
1268                # node whose ``capabilities().harness_access`` said it reads
1269                # them, or to the target's own node (where an older node's
1270                # local read is still right).
1271                **(
1272                    {
1273                        "target_access": target_access,
1274                        "vpn_config": vpn_config,
1275                        "estate_zones": list(estate_zones),
1276                        "target_endpoints": list(target_endpoints),
1277                    }
1278                    if target_access
1279                    else {}
1280                ),
1281                # #3020: names → the target node's ingress address, for a
1282                # published target handed over the agent-ingress tunnel. Sent
1283                # only when there are some, and only to a node whose
1284                # ``capabilities().harness_ingress_access`` said it reads them.
1285                **({"target_hosts": dict(target_hosts)} if target_hosts else {}),
1286                # #3446: the published endpoints that are UDP, which the node
1287                # never TCP-probes. Sent only when there are some.
1288                **(
1289                    {"target_datagram_endpoints": list(target_datagram_endpoints)}
1290                    if target_datagram_endpoints
1291                    else {}
1292                ),
1293                # #2356: ⚠️ sent only when asked for, so a dispatch that does
1294                # not ask is byte-identical to an older platform's — and an
1295                # older node, which would ignore the fields, is never sent
1296                # them: the caller checks ``capabilities().harness_admission``.
1297                **(
1298                    {
1299                        "admission_profile": True,
1300                        "egress_allow": list(egress_allow or []),
1301                        "internet": internet,
1302                        "rootfs_budget_bytes": rootfs_budget_bytes,
1303                        "unpack_budget_bytes": unpack_budget_bytes,
1304                        "disk_low_watermark_bytes": disk_low_watermark_bytes,
1305                        "ingress": ingress,
1306                        # #2819 W1, F2: sent only when asked for.
1307                        **({"local_ingress": True} if local_ingress else {}),
1308                    }
1309                    if admission_profile
1310                    else {}
1311                ),
1312                "image_policy": image_policy.model_dump() if image_policy is not None else None,
1313                # #2608 N4: only for a row with no owner; see the node's request.
1314                **({"platform_image": True} if platform_image else {}),
1315                **({"exploit": exploit} if exploit is not None else {}),
1316            },
1317            timeout=(
1318                timeout_s
1319                # #2819 W2: the exploit's `solution/Dockerfile` builds first.
1320                + (SOLUTION_BUILD_TIMEOUT_S if exploit is not None else 0)
1321                + IMAGE_PULL_TIMEOUT_S
1322                + HARNESS_IMAGE_INSPECT_TIMEOUT_S
1323                + HARNESS_CREATE_TIMEOUT_S
1324                + 30
1325            ),
1326            retry_on=RETRY_UNSENT,
1327        )
1328        _raise_for_node_status(resp)
1329        return _parsed(HarnessRun, resp)
1330
1331    def cancel_harness(self, *, run_id: str) -> int:
1332        """Stop whatever this node is running for ``run_id``; returns how many.
1333
1334        Until this existed a dispatched container was reclaimed by
1335        **nothing but its own timeout** — ``_harness_timeout``'s docstring
1336        says exactly that — so an operator pressing cancel on a
1337        match-long run was asking for something the platform had no way
1338        to deliver.
1339
1340        ⚠️ **Zero is an ordinary answer, not a failure.** The run may have
1341        finished on its own, been dispatched to a different node, or been
1342        lost to a node restart. A caller that read zero as an error would
1343        report a broken platform every time a cancel arrived a second
1344        late.
1345        """
1346        resp = self.request("POST", "/harness/cancel", json={"run_id": run_id})
1347        body = resp.json()
1348        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0
1349
1350    def verify_patch(
1351        self, challenge_id: str, files: dict[str, bytes], *, deadline_s: int
1352    ) -> PatchVerifyResult:
1353        """Build a submitted patch on the node and return its verdict.
1354
1355        ``files`` maps a challenge-relative path to its new bytes; base64
1356        is applied here because a patch target may legitimately be
1357        binary. Returns ``{verdict, detail, applied, steps, error}``.
1358
1359        **An empty ``verdict`` with a populated ``error`` is not a
1360        judgement** — it means the node refused the submission, the
1361        verifier crashed, or the run's deadline ran out. The caller must
1362        retry or retire the lease rather than write a score, since failing
1363        to judge a patch is not the same as judging it unfixed.
1364
1365        ⚠️ *deadline_s* bounds the node's whole run (#3164,
1366        `ctfy.core.patch_budget`), and the HTTP wait is derived from it —
1367        `patch_client_wait_s`, past the node's own answer — so the client
1368        never abandons a request the node is still working on. Sent once:
1369        a ``ReadTimeout`` is a request the node has (#3162).
1370        """
1371        resp = self.request(
1372            "POST",
1373            "/patches/verify",
1374            json={
1375                "challenge_id": challenge_id,
1376                "files": {
1377                    path: base64.b64encode(content).decode() for path, content in files.items()
1378                },
1379                "deadline_s": deadline_s,
1380            },
1381            timeout=patch_client_wait_s(deadline_s),
1382            retry_on=RETRY_UNSENT,
1383        )
1384        _raise_for_node_status(resp)
1385        return _parsed(PatchVerifyResult, resp)
1386
1387    def collect_patch_files(
1388        self, instance_id: str, service: str, paths: list[str]
1389    ) -> dict[str, bytes]:
1390        """Read a player's SSH edits back out of their running box.
1391
1392        ``paths`` are absolute in-container paths the *platform* derived
1393        from the pristine source tree. Returns only what could be read:
1394        a path missing from the reply means the player did not change it,
1395        and the caller falls back to the shipped file rather than
1396        recording a deletion they never made.
1397        """
1398        resp = self.request(
1399            "POST",
1400            f"/instances/{instance_id}/patch-collect",
1401            json={"service": service, "paths": paths},
1402        )
1403        _raise_for_node_status(resp)
1404        body: dict[str, str] = resp.json()
1405        return {path: base64.b64decode(blob) for path, blob in body.items()}
1406
1407    def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
1408        """Rotate a round's flags into every box of this node, in one call.
1409
1410        ⚠️ **One call for the whole node, never one per box.** §5.2's
1411        capacity red line is 1000 teams x 3 services injected inside
1412        120 s; per-box that is 3000 round trips, batched it is about
1413        fourteen. The signature is the enforcement — a caller cannot
1414        accidentally loop.
1415
1416        Returns ``{instance_id: ""}`` for the boxes written and a reason
1417        for the ones that were not, because one team's unreachable box
1418        must not cost every other team on the node its rotation. An
1419        unrotated box keeps serving last round's flag, which anyone
1420        holding the old value can replay.
1421        """
1422        resp = self.request("POST", "/awd/answers", json={"boxes": boxes})
1423        _raise_for_node_status(resp)
1424        result: dict[str, str] = resp.json()
1425        return result
1426
1427    def probe_awd_service(
1428        self,
1429        *,
1430        instance_id: str,
1431        service_id: str,
1432        tick: int,
1433        flag: str,
1434        previous_flag: str = "",
1435        previous_flag_id: str = "",
1436    ) -> dict[str, str]:
1437        """Run one round's SLA probes against one box, from outside it.
1438
1439        The flags travel *to* the node: a checker that sourced its
1440        expected value from the box it is checking would pass on any box
1441        that returns whatever it was handed.
1442        """
1443        resp = self.request(
1444            "POST",
1445            "/awd/probe",
1446            json={
1447                "instance_id": instance_id,
1448                "service_id": service_id,
1449                "tick": tick,
1450                "flag": flag,
1451                "previous_flag": previous_flag,
1452                "previous_flag_id": previous_flag_id,
1453            },
1454        )
1455        _raise_for_node_status(resp)
1456        result: dict[str, str] = resp.json()
1457        return result
1458
1459    def attack_awd_box(
1460        self,
1461        *,
1462        instance_id: str,
1463        tick: int,
1464        timeout_s: int = 60,
1465        agent_image: str = "",
1466        counter_flag: str = "",
1467        forensic_marker: str = "",
1468        llm_api_key: str = "",
1469    ) -> AwdAttackResult:
1470        """Run this box's own reference exploit against it, once.
1471
1472        Per **box**, not per service: one `run_exploit` discovers every
1473        answer the challenge declares and prints them all.
1474
1475        ``timeout_s`` is sent because the platform is what knows how long
1476        a round is; the node clamps it, because a container held open for
1477        an hour is the node's problem whoever asked for it.
1478
1479        ``forensic_marker`` is §4.3.1's, carried only by the agent
1480        branch: `build_attack_argv` passes no environment at all, so a
1481        marker on the reference exploit would be the one value that
1482        voids `EXPLOIT007`'s guarantee.
1483
1484        ``agent_image`` + ``counter_flag`` switch the node to §4.2b's
1485        agent attacker. ⚠️ **Both or neither** — the node refuses a half
1486        request rather than falling back to the reference exploit, which
1487        would run a different format than the one configured while the
1488        board looked entirely normal.
1489        """
1490        resp = self.request(
1491            "POST",
1492            "/awd/attack",
1493            json={
1494                "instance_id": instance_id,
1495                "tick": tick,
1496                "timeout_s": timeout_s,
1497                "agent_image": agent_image,
1498                "counter_flag": counter_flag,
1499                "forensic_marker": forensic_marker,
1500                "llm_api_key": llm_api_key,
1501            },
1502        )
1503        _raise_for_node_status(resp)
1504        return _parsed(AwdAttackResult, resp)
1505
1506    def get_container_logs(self, instance_id: str) -> str:
1507        """Fetch combined container stdout/stderr for archival."""
1508        resp = self.request("GET", f"/instances/{instance_id}/container-logs")
1509        resp.raise_for_status()
1510        return str(resp.json().get("logs") or "")
1511
1512    def get_pcap(self, instance_id: str) -> PcapCapture:
1513        """Fetch the merged tcpdump capture for *instance_id*.
1514
1515        ``data`` is empty when no capture exists (sidecar disabled,
1516        instance never reached the running state, etc.) so callers can
1517        persist conditionally without try/except.
1518
1519        ⚠️ **It also says what the per-instance cap already dropped**
1520        (#1714), off the node's response headers, and ``None`` when the
1521        node did not say — an older node, which is *not reported*, never
1522        a clean capture.
1523        """
1524        resp = self.request("GET", f"/instances/{instance_id}/pcap")
1525        if resp.status_code == 404:
1526            return PcapCapture(data=b"")
1527        resp.raise_for_status()
1528        return PcapCapture(
1529            data=resp.content,
1530            dropped_bytes=_header_int(resp, CAPTURE_DROPPED_BYTES_HEADER),
1531            dropped_segments=_header_int(resp, CAPTURE_DROPPED_SEGMENTS_HEADER),
1532        )
1533
1534    def get_harness_artifact(self, artifact_id: str) -> bytes:
1535        """One finished run's collected archive, or ``b""``.
1536
1537        ⚠️ **Empty rather than an exception for a 404**, following
1538        :meth:`get_pcap`. The node reaps uncollected archives, and a
1539        platform that comes back after a restart is exactly the caller
1540        that meets one that is gone — a state its own record already
1541        describes, not a transport failure.
1542        """
1543        resp = self.request("GET", f"/harness/artifacts/{artifact_id}")
1544        if resp.status_code == 404:
1545            return b""
1546        resp.raise_for_status()
1547        return resp.content
1548
1549    def delete_harness_artifact(self, artifact_id: str) -> bool:
1550        """Tell the node the archive has been stored and can go.
1551
1552        ⚠️ **Never raises on a transport failure**, because it is called
1553        after the bytes are safely in object storage: turning a cleanup
1554        error into the run's error would replace a success with a
1555        janitorial complaint. The node's reaper collects whatever this
1556        misses.
1557        """
1558        try:
1559            resp = self.request("DELETE", f"/harness/artifacts/{artifact_id}")
1560        except Exception:
1561            return False
1562        return resp.status_code == 200
1563
1564    def get_agent_runtime(self, instance_id: str) -> AgentRuntime:
1565        """Provider-agnostic runtime hints for attaching an agent (proxy URL,
1566        CA PEM, optional Docker-specific names). Replaces the old
1567        sandbox-network shape."""
1568        resp = self.request("GET", f"/instances/{instance_id}/agent-runtime")
1569        resp.raise_for_status()
1570        return _parsed(AgentRuntime, resp)
1571
1572    # -- per-instance rendered attachments ---------------------------------
1573
1574    def list_instance_attachments(self, instance_id: str) -> AttachmentList:
1575        """List per-instance attachments rendered into the node's workdir.
1576
1577        Backs the platform's per-instance attachment endpoint, which
1578        surfaces the team-specific rendered file listing.
1579
1580        ⚠️ This used to hand back the raw dict, under a docstring saying
1581        "caller projects through :class:`AttachmentList` for typing" —
1582        the node's route already declared that model, so the type existed
1583        and this method's signature was the one place it was dropped.
1584        Projecting *here* means there is one place a malformed answer is
1585        caught rather than one per caller."""
1586        resp = self.request("GET", f"/instances/{instance_id}/attachments")
1587        resp.raise_for_status()
1588        return _parsed(AttachmentList, resp)
1589
1590    def get_vpn_config(self, instance_id: str) -> bytes:
1591        """Fetch the per-instance VPN client config from the node.
1592
1593        The node reads the backend's client config out of the
1594        instance's VPN service container (generated on first boot by
1595        the base image's bootstrap) and rewrites its endpoint to the
1596        node's public host:port. Returns the body verbatim — the
1597        platform-side route adds the ``Content-Disposition`` header
1598        before re-emitting to the player, because only it knows the
1599        backend and therefore the filename the player needs.
1600
1601        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1602        proxy route catches 404 and re-emits to the player, anything
1603        else is treated as a 502.
1604        """
1605        resp = self.request("GET", f"/instances/{instance_id}/vpn-config")
1606        resp.raise_for_status()
1607        return resp.content
1608
1609    def get_vpn_access(self, instance_id: str) -> tuple[bytes, tuple[str, ...] | None]:
1610        """The player's VPN config plus the estate's resolver zones (#2861 B).
1611
1612        The config is :meth:`get_vpn_config`'s, byte for byte; the zones ride
1613        a response header the node adds only when asked. ``None`` for the
1614        zones is a node that did not say (older, or docker unreadable there)
1615        — never "no zones". Raises like :meth:`get_vpn_config`.
1616        """
1617        resp = self.request(
1618            "GET", f"/instances/{instance_id}/vpn-config", params={"estate_zones": "true"}
1619        )
1620        resp.raise_for_status()
1621        raw = resp.headers.get("X-Ctfy-Estate-Zones")
1622        return resp.content, (tuple(raw.split()) if raw is not None else None)
1623
1624    def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
1625        """Download one per-instance attachment as ``(bytes, content_type)``.
1626
1627        Buffers the whole body — attachments are bounded (typical case
1628        is a 10 KB binary or a small text file). For huge captures the
1629        caller should hand the player a CDN URL instead.
1630
1631        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1632        proxy route catches 404 and re-emits to the player, anything
1633        else is a 502. Invalid names or instance keys raise
1634        ``InvalidAttachmentName`` before any request is sent."""
1635        # The instance is a single route key, never part of the file name.
1636        if component_refusal(instance_id) is not None:
1637            raise InvalidAttachmentName("Invalid attachment instance")
1638        resp = self.request(
1639            "GET",
1640            f"/instances/{quote(instance_id, safe='')}/attachments/{attachment_url_path(filename)}",
1641        )
1642        resp.raise_for_status()
1643        return resp.content, resp.headers.get("content-type", "application/octet-stream")

Thin sync HTTP client; one instance per node URL.

NodeClient( node_url: str, token: str, *, timeout: int = 600, max_retries: int = 2, transport: httpx.BaseTransport | None = None, disabled_features: Iterable[str] = ())
251    def __init__(
252        self,
253        node_url: str,
254        token: str,
255        *,
256        timeout: int = DEFAULT_CLIENT_TIMEOUT,
257        max_retries: int = 2,
258        transport: httpx.BaseTransport | None = None,
259        disabled_features: Iterable[str] = (),
260    ) -> None:
261        """*disabled_features* are the features an admin switched off on this
262        node (#3037, `ctfy.core.node_features`): :meth:`capabilities` answers
263        ``False`` for each, whatever the node says, so a caller reads the
264        effective value and cannot forget the switch. ``()`` is the raw answer
265        — every platform construction passes the node row's set
266        (`test_every_node_client_carries_the_switches`).
267
268        *transport* is `BaseHttpClient`'s own seam, passed through so a
269        test can drive a real node app over the real client — the hop the
270        fakes on either side of it cannot see.
271
272        *max_retries* is passed through too: a best-effort read or a stop
273        is tried once (#2812), because a retry on a timeout multiplies the
274        one number that has to stay small.
275
276        ⚠️ **A call that starts long work retries only a request that never
277        left** (#3162, :data:`~ctfy.sdk.base.RETRY_UNSENT`): every POST here
278        that names its own HTTP timeout says so per call, because a
279        ``ReadTimeout`` is a request the node has and is still running.
280        ``start_instance`` keeps the default — the node replays it from the
281        instance id. Guarded by `ctfy/tests/test_long_node_work_is_sent_once.py`."""
282        super().__init__(
283            f"{node_url.rstrip('/')}/api/v1",
284            token,
285            timeout=timeout,
286            max_retries=max_retries,
287            transport=transport,
288        )
289        self.disabled_features = frozenset(disabled_features)

disabled_features are the features an admin switched off on this node (#3037, ctfy.core.node_features): capabilities() answers False for each, whatever the node says, so a caller reads the effective value and cannot forget the switch. () is the raw answer — every platform construction passes the node row's set (test_every_node_client_carries_the_switches).

transport is BaseHttpClient's own seam, passed through so a test can drive a real node app over the real client — the hop the fakes on either side of it cannot see.

max_retries is passed through too: a best-effort read or a stop is tried once (#2812), because a retry on a timeout multiplies the one number that has to stay small.

⚠️ A call that starts long work retries only a request that never left (#3162, ~ctfy.sdk.base.RETRY_UNSENT): every POST here that names its own HTTP timeout says so per call, because a ReadTimeout is a request the node has and is still running. start_instance keeps the default — the node replays it from the instance id. Guarded by ctfy/tests/test_long_node_work_is_sent_once.py.

disabled_features
def start_instance( self, *, challenge_id: str, instance_id: str, ttl: int, answers: dict[str, str], proxy_output_dir: str | None = None, env: dict[str, str] | None = None, publish_gamebox: bool = False, match_network: ctfy.core.awd_network.MatchNetwork | None = None, open_shell_password: str = '', authored_revision: str = '', internet: ctfy.core.enums.InternetAccess = <InternetAccess.ALLOW: 'allow'>, tcp_access: ctfy.core.enums.TcpAccess = <TcpAccess.TLS_SNI: 'tls_sni'>, egress_allow_hosts: list[str] | None = None, resource_budget: ctfy.core.resource_budget.ResourceBudget | None = None, compose_up_timeout_s: int = 0) -> ctfy.server.models.node.InstanceStarted:
293    def start_instance(
294        self,
295        *,
296        challenge_id: str,
297        instance_id: str,
298        ttl: int,
299        answers: dict[str, str],
300        proxy_output_dir: str | None = None,
301        env: dict[str, str] | None = None,
302        publish_gamebox: bool = False,
303        match_network: MatchNetwork | None = None,
304        open_shell_password: str = "",
305        authored_revision: str = "",
306        internet: InternetAccess = InternetAccess.ALLOW,
307        tcp_access: TcpAccess = TcpAccess.TLS_SNI,
308        egress_allow_hosts: list[str] | None = None,
309        resource_budget: ResourceBudget | None = None,
310        compose_up_timeout_s: int = 0,
311    ) -> InstanceStarted:
312        resp = self.request(
313            "POST",
314            "/instances",
315            json={
316                # #1835: the authored tree's revision this start must run;
317                # a node holding another (or none) fetches it first.
318                "authored_revision": authored_revision,
319                "challenge_id": challenge_id,
320                "instance_id": instance_id,
321                "ttl": ttl,
322                "answers": answers,
323                "proxy_output_dir": proxy_output_dir,
324                "env": env or {},
325                "publish_gamebox": publish_gamebox,
326                # ``None`` rather than an omitted key: the node's model
327                # defaults to None either way, and sending it explicitly
328                # keeps the body's shape stable across both branches.
329                "match_network": (dataclasses.asdict(match_network) if match_network else None),
330                # Classic-AWD open shell (#1725): the box's starting root
331                # password, empty for every managed / non-AWD start.
332                "open_shell_password": open_shell_password,
333                # #1969: ``deny`` holds the box off the internet; the hosts
334                # are the platform-internal ones it still reaches.
335                "internet": str(internet),
336                # #2082: how a player reaches the box's TCP services.
337                "tcp_access": str(tcp_access),
338                "egress_allow_hosts": list(egress_allow_hosts or []),
339                # The CPU / memory a player-authored box is held to; ``None``
340                # for the corpus and from an older platform.
341                "resource_budget": (
342                    resource_budget.model_dump() if resource_budget is not None else None
343                ),
344                # #3165: the platform's ``compose_up_timeout_s``, so the node
345                # spends the budget the platform waits on; ``0`` = the node's own.
346                "compose_up_timeout_s": compose_up_timeout_s,
347            },
348        )
349        _raise_for_node_status(resp)
350        return _parsed(InstanceStarted, resp)
def stop_instance(self, instance_id: str) -> None:
352    def stop_instance(self, instance_id: str) -> None:
353        resp = self.request("DELETE", f"/instances/{instance_id}")
354        resp.raise_for_status()
def apply_fabric( self, *, index: int, interface: dict[str, typing.Any], match_network: str = '') -> ctfy.server.models.node.FabricStatus:
356    def apply_fabric(
357        self,
358        *,
359        index: int,
360        interface: dict[str, Any],
361        match_network: str = "",
362    ) -> FabricStatus:
363        """Put this node on one match's WireGuard fabric.
364
365        ⚠️ **Returns the model, never ``resp.json()``.** The platform
366        reads the verdict field by field, and on a dict a renamed field
367        is ``None`` — falsy — which reads as "the arena did not open",
368        so a wire change would quietly report every node as broken.
369
370        Returns the node's verdict rather than raising on a refusal: the
371        route answers **200 with a body** precisely so that "this node is
372        unreachable" and "this node rejected the plan" stay different
373        facts, and a caller fanning out over an arena needs to tell them
374        apart — one is waited on, the other is looked at.
375
376        *match_network* lets the node check the tunnel landed in the
377        namespace its boxes are in, and open the bridge's one hole. It is
378        the platform's own fact (`awd_network.network_name`), so the node
379        is told rather than guessing.
380        """
381        resp = self.request(
382            "POST",
383            "/fabric",
384            json={
385                "index": index,
386                "interface": interface,
387                "match_network": match_network,
388            },
389        )
390        resp.raise_for_status()
391        return _parsed(FabricStatus, resp)

Put this node on one match's WireGuard fabric.

⚠️ Returns the model, never resp.json(). The platform reads the verdict field by field, and on a dict a renamed field is None — falsy — which reads as "the arena did not open", so a wire change would quietly report every node as broken.

Returns the node's verdict rather than raising on a refusal: the route answers 200 with a body precisely so that "this node is unreachable" and "this node rejected the plan" stay different facts, and a caller fanning out over an arena needs to tell them apart — one is waited on, the other is looked at.

match_network lets the node check the tunnel landed in the namespace its boxes are in, and open the bridge's one hole. It is the platform's own fact (awd_network.network_name), so the node is told rather than guessing.

def probe_fabric( self, *, index: int, peers: list[dict[str, str]], mtu: int = 0) -> ctfy.server.models.node.FabricProbeReport:
393    def probe_fabric(
394        self,
395        *,
396        index: int,
397        peers: list[dict[str, str]],
398        mtu: int = 0,
399    ) -> FabricProbeReport:
400        """Ask named peers the three link questions. Applies nothing.
401
402        ⚠️ **Returns the model, never ``resp.json()``** — same reason
403        `apply_fabric` gives: on a dict a renamed field reads as falsy,
404        which here means "your tunnel is down" about a wire change.
405        """
406        resp = self.request(
407            "POST",
408            "/fabric/probe",
409            json={"index": index, "peers": peers, "mtu": mtu},
410        )
411        resp.raise_for_status()
412        return _parsed(FabricProbeReport, resp)

Ask named peers the three link questions. Applies nothing.

⚠️ Returns the model, never resp.json() — same reason apply_fabric gives: on a dict a renamed field reads as falsy, which here means "your tunnel is down" about a wire change.

def remove_fabric( self, *, index: int, match_network: str = '') -> ctfy.server.models.node.FabricStatus:
414    def remove_fabric(self, *, index: int, match_network: str = "") -> FabricStatus:
415        """Take this node off one match's fabric and close its hole.
416
417        ⚠️ *match_network* is what lets the node resolve the bridge and
418        delete the ``DOCKER-USER`` rules by name. Without it the tunnel
419        goes and the hole stays — open on whatever docker gives that
420        bridge name next.
421        """
422        resp = self.request(
423            "DELETE",
424            f"/fabric/{index}",
425            params={"match_network": match_network},
426        )
427        resp.raise_for_status()
428        return _parsed(FabricStatus, resp)

Take this node off one match's fabric and close its hole.

⚠️ match_network is what lets the node resolve the bridge and delete the DOCKER-USER rules by name. Without it the tunnel goes and the hole stays — open on whatever docker gives that bridge name next.

def stop_instance_service(self, instance_id: str, service: str) -> int:
430    def stop_instance_service(self, instance_id: str, service: str) -> int:
431        """Stop one compose service, leaving the instance running.
432
433        The exercise format's session close (§4b.4). Returns how many
434        containers the node stopped — 0 means the challenge declares no
435        such service, which is a real answer rather than a failure, so
436        the caller can distinguish it from a cut tunnel.
437        """
438        resp = self.request(
439            "POST",
440            f"/instances/{instance_id}/stop-service",
441            json={"service": service},
442        )
443        resp.raise_for_status()
444        body = resp.json()
445        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0

Stop one compose service, leaving the instance running.

The exercise format's session close (§4b.4). Returns how many containers the node stopped — 0 means the challenge declares no such service, which is a real answer rather than a failure, so the caller can distinguish it from a cut tunnel.

def capabilities( self, *, harness_admission: bool = False, agent_ingress: bool = False) -> ctfy.server.models.NodeCapabilities:
447    def capabilities(
448        self, *, harness_admission: bool = False, agent_ingress: bool = False
449    ) -> NodeCapabilities:
450        """What this node can do beyond the original verbs.
451
452        ⚠️ **A 404 is an answer, not an error**: a node from before hold /
453        resume has no such route, and "cannot hold" is exactly what the
454        platform needs to hear -- the caller then falls back to deleting.
455        Any other failure raises, because an unreachable node is not a node
456        that cannot hold.
457        """
458        # ⚠️ #2356: ``harness_admission`` makes the node prove it can hold
459        # an admitted run, which writes its host firewall — asked only by
460        # the admission paths, never by hold / internet.
461        # #3020: ``agent_ingress`` makes the node converge its listener,
462        # asked only before handing a published target over WireGuard.
463        params = {
464            **({"harness_admission": "true"} if harness_admission else {}),
465            **({"agent_ingress": "true"} if agent_ingress else {}),
466        }
467        resp = self.request("GET", "/capabilities", params=params or None)
468        if resp.status_code == 404:
469            return NodeCapabilities()
470        resp.raise_for_status()
471        return masked_capabilities(_parsed(NodeCapabilities, resp), self.disabled_features)

What this node can do beyond the original verbs.

⚠️ A 404 is an answer, not an error: a node from before hold / resume has no such route, and "cannot hold" is exactly what the platform needs to hear -- the caller then falls back to deleting. Any other failure raises, because an unreachable node is not a node that cannot hold.

def create_agent_ingress_peer( self, *, run_id: str, public_key: str, ports: list[int], ttl_s: int) -> ctfy.server.models.node.AgentIngressPeer:
473    def create_agent_ingress_peer(
474        self, *, run_id: str, public_key: str, ports: list[int], ttl_s: int
475    ) -> AgentIngressPeer:
476        """Add *run_id*'s peer to the node's agent-ingress listener (#3020).
477
478        Only the public key crosses; the caller keeps the private half.
479        Raises on any non-2xx (the node's refusal is in the detail).
480        """
481        body = AgentIngressPeerRequest(
482            run_id=run_id, public_key=public_key, ports=ports, ttl_s=ttl_s
483        )
484        resp = self.request("POST", "/agent-ingress/peers", json=body.model_dump(mode="json"))
485        resp.raise_for_status()
486        return _parsed(AgentIngressPeer, resp)

Add run_id's peer to the node's agent-ingress listener (#3020).

Only the public key crosses; the caller keeps the private half. Raises on any non-2xx (the node's refusal is in the detail).

def delete_agent_ingress_peer(self, run_id: str) -> bool:
488    def delete_agent_ingress_peer(self, run_id: str) -> bool:
489        """Revoke *run_id*'s peer; ``False`` when the node held none."""
490        resp = self.request("DELETE", f"/agent-ingress/peers/{run_id}")
491        resp.raise_for_status()
492        return _parsed(AgentIngressPeerRemoved, resp).removed

Revoke run_id's peer; False when the node held none.

def hold_instance(self, instance_id: str, mode: str) -> int:
494    def hold_instance(self, instance_id: str, mode: str) -> int:
495        """Pause or stop an instance, removing nothing. Returns containers held."""
496        resp = self.request("POST", f"/instances/{instance_id}/hold", json={"mode": mode})
497        resp.raise_for_status()
498        return _parsed(InstanceHeld, resp).count

Pause or stop an instance, removing nothing. Returns containers held.

def resume_instance(self, instance_id: str) -> int:
500    def resume_instance(self, instance_id: str) -> int:
501        """Bring a held instance back. Returns containers brought back."""
502        resp = self.request("POST", f"/instances/{instance_id}/resume")
503        resp.raise_for_status()
504        return _parsed(InstanceHeld, resp).count

Bring a held instance back. Returns containers brought back.

def change_instance_internet( self, instance_id: str, change: ctfy.server.models.node.InstanceInternetChange) -> ctfy.server.models.node.InstanceInternetChanged:
506    def change_instance_internet(
507        self, instance_id: str, change: InstanceInternetChange
508    ) -> InstanceInternetChanged:
509        """Change a running box's internet verdict (#2067).
510
511        Raises :class:`NodeRequestError` on any refusal, carrying the node's
512        ``code`` (:data:`~ctfy.core.exceptions.NODE_INTERNET_CHANGE_FAILED`
513        when its firewall could not converge — the node has then put its
514        record back, so nothing changed).
515        """
516        resp = self.request(
517            "POST", f"/instances/{instance_id}/internet", json=change.model_dump(mode="json")
518        )
519        _raise_for_node_status(resp)
520        return _parsed(InstanceInternetChanged, resp)

Change a running box's internet verdict (#2067).

Raises NodeRequestError on any refusal, carrying the node's code (~ctfy.core.exceptions.NODE_INTERNET_CHANGE_FAILED when its firewall could not converge — the node has then put its record back, so nothing changed).

def stop_all(self) -> None:
522    def stop_all(self) -> None:
523        resp = self.request("POST", "/admin/stop-all")
524        resp.raise_for_status()
def put_authored_challenge( self, push: ctfy.server.models.NodeAuthoredPush) -> ctfy.server.models.NodeAuthoredResult:
526    def put_authored_challenge(self, push: NodeAuthoredPush) -> NodeAuthoredResult:
527        """Ask the node to write an approved player upload into its catalog.
528
529        ⚠️ **The bytes travel here, unlike `put_generated_range`.** That
530        one sends a scenario because the node can compile it; a
531        player-authored tree has no compiler and no corpus entry, so the
532        archive is what there is to send.
533
534        The node answers **200 with ``ok: false``** for a refusal, so a
535        node that declined the archive and a node that could not be
536        reached stay different facts; only the second raises here.
537        """
538        resp = self.request("POST", "/admin/authored", json=push.model_dump(mode="json"))
539        resp.raise_for_status()
540        return _parsed(NodeAuthoredResult, resp)

Ask the node to write an approved player upload into its catalog.

⚠️ The bytes travel here, unlike put_generated_range. That one sends a scenario because the node can compile it; a player-authored tree has no compiler and no corpus entry, so the archive is what there is to send.

The node answers 200 with ok: false for a refusal, so a node that declined the archive and a node that could not be reached stay different facts; only the second raises here.

def smoke_authored_challenge( self, push: ctfy.server.models.NodeAuthoredSmokePush, *, timeout: int | None = None) -> ctfy.server.models.NodeAuthoredSmokeResult:
542    def smoke_authored_challenge(
543        self, push: NodeAuthoredSmokePush, *, timeout: int | None = None
544    ) -> NodeAuthoredSmokeResult:
545        """Ask the node to run an authored challenge's own reference exploit.
546
547        ⚠️ **Takes its own timeout.** A smoke run brings a whole estate
548        up; the client default is sized for a status call and would give
549        up while the node is still doing what it was asked.
550        """
551        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
552        if timeout is not None:
553            kwargs["timeout"] = timeout
554        resp = self.request("POST", "/admin/authored/smoke", retry_on=RETRY_UNSENT, **kwargs)
555        resp.raise_for_status()
556        return _parsed(NodeAuthoredSmokeResult, resp)

Ask the node to run an authored challenge's own reference exploit.

⚠️ Takes its own timeout. A smoke run brings a whole estate up; the client default is sized for a status call and would give up while the node is still doing what it was asked.

def smoke_progress( self, run_id: str, since: int = 0) -> ctfy.core.smoke_live.NodeSmokeProgress | None:
558    def smoke_progress(self, run_id: str, since: int = 0) -> NodeSmokeProgress | None:
559        """The steps and lines a running smoke has produced since *since* (#2145).
560
561        ``None`` when the node holds no feed for *run_id* — a node older than
562        the feed, or a run that never started — which a watcher reads as
563        *unwatched*. Any other failure raises: the caller decides that a
564        watcher's trouble must never fail the run it watches.
565        """
566        # ⚠️ A short timeout of its own: this is read once a second beside a
567        # run that may be minutes long, and a read that hangs on a busy worker
568        # must not hold the watcher past the run it watches.
569        resp = self.request(
570            "GET",
571            f"/admin/authored/smoke/{run_id}/progress",
572            params={"since": since},
573            timeout=10,
574        )
575        if resp.status_code == 404:
576            return None
577        resp.raise_for_status()
578        return _parsed(NodeSmokeProgress, resp)

The steps and lines a running smoke has produced since since (#2145).

None when the node holds no feed for run_id — a node older than the feed, or a run that never started — which a watcher reads as unwatched. Any other failure raises: the caller decides that a watcher's trouble must never fail the run it watches.

def smoke_entrance(self, run_id: str) -> ctfy.server.models.NodeSmokeEntrance:
580    def smoke_entrance(self, run_id: str) -> NodeSmokeEntrance:
581        """The player's entrance to the smoke box held for *run_id* (#2819 W2).
582
583        ``pending`` / ``ready`` / ``gone``; a 404 (a node older than the
584        route) raises, which the caller reads as *this node cannot hold*.
585        """
586        resp = self.request("GET", f"/admin/authored/smoke/{run_id}/entrance", timeout=15)
587        resp.raise_for_status()
588        return _parsed(NodeSmokeEntrance, resp)

The player's entrance to the smoke box held for run_id (#2819 W2).

pending / ready / gone; a 404 (a node older than the route) raises, which the caller reads as this node cannot hold.

def post_smoke_exploit( self, run_id: str, outcome: ctfy.server.models.NodeSmokeExploitOutcome) -> bool:
590    def post_smoke_exploit(self, run_id: str, outcome: NodeSmokeExploitOutcome) -> bool:
591        """Hand the held smoke box its exploit outcome (#2819 W2).
592
593        ``True`` when the node took it; ``False`` when it holds no box for
594        *run_id* any more (it tore down on its own timeout) or had one.
595        """
596        resp = self.request(
597            "POST",
598            f"/admin/authored/smoke/{run_id}/exploit",
599            json=outcome.model_dump(mode="json"),
600            timeout=30,
601            # First outcome wins on the node, so a resend is harmless; but a
602            # read timeout is a post that may have landed (#3162).
603            retry_on=RETRY_UNSENT,
604        )
605        if resp.status_code == 404:
606            return False
607        resp.raise_for_status()
608        return bool(resp.json().get("taken"))

Hand the held smoke box its exploit outcome (#2819 W2).

True when the node took it; False when it holds no box for run_id any more (it tore down on its own timeout) or had one.

def harness_log( self, run_id: str, after: int = 0) -> ctfy.core.agent_log.NodeAgentLog | None:
610    def harness_log(self, run_id: str, after: int = 0) -> NodeAgentLog | None:
611        """An agent run's console lines numbered above *after* (#2401).
612
613        ``None`` when the node holds no console for *run_id* — a node older
614        than the feed, or a run that never started there — which a watcher
615        reads as *unwatched*. Any other failure raises: the caller decides
616        that a watcher's trouble must never fail the run it watches.
617        """
618        # ⚠️ A short timeout of its own, as ``smoke_progress``: read once a
619        # second beside a run that may be hours long.
620        resp = self.request(
621            "GET", f"/harness/runs/{run_id}/log", params={"after": after}, timeout=10
622        )
623        if resp.status_code == 404:
624            return None
625        resp.raise_for_status()
626        return _parsed(NodeAgentLog, resp)

An agent run's console lines numbered above after (#2401).

None when the node holds no console for run_id — a node older than the feed, or a run that never started there — which a watcher reads as unwatched. Any other failure raises: the caller decides that a watcher's trouble must never fail the run it watches.

def harness_result(self, run_id: str) -> ctfy.server.models.node.HarnessRunResult | None:
628    def harness_result(self, run_id: str) -> HarnessRunResult | None:
629        """What the node knows of a run's harness call (#3021).
630
631        ``None`` when the node has no such route — a node older than the
632        result store, which can never return a run's outcome after its call
633        is gone. ``state="unknown"`` is the node answering that it holds
634        nothing for *run_id*. Any other failure raises: an unreachable node
635        is neither of those, and the caller bounds how long it waits on one.
636        """
637        resp = self.request("GET", f"/harness/runs/{run_id}/result", timeout=30)
638        if resp.status_code == 404:
639            return None
640        resp.raise_for_status()
641        return _parsed(HarnessRunResult, resp)

What the node knows of a run's harness call (#3021).

None when the node has no such route — a node older than the result store, which can never return a run's outcome after its call is gone. state="unknown" is the node answering that it holds nothing for run_id. Any other failure raises: an unreachable node is neither of those, and the caller bounds how long it waits on one.

def put_generated_range( self, push: ctfy.server.models.NodeRangePush) -> ctfy.server.models.NodeRangeResult:
643    def put_generated_range(self, push: NodeRangePush) -> NodeRangeResult:
644        """Ask the node to build a generated range from its scenario.
645
646        ⚠️ **The scenario travels, not the estate.** The corpus is
647        mounted on the node and the compiler ships inside it, so 70-odd
648        rendered files never cross this API — and the corpus commit that
649        rides along is what stops one scenario becoming two different
650        estates under one id across a fleet.
651
652        The node answers **200 with ``ok: false``** for a refusal, so a
653        node that declined and a node that could not be reached stay
654        different facts; only the second raises here.
655        """
656        resp = self.request("POST", "/admin/ranges", json=push.model_dump(mode="json"))
657        resp.raise_for_status()
658        return _parsed(NodeRangeResult, resp)

Ask the node to build a generated range from its scenario.

⚠️ The scenario travels, not the estate. The corpus is mounted on the node and the compiler ships inside it, so 70-odd rendered files never cross this API — and the corpus commit that rides along is what stops one scenario becoming two different estates under one id across a fleet.

The node answers 200 with ok: false for a refusal, so a node that declined and a node that could not be reached stay different facts; only the second raises here.

def verify_generated_range( self, push: ctfy.server.models.NodeRangeVerifyPush, *, timeout: int | None = None) -> ctfy.server.models.NodeRangeVerifyResult:
660    def verify_generated_range(
661        self, push: NodeRangeVerifyPush, *, timeout: int | None = None
662    ) -> NodeRangeVerifyResult:
663        """Ask the node to build a scenario, solve it, and take it down.
664
665        ⚠️ **The longest call in this API**, so the caller passes its own
666        timeout: the node's budget is ``compose_timeout_s +
667        solve_timeout_s`` and a client that used the default would give
668        up while the node was still building — then retry, and start a
669        second estate beside the first.
670
671        ⚠️ A client timeout does **not** leave a range running: the node
672        tears down in its own ``finally``. What is lost is the answer,
673        not the host.
674
675        The node answers **200 with an ``error``** for a refusal, the
676        same split ``put_generated_range`` uses; only an unreachable node
677        raises.
678        """
679        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
680        if timeout is not None:
681            # httpx takes a per-request override; the client-wide value
682            # stays whatever the caller constructed this with.
683            kwargs["timeout"] = timeout
684        resp = self.request("POST", "/admin/ranges/verify", retry_on=RETRY_UNSENT, **kwargs)
685        resp.raise_for_status()
686        return _parsed(NodeRangeVerifyResult, resp)

Ask the node to build a scenario, solve it, and take it down.

⚠️ The longest call in this API, so the caller passes its own timeout: the node's budget is compose_timeout_s + solve_timeout_s and a client that used the default would give up while the node was still building — then retry, and start a second estate beside the first.

⚠️ A client timeout does not leave a range running: the node tears down in its own finally. What is lost is the answer, not the host.

The node answers 200 with an error for a refusal, the same split put_generated_range uses; only an unreachable node raises.

def cancel_generated_range_verify(self, run_id: str) -> bool:
688    def cancel_generated_range_verify(self, run_id: str) -> bool:
689        """Tell the node to stop a verification it is running.
690
691        ⚠️ **A miss is success, not a 404 to report.** The cancel races
692        the run's own finish, so "no such run" means the run already
693        ended — which is what the operator asked for. The node answers
694        ``{"cancelled": false}`` for that case rather than raising, and
695        this returns it so a caller that wants to say "it had already
696        finished" can.
697        """
698        resp = self.request("POST", f"/admin/ranges/verify/{run_id}/cancel")
699        resp.raise_for_status()
700        body = resp.json()
701        return bool(body.get("cancelled"))

Tell the node to stop a verification it is running.

⚠️ A miss is success, not a 404 to report. The cancel races the run's own finish, so "no such run" means the run already ended — which is what the operator asked for. The node answers {"cancelled": false} for that case rather than raising, and this returns it so a caller that wants to say "it had already finished" can.

def rescan_challenges(self) -> ctfy.core.challenge.ChallengeReloadResult:
703    def rescan_challenges(self) -> ChallengeReloadResult:
704        """Tell the node to drop its spec cache and re-scan challenges_dir.
705
706        Returns ``{total, added, removed}`` so the platform can report
707        the per-node outcome of a cluster-wide rescan."""
708        resp = self.request("POST", "/admin/rescan-challenges")
709        resp.raise_for_status()
710        return _parsed(ChallengeReloadResult, resp)

Tell the node to drop its spec cache and re-scan challenges_dir.

Returns {total, added, removed} so the platform can report the per-node outcome of a cluster-wide rescan.

def build_challenge( self, challenge_id: str, *, authored_revision: str = '', resource_budget: ctfy.core.resource_budget.ResourceBudget | None = None) -> ctfy.server.node_state.ChallengeBuildState:
714    def build_challenge(
715        self,
716        challenge_id: str,
717        *,
718        authored_revision: str = "",
719        resource_budget: ResourceBudget | None = None,
720    ) -> ChallengeBuildState:
721        """Ask the node to pre-build images for *challenge_id*.
722
723        Fires-and-returns: the node persists ``status="building"`` and
724        spawns a daemon thread; the body returned here is that initial
725        state row. Polling :meth:`get_build_state` is how the platform
726        learns when it lands on ``built`` / ``failed``. *authored_revision*
727        is #1835's: the tree the build must be of, fetched if not held.
728        """
729        params = {"authored_revision": authored_revision} if authored_revision else None
730        # #2504: an authored tree pre-builds under this budget, or not at all.
731        kwargs: dict[str, Any] = {}
732        if resource_budget is not None:
733            kwargs["json"] = {"resource_budget": resource_budget.model_dump()}
734        resp = self.request(
735            "POST", f"/admin/challenges/{challenge_id}/build", params=params, **kwargs
736        )
737        _raise_for_node_status(resp)
738        return _parsed(ChallengeBuildState, resp)

Ask the node to pre-build images for challenge_id.

Fires-and-returns: the node persists status="building" and spawns a daemon thread; the body returned here is that initial state row. Polling get_build_state() is how the platform learns when it lands on built / failed. authored_revision is #1835's: the tree the build must be of, fetched if not held.

def build_all_challenges( self, challenge_ids: list[str] | None = None, *, resource_budget: ctfy.core.resource_budget.ResourceBudget | None = None) -> ctfy.server.models.node.BuildAllQueued:
740    def build_all_challenges(
741        self,
742        challenge_ids: list[str] | None = None,
743        *,
744        resource_budget: ResourceBudget | None = None,
745    ) -> BuildAllQueued:
746        """Queue background pre-build on the node.
747
748        With ``challenge_ids=None`` the node builds every spec it knows.
749        With an explicit list (the platform scoping a bulk build to a
750        competition's challenge set) only those are built. Returns
751        ``{queued, skipped_built, skipped_in_progress}`` so the platform
752        can report per-node what got picked up. Sequential on the node
753        side — no fan-out across the corpus.
754        """
755        kwargs: dict[str, Any] = {}
756        body: dict[str, Any] = {}
757        if challenge_ids is not None:
758            body["challenge_ids"] = challenge_ids
759        if resource_budget is not None:
760            # #2504: what an authored id in the scope pre-builds under.
761            body["resource_budget"] = resource_budget.model_dump()
762        if body:
763            kwargs["json"] = body
764        resp = self.request("POST", "/admin/challenges/build-all", **kwargs)
765        _raise_for_node_status(resp)
766        return _parsed(BuildAllQueued, resp)

Queue background pre-build on the node.

With challenge_ids=None the node builds every spec it knows. With an explicit list (the platform scoping a bulk build to a competition's challenge set) only those are built. Returns {queued, skipped_built, skipped_in_progress} so the platform can report per-node what got picked up. Sequential on the node side — no fan-out across the corpus.

def build_push_challenge( self, challenge_id: str, *, push_ticket: str = '', deadline_s: int = 0, cleanup: bool = False, cache_max_bytes: int = 0, confine_network: bool = False, authored_revision: str = '', run_id: str = '', resource_budget: ctfy.core.resource_budget.ResourceBudget | None = None) -> ctfy.core.prebuild_tag.ChallengeFreezeResult:
768    def build_push_challenge(
769        self,
770        challenge_id: str,
771        *,
772        push_ticket: str = "",
773        deadline_s: int = 0,
774        cleanup: bool = False,
775        cache_max_bytes: int = 0,
776        confine_network: bool = False,
777        authored_revision: str = "",
778        run_id: str = "",
779        resource_budget: ResourceBudget | None = None,
780    ) -> ChallengeFreezeResult:
781        """Build one challenge's ``build:`` services and freeze them to the registry.
782
783        Layer 2 producer (P0). **Blocks** until the challenge is built +
784        pushed (or skipped as already frozen), unlike the fire-and-return
785        :meth:`build_challenge`. ⚠️ *deadline_s* bounds the node's whole
786        freeze (#3167, `ctfy.core.freeze_budget`; ``0`` sends the node's own
787        default), and the HTTP wait is derived from it —
788        `freeze_client_wait_s`, past the node's own answer — so the client
789        never abandons a freeze the node is still running. Sent once: a
790        ``ReadTimeout`` is a request the node has (#3162). Returns the per-service freeze result;
791        the platform's ``challenge_build_push`` task calls this one
792        challenge at a time. *push_ticket* is the platform's short-lived
793        registry credential the node's ``docker push`` authenticates with
794        — the node runs it in a container with no ambient login.
795        *cleanup* asks the node to remove what the build left and prune its
796        build cache to *cache_max_bytes* afterwards, and *confine_network*
797        keeps every ``RUN`` off the host's network (both the freeze pool, #1827).
798        *run_id* names this call for :meth:`cancel_build_push` and
799        :meth:`build_push_progress` while it is in flight (#1949).
800        """
801        resp = self.request(
802            "POST",
803            f"/admin/challenges/{challenge_id}/build-push",
804            json={
805                "push_ticket": push_ticket,
806                "cleanup": cleanup,
807                "cache_max_bytes": cache_max_bytes,
808                "confine_network": confine_network,
809                "authored_revision": authored_revision,
810                "run_id": run_id,
811                "deadline_s": deadline_s,
812                # #2504: the build limits an authored tree is frozen under;
813                # a node refuses one that arrives without them.
814                "resource_budget": (
815                    resource_budget.model_dump() if resource_budget is not None else None
816                ),
817            },
818            timeout=freeze_client_wait_s(deadline_s or freeze_deadline_s()),
819            retry_on=RETRY_UNSENT,
820        )
821        _raise_for_node_status(resp)
822        return _parsed(ChallengeFreezeResult, resp)

Build one challenge's build: services and freeze them to the registry.

Layer 2 producer (P0). Blocks until the challenge is built + pushed (or skipped as already frozen), unlike the fire-and-return build_challenge(). ⚠️ deadline_s bounds the node's whole freeze (#3167, ctfy.core.freeze_budget; 0 sends the node's own default), and the HTTP wait is derived from it — freeze_client_wait_s, past the node's own answer — so the client never abandons a freeze the node is still running. Sent once: a ReadTimeout is a request the node has (#3162). Returns the per-service freeze result; the platform's challenge_build_push task calls this one challenge at a time. push_ticket is the platform's short-lived registry credential the node's docker push authenticates with — the node runs it in a container with no ambient login. cleanup asks the node to remove what the build left and prune its build cache to cache_max_bytes afterwards, and confine_network keeps every RUN off the host's network (both the freeze pool, #1827). run_id names this call for cancel_build_push() and build_push_progress() while it is in flight (#1949).

def cancel_build_push(self, run_id: str) -> ctfy.server.models.node.BuildPushCancelled:
824    def cancel_build_push(self, run_id: str) -> BuildPushCancelled:
825        """Cancel the freeze :meth:`build_push_challenge` started under *run_id* (#1949).
826
827        Idempotent; ``found`` false means the node holds no such run.
828        """
829        resp = self.request("POST", "/admin/build-push/cancel", json={"run_id": run_id})
830        _raise_for_node_status(resp)
831        return _parsed(BuildPushCancelled, resp)

Cancel the freeze build_push_challenge() started under run_id (#1949).

Idempotent; found false means the node holds no such run.

def build_push_progress(self, run_id: str) -> ctfy.server.models.node.BuildPushProgress:
833    def build_push_progress(self, run_id: str) -> BuildPushProgress:
834        """What the freeze running under *run_id* is doing now (#1949)."""
835        resp = self.request("GET", f"/admin/build-push/{run_id}/progress")
836        _raise_for_node_status(resp)
837        return _parsed(BuildPushProgress, resp)

What the freeze running under run_id is doing now (#1949).

def get_disk_free(self) -> ctfy.server.models.node.NodeDiskFree:
839    def get_disk_free(self) -> NodeDiskFree:
840        """Free space on the node's data filesystem, read now (#1827).
841
842        Raises on any failure — including a node predating the route, which
843        answers 404 — and the freeze reads every failure as *cannot tell*,
844        which stops it.
845        """
846        resp = self.request("GET", "/admin/disk")
847        _raise_for_node_status(resp)
848        return _parsed(NodeDiskFree, resp)

Free space on the node's data filesystem, read now (#1827).

Raises on any failure — including a node predating the route, which answers 404 — and the freeze reads every failure as cannot tell, which stops it.

def get_build_state(self) -> ctfy.server.models.node.BuildStateRows:
850    def get_build_state(self) -> BuildStateRows:
851        """Fetch every per-challenge build-state row on this node.
852
853        Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``.
854        ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
855        ``failed``; ``unbuilt`` placeholders are synthesised for specs
856        the node has seen but never been asked to build.
857        """
858        resp = self.request("GET", "/admin/challenges/build-state")
859        _raise_for_node_status(resp)
860        return _parsed(BuildStateRows, resp)

Fetch every per-challenge build-state row on this node.

Returns {"rows": [{challenge_id, status, built_at, error}, …]}. status is one of unbuilt / building / built / failed; unbuilt placeholders are synthesised for specs the node has seen but never been asked to build.

def pull_challenge(self, challenge_id: str) -> ctfy.server.node_state.ChallengePullState:
862    def pull_challenge(self, challenge_id: str) -> ChallengePullState:
863        """Ask the node to pre-pull registry images for *challenge_id*.
864
865        Pull-side twin of :meth:`build_challenge`: the node persists
866        ``status="pulling"`` and spawns a daemon thread; poll
867        :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing.
868        """
869        resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull")
870        _raise_for_node_status(resp)
871        return _parsed(ChallengePullState, resp)

Ask the node to pre-pull registry images for challenge_id.

Pull-side twin of build_challenge(): the node persists status="pulling" and spawns a daemon thread; poll get_pull_state() for the pulled / failed landing.

def pull_all_challenges( self, challenge_ids: list[str] | None = None) -> ctfy.server.models.node.PullAllQueued:
873    def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> PullAllQueued:
874        """Queue background pre-pull on the node.
875
876        With ``challenge_ids=None`` the node pulls every spec it knows;
877        with an explicit list only those (the platform scoping to a
878        competition). Returns ``{queued, skipped_pulled,
879        skipped_in_progress}``.
880        """
881        kwargs: dict[str, Any] = {}
882        if challenge_ids is not None:
883            kwargs["json"] = {"challenge_ids": challenge_ids}
884        resp = self.request("POST", "/admin/challenges/pull-all", **kwargs)
885        _raise_for_node_status(resp)
886        return _parsed(PullAllQueued, resp)

Queue background pre-pull on the node.

With challenge_ids=None the node pulls every spec it knows; with an explicit list only those (the platform scoping to a competition). Returns {queued, skipped_pulled, skipped_in_progress}.

def get_pull_state(self) -> ctfy.server.models.node.PullStateRows:
888    def get_pull_state(self) -> PullStateRows:
889        """Fetch every per-challenge pull-state row on this node.
890
891        Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``.
892        ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
893        ``failed``; ``unpulled`` placeholders are synthesised for specs
894        the node has seen but never been asked to pull.
895        """
896        resp = self.request("GET", "/admin/challenges/pull-state")
897        _raise_for_node_status(resp)
898        return _parsed(PullStateRows, resp)

Fetch every per-challenge pull-state row on this node.

Returns {"rows": [{challenge_id, status, pulled_at, error}, …]}. status is one of unpulled / pulling / pulled / failed; unpulled placeholders are synthesised for specs the node has seen but never been asked to pull.

def get_status(self, instance_id: str) -> ctfy.server.models.node.InstanceStatusReport:
902    def get_status(self, instance_id: str) -> InstanceStatusReport:
903        resp = self.request("GET", f"/instances/{instance_id}/status")
904        resp.raise_for_status()
905        return _parsed(InstanceStatusReport, resp)
def list_instances(self) -> list[ctfy.server.models.node.NodeInstanceSummary]:
907    def list_instances(self) -> list[NodeInstanceSummary]:
908        """Every instance the node still has bookkeeping for.
909
910        Used by the platform's boot re-adoption pass to decide which
911        durable claims still have containers behind them. ``get_status``
912        answers the same question one id at a time; asking it per claim
913        would cost one round trip per instance to a node that may be
914        slow or down, on the path that gates startup.
915        """
916        resp = self.request("GET", "/instances")
917        resp.raise_for_status()
918        return _parsed(NodeInstanceList, resp).items

Every instance the node still has bookkeeping for.

Used by the platform's boot re-adoption pass to decide which durable claims still have containers behind them. get_status answers the same question one id at a time; asking it per claim would cost one round trip per instance to a node that may be slow or down, on the path that gates startup.

def check_health(self, instance_id: str) -> bool:
920    def check_health(self, instance_id: str) -> bool:
921        return self.check_health_verbose(instance_id).healthy
def check_health_verbose(self, instance_id: str) -> HealthReading:
923    def check_health_verbose(self, instance_id: str) -> HealthReading:
924        """Health plus whatever agent-deception telemetry the node read.
925
926        One call, because the platform already polls this endpoint for
927        every live instance every tick and a second round trip per
928        instance would be the whole cost of the feature. ``check_health``
929        stays a bool for the callers that only ever wanted one.
930
931        Never an error, whatever arrives: this rides the health poll,
932        and telemetry must not be able to mark a healthy instance
933        unhealthy.
934
935        ⚠️ **The two ways this can come back empty are different facts
936        and used to be one.** An older node sends no ``deception`` key
937        at all — it *did not say*, which is ``channel=None`` and
938        authorises nothing. A body this platform cannot parse is
939        something else: the node did say, and we could not read it. That
940        is ``unreadable``, the same value the reader gives for a missing
941        file, because it is the same fact — there is no usable channel
942        here — arriving through a different door.
943
944        The parse failure that matters is version skew in the other
945        direction: a **newer** node reporting an ``ObservationKind`` this
946        platform does not have. ``Observation.kind`` is typed, so one
947        unknown member fails the whole model rather than dropping one
948        row — which is the right refusal (the node's word is not ours to
949        edit) and exactly why it must not land as "reported nothing".
950        """
951        resp = self.request("GET", f"/instances/{instance_id}/health")
952        resp.raise_for_status()
953        body = resp.json()
954        raw = body.get("deception")
955        if not raw:
956            summary = DeceptionSummary()
957        else:
958            try:
959                summary = DeceptionSummary.model_validate(raw)
960            except ValidationError as exc:
961                log.info(
962                    "unreadable deception telemetry from node",
963                    instance_id=instance_id,
964                    error=tail(str(exc), limit=200),
965                )
966                summary = DeceptionSummary(channel="unreadable")
967        raw_contact = body.get("contact")
968        if not raw_contact:
969            contact = ContactReading()
970        else:
971            try:
972                contact = ContactReading.model_validate(raw_contact)
973            except ValidationError as exc:
974                log.info(
975                    "unreadable contact telemetry from node",
976                    instance_id=instance_id,
977                    error=tail(str(exc), limit=200),
978                )
979                contact = ContactReading(channel="unreadable")
980        raw_procs = body.get("new_processes")
981        # ⚠️ Shape-checked rather than trusted: an older node sends
982        # nothing and a newer one could send a shape this platform does
983        # not model, and neither may reach a reader as *a container full
984        # of intruders*. Anything unreadable is silence, which for a
985        # signal built to under-report is the honest reading.
986        procs: dict[str, list[str]] = {}
987        if isinstance(raw_procs, dict):
988            for container, names in raw_procs.items():
989                if isinstance(container, str) and isinstance(names, list):
990                    procs[container] = [str(n) for n in names]
991        return HealthReading(
992            healthy=bool(body.get("is_healthy")),
993            deception=summary,
994            contact=contact,
995            new_processes=procs,
996        )

Health plus whatever agent-deception telemetry the node read.

One call, because the platform already polls this endpoint for every live instance every tick and a second round trip per instance would be the whole cost of the feature. check_health stays a bool for the callers that only ever wanted one.

Never an error, whatever arrives: this rides the health poll, and telemetry must not be able to mark a healthy instance unhealthy.

⚠️ The two ways this can come back empty are different facts and used to be one. An older node sends no deception key at all — it did not say, which is channel=None and authorises nothing. A body this platform cannot parse is something else: the node did say, and we could not read it. That is unreadable, the same value the reader gives for a missing file, because it is the same fact — there is no usable channel here — arriving through a different door.

The parse failure that matters is version skew in the other direction: a newer node reporting an ObservationKind this platform does not have. Observation.kind is typed, so one unknown member fails the whole model rather than dropping one row — which is the right refusal (the node's word is not ours to edit) and exactly why it must not land as "reported nothing".

def node_health(self) -> dict[str, typing.Any]:
 998    def node_health(self) -> dict[str, Any]:
 999        """Liveness + ``{running, capacity}`` for heartbeat."""
1000        resp = self.request("GET", "/health")
1001        resp.raise_for_status()
1002        body: dict[str, Any] = resp.json()
1003        return body

Liveness + {running, capacity} for heartbeat.

def get_traffic_page( self, instance_id: str, *, cursor: str = '', limit: int = 0) -> ctfy.server.models.InstanceTrafficPage:
1007    def get_traffic_page(
1008        self, instance_id: str, *, cursor: str = "", limit: int = 0
1009    ) -> InstanceTrafficPage:
1010        """One page of mitmproxy flows; the capture lives on the node's FS.
1011
1012        ⚠️ **The one method that must not use ``_parsed``.** A node from
1013        before paging answers with a bare *list* — it sent the whole
1014        capture and understands no cursor — and a strict
1015        ``model_validate`` turns that supported skew into a raised
1016        request. So the body goes through ``coerce_page``, which already
1017        owns every decision about an unexpected shape, and the caller
1018        gets the model.
1019
1020        This is the same move ``coerce_page`` was written for, one layer
1021        down. Its docstring used to say the parsing belonged
1022        "platform-side … so a route and this client cannot come to
1023        disagree" — and there were *three* platform-side calls doing it.
1024        There is one call now, at the seam that knows the request
1025        cursor, which is what the fallback needs; the routes hold the
1026        type instead of re-deciding its shape.
1027
1028        ⚠️ **An empty ``cursor`` means "from the beginning".** Passing
1029        one on every poll re-reads the whole capture, which is the cost
1030        the paged read exists to remove — arriving through the caller
1031        rather than through the node.
1032        """
1033        params: dict[str, Any] = {}
1034        if cursor:
1035            params["cursor"] = cursor
1036        if limit > 0:
1037            params["limit"] = limit
1038        resp = self.request("GET", f"/instances/{instance_id}/traffic", params=params)
1039        resp.raise_for_status()
1040        page = coerce_page(resp.json(), cursor)
1041        return InstanceTrafficPage(
1042            flows=page.flows,
1043            cursor=page.cursor,
1044            truncated=page.truncated,
1045            capture=page.capture,
1046        )

One page of mitmproxy flows; the capture lives on the node's FS.

⚠️ The one method that must not use _parsed. A node from before paging answers with a bare list — it sent the whole capture and understands no cursor — and a strict model_validate turns that supported skew into a raised request. So the body goes through coerce_page, which already owns every decision about an unexpected shape, and the caller gets the model.

This is the same move coerce_page was written for, one layer down. Its docstring used to say the parsing belonged "platform-side … so a route and this client cannot come to disagree" — and there were three platform-side calls doing it. There is one call now, at the seam that knows the request cursor, which is what the fallback needs; the routes hold the type instead of re-deciding its shape.

⚠️ An empty cursor means "from the beginning". Passing one on every poll re-reads the whole capture, which is the cost the paged read exists to remove — arriving through the caller rather than through the node.

def get_traffic_witnesses( self, instance_id: str, *, cursor: str = '', limit: int = 0) -> ctfy.server.models.InstanceTrafficWitnesses:
1048    def get_traffic_witnesses(
1049        self, instance_id: str, *, cursor: str = "", limit: int = 0
1050    ) -> InstanceTrafficWitnesses:
1051        """Who spoke to this box since *cursor*, with no bodies moved.
1052
1053        §7.1's correlation reads one gamebox per team per round. Through
1054        :meth:`get_traffic_page` that is every flow, both bodies
1055        included (~100 kB apiece by ``CLAUDE.md``'s measurement),
1056        pulled across the wire to extract a handful of addresses; the
1057        node reduces instead and this carries the result.
1058
1059        ⚠️ **Strict parsing here, unlike its neighbour, and that is the
1060        difference between an old field and an old route.**
1061        ``get_traffic_page`` is lenient because a node from before
1062        paging answers the *same* path with a bare list, which is
1063        supported skew. This route did not exist on such a node at all,
1064        so the honest answer from one is a 404 — and a lenient parse of
1065        an error body would hand the round loop an empty witness set,
1066        which reads as *nobody was there*. The caller catches the
1067        request error and records the round as unknown.
1068        """
1069        params: dict[str, Any] = {}
1070        if cursor:
1071            params["cursor"] = cursor
1072        if limit > 0:
1073            params["limit"] = limit
1074        resp = self.request("GET", f"/instances/{instance_id}/traffic-witnesses", params=params)
1075        resp.raise_for_status()
1076        return _parsed(InstanceTrafficWitnesses, resp)

Who spoke to this box since cursor, with no bodies moved.

§7.1's correlation reads one gamebox per team per round. Through get_traffic_page() that is every flow, both bodies included (~100 kB apiece by CLAUDE.md's measurement), pulled across the wire to extract a handful of addresses; the node reduces instead and this carries the result.

⚠️ Strict parsing here, unlike its neighbour, and that is the difference between an old field and an old route. get_traffic_page is lenient because a node from before paging answers the same path with a bare list, which is supported skew. This route did not exist on such a node at all, so the honest answer from one is a 404 — and a lenient parse of an error body would hand the round loop an empty witness set, which reads as nobody was there. The caller catches the request error and records the round as unknown.

def list_containers(self, instance_id: str) -> list[ctfy.server.models.node.ComposeContainer]:
1078    def list_containers(self, instance_id: str) -> list[ComposeContainer]:
1079        """Enumerate every container in an instance's compose project.
1080
1081        Backs the admin-shell feature: the platform forwards a
1082        ``GET /admin/instances/{id}/containers`` to the assigned node,
1083        which returns one row per container (challenge services and
1084        platform-injected sidecars alike). The WebSocket reverse-proxy
1085        is opened separately and does not go through ``NodeClient``.
1086        """
1087        resp = self.request("GET", f"/instances/{instance_id}/containers")
1088        _raise_for_node_status(resp)
1089        return _parsed_list(ComposeContainer, resp)

Enumerate every container in an instance's compose project.

Backs the admin-shell feature: the platform forwards a GET /admin/instances/{id}/containers to the assigned node, which returns one row per container (challenge services and platform-injected sidecars alike). The WebSocket reverse-proxy is opened separately and does not go through NodeClient.

def run_checker( self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30) -> ctfy.server.models.node.CheckResult:
1091    def run_checker(
1092        self,
1093        instance_id: str,
1094        *,
1095        service: str,
1096        cmd: list[str],
1097        timeout_s: int = DEFAULT_JUDGE_TIMEOUT_S,
1098    ) -> CheckResult:
1099        """Exec an author-supplied checker in a trusted judge sidecar.
1100
1101        Backs the checker / exec-judge feature: the platform forwards a
1102        verify request to the assigned node, which runs ``cmd`` inside the
1103        named sidecar and returns
1104        ``{exit_code, stdout, stderr, timed_out, error}``.
1105
1106        ⚠️ *timeout_s* is the author's, and the node clamps it (#3169): the
1107        clamped budget is what is sent and what the wait is derived from
1108        (`core.judge_budget.platform_wait_s`), so ``0`` no longer waits 10 s
1109        on a node running 30, and ``600`` no longer holds a threadpool slot
1110        for 610 s on a node that stops at 120. Sent once (``RETRY_UNSENT``).
1111        """
1112        exec_s = judge_exec_s(timeout_s)
1113        resp = self.request(
1114            "POST",
1115            f"/instances/{instance_id}/check",
1116            json={"service": service, "cmd": cmd, "timeout_s": exec_s},
1117            timeout=judge_platform_wait_s(exec_s),
1118            retry_on=RETRY_UNSENT,
1119        )
1120        _raise_for_node_status(resp)
1121        return _parsed(CheckResult, resp)

Exec an author-supplied checker in a trusted judge sidecar.

Backs the checker / exec-judge feature: the platform forwards a verify request to the assigned node, which runs cmd inside the named sidecar and returns {exit_code, stdout, stderr, timed_out, error}.

⚠️ timeout_s is the author's, and the node clamps it (#3169): the clamped budget is what is sent and what the wait is derived from (core.judge_budget.platform_wait_s), so 0 no longer waits 10 s on a node running 30, and 600 no longer holds a threadpool slot for 610 s on a node that stops at 120. Sent once (RETRY_UNSENT).

def run_harness( self, *, image: str, run_id: str = '', env: dict[str, str], timeout_s: int = 1800, untrusted: bool = False, network: str = '', own_network: bool = False, match_network: str = '', match_address: str = '', brief: dict[str, typing.Any] | None = None, target_instance: str = '', target_aliases: dict[str, str] | None = None, target_access: str = '', vpn_config: str = '', estate_zones: Sequence[str] = (), target_endpoints: Sequence[str] = (), target_hosts: Mapping[str, str] | None = None, target_datagram_endpoints: Sequence[str] = (), pull_ticket: str = '', registry_auth: ctfy.server.models.node.HarnessRegistryAuth | None = None, limits: ctfy.server.models.node.HarnessLimits | None = None, admission_profile: bool = False, egress_allow: list[str] | None = None, internet: bool = True, rootfs_budget_bytes: int = 0, unpack_budget_bytes: int = 0, disk_low_watermark_bytes: int = 0, ingress: bool = True, local_ingress: bool = False, platform_image: bool = False, image_policy: ctfy.server.models.node.HarnessImagePolicy | None, attachments: dict[str, str] | None = None, keep_work: bool = False, resume_work: bool = False, exploit: dict[str, typing.Any] | None = None) -> ctfy.server.models.node.HarnessRun:
1123    def run_harness(
1124        self,
1125        *,
1126        image: str,
1127        run_id: str = "",
1128        env: dict[str, str],
1129        timeout_s: int = 1800,
1130        untrusted: bool = False,
1131        network: str = "",
1132        own_network: bool = False,
1133        match_network: str = "",
1134        match_address: str = "",
1135        brief: dict[str, Any] | None = None,
1136        target_instance: str = "",
1137        target_aliases: dict[str, str] | None = None,
1138        target_access: str = "",
1139        vpn_config: str = "",
1140        estate_zones: Sequence[str] = (),
1141        target_endpoints: Sequence[str] = (),
1142        target_hosts: Mapping[str, str] | None = None,
1143        target_datagram_endpoints: Sequence[str] = (),
1144        pull_ticket: str = "",
1145        registry_auth: HarnessRegistryAuth | None = None,
1146        limits: HarnessLimits | None = None,
1147        admission_profile: bool = False,
1148        egress_allow: list[str] | None = None,
1149        internet: bool = True,
1150        rootfs_budget_bytes: int = 0,
1151        unpack_budget_bytes: int = 0,
1152        disk_low_watermark_bytes: int = 0,
1153        ingress: bool = True,
1154        local_ingress: bool = False,
1155        platform_image: bool = False,
1156        image_policy: HarnessImagePolicy | None,
1157        attachments: dict[str, str] | None = None,
1158        keep_work: bool = False,
1159        resume_work: bool = False,
1160        exploit: dict[str, Any] | None = None,
1161    ) -> HarnessRun:
1162        """Run a one-shot evaluation-harness container on the node.
1163
1164        ``exploit`` (#2819 W2) runs a smoke's reference exploit in the fence
1165        instead of an agent (``HarnessExploit``'s fields); sent only when set.
1166
1167        ``image_policy`` (#2608) is **required, with no default**: every
1168        caller says whether this image is a user's (the registry rules the
1169        node re-checks before pulling) or the platform's own (``None``). An
1170        untrusted run without one is refused by the node.
1171
1172        The node ``docker run``s ``image`` with ``env`` injected, waits for it
1173        to exit (bounded by ``timeout_s``), and returns
1174        ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as
1175        :meth:`run_checker`. The harness writes its JSON rollup to stdout. The
1176        per-call HTTP timeout sits above the node's run budget so the client
1177        doesn't abandon the request before the (long-running) harness returns.
1178
1179        ``untrusted`` runs the image under the node's isolation profile —
1180        off host networking, bounded resources, no capabilities — for an
1181        image the platform did not build. It requires a network: the
1182        node answers 400 without one, because "isolate this" and "I will
1183        not say where the target lives" do not compose.
1184
1185        Any of ``network`` (the target instance's own, when the caller
1186        knows it), ``own_network`` or ``target_instance`` satisfies that.
1187        ⚠️ Most eval dispatches need the second: there the harness
1188        *starts* the target partway through its own run, so at ``docker
1189        run`` time no target network exists and there is no name to
1190        send. The node creates a fresh bridge and removes it afterwards.
1191
1192        ``target_instance`` reverses that ordering, and is what an
1193        `engagement` uses: the platform starts the estate first and names
1194        it here, and the node wires its published containers to the
1195        agent's bridge. ⚠️ An **id**, never a network name — the name is
1196        a fact only the node's daemon can read, and ``docker run``
1197        *creates* whatever string it is handed, so a platform guessing
1198        one starts a run that reaches nothing.
1199        """
1200        resp = self.request(
1201            "POST",
1202            "/harness/run",
1203            json={
1204                "image": image,
1205                "env": env,
1206                "timeout_s": timeout_s,
1207                "untrusted": untrusted,
1208                "network": network,
1209                "own_network": own_network,
1210                "match_network": match_network,
1211                # ⚠️ **The platform-assigned arena address.** The node
1212                # connects the agent to the match network with this
1213                # ``--ip``, so the address is known before the round loop
1214                # asks (§7.1). Empty means "let docker's IPAM pick" — an
1215                # older platform's behaviour, and what keeps a node reading
1216                # this field unchanged for every non-arena dispatch.
1217                "match_address": match_address,
1218                # ⚠️ Sent even when empty, so a node reading it gets the
1219                # same shape either way; the node's default is what makes
1220                # an older platform's dispatch unchanged.
1221                "pull_ticket": pull_ticket,
1222                # #3475: the owner's registry login, sent **only when set**,
1223                # so a node that predates the field sees the body it always did.
1224                **(
1225                    {"registry_auth": registry_auth.model_dump()}
1226                    if registry_auth is not None
1227                    else {}
1228                ),
1229                # ⚠️ ``None`` leaves the node's own defaults in force, which is
1230                # what a dispatch said before the ceilings were settings.
1231                "limits": limits.model_dump() if limits is not None else None,
1232                # ⚠️ Same rule, and empty is a real answer here too: a
1233                # dispatch with no run row (the conformance probe, a dry
1234                # run) has nothing to label, and a label with an empty
1235                # value would make every one of them answer one query.
1236                "run_id": run_id,
1237                # ⚠️ Sent as the already-built manifest. The platform is
1238                # the side that knows the challenge, the competition and
1239                # the run mode; a node deriving its own would be a
1240                # second implementation of a published contract.
1241                "brief": brief,
1242                "target_instance": target_instance,
1243                # ⚠️ **The names the brief published, so they resolve on
1244                # the agent's bridge too.** An estate's hosts answer to a
1245                # per-instance FQDN minted by its own resolver; joined to
1246                # a new network a container answers only to its compose
1247                # *service* name. Sending the mapping is what keeps the
1248                # address the agent was told about true under either
1249                # placement — without it the agent is handed a hostname
1250                # that does not resolve, reaches nothing, and reports an
1251                # empty result, which reads as a model that gave up.
1252                #
1253                # ⚠️ The platform's to send: it computed the surface. A
1254                # node re-deriving it from the rendered compose would be
1255                # a second implementation of the address the brief has
1256                # already promised.
1257                "target_aliases": target_aliases or {},
1258                # #2550: ``{name: base64}`` for ``/ctfy/work/attachments/``.
1259                # ⚠️ Sent only when there are some, so every other dispatch
1260                # is byte-identical to before.
1261                **({"attachments": attachments} if attachments else {}),
1262                # #2550 restarts: sent only when asked for, like the files.
1263                **({"keep_work": True} if keep_work else {}),
1264                **({"resume_work": True} if resume_work else {}),
1265                # #2861 slice B: the target access the platform read off the
1266                # target's node. ⚠️ Sent only when there is one, so every
1267                # other dispatch is byte-identical to before; and only to a
1268                # node whose ``capabilities().harness_access`` said it reads
1269                # them, or to the target's own node (where an older node's
1270                # local read is still right).
1271                **(
1272                    {
1273                        "target_access": target_access,
1274                        "vpn_config": vpn_config,
1275                        "estate_zones": list(estate_zones),
1276                        "target_endpoints": list(target_endpoints),
1277                    }
1278                    if target_access
1279                    else {}
1280                ),
1281                # #3020: names → the target node's ingress address, for a
1282                # published target handed over the agent-ingress tunnel. Sent
1283                # only when there are some, and only to a node whose
1284                # ``capabilities().harness_ingress_access`` said it reads them.
1285                **({"target_hosts": dict(target_hosts)} if target_hosts else {}),
1286                # #3446: the published endpoints that are UDP, which the node
1287                # never TCP-probes. Sent only when there are some.
1288                **(
1289                    {"target_datagram_endpoints": list(target_datagram_endpoints)}
1290                    if target_datagram_endpoints
1291                    else {}
1292                ),
1293                # #2356: ⚠️ sent only when asked for, so a dispatch that does
1294                # not ask is byte-identical to an older platform's — and an
1295                # older node, which would ignore the fields, is never sent
1296                # them: the caller checks ``capabilities().harness_admission``.
1297                **(
1298                    {
1299                        "admission_profile": True,
1300                        "egress_allow": list(egress_allow or []),
1301                        "internet": internet,
1302                        "rootfs_budget_bytes": rootfs_budget_bytes,
1303                        "unpack_budget_bytes": unpack_budget_bytes,
1304                        "disk_low_watermark_bytes": disk_low_watermark_bytes,
1305                        "ingress": ingress,
1306                        # #2819 W1, F2: sent only when asked for.
1307                        **({"local_ingress": True} if local_ingress else {}),
1308                    }
1309                    if admission_profile
1310                    else {}
1311                ),
1312                "image_policy": image_policy.model_dump() if image_policy is not None else None,
1313                # #2608 N4: only for a row with no owner; see the node's request.
1314                **({"platform_image": True} if platform_image else {}),
1315                **({"exploit": exploit} if exploit is not None else {}),
1316            },
1317            timeout=(
1318                timeout_s
1319                # #2819 W2: the exploit's `solution/Dockerfile` builds first.
1320                + (SOLUTION_BUILD_TIMEOUT_S if exploit is not None else 0)
1321                + IMAGE_PULL_TIMEOUT_S
1322                + HARNESS_IMAGE_INSPECT_TIMEOUT_S
1323                + HARNESS_CREATE_TIMEOUT_S
1324                + 30
1325            ),
1326            retry_on=RETRY_UNSENT,
1327        )
1328        _raise_for_node_status(resp)
1329        return _parsed(HarnessRun, resp)

Run a one-shot evaluation-harness container on the node.

exploit (#2819 W2) runs a smoke's reference exploit in the fence instead of an agent (HarnessExploit's fields); sent only when set.

image_policy (#2608) is required, with no default: every caller says whether this image is a user's (the registry rules the node re-checks before pulling) or the platform's own (None). An untrusted run without one is refused by the node.

The node docker runs image with env injected, waits for it to exit (bounded by timeout_s), and returns {exit_code, stdout, stderr, timed_out, error} — the same shape as run_checker(). The harness writes its JSON rollup to stdout. The per-call HTTP timeout sits above the node's run budget so the client doesn't abandon the request before the (long-running) harness returns.

untrusted runs the image under the node's isolation profile — off host networking, bounded resources, no capabilities — for an image the platform did not build. It requires a network: the node answers 400 without one, because "isolate this" and "I will not say where the target lives" do not compose.

Any of network (the target instance's own, when the caller knows it), own_network or target_instance satisfies that. ⚠️ Most eval dispatches need the second: there the harness starts the target partway through its own run, so at docker run time no target network exists and there is no name to send. The node creates a fresh bridge and removes it afterwards.

target_instance reverses that ordering, and is what an engagement uses: the platform starts the estate first and names it here, and the node wires its published containers to the agent's bridge. ⚠️ An id, never a network name — the name is a fact only the node's daemon can read, and docker run creates whatever string it is handed, so a platform guessing one starts a run that reaches nothing.

def cancel_harness(self, *, run_id: str) -> int:
1331    def cancel_harness(self, *, run_id: str) -> int:
1332        """Stop whatever this node is running for ``run_id``; returns how many.
1333
1334        Until this existed a dispatched container was reclaimed by
1335        **nothing but its own timeout** — ``_harness_timeout``'s docstring
1336        says exactly that — so an operator pressing cancel on a
1337        match-long run was asking for something the platform had no way
1338        to deliver.
1339
1340        ⚠️ **Zero is an ordinary answer, not a failure.** The run may have
1341        finished on its own, been dispatched to a different node, or been
1342        lost to a node restart. A caller that read zero as an error would
1343        report a broken platform every time a cancel arrived a second
1344        late.
1345        """
1346        resp = self.request("POST", "/harness/cancel", json={"run_id": run_id})
1347        body = resp.json()
1348        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0

Stop whatever this node is running for run_id; returns how many.

Until this existed a dispatched container was reclaimed by nothing but its own timeout — _harness_timeout's docstring says exactly that — so an operator pressing cancel on a match-long run was asking for something the platform had no way to deliver.

⚠️ Zero is an ordinary answer, not a failure. The run may have finished on its own, been dispatched to a different node, or been lost to a node restart. A caller that read zero as an error would report a broken platform every time a cancel arrived a second late.

def verify_patch( self, challenge_id: str, files: dict[str, bytes], *, deadline_s: int) -> ctfy.server.models.node.PatchVerifyResult:
1350    def verify_patch(
1351        self, challenge_id: str, files: dict[str, bytes], *, deadline_s: int
1352    ) -> PatchVerifyResult:
1353        """Build a submitted patch on the node and return its verdict.
1354
1355        ``files`` maps a challenge-relative path to its new bytes; base64
1356        is applied here because a patch target may legitimately be
1357        binary. Returns ``{verdict, detail, applied, steps, error}``.
1358
1359        **An empty ``verdict`` with a populated ``error`` is not a
1360        judgement** — it means the node refused the submission, the
1361        verifier crashed, or the run's deadline ran out. The caller must
1362        retry or retire the lease rather than write a score, since failing
1363        to judge a patch is not the same as judging it unfixed.
1364
1365        ⚠️ *deadline_s* bounds the node's whole run (#3164,
1366        `ctfy.core.patch_budget`), and the HTTP wait is derived from it —
1367        `patch_client_wait_s`, past the node's own answer — so the client
1368        never abandons a request the node is still working on. Sent once:
1369        a ``ReadTimeout`` is a request the node has (#3162).
1370        """
1371        resp = self.request(
1372            "POST",
1373            "/patches/verify",
1374            json={
1375                "challenge_id": challenge_id,
1376                "files": {
1377                    path: base64.b64encode(content).decode() for path, content in files.items()
1378                },
1379                "deadline_s": deadline_s,
1380            },
1381            timeout=patch_client_wait_s(deadline_s),
1382            retry_on=RETRY_UNSENT,
1383        )
1384        _raise_for_node_status(resp)
1385        return _parsed(PatchVerifyResult, resp)

Build a submitted patch on the node and return its verdict.

files maps a challenge-relative path to its new bytes; base64 is applied here because a patch target may legitimately be binary. Returns {verdict, detail, applied, steps, error}.

An empty verdict with a populated error is not a judgement — it means the node refused the submission, the verifier crashed, or the run's deadline ran out. The caller must retry or retire the lease rather than write a score, since failing to judge a patch is not the same as judging it unfixed.

⚠️ deadline_s bounds the node's whole run (#3164, ctfy.core.patch_budget), and the HTTP wait is derived from it — patch_client_wait_s, past the node's own answer — so the client never abandons a request the node is still working on. Sent once: a ReadTimeout is a request the node has (#3162).

def collect_patch_files( self, instance_id: str, service: str, paths: list[str]) -> dict[str, bytes]:
1387    def collect_patch_files(
1388        self, instance_id: str, service: str, paths: list[str]
1389    ) -> dict[str, bytes]:
1390        """Read a player's SSH edits back out of their running box.
1391
1392        ``paths`` are absolute in-container paths the *platform* derived
1393        from the pristine source tree. Returns only what could be read:
1394        a path missing from the reply means the player did not change it,
1395        and the caller falls back to the shipped file rather than
1396        recording a deletion they never made.
1397        """
1398        resp = self.request(
1399            "POST",
1400            f"/instances/{instance_id}/patch-collect",
1401            json={"service": service, "paths": paths},
1402        )
1403        _raise_for_node_status(resp)
1404        body: dict[str, str] = resp.json()
1405        return {path: base64.b64decode(blob) for path, blob in body.items()}

Read a player's SSH edits back out of their running box.

paths are absolute in-container paths the platform derived from the pristine source tree. Returns only what could be read: a path missing from the reply means the player did not change it, and the caller falls back to the shipped file rather than recording a deletion they never made.

def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
1407    def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
1408        """Rotate a round's flags into every box of this node, in one call.
1409
1410        ⚠️ **One call for the whole node, never one per box.** §5.2's
1411        capacity red line is 1000 teams x 3 services injected inside
1412        120 s; per-box that is 3000 round trips, batched it is about
1413        fourteen. The signature is the enforcement — a caller cannot
1414        accidentally loop.
1415
1416        Returns ``{instance_id: ""}`` for the boxes written and a reason
1417        for the ones that were not, because one team's unreachable box
1418        must not cost every other team on the node its rotation. An
1419        unrotated box keeps serving last round's flag, which anyone
1420        holding the old value can replay.
1421        """
1422        resp = self.request("POST", "/awd/answers", json={"boxes": boxes})
1423        _raise_for_node_status(resp)
1424        result: dict[str, str] = resp.json()
1425        return result

Rotate a round's flags into every box of this node, in one call.

⚠️ One call for the whole node, never one per box. §5.2's capacity red line is 1000 teams x 3 services injected inside 120 s; per-box that is 3000 round trips, batched it is about fourteen. The signature is the enforcement — a caller cannot accidentally loop.

Returns {instance_id: ""} for the boxes written and a reason for the ones that were not, because one team's unreachable box must not cost every other team on the node its rotation. An unrotated box keeps serving last round's flag, which anyone holding the old value can replay.

def probe_awd_service( self, *, instance_id: str, service_id: str, tick: int, flag: str, previous_flag: str = '', previous_flag_id: str = '') -> dict[str, str]:
1427    def probe_awd_service(
1428        self,
1429        *,
1430        instance_id: str,
1431        service_id: str,
1432        tick: int,
1433        flag: str,
1434        previous_flag: str = "",
1435        previous_flag_id: str = "",
1436    ) -> dict[str, str]:
1437        """Run one round's SLA probes against one box, from outside it.
1438
1439        The flags travel *to* the node: a checker that sourced its
1440        expected value from the box it is checking would pass on any box
1441        that returns whatever it was handed.
1442        """
1443        resp = self.request(
1444            "POST",
1445            "/awd/probe",
1446            json={
1447                "instance_id": instance_id,
1448                "service_id": service_id,
1449                "tick": tick,
1450                "flag": flag,
1451                "previous_flag": previous_flag,
1452                "previous_flag_id": previous_flag_id,
1453            },
1454        )
1455        _raise_for_node_status(resp)
1456        result: dict[str, str] = resp.json()
1457        return result

Run one round's SLA probes against one box, from outside it.

The flags travel to the node: a checker that sourced its expected value from the box it is checking would pass on any box that returns whatever it was handed.

def attack_awd_box( self, *, instance_id: str, tick: int, timeout_s: int = 60, agent_image: str = '', counter_flag: str = '', forensic_marker: str = '', llm_api_key: str = '') -> ctfy.server.models.node.AwdAttackResult:
1459    def attack_awd_box(
1460        self,
1461        *,
1462        instance_id: str,
1463        tick: int,
1464        timeout_s: int = 60,
1465        agent_image: str = "",
1466        counter_flag: str = "",
1467        forensic_marker: str = "",
1468        llm_api_key: str = "",
1469    ) -> AwdAttackResult:
1470        """Run this box's own reference exploit against it, once.
1471
1472        Per **box**, not per service: one `run_exploit` discovers every
1473        answer the challenge declares and prints them all.
1474
1475        ``timeout_s`` is sent because the platform is what knows how long
1476        a round is; the node clamps it, because a container held open for
1477        an hour is the node's problem whoever asked for it.
1478
1479        ``forensic_marker`` is §4.3.1's, carried only by the agent
1480        branch: `build_attack_argv` passes no environment at all, so a
1481        marker on the reference exploit would be the one value that
1482        voids `EXPLOIT007`'s guarantee.
1483
1484        ``agent_image`` + ``counter_flag`` switch the node to §4.2b's
1485        agent attacker. ⚠️ **Both or neither** — the node refuses a half
1486        request rather than falling back to the reference exploit, which
1487        would run a different format than the one configured while the
1488        board looked entirely normal.
1489        """
1490        resp = self.request(
1491            "POST",
1492            "/awd/attack",
1493            json={
1494                "instance_id": instance_id,
1495                "tick": tick,
1496                "timeout_s": timeout_s,
1497                "agent_image": agent_image,
1498                "counter_flag": counter_flag,
1499                "forensic_marker": forensic_marker,
1500                "llm_api_key": llm_api_key,
1501            },
1502        )
1503        _raise_for_node_status(resp)
1504        return _parsed(AwdAttackResult, resp)

Run this box's own reference exploit against it, once.

Per box, not per service: one run_exploit discovers every answer the challenge declares and prints them all.

timeout_s is sent because the platform is what knows how long a round is; the node clamps it, because a container held open for an hour is the node's problem whoever asked for it.

forensic_marker is §4.3.1's, carried only by the agent branch: build_attack_argv passes no environment at all, so a marker on the reference exploit would be the one value that voids EXPLOIT007's guarantee.

agent_image + counter_flag switch the node to §4.2b's agent attacker. ⚠️ Both or neither — the node refuses a half request rather than falling back to the reference exploit, which would run a different format than the one configured while the board looked entirely normal.

def get_container_logs(self, instance_id: str) -> str:
1506    def get_container_logs(self, instance_id: str) -> str:
1507        """Fetch combined container stdout/stderr for archival."""
1508        resp = self.request("GET", f"/instances/{instance_id}/container-logs")
1509        resp.raise_for_status()
1510        return str(resp.json().get("logs") or "")

Fetch combined container stdout/stderr for archival.

def get_pcap(self, instance_id: str) -> PcapCapture:
1512    def get_pcap(self, instance_id: str) -> PcapCapture:
1513        """Fetch the merged tcpdump capture for *instance_id*.
1514
1515        ``data`` is empty when no capture exists (sidecar disabled,
1516        instance never reached the running state, etc.) so callers can
1517        persist conditionally without try/except.
1518
1519        ⚠️ **It also says what the per-instance cap already dropped**
1520        (#1714), off the node's response headers, and ``None`` when the
1521        node did not say — an older node, which is *not reported*, never
1522        a clean capture.
1523        """
1524        resp = self.request("GET", f"/instances/{instance_id}/pcap")
1525        if resp.status_code == 404:
1526            return PcapCapture(data=b"")
1527        resp.raise_for_status()
1528        return PcapCapture(
1529            data=resp.content,
1530            dropped_bytes=_header_int(resp, CAPTURE_DROPPED_BYTES_HEADER),
1531            dropped_segments=_header_int(resp, CAPTURE_DROPPED_SEGMENTS_HEADER),
1532        )

Fetch the merged tcpdump capture for instance_id.

data is empty when no capture exists (sidecar disabled, instance never reached the running state, etc.) so callers can persist conditionally without try/except.

⚠️ It also says what the per-instance cap already dropped (#1714), off the node's response headers, and None when the node did not say — an older node, which is not reported, never a clean capture.

def get_harness_artifact(self, artifact_id: str) -> bytes:
1534    def get_harness_artifact(self, artifact_id: str) -> bytes:
1535        """One finished run's collected archive, or ``b""``.
1536
1537        ⚠️ **Empty rather than an exception for a 404**, following
1538        :meth:`get_pcap`. The node reaps uncollected archives, and a
1539        platform that comes back after a restart is exactly the caller
1540        that meets one that is gone — a state its own record already
1541        describes, not a transport failure.
1542        """
1543        resp = self.request("GET", f"/harness/artifacts/{artifact_id}")
1544        if resp.status_code == 404:
1545            return b""
1546        resp.raise_for_status()
1547        return resp.content

One finished run's collected archive, or b"".

⚠️ Empty rather than an exception for a 404, following get_pcap(). The node reaps uncollected archives, and a platform that comes back after a restart is exactly the caller that meets one that is gone — a state its own record already describes, not a transport failure.

def delete_harness_artifact(self, artifact_id: str) -> bool:
1549    def delete_harness_artifact(self, artifact_id: str) -> bool:
1550        """Tell the node the archive has been stored and can go.
1551
1552        ⚠️ **Never raises on a transport failure**, because it is called
1553        after the bytes are safely in object storage: turning a cleanup
1554        error into the run's error would replace a success with a
1555        janitorial complaint. The node's reaper collects whatever this
1556        misses.
1557        """
1558        try:
1559            resp = self.request("DELETE", f"/harness/artifacts/{artifact_id}")
1560        except Exception:
1561            return False
1562        return resp.status_code == 200

Tell the node the archive has been stored and can go.

⚠️ Never raises on a transport failure, because it is called after the bytes are safely in object storage: turning a cleanup error into the run's error would replace a success with a janitorial complaint. The node's reaper collects whatever this misses.

def get_agent_runtime(self, instance_id: str) -> ctfy.server.models.node.AgentRuntime:
1564    def get_agent_runtime(self, instance_id: str) -> AgentRuntime:
1565        """Provider-agnostic runtime hints for attaching an agent (proxy URL,
1566        CA PEM, optional Docker-specific names). Replaces the old
1567        sandbox-network shape."""
1568        resp = self.request("GET", f"/instances/{instance_id}/agent-runtime")
1569        resp.raise_for_status()
1570        return _parsed(AgentRuntime, resp)

Provider-agnostic runtime hints for attaching an agent (proxy URL, CA PEM, optional Docker-specific names). Replaces the old sandbox-network shape.

def list_instance_attachments(self, instance_id: str) -> ctfy.server.models.AttachmentList:
1574    def list_instance_attachments(self, instance_id: str) -> AttachmentList:
1575        """List per-instance attachments rendered into the node's workdir.
1576
1577        Backs the platform's per-instance attachment endpoint, which
1578        surfaces the team-specific rendered file listing.
1579
1580        ⚠️ This used to hand back the raw dict, under a docstring saying
1581        "caller projects through :class:`AttachmentList` for typing" —
1582        the node's route already declared that model, so the type existed
1583        and this method's signature was the one place it was dropped.
1584        Projecting *here* means there is one place a malformed answer is
1585        caught rather than one per caller."""
1586        resp = self.request("GET", f"/instances/{instance_id}/attachments")
1587        resp.raise_for_status()
1588        return _parsed(AttachmentList, resp)

List per-instance attachments rendered into the node's workdir.

Backs the platform's per-instance attachment endpoint, which surfaces the team-specific rendered file listing.

⚠️ This used to hand back the raw dict, under a docstring saying "caller projects through AttachmentList for typing" — the node's route already declared that model, so the type existed and this method's signature was the one place it was dropped. Projecting here means there is one place a malformed answer is caught rather than one per caller.

def get_vpn_config(self, instance_id: str) -> bytes:
1590    def get_vpn_config(self, instance_id: str) -> bytes:
1591        """Fetch the per-instance VPN client config from the node.
1592
1593        The node reads the backend's client config out of the
1594        instance's VPN service container (generated on first boot by
1595        the base image's bootstrap) and rewrites its endpoint to the
1596        node's public host:port. Returns the body verbatim — the
1597        platform-side route adds the ``Content-Disposition`` header
1598        before re-emitting to the player, because only it knows the
1599        backend and therefore the filename the player needs.
1600
1601        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1602        proxy route catches 404 and re-emits to the player, anything
1603        else is treated as a 502.
1604        """
1605        resp = self.request("GET", f"/instances/{instance_id}/vpn-config")
1606        resp.raise_for_status()
1607        return resp.content

Fetch the per-instance VPN client config from the node.

The node reads the backend's client config out of the instance's VPN service container (generated on first boot by the base image's bootstrap) and rewrites its endpoint to the node's public host:port. Returns the body verbatim — the platform-side route adds the Content-Disposition header before re-emitting to the player, because only it knows the backend and therefore the filename the player needs.

Raises httpx.HTTPStatusError on non-2xx — the platform's proxy route catches 404 and re-emits to the player, anything else is treated as a 502.

def get_vpn_access(self, instance_id: str) -> tuple[bytes, tuple[str, ...] | None]:
1609    def get_vpn_access(self, instance_id: str) -> tuple[bytes, tuple[str, ...] | None]:
1610        """The player's VPN config plus the estate's resolver zones (#2861 B).
1611
1612        The config is :meth:`get_vpn_config`'s, byte for byte; the zones ride
1613        a response header the node adds only when asked. ``None`` for the
1614        zones is a node that did not say (older, or docker unreadable there)
1615        — never "no zones". Raises like :meth:`get_vpn_config`.
1616        """
1617        resp = self.request(
1618            "GET", f"/instances/{instance_id}/vpn-config", params={"estate_zones": "true"}
1619        )
1620        resp.raise_for_status()
1621        raw = resp.headers.get("X-Ctfy-Estate-Zones")
1622        return resp.content, (tuple(raw.split()) if raw is not None else None)

The player's VPN config plus the estate's resolver zones (#2861 B).

The config is get_vpn_config()'s, byte for byte; the zones ride a response header the node adds only when asked. None for the zones is a node that did not say (older, or docker unreadable there) — never "no zones". Raises like get_vpn_config().

def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
1624    def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
1625        """Download one per-instance attachment as ``(bytes, content_type)``.
1626
1627        Buffers the whole body — attachments are bounded (typical case
1628        is a 10 KB binary or a small text file). For huge captures the
1629        caller should hand the player a CDN URL instead.
1630
1631        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1632        proxy route catches 404 and re-emits to the player, anything
1633        else is a 502. Invalid names or instance keys raise
1634        ``InvalidAttachmentName`` before any request is sent."""
1635        # The instance is a single route key, never part of the file name.
1636        if component_refusal(instance_id) is not None:
1637            raise InvalidAttachmentName("Invalid attachment instance")
1638        resp = self.request(
1639            "GET",
1640            f"/instances/{quote(instance_id, safe='')}/attachments/{attachment_url_path(filename)}",
1641        )
1642        resp.raise_for_status()
1643        return resp.content, resp.headers.get("content-type", "application/octet-stream")

Download one per-instance attachment as (bytes, content_type).

Buffers the whole body — attachments are bounded (typical case is a 10 KB binary or a small text file). For huge captures the caller should hand the player a CDN URL instead.

Raises httpx.HTTPStatusError on non-2xx — the platform's proxy route catches 404 and re-emits to the player, anything else is a 502. Invalid names or instance keys raise InvalidAttachmentName before any request is sent.