Skip to content

Commit e7dbfd1

Browse files
fix(control-plane): fail closed on malformed runtime recovery and settlement payloads (#3525)
* fix(control-plane): fail closed on malformed runtime recovery and settlement payloads Harden the TS/Python boundary so malformed settlement metadata and failed-Turn recovery state are rejected instead of being silently treated as absent. Add regressions for corrupted settlement receipts, malformed failure payloads, and corrupted failed-turn recovery journals. Signed-off-by: duang777 <duangjl007@gmail.com> * fix(control-plane): continue past corrupted event-log candidates Signed-off-by: duang777 <duangjl007@gmail.com> * refactor(settlement): centralize runtime payload decoding Signed-off-by: huangruiteng <huangrt01@163.com> --------- Signed-off-by: duang777 <duangjl007@gmail.com> Signed-off-by: huangruiteng <huangrt01@163.com> Co-authored-by: huangruiteng <huangrt01@163.com>
1 parent 6a5ac72 commit e7dbfd1

9 files changed

Lines changed: 294 additions & 70 deletions

‎loopx/control_plane/effect_program.py‎

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -313,37 +313,107 @@ def _runtime_result(result: SettlementResult[Any]) -> dict[str, Any]:
313313
}
314314

315315

316-
def _runtime_result_metadata(
316+
def _required_settlement_string(
317+
payload: Mapping[str, Any],
318+
key: str,
319+
*,
320+
shape: str,
321+
) -> str:
322+
value = payload.get(key)
323+
if not isinstance(value, str) or not value.strip():
324+
raise RuntimeError(f"TypeScript settlement result {shape} shape mismatch")
325+
return value
326+
327+
328+
_SettlementEnum = TypeVar("_SettlementEnum", bound=StrEnum)
329+
330+
331+
def _required_settlement_enum(
332+
enum_type: type[_SettlementEnum],
333+
payload: Mapping[str, Any],
334+
key: str,
335+
*,
336+
shape: str,
337+
) -> _SettlementEnum:
338+
try:
339+
return enum_type(_required_settlement_string(payload, key, shape=shape))
340+
except ValueError:
341+
raise RuntimeError(
342+
f"TypeScript settlement result {shape} shape mismatch"
343+
) from None
344+
345+
346+
def decode_settlement_receipt_payload(payload: Any) -> SettlementReceipt:
347+
if not isinstance(payload, Mapping):
348+
raise RuntimeError("TypeScript settlement result receipts shape mismatch")
349+
source_ref = payload.get("source_ref")
350+
if source_ref is not None and (
351+
not isinstance(source_ref, str) or not source_ref.strip()
352+
):
353+
raise RuntimeError("TypeScript settlement result receipts shape mismatch")
354+
return SettlementReceipt(
355+
step_kind=_required_settlement_enum(
356+
SettlementStepKind,
357+
payload,
358+
"step_kind",
359+
shape="receipts",
360+
),
361+
status=_required_settlement_string(payload, "status", shape="receipts"),
362+
effect_id=_required_settlement_string(
363+
payload,
364+
"effect_id",
365+
shape="receipts",
366+
),
367+
source_ref=source_ref,
368+
)
369+
370+
371+
def decode_settlement_result_payload(
317372
payload: Any,
318-
) -> tuple[tuple[SettlementReceipt, ...], SettlementFailure | None]:
373+
) -> tuple[Any, tuple[SettlementReceipt, ...], SettlementFailure | None]:
374+
"""Decode the shared TS settlement envelope at the Python boundary."""
375+
319376
if not isinstance(payload, Mapping):
320377
raise RuntimeError("TypeScript settlement result shape mismatch")
378+
if "value" not in payload or "failure" not in payload:
379+
raise RuntimeError("TypeScript settlement result shape mismatch")
321380
receipts_value = payload.get("receipts")
322-
receipts = (
323-
tuple(
324-
SettlementReceipt(
325-
step_kind=SettlementStepKind(str(receipt["step_kind"])),
326-
status=str(receipt["status"]),
327-
effect_id=str(receipt["effect_id"]),
328-
source_ref=str(receipt.get("source_ref") or "") or None,
329-
)
330-
for receipt in receipts_value
331-
if isinstance(receipt, Mapping)
332-
)
333-
if isinstance(receipts_value, list)
334-
else ()
381+
if not isinstance(receipts_value, list):
382+
raise RuntimeError("TypeScript settlement result receipts shape mismatch")
383+
receipts = tuple(
384+
decode_settlement_receipt_payload(receipt) for receipt in receipts_value
335385
)
336386
failure_value = payload.get("failure")
337387
failure = None
338-
if isinstance(failure_value, Mapping):
388+
if failure_value is not None:
389+
if not isinstance(failure_value, Mapping):
390+
raise RuntimeError("TypeScript settlement result failure shape mismatch")
391+
if payload.get("value") is not None:
392+
raise RuntimeError("TypeScript settlement result shape mismatch")
339393
details = failure_value.get("details")
394+
if details is not None and not isinstance(details, Mapping):
395+
raise RuntimeError("TypeScript settlement result failure shape mismatch")
340396
failure = SettlementFailure(
341-
kind=SettlementFailureKind(str(failure_value["kind"])),
342-
step_kind=SettlementStepKind(str(failure_value["step_kind"])),
343-
reason=str(failure_value["reason"]),
344-
details=dict(details) if isinstance(details, Mapping) else None,
397+
kind=_required_settlement_enum(
398+
SettlementFailureKind,
399+
failure_value,
400+
"kind",
401+
shape="failure",
402+
),
403+
step_kind=_required_settlement_enum(
404+
SettlementStepKind,
405+
failure_value,
406+
"step_kind",
407+
shape="failure",
408+
),
409+
reason=_required_settlement_string(
410+
failure_value,
411+
"reason",
412+
shape="failure",
413+
),
414+
details=dict(details) if details is not None else None,
345415
)
346-
return receipts, failure
416+
return payload.get("value"), receipts, failure
347417

348418

349419
T = TypeVar("T")
@@ -412,7 +482,7 @@ def bind(self, step: Callable[[T], SettlementResult[U]]) -> SettlementResult[U]:
412482
"next": _runtime_result(next_result),
413483
},
414484
)
415-
receipts, failure = _runtime_result_metadata(reduced)
485+
_, receipts, failure = decode_settlement_result_payload(reduced)
416486
return SettlementResult(
417487
value=next_result.value,
418488
receipts=receipts,

‎loopx/control_plane/goals/active_state_event_projection.py‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def active_state_event_projection_fields(
5656
event_log_basename: str = DEFAULT_STATE_EVENT_LOG_BASENAME,
5757
) -> dict[str, Any]:
5858
goal_id = str(goal.get("id") or "").strip()
59+
first_warning: dict[str, Any] | None = None
5960
for event_log_path in state_event_log_candidates(
6061
goal,
6162
state_path=state_path,
@@ -79,15 +80,17 @@ def active_state_event_projection_fields(
7980
item_limit=item_limit,
8081
)
8182
except (OSError, StateEventError) as exc:
82-
return {
83-
"state_event_projection_warning": {
84-
"schema_version": STATE_EVENT_READ_WARNING_SCHEMA_VERSION,
85-
"source": "event_log",
86-
"event_log": event_log_path.name,
87-
"fallback": "markdown_active_state",
88-
"reason": type(exc).__name__,
83+
if first_warning is None:
84+
first_warning = {
85+
"state_event_projection_warning": {
86+
"schema_version": STATE_EVENT_READ_WARNING_SCHEMA_VERSION,
87+
"source": "event_log",
88+
"event_log": event_log_path.name,
89+
"fallback": "markdown_active_state",
90+
"reason": type(exc).__name__,
91+
}
8992
}
90-
}
93+
continue
9194
if fields:
9295
fields["state_event_projection"] = {
9396
"schema_version": STATE_EVENT_PROJECTION_SCHEMA_VERSION,
@@ -100,4 +103,4 @@ def active_state_event_projection_fields(
100103
"projection_version": projection.get("projection_version"),
101104
}
102105
return fields
103-
return {}
106+
return first_warning or {}

‎loopx/control_plane/settlement_driver.py‎

Lines changed: 4 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
from typing import Any
1313

1414
from .effect_program import (
15-
SettlementFailure,
16-
SettlementFailureKind,
1715
SettlementIdentity,
1816
SettlementReceipt,
1917
SettlementResult,
2018
SettlementStepKind,
19+
decode_settlement_receipt_payload,
20+
decode_settlement_result_payload,
2121
)
2222
from .effect_runtime import effect_runtime_result
2323

@@ -26,44 +26,13 @@ def _identity_payload(identity: SettlementIdentity) -> dict[str, Any]:
2626
return identity.as_dict()
2727

2828

29-
def _receipt_from_payload(payload: Mapping[str, Any]) -> SettlementReceipt:
30-
return SettlementReceipt(
31-
step_kind=SettlementStepKind(str(payload["step_kind"])),
32-
status=str(payload["status"]),
33-
effect_id=str(payload["effect_id"]),
34-
source_ref=str(payload.get("source_ref") or "") or None,
35-
)
36-
37-
3829
def decode_settlement_result(
3930
payload: Any,
4031
*,
4132
value_decoder: Callable[[Any], Any] | None = None,
4233
projection_payload: Mapping[str, Any] | None = None,
4334
) -> SettlementResult[Any]:
44-
if not isinstance(payload, Mapping):
45-
raise RuntimeError("TypeScript settlement result shape mismatch")
46-
receipts_value = payload.get("receipts")
47-
receipts = (
48-
tuple(
49-
_receipt_from_payload(receipt)
50-
for receipt in receipts_value
51-
if isinstance(receipt, Mapping)
52-
)
53-
if isinstance(receipts_value, list)
54-
else ()
55-
)
56-
failure_value = payload.get("failure")
57-
failure = None
58-
if isinstance(failure_value, Mapping):
59-
details = failure_value.get("details")
60-
failure = SettlementFailure(
61-
kind=SettlementFailureKind(str(failure_value["kind"])),
62-
step_kind=SettlementStepKind(str(failure_value["step_kind"])),
63-
reason=str(failure_value["reason"]),
64-
details=dict(details) if isinstance(details, Mapping) else None,
65-
)
66-
value = payload.get("value")
35+
value, receipts, failure = decode_settlement_result_payload(payload)
6736
if value_decoder is not None and value is not None:
6837
value = value_decoder(value)
6938
return SettlementResult(
@@ -113,4 +82,4 @@ def settlement_receipt(
11382
)
11483
if not isinstance(payload, Mapping):
11584
raise RuntimeError("TypeScript settlement receipt shape mismatch")
116-
return _receipt_from_payload(payload)
85+
return decode_settlement_receipt_payload(payload)

‎loopx/control_plane/todos/event_writeback.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def _canonical_event_projection_source(
108108
continue
109109
projection = build_state_projection(events, goal_id=goal_id or None)
110110
except (OSError, StateEventError):
111-
return None
111+
continue
112112
if all(
113113
projection.get(key) == projection_authority.get(key)
114114
for key in authority_keys

‎loopx/control_plane/turn_driver/session_recovery.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ def reconcile_failed_turn_retry_request(
3636
*,
3737
session_binding_resolver: SessionBindingResolver | None,
3838
) -> dict[str, Any]:
39-
recovery_value = journal.get("host_recovery")
40-
recovery = dict(recovery_value) if isinstance(recovery_value, Mapping) else {}
41-
if not recovery:
39+
if "host_recovery" not in journal:
4240
return dict(request)
41+
recovery_value = journal.get("host_recovery")
42+
if not isinstance(recovery_value, Mapping):
43+
raise ValueError("failed-Turn host recovery shape mismatch")
44+
recovery = dict(recovery_value)
4345
if recovery.get("schema_version") != HOST_RECOVERY_SCHEMA_VERSION:
4446
raise ValueError("failed-Turn host recovery has an unsupported schema")
4547
if recovery.get("kind") not in HOST_RECOVERY_KINDS:

‎tests/control_plane/test_quota_settlement.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
from pathlib import Path
55
from types import SimpleNamespace
6+
from unittest.mock import patch
67

78
import pytest
89

@@ -284,6 +285,28 @@ def spend(_value: int) -> SettlementResult[str]:
284285
assert result.receipts == failed.receipts
285286

286287

288+
def test_settlement_result_bind_rejects_corrupted_receipts() -> None:
289+
def writeback(value: int) -> SettlementResult[int]:
290+
return SettlementResult.pure(value + 1)
291+
292+
with patch.object(core_effect_program, "effect_runtime_result") as runtime_result:
293+
runtime_result.side_effect = [
294+
{"execute": True},
295+
{
296+
"value": {
297+
"completed_phases": [],
298+
"writeback": None,
299+
"quota_spend": None,
300+
},
301+
"receipts": ["corrupted-receipt"],
302+
"failure": None,
303+
},
304+
]
305+
306+
with pytest.raises(RuntimeError, match="receipts shape mismatch"):
307+
SettlementResult.pure(2).bind(writeback)
308+
309+
287310
def test_codex_app_plan_projects_one_identity_across_settlement_steps() -> None:
288311
plan = build_codex_app_settlement_plan(
289312
goal_id=GOAL_ID,

‎tests/control_plane/test_settlement_driver.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
SettlementStepKind,
88
)
99
from loopx.control_plane.settlement_driver import (
10+
decode_settlement_result,
1011
effect_ids_match,
1112
settlement_receipt,
1213
)
@@ -209,3 +210,69 @@ def test_turn_settlement_fails_closed_when_prepared_outcome_is_unknown(
209210
assert result.failure.kind.value == "effect_outcome_unknown"
210211
assert result.failure.step_kind is SettlementStepKind.DURABLE_WRITEBACK
211212
assert calls["writeback"] == 0
213+
214+
215+
def test_decode_settlement_result_rejects_corrupted_failure_payload() -> None:
216+
with pytest.raises(RuntimeError, match="failure shape mismatch"):
217+
decode_settlement_result(
218+
{
219+
"value": {
220+
"completed_phases": [],
221+
"writeback": None,
222+
"quota_spend": None,
223+
},
224+
"receipts": [
225+
{
226+
"step_kind": "validation",
227+
"status": "committed",
228+
"effect_id": "effect-1",
229+
},
230+
],
231+
"failure": "corrupted-failure",
232+
}
233+
)
234+
235+
236+
@pytest.mark.parametrize(
237+
("payload", "message"),
238+
[
239+
(
240+
{"value": {}, "receipts": [], "failure": {"kind": "cancelled"}},
241+
"result shape mismatch",
242+
),
243+
(
244+
{
245+
"value": None,
246+
"receipts": [],
247+
"failure": {
248+
"kind": "cancelled",
249+
"step_kind": "durable_writeback",
250+
"reason": "cancelled",
251+
"details": "corrupted-details",
252+
},
253+
},
254+
"failure shape mismatch",
255+
),
256+
(
257+
{
258+
"value": {},
259+
"receipts": [
260+
{
261+
"step_kind": "validation",
262+
"status": 1,
263+
"effect_id": "effect-1",
264+
}
265+
],
266+
"failure": None,
267+
},
268+
"receipts shape mismatch",
269+
),
270+
({"value": {}, "receipts": []}, "result shape mismatch"),
271+
],
272+
)
273+
def test_decode_settlement_result_rejects_invalid_union_states(
274+
payload: object,
275+
message: str,
276+
) -> None:
277+
with pytest.raises(RuntimeError, match=message):
278+
decode_settlement_result(payload)

0 commit comments

Comments
 (0)