openenv/echo_env
6
1"""Versioned runtime inputs and execution evidence, independent of a provider."""2 3import json4import math5import re6from dataclasses import dataclass7from pathlib import Path8from typing import Any, Literal9 10from pydantic import (11 BaseModel,12 ConfigDict,13 Field,14 field_validator,15 model_validator,16 ValidationError,17)18 19from ..manifest import ExecutionDeclaration, NetworkPolicy, ResourceDeclaration20 21MAX_PLAN_BYTES = 65_53622MAX_PLAN_ACTIONS = 10023MAX_JSON_DEPTH = 3224MAX_JSON_NODES = 10_00025 26 27class RuntimePlanError(ValueError):28 """A public runtime plan cannot be read safely or violates its data contract."""29 30 31def _check_json(value: Any, *, depth: int = 0, budget: list[int] | None = None) -> None:32 """Reject executable objects, non-finite numbers and excessive nesting."""33 if budget is None:34 budget = [MAX_JSON_NODES]35 budget[0] -= 136 if budget[0] < 0 or depth > MAX_JSON_DEPTH:37 raise ValueError("runtime JSON exceeds the node or nesting limit")38 if value is None or type(value) in (bool, int):39 return40 if type(value) is float:41 if not math.isfinite(value):42 raise ValueError("runtime JSON numbers must be finite")43 return44 if type(value) is str:45 return46 if type(value) is dict:47 if not all(type(key) is str for key in value):48 raise ValueError("runtime JSON object keys must be strings")49 children = value.values()50 elif type(value) is list:51 children = value52 else:53 raise ValueError("runtime inputs must contain only JSON data")54 for child in children:55 _check_json(child, depth=depth + 1, budget=budget)56 57 58class RuntimeReset(BaseModel):59 """60 Explicit reset inputs for one measured episode.61 62 Attributes:63 episode_id (`str`):64 Requested episode identity; verified by the state contract grader.65 seed (`int`):66 Requested seed. Acceptance alone does not establish determinism.67 options (`dict[str, Any]`, *optional*):68 Additional public reset arguments, never replacements for the seed or ID.69 """70 71 model_config = ConfigDict(extra="forbid", strict=True, frozen=True)72 73 episode_id: str = Field(min_length=1, max_length=128)74 seed: int = Field(ge=0, le=2**32 - 1)75 options: dict[str, Any] = Field(default_factory=dict)76 77 @field_validator("options")78 @classmethod79 def _public_options(cls, value: dict[str, Any]) -> dict[str, Any]:80 if {"episode_id", "seed"} & value.keys():81 raise ValueError("reset options cannot override episode_id or seed")82 _check_json(value)83 return value84 85 86class RuntimePlan(BaseModel):87 """88 Bounded, data-only runtime inputs. Capability declarations stay in the manifest.89 90 Attributes:91 plan_schema_version (`str`):92 The pinned sidecar schema version, `"1"`.93 reset ([`~openenv.validation.runtime.contracts.RuntimeReset`]):94 Inputs to the first reset on the measured WebSocket session.95 actions (`list[dict[str, Any]]`):96 Between one and 100 public actions, applied in order until termination.97 """98 99 model_config = ConfigDict(extra="forbid", strict=True, frozen=True)100 101 plan_schema_version: Literal["1"]102 reset: RuntimeReset103 actions: list[dict[str, Any]] = Field(min_length=1, max_length=MAX_PLAN_ACTIONS)104 105 @field_validator("actions")106 @classmethod107 def _data_actions(cls, value: list[dict[str, Any]]) -> list[dict[str, Any]]:108 _check_json(value)109 return value110 111 @model_validator(mode="after")112 def _total_size(self) -> "RuntimePlan":113 size = len(self.model_dump_json().encode("utf-8"))114 if size > MAX_PLAN_BYTES:115 raise ValueError(f"runtime plan exceeds {MAX_PLAN_BYTES} bytes")116 return self117 118 119def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:120 result = {}121 for key, value in pairs:122 if key in result:123 raise ValueError("duplicate runtime JSON key")124 result[key] = value125 return result126 127 128def load_runtime_plan(root: Path, execution: ExecutionDeclaration) -> RuntimePlan:129 """130 Read a bounded runtime plan without importing any subject code.131 132 Args:133 root (`Path`):134 Package root; the resolved plan must stay inside this directory.135 execution ([`~openenv.validation.manifest.ExecutionDeclaration`]):136 Manifest-owned location of the JSON sidecar.137 138 Returns:139 [`~openenv.validation.runtime.contracts.RuntimePlan`]: validated public inputs.140 141 Raises:142 [`~openenv.validation.runtime.contracts.RuntimePlanError`]:143 Missing, escaped, oversized, ambiguous or invalid input.144 """145 try:146 package_root = Path(root).resolve(strict=True)147 path = (package_root / execution.probe_path).resolve(strict=True)148 if not path.is_relative_to(package_root) or not path.is_file():149 raise ValueError("runtime plan must be a regular file inside the package")150 with path.open("rb") as source:151 payload = source.read(MAX_PLAN_BYTES + 1)152 if len(payload) > MAX_PLAN_BYTES:153 raise ValueError(f"runtime plan exceeds {MAX_PLAN_BYTES} bytes")154 raw = json.loads(payload, object_pairs_hook=_unique_object)155 _check_json(raw)156 return RuntimePlan.model_validate(raw)157 except ValidationError as exc:158 fields = RuntimePlan.model_fields.keys() | RuntimeReset.model_fields.keys()159 errors = []160 for error in exc.errors(161 include_input=False, include_context=False, include_url=False162 )[:5]:163 location = ".".join(164 str(part) if isinstance(part, int) or part in fields else "<field>"165 for part in error["loc"]166 )167 # These messages come from the fixed schema and validators; raw inputs,168 # exception context and subject-controlled field names are omitted.169 errors.append(f"{location or '<root>'}: {error['type']} ({error['msg']})")170 if exc.error_count() > 5:171 errors.append(f"... ({exc.error_count()} errors total)")172 raise RuntimePlanError(173 "runtime plan schema validation failed: " + "; ".join(errors)174 ) from exc175 except json.JSONDecodeError as exc:176 raise RuntimePlanError(177 f"invalid runtime plan JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}"178 ) from exc179 except UnicodeError as exc:180 raise RuntimePlanError("runtime plan contains invalid text encoding") from exc181 except OSError as exc:182 raise RuntimePlanError(183 f"runtime plan could not be read ({type(exc).__name__})"184 ) from exc185 except (ValueError, RecursionError) as exc:186 raise RuntimePlanError(f"invalid runtime plan: {exc}") from exc187 188 189class LaunchSpec(BaseModel):190 """191 Explicit validation-only launch request; no caller environment is inherited.192 193 Attributes:194 image_ref (`str`):195 Immutable local image ID or repository digest.196 resources ([`~openenv.validation.manifest.ResourceDeclaration`]):197 Subject CPU, memory, writable-storage and episode budgets.198 network ([`~openenv.validation.manifest.NetworkPolicy`]):199 Requested policy; unsupported enforcement must be refused.200 run_id (`str`):201 Unique run-owned resource label, safe for provider names.202 startup_timeout_s (`float`, *optional*, defaults to 30):203 Independent readiness deadline, at most 300 seconds.204 env_vars (`dict[str, str]`, *optional*):205 Explicit environment only; providers do not forward host credentials.206 """207 208 model_config = ConfigDict(extra="forbid", frozen=True)209 210 image_ref: str = Field(pattern=r"^(?:[^@\s]+@)?sha256:[0-9a-f]{64}$")211 resources: ResourceDeclaration212 network: NetworkPolicy213 run_id: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")214 startup_timeout_s: float = Field(default=30.0, gt=0.0, le=300.0)215 env_vars: dict[str, str] = Field(default_factory=dict, max_length=64)216 217 @field_validator("env_vars")218 @classmethod219 def _explicit_environment(cls, value: dict[str, str]) -> dict[str, str]:220 for name, content in value.items():221 if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None:222 raise ValueError("invalid environment variable name")223 if "\x00" in content or len(content.encode("utf-8")) > 8192:224 raise ValueError("environment value contains NUL or exceeds 8192 bytes")225 return value226 227 @field_validator("resources")228 @classmethod229 def _finite_resources(cls, value: ResourceDeclaration) -> ResourceDeclaration:230 if not math.isfinite(value.cpu) or not math.isfinite(value.episode_timeout_s):231 raise ValueError("runtime resource limits must be finite")232 return value233 234 235@dataclass(frozen=True)236class WireExchange:237 """238 One immutable raw exchange, captured before client defaults or type coercion.239 240 Attributes:241 operation (`str`):242 The reset, step or state operation performed on the measured session.243 request_json (`str`):244 Original serialized request; parse only when grading.245 response_json (`str`):246 Original serialized response, including malformed JSON for diagnostics.247 """248 249 operation: Literal["reset", "step", "state"]250 request_json: str251 response_json: str252 253 254@dataclass(frozen=True)255class RuntimeEvidence:256 """257 Collector evidence shared by graders without permitting mutation of the episode.258 259 Attributes:260 exchanges (`tuple[WireExchange, ...]`):261 Ordered operations on one WebSocket session.262 observation_schema_json (`str`, *optional*):263 The advertised observation schema, exactly `/schema`'s observation value.264 failure_phase (`str`, *optional*):265 Collector phase that failed; a truncated transcript cannot pass silently.266 failure_reason (`str`, *optional*):267 Credential-safe explanation of the collection failure.268 reset_observation_schema_json (`str`, *optional*):269 Explicit `/schema` reset_observation value; absent means use the step schema.270 """271 272 exchanges: tuple[WireExchange, ...] = ()273 observation_schema_json: str | None = None274 failure_phase: str | None = None275 failure_reason: str | None = None276 reset_observation_schema_json: str | None = None277 