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"]
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.
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.
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.
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.
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.
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.
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.
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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}.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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().
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).
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.
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.
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).
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.
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.