Repository navigation
fix(connections): room tools act through the connection serving the room's server - #5977
Merged
Merged
Conversation
…oom's server On an install connected to several homeservers, room_ops, notify and channel-env.sh always resolved the legacy unsuffixed credential, so an upload to a room on another server got 403 "not a member" from the wrong gateway. connections.connection_for_room/room_credential is now the one owner of that choice: the row whose server equals the room ID's domain exactly (never a suffix match), the lane with a task open in a domainless room, else the primary; no room is the primary. A domain no row serves, a disconnected row, or a row with no token for its own gateway is refused by name. Fewer than two rows keeps every caller's existing chain. room_ops' gateway(room_id), notify's _gateway_config and channel-env.sh --room delegate to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 94%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
skills/agent-room-ops/join.pyLines 30-38 30 gate = load_gate() if gate is None else gate
31 if not gate_allows(agent_mxid, room_id, gate):
32 return _result(False, room_id=room_id,
33 reason=f"client gate denied for {agent_mxid}")
! 34 base, headers = gateway(room_id)
35 if not base:
36 return _result(False, room_id=room_id, reason="no gateway configured")
37 url = f"{base}/v1/rooms/{quote(room_id)}/join"
38 try:skills/agent-room-ops/media.pyLines 65-73 65 return _result(False, reason="no media ref given", room_id=room_id)
66 gate = load_gate() if gate is None else gate
67 if not gate_allows(agent_mxid, room_id, gate):
68 return _result(False, ref=ref, room_id=room_id, reason=f"client gate denied for {agent_mxid}")
! 69 base, headers = gateway(room_id)
70 if not base:
71 return _result(False, ref=ref, room_id=room_id, reason="no gateway configured")
72 from _gateway import urlencode
73 q = {"ref": ref}skills/agent-room-ops/react.pyLines 34-42 34 gate = load_gate() if gate is None else gate
35 if not gate_allows(agent_mxid, room_id, gate):
36 return _result(False, room_id=room_id, event_id=event_id, key=key,
37 reason=f"client gate denied for {agent_mxid}")
! 38 base, headers = gateway(room_id)
39 if not base:
40 return _result(False, room_id=room_id, event_id=event_id, key=key,
41 reason="no gateway configured")
42 url = f"{base}/v1/rooms/{quote(room_id)}/{verb}"src/connections.pyLines 1266-1275 1266 from channel_token import RELAY_TOKEN_VARS, token_from_env_file
1267 from util_paths import claude_home_path
1268 path = resolve_channel_env(claude_home_path("channels"), channel_dir or CHANNEL)
1269 return [token_from_env_file(v, path) for v in RELAY_TOKEN_VARS] if path else []
! 1270 except Exception: # noqa: BLE001 - no channel home or an unreadable file is simply no token
! 1271 return []
1272
1273
1274 def connection_credential(row: Mapping[str, object], environ: Optional[Mapping[str, str]] = None, *,
1275 vault=None) -> "tuple[str, str]":
|
5 tasks done
11 of 12 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
After an install connects a second homeserver (for example after moving to a new one), room tools still act through the original connection. Asked to upload a file into a room on the new server, an agent's upload got 403 "not a member", and its room list showed only rooms on the old server. The same call with the old server's room ID worked.
Root cause
Nothing on the room-tool path looked at the room's server:
skills/agent-room-ops/_gateway.pygateway()took no room. It resolved envGATEWAY_TOKEN/REMOTE_TASK_TOKEN, thenchannels/$REMOTE_TASK_CHANNEL_DIR or ag2space/.env, then the vault keyREMOTE_TASK_TOKEN. In the core session that chain always lands on the legacy, unsuffixed connection. Every room_ops verb (send, say, mention, read, fetch, react, join, members, grant, events emit) went through it.scripts/channel-env.sh→src/channel_env_resolve.pyresolve_channel_env(channels_dir, source)picks a file by layout only:.envfirst, then sorted*.env. With no room input, sourcing it gives the legacy token whatever room comes next.skills/task-progress/scripts/notify.py_gateway_config(source)had the same env-first,channels/<source>chain.The gateway bridges were already correct. Each lane posts task results itself, and the proactive drain only claims rooms on its own homeserver (
delivery_destination.deliverable_here).Fix
The choice now has one owner,
src/connections.py:connection_for_room(room_id)picks the row whoseserver(oragent_mxidserver) is exactly the room ID's domain, sodev.example.orgnever matchesexample.org. A room ID with no domain goes to the one lane with a task open in that room, else the primary. No room means the primary. With fewer than two rows, no primary, or an unreadable record it returnsNone, and the caller keeps its existing single-connection chain.connection_credential(row)takes the first token among the row's env var, its launch keys, its channel env file and its vault key, and only accepts one whose embedded gateway (if any) is the row's own host. Another server's token is never used.NoConnectionForRoom, naming the server, when no row serves the domain, the row is disconnected, or the row has no token for its own gateway.The selection logic contains no server names.
Callers that now delegate:
_gateway.gateway(room_id)and every room_ops verb that has a room. A refusal makes room_ops print{"ok": false, "reason": ...}and exit 1.notify.py_gateway_config(source, room).channel-env.sh <source> --room <room-id>: prints the file for that room's connection, or nothing and exit 1 when that connection has no file here.engine-conflict-resolve/deliver.py, which callsgateway(room_id).docs/connections.mdgains an "Acting in a room" section.Tests
tests/connections-room-routing.test.py(new) is the resolver contract. It runs three connections at once (an old server, a new one, and that new one'sdev.subdomain), plus an arbitraryexample.orgrow. It checks: exact-domain routing; a subdomain never matching its parent; a case-insensitive domain; agent-mxid matching; no room giving the primary; a domainless room following the lane with a task open in it; an unknown server and a disconnected row refused by name; single-row and missing records returningNone; a token for another gateway skipped; vault, launch-key and channel-file sources; a missing token refused.tests/room-ops-room-connection.test.py(new) covers delegation.gateway()forwards the room and its answer outranks the env token.room_ops sendandsayreach the routed gateway with the routed bearer, a refusal exits 1, and notify delegates. Without coresrc/, both keep today's chain.tests/channel-env-resolve.test.pyadds--roomcases in-process. Through the bash wrapper, a stub interpreter proves the room reaches the resolver, and bad--roomusage exits 2.Ran: connections, connections-cli, channel-env-, room-ops-, room_ops_, agent-room-ops-, test_room_ops, every notify test, engine-conflict-resolve, credential-contract-golden, hermetic-env-structural, requeue-prelude-rebuild, turn-ledger-stop-gate. All pass. Also ran ruff,
review-checks.sh(prose-cap clean), lint-hermetic-bridge-tests and lint-claude-home-path. Diff coverage was 96% from a subset of those tests.Same pattern, not in this PR
These still pick their gateway from the legacy chain with no room or server input. Each can delegate to
connections.room_credentialin a follow-up:src/owner_room_access.pyandsrc/channel_credentials.py: the pending-questions room database and the crons table fall back to the AG2 Space channel file.skills/schedule-crons/ensure-cron-room.py,skills/bee-actions/scripts/bee_actions.py, and the health-check gateway credentials: env-only token chains.skills/tutorial/scripts/getting_started.py, throughchannel_credentials.rooms,history,agentsandevents pull/streamhave no room, so they act through the primary only and do not list every server.src/result_markers.pyand its vendored copy) still names the lane's channel dir. room_ops now resolves by room itself, so that fallback is rarely needed.🤖 Generated with Claude Code