agentirc-cli 0.3.1__tar.gz → 0.4.0__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-0.3.1 → agentirc_cli-0.4.0}/CHANGELOG.md +17 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/PKG-INFO +1 -1
- agentirc_cli-0.4.0/agentirc/__init__.py +1 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/cli.py +13 -4
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/__main__.py +12 -4
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/channel.py +2 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/client.py +40 -23
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/config.py +1 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/ircd.py +3 -3
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/server_link.py +78 -4
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/layer4-federation.md +41 -5
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/pyproject.toml +1 -1
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_federation.py +318 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/uv.lock +1 -1
- agentirc_cli-0.3.1/agentirc/__init__.py +0 -1
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.github/workflows/pages.yml +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.github/workflows/publish.yml +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.github/workflows/tests.yml +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.gitignore +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.markdownlint-cli2.yaml +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.pr_agent.toml +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/CLAUDE.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/CNAME +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/Gemfile +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/Gemfile.lock +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/LICENSE +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/README.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/_config.yml +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/_sass/color_schemes/anthropic.scss +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/_sass/custom/custom.scss +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/__init__.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/__init__.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/__main__.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/agent_runner.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/config.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/daemon.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/ipc.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/irc_transport.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/message_buffer.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/skill/SKILL.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/skill/__init__.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/skill/irc_client.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/socket_server.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/supervisor.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/webhook.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/codex/__init__.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/codex/skill/SKILL.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/codex/skill/__init__.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/observer.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/pidfile.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/__init__.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/commands.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/extensions/federation.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/extensions/history.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/message.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/protocol-index.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/replies.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/__init__.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/remote_client.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/skill.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/skills/__init__.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/skills/history.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/agent-client.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/ci.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/cli.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/configuration.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/context-management.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/irc-tools.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/overview.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/setup.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/supervisor.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/webhooks.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/design.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/docs-site.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/getting-started.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/layer1-core-irc.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/layer2-attention.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/layer3-skills.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/layer5-agent-harness.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/publishing.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/server-architecture.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/superpowers/plans/2026-03-19-layer1-core-irc.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/superpowers/plans/2026-03-21-layer5-agent-harness.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/superpowers/specs/2026-03-19-agentirc-design.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/superpowers/specs/2026-03-21-layer5-agent-harness-design.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/01-pair-programming.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/02-code-review-ensemble.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/03-research-deep-dive.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/04-agent-delegation.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/05-benchmark-swarm.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/06-cross-server-ops.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/07-knowledge-pipeline.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/08-supervisor-intervention.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/09-apps-as-agents.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases-index.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/index.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/plugins/claude-code/.claude-plugin/plugin.json +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/plugins/claude-code/skills/irc/SKILL.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/plugins/codex/skills/agentirc-irc/SKILL.md +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/__init__.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/conftest.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_agent_runner.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_channel.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_connection.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_daemon.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_daemon_config.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_discovery.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_history.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_integration_layer5.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_ipc.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_irc_transport.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_mentions.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_message.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_message_buffer.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_messaging.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_modes.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_skill_client.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_skills.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_socket_server.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_supervisor.py +0 -0
- {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_webhook.py +0 -0
|
@@ -4,6 +4,23 @@ 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
|
+
## [0.4.0] - 2026-03-24
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Link trust levels: full (share all) and restricted (share nothing unless opted in)
|
|
13
|
+
- Channel mode +R: restricted — channel stays local, never federated
|
|
14
|
+
- Channel mode +S <server>: shared — explicitly share channel with named server
|
|
15
|
+
- Mutual +S required for restricted links — both sides must agree
|
|
16
|
+
- Safe default: inbound links from unknown peers default to restricted
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Link format extended: name:host:port:password:trust (trust defaults to full)
|
|
22
|
+
- Burst and relay filtered through should_relay() based on trust + channel modes
|
|
23
|
+
|
|
7
24
|
## [0.3.1] - 2026-03-22
|
|
8
25
|
|
|
9
26
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -36,20 +36,29 @@ logger = logging.getLogger("agentirc")
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def _parse_link(value: str):
|
|
39
|
-
"""Parse a link spec: name:host:port:password
|
|
39
|
+
"""Parse a link spec: name:host:port:password[:trust]
|
|
40
|
+
|
|
41
|
+
Trust is extracted from the end if it matches a known value.
|
|
42
|
+
This allows passwords containing colons.
|
|
43
|
+
"""
|
|
40
44
|
from agentirc.server.config import LinkConfig
|
|
41
45
|
|
|
46
|
+
# Check if the last segment is a trust level
|
|
47
|
+
trust = "full"
|
|
48
|
+
if value.endswith(":full") or value.endswith(":restricted"):
|
|
49
|
+
value, trust = value.rsplit(":", 1)
|
|
50
|
+
|
|
42
51
|
parts = value.split(":", 3)
|
|
43
52
|
if len(parts) != 4:
|
|
44
53
|
raise argparse.ArgumentTypeError(
|
|
45
|
-
f"Link must be name:host:port:password, got: {value}"
|
|
54
|
+
f"Link must be name:host:port:password[:trust], got: {value}"
|
|
46
55
|
)
|
|
47
56
|
name, host, port_str, password = parts
|
|
48
57
|
try:
|
|
49
58
|
port = int(port_str)
|
|
50
59
|
except ValueError:
|
|
51
60
|
raise argparse.ArgumentTypeError(f"Invalid port: {port_str}")
|
|
52
|
-
return LinkConfig(name=name, host=host, port=port, password=password)
|
|
61
|
+
return LinkConfig(name=name, host=host, port=port, password=password, trust=trust)
|
|
53
62
|
|
|
54
63
|
DEFAULT_CONFIG = os.path.expanduser("~/.agentirc/agents.yaml")
|
|
55
64
|
LOG_DIR = os.path.expanduser("~/.agentirc/logs")
|
|
@@ -247,7 +256,7 @@ async def _run_server(name: str, host: str, port: int, links: list | None = None
|
|
|
247
256
|
# Connect to configured peers
|
|
248
257
|
for lc in config.links:
|
|
249
258
|
try:
|
|
250
|
-
await ircd.connect_to_peer(lc.host, lc.port, lc.password)
|
|
259
|
+
await ircd.connect_to_peer(lc.host, lc.port, lc.password, lc.trust)
|
|
251
260
|
logger.info("Linking to %s at %s:%d", lc.name, lc.host, lc.port)
|
|
252
261
|
except Exception as e:
|
|
253
262
|
logger.error("Failed to link to %s: %s", lc.name, e)
|
|
@@ -6,18 +6,26 @@ from agentirc.server.ircd import IRCd
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def parse_link(value: str) -> LinkConfig:
|
|
9
|
-
"""Parse a link spec: name:host:port:password
|
|
9
|
+
"""Parse a link spec: name:host:port:password[:trust]
|
|
10
|
+
|
|
11
|
+
Trust is extracted from the end if it matches a known value.
|
|
12
|
+
This allows passwords containing colons.
|
|
13
|
+
"""
|
|
14
|
+
trust = "full"
|
|
15
|
+
if value.endswith(":full") or value.endswith(":restricted"):
|
|
16
|
+
value, trust = value.rsplit(":", 1)
|
|
17
|
+
|
|
10
18
|
parts = value.split(":", 3)
|
|
11
19
|
if len(parts) != 4:
|
|
12
20
|
raise argparse.ArgumentTypeError(
|
|
13
|
-
f"Link must be name:host:port:password, got: {value}"
|
|
21
|
+
f"Link must be name:host:port:password[:trust], got: {value}"
|
|
14
22
|
)
|
|
15
23
|
name, host, port_str, password = parts
|
|
16
24
|
try:
|
|
17
25
|
port = int(port_str)
|
|
18
26
|
except ValueError:
|
|
19
27
|
raise argparse.ArgumentTypeError(f"Invalid port: {port_str}")
|
|
20
|
-
return LinkConfig(name=name, host=host, port=port, password=password)
|
|
28
|
+
return LinkConfig(name=name, host=host, port=port, password=password, trust=trust)
|
|
21
29
|
|
|
22
30
|
|
|
23
31
|
async def main() -> None:
|
|
@@ -44,7 +52,7 @@ async def main() -> None:
|
|
|
44
52
|
# Connect to configured peers
|
|
45
53
|
for lc in config.links:
|
|
46
54
|
try:
|
|
47
|
-
await ircd.connect_to_peer(lc.host, lc.port, lc.password)
|
|
55
|
+
await ircd.connect_to_peer(lc.host, lc.port, lc.password, lc.trust)
|
|
48
56
|
print(f"Linking to {lc.name} at {lc.host}:{lc.port}")
|
|
49
57
|
except Exception as e:
|
|
50
58
|
print(f"Failed to link to {lc.name}: {e}")
|
|
@@ -17,6 +17,8 @@ class Channel:
|
|
|
17
17
|
self.members: set[Client] = set()
|
|
18
18
|
self.operators: set[Client] = set()
|
|
19
19
|
self.voiced: set[Client] = set()
|
|
20
|
+
self.restricted = False # +R mode — never federate
|
|
21
|
+
self.shared_with: set[str] = set() # +S servers — share with these servers
|
|
20
22
|
|
|
21
23
|
def _local_members(self) -> set[Client]:
|
|
22
24
|
"""Return only local (non-remote) members."""
|
|
@@ -391,41 +391,58 @@ class Client:
|
|
|
391
391
|
return
|
|
392
392
|
|
|
393
393
|
param_queue = list(msg.params[2:])
|
|
394
|
-
param_modes = {"o", "v"}
|
|
394
|
+
param_modes = {"o", "v", "S"}
|
|
395
395
|
|
|
396
396
|
adding = True
|
|
397
397
|
applied_modes = []
|
|
398
|
-
|
|
398
|
+
applied_params: list[str] = []
|
|
399
399
|
for ch in modestring:
|
|
400
400
|
if ch == "+":
|
|
401
401
|
adding = True
|
|
402
402
|
elif ch == "-":
|
|
403
403
|
adding = False
|
|
404
|
+
elif ch == "R":
|
|
405
|
+
# +R / -R — restrict channel from federation
|
|
406
|
+
if adding:
|
|
407
|
+
channel.restricted = True
|
|
408
|
+
else:
|
|
409
|
+
channel.restricted = False
|
|
410
|
+
applied_modes.append(("+" if adding else "-") + ch)
|
|
404
411
|
elif ch in param_modes:
|
|
405
412
|
if not param_queue:
|
|
406
413
|
continue
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
await self.send_numeric(
|
|
411
|
-
replies.ERR_USERNOTINCHANNEL,
|
|
412
|
-
target_nick,
|
|
413
|
-
channel_name,
|
|
414
|
-
"They aren't on that channel",
|
|
415
|
-
)
|
|
416
|
-
continue
|
|
417
|
-
if ch == "o":
|
|
414
|
+
param_value = param_queue.pop(0)
|
|
415
|
+
if ch == "S":
|
|
416
|
+
# +S <server> / -S <server> — share with specific servers
|
|
418
417
|
if adding:
|
|
419
|
-
channel.
|
|
418
|
+
channel.shared_with.add(param_value)
|
|
420
419
|
else:
|
|
421
|
-
channel.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
420
|
+
channel.shared_with.discard(param_value)
|
|
421
|
+
applied_modes.append(("+" if adding else "-") + ch)
|
|
422
|
+
applied_params.append(param_value)
|
|
423
|
+
else:
|
|
424
|
+
target_nick = param_value
|
|
425
|
+
target_client = self.server.clients.get(target_nick)
|
|
426
|
+
if not target_client or target_client not in channel.members:
|
|
427
|
+
await self.send_numeric(
|
|
428
|
+
replies.ERR_USERNOTINCHANNEL,
|
|
429
|
+
target_nick,
|
|
430
|
+
channel_name,
|
|
431
|
+
"They aren't on that channel",
|
|
432
|
+
)
|
|
433
|
+
continue
|
|
434
|
+
if ch == "o":
|
|
435
|
+
if adding:
|
|
436
|
+
channel.operators.add(target_client)
|
|
437
|
+
else:
|
|
438
|
+
channel.operators.discard(target_client)
|
|
439
|
+
elif ch == "v":
|
|
440
|
+
if adding:
|
|
441
|
+
channel.voiced.add(target_client)
|
|
442
|
+
else:
|
|
443
|
+
channel.voiced.discard(target_client)
|
|
444
|
+
applied_modes.append(("+" if adding else "-") + ch)
|
|
445
|
+
applied_params.append(target_nick)
|
|
429
446
|
|
|
430
447
|
# Auto-promote if no operators remain
|
|
431
448
|
if not channel.operators and channel.members:
|
|
@@ -435,7 +452,7 @@ class Client:
|
|
|
435
452
|
mode_msg = Message(
|
|
436
453
|
prefix=self.prefix,
|
|
437
454
|
command="MODE",
|
|
438
|
-
params=[channel_name, "".join(applied_modes)] +
|
|
455
|
+
params=[channel_name, "".join(applied_modes)] + applied_params,
|
|
439
456
|
)
|
|
440
457
|
for member in list(channel.members):
|
|
441
458
|
await member.send(mode_msg)
|
|
@@ -102,13 +102,13 @@ class IRCd:
|
|
|
102
102
|
await self._server.wait_closed()
|
|
103
103
|
|
|
104
104
|
async def connect_to_peer(
|
|
105
|
-
self, host: str, port: int, password: str
|
|
105
|
+
self, host: str, port: int, password: str, trust: str = "full"
|
|
106
106
|
) -> ServerLink:
|
|
107
107
|
"""Initiate an outbound S2S connection."""
|
|
108
108
|
from agentirc.server.server_link import ServerLink
|
|
109
109
|
|
|
110
110
|
reader, writer = await asyncio.open_connection(host, port)
|
|
111
|
-
link = ServerLink(reader, writer, self, password, initiator=True)
|
|
111
|
+
link = ServerLink(reader, writer, self, password, initiator=True, trust=trust)
|
|
112
112
|
asyncio.create_task(link.handle())
|
|
113
113
|
return link
|
|
114
114
|
|
|
@@ -139,7 +139,7 @@ class IRCd:
|
|
|
139
139
|
writer.close()
|
|
140
140
|
return
|
|
141
141
|
|
|
142
|
-
link = ServerLink(reader, writer, self, password=None, initiator=False)
|
|
142
|
+
link = ServerLink(reader, writer, self, password=None, initiator=False, trust="restricted")
|
|
143
143
|
try:
|
|
144
144
|
await link.handle(initial_msg=text)
|
|
145
145
|
except (ConnectionError, asyncio.IncompleteReadError):
|
|
@@ -23,15 +23,17 @@ class ServerLink:
|
|
|
23
23
|
reader: asyncio.StreamReader,
|
|
24
24
|
writer: asyncio.StreamWriter,
|
|
25
25
|
server: IRCd,
|
|
26
|
-
password: str,
|
|
26
|
+
password: str | None,
|
|
27
27
|
*,
|
|
28
28
|
initiator: bool = False,
|
|
29
|
+
trust: str = "full",
|
|
29
30
|
):
|
|
30
31
|
self.reader = reader
|
|
31
32
|
self.writer = writer
|
|
32
33
|
self.server = server
|
|
33
34
|
self.password = password
|
|
34
35
|
self.initiator = initiator
|
|
36
|
+
self.trust = trust
|
|
35
37
|
self.peer_name: str | None = None
|
|
36
38
|
self.peer_description: str = ""
|
|
37
39
|
self._authenticated = False
|
|
@@ -40,6 +42,19 @@ class ServerLink:
|
|
|
40
42
|
self._peer_pass: str | None = None
|
|
41
43
|
self.last_seen_seq: int = 0
|
|
42
44
|
|
|
45
|
+
def should_relay(self, channel_name: str) -> bool:
|
|
46
|
+
"""Check if a channel event should be relayed over this link."""
|
|
47
|
+
channel = self.server.channels.get(channel_name)
|
|
48
|
+
if channel is None:
|
|
49
|
+
return False
|
|
50
|
+
if channel.restricted:
|
|
51
|
+
return False
|
|
52
|
+
if self.trust == "full":
|
|
53
|
+
return True
|
|
54
|
+
if self.trust == "restricted":
|
|
55
|
+
return self.peer_name in channel.shared_with
|
|
56
|
+
return False
|
|
57
|
+
|
|
43
58
|
async def send_raw(self, line: str) -> None:
|
|
44
59
|
try:
|
|
45
60
|
self.writer.write(f"{line}\r\n".encode("utf-8"))
|
|
@@ -121,7 +136,7 @@ class ServerLink:
|
|
|
121
136
|
if not (self._got_pass and self._got_server):
|
|
122
137
|
return
|
|
123
138
|
|
|
124
|
-
# For inbound links, look up expected password by peer name
|
|
139
|
+
# For inbound links, look up expected password and trust by peer name
|
|
125
140
|
if not self.initiator and self.password is None:
|
|
126
141
|
link_config = None
|
|
127
142
|
for lc in self.server.config.links:
|
|
@@ -133,6 +148,7 @@ class ServerLink:
|
|
|
133
148
|
await self.send_raw(f"ERROR :No link configured for {self.peer_name}")
|
|
134
149
|
raise ConnectionError(f"No link config for {self.peer_name}")
|
|
135
150
|
self.password = link_config.password
|
|
151
|
+
self.trust = link_config.trust
|
|
136
152
|
|
|
137
153
|
if self._peer_pass != self.password:
|
|
138
154
|
logger.warning("Bad password from peer %s", self.peer_name)
|
|
@@ -172,8 +188,10 @@ class ServerLink:
|
|
|
172
188
|
f"SNICK {client.nick} {client.user} {client.host} :{client.realname}"
|
|
173
189
|
)
|
|
174
190
|
|
|
175
|
-
# Send channel membership
|
|
191
|
+
# Send channel membership (filtered by trust)
|
|
176
192
|
for channel in self.server.channels.values():
|
|
193
|
+
if not self.should_relay(channel.name):
|
|
194
|
+
continue
|
|
177
195
|
local_nicks = [
|
|
178
196
|
m.nick for m in channel.members
|
|
179
197
|
if not isinstance(m, RemoteClient)
|
|
@@ -182,8 +200,10 @@ class ServerLink:
|
|
|
182
200
|
nicks_str = " ".join(local_nicks)
|
|
183
201
|
await self.send_raw(f"SJOIN {channel.name} {nicks_str}")
|
|
184
202
|
|
|
185
|
-
# Send channel topics
|
|
203
|
+
# Send channel topics (filtered by trust)
|
|
186
204
|
for channel in self.server.channels.values():
|
|
205
|
+
if not self.should_relay(channel.name):
|
|
206
|
+
continue
|
|
187
207
|
if channel.topic:
|
|
188
208
|
# Find who set it (use first local member as setter)
|
|
189
209
|
local_members = [
|
|
@@ -229,6 +249,19 @@ class ServerLink:
|
|
|
229
249
|
channel_name = msg.params[0]
|
|
230
250
|
nicks = msg.params[1:]
|
|
231
251
|
|
|
252
|
+
# Check incoming trust: if we have a restricted trust for this peer,
|
|
253
|
+
# only accept channel data for channels that have +S <peer>
|
|
254
|
+
existing = self.server.channels.get(channel_name)
|
|
255
|
+
if existing:
|
|
256
|
+
if existing.restricted:
|
|
257
|
+
return
|
|
258
|
+
if self.trust == "restricted" and self.peer_name not in existing.shared_with:
|
|
259
|
+
return
|
|
260
|
+
else:
|
|
261
|
+
# Channel doesn't exist locally yet — restricted links cannot create new channels
|
|
262
|
+
if self.trust == "restricted":
|
|
263
|
+
return
|
|
264
|
+
|
|
232
265
|
channel = self.server.get_or_create_channel(channel_name)
|
|
233
266
|
|
|
234
267
|
for nick in nicks:
|
|
@@ -255,6 +288,11 @@ class ServerLink:
|
|
|
255
288
|
|
|
256
289
|
channel = self.server.channels.get(channel_name)
|
|
257
290
|
if channel:
|
|
291
|
+
# Check incoming trust
|
|
292
|
+
if channel.restricted:
|
|
293
|
+
return
|
|
294
|
+
if self.trust == "restricted" and self.peer_name not in channel.shared_with:
|
|
295
|
+
return
|
|
258
296
|
channel.topic = topic
|
|
259
297
|
|
|
260
298
|
# --- Real-time relay handlers (incoming from peer) ---
|
|
@@ -267,6 +305,15 @@ class ServerLink:
|
|
|
267
305
|
sender_nick = msg.params[1]
|
|
268
306
|
text = msg.params[2]
|
|
269
307
|
|
|
308
|
+
# Check incoming trust for channel messages
|
|
309
|
+
if target.startswith("#"):
|
|
310
|
+
channel = self.server.channels.get(target)
|
|
311
|
+
if channel:
|
|
312
|
+
if channel.restricted:
|
|
313
|
+
return
|
|
314
|
+
if self.trust == "restricted" and self.peer_name not in channel.shared_with:
|
|
315
|
+
return
|
|
316
|
+
|
|
270
317
|
relay = Message(
|
|
271
318
|
prefix=f"{sender_nick}!*@*",
|
|
272
319
|
command="PRIVMSG",
|
|
@@ -322,6 +369,15 @@ class ServerLink:
|
|
|
322
369
|
sender_nick = msg.params[1]
|
|
323
370
|
text = msg.params[2]
|
|
324
371
|
|
|
372
|
+
# Check incoming trust for channel notices
|
|
373
|
+
if target.startswith("#"):
|
|
374
|
+
channel = self.server.channels.get(target)
|
|
375
|
+
if channel:
|
|
376
|
+
if channel.restricted:
|
|
377
|
+
return
|
|
378
|
+
if self.trust == "restricted" and self.peer_name not in channel.shared_with:
|
|
379
|
+
return
|
|
380
|
+
|
|
325
381
|
rc = self.server.remote_clients.get(sender_nick)
|
|
326
382
|
prefix = rc.prefix if rc else f"{sender_nick}!*@*"
|
|
327
383
|
relay = Message(prefix=prefix, command="NOTICE", params=[target, text])
|
|
@@ -361,6 +417,12 @@ class ServerLink:
|
|
|
361
417
|
if not channel:
|
|
362
418
|
return
|
|
363
419
|
|
|
420
|
+
# Check incoming trust
|
|
421
|
+
if channel.restricted:
|
|
422
|
+
return
|
|
423
|
+
if self.trust == "restricted" and self.peer_name not in channel.shared_with:
|
|
424
|
+
return
|
|
425
|
+
|
|
364
426
|
# Notify local members
|
|
365
427
|
part_params = [channel_name, reason] if reason else [channel_name]
|
|
366
428
|
part_msg = Message(prefix=rc.prefix, command="PART", params=part_params)
|
|
@@ -454,6 +516,9 @@ class ServerLink:
|
|
|
454
516
|
if event.type == EventType.MESSAGE:
|
|
455
517
|
target = event.channel or event.data.get("target", "")
|
|
456
518
|
text = event.data.get("text", "")
|
|
519
|
+
# Filter channel messages through trust check
|
|
520
|
+
if target.startswith("#") and not self.should_relay(target):
|
|
521
|
+
return
|
|
457
522
|
cmd = event.data.get("notice") and "SNOTICE" or "SMSG"
|
|
458
523
|
await self.send_raw(
|
|
459
524
|
f":{origin} {cmd} {target} {event.nick} :{text}"
|
|
@@ -469,6 +534,9 @@ class ServerLink:
|
|
|
469
534
|
if event.type == EventType.MESSAGE:
|
|
470
535
|
target = event.channel or event.data.get("target", "")
|
|
471
536
|
text = event.data.get("text", "")
|
|
537
|
+
# Filter channel messages through trust check
|
|
538
|
+
if target.startswith("#") and not self.should_relay(target):
|
|
539
|
+
return
|
|
472
540
|
if event.data.get("notice"):
|
|
473
541
|
await self.send_raw(
|
|
474
542
|
f":{origin} SNOTICE {target} {event.nick} :{text}"
|
|
@@ -479,11 +547,15 @@ class ServerLink:
|
|
|
479
547
|
)
|
|
480
548
|
elif event.type == EventType.JOIN:
|
|
481
549
|
channel_name = event.channel
|
|
550
|
+
if not self.should_relay(channel_name):
|
|
551
|
+
return
|
|
482
552
|
await self.send_raw(
|
|
483
553
|
f":{origin} SJOIN {channel_name} {event.nick}"
|
|
484
554
|
)
|
|
485
555
|
elif event.type == EventType.PART:
|
|
486
556
|
channel_name = event.channel
|
|
557
|
+
if not self.should_relay(channel_name):
|
|
558
|
+
return
|
|
487
559
|
reason = event.data.get("reason", "")
|
|
488
560
|
await self.send_raw(
|
|
489
561
|
f":{origin} SPART {channel_name} {event.nick} :{reason}"
|
|
@@ -495,6 +567,8 @@ class ServerLink:
|
|
|
495
567
|
)
|
|
496
568
|
elif event.type == EventType.TOPIC:
|
|
497
569
|
channel_name = event.channel
|
|
570
|
+
if not self.should_relay(channel_name):
|
|
571
|
+
return
|
|
498
572
|
topic = event.data.get("topic", "")
|
|
499
573
|
await self.send_raw(
|
|
500
574
|
f":{origin} STOPIC {channel_name} {event.nick} :{topic}"
|
|
@@ -63,22 +63,57 @@ agentirc server start --name thor --port 6668 --link spark:localhost:6667:secret
|
|
|
63
63
|
|
|
64
64
|
### Link Format
|
|
65
65
|
|
|
66
|
+
```text
|
|
67
|
+
--link name:host:port:password[:trust]
|
|
66
68
|
```
|
|
67
|
-
|
|
69
|
+
|
|
70
|
+
Trust is `full` (default) or `restricted`:
|
|
71
|
+
|
|
72
|
+
- **full** — share all channels (except `+R` restricted ones). Use for trusted
|
|
73
|
+
home mesh servers.
|
|
74
|
+
- **restricted** — share nothing unless both sides explicitly agree with `+S`.
|
|
75
|
+
Use for external or public servers.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Home mesh — full trust (default)
|
|
79
|
+
agentirc server start --name spark --port 6667 --link thor:machineB:6667:secret
|
|
80
|
+
|
|
81
|
+
# Public server — restricted trust
|
|
82
|
+
agentirc server start --name spark --port 6667 --link public:example.com:6667:pubpass:restricted
|
|
68
83
|
```
|
|
69
84
|
|
|
85
|
+
### Channel Federation Modes
|
|
86
|
+
|
|
87
|
+
| Mode | Meaning |
|
|
88
|
+
|------|---------|
|
|
89
|
+
| `+R` | Restricted — channel stays local, never shared (even on full links) |
|
|
90
|
+
| `+S <server>` | Shared — share this channel with the named server |
|
|
91
|
+
| `-R` | Remove restricted flag |
|
|
92
|
+
| `-S <server>` | Stop sharing with server |
|
|
93
|
+
|
|
94
|
+
Examples:
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
MODE #internal +R # keep this channel local
|
|
98
|
+
MODE #collab +S public-server # share with public-server
|
|
99
|
+
MODE #collab -S public-server # stop sharing
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
For restricted links, **both sides** must set `+S` for a channel to sync.
|
|
103
|
+
This prevents one server from unilaterally pulling channels from another.
|
|
104
|
+
|
|
70
105
|
### Programmatic
|
|
71
106
|
|
|
72
107
|
```python
|
|
73
|
-
await server_a.connect_to_peer("localhost", 6668, "shared_secret")
|
|
108
|
+
await server_a.connect_to_peer("localhost", 6668, "shared_secret", trust="full")
|
|
74
109
|
```
|
|
75
110
|
|
|
76
111
|
## What Syncs
|
|
77
112
|
|
|
78
113
|
- Client presence (SNICK on registration and burst)
|
|
79
|
-
- Channel membership (SJOIN/SPART)
|
|
80
|
-
- Messages (SMSG/SNOTICE)
|
|
81
|
-
- Topics (STOPIC)
|
|
114
|
+
- Channel membership (SJOIN/SPART) — filtered by trust and channel modes
|
|
115
|
+
- Messages (SMSG/SNOTICE) — filtered by trust and channel modes
|
|
116
|
+
- Topics (STOPIC) — filtered by trust and channel modes
|
|
82
117
|
- Client disconnects (SQUITUSER)
|
|
83
118
|
- @mention notifications across servers
|
|
84
119
|
|
|
@@ -87,6 +122,7 @@ await server_a.connect_to_peer("localhost", 6668, "shared_secret")
|
|
|
87
122
|
- Authentication
|
|
88
123
|
- Skills data (populated independently via synced events)
|
|
89
124
|
- Channel modes/operators (local authority only)
|
|
125
|
+
- Channels marked `+R` (restricted)
|
|
90
126
|
|
|
91
127
|
## Wire Protocol
|
|
92
128
|
|