Lars Talian commited on
Commit
e37c7b1
·
1 Parent(s): 0fd9230

Wire console to real episode state

Browse files
src/open_range/server/console.py CHANGED
@@ -6,6 +6,7 @@ the range environment state, viewing action history, and triggering resets.
6
 
7
  from __future__ import annotations
8
 
 
9
  import time
10
  from typing import Any
11
 
@@ -20,6 +21,7 @@ console_router = APIRouter(prefix="/console", tags=["console"])
20
 
21
  _action_history: list[dict[str, Any]] = []
22
  _MAX_HISTORY = 50 # keep more than 20 internally, but serve 20
 
23
 
24
 
25
  def record_action(action_record: dict[str, Any]) -> None:
@@ -39,6 +41,49 @@ def get_history(limit: int = 20) -> list[dict[str, Any]]:
39
  return list(reversed(_action_history[-limit:]))
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  # ---------------------------------------------------------------------------
43
  # API routes
44
  # ---------------------------------------------------------------------------
@@ -48,6 +93,15 @@ def get_history(limit: int = 20) -> list[dict[str, Any]]:
48
  async def api_snapshot(request: Request) -> JSONResponse:
49
  """Return current snapshot metadata (no truth graph or flags)."""
50
  ctx = _get_env_context(request)
 
 
 
 
 
 
 
 
 
51
  env = ctx["env"]
52
  snapshot = env.snapshot
53
  if snapshot is None:
@@ -84,6 +138,15 @@ async def api_snapshot(request: Request) -> JSONResponse:
84
  async def api_episode(request: Request) -> JSONResponse:
85
  """Return current episode state."""
86
  ctx = _get_env_context(request)
 
 
 
 
 
 
 
 
 
87
  env = ctx["env"]
88
  state = env.state
89
  return JSONResponse({
@@ -120,8 +183,9 @@ def _get_env_context(request: Request) -> dict[str, Any]:
120
 
121
  Priority:
122
  1. Active OpenEnv WebSocket session environment (session-scoped truth)
123
- 2. ``app.state.env`` fallback environment (global app scope)
124
- 3. Lazily created fallback environment (tests/dev)
 
125
  """
126
  app = request.app
127
 
@@ -132,6 +196,7 @@ def _get_env_context(request: Request) -> dict[str, Any]:
132
  session_id, env = next(iter(sessions.items()))
133
  return {
134
  "env": env,
 
135
  "state_scope": "websocket_session",
136
  "session_id": session_id,
137
  "warning": None,
@@ -144,6 +209,7 @@ def _get_env_context(request: Request) -> dict[str, Any]:
144
  )
145
  return {
146
  "env": sessions[selected_id],
 
147
  "state_scope": "websocket_session",
148
  "session_id": selected_id,
149
  "warning": (
@@ -152,9 +218,23 @@ def _get_env_context(request: Request) -> dict[str, Any]:
152
  ),
153
  }
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  if hasattr(app.state, "env"):
156
  return {
157
  "env": app.state.env,
 
158
  "state_scope": "app_state_env",
159
  "session_id": None,
160
  "warning": (
@@ -170,6 +250,7 @@ def _get_env_context(request: Request) -> dict[str, Any]:
170
  app.state._fallback_env = RangeEnvironment(docker_available=False)
171
  return {
172
  "env": app.state._fallback_env,
 
173
  "state_scope": "fallback_env",
174
  "session_id": None,
175
  "warning": "Console is using a fallback environment (no server session available).",
@@ -284,6 +365,7 @@ _CONSOLE_HTML = """\
284
  }
285
  .history-item .mode-red { color: var(--red); }
286
  .history-item .mode-blue { color: var(--accent); }
 
287
  .history-item .ts {
288
  color: var(--text-dim);
289
  font-size: 11px;
@@ -424,7 +506,11 @@ function renderHistory(items) {
424
  return;
425
  }
426
  el.innerHTML = items.map(function(it) {
427
- const modeClass = it.mode === "red" ? "mode-red" : "mode-blue";
 
 
 
 
428
  return '<div class="history-item">' +
429
  '<span class="ts">' + fmtTime(it.time) + '</span>' +
430
  '<span class="step">step ' + (it.step || "-") + '</span> ' +
 
6
 
7
  from __future__ import annotations
8
 
9
+ import copy
10
  import time
11
  from typing import Any
12
 
 
21
 
22
  _action_history: list[dict[str, Any]] = []
23
  _MAX_HISTORY = 50 # keep more than 20 internally, but serve 20
24
+ _published_episode: dict[str, dict[str, Any]] | None = None
25
 
26
 
27
  def record_action(action_record: dict[str, Any]) -> None:
 
41
  return list(reversed(_action_history[-limit:]))
42
 
43
 
44
+ def publish_episode(snapshot: Any, state: Any) -> None:
45
+ """Publish the latest episode summary for console readers.
46
+
47
+ This is the bridge used by real reset/step traffic from HTTP handlers,
48
+ where OpenEnv creates short-lived environment instances per request.
49
+ """
50
+ global _published_episode
51
+
52
+ topo = snapshot.topology if snapshot and isinstance(snapshot.topology, dict) else {}
53
+ hosts = topo.get("hosts", [])
54
+ zones = topo.get("zones", {})
55
+ vuln_count = 0
56
+ if snapshot is not None and getattr(snapshot, "truth_graph", None) is not None:
57
+ vuln_count = len(getattr(snapshot.truth_graph, "vulns", []) or [])
58
+
59
+ _published_episode = {
60
+ "snapshot": {
61
+ "id": getattr(state, "episode_id", None),
62
+ "tier": topo.get("tier", getattr(state, "tier", 1)),
63
+ "hosts": list(hosts) if isinstance(hosts, list) else [],
64
+ "zones": copy.deepcopy(zones) if isinstance(zones, dict) else {},
65
+ "vuln_count": vuln_count,
66
+ },
67
+ "episode": {
68
+ "step_count": int(getattr(state, "step_count", 0) or 0),
69
+ "flags_found": len(getattr(state, "flags_found", []) or []),
70
+ "mode": getattr(state, "mode", ""),
71
+ "services_status": copy.deepcopy(getattr(state, "services_status", {}) or {}),
72
+ },
73
+ }
74
+
75
+
76
+ def clear_episode() -> None:
77
+ """Clear the published episode summary."""
78
+ global _published_episode
79
+ _published_episode = None
80
+
81
+
82
+ def get_published_episode() -> dict[str, dict[str, Any]] | None:
83
+ """Return a defensive copy of the published episode summary."""
84
+ return copy.deepcopy(_published_episode)
85
+
86
+
87
  # ---------------------------------------------------------------------------
88
  # API routes
89
  # ---------------------------------------------------------------------------
 
93
  async def api_snapshot(request: Request) -> JSONResponse:
94
  """Return current snapshot metadata (no truth graph or flags)."""
95
  ctx = _get_env_context(request)
96
+ published = ctx.get("published_episode")
97
+ if published is not None:
98
+ return JSONResponse({
99
+ **published["snapshot"],
100
+ "state_scope": ctx["state_scope"],
101
+ "session_id": ctx["session_id"],
102
+ "warning": ctx["warning"],
103
+ })
104
+
105
  env = ctx["env"]
106
  snapshot = env.snapshot
107
  if snapshot is None:
 
138
  async def api_episode(request: Request) -> JSONResponse:
139
  """Return current episode state."""
140
  ctx = _get_env_context(request)
141
+ published = ctx.get("published_episode")
142
+ if published is not None:
143
+ return JSONResponse({
144
+ **published["episode"],
145
+ "state_scope": ctx["state_scope"],
146
+ "session_id": ctx["session_id"],
147
+ "warning": ctx["warning"],
148
+ })
149
+
150
  env = ctx["env"]
151
  state = env.state
152
  return JSONResponse({
 
183
 
184
  Priority:
185
  1. Active OpenEnv WebSocket session environment (session-scoped truth)
186
+ 2. Published reset/step state from real HTTP traffic
187
+ 3. ``app.state.env`` fallback environment (global app scope)
188
+ 4. Lazily created fallback environment (tests/dev)
189
  """
190
  app = request.app
191
 
 
196
  session_id, env = next(iter(sessions.items()))
197
  return {
198
  "env": env,
199
+ "published_episode": None,
200
  "state_scope": "websocket_session",
201
  "session_id": session_id,
202
  "warning": None,
 
209
  )
210
  return {
211
  "env": sessions[selected_id],
212
+ "published_episode": None,
213
  "state_scope": "websocket_session",
214
  "session_id": selected_id,
215
  "warning": (
 
218
  ),
219
  }
220
 
221
+ published = get_published_episode()
222
+ if published is not None:
223
+ return {
224
+ "env": None,
225
+ "published_episode": published,
226
+ "state_scope": "published_episode",
227
+ "session_id": None,
228
+ "warning": (
229
+ "No active WebSocket session found; console is showing the most "
230
+ "recent reset/step state observed by the server."
231
+ ),
232
+ }
233
+
234
  if hasattr(app.state, "env"):
235
  return {
236
  "env": app.state.env,
237
+ "published_episode": None,
238
  "state_scope": "app_state_env",
239
  "session_id": None,
240
  "warning": (
 
250
  app.state._fallback_env = RangeEnvironment(docker_available=False)
251
  return {
252
  "env": app.state._fallback_env,
253
+ "published_episode": None,
254
  "state_scope": "fallback_env",
255
  "session_id": None,
256
  "warning": "Console is using a fallback environment (no server session available).",
 
365
  }
366
  .history-item .mode-red { color: var(--red); }
367
  .history-item .mode-blue { color: var(--accent); }
368
+ .history-item .mode-system { color: var(--yellow); }
369
  .history-item .ts {
370
  color: var(--text-dim);
371
  font-size: 11px;
 
506
  return;
507
  }
508
  el.innerHTML = items.map(function(it) {
509
+ const modeClass = it.mode === "red"
510
+ ? "mode-red"
511
+ : it.mode === "blue"
512
+ ? "mode-blue"
513
+ : "mode-system";
514
  return '<div class="history-item">' +
515
  '<span class="ts">' + fmtTime(it.time) + '</span>' +
516
  '<span class="step">step ' + (it.step || "-") + '</span> ' +
src/open_range/server/environment.py CHANGED
@@ -924,6 +924,15 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
924
  except Exception:
925
  pass
926
 
 
 
 
 
 
 
 
 
 
927
  # -----------------------------------------------------------------
928
  # Snapshot selection
929
  # -----------------------------------------------------------------
@@ -1462,6 +1471,16 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
1462
  )
1463
 
1464
  self._publish_console_state()
 
 
 
 
 
 
 
 
 
 
1465
  return RangeObservation(stdout=briefing)
1466
 
1467
  def step(
@@ -1520,6 +1539,15 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
1520
  }
1521
 
1522
  if cmd_name in meta_handlers:
 
 
 
 
 
 
 
 
 
1523
  obs = meta_handlers[cmd_name](action)
1524
  self._refresh_services_status()
1525
  obs = self._apply_rewards(action, obs)
@@ -1550,12 +1578,7 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
1550
  self._red_history.append(action_record)
1551
  else:
1552
  self._blue_history.append(action_record)
1553
- try:
1554
- from open_range.server.console import record_action
1555
-
1556
- record_action({"mode": action.mode, **action_record})
1557
- except Exception:
1558
- pass
1559
 
1560
  # Check for milestone completion (#17)
1561
  milestone = self._check_milestone(stdout)
 
924
  except Exception:
925
  pass
926
 
927
+ def _record_console_action(self, mode: str, action_record: dict[str, Any]) -> None:
928
+ """Record a console-visible action without coupling to console internals."""
929
+ try:
930
+ from open_range.server.console import record_action
931
+
932
+ record_action({"mode": mode, **action_record})
933
+ except Exception:
934
+ pass
935
+
936
  # -----------------------------------------------------------------
937
  # Snapshot selection
938
  # -----------------------------------------------------------------
 
1471
  )
1472
 
1473
  self._publish_console_state()
1474
+ self._record_console_action(
1475
+ "system",
1476
+ {
1477
+ "step": 0,
1478
+ "command": "reset",
1479
+ "cmd_name": "reset",
1480
+ "time": time.time(),
1481
+ "episode_id": eid,
1482
+ },
1483
+ )
1484
  return RangeObservation(stdout=briefing)
1485
 
1486
  def step(
 
1539
  }
1540
 
1541
  if cmd_name in meta_handlers:
1542
+ self._record_console_action(
1543
+ action.mode,
1544
+ {
1545
+ "step": self._state.step_count,
1546
+ "command": action.command,
1547
+ "cmd_name": cmd_name,
1548
+ "time": time.time(),
1549
+ },
1550
+ )
1551
  obs = meta_handlers[cmd_name](action)
1552
  self._refresh_services_status()
1553
  obs = self._apply_rewards(action, obs)
 
1578
  self._red_history.append(action_record)
1579
  else:
1580
  self._blue_history.append(action_record)
1581
+ self._record_console_action(action.mode, action_record)
 
 
 
 
 
1582
 
1583
  # Check for milestone completion (#17)
1584
  milestone = self._check_milestone(stdout)
tests/test_console.py CHANGED
@@ -1,21 +1,22 @@
1
- """Tests for the operator debugging console (issue #28).
2
 
3
  Uses Starlette's TestClient against the OpenEnv app with console router.
4
  No Docker dependency.
5
 
6
- Note: OpenEnv HTTP endpoints are stateless (each creates a new env instance).
7
- Console API uses a fallback env stored on app.state. History is recorded
8
- via the module-level record_action() / clear_history() helpers.
9
  """
10
 
11
  from __future__ import annotations
12
 
 
 
13
  import pytest
14
  from starlette.testclient import TestClient
15
 
16
  from open_range.protocols import SnapshotSpec
17
  from open_range.server.app import create_app
18
- from open_range.server.console import clear_history, record_action
19
  from open_range.server.environment import RangeEnvironment
20
 
21
  _TEST_SNAPSHOT = SnapshotSpec(
@@ -36,8 +37,11 @@ def client():
36
  # Store a shared env so console API endpoints can access state
37
  env = RangeEnvironment(docker_available=False)
38
  app.state.env = env
 
 
 
 
39
  clear_history()
40
- return TestClient(app)
41
 
42
 
43
  @pytest.fixture()
@@ -101,6 +105,27 @@ class TestSnapshotAPI:
101
  assert "flags" not in data
102
  assert "golden_path" not in data
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  # ===================================================================
106
  # GET /console/api/episode
@@ -133,6 +158,25 @@ class TestEpisodeAPI:
133
  data = client.get("/console/api/episode").json()
134
  assert data["step_count"] == 1
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
  # ===================================================================
138
  # GET /console/api/history
@@ -176,9 +220,32 @@ class TestHistoryAPI:
176
  env.reset(snapshot=_TEST_SNAPSHOT)
177
  env.step(RangeAction(command="nmap -sV web", mode="red"))
178
  data = client.get("/console/api/history").json()
179
- assert len(data) == 1
180
  assert data[0]["command"] == "nmap -sV web"
181
  assert data[0]["mode"] == "red"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
  def test_history_max_20(self, client: TestClient):
184
  """History API should return at most 20 entries."""
@@ -188,3 +255,22 @@ class TestHistoryAPI:
188
  record_action({"step": i, "command": f"cmd_{i}", "mode": "red", "time": time.time()})
189
  data = client.get("/console/api/history").json()
190
  assert len(data) == 20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the operator debugging console.
2
 
3
  Uses Starlette's TestClient against the OpenEnv app with console router.
4
  No Docker dependency.
5
 
6
+ The console prefers live WebSocket session state, then published reset/step
7
+ state from real traffic, then a local fallback env for dev/test use.
 
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ from unittest.mock import patch
13
+
14
  import pytest
15
  from starlette.testclient import TestClient
16
 
17
  from open_range.protocols import SnapshotSpec
18
  from open_range.server.app import create_app
19
+ from open_range.server.console import clear_episode, clear_history, record_action
20
  from open_range.server.environment import RangeEnvironment
21
 
22
  _TEST_SNAPSHOT = SnapshotSpec(
 
37
  # Store a shared env so console API endpoints can access state
38
  env = RangeEnvironment(docker_available=False)
39
  app.state.env = env
40
+ clear_episode()
41
+ clear_history()
42
+ yield TestClient(app)
43
+ clear_episode()
44
  clear_history()
 
45
 
46
 
47
  @pytest.fixture()
 
105
  assert "flags" not in data
106
  assert "golden_path" not in data
107
 
108
+ def test_http_reset_publishes_snapshot_to_console(self):
109
+ with patch(
110
+ "open_range.server.environment.RangeEnvironment._select_snapshot",
111
+ return_value=_TEST_SNAPSHOT,
112
+ ), patch(
113
+ "open_range.server.environment.RangeEnvironment._ensure_clean_reset_path",
114
+ ):
115
+ app = create_app()
116
+ clear_episode()
117
+ clear_history()
118
+ client = TestClient(app)
119
+ resp = client.post("/reset", json={"episode_id": "http_reset_1"})
120
+ assert resp.status_code == 200
121
+
122
+ data = client.get("/console/api/snapshot").json()
123
+ assert data["id"] == "http_reset_1"
124
+ assert data["state_scope"] == "published_episode"
125
+ assert data["hosts"] == ["attacker", "siem"]
126
+ clear_episode()
127
+ clear_history()
128
+
129
 
130
  # ===================================================================
131
  # GET /console/api/episode
 
158
  data = client.get("/console/api/episode").json()
159
  assert data["step_count"] == 1
160
 
161
+ def test_http_reset_publishes_episode_to_console(self):
162
+ with patch(
163
+ "open_range.server.environment.RangeEnvironment._select_snapshot",
164
+ return_value=_TEST_SNAPSHOT,
165
+ ), patch(
166
+ "open_range.server.environment.RangeEnvironment._ensure_clean_reset_path",
167
+ ):
168
+ app = create_app()
169
+ clear_episode()
170
+ clear_history()
171
+ client = TestClient(app)
172
+ client.post("/reset", json={"episode_id": "http_reset_2"})
173
+ data = client.get("/console/api/episode").json()
174
+ assert data["step_count"] == 0
175
+ assert data["mode"] == "red"
176
+ assert data["state_scope"] == "published_episode"
177
+ clear_episode()
178
+ clear_history()
179
+
180
 
181
  # ===================================================================
182
  # GET /console/api/history
 
220
  env.reset(snapshot=_TEST_SNAPSHOT)
221
  env.step(RangeAction(command="nmap -sV web", mode="red"))
222
  data = client.get("/console/api/history").json()
223
+ assert len(data) == 2
224
  assert data[0]["command"] == "nmap -sV web"
225
  assert data[0]["mode"] == "red"
226
+ assert data[1]["command"] == "reset"
227
+ assert data[1]["mode"] == "system"
228
+
229
+ def test_history_records_meta_step_commands(self, client: TestClient, env: RangeEnvironment):
230
+ from open_range.server.models import RangeAction
231
+
232
+ env.reset(snapshot=_TEST_SNAPSHOT)
233
+ env.step(RangeAction(command="submit_finding suspicious scan on web", mode="blue"))
234
+ data = client.get("/console/api/history").json()
235
+ assert data[0]["command"] == "submit_finding suspicious scan on web"
236
+ assert data[0]["mode"] == "blue"
237
+
238
+ def test_history_reset_clears_prior_entries_and_records_reset(self, client: TestClient, env: RangeEnvironment):
239
+ import time
240
+
241
+ record_action({"step": 99, "command": "old", "mode": "red", "time": time.time()})
242
+
243
+ env.reset(snapshot=_TEST_SNAPSHOT, episode_id="history_reset")
244
+ data = client.get("/console/api/history").json()
245
+ assert len(data) == 1
246
+ assert data[0]["command"] == "reset"
247
+ assert data[0]["mode"] == "system"
248
+ assert data[0]["episode_id"] == "history_reset"
249
 
250
  def test_history_max_20(self, client: TestClient):
251
  """History API should return at most 20 entries."""
 
255
  record_action({"step": i, "command": f"cmd_{i}", "mode": "red", "time": time.time()})
256
  data = client.get("/console/api/history").json()
257
  assert len(data) == 20
258
+
259
+ def test_http_reset_records_history(self):
260
+ with patch(
261
+ "open_range.server.environment.RangeEnvironment._select_snapshot",
262
+ return_value=_TEST_SNAPSHOT,
263
+ ), patch(
264
+ "open_range.server.environment.RangeEnvironment._ensure_clean_reset_path",
265
+ ):
266
+ app = create_app()
267
+ clear_episode()
268
+ clear_history()
269
+ client = TestClient(app)
270
+ client.post("/reset", json={"episode_id": "http_reset_3"})
271
+ data = client.get("/console/api/history").json()
272
+ assert len(data) == 1
273
+ assert data[0]["command"] == "reset"
274
+ assert data[0]["mode"] == "system"
275
+ clear_episode()
276
+ clear_history()
tests/test_console_bridge.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Focused console bridge tests without TestClient transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from types import SimpleNamespace
7
+ from unittest.mock import patch
8
+
9
+ from open_range.protocols import SnapshotSpec
10
+ from open_range.server.app import create_app
11
+ from open_range.server.console import (
12
+ api_episode,
13
+ api_history,
14
+ api_snapshot,
15
+ clear_episode,
16
+ clear_history,
17
+ get_history,
18
+ )
19
+ from open_range.server.environment import RangeEnvironment
20
+ from open_range.server.models import RangeAction
21
+
22
+ _TEST_SNAPSHOT = SnapshotSpec(
23
+ topology={"hosts": ["attacker", "siem"]},
24
+ flags=[],
25
+ golden_path=[],
26
+ task={
27
+ "red_briefing": "Console bridge test mode.",
28
+ "blue_briefing": "Console bridge test mode.",
29
+ },
30
+ )
31
+
32
+
33
+ def _request(app):
34
+ return SimpleNamespace(app=app)
35
+
36
+
37
+ def _json_response_payload(response) -> dict | list:
38
+ return json.loads(response.body.decode())
39
+
40
+
41
+ def test_http_reset_publishes_console_snapshot_and_episode():
42
+ clear_episode()
43
+ clear_history()
44
+ with patch(
45
+ "open_range.server.environment.RangeEnvironment._select_snapshot",
46
+ return_value=_TEST_SNAPSHOT,
47
+ ), patch(
48
+ "open_range.server.environment.RangeEnvironment._ensure_clean_reset_path",
49
+ ):
50
+ app = create_app()
51
+ env = app.state.openenv_server._env_factory()
52
+ try:
53
+ env.reset(episode_id="http_console_ep")
54
+ finally:
55
+ env.close()
56
+
57
+ snapshot = _json_response_payload(_run(api_snapshot(_request(app))))
58
+ episode = _json_response_payload(_run(api_episode(_request(app))))
59
+
60
+ assert snapshot["id"] == "http_console_ep"
61
+ assert snapshot["hosts"] == ["attacker", "siem"]
62
+ assert snapshot["state_scope"] == "published_episode"
63
+ assert episode["step_count"] == 0
64
+ assert episode["mode"] == "red"
65
+ assert episode["state_scope"] == "published_episode"
66
+
67
+
68
+ def test_environment_reset_clears_history_and_records_reset():
69
+ clear_episode()
70
+ clear_history()
71
+ env = RangeEnvironment(docker_available=False)
72
+
73
+ env.reset(snapshot=_TEST_SNAPSHOT, episode_id="console_reset_ep")
74
+
75
+ history = get_history()
76
+ assert len(history) == 1
77
+ assert history[0]["command"] == "reset"
78
+ assert history[0]["mode"] == "system"
79
+ assert history[0]["episode_id"] == "console_reset_ep"
80
+
81
+
82
+ def test_environment_meta_steps_record_console_history():
83
+ clear_episode()
84
+ clear_history()
85
+ env = RangeEnvironment(docker_available=False)
86
+ env.reset(snapshot=_TEST_SNAPSHOT, episode_id="console_meta_ep")
87
+
88
+ env.step(RangeAction(command="submit_finding suspicious scan on web", mode="blue"))
89
+
90
+ history = _json_response_payload(_run(api_history()))
91
+ assert len(history) == 2
92
+ assert history[0]["command"] == "submit_finding suspicious scan on web"
93
+ assert history[0]["mode"] == "blue"
94
+ assert history[1]["command"] == "reset"
95
+
96
+
97
+ def _run(awaitable):
98
+ import asyncio
99
+
100
+ return asyncio.run(awaitable)
tests/test_console_context.py CHANGED
@@ -4,8 +4,10 @@ from __future__ import annotations
4
 
5
  from types import SimpleNamespace
6
 
7
- from open_range.server.console import _get_env_context
 
8
  from open_range.server.environment import RangeEnvironment
 
9
 
10
 
11
  class _Req:
@@ -18,6 +20,7 @@ def _app_with_state(**kwargs):
18
 
19
 
20
  def test_prefers_active_websocket_session_env():
 
21
  fallback_env = RangeEnvironment(docker_available=False)
22
  ws_env = RangeEnvironment(docker_available=False)
23
  server = SimpleNamespace(
@@ -34,6 +37,7 @@ def test_prefers_active_websocket_session_env():
34
 
35
 
36
  def test_uses_app_state_env_when_no_active_session():
 
37
  fallback_env = RangeEnvironment(docker_available=False)
38
  server = SimpleNamespace(_sessions={}, _session_info={})
39
  request = _Req(_app_with_state(env=fallback_env, openenv_server=server))
@@ -46,6 +50,7 @@ def test_uses_app_state_env_when_no_active_session():
46
 
47
 
48
  def test_multiple_sessions_selects_most_recent_and_warns():
 
49
  older_env = RangeEnvironment(docker_available=False)
50
  newer_env = RangeEnvironment(docker_available=False)
51
  server = SimpleNamespace(
@@ -62,3 +67,29 @@ def test_multiple_sessions_selects_most_recent_and_warns():
62
  assert ctx["state_scope"] == "websocket_session"
63
  assert ctx["session_id"] == "new"
64
  assert "active sessions" in (ctx["warning"] or "").lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  from types import SimpleNamespace
6
 
7
+ from open_range.protocols import SnapshotSpec
8
+ from open_range.server.console import _get_env_context, clear_episode, publish_episode
9
  from open_range.server.environment import RangeEnvironment
10
+ from open_range.server.models import RangeState
11
 
12
 
13
  class _Req:
 
20
 
21
 
22
  def test_prefers_active_websocket_session_env():
23
+ clear_episode()
24
  fallback_env = RangeEnvironment(docker_available=False)
25
  ws_env = RangeEnvironment(docker_available=False)
26
  server = SimpleNamespace(
 
37
 
38
 
39
  def test_uses_app_state_env_when_no_active_session():
40
+ clear_episode()
41
  fallback_env = RangeEnvironment(docker_available=False)
42
  server = SimpleNamespace(_sessions={}, _session_info={})
43
  request = _Req(_app_with_state(env=fallback_env, openenv_server=server))
 
50
 
51
 
52
  def test_multiple_sessions_selects_most_recent_and_warns():
53
+ clear_episode()
54
  older_env = RangeEnvironment(docker_available=False)
55
  newer_env = RangeEnvironment(docker_available=False)
56
  server = SimpleNamespace(
 
67
  assert ctx["state_scope"] == "websocket_session"
68
  assert ctx["session_id"] == "new"
69
  assert "active sessions" in (ctx["warning"] or "").lower()
70
+
71
+
72
+ def test_uses_published_episode_before_app_state_fallback():
73
+ clear_episode()
74
+ snapshot = SnapshotSpec(
75
+ topology={"hosts": ["attacker"], "zones": {"dmz": ["web"]}, "tier": 2},
76
+ flags=[],
77
+ golden_path=[],
78
+ task={"red_briefing": "r", "blue_briefing": "b"},
79
+ )
80
+ publish_episode(
81
+ snapshot,
82
+ RangeState(episode_id="published_ep", step_count=4, mode="blue", tier=2),
83
+ )
84
+
85
+ fallback_env = RangeEnvironment(docker_available=False)
86
+ server = SimpleNamespace(_sessions={}, _session_info={})
87
+ request = _Req(_app_with_state(env=fallback_env, openenv_server=server))
88
+
89
+ ctx = _get_env_context(request)
90
+ assert ctx["env"] is None
91
+ assert ctx["published_episode"]["snapshot"]["id"] == "published_ep"
92
+ assert ctx["state_scope"] == "published_episode"
93
+ assert ctx["session_id"] is None
94
+ assert "most recent reset/step state" in (ctx["warning"] or "")
95
+ clear_episode()