From e1542dbc90122372c259b09aabbc2fa7bd98d34c Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 11 May 2026 15:48:29 -0700 Subject: [PATCH] feat(parity): BodyVerify Layer 1 echo-back substrate primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity port of cueapi/cueapi PR #795. Adds the X-CueAPI-Verify-Echo opt-in echo-back primitive on POST /v1/messages and POST /v1/cues/{cue_id}/fire so callers can diff sent vs received body bytes and catch caller-side shell expansion that silently mutates body content at variable-assignment time. Design doc: https://trydock.ai/workspaces/cue-message-silent-corruption-substrate-design-2026-05-11 Files added: - app/utils/verify_echo.py (verbatim port from private) - tests/test_verify_echo.py (adapted: fire metachar parametrization is private-only since OSS FireRequest carries only send_at; hosted's payload_override is the corruption vector and lives in private) Files modified: - app/routers/messages.py — send_message integrates apply_verify_echo - app/routers/cues.py — fire_cue takes Request param + integrates helper Parity-manifest updates: - New entry: app/utils/verify_echo.py - Updated: app/routers/messages.py + app/routers/cues.py deviation notes - Documents the OSS / private fire-test divergence in a single place 27/27 OSS tests pass locally (private had 33; 6 fire-metachar tests deferred per FireRequest shape divergence). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/routers/cues.py | 11 +- app/routers/messages.py | 10 +- app/utils/verify_echo.py | 82 ++++++++ parity-manifest.json | 7 +- tests/test_verify_echo.py | 397 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 501 insertions(+), 6 deletions(-) create mode 100644 app/utils/verify_echo.py create mode 100644 tests/test_verify_echo.py diff --git a/app/routers/cues.py b/app/routers/cues.py index 927a3b5..1f85dd2 100644 --- a/app/routers/cues.py +++ b/app/routers/cues.py @@ -2,13 +2,14 @@ from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from sqlalchemy.ext.asyncio import AsyncSession from app.auth import AuthenticatedUser, get_current_user from app.database import get_db from app.schemas.cue import CueCreate, CueDetailResponse, CueListResponse, CueResponse, CueUpdate, FireRequest from app.services.cue_service import create_cue, delete_cue, get_cue, list_cues, update_cue +from app.utils.verify_echo import apply_verify_echo router = APIRouter(prefix="/v1/cues", tags=["cues"]) @@ -91,6 +92,7 @@ async def delete( @router.post("/{cue_id}/fire", status_code=200) async def fire_cue( cue_id: str, + request: Request, body: Optional[FireRequest] = None, user: AuthenticatedUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), @@ -159,8 +161,13 @@ async def fire_cue( db.add(outbox) await db.commit() - return { + response_content: dict = { "id": str(execution_id), "cue_id": cue.id, "scheduled_for": effective_scheduled_for.isoformat(), "status": "pending", "triggered_by": "manual_fire", } + # BodyVerify Layer 1: opt-in echo-back when caller sets + # X-CueAPI-Verify-Echo: true. Helper returns {} when header absent + # so non-opted clients see zero shape change. + response_content.update(apply_verify_echo(request=request, parsed_body=body)) + return response_content diff --git a/app/routers/messages.py b/app/routers/messages.py index 2726d2f..ce520e7 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -40,6 +40,7 @@ mark_read, to_response_dict, ) +from app.utils.verify_echo import apply_verify_echo from fastapi.responses import JSONResponse router = APIRouter(prefix="/v1/messages", tags=["messages"]) @@ -101,9 +102,16 @@ async def send_message( # to priority=3. Surface the signal so senders can detect and # adapt without parsing message body. headers["X-CueAPI-Priority-Downgraded"] = "true" + response_content = MessageResponse( + **to_response_dict(msg) + ).model_dump(mode="json") + # BodyVerify Layer 1: opt-in echo-back when caller sets + # X-CueAPI-Verify-Echo: true. Helper returns {} when header absent + # so non-opted clients see zero shape change. + response_content.update(apply_verify_echo(request=request, parsed_body=body)) return JSONResponse( status_code=status_code, - content=MessageResponse(**to_response_dict(msg)).model_dump(mode="json"), + content=response_content, headers=headers, ) diff --git a/app/utils/verify_echo.py b/app/utils/verify_echo.py new file mode 100644 index 0000000..5e22031 --- /dev/null +++ b/app/utils/verify_echo.py @@ -0,0 +1,82 @@ +"""Body-verify echo-back primitive (Layer 1 of silent-body-corruption defense). + +When request header ``X-CueAPI-Verify-Echo: true`` is present on a supported +endpoint, the server adds ``body_received`` and ``body_received_sha256`` +fields to the 200 response. The caller diffs sent body vs received to detect +caller-side shell expansion (backticks, $-paren, ${VAR}) that silently +corrupts body content before send-time. + +Why this exists: 2026-05-11 ~22:00Z, CMA's outbound Cue Messages 0/6 to +cue-pm fell to garbage via inline bash ``-d "$BODY"`` where $BODY had been +mutated by shell expansion at variable-assignment time. Server received +valid JSON with wrong content; no layer fails loud. Echo-back is the +keystone for the 4-layer defense: substrate (this), SDK auto-verify, CLI +force-file mode, docs leading with file-payload pattern. + +Design doc: https://trydock.ai/workspaces/cue-message-silent-corruption-substrate-design-2026-05-11 +""" +from __future__ import annotations + +import hashlib +import json +from typing import Any, Dict, Optional + +from fastapi import Request + + +VERIFY_ECHO_HEADER = "X-CueAPI-Verify-Echo" + + +def verify_echo_requested(request: Request) -> bool: + """True iff ``X-CueAPI-Verify-Echo: true`` header is present (case-insensitive).""" + return request.headers.get(VERIFY_ECHO_HEADER, "").strip().lower() == "true" + + +def _canonical_json_bytes(value: Any) -> bytes: + """Stable JSON serialization for hashing: sorted keys + no whitespace.""" + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + + +def apply_verify_echo(*, request: Request, parsed_body: Optional[Any]) -> Dict[str, Any]: + """Return verify-echo fields to merge into the response. + + Returns ``{}`` when the header is absent (zero-cost no-op for non-opted + clients). When present, returns:: + + { + "body_received": , + "body_received_sha256": <64-char hex digest>, + } + + Hashing rule: + + * ``None`` body → SHA256 of empty bytes (well-known constant). + * Pydantic model → ``model_dump(mode="json")`` then canonical JSON. + * dict → canonical JSON. + * Otherwise → ``str()`` then UTF-8 bytes. + + The dict is intended to be ``.update()``-merged into the response dict + or returned alongside other fields. Caller is responsible for placement. + """ + if not verify_echo_requested(request): + return {} + + if parsed_body is None: + body_view: Any = None + sha_input: bytes = b"" + elif hasattr(parsed_body, "model_dump"): + body_view = parsed_body.model_dump(mode="json") + sha_input = _canonical_json_bytes(body_view) + elif isinstance(parsed_body, dict): + body_view = parsed_body + sha_input = _canonical_json_bytes(body_view) + else: + body_view = str(parsed_body) + sha_input = body_view.encode("utf-8") + + return { + "body_received": body_view, + "body_received_sha256": hashlib.sha256(sha_input).hexdigest(), + } diff --git a/parity-manifest.json b/parity-manifest.json index 93de5f9..3927efc 100644 --- a/parity-manifest.json +++ b/parity-manifest.json @@ -116,7 +116,7 @@ {"path": "app/routers/__init__.py", "private_counterpart": "app/routers/__init__.py", "last_synced": "2026-04-16"}, {"path": "app/routers/alerts.py", "private_counterpart": "app/routers/alerts.py", "last_synced": "2026-04-17"}, {"path": "app/routers/auth_routes.py", "private_counterpart": "app/routers/auth_routes.py", "last_synced": "2026-04-16"}, - {"path": "app/routers/cues.py", "private_counterpart": "app/routers/cues.py", "last_synced": "2026-04-16"}, + {"path": "app/routers/cues.py", "private_counterpart": "app/routers/cues.py", "last_synced": "2026-05-11", "ported_in": "bodyverify-layer-1-substrate-echo-back", "deviation": "BodyVerify port: fire_cue handler integrates apply_verify_echo on response. OSS FireRequest carries only send_at; private's payload_override (dict with user-supplied string content carrying corruption-vector) is hosted-only. Metachar-class parametrization tests on the fire path are intentionally private-only — substrate helper coverage is exercised via messages endpoint tests."}, {"path": "app/routers/device_code.py", "private_counterpart": "app/routers/device_code.py", "last_synced": "2026-04-16"}, {"path": "app/routers/echo.py", "private_counterpart": "app/routers/echo.py", "last_synced": "2026-04-16"}, {"path": "app/routers/executions.py", "private_counterpart": "app/routers/executions.py", "last_synced": "2026-04-16"}, @@ -125,7 +125,7 @@ {"path": "app/routers/webhook_secret.py", "private_counterpart": "app/routers/webhook_secret.py", "last_synced": "2026-04-16"}, {"path": "app/routers/workers.py", "private_counterpart": "app/routers/workers.py", "last_synced": "2026-04-16"}, {"path": "app/routers/agents.py", "private_counterpart": "app/routers/agents.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, - {"path": "app/routers/messages.py", "private_counterpart": "app/routers/messages.py", "last_synced": "2026-05-11", "ported_in": "messaging-primitive-port + messaging-emission-port (PR-2a)", "deviation": "PR-2a addition: correlation_id passthrough to create_message service."}, + {"path": "app/routers/messages.py", "private_counterpart": "app/routers/messages.py", "last_synced": "2026-05-11", "ported_in": "messaging-primitive-port + messaging-emission-port (PR-2a) + bodyverify-layer-1-substrate-echo-back", "deviation": "PR-2a addition: correlation_id passthrough to create_message service. BodyVerify port: send_message handler integrates apply_verify_echo helper on response — identical shape to private; create_message tuple unpack is 3-element OSS vs 4-element private (private adds bcc_emitted)."}, {"path": "app/routers/events.py", "private_counterpart": "app/routers/events.py", "last_synced": "2026-05-11", "ported_in": "event-emit-primitive-port (PR-1b) + long-poll-port (PR-1b spec Q1) + item-1-inline-body-port + item-2b-cursor-advance-ack-port", "deviation": "Long-poll uses internal polling loop rather than PostgreSQL LISTEN/NOTIFY per impl trade-off; wire contract identical. Future LISTEN/NOTIFY swap tracked at cmp0m7n7r (private) — file OSS counterpart row when prioritized. Item 1 addition: SubscriptionCreate accepts inline_body field; SubscriptionResponse surfaces it. Item 2(b) addition: PATCH /subscriptions/{id}/ack endpoint + AckSubscriptionRequest schema + _advance_ack_after_pull helper + last_acked_event_id surfaced on SubscriptionResponse."} ], "schemas": [ @@ -165,7 +165,8 @@ {"path": "app/utils/templates.py", "private_counterpart": "app/utils/templates.py", "last_synced": "2026-04-16"}, {"path": "app/utils/url_validation.py", "private_counterpart": "app/utils/url_validation.py", "last_synced": "2026-04-16"}, {"path": "app/utils/retry_after.py", "private_counterpart": "app/utils/retry_after.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, - {"path": "app/utils/slug.py", "private_counterpart": "app/utils/slug.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"} + {"path": "app/utils/slug.py", "private_counterpart": "app/utils/slug.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, + {"path": "app/utils/verify_echo.py", "private_counterpart": "app/utils/verify_echo.py", "last_synced": "2026-05-11", "ported_in": "bodyverify-layer-1-substrate-echo-back"} ], "worker": [ {"path": "worker/tasks.py", "private_counterpart": "worker/tasks.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port", "deviation": "ports messaging-specific functions only (deliver_message_task, retry_message_task, _check_concurrent_cap_or_recycle, _release_concurrent, _load_message_context, _claim_message, _route_attempt_outcome) — does not port other private deltas (catch_up_policy, heartbeat trigger, etc., scheduled for a follow-up phase)"}, diff --git a/tests/test_verify_echo.py b/tests/test_verify_echo.py new file mode 100644 index 0000000..41acaf6 --- /dev/null +++ b/tests/test_verify_echo.py @@ -0,0 +1,397 @@ +"""Tests for BodyVerify Layer 1 — substrate echo-back primitive. + +Design doc: https://trydock.ai/workspaces/cue-message-silent-corruption-substrate-design-2026-05-11 + +Coverage targets: + +- Helper ``apply_verify_echo``: header-absent zero-cost no-op, header-present + echo-back, hash determinism, branch coverage (None / Pydantic model / dict / + other), canonical JSON hashing (sorted keys, no whitespace). +- ``POST /v1/messages``: round-trip happy path; 6 metachar classes round-trip + byte-identical; no-header → no echo fields (backwards-compat); empty body + (well-formed but minimal content); 32KB cap edge. +- ``POST /v1/cues/{cue_id}/fire``: round-trip with payload_override; metachar + classes; no-body fire (FireRequest=None path) still produces echo when header + set. +- Definition of Done item 1 (substrate echo-back): 6 metachar classes assertion + matrix covering backticks, $-paren, ${VAR}, backslash, quotes, mixed. +""" +from __future__ import annotations + +import hashlib +import json +import uuid + +import pytest +from fastapi import Request + +from app.utils.verify_echo import ( + VERIFY_ECHO_HEADER, + _canonical_json_bytes, + apply_verify_echo, + verify_echo_requested, +) + + +def _fake_request(headers: dict) -> Request: + """Build a minimal ASGI Request stub for header-reading tests.""" + scope = { + "type": "http", + "headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()], + } + return Request(scope) + + +# ─────────────────────────────────────────────────────────────────────── +# Helper unit tests — apply_verify_echo / verify_echo_requested +# ─────────────────────────────────────────────────────────────────────── + + +def test_verify_echo_requested_true_when_header_set(): + req = _fake_request({VERIFY_ECHO_HEADER: "true"}) + assert verify_echo_requested(req) is True + + +def test_verify_echo_requested_case_insensitive(): + req = _fake_request({VERIFY_ECHO_HEADER: "TRUE"}) + assert verify_echo_requested(req) is True + + +def test_verify_echo_requested_strips_whitespace(): + req = _fake_request({VERIFY_ECHO_HEADER: " true "}) + assert verify_echo_requested(req) is True + + +def test_verify_echo_requested_false_when_absent(): + req = _fake_request({}) + assert verify_echo_requested(req) is False + + +def test_verify_echo_requested_false_when_not_true(): + req = _fake_request({VERIFY_ECHO_HEADER: "1"}) + assert verify_echo_requested(req) is False + req2 = _fake_request({VERIFY_ECHO_HEADER: "yes"}) + assert verify_echo_requested(req2) is False + + +def test_apply_verify_echo_returns_empty_dict_without_header(): + req = _fake_request({}) + assert apply_verify_echo(request=req, parsed_body={"any": "value"}) == {} + + +def test_apply_verify_echo_none_body(): + req = _fake_request({VERIFY_ECHO_HEADER: "true"}) + result = apply_verify_echo(request=req, parsed_body=None) + assert result["body_received"] is None + # SHA256 of empty bytes is a well-known constant. + assert result["body_received_sha256"] == hashlib.sha256(b"").hexdigest() + + +def test_apply_verify_echo_dict_body(): + req = _fake_request({VERIFY_ECHO_HEADER: "true"}) + body = {"message": "hello", "priority": 3} + result = apply_verify_echo(request=req, parsed_body=body) + assert result["body_received"] == body + expected = hashlib.sha256( + json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + assert result["body_received_sha256"] == expected + + +def test_apply_verify_echo_pydantic_model_body(): + from pydantic import BaseModel + + class _M(BaseModel): + a: str + b: int + + req = _fake_request({VERIFY_ECHO_HEADER: "true"}) + model = _M(a="x", b=42) + result = apply_verify_echo(request=req, parsed_body=model) + assert result["body_received"] == {"a": "x", "b": 42} + assert isinstance(result["body_received_sha256"], str) + assert len(result["body_received_sha256"]) == 64 + + +def test_apply_verify_echo_hash_deterministic_across_key_order(): + """Canonical JSON (sorted keys) means {a,b} and {b,a} hash identically.""" + req = _fake_request({VERIFY_ECHO_HEADER: "true"}) + r1 = apply_verify_echo(request=req, parsed_body={"a": 1, "b": 2}) + r2 = apply_verify_echo(request=req, parsed_body={"b": 2, "a": 1}) + assert r1["body_received_sha256"] == r2["body_received_sha256"] + + +def test_apply_verify_echo_other_type_body(): + req = _fake_request({VERIFY_ECHO_HEADER: "true"}) + result = apply_verify_echo(request=req, parsed_body=12345) + assert result["body_received"] == "12345" + assert result["body_received_sha256"] == hashlib.sha256(b"12345").hexdigest() + + +def test_canonical_json_bytes_unicode_preserved(): + """ensure_ascii=False → unicode chars round-trip byte-faithful in hash.""" + out = _canonical_json_bytes({"msg": "héllo"}) + assert b"h\xc3\xa9llo" in out + + +# ─────────────────────────────────────────────────────────────────────── +# Integration — POST /v1/messages +# ─────────────────────────────────────────────────────────────────────── + + +async def _make_agent(client, headers, slug=None): + payload = {"display_name": f"Echo Agent {uuid.uuid4().hex[:6]}", "metadata": {}} + if slug: + payload["slug"] = slug + r = await client.post("/v1/agents", json=payload, headers=headers) + assert r.status_code == 201, r.text + return r.json() + + +def _from_header(agent): + return {"X-Cueapi-From-Agent": agent["id"]} + + +@pytest.mark.asyncio +async def test_messages_no_header_no_echo_fields(client, auth_headers): + """Backwards-compat: response without X-CueAPI-Verify-Echo has no echo fields.""" + sender = await _make_agent(client, auth_headers, slug=f"echo-noh-s-{uuid.uuid4().hex[:6]}") + recipient = await _make_agent(client, auth_headers, slug=f"echo-noh-r-{uuid.uuid4().hex[:6]}") + r = await client.post( + "/v1/messages", + json={"to": recipient["id"], "body": "plain"}, + headers={**auth_headers, **_from_header(sender)}, + ) + assert r.status_code == 201 + data = r.json() + assert "body_received" not in data + assert "body_received_sha256" not in data + + +@pytest.mark.asyncio +async def test_messages_echo_roundtrip_happy_path(client, auth_headers): + """Header present → response includes body_received matching sent body.""" + sender = await _make_agent(client, auth_headers, slug=f"echo-hp-s-{uuid.uuid4().hex[:6]}") + recipient = await _make_agent(client, auth_headers, slug=f"echo-hp-r-{uuid.uuid4().hex[:6]}") + body_text = "round-trip test body" + r = await client.post( + "/v1/messages", + json={"to": recipient["id"], "body": body_text}, + headers={ + **auth_headers, + **_from_header(sender), + VERIFY_ECHO_HEADER: "true", + }, + ) + assert r.status_code == 201 + data = r.json() + assert "body_received" in data + assert data["body_received"]["body"] == body_text + assert data["body_received"]["to"] == recipient["id"] + assert isinstance(data["body_received_sha256"], str) + assert len(data["body_received_sha256"]) == 64 + + +@pytest.mark.parametrize( + "metachar_class, payload", + [ + ("backticks", "literal `backticks` should survive"), + ("dollar_paren", "literal $(echo X) should survive"), + ("dollar_brace", "literal ${VAR} should survive"), + ("backslash", "literal \\n \\t \\\\ should survive"), + ("quotes", "literal 'single' and \"double\" quotes should survive"), + ("mixed", "mixed: `cmd` $(sub) ${ref} \\esc \"q\" 'q' should survive"), + ], +) +@pytest.mark.asyncio +async def test_messages_echo_six_metachar_classes( + client, auth_headers, metachar_class, payload +): + """Definition of Done item 1: byte-identical round-trip for 6 metachar classes.""" + sender = await _make_agent( + client, auth_headers, slug=f"echo-{metachar_class[:6]}-s-{uuid.uuid4().hex[:5]}" + ) + recipient = await _make_agent( + client, auth_headers, slug=f"echo-{metachar_class[:6]}-r-{uuid.uuid4().hex[:5]}" + ) + r = await client.post( + "/v1/messages", + json={"to": recipient["id"], "body": payload}, + headers={ + **auth_headers, + **_from_header(sender), + VERIFY_ECHO_HEADER: "true", + }, + ) + assert r.status_code == 201, r.text + data = r.json() + assert ( + data["body_received"]["body"] == payload + ), f"metachar class {metachar_class} did NOT survive round-trip" + + +@pytest.mark.asyncio +async def test_messages_echo_header_lowercase_value_works(client, auth_headers): + """Header value 'TRUE' / 'True' all match (case-insensitive).""" + sender = await _make_agent(client, auth_headers, slug=f"echo-case-s-{uuid.uuid4().hex[:6]}") + recipient = await _make_agent(client, auth_headers, slug=f"echo-case-r-{uuid.uuid4().hex[:6]}") + r = await client.post( + "/v1/messages", + json={"to": recipient["id"], "body": "case test"}, + headers={ + **auth_headers, + **_from_header(sender), + VERIFY_ECHO_HEADER: "True", + }, + ) + assert r.status_code == 201 + assert "body_received" in r.json() + + +@pytest.mark.asyncio +async def test_messages_echo_header_false_value_no_fields(client, auth_headers): + """Header value 'false' (or other non-'true') → no echo fields.""" + sender = await _make_agent(client, auth_headers, slug=f"echo-false-s-{uuid.uuid4().hex[:6]}") + recipient = await _make_agent(client, auth_headers, slug=f"echo-false-r-{uuid.uuid4().hex[:6]}") + r = await client.post( + "/v1/messages", + json={"to": recipient["id"], "body": "plain"}, + headers={ + **auth_headers, + **_from_header(sender), + VERIFY_ECHO_HEADER: "false", + }, + ) + assert r.status_code == 201 + assert "body_received" not in r.json() + + +@pytest.mark.asyncio +async def test_messages_echo_32kb_cap_edge(client, auth_headers): + """Body at the 32KB inline cap still round-trips byte-identical.""" + sender = await _make_agent(client, auth_headers, slug=f"echo-32k-s-{uuid.uuid4().hex[:6]}") + recipient = await _make_agent(client, auth_headers, slug=f"echo-32k-r-{uuid.uuid4().hex[:6]}") + # 32 KB minus a safety margin so we sit JUST under the limit + large_body = "x" * (32 * 1024 - 100) + r = await client.post( + "/v1/messages", + json={"to": recipient["id"], "body": large_body}, + headers={ + **auth_headers, + **_from_header(sender), + VERIFY_ECHO_HEADER: "true", + }, + ) + assert r.status_code == 201, r.text + data = r.json() + assert data["body_received"]["body"] == large_body + assert len(data["body_received"]["body"]) == len(large_body) + + +# ─────────────────────────────────────────────────────────────────────── +# Integration — POST /v1/cues/{cue_id}/fire +# ─────────────────────────────────────────────────────────────────────── + + +async def _create_fire_cue(client, auth_headers, name=None): + """Create a cue we can fire repeatedly in tests.""" + n = name or f"echo-fire-{uuid.uuid4().hex[:8]}" + r = await client.post( + "/v1/cues", + json={ + "name": n, + "schedule": {"type": "recurring", "cron": "0 * * * *"}, + "callback": {"url": "https://example.com/webhook"}, + "payload": {"task": "verify-echo-test"}, + }, + headers=auth_headers, + ) + assert r.status_code == 201, r.text + return r.json()["id"] + + +@pytest.mark.asyncio +async def test_fire_no_header_no_echo_fields(client, auth_headers): + """Backwards-compat: fire response without header has no echo fields.""" + cue_id = await _create_fire_cue(client, auth_headers) + r = await client.post(f"/v1/cues/{cue_id}/fire", headers=auth_headers) + assert r.status_code == 200 + data = r.json() + assert "body_received" not in data + assert "body_received_sha256" not in data + + +@pytest.mark.asyncio +async def test_fire_echo_with_send_at(client, auth_headers): + """Header present + FireRequest body → response echoes parsed FireRequest. + + Note: OSS ``FireRequest`` carries only ``send_at`` (datetime). Hosted's + ``payload_override`` (dict with user content) is the corruption vector + on the fire path and lives in cueapi/cueapi only. This test exercises + the substrate echo-back against whatever fields OSS ``FireRequest`` + exposes; metachar parametrization on the fire path is intentionally + private-only (see parity-manifest deviation note). + """ + from datetime import datetime, timedelta, timezone + cue_id = await _create_fire_cue(client, auth_headers) + future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() + r = await client.post( + f"/v1/cues/{cue_id}/fire", + json={"send_at": future}, + headers={**auth_headers, VERIFY_ECHO_HEADER: "true"}, + ) + assert r.status_code == 200, r.text + data = r.json() + assert "body_received" in data + # FireRequest.send_at is the only OSS field; assert it round-trips. + assert data["body_received"]["send_at"] is not None + assert len(data["body_received_sha256"]) == 64 + + +@pytest.mark.asyncio +async def test_fire_echo_no_body_returns_none_echo(client, auth_headers): + """Header set but no fire request body → body_received=None (FireRequest=None path).""" + cue_id = await _create_fire_cue(client, auth_headers) + r = await client.post( + f"/v1/cues/{cue_id}/fire", + headers={**auth_headers, VERIFY_ECHO_HEADER: "true"}, + ) + assert r.status_code == 200, r.text + data = r.json() + assert "body_received" in data + assert data["body_received"] is None + # SHA256 of empty bytes + assert ( + data["body_received_sha256"] + == hashlib.sha256(b"").hexdigest() + ) + + +@pytest.mark.asyncio +async def test_fire_echo_preserves_original_response_fields(client, auth_headers): + """Echo fields are additive — existing fire response shape unchanged.""" + cue_id = await _create_fire_cue(client, auth_headers) + r = await client.post( + f"/v1/cues/{cue_id}/fire", + headers={**auth_headers, VERIFY_ECHO_HEADER: "true"}, + ) + assert r.status_code == 200 + data = r.json() + # Original response shape preserved + assert "id" in data + assert "cue_id" in data + assert data["cue_id"] == cue_id + assert data["status"] == "pending" + assert data["triggered_by"] == "manual_fire" + # Echo fields additionally present + assert "body_received" in data + assert "body_received_sha256" in data + + +# Note: metachar-class parametrization on the fire path is private-only. +# OSS ``FireRequest`` carries only ``send_at`` (datetime); hosted's +# ``payload_override`` (dict with user-supplied string content) is the +# corruption vector and lives in cueapi/cueapi exclusively. See +# parity-manifest ``oss_only_exclusions`` for the deviation note. +# The metachar-class round-trip discipline IS exercised on the +# /v1/messages endpoint above — same substrate helper, same six classes.