diff --git a/loopx/cli_commands/goal_channel_operation.py b/loopx/cli_commands/goal_channel_operation.py index 92eb137c1e..cc8e70ef9e 100644 --- a/loopx/cli_commands/goal_channel_operation.py +++ b/loopx/cli_commands/goal_channel_operation.py @@ -245,6 +245,30 @@ def _operation_error_packet( return packet +def _safe_validation_reason(exc: BaseException) -> str: + """Return the operator-actionable validation reason without private paths. + + The typed-operation normalizer and proposal helpers raise code-authored + ValueErrors ("operation payload_digest does not match payload", "operation + executor binding is invalid", ...). Collapsing every one of them into + "the Goal Channel configuration is invalid" hides the real cause from the + operator and from the agent. Keep the message when it is clearly a + code-authored phrase, and fall back to the exception class otherwise. + """ + + text = str(exc).strip() + if ( + not text + or len(text) > 240 + or "/" in text + or "\\" in text + or "~" in text + or any(ord(character) < 32 for character in text) + ): + return type(exc).__name__ + return text + + def run_goal_channel_operation( args: argparse.Namespace, *, @@ -352,11 +376,14 @@ def run_goal_channel_operation( blocker="operation_handoff_conflict", summary=str(exc), ) - except ValueError: + except ValueError as exc: + # Keep the compatibility blocker, but never discard the real reason the + # typed-operation owner rejected this request. return _operation_error_packet( request=request, blocker="invalid_configuration", summary="the Goal Channel configuration is invalid", + details={"validation_reason": _safe_validation_reason(exc)}, ) except Exception: return _operation_error_packet( diff --git a/tests/extensions/test_lark_goal_channel_operation.py b/tests/extensions/test_lark_goal_channel_operation.py index 6d241e911e..def1a9cfa0 100644 --- a/tests/extensions/test_lark_goal_channel_operation.py +++ b/tests/extensions/test_lark_goal_channel_operation.py @@ -19,6 +19,7 @@ from loopx.cli_commands.goal_channel_operation import ( GoalChannelOperationContext, _prepare_goal_channel_operation, + _safe_validation_reason, run_goal_channel_operation, ) from loopx.control_plane.collaboration.operation_handoff import agent_operation_action @@ -2405,3 +2406,26 @@ def failing_record( assert exc_info.value.failure_stage == "record_delivery_receipt" assert exc_info.value.external_write_performed is None assert any("+messages-send" in call for call in calls) + + +def test_safe_validation_reason_keeps_actionable_code_messages() -> None: + """A rejected prepare must surface its real reason, not a generic line.""" + + assert ( + _safe_validation_reason(ValueError("operation executor binding is invalid")) + == "operation executor binding is invalid" + ) + assert ( + _safe_validation_reason( + ValueError("operation payload_digest does not match payload") + ) + == "operation payload_digest does not match payload" + ) + # Anything that could carry a private path or control characters collapses + # to the exception class instead of reaching the operator packet. + assert ( + _safe_validation_reason(ValueError("private detail: /home/operator/secret")) + == "ValueError" + ) + assert _safe_validation_reason(ValueError("line\nbreak")) == "ValueError" + assert _safe_validation_reason(ValueError("")) == "ValueError"