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 viaclient.competition(id).~ctfy.sdk.admin.AdminClient— the operator client (/api/v1/admin/*and fleet management). Reached viaclient.adminorAdminClient.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, pluscomp.team.invites(codes + email invites) andcomp.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"]
73class AdminClient: 74 """Admin / operator surface, grouped into resource namespaces. 75 76 Namespaces: :attr:`users`, :attr:`competitions`, :attr:`announcements`, 77 :attr:`achievements`, :attr:`challenges`, :attr:`instances`, 78 :attr:`records`, :attr:`nodes`, :attr:`observability`, :attr:`settings`, 79 :attr:`competition_admins`. 80 """ 81 82 def __init__(self, http: BaseHttpClient) -> None: 83 #: Shared transport (the parent PlatformClient when reached via 84 #: ``client.admin``, or an owned client when built via ``connect``). 85 self._http = http 86 87 @classmethod 88 def connect( 89 cls, 90 server_url: str, 91 token: str = "", 92 *, 93 max_retries: int = 3, 94 timeout: int = DEFAULT_CLIENT_TIMEOUT, 95 ) -> AdminClient: 96 """Build an ``AdminClient`` that owns its own transport. 97 98 For operator tooling that doesn't already hold a 99 :class:`PlatformClient`. The returned client is a context manager 100 that closes its transport on exit. 101 """ 102 base = server_url.rstrip("/") 103 http = BaseHttpClient(f"{base}/api/v1", token, timeout=timeout, max_retries=max_retries) 104 return cls(http) 105 106 @cached_property 107 def users(self) -> AdminUsersResource: 108 return AdminUsersResource(self._http) 109 110 @cached_property 111 def competitions(self) -> AdminCompetitionsResource: 112 return AdminCompetitionsResource(self._http) 113 114 @cached_property 115 def announcements(self) -> AdminAnnouncementsResource: 116 return AdminAnnouncementsResource(self._http) 117 118 @cached_property 119 def achievements(self) -> AdminAchievementsResource: 120 return AdminAchievementsResource(self._http) 121 122 @cached_property 123 def challenges(self) -> AdminChallengesResource: 124 return AdminChallengesResource(self._http) 125 126 @cached_property 127 def email(self) -> AdminEmailResource: 128 return AdminEmailResource(self._http) 129 130 @cached_property 131 def patches(self) -> AdminPatchesResource: 132 return AdminPatchesResource(self._http) 133 134 @cached_property 135 def awd(self) -> AdminAwdResource: 136 return AdminAwdResource(self._http) 137 138 @cached_property 139 def series(self) -> AdminSeriesResource: 140 return AdminSeriesResource(self._http) 141 142 @cached_property 143 def instances(self) -> AdminInstancesResource: 144 return AdminInstancesResource(self._http) 145 146 @cached_property 147 def records(self) -> AdminRecordsResource: 148 return AdminRecordsResource(self._http) 149 150 @cached_property 151 def nodes(self) -> AdminNodesResource: 152 return AdminNodesResource(self._http) 153 154 @cached_property 155 def observability(self) -> AdminObservabilityResource: 156 return AdminObservabilityResource(self._http) 157 158 @cached_property 159 def settings(self) -> AdminSettingsResource: 160 return AdminSettingsResource(self._http) 161 162 @cached_property 163 def competition_admins(self) -> AdminCompetitionAdminsResource: 164 return AdminCompetitionAdminsResource(self._http) 165 166 @cached_property 167 def competition_invites(self) -> AdminCompetitionInvitesResource: 168 return AdminCompetitionInvitesResource(self._http) 169 170 @cached_property 171 def registrations(self) -> AdminRegistrationsResource: 172 """Entrant roster, eligibility review, and CSV export.""" 173 return AdminRegistrationsResource(self._http) 174 175 @cached_property 176 def teams(self) -> AdminTeamsResource: 177 """Enforcement against a squad — disqualify and reinstate. 178 179 Separate from ``registrations`` on purpose: that one rules on 180 eligibility (which decides prizes), this one decides whether the 181 team is in the event at all. 182 """ 183 return AdminTeamsResource(self._http) 184 185 @cached_property 186 def tasks(self) -> AdminTasksResource: 187 return AdminTasksResource(self._http) 188 189 @cached_property 190 def scheduled_jobs(self) -> AdminScheduledJobsResource: 191 return AdminScheduledJobsResource(self._http) 192 193 @cached_property 194 def eval_models(self) -> AdminEvalModelsResource: 195 return AdminEvalModelsResource(self._http) 196 197 @cached_property 198 def eval_vendors(self) -> AdminEvalVendorsResource: 199 return AdminEvalVendorsResource(self._http) 200 201 @cached_property 202 def eval_harnesses(self) -> AdminEvalHarnessesResource: 203 return AdminEvalHarnessesResource(self._http) 204 205 @cached_property 206 def eval_runs(self) -> AdminEvalRunsResource: 207 return AdminEvalRunsResource(self._http) 208 209 @cached_property 210 def eval_campaigns(self) -> AdminEvalCampaignsResource: 211 return AdminEvalCampaignsResource(self._http) 212 213 def close(self) -> None: 214 """Close the underlying transport. 215 216 Only call this on a standalone client built via :meth:`connect`; 217 when reached via ``PlatformClient.admin`` the transport is shared 218 with — and closed by — the parent player client. 219 """ 220 self._http.close() 221 222 def __enter__(self) -> Self: 223 return self 224 225 def __exit__( 226 self, 227 exc_type: type[BaseException] | None, 228 exc: BaseException | None, 229 tb: TracebackType | None, 230 ) -> None: 231 self.close()
Admin / operator surface, grouped into resource namespaces.
Namespaces: users, competitions, announcements,
achievements, challenges, instances,
records, nodes, observability, settings,
competition_admins.
87 @classmethod 88 def connect( 89 cls, 90 server_url: str, 91 token: str = "", 92 *, 93 max_retries: int = 3, 94 timeout: int = DEFAULT_CLIENT_TIMEOUT, 95 ) -> AdminClient: 96 """Build an ``AdminClient`` that owns its own transport. 97 98 For operator tooling that doesn't already hold a 99 :class:`PlatformClient`. The returned client is a context manager 100 that closes its transport on exit. 101 """ 102 base = server_url.rstrip("/") 103 http = BaseHttpClient(f"{base}/api/v1", token, timeout=timeout, max_retries=max_retries) 104 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.
170 @cached_property 171 def registrations(self) -> AdminRegistrationsResource: 172 """Entrant roster, eligibility review, and CSV export.""" 173 return AdminRegistrationsResource(self._http)
Entrant roster, eligibility review, and CSV export.
175 @cached_property 176 def teams(self) -> AdminTeamsResource: 177 """Enforcement against a squad — disqualify and reinstate. 178 179 Separate from ``registrations`` on purpose: that one rules on 180 eligibility (which decides prizes), this one decides whether the 181 team is in the event at all. 182 """ 183 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.
213 def close(self) -> None: 214 """Close the underlying transport. 215 216 Only call this on a standalone client built via :meth:`connect`; 217 when reached via ``PlatformClient.admin`` the transport is shared 218 with — and closed by — the parent player client. 219 """ 220 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.
49class NodeClient(BaseHttpClient): 50 """Thin sync HTTP client; one instance per node URL.""" 51 52 def __init__( 53 self, 54 node_url: str, 55 token: str, 56 *, 57 timeout: int = DEFAULT_CLIENT_TIMEOUT, 58 ) -> None: 59 super().__init__(f"{node_url.rstrip('/')}/api/v1", token, timeout=timeout) 60 61 # -- lifecycle ---------------------------------------------------------- 62 63 def start_instance( 64 self, 65 *, 66 challenge_id: str, 67 instance_id: str, 68 ttl: int, 69 answers: dict[str, str], 70 proxy_output_dir: str | None = None, 71 env: dict[str, str] | None = None, 72 publish_gamebox: bool = False, 73 ) -> dict[str, Any]: 74 resp = self.request( 75 "POST", 76 "/instances", 77 json={ 78 "challenge_id": challenge_id, 79 "instance_id": instance_id, 80 "ttl": ttl, 81 "answers": answers, 82 "proxy_output_dir": proxy_output_dir, 83 "env": env or {}, 84 "publish_gamebox": publish_gamebox, 85 }, 86 ) 87 _raise_for_node_status(resp) 88 body: dict[str, Any] = resp.json() 89 return body 90 91 def stop_instance(self, instance_id: str) -> None: 92 resp = self.request("DELETE", f"/instances/{instance_id}") 93 resp.raise_for_status() 94 95 def stop_all(self) -> None: 96 resp = self.request("POST", "/admin/stop-all") 97 resp.raise_for_status() 98 99 def rescan_challenges(self) -> dict[str, Any]: 100 """Tell the node to drop its spec cache and re-scan challenges_dir. 101 102 Returns ``{total, added, removed}`` so the platform can report 103 the per-node outcome of a cluster-wide rescan.""" 104 resp = self.request("POST", "/admin/rescan-challenges") 105 resp.raise_for_status() 106 body: dict[str, Any] = resp.json() 107 return body 108 109 # -- admin pre-build (image cache warming) ------------------------------ 110 111 def build_challenge(self, challenge_id: str) -> dict[str, Any]: 112 """Ask the node to pre-build images for *challenge_id*. 113 114 Fires-and-returns: the node persists ``status="building"`` and 115 spawns a daemon thread; the body returned here is that initial 116 state row. Polling :meth:`get_build_state` is how the platform 117 learns when it lands on ``built`` / ``failed``. 118 """ 119 resp = self.request("POST", f"/admin/challenges/{challenge_id}/build") 120 _raise_for_node_status(resp) 121 body: dict[str, Any] = resp.json() 122 return body 123 124 def build_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]: 125 """Queue background pre-build on the node. 126 127 With ``challenge_ids=None`` the node builds every spec it knows. 128 With an explicit list (the platform scoping a bulk build to a 129 competition's challenge set) only those are built. Returns 130 ``{queued, skipped_built, skipped_in_progress}`` so the platform 131 can report per-node what got picked up. Sequential on the node 132 side — no fan-out across the corpus. 133 """ 134 kwargs: dict[str, Any] = {} 135 if challenge_ids is not None: 136 kwargs["json"] = {"challenge_ids": challenge_ids} 137 resp = self.request("POST", "/admin/challenges/build-all", **kwargs) 138 _raise_for_node_status(resp) 139 body: dict[str, Any] = resp.json() 140 return body 141 142 def get_build_state(self) -> dict[str, Any]: 143 """Fetch every per-challenge build-state row on this node. 144 145 Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``. 146 ``status`` is one of ``unbuilt`` / ``building`` / ``built`` / 147 ``failed``; ``unbuilt`` placeholders are synthesised for specs 148 the node has seen but never been asked to build. 149 """ 150 resp = self.request("GET", "/admin/challenges/build-state") 151 _raise_for_node_status(resp) 152 body: dict[str, Any] = resp.json() 153 return body 154 155 def pull_challenge(self, challenge_id: str) -> dict[str, Any]: 156 """Ask the node to pre-pull registry images for *challenge_id*. 157 158 Pull-side twin of :meth:`build_challenge`: the node persists 159 ``status="pulling"`` and spawns a daemon thread; poll 160 :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing. 161 """ 162 resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull") 163 _raise_for_node_status(resp) 164 body: dict[str, Any] = resp.json() 165 return body 166 167 def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]: 168 """Queue background pre-pull on the node. 169 170 With ``challenge_ids=None`` the node pulls every spec it knows; 171 with an explicit list only those (the platform scoping to a 172 competition). Returns ``{queued, skipped_pulled, 173 skipped_in_progress}``. 174 """ 175 kwargs: dict[str, Any] = {} 176 if challenge_ids is not None: 177 kwargs["json"] = {"challenge_ids": challenge_ids} 178 resp = self.request("POST", "/admin/challenges/pull-all", **kwargs) 179 _raise_for_node_status(resp) 180 body: dict[str, Any] = resp.json() 181 return body 182 183 def get_pull_state(self) -> dict[str, Any]: 184 """Fetch every per-challenge pull-state row on this node. 185 186 Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``. 187 ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` / 188 ``failed``; ``unpulled`` placeholders are synthesised for specs 189 the node has seen but never been asked to pull. 190 """ 191 resp = self.request("GET", "/admin/challenges/pull-state") 192 _raise_for_node_status(resp) 193 body: dict[str, Any] = resp.json() 194 return body 195 196 # -- status / health ---------------------------------------------------- 197 198 def get_status(self, instance_id: str) -> dict[str, Any]: 199 resp = self.request("GET", f"/instances/{instance_id}/status") 200 resp.raise_for_status() 201 body: dict[str, Any] = resp.json() 202 return body 203 204 def list_instances(self) -> list[dict[str, Any]]: 205 """Every instance the node still has bookkeeping for. 206 207 Used by the platform's boot re-adoption pass to decide which 208 durable claims still have containers behind them. ``get_status`` 209 answers the same question one id at a time; asking it per claim 210 would cost one round trip per instance to a node that may be 211 slow or down, on the path that gates startup. 212 """ 213 resp = self.request("GET", "/instances") 214 resp.raise_for_status() 215 items: list[dict[str, Any]] = resp.json().get("items", []) 216 return items 217 218 def check_health(self, instance_id: str) -> bool: 219 resp = self.request("GET", f"/instances/{instance_id}/health") 220 resp.raise_for_status() 221 return bool(resp.json().get("is_healthy")) 222 223 def node_health(self) -> dict[str, Any]: 224 """Liveness + ``{running, capacity}`` for heartbeat.""" 225 resp = self.request("GET", "/health") 226 resp.raise_for_status() 227 body: dict[str, Any] = resp.json() 228 return body 229 230 # -- traffic / runtime -------------------------------------------------- 231 232 def get_traffic(self, instance_id: str) -> dict[str, Any]: 233 """Fetch mitmproxy flow data; file lives on the node's FS.""" 234 resp = self.request("GET", f"/instances/{instance_id}/traffic") 235 resp.raise_for_status() 236 body: dict[str, Any] = resp.json() 237 return body 238 239 def list_containers(self, instance_id: str) -> list[dict[str, Any]]: 240 """Enumerate every container in an instance's compose project. 241 242 Backs the admin-shell feature: the platform forwards a 243 ``GET /admin/instances/{id}/containers`` to the assigned node, 244 which returns one row per container (challenge services and 245 platform-injected sidecars alike). The WebSocket reverse-proxy 246 is opened separately and does not go through ``NodeClient``. 247 """ 248 resp = self.request("GET", f"/instances/{instance_id}/containers") 249 _raise_for_node_status(resp) 250 body: list[dict[str, Any]] = resp.json() 251 return body 252 253 def run_checker( 254 self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30 255 ) -> dict[str, Any]: 256 """Exec an author-supplied checker in a trusted judge sidecar. 257 258 Backs the checker / exec-judge feature: the platform forwards a 259 verify request to the assigned node, which runs ``cmd`` inside the 260 named sidecar and returns 261 ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP 262 timeout sits above the node's exec budget so the client doesn't 263 abandon the request before the node returns. 264 """ 265 resp = self.request( 266 "POST", 267 f"/instances/{instance_id}/check", 268 json={"service": service, "cmd": cmd, "timeout_s": timeout_s}, 269 timeout=timeout_s + 10, 270 ) 271 _raise_for_node_status(resp) 272 body: dict[str, Any] = resp.json() 273 return body 274 275 def run_harness( 276 self, *, image: str, env: dict[str, str], timeout_s: int = 1800 277 ) -> dict[str, Any]: 278 """Run a one-shot evaluation-harness container on the node. 279 280 The node ``docker run``s ``image`` with ``env`` injected, waits for it 281 to exit (bounded by ``timeout_s``), and returns 282 ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as 283 :meth:`run_checker`. The harness writes its JSON rollup to stdout. The 284 per-call HTTP timeout sits above the node's run budget so the client 285 doesn't abandon the request before the (long-running) harness returns. 286 """ 287 resp = self.request( 288 "POST", 289 "/harness/run", 290 json={"image": image, "env": env, "timeout_s": timeout_s}, 291 timeout=timeout_s + 30, 292 ) 293 _raise_for_node_status(resp) 294 body: dict[str, Any] = resp.json() 295 return body 296 297 def verify_patch( 298 self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900 299 ) -> dict[str, Any]: 300 """Build a submitted patch on the node and return its verdict. 301 302 ``files`` maps a challenge-relative path to its new bytes; base64 303 is applied here because a patch target may legitimately be 304 binary. Returns ``{verdict, detail, applied, steps, error}``. 305 306 **An empty ``verdict`` with a populated ``error`` is not a 307 judgement** — it means the node refused the submission or the 308 verifier crashed. The caller must retry or retire the lease 309 rather than write a score, since failing to judge a patch is not 310 the same as judging it unfixed. 311 312 The per-call HTTP timeout sits above the node's own budget so the 313 client doesn't abandon the request while the node is still 314 building. 315 """ 316 resp = self.request( 317 "POST", 318 "/patches/verify", 319 json={ 320 "challenge_id": challenge_id, 321 "files": { 322 path: base64.b64encode(content).decode() for path, content in files.items() 323 }, 324 "timeout_s": timeout_s, 325 }, 326 timeout=timeout_s + 60, 327 ) 328 _raise_for_node_status(resp) 329 body: dict[str, Any] = resp.json() 330 return body 331 332 def collect_patch_files( 333 self, instance_id: str, service: str, paths: list[str] 334 ) -> dict[str, bytes]: 335 """Read a player's SSH edits back out of their running box. 336 337 ``paths`` are absolute in-container paths the *platform* derived 338 from the pristine source tree. Returns only what could be read: 339 a path missing from the reply means the player did not change it, 340 and the caller falls back to the shipped file rather than 341 recording a deletion they never made. 342 """ 343 resp = self.request( 344 "POST", 345 f"/instances/{instance_id}/patch-collect", 346 json={"service": service, "paths": paths}, 347 ) 348 _raise_for_node_status(resp) 349 body: dict[str, str] = resp.json() 350 return {path: base64.b64decode(blob) for path, blob in body.items()} 351 352 def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]: 353 """Rotate a round's flags into every box of this node, in one call. 354 355 ⚠️ **One call for the whole node, never one per box.** §5.2's 356 capacity red line is 1000 teams x 3 services injected inside 357 120 s; per-box that is 3000 round trips, batched it is about 358 fourteen. The signature is the enforcement — a caller cannot 359 accidentally loop. 360 361 Returns ``{instance_id: ""}`` for the boxes written and a reason 362 for the ones that were not, because one team's unreachable box 363 must not cost every other team on the node its rotation. An 364 unrotated box keeps serving last round's flag, which anyone 365 holding the old value can replay. 366 """ 367 resp = self.request("POST", "/awd/answers", json={"boxes": boxes}) 368 _raise_for_node_status(resp) 369 result: dict[str, str] = resp.json() 370 return result 371 372 def probe_awd_service( 373 self, 374 *, 375 instance_id: str, 376 service_id: str, 377 tick: int, 378 flag: str, 379 previous_flag: str = "", 380 previous_flag_id: str = "", 381 ) -> dict[str, str]: 382 """Run one round's SLA probes against one box, from outside it. 383 384 The flags travel *to* the node: a checker that sourced its 385 expected value from the box it is checking would pass on any box 386 that returns whatever it was handed. 387 """ 388 resp = self.request( 389 "POST", 390 "/awd/probe", 391 json={ 392 "instance_id": instance_id, 393 "service_id": service_id, 394 "tick": tick, 395 "flag": flag, 396 "previous_flag": previous_flag, 397 "previous_flag_id": previous_flag_id, 398 }, 399 ) 400 _raise_for_node_status(resp) 401 result: dict[str, str] = resp.json() 402 return result 403 404 def get_container_logs(self, instance_id: str) -> str: 405 """Fetch combined container stdout/stderr for archival.""" 406 resp = self.request("GET", f"/instances/{instance_id}/container-logs") 407 resp.raise_for_status() 408 return str(resp.json().get("logs") or "") 409 410 def get_pcap(self, instance_id: str) -> bytes: 411 """Fetch the raw tcpdump capture for *instance_id*. 412 413 Returns an empty ``bytes`` when no capture exists (sidecar 414 disabled, instance never reached the running state, etc.) so 415 callers can persist conditionally without try/except. 416 """ 417 resp = self.request("GET", f"/instances/{instance_id}/pcap") 418 if resp.status_code == 404: 419 return b"" 420 resp.raise_for_status() 421 return resp.content 422 423 def get_agent_runtime(self, instance_id: str) -> dict[str, Any]: 424 """Provider-agnostic runtime hints for attaching an agent (proxy URL, 425 CA PEM, optional Docker-specific names). Replaces the old 426 sandbox-network shape.""" 427 resp = self.request("GET", f"/instances/{instance_id}/agent-runtime") 428 resp.raise_for_status() 429 body: dict[str, Any] = resp.json() 430 return body 431 432 # -- per-instance rendered attachments --------------------------------- 433 434 def list_instance_attachments(self, instance_id: str) -> dict[str, Any]: 435 """List per-instance attachments rendered into the node's workdir. 436 437 Returns the raw JSON dict — caller projects through 438 :class:`AttachmentList` for typing. Used by the platform's 439 per-instance attachment endpoint to surface team-specific 440 rendered file listings.""" 441 resp = self.request("GET", f"/instances/{instance_id}/attachments") 442 resp.raise_for_status() 443 body: dict[str, Any] = resp.json() 444 return body 445 446 def get_openvpn_config(self, instance_id: str) -> bytes: 447 """Fetch the per-instance OpenVPN client config from the node. 448 449 The node reads ``/etc/openvpn/client.ovpn`` out of the instance's 450 ``openvpn`` service container (generated on first boot by the 451 ``ctfy/openvpn-base`` image's bootstrap). Returns the body 452 verbatim — the platform-side route adds the 453 ``Content-Disposition`` header before re-emitting to the player. 454 455 Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's 456 proxy route catches 404 and re-emits to the player, anything 457 else is treated as a 502. 458 """ 459 resp = self.request("GET", f"/instances/{instance_id}/openvpn-config") 460 resp.raise_for_status() 461 return resp.content 462 463 def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]: 464 """Download one per-instance attachment as ``(bytes, content_type)``. 465 466 Buffers the whole body — attachments are bounded (typical case 467 is a 10 KB binary or a small text file). For huge captures the 468 caller should hand the player a CDN URL instead. 469 470 Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's 471 proxy route catches 404 and re-emits to the player, anything 472 else is a 502.""" 473 resp = self.request( 474 "GET", 475 f"/instances/{instance_id}/attachments/{filename}", 476 ) 477 resp.raise_for_status() 478 return resp.content, resp.headers.get("content-type", "application/octet-stream")
Thin sync HTTP client; one instance per node URL.
63 def start_instance( 64 self, 65 *, 66 challenge_id: str, 67 instance_id: str, 68 ttl: int, 69 answers: dict[str, str], 70 proxy_output_dir: str | None = None, 71 env: dict[str, str] | None = None, 72 publish_gamebox: bool = False, 73 ) -> dict[str, Any]: 74 resp = self.request( 75 "POST", 76 "/instances", 77 json={ 78 "challenge_id": challenge_id, 79 "instance_id": instance_id, 80 "ttl": ttl, 81 "answers": answers, 82 "proxy_output_dir": proxy_output_dir, 83 "env": env or {}, 84 "publish_gamebox": publish_gamebox, 85 }, 86 ) 87 _raise_for_node_status(resp) 88 body: dict[str, Any] = resp.json() 89 return body
99 def rescan_challenges(self) -> dict[str, Any]: 100 """Tell the node to drop its spec cache and re-scan challenges_dir. 101 102 Returns ``{total, added, removed}`` so the platform can report 103 the per-node outcome of a cluster-wide rescan.""" 104 resp = self.request("POST", "/admin/rescan-challenges") 105 resp.raise_for_status() 106 body: dict[str, Any] = resp.json() 107 return body
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.
111 def build_challenge(self, challenge_id: str) -> dict[str, Any]: 112 """Ask the node to pre-build images for *challenge_id*. 113 114 Fires-and-returns: the node persists ``status="building"`` and 115 spawns a daemon thread; the body returned here is that initial 116 state row. Polling :meth:`get_build_state` is how the platform 117 learns when it lands on ``built`` / ``failed``. 118 """ 119 resp = self.request("POST", f"/admin/challenges/{challenge_id}/build") 120 _raise_for_node_status(resp) 121 body: dict[str, Any] = resp.json() 122 return body
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.
124 def build_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]: 125 """Queue background pre-build on the node. 126 127 With ``challenge_ids=None`` the node builds every spec it knows. 128 With an explicit list (the platform scoping a bulk build to a 129 competition's challenge set) only those are built. Returns 130 ``{queued, skipped_built, skipped_in_progress}`` so the platform 131 can report per-node what got picked up. Sequential on the node 132 side — no fan-out across the corpus. 133 """ 134 kwargs: dict[str, Any] = {} 135 if challenge_ids is not None: 136 kwargs["json"] = {"challenge_ids": challenge_ids} 137 resp = self.request("POST", "/admin/challenges/build-all", **kwargs) 138 _raise_for_node_status(resp) 139 body: dict[str, Any] = resp.json() 140 return body
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.
142 def get_build_state(self) -> dict[str, Any]: 143 """Fetch every per-challenge build-state row on this node. 144 145 Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``. 146 ``status`` is one of ``unbuilt`` / ``building`` / ``built`` / 147 ``failed``; ``unbuilt`` placeholders are synthesised for specs 148 the node has seen but never been asked to build. 149 """ 150 resp = self.request("GET", "/admin/challenges/build-state") 151 _raise_for_node_status(resp) 152 body: dict[str, Any] = resp.json() 153 return body
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.
155 def pull_challenge(self, challenge_id: str) -> dict[str, Any]: 156 """Ask the node to pre-pull registry images for *challenge_id*. 157 158 Pull-side twin of :meth:`build_challenge`: the node persists 159 ``status="pulling"`` and spawns a daemon thread; poll 160 :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing. 161 """ 162 resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull") 163 _raise_for_node_status(resp) 164 body: dict[str, Any] = resp.json() 165 return body
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.
167 def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]: 168 """Queue background pre-pull on the node. 169 170 With ``challenge_ids=None`` the node pulls every spec it knows; 171 with an explicit list only those (the platform scoping to a 172 competition). Returns ``{queued, skipped_pulled, 173 skipped_in_progress}``. 174 """ 175 kwargs: dict[str, Any] = {} 176 if challenge_ids is not None: 177 kwargs["json"] = {"challenge_ids": challenge_ids} 178 resp = self.request("POST", "/admin/challenges/pull-all", **kwargs) 179 _raise_for_node_status(resp) 180 body: dict[str, Any] = resp.json() 181 return body
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}.
183 def get_pull_state(self) -> dict[str, Any]: 184 """Fetch every per-challenge pull-state row on this node. 185 186 Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``. 187 ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` / 188 ``failed``; ``unpulled`` placeholders are synthesised for specs 189 the node has seen but never been asked to pull. 190 """ 191 resp = self.request("GET", "/admin/challenges/pull-state") 192 _raise_for_node_status(resp) 193 body: dict[str, Any] = resp.json() 194 return body
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.
204 def list_instances(self) -> list[dict[str, Any]]: 205 """Every instance the node still has bookkeeping for. 206 207 Used by the platform's boot re-adoption pass to decide which 208 durable claims still have containers behind them. ``get_status`` 209 answers the same question one id at a time; asking it per claim 210 would cost one round trip per instance to a node that may be 211 slow or down, on the path that gates startup. 212 """ 213 resp = self.request("GET", "/instances") 214 resp.raise_for_status() 215 items: list[dict[str, Any]] = resp.json().get("items", []) 216 return 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.
223 def node_health(self) -> dict[str, Any]: 224 """Liveness + ``{running, capacity}`` for heartbeat.""" 225 resp = self.request("GET", "/health") 226 resp.raise_for_status() 227 body: dict[str, Any] = resp.json() 228 return body
Liveness + {running, capacity} for heartbeat.
232 def get_traffic(self, instance_id: str) -> dict[str, Any]: 233 """Fetch mitmproxy flow data; file lives on the node's FS.""" 234 resp = self.request("GET", f"/instances/{instance_id}/traffic") 235 resp.raise_for_status() 236 body: dict[str, Any] = resp.json() 237 return body
Fetch mitmproxy flow data; file lives on the node's FS.
239 def list_containers(self, instance_id: str) -> list[dict[str, Any]]: 240 """Enumerate every container in an instance's compose project. 241 242 Backs the admin-shell feature: the platform forwards a 243 ``GET /admin/instances/{id}/containers`` to the assigned node, 244 which returns one row per container (challenge services and 245 platform-injected sidecars alike). The WebSocket reverse-proxy 246 is opened separately and does not go through ``NodeClient``. 247 """ 248 resp = self.request("GET", f"/instances/{instance_id}/containers") 249 _raise_for_node_status(resp) 250 body: list[dict[str, Any]] = resp.json() 251 return body
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.
253 def run_checker( 254 self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30 255 ) -> dict[str, Any]: 256 """Exec an author-supplied checker in a trusted judge sidecar. 257 258 Backs the checker / exec-judge feature: the platform forwards a 259 verify request to the assigned node, which runs ``cmd`` inside the 260 named sidecar and returns 261 ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP 262 timeout sits above the node's exec budget so the client doesn't 263 abandon the request before the node returns. 264 """ 265 resp = self.request( 266 "POST", 267 f"/instances/{instance_id}/check", 268 json={"service": service, "cmd": cmd, "timeout_s": timeout_s}, 269 timeout=timeout_s + 10, 270 ) 271 _raise_for_node_status(resp) 272 body: dict[str, Any] = resp.json() 273 return body
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.
275 def run_harness( 276 self, *, image: str, env: dict[str, str], timeout_s: int = 1800 277 ) -> dict[str, Any]: 278 """Run a one-shot evaluation-harness container on the node. 279 280 The node ``docker run``s ``image`` with ``env`` injected, waits for it 281 to exit (bounded by ``timeout_s``), and returns 282 ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as 283 :meth:`run_checker`. The harness writes its JSON rollup to stdout. The 284 per-call HTTP timeout sits above the node's run budget so the client 285 doesn't abandon the request before the (long-running) harness returns. 286 """ 287 resp = self.request( 288 "POST", 289 "/harness/run", 290 json={"image": image, "env": env, "timeout_s": timeout_s}, 291 timeout=timeout_s + 30, 292 ) 293 _raise_for_node_status(resp) 294 body: dict[str, Any] = resp.json() 295 return body
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.
297 def verify_patch( 298 self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900 299 ) -> dict[str, Any]: 300 """Build a submitted patch on the node and return its verdict. 301 302 ``files`` maps a challenge-relative path to its new bytes; base64 303 is applied here because a patch target may legitimately be 304 binary. Returns ``{verdict, detail, applied, steps, error}``. 305 306 **An empty ``verdict`` with a populated ``error`` is not a 307 judgement** — it means the node refused the submission or the 308 verifier crashed. The caller must retry or retire the lease 309 rather than write a score, since failing to judge a patch is not 310 the same as judging it unfixed. 311 312 The per-call HTTP timeout sits above the node's own budget so the 313 client doesn't abandon the request while the node is still 314 building. 315 """ 316 resp = self.request( 317 "POST", 318 "/patches/verify", 319 json={ 320 "challenge_id": challenge_id, 321 "files": { 322 path: base64.b64encode(content).decode() for path, content in files.items() 323 }, 324 "timeout_s": timeout_s, 325 }, 326 timeout=timeout_s + 60, 327 ) 328 _raise_for_node_status(resp) 329 body: dict[str, Any] = resp.json() 330 return body
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.
332 def collect_patch_files( 333 self, instance_id: str, service: str, paths: list[str] 334 ) -> dict[str, bytes]: 335 """Read a player's SSH edits back out of their running box. 336 337 ``paths`` are absolute in-container paths the *platform* derived 338 from the pristine source tree. Returns only what could be read: 339 a path missing from the reply means the player did not change it, 340 and the caller falls back to the shipped file rather than 341 recording a deletion they never made. 342 """ 343 resp = self.request( 344 "POST", 345 f"/instances/{instance_id}/patch-collect", 346 json={"service": service, "paths": paths}, 347 ) 348 _raise_for_node_status(resp) 349 body: dict[str, str] = resp.json() 350 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.
352 def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]: 353 """Rotate a round's flags into every box of this node, in one call. 354 355 ⚠️ **One call for the whole node, never one per box.** §5.2's 356 capacity red line is 1000 teams x 3 services injected inside 357 120 s; per-box that is 3000 round trips, batched it is about 358 fourteen. The signature is the enforcement — a caller cannot 359 accidentally loop. 360 361 Returns ``{instance_id: ""}`` for the boxes written and a reason 362 for the ones that were not, because one team's unreachable box 363 must not cost every other team on the node its rotation. An 364 unrotated box keeps serving last round's flag, which anyone 365 holding the old value can replay. 366 """ 367 resp = self.request("POST", "/awd/answers", json={"boxes": boxes}) 368 _raise_for_node_status(resp) 369 result: dict[str, str] = resp.json() 370 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.
372 def probe_awd_service( 373 self, 374 *, 375 instance_id: str, 376 service_id: str, 377 tick: int, 378 flag: str, 379 previous_flag: str = "", 380 previous_flag_id: str = "", 381 ) -> dict[str, str]: 382 """Run one round's SLA probes against one box, from outside it. 383 384 The flags travel *to* the node: a checker that sourced its 385 expected value from the box it is checking would pass on any box 386 that returns whatever it was handed. 387 """ 388 resp = self.request( 389 "POST", 390 "/awd/probe", 391 json={ 392 "instance_id": instance_id, 393 "service_id": service_id, 394 "tick": tick, 395 "flag": flag, 396 "previous_flag": previous_flag, 397 "previous_flag_id": previous_flag_id, 398 }, 399 ) 400 _raise_for_node_status(resp) 401 result: dict[str, str] = resp.json() 402 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.
404 def get_container_logs(self, instance_id: str) -> str: 405 """Fetch combined container stdout/stderr for archival.""" 406 resp = self.request("GET", f"/instances/{instance_id}/container-logs") 407 resp.raise_for_status() 408 return str(resp.json().get("logs") or "")
Fetch combined container stdout/stderr for archival.
410 def get_pcap(self, instance_id: str) -> bytes: 411 """Fetch the raw tcpdump capture for *instance_id*. 412 413 Returns an empty ``bytes`` when no capture exists (sidecar 414 disabled, instance never reached the running state, etc.) so 415 callers can persist conditionally without try/except. 416 """ 417 resp = self.request("GET", f"/instances/{instance_id}/pcap") 418 if resp.status_code == 404: 419 return b"" 420 resp.raise_for_status() 421 return resp.content
Fetch the raw tcpdump capture for instance_id.
Returns an empty bytes when no capture exists (sidecar
disabled, instance never reached the running state, etc.) so
callers can persist conditionally without try/except.
423 def get_agent_runtime(self, instance_id: str) -> dict[str, Any]: 424 """Provider-agnostic runtime hints for attaching an agent (proxy URL, 425 CA PEM, optional Docker-specific names). Replaces the old 426 sandbox-network shape.""" 427 resp = self.request("GET", f"/instances/{instance_id}/agent-runtime") 428 resp.raise_for_status() 429 body: dict[str, Any] = resp.json() 430 return body
Provider-agnostic runtime hints for attaching an agent (proxy URL, CA PEM, optional Docker-specific names). Replaces the old sandbox-network shape.
434 def list_instance_attachments(self, instance_id: str) -> dict[str, Any]: 435 """List per-instance attachments rendered into the node's workdir. 436 437 Returns the raw JSON dict — caller projects through 438 :class:`AttachmentList` for typing. Used by the platform's 439 per-instance attachment endpoint to surface team-specific 440 rendered file listings.""" 441 resp = self.request("GET", f"/instances/{instance_id}/attachments") 442 resp.raise_for_status() 443 body: dict[str, Any] = resp.json() 444 return body
List per-instance attachments rendered into the node's workdir.
Returns the raw JSON dict — caller projects through
AttachmentList for typing. Used by the platform's
per-instance attachment endpoint to surface team-specific
rendered file listings.
446 def get_openvpn_config(self, instance_id: str) -> bytes: 447 """Fetch the per-instance OpenVPN client config from the node. 448 449 The node reads ``/etc/openvpn/client.ovpn`` out of the instance's 450 ``openvpn`` service container (generated on first boot by the 451 ``ctfy/openvpn-base`` image's bootstrap). Returns the body 452 verbatim — the platform-side route adds the 453 ``Content-Disposition`` header before re-emitting to the player. 454 455 Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's 456 proxy route catches 404 and re-emits to the player, anything 457 else is treated as a 502. 458 """ 459 resp = self.request("GET", f"/instances/{instance_id}/openvpn-config") 460 resp.raise_for_status() 461 return resp.content
Fetch the per-instance OpenVPN client config from the node.
The node reads /etc/openvpn/client.ovpn out of the instance's
openvpn service container (generated on first boot by the
ctfy/openvpn-base image's bootstrap). Returns the body
verbatim — the platform-side route adds the
Content-Disposition header before re-emitting to the player.
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.
463 def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]: 464 """Download one per-instance attachment as ``(bytes, content_type)``. 465 466 Buffers the whole body — attachments are bounded (typical case 467 is a 10 KB binary or a small text file). For huge captures the 468 caller should hand the player a CDN URL instead. 469 470 Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's 471 proxy route catches 404 and re-emits to the player, anything 472 else is a 502.""" 473 resp = self.request( 474 "GET", 475 f"/instances/{instance_id}/attachments/{filename}", 476 ) 477 resp.raise_for_status() 478 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.
82class PlatformClient(BaseHttpClient): 83 """Player-facing HTTP client for the ctfy platform. 84 85 Methods are grouped into resource namespaces (``client.teams.list()``, …) 86 plus a per-competition scope via ``client.competition(id)``. The admin 87 surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via 88 :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers. 89 """ 90 91 def __init__( 92 self, 93 server_url: str, 94 token: str = "", 95 max_retries: int = 3, 96 timeout: int = DEFAULT_CLIENT_TIMEOUT, 97 ): 98 self._url = server_url.rstrip("/") 99 super().__init__(f"{self._url}/api/v1", token, timeout=timeout, max_retries=max_retries) 100 101 @property 102 def server_url(self) -> str: 103 """The platform root, without the ``/api/v1`` suffix. 104 105 Exposed for callers that must build a non-``/api/v1`` URL of 106 their own — today the shell WebSocket, whose ticket carries a 107 platform-relative path the client has to absolutise against the 108 scheme and host *it* reached us on. 109 """ 110 return self._url 111 112 # -- resource namespaces ------------------------------------------------ 113 114 @cached_property 115 def registration(self) -> RegistrationResource: 116 """The caller's own per-competition registration + team logo.""" 117 return RegistrationResource(self) 118 119 @cached_property 120 def teams(self) -> TeamsResource: 121 """Public team discovery + profile reads.""" 122 return TeamsResource(self) 123 124 @cached_property 125 def users(self) -> UsersResource: 126 """Public per-user profile reads.""" 127 return UsersResource(self) 128 129 @cached_property 130 def me(self) -> MeResource: 131 """The calling user's profile, inbox, account, and progress.""" 132 return MeResource(self) 133 134 @cached_property 135 def auth(self) -> AuthResource: 136 """Password auth, OAuth identities, API tokens, sign-in discovery.""" 137 return AuthResource(self) 138 139 @cached_property 140 def achievements(self) -> AchievementsResource: 141 """Public badge catalog, recent-unlock feed, easter eggs.""" 142 return AchievementsResource(self) 143 144 @cached_property 145 def challenges(self) -> ChallengesResource: 146 """Global challenge catalog, attachments, facets, feedback chips.""" 147 return ChallengesResource(self) 148 149 @cached_property 150 def competitions(self) -> CompetitionsResource: 151 """Discover competitions (``list`` / ``get``). To act within one, use 152 :meth:`competition`.""" 153 return CompetitionsResource(self) 154 155 def competition(self, competition_id: str) -> Competition: 156 """Scope to one competition. The inherently competition-scoped 157 operations — register, your team (captain tooling + invites + 158 join-requests), the comp's challenges, team search, and standings — 159 hang off the returned :class:`~ctfy.sdk.competition.Competition` 160 handle, so ``competition_id`` is named once instead of on every call. 161 (Instance lifecycle and answer submission stay on :attr:`instances` / 162 :attr:`submissions`, keyed by ``instance_id``.)""" 163 return Competition(self, competition_id) 164 165 @cached_property 166 def series(self) -> SeriesResource: 167 """A recurring contest's ladder (public), not its schedule — 168 configuring a series is ``client.admin.series``.""" 169 return SeriesResource(self) 170 171 @cached_property 172 def eval(self) -> EvalResource: 173 """Public model-evaluation leaderboard (ranking + dimensional breakdowns).""" 174 return EvalResource(self) 175 176 @cached_property 177 def vendor(self) -> VendorResource: 178 """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer).""" 179 return VendorResource(self) 180 181 @cached_property 182 def instances(self) -> InstancesResource: 183 """Launch / control / inspect challenge instances by instance id 184 (the competition is inferred server-side).""" 185 return InstancesResource(self) 186 187 @cached_property 188 def submissions(self) -> SubmissionsResource: 189 """Submit / verify answers + list submissions (by instance id), plus 190 the instance-less QA challenges.""" 191 return SubmissionsResource(self) 192 193 @cached_property 194 def patches(self) -> PatchesResource: 195 """AWD+ defence: submit a patch, then watch for its verdict.""" 196 return PatchesResource(self) 197 198 @cached_property 199 def awd(self) -> AwdResource: 200 """Classic AWD: submit a round's captured flags in one batch.""" 201 return AwdResource(self) 202 203 @cached_property 204 def scoreboard(self) -> ScoreboardResource: 205 """Global standings, per-challenge stats, persisted snapshots.""" 206 return ScoreboardResource(self) 207 208 @cached_property 209 def activities(self) -> ActivitiesResource: 210 """Platform activity log + histogram.""" 211 return ActivitiesResource(self) 212 213 @cached_property 214 def nodes(self) -> NodesResource: 215 """Public worker-node list (operator node mgmt is on ``admin.nodes``).""" 216 return NodesResource(self) 217 218 @cached_property 219 def admin(self) -> AdminClient: 220 """Admin / operator surface (separate, role-gated server-side). 221 222 Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`. 223 """ 224 return AdminClient(self) 225 226 # -- top-level convenience: server status / realtime / version ---------- 227 228 def health(self) -> HealthResponse: 229 resp = self.request("GET", "/health") 230 _raise_for_status(resp) 231 return HealthResponse.model_validate(resp.json()) 232 233 def get_meta(self) -> MetaResponse: 234 """Server identity + challenge repo SHA + build version. 235 Admin tokens additionally see cluster capacity + team / solve 236 counts in the same payload.""" 237 resp = self.request("GET", "/meta") 238 _raise_for_status(resp) 239 return MetaResponse.model_validate(resp.json()) 240 241 def cluster_info(self) -> ClusterInfo: 242 """Aggregate worker-node capacity / utilisation (the public 243 capacity banner; no per-node detail).""" 244 resp = self.request("GET", "/cluster-info") 245 _raise_for_status(resp) 246 return ClusterInfo.model_validate(resp.json()) 247 248 def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck: 249 """Compare this client's ``ctfy`` version against the server's. 250 251 Uses the cheap unauthenticated ``/health`` probe (pass an 252 already-fetched :class:`HealthResponse` to avoid a second round 253 trip). Pure classification — never prints, never raises on a 254 mismatch, never mutates anything. The caller (CLI, MCP, harness) 255 decides what to do with :class:`~ctfy.core.version.VersionCheck` 256 (typically: print ``.message`` to stderr when not ``.quiet``). 257 258 A server too old to report its version yields 259 :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which 260 is ``.quiet`` — so this stays silent against legacy servers 261 rather than crying wolf. 262 """ 263 h = health if health is not None else self.health() 264 return check_compatibility(package_version(), h.version) 265 266 def events(self, *, auto_reconnect: bool = True) -> EventStream: 267 """Open the platform SSE event stream. 268 269 Yields ``{"event": <name>, "data": <dict>}`` for each frame. 270 Filters: team-scoped events for every team the caller's user is 271 on (across every per-comp competition), plus all global events; 272 admin tokens additionally see admin-only frames. 273 274 Usage:: 275 276 with client.events() as stream: 277 for event in stream: 278 if event["event"] == "solve": 279 ... 280 281 Auto-reconnects on transient network errors with exponential 282 backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make 283 the iterator raise instead. 284 """ 285 token = self._token 286 287 def _factory() -> httpx.Client: 288 # New client per session so a reconnect after a stale 289 # connection drops fresh sockets, not warmed-over ones. 290 return httpx.Client(base_url=self._base_url, timeout=None) 291 292 return EventStream( 293 client_factory=_factory, 294 path="/events", 295 token=token, 296 auto_reconnect=auto_reconnect, 297 )
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.
101 @property 102 def server_url(self) -> str: 103 """The platform root, without the ``/api/v1`` suffix. 104 105 Exposed for callers that must build a non-``/api/v1`` URL of 106 their own — today the shell WebSocket, whose ticket carries a 107 platform-relative path the client has to absolutise against the 108 scheme and host *it* reached us on. 109 """ 110 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.
114 @cached_property 115 def registration(self) -> RegistrationResource: 116 """The caller's own per-competition registration + team logo.""" 117 return RegistrationResource(self)
The caller's own per-competition registration + team logo.
119 @cached_property 120 def teams(self) -> TeamsResource: 121 """Public team discovery + profile reads.""" 122 return TeamsResource(self)
Public team discovery + profile reads.
124 @cached_property 125 def users(self) -> UsersResource: 126 """Public per-user profile reads.""" 127 return UsersResource(self)
Public per-user profile reads.
129 @cached_property 130 def me(self) -> MeResource: 131 """The calling user's profile, inbox, account, and progress.""" 132 return MeResource(self)
The calling user's profile, inbox, account, and progress.
134 @cached_property 135 def auth(self) -> AuthResource: 136 """Password auth, OAuth identities, API tokens, sign-in discovery.""" 137 return AuthResource(self)
Password auth, OAuth identities, API tokens, sign-in discovery.
139 @cached_property 140 def achievements(self) -> AchievementsResource: 141 """Public badge catalog, recent-unlock feed, easter eggs.""" 142 return AchievementsResource(self)
Public badge catalog, recent-unlock feed, easter eggs.
144 @cached_property 145 def challenges(self) -> ChallengesResource: 146 """Global challenge catalog, attachments, facets, feedback chips.""" 147 return ChallengesResource(self)
Global challenge catalog, attachments, facets, feedback chips.
149 @cached_property 150 def competitions(self) -> CompetitionsResource: 151 """Discover competitions (``list`` / ``get``). To act within one, use 152 :meth:`competition`.""" 153 return CompetitionsResource(self)
Discover competitions (list / get). To act within one, use
competition().
155 def competition(self, competition_id: str) -> Competition: 156 """Scope to one competition. The inherently competition-scoped 157 operations — register, your team (captain tooling + invites + 158 join-requests), the comp's challenges, team search, and standings — 159 hang off the returned :class:`~ctfy.sdk.competition.Competition` 160 handle, so ``competition_id`` is named once instead of on every call. 161 (Instance lifecycle and answer submission stay on :attr:`instances` / 162 :attr:`submissions`, keyed by ``instance_id``.)""" 163 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.)
165 @cached_property 166 def series(self) -> SeriesResource: 167 """A recurring contest's ladder (public), not its schedule — 168 configuring a series is ``client.admin.series``.""" 169 return SeriesResource(self)
A recurring contest's ladder (public), not its schedule —
configuring a series is client.admin.series.
171 @cached_property 172 def eval(self) -> EvalResource: 173 """Public model-evaluation leaderboard (ranking + dimensional breakdowns).""" 174 return EvalResource(self)
Public model-evaluation leaderboard (ranking + dimensional breakdowns).
176 @cached_property 177 def vendor(self) -> VendorResource: 178 """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer).""" 179 return VendorResource(self)
Vendor tenancy — the calling vendor's own models / runs (pv_ bearer).
181 @cached_property 182 def instances(self) -> InstancesResource: 183 """Launch / control / inspect challenge instances by instance id 184 (the competition is inferred server-side).""" 185 return InstancesResource(self)
Launch / control / inspect challenge instances by instance id (the competition is inferred server-side).
187 @cached_property 188 def submissions(self) -> SubmissionsResource: 189 """Submit / verify answers + list submissions (by instance id), plus 190 the instance-less QA challenges.""" 191 return SubmissionsResource(self)
Submit / verify answers + list submissions (by instance id), plus the instance-less QA challenges.
193 @cached_property 194 def patches(self) -> PatchesResource: 195 """AWD+ defence: submit a patch, then watch for its verdict.""" 196 return PatchesResource(self)
AWD+ defence: submit a patch, then watch for its verdict.
198 @cached_property 199 def awd(self) -> AwdResource: 200 """Classic AWD: submit a round's captured flags in one batch.""" 201 return AwdResource(self)
Classic AWD: submit a round's captured flags in one batch.
203 @cached_property 204 def scoreboard(self) -> ScoreboardResource: 205 """Global standings, per-challenge stats, persisted snapshots.""" 206 return ScoreboardResource(self)
Global standings, per-challenge stats, persisted snapshots.
208 @cached_property 209 def activities(self) -> ActivitiesResource: 210 """Platform activity log + histogram.""" 211 return ActivitiesResource(self)
Platform activity log + histogram.
213 @cached_property 214 def nodes(self) -> NodesResource: 215 """Public worker-node list (operator node mgmt is on ``admin.nodes``).""" 216 return NodesResource(self)
Public worker-node list (operator node mgmt is on admin.nodes).
218 @cached_property 219 def admin(self) -> AdminClient: 220 """Admin / operator surface (separate, role-gated server-side). 221 222 Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`. 223 """ 224 return AdminClient(self)
Admin / operator surface (separate, role-gated server-side).
Shares this client's transport. See ~ctfy.sdk.admin.AdminClient.
233 def get_meta(self) -> MetaResponse: 234 """Server identity + challenge repo SHA + build version. 235 Admin tokens additionally see cluster capacity + team / solve 236 counts in the same payload.""" 237 resp = self.request("GET", "/meta") 238 _raise_for_status(resp) 239 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.
241 def cluster_info(self) -> ClusterInfo: 242 """Aggregate worker-node capacity / utilisation (the public 243 capacity banner; no per-node detail).""" 244 resp = self.request("GET", "/cluster-info") 245 _raise_for_status(resp) 246 return ClusterInfo.model_validate(resp.json())
Aggregate worker-node capacity / utilisation (the public capacity banner; no per-node detail).
248 def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck: 249 """Compare this client's ``ctfy`` version against the server's. 250 251 Uses the cheap unauthenticated ``/health`` probe (pass an 252 already-fetched :class:`HealthResponse` to avoid a second round 253 trip). Pure classification — never prints, never raises on a 254 mismatch, never mutates anything. The caller (CLI, MCP, harness) 255 decides what to do with :class:`~ctfy.core.version.VersionCheck` 256 (typically: print ``.message`` to stderr when not ``.quiet``). 257 258 A server too old to report its version yields 259 :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which 260 is ``.quiet`` — so this stays silent against legacy servers 261 rather than crying wolf. 262 """ 263 h = health if health is not None else self.health() 264 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.
266 def events(self, *, auto_reconnect: bool = True) -> EventStream: 267 """Open the platform SSE event stream. 268 269 Yields ``{"event": <name>, "data": <dict>}`` for each frame. 270 Filters: team-scoped events for every team the caller's user is 271 on (across every per-comp competition), plus all global events; 272 admin tokens additionally see admin-only frames. 273 274 Usage:: 275 276 with client.events() as stream: 277 for event in stream: 278 if event["event"] == "solve": 279 ... 280 281 Auto-reconnects on transient network errors with exponential 282 backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make 283 the iterator raise instead. 284 """ 285 token = self._token 286 287 def _factory() -> httpx.Client: 288 # New client per session so a reconnect after a stale 289 # connection drops fresh sockets, not warmed-over ones. 290 return httpx.Client(base_url=self._base_url, timeout=None) 291 292 return EventStream( 293 client_factory=_factory, 294 path="/events", 295 token=token, 296 auto_reconnect=auto_reconnect, 297 )
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.