Lars Talian commited on
Commit
fda4cbc
·
1 Parent(s): 7529adc

fix: align package with openenv contract

Browse files
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && \
6
+ apt-get install -y --no-install-recommends \
7
+ docker.io \
8
+ curl \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ COPY pyproject.toml .
12
+ COPY openenv.yaml .
13
+ COPY server/ server/
14
+ COPY src/ src/
15
+
16
+ RUN pip install --no-cache-dir -e .
17
+
18
+ EXPOSE 8000
19
+
20
+ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
21
+ CMD curl -f http://localhost:8000/health || exit 1
22
+
23
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
README.md CHANGED
@@ -31,14 +31,27 @@ Red and Blue operate on the **same infrastructure simultaneously**. Red's stealt
31
  ## Quick Start
32
 
33
  ```bash
34
- git clone https://github.com/open-cybernauts/open-range.git && cd open-range
35
- uv sync --all-extras
 
 
 
 
 
36
 
37
  # End-to-end demo (no Docker, no LLM)
38
  uv run python examples/demo.py
39
 
40
- # Run the server
41
- python -m open_range.server
 
 
 
 
 
 
 
 
42
 
43
  # Tests
44
  uv run pytest tests/ -v --tb=short
@@ -50,6 +63,8 @@ uv run pytest tests/ -v --tb=short
50
 
51
  **Builder** — Takes a manifest + curriculum context, outputs a `SnapshotSpec`: topology graph, truth graph (planted vulns + exploit chain), evidence graph (what Blue can find), flags, golden path, NPC traffic, and task briefings. Three implementations: `LLMSnapshotBuilder` (production, via litellm), `TemplateOnlyBuilder` (deterministic, for tests), `FileBuilder` (load from disk).
52
 
 
 
53
  **Validator** — 10-check admission pipeline. 8 mechanical checks (build/boot, exploitability, patchability, evidence sufficiency, reward grounding, isolation, task feasibility, difficulty calibration) + 2 LLM advisory checks (NPC consistency, realism review). Inverse mutation: patching each planted vuln must break its exploit step.
54
 
55
  **Environment** — `RangeEnvironment(Environment)` following the OpenEnv contract. `reset()` picks a frozen snapshot + samples a task. `step(action)` routes commands to the appropriate container — Red runs on the attacker box, Blue runs on the SIEM. No artificial command allowlists; the container's installed tools are the constraint.
 
31
  ## Quick Start
32
 
33
  ```bash
34
+ # Install
35
+ git clone https://github.com/open-cybernauts/open-range.git
36
+ cd open-range
37
+ uv sync
38
+
39
+ # Optional: enable the LiteLLM-backed builder pipeline
40
+ uv sync --extra builder
41
 
42
  # End-to-end demo (no Docker, no LLM)
43
  uv run python examples/demo.py
44
 
45
+ # Run the OpenEnv client against a running server
46
+ uv run python examples/remote_client_demo.py --base-url http://localhost:8000
47
+
48
+ # Run the FastAPI server
49
+ uv run server # default: 127.0.0.1:8000
50
+ uv run server --port 9000 # custom port
51
+ uv run server --host 0.0.0.0 # bind all interfaces
52
+
53
+ # Or via uvicorn directly
54
+ uv run uvicorn server.app:app --host 0.0.0.0 --port 8000 --reload
55
 
56
  # Tests
57
  uv run pytest tests/ -v --tb=short
 
63
 
64
  **Builder** — Takes a manifest + curriculum context, outputs a `SnapshotSpec`: topology graph, truth graph (planted vulns + exploit chain), evidence graph (what Blue can find), flags, golden path, NPC traffic, and task briefings. Three implementations: `LLMSnapshotBuilder` (production, via litellm), `TemplateOnlyBuilder` (deterministic, for tests), `FileBuilder` (load from disk).
65
 
66
+ The deployed package exposes the standard OpenEnv `reset()`, `step()`, and `state()` contract through `server.app:app`, which is the entrypoint referenced by `openenv.yaml`.
67
+
68
  **Validator** — 10-check admission pipeline. 8 mechanical checks (build/boot, exploitability, patchability, evidence sufficiency, reward grounding, isolation, task feasibility, difficulty calibration) + 2 LLM advisory checks (NPC consistency, realism review). Inverse mutation: patching each planted vuln must break its exploit step.
69
 
70
  **Environment** — `RangeEnvironment(Environment)` following the OpenEnv contract. `reset()` picks a frozen snapshot + samples a task. `step(action)` routes commands to the appropriate container — Red runs on the attacker box, Blue runs on the SIEM. No artificial command allowlists; the container's installed tools are the constraint.
docs/openenv-compliance.md CHANGED
@@ -6,46 +6,35 @@ OpenRange implements the OpenEnv 0.2.x environment contract. This doc maps every
6
 
7
  | Requirement | Status | Implementation |
8
  |-------------|--------|----------------|
9
- | `Environment` subclass | Done | `RangeEnvironment` extends `Environment[RangeAction, RangeObservation, RangeState]` when openenv is installed; falls back to plain `object` base with the same API surface |
10
  | `reset()` returns `ObsT` | Done | Returns `RangeObservation` with episode briefing |
11
  | `step()` returns `ObsT` | Done | Returns `RangeObservation` with stdout/stderr/reward/done |
12
  | `state` property returns `StateT` | Done | Returns `RangeState` (episode_id, step_count, mode, flags_found, services_status, tier) |
13
- | `Action` subclass (Pydantic, extra=forbid) | Done | `RangeAction(Action)` with `command: str`, `mode: Literal["red", "blue"]`. Note: `extra="forbid"` comes from the openenv `Action` base; the fallback stub inherits plain `BaseModel` without it |
14
  | `Observation` subclass (Pydantic, extra=forbid) | Done | `RangeObservation(Observation)` — inherits `done`, `reward` from base; adds `stdout`, `stderr`, `flags_captured`, `alerts` |
15
  | `State` subclass (Pydantic, extra=allow) | Done | `RangeState(State)` — inherits `episode_id`, `step_count` from base; adds `mode`, `flags_found`, `services_status`, `tier` |
16
- | `create_app(Class, ActionType, ObsType)` | Done | `app.py` tries `openenv.core.env_server.create_app(RangeEnvironment, RangeAction, RangeObservation, env_name="open_range")`; falls back to a standalone FastAPI app with equivalent endpoints |
17
- | `EnvClient` subclass | Done | `OpenRangeEnv(EnvClient[RangeAction, RangeObservation, RangeState])` when openenv is installed; stub client otherwise |
18
  | `_step_payload()` | Done | Returns `{"command": action.command, "mode": action.mode}` |
19
  | `_parse_result()` | Done | Parses server response to `StepResult[RangeObservation]` |
20
  | `_parse_state()` | Done | Parses server response to `RangeState` |
21
- | `/health` endpoint | Done | Returns `{"status": "ok"}` (standalone) or openenv default |
22
- | `/metadata` endpoint | Done | Returns name, version, description, supports_concurrent_sessions |
23
- | `/schema` endpoint | Done | Returns JSON schemas for RangeAction, RangeObservation, RangeState |
24
- | `/ws` WebSocket | Done | Standalone implementation with per-connection `RangeEnvironment` instance; accepts `reset`, `step`, `state` message types |
25
- | `/reset`, `/step`, `/state` HTTP | Done | POST /reset, POST /step, GET /state — all implemented in standalone fallback and via `create_app` |
26
  | `Rubric` for rewards | Done | `CompositeRedReward`, `CompositeBlueReward` (lazy-loaded in `RangeEnvironment._apply_rewards`) |
27
- | `openenv.yaml` manifest | Done | `openenv.yaml` at project root (name: open_range, version: 0.1.0) |
28
- | `Dockerfile` | Done | `server/Dockerfile` Python 3.11-slim, docker.io + curl, healthcheck on `/health`, CMD uvicorn |
29
- | `python -m open_range.server` entry point | Done | `server/__main__.py` starts uvicorn with `--host`, `--port`, `--reload`, `--log-level` flags |
30
 
31
- ## Dual-Mode Server
32
 
33
- The server (`app.py`) operates in two modes:
34
 
35
- 1. **OpenEnv mode** — when `openenv` is installed, delegates to `openenv.core.env_server.create_app` which provides all endpoints automatically.
36
- 2. **Standalone mode** — when `openenv` is not installed, runs a self-contained FastAPI app with equivalent HTTP endpoints (GET /health, GET /metadata, GET /schema, POST /reset, POST /step, GET /state) and a WebSocket endpoint at `/ws` with per-connection environment isolation.
37
-
38
- Both modes serve the same contract. The standalone fallback ensures the server works without the openenv package installed.
39
-
40
- ### WebSocket Protocol
41
-
42
- The `/ws` endpoint accepts JSON messages with a `type` field:
43
-
44
- - `{"type": "reset"}` or `{"type": "reset", "seed": 42, "episode_id": "ep1"}` — reset the environment
45
- - `{"type": "step", "command": "nmap -sV 10.0.1.2", "mode": "red"}` — execute an action
46
- - `{"type": "state"}` — get current episode state
47
-
48
- Responses include a `type` field: `"observation"`, `"state"`, or `"error"`.
49
 
50
  ## Deployment
51
 
 
6
 
7
  | Requirement | Status | Implementation |
8
  |-------------|--------|----------------|
9
+ | `Environment` subclass | Done | `RangeEnvironment` extends `Environment[RangeAction, RangeObservation, RangeState]` |
10
  | `reset()` returns `ObsT` | Done | Returns `RangeObservation` with episode briefing |
11
  | `step()` returns `ObsT` | Done | Returns `RangeObservation` with stdout/stderr/reward/done |
12
  | `state` property returns `StateT` | Done | Returns `RangeState` (episode_id, step_count, mode, flags_found, services_status, tier) |
13
+ | `Action` subclass (Pydantic, extra=forbid) | Done | `RangeAction(Action)` with `command: str`, `mode: Literal["red", "blue"]` |
14
  | `Observation` subclass (Pydantic, extra=forbid) | Done | `RangeObservation(Observation)` — inherits `done`, `reward` from base; adds `stdout`, `stderr`, `flags_captured`, `alerts` |
15
  | `State` subclass (Pydantic, extra=allow) | Done | `RangeState(State)` — inherits `episode_id`, `step_count` from base; adds `mode`, `flags_found`, `services_status`, `tier` |
16
+ | `create_app(Class, ActionType, ObsType)` | Done | `open_range.server.app:create_app()` delegates directly to `openenv.core.env_server.create_app(...)` |
17
+ | `EnvClient` subclass | Done | `OpenRangeEnv(EnvClient[RangeAction, RangeObservation, RangeState])` |
18
  | `_step_payload()` | Done | Returns `{"command": action.command, "mode": action.mode}` |
19
  | `_parse_result()` | Done | Parses server response to `StepResult[RangeObservation]` |
20
  | `_parse_state()` | Done | Parses server response to `RangeState` |
21
+ | `/health` endpoint | Done | Provided by `create_app(...)` |
22
+ | `/metadata` endpoint | Done | Provided by `create_app(...)` |
23
+ | `/schema` endpoint | Done | Provided by `create_app(...)` |
24
+ | `/ws` WebSocket | Done | Provided by `create_app(...)` |
25
+ | `/reset`, `/step`, `/state` HTTP | Done | Provided by `create_app(...)` |
26
  | `Rubric` for rewards | Done | `CompositeRedReward`, `CompositeBlueReward` (lazy-loaded in `RangeEnvironment._apply_rewards`) |
27
+ | `openenv.yaml` manifest | Done | Root `openenv.yaml` with `spec_version`, `type`, `runtime`, `app`, and `port` |
28
+ | `Dockerfile` | Done | Root `Dockerfile` plus `server/Dockerfile`, both launching `uvicorn server.app:app` |
29
+ | `python -m open_range.server` entry point | Done | `open_range.server.__main__` plus `server` console script |
30
 
31
+ ## Server Mode
32
 
33
+ The server entrypoint is the standard OpenEnv app factory:
34
 
35
+ - `open_range.server.app:create_app()` returns `create_app(RangeEnvironment, RangeAction, RangeObservation, env_name="open_range")`
36
+ - `server.app:app` is the repository-level wrapper referenced by `openenv.yaml`
37
+ - The OpenEnv-generated HTTP and WebSocket endpoints are the only public runtime contract
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  ## Deployment
40
 
examples/remote_client_demo.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal OpenEnv client demo for a running OpenRange server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from open_range import OpenRangeEnv, RangeAction
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(description="Connect to a running OpenRange server")
12
+ parser.add_argument(
13
+ "--base-url",
14
+ default="http://localhost:8000",
15
+ help="OpenEnv server base URL",
16
+ )
17
+ args = parser.parse_args()
18
+
19
+ with OpenRangeEnv(base_url=args.base_url).sync() as env:
20
+ result = env.reset()
21
+ print(result.observation.stdout)
22
+
23
+ result = env.step(
24
+ RangeAction(command="nmap -sV 10.0.1.0/24", mode="red")
25
+ )
26
+ print(result.observation.stdout)
27
+ print(f"reward={result.reward} done={result.done}")
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
openenv.yaml CHANGED
@@ -1,5 +1,9 @@
1
  spec_version: 1
2
  name: open_range
 
 
 
 
3
  version: 0.1.0
4
  type: space
5
  runtime: fastapi
 
1
  spec_version: 1
2
  name: open_range
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
  version: 0.1.0
8
  type: space
9
  runtime: fastapi
pyproject.toml CHANGED
@@ -11,13 +11,13 @@ dependencies = [
11
  "pyyaml>=6.0",
12
  "docker>=7.0",
13
  "jinja2>=3.1",
14
- "litellm>=1.30",
15
- "uvicorn>=0.24",
16
  ]
17
 
18
  [project.optional-dependencies]
19
  dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27"]
20
  training = ["trl>=0.8", "unsloth"]
 
21
 
22
  [build-system]
23
  requires = ["hatchling"]
 
11
  "pyyaml>=6.0",
12
  "docker>=7.0",
13
  "jinja2>=3.1",
14
+ "uvicorn>=0.27",
 
15
  ]
16
 
17
  [project.optional-dependencies]
18
  dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27"]
19
  training = ["trl>=0.8", "unsloth"]
20
+ builder = ["litellm>=1.30"]
21
 
22
  [build-system]
23
  requires = ["hatchling"]
server/Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && \
6
+ apt-get install -y --no-install-recommends \
7
+ docker.io \
8
+ curl \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ COPY pyproject.toml .
12
+ COPY openenv.yaml .
13
+ COPY server/ server/
14
+ COPY src/ src/
15
+
16
+ RUN pip install --no-cache-dir -e .
17
+
18
+ EXPOSE 8000
19
+
20
+ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
21
+ CMD curl -f http://localhost:8000/health || exit 1
22
+
23
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
server/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Repository-level OpenEnv server entrypoints."""
2
+
3
+ from .app import app, create_app
4
+ from .environment import RangeEnvironment
5
+
6
+ __all__ = ["RangeEnvironment", "app", "create_app"]
server/app.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenEnv app entrypoint expected by ``openenv.yaml``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from open_range.server.app import app, create_app
6
+
7
+ __all__ = ["app", "create_app"]
8
+
9
+
10
+ def main() -> None:
11
+ """Run the repository-level server entrypoint via uvicorn."""
12
+ import uvicorn
13
+
14
+ uvicorn.run("server.app:app", host="0.0.0.0", port=8000)
15
+
16
+
17
+ if __name__ == "__main__":
18
+ main()
server/environment.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Repository-level environment wrapper for OpenEnv tooling."""
2
+
3
+ from open_range.server.environment import RangeEnvironment
4
+
5
+ __all__ = ["RangeEnvironment"]
src/open_range/__init__.py CHANGED
@@ -1,11 +1,17 @@
1
- """OpenRange: Multi-agent cybersecurity gymnasium built on OpenEnv."""
2
 
3
  from open_range.client.client import OpenRangeEnv
4
- from open_range.server.models import RangeAction, RangeObservation, RangeState
 
 
 
 
 
5
 
6
  __all__ = [
7
  "OpenRangeEnv",
8
  "RangeAction",
 
9
  "RangeObservation",
10
  "RangeState",
11
  ]
 
1
+ """OpenRange public package surface."""
2
 
3
  from open_range.client.client import OpenRangeEnv
4
+ from open_range.server.environment import RangeEnvironment
5
+ from open_range.server.models import (
6
+ RangeAction,
7
+ RangeObservation,
8
+ RangeState,
9
+ )
10
 
11
  __all__ = [
12
  "OpenRangeEnv",
13
  "RangeAction",
14
+ "RangeEnvironment",
15
  "RangeObservation",
16
  "RangeState",
17
  ]
src/open_range/builder/builder.py CHANGED
@@ -14,7 +14,10 @@ import random
14
  from pathlib import Path
15
  from typing import Any
16
 
17
- import litellm
 
 
 
18
 
19
  from open_range.protocols import (
20
  BuildContext,
@@ -66,6 +69,12 @@ class LLMSnapshotBuilder:
66
  context: BuildContext,
67
  ) -> SnapshotSpec:
68
  """Call LLM to generate a candidate snapshot spec."""
 
 
 
 
 
 
69
  user_payload = json.dumps(
70
  {
71
  "manifest": manifest,
 
14
  from pathlib import Path
15
  from typing import Any
16
 
17
+ try:
18
+ import litellm
19
+ except ImportError: # pragma: no cover - exercised only without builder extra
20
+ litellm = None
21
 
22
  from open_range.protocols import (
23
  BuildContext,
 
69
  context: BuildContext,
70
  ) -> SnapshotSpec:
71
  """Call LLM to generate a candidate snapshot spec."""
72
+ if litellm is None:
73
+ raise RuntimeError(
74
+ "LLMSnapshotBuilder requires the optional builder extra. "
75
+ "Install with `pip install open-range[builder]`."
76
+ )
77
+
78
  user_payload = json.dumps(
79
  {
80
  "manifest": manifest,
src/open_range/client/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """OpenRange client package."""
2
 
3
  from open_range.client.client import OpenRangeEnv
4
 
 
1
+ """Typed OpenEnv client exports."""
2
 
3
  from open_range.client.client import OpenRangeEnv
4
 
src/open_range/client/client.py CHANGED
@@ -1,64 +1,30 @@
1
- """OpenEnv client for OpenRange.
2
-
3
- Provides OpenRangeEnv which wraps the typed EnvClient with
4
- OpenRange-specific action/observation/state parsing.
5
- """
6
 
7
  from __future__ import annotations
8
 
9
- from typing import Any
 
10
 
11
  from open_range.server.models import RangeAction, RangeObservation, RangeState
12
 
13
- try:
14
- from openenv.core.env_client import EnvClient
15
- from openenv.core.client_types import StepResult
16
-
17
- class OpenRangeEnv(EnvClient[RangeAction, RangeObservation, RangeState]):
18
- """Typed OpenEnv client for OpenRange."""
19
-
20
- def _step_payload(self, action: RangeAction) -> dict:
21
- return {"command": action.command, "mode": action.mode}
22
-
23
- def _parse_result(self, payload: dict) -> StepResult[RangeObservation]:
24
- obs = RangeObservation(**payload.get("observation", {}))
25
- return StepResult(
26
- observation=obs,
27
- reward=payload.get("reward"),
28
- done=bool(payload.get("done", False)),
29
- )
30
-
31
- def _parse_state(self, payload: dict) -> RangeState:
32
- return RangeState(**payload)
33
-
34
- except ImportError:
35
- # Stub for development without openenv installed
36
- from dataclasses import dataclass
37
-
38
- @dataclass
39
- class StepResult: # type: ignore[no-redef]
40
- """Minimal StepResult stub matching openenv.core.client_types."""
41
-
42
- observation: RangeObservation
43
- reward: float | None = None
44
- done: bool = False
45
 
46
- class OpenRangeEnv: # type: ignore[no-redef]
47
- """Stub client for development without openenv."""
48
 
49
- def __init__(self, base_url: str = "http://localhost:8000"):
50
- self.base_url = base_url
 
51
 
52
- def _step_payload(self, action: RangeAction) -> dict:
53
- return {"command": action.command, "mode": action.mode}
54
 
55
- def _parse_result(self, payload: dict) -> StepResult:
56
- obs = RangeObservation(**payload.get("observation", {}))
57
- return StepResult(
58
- observation=obs,
59
- reward=payload.get("reward"),
60
- done=bool(payload.get("done", False)),
61
- )
62
 
63
- def _parse_state(self, payload: dict) -> RangeState:
64
- return RangeState(**payload)
 
1
+ """Typed OpenEnv client for OpenRange."""
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ from openenv.core.client_types import StepResult
6
+ from openenv.core.env_client import EnvClient
7
 
8
  from open_range.server.models import RangeAction, RangeObservation, RangeState
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ class OpenRangeEnv(EnvClient[RangeAction, RangeObservation, RangeState]):
12
+ """Typed OpenEnv client that speaks the standard reset/step/state contract."""
13
 
14
+ def sync(self) -> "OpenRangeEnv":
15
+ """Compatibility wrapper matching the documented OpenEnv sync pattern."""
16
+ return self
17
 
18
+ def _step_payload(self, action: RangeAction) -> dict:
19
+ return {"command": action.command, "mode": action.mode}
20
 
21
+ def _parse_result(self, payload: dict) -> StepResult[RangeObservation]:
22
+ obs = RangeObservation(**payload.get("observation", {}))
23
+ return StepResult(
24
+ observation=obs,
25
+ reward=payload.get("reward"),
26
+ done=bool(payload.get("done", False)),
27
+ )
28
 
29
+ def _parse_state(self, payload: dict) -> RangeState:
30
+ return RangeState(**payload)
src/open_range/server/Dockerfile CHANGED
@@ -31,6 +31,7 @@ COPY --from=builder /app/src /app/src
31
  COPY --from=builder /app/pyproject.toml /app/pyproject.toml
32
  COPY --from=builder /app/openenv.yaml /app/openenv.yaml
33
  COPY --from=builder /app/manifests /app/manifests
 
34
 
35
  ENV PATH="/app/.venv/bin:$PATH"
36
  ENV PYTHONPATH="/app/src:$PYTHONPATH"
@@ -40,4 +41,4 @@ EXPOSE 8000
40
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
41
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
42
 
43
- CMD ["uvicorn", "open_range.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
 
31
  COPY --from=builder /app/pyproject.toml /app/pyproject.toml
32
  COPY --from=builder /app/openenv.yaml /app/openenv.yaml
33
  COPY --from=builder /app/manifests /app/manifests
34
+ COPY server/ server/
35
 
36
  ENV PATH="/app/.venv/bin:$PATH"
37
  ENV PYTHONPATH="/app/src:$PYTHONPATH"
 
41
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
42
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
43
 
44
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
src/open_range/server/__init__.py CHANGED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Server-side exports for OpenRange."""
2
+
3
+ from open_range.server.app import app, create_app
4
+ from open_range.server.environment import RangeEnvironment
5
+
6
+ __all__ = ["RangeEnvironment", "app", "create_app"]
src/open_range/server/app.py CHANGED
@@ -1,295 +1,32 @@
1
- """FastAPI application for the OpenRange cybersecurity gymnasium.
2
-
3
- If openenv is installed, delegates to ``openenv.core.env_server.create_app``
4
- which provides /health, /reset, /step, /state, /ws, /metadata, /schema
5
- endpoints automatically.
6
-
7
- Otherwise falls back to a manual FastAPI app with equivalent HTTP endpoints
8
- plus a WebSocket endpoint at ``/ws`` for persistent sessions.
9
- """
10
 
11
  from __future__ import annotations
12
 
13
- import json
14
- import logging
15
- from typing import Any
16
 
17
- from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
18
- from pydantic import BaseModel, ValidationError
19
-
20
- from open_range.server.console import clear_history, console_router, record_action
21
  from open_range.server.environment import RangeEnvironment
22
- from open_range.server.models import RangeAction, RangeObservation, RangeState
23
-
24
- logger = logging.getLogger(__name__)
25
-
26
- _APP_VERSION = "0.1.0"
27
-
28
- # ---------------------------------------------------------------------------
29
- # Try the OpenEnv app factory first
30
- # ---------------------------------------------------------------------------
31
-
32
-
33
- def _try_openenv_app() -> FastAPI | None:
34
- """Attempt to create the app via openenv.create_app.
35
-
36
- Returns None if openenv is not installed or the import fails.
37
- """
38
- try:
39
- from openenv.core.env_server import create_app
40
-
41
- openenv_app = create_app(
42
- RangeEnvironment,
43
- RangeAction,
44
- RangeObservation,
45
- env_name="open_range",
46
- )
47
- openenv_app.include_router(console_router)
48
- return openenv_app
49
- except ImportError:
50
- logger.info("openenv not installed -- using standalone FastAPI app")
51
- return None
52
- except Exception as exc:
53
- logger.warning("openenv create_app failed (%s) -- falling back", exc)
54
- return None
55
-
56
-
57
- # ---------------------------------------------------------------------------
58
- # Request/Response models matching OpenEnv HTTP protocol
59
- # ---------------------------------------------------------------------------
60
-
61
-
62
- class ResetRequest(BaseModel):
63
- """Matches openenv.core.env_server.types.ResetRequest."""
64
-
65
- seed: int | None = None
66
- episode_id: str | None = None
67
 
68
 
69
- class StepRequest(BaseModel):
70
- """Matches openenv.core.env_server.types.StepRequest."""
71
-
72
- action: dict[str, Any]
73
- timeout_s: float | None = None
74
- request_id: str | None = None
75
-
76
-
77
- # ---------------------------------------------------------------------------
78
- # Standalone FastAPI fallback
79
- # ---------------------------------------------------------------------------
80
-
81
-
82
- def _create_standalone_app() -> FastAPI:
83
- """Build a FastAPI app that mirrors the OpenEnv endpoint contract.
84
-
85
- Endpoints
86
- ---------
87
- GET /health -- liveness check
88
- GET /metadata -- environment metadata
89
- GET /schema -- JSON schemas for action, observation, state
90
- POST /reset -- reset environment, returns initial observation
91
- POST /step -- execute an action, returns observation + reward + done
92
- GET /state -- current episode state
93
- WS /ws -- persistent WebSocket session (JSON messages)
94
- """
95
-
96
- fastapi_app = FastAPI(
97
- title="OpenRange",
98
- description="Multi-agent cybersecurity gymnasium",
99
- version=_APP_VERSION,
100
  )
 
 
101
 
102
- # Shared environment instance for HTTP endpoints.
103
- # Each WebSocket session creates its own isolated instance.
104
- env = RangeEnvironment()
105
-
106
- # Store env on app.state so the console router can access it
107
- fastapi_app.state.env = env
108
-
109
- # Include the operator console router
110
- fastapi_app.include_router(console_router)
111
-
112
- # ---------------------------------------------------------------
113
- # HTTP endpoints (matches OpenEnv HTTPEnvServer contract)
114
- # ---------------------------------------------------------------
115
-
116
- @fastapi_app.get("/health")
117
- async def health() -> dict[str, str]:
118
- return {"status": "healthy"}
119
-
120
- @fastapi_app.get("/metadata")
121
- async def metadata() -> dict[str, Any]:
122
- return {
123
- "name": "open_range",
124
- "version": _APP_VERSION,
125
- "description": "Multi-agent cybersecurity gymnasium built on OpenEnv",
126
- "supports_concurrent_sessions": False,
127
- }
128
-
129
- @fastapi_app.get("/schema")
130
- async def schema() -> dict[str, Any]:
131
- return {
132
- "action": RangeAction.model_json_schema(),
133
- "observation": RangeObservation.model_json_schema(),
134
- "state": RangeState.model_json_schema(),
135
- }
136
-
137
- @fastapi_app.post("/reset")
138
- async def reset(req: ResetRequest | None = None) -> dict[str, Any]:
139
- req = req or ResetRequest()
140
- clear_history()
141
- obs = env.reset(seed=req.seed, episode_id=req.episode_id)
142
- return {
143
- "observation": obs.model_dump(),
144
- "reward": obs.reward,
145
- "done": obs.done,
146
- }
147
 
148
- @fastapi_app.post("/step")
149
- async def step(request: Request) -> dict[str, Any]:
150
- import time as _time
151
-
152
- body = await request.json()
153
- # Accept both StepRequest {"action": {...}} and direct RangeAction {"command": ..., "mode": ...}
154
- if "action" in body:
155
- action = RangeAction(**body["action"])
156
- timeout_s = body.get("timeout_s")
157
- else:
158
- action = RangeAction(**body)
159
- timeout_s = None
160
- obs = env.step(action, timeout_s=timeout_s)
161
- record_action({
162
- "step": env.state.step_count,
163
- "command": action.command,
164
- "mode": action.mode,
165
- "time": _time.time(),
166
- })
167
- return {
168
- "observation": obs.model_dump(),
169
- "reward": obs.reward,
170
- "done": obs.done,
171
- }
172
-
173
- @fastapi_app.get("/state")
174
- async def get_state() -> dict[str, Any]:
175
- return env.state.model_dump()
176
-
177
- # ---------------------------------------------------------------
178
- # WebSocket endpoint (matches OpenEnv WebSocket protocol)
179
- # ---------------------------------------------------------------
180
-
181
- @fastapi_app.websocket("/ws")
182
- async def ws_endpoint(websocket: WebSocket) -> None:
183
- """Persistent WebSocket session with per-connection environment.
184
-
185
- Message protocol (matches OpenEnv WebSocket contract):
186
-
187
- Client sends:
188
- {"type": "reset", "data": {"seed": 42, "episode_id": "ep1"}}
189
- {"type": "step", "data": {"command": "...", "mode": "red"}}
190
- {"type": "state"}
191
- {"type": "close"}
192
-
193
- Server responds:
194
- {"type": "observation", "data": {...}}
195
- {"type": "state", "data": {...}}
196
- {"type": "error", "data": {"message": "...", "code": "..."}}
197
- """
198
- await websocket.accept()
199
-
200
- # Each WebSocket session gets its own environment instance
201
- ws_env = RangeEnvironment()
202
-
203
- try:
204
- while True:
205
- raw = await websocket.receive_text()
206
- try:
207
- msg = json.loads(raw)
208
- except json.JSONDecodeError:
209
- await websocket.send_json({
210
- "type": "error",
211
- "data": {"message": "Invalid JSON", "code": "parse_error"},
212
- })
213
- continue
214
-
215
- msg_type = msg.get("type", "")
216
- msg_data = msg.get("data", {})
217
-
218
- if msg_type == "reset":
219
- seed = msg_data.get("seed") if msg_data else msg.get("seed")
220
- episode_id = msg_data.get("episode_id") if msg_data else msg.get("episode_id")
221
- obs = ws_env.reset(seed=seed, episode_id=episode_id)
222
- await websocket.send_json({
223
- "type": "observation",
224
- "data": obs.model_dump(),
225
- })
226
-
227
- elif msg_type == "step":
228
- try:
229
- action = RangeAction(
230
- command=msg_data.get("command", msg.get("command", "")),
231
- mode=msg_data.get("mode", msg.get("mode", "red")),
232
- )
233
- except ValidationError as ve:
234
- await websocket.send_json({
235
- "type": "error",
236
- "data": {"message": str(ve), "code": "validation_error"},
237
- })
238
- continue
239
-
240
- obs = ws_env.step(action)
241
- await websocket.send_json({
242
- "type": "observation",
243
- "data": obs.model_dump(),
244
- })
245
-
246
- elif msg_type == "state":
247
- await websocket.send_json({
248
- "type": "state",
249
- "data": ws_env.state.model_dump(),
250
- })
251
-
252
- elif msg_type == "close":
253
- await websocket.close()
254
- break
255
-
256
- else:
257
- await websocket.send_json({
258
- "type": "error",
259
- "data": {
260
- "message": f"Unknown message type: {msg_type!r}",
261
- "code": "unknown_type",
262
- },
263
- })
264
-
265
- except WebSocketDisconnect:
266
- ws_env.close()
267
- logger.debug("WebSocket client disconnected")
268
-
269
- return fastapi_app
270
-
271
-
272
- # ---------------------------------------------------------------------------
273
- # Module-level app instance (used by uvicorn)
274
- # ---------------------------------------------------------------------------
275
-
276
-
277
- def create_app() -> FastAPI:
278
- """Create the OpenRange FastAPI application.
279
 
280
- Tries openenv's create_app first; falls back to standalone.
281
- """
282
- openenv_app = _try_openenv_app()
283
- if openenv_app is not None:
284
- return openenv_app
285
- return _create_standalone_app()
286
 
287
 
288
  app = create_app()
289
-
290
-
291
- def main() -> None:
292
- """Entry point for ``uv run server`` or ``python -m open_range.server``."""
293
- import uvicorn
294
-
295
- uvicorn.run(app, host="0.0.0.0", port=8000)
 
1
+ """FastAPI application wired through the OpenEnv app factory."""
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ from fastapi import FastAPI
6
+ from openenv.core.env_server import create_app as create_openenv_app
 
7
 
8
+ from open_range.server.console import console_router
 
 
 
9
  from open_range.server.environment import RangeEnvironment
10
+ from open_range.server.models import RangeAction, RangeObservation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
 
13
+ def create_app() -> FastAPI:
14
+ """Create the OpenRange app using the standard OpenEnv contract."""
15
+ app = create_openenv_app(
16
+ RangeEnvironment,
17
+ RangeAction,
18
+ RangeObservation,
19
+ env_name="open_range",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  )
21
+ app.include_router(console_router)
22
+ return app
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ def main() -> None:
26
+ """Run the installed package entrypoint via uvicorn."""
27
+ import uvicorn
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
+ uvicorn.run("open_range.server.app:app", host="0.0.0.0", port=8000)
 
 
 
 
 
30
 
31
 
32
  app = create_app()
 
 
 
 
 
 
 
uv.lock CHANGED
@@ -1870,7 +1870,6 @@ dependencies = [
1870
  { name = "docker" },
1871
  { name = "fastapi" },
1872
  { name = "jinja2" },
1873
- { name = "litellm" },
1874
  { name = "openenv-core", extra = ["core"] },
1875
  { name = "pydantic" },
1876
  { name = "pyyaml" },
@@ -1878,6 +1877,9 @@ dependencies = [
1878
  ]
1879
 
1880
  [package.optional-dependencies]
 
 
 
1881
  dev = [
1882
  { name = "httpx" },
1883
  { name = "pytest" },
@@ -1894,7 +1896,7 @@ requires-dist = [
1894
  { name = "fastapi", specifier = ">=0.115" },
1895
  { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
1896
  { name = "jinja2", specifier = ">=3.1" },
1897
- { name = "litellm", specifier = ">=1.30" },
1898
  { name = "openenv-core", extras = ["core"], specifier = ">=0.2.1" },
1899
  { name = "pydantic", specifier = ">=2.0" },
1900
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
@@ -1902,9 +1904,9 @@ requires-dist = [
1902
  { name = "pyyaml", specifier = ">=6.0" },
1903
  { name = "trl", marker = "extra == 'training'", specifier = ">=0.8" },
1904
  { name = "unsloth", marker = "extra == 'training'" },
1905
- { name = "uvicorn", specifier = ">=0.24" },
1906
  ]
1907
- provides-extras = ["dev", "training"]
1908
 
1909
  [[package]]
1910
  name = "openai"
 
1870
  { name = "docker" },
1871
  { name = "fastapi" },
1872
  { name = "jinja2" },
 
1873
  { name = "openenv-core", extra = ["core"] },
1874
  { name = "pydantic" },
1875
  { name = "pyyaml" },
 
1877
  ]
1878
 
1879
  [package.optional-dependencies]
1880
+ builder = [
1881
+ { name = "litellm" },
1882
+ ]
1883
  dev = [
1884
  { name = "httpx" },
1885
  { name = "pytest" },
 
1896
  { name = "fastapi", specifier = ">=0.115" },
1897
  { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
1898
  { name = "jinja2", specifier = ">=3.1" },
1899
+ { name = "litellm", marker = "extra == 'builder'", specifier = ">=1.30" },
1900
  { name = "openenv-core", extras = ["core"], specifier = ">=0.2.1" },
1901
  { name = "pydantic", specifier = ">=2.0" },
1902
  { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
 
1904
  { name = "pyyaml", specifier = ">=6.0" },
1905
  { name = "trl", marker = "extra == 'training'", specifier = ">=0.8" },
1906
  { name = "unsloth", marker = "extra == 'training'" },
1907
+ { name = "uvicorn", specifier = ">=0.27" },
1908
  ]
1909
+ provides-extras = ["dev", "training", "builder"]
1910
 
1911
  [[package]]
1912
  name = "openai"