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.
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/CHANGELOG.md +30 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/PKG-INFO +1 -1
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/audit.py +7 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/ircd.py +58 -18
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/protocol.py +2 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/server_link.py +59 -9
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/api-stability.md +14 -12
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/extension-api.md +1 -1
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/pyproject.toml +1 -1
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_basic.py +9 -2
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_lifecycle.py +16 -9
- agentirc_cli-9.5.0a2/tests/test_wire_format_envelope.py +325 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/uv.lock +1 -1
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/SKILL.md +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/portability-lint.sh +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-batch.sh +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-comments.sh +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-reply.sh +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-sonar.sh +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-status.sh +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/workflow.sh +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills.local.yaml.example +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.github/workflows/publish.yml +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.github/workflows/tests.yml +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.gitignore +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/CLAUDE.md +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/LICENSE +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/README.md +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/__init__.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/__main__.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/__init__.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/aio.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/bots/__init__.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/bots/bot_manager.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/bots/http_listener.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/cli_shared/__init__.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/cli_shared/constants.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/cli_shared/mesh.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/constants.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/pidfile.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/protocol/__init__.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/protocol/message.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/protocol/replies.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/__init__.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/context.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/metrics.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/telemetry/tracing.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/_internal/virtual_client.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/channel.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/cli.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/client.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/config.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/events.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/history_store.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/remote_client.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/room_store.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/rooms_util.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skill.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skills/__init__.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skills/history.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skills/icon.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skills/rooms.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/skills/threads.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/agentirc/thread_store.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/cli.md +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/deployment.md +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/steward/onboarding.md +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/superpowers/specs/2026-04-30-bootstrap-design.md +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/superpowers/specs/2026-05-01-bot-extension-api-design.md +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/superpowers/specs/2026-05-01-task14-audit.md +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/__init__.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/_helpers.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/conftest.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/__init__.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/_fakes.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/_metrics_helpers.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_emit.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_lifecycle.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_module.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_audit_parse_error.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_config.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_dispatch_span.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_emit_event_span.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_metrics_init.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_metrics_s2s.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_outbound_inject.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_parse_error.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_s2s_relay_span.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_server_init.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_server_link_inject.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/telemetry/test_tracing.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_channel.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_cli.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_config_loader.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_connection.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_discovery.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_catalog.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_federation.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_history.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_events_reserved_nick.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_federation.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_history.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_link_reconnect.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_mentions.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_messaging.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_modes.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_protocol_bot_exports.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_room_persistence.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_rooms_federation.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_rooms_integration.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_server_icon_skill.py +0 -0
- {agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/tests/test_skills.py +0 -0
- {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.
|
|
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
|
|
279
|
-
"""Build the public event
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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(
|
|
291
|
-
"""Base64-encode the
|
|
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(
|
|
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
|
|
321
|
+
"Event %s envelope not JSON-serializable, surfacing empty envelope: %s",
|
|
299
322
|
type_wire,
|
|
300
323
|
exc,
|
|
301
324
|
)
|
|
302
|
-
|
|
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
|
-
|
|
356
|
-
encoded = self._encode_event_data(
|
|
357
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
926
|
+
if verb_channel is not None and not self._check_incoming_trust(verb_channel):
|
|
904
927
|
return
|
|
905
928
|
|
|
906
|
-
|
|
907
|
-
if
|
|
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=
|
|
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
|
-
|
|
1018
|
-
encoded = self.server._encode_event_data(
|
|
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 (
|
|
18
|
-
>
|
|
19
|
-
>
|
|
20
|
-
> `
|
|
21
|
-
>
|
|
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. **
|
|
24
|
-
>
|
|
25
|
-
>
|
|
26
|
-
> -
|
|
27
|
-
>
|
|
28
|
-
> `
|
|
29
|
-
>
|
|
23
|
+
> field. **Declarations slice — symbols are importable but inert.**
|
|
24
|
+
> - **9.5.0a2 (current alpha — wire-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.
|
|
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 `","`
|
|
@@ -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
|
-
|
|
172
|
-
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
323
|
+
assert envelope["data"]["room_id"].startswith(
|
|
314
324
|
"R"
|
|
315
|
-
), f"Expected room_id to start with R, got: {
|
|
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
|
|
File without changes
|
{agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/portability-lint.sh
RENAMED
|
File without changes
|
|
File without changes
|
{agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/.claude/skills/pr-review/scripts/pr-comments.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/superpowers/specs/2026-04-30-bootstrap-design.md
RENAMED
|
File without changes
|
|
File without changes
|
{agentirc_cli-9.5.0a1 → agentirc_cli-9.5.0a2}/docs/superpowers/specs/2026-05-01-task14-audit.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|