agentflow-proof 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentflow/runtime.py ADDED
@@ -0,0 +1,863 @@
1
+ """Runtime config validation and readiness snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import http.client
7
+ import ipaddress
8
+ import json
9
+ import os
10
+ import shutil
11
+ import socket
12
+ import ssl
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Tuple, Union
16
+ from urllib.parse import urljoin, urlsplit
17
+
18
+ IPAddress = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
19
+
20
+ from .artifacts import try_read_json, utc_now
21
+ from .contracts import (
22
+ ADAPTERS,
23
+ MCP_READINESS_CHECKS,
24
+ MCP_TRANSPORTS,
25
+ READINESS_CHECKS,
26
+ ROUTE_POLICIES,
27
+ RUNTIME_CONFIG_SCHEMA_VERSION,
28
+ RUNTIME_SNAPSHOT_SCHEMA_VERSION,
29
+ )
30
+ from .versioning import validate_schema_version
31
+
32
+
33
+ def sha256_path(path: Path) -> str:
34
+ digest = hashlib.sha256()
35
+ with path.open("rb") as handle:
36
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
37
+ digest.update(chunk)
38
+ return digest.hexdigest()
39
+
40
+
41
+ def validate_runtime_config(config: Dict[str, Any]) -> List[Dict[str, Any]]:
42
+ findings: List[Dict[str, Any]] = []
43
+ for message in validate_schema_version(
44
+ config.get("schema_version"), RUNTIME_CONFIG_SCHEMA_VERSION, "runtime-config"
45
+ ):
46
+ findings.append(
47
+ {
48
+ "id": "runtime_config_schema_version",
49
+ "severity": "error",
50
+ "message": message,
51
+ }
52
+ )
53
+
54
+ runtimes = config.get("runtimes", {})
55
+ routes = config.get("routes", {})
56
+ if not isinstance(runtimes, dict):
57
+ findings.append(
58
+ {"id": "runtimes_invalid", "severity": "error", "message": "runtimes must be an object"}
59
+ )
60
+ runtimes = {}
61
+ if not isinstance(routes, dict):
62
+ findings.append(
63
+ {"id": "routes_invalid", "severity": "error", "message": "routes must be an object"}
64
+ )
65
+ routes = {}
66
+
67
+ default_runtime = config.get("default_runtime")
68
+ if isinstance(default_runtime, str) and default_runtime not in runtimes:
69
+ findings.append(
70
+ {
71
+ "id": "default_runtime_unknown",
72
+ "severity": "error",
73
+ "message": f"default_runtime {default_runtime} is not configured",
74
+ }
75
+ )
76
+
77
+ for runtime_id, runtime in runtimes.items():
78
+ if not isinstance(runtime, dict):
79
+ findings.append(
80
+ {
81
+ "id": "runtime_invalid",
82
+ "severity": "error",
83
+ "message": f"runtime {runtime_id} must be an object",
84
+ }
85
+ )
86
+ continue
87
+ adapter = runtime.get("adapter")
88
+ if adapter not in ADAPTERS:
89
+ findings.append(
90
+ {
91
+ "id": "adapter_unknown",
92
+ "severity": "error",
93
+ "message": f"runtime {runtime_id} has unknown adapter {adapter}",
94
+ }
95
+ )
96
+ if not isinstance(runtime.get("enabled"), bool):
97
+ findings.append(
98
+ {
99
+ "id": "enabled_invalid",
100
+ "severity": "error",
101
+ "message": f"runtime {runtime_id}.enabled must be boolean",
102
+ }
103
+ )
104
+ readiness = runtime.get("readiness", {})
105
+ if isinstance(readiness, dict):
106
+ check = readiness.get("check", "command_exists")
107
+ if check not in READINESS_CHECKS:
108
+ findings.append(
109
+ {
110
+ "id": "readiness_check_unknown",
111
+ "severity": "error",
112
+ "message": f"runtime {runtime_id} has unknown readiness check {check}",
113
+ }
114
+ )
115
+ capabilities = runtime.get("capabilities", {})
116
+ if isinstance(capabilities, dict):
117
+ for field in ("declared", "required"):
118
+ value = capabilities.get(field, [])
119
+ if field in capabilities and not isinstance(value, list):
120
+ findings.append(
121
+ {
122
+ "id": f"capabilities_{field}_invalid",
123
+ "severity": "error",
124
+ "message": (
125
+ f"runtime {runtime_id}.capabilities.{field} must be an array"
126
+ ),
127
+ }
128
+ )
129
+
130
+ for route_name, route in routes.items():
131
+ if not isinstance(route, dict):
132
+ findings.append(
133
+ {
134
+ "id": "route_invalid",
135
+ "severity": "error",
136
+ "message": f"route {route_name} must be an object",
137
+ }
138
+ )
139
+ continue
140
+ primary = route.get("primary")
141
+ primary_lookup = True
142
+ if primary is None:
143
+ primary = default_runtime if isinstance(default_runtime, str) else "__missing__"
144
+ elif not isinstance(primary, str):
145
+ findings.append(
146
+ {
147
+ "id": "route_primary_invalid",
148
+ "severity": "error",
149
+ "message": f"route {route_name}.primary must be a string",
150
+ }
151
+ )
152
+ primary = "__missing__"
153
+ primary_lookup = False
154
+ if primary_lookup and primary not in runtimes:
155
+ findings.append(
156
+ {
157
+ "id": "route_primary_unknown",
158
+ "severity": "error",
159
+ "message": f"route {route_name}.primary references unknown runtime {primary}",
160
+ }
161
+ )
162
+ fallbacks = route.get("fallbacks", [])
163
+ valid_fallbacks = []
164
+ for fallback in fallbacks if isinstance(fallbacks, list) else []:
165
+ if not isinstance(fallback, str):
166
+ findings.append(
167
+ {
168
+ "id": "route_fallback_invalid",
169
+ "severity": "error",
170
+ "message": f"route {route_name}.fallbacks entries must be strings",
171
+ }
172
+ )
173
+ continue
174
+ valid_fallbacks.append(fallback)
175
+ if fallback not in runtimes:
176
+ findings.append(
177
+ {
178
+ "id": "route_fallback_unknown",
179
+ "severity": "error",
180
+ "message": f"route {route_name}.fallbacks references unknown runtime {fallback}",
181
+ }
182
+ )
183
+ policy = route.get("policy", "manual_only")
184
+ if policy not in ROUTE_POLICIES:
185
+ findings.append(
186
+ {
187
+ "id": "route_policy_unknown",
188
+ "severity": "error",
189
+ "message": f"route {route_name}.policy is unknown: {policy}",
190
+ }
191
+ )
192
+ target_ids = [primary] + valid_fallbacks
193
+ declared = set()
194
+ for target_id in target_ids:
195
+ runtime = runtimes.get(target_id)
196
+ if isinstance(runtime, dict):
197
+ capabilities = runtime.get("capabilities", {})
198
+ if isinstance(capabilities, dict):
199
+ declared_values = capabilities.get("declared", [])
200
+ if isinstance(declared_values, list):
201
+ declared.update(item for item in declared_values if isinstance(item, str))
202
+ requires = route.get("requires", [])
203
+ if not isinstance(requires, list):
204
+ findings.append(
205
+ {
206
+ "id": "route_requires_invalid",
207
+ "severity": "error",
208
+ "message": f"route {route_name}.requires must be an array",
209
+ }
210
+ )
211
+ requires = []
212
+ required = set(item for item in requires if isinstance(item, str))
213
+ missing = sorted(required - declared)
214
+ if missing:
215
+ findings.append(
216
+ {
217
+ "id": "route_requires_unsatisfied",
218
+ "severity": "warning",
219
+ "message": (
220
+ f"route {route_name} requirements are not declared by "
221
+ f"targeted runtimes: {', '.join(missing)}"
222
+ ),
223
+ }
224
+ )
225
+ primary_runtime = runtimes.get(primary)
226
+ if isinstance(primary_runtime, dict) and primary_runtime.get("enabled") is False:
227
+ findings.append(
228
+ {
229
+ "id": "route_primary_disabled",
230
+ "severity": "warning",
231
+ "message": f"route {route_name}.primary runtime {primary} is disabled",
232
+ }
233
+ )
234
+
235
+ mcp_servers = config.get("mcp_servers", {})
236
+ if not isinstance(mcp_servers, dict):
237
+ findings.append(
238
+ {
239
+ "id": "mcp_servers_invalid",
240
+ "severity": "error",
241
+ "message": "mcp_servers must be an object",
242
+ }
243
+ )
244
+ mcp_servers = {}
245
+ for server_id, server in mcp_servers.items():
246
+ if not isinstance(server, dict):
247
+ findings.append(
248
+ {
249
+ "id": "mcp_server_invalid",
250
+ "severity": "error",
251
+ "message": f"mcp server {server_id} must be an object",
252
+ }
253
+ )
254
+ continue
255
+ if not isinstance(server.get("enabled"), bool):
256
+ findings.append(
257
+ {
258
+ "id": "mcp_enabled_invalid",
259
+ "severity": "error",
260
+ "message": f"mcp server {server_id}.enabled must be boolean",
261
+ }
262
+ )
263
+ transport = server.get("transport", "stdio")
264
+ if transport not in MCP_TRANSPORTS:
265
+ findings.append(
266
+ {
267
+ "id": "mcp_transport_unknown",
268
+ "severity": "error",
269
+ "message": f"mcp server {server_id} has unknown transport {transport}",
270
+ }
271
+ )
272
+ declared_tools = server.get("declared_tools", [])
273
+ if "declared_tools" in server and not isinstance(declared_tools, list):
274
+ findings.append(
275
+ {
276
+ "id": "mcp_declared_tools_invalid",
277
+ "severity": "error",
278
+ "message": f"mcp server {server_id}.declared_tools must be an array",
279
+ }
280
+ )
281
+ readiness = server.get("readiness", {})
282
+ if isinstance(readiness, dict):
283
+ check = readiness.get("check", "none")
284
+ if check not in MCP_READINESS_CHECKS:
285
+ findings.append(
286
+ {
287
+ "id": "mcp_readiness_check_unknown",
288
+ "severity": "error",
289
+ "message": f"mcp server {server_id} has unknown readiness check {check}",
290
+ }
291
+ )
292
+
293
+ return findings
294
+
295
+
296
+ def _declared(runtime: Dict[str, Any]) -> List[str]:
297
+ capabilities = runtime.get("capabilities", {})
298
+ if not isinstance(capabilities, dict):
299
+ return []
300
+ declared = capabilities.get("declared", [])
301
+ if not isinstance(declared, list):
302
+ return []
303
+ return sorted(item for item in declared if isinstance(item, str))
304
+
305
+
306
+ def _required(runtime: Dict[str, Any]) -> List[str]:
307
+ capabilities = runtime.get("capabilities", {})
308
+ if not isinstance(capabilities, dict):
309
+ return []
310
+ required = capabilities.get("required", [])
311
+ if not isinstance(required, list):
312
+ return []
313
+ return sorted(item for item in required if isinstance(item, str))
314
+
315
+
316
+ def _command_available(command: Optional[str]) -> bool:
317
+ if not command:
318
+ return False
319
+ if os.path.isabs(command):
320
+ path = Path(command)
321
+ return path.exists() and os.access(path, os.X_OK)
322
+ return shutil.which(command) is not None
323
+
324
+
325
+ # Ranges that front cloud instance-metadata services or "this host":
326
+ # AWS/GCP/Azure/Oracle/DO 169.254.169.254, Alibaba 100.100.100.200 (CGNAT),
327
+ # AWS IPv6 IMDS fd00:ec2::254, the 0.0.0.0/8 "this network" wildcard, and IPv6
328
+ # link-local. These are never legitimate LLM endpoints, so the opt-in `http`
329
+ # readiness probe refuses them while loopback/LAN model servers (ollama,
330
+ # LM Studio, self-hosted) stay allowed. This denylist is defense-in-depth: the
331
+ # user-configured models_url is the de-facto allowlist, and the probe pins to
332
+ # the validated IP (below) so it cannot be a general-purpose SSRF primitive.
333
+ _METADATA_BLOCK_NETS = (
334
+ ipaddress.ip_network("0.0.0.0/8"),
335
+ ipaddress.ip_network("169.254.0.0/16"),
336
+ ipaddress.ip_network("100.64.0.0/10"),
337
+ ipaddress.ip_network("fe80::/10"),
338
+ ipaddress.ip_network("fd00:ec2::254/128"),
339
+ )
340
+
341
+ _MAX_PROBE_REDIRECTS = 5
342
+
343
+
344
+ def _addresses_to_check(ip: IPAddress) -> List[IPAddress]:
345
+ """The address plus any IPv4 it embeds, so v6 encodings can't smuggle a
346
+ blocked v4 (`::ffff:169.254.169.254`, `::a9fe:a9fe`, 6to4, teredo)."""
347
+ checks: List[IPAddress] = [ip]
348
+ if isinstance(ip, ipaddress.IPv6Address):
349
+ if ip.ipv4_mapped:
350
+ checks.append(ip.ipv4_mapped)
351
+ if ip.sixtofour:
352
+ checks.append(ip.sixtofour)
353
+ if ip.teredo:
354
+ checks.append(ip.teredo[1])
355
+ # IPv4-compatible (deprecated ::/96), excluding :: and ::1.
356
+ if int(ip) >> 32 == 0 and int(ip) > 1:
357
+ checks.append(ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF))
358
+ return checks
359
+
360
+
361
+ def _ip_is_blocked(ip: IPAddress) -> bool:
362
+ for candidate in _addresses_to_check(ip):
363
+ for net in _METADATA_BLOCK_NETS:
364
+ if candidate.version == net.version and candidate in net:
365
+ return True
366
+ return False
367
+
368
+
369
+ def _resolve_validated_ip(host: str, timeout: float) -> Tuple[Optional[IPAddress], Optional[str]]:
370
+ """Resolve a host to one safe IP, or return a refusal reason.
371
+
372
+ The resolution result is what the probe then connects to (IP pinning), so
373
+ there is no second, unvalidated lookup — closing DNS-rebinding and
374
+ obfuscated-literal (decimal/hex/octal) bypasses. Resolution failure is
375
+ fail-closed: the probe refuses rather than letting a later lookup proceed
376
+ unchecked. A bounded DNS timeout prevents a hostile resolver from hanging
377
+ runtime-status.
378
+ """
379
+ try:
380
+ resolved: List[IPAddress] = [ipaddress.ip_address(host)]
381
+ except ValueError:
382
+ previous = socket.getdefaulttimeout()
383
+ socket.setdefaulttimeout(timeout)
384
+ try:
385
+ resolved = [
386
+ ipaddress.ip_address(info[4][0].split("%", 1)[0])
387
+ for info in socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
388
+ ]
389
+ except (socket.gaierror, OSError, ValueError) as exc:
390
+ return None, f"refusing probe: cannot resolve host {host!r}: {exc}"
391
+ finally:
392
+ socket.setdefaulttimeout(previous)
393
+ if not resolved:
394
+ return None, f"refusing probe: host {host!r} resolved to no addresses"
395
+ for ip in resolved:
396
+ if _ip_is_blocked(ip):
397
+ return None, f"refusing probe to instance-metadata/internal address: {ip}"
398
+ return resolved[0], None
399
+
400
+
401
+ def _probe_once(url: str, timeout: float) -> Tuple[Optional[http.client.HTTPResponse], Optional[str]]:
402
+ """Open one hop, connecting to the validated, pinned IP (not a re-resolve)."""
403
+ parts = urlsplit(url)
404
+ if parts.scheme not in ("http", "https"):
405
+ return None, f"refusing non-http(s) probe scheme: {url!r}"
406
+ host = parts.hostname
407
+ if not host:
408
+ return None, f"refusing probe: no host in {url!r}"
409
+ port = parts.port or (443 if parts.scheme == "https" else 80)
410
+ ip, reason = _resolve_validated_ip(host, timeout)
411
+ if reason:
412
+ return None, reason
413
+ raw = socket.create_connection((str(ip), port), timeout=timeout)
414
+ try:
415
+ if parts.scheme == "https":
416
+ context = ssl.create_default_context()
417
+ raw = context.wrap_socket(raw, server_hostname=host)
418
+ conn = http.client.HTTPConnection(host, port, timeout=timeout)
419
+ conn.sock = raw
420
+ target = parts.path or "/"
421
+ if parts.query:
422
+ target += "?" + parts.query
423
+ authority = host if port in (80, 443) else f"{host}:{port}"
424
+ conn.request("GET", target, headers={"Host": authority})
425
+ return conn.getresponse(), None
426
+ except Exception:
427
+ raw.close()
428
+ raise
429
+
430
+
431
+ def _http_models(url: str, timeout: float) -> Tuple[bool, List[str], str]:
432
+ if urlsplit(url).scheme not in ("http", "https"):
433
+ return False, [], f"refusing non-http(s) probe scheme: {url!r}"
434
+ current = url
435
+ try:
436
+ for _ in range(_MAX_PROBE_REDIRECTS + 1):
437
+ response, reason = _probe_once(current, timeout)
438
+ if reason:
439
+ return False, [], reason
440
+ try:
441
+ if 300 <= response.status < 400:
442
+ location = response.getheader("Location")
443
+ if not location:
444
+ return False, [], f"redirect ({response.status}) without Location"
445
+ # Re-validate every hop: a benign URL cannot redirect into a
446
+ # blocked range.
447
+ current = urljoin(current, location)
448
+ continue
449
+ if response.status < 200 or response.status >= 300:
450
+ return False, [], f"HTTP status {response.status}"
451
+ payload = json.loads(response.read().decode("utf-8"))
452
+ break
453
+ finally:
454
+ response.close()
455
+ else:
456
+ return False, [], f"refusing probe: more than {_MAX_PROBE_REDIRECTS} redirects"
457
+ except (OSError, ssl.SSLError, http.client.HTTPException, ValueError) as exc:
458
+ return False, [], str(exc)
459
+ models = []
460
+ for item in payload.get("data", []) if isinstance(payload, dict) else []:
461
+ model_id = item.get("id") if isinstance(item, dict) else None
462
+ if isinstance(model_id, str):
463
+ models.append(model_id)
464
+ return True, sorted(models), "HTTP probe succeeded"
465
+
466
+
467
+ def _http_status(url: str, timeout: float) -> str:
468
+ """Liveness-only probe for #19: one GET, status line only, body never read.
469
+
470
+ Returns "succeeded" (any HTTP status line, 3xx-5xx included -- the endpoint
471
+ answered), "refused" (scheme/SSRF/resolution guard blocked the probe), or
472
+ "failed" (connection error / timeout). Redirects are NOT followed: a
473
+ redirect status line already proves liveness. Raw error text (which can
474
+ embed host/IP/url) is discarded here so the snapshot layer only ever sees
475
+ the outcome enum (I5).
476
+ """
477
+ try:
478
+ response, reason = _probe_once(url, timeout)
479
+ except (OSError, ssl.SSLError, http.client.HTTPException, ValueError):
480
+ return "failed"
481
+ if reason:
482
+ return "refused"
483
+ response.close()
484
+ return "succeeded"
485
+
486
+
487
+ def _spawn_liveness(command_args: List[str], grace: float) -> Tuple[bool, str]:
488
+ """Treat a process that stays alive past grace as reachable."""
489
+ if not command_args:
490
+ return False, "no command configured for command_spawn"
491
+ try:
492
+ proc = subprocess.Popen(
493
+ command_args,
494
+ stdin=subprocess.PIPE,
495
+ stdout=subprocess.PIPE,
496
+ stderr=subprocess.PIPE,
497
+ )
498
+ except (OSError, TypeError, ValueError) as exc:
499
+ return False, f"failed to spawn: {exc}"
500
+ try:
501
+ returncode = proc.wait(timeout=grace)
502
+ return returncode == 0, f"command exited {returncode}"
503
+ except subprocess.TimeoutExpired:
504
+ return True, "process alive after grace period"
505
+ finally:
506
+ if proc.poll() is None:
507
+ proc.terminate()
508
+ try:
509
+ proc.wait(timeout=2)
510
+ except subprocess.TimeoutExpired:
511
+ proc.kill()
512
+ proc.wait()
513
+ for pipe in (proc.stdin, proc.stdout, proc.stderr):
514
+ if pipe is not None:
515
+ pipe.close()
516
+
517
+
518
+ def _normalized_declared_tools(server: Dict[str, Any]) -> List[str]:
519
+ # #19/P3: strings only, deduped, sorted -> stable snapshots and proof diffs.
520
+ tools = server.get("declared_tools", [])
521
+ if not isinstance(tools, list):
522
+ return []
523
+ return sorted({item for item in tools if isinstance(item, str)})
524
+
525
+
526
+ def _mcp_server_status(
527
+ server_id: str, server: Dict[str, Any], allow_probe: bool
528
+ ) -> Dict[str, Any]:
529
+ """Evidence-only MCP status per the #19 rule table.
530
+
531
+ Messages come from a closed vocabulary; probe targets (url/command/args)
532
+ are never echoed into the snapshot (I5). No MCP/JSON-RPC is ever spoken
533
+ (I1) -- liveness only.
534
+ """
535
+ declared_tools = _normalized_declared_tools(server)
536
+ readiness = server.get("readiness", {})
537
+ if not isinstance(readiness, dict):
538
+ readiness = {}
539
+ # Absent readiness is declared-only ("not probed"); an explicit
540
+ # {"check": "none"} is a deliberate opt-out ("readiness check disabled").
541
+ declared_only = not readiness
542
+ check = readiness.get("check", "none")
543
+ enabled = server.get("enabled") is True
544
+ transport = server.get("transport", "stdio")
545
+ if transport not in MCP_TRANSPORTS:
546
+ transport = "stdio"
547
+
548
+ status = "configured"
549
+ reachable = False
550
+ check_status = "not_run"
551
+ message = "not probed"
552
+ probe_ran = False
553
+
554
+ if not enabled:
555
+ status = "unavailable"
556
+ check_status = "skipped"
557
+ message = "server disabled"
558
+ elif check == "none":
559
+ if not declared_only:
560
+ check_status = "not_applicable"
561
+ message = "readiness check disabled"
562
+ # declared-only stays not_run / "not probed"
563
+ elif check == "command_exists":
564
+ command = readiness.get("command")
565
+ if isinstance(command, str) and command:
566
+ probe_ran = True
567
+ reachable = _command_available(command)
568
+ status = "ready" if reachable else "unavailable"
569
+ check_status = "passed" if reachable else "failed"
570
+ message = "command found" if reachable else "command not found"
571
+ # no command -> stays configured / not probed
572
+ elif check == "command_spawn":
573
+ if allow_probe:
574
+ command = readiness.get("command")
575
+ args = readiness.get("args", [])
576
+ command_args = (
577
+ [command] + args
578
+ if isinstance(command, str) and isinstance(args, list)
579
+ else []
580
+ )
581
+ spawn_config = readiness.get("spawn", {})
582
+ if not isinstance(spawn_config, dict):
583
+ spawn_config = {}
584
+ try:
585
+ grace = float(spawn_config.get("grace_seconds", 0.5))
586
+ except (TypeError, ValueError):
587
+ grace = 0.5
588
+ probe_ran = True
589
+ reachable, _raw = _spawn_liveness(command_args, max(0.0, grace))
590
+ status = "ready" if reachable else "unavailable"
591
+ check_status = "passed" if reachable else "failed"
592
+ # fixed vocabulary: discard _spawn_liveness's raw message (I5)
593
+ message = "process alive after grace period" if reachable else "command exited"
594
+ else:
595
+ message = "command_spawn skipped without --probe"
596
+ elif check == "http_status":
597
+ if allow_probe:
598
+ url = readiness.get("url", "")
599
+ try:
600
+ timeout = float(readiness.get("timeout_seconds", 2))
601
+ except (TypeError, ValueError):
602
+ timeout = 2.0
603
+ probe_ran = True
604
+ outcome = _http_status(url if isinstance(url, str) else "", timeout)
605
+ reachable = outcome == "succeeded"
606
+ status = "ready" if reachable else "unavailable"
607
+ check_status = "passed" if reachable else "failed"
608
+ message = f"http status probe {outcome}"
609
+ else:
610
+ message = "http status probe skipped without --probe"
611
+
612
+ entry = {
613
+ "id": server_id,
614
+ "transport": transport,
615
+ "enabled": enabled,
616
+ "status": status,
617
+ "reachable": reachable,
618
+ "declared_tools": declared_tools,
619
+ "declared_tool_count": len(declared_tools),
620
+ "tool_source": "declared",
621
+ "checks": [
622
+ {
623
+ "name": check,
624
+ "status": check_status,
625
+ "severity": "info",
626
+ "message": message,
627
+ }
628
+ ],
629
+ }
630
+ entry["_probe_ran"] = probe_ran # stripped by the caller after findings
631
+ return entry
632
+
633
+
634
+ def build_runtime_status(
635
+ config_path: Path,
636
+ record_id: str,
637
+ allow_probe: bool = False,
638
+ ) -> Dict[str, Any]:
639
+ config, read_error = try_read_json(config_path)
640
+ config_hash = sha256_path(config_path)
641
+ if config is None:
642
+ return {
643
+ "schema_version": RUNTIME_SNAPSHOT_SCHEMA_VERSION,
644
+ "id": record_id,
645
+ "created_at": utc_now(),
646
+ "runtime_config_sha256": config_hash,
647
+ "runtimes": [],
648
+ "routes": [],
649
+ "mcp_servers": [],
650
+ "findings": [
651
+ {
652
+ "id": "runtime_config_malformed",
653
+ "severity": "error",
654
+ "message": read_error,
655
+ }
656
+ ],
657
+ }
658
+ findings = validate_runtime_config(config)
659
+ runtimes = config.get("runtimes", {})
660
+ routes = config.get("routes", {})
661
+ default_runtime = config.get("default_runtime")
662
+ if not isinstance(runtimes, dict):
663
+ runtimes = {}
664
+ if not isinstance(routes, dict):
665
+ routes = {}
666
+ runtime_statuses: List[Dict[str, Any]] = []
667
+ runtime_lookup: Dict[str, Dict[str, Any]] = {}
668
+
669
+ for runtime_id, runtime in sorted(runtimes.items()):
670
+ if not isinstance(runtime, dict):
671
+ continue
672
+ declared = _declared(runtime)
673
+ required = _required(runtime)
674
+ readiness = runtime.get("readiness", {})
675
+ if not isinstance(readiness, dict):
676
+ readiness = {}
677
+ check = readiness.get("check", "command_exists")
678
+ reachable = False
679
+ observed: List[str] = []
680
+ capability_source = "none"
681
+ check_name = check
682
+ severity = "info"
683
+ status = "configured"
684
+ message = "not probed"
685
+ check_status = "failed"
686
+ probe_skipped = False
687
+
688
+ if check == "none":
689
+ status = "configured"
690
+ message = "readiness check disabled"
691
+ check_status = "not_applicable"
692
+ elif check == "http":
693
+ if allow_probe:
694
+ http_config = readiness.get("http", {})
695
+ if not isinstance(http_config, dict):
696
+ http_config = {}
697
+ url = http_config.get("models_url", "")
698
+ try:
699
+ timeout = float(http_config.get("timeout_seconds", 2))
700
+ except (TypeError, ValueError):
701
+ timeout = 2.0
702
+ reachable, models, message = _http_models(url, timeout)
703
+ observed = sorted(set(models).intersection(declared))
704
+ capability_source = "probed"
705
+ else:
706
+ message = "http probe skipped without --probe"
707
+ check_status = "not_run"
708
+ probe_skipped = True
709
+ elif check == "http_status":
710
+ if allow_probe:
711
+ url = readiness.get("url", "")
712
+ try:
713
+ timeout = float(readiness.get("timeout_seconds", 2))
714
+ except (TypeError, ValueError):
715
+ timeout = 2.0
716
+ outcome = _http_status(url if isinstance(url, str) else "", timeout)
717
+ reachable = outcome == "succeeded"
718
+ message = f"http status probe {outcome}"
719
+ check_status = "passed" if reachable else "failed"
720
+ else:
721
+ message = "http status probe skipped without --probe"
722
+ check_status = "not_run"
723
+ probe_skipped = True
724
+ elif check == "command_spawn" and allow_probe:
725
+ command = readiness.get("command")
726
+ args = readiness.get("args", [])
727
+ command_args = [command] + args if isinstance(command, str) and isinstance(args, list) else []
728
+ spawn_config = readiness.get("spawn", {})
729
+ if not isinstance(spawn_config, dict):
730
+ spawn_config = {}
731
+ try:
732
+ grace = float(spawn_config.get("grace_seconds", 0.5))
733
+ except (TypeError, ValueError):
734
+ grace = 0.5
735
+ reachable, message = _spawn_liveness(command_args, max(0.0, grace))
736
+ else:
737
+ if check == "command_spawn":
738
+ check_name = "command_exists"
739
+ message = "command_spawn downgraded to command_exists without --probe"
740
+ command = readiness.get("command")
741
+ reachable = _command_available(command if isinstance(command, str) else None)
742
+ if check != "command_spawn":
743
+ message = "command exists" if reachable else "command not found"
744
+
745
+ if reachable:
746
+ if capability_source == "none":
747
+ observed = declared
748
+ capability_source = "declared"
749
+ missing = sorted(set(required) - set(observed))
750
+ status = "degraded" if missing else "ready"
751
+ check_status = "passed"
752
+ elif check != "none" and not probe_skipped:
753
+ status = "unavailable"
754
+ if runtime.get("enabled") is False:
755
+ reachable = False
756
+ observed = []
757
+ capability_source = "none"
758
+ status = "unavailable"
759
+ check_status = "skipped"
760
+ message = "runtime disabled"
761
+
762
+ runtime_entry = {
763
+ "id": runtime_id,
764
+ "adapter": runtime.get("adapter", "custom"),
765
+ "enabled": bool(runtime.get("enabled")),
766
+ "status": status,
767
+ "declared_capabilities": declared,
768
+ "observed_capabilities": observed,
769
+ "capability_source": capability_source,
770
+ "reachable": reachable,
771
+ "version": "unknown",
772
+ "checks": [
773
+ {
774
+ "name": check_name,
775
+ "status": check_status,
776
+ "severity": severity,
777
+ "message": message,
778
+ }
779
+ ],
780
+ }
781
+ runtime_statuses.append(runtime_entry)
782
+ runtime_lookup[runtime_id] = runtime_entry
783
+
784
+ for entry in runtime_statuses:
785
+ if entry["status"] == "unavailable" and entry["enabled"]:
786
+ findings.append(
787
+ {
788
+ "id": "runtime_unavailable",
789
+ "severity": "warning",
790
+ "message": f"runtime {entry['id']} is unavailable",
791
+ }
792
+ )
793
+
794
+ route_statuses: List[Dict[str, Any]] = []
795
+ for role, route in sorted(routes.items()):
796
+ if not isinstance(route, dict):
797
+ continue
798
+ primary = route.get("primary")
799
+ if primary is None and isinstance(default_runtime, str):
800
+ primary = default_runtime
801
+ if not isinstance(primary, str):
802
+ primary = "__missing__"
803
+ primary_status = runtime_lookup.get(
804
+ primary, {"status": "unavailable", "observed_capabilities": []}
805
+ )
806
+ route_requires = route.get("requires", [])
807
+ if not isinstance(route_requires, list):
808
+ route_requires = []
809
+ required = sorted(item for item in route_requires if isinstance(item, str))
810
+ observed = set(primary_status.get("observed_capabilities", []))
811
+ missing = sorted(set(required) - observed)
812
+ route_entry = {
813
+ "role": role,
814
+ "primary": primary,
815
+ "status": (
816
+ "degraded"
817
+ if missing and primary_status.get("status") == "ready"
818
+ else primary_status.get("status", "unavailable")
819
+ ),
820
+ "missing_capabilities": missing,
821
+ }
822
+ if route.get("allow_degraded") is True and route_entry["status"] == "degraded":
823
+ route_entry["status"] = "ready"
824
+ route_entry["allow_degraded_applied"] = True
825
+ route_statuses.append(route_entry)
826
+ if route.get("required") is True and route_entry["status"] == "unavailable":
827
+ findings.append(
828
+ {
829
+ "id": "required_route_unavailable",
830
+ "severity": "error",
831
+ "message": f"required route {role} is unavailable",
832
+ }
833
+ )
834
+
835
+ mcp_statuses: List[Dict[str, Any]] = []
836
+ mcp_servers = config.get("mcp_servers", {})
837
+ if not isinstance(mcp_servers, dict):
838
+ mcp_servers = {}
839
+ for server_id, server in sorted(mcp_servers.items()):
840
+ if not isinstance(server, dict):
841
+ continue
842
+ entry = _mcp_server_status(server_id, server, allow_probe)
843
+ probe_ran = entry.pop("_probe_ran")
844
+ if entry["enabled"] and probe_ran and entry["status"] == "unavailable":
845
+ findings.append(
846
+ {
847
+ "id": "mcp_server_unavailable",
848
+ "severity": "warning",
849
+ "message": f"mcp server {entry['id']} is unavailable",
850
+ }
851
+ )
852
+ mcp_statuses.append(entry)
853
+
854
+ return {
855
+ "schema_version": RUNTIME_SNAPSHOT_SCHEMA_VERSION,
856
+ "id": record_id,
857
+ "created_at": utc_now(),
858
+ "runtime_config_sha256": config_hash,
859
+ "runtimes": runtime_statuses,
860
+ "routes": route_statuses,
861
+ "mcp_servers": mcp_statuses,
862
+ "findings": findings,
863
+ }