agentirc-cli 9.5.0a1__tar.gz → 9.5.0a2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/CHANGELOG.md +30 -0
  2. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/PKG-INFO +1 -1
  3. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/audit.py +7 -0
  4. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/ircd.py +58 -18
  5. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/protocol.py +2 -0
  6. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/server_link.py +59 -9
  7. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/api-stability.md +14 -12
  8. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/extension-api.md +1 -1
  9. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/pyproject.toml +1 -1
  10. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_basic.py +9 -2
  11. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_lifecycle.py +16 -9
  12. agentirc_cli-9.5.0a2/tests/test_wire_format_envelope.py +325 -0
  13. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/uv.lock +1 -1
  14. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/SKILL.md +0 -0
  15. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/portability-lint.sh +0 -0
  16. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-batch.sh +0 -0
  17. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-comments.sh +0 -0
  18. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-reply.sh +0 -0
  19. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-sonar.sh +0 -0
  20. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-status.sh +0 -0
  21. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/workflow.sh +0 -0
  22. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills.local.yaml.example +0 -0
  23. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.github/workflows/publish.yml +0 -0
  24. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.github/workflows/tests.yml +0 -0
  25. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.gitignore +0 -0
  26. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/CLAUDE.md +0 -0
  27. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/LICENSE +0 -0
  28. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/README.md +0 -0
  29. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/__init__.py +0 -0
  30. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/__main__.py +0 -0
  31. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/__init__.py +0 -0
  32. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/aio.py +0 -0
  33. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/bots/__init__.py +0 -0
  34. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/bots/bot_manager.py +0 -0
  35. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/bots/http_listener.py +0 -0
  36. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/cli_shared/__init__.py +0 -0
  37. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/cli_shared/constants.py +0 -0
  38. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/cli_shared/mesh.py +0 -0
  39. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/constants.py +0 -0
  40. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/pidfile.py +0 -0
  41. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/protocol/__init__.py +0 -0
  42. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/protocol/message.py +0 -0
  43. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/protocol/replies.py +0 -0
  44. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/__init__.py +0 -0
  45. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/context.py +0 -0
  46. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/metrics.py +0 -0
  47. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/tracing.py +0 -0
  48. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/virtual_client.py +0 -0
  49. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/channel.py +0 -0
  50. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/cli.py +0 -0
  51. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/client.py +0 -0
  52. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/config.py +0 -0
  53. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/events.py +0 -0
  54. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/history_store.py +0 -0
  55. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/remote_client.py +0 -0
  56. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/room_store.py +0 -0
  57. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/rooms_util.py +0 -0
  58. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skill.py +0 -0
  59. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skills/__init__.py +0 -0
  60. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skills/history.py +0 -0
  61. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skills/icon.py +0 -0
  62. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skills/rooms.py +0 -0
  63. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skills/threads.py +0 -0
  64. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/thread_store.py +0 -0
  65. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/cli.md +0 -0
  66. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/deployment.md +0 -0
  67. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/steward/onboarding.md +0 -0
  68. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/superpowers/specs/2026-04-30-bootstrap-design.md +0 -0
  69. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/superpowers/specs/2026-05-01-bot-extension-api-design.md +0 -0
  70. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/superpowers/specs/2026-05-01-task14-audit.md +0 -0
  71. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/__init__.py +0 -0
  72. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/_helpers.py +0 -0
  73. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/conftest.py +0 -0
  74. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/__init__.py +0 -0
  75. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/_fakes.py +0 -0
  76. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/_metrics_helpers.py +0 -0
  77. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_emit.py +0 -0
  78. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_lifecycle.py +0 -0
  79. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_module.py +0 -0
  80. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_parse_error.py +0 -0
  81. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_config.py +0 -0
  82. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_dispatch_span.py +0 -0
  83. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_emit_event_span.py +0 -0
  84. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_metrics_init.py +0 -0
  85. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_metrics_s2s.py +0 -0
  86. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_outbound_inject.py +0 -0
  87. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_parse_error.py +0 -0
  88. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_s2s_relay_span.py +0 -0
  89. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_server_init.py +0 -0
  90. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_server_link_inject.py +0 -0
  91. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_tracing.py +0 -0
  92. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_channel.py +0 -0
  93. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_cli.py +0 -0
  94. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_config_loader.py +0 -0
  95. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_connection.py +0 -0
  96. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_discovery.py +0 -0
  97. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_catalog.py +0 -0
  98. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_federation.py +0 -0
  99. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_history.py +0 -0
  100. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_reserved_nick.py +0 -0
  101. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_federation.py +0 -0
  102. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_history.py +0 -0
  103. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_link_reconnect.py +0 -0
  104. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_mentions.py +0 -0
  105. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_messaging.py +0 -0
  106. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_modes.py +0 -0
  107. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_protocol_bot_exports.py +0 -0
  108. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_room_persistence.py +0 -0
  109. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_rooms_federation.py +0 -0
  110. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_rooms_integration.py +0 -0
  111. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_server_icon_skill.py +0 -0
  112. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_skills.py +0 -0
  113. {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_threads.py +0 -0
@@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [9.5.0a2] - 2026-05-02
8
+
9
+ ### Changed
10
+
11
+ - **Wire format: SEVENT payload and IRCv3 `event-data` tag now carry the 5-field envelope** `{type, channel, nick, data, timestamp}`. Both the federation seam (`SEVENT` between linked servers) and the human-visible `#system` PRIVMSG `event-data` tag emit the new shape via `IRCd._build_event_envelope` + `IRCd._encode_event_data`. Type-specific keys (e.g. `text`, `room_id`, `purpose`) now nest under `data`; `nick` and `channel` remain at the top level. See `docs/superpowers/specs/2026-05-01-bot-extension-api-design.md` § Decision A for the rationale.
12
+ - `IRCd._build_event_payload` is replaced by `IRCd._build_event_envelope` (internal — no public API impact). Old name removed; the only two callsites (`_surface_event_privmsg` and `ServerLink.relay_event`'s SEVENT fallback) updated in the same commit.
13
+
14
+ ### Added
15
+
16
+ - `ServerLink._is_envelope(decoded)` — sniff helper that recognises the new envelope shape vs. the legacy data-only dict at decode time. `_handle_sevent` uses it for asymmetric tolerance (see Notes).
17
+ - `agentirc.protocol.SEVENT` — verb-name constant for the federation event-relay verb. Used by the new test suite and the outbound relay path. Other agentirc-side string-literal callsites are tracked separately in [#11](https://github.com/agentculture/agentirc/issues/11).
18
+ - `tests/test_wire_format_envelope.py` — 12 tests including a golden-file byte-equality lock-in for the canonical JSON encoding (any future change to the encoder that breaks this is a wire-format break and requires a major bump), plus regression guards for the trust-bypass and underscore-injection fixes below.
19
+
20
+ ### Security
21
+
22
+ - **`ServerLink._handle_sevent` now treats the SEVENT verb-arg channel as authoritative.** The previous draft of this PR (pre-review) used the envelope's `channel` field on the receive side, which would have allowed a malformed/malicious peer to bypass `_check_incoming_trust` by sending `target="*"` (no trust check) while putting a restricted channel name in the envelope. The verb-arg channel is what the trust gate already protects, so the receiver now ignores any envelope channel claim and uses `verb_channel` for both the trust check and the resulting `Event.channel`.
23
+ - **Peer-supplied `_`-prefixed keys are stripped from incoming SEVENT data before emit.** `_render` and similar server-internal hints would otherwise let a peer dictate human-readable surfacing on this server. The receiver strips every key starting with `_` from the decoded data before adding its own `_origin` marker.
24
+
25
+ ### Fixed
26
+
27
+ - **`IRCd._encode_event_data` fallback now produces a valid 5-field envelope instead of `b"{}"`.** A serialization failure used to emit a bare empty dict, which fails the receiver's envelope sniff and is misclassified as legacy data-only — worse than emitting an event with no type-specific payload. The fallback now emits `{type, channel, nick, data: {}, timestamp: time.time()}` so consumers can still parse the shape.
28
+
29
+ ### Notes
30
+
31
+ - **Federation interop story.** A 9.5 daemon's `_handle_sevent` sniffs the decoded payload: if it has a top-level `type` (string) and `data` (dict), treat as 9.5+ envelope; otherwise treat as ≤9.4 legacy data-only and reconstruct an `Event` from the SEVENT verb args + the bare data dict. This gives 9.5 receivers asymmetric tolerance — they read traffic from both upgraded and pre-9.5 peers — so federations can roll forward one peer at a time. The reverse direction (9.5 emit → 9.4 receive) does **not** work; 9.4 daemons have no sniff and will fail to find expected `data` fields. Operators must either upgrade all peers in lockstep, or roll one at a time and accept that 9.5-emitted events drop on still-9.4 peers until those peers also upgrade. This matches the migration cost the spec calls out.
32
+ - **Audit JSONL is unchanged.** `agentirc/_internal/telemetry/audit.py:build_audit_record` continues to record the legacy data-only payload (data dict with `nick`/`channel` merged in via `setdefault`, `_`-prefixed keys stripped). Ops dashboards and downstream log processors that read the audit JSONL keep their existing field shape; the 5-field envelope is reserved for the public network surface (SEVENT payload + IRCv3 `event-data` tag).
33
+ - **Audit timestamp on legacy peers.** When `_handle_sevent` decodes a legacy payload from a ≤9.4 peer, no originating timestamp is available; the receiver assigns `time.time()` to the reconstructed `Event.timestamp`. 9.5+ peers preserve the originating timestamp.
34
+ - This is the **wire-format slice** of the bot extension API. The next slice (9.5.0a3) wires the bot CAP behavior (`agentirc.io/bot`), the `EVENTSUB`/`EVENTUNSUB`/`EVENTPUB` handlers, and the `SubscriptionRegistry`. 9.5.0 final adds `webhook_port` unbinding and flips `docs/api-stability.md` to "current".
35
+ - Tracks [agentculture/agentirc#15](https://github.com/agentculture/agentirc/issues/15).
36
+
7
37
  ## [9.5.0a1] - 2026-05-02
8
38
 
9
39
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentirc-cli
3
- Version: 9.5.0a1
3
+ Version: 9.5.0a2
4
4
  Summary: Agent-friendly IRCd: server core for AI agent meshes
5
5
  Project-URL: Homepage, https://github.com/agentculture/agentirc
6
6
  Project-URL: Issues, https://github.com/agentculture/agentirc/issues
@@ -313,6 +313,13 @@ def build_audit_record(
313
313
 
314
314
  See culture/protocol/extensions/audit.md for the field set.
315
315
  """
316
+ # Audit JSONL records the legacy data-only payload (data dict with
317
+ # nick/channel merged in via setdefault, _-prefixed keys stripped).
318
+ # This intentionally diverges from the 9.5+ public wire envelope built
319
+ # by IRCd._build_event_envelope: ops dashboards and downstream log
320
+ # processors that read the audit JSONL keep their existing field shape;
321
+ # the 5-field envelope is reserved for the public network surface
322
+ # (SEVENT payload + IRCv3 event-data tag).
316
323
  payload = {k: v for k, v in (event.data or {}).items() if not k.startswith("_")}
317
324
  if event.nick:
318
325
  payload.setdefault("nick", event.nick)
@@ -275,31 +275,63 @@ class IRCd:
275
275
  _NO_SURFACE_TYPES = NO_SURFACE_EVENT_TYPES
276
276
 
277
277
  @staticmethod
278
- def _build_event_payload(event: Event) -> dict:
279
- """Build the public event payload, enriched with canonical actor/channel."""
280
- payload = {k: v for k, v in event.data.items() if not k.startswith("_")}
281
- # Emitters that only set Event.nick (not data['nick']) still get a
282
- # correct render + payload thanks to setdefault.
283
- if event.nick:
284
- payload.setdefault("nick", event.nick)
285
- if event.channel:
286
- payload.setdefault("channel", event.channel)
287
- return payload
278
+ def _build_event_envelope(event: Event) -> dict:
279
+ """Build the public 5-field event envelope.
280
+
281
+ Shape (semver-stable as of 9.5.0a2)::
282
+
283
+ {
284
+ "type": str, # wire-form event type, e.g. "user.join"
285
+ "channel": str | None, # channel name or None
286
+ "nick": str, # actor's nickname (empty for purely-server events)
287
+ "data": dict, # type-specific payload, _-prefixed keys stripped
288
+ "timestamp": float, # Unix epoch seconds with sub-second precision
289
+ }
290
+
291
+ See ``docs/superpowers/specs/2026-05-01-bot-extension-api-design.md``
292
+ § Decision A for the rationale and federation-interop story.
293
+ """
294
+ type_wire = event.type.value if hasattr(event.type, "value") else str(event.type)
295
+ return {
296
+ "type": type_wire,
297
+ "channel": event.channel,
298
+ "nick": event.nick,
299
+ "data": {k: v for k, v in event.data.items() if not k.startswith("_")},
300
+ "timestamp": event.timestamp,
301
+ }
288
302
 
289
303
  @staticmethod
290
- def _encode_event_data(payload: dict, type_wire: str) -> str:
291
- """Base64-encode the payload as JSON; fall back to '{}' on TypeError."""
304
+ def _encode_event_data(envelope: dict, type_wire: str) -> str:
305
+ """Base64-encode the envelope as canonical JSON.
306
+
307
+ On serialization failure the fallback is a *minimal but well-formed*
308
+ envelope ``{type, channel, nick, data, timestamp}`` with empty
309
+ ``data`` and current wall-clock ``timestamp`` — never a bare ``{}``.
310
+ Subscribers and federation peers depend on the 5-field shape; an
311
+ empty-dict fallback would fail the receiver's envelope sniff and
312
+ be misclassified as legacy data-only, which is worse than emitting
313
+ an event with no type-specific payload.
314
+ """
292
315
  try:
293
316
  return base64.b64encode(
294
- json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
317
+ json.dumps(envelope, separators=(",", ":"), sort_keys=True).encode("utf-8")
295
318
  ).decode("ascii")
296
319
  except (TypeError, ValueError) as exc:
297
320
  logger.warning(
298
- "Event %s payload not JSON-serializable, surfacing with empty payload: %s",
321
+ "Event %s envelope not JSON-serializable, surfacing empty envelope: %s",
299
322
  type_wire,
300
323
  exc,
301
324
  )
302
- return base64.b64encode(b"{}").decode("ascii")
325
+ empty_envelope = {
326
+ "type": type_wire,
327
+ "channel": envelope.get("channel") if isinstance(envelope, dict) else None,
328
+ "nick": envelope.get("nick", "") if isinstance(envelope, dict) else "",
329
+ "data": {},
330
+ "timestamp": time.time(),
331
+ }
332
+ return base64.b64encode(
333
+ json.dumps(empty_envelope, separators=(",", ":"), sort_keys=True).encode("utf-8")
334
+ ).decode("ascii")
303
335
 
304
336
  async def _deliver_to_members(self, channel, msg: Message, type_wire: str) -> None:
305
337
  """Send the surfaced PRIVMSG to channel members (skipping VirtualClients)."""
@@ -352,9 +384,17 @@ class IRCd:
352
384
  origin_server = event.data.get("_origin") or self.config.name
353
385
  system_nick = f"{SYSTEM_USER_PREFIX}{origin_server}"
354
386
 
355
- payload = self._build_event_payload(event)
356
- encoded = self._encode_event_data(payload, type_wire)
357
- body = event.data.get("_render") or render_event(type_wire, payload, event.channel)
387
+ envelope = self._build_event_envelope(event)
388
+ encoded = self._encode_event_data(envelope, type_wire)
389
+ # Render templates expect the type-specific data dict (with nick/channel
390
+ # merged in for backward compat with the pre-9.5 render-function shape),
391
+ # not the envelope. Reconstruct the legacy data shape for render only.
392
+ render_data = dict(envelope["data"])
393
+ if event.nick:
394
+ render_data.setdefault("nick", event.nick)
395
+ if event.channel:
396
+ render_data.setdefault("channel", event.channel)
397
+ body = event.data.get("_render") or render_event(type_wire, render_data, event.channel)
358
398
 
359
399
  msg = Message(
360
400
  tags={EVENT_TAG_TYPE: type_wire, EVENT_TAG_DATA: encoded},
@@ -147,6 +147,7 @@ SROOMMETA = "SROOMMETA"
147
147
  SROOMARCHIVE = "SROOMARCHIVE"
148
148
  STAGS = "STAGS"
149
149
  STHREAD = "STHREAD"
150
+ SEVENT = "SEVENT"
150
151
  BACKFILL = "BACKFILL"
151
152
  BACKFILLEND = "BACKFILLEND"
152
153
 
@@ -344,6 +345,7 @@ __all__ = [
344
345
  "BACKFILL",
345
346
  "BACKFILLEND",
346
347
  "SERVER",
348
+ "SEVENT",
347
349
  "SJOIN",
348
350
  "SMSG",
349
351
  "SNICK",
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
11
11
  from opentelemetry import trace as otel_trace
12
12
  from opentelemetry.context import Context as _OtelContext
13
13
 
14
+ from agentirc import protocol
14
15
  from agentirc.remote_client import RemoteClient
15
16
  from agentirc.skill import Event, EventType
16
17
  from agentirc._internal.aio import maybe_await
@@ -887,34 +888,83 @@ class ServerLink:
887
888
  except ValueError:
888
889
  return type_str
889
890
 
891
+ @staticmethod
892
+ def _is_envelope(decoded: dict) -> bool:
893
+ """Sniff whether a decoded SEVENT payload is a 9.5+ 5-field envelope.
894
+
895
+ 9.5+ peers emit ``{type, channel, nick, data, timestamp}`` with a
896
+ nested ``data`` dict. 9.4 and earlier peers emit the type-specific
897
+ ``data`` dict directly (no outer envelope, no top-level ``type``).
898
+ Both are valid SEVENT payloads on the wire; this sniff lets a 9.5
899
+ receiver tolerate either shape so federations can roll forward one
900
+ peer at a time.
901
+ """
902
+ return (
903
+ isinstance(decoded.get("type"), str)
904
+ and isinstance(decoded.get("data"), dict)
905
+ )
906
+
890
907
  async def _handle_sevent(self, msg: Message) -> None:
891
908
  """Ingest a generic federated event from a peer.
892
909
 
893
- Wire format: SEVENT <origin-server> <seq> <type> <channel_or_*> :<b64-json-data>
910
+ Wire format: SEVENT <origin-server> <seq> <type> <channel_or_*> :<b64-json>
911
+
912
+ The base64-JSON payload is the 5-field envelope from 9.5+ peers, or
913
+ a bare type-specific data dict from ≤9.4 peers (asymmetric tolerance
914
+ via :meth:`_is_envelope`). Either way we reconstruct an :class:`Event`
915
+ with all five fields populated, falling back to ``time.time()`` for
916
+ ``timestamp`` only when the legacy peer didn't provide one.
894
917
  """
895
918
  if len(msg.params) < 5:
896
919
  return
897
920
  origin, _seq, type_str, target, b64 = msg.params[:5]
898
- channel = None if target == "*" else target
921
+ verb_channel = None if target == "*" else target
899
922
 
900
923
  if origin != self.peer_name:
901
924
  logger.warning("SEVENT origin %s != peer %s", origin, self.peer_name)
902
925
 
903
- if channel is not None and not self._check_incoming_trust(channel):
926
+ if verb_channel is not None and not self._check_incoming_trust(verb_channel):
904
927
  return
905
928
 
906
- data = self._decode_sevent_payload(b64, self.peer_name)
907
- if data is None:
929
+ decoded = self._decode_sevent_payload(b64, self.peer_name)
930
+ if decoded is None:
908
931
  return
909
932
 
933
+ if self._is_envelope(decoded):
934
+ # 9.5+ envelope. Verb-arg type wins on mismatch (defensive — the
935
+ # SEVENT line is the canonical type carrier).
936
+ raw_data = decoded["data"]
937
+ nick = decoded.get("nick") or f"{SYSTEM_USER_PREFIX}{origin}"
938
+ timestamp = decoded.get("timestamp")
939
+ if not isinstance(timestamp, (int, float)):
940
+ timestamp = time.time()
941
+ else:
942
+ # ≤9.4 legacy: decoded dict IS the data payload.
943
+ raw_data = decoded
944
+ nick = raw_data.get("nick", f"{SYSTEM_USER_PREFIX}{origin}")
945
+ timestamp = time.time()
946
+
947
+ # Verb-arg channel is authoritative for routing and trust: the
948
+ # _check_incoming_trust() above gated on it. Ignore any channel claim
949
+ # in the envelope — a malformed/malicious peer must not be able to
950
+ # bypass trust by sending target="*" while putting a restricted
951
+ # channel name in the envelope.
952
+ channel = verb_channel
953
+
954
+ # Strip incoming `_`-prefixed keys from the peer-supplied data before
955
+ # we add our own `_origin` marker. `_render` and similar server-side
956
+ # hints must not be peer-controllable; allowing them would let a
957
+ # peer dictate the human-readable surfacing on this server.
958
+ data = {k: v for k, v in raw_data.items() if not k.startswith("_")}
910
959
  data["_origin"] = origin
911
960
  type_enum = self._parse_event_type(type_str)
912
961
 
913
962
  ev = Event(
914
963
  type=type_enum,
915
964
  channel=channel,
916
- nick=data.get("nick", f"{SYSTEM_USER_PREFIX}{origin}"),
965
+ nick=nick,
917
966
  data=data,
967
+ timestamp=timestamp,
918
968
  )
919
969
  await self.server.emit_event(ev)
920
970
 
@@ -1014,14 +1064,14 @@ class ServerLink:
1014
1064
  else:
1015
1065
  # If no typed relay exists, fall back to generic SEVENT.
1016
1066
  # v1 assumes all peers support SEVENT; cap negotiation is deferred — see plan task 12.
1017
- payload = self.server._build_event_payload(event)
1018
- encoded = self.server._encode_event_data(payload, event_type_str)
1067
+ envelope = self.server._build_event_envelope(event)
1068
+ encoded = self.server._encode_event_data(envelope, event_type_str)
1019
1069
  target = event.channel or "*"
1020
1070
  # Egress trust check: channel-scoped events respect should_relay; global events always relay
1021
1071
  if event.channel is None or self.should_relay(event.channel):
1022
1072
  seq = self.server._seq # current local seq; peer stores but doesn't re-sequence
1023
1073
  await self.send_raw(
1024
- f":{origin} SEVENT {origin} {seq} {event_type_str} {target} :{encoded}"
1074
+ f":{origin} {protocol.SEVENT} {origin} {seq} {event_type_str} {target} :{encoded}"
1025
1075
  )
1026
1076
  relayed = True
1027
1077
 
@@ -14,19 +14,21 @@ import only from these three modules.
14
14
 
15
15
  > **Bot extension API — phased rollout:**
16
16
  >
17
- > - **9.5.0a1 (current alpha — declarations slice):** `agentirc.protocol`
18
- > exports the `Event` dataclass, the `EventType` enum (now `StrEnum`),
19
- > 20 per-type `EVENT_TYPE_*` string constants, the
20
- > `EVENTSUB`/`EVENTUNSUB`/`EVENT`/`EVENTERR`/`EVENTPUB` verb constants,
21
- > and the `BOT_CAP = "agentirc.io/bot"` capability identifier.
17
+ > - **9.5.0a1 (shipped):** `agentirc.protocol` exports the `Event`
18
+ > dataclass, the `EventType` enum (now `StrEnum`), 20 per-type
19
+ > `EVENT_TYPE_*` string constants, the `EVENTSUB`/`EVENTUNSUB`/
20
+ > `EVENT`/`EVENTERR`/`EVENTPUB` verb constants, and the
21
+ > `BOT_CAP = "agentirc.io/bot"` capability identifier.
22
22
  > `ServerConfig` gains the `event_subscription_queue_max: int = 1024`
23
- > field. **The symbols are importable; the daemon does not yet handle
24
- > the verbs and does not advertise `BOT_CAP`** calling them is a
25
- > no-op until the behavior slices land.
26
- > - **9.5.0a2 (planned):** wire-format envelope refactor
27
- > `_build_event_payload`/`_encode_event_data` emit the 5-field envelope
28
- > `{type, channel, nick, data, timestamp}`; federated `SEVENT` shifts
29
- > to the new shape. Internal change; no new public symbols.
23
+ > field. **Declarations slice — symbols are importable but inert.**
24
+ > - **9.5.0a2 (current alphawire-format slice):** `IRCd._build_event_payload`
25
+ > replaced by `IRCd._build_event_envelope`. Federated `SEVENT` payload
26
+ > and the IRCv3 `event-data` tag on `#system` PRIVMSGs now carry the
27
+ > canonical 5-field envelope `{type, channel, nick, data, timestamp}`.
28
+ > `ServerLink._handle_sevent` sniffs the shape so 9.5 receivers
29
+ > tolerate both envelope (9.5+ peers) and legacy data-only (≤9.4
30
+ > peers); 9.5→9.4 emit breaks until peers upgrade. Internal change;
31
+ > no new public symbols. See CHANGELOG `[9.5.0a2]` § Notes.
30
32
  > - **9.5.0a3 (planned):** bot-CAP behavior, `EVENTSUB`/`EVENTUNSUB`
31
33
  > handlers, the in-memory `SubscriptionRegistry`, and `EVENTPUB`
32
34
  > handler. Daemon starts advertising `BOT_CAP` in `CAP LS` output.
@@ -104,7 +104,7 @@ in the trailing parameter. Decode with any JSON parser.
104
104
  | `type` | string | yes | One of the canonical event-type strings (see vocabulary below). Unknown types are tolerated — forward-compat. |
105
105
  | `channel` | string-or-null | yes | Channel name for channel-scoped events, `null` otherwise. |
106
106
  | `nick` | string | yes | Actor's nickname, or empty string for purely-server-emitted events. |
107
- | `data` | object | yes | Type-specific payload. Always an object. Keys starting with `_` are reserved metadata (e.g. `_origin` is the originating server name across federation links). |
107
+ | `data` | object | yes | Type-specific payload. Always an object. Subscribers may observe `_`-prefixed metadata keys (most notably `_origin`, the originating server name across federation links). Such keys are **not transmitted** by the originating server — the encoder strips them at emit time, and the receiving server reconstructs `_origin` at decode time from the SEVENT verb args. Peers cannot inject `_render` or other server-internal hints across the federation seam. |
108
108
  | `timestamp` | number | yes | Unix epoch seconds with sub-second precision. |
109
109
 
110
110
  JSON encoding is canonical: keys sorted lexicographically, separators `","`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentirc-cli"
3
- version = "9.5.0a1"
3
+ version = "9.5.0a2"
4
4
  description = "Agent-friendly IRCd: server core for AI agent meshes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -168,8 +168,15 @@ async def test_event_data_is_base64_json(server, make_client):
168
168
  tags = line[at_idx + 1 : space_idx]
169
169
  data_piece = [p for p in tags.split(";") if p.startswith("event-data=")][0]
170
170
  b64 = data_piece.split("=", 1)[1]
171
- decoded = json.loads(base64.b64decode(b64))
172
- assert decoded["nick"] == "testserv-bob"
171
+ envelope = json.loads(base64.b64decode(b64))
172
+ # Envelope (9.5.0a2+): top-level nick is the event actor (event.nick,
173
+ # the system user emitting on behalf of the agent); data carries the
174
+ # type-specific payload, including the agent's own nick.
175
+ assert envelope["type"] == "agent.connect"
176
+ assert envelope["nick"] == "system-testserv"
177
+ assert envelope["data"]["nick"] == "testserv-bob"
178
+ assert envelope["channel"] is None
179
+ assert isinstance(envelope["timestamp"], (int, float))
173
180
 
174
181
 
175
182
  @pytest.mark.asyncio
@@ -24,6 +24,12 @@ from tests.conftest import IRCTestClient
24
24
  def _decode_event_payload(lines: str) -> dict:
25
25
  """Extract and decode the IRCv3 `event-data=<b64-json>` tag.
26
26
 
27
+ Returns the 5-field envelope ``{type, channel, nick, data, timestamp}``
28
+ introduced in 9.5.0a2. Pre-9.5 callers asserting on type-specific fields
29
+ (e.g. ``payload["text"]``, ``payload["room_id"]``) must navigate one
30
+ level deeper into ``payload["data"]``; ``nick`` and ``channel`` remain
31
+ top-level.
32
+
27
33
  ``lines`` is multi-line text as returned by ``recv_until``; scan each
28
34
  line for the ``@event=...`` tag blob and decode the matching
29
35
  ``event-data=`` value. Only tagged PRIVMSG-style lines start with ``@``.
@@ -305,17 +311,18 @@ async def test_room_create_emitted_on_roomcreate(server, make_client):
305
311
  assert "event=room.create" in line, f"Expected room.create tag, got: {line!r}"
306
312
  assert "testserv-creator created room #research" in line
307
313
 
308
- # Lock the structured payload fields downstream consumers rely on.
309
- payload = _decode_event_payload(line)
310
- assert payload["nick"] == "testserv-creator"
311
- assert payload["purpose"] == "AI research"
314
+ # Lock the structured envelope fields downstream consumers rely on.
315
+ # Envelope (9.5.0a2+): top-level type/channel/nick/data/timestamp;
316
+ # type-specific fields nested under data.
317
+ envelope = _decode_event_payload(line)
318
+ assert envelope["type"] == "room.create"
319
+ assert envelope["nick"] == "testserv-creator"
320
+ assert envelope["channel"] == "#research"
321
+ assert envelope["data"]["purpose"] == "AI research"
312
322
  # room_id is generated server-side — shape check only (starts with "R").
313
- assert payload["room_id"].startswith(
323
+ assert envelope["data"]["room_id"].startswith(
314
324
  "R"
315
- ), f"Expected room_id to start with R, got: {payload['room_id']!r}"
316
- # The server enriches the payload with the channel when the event is
317
- # channel-scoped (see IRCd._build_event_payload).
318
- assert payload["channel"] == "#research"
325
+ ), f"Expected room_id to start with R, got: {envelope['data']['room_id']!r}"
319
326
 
320
327
 
321
328
  @pytest.mark.asyncio
@@ -0,0 +1,325 @@
1
+ """Wire-format envelope (9.5.0a2) tests.
2
+
3
+ Locks in the public 5-field envelope shape that bot subscribers and
4
+ federation peers exchange. Tests:
5
+
6
+ - Golden-file byte lock: a known ``Event`` encodes to exactly one
7
+ base64 string. Any change to ``_build_event_envelope`` or
8
+ ``_encode_event_data`` that breaks this is a wire-format break and
9
+ must be a major bump per the semver contract.
10
+ - Round-trip: encode → decode → reconstruct ``Event`` → equal to the
11
+ original (including the floating-point ``timestamp``).
12
+ - Asymmetric sniff tolerance: ``ServerLink._handle_sevent`` decodes
13
+ both 9.5+ envelope shape AND ≤9.4 legacy data-only shape, letting
14
+ 9.5 daemons read federation traffic from older peers during an
15
+ in-place upgrade.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import json
22
+ import time
23
+
24
+ import pytest
25
+
26
+ from agentirc._internal.protocol.message import Message
27
+ from agentirc.ircd import IRCd
28
+ from agentirc.protocol import Event, EventType, SEVENT
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Golden-file byte lock
33
+ # ---------------------------------------------------------------------------
34
+
35
+ # A canonical ``user.join`` event with deterministic fields (fixed timestamp
36
+ # so the encoded blob is reproducible across runs) and ``_origin`` in data
37
+ # (must be stripped by ``_build_event_envelope``).
38
+ _GOLDEN_EVENT = Event(
39
+ type=EventType.JOIN,
40
+ channel="#room",
41
+ nick="alice",
42
+ data={"text": "hi", "_origin": "should-strip"},
43
+ timestamp=1714568400.0,
44
+ )
45
+
46
+ # Locked-in expected wire bytes. Derived from the canonical JSON encoding:
47
+ # {"channel":"#room","data":{"text":"hi"},"nick":"alice","timestamp":1714568400.0,"type":"user.join"}
48
+ # Sorted keys, separators=(",", ":"), UTF-8, then base64.
49
+ _GOLDEN_BASE64 = (
50
+ "eyJjaGFubmVsIjoiI3Jvb20iLCJkYXRhIjp7InRleHQiOiJoaSJ9LCJuaWNrIjoi"
51
+ "YWxpY2UiLCJ0aW1lc3RhbXAiOjE3MTQ1Njg0MDAuMCwidHlwZSI6InVzZXIuam9pbiJ9"
52
+ )
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Federation-test scaffolding helper
57
+ # ---------------------------------------------------------------------------
58
+
59
+ async def _send_sevent_and_get_event(
60
+ linked_servers,
61
+ payload: dict,
62
+ *,
63
+ verb_channel: str = "#room",
64
+ verb_type: str = "user.join",
65
+ pre_create_channel: bool = True,
66
+ ):
67
+ """Encode payload as SEVENT, dispatch through the alpha→beta link, return the resulting Event.
68
+
69
+ Used by every ``test_handle_sevent_*`` test to eliminate per-test
70
+ scaffolding (the encode + Message + link-lookup + dispatch + event-fetch
71
+ boilerplate). The payload may be a 9.5+ envelope or a ≤9.4 legacy data
72
+ dict; the receiver's ``ServerLink._is_envelope`` sniff handles both.
73
+ """
74
+ alpha, beta = linked_servers
75
+ encoded = base64.b64encode(
76
+ json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
77
+ ).decode("ascii")
78
+ alpha_link = next(
79
+ link for link in beta.links.values() if link.peer_name == alpha.config.name
80
+ )
81
+ msg = Message(
82
+ prefix=None,
83
+ command=SEVENT,
84
+ params=[alpha.config.name, "1", verb_type, verb_channel, encoded],
85
+ )
86
+ if pre_create_channel and verb_channel != "*":
87
+ beta.get_or_create_channel(verb_channel)
88
+ before_count = len(beta._event_log)
89
+ await alpha_link._handle_sevent(msg)
90
+ assert len(beta._event_log) == before_count + 1
91
+ _, ev = beta._event_log[-1]
92
+ return ev
93
+
94
+
95
+ def test_envelope_byte_lock():
96
+ """The canonical JSON encoding of a known Event matches the golden b64.
97
+
98
+ Locks the public wire format under the semver contract. Any change here
99
+ is a wire-format break — major bump required.
100
+ """
101
+ envelope = IRCd._build_event_envelope(_GOLDEN_EVENT)
102
+ encoded = IRCd._encode_event_data(envelope, "user.join")
103
+ assert encoded == _GOLDEN_BASE64, (
104
+ "Wire format break: _build_event_envelope + _encode_event_data no "
105
+ "longer produces the locked-in canonical encoding. If this is "
106
+ "intentional, a major version bump is required per docs/api-stability.md."
107
+ )
108
+
109
+
110
+ def test_envelope_strips_underscore_keys():
111
+ """``_``-prefixed keys (federation metadata) must not leak into ``data``."""
112
+ envelope = IRCd._build_event_envelope(_GOLDEN_EVENT)
113
+ assert "_origin" not in envelope["data"]
114
+ assert envelope["data"] == {"text": "hi"}
115
+
116
+
117
+ def test_envelope_shape():
118
+ """Exactly five keys, predictable types, top-level nick/channel."""
119
+ envelope = IRCd._build_event_envelope(_GOLDEN_EVENT)
120
+ assert set(envelope.keys()) == {"type", "channel", "nick", "data", "timestamp"}
121
+ assert envelope["type"] == "user.join"
122
+ assert envelope["channel"] == "#room"
123
+ assert envelope["nick"] == "alice"
124
+ assert envelope["data"] == {"text": "hi"}
125
+ # Use pytest.approx to avoid SonarCloud python:S1244 (float equality);
126
+ # the value round-trips exactly in IEEE 754 so default tolerance suffices.
127
+ assert envelope["timestamp"] == pytest.approx(1714568400.0)
128
+
129
+
130
+ def test_envelope_round_trip():
131
+ """encode → decode → reconstruct preserves all 5 envelope fields."""
132
+ envelope = IRCd._build_event_envelope(_GOLDEN_EVENT)
133
+ encoded = IRCd._encode_event_data(envelope, "user.join")
134
+ decoded = json.loads(base64.b64decode(encoded))
135
+ assert decoded == envelope
136
+
137
+ # Reconstruct the Event from the decoded envelope (this is what
138
+ # _handle_sevent does on the receive side).
139
+ reconstructed = Event(
140
+ type=decoded["type"],
141
+ channel=decoded["channel"],
142
+ nick=decoded["nick"],
143
+ data=decoded["data"],
144
+ timestamp=decoded["timestamp"],
145
+ )
146
+ assert reconstructed.type == "user.join"
147
+ assert reconstructed.channel == "#room"
148
+ assert reconstructed.nick == "alice"
149
+ assert reconstructed.data == {"text": "hi"}
150
+ assert reconstructed.timestamp == pytest.approx(1714568400.0)
151
+
152
+
153
+ def test_envelope_with_null_channel():
154
+ """A nick-scoped event has channel=None at the envelope's top level."""
155
+ ev = Event(
156
+ type=EventType.AGENT_CONNECT,
157
+ channel=None,
158
+ nick="system-alpha",
159
+ data={"nick": "agent-bob"},
160
+ timestamp=1714568500.5,
161
+ )
162
+ envelope = IRCd._build_event_envelope(ev)
163
+ assert envelope["channel"] is None
164
+ assert envelope["nick"] == "system-alpha"
165
+ assert envelope["data"]["nick"] == "agent-bob"
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Asymmetric sniff tolerance
170
+ # ---------------------------------------------------------------------------
171
+
172
+ def test_is_envelope_recognises_9_5_shape():
173
+ """Sniff returns True for the 5-field envelope shape."""
174
+ from agentirc.server_link import ServerLink
175
+
176
+ decoded = {
177
+ "type": "user.join",
178
+ "channel": "#room",
179
+ "nick": "alice",
180
+ "data": {"text": "hi"},
181
+ "timestamp": 1714568400.0,
182
+ }
183
+ assert ServerLink._is_envelope(decoded) is True
184
+
185
+
186
+ def test_is_envelope_rejects_legacy_data_only_shape():
187
+ """Sniff returns False for the legacy data-only dict (no top-level type/data)."""
188
+ from agentirc.server_link import ServerLink
189
+
190
+ legacy = {"nick": "alice", "channel": "#room", "text": "hi"}
191
+ assert ServerLink._is_envelope(legacy) is False
192
+
193
+
194
+ def test_is_envelope_rejects_partial_shapes():
195
+ """Sniff is strict — both `type` (string) AND `data` (dict) must be present."""
196
+ from agentirc.server_link import ServerLink
197
+
198
+ # Has `type` but no `data` dict
199
+ assert ServerLink._is_envelope({"type": "user.join", "nick": "alice"}) is False
200
+ # Has `data` but no `type`
201
+ assert ServerLink._is_envelope({"data": {"text": "hi"}}) is False
202
+ # `data` present but not a dict
203
+ assert ServerLink._is_envelope({"type": "user.join", "data": "not-a-dict"}) is False
204
+ # Empty dict
205
+ assert ServerLink._is_envelope({}) is False
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Federation interop via _handle_sevent
210
+ # ---------------------------------------------------------------------------
211
+
212
+ # These tests exercise _handle_sevent's sniff-and-reconstruct path directly.
213
+ # They use the existing `linked_servers` fixture for the integration round-trip;
214
+ # the unit-level sniff tests above lock in the helper.
215
+
216
+ @pytest.mark.asyncio
217
+ async def test_handle_sevent_decodes_9_5_envelope(linked_servers):
218
+ """A 9.5+ peer's envelope payload reconstructs an Event with all 5 fields."""
219
+ alpha, _ = linked_servers
220
+ envelope = {
221
+ "type": "user.join",
222
+ "channel": "#room",
223
+ "nick": "alice",
224
+ "data": {"text": "hi"},
225
+ "timestamp": 1714568400.0,
226
+ }
227
+ ev = await _send_sevent_and_get_event(linked_servers, envelope)
228
+
229
+ assert str(ev.type) == "user.join"
230
+ assert ev.channel == "#room"
231
+ assert ev.nick == "alice"
232
+ assert ev.data["text"] == "hi"
233
+ # Timestamp from the envelope should round-trip (originating peer's clock).
234
+ assert ev.timestamp == pytest.approx(1714568400.0)
235
+ # Receiver sets _origin to track the originating peer.
236
+ assert ev.data["_origin"] == alpha.config.name
237
+
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_handle_sevent_ignores_envelope_channel_claim(linked_servers):
241
+ """Verb-arg channel is authoritative; envelope channel claim is ignored.
242
+
243
+ Regression guard for PR #18 review (Qodo 3176230784, Copilot 3176232442):
244
+ a malformed peer must not be able to bypass the trust check by sending
245
+ target="*" while putting a restricted channel name in the envelope. The
246
+ receiver always uses the SEVENT verb-arg channel for both the trust
247
+ check and the resulting Event.channel.
248
+ """
249
+ envelope = {
250
+ "type": "user.join",
251
+ # Peer claims #attack-target in the envelope, but verb-arg is "*".
252
+ "channel": "#attack-target",
253
+ "nick": "alice",
254
+ "data": {"text": "hi"},
255
+ "timestamp": 1714568400.0,
256
+ }
257
+ # verb_channel="*" → no trust check fires, no channel injection.
258
+ ev = await _send_sevent_and_get_event(
259
+ linked_servers, envelope, verb_channel="*", pre_create_channel=False
260
+ )
261
+
262
+ # Verb-arg "*" mapped to None. Envelope's "#attack-target" is dropped.
263
+ assert ev.channel is None, (
264
+ f"Envelope channel claim leaked through trust gate: {ev.channel!r}"
265
+ )
266
+
267
+
268
+ @pytest.mark.asyncio
269
+ async def test_handle_sevent_strips_underscore_metadata(linked_servers):
270
+ """`_`-prefixed keys in peer-supplied data are stripped before emit_event.
271
+
272
+ Regression guard for PR #18 review (Copilot 3176232430): a peer must not
273
+ be able to inject `_render` (or any other server-internal `_`-prefixed
274
+ metadata) via SEVENT and influence local surfacing. The receiver strips
275
+ every `_`-key from the decoded data before adding its own `_origin`.
276
+ """
277
+ alpha, _ = linked_servers
278
+ envelope = {
279
+ "type": "user.join",
280
+ "channel": "#room",
281
+ "nick": "alice",
282
+ "data": {
283
+ "text": "hi",
284
+ "_render": "ATTACKER-CONTROLLED RENDER STRING",
285
+ "_origin": "spoofed-origin",
286
+ "_secret_hint": "should-not-survive",
287
+ },
288
+ "timestamp": 1714568400.0,
289
+ }
290
+ ev = await _send_sevent_and_get_event(linked_servers, envelope)
291
+
292
+ # `text` (non-underscore) survives; all peer-supplied `_`-keys do not.
293
+ assert ev.data["text"] == "hi"
294
+ assert "_render" not in ev.data, "_render injection survived sevent decode"
295
+ assert "_secret_hint" not in ev.data
296
+ # `_origin` is set by the receiver to the actual peer name (not the
297
+ # spoofed value). The peer-supplied `_origin` was stripped first.
298
+ assert ev.data["_origin"] == alpha.config.name
299
+
300
+
301
+ @pytest.mark.asyncio
302
+ async def test_handle_sevent_decodes_legacy_data_only(linked_servers):
303
+ """A ≤9.4 peer's data-only payload still reconstructs cleanly via sniff fallback.
304
+
305
+ Locks asymmetric tolerance: 9.5 receiver tolerates the legacy 9.4 emit
306
+ shape so federations can roll forward one peer at a time. Without this
307
+ sniff, a half-upgraded federation would lose all events from the
308
+ not-yet-upgraded side.
309
+ """
310
+ alpha, _ = linked_servers
311
+ # Legacy shape: bare data dict, no top-level type or data wrapper.
312
+ # 9.4 emitters merged nick into the data dict via setdefault.
313
+ legacy_payload = {"nick": "alice", "channel": "#room", "text": "hi"}
314
+
315
+ before = time.time()
316
+ ev = await _send_sevent_and_get_event(linked_servers, legacy_payload)
317
+ after = time.time()
318
+
319
+ assert str(ev.type) == "user.join"
320
+ assert ev.channel == "#room"
321
+ assert ev.nick == "alice"
322
+ assert ev.data["text"] == "hi"
323
+ # Legacy peers don't ship a timestamp; receiver fills with time.time().
324
+ assert before <= ev.timestamp <= after
325
+ assert ev.data["_origin"] == alpha.config.name
@@ -10,7 +10,7 @@ resolution-markers = [
10
10
 
11
11
  [[package]]
12
12
  name = "agentirc-cli"
13
- version = "9.5.0a1"
13
+ version = "9.5.0a2"
14
14
  source = { editable = "." }
15
15
  dependencies = [
16
16
  { name = "opentelemetry-api" },
File without changes
File without changes
File without changes