From 7da34a6e9a62690dc5ea9ba3210ea914b952a4b0 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Sun, 10 May 2026 11:21:20 -0700 Subject: [PATCH 1/2] fix(cli): agents webhook-secret regenerate sends X-Confirm-Destructive header (Bug cmp03hy9o) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cueapi agents webhook-secret regenerate ` was making the POST /v1/agents/{ref}/webhook-secret/regenerate request without the `X-Confirm-Destructive: true` header that the server requires. Result: even after the user typed `y` at the confirmation prompt, the request was rejected HTTP 400 server-side. Surfaced 2026-05-10 from Phase 2 messaging smoke testing — identical bug shape to the one already fixed on `cueapi key webhook-secret regenerate` (which did pass the header). Mirror the existing pattern: pass the header on the httpx POST right after the Y/N confirm clears. Tests: - New `test_agents_webhook_secret_regenerate_sends_destructive_header` pins the header on the wire — same shape as the existing `test_key_webhook_secret_regenerate_sends_destructive_header`. --- cueapi/cli.py | 6 +++++- tests/test_cli.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/cueapi/cli.py b/cueapi/cli.py index 794f0be..c036310 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -1662,7 +1662,11 @@ def agents_webhook_secret_regenerate(ctx: click.Context, ref: str, yes: bool) -> return try: with CueAPIClient(api_key=ctx.obj.get("api_key"), profile=ctx.obj.get("profile")) as client: - resp = client.post(f"/agents/{ref}/webhook-secret/regenerate", json={}) + resp = client.post( + f"/agents/{ref}/webhook-secret/regenerate", + json={}, + headers={"X-Confirm-Destructive": "true"}, + ) if resp.status_code == 200: data = resp.json() click.echo() diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e8c37c..314a299 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -682,6 +682,34 @@ def test_agents_webhook_secret_regenerate_with_yes(monkeypatch): assert "save now" in result.output.lower() or "shown once" in result.output.lower() +def test_agents_webhook_secret_regenerate_sends_destructive_header(monkeypatch): + """Pin: `cueapi agents webhook-secret regenerate` MUST send + `X-Confirm-Destructive: true` header — server requires it (Bug + cmp03hy9o, surfaced 2026-05-10 from Phase 2 messaging smoke). + Mirrors the same header pin on `cueapi key webhook-secret regenerate`. + """ + holder: dict = {} + _patch_ws_client( + monkeypatch, + holder, + responses={ + ("POST", "/agents/agt_x/webhook-secret/regenerate"): lambda: _FakeResp( + 200, {"webhook_secret": "wsec_new"} + ) + }, + ) + result = runner.invoke( + main, + ["agents", "webhook-secret", "regenerate", "agt_x", "--yes"], + ) + assert result.exit_code == 0, result.output + method, path, body, headers = holder["client"].calls[-1] + assert method == "POST" + assert path == "/agents/agt_x/webhook-secret/regenerate" + assert headers == {"X-Confirm-Destructive": "true"} + assert "wsec_new" in result.output + + # --- inbox / sent --- From f10548afabe51a64a797dfe8423b489c651e0c93 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Sun, 10 May 2026 11:26:29 -0700 Subject: [PATCH 2/2] feat(cli): per-message send_at scheduling on messages send + message-to (#623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional `--send-at` (ISO 8601 string) to `cueapi messages send` and `cueapi message-to` — ports the private-monorepo cueapi #623 server-side change that's already shipped on POST /v1/messages (MessageCreate.send_at in app/schemas/message.py). Server contract pinned: - NULL send_at (default) → send now - Future timestamp → message gates inbox-fetch + push delivery until send_at <= now() - Past timestamps → forgiving fallback (treated as send-now) - Same semantics as cue-fire send_at (PR #618, already shipped) Wire format: send_at flows in the BODY of POST /v1/messages, NOT a header. Mirrors the cue-fire send_at transport. Different from idempotency_key, which is a header. Verify-server-transport-per- endpoint pinned in tests so a refactor doesn't accidentally promote it to a header. Tests: - messages_send: 2 new (send_at_omitted_by_default, send_at_passed_in_body) - message_to: 2 new (send_at_passed_in_body, send_at_omitted_when_unset) - 171/171 pass total This unblocks the cueapi-action port (its messages-send shell branch needs the underlying CLI flag to surface --send-at to GitHub Actions callers). Parallels cueapi-python (already shipped) and cueapi-mcp (just shipped via PR #33) ports of the same private-monorepo PR #623. --- cueapi/cli.py | 38 +++++++++++++++++ tests/test_cli.py | 105 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/cueapi/cli.py b/cueapi/cli.py index c036310..6f55d2b 100644 --- a/cueapi/cli.py +++ b/cueapi/cli.py @@ -1932,6 +1932,19 @@ def messages() -> None: "different body returns HTTP 409 idempotency_key_conflict." ), ) +@click.option( + "--send-at", + "send_at", + default=None, + help=( + "Optional ISO 8601 timestamp to schedule this message for future delivery " + "(hosted PR #623, §13). Omitted = send now (server default). When set in the " + "future, the message sits in the recipient's inbox-query gate until " + "send-at <= now(); push-delivery dispatch is also gated. Past timestamps " + "are treated as send-now (forgiving fallback). Same semantics as cue-fire " + "--send-at (PR #618). Sent as a BODY field on POST /v1/messages." + ), +) @click.pass_context def messages_send( ctx: click.Context, @@ -1945,6 +1958,7 @@ def messages_send( reply_to_agent: Optional[str], metadata: Optional[str], idempotency_key: Optional[str], + send_at: Optional[str], ) -> None: """Send a message.""" body: dict = {"to": to, "body": body_text} @@ -1965,6 +1979,11 @@ def messages_send( body["metadata"] = json.loads(metadata) except json.JSONDecodeError: raise click.UsageError("--metadata must be valid JSON") + # send_at flows in the body (server contract: MessageCreate.send_at, + # app/schemas/message.py). Mirrors cue-fire send_at transport (also a + # body field). Different from idempotency_key, which is a header. + if send_at: + body["send_at"] = send_at headers: dict = {"X-Cueapi-From-Agent": from_agent} if idempotency_key: @@ -2237,6 +2256,19 @@ def _resolve_recipient(client, recipient: str) -> str: "24h returns the existing message with HTTP 200 instead of 201." ), ) +@click.option( + "--send-at", + "send_at", + default=None, + help=( + "Optional ISO 8601 timestamp to schedule this message for future delivery " + "(hosted PR #623, §13). Omitted = send now (server default). When set in the " + "future, the message sits in the recipient's inbox-query gate until " + "send-at <= now(); push-delivery dispatch is also gated. Past timestamps " + "are treated as send-now (forgiving fallback). Same semantics as cue-fire " + "--send-at (PR #618). Sent as a BODY field on POST /v1/messages." + ), +) @click.pass_context def message_to( ctx: click.Context, @@ -2251,6 +2283,7 @@ def message_to( metadata: Optional[str], mode: str, idempotency_key: Optional[str], + send_at: Optional[str], ) -> None: """Send a message to a recipient by name, slug, or agent ID. @@ -2280,6 +2313,11 @@ def message_to( # senders. `auto` is also redundant to send. if mode != "auto": body["delivery_mode"] = mode + # send_at flows in the body (server contract: MessageCreate.send_at). + # Mirrors cue-fire send_at transport. Different from idempotency_key, + # which is a header. + if send_at: + body["send_at"] = send_at headers: dict = {"X-Cueapi-From-Agent": from_agent} if idempotency_key: diff --git a/tests/test_cli.py b/tests/test_cli.py index 314a299..a196494 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1969,6 +1969,61 @@ def test_messages_send_with_all_optionals(monkeypatch): assert headers["Idempotency-Key"] == "idemp-key-1" +def test_messages_send_send_at_omitted_by_default(monkeypatch): + # Wire-format must match pre-#623 senders when --send-at is not passed. + # Server contract: NULL send_at === deliver immediately. + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_x", "delivery_state": "queued"} + ) + }, + ) + result = runner.invoke( + main, + ["messages", "send", "--from", "sender@x", "--to", "recipient@y", "--body", "hello"], + ) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert "send_at" not in body + + +def test_messages_send_send_at_passed_in_body(monkeypatch): + # send_at flows in the body (server contract: MessageCreate.send_at, + # app/schemas/message.py). Mirrors cue-fire send_at transport. + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_x", "delivery_state": "queued"} + ) + }, + ) + future = "2099-01-01T00:00:00Z" + result = runner.invoke( + main, + [ + "messages", "send", + "--from", "sender@x", + "--to", "recipient@y", + "--body", "hello", + "--send-at", future, + ], + ) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert body["send_at"] == future + # Verify it's a body field, NOT a header (regression guard). + headers = holder["client"].calls[-1][3] + assert "Send-At" not in headers + assert "X-Cueapi-Send-At" not in headers + + def test_messages_send_omits_expects_reply_when_unset(monkeypatch): # Default false MUST NOT appear in the body — server's Pydantic default # is false, and sending `expects_reply: false` explicitly creates noise. @@ -2611,6 +2666,56 @@ def test_message_to_omits_expects_reply_when_unset(monkeypatch): assert "expects_reply" not in body +def test_message_to_send_at_passed_in_body(monkeypatch): + # Same parity as `messages send` — send_at flows in body, not header. + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_z", "delivery_state": "queued"} + ) + }, + ) + future = "2099-01-01T00:00:00Z" + result = runner.invoke( + main, + [ + "message-to", "agt_x", + "--from", "y@z", + "--body", "hi", + "--send-at", future, + ], + ) + assert result.exit_code == 0, result.output + body = holder["client"].calls[-1][2] + assert body["send_at"] == future + headers = holder["client"].calls[-1][3] + assert "Send-At" not in headers + assert "X-Cueapi-Send-At" not in headers + + +def test_message_to_send_at_omitted_when_unset(monkeypatch): + holder: dict = {} + _patch_messages_client( + monkeypatch, + holder, + responses={ + ("POST", "/messages"): lambda: _FakeResp( + 201, {"id": "msg_z", "delivery_state": "queued"} + ) + }, + ) + result = runner.invoke( + main, + ["message-to", "agt_x", "--from", "y@z", "--body", "hi"], + ) + assert result.exit_code == 0 + body = holder["client"].calls[-1][2] + assert "send_at" not in body + + def test_message_to_priority_validated_by_click_intrange(): result = runner.invoke( main,