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.scoreboard — global standings, per-challenge stats, snapshots.
  • 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.scoreboard`` — global standings, per-challenge stats, snapshots.
106- ``client.achievements`` / ``client.activities`` / ``client.nodes`` — public
107  badge catalog, activity log, worker-node list.
108
109Per-competition scope — ``client.competition(id)`` returns a
110:class:`~ctfy.sdk.competition.Competition` handle for the operations that are
111*inherently* competition-scoped, so ``competition_id`` is named once:
112
113- ``comp.register(mode="solo" | "create" | "join", …)`` — get on a team.
114- ``comp.team`` — your team: ``rename`` / ``leave`` / ``kick``, plus
115  ``comp.team.invites`` (codes + email invites) and ``comp.team.requests``
116  (approve / reject join requests).
117- ``comp.challenges()`` / ``comp.search_teams()`` and the standings
118  (``comp.scoreboard()`` / ``score_history`` / ``score_distribution`` /
119  ``solve_matrix`` / ``challenge_breakdown``).
120
121Top-level convenience methods stay flat: ``client.health()``,
122``client.get_meta()``, ``client.cluster_info()``, ``client.events()``,
123``client.check_server_compatibility()``.
124
125Admin surface
126-------------
127
128``client.admin`` (an :class:`~ctfy.sdk.admin.AdminClient`) carries the
129operator endpoints, grouped the same way: ``admin.users``,
130``admin.competitions``, ``admin.announcements``, ``admin.achievements``,
131``admin.challenges``, ``admin.instances``, ``admin.records`` (instance
132forensics), ``admin.nodes``, ``admin.observability``, ``admin.settings``,
133``admin.competition_admins``. Operator tooling without a player client can use
134``AdminClient.connect(url, token=...)``.
135
136Realtime
137~~~~~~~~
138
139:meth:`PlatformClient.events` yields an auto-reconnecting
140:class:`~ctfy.sdk.events.EventStream` over the SSE endpoint —
141solve broadcasts, instance state changes, scoreboard refreshes.
142
143See also
144--------
145
146- :class:`PlatformClient` — the player client; every namespace method has
147  full docstrings.
148- :class:`AdminClient` — the operator client.
149- ``ctfy.server.models`` — the typed response shapes
150  (``ChallengeInfo``, ``InstanceInfo``, ``SubmissionResponse``, …)
151  that SDK methods return. Re-using the server's Pydantic models means
152  the wire shape can never drift between the two sides.
153- ``<platform>/docs`` — auto-generated FastAPI Swagger UI for the
154  raw REST API (language-agnostic).
155- ``<platform>/api/v1/mcp/`` — streamable-HTTP MCP endpoint with
156  the same operations as MCP tools, if the agent speaks MCP
157  instead of Python.
158- :class:`~ctfy.sdk.node_client.NodeClient` — separate client for
159  worker-node ↔ platform RPCs; not what agent harnesses want.
160"""
161
162from ctfy.sdk.admin import AdminClient
163from ctfy.sdk.client import PlatformClient
164from ctfy.sdk.node_client import NodeClient
165
166__all__ = ["AdminClient", "NodeClient", "PlatformClient"]
class AdminClient:
 92class AdminClient:
 93    """Admin / operator surface, grouped into resource namespaces.
 94
 95    Namespaces: :attr:`users`, :attr:`competitions`, :attr:`announcements`,
 96    :attr:`achievements`, :attr:`challenges`, :attr:`gauntlet`,
 97    :attr:`instances`,
 98    :attr:`records`, :attr:`nodes`, :attr:`observability`, :attr:`settings`,
 99    :attr:`competition_admins`.
100    """
101
102    def __init__(self, http: BaseHttpClient) -> None:
103        #: Shared transport (the parent PlatformClient when reached via
104        #: ``client.admin``, or an owned client when built via ``connect``).
105        self._http = http
106
107    @classmethod
108    def connect(
109        cls,
110        server_url: str,
111        token: str = "",
112        *,
113        max_retries: int = 2,
114        timeout: int = DEFAULT_CLIENT_TIMEOUT,
115    ) -> AdminClient:
116        """Build an ``AdminClient`` that owns its own transport.
117
118        For operator tooling that doesn't already hold a
119        :class:`PlatformClient`. The returned client is a context manager
120        that closes its transport on exit.
121        """
122        base = server_url.rstrip("/")
123        http = BaseHttpClient(f"{base}/api/v1", token, timeout=timeout, max_retries=max_retries)
124        return cls(http)
125
126    @cached_property
127    def users(self) -> AdminUsersResource:
128        return AdminUsersResource(self._http)
129
130    @cached_property
131    def competitions(self) -> AdminCompetitionsResource:
132        return AdminCompetitionsResource(self._http)
133
134    @cached_property
135    def announcements(self) -> AdminAnnouncementsResource:
136        return AdminAnnouncementsResource(self._http)
137
138    @cached_property
139    def changelog(self) -> AdminChangelogResource:
140        return AdminChangelogResource(self._http)
141
142    @cached_property
143    def institutions(self) -> AdminInstitutionsResource:
144        return AdminInstitutionsResource(self._http)
145
146    @cached_property
147    def achievements(self) -> AdminAchievementsResource:
148        return AdminAchievementsResource(self._http)
149
150    @cached_property
151    def challenges(self) -> AdminChallengesResource:
152        return AdminChallengesResource(self._http)
153
154    @cached_property
155    def gauntlet(self) -> AdminGauntletResource:
156        return AdminGauntletResource(self._http)
157
158    @cached_property
159    def email(self) -> AdminEmailResource:
160        return AdminEmailResource(self._http)
161
162    @cached_property
163    def llm_calls(self) -> AdminLlmCallsResource:
164        return AdminLlmCallsResource(self._http)
165
166    @cached_property
167    def llm_providers(self) -> AdminLlmProvidersResource:
168        return AdminLlmProvidersResource(self._http)
169
170    @cached_property
171    def patches(self) -> AdminPatchesResource:
172        return AdminPatchesResource(self._http)
173
174    @cached_property
175    def reports(self) -> AdminReportsResource:
176        return AdminReportsResource(self._http)
177
178    @cached_property
179    def awd(self) -> AdminAwdResource:
180        return AdminAwdResource(self._http)
181
182    @cached_property
183    def series(self) -> AdminSeriesResource:
184        return AdminSeriesResource(self._http)
185
186    @cached_property
187    def instances(self) -> AdminInstancesResource:
188        return AdminInstancesResource(self._http)
189
190    @cached_property
191    def records(self) -> AdminRecordsResource:
192        return AdminRecordsResource(self._http)
193
194    @cached_property
195    def nodes(self) -> AdminNodesResource:
196        return AdminNodesResource(self._http)
197
198    @cached_property
199    def observability(self) -> AdminObservabilityResource:
200        return AdminObservabilityResource(self._http)
201
202    @cached_property
203    def settings(self) -> AdminSettingsResource:
204        return AdminSettingsResource(self._http)
205
206    @cached_property
207    def competition_admins(self) -> AdminCompetitionAdminsResource:
208        return AdminCompetitionAdminsResource(self._http)
209
210    @cached_property
211    def competition_invites(self) -> AdminCompetitionInvitesResource:
212        return AdminCompetitionInvitesResource(self._http)
213
214    @cached_property
215    def competition_applications(self) -> AdminCompetitionApplicationsResource:
216        """Applications to a private competition, and the verdict on each."""
217        return AdminCompetitionApplicationsResource(self._http)
218
219    @cached_property
220    def competition_attachments(self) -> AdminCompetitionAttachmentsResource:
221        """A competition's rules / handbook / notice files (#1808)."""
222        return AdminCompetitionAttachmentsResource(self._http)
223
224    @cached_property
225    def registrations(self) -> AdminRegistrationsResource:
226        """Entrant roster, eligibility review, and CSV export."""
227        return AdminRegistrationsResource(self._http)
228
229    @cached_property
230    def teams(self) -> AdminTeamsResource:
231        """Enforcement against a squad — disqualify and reinstate.
232
233        Separate from ``registrations`` on purpose: that one rules on
234        eligibility (which decides prizes), this one decides whether the
235        team is in the event at all.
236        """
237        return AdminTeamsResource(self._http)
238
239    @cached_property
240    def tasks(self) -> AdminTasksResource:
241        return AdminTasksResource(self._http)
242
243    @cached_property
244    def scheduled_jobs(self) -> AdminScheduledJobsResource:
245        return AdminScheduledJobsResource(self._http)
246
247    @cached_property
248    def authored_challenges(self) -> AdminAuthoredChallengesResource:
249        """The review queue for player-authored challenges."""
250        return AdminAuthoredChallengesResource(self._http)
251
252    @cached_property
253    def authoring_reviewers(self) -> AdminAuthoringReviewersResource:
254        """Who may rule on one competition's uploads (super-admin)."""
255        return AdminAuthoringReviewersResource(self._http)
256
257    @cached_property
258    def competition_authored_challenges(self) -> AdminCompetitionAuthoredChallengesResource:
259        """One competition's uploads, for its delegated reviewer: read,
260        audit and rule — never publish."""
261        return AdminCompetitionAuthoredChallengesResource(self._http)
262
263    @cached_property
264    def llm_models(self) -> AdminLlmModelsResource:
265        return AdminLlmModelsResource(self._http)
266
267    @cached_property
268    def eval_vendors(self) -> AdminEvalVendorsResource:
269        return AdminEvalVendorsResource(self._http)
270
271    @cached_property
272    def eval_harnesses(self) -> AdminEvalHarnessesResource:
273        return AdminEvalHarnessesResource(self._http)
274
275    @property
276    def eval_allowances(self) -> AdminEvalAllowancesResource:
277        return AdminEvalAllowancesResource(self._http)
278
279    @property
280    def registry(self) -> AdminRegistryResource:
281        """Registry storage: the usage report and the manual reclaim."""
282        return AdminRegistryResource(self._http)
283
284    @cached_property
285    def eval_runs(self) -> AdminEvalRunsResource:
286        return AdminEvalRunsResource(self._http)
287
288    @cached_property
289    def eval_campaigns(self) -> AdminEvalCampaignsResource:
290        return AdminEvalCampaignsResource(self._http)
291
292    def close(self) -> None:
293        """Close the underlying transport.
294
295        Only call this on a standalone client built via :meth:`connect`;
296        when reached via ``PlatformClient.admin`` the transport is shared
297        with — and closed by — the parent player client.
298        """
299        self._http.close()
300
301    def __enter__(self) -> Self:
302        return self
303
304    def __exit__(
305        self,
306        exc_type: type[BaseException] | None,
307        exc: BaseException | None,
308        tb: TracebackType | None,
309    ) -> None:
310        self.close()

Admin / operator surface, grouped into resource namespaces.

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

AdminClient(http: ctfy.sdk.base.BaseHttpClient)
102    def __init__(self, http: BaseHttpClient) -> None:
103        #: Shared transport (the parent PlatformClient when reached via
104        #: ``client.admin``, or an owned client when built via ``connect``).
105        self._http = http
@classmethod
def connect( cls, server_url: str, token: str = '', *, max_retries: int = 2, timeout: int = 600) -> AdminClient:
107    @classmethod
108    def connect(
109        cls,
110        server_url: str,
111        token: str = "",
112        *,
113        max_retries: int = 2,
114        timeout: int = DEFAULT_CLIENT_TIMEOUT,
115    ) -> AdminClient:
116        """Build an ``AdminClient`` that owns its own transport.
117
118        For operator tooling that doesn't already hold a
119        :class:`PlatformClient`. The returned client is a context manager
120        that closes its transport on exit.
121        """
122        base = server_url.rstrip("/")
123        http = BaseHttpClient(f"{base}/api/v1", token, timeout=timeout, max_retries=max_retries)
124        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.

126    @cached_property
127    def users(self) -> AdminUsersResource:
128        return AdminUsersResource(self._http)
130    @cached_property
131    def competitions(self) -> AdminCompetitionsResource:
132        return AdminCompetitionsResource(self._http)
134    @cached_property
135    def announcements(self) -> AdminAnnouncementsResource:
136        return AdminAnnouncementsResource(self._http)
138    @cached_property
139    def changelog(self) -> AdminChangelogResource:
140        return AdminChangelogResource(self._http)
142    @cached_property
143    def institutions(self) -> AdminInstitutionsResource:
144        return AdminInstitutionsResource(self._http)
146    @cached_property
147    def achievements(self) -> AdminAchievementsResource:
148        return AdminAchievementsResource(self._http)
150    @cached_property
151    def challenges(self) -> AdminChallengesResource:
152        return AdminChallengesResource(self._http)
154    @cached_property
155    def gauntlet(self) -> AdminGauntletResource:
156        return AdminGauntletResource(self._http)
158    @cached_property
159    def email(self) -> AdminEmailResource:
160        return AdminEmailResource(self._http)
162    @cached_property
163    def llm_calls(self) -> AdminLlmCallsResource:
164        return AdminLlmCallsResource(self._http)
166    @cached_property
167    def llm_providers(self) -> AdminLlmProvidersResource:
168        return AdminLlmProvidersResource(self._http)
170    @cached_property
171    def patches(self) -> AdminPatchesResource:
172        return AdminPatchesResource(self._http)
174    @cached_property
175    def reports(self) -> AdminReportsResource:
176        return AdminReportsResource(self._http)
178    @cached_property
179    def awd(self) -> AdminAwdResource:
180        return AdminAwdResource(self._http)
182    @cached_property
183    def series(self) -> AdminSeriesResource:
184        return AdminSeriesResource(self._http)
186    @cached_property
187    def instances(self) -> AdminInstancesResource:
188        return AdminInstancesResource(self._http)
190    @cached_property
191    def records(self) -> AdminRecordsResource:
192        return AdminRecordsResource(self._http)
194    @cached_property
195    def nodes(self) -> AdminNodesResource:
196        return AdminNodesResource(self._http)
198    @cached_property
199    def observability(self) -> AdminObservabilityResource:
200        return AdminObservabilityResource(self._http)
202    @cached_property
203    def settings(self) -> AdminSettingsResource:
204        return AdminSettingsResource(self._http)
206    @cached_property
207    def competition_admins(self) -> AdminCompetitionAdminsResource:
208        return AdminCompetitionAdminsResource(self._http)
210    @cached_property
211    def competition_invites(self) -> AdminCompetitionInvitesResource:
212        return AdminCompetitionInvitesResource(self._http)
214    @cached_property
215    def competition_applications(self) -> AdminCompetitionApplicationsResource:
216        """Applications to a private competition, and the verdict on each."""
217        return AdminCompetitionApplicationsResource(self._http)

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

219    @cached_property
220    def competition_attachments(self) -> AdminCompetitionAttachmentsResource:
221        """A competition's rules / handbook / notice files (#1808)."""
222        return AdminCompetitionAttachmentsResource(self._http)

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

224    @cached_property
225    def registrations(self) -> AdminRegistrationsResource:
226        """Entrant roster, eligibility review, and CSV export."""
227        return AdminRegistrationsResource(self._http)

Entrant roster, eligibility review, and CSV export.

229    @cached_property
230    def teams(self) -> AdminTeamsResource:
231        """Enforcement against a squad — disqualify and reinstate.
232
233        Separate from ``registrations`` on purpose: that one rules on
234        eligibility (which decides prizes), this one decides whether the
235        team is in the event at all.
236        """
237        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.

239    @cached_property
240    def tasks(self) -> AdminTasksResource:
241        return AdminTasksResource(self._http)
243    @cached_property
244    def scheduled_jobs(self) -> AdminScheduledJobsResource:
245        return AdminScheduledJobsResource(self._http)
247    @cached_property
248    def authored_challenges(self) -> AdminAuthoredChallengesResource:
249        """The review queue for player-authored challenges."""
250        return AdminAuthoredChallengesResource(self._http)

The review queue for player-authored challenges.

252    @cached_property
253    def authoring_reviewers(self) -> AdminAuthoringReviewersResource:
254        """Who may rule on one competition's uploads (super-admin)."""
255        return AdminAuthoringReviewersResource(self._http)

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

257    @cached_property
258    def competition_authored_challenges(self) -> AdminCompetitionAuthoredChallengesResource:
259        """One competition's uploads, for its delegated reviewer: read,
260        audit and rule — never publish."""
261        return AdminCompetitionAuthoredChallengesResource(self._http)

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

263    @cached_property
264    def llm_models(self) -> AdminLlmModelsResource:
265        return AdminLlmModelsResource(self._http)
267    @cached_property
268    def eval_vendors(self) -> AdminEvalVendorsResource:
269        return AdminEvalVendorsResource(self._http)
271    @cached_property
272    def eval_harnesses(self) -> AdminEvalHarnessesResource:
273        return AdminEvalHarnessesResource(self._http)
275    @property
276    def eval_allowances(self) -> AdminEvalAllowancesResource:
277        return AdminEvalAllowancesResource(self._http)
279    @property
280    def registry(self) -> AdminRegistryResource:
281        """Registry storage: the usage report and the manual reclaim."""
282        return AdminRegistryResource(self._http)

Registry storage: the usage report and the manual reclaim.

284    @cached_property
285    def eval_runs(self) -> AdminEvalRunsResource:
286        return AdminEvalRunsResource(self._http)
288    @cached_property
289    def eval_campaigns(self) -> AdminEvalCampaignsResource:
290        return AdminEvalCampaignsResource(self._http)
def close(self) -> None:
292    def close(self) -> None:
293        """Close the underlying transport.
294
295        Only call this on a standalone client built via :meth:`connect`;
296        when reached via ``PlatformClient.admin`` the transport is shared
297        with — and closed by — the parent player client.
298        """
299        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):
 192class NodeClient(BaseHttpClient):
 193    """Thin sync HTTP client; one instance per node URL."""
 194
 195    def __init__(
 196        self,
 197        node_url: str,
 198        token: str,
 199        *,
 200        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 201        transport: httpx.BaseTransport | None = None,
 202    ) -> None:
 203        """*transport* is `BaseHttpClient`'s own seam, passed through so a
 204        test can drive a real node app over the real client — the hop the
 205        fakes on either side of it cannot see."""
 206        super().__init__(
 207            f"{node_url.rstrip('/')}/api/v1", token, timeout=timeout, transport=transport
 208        )
 209
 210    # -- lifecycle ----------------------------------------------------------
 211
 212    def start_instance(
 213        self,
 214        *,
 215        challenge_id: str,
 216        instance_id: str,
 217        ttl: int,
 218        answers: dict[str, str],
 219        proxy_output_dir: str | None = None,
 220        env: dict[str, str] | None = None,
 221        publish_gamebox: bool = False,
 222        match_network: MatchNetwork | None = None,
 223        open_shell_password: str = "",
 224        authored_revision: str = "",
 225    ) -> InstanceStarted:
 226        resp = self.request(
 227            "POST",
 228            "/instances",
 229            json={
 230                # #1835: the authored tree's revision this start must run;
 231                # a node holding another (or none) fetches it first.
 232                "authored_revision": authored_revision,
 233                "challenge_id": challenge_id,
 234                "instance_id": instance_id,
 235                "ttl": ttl,
 236                "answers": answers,
 237                "proxy_output_dir": proxy_output_dir,
 238                "env": env or {},
 239                "publish_gamebox": publish_gamebox,
 240                # ``None`` rather than an omitted key: the node's model
 241                # defaults to None either way, and sending it explicitly
 242                # keeps the body's shape stable across both branches.
 243                "match_network": (dataclasses.asdict(match_network) if match_network else None),
 244                # Classic-AWD open shell (#1725): the box's starting root
 245                # password, empty for every managed / non-AWD start.
 246                "open_shell_password": open_shell_password,
 247            },
 248        )
 249        _raise_for_node_status(resp)
 250        return _parsed(InstanceStarted, resp)
 251
 252    def stop_instance(self, instance_id: str) -> None:
 253        resp = self.request("DELETE", f"/instances/{instance_id}")
 254        resp.raise_for_status()
 255
 256    def apply_fabric(
 257        self,
 258        *,
 259        index: int,
 260        interface: dict[str, Any],
 261        match_network: str = "",
 262    ) -> FabricStatus:
 263        """Put this node on one match's WireGuard fabric.
 264
 265        ⚠️ **Returns the model, never ``resp.json()``.** The platform
 266        reads the verdict field by field, and on a dict a renamed field
 267        is ``None`` — falsy — which reads as "the arena did not open",
 268        so a wire change would quietly report every node as broken.
 269
 270        Returns the node's verdict rather than raising on a refusal: the
 271        route answers **200 with a body** precisely so that "this node is
 272        unreachable" and "this node rejected the plan" stay different
 273        facts, and a caller fanning out over an arena needs to tell them
 274        apart — one is waited on, the other is looked at.
 275
 276        *match_network* lets the node check the tunnel landed in the
 277        namespace its boxes are in, and open the bridge's one hole. It is
 278        the platform's own fact (`awd_network.network_name`), so the node
 279        is told rather than guessing.
 280        """
 281        resp = self.request(
 282            "POST",
 283            "/fabric",
 284            json={
 285                "index": index,
 286                "interface": interface,
 287                "match_network": match_network,
 288            },
 289        )
 290        resp.raise_for_status()
 291        return _parsed(FabricStatus, resp)
 292
 293    def probe_fabric(
 294        self,
 295        *,
 296        index: int,
 297        peers: list[dict[str, str]],
 298        mtu: int = 0,
 299    ) -> FabricProbeReport:
 300        """Ask named peers the three link questions. Applies nothing.
 301
 302        ⚠️ **Returns the model, never ``resp.json()``** — same reason
 303        `apply_fabric` gives: on a dict a renamed field reads as falsy,
 304        which here means "your tunnel is down" about a wire change.
 305        """
 306        resp = self.request(
 307            "POST",
 308            "/fabric/probe",
 309            json={"index": index, "peers": peers, "mtu": mtu},
 310        )
 311        resp.raise_for_status()
 312        return _parsed(FabricProbeReport, resp)
 313
 314    def remove_fabric(self, *, index: int, match_network: str = "") -> FabricStatus:
 315        """Take this node off one match's fabric and close its hole.
 316
 317        ⚠️ *match_network* is what lets the node resolve the bridge and
 318        delete the ``DOCKER-USER`` rules by name. Without it the tunnel
 319        goes and the hole stays — open on whatever docker gives that
 320        bridge name next.
 321        """
 322        resp = self.request(
 323            "DELETE",
 324            f"/fabric/{index}",
 325            params={"match_network": match_network},
 326        )
 327        resp.raise_for_status()
 328        return _parsed(FabricStatus, resp)
 329
 330    def stop_instance_service(self, instance_id: str, service: str) -> int:
 331        """Stop one compose service, leaving the instance running.
 332
 333        The exercise format's session close (§4b.4). Returns how many
 334        containers the node stopped — 0 means the challenge declares no
 335        such service, which is a real answer rather than a failure, so
 336        the caller can distinguish it from a cut tunnel.
 337        """
 338        resp = self.request(
 339            "POST",
 340            f"/instances/{instance_id}/stop-service",
 341            json={"service": service},
 342        )
 343        resp.raise_for_status()
 344        body = resp.json()
 345        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0
 346
 347    def stop_all(self) -> None:
 348        resp = self.request("POST", "/admin/stop-all")
 349        resp.raise_for_status()
 350
 351    def put_authored_challenge(self, push: NodeAuthoredPush) -> NodeAuthoredResult:
 352        """Ask the node to write an approved player upload into its catalog.
 353
 354        ⚠️ **The bytes travel here, unlike `put_generated_range`.** That
 355        one sends a scenario because the node can compile it; a
 356        player-authored tree has no compiler and no corpus entry, so the
 357        archive is what there is to send.
 358
 359        The node answers **200 with ``ok: false``** for a refusal, so a
 360        node that declined the archive and a node that could not be
 361        reached stay different facts; only the second raises here.
 362        """
 363        resp = self.request("POST", "/admin/authored", json=push.model_dump(mode="json"))
 364        resp.raise_for_status()
 365        return _parsed(NodeAuthoredResult, resp)
 366
 367    def smoke_authored_challenge(
 368        self, push: NodeAuthoredSmokePush, *, timeout: int | None = None
 369    ) -> NodeAuthoredSmokeResult:
 370        """Ask the node to run an authored challenge's own reference exploit.
 371
 372        ⚠️ **Takes its own timeout.** A smoke run brings a whole estate
 373        up; the client default is sized for a status call and would give
 374        up while the node is still doing what it was asked.
 375        """
 376        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
 377        if timeout is not None:
 378            kwargs["timeout"] = timeout
 379        resp = self.request("POST", "/admin/authored/smoke", **kwargs)
 380        resp.raise_for_status()
 381        return _parsed(NodeAuthoredSmokeResult, resp)
 382
 383    def put_generated_range(self, push: NodeRangePush) -> NodeRangeResult:
 384        """Ask the node to build a generated range from its scenario.
 385
 386        ⚠️ **The scenario travels, not the estate.** The corpus is
 387        mounted on the node and the compiler ships inside it, so 70-odd
 388        rendered files never cross this API — and the corpus commit that
 389        rides along is what stops one scenario becoming two different
 390        estates under one id across a fleet.
 391
 392        The node answers **200 with ``ok: false``** for a refusal, so a
 393        node that declined and a node that could not be reached stay
 394        different facts; only the second raises here.
 395        """
 396        resp = self.request("POST", "/admin/ranges", json=push.model_dump(mode="json"))
 397        resp.raise_for_status()
 398        return _parsed(NodeRangeResult, resp)
 399
 400    def verify_generated_range(
 401        self, push: NodeRangeVerifyPush, *, timeout: int | None = None
 402    ) -> NodeRangeVerifyResult:
 403        """Ask the node to build a scenario, solve it, and take it down.
 404
 405        ⚠️ **The longest call in this API**, so the caller passes its own
 406        timeout: the node's budget is ``compose_timeout_s +
 407        solve_timeout_s`` and a client that used the default would give
 408        up while the node was still building — then retry, and start a
 409        second estate beside the first.
 410
 411        ⚠️ A client timeout does **not** leave a range running: the node
 412        tears down in its own ``finally``. What is lost is the answer,
 413        not the host.
 414
 415        The node answers **200 with an ``error``** for a refusal, the
 416        same split ``put_generated_range`` uses; only an unreachable node
 417        raises.
 418        """
 419        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
 420        if timeout is not None:
 421            # httpx takes a per-request override; the client-wide value
 422            # stays whatever the caller constructed this with.
 423            kwargs["timeout"] = timeout
 424        resp = self.request("POST", "/admin/ranges/verify", **kwargs)
 425        resp.raise_for_status()
 426        return _parsed(NodeRangeVerifyResult, resp)
 427
 428    def cancel_generated_range_verify(self, run_id: str) -> bool:
 429        """Tell the node to stop a verification it is running.
 430
 431        ⚠️ **A miss is success, not a 404 to report.** The cancel races
 432        the run's own finish, so "no such run" means the run already
 433        ended — which is what the operator asked for. The node answers
 434        ``{"cancelled": false}`` for that case rather than raising, and
 435        this returns it so a caller that wants to say "it had already
 436        finished" can.
 437        """
 438        resp = self.request("POST", f"/admin/ranges/verify/{run_id}/cancel")
 439        resp.raise_for_status()
 440        body = resp.json()
 441        return bool(body.get("cancelled"))
 442
 443    def rescan_challenges(self) -> ChallengeReloadResult:
 444        """Tell the node to drop its spec cache and re-scan challenges_dir.
 445
 446        Returns ``{total, added, removed}`` so the platform can report
 447        the per-node outcome of a cluster-wide rescan."""
 448        resp = self.request("POST", "/admin/rescan-challenges")
 449        resp.raise_for_status()
 450        return _parsed(ChallengeReloadResult, resp)
 451
 452    # -- admin pre-build (image cache warming) ------------------------------
 453
 454    def build_challenge(
 455        self, challenge_id: str, *, authored_revision: str = ""
 456    ) -> ChallengeBuildState:
 457        """Ask the node to pre-build images for *challenge_id*.
 458
 459        Fires-and-returns: the node persists ``status="building"`` and
 460        spawns a daemon thread; the body returned here is that initial
 461        state row. Polling :meth:`get_build_state` is how the platform
 462        learns when it lands on ``built`` / ``failed``. *authored_revision*
 463        is #1835's: the tree the build must be of, fetched if not held.
 464        """
 465        params = {"authored_revision": authored_revision} if authored_revision else None
 466        resp = self.request("POST", f"/admin/challenges/{challenge_id}/build", params=params)
 467        _raise_for_node_status(resp)
 468        return _parsed(ChallengeBuildState, resp)
 469
 470    def build_all_challenges(self, challenge_ids: list[str] | None = None) -> BuildAllQueued:
 471        """Queue background pre-build on the node.
 472
 473        With ``challenge_ids=None`` the node builds every spec it knows.
 474        With an explicit list (the platform scoping a bulk build to a
 475        competition's challenge set) only those are built. Returns
 476        ``{queued, skipped_built, skipped_in_progress}`` so the platform
 477        can report per-node what got picked up. Sequential on the node
 478        side — no fan-out across the corpus.
 479        """
 480        kwargs: dict[str, Any] = {}
 481        if challenge_ids is not None:
 482            kwargs["json"] = {"challenge_ids": challenge_ids}
 483        resp = self.request("POST", "/admin/challenges/build-all", **kwargs)
 484        _raise_for_node_status(resp)
 485        return _parsed(BuildAllQueued, resp)
 486
 487    def build_push_challenge(
 488        self,
 489        challenge_id: str,
 490        *,
 491        push_ticket: str = "",
 492        timeout_s: int = 1800,
 493        cleanup: bool = False,
 494        cache_max_bytes: int = 0,
 495        confine_network: bool = False,
 496        authored_revision: str = "",
 497    ) -> ChallengeFreezeResult:
 498        """Build one challenge's ``build:`` services and freeze them to the registry.
 499
 500        Layer 2 producer (P0). **Blocks** until the challenge is built +
 501        pushed (or skipped as already frozen), unlike the fire-and-return
 502        :meth:`build_challenge`, so the per-call HTTP timeout sits above
 503        the node's build budget. Returns the per-service freeze result;
 504        the platform's ``challenge_build_push`` task calls this one
 505        challenge at a time. *push_ticket* is the platform's short-lived
 506        registry credential the node's ``docker push`` authenticates with
 507        — the node runs it in a container with no ambient login.
 508        *cleanup* asks the node to remove what the build left and prune its
 509        build cache to *cache_max_bytes* afterwards, and *confine_network*
 510        keeps every ``RUN`` off the host's network (both the freeze pool, #1827).
 511        """
 512        resp = self.request(
 513            "POST",
 514            f"/admin/challenges/{challenge_id}/build-push",
 515            json={
 516                "push_ticket": push_ticket,
 517                "cleanup": cleanup,
 518                "cache_max_bytes": cache_max_bytes,
 519                "confine_network": confine_network,
 520                "authored_revision": authored_revision,
 521            },
 522            timeout=timeout_s + 60,
 523        )
 524        _raise_for_node_status(resp)
 525        return _parsed(ChallengeFreezeResult, resp)
 526
 527    def get_disk_free(self) -> NodeDiskFree:
 528        """Free space on the node's data filesystem, read now (#1827).
 529
 530        Raises on any failure — including a node predating the route, which
 531        answers 404 — and the freeze reads every failure as *cannot tell*,
 532        which stops it.
 533        """
 534        resp = self.request("GET", "/admin/disk")
 535        _raise_for_node_status(resp)
 536        return _parsed(NodeDiskFree, resp)
 537
 538    def get_build_state(self) -> BuildStateRows:
 539        """Fetch every per-challenge build-state row on this node.
 540
 541        Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``.
 542        ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
 543        ``failed``; ``unbuilt`` placeholders are synthesised for specs
 544        the node has seen but never been asked to build.
 545        """
 546        resp = self.request("GET", "/admin/challenges/build-state")
 547        _raise_for_node_status(resp)
 548        return _parsed(BuildStateRows, resp)
 549
 550    def pull_challenge(self, challenge_id: str) -> ChallengePullState:
 551        """Ask the node to pre-pull registry images for *challenge_id*.
 552
 553        Pull-side twin of :meth:`build_challenge`: the node persists
 554        ``status="pulling"`` and spawns a daemon thread; poll
 555        :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing.
 556        """
 557        resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull")
 558        _raise_for_node_status(resp)
 559        return _parsed(ChallengePullState, resp)
 560
 561    def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> PullAllQueued:
 562        """Queue background pre-pull on the node.
 563
 564        With ``challenge_ids=None`` the node pulls every spec it knows;
 565        with an explicit list only those (the platform scoping to a
 566        competition). Returns ``{queued, skipped_pulled,
 567        skipped_in_progress}``.
 568        """
 569        kwargs: dict[str, Any] = {}
 570        if challenge_ids is not None:
 571            kwargs["json"] = {"challenge_ids": challenge_ids}
 572        resp = self.request("POST", "/admin/challenges/pull-all", **kwargs)
 573        _raise_for_node_status(resp)
 574        return _parsed(PullAllQueued, resp)
 575
 576    def get_pull_state(self) -> PullStateRows:
 577        """Fetch every per-challenge pull-state row on this node.
 578
 579        Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``.
 580        ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
 581        ``failed``; ``unpulled`` placeholders are synthesised for specs
 582        the node has seen but never been asked to pull.
 583        """
 584        resp = self.request("GET", "/admin/challenges/pull-state")
 585        _raise_for_node_status(resp)
 586        return _parsed(PullStateRows, resp)
 587
 588    # -- status / health ----------------------------------------------------
 589
 590    def get_status(self, instance_id: str) -> InstanceStatusReport:
 591        resp = self.request("GET", f"/instances/{instance_id}/status")
 592        resp.raise_for_status()
 593        return _parsed(InstanceStatusReport, resp)
 594
 595    def list_instances(self) -> list[NodeInstanceSummary]:
 596        """Every instance the node still has bookkeeping for.
 597
 598        Used by the platform's boot re-adoption pass to decide which
 599        durable claims still have containers behind them. ``get_status``
 600        answers the same question one id at a time; asking it per claim
 601        would cost one round trip per instance to a node that may be
 602        slow or down, on the path that gates startup.
 603        """
 604        resp = self.request("GET", "/instances")
 605        resp.raise_for_status()
 606        return _parsed(NodeInstanceList, resp).items
 607
 608    def check_health(self, instance_id: str) -> bool:
 609        return self.check_health_verbose(instance_id).healthy
 610
 611    def check_health_verbose(self, instance_id: str) -> HealthReading:
 612        """Health plus whatever agent-deception telemetry the node read.
 613
 614        One call, because the platform already polls this endpoint for
 615        every live instance every tick and a second round trip per
 616        instance would be the whole cost of the feature. ``check_health``
 617        stays a bool for the callers that only ever wanted one.
 618
 619        Never an error, whatever arrives: this rides the health poll,
 620        and telemetry must not be able to mark a healthy instance
 621        unhealthy.
 622
 623        ⚠️ **The two ways this can come back empty are different facts
 624        and used to be one.** An older node sends no ``deception`` key
 625        at all — it *did not say*, which is ``channel=None`` and
 626        authorises nothing. A body this platform cannot parse is
 627        something else: the node did say, and we could not read it. That
 628        is ``unreadable``, the same value the reader gives for a missing
 629        file, because it is the same fact — there is no usable channel
 630        here — arriving through a different door.
 631
 632        The parse failure that matters is version skew in the other
 633        direction: a **newer** node reporting an ``ObservationKind`` this
 634        platform does not have. ``Observation.kind`` is typed, so one
 635        unknown member fails the whole model rather than dropping one
 636        row — which is the right refusal (the node's word is not ours to
 637        edit) and exactly why it must not land as "reported nothing".
 638        """
 639        resp = self.request("GET", f"/instances/{instance_id}/health")
 640        resp.raise_for_status()
 641        body = resp.json()
 642        raw = body.get("deception")
 643        if not raw:
 644            summary = DeceptionSummary()
 645        else:
 646            try:
 647                summary = DeceptionSummary.model_validate(raw)
 648            except ValidationError as exc:
 649                log.info(
 650                    "unreadable deception telemetry from node",
 651                    instance_id=instance_id,
 652                    error=tail(str(exc), limit=200),
 653                )
 654                summary = DeceptionSummary(channel="unreadable")
 655        raw_contact = body.get("contact")
 656        if not raw_contact:
 657            contact = ContactReading()
 658        else:
 659            try:
 660                contact = ContactReading.model_validate(raw_contact)
 661            except ValidationError as exc:
 662                log.info(
 663                    "unreadable contact telemetry from node",
 664                    instance_id=instance_id,
 665                    error=tail(str(exc), limit=200),
 666                )
 667                contact = ContactReading(channel="unreadable")
 668        raw_procs = body.get("new_processes")
 669        # ⚠️ Shape-checked rather than trusted: an older node sends
 670        # nothing and a newer one could send a shape this platform does
 671        # not model, and neither may reach a reader as *a container full
 672        # of intruders*. Anything unreadable is silence, which for a
 673        # signal built to under-report is the honest reading.
 674        procs: dict[str, list[str]] = {}
 675        if isinstance(raw_procs, dict):
 676            for container, names in raw_procs.items():
 677                if isinstance(container, str) and isinstance(names, list):
 678                    procs[container] = [str(n) for n in names]
 679        return HealthReading(
 680            healthy=bool(body.get("is_healthy")),
 681            deception=summary,
 682            contact=contact,
 683            new_processes=procs,
 684        )
 685
 686    def node_health(self) -> dict[str, Any]:
 687        """Liveness + ``{running, capacity}`` for heartbeat."""
 688        resp = self.request("GET", "/health")
 689        resp.raise_for_status()
 690        body: dict[str, Any] = resp.json()
 691        return body
 692
 693    # -- traffic / runtime --------------------------------------------------
 694
 695    def get_traffic_page(
 696        self, instance_id: str, *, cursor: str = "", limit: int = 0
 697    ) -> InstanceTrafficPage:
 698        """One page of mitmproxy flows; the capture lives on the node's FS.
 699
 700        ⚠️ **The one method that must not use ``_parsed``.** A node from
 701        before paging answers with a bare *list* — it sent the whole
 702        capture and understands no cursor — and a strict
 703        ``model_validate`` turns that supported skew into a raised
 704        request. So the body goes through ``coerce_page``, which already
 705        owns every decision about an unexpected shape, and the caller
 706        gets the model.
 707
 708        This is the same move ``coerce_page`` was written for, one layer
 709        down. Its docstring used to say the parsing belonged
 710        "platform-side … so a route and this client cannot come to
 711        disagree" — and there were *three* platform-side calls doing it.
 712        There is one call now, at the seam that knows the request
 713        cursor, which is what the fallback needs; the routes hold the
 714        type instead of re-deciding its shape.
 715
 716        ⚠️ **An empty ``cursor`` means "from the beginning".** Passing
 717        one on every poll re-reads the whole capture, which is the cost
 718        the paged read exists to remove — arriving through the caller
 719        rather than through the node.
 720        """
 721        params: dict[str, Any] = {}
 722        if cursor:
 723            params["cursor"] = cursor
 724        if limit > 0:
 725            params["limit"] = limit
 726        resp = self.request("GET", f"/instances/{instance_id}/traffic", params=params)
 727        resp.raise_for_status()
 728        page = coerce_page(resp.json(), cursor)
 729        return InstanceTrafficPage(
 730            flows=page.flows,
 731            cursor=page.cursor,
 732            truncated=page.truncated,
 733            capture=page.capture,
 734        )
 735
 736    def get_traffic_witnesses(
 737        self, instance_id: str, *, cursor: str = "", limit: int = 0
 738    ) -> InstanceTrafficWitnesses:
 739        """Who spoke to this box since *cursor*, with no bodies moved.
 740
 741        §7.1's correlation reads one gamebox per team per round. Through
 742        :meth:`get_traffic_page` that is every flow, both bodies
 743        included (~100 kB apiece by ``CLAUDE.md``'s measurement),
 744        pulled across the wire to extract a handful of addresses; the
 745        node reduces instead and this carries the result.
 746
 747        ⚠️ **Strict parsing here, unlike its neighbour, and that is the
 748        difference between an old field and an old route.**
 749        ``get_traffic_page`` is lenient because a node from before
 750        paging answers the *same* path with a bare list, which is
 751        supported skew. This route did not exist on such a node at all,
 752        so the honest answer from one is a 404 — and a lenient parse of
 753        an error body would hand the round loop an empty witness set,
 754        which reads as *nobody was there*. The caller catches the
 755        request error and records the round as unknown.
 756        """
 757        params: dict[str, Any] = {}
 758        if cursor:
 759            params["cursor"] = cursor
 760        if limit > 0:
 761            params["limit"] = limit
 762        resp = self.request("GET", f"/instances/{instance_id}/traffic-witnesses", params=params)
 763        resp.raise_for_status()
 764        return _parsed(InstanceTrafficWitnesses, resp)
 765
 766    def list_containers(self, instance_id: str) -> list[ComposeContainer]:
 767        """Enumerate every container in an instance's compose project.
 768
 769        Backs the admin-shell feature: the platform forwards a
 770        ``GET /admin/instances/{id}/containers`` to the assigned node,
 771        which returns one row per container (challenge services and
 772        platform-injected sidecars alike). The WebSocket reverse-proxy
 773        is opened separately and does not go through ``NodeClient``.
 774        """
 775        resp = self.request("GET", f"/instances/{instance_id}/containers")
 776        _raise_for_node_status(resp)
 777        return _parsed_list(ComposeContainer, resp)
 778
 779    def run_checker(
 780        self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30
 781    ) -> CheckResult:
 782        """Exec an author-supplied checker in a trusted judge sidecar.
 783
 784        Backs the checker / exec-judge feature: the platform forwards a
 785        verify request to the assigned node, which runs ``cmd`` inside the
 786        named sidecar and returns
 787        ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP
 788        timeout sits above the node's exec budget so the client doesn't
 789        abandon the request before the node returns.
 790        """
 791        resp = self.request(
 792            "POST",
 793            f"/instances/{instance_id}/check",
 794            json={"service": service, "cmd": cmd, "timeout_s": timeout_s},
 795            timeout=timeout_s + 10,
 796        )
 797        _raise_for_node_status(resp)
 798        return _parsed(CheckResult, resp)
 799
 800    def run_harness(
 801        self,
 802        *,
 803        image: str,
 804        run_id: str = "",
 805        env: dict[str, str],
 806        timeout_s: int = 1800,
 807        untrusted: bool = False,
 808        network: str = "",
 809        own_network: bool = False,
 810        match_network: str = "",
 811        match_address: str = "",
 812        brief: dict[str, Any] | None = None,
 813        target_instance: str = "",
 814        target_aliases: dict[str, str] | None = None,
 815        pull_ticket: str = "",
 816        limits: HarnessLimits | None = None,
 817    ) -> HarnessRun:
 818        """Run a one-shot evaluation-harness container on the node.
 819
 820        The node ``docker run``s ``image`` with ``env`` injected, waits for it
 821        to exit (bounded by ``timeout_s``), and returns
 822        ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as
 823        :meth:`run_checker`. The harness writes its JSON rollup to stdout. The
 824        per-call HTTP timeout sits above the node's run budget so the client
 825        doesn't abandon the request before the (long-running) harness returns.
 826
 827        ``untrusted`` runs the image under the node's isolation profile —
 828        off host networking, bounded resources, no capabilities — for an
 829        image the platform did not build. It requires a network: the
 830        node answers 400 without one, because "isolate this" and "I will
 831        not say where the target lives" do not compose.
 832
 833        Any of ``network`` (the target instance's own, when the caller
 834        knows it), ``own_network`` or ``target_instance`` satisfies that.
 835        ⚠️ Most eval dispatches need the second: there the harness
 836        *starts* the target partway through its own run, so at ``docker
 837        run`` time no target network exists and there is no name to
 838        send. The node creates a fresh bridge and removes it afterwards.
 839
 840        ``target_instance`` reverses that ordering, and is what an
 841        `engagement` uses: the platform starts the estate first and names
 842        it here, and the node wires its published containers to the
 843        agent's bridge. ⚠️ An **id**, never a network name — the name is
 844        a fact only the node's daemon can read, and ``docker run``
 845        *creates* whatever string it is handed, so a platform guessing
 846        one starts a run that reaches nothing.
 847        """
 848        resp = self.request(
 849            "POST",
 850            "/harness/run",
 851            json={
 852                "image": image,
 853                "env": env,
 854                "timeout_s": timeout_s,
 855                "untrusted": untrusted,
 856                "network": network,
 857                "own_network": own_network,
 858                "match_network": match_network,
 859                # ⚠️ **The platform-assigned arena address.** The node
 860                # connects the agent to the match network with this
 861                # ``--ip``, so the address is known before the round loop
 862                # asks (§7.1). Empty means "let docker's IPAM pick" — an
 863                # older platform's behaviour, and what keeps a node reading
 864                # this field unchanged for every non-arena dispatch.
 865                "match_address": match_address,
 866                # ⚠️ Sent even when empty, so a node reading it gets the
 867                # same shape either way; the node's default is what makes
 868                # an older platform's dispatch unchanged.
 869                "pull_ticket": pull_ticket,
 870                # ⚠️ ``None`` leaves the node's own defaults in force, which is
 871                # what a dispatch said before the ceilings were settings.
 872                "limits": limits.model_dump() if limits is not None else None,
 873                # ⚠️ Same rule, and empty is a real answer here too: a
 874                # dispatch with no run row (the conformance probe, a dry
 875                # run) has nothing to label, and a label with an empty
 876                # value would make every one of them answer one query.
 877                "run_id": run_id,
 878                # ⚠️ Sent as the already-built manifest. The platform is
 879                # the side that knows the challenge, the competition and
 880                # the run mode; a node deriving its own would be a
 881                # second implementation of a published contract.
 882                "brief": brief,
 883                "target_instance": target_instance,
 884                # ⚠️ **The names the brief published, so they resolve on
 885                # the agent's bridge too.** An estate's hosts answer to a
 886                # per-instance FQDN minted by its own resolver; joined to
 887                # a new network a container answers only to its compose
 888                # *service* name. Sending the mapping is what keeps the
 889                # address the agent was told about true under either
 890                # placement — without it the agent is handed a hostname
 891                # that does not resolve, reaches nothing, and reports an
 892                # empty result, which reads as a model that gave up.
 893                #
 894                # ⚠️ The platform's to send: it computed the surface. A
 895                # node re-deriving it from the rendered compose would be
 896                # a second implementation of the address the brief has
 897                # already promised.
 898                "target_aliases": target_aliases or {},
 899            },
 900            timeout=timeout_s + 30,
 901        )
 902        _raise_for_node_status(resp)
 903        return _parsed(HarnessRun, resp)
 904
 905    def cancel_harness(self, *, run_id: str) -> int:
 906        """Stop whatever this node is running for ``run_id``; returns how many.
 907
 908        Until this existed a dispatched container was reclaimed by
 909        **nothing but its own timeout** — ``_harness_timeout``'s docstring
 910        says exactly that — so an operator pressing cancel on a
 911        match-long run was asking for something the platform had no way
 912        to deliver.
 913
 914        ⚠️ **Zero is an ordinary answer, not a failure.** The run may have
 915        finished on its own, been dispatched to a different node, or been
 916        lost to a node restart. A caller that read zero as an error would
 917        report a broken platform every time a cancel arrived a second
 918        late.
 919        """
 920        resp = self.request("POST", "/harness/cancel", json={"run_id": run_id})
 921        body = resp.json()
 922        return int(body.get("stopped", 0)) if isinstance(body, dict) else 0
 923
 924    def verify_patch(
 925        self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900
 926    ) -> PatchVerifyResult:
 927        """Build a submitted patch on the node and return its verdict.
 928
 929        ``files`` maps a challenge-relative path to its new bytes; base64
 930        is applied here because a patch target may legitimately be
 931        binary. Returns ``{verdict, detail, applied, steps, error}``.
 932
 933        **An empty ``verdict`` with a populated ``error`` is not a
 934        judgement** — it means the node refused the submission or the
 935        verifier crashed. The caller must retry or retire the lease
 936        rather than write a score, since failing to judge a patch is not
 937        the same as judging it unfixed.
 938
 939        The per-call HTTP timeout sits above the node's own budget so the
 940        client doesn't abandon the request while the node is still
 941        building.
 942        """
 943        resp = self.request(
 944            "POST",
 945            "/patches/verify",
 946            json={
 947                "challenge_id": challenge_id,
 948                "files": {
 949                    path: base64.b64encode(content).decode() for path, content in files.items()
 950                },
 951                "timeout_s": timeout_s,
 952            },
 953            timeout=timeout_s + 60,
 954        )
 955        _raise_for_node_status(resp)
 956        return _parsed(PatchVerifyResult, resp)
 957
 958    def collect_patch_files(
 959        self, instance_id: str, service: str, paths: list[str]
 960    ) -> dict[str, bytes]:
 961        """Read a player's SSH edits back out of their running box.
 962
 963        ``paths`` are absolute in-container paths the *platform* derived
 964        from the pristine source tree. Returns only what could be read:
 965        a path missing from the reply means the player did not change it,
 966        and the caller falls back to the shipped file rather than
 967        recording a deletion they never made.
 968        """
 969        resp = self.request(
 970            "POST",
 971            f"/instances/{instance_id}/patch-collect",
 972            json={"service": service, "paths": paths},
 973        )
 974        _raise_for_node_status(resp)
 975        body: dict[str, str] = resp.json()
 976        return {path: base64.b64decode(blob) for path, blob in body.items()}
 977
 978    def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
 979        """Rotate a round's flags into every box of this node, in one call.
 980
 981        ⚠️ **One call for the whole node, never one per box.** §5.2's
 982        capacity red line is 1000 teams x 3 services injected inside
 983        120 s; per-box that is 3000 round trips, batched it is about
 984        fourteen. The signature is the enforcement — a caller cannot
 985        accidentally loop.
 986
 987        Returns ``{instance_id: ""}`` for the boxes written and a reason
 988        for the ones that were not, because one team's unreachable box
 989        must not cost every other team on the node its rotation. An
 990        unrotated box keeps serving last round's flag, which anyone
 991        holding the old value can replay.
 992        """
 993        resp = self.request("POST", "/awd/answers", json={"boxes": boxes})
 994        _raise_for_node_status(resp)
 995        result: dict[str, str] = resp.json()
 996        return result
 997
 998    def probe_awd_service(
 999        self,
1000        *,
1001        instance_id: str,
1002        service_id: str,
1003        tick: int,
1004        flag: str,
1005        previous_flag: str = "",
1006        previous_flag_id: str = "",
1007    ) -> dict[str, str]:
1008        """Run one round's SLA probes against one box, from outside it.
1009
1010        The flags travel *to* the node: a checker that sourced its
1011        expected value from the box it is checking would pass on any box
1012        that returns whatever it was handed.
1013        """
1014        resp = self.request(
1015            "POST",
1016            "/awd/probe",
1017            json={
1018                "instance_id": instance_id,
1019                "service_id": service_id,
1020                "tick": tick,
1021                "flag": flag,
1022                "previous_flag": previous_flag,
1023                "previous_flag_id": previous_flag_id,
1024            },
1025        )
1026        _raise_for_node_status(resp)
1027        result: dict[str, str] = resp.json()
1028        return result
1029
1030    def attack_awd_box(
1031        self,
1032        *,
1033        instance_id: str,
1034        tick: int,
1035        timeout_s: int = 60,
1036        agent_image: str = "",
1037        counter_flag: str = "",
1038        forensic_marker: str = "",
1039        llm_api_key: str = "",
1040    ) -> AwdAttackResult:
1041        """Run this box's own reference exploit against it, once.
1042
1043        Per **box**, not per service: one `run_exploit` discovers every
1044        answer the challenge declares and prints them all.
1045
1046        ``timeout_s`` is sent because the platform is what knows how long
1047        a round is; the node clamps it, because a container held open for
1048        an hour is the node's problem whoever asked for it.
1049
1050        ``forensic_marker`` is §4.3.1's, carried only by the agent
1051        branch: `build_attack_argv` passes no environment at all, so a
1052        marker on the reference exploit would be the one value that
1053        voids `EXPLOIT007`'s guarantee.
1054
1055        ``agent_image`` + ``counter_flag`` switch the node to §4.2b's
1056        agent attacker. ⚠️ **Both or neither** — the node refuses a half
1057        request rather than falling back to the reference exploit, which
1058        would run a different format than the one configured while the
1059        board looked entirely normal.
1060        """
1061        resp = self.request(
1062            "POST",
1063            "/awd/attack",
1064            json={
1065                "instance_id": instance_id,
1066                "tick": tick,
1067                "timeout_s": timeout_s,
1068                "agent_image": agent_image,
1069                "counter_flag": counter_flag,
1070                "forensic_marker": forensic_marker,
1071                "llm_api_key": llm_api_key,
1072            },
1073        )
1074        _raise_for_node_status(resp)
1075        return _parsed(AwdAttackResult, resp)
1076
1077    def get_container_logs(self, instance_id: str) -> str:
1078        """Fetch combined container stdout/stderr for archival."""
1079        resp = self.request("GET", f"/instances/{instance_id}/container-logs")
1080        resp.raise_for_status()
1081        return str(resp.json().get("logs") or "")
1082
1083    def get_pcap(self, instance_id: str) -> PcapCapture:
1084        """Fetch the merged tcpdump capture for *instance_id*.
1085
1086        ``data`` is empty when no capture exists (sidecar disabled,
1087        instance never reached the running state, etc.) so callers can
1088        persist conditionally without try/except.
1089
1090        ⚠️ **It also says what the per-instance cap already dropped**
1091        (#1714), off the node's response headers, and ``None`` when the
1092        node did not say — an older node, which is *not reported*, never
1093        a clean capture.
1094        """
1095        resp = self.request("GET", f"/instances/{instance_id}/pcap")
1096        if resp.status_code == 404:
1097            return PcapCapture(data=b"")
1098        resp.raise_for_status()
1099        return PcapCapture(
1100            data=resp.content,
1101            dropped_bytes=_header_int(resp, CAPTURE_DROPPED_BYTES_HEADER),
1102            dropped_segments=_header_int(resp, CAPTURE_DROPPED_SEGMENTS_HEADER),
1103        )
1104
1105    def get_harness_artifact(self, artifact_id: str) -> bytes:
1106        """One finished run's collected archive, or ``b""``.
1107
1108        ⚠️ **Empty rather than an exception for a 404**, following
1109        :meth:`get_pcap`. The node reaps uncollected archives, and a
1110        platform that comes back after a restart is exactly the caller
1111        that meets one that is gone — a state its own record already
1112        describes, not a transport failure.
1113        """
1114        resp = self.request("GET", f"/harness/artifacts/{artifact_id}")
1115        if resp.status_code == 404:
1116            return b""
1117        resp.raise_for_status()
1118        return resp.content
1119
1120    def delete_harness_artifact(self, artifact_id: str) -> bool:
1121        """Tell the node the archive has been stored and can go.
1122
1123        ⚠️ **Never raises on a transport failure**, because it is called
1124        after the bytes are safely in object storage: turning a cleanup
1125        error into the run's error would replace a success with a
1126        janitorial complaint. The node's reaper collects whatever this
1127        misses.
1128        """
1129        try:
1130            resp = self.request("DELETE", f"/harness/artifacts/{artifact_id}")
1131        except Exception:
1132            return False
1133        return resp.status_code == 200
1134
1135    def get_agent_runtime(self, instance_id: str) -> AgentRuntime:
1136        """Provider-agnostic runtime hints for attaching an agent (proxy URL,
1137        CA PEM, optional Docker-specific names). Replaces the old
1138        sandbox-network shape."""
1139        resp = self.request("GET", f"/instances/{instance_id}/agent-runtime")
1140        resp.raise_for_status()
1141        return _parsed(AgentRuntime, resp)
1142
1143    # -- per-instance rendered attachments ---------------------------------
1144
1145    def list_instance_attachments(self, instance_id: str) -> AttachmentList:
1146        """List per-instance attachments rendered into the node's workdir.
1147
1148        Backs the platform's per-instance attachment endpoint, which
1149        surfaces the team-specific rendered file listing.
1150
1151        ⚠️ This used to hand back the raw dict, under a docstring saying
1152        "caller projects through :class:`AttachmentList` for typing" —
1153        the node's route already declared that model, so the type existed
1154        and this method's signature was the one place it was dropped.
1155        Projecting *here* means there is one place a malformed answer is
1156        caught rather than one per caller."""
1157        resp = self.request("GET", f"/instances/{instance_id}/attachments")
1158        resp.raise_for_status()
1159        return _parsed(AttachmentList, resp)
1160
1161    def get_vpn_config(self, instance_id: str) -> bytes:
1162        """Fetch the per-instance VPN client config from the node.
1163
1164        The node reads the backend's client config out of the
1165        instance's VPN service container (generated on first boot by
1166        the base image's bootstrap) and rewrites its endpoint to the
1167        node's public host:port. Returns the body verbatim — the
1168        platform-side route adds the ``Content-Disposition`` header
1169        before re-emitting to the player, because only it knows the
1170        backend and therefore the filename the player needs.
1171
1172        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1173        proxy route catches 404 and re-emits to the player, anything
1174        else is treated as a 502.
1175        """
1176        resp = self.request("GET", f"/instances/{instance_id}/vpn-config")
1177        resp.raise_for_status()
1178        return resp.content
1179
1180    def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
1181        """Download one per-instance attachment as ``(bytes, content_type)``.
1182
1183        Buffers the whole body — attachments are bounded (typical case
1184        is a 10 KB binary or a small text file). For huge captures the
1185        caller should hand the player a CDN URL instead.
1186
1187        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1188        proxy route catches 404 and re-emits to the player, anything
1189        else is a 502."""
1190        resp = self.request(
1191            "GET",
1192            f"/instances/{instance_id}/attachments/{filename}",
1193        )
1194        resp.raise_for_status()
1195        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, transport: httpx.BaseTransport | None = None)
195    def __init__(
196        self,
197        node_url: str,
198        token: str,
199        *,
200        timeout: int = DEFAULT_CLIENT_TIMEOUT,
201        transport: httpx.BaseTransport | None = None,
202    ) -> None:
203        """*transport* is `BaseHttpClient`'s own seam, passed through so a
204        test can drive a real node app over the real client — the hop the
205        fakes on either side of it cannot see."""
206        super().__init__(
207            f"{node_url.rstrip('/')}/api/v1", token, timeout=timeout, transport=transport
208        )

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.

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 = '') -> ctfy.server.models.node.InstanceStarted:
212    def start_instance(
213        self,
214        *,
215        challenge_id: str,
216        instance_id: str,
217        ttl: int,
218        answers: dict[str, str],
219        proxy_output_dir: str | None = None,
220        env: dict[str, str] | None = None,
221        publish_gamebox: bool = False,
222        match_network: MatchNetwork | None = None,
223        open_shell_password: str = "",
224        authored_revision: str = "",
225    ) -> InstanceStarted:
226        resp = self.request(
227            "POST",
228            "/instances",
229            json={
230                # #1835: the authored tree's revision this start must run;
231                # a node holding another (or none) fetches it first.
232                "authored_revision": authored_revision,
233                "challenge_id": challenge_id,
234                "instance_id": instance_id,
235                "ttl": ttl,
236                "answers": answers,
237                "proxy_output_dir": proxy_output_dir,
238                "env": env or {},
239                "publish_gamebox": publish_gamebox,
240                # ``None`` rather than an omitted key: the node's model
241                # defaults to None either way, and sending it explicitly
242                # keeps the body's shape stable across both branches.
243                "match_network": (dataclasses.asdict(match_network) if match_network else None),
244                # Classic-AWD open shell (#1725): the box's starting root
245                # password, empty for every managed / non-AWD start.
246                "open_shell_password": open_shell_password,
247            },
248        )
249        _raise_for_node_status(resp)
250        return _parsed(InstanceStarted, resp)
def stop_instance(self, instance_id: str) -> None:
252    def stop_instance(self, instance_id: str) -> None:
253        resp = self.request("DELETE", f"/instances/{instance_id}")
254        resp.raise_for_status()
def apply_fabric( self, *, index: int, interface: dict[str, typing.Any], match_network: str = '') -> ctfy.server.models.node.FabricStatus:
256    def apply_fabric(
257        self,
258        *,
259        index: int,
260        interface: dict[str, Any],
261        match_network: str = "",
262    ) -> FabricStatus:
263        """Put this node on one match's WireGuard fabric.
264
265        ⚠️ **Returns the model, never ``resp.json()``.** The platform
266        reads the verdict field by field, and on a dict a renamed field
267        is ``None`` — falsy — which reads as "the arena did not open",
268        so a wire change would quietly report every node as broken.
269
270        Returns the node's verdict rather than raising on a refusal: the
271        route answers **200 with a body** precisely so that "this node is
272        unreachable" and "this node rejected the plan" stay different
273        facts, and a caller fanning out over an arena needs to tell them
274        apart — one is waited on, the other is looked at.
275
276        *match_network* lets the node check the tunnel landed in the
277        namespace its boxes are in, and open the bridge's one hole. It is
278        the platform's own fact (`awd_network.network_name`), so the node
279        is told rather than guessing.
280        """
281        resp = self.request(
282            "POST",
283            "/fabric",
284            json={
285                "index": index,
286                "interface": interface,
287                "match_network": match_network,
288            },
289        )
290        resp.raise_for_status()
291        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:
293    def probe_fabric(
294        self,
295        *,
296        index: int,
297        peers: list[dict[str, str]],
298        mtu: int = 0,
299    ) -> FabricProbeReport:
300        """Ask named peers the three link questions. Applies nothing.
301
302        ⚠️ **Returns the model, never ``resp.json()``** — same reason
303        `apply_fabric` gives: on a dict a renamed field reads as falsy,
304        which here means "your tunnel is down" about a wire change.
305        """
306        resp = self.request(
307            "POST",
308            "/fabric/probe",
309            json={"index": index, "peers": peers, "mtu": mtu},
310        )
311        resp.raise_for_status()
312        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:
314    def remove_fabric(self, *, index: int, match_network: str = "") -> FabricStatus:
315        """Take this node off one match's fabric and close its hole.
316
317        ⚠️ *match_network* is what lets the node resolve the bridge and
318        delete the ``DOCKER-USER`` rules by name. Without it the tunnel
319        goes and the hole stays — open on whatever docker gives that
320        bridge name next.
321        """
322        resp = self.request(
323            "DELETE",
324            f"/fabric/{index}",
325            params={"match_network": match_network},
326        )
327        resp.raise_for_status()
328        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:
330    def stop_instance_service(self, instance_id: str, service: str) -> int:
331        """Stop one compose service, leaving the instance running.
332
333        The exercise format's session close (§4b.4). Returns how many
334        containers the node stopped — 0 means the challenge declares no
335        such service, which is a real answer rather than a failure, so
336        the caller can distinguish it from a cut tunnel.
337        """
338        resp = self.request(
339            "POST",
340            f"/instances/{instance_id}/stop-service",
341            json={"service": service},
342        )
343        resp.raise_for_status()
344        body = resp.json()
345        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 stop_all(self) -> None:
347    def stop_all(self) -> None:
348        resp = self.request("POST", "/admin/stop-all")
349        resp.raise_for_status()
def put_authored_challenge( self, push: ctfy.server.models.NodeAuthoredPush) -> ctfy.server.models.NodeAuthoredResult:
351    def put_authored_challenge(self, push: NodeAuthoredPush) -> NodeAuthoredResult:
352        """Ask the node to write an approved player upload into its catalog.
353
354        ⚠️ **The bytes travel here, unlike `put_generated_range`.** That
355        one sends a scenario because the node can compile it; a
356        player-authored tree has no compiler and no corpus entry, so the
357        archive is what there is to send.
358
359        The node answers **200 with ``ok: false``** for a refusal, so a
360        node that declined the archive and a node that could not be
361        reached stay different facts; only the second raises here.
362        """
363        resp = self.request("POST", "/admin/authored", json=push.model_dump(mode="json"))
364        resp.raise_for_status()
365        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:
367    def smoke_authored_challenge(
368        self, push: NodeAuthoredSmokePush, *, timeout: int | None = None
369    ) -> NodeAuthoredSmokeResult:
370        """Ask the node to run an authored challenge's own reference exploit.
371
372        ⚠️ **Takes its own timeout.** A smoke run brings a whole estate
373        up; the client default is sized for a status call and would give
374        up while the node is still doing what it was asked.
375        """
376        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
377        if timeout is not None:
378            kwargs["timeout"] = timeout
379        resp = self.request("POST", "/admin/authored/smoke", **kwargs)
380        resp.raise_for_status()
381        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 put_generated_range( self, push: ctfy.server.models.NodeRangePush) -> ctfy.server.models.NodeRangeResult:
383    def put_generated_range(self, push: NodeRangePush) -> NodeRangeResult:
384        """Ask the node to build a generated range from its scenario.
385
386        ⚠️ **The scenario travels, not the estate.** The corpus is
387        mounted on the node and the compiler ships inside it, so 70-odd
388        rendered files never cross this API — and the corpus commit that
389        rides along is what stops one scenario becoming two different
390        estates under one id across a fleet.
391
392        The node answers **200 with ``ok: false``** for a refusal, so a
393        node that declined and a node that could not be reached stay
394        different facts; only the second raises here.
395        """
396        resp = self.request("POST", "/admin/ranges", json=push.model_dump(mode="json"))
397        resp.raise_for_status()
398        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:
400    def verify_generated_range(
401        self, push: NodeRangeVerifyPush, *, timeout: int | None = None
402    ) -> NodeRangeVerifyResult:
403        """Ask the node to build a scenario, solve it, and take it down.
404
405        ⚠️ **The longest call in this API**, so the caller passes its own
406        timeout: the node's budget is ``compose_timeout_s +
407        solve_timeout_s`` and a client that used the default would give
408        up while the node was still building — then retry, and start a
409        second estate beside the first.
410
411        ⚠️ A client timeout does **not** leave a range running: the node
412        tears down in its own ``finally``. What is lost is the answer,
413        not the host.
414
415        The node answers **200 with an ``error``** for a refusal, the
416        same split ``put_generated_range`` uses; only an unreachable node
417        raises.
418        """
419        kwargs: dict[str, Any] = {"json": push.model_dump(mode="json")}
420        if timeout is not None:
421            # httpx takes a per-request override; the client-wide value
422            # stays whatever the caller constructed this with.
423            kwargs["timeout"] = timeout
424        resp = self.request("POST", "/admin/ranges/verify", **kwargs)
425        resp.raise_for_status()
426        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:
428    def cancel_generated_range_verify(self, run_id: str) -> bool:
429        """Tell the node to stop a verification it is running.
430
431        ⚠️ **A miss is success, not a 404 to report.** The cancel races
432        the run's own finish, so "no such run" means the run already
433        ended — which is what the operator asked for. The node answers
434        ``{"cancelled": false}`` for that case rather than raising, and
435        this returns it so a caller that wants to say "it had already
436        finished" can.
437        """
438        resp = self.request("POST", f"/admin/ranges/verify/{run_id}/cancel")
439        resp.raise_for_status()
440        body = resp.json()
441        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:
443    def rescan_challenges(self) -> ChallengeReloadResult:
444        """Tell the node to drop its spec cache and re-scan challenges_dir.
445
446        Returns ``{total, added, removed}`` so the platform can report
447        the per-node outcome of a cluster-wide rescan."""
448        resp = self.request("POST", "/admin/rescan-challenges")
449        resp.raise_for_status()
450        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 = '') -> ctfy.server.node_state.ChallengeBuildState:
454    def build_challenge(
455        self, challenge_id: str, *, authored_revision: str = ""
456    ) -> ChallengeBuildState:
457        """Ask the node to pre-build images for *challenge_id*.
458
459        Fires-and-returns: the node persists ``status="building"`` and
460        spawns a daemon thread; the body returned here is that initial
461        state row. Polling :meth:`get_build_state` is how the platform
462        learns when it lands on ``built`` / ``failed``. *authored_revision*
463        is #1835's: the tree the build must be of, fetched if not held.
464        """
465        params = {"authored_revision": authored_revision} if authored_revision else None
466        resp = self.request("POST", f"/admin/challenges/{challenge_id}/build", params=params)
467        _raise_for_node_status(resp)
468        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) -> ctfy.server.models.node.BuildAllQueued:
470    def build_all_challenges(self, challenge_ids: list[str] | None = None) -> BuildAllQueued:
471        """Queue background pre-build on the node.
472
473        With ``challenge_ids=None`` the node builds every spec it knows.
474        With an explicit list (the platform scoping a bulk build to a
475        competition's challenge set) only those are built. Returns
476        ``{queued, skipped_built, skipped_in_progress}`` so the platform
477        can report per-node what got picked up. Sequential on the node
478        side — no fan-out across the corpus.
479        """
480        kwargs: dict[str, Any] = {}
481        if challenge_ids is not None:
482            kwargs["json"] = {"challenge_ids": challenge_ids}
483        resp = self.request("POST", "/admin/challenges/build-all", **kwargs)
484        _raise_for_node_status(resp)
485        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 = '', timeout_s: int = 1800, cleanup: bool = False, cache_max_bytes: int = 0, confine_network: bool = False, authored_revision: str = '') -> ctfy.core.prebuild_tag.ChallengeFreezeResult:
487    def build_push_challenge(
488        self,
489        challenge_id: str,
490        *,
491        push_ticket: str = "",
492        timeout_s: int = 1800,
493        cleanup: bool = False,
494        cache_max_bytes: int = 0,
495        confine_network: bool = False,
496        authored_revision: str = "",
497    ) -> ChallengeFreezeResult:
498        """Build one challenge's ``build:`` services and freeze them to the registry.
499
500        Layer 2 producer (P0). **Blocks** until the challenge is built +
501        pushed (or skipped as already frozen), unlike the fire-and-return
502        :meth:`build_challenge`, so the per-call HTTP timeout sits above
503        the node's build budget. Returns the per-service freeze result;
504        the platform's ``challenge_build_push`` task calls this one
505        challenge at a time. *push_ticket* is the platform's short-lived
506        registry credential the node's ``docker push`` authenticates with
507        — the node runs it in a container with no ambient login.
508        *cleanup* asks the node to remove what the build left and prune its
509        build cache to *cache_max_bytes* afterwards, and *confine_network*
510        keeps every ``RUN`` off the host's network (both the freeze pool, #1827).
511        """
512        resp = self.request(
513            "POST",
514            f"/admin/challenges/{challenge_id}/build-push",
515            json={
516                "push_ticket": push_ticket,
517                "cleanup": cleanup,
518                "cache_max_bytes": cache_max_bytes,
519                "confine_network": confine_network,
520                "authored_revision": authored_revision,
521            },
522            timeout=timeout_s + 60,
523        )
524        _raise_for_node_status(resp)
525        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(), so the per-call HTTP timeout sits above the node's build budget. 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).

def get_disk_free(self) -> ctfy.server.models.node.NodeDiskFree:
527    def get_disk_free(self) -> NodeDiskFree:
528        """Free space on the node's data filesystem, read now (#1827).
529
530        Raises on any failure — including a node predating the route, which
531        answers 404 — and the freeze reads every failure as *cannot tell*,
532        which stops it.
533        """
534        resp = self.request("GET", "/admin/disk")
535        _raise_for_node_status(resp)
536        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:
538    def get_build_state(self) -> BuildStateRows:
539        """Fetch every per-challenge build-state row on this node.
540
541        Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``.
542        ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
543        ``failed``; ``unbuilt`` placeholders are synthesised for specs
544        the node has seen but never been asked to build.
545        """
546        resp = self.request("GET", "/admin/challenges/build-state")
547        _raise_for_node_status(resp)
548        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:
550    def pull_challenge(self, challenge_id: str) -> ChallengePullState:
551        """Ask the node to pre-pull registry images for *challenge_id*.
552
553        Pull-side twin of :meth:`build_challenge`: the node persists
554        ``status="pulling"`` and spawns a daemon thread; poll
555        :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing.
556        """
557        resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull")
558        _raise_for_node_status(resp)
559        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:
561    def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> PullAllQueued:
562        """Queue background pre-pull on the node.
563
564        With ``challenge_ids=None`` the node pulls every spec it knows;
565        with an explicit list only those (the platform scoping to a
566        competition). Returns ``{queued, skipped_pulled,
567        skipped_in_progress}``.
568        """
569        kwargs: dict[str, Any] = {}
570        if challenge_ids is not None:
571            kwargs["json"] = {"challenge_ids": challenge_ids}
572        resp = self.request("POST", "/admin/challenges/pull-all", **kwargs)
573        _raise_for_node_status(resp)
574        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:
576    def get_pull_state(self) -> PullStateRows:
577        """Fetch every per-challenge pull-state row on this node.
578
579        Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``.
580        ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
581        ``failed``; ``unpulled`` placeholders are synthesised for specs
582        the node has seen but never been asked to pull.
583        """
584        resp = self.request("GET", "/admin/challenges/pull-state")
585        _raise_for_node_status(resp)
586        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:
590    def get_status(self, instance_id: str) -> InstanceStatusReport:
591        resp = self.request("GET", f"/instances/{instance_id}/status")
592        resp.raise_for_status()
593        return _parsed(InstanceStatusReport, resp)
def list_instances(self) -> list[ctfy.server.models.node.NodeInstanceSummary]:
595    def list_instances(self) -> list[NodeInstanceSummary]:
596        """Every instance the node still has bookkeeping for.
597
598        Used by the platform's boot re-adoption pass to decide which
599        durable claims still have containers behind them. ``get_status``
600        answers the same question one id at a time; asking it per claim
601        would cost one round trip per instance to a node that may be
602        slow or down, on the path that gates startup.
603        """
604        resp = self.request("GET", "/instances")
605        resp.raise_for_status()
606        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:
608    def check_health(self, instance_id: str) -> bool:
609        return self.check_health_verbose(instance_id).healthy
def check_health_verbose(self, instance_id: str) -> ctfy.sdk.node_client.HealthReading:
611    def check_health_verbose(self, instance_id: str) -> HealthReading:
612        """Health plus whatever agent-deception telemetry the node read.
613
614        One call, because the platform already polls this endpoint for
615        every live instance every tick and a second round trip per
616        instance would be the whole cost of the feature. ``check_health``
617        stays a bool for the callers that only ever wanted one.
618
619        Never an error, whatever arrives: this rides the health poll,
620        and telemetry must not be able to mark a healthy instance
621        unhealthy.
622
623        ⚠️ **The two ways this can come back empty are different facts
624        and used to be one.** An older node sends no ``deception`` key
625        at all — it *did not say*, which is ``channel=None`` and
626        authorises nothing. A body this platform cannot parse is
627        something else: the node did say, and we could not read it. That
628        is ``unreadable``, the same value the reader gives for a missing
629        file, because it is the same fact — there is no usable channel
630        here — arriving through a different door.
631
632        The parse failure that matters is version skew in the other
633        direction: a **newer** node reporting an ``ObservationKind`` this
634        platform does not have. ``Observation.kind`` is typed, so one
635        unknown member fails the whole model rather than dropping one
636        row — which is the right refusal (the node's word is not ours to
637        edit) and exactly why it must not land as "reported nothing".
638        """
639        resp = self.request("GET", f"/instances/{instance_id}/health")
640        resp.raise_for_status()
641        body = resp.json()
642        raw = body.get("deception")
643        if not raw:
644            summary = DeceptionSummary()
645        else:
646            try:
647                summary = DeceptionSummary.model_validate(raw)
648            except ValidationError as exc:
649                log.info(
650                    "unreadable deception telemetry from node",
651                    instance_id=instance_id,
652                    error=tail(str(exc), limit=200),
653                )
654                summary = DeceptionSummary(channel="unreadable")
655        raw_contact = body.get("contact")
656        if not raw_contact:
657            contact = ContactReading()
658        else:
659            try:
660                contact = ContactReading.model_validate(raw_contact)
661            except ValidationError as exc:
662                log.info(
663                    "unreadable contact telemetry from node",
664                    instance_id=instance_id,
665                    error=tail(str(exc), limit=200),
666                )
667                contact = ContactReading(channel="unreadable")
668        raw_procs = body.get("new_processes")
669        # ⚠️ Shape-checked rather than trusted: an older node sends
670        # nothing and a newer one could send a shape this platform does
671        # not model, and neither may reach a reader as *a container full
672        # of intruders*. Anything unreadable is silence, which for a
673        # signal built to under-report is the honest reading.
674        procs: dict[str, list[str]] = {}
675        if isinstance(raw_procs, dict):
676            for container, names in raw_procs.items():
677                if isinstance(container, str) and isinstance(names, list):
678                    procs[container] = [str(n) for n in names]
679        return HealthReading(
680            healthy=bool(body.get("is_healthy")),
681            deception=summary,
682            contact=contact,
683            new_processes=procs,
684        )

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]:
686    def node_health(self) -> dict[str, Any]:
687        """Liveness + ``{running, capacity}`` for heartbeat."""
688        resp = self.request("GET", "/health")
689        resp.raise_for_status()
690        body: dict[str, Any] = resp.json()
691        return body

Liveness + {running, capacity} for heartbeat.

def get_traffic_page( self, instance_id: str, *, cursor: str = '', limit: int = 0) -> ctfy.server.models.InstanceTrafficPage:
695    def get_traffic_page(
696        self, instance_id: str, *, cursor: str = "", limit: int = 0
697    ) -> InstanceTrafficPage:
698        """One page of mitmproxy flows; the capture lives on the node's FS.
699
700        ⚠️ **The one method that must not use ``_parsed``.** A node from
701        before paging answers with a bare *list* — it sent the whole
702        capture and understands no cursor — and a strict
703        ``model_validate`` turns that supported skew into a raised
704        request. So the body goes through ``coerce_page``, which already
705        owns every decision about an unexpected shape, and the caller
706        gets the model.
707
708        This is the same move ``coerce_page`` was written for, one layer
709        down. Its docstring used to say the parsing belonged
710        "platform-side … so a route and this client cannot come to
711        disagree" — and there were *three* platform-side calls doing it.
712        There is one call now, at the seam that knows the request
713        cursor, which is what the fallback needs; the routes hold the
714        type instead of re-deciding its shape.
715
716        ⚠️ **An empty ``cursor`` means "from the beginning".** Passing
717        one on every poll re-reads the whole capture, which is the cost
718        the paged read exists to remove — arriving through the caller
719        rather than through the node.
720        """
721        params: dict[str, Any] = {}
722        if cursor:
723            params["cursor"] = cursor
724        if limit > 0:
725            params["limit"] = limit
726        resp = self.request("GET", f"/instances/{instance_id}/traffic", params=params)
727        resp.raise_for_status()
728        page = coerce_page(resp.json(), cursor)
729        return InstanceTrafficPage(
730            flows=page.flows,
731            cursor=page.cursor,
732            truncated=page.truncated,
733            capture=page.capture,
734        )

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:
736    def get_traffic_witnesses(
737        self, instance_id: str, *, cursor: str = "", limit: int = 0
738    ) -> InstanceTrafficWitnesses:
739        """Who spoke to this box since *cursor*, with no bodies moved.
740
741        §7.1's correlation reads one gamebox per team per round. Through
742        :meth:`get_traffic_page` that is every flow, both bodies
743        included (~100 kB apiece by ``CLAUDE.md``'s measurement),
744        pulled across the wire to extract a handful of addresses; the
745        node reduces instead and this carries the result.
746
747        ⚠️ **Strict parsing here, unlike its neighbour, and that is the
748        difference between an old field and an old route.**
749        ``get_traffic_page`` is lenient because a node from before
750        paging answers the *same* path with a bare list, which is
751        supported skew. This route did not exist on such a node at all,
752        so the honest answer from one is a 404 — and a lenient parse of
753        an error body would hand the round loop an empty witness set,
754        which reads as *nobody was there*. The caller catches the
755        request error and records the round as unknown.
756        """
757        params: dict[str, Any] = {}
758        if cursor:
759            params["cursor"] = cursor
760        if limit > 0:
761            params["limit"] = limit
762        resp = self.request("GET", f"/instances/{instance_id}/traffic-witnesses", params=params)
763        resp.raise_for_status()
764        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]:
766    def list_containers(self, instance_id: str) -> list[ComposeContainer]:
767        """Enumerate every container in an instance's compose project.
768
769        Backs the admin-shell feature: the platform forwards a
770        ``GET /admin/instances/{id}/containers`` to the assigned node,
771        which returns one row per container (challenge services and
772        platform-injected sidecars alike). The WebSocket reverse-proxy
773        is opened separately and does not go through ``NodeClient``.
774        """
775        resp = self.request("GET", f"/instances/{instance_id}/containers")
776        _raise_for_node_status(resp)
777        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:
779    def run_checker(
780        self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30
781    ) -> CheckResult:
782        """Exec an author-supplied checker in a trusted judge sidecar.
783
784        Backs the checker / exec-judge feature: the platform forwards a
785        verify request to the assigned node, which runs ``cmd`` inside the
786        named sidecar and returns
787        ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP
788        timeout sits above the node's exec budget so the client doesn't
789        abandon the request before the node returns.
790        """
791        resp = self.request(
792            "POST",
793            f"/instances/{instance_id}/check",
794            json={"service": service, "cmd": cmd, "timeout_s": timeout_s},
795            timeout=timeout_s + 10,
796        )
797        _raise_for_node_status(resp)
798        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}. The per-call HTTP timeout sits above the node's exec budget so the client doesn't abandon the request before the node returns.

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, pull_ticket: str = '', limits: ctfy.server.models.node.HarnessLimits | None = None) -> ctfy.server.models.node.HarnessRun:
800    def run_harness(
801        self,
802        *,
803        image: str,
804        run_id: str = "",
805        env: dict[str, str],
806        timeout_s: int = 1800,
807        untrusted: bool = False,
808        network: str = "",
809        own_network: bool = False,
810        match_network: str = "",
811        match_address: str = "",
812        brief: dict[str, Any] | None = None,
813        target_instance: str = "",
814        target_aliases: dict[str, str] | None = None,
815        pull_ticket: str = "",
816        limits: HarnessLimits | None = None,
817    ) -> HarnessRun:
818        """Run a one-shot evaluation-harness container on the node.
819
820        The node ``docker run``s ``image`` with ``env`` injected, waits for it
821        to exit (bounded by ``timeout_s``), and returns
822        ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as
823        :meth:`run_checker`. The harness writes its JSON rollup to stdout. The
824        per-call HTTP timeout sits above the node's run budget so the client
825        doesn't abandon the request before the (long-running) harness returns.
826
827        ``untrusted`` runs the image under the node's isolation profile —
828        off host networking, bounded resources, no capabilities — for an
829        image the platform did not build. It requires a network: the
830        node answers 400 without one, because "isolate this" and "I will
831        not say where the target lives" do not compose.
832
833        Any of ``network`` (the target instance's own, when the caller
834        knows it), ``own_network`` or ``target_instance`` satisfies that.
835        ⚠️ Most eval dispatches need the second: there the harness
836        *starts* the target partway through its own run, so at ``docker
837        run`` time no target network exists and there is no name to
838        send. The node creates a fresh bridge and removes it afterwards.
839
840        ``target_instance`` reverses that ordering, and is what an
841        `engagement` uses: the platform starts the estate first and names
842        it here, and the node wires its published containers to the
843        agent's bridge. ⚠️ An **id**, never a network name — the name is
844        a fact only the node's daemon can read, and ``docker run``
845        *creates* whatever string it is handed, so a platform guessing
846        one starts a run that reaches nothing.
847        """
848        resp = self.request(
849            "POST",
850            "/harness/run",
851            json={
852                "image": image,
853                "env": env,
854                "timeout_s": timeout_s,
855                "untrusted": untrusted,
856                "network": network,
857                "own_network": own_network,
858                "match_network": match_network,
859                # ⚠️ **The platform-assigned arena address.** The node
860                # connects the agent to the match network with this
861                # ``--ip``, so the address is known before the round loop
862                # asks (§7.1). Empty means "let docker's IPAM pick" — an
863                # older platform's behaviour, and what keeps a node reading
864                # this field unchanged for every non-arena dispatch.
865                "match_address": match_address,
866                # ⚠️ Sent even when empty, so a node reading it gets the
867                # same shape either way; the node's default is what makes
868                # an older platform's dispatch unchanged.
869                "pull_ticket": pull_ticket,
870                # ⚠️ ``None`` leaves the node's own defaults in force, which is
871                # what a dispatch said before the ceilings were settings.
872                "limits": limits.model_dump() if limits is not None else None,
873                # ⚠️ Same rule, and empty is a real answer here too: a
874                # dispatch with no run row (the conformance probe, a dry
875                # run) has nothing to label, and a label with an empty
876                # value would make every one of them answer one query.
877                "run_id": run_id,
878                # ⚠️ Sent as the already-built manifest. The platform is
879                # the side that knows the challenge, the competition and
880                # the run mode; a node deriving its own would be a
881                # second implementation of a published contract.
882                "brief": brief,
883                "target_instance": target_instance,
884                # ⚠️ **The names the brief published, so they resolve on
885                # the agent's bridge too.** An estate's hosts answer to a
886                # per-instance FQDN minted by its own resolver; joined to
887                # a new network a container answers only to its compose
888                # *service* name. Sending the mapping is what keeps the
889                # address the agent was told about true under either
890                # placement — without it the agent is handed a hostname
891                # that does not resolve, reaches nothing, and reports an
892                # empty result, which reads as a model that gave up.
893                #
894                # ⚠️ The platform's to send: it computed the surface. A
895                # node re-deriving it from the rendered compose would be
896                # a second implementation of the address the brief has
897                # already promised.
898                "target_aliases": target_aliases or {},
899            },
900            timeout=timeout_s + 30,
901        )
902        _raise_for_node_status(resp)
903        return _parsed(HarnessRun, resp)

Run a one-shot evaluation-harness container on 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:
905    def cancel_harness(self, *, run_id: str) -> int:
906        """Stop whatever this node is running for ``run_id``; returns how many.
907
908        Until this existed a dispatched container was reclaimed by
909        **nothing but its own timeout** — ``_harness_timeout``'s docstring
910        says exactly that — so an operator pressing cancel on a
911        match-long run was asking for something the platform had no way
912        to deliver.
913
914        ⚠️ **Zero is an ordinary answer, not a failure.** The run may have
915        finished on its own, been dispatched to a different node, or been
916        lost to a node restart. A caller that read zero as an error would
917        report a broken platform every time a cancel arrived a second
918        late.
919        """
920        resp = self.request("POST", "/harness/cancel", json={"run_id": run_id})
921        body = resp.json()
922        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], *, timeout_s: int = 900) -> ctfy.server.models.node.PatchVerifyResult:
924    def verify_patch(
925        self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900
926    ) -> PatchVerifyResult:
927        """Build a submitted patch on the node and return its verdict.
928
929        ``files`` maps a challenge-relative path to its new bytes; base64
930        is applied here because a patch target may legitimately be
931        binary. Returns ``{verdict, detail, applied, steps, error}``.
932
933        **An empty ``verdict`` with a populated ``error`` is not a
934        judgement** — it means the node refused the submission or the
935        verifier crashed. The caller must retry or retire the lease
936        rather than write a score, since failing to judge a patch is not
937        the same as judging it unfixed.
938
939        The per-call HTTP timeout sits above the node's own budget so the
940        client doesn't abandon the request while the node is still
941        building.
942        """
943        resp = self.request(
944            "POST",
945            "/patches/verify",
946            json={
947                "challenge_id": challenge_id,
948                "files": {
949                    path: base64.b64encode(content).decode() for path, content in files.items()
950                },
951                "timeout_s": timeout_s,
952            },
953            timeout=timeout_s + 60,
954        )
955        _raise_for_node_status(resp)
956        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 or the verifier crashed. 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.

The per-call HTTP timeout sits above the node's own budget so the client doesn't abandon the request while the node is still building.

def collect_patch_files( self, instance_id: str, service: str, paths: list[str]) -> dict[str, bytes]:
958    def collect_patch_files(
959        self, instance_id: str, service: str, paths: list[str]
960    ) -> dict[str, bytes]:
961        """Read a player's SSH edits back out of their running box.
962
963        ``paths`` are absolute in-container paths the *platform* derived
964        from the pristine source tree. Returns only what could be read:
965        a path missing from the reply means the player did not change it,
966        and the caller falls back to the shipped file rather than
967        recording a deletion they never made.
968        """
969        resp = self.request(
970            "POST",
971            f"/instances/{instance_id}/patch-collect",
972            json={"service": service, "paths": paths},
973        )
974        _raise_for_node_status(resp)
975        body: dict[str, str] = resp.json()
976        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]:
978    def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
979        """Rotate a round's flags into every box of this node, in one call.
980
981        ⚠️ **One call for the whole node, never one per box.** §5.2's
982        capacity red line is 1000 teams x 3 services injected inside
983        120 s; per-box that is 3000 round trips, batched it is about
984        fourteen. The signature is the enforcement — a caller cannot
985        accidentally loop.
986
987        Returns ``{instance_id: ""}`` for the boxes written and a reason
988        for the ones that were not, because one team's unreachable box
989        must not cost every other team on the node its rotation. An
990        unrotated box keeps serving last round's flag, which anyone
991        holding the old value can replay.
992        """
993        resp = self.request("POST", "/awd/answers", json={"boxes": boxes})
994        _raise_for_node_status(resp)
995        result: dict[str, str] = resp.json()
996        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]:
 998    def probe_awd_service(
 999        self,
1000        *,
1001        instance_id: str,
1002        service_id: str,
1003        tick: int,
1004        flag: str,
1005        previous_flag: str = "",
1006        previous_flag_id: str = "",
1007    ) -> dict[str, str]:
1008        """Run one round's SLA probes against one box, from outside it.
1009
1010        The flags travel *to* the node: a checker that sourced its
1011        expected value from the box it is checking would pass on any box
1012        that returns whatever it was handed.
1013        """
1014        resp = self.request(
1015            "POST",
1016            "/awd/probe",
1017            json={
1018                "instance_id": instance_id,
1019                "service_id": service_id,
1020                "tick": tick,
1021                "flag": flag,
1022                "previous_flag": previous_flag,
1023                "previous_flag_id": previous_flag_id,
1024            },
1025        )
1026        _raise_for_node_status(resp)
1027        result: dict[str, str] = resp.json()
1028        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:
1030    def attack_awd_box(
1031        self,
1032        *,
1033        instance_id: str,
1034        tick: int,
1035        timeout_s: int = 60,
1036        agent_image: str = "",
1037        counter_flag: str = "",
1038        forensic_marker: str = "",
1039        llm_api_key: str = "",
1040    ) -> AwdAttackResult:
1041        """Run this box's own reference exploit against it, once.
1042
1043        Per **box**, not per service: one `run_exploit` discovers every
1044        answer the challenge declares and prints them all.
1045
1046        ``timeout_s`` is sent because the platform is what knows how long
1047        a round is; the node clamps it, because a container held open for
1048        an hour is the node's problem whoever asked for it.
1049
1050        ``forensic_marker`` is §4.3.1's, carried only by the agent
1051        branch: `build_attack_argv` passes no environment at all, so a
1052        marker on the reference exploit would be the one value that
1053        voids `EXPLOIT007`'s guarantee.
1054
1055        ``agent_image`` + ``counter_flag`` switch the node to §4.2b's
1056        agent attacker. ⚠️ **Both or neither** — the node refuses a half
1057        request rather than falling back to the reference exploit, which
1058        would run a different format than the one configured while the
1059        board looked entirely normal.
1060        """
1061        resp = self.request(
1062            "POST",
1063            "/awd/attack",
1064            json={
1065                "instance_id": instance_id,
1066                "tick": tick,
1067                "timeout_s": timeout_s,
1068                "agent_image": agent_image,
1069                "counter_flag": counter_flag,
1070                "forensic_marker": forensic_marker,
1071                "llm_api_key": llm_api_key,
1072            },
1073        )
1074        _raise_for_node_status(resp)
1075        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:
1077    def get_container_logs(self, instance_id: str) -> str:
1078        """Fetch combined container stdout/stderr for archival."""
1079        resp = self.request("GET", f"/instances/{instance_id}/container-logs")
1080        resp.raise_for_status()
1081        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:
1083    def get_pcap(self, instance_id: str) -> PcapCapture:
1084        """Fetch the merged tcpdump capture for *instance_id*.
1085
1086        ``data`` is empty when no capture exists (sidecar disabled,
1087        instance never reached the running state, etc.) so callers can
1088        persist conditionally without try/except.
1089
1090        ⚠️ **It also says what the per-instance cap already dropped**
1091        (#1714), off the node's response headers, and ``None`` when the
1092        node did not say — an older node, which is *not reported*, never
1093        a clean capture.
1094        """
1095        resp = self.request("GET", f"/instances/{instance_id}/pcap")
1096        if resp.status_code == 404:
1097            return PcapCapture(data=b"")
1098        resp.raise_for_status()
1099        return PcapCapture(
1100            data=resp.content,
1101            dropped_bytes=_header_int(resp, CAPTURE_DROPPED_BYTES_HEADER),
1102            dropped_segments=_header_int(resp, CAPTURE_DROPPED_SEGMENTS_HEADER),
1103        )

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:
1105    def get_harness_artifact(self, artifact_id: str) -> bytes:
1106        """One finished run's collected archive, or ``b""``.
1107
1108        ⚠️ **Empty rather than an exception for a 404**, following
1109        :meth:`get_pcap`. The node reaps uncollected archives, and a
1110        platform that comes back after a restart is exactly the caller
1111        that meets one that is gone — a state its own record already
1112        describes, not a transport failure.
1113        """
1114        resp = self.request("GET", f"/harness/artifacts/{artifact_id}")
1115        if resp.status_code == 404:
1116            return b""
1117        resp.raise_for_status()
1118        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:
1120    def delete_harness_artifact(self, artifact_id: str) -> bool:
1121        """Tell the node the archive has been stored and can go.
1122
1123        ⚠️ **Never raises on a transport failure**, because it is called
1124        after the bytes are safely in object storage: turning a cleanup
1125        error into the run's error would replace a success with a
1126        janitorial complaint. The node's reaper collects whatever this
1127        misses.
1128        """
1129        try:
1130            resp = self.request("DELETE", f"/harness/artifacts/{artifact_id}")
1131        except Exception:
1132            return False
1133        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:
1135    def get_agent_runtime(self, instance_id: str) -> AgentRuntime:
1136        """Provider-agnostic runtime hints for attaching an agent (proxy URL,
1137        CA PEM, optional Docker-specific names). Replaces the old
1138        sandbox-network shape."""
1139        resp = self.request("GET", f"/instances/{instance_id}/agent-runtime")
1140        resp.raise_for_status()
1141        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:
1145    def list_instance_attachments(self, instance_id: str) -> AttachmentList:
1146        """List per-instance attachments rendered into the node's workdir.
1147
1148        Backs the platform's per-instance attachment endpoint, which
1149        surfaces the team-specific rendered file listing.
1150
1151        ⚠️ This used to hand back the raw dict, under a docstring saying
1152        "caller projects through :class:`AttachmentList` for typing" —
1153        the node's route already declared that model, so the type existed
1154        and this method's signature was the one place it was dropped.
1155        Projecting *here* means there is one place a malformed answer is
1156        caught rather than one per caller."""
1157        resp = self.request("GET", f"/instances/{instance_id}/attachments")
1158        resp.raise_for_status()
1159        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:
1161    def get_vpn_config(self, instance_id: str) -> bytes:
1162        """Fetch the per-instance VPN client config from the node.
1163
1164        The node reads the backend's client config out of the
1165        instance's VPN service container (generated on first boot by
1166        the base image's bootstrap) and rewrites its endpoint to the
1167        node's public host:port. Returns the body verbatim — the
1168        platform-side route adds the ``Content-Disposition`` header
1169        before re-emitting to the player, because only it knows the
1170        backend and therefore the filename the player needs.
1171
1172        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1173        proxy route catches 404 and re-emits to the player, anything
1174        else is treated as a 502.
1175        """
1176        resp = self.request("GET", f"/instances/{instance_id}/vpn-config")
1177        resp.raise_for_status()
1178        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 download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
1180    def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
1181        """Download one per-instance attachment as ``(bytes, content_type)``.
1182
1183        Buffers the whole body — attachments are bounded (typical case
1184        is a 10 KB binary or a small text file). For huge captures the
1185        caller should hand the player a CDN URL instead.
1186
1187        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
1188        proxy route catches 404 and re-emits to the player, anything
1189        else is a 502."""
1190        resp = self.request(
1191            "GET",
1192            f"/instances/{instance_id}/attachments/{filename}",
1193        )
1194        resp.raise_for_status()
1195        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.

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

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)
 94    def __init__(
 95        self,
 96        server_url: str,
 97        token: str = "",
 98        max_retries: int = 2,
 99        timeout: int = DEFAULT_PLATFORM_TIMEOUT,
100        transport: httpx.BaseTransport | None = None,
101    ):
102        self._url = server_url.rstrip("/")
103        super().__init__(
104            f"{self._url}/api/v1",
105            token,
106            timeout=timeout,
107            max_retries=max_retries,
108            transport=transport,
109        )

max_retries is how many times a request that could not connect, or timed out, is tried again — 0 is one attempt.

⚠️ It counted attempts: the loop ran range(max_retries), so 0 sent nothing and died on a bare AssertionError, the one value whose meaning the ecosystem agrees on (anthropic and openai's max_retries, httpx's retries). The default kept its behaviour rather than its number — three attempts, now spelled 2 — and a negative count is refused here, not at the first request.

transport swaps where requests are delivered, not what delivers them.

⚠️ It exists so nothing has to substitute the client itself. The retry loop below catches httpx.ConnectError / httpx.TimeoutException, so this class is coupled to httpx's exception hierarchy and not merely to "something that can send a request" — a different library's client would satisfy a structural reading of the seam and then raise past those handlers. Tests used to assemble instances with __new__ + a hand-set _client; a real parameter is the supported version of what they were reaching for, and it keeps self._client genuinely an httpx.Client.

None is httpx's own default, so an ordinary caller is unaffected.

server_url: str
111    @property
112    def server_url(self) -> str:
113        """The platform root, without the ``/api/v1`` suffix.
114
115        Exposed for callers that must build a non-``/api/v1`` URL of
116        their own — today the shell WebSocket, whose ticket carries a
117        platform-relative path the client has to absolutise against the
118        scheme and host *it* reached us on.
119        """
120        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.

124    @cached_property
125    def registration(self) -> RegistrationResource:
126        """The caller's own per-competition registration + team logo."""
127        return RegistrationResource(self)

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

129    @property
130    def authoring(self) -> AuthoringResource:
131        """Upload a challenge your team wrote, for a peer-authored event."""
132        return AuthoringResource(self)

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

134    @cached_property
135    def teams(self) -> TeamsResource:
136        """Public team discovery + profile reads."""
137        return TeamsResource(self)

Public team discovery + profile reads.

139    @cached_property
140    def users(self) -> UsersResource:
141        """Public per-user profile reads."""
142        return UsersResource(self)

Public per-user profile reads.

144    @cached_property
145    def me(self) -> MeResource:
146        """The calling user's profile, inbox, account, and progress."""
147        return MeResource(self)

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

149    @cached_property
150    def auth(self) -> AuthResource:
151        """Password auth, OAuth identities, API tokens, sign-in discovery."""
152        return AuthResource(self)

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

154    @cached_property
155    def achievements(self) -> AchievementsResource:
156        """Public badge catalog, recent-unlock feed, easter eggs."""
157        return AchievementsResource(self)

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

159    @cached_property
160    def challenges(self) -> ChallengesResource:
161        """Global challenge catalog, attachments, facets, feedback chips."""
162        return ChallengesResource(self)

Global challenge catalog, attachments, facets, feedback chips.

164    @cached_property
165    def changelog(self) -> ChangelogResource:
166        """Public "What's New" feed, plus this account's seen marker."""
167        return ChangelogResource(self)

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

169    @cached_property
170    def competitions(self) -> CompetitionsResource:
171        """Discover competitions (``list`` / ``get``). To act within one, use
172        :meth:`competition`."""
173        return CompetitionsResource(self)

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

def competition(self, competition_id: str) -> ctfy.sdk.competition.Competition:
175    def competition(self, competition_id: str) -> Competition:
176        """Scope to one competition. The inherently competition-scoped
177        operations — register, your team (captain tooling + invites +
178        join-requests), the comp's challenges, team search, and standings —
179        hang off the returned :class:`~ctfy.sdk.competition.Competition`
180        handle, so ``competition_id`` is named once instead of on every call.
181        (Instance lifecycle and answer submission stay on :attr:`instances` /
182        :attr:`submissions`, keyed by ``instance_id``.)"""
183        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.)

185    @cached_property
186    def series(self) -> SeriesResource:
187        """A recurring contest's ladder (public), not its schedule —
188        configuring a series is ``client.admin.series``."""
189        return SeriesResource(self)

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

191    @cached_property
192    def eval(self) -> EvalResource:
193        """Public model-evaluation leaderboard (ranking + dimensional breakdowns)."""
194        return EvalResource(self)

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

196    @property
197    def registry(self) -> RegistryResource:
198        """The container registry's token endpoint (docker follows it itself)."""
199        return RegistryResource(self)

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

201    @cached_property
202    def vendor(self) -> VendorResource:
203        """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer)."""
204        return VendorResource(self)

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

206    @cached_property
207    def instances(self) -> InstancesResource:
208        """Launch / control / inspect challenge instances by instance id
209        (the competition is inferred server-side)."""
210        return InstancesResource(self)

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

212    @cached_property
213    def submissions(self) -> SubmissionsResource:
214        """Submit / verify answers + list submissions (by instance id), plus
215        the instance-less QA challenges."""
216        return SubmissionsResource(self)

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

218    @cached_property
219    def patches(self) -> PatchesResource:
220        """AWD+ defence: submit a patch, then watch for its verdict."""
221        return PatchesResource(self)

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

223    @cached_property
224    def awd(self) -> AwdResource:
225        """Classic AWD: submit a round's captured flags in one batch."""
226        return AwdResource(self)

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

228    @cached_property
229    def reports(self) -> ReportsResource:
230        """The engagement report: save a version, read the current one."""
231        return ReportsResource(self)

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

233    @cached_property
234    def scoreboard(self) -> ScoreboardResource:
235        """Global standings, per-challenge stats, persisted snapshots."""
236        return ScoreboardResource(self)

Global standings, per-challenge stats, persisted snapshots.

238    @cached_property
239    def activities(self) -> ActivitiesResource:
240        """Platform activity log + histogram."""
241        return ActivitiesResource(self)

Platform activity log + histogram.

243    @cached_property
244    def nodes(self) -> NodesResource:
245        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
246        return NodesResource(self)

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

admin: AdminClient
248    @cached_property
249    def admin(self) -> AdminClient:
250        """Admin / operator surface (separate, role-gated server-side).
251
252        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
253        """
254        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:
258    def health(self) -> HealthResponse:
259        resp = self.request("GET", "/health")
260        _raise_for_status(resp)
261        return HealthResponse.model_validate(resp.json())
def get_meta(self) -> ctfy.server.models.MetaResponse:
263    def get_meta(self) -> MetaResponse:
264        """Server identity + challenge repo SHA + build version.
265        Admin tokens additionally see cluster capacity + team / solve
266        counts in the same payload."""
267        resp = self.request("GET", "/meta")
268        _raise_for_status(resp)
269        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 cluster_info(self) -> ctfy.server.models.ClusterInfo:
271    def cluster_info(self) -> ClusterInfo:
272        """Aggregate worker-node capacity / utilisation (the public
273        capacity banner; no per-node detail)."""
274        resp = self.request("GET", "/cluster-info")
275        _raise_for_status(resp)
276        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:
278    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
279        """Compare this client's ``ctfy`` version against the server's.
280
281        Uses the cheap unauthenticated ``/health`` probe (pass an
282        already-fetched :class:`HealthResponse` to avoid a second round
283        trip). Pure classification — never prints, never raises on a
284        mismatch, never mutates anything. The caller (CLI, MCP, harness)
285        decides what to do with :class:`~ctfy.core.version.VersionCheck`
286        (typically: print ``.message`` to stderr when not ``.quiet``).
287
288        A server too old to report its version yields
289        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
290        is ``.quiet`` — so this stays silent against legacy servers
291        rather than crying wolf.
292        """
293        h = health if health is not None else self.health()
294        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:
296    def events(self, *, auto_reconnect: bool = True) -> EventStream:
297        """Open the platform SSE event stream.
298
299        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
300        Filters: team-scoped events for every team the caller's user is
301        on (across every per-comp competition), plus all global events;
302        admin tokens additionally see admin-only frames.
303
304        Usage::
305
306            with client.events() as stream:
307                for event in stream:
308                    if event["event"] == "solve":
309                        ...
310
311        Auto-reconnects on transient network errors with exponential
312        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
313        the iterator raise instead.
314        """
315        token = self._token
316
317        def _factory() -> httpx.Client:
318            # New client per session so a reconnect after a stale
319            # connection drops fresh sockets, not warmed-over ones.
320            return httpx.Client(base_url=self._base_url, timeout=None)
321
322        return EventStream(
323            client_factory=_factory,
324            path="/events",
325            token=token,
326            auto_reconnect=auto_reconnect,
327        )

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.