ctfy.sdk

Python SDK for the ctfy platform.

Drives the platform's /api/v1/* REST API from Python with typed Pydantic returns and auto-retry on transient errors. Two clients, split by audience — the same line the ctfy / ctfy-admin CLI binaries draw:

  • ~ctfy.sdk.client.PlatformClient — the player client. This is what agents and players want. Methods are grouped into resource namespaces (client.teams, client.instances, …) plus a per-competition handle via client.competition(id).
  • ~ctfy.sdk.admin.AdminClient — the operator client (/api/v1/admin/* and fleet management). Reached via client.admin or AdminClient.connect(). Kept separate so the player reference never surfaces admin endpoints they can't call.

Install

The full ctfy package pulls in the server too. For an agent / harness that only needs the client, use the client extra::

pip install "ctfy[client]"

Authenticate

Sign in to the platform in the browser, then go to Settings → API tokens and mint a fine-grained token (prefix pf_). These tokens launch instances and submit answers on the team's behalf but cannot mint more tokens, re-link OAuth providers, or change team membership — so a leaked token never costs you the account.

Export it as CTFY_TOKEN for the CLI / MCP server, or pass it directly to PlatformClient.

Quick start

End-to-end: discover a competition, register, list its challenges, launch one, read the attack surface, submit a captured flag::

from ctfy.sdk import PlatformClient

client = PlatformClient("https://ctfy.example.com", token="pf_xxx")

# 1) Find a running competition
comps = client.competitions.list(phase="running")
comp = comps[0]
print(comp.id, comp.title)

# 2) Scope to the competition, then register Solo (skip if already on a
#    team: inspect ``client.me.get().competition_teams`` first)
comp_api = client.competition(comp.id)
team = comp_api.register(mode="solo")
print(team.id)

# 3) List the challenges scoped to this competition (curated order)
challenges = comp_api.challenges()
for ch in challenges:
    print(f"  {ch.id} [{ch.category.value}/{ch.difficulty}] {ch.name}")

# 4) Launch the first challenge — blocks until ready
ready = client.instances.start(
    challenges[0].id, competition_id=comp.id
)
print(f"instance {ready.id}")

# 5) Read the attack surface (services + credentials, plus VPN for
#    engagement-mode challenges)
for svc in ready.surface.services:
    print(f"  [{svc.service_type.value}] {svc.url}")
    if svc.credentials:
        print(f"    {svc.credentials.username}/{svc.credentials.password}")
if ready.surface.vpn:
    print(f"  VPN: {ready.surface.vpn.host}:{ready.surface.vpn.port}")

# 6) Submit a captured flag (oracle-probe via
#    ``client.submissions.verify()`` first if you don't want to burn
#    an audit row)
result = client.submissions.submit(ready.id, "FLAG{your_flag_here}")
print(f"correct={result.correct} solved={result.challenge_fully_solved}")

# 7) Tear down (or let TTL expire — default 24h, admin-tunable)
client.instances.stop(ready.id)

A runnable, interactive copy of this script lives at examples/quickstart.py.

Player surface

Global / account namespaces on ~ctfy.sdk.client.PlatformClient:

  • client.teams / client.users — public team + user profile reads.
  • client.me — the caller's profile, inbox, account export/delete, achievements, solve + milestone progress.
  • client.auth — password register/login, OAuth identities, API token CRUD, sign-in discovery.
  • client.competitions — discover competitions (list / get).
  • client.challenges — global catalog, attachments, facets, feedback chips.
  • client.instances — start (blocks until ready), status, stop, renew, list (by instance id; the competition is inferred).
  • client.submissions — submit (graded), verify (oracle probe, no record), list, and QA challenges.
  • client.achievements / client.activities / client.nodes — public badge catalog, activity log, worker-node list.

Per-competition scope — client.competition(id) returns a ~ctfy.sdk.competition.Competition handle for the operations that are inherently competition-scoped, so competition_id is named once:

  • comp.register(mode="solo" | "create" | "join", …) — get on a team.
  • comp.team — your team: rename / leave / kick, plus comp.team.invites (codes + email invites) and comp.team.requests (approve / reject join requests).
  • comp.challenges() / comp.search_teams() and the standings (comp.scoreboard() / score_history / score_distribution / solve_matrix / challenge_breakdown).

Top-level convenience methods stay flat: client.health(), client.get_meta(), client.cluster_info(), client.events(), client.check_server_compatibility().

Admin surface

client.admin (an ~ctfy.sdk.admin.AdminClient) carries the operator endpoints, grouped the same way: admin.users, admin.competitions, admin.announcements, admin.achievements, admin.challenges, admin.instances, admin.records (instance forensics), admin.nodes, admin.observability, admin.settings, admin.competition_admins. Operator tooling without a player client can use AdminClient.connect(url, token=...).

Realtime ~~~~

PlatformClient.events() yields an auto-reconnecting ~ctfy.sdk.events.EventStream over the SSE endpoint — solve broadcasts, instance state changes, scoreboard refreshes.

See also

  • PlatformClient — the player client; every namespace method has full docstrings.
  • AdminClient — the operator client.
  • ctfy.server.models — the typed response shapes (ChallengeInfo, InstanceInfo, SubmissionResponse, …) that SDK methods return. Re-using the server's Pydantic models means the wire shape can never drift between the two sides.
  • <platform>/docs — auto-generated FastAPI Swagger UI for the raw REST API (language-agnostic).
  • <platform>/api/v1/mcp/ — streamable-HTTP MCP endpoint with the same operations as MCP tools, if the agent speaks MCP instead of Python.
  • ~ctfy.sdk.node_client.NodeClient — separate client for worker-node ↔ platform RPCs; not what agent harnesses want.
  1"""Python SDK for the ctfy platform.
  2
  3Drives the platform's ``/api/v1/*`` REST API from Python with typed Pydantic
  4returns and auto-retry on transient errors. Two clients, split by audience —
  5the same line the ``ctfy`` / ``ctfy-admin`` CLI binaries draw:
  6
  7- :class:`~ctfy.sdk.client.PlatformClient` — the **player** client. This is
  8  what agents and players want. Methods are grouped into resource namespaces
  9  (``client.teams``, ``client.instances``, …) plus a per-competition handle
 10  via ``client.competition(id)``.
 11- :class:`~ctfy.sdk.admin.AdminClient` — the **operator** client
 12  (``/api/v1/admin/*`` and fleet management). Reached via ``client.admin`` or
 13  :meth:`AdminClient.connect`. Kept separate so the player reference never
 14  surfaces admin endpoints they can't call.
 15
 16Install
 17-------
 18
 19The full ``ctfy`` package pulls in the server too. For an agent /
 20harness that only needs the client, use the ``client`` extra::
 21
 22    pip install "ctfy[client]"
 23
 24Authenticate
 25------------
 26
 27Sign in to the platform in the browser, then go to **Settings → API
 28tokens** and mint a fine-grained token (prefix ``pf_``). These tokens
 29launch instances and submit answers on the team's behalf but cannot
 30mint more tokens, re-link OAuth providers, or change team
 31membership — so a leaked token never costs you the account.
 32
 33Export it as ``CTFY_TOKEN`` for the CLI / MCP server, or pass it
 34directly to :class:`PlatformClient`.
 35
 36Quick start
 37-----------
 38
 39End-to-end: discover a competition, register, list its challenges,
 40launch one, read the attack surface, submit a captured flag::
 41
 42    from ctfy.sdk import PlatformClient
 43
 44    client = PlatformClient("https://ctfy.example.com", token="pf_xxx")
 45
 46    # 1) Find a running competition
 47    comps = client.competitions.list(phase="running")
 48    comp = comps[0]
 49    print(comp.id, comp.title)
 50
 51    # 2) Scope to the competition, then register Solo (skip if already on a
 52    #    team: inspect ``client.me.get().competition_teams`` first)
 53    comp_api = client.competition(comp.id)
 54    team = comp_api.register(mode="solo")
 55    print(team.id)
 56
 57    # 3) List the challenges scoped to this competition (curated order)
 58    challenges = comp_api.challenges()
 59    for ch in challenges:
 60        print(f"  {ch.id} [{ch.category.value}/{ch.difficulty}] {ch.name}")
 61
 62    # 4) Launch the first challenge — blocks until ready
 63    ready = client.instances.start(
 64        challenges[0].id, competition_id=comp.id
 65    )
 66    print(f"instance {ready.id}")
 67
 68    # 5) Read the attack surface (services + credentials, plus VPN for
 69    #    engagement-mode challenges)
 70    for svc in ready.surface.services:
 71        print(f"  [{svc.service_type.value}] {svc.url}")
 72        if svc.credentials:
 73            print(f"    {svc.credentials.username}/{svc.credentials.password}")
 74    if ready.surface.vpn:
 75        print(f"  VPN: {ready.surface.vpn.host}:{ready.surface.vpn.port}")
 76
 77    # 6) Submit a captured flag (oracle-probe via
 78    #    ``client.submissions.verify()`` first if you don't want to burn
 79    #    an audit row)
 80    result = client.submissions.submit(ready.id, "FLAG{your_flag_here}")
 81    print(f"correct={result.correct} solved={result.challenge_fully_solved}")
 82
 83    # 7) Tear down (or let TTL expire — default 24h, admin-tunable)
 84    client.instances.stop(ready.id)
 85
 86A runnable, interactive copy of this script lives at
 87``examples/quickstart.py``.
 88
 89Player surface
 90--------------
 91
 92Global / account namespaces on :class:`~ctfy.sdk.client.PlatformClient`:
 93
 94- ``client.teams`` / ``client.users`` — public team + user profile reads.
 95- ``client.me`` — the caller's profile, inbox, account export/delete,
 96  achievements, solve + milestone progress.
 97- ``client.auth`` — password register/login, OAuth identities, API token
 98  CRUD, sign-in discovery.
 99- ``client.competitions`` — discover competitions (``list`` / ``get``).
100- ``client.challenges`` — global catalog, attachments, facets, feedback chips.
101- ``client.instances`` — ``start`` (blocks until ready), ``status``, ``stop``,
102  ``renew``, ``list`` (by instance id; the competition is inferred).
103- ``client.submissions`` — ``submit`` (graded), ``verify`` (oracle probe,
104  no record), ``list``, and QA challenges.
105- ``client.achievements`` / ``client.activities`` / ``client.nodes`` — public
106  badge catalog, activity log, worker-node list.
107
108Per-competition scope — ``client.competition(id)`` returns a
109:class:`~ctfy.sdk.competition.Competition` handle for the operations that are
110*inherently* competition-scoped, so ``competition_id`` is named once:
111
112- ``comp.register(mode="solo" | "create" | "join", …)`` — get on a team.
113- ``comp.team`` — your team: ``rename`` / ``leave`` / ``kick``, plus
114  ``comp.team.invites`` (codes + email invites) and ``comp.team.requests``
115  (approve / reject join requests).
116- ``comp.challenges()`` / ``comp.search_teams()`` and the standings
117  (``comp.scoreboard()`` / ``score_history`` / ``score_distribution`` /
118  ``solve_matrix`` / ``challenge_breakdown``).
119
120Top-level convenience methods stay flat: ``client.health()``,
121``client.get_meta()``, ``client.cluster_info()``, ``client.events()``,
122``client.check_server_compatibility()``.
123
124Admin surface
125-------------
126
127``client.admin`` (an :class:`~ctfy.sdk.admin.AdminClient`) carries the
128operator endpoints, grouped the same way: ``admin.users``,
129``admin.competitions``, ``admin.announcements``, ``admin.achievements``,
130``admin.challenges``, ``admin.instances``, ``admin.records`` (instance
131forensics), ``admin.nodes``, ``admin.observability``, ``admin.settings``,
132``admin.competition_admins``. Operator tooling without a player client can use
133``AdminClient.connect(url, token=...)``.
134
135Realtime
136~~~~~~~~
137
138:meth:`PlatformClient.events` yields an auto-reconnecting
139:class:`~ctfy.sdk.events.EventStream` over the SSE endpoint —
140solve broadcasts, instance state changes, scoreboard refreshes.
141
142See also
143--------
144
145- :class:`PlatformClient` — the player client; every namespace method has
146  full docstrings.
147- :class:`AdminClient` — the operator client.
148- ``ctfy.server.models`` — the typed response shapes
149  (``ChallengeInfo``, ``InstanceInfo``, ``SubmissionResponse``, …)
150  that SDK methods return. Re-using the server's Pydantic models means
151  the wire shape can never drift between the two sides.
152- ``<platform>/docs`` — auto-generated FastAPI Swagger UI for the
153  raw REST API (language-agnostic).
154- ``<platform>/api/v1/mcp/`` — streamable-HTTP MCP endpoint with
155  the same operations as MCP tools, if the agent speaks MCP
156  instead of Python.
157- :class:`~ctfy.sdk.node_client.NodeClient` — separate client for
158  worker-node ↔ platform RPCs; not what agent harnesses want.
159"""
160
161from ctfy.sdk.admin import AdminClient
162from ctfy.sdk.client import PlatformClient
163from ctfy.sdk.node_client import NodeClient
164
165__all__ = ["AdminClient", "NodeClient", "PlatformClient"]
class AdminClient:
108class AdminClient:
109    """Admin / operator surface, grouped into resource namespaces.
110
111    Namespaces: :attr:`users`, :attr:`competitions`, :attr:`suites`, :attr:`announcements`,
112    :attr:`achievements`, :attr:`challenges`, :attr:`gauntlet`,
113    :attr:`instances`,
114    :attr:`records`, :attr:`nodes`, :attr:`observability`, :attr:`settings`,
115    :attr:`competition_admins`.
116    """
117
118    def __init__(self, http: BaseHttpClient) -> None:
119        #: Shared transport (the parent PlatformClient when reached via
120        #: ``client.admin``, or an owned client when built via ``connect``).
121        self._http = http
122
123    @classmethod
124    def connect(
125        cls,
126        server_url: str,
127        token: str = "",
128        *,
129        max_retries: int = 2,
130        timeout: int = DEFAULT_CLIENT_TIMEOUT,
131    ) -> AdminClient:
132        """Build an ``AdminClient`` that owns its own transport.
133
134        For operator tooling that doesn't already hold a
135        :class:`PlatformClient`. The returned client is a context manager
136        that closes its transport on exit.
137        """
138        base = server_url.rstrip("/")
139        http = BaseHttpClient(f"{base}/api/v1", token, timeout=timeout, max_retries=max_retries)
140        return cls(http)
141
142    @cached_property
143    def users(self) -> AdminUsersResource:
144        return AdminUsersResource(self._http)
145
146    @cached_property
147    def competitions(self) -> AdminCompetitionsResource:
148        return AdminCompetitionsResource(self._http)
149
150    @cached_property
151    def suites(self) -> AdminSuitesResource:
152        """A suite through its own door: its own fields only (#2896)."""
153        return AdminSuitesResource(self._http)
154
155    @cached_property
156    def announcements(self) -> AdminAnnouncementsResource:
157        return AdminAnnouncementsResource(self._http)
158
159    @cached_property
160    def changelog(self) -> AdminChangelogResource:
161        return AdminChangelogResource(self._http)
162
163    @cached_property
164    def contact_messages(self) -> AdminContactMessagesResource:
165        """What visitors wrote on the Contact page (#3414)."""
166        return AdminContactMessagesResource(self._http)
167
168    @cached_property
169    def institutions(self) -> AdminInstitutionsResource:
170        return AdminInstitutionsResource(self._http)
171
172    @cached_property
173    def exclusion_groups(self) -> AdminExclusionGroupsResource:
174        return AdminExclusionGroupsResource(self._http)
175
176    @cached_property
177    def achievements(self) -> AdminAchievementsResource:
178        return AdminAchievementsResource(self._http)
179
180    @cached_property
181    def challenges(self) -> AdminChallengesResource:
182        return AdminChallengesResource(self._http)
183
184    @cached_property
185    def gauntlet(self) -> AdminGauntletResource:
186        return AdminGauntletResource(self._http)
187
188    @cached_property
189    def email(self) -> AdminEmailResource:
190        return AdminEmailResource(self._http)
191
192    @cached_property
193    def ip_location(self) -> AdminIpLocationResource:
194        return AdminIpLocationResource(self._http)
195
196    @cached_property
197    def llm_calls(self) -> AdminLlmCallsResource:
198        return AdminLlmCallsResource(self._http)
199
200    @cached_property
201    def llm_providers(self) -> AdminLlmProvidersResource:
202        return AdminLlmProvidersResource(self._http)
203
204    @cached_property
205    def patches(self) -> AdminPatchesResource:
206        return AdminPatchesResource(self._http)
207
208    @cached_property
209    def reports(self) -> AdminReportsResource:
210        return AdminReportsResource(self._http)
211
212    @cached_property
213    def awd(self) -> AdminAwdResource:
214        return AdminAwdResource(self._http)
215
216    @cached_property
217    def series(self) -> AdminSeriesResource:
218        return AdminSeriesResource(self._http)
219
220    @cached_property
221    def instances(self) -> AdminInstancesResource:
222        return AdminInstancesResource(self._http)
223
224    @cached_property
225    def records(self) -> AdminRecordsResource:
226        return AdminRecordsResource(self._http)
227
228    @cached_property
229    def nodes(self) -> AdminNodesResource:
230        return AdminNodesResource(self._http)
231
232    @cached_property
233    def observability(self) -> AdminObservabilityResource:
234        return AdminObservabilityResource(self._http)
235
236    @cached_property
237    def settings(self) -> AdminSettingsResource:
238        return AdminSettingsResource(self._http)
239
240    @cached_property
241    def competition_admins(self) -> AdminCompetitionAdminsResource:
242        return AdminCompetitionAdminsResource(self._http)
243
244    @cached_property
245    def admin_invites(self) -> AdminAdminInvitesResource:
246        """Invite an address with no account yet to be an administrator."""
247        return AdminAdminInvitesResource(self._http)
248
249    @cached_property
250    def competition_invites(self) -> AdminCompetitionInvitesResource:
251        return AdminCompetitionInvitesResource(self._http)
252
253    @cached_property
254    def competition_applications(self) -> AdminCompetitionApplicationsResource:
255        """Applications to a private competition, and the verdict on each."""
256        return AdminCompetitionApplicationsResource(self._http)
257
258    @cached_property
259    def competition_attachments(self) -> AdminCompetitionAttachmentsResource:
260        """A competition's rules / handbook / notice files (#1808)."""
261        return AdminCompetitionAttachmentsResource(self._http)
262
263    @cached_property
264    def competition_boards(self) -> AdminCompetitionBoardsResource:
265        """A competition's extra boards — published and shadow (#1965 G)."""
266        return AdminCompetitionBoardsResource(self._http)
267
268    @cached_property
269    def recap(self) -> AdminRecapResource:
270        """A competition's recap, its publication and its report email (#3023)."""
271        return AdminRecapResource(self._http)
272
273    @cached_property
274    def challenge_schedule(self) -> AdminChallengeScheduleResource:
275        """When each challenge of a competition appears and shuts (#2093)."""
276        return AdminChallengeScheduleResource(self._http)
277
278    @cached_property
279    def release_plan(self) -> AdminReleasePlanResource:
280        """Waves, per-challenge rules and hints: drafts, publish, release (#2093, #2101)."""
281        return AdminReleasePlanResource(self._http)
282
283    @cached_property
284    def env_sharing(self) -> AdminEnvSharingResource:
285        """How many teams stand on one environment, the groups behind it and
286        the health of the shared environments (#2129 B)."""
287        return AdminEnvSharingResource(self._http)
288
289    @cached_property
290    def machines(self) -> AdminMachinesResource:
291        """Machine logins handed to teams, captain-only (#2132)."""
292        return AdminMachinesResource(self._http)
293
294    @cached_property
295    def messages(self) -> AdminMessagesResource:
296        """Site messages from an event's organisers or the platform (#2073)."""
297        return AdminMessagesResource(self._http)
298
299    @cached_property
300    def prewarm(self) -> AdminPrewarmResource:
301        """Whether a competition's images are warm on its nodes (#2129)."""
302        return AdminPrewarmResource(self._http)
303
304    @cached_property
305    def competition_topics(self) -> AdminCompetitionTopicsResource:
306        """A competition's scheduled topics, released by the clock (#1965)."""
307        return AdminCompetitionTopicsResource(self._http)
308
309    @cached_property
310    def registrations(self) -> AdminRegistrationsResource:
311        """Entrant roster, eligibility review, and CSV export."""
312        return AdminRegistrationsResource(self._http)
313
314    @cached_property
315    def teams(self) -> AdminTeamsResource:
316        """Enforcement against a squad — disqualify and reinstate.
317
318        Separate from ``registrations`` on purpose: that one rules on
319        eligibility (which decides prizes), this one decides whether the
320        team is in the event at all.
321        """
322        return AdminTeamsResource(self._http)
323
324    @cached_property
325    def challenge_stats(self) -> AdminChallengeStatsResource:
326        """How players did against an event's challenges: the organiser's
327        table and the single-challenge reading (#2146)."""
328        return AdminChallengeStatsResource(self._http)
329
330    @cached_property
331    def risk(self) -> AdminRiskResource:
332        """Risk control: the case queue (organisers) and an account's sealed
333        sightings (platform administrators). Detection reports; people decide."""
334        return AdminRiskResource(self._http)
335
336    @cached_property
337    def tasks(self) -> AdminTasksResource:
338        return AdminTasksResource(self._http)
339
340    @cached_property
341    def scheduled_jobs(self) -> AdminScheduledJobsResource:
342        return AdminScheduledJobsResource(self._http)
343
344    @cached_property
345    def authored_challenges(self) -> AdminAuthoredChallengesResource:
346        """The review queue for player-authored challenges."""
347        return AdminAuthoredChallengesResource(self._http)
348
349    @cached_property
350    def authoring_reviewers(self) -> AdminAuthoringReviewersResource:
351        """Who may rule on one competition's uploads (super-admin)."""
352        return AdminAuthoringReviewersResource(self._http)
353
354    @cached_property
355    def competition_authored_challenges(self) -> AdminCompetitionAuthoredChallengesResource:
356        """One competition's uploads, for its delegated reviewer: read,
357        audit and rule — never publish."""
358        return AdminCompetitionAuthoredChallengesResource(self._http)
359
360    @cached_property
361    def llm_models(self) -> AdminLlmModelsResource:
362        return AdminLlmModelsResource(self._http)
363
364    @cached_property
365    def eval_vendors(self) -> AdminEvalVendorsResource:
366        return AdminEvalVendorsResource(self._http)
367
368    @cached_property
369    def eval_harnesses(self) -> AdminEvalHarnessesResource:
370        return AdminEvalHarnessesResource(self._http)
371
372    @property
373    def eval_allowances(self) -> AdminEvalAllowancesResource:
374        return AdminEvalAllowancesResource(self._http)
375
376    @property
377    def registry(self) -> AdminRegistryResource:
378        """Registry storage: the usage report and the manual reclaim."""
379        return AdminRegistryResource(self._http)
380
381    @cached_property
382    def eval_runs(self) -> AdminEvalRunsResource:
383        return AdminEvalRunsResource(self._http)
384
385    @cached_property
386    def eval_campaigns(self) -> AdminEvalCampaignsResource:
387        return AdminEvalCampaignsResource(self._http)
388
389    def close(self) -> None:
390        """Close the underlying transport.
391
392        Only call this on a standalone client built via :meth:`connect`;
393        when reached via ``PlatformClient.admin`` the transport is shared
394        with — and closed by — the parent player client.
395        """
396        self._http.close()
397
398    def __enter__(self) -> Self:
399        return self
400
401    def __exit__(
402        self,
403        exc_type: type[BaseException] | None,
404        exc: BaseException | None,
405        tb: TracebackType | None,
406    ) -> None:
407        self.close()

Admin / operator surface, grouped into resource namespaces.

Namespaces: users, competitions, suites, announcements, achievements, challenges, gauntlet, instances, records, nodes, observability, settings, competition_admins.

AdminClient(http: ctfy.sdk.base.BaseHttpClient)
118    def __init__(self, http: BaseHttpClient) -> None:
119        #: Shared transport (the parent PlatformClient when reached via
120        #: ``client.admin``, or an owned client when built via ``connect``).
121        self._http = http
@classmethod
def connect( cls, server_url: str, token: str = '', *, max_retries: int = 2, timeout: int = 600) -> AdminClient:
123    @classmethod
124    def connect(
125        cls,
126        server_url: str,
127        token: str = "",
128        *,
129        max_retries: int = 2,
130        timeout: int = DEFAULT_CLIENT_TIMEOUT,
131    ) -> AdminClient:
132        """Build an ``AdminClient`` that owns its own transport.
133
134        For operator tooling that doesn't already hold a
135        :class:`PlatformClient`. The returned client is a context manager
136        that closes its transport on exit.
137        """
138        base = server_url.rstrip("/")
139        http = BaseHttpClient(f"{base}/api/v1", token, timeout=timeout, max_retries=max_retries)
140        return cls(http)

Build an AdminClient that owns its own transport.

For operator tooling that doesn't already hold a PlatformClient. The returned client is a context manager that closes its transport on exit.

142    @cached_property
143    def users(self) -> AdminUsersResource:
144        return AdminUsersResource(self._http)
146    @cached_property
147    def competitions(self) -> AdminCompetitionsResource:
148        return AdminCompetitionsResource(self._http)
150    @cached_property
151    def suites(self) -> AdminSuitesResource:
152        """A suite through its own door: its own fields only (#2896)."""
153        return AdminSuitesResource(self._http)

A suite through its own door: its own fields only (#2896).

155    @cached_property
156    def announcements(self) -> AdminAnnouncementsResource:
157        return AdminAnnouncementsResource(self._http)
159    @cached_property
160    def changelog(self) -> AdminChangelogResource:
161        return AdminChangelogResource(self._http)
163    @cached_property
164    def contact_messages(self) -> AdminContactMessagesResource:
165        """What visitors wrote on the Contact page (#3414)."""
166        return AdminContactMessagesResource(self._http)

What visitors wrote on the Contact page (#3414).

168    @cached_property
169    def institutions(self) -> AdminInstitutionsResource:
170        return AdminInstitutionsResource(self._http)
172    @cached_property
173    def exclusion_groups(self) -> AdminExclusionGroupsResource:
174        return AdminExclusionGroupsResource(self._http)
176    @cached_property
177    def achievements(self) -> AdminAchievementsResource:
178        return AdminAchievementsResource(self._http)
180    @cached_property
181    def challenges(self) -> AdminChallengesResource:
182        return AdminChallengesResource(self._http)
184    @cached_property
185    def gauntlet(self) -> AdminGauntletResource:
186        return AdminGauntletResource(self._http)
188    @cached_property
189    def email(self) -> AdminEmailResource:
190        return AdminEmailResource(self._http)
192    @cached_property
193    def ip_location(self) -> AdminIpLocationResource:
194        return AdminIpLocationResource(self._http)
196    @cached_property
197    def llm_calls(self) -> AdminLlmCallsResource:
198        return AdminLlmCallsResource(self._http)
200    @cached_property
201    def llm_providers(self) -> AdminLlmProvidersResource:
202        return AdminLlmProvidersResource(self._http)
204    @cached_property
205    def patches(self) -> AdminPatchesResource:
206        return AdminPatchesResource(self._http)
208    @cached_property
209    def reports(self) -> AdminReportsResource:
210        return AdminReportsResource(self._http)
212    @cached_property
213    def awd(self) -> AdminAwdResource:
214        return AdminAwdResource(self._http)
216    @cached_property
217    def series(self) -> AdminSeriesResource:
218        return AdminSeriesResource(self._http)
220    @cached_property
221    def instances(self) -> AdminInstancesResource:
222        return AdminInstancesResource(self._http)
224    @cached_property
225    def records(self) -> AdminRecordsResource:
226        return AdminRecordsResource(self._http)
228    @cached_property
229    def nodes(self) -> AdminNodesResource:
230        return AdminNodesResource(self._http)
232    @cached_property
233    def observability(self) -> AdminObservabilityResource:
234        return AdminObservabilityResource(self._http)
236    @cached_property
237    def settings(self) -> AdminSettingsResource:
238        return AdminSettingsResource(self._http)
240    @cached_property
241    def competition_admins(self) -> AdminCompetitionAdminsResource:
242        return AdminCompetitionAdminsResource(self._http)
244    @cached_property
245    def admin_invites(self) -> AdminAdminInvitesResource:
246        """Invite an address with no account yet to be an administrator."""
247        return AdminAdminInvitesResource(self._http)

Invite an address with no account yet to be an administrator.

249    @cached_property
250    def competition_invites(self) -> AdminCompetitionInvitesResource:
251        return AdminCompetitionInvitesResource(self._http)
253    @cached_property
254    def competition_applications(self) -> AdminCompetitionApplicationsResource:
255        """Applications to a private competition, and the verdict on each."""
256        return AdminCompetitionApplicationsResource(self._http)

Applications to a private competition, and the verdict on each.

258    @cached_property
259    def competition_attachments(self) -> AdminCompetitionAttachmentsResource:
260        """A competition's rules / handbook / notice files (#1808)."""
261        return AdminCompetitionAttachmentsResource(self._http)

A competition's rules / handbook / notice files (#1808).

263    @cached_property
264    def competition_boards(self) -> AdminCompetitionBoardsResource:
265        """A competition's extra boards — published and shadow (#1965 G)."""
266        return AdminCompetitionBoardsResource(self._http)

A competition's extra boards — published and shadow (#1965 G).

268    @cached_property
269    def recap(self) -> AdminRecapResource:
270        """A competition's recap, its publication and its report email (#3023)."""
271        return AdminRecapResource(self._http)

A competition's recap, its publication and its report email (#3023).

273    @cached_property
274    def challenge_schedule(self) -> AdminChallengeScheduleResource:
275        """When each challenge of a competition appears and shuts (#2093)."""
276        return AdminChallengeScheduleResource(self._http)

When each challenge of a competition appears and shuts (#2093).

278    @cached_property
279    def release_plan(self) -> AdminReleasePlanResource:
280        """Waves, per-challenge rules and hints: drafts, publish, release (#2093, #2101)."""
281        return AdminReleasePlanResource(self._http)

Waves, per-challenge rules and hints: drafts, publish, release (#2093, #2101).

283    @cached_property
284    def env_sharing(self) -> AdminEnvSharingResource:
285        """How many teams stand on one environment, the groups behind it and
286        the health of the shared environments (#2129 B)."""
287        return AdminEnvSharingResource(self._http)

How many teams stand on one environment, the groups behind it and the health of the shared environments (#2129 B).

289    @cached_property
290    def machines(self) -> AdminMachinesResource:
291        """Machine logins handed to teams, captain-only (#2132)."""
292        return AdminMachinesResource(self._http)

Machine logins handed to teams, captain-only (#2132).

294    @cached_property
295    def messages(self) -> AdminMessagesResource:
296        """Site messages from an event's organisers or the platform (#2073)."""
297        return AdminMessagesResource(self._http)

Site messages from an event's organisers or the platform (#2073).

299    @cached_property
300    def prewarm(self) -> AdminPrewarmResource:
301        """Whether a competition's images are warm on its nodes (#2129)."""
302        return AdminPrewarmResource(self._http)

Whether a competition's images are warm on its nodes (#2129).

304    @cached_property
305    def competition_topics(self) -> AdminCompetitionTopicsResource:
306        """A competition's scheduled topics, released by the clock (#1965)."""
307        return AdminCompetitionTopicsResource(self._http)

A competition's scheduled topics, released by the clock (#1965).

309    @cached_property
310    def registrations(self) -> AdminRegistrationsResource:
311        """Entrant roster, eligibility review, and CSV export."""
312        return AdminRegistrationsResource(self._http)

Entrant roster, eligibility review, and CSV export.

314    @cached_property
315    def teams(self) -> AdminTeamsResource:
316        """Enforcement against a squad — disqualify and reinstate.
317
318        Separate from ``registrations`` on purpose: that one rules on
319        eligibility (which decides prizes), this one decides whether the
320        team is in the event at all.
321        """
322        return AdminTeamsResource(self._http)

Enforcement against a squad — disqualify and reinstate.

Separate from registrations on purpose: that one rules on eligibility (which decides prizes), this one decides whether the team is in the event at all.

324    @cached_property
325    def challenge_stats(self) -> AdminChallengeStatsResource:
326        """How players did against an event's challenges: the organiser's
327        table and the single-challenge reading (#2146)."""
328        return AdminChallengeStatsResource(self._http)

How players did against an event's challenges: the organiser's table and the single-challenge reading (#2146).

330    @cached_property
331    def risk(self) -> AdminRiskResource:
332        """Risk control: the case queue (organisers) and an account's sealed
333        sightings (platform administrators). Detection reports; people decide."""
334        return AdminRiskResource(self._http)

Risk control: the case queue (organisers) and an account's sealed sightings (platform administrators). Detection reports; people decide.

336    @cached_property
337    def tasks(self) -> AdminTasksResource:
338        return AdminTasksResource(self._http)
340    @cached_property
341    def scheduled_jobs(self) -> AdminScheduledJobsResource:
342        return AdminScheduledJobsResource(self._http)
344    @cached_property
345    def authored_challenges(self) -> AdminAuthoredChallengesResource:
346        """The review queue for player-authored challenges."""
347        return AdminAuthoredChallengesResource(self._http)

The review queue for player-authored challenges.

349    @cached_property
350    def authoring_reviewers(self) -> AdminAuthoringReviewersResource:
351        """Who may rule on one competition's uploads (super-admin)."""
352        return AdminAuthoringReviewersResource(self._http)

Who may rule on one competition's uploads (super-admin).

354    @cached_property
355    def competition_authored_challenges(self) -> AdminCompetitionAuthoredChallengesResource:
356        """One competition's uploads, for its delegated reviewer: read,
357        audit and rule — never publish."""
358        return AdminCompetitionAuthoredChallengesResource(self._http)

One competition's uploads, for its delegated reviewer: read, audit and rule — never publish.

360    @cached_property
361    def llm_models(self) -> AdminLlmModelsResource:
362        return AdminLlmModelsResource(self._http)
364    @cached_property
365    def eval_vendors(self) -> AdminEvalVendorsResource:
366        return AdminEvalVendorsResource(self._http)
368    @cached_property
369    def eval_harnesses(self) -> AdminEvalHarnessesResource:
370        return AdminEvalHarnessesResource(self._http)
372    @property
373    def eval_allowances(self) -> AdminEvalAllowancesResource:
374        return AdminEvalAllowancesResource(self._http)
376    @property
377    def registry(self) -> AdminRegistryResource:
378        """Registry storage: the usage report and the manual reclaim."""
379        return AdminRegistryResource(self._http)

Registry storage: the usage report and the manual reclaim.

381    @cached_property
382    def eval_runs(self) -> AdminEvalRunsResource:
383        return AdminEvalRunsResource(self._http)
385    @cached_property
386    def eval_campaigns(self) -> AdminEvalCampaignsResource:
387        return AdminEvalCampaignsResource(self._http)
def close(self) -> None:
389    def close(self) -> None:
390        """Close the underlying transport.
391
392        Only call this on a standalone client built via :meth:`connect`;
393        when reached via ``PlatformClient.admin`` the transport is shared
394        with — and closed by — the parent player client.
395        """
396        self._http.close()

Close the underlying transport.

Only call this on a standalone client built via connect(); when reached via PlatformClient.admin the transport is shared with — and closed by — the parent player client.

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) -> ctfy.sdk.node_client.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) -> ctfy.sdk.node_client.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.

class PlatformClient(ctfy.sdk.base.BaseHttpClient):
 97class PlatformClient(BaseHttpClient):
 98    """Player-facing HTTP client for the ctfy platform.
 99
100    Methods are grouped into resource namespaces (``client.teams.list()``, …)
101    plus a per-competition scope via ``client.competition(id)``. The admin
102    surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via
103    :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers.
104    """
105
106    def __init__(
107        self,
108        server_url: str,
109        token: str = "",
110        max_retries: int = 2,
111        timeout: int = DEFAULT_PLATFORM_TIMEOUT,
112        transport: httpx.BaseTransport | None = None,
113        unavailable_budget_s: float = UNAVAILABLE_BUDGET_S,
114    ):
115        """``unavailable_budget_s``: how long one call waits out a ``503`` +
116        ``Retry-After`` (#3312: a redeploy is a wait, not a failed tool
117        call). ``0`` never waits — the platform's own MCP endpoint calling
118        itself, whose tool thread holds one of the pool's slots (#3504)."""
119        self._url = server_url.rstrip("/")
120        super().__init__(
121            f"{self._url}/api/v1",
122            token,
123            timeout=timeout,
124            max_retries=max_retries,
125            transport=transport,
126            unavailable_budget_s=unavailable_budget_s,
127        )
128
129    @property
130    def server_url(self) -> str:
131        """The platform root, without the ``/api/v1`` suffix.
132
133        Exposed for callers that must build a non-``/api/v1`` URL of
134        their own — today the shell WebSocket, whose ticket carries a
135        platform-relative path the client has to absolutise against the
136        scheme and host *it* reached us on.
137        """
138        return self._url
139
140    # -- resource namespaces ------------------------------------------------
141
142    @cached_property
143    def registration(self) -> RegistrationResource:
144        """The caller's own per-competition registration + team logo."""
145        return RegistrationResource(self)
146
147    @property
148    def authoring(self) -> AuthoringResource:
149        """Upload a challenge your team wrote, for a peer-authored event."""
150        return AuthoringResource(self)
151
152    @cached_property
153    def teams(self) -> TeamsResource:
154        """Public team discovery + profile reads."""
155        return TeamsResource(self)
156
157    @cached_property
158    def users(self) -> UsersResource:
159        """Public per-user profile reads."""
160        return UsersResource(self)
161
162    @cached_property
163    def me(self) -> MeResource:
164        """The calling user's profile, inbox, account, and progress."""
165        return MeResource(self)
166
167    @cached_property
168    def messages(self) -> MessagesResource:
169        """Site messages and the inbox stream (#2073)."""
170        return MessagesResource(self)
171
172    @cached_property
173    def auth(self) -> AuthResource:
174        """Password auth, OAuth identities, API tokens, sign-in discovery."""
175        return AuthResource(self)
176
177    @cached_property
178    def achievements(self) -> AchievementsResource:
179        """Public badge catalog, recent-unlock feed, easter eggs."""
180        return AchievementsResource(self)
181
182    @cached_property
183    def challenges(self) -> ChallengesResource:
184        """Global challenge catalog, attachments, facets, feedback chips."""
185        return ChallengesResource(self)
186
187    @cached_property
188    def site(self) -> SiteResource:
189        """The operator's site pages and their Contact form (#3414)."""
190        return SiteResource(self)
191
192    @cached_property
193    def hints(self) -> HintsResource:
194        """A challenge's hint tiers in a competition: read, and open (#2101)."""
195        return HintsResource(self)
196
197    @cached_property
198    def changelog(self) -> ChangelogResource:
199        """Public "What's New" feed, plus this account's seen marker."""
200        return ChangelogResource(self)
201
202    @cached_property
203    def competitions(self) -> CompetitionsResource:
204        """Discover competitions (``list`` / ``get``). To act within one, use
205        :meth:`competition`."""
206        return CompetitionsResource(self)
207
208    def competition(self, competition_id: str) -> Competition:
209        """Scope to one competition. The inherently competition-scoped
210        operations — register, your team (captain tooling + invites +
211        join-requests), the comp's challenges, team search, and standings —
212        hang off the returned :class:`~ctfy.sdk.competition.Competition`
213        handle, so ``competition_id`` is named once instead of on every call.
214        (Instance lifecycle and answer submission stay on :attr:`instances` /
215        :attr:`submissions`, keyed by ``instance_id``.)"""
216        return Competition(self, competition_id)
217
218    @cached_property
219    def series(self) -> SeriesResource:
220        """A recurring contest's ladder (public), not its schedule —
221        configuring a series is ``client.admin.series``."""
222        return SeriesResource(self)
223
224    @property
225    def suites(self) -> SuitesResource:
226        """The benchmark suites -- public challenge sets anybody practises or
227        evaluates against at any time (#2097). Playing one is
228        ``client.competition(id)``, like any competition."""
229        return SuitesResource(self)
230
231    @cached_property
232    def eval(self) -> EvalResource:
233        """Public model-evaluation leaderboard (ranking + dimensional breakdowns)."""
234        return EvalResource(self)
235
236    @property
237    def registry(self) -> RegistryResource:
238        """The container registry's token endpoint (docker follows it itself)."""
239        return RegistryResource(self)
240
241    @cached_property
242    def vendor(self) -> VendorResource:
243        """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer)."""
244        return VendorResource(self)
245
246    @cached_property
247    def instances(self) -> InstancesResource:
248        """Launch / control / inspect challenge instances by instance id
249        (the competition is inferred server-side)."""
250        return InstancesResource(self)
251
252    @cached_property
253    def submissions(self) -> SubmissionsResource:
254        """Submit / verify answers + list submissions (by instance id), plus
255        the instance-less QA challenges."""
256        return SubmissionsResource(self)
257
258    @cached_property
259    def patches(self) -> PatchesResource:
260        """AWD+ defence: submit a patch, then watch for its verdict."""
261        return PatchesResource(self)
262
263    @cached_property
264    def awd(self) -> AwdResource:
265        """Classic AWD: submit a round's captured flags in one batch."""
266        return AwdResource(self)
267
268    @cached_property
269    def reports(self) -> ReportsResource:
270        """The engagement report: save a version, read the current one."""
271        return ReportsResource(self)
272
273    @cached_property
274    def search(self) -> SearchResource:
275        """Global search: competitions, challenges and teams by name."""
276        return SearchResource(self)
277
278    @cached_property
279    def tasks(self) -> TasksResource:
280        """The background tasks you may see: when each waited, ran and ended."""
281        return TasksResource(self)
282
283    @cached_property
284    def activities(self) -> ActivitiesResource:
285        """Platform activity log + histogram."""
286        return ActivitiesResource(self)
287
288    @cached_property
289    def nodes(self) -> NodesResource:
290        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
291        return NodesResource(self)
292
293    @cached_property
294    def admin(self) -> AdminClient:
295        """Admin / operator surface (separate, role-gated server-side).
296
297        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
298        """
299        return AdminClient(self)
300
301    # -- top-level convenience: server status / realtime / version ----------
302
303    def health(self) -> HealthResponse:
304        resp = self.request("GET", "/health")
305        _raise_for_status(resp)
306        return HealthResponse.model_validate(resp.json())
307
308    def get_meta(self) -> MetaResponse:
309        """Server identity + challenge repo SHA + build version.
310        Admin tokens additionally see cluster capacity + team / solve
311        counts in the same payload."""
312        resp = self.request("GET", "/meta")
313        _raise_for_status(resp)
314        return MetaResponse.model_validate(resp.json())
315
316    def get_landing(self) -> LandingResponse:
317        """What the public home page shows: the featured event (``None``
318        when nothing featured may be shown) and the catalog by domain."""
319        resp = self.request("GET", "/landing")
320        _raise_for_status(resp)
321        return LandingResponse.model_validate(resp.json())
322
323    def get_public_stats(self) -> PublicStatsResponse:
324        """The home page's live eval numbers and rounded platform scale
325        (#3413). Public and the same for every caller."""
326        resp = self.request("GET", "/stats/public")
327        _raise_for_status(resp)
328        return PublicStatsResponse.model_validate(resp.json())
329
330    def cluster_info(self) -> ClusterInfo:
331        """Aggregate worker-node capacity / utilisation (the public
332        capacity banner; no per-node detail)."""
333        resp = self.request("GET", "/cluster-info")
334        _raise_for_status(resp)
335        return ClusterInfo.model_validate(resp.json())
336
337    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
338        """Compare this client's ``ctfy`` version against the server's.
339
340        Uses the cheap unauthenticated ``/health`` probe (pass an
341        already-fetched :class:`HealthResponse` to avoid a second round
342        trip). Pure classification — never prints, never raises on a
343        mismatch, never mutates anything. The caller (CLI, MCP, harness)
344        decides what to do with :class:`~ctfy.core.version.VersionCheck`
345        (typically: print ``.message`` to stderr when not ``.quiet``).
346
347        A server too old to report its version yields
348        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
349        is ``.quiet`` — so this stays silent against legacy servers
350        rather than crying wolf.
351        """
352        h = health if health is not None else self.health()
353        return check_compatibility(package_version(), h.version)
354
355    def events(self, *, auto_reconnect: bool = True) -> EventStream:
356        """Open the platform SSE event stream.
357
358        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
359        Filters: team-scoped events for every team the caller's user is
360        on (across every per-comp competition), plus all global events;
361        admin tokens additionally see admin-only frames.
362
363        Usage::
364
365            with client.events() as stream:
366                for event in stream:
367                    if event["event"] == "solve":
368                        ...
369
370        Auto-reconnects on transient network errors with exponential
371        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
372        the iterator raise instead.
373        """
374        token = self._token
375
376        def _factory() -> httpx.Client:
377            # New client per session so a reconnect after a stale
378            # connection drops fresh sockets, not warmed-over ones.
379            return httpx.Client(base_url=self._base_url, timeout=None)
380
381        return EventStream(
382            client_factory=_factory,
383            path="/events",
384            token=token,
385            auto_reconnect=auto_reconnect,
386        )

Player-facing HTTP client for the ctfy platform.

Methods are grouped into resource namespaces (client.teams.list(), …) plus a per-competition scope via client.competition(id). The admin surface is a separate ~ctfy.sdk.admin.AdminClient reached via admin. Used by the CLI, the SDK, the MCP server, and external callers.

PlatformClient( server_url: str, token: str = '', max_retries: int = 2, timeout: int = 60, transport: httpx.BaseTransport | None = None, unavailable_budget_s: float = 120.0)
106    def __init__(
107        self,
108        server_url: str,
109        token: str = "",
110        max_retries: int = 2,
111        timeout: int = DEFAULT_PLATFORM_TIMEOUT,
112        transport: httpx.BaseTransport | None = None,
113        unavailable_budget_s: float = UNAVAILABLE_BUDGET_S,
114    ):
115        """``unavailable_budget_s``: how long one call waits out a ``503`` +
116        ``Retry-After`` (#3312: a redeploy is a wait, not a failed tool
117        call). ``0`` never waits — the platform's own MCP endpoint calling
118        itself, whose tool thread holds one of the pool's slots (#3504)."""
119        self._url = server_url.rstrip("/")
120        super().__init__(
121            f"{self._url}/api/v1",
122            token,
123            timeout=timeout,
124            max_retries=max_retries,
125            transport=transport,
126            unavailable_budget_s=unavailable_budget_s,
127        )

unavailable_budget_s: how long one call waits out a 503 + Retry-After (#3312: a redeploy is a wait, not a failed tool call). 0 never waits — the platform's own MCP endpoint calling itself, whose tool thread holds one of the pool's slots (#3504).

server_url: str
129    @property
130    def server_url(self) -> str:
131        """The platform root, without the ``/api/v1`` suffix.
132
133        Exposed for callers that must build a non-``/api/v1`` URL of
134        their own — today the shell WebSocket, whose ticket carries a
135        platform-relative path the client has to absolutise against the
136        scheme and host *it* reached us on.
137        """
138        return self._url

The platform root, without the /api/v1 suffix.

Exposed for callers that must build a non-/api/v1 URL of their own — today the shell WebSocket, whose ticket carries a platform-relative path the client has to absolutise against the scheme and host it reached us on.

142    @cached_property
143    def registration(self) -> RegistrationResource:
144        """The caller's own per-competition registration + team logo."""
145        return RegistrationResource(self)

The caller's own per-competition registration + team logo.

147    @property
148    def authoring(self) -> AuthoringResource:
149        """Upload a challenge your team wrote, for a peer-authored event."""
150        return AuthoringResource(self)

Upload a challenge your team wrote, for a peer-authored event.

152    @cached_property
153    def teams(self) -> TeamsResource:
154        """Public team discovery + profile reads."""
155        return TeamsResource(self)

Public team discovery + profile reads.

157    @cached_property
158    def users(self) -> UsersResource:
159        """Public per-user profile reads."""
160        return UsersResource(self)

Public per-user profile reads.

162    @cached_property
163    def me(self) -> MeResource:
164        """The calling user's profile, inbox, account, and progress."""
165        return MeResource(self)

The calling user's profile, inbox, account, and progress.

167    @cached_property
168    def messages(self) -> MessagesResource:
169        """Site messages and the inbox stream (#2073)."""
170        return MessagesResource(self)

Site messages and the inbox stream (#2073).

172    @cached_property
173    def auth(self) -> AuthResource:
174        """Password auth, OAuth identities, API tokens, sign-in discovery."""
175        return AuthResource(self)

Password auth, OAuth identities, API tokens, sign-in discovery.

177    @cached_property
178    def achievements(self) -> AchievementsResource:
179        """Public badge catalog, recent-unlock feed, easter eggs."""
180        return AchievementsResource(self)

Public badge catalog, recent-unlock feed, easter eggs.

182    @cached_property
183    def challenges(self) -> ChallengesResource:
184        """Global challenge catalog, attachments, facets, feedback chips."""
185        return ChallengesResource(self)

Global challenge catalog, attachments, facets, feedback chips.

187    @cached_property
188    def site(self) -> SiteResource:
189        """The operator's site pages and their Contact form (#3414)."""
190        return SiteResource(self)

The operator's site pages and their Contact form (#3414).

192    @cached_property
193    def hints(self) -> HintsResource:
194        """A challenge's hint tiers in a competition: read, and open (#2101)."""
195        return HintsResource(self)

A challenge's hint tiers in a competition: read, and open (#2101).

197    @cached_property
198    def changelog(self) -> ChangelogResource:
199        """Public "What's New" feed, plus this account's seen marker."""
200        return ChangelogResource(self)

Public "What's New" feed, plus this account's seen marker.

202    @cached_property
203    def competitions(self) -> CompetitionsResource:
204        """Discover competitions (``list`` / ``get``). To act within one, use
205        :meth:`competition`."""
206        return CompetitionsResource(self)

Discover competitions (list / get). To act within one, use competition().

def competition(self, competition_id: str) -> ctfy.sdk.competition.Competition:
208    def competition(self, competition_id: str) -> Competition:
209        """Scope to one competition. The inherently competition-scoped
210        operations — register, your team (captain tooling + invites +
211        join-requests), the comp's challenges, team search, and standings —
212        hang off the returned :class:`~ctfy.sdk.competition.Competition`
213        handle, so ``competition_id`` is named once instead of on every call.
214        (Instance lifecycle and answer submission stay on :attr:`instances` /
215        :attr:`submissions`, keyed by ``instance_id``.)"""
216        return Competition(self, competition_id)

Scope to one competition. The inherently competition-scoped operations — register, your team (captain tooling + invites + join-requests), the comp's challenges, team search, and standings — hang off the returned ~ctfy.sdk.competition.Competition handle, so competition_id is named once instead of on every call. (Instance lifecycle and answer submission stay on instances / submissions, keyed by instance_id.)

218    @cached_property
219    def series(self) -> SeriesResource:
220        """A recurring contest's ladder (public), not its schedule —
221        configuring a series is ``client.admin.series``."""
222        return SeriesResource(self)

A recurring contest's ladder (public), not its schedule — configuring a series is client.admin.series.

224    @property
225    def suites(self) -> SuitesResource:
226        """The benchmark suites -- public challenge sets anybody practises or
227        evaluates against at any time (#2097). Playing one is
228        ``client.competition(id)``, like any competition."""
229        return SuitesResource(self)

The benchmark suites -- public challenge sets anybody practises or evaluates against at any time (#2097). Playing one is client.competition(id), like any competition.

231    @cached_property
232    def eval(self) -> EvalResource:
233        """Public model-evaluation leaderboard (ranking + dimensional breakdowns)."""
234        return EvalResource(self)

Public model-evaluation leaderboard (ranking + dimensional breakdowns).

236    @property
237    def registry(self) -> RegistryResource:
238        """The container registry's token endpoint (docker follows it itself)."""
239        return RegistryResource(self)

The container registry's token endpoint (docker follows it itself).

241    @cached_property
242    def vendor(self) -> VendorResource:
243        """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer)."""
244        return VendorResource(self)

Vendor tenancy — the calling vendor's own models / runs (pv_ bearer).

246    @cached_property
247    def instances(self) -> InstancesResource:
248        """Launch / control / inspect challenge instances by instance id
249        (the competition is inferred server-side)."""
250        return InstancesResource(self)

Launch / control / inspect challenge instances by instance id (the competition is inferred server-side).

252    @cached_property
253    def submissions(self) -> SubmissionsResource:
254        """Submit / verify answers + list submissions (by instance id), plus
255        the instance-less QA challenges."""
256        return SubmissionsResource(self)

Submit / verify answers + list submissions (by instance id), plus the instance-less QA challenges.

258    @cached_property
259    def patches(self) -> PatchesResource:
260        """AWD+ defence: submit a patch, then watch for its verdict."""
261        return PatchesResource(self)

AWD+ defence: submit a patch, then watch for its verdict.

263    @cached_property
264    def awd(self) -> AwdResource:
265        """Classic AWD: submit a round's captured flags in one batch."""
266        return AwdResource(self)

Classic AWD: submit a round's captured flags in one batch.

268    @cached_property
269    def reports(self) -> ReportsResource:
270        """The engagement report: save a version, read the current one."""
271        return ReportsResource(self)

The engagement report: save a version, read the current one.

273    @cached_property
274    def search(self) -> SearchResource:
275        """Global search: competitions, challenges and teams by name."""
276        return SearchResource(self)

Global search: competitions, challenges and teams by name.

278    @cached_property
279    def tasks(self) -> TasksResource:
280        """The background tasks you may see: when each waited, ran and ended."""
281        return TasksResource(self)

The background tasks you may see: when each waited, ran and ended.

283    @cached_property
284    def activities(self) -> ActivitiesResource:
285        """Platform activity log + histogram."""
286        return ActivitiesResource(self)

Platform activity log + histogram.

288    @cached_property
289    def nodes(self) -> NodesResource:
290        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
291        return NodesResource(self)

Public worker-node list (operator node mgmt is on admin.nodes).

admin: AdminClient
293    @cached_property
294    def admin(self) -> AdminClient:
295        """Admin / operator surface (separate, role-gated server-side).
296
297        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
298        """
299        return AdminClient(self)

Admin / operator surface (separate, role-gated server-side).

Shares this client's transport. See ~ctfy.sdk.admin.AdminClient.

def health(self) -> ctfy.server.models.HealthResponse:
303    def health(self) -> HealthResponse:
304        resp = self.request("GET", "/health")
305        _raise_for_status(resp)
306        return HealthResponse.model_validate(resp.json())
def get_meta(self) -> ctfy.server.models.MetaResponse:
308    def get_meta(self) -> MetaResponse:
309        """Server identity + challenge repo SHA + build version.
310        Admin tokens additionally see cluster capacity + team / solve
311        counts in the same payload."""
312        resp = self.request("GET", "/meta")
313        _raise_for_status(resp)
314        return MetaResponse.model_validate(resp.json())

Server identity + challenge repo SHA + build version. Admin tokens additionally see cluster capacity + team / solve counts in the same payload.

def get_landing(self) -> ctfy.server.models.LandingResponse:
316    def get_landing(self) -> LandingResponse:
317        """What the public home page shows: the featured event (``None``
318        when nothing featured may be shown) and the catalog by domain."""
319        resp = self.request("GET", "/landing")
320        _raise_for_status(resp)
321        return LandingResponse.model_validate(resp.json())

What the public home page shows: the featured event (None when nothing featured may be shown) and the catalog by domain.

def get_public_stats(self) -> ctfy.server.models.PublicStatsResponse:
323    def get_public_stats(self) -> PublicStatsResponse:
324        """The home page's live eval numbers and rounded platform scale
325        (#3413). Public and the same for every caller."""
326        resp = self.request("GET", "/stats/public")
327        _raise_for_status(resp)
328        return PublicStatsResponse.model_validate(resp.json())

The home page's live eval numbers and rounded platform scale (#3413). Public and the same for every caller.

def cluster_info(self) -> ctfy.server.models.ClusterInfo:
330    def cluster_info(self) -> ClusterInfo:
331        """Aggregate worker-node capacity / utilisation (the public
332        capacity banner; no per-node detail)."""
333        resp = self.request("GET", "/cluster-info")
334        _raise_for_status(resp)
335        return ClusterInfo.model_validate(resp.json())

Aggregate worker-node capacity / utilisation (the public capacity banner; no per-node detail).

def check_server_compatibility( self, *, health: ctfy.server.models.HealthResponse | None = None) -> ctfy.core.version.VersionCheck:
337    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
338        """Compare this client's ``ctfy`` version against the server's.
339
340        Uses the cheap unauthenticated ``/health`` probe (pass an
341        already-fetched :class:`HealthResponse` to avoid a second round
342        trip). Pure classification — never prints, never raises on a
343        mismatch, never mutates anything. The caller (CLI, MCP, harness)
344        decides what to do with :class:`~ctfy.core.version.VersionCheck`
345        (typically: print ``.message`` to stderr when not ``.quiet``).
346
347        A server too old to report its version yields
348        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
349        is ``.quiet`` — so this stays silent against legacy servers
350        rather than crying wolf.
351        """
352        h = health if health is not None else self.health()
353        return check_compatibility(package_version(), h.version)

Compare this client's ctfy version against the server's.

Uses the cheap unauthenticated /health probe (pass an already-fetched HealthResponse to avoid a second round trip). Pure classification — never prints, never raises on a mismatch, never mutates anything. The caller (CLI, MCP, harness) decides what to do with ~ctfy.core.version.VersionCheck (typically: print .message to stderr when not .quiet).

A server too old to report its version yields Compatibility.UNKNOWN (health.version == ""), which is .quiet — so this stays silent against legacy servers rather than crying wolf.

def events(self, *, auto_reconnect: bool = True) -> ctfy.sdk.events.EventStream:
355    def events(self, *, auto_reconnect: bool = True) -> EventStream:
356        """Open the platform SSE event stream.
357
358        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
359        Filters: team-scoped events for every team the caller's user is
360        on (across every per-comp competition), plus all global events;
361        admin tokens additionally see admin-only frames.
362
363        Usage::
364
365            with client.events() as stream:
366                for event in stream:
367                    if event["event"] == "solve":
368                        ...
369
370        Auto-reconnects on transient network errors with exponential
371        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
372        the iterator raise instead.
373        """
374        token = self._token
375
376        def _factory() -> httpx.Client:
377            # New client per session so a reconnect after a stale
378            # connection drops fresh sockets, not warmed-over ones.
379            return httpx.Client(base_url=self._base_url, timeout=None)
380
381        return EventStream(
382            client_factory=_factory,
383            path="/events",
384            token=token,
385            auto_reconnect=auto_reconnect,
386        )

Open the platform SSE event stream.

Yields {"event": <name>, "data": <dict>} for each frame. Filters: team-scoped events for every team the caller's user is on (across every per-comp competition), plus all global events; admin tokens additionally see admin-only frames.

Usage::

with client.events() as stream:
    for event in stream:
        if event["event"] == "solve":
            ...

Auto-reconnects on transient network errors with exponential backoff (1s → 30s capped). Set auto_reconnect=False to make the iterator raise instead.