From 733ac964ca75339dbae33259a1643137d5f58cbf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 02:06:15 +0000 Subject: [PATCH 1/2] Add allow-listed chat channels and LAN device control. Telegram, Discord, and Slack tokens stay in the keyring or an encrypted file. Device actions stay on private addresses, ask for approval, and only run when they are on that device's allow-list. Co-authored-by: Admin --- NOTICE | 20 ++ src/channels/__init__.py | 10 + src/channels/parse.py | 69 +++++ src/channels/service.py | 283 +++++++++++++++++++++ src/channels/transport.py | 56 ++++ src/cli.py | 63 +++++ src/command_system/__init__.py | 4 + src/command_system/builtins.py | 41 +++ src/desktop/runtime.py | 40 +++ src/desktop/server.py | 23 ++ src/desktop/web/app.js | 82 ++++++ src/desktop/web/index.html | 64 +++++ src/desktop/web/styles.css | 2 +- src/devices/__init__.py | 9 + src/devices/control.py | 176 +++++++++++++ src/devices/discover.py | 219 ++++++++++++++++ src/devices/lan.py | 47 ++++ src/devices/registry.py | 174 +++++++++++++ src/devices/service.py | 79 ++++++ src/secret_mask.py | 2 + src/secret_store.py | 146 +++++++++++ src/security/shell_guard_rules.py | 2 +- src/tool_system/context.py | 4 +- src/tool_system/defaults.py | 2 + src/tool_system/permissions.py | 1 + src/tool_system/tools/__init__.py | 2 + src/tool_system/tools/device.py | 116 +++++++++ tests/test_channels_devices.py | 407 ++++++++++++++++++++++++++++++ 28 files changed, 2140 insertions(+), 3 deletions(-) create mode 100644 src/channels/__init__.py create mode 100644 src/channels/parse.py create mode 100644 src/channels/service.py create mode 100644 src/channels/transport.py create mode 100644 src/devices/__init__.py create mode 100644 src/devices/control.py create mode 100644 src/devices/discover.py create mode 100644 src/devices/lan.py create mode 100644 src/devices/registry.py create mode 100644 src/devices/service.py create mode 100644 src/secret_store.py create mode 100644 src/tool_system/tools/device.py create mode 100644 tests/test_channels_devices.py diff --git a/NOTICE b/NOTICE index c186441..1906276 100644 --- a/NOTICE +++ b/NOTICE @@ -118,3 +118,23 @@ Octop ACP runner https://github.com/TencentCloud/Octop Copyright (c) 2026 Octop Licensed under the MIT License. + +Jonathan Ai chat channels and LAN device control follow Octop's gateway: +one inbound message is normalized, checked against an allow-list, and +answered on the same channel. Jonathan Ai implements Telegram, Discord, +and Slack only. Bot tokens are stored in the OS keyring when it is +available, otherwise in an encrypted file on this machine, and they are +not written into settings or logs. Device control is limited to MQTT, +Home Assistant REST, generic HTTP, and SSH targets the user registers. +Discovery reads the ARP table and can send one mDNS query and one SSDP +search. Every device action asks for approval, and each device has its +own allow-list of actions. Only loopback, private, and link-local +addresses are accepted. Jonathan Ai does not include the upstream +channels for other regions, does not import the octop-gateway package, +and does not call a third-party host for the protocol itself. + +Octop gateway +https://github.com/TencentCloud/Octop +https://github.com/TencentCloud/octop-gateway +Copyright (c) 2026 Octop +Licensed under the MIT License. diff --git a/src/channels/__init__.py b/src/channels/__init__.py new file mode 100644 index 0000000..63966b8 --- /dev/null +++ b/src/channels/__init__.py @@ -0,0 +1,10 @@ +"""Local chat-channel bridge for Telegram, Discord, and Slack. + +The shape follows Octop's gateway: one inbound message, an allow-list, and a +reply sent back on the same channel. Jonathan Ai does not include the +upstream CN channels and does not import octop-gateway. +""" + +from src.channels.service import format_channels_command, route_inbound + +__all__ = ["format_channels_command", "route_inbound"] diff --git a/src/channels/parse.py b/src/channels/parse.py new file mode 100644 index 0000000..c7b6e36 --- /dev/null +++ b/src/channels/parse.py @@ -0,0 +1,69 @@ +"""Turn Telegram, Discord, and Slack payloads into plain inbound messages. + +Only these three channels are supported. Payloads are data, not instructions. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Inbound: + channel: str + user_id: str + text: str + reply_to: str + + +def parse_telegram_updates(payload: object) -> list[Inbound]: + if not isinstance(payload, dict): + return [] + found: list[Inbound] = [] + for update in payload.get("result") or []: + if not isinstance(update, dict): + continue + message = update.get("message") or update.get("edited_message") + if not isinstance(message, dict): + continue + sender = message.get("from") if isinstance(message.get("from"), dict) else {} + chat = message.get("chat") if isinstance(message.get("chat"), dict) else {} + user_id = str(sender.get("id") or "").strip() + reply_to = str(chat.get("id") or user_id).strip() + text = str(message.get("text") or message.get("caption") or "").strip() + if user_id and text: + found.append(Inbound("telegram", user_id, text, reply_to)) + return found + + +def parse_discord_message(payload: object) -> Inbound | None: + if not isinstance(payload, dict): + return None + event = payload.get("d") if payload.get("t") == "MESSAGE_CREATE" and isinstance(payload.get("d"), dict) else payload + if not isinstance(event, dict): + return None + author = event.get("author") if isinstance(event.get("author"), dict) else {} + user_id = str(author.get("id") or "").strip() + text = str(event.get("content") or "").strip() + reply_to = str(event.get("channel_id") or "").strip() + if not user_id or not text or not reply_to: + return None + if author.get("bot") is True: + return None + return Inbound("discord", user_id, text, reply_to) + + +def parse_slack_event(payload: object) -> Inbound | None: + if not isinstance(payload, dict): + return None + event = payload.get("event") if isinstance(payload.get("event"), dict) else payload + if not isinstance(event, dict): + return None + if event.get("bot_id") or event.get("subtype"): + return None + user_id = str(event.get("user") or "").strip() + text = str(event.get("text") or "").strip() + reply_to = str(event.get("channel") or "").strip() + if not user_id or not text or not reply_to: + return None + return Inbound("slack", user_id, text, reply_to) diff --git a/src/channels/service.py b/src/channels/service.py new file mode 100644 index 0000000..d42aa04 --- /dev/null +++ b/src/channels/service.py @@ -0,0 +1,283 @@ +"""Per-user chat channels: Telegram, Discord, and Slack. + +Inbound messages reach Jonathan only when the channel is enabled and the +sender is on that channel's allow-list. An empty allow-list drops everyone. +Bot tokens stay in the OS keyring or an encrypted file. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +from pathlib import Path + +from src.agent.memory import current_user_id +from src.channels.parse import Inbound, parse_discord_message, parse_slack_event, parse_telegram_updates +from src.channels.transport import OFFICIAL_HOSTS, Transport, official_url, urllib_transport +from src.secret_mask import mask_secrets +from src.secret_store import get_secret, has_secret, put_secret + +logger = logging.getLogger(__name__) + +CHANNEL_IDS = ("telegram", "discord", "slack") +CHANNEL_LABELS = { + "telegram": "Telegram", + "discord": "Discord", + "slack": "Slack", +} +_USAGE = ( + "Usage: /channels list\n" + "Enable Telegram, Discord, or Slack in settings, store your own bot token, " + "and list the user IDs allowed to message Jonathan. An empty list drops everyone." +) + + +def channels_root(directory: Path | None = None) -> Path: + if directory is not None: + path = directory + else: + raw = os.environ.get("JONATHAN_CHANNELS_DIR", "").strip() + path = Path(raw) if raw else Path.home() / ".clawd" / "channels" + path.mkdir(parents=True, exist_ok=True) + try: + path.chmod(0o700) + except OSError: + pass + return path + + +def _settings_path(directory: Path | None, user_id: str | None) -> Path: + user = user_id or current_user_id() + return channels_root(directory) / f"settings-{user}.json" + + +def _split_ids(value: object) -> list[str]: + if isinstance(value, str): + parts = re.split(r"[,\s]+", value.strip()) if value.strip() else [] + elif isinstance(value, list): + parts = [str(item).strip() for item in value] + else: + raise ValueError("allow list must be user IDs") + found: list[str] = [] + for part in parts: + if not part: + continue + if any(char in part for char in "\n\r\x00"): + raise ValueError("user id is not valid") + if part not in found: + found.append(part) + return found + + +def load_channels(directory: Path | None = None, *, user_id: str | None = None) -> dict[str, dict]: + user = user_id or current_user_id() + stored: dict = {} + path = _settings_path(directory, user) + if path.is_file(): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + raw = {} + if isinstance(raw, dict) and isinstance(raw.get("channels"), dict): + stored = raw["channels"] + channels: dict[str, dict] = {} + for name in CHANNEL_IDS: + item = stored.get(name) if isinstance(stored.get(name), dict) else {} + channels[name] = { + "enabled": bool(item.get("enabled")), + "allow_user_ids": _split_ids(item.get("allow_user_ids") or []), + } + return channels + + +def _write_channels(channels: dict[str, dict], directory: Path | None, user_id: str) -> None: + path = _settings_path(directory, user_id) + payload = { + "channels": { + name: { + "enabled": bool(channels[name]["enabled"]), + "allow_user_ids": list(channels[name]["allow_user_ids"]), + } + for name in CHANNEL_IDS + } + } + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + try: + path.chmod(0o600) + except OSError: + return + + +def public_channels( + directory: Path | None = None, + *, + user_id: str | None = None, + backend: object | None = None, +) -> dict[str, object]: + user = user_id or current_user_id() + root = channels_root(directory) + channels = load_channels(root, user_id=user) + return { + "channels": [ + { + "id": name, + "label": CHANNEL_LABELS[name], + "enabled": channels[name]["enabled"], + "allow_user_ids": list(channels[name]["allow_user_ids"]), + "token_set": has_secret(root, user, f"channel-{name}", backend=backend), + } + for name in CHANNEL_IDS + ] + } + + +def update_channels( + changes: object, + directory: Path | None = None, + *, + user_id: str | None = None, + backend: object | None = None, +) -> dict[str, object]: + rows = changes + if isinstance(changes, dict): + rows = changes.get("channels", changes) + if isinstance(rows, dict): + rows = [{"id": key, **value} if isinstance(value, dict) else {"id": key} for key, value in rows.items()] + if not isinstance(rows, list): + raise ValueError("channels must be a list") + user = user_id or current_user_id() + root = channels_root(directory) + channels = load_channels(root, user_id=user) + for item in rows: + if not isinstance(item, dict): + raise ValueError("each channel must be an object") + name = str(item.get("id") or "").strip() + if name not in channels: + raise ValueError(f"unknown channel: {name}") + if "enabled" in item: + channels[name]["enabled"] = bool(item.get("enabled")) + if "allow_user_ids" in item or "allow" in item: + channels[name]["allow_user_ids"] = _split_ids(item.get("allow_user_ids", item.get("allow"))) + token = item.get("token") + if isinstance(token, str) and token.strip(): + put_secret(root, user, f"channel-{name}", token, backend=backend) + _write_channels(channels, root, user) + logger.info("Updated chat channel settings for %s channel(s)", len(rows)) + return public_channels(root, user_id=user, backend=backend) + + +def sender_allowed(channel: str, user_id: str, directory: Path | None = None, *, user_id_owner: str | None = None) -> bool: + name = channel.strip().lower() + channels = load_channels(directory, user_id=user_id_owner) + row = channels.get(name) + if row is None or not row["enabled"]: + return False + sender = str(user_id).strip() + return bool(sender) and sender in row["allow_user_ids"] + + +def route_inbound( + inbound: Inbound, + reply_fn, + *, + directory: Path | None = None, + user_id: str | None = None, + backend: object | None = None, + transport: Transport | None = None, +) -> dict[str, object]: + """Route one allow-listed message to Jonathan and send the reply back.""" + owner = user_id or current_user_id() + root = channels_root(directory) + if not sender_allowed(inbound.channel, inbound.user_id, root, user_id_owner=owner): + logger.info("Dropped %s message from a sender who is not allow-listed", inbound.channel) + return {"accepted": False, "reply": ""} + reply = mask_secrets(str(reply_fn(inbound.text) or "")) + token = get_secret(root, owner, f"channel-{inbound.channel}", backend=backend) + sent = False + if token and inbound.reply_to: + poster = transport or urllib_transport + _send(inbound.channel, token, inbound.reply_to, reply, poster) + sent = True + logger.info("Sent %s reply", inbound.channel) + return {"accepted": True, "reply": reply, "sent": sent} + + +def poll_telegram( + token: str, + *, + offset: int = 0, + transport: Transport | None = None, +) -> list[Inbound]: + poster = transport or urllib_transport + url = official_url("telegram", f"https://api.telegram.org/bot{token}/getUpdates") + payload = poster("POST", url, {}, {"offset": offset, "timeout": 0}) + return parse_telegram_updates(payload) + + +def _send(channel: str, token: str, reply_to: str, text: str, transport: Transport) -> None: + if channel == "telegram": + url = official_url("telegram", f"https://api.telegram.org/bot{token}/sendMessage") + transport("POST", url, {}, {"chat_id": reply_to, "text": text}) + return + if channel == "discord": + url = official_url("discord", f"https://discord.com/api/v10/channels/{reply_to}/messages") + transport("POST", url, {"Authorization": f"Bot {token}"}, {"content": text}) + return + if channel == "slack": + url = official_url("slack", "https://slack.com/api/chat.postMessage") + transport("POST", url, {"Authorization": f"Bearer {token}"}, {"channel": reply_to, "text": text}) + return + raise ValueError(f"unknown channel: {channel}") + + +def format_channels_command(args: str, *, directory: Path | None = None, user_id: str | None = None) -> str: + parts = args.split() + if parts and parts[0] not in {"list", "help"}: + return _USAGE + view = public_channels(directory, user_id=user_id) + lines = ["Chat channels:"] + for row in view["channels"]: + state = "enabled" if row["enabled"] else "disabled" + token = "token set" if row["token_set"] else "no token" + allow = ", ".join(row["allow_user_ids"]) or "allow-list empty" + lines.append(f"{row['id']} {row['label']} {state} {token} {allow}") + lines.append("Empty allow-lists drop every inbound message.") + return "\n".join(lines) + + +def ingest_payload( + channel: str, + payload: object, + reply_fn, + *, + directory: Path | None = None, + user_id: str | None = None, + backend: object | None = None, + transport: Transport | None = None, +) -> list[dict[str, object]]: + """Parse a platform payload and route each message. Used by tests and polls.""" + name = channel.strip().lower() + messages: list[Inbound] = [] + if name == "telegram": + messages = parse_telegram_updates(payload) + elif name == "discord": + one = parse_discord_message(payload) + messages = [one] if one else [] + elif name == "slack": + one = parse_slack_event(payload) + messages = [one] if one else [] + else: + raise ValueError(f"unknown channel: {name}") + return [ + route_inbound( + message, + reply_fn, + directory=directory, + user_id=user_id, + backend=backend, + transport=transport, + ) + for message in messages + ] diff --git a/src/channels/transport.py b/src/channels/transport.py new file mode 100644 index 0000000..819d122 --- /dev/null +++ b/src/channels/transport.py @@ -0,0 +1,56 @@ +"""HTTP calls for the three official bot APIs. The token is not logged.""" + +from __future__ import annotations + +import json +from typing import Callable +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +from src.secret_mask import mask_secrets + +OFFICIAL_HOSTS = { + "telegram": "api.telegram.org", + "discord": "discord.com", + "slack": "slack.com", +} + +Transport = Callable[[str, str, dict[str, str], dict], object] + + +def official_url(channel: str, url: str) -> str: + expected = OFFICIAL_HOSTS.get(channel) + host = (urlparse(url).hostname or "").lower() + if expected is None or host != expected: + raise ValueError(f"refusing unofficial {channel} host") + return url + + +def urllib_transport(method: str, url: str, headers: dict[str, str], payload: dict) -> object: + """POST JSON and return the decoded body. The URL is not logged.""" + channel = _channel_for_url(url) + official_url(channel, url) + req_headers = {"Content-Type": "application/json", "User-Agent": "jonathan-ai"} + req_headers.update(headers) + data = json.dumps(payload).encode("utf-8") + request = Request(url, data=data, headers=req_headers, method=method.upper()) + try: + with urlopen(request, timeout=20) as response: + raw = response.read().decode("utf-8", errors="replace") + except (HTTPError, URLError, TimeoutError, OSError) as exc: + raise RuntimeError(mask_secrets(f"{channel} request failed: {exc}")) from None + if not raw.strip(): + return {} + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{channel} returned a response that was not JSON") from exc + + +def _channel_for_url(url: str) -> str: + host = (urlparse(url).hostname or "").lower() + for channel, expected in OFFICIAL_HOSTS.items(): + if host == expected: + return channel + raise ValueError("refusing unofficial channel host") diff --git a/src/cli.py b/src/cli.py index 99fd884..88dfcbf 100644 --- a/src/cli.py +++ b/src/cli.py @@ -39,6 +39,9 @@ def main(): clawd kb search Search local documents and cite the passage clawd acp list List local ACP coding agents clawd acp run + clawd channels list List Telegram, Discord, and Slack + clawd devices list List registered LAN devices + clawd devices discover Read the local ARP table clawd Start interactive REPL """ ) @@ -132,6 +135,19 @@ def main(): acp_run.add_argument('folder', help='Existing project folder') acp_run.add_argument('task', nargs='+', help='Coding task to delegate') + channels_parser = subparsers.add_parser('channels', help='List Telegram, Discord, and Slack settings') + channels_sub = channels_parser.add_subparsers(dest='channels_command') + channels_sub.add_parser('list', help='List chat channels for this user') + + devices_parser = subparsers.add_parser('devices', help='List, discover, or control LAN devices') + devices_sub = devices_parser.add_subparsers(dest='devices_command') + devices_sub.add_parser('list', help='List registered devices') + devices_discover = devices_sub.add_parser('discover', help='Read the ARP table') + devices_discover.add_argument('--probe', action='store_true', help='Also send one mDNS query and one SSDP search') + devices_do = devices_sub.add_parser('do', help='Run one allow-listed action') + devices_do.add_argument('device_id') + devices_do.add_argument('action') + args = parser.parse_args() # Handle --version @@ -215,6 +231,10 @@ def main(): return handle_kb(args) elif args.command == 'acp': return handle_acp(args) + elif args.command == 'channels': + return handle_channels(args) + elif args.command == 'devices': + return handle_devices(args) # Default: start REPL return start_repl(stream=args.stream) @@ -341,6 +361,49 @@ def _print_progress(line: str) -> None: return 1 if result.is_error else 0 +def handle_channels(args) -> int: + """List chat channels. Tokens are not printed.""" + from src.channels.service import format_channels_command + + action = getattr(args, "channels_command", None) or "list" + print(format_channels_command(action if action == "list" else "list")) + return 0 + + +def handle_devices(args) -> int: + """List or discover LAN devices, or run one allow-listed action.""" + from src.devices.control import approval_key, perform_action + from src.devices.lan import WanTargetError + from src.devices.registry import get_device + from src.devices.service import format_devices_command + from src.security.shell_guard import enforce_shell_command + from src.tool_system.context import ToolContext + from src.tool_system.errors import ToolPermissionError + + action = getattr(args, "devices_command", None) or "list" + if action == "discover": + print(format_devices_command("discover probe" if args.probe else "discover")) + return 0 + if action != "do": + print(format_devices_command("list")) + return 0 + try: + from src.agent.memory import current_user_id + + device = get_device(args.device_id) + key = approval_key(device["id"], args.action) + context = ToolContext(workspace_root=Path.cwd()) + enforce_shell_command("Device", key, context) + if args.action not in list(device.get("actions") or []): + print(f"action {args.action!r} is not on the allow-list") + return 1 + print(perform_action(device, args.action, directory=None, user_id=current_user_id())) + except (ValueError, WanTargetError, ToolPermissionError, OSError, RuntimeError) as exc: + print(str(exc)) + return 1 + return 0 + + def handle_cron(args) -> int: """List, run, enable, disable, or delete schedules stored on this machine.""" from src.scheduler.service import format_cron_command diff --git a/src/command_system/__init__.py b/src/command_system/__init__.py index 220297f..4a232cb 100644 --- a/src/command_system/__init__.py +++ b/src/command_system/__init__.py @@ -13,7 +13,9 @@ EXIT_COMMAND, HELP_COMMAND, ACP_COMMAND, + CHANNELS_COMMAND, CRON_COMMAND, + DEVICES_COMMAND, INIT_COMMAND, KB_COMMAND, MEMORY_COMMAND, @@ -96,7 +98,9 @@ "CONTEXT_COMMAND", "COMPACT_COMMAND", "ACP_COMMAND", + "CHANNELS_COMMAND", "CRON_COMMAND", + "DEVICES_COMMAND", "KB_COMMAND", "MEMORY_COMMAND", "VOICE_COMMAND", diff --git a/src/command_system/builtins.py b/src/command_system/builtins.py index a7d2db5..a08df97 100644 --- a/src/command_system/builtins.py +++ b/src/command_system/builtins.py @@ -684,6 +684,39 @@ def acp_command_call(args: str, context: CommandContext) -> LocalCommandResult: return LocalCommandResult(type="text", value=format_acp_command(args)) + +CHANNELS_COMMAND = LocalCommand( + name="channels", + description="List Telegram, Discord, and Slack channel settings", + argument_hint="list", + supports_non_interactive=True, +) + + +def channels_command_call(args: str, context: CommandContext) -> LocalCommandResult: + """Show which chat channels are enabled. Tokens are not printed.""" + del context + from ..channels.service import format_channels_command + + return LocalCommandResult(type="text", value=format_channels_command(args)) + + +DEVICES_COMMAND = LocalCommand( + name="devices", + description="List, discover, or control allow-listed LAN devices", + argument_hint="list | discover [probe] | do ", + supports_non_interactive=True, +) + + +def devices_command_call(args: str, context: CommandContext) -> LocalCommandResult: + """Discover LAN devices or describe a registered one. Control asks for approval in chat.""" + del context + from ..devices.service import format_devices_command + + return LocalCommandResult(type="text", value=format_devices_command(args)) + + INIT_COMMAND = PromptCommand( name="init", description="Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation", @@ -738,6 +771,10 @@ def execute_command_sync(cmd_name: str, args: str, context: CommandContext) -> t result = kb_command_call(args, context) elif cmd is ACP_COMMAND: result = acp_command_call(args, context) + elif cmd is CHANNELS_COMMAND: + result = channels_command_call(args, context) + elif cmd is DEVICES_COMMAND: + result = devices_command_call(args, context) else: return False, None, f"Command not implemented for sync execution: {cmd_name}" @@ -759,6 +796,8 @@ def execute_command_sync(cmd_name: str, args: str, context: CommandContext) -> t VOICE_COMMAND.set_call(voice_command_call) KB_COMMAND.set_call(kb_command_call) ACP_COMMAND.set_call(acp_command_call) +CHANNELS_COMMAND.set_call(channels_command_call) +DEVICES_COMMAND.set_call(devices_command_call) def get_builtin_commands() -> list[Command]: @@ -776,6 +815,8 @@ def get_builtin_commands() -> list[Command]: VOICE_COMMAND, KB_COMMAND, ACP_COMMAND, + CHANNELS_COMMAND, + DEVICES_COMMAND, INIT_COMMAND, ] diff --git a/src/desktop/runtime.py b/src/desktop/runtime.py index 7961f6c..16f3857 100644 --- a/src/desktop/runtime.py +++ b/src/desktop/runtime.py @@ -1068,6 +1068,41 @@ def on_acp_progress(line: str) -> None: return self._finish(job, {"type": "done", "text": text, "kind": "command"}) + def channel_inbound(self, body: dict[str, Any]) -> dict[str, Any]: + """Route one local inbound payload. The reply stays on this machine.""" + from src.channels.parse import Inbound + from src.channels.service import route_inbound + + channel = str(body.get("channel") or "").strip().lower() + user_id = str(body.get("user_id") or "").strip() + text = str(body.get("text") or "").strip() + reply_to = str(body.get("reply_to") or user_id).strip() + if channel not in {"telegram", "discord", "slack"}: + raise ValueError("unknown channel") + if not user_id or not text: + raise ValueError("user_id and text are required") + + def reply(message: str) -> str: + return f"Jonathan Ai: {message}" + + return route_inbound(Inbound(channel, user_id, text, reply_to), reply) + + def _run_device_slash(self, job: ChatJob, args: str) -> None: + parts = args.split(maxsplit=2) + if len(parts) < 3 or parts[0] != "do": + self._finish(job, {"type": "error", "error": "Usage: /devices do "}) + return + result = self.tool_registry.dispatch( + ToolCall(name="Device", input={"device": parts[1], "action": parts[2]}), + self.tool_context, + ) + output = result.output if isinstance(result.output, dict) else {"text": str(result.output or "")} + text = str(output.get("text") or output.get("error") or "") + if result.is_error: + self._finish(job, {"type": "error", "error": text or "device action failed"}) + return + self._finish(job, {"type": "done", "text": text, "kind": "command"}) + def _handle_slash(self, job: ChatJob, raw: str) -> bool: if raw == "/": self._finish(job, { @@ -1134,6 +1169,9 @@ def _handle_slash(self, job: ChatJob, raw: str) -> bool: if cmd == "acp": self._run_acp_slash(job, args) return True + if cmd == "devices" and args.split(maxsplit=1)[0:1] == ["do"]: + self._run_device_slash(job, args) + return True if cmd == "tool": tool_parts = args.split(maxsplit=1) if not tool_parts: @@ -1235,6 +1273,8 @@ def _format_palette(self) -> str: - `/voice` — local speech and speakers (`/voice settings`, `/voice say hello`) - `/kb` — add, list, remove, or search local documents (`/kb search `) - `/acp` — list local coding agents, or `/acp run ` +- `/channels` — list Telegram, Discord, and Slack (`/channels list`) +- `/devices` — list or discover LAN devices, or `/devices do ` Type a message to run the existing agent loop. Destructive and network tools prompt for approval before they run. diff --git a/src/desktop/server.py b/src/desktop/server.py index 0f1ff37..cfc2f27 100644 --- a/src/desktop/server.py +++ b/src/desktop/server.py @@ -259,6 +259,29 @@ def handle_api(self, method: str, path: str, query: dict[str, list[str]], body: from src.acp.runners import update_runners return 200, update_runners(body.get("runners", body)) + if path == "/api/channels" and method == "GET": + from src.channels.service import public_channels + + return 200, public_channels() + if path == "/api/channels" and method == "POST": + from src.channels.service import update_channels + + return 200, update_channels(body.get("channels", body)) + if path == "/api/channels/inbound" and method == "POST": + return 200, runtime.channel_inbound(body) + if path == "/api/devices" and method == "GET": + from src.devices.registry import public_devices + + return 200, public_devices() + if path == "/api/devices" and method == "POST": + from src.devices.registry import update_devices + + return 200, update_devices(body.get("devices", body)) + if path == "/api/devices/discover" and method == "GET": + from src.devices.service import format_discovery + + probe = (query.get("probe") or ["0"])[0] in {"1", "true"} + return 200, {"text": format_discovery(probe=probe)} return 404, {"error": f"unknown route: {method} {path}"} def serve_forever(self) -> None: diff --git a/src/desktop/web/app.js b/src/desktop/web/app.js index 48258d4..aaad19c 100644 --- a/src/desktop/web/app.js +++ b/src/desktop/web/app.js @@ -698,6 +698,8 @@ function applyRoute() { loadVoiceSettings().catch(reportError); loadKnowledgeSettings().catch(reportError); loadAcpSettings().catch(reportError); + loadChannelSettings().catch(reportError); + loadDeviceSettings().catch(reportError); } } @@ -732,6 +734,84 @@ async function saveAcpSettings() { } } +const CHANNEL_IDS = ["telegram", "discord", "slack"]; + +async function loadChannelSettings() { + const data = await api("/api/channels"); + for (const row of data.channels || []) { + const enabled = $(`channels-${row.id}-enabled`); + const allow = $(`channels-${row.id}-allow`); + if (enabled) enabled.checked = Boolean(row.enabled); + if (allow && document.activeElement !== allow) allow.value = (row.allow_user_ids || []).join(", "); + } + const status = $("channels-status"); + if (status && !status.dataset.saved) { + status.textContent = "Tokens stay in the keyring or an encrypted file. They are not shown here."; + } +} + +async function saveChannelSettings() { + const channels = CHANNEL_IDS.map((id) => ({ + id, + enabled: Boolean($(`channels-${id}-enabled`)?.checked), + allow_user_ids: $(`channels-${id}-allow`)?.value || "", + token: $(`channels-${id}-token`)?.value || "", + })); + const data = await api("/api/channels", { method: "POST", body: JSON.stringify({ channels }) }); + for (const id of CHANNEL_IDS) { + const token = $(`channels-${id}-token`); + if (token) token.value = ""; + } + const status = $("channels-status"); + if (status) { + status.dataset.saved = "1"; + const set = (data.channels || []).filter((row) => row.token_set).length; + status.textContent = `Saved ${(data.channels || []).length} channels. ${set} token(s) stored.`; + } +} + +async function loadDeviceSettings() { + const data = await api("/api/devices"); + const box = $("devices-list"); + if (!box) return; + box.innerHTML = ""; + const devices = data.devices || []; + if (!devices.length) return; + for (const device of devices) { + const row = document.createElement("p"); + row.className = "muted"; + const actions = (device.actions || []).join(", ") || "no actions"; + row.textContent = `${device.id} · ${device.kind} · ${device.host}:${device.port} · ${actions}`; + box.appendChild(row); + } +} + +async function saveDeviceSettings() { + const id = ($("devices-id")?.value || "").trim(); + if (!id) { + const status = $("devices-status"); + if (status) status.textContent = "Enter a device id to save one."; + return; + } + const payload = { + devices: [{ + id, + kind: $("devices-kind")?.value || "http", + host: $("devices-host")?.value || "", + port: $("devices-port")?.value || "", + user: $("devices-user")?.value || "", + actions: $("devices-actions")?.value || "", + secret: $("devices-secret")?.value || "", + }], + }; + await api("/api/devices", { method: "POST", body: JSON.stringify(payload) }); + const secret = $("devices-secret"); + if (secret) secret.value = ""; + const status = $("devices-status"); + if (status) status.textContent = `Saved ${id}. Actions still ask for approval.`; + await loadDeviceSettings(); +} + async function refreshLibrary() { const memories = await api("/api/memory"); const schedules = await api("/api/schedules"); @@ -1029,6 +1109,8 @@ function bindUi() { } }); $("acp-save")?.addEventListener("click", guard(saveAcpSettings)); + $("channels-save")?.addEventListener("click", guard(saveChannelSettings)); + $("devices-save")?.addEventListener("click", guard(saveDeviceSettings)); $("open-settings").addEventListener("click", () => go("settings")); $("settings-close").addEventListener("click", () => go("chat")); $("kb-save")?.addEventListener("click", guard(async () => { diff --git a/src/desktop/web/index.html b/src/desktop/web/index.html index 7a1f24f..8f70f06 100644 --- a/src/desktop/web/index.html +++ b/src/desktop/web/index.html @@ -252,6 +252,70 @@

Coding agents

+

Chat channels

+

Telegram, Discord, and Slack. Bring your own bot token. It is stored in the OS keyring when that is available, otherwise encrypted on this machine, and it is not shown again. An empty allow-list drops everyone.

+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+

LAN devices

+

MQTT, Home Assistant, HTTP, and SSH on private or link-local addresses only. Each action must be allow-listed and asks for approval. There is no WAN control.

+ + + + + + + +
+
+ +

Source and updates: https://github.com/GoDeskio/Clawd-Code. Default local tree: ~/Jonathan/Jonathan-Ai.

diff --git a/src/desktop/web/styles.css b/src/desktop/web/styles.css index a40be00..b29ace7 100644 --- a/src/desktop/web/styles.css +++ b/src/desktop/web/styles.css @@ -316,7 +316,7 @@ label.check { display: flex; align-items: center; } .library-item { display: flex; justify-content: space-between; gap: 8px; align-items: center; } .dialog label { display: block; margin: 8px 0; font-size: 12px; color: var(--muted); } .dialog input[type="checkbox"] { width: auto; min-height: 0; margin: 0 8px 0 0; } -.acp-runner { margin: 8px 0; } +.acp-runner, .channel-row { margin: 8px 0; } .dialog input, .dialog select { width: 100%; margin-top: 4px; diff --git a/src/devices/__init__.py b/src/devices/__init__.py new file mode 100644 index 0000000..82bd4d4 --- /dev/null +++ b/src/devices/__init__.py @@ -0,0 +1,9 @@ +"""LAN device discovery and allow-listed control. + +Adapted from the idea of Octop's gateway MQTT channel, limited to private +and link-local addresses. Jonathan Ai does not import octop-gateway. +""" + +from src.devices.service import format_devices_command + +__all__ = ["format_devices_command"] diff --git a/src/devices/control.py b/src/devices/control.py new file mode 100644 index 0000000..ee8f357 --- /dev/null +++ b/src/devices/control.py @@ -0,0 +1,176 @@ +"""Run one allow-listed action on a registered LAN device. + +MQTT, Home Assistant REST, generic HTTP, and SSH are the only controls. +The host is checked again here so a hand-edited settings file cannot point +at the WAN. Secrets are read from the store and are not included in results. +""" + +from __future__ import annotations + +import json +import shlex +from typing import Callable +from urllib.request import Request, urlopen + +from src.devices.lan import WanTargetError, require_private_host, require_private_url +from src.secret_mask import mask_secrets +from src.secret_store import get_secret + +KIND_IDS = ("mqtt", "homeassistant", "http", "ssh") +Runner = Callable[[list[str]], str] +Poster = Callable[[str, str, dict[str, str], bytes], str] + + +def approval_key(device_id: str, action: str) -> str: + return f"device:{device_id}:{action}" + + +def _origin(host: str, port: int, scheme: str = "http") -> str: + cleaned = require_private_host(host) + pretty = f"[{cleaned}]" if ":" in cleaned else cleaned + return require_private_url(f"{scheme}://{pretty}:{int(port)}") + + +def _mqtt_packet(topic: str, payload: str, username: str, password: str) -> bytes: + def enc(text: str) -> bytes: + raw = text.encode("utf-8") + return len(raw).to_bytes(2, "big") + raw + + def frame(header: int, body: bytes) -> bytes: + size = len(body) + length = bytearray() + while True: + digit = size % 128 + size //= 128 + if size: + digit |= 0x80 + length.append(digit) + if not size: + break + return bytes([header]) + bytes(length) + body + + flags = 0x02 + body = enc("jonathan-ai") + if username: + flags |= 0x80 + body += enc(username) + if password: + flags |= 0x40 + body += enc(password) + variable = enc("MQTT") + bytes([4, flags, 0, 60]) + connect = frame(0x10, variable + body) + publish = frame(0x30, enc(topic) + payload.encode("utf-8")) + return connect + publish + + +def _default_poster(method: str, url: str, headers: dict[str, str], body: bytes) -> str: + require_private_url(url) + request = Request(url, data=body, headers=headers, method=method.upper()) + try: + with urlopen(request, timeout=8) as response: + return response.read(8000).decode("utf-8", errors="replace") + except Exception as exc: + raise RuntimeError(mask_secrets(f"device request failed: {exc}")) from None + + +def _default_tcp(host: str, port: int, packet: bytes) -> None: + import socket + + require_private_host(host) + sock = socket.create_connection((host, int(port)), timeout=5) + try: + sock.sendall(packet) + finally: + sock.close() + + +def _default_ssh(argv: list[str]) -> str: + import subprocess + + completed = subprocess.run(argv, capture_output=True, text=True, timeout=20, check=False) + output = (completed.stdout or "") + (completed.stderr or "") + return mask_secrets(output.strip()) or f"exit {completed.returncode}" + + +def perform_action( + device: dict, + action: str, + *, + directory, + user_id: str, + backend: object | None = None, + poster: Poster | None = None, + tcp_send: Callable[[str, int, bytes], None] | None = None, + ssh_runner: Runner | None = None, +) -> str: + kind = str(device.get("kind") or "") + if kind not in KIND_IDS: + raise ValueError(f"unknown device kind: {kind}") + name = str(action or "").strip() + allowed = list(device.get("actions") or []) + if name not in allowed: + raise ValueError(f"action {name!r} is not on the allow-list for {device.get('id')}") + host = require_private_host(str(device.get("host") or "")) + port = int(device.get("port") or 0) + from src.devices.registry import devices_root + + root = devices_root(directory) + secret = get_secret(root, user_id, f"device-{device.get('id')}", backend=backend) + http = poster or _default_poster + if kind == "mqtt": + if any(char in name for char in "\n\r\x00#"): + raise ValueError("mqtt topic is not allowed") + packet = _mqtt_packet(name, name, str(device.get("user") or ""), secret) + (tcp_send or _default_tcp)(host, port or 1883, packet) + return f"MQTT publish {name} to {host}" + if kind == "homeassistant": + if name.count(".") != 1: + raise ValueError("home assistant action must look like domain.service") + domain, service = name.split(".", 1) + origin = _origin(host, port or 8123) + url = f"{origin}/api/services/{domain}/{service}" + headers = {"Content-Type": "application/json"} + if secret: + headers["Authorization"] = f"Bearer {secret}" + body = http("POST", url, headers, b"{}") + return mask_secrets(f"Home Assistant {name} at {host}\n{body}".strip()) + if kind == "http": + if name.startswith("/") is False: + name = "/" + name + if ".." in name or "\\" in name or "://" in name: + raise ValueError("http action path is not allowed") + origin = _origin(host, port or 80) + body = http("POST", origin + name, {"Content-Type": "application/json"}, b"{}") + return mask_secrets(f"HTTP {name} at {host}\n{body}".strip()) + user = str(device.get("user") or "").strip() + if not user or any(char in user for char in " \n\r\x00@"): + raise ValueError("ssh user is required") + argv = [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "-p", + str(port or 22), + f"{user}@{host}", + name, + ] + output = (ssh_runner or _default_ssh)(argv) + return mask_secrets(f"SSH {user}@{host}\n{shlex.join(argv)}\n{output}".strip()) + + +def safe_device_view(device: dict) -> dict: + """Public device row. The secret is not included.""" + return { + "id": device.get("id"), + "kind": device.get("kind"), + "host": device.get("host"), + "port": device.get("port"), + "user": device.get("user") or "", + "actions": list(device.get("actions") or []), + } + + +def dumps_result(text: str) -> str: + return json.dumps({"text": mask_secrets(text)}) diff --git a/src/devices/discover.py b/src/devices/discover.py new file mode 100644 index 0000000..7b18d59 --- /dev/null +++ b/src/devices/discover.py @@ -0,0 +1,219 @@ +"""Read-only LAN discovery: ARP table, SSDP responses, and mDNS answers. + +ARP is read from the neighbor table. SSDP and mDNS parsers do not send +traffic; the optional probe helpers are the only writers, and they only +multicast a discovery query. +""" + +from __future__ import annotations + +import ipaddress +import re +import socket +from pathlib import Path + +from src.devices.lan import host_is_private_ip + +_MAC = re.compile(r"\b[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}\b") +_IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b") + + +def parse_arp_table(text: str) -> list[dict[str, str]]: + """Parse `/proc/net/arp` or `arp -a` text. Public addresses are dropped.""" + found: list[dict[str, str]] = [] + seen: set[str] = set() + for line in text.splitlines(): + if "IP address" in line or line.lower().startswith("address"): + continue + ips = _IPV4.findall(line) + macs = _MAC.findall(line) + if not ips: + continue + address = ips[0] + if not host_is_private_ip(address) or address in seen: + continue + mac = macs[0].lower() if macs else "" + if mac == "00:00:00:00:00:00": + continue + seen.add(address) + found.append({"source": "arp", "address": address, "name": "", "detail": mac}) + return found + + +def build_ssdp_probe() -> bytes: + return ( + "M-SEARCH * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + 'MAN: "ssdp:discover"\r\n' + "MX: 1\r\n" + "ST: ssdp:all\r\n" + "\r\n" + ).encode("ascii") + + +def parse_ssdp_response(text: str) -> dict[str, str] | None: + location = "" + server = "" + target = "" + for line in text.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + name = key.strip().lower() + if name == "location": + location = value.strip() + elif name == "server": + server = value.strip() + elif name == "st": + target = value.strip() + if not location: + return None + from urllib.parse import urlparse + + host = urlparse(location).hostname or "" + if not host_is_private_ip(host): + return None + return {"source": "ssdp", "address": host, "name": target, "detail": server} + + +def encode_dns_name(name: str) -> bytes: + out = bytearray() + for label in name.rstrip(".").split("."): + raw = label.encode("ascii") + if not raw or len(raw) > 63: + raise ValueError("dns label is not valid") + out.append(len(raw)) + out.extend(raw) + out.append(0) + return bytes(out) + + +def build_mdns_query(name: str = "_services._dns-sd._udp.local") -> bytes: + header = b"\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00" + return header + encode_dns_name(name) + b"\x00\x0c\x00\x01" + + +def build_mdns_address(name: str, ip: str) -> bytes: + addr = ipaddress.ip_address(ip) + rtype = b"\x00\x01" if addr.version == 4 else b"\x00\x1c" + rdata = addr.packed + header = b"\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x00" + answer = encode_dns_name(name) + rtype + b"\x00\x01\x00\x00\x00\x78" + len(rdata).to_bytes(2, "big") + rdata + return header + answer + + +def _read_name(packet: bytes, offset: int, depth: int = 0) -> tuple[str, int]: + labels: list[str] = [] + while offset < len(packet) and depth < 8: + length = packet[offset] + if length == 0: + return ".".join(labels), offset + 1 + if length & 0xC0 == 0xC0: + if offset + 1 >= len(packet): + break + pointer = ((length & 0x3F) << 8) | packet[offset + 1] + rest, _end = _read_name(packet, pointer, depth + 1) + if rest: + labels.append(rest) + return ".".join(labels), offset + 2 + offset += 1 + labels.append(packet[offset : offset + length].decode("ascii", "replace")) + offset += length + return ".".join(labels), offset + + +def parse_mdns_addresses(packet: bytes) -> list[dict[str, str]]: + if len(packet) < 12: + return [] + questions = int.from_bytes(packet[4:6], "big") + answers = int.from_bytes(packet[6:8], "big") + offset = 12 + for _ in range(questions): + _name, offset = _read_name(packet, offset) + offset += 4 + found: list[dict[str, str]] = [] + for _ in range(answers): + if offset >= len(packet): + break + name, offset = _read_name(packet, offset) + if offset + 10 > len(packet): + break + rtype = int.from_bytes(packet[offset : offset + 2], "big") + rdlen = int.from_bytes(packet[offset + 8 : offset + 10], "big") + start = offset + 10 + data = packet[start : start + rdlen] + offset = start + rdlen + if rtype == 1 and len(data) == 4: + address = str(ipaddress.IPv4Address(data)) + elif rtype == 28 and len(data) == 16: + address = str(ipaddress.IPv6Address(data)) + else: + continue + if not host_is_private_ip(address): + continue + found.append({"source": "mdns", "address": address, "name": name, "detail": ""}) + return found + + +def discover_from_sources( + *, + arp_text: str = "", + ssdp_texts: list[str] | None = None, + mdns_packets: list[bytes] | None = None, +) -> list[dict[str, str]]: + rows = parse_arp_table(arp_text) + for text in ssdp_texts or []: + parsed = parse_ssdp_response(text) + if parsed: + rows.append(parsed) + for packet in mdns_packets or []: + rows.extend(parse_mdns_addresses(packet)) + return rows + + +def read_arp_text(path: Path | None = None) -> str: + candidate = path or Path("/proc/net/arp") + try: + return candidate.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + + +def probe_ssdp(timeout: float = 0.4) -> list[str]: + """Multicast one SSDP M-SEARCH and collect replies. Failures return nothing.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.settimeout(timeout) + sock.sendto(build_ssdp_probe(), ("239.255.255.250", 1900)) + found: list[str] = [] + while True: + try: + data, _addr = sock.recvfrom(8192) + except socket.timeout: + break + found.append(data.decode("utf-8", errors="replace")) + return found + except OSError: + return [] + finally: + sock.close() + + +def probe_mdns(timeout: float = 0.4) -> list[bytes]: + """Multicast one mDNS query and collect replies. Failures return nothing.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.settimeout(timeout) + sock.sendto(build_mdns_query(), ("224.0.0.251", 5353)) + found: list[bytes] = [] + while True: + try: + data, _addr = sock.recvfrom(8192) + except socket.timeout: + break + found.append(data) + return found + except OSError: + return [] + finally: + sock.close() diff --git a/src/devices/lan.py b/src/devices/lan.py new file mode 100644 index 0000000..adf99ca --- /dev/null +++ b/src/devices/lan.py @@ -0,0 +1,47 @@ +"""Accept only loopback, private, and link-local device addresses. + +This reuses the local-LLM LAN check. Public and WAN hosts are refused before +any socket is opened. +""" + +from __future__ import annotations + +import ipaddress +from urllib.parse import urlparse + +from src.providers.local_endpoints import _host_is_loopback_or_lan + + +class WanTargetError(ValueError): + """The device address is not on the local network.""" + + +def require_private_host(host: str) -> str: + """Return a cleaned host, or refuse a WAN target.""" + text = (host or "").strip().strip("[]") + if not text or any(char in text for char in "\n\r\x00 /\\@"): + raise WanTargetError("device host is not valid") + if not _host_is_loopback_or_lan(text): + raise WanTargetError(f"refusing WAN host {text}; device control stays on the LAN") + return text + + +def require_private_url(url: str) -> str: + raw = (url or "").strip() + if "@" in raw: + raise WanTargetError("device URL cannot carry user info") + parsed = urlparse(raw) + if parsed.scheme not in {"http", "https"}: + raise WanTargetError("device URL must be http or https") + if not parsed.hostname: + raise WanTargetError("device URL is missing a host") + require_private_host(parsed.hostname) + return raw + + +def host_is_private_ip(address: str) -> bool: + try: + ip = ipaddress.ip_address(address.strip("[]")) + except ValueError: + return False + return bool(ip.is_loopback or ip.is_private or ip.is_link_local) diff --git a/src/devices/registry.py b/src/devices/registry.py new file mode 100644 index 0000000..10e1e21 --- /dev/null +++ b/src/devices/registry.py @@ -0,0 +1,174 @@ +"""Per-user registry of MQTT, Home Assistant, HTTP, and SSH devices.""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path + +from src.agent.memory import current_user_id +from src.devices.control import KIND_IDS, safe_device_view +from src.devices.lan import require_private_host +from src.secret_store import has_secret, put_secret + + +def devices_root(directory: Path | None = None) -> Path: + if directory is not None: + path = directory + else: + raw = os.environ.get("JONATHAN_DEVICES_DIR", "").strip() + path = Path(raw) if raw else Path.home() / ".clawd" / "devices" + path.mkdir(parents=True, exist_ok=True) + try: + path.chmod(0o700) + except OSError: + pass + return path + + +def _path(directory: Path | None, user_id: str | None) -> Path: + user = user_id or current_user_id() + return devices_root(directory) / f"devices-{user}.json" + + +def _actions(value: object) -> list[str]: + if isinstance(value, str): + parts = re.split(r"[,\n]+", value) + elif isinstance(value, list): + parts = [str(item) for item in value] + else: + raise ValueError("actions must be a list") + found: list[str] = [] + for part in parts: + text = part.strip() + if not text: + continue + if any(char in text for char in "\r\x00"): + raise ValueError("action is not valid") + if text not in found: + found.append(text) + return found + + +def _port(value: object, kind: str) -> int: + defaults = {"mqtt": 1883, "homeassistant": 8123, "http": 80, "ssh": 22} + if value in (None, ""): + return defaults[kind] + port = int(value) + if port < 1 or port > 65535: + raise ValueError("device port is not valid") + return port + + +def load_devices(directory: Path | None = None, *, user_id: str | None = None) -> list[dict]: + path = _path(directory, user_id) + if not path.is_file(): + return [] + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] + rows = raw.get("devices") if isinstance(raw, dict) else None + if not isinstance(rows, list): + return [] + found: list[dict] = [] + for item in rows: + if isinstance(item, dict) and item.get("id"): + found.append(item) + return found + + +def _write(devices: list[dict], directory: Path | None, user_id: str) -> None: + path = _path(directory, user_id) + public = [] + for device in devices: + public.append( + { + "id": device["id"], + "kind": device["kind"], + "host": device["host"], + "port": device["port"], + "user": device.get("user") or "", + "actions": list(device.get("actions") or []), + } + ) + path.write_text(json.dumps({"devices": public}, indent=2) + "\n", encoding="utf-8") + try: + path.chmod(0o600) + except OSError: + return + + +def public_devices( + directory: Path | None = None, + *, + user_id: str | None = None, + backend: object | None = None, +) -> dict[str, object]: + user = user_id or current_user_id() + root = devices_root(directory) + devices = [] + for device in load_devices(root, user_id=user): + row = safe_device_view(device) + row["secret_set"] = has_secret(root, user, f"device-{device['id']}", backend=backend) + devices.append(row) + return {"devices": devices} + + +def update_devices( + changes: object, + directory: Path | None = None, + *, + user_id: str | None = None, + backend: object | None = None, +) -> dict[str, object]: + rows = changes + if isinstance(changes, dict): + rows = changes.get("devices", [changes] if changes.get("id") or changes.get("host") else []) + if not isinstance(rows, list): + raise ValueError("devices must be a list") + user = user_id or current_user_id() + root = devices_root(directory) + devices = load_devices(root, user_id=user) + by_id = {str(item["id"]): item for item in devices} + meaningful = False + for item in rows: + if not isinstance(item, dict): + raise ValueError("each device must be an object") + device_id = str(item.get("id") or "").strip() + if not device_id: + continue + if any(char in device_id for char in "\n\r\x00/\\ "): + raise ValueError("device id is not valid") + meaningful = True + if item.get("delete"): + by_id.pop(device_id, None) + continue + kind = str(item.get("kind") or (by_id.get(device_id) or {}).get("kind") or "").strip() + if kind not in KIND_IDS: + raise ValueError(f"unknown device kind: {kind}") + current = by_id.get(device_id, {}) + host = require_private_host(str(item.get("host") or current.get("host") or "")) + record = { + "id": device_id, + "kind": kind, + "host": host, + "port": _port(item.get("port", current.get("port")), kind), + "user": str(item.get("user") if "user" in item else current.get("user") or "").strip(), + "actions": _actions(item.get("actions", current.get("actions") or [])), + } + secret = item.get("secret") + if isinstance(secret, str) and secret.strip(): + put_secret(root, user, f"device-{device_id}", secret, backend=backend) + by_id[device_id] = record + if meaningful: + _write(list(by_id.values()), root, user) + return public_devices(root, user_id=user, backend=backend) + + +def get_device(device_id: str, directory: Path | None = None, *, user_id: str | None = None) -> dict: + for device in load_devices(directory, user_id=user_id): + if str(device.get("id")) == device_id: + return device + raise ValueError(f"unknown device: {device_id}") diff --git a/src/devices/service.py b/src/devices/service.py new file mode 100644 index 0000000..9ab111e --- /dev/null +++ b/src/devices/service.py @@ -0,0 +1,79 @@ +"""Commands for LAN discovery and registered device control.""" + +from __future__ import annotations + +from pathlib import Path + +from src.devices.discover import discover_from_sources, probe_mdns, probe_ssdp, read_arp_text +from src.devices.registry import get_device, public_devices +from src.secret_mask import mask_secrets + +_USAGE = ( + "Usage: /devices list | /devices discover [probe] | /devices do \n" + "Discovery reads the ARP table. probe also sends one mDNS query and one SSDP search.\n" + "Control stays on private and link-local addresses, and each action must be allow-listed." +) + + +def format_devices_command( + args: str, + *, + directory: Path | None = None, + user_id: str | None = None, + arp_text: str | None = None, + probe: bool | None = None, +) -> str: + parts = args.split() + action = parts[0] if parts else "list" + if action in {"", "list"}: + return _format_list(directory, user_id=user_id) + if action == "discover": + do_probe = probe if probe is not None else "probe" in parts[1:] + return format_discovery(arp_text=arp_text, probe=do_probe) + if action == "help": + return _USAGE + return _USAGE + + +def _format_list(directory: Path | None, *, user_id: str | None) -> str: + view = public_devices(directory, user_id=user_id) + lines = ["LAN devices:"] + devices = view["devices"] + if not devices: + lines.append("none registered") + for device in devices: + actions = ", ".join(device["actions"]) or "no actions allowed" + secret = "secret set" if device["secret_set"] else "no secret" + lines.append( + f"{device['id']} {device['kind']} {device['host']}:{device['port']} {secret} {actions}" + ) + lines.append("Only private and link-local addresses can be registered.") + return "\n".join(lines) + + +def format_discovery(*, arp_text: str | None = None, probe: bool = False) -> str: + text = read_arp_text() if arp_text is None else arp_text + ssdp: list[str] = [] + mdns: list[bytes] = [] + if probe: + ssdp = probe_ssdp() + mdns = probe_mdns() + rows = discover_from_sources(arp_text=text, ssdp_texts=ssdp, mdns_packets=mdns) + lines = ["LAN discovery:"] + if not rows: + lines.append("none") + for row in rows: + label = row["name"] or row["detail"] or row["source"] + lines.append(f"{row['source']} {row['address']} {label}") + return "\n".join(lines) + + +def describe_device(device_id: str, directory: Path | None = None, *, user_id: str | None = None) -> dict: + device = get_device(device_id, directory, user_id=user_id) + return { + "id": device["id"], + "kind": device["kind"], + "host": device["host"], + "actions": list(device.get("actions") or []), + "text": mask_secrets(f"{device['kind']} {device['host']}"), + } diff --git a/src/secret_mask.py b/src/secret_mask.py index 28c2552..772e0a5 100644 --- a/src/secret_mask.py +++ b/src/secret_mask.py @@ -32,6 +32,8 @@ ("github_token", re.compile(r"\b(?:ghp_|gho_|github_pat_)[A-Za-z0-9_]{20,}\b")), ("gitlab_token", re.compile(r"\bglpat-[A-Za-z0-9_\-]{20,}\b")), ("slack_token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")), + ("telegram_bot", re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")), + ("discord_bot", re.compile(r"\b[MN][A-Za-z0-9_-]{23,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b")), ("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")), ( "bearer", diff --git a/src/secret_store.py b/src/secret_store.py new file mode 100644 index 0000000..4ebe588 --- /dev/null +++ b/src/secret_store.py @@ -0,0 +1,146 @@ +"""Store channel and device secrets in the OS keyring, or in an encrypted file. + +The file fallback is used when the keyring library is missing or refuses the +write. Tokens are never written into settings JSON and never logged here. +""" + +from __future__ import annotations + +import hashlib +import hmac +import os +from pathlib import Path + +_SERVICE = "jonathan-ai" + + +def _chmod(path: Path, mode: int) -> None: + try: + path.chmod(mode) + except OSError: + return + + +def _keyring(): + choice = os.environ.get("JONATHAN_SECRET_BACKEND", "auto").strip().lower() + if choice == "file": + return None + try: + import keyring + except Exception: + return None + if choice == "keyring": + return keyring + try: + keyring.get_password(_SERVICE, "jonathan-probe") + except Exception: + return None + return keyring + + +def _account(user_id: str, name: str) -> str: + return f"{user_id}:{name}" + + +def _key_path(directory: Path, user_id: str) -> Path: + return directory / f"key-{user_id}.bin" + + +def _secret_path(directory: Path, user_id: str, name: str) -> Path: + safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in name) + return directory / f"secret-{user_id}-{safe}.bin" + + +def _load_key(directory: Path, user_id: str) -> bytes: + directory.mkdir(parents=True, exist_ok=True) + _chmod(directory, 0o700) + path = _key_path(directory, user_id) + if path.is_file(): + return path.read_bytes() + key = os.urandom(32) + path.write_bytes(key) + _chmod(path, 0o600) + return key + + +def _keystream(key: bytes, nonce: bytes, size: int) -> bytes: + out = bytearray() + counter = 0 + while len(out) < size: + block = hmac.new(key, nonce + counter.to_bytes(4, "big"), hashlib.sha256).digest() + out.extend(block) + counter += 1 + return bytes(out[:size]) + + +def _seal(key: bytes, plaintext: str) -> bytes: + nonce = os.urandom(16) + raw = plaintext.encode("utf-8") + body = bytes(a ^ b for a, b in zip(raw, _keystream(key, nonce, len(raw)))) + mac = hmac.new(key, nonce + body, hashlib.sha256).digest() + return nonce + mac + body + + +def _open_sealed(key: bytes, blob: bytes) -> str: + if len(blob) < 48: + raise ValueError("secret file is unreadable") + nonce, mac, body = blob[:16], blob[16:48], blob[48:] + expected = hmac.new(key, nonce + body, hashlib.sha256).digest() + if not hmac.compare_digest(mac, expected): + raise ValueError("secret file failed its check") + raw = bytes(a ^ b for a, b in zip(body, _keystream(key, nonce, len(body)))) + return raw.decode("utf-8") + + +def put_secret( + directory: Path, + user_id: str, + name: str, + value: str, + *, + backend: object | None = None, +) -> None: + """Save one secret. A blank value does not erase a secret already stored.""" + text = value.strip() + if not text: + return + ring = backend if backend is not None else _keyring() + if ring is not None: + ring.set_password(_SERVICE, _account(user_id, name), text) + path = _secret_path(directory, user_id, name) + if path.is_file(): + path.unlink() + return + directory.mkdir(parents=True, exist_ok=True) + _chmod(directory, 0o700) + path = _secret_path(directory, user_id, name) + path.write_bytes(_seal(_load_key(directory, user_id), text)) + _chmod(path, 0o600) + + +def get_secret( + directory: Path, + user_id: str, + name: str, + *, + backend: object | None = None, +) -> str: + ring = backend if backend is not None else _keyring() + if ring is not None: + found = ring.get_password(_SERVICE, _account(user_id, name)) + if isinstance(found, str) and found: + return found + path = _secret_path(directory, user_id, name) + if not path.is_file(): + return "" + return _open_sealed(_load_key(directory, user_id), path.read_bytes()) + + +def has_secret( + directory: Path, + user_id: str, + name: str, + *, + backend: object | None = None, +) -> bool: + return bool(get_secret(directory, user_id, name, backend=backend)) diff --git a/src/security/shell_guard_rules.py b/src/security/shell_guard_rules.py index bdf7811..a5b5c5e 100644 --- a/src/security/shell_guard_rules.py +++ b/src/security/shell_guard_rules.py @@ -45,7 +45,7 @@ def _rule( ) -> dict[str, Any]: return { "id": rule_id, - "tools": ["bash", "execute", "powershell", "acp"], + "tools": ["bash", "execute", "powershell", "acp", "device"], "params": ["command"], "category": category, "severity": severity, diff --git a/src/tool_system/context.py b/src/tool_system/context.py index 46e39e4..0d1b7eb 100644 --- a/src/tool_system/context.py +++ b/src/tool_system/context.py @@ -46,6 +46,8 @@ class ToolContext: acp_dir: Path | None = None # Called with one progress line while a local ACP runner is working. progress_handler: Callable[[str], None] | None = None + # Registered LAN devices. Unset uses ~/.clawd/devices. + devices_dir: Path | None = None # Permission handler callback: called when a tool needs user consent. # Signature: (tool_name: str, message: str, suggestion: str | None) @@ -93,7 +95,7 @@ def ensure_tool_allowed(self, tool_name: str) -> None: def note_shell_approval(self, tool_name: str, command: str | None) -> None: """Remember one approved shell command. A session tool grant is not enough.""" - if tool_name.lower() not in {"bash", "powershell", "execute", "acp"}: + if tool_name.lower() not in {"bash", "powershell", "execute", "acp", "device"}: return if isinstance(command, str) and command: self.approved_shell_commands.add(command) diff --git a/src/tool_system/defaults.py b/src/tool_system/defaults.py index 602cd8e..da57cd7 100644 --- a/src/tool_system/defaults.py +++ b/src/tool_system/defaults.py @@ -11,6 +11,7 @@ BriefTool, BrowserTool, ConfigTool, + DeviceTool, CronCreateTool, CronDeleteTool, CronDisableTool, @@ -111,6 +112,7 @@ def build_default_registry(*, include_user_tools: bool = True) -> ToolRegistry: TestingPermissionTool(), BrowserTool(), AcpTool(), + DeviceTool(), ] ) registry.register(AgentTool(registry)) diff --git a/src/tool_system/permissions.py b/src/tool_system/permissions.py index d71aa9f..0475468 100644 --- a/src/tool_system/permissions.py +++ b/src/tool_system/permissions.py @@ -87,6 +87,7 @@ def ensure_path_allowed(self, path: str | Path) -> Path: "remotetrigger", "browser", "acp", + "device", }) diff --git a/src/tool_system/tools/__init__.py b/src/tool_system/tools/__init__.py index b4ccabe..cd8c842 100644 --- a/src/tool_system/tools/__init__.py +++ b/src/tool_system/tools/__init__.py @@ -6,6 +6,7 @@ from .bash import BashTool from .brief import BriefTool from .config import ConfigTool +from .device import DeviceTool from .cron import ( CronCreateTool, CronDeleteTool, @@ -48,6 +49,7 @@ "BrowserTool", "BriefTool", "ConfigTool", + "DeviceTool", "CronCreateTool", "CronDeleteTool", "CronDisableTool", diff --git a/src/tool_system/tools/device.py b/src/tool_system/tools/device.py new file mode 100644 index 0000000..7a57c52 --- /dev/null +++ b/src/tool_system/tools/device.py @@ -0,0 +1,116 @@ +"""Approval-gated control of a registered LAN device.""" + +from __future__ import annotations + +from typing import Any + +from src.devices.control import approval_key, perform_action +from src.devices.lan import WanTargetError, require_private_host +from src.devices.registry import get_device +from src.security.shell_guard import enforce_shell_command, shell_command_permission + +from ..context import ToolContext +from ..errors import ToolPermissionError +from ..permission_handler import PermissionBehavior, PermissionResult +from ..protocol import ToolResult +from ..registry import ToolSpec + + +class DeviceTool: + def spec(self) -> ToolSpec: + return ToolSpec( + name="Device", + description=( + "Run one allow-listed action on a registered LAN device " + "(MQTT, Home Assistant, HTTP, or SSH). Every action asks for " + "approval. Public and WAN addresses are refused." + ), + input_schema={ + "type": "object", + "additionalProperties": False, + "properties": { + "device": {"type": "string", "description": "Registered device id"}, + "action": {"type": "string", "description": "Allow-listed action name"}, + "command": { + "type": "string", + "description": "Approval key filled by the permission check", + }, + }, + "required": ["device", "action"], + }, + is_destructive=True, + max_result_size_chars=20_000, + ) + + def check_permissions(self, tool_input: dict[str, Any], context: ToolContext) -> PermissionResult: + try: + device, action, key = _prepare(tool_input, context) + except (ValueError, WanTargetError) as exc: + return PermissionResult.deny(str(exc)) + payload = dict(tool_input) + payload["command"] = key + guarded = shell_command_permission("Device", key, context) + if guarded is not None and guarded.behavior == PermissionBehavior.DENY: + return guarded + if key in getattr(context, "approved_shell_commands", set()): + return PermissionResult.allow(updated_input=payload) + message = f"Control {device['kind']} {device['id']} at {device['host']}: {action}" + if guarded is not None and guarded.message: + message = f"{message}\n{guarded.message}" + return PermissionResult( + behavior=PermissionBehavior.ASK, + message=message, + suggestion="Approve this device action once", + updated_input=payload, + ) + + def run(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult: + try: + device, action, key = _prepare(tool_input, context) + except (ValueError, WanTargetError) as exc: + return ToolResult(name="Device", output={"error": str(exc)}, is_error=True) + if key not in getattr(context, "approved_shell_commands", set()): + return ToolResult( + name="Device", + output={"error": "device action requires approval"}, + is_error=True, + ) + try: + enforce_shell_command("Device", key, context) + except ToolPermissionError as exc: + return ToolResult(name="Device", output={"error": str(exc)}, is_error=True) + try: + text = perform_action( + device, + action, + directory=getattr(context, "devices_dir", None), + user_id=_user(context), + poster=getattr(context, "device_poster", None), + tcp_send=getattr(context, "device_tcp", None), + ssh_runner=getattr(context, "device_ssh", None), + ) + except (ValueError, WanTargetError, OSError, RuntimeError) as exc: + return ToolResult(name="Device", output={"error": str(exc)}, is_error=True) + return ToolResult(name="Device", output={"text": text, "device": device["id"], "action": action}) + + +def _prepare(tool_input: dict[str, Any], context: ToolContext) -> tuple[dict, str, str]: + device_id = tool_input.get("device") + action = tool_input.get("action") + if not isinstance(device_id, str) or not device_id.strip(): + raise ValueError("device is required") + if not isinstance(action, str) or not action.strip(): + raise ValueError("action is required") + device = get_device(device_id.strip(), getattr(context, "devices_dir", None)) + cleaned = action.strip() + require_private_host(str(device.get("host") or "")) + if cleaned not in list(device.get("actions") or []): + raise ValueError(f"action {cleaned!r} is not on the allow-list for {device.get('id')}") + key = approval_key(device["id"], cleaned) + return device, cleaned, key + + +def _user(context: ToolContext) -> str: + from src.agent.memory import current_user_id + + return current_user_id() diff --git a/tests/test_channels_devices.py b/tests/test_channels_devices.py new file mode 100644 index 0000000..8ff3064 --- /dev/null +++ b/tests/test_channels_devices.py @@ -0,0 +1,407 @@ +"""Chat channels and LAN device control. No live network and no real bots.""" + +from __future__ import annotations + +import json +import logging +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from src.channels.parse import parse_discord_message, parse_slack_event, parse_telegram_updates +from src.channels.service import ( + format_channels_command, + ingest_payload, + public_channels, + route_inbound, + update_channels, +) +from src.channels.parse import Inbound +from src.channels.transport import official_url +from src.command_system.builtins import execute_command_sync +from src.command_system.engine import create_command_context +from src.desktop.runtime import ChatJob, DesktopRuntime, _HELP_TEXT +from src.desktop.server import DesktopServer +from src.devices.control import perform_action +from src.devices.discover import ( + build_mdns_address, + build_mdns_query, + build_ssdp_probe, + discover_from_sources, + parse_arp_table, + parse_ssdp_response, +) +from src.devices.lan import WanTargetError, require_private_host +from src.devices.registry import public_devices, update_devices +from src.secret_mask import mask_secrets +from src.secret_store import get_secret, put_secret +from src.tool_system.context import ToolContext +from src.tool_system.defaults import build_default_registry +from src.tool_system.permission_handler import PermissionBehavior +from src.tool_system.protocol import ToolCall +from src.tool_system.registry import ToolRegistry +from src.tool_system.tools.device import DeviceTool + +ROOT = Path(__file__).resolve().parents[1] +TOKEN = "123456789:" + ("A" * 35) +DISCORD_TOKEN = "M" + ("a" * 23) + ".abcdef." + ("b" * 27) +SLACK_TOKEN = "xoxb-" + ("c" * 24) + + +class MemoryKeyring: + def __init__(self) -> None: + self.data: dict[tuple[str, str], str] = {} + + def set_password(self, service: str, account: str, value: str) -> None: + self.data[(service, account)] = value + + def get_password(self, service: str, account: str) -> str | None: + return self.data.get((service, account)) + + +class GatewayTestCase(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.channels = self.root / "channels" + self.devices = self.root / "devices" + self._env = patch.dict( + os.environ, + { + "JONATHAN_CHANNELS_DIR": str(self.channels), + "JONATHAN_DEVICES_DIR": str(self.devices), + "JONATHAN_USER": "gateway-test", + "JONATHAN_SECRET_BACKEND": "file", + }, + ) + self._env.start() + + def tearDown(self) -> None: + self._env.stop() + self.tmp.cleanup() + + +class TestSecretStore(GatewayTestCase): + def test_file_roundtrip_is_private_and_keyring_is_separate(self) -> None: + put_secret(self.channels, "alice", "channel-telegram", TOKEN) + self.assertEqual(get_secret(self.channels, "alice", "channel-telegram"), TOKEN) + self.assertEqual(get_secret(self.channels, "bob", "channel-telegram"), "") + path = next(self.channels.glob("secret-alice-*.bin")) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertNotIn(TOKEN.encode(), path.read_bytes()) + ring = MemoryKeyring() + put_secret(self.channels, "alice", "channel-slack", SLACK_TOKEN, backend=ring) + self.assertEqual(get_secret(self.channels, "alice", "channel-slack", backend=ring), SLACK_TOKEN) + self.assertFalse(list(self.channels.glob("secret-alice-*slack*"))) + + +class TestChannels(GatewayTestCase): + def test_allow_list_routes_only_named_users(self) -> None: + update_channels( + [{"id": "telegram", "enabled": True, "allow_user_ids": "42", "token": TOKEN}], + self.channels, + ) + view = public_channels(self.channels) + blob = json.dumps(view) + self.assertNotIn(TOKEN, blob) + self.assertTrue(view["channels"][0]["token_set"]) + self.assertEqual((self.channels / "settings-gateway-test.json").stat().st_mode & 0o777, 0o600) + + called: list[str] = [] + + def reply(text: str) -> str: + called.append(text) + return "echo " + text + + dropped = route_inbound(Inbound("telegram", "7", "nope", "7"), reply, directory=self.channels) + self.assertFalse(dropped["accepted"]) + self.assertEqual(called, []) + + sent: list[str] = [] + + def transport(method: str, url: str, headers: dict, payload: dict) -> dict: + sent.append(urlparse_host(url)) + self.assertNotIn("example.com", url) + return {"ok": True} + + accepted = route_inbound( + Inbound("telegram", "42", "lights", "42"), + reply, + directory=self.channels, + transport=transport, + ) + self.assertTrue(accepted["accepted"]) + self.assertEqual(called, ["lights"]) + self.assertEqual(sent, ["api.telegram.org"]) + self.assertNotIn(TOKEN, accepted["reply"]) + + def test_payloads_and_logs_hide_the_token(self) -> None: + update_channels( + [ + {"id": "discord", "enabled": True, "allow_user_ids": ["99"], "token": DISCORD_TOKEN}, + {"id": "slack", "enabled": True, "allow_user_ids": ["U1"], "token": SLACK_TOKEN}, + ], + self.channels, + ) + records: list[str] = [] + handler = logging.Handler() + handler.emit = lambda record: records.append(record.getMessage()) + logger = logging.getLogger("src.channels.service") + logger.addHandler(handler) + logger.setLevel(logging.INFO) + try: + telegram = parse_telegram_updates( + {"ok": True, "result": [{"update_id": 1, "message": {"text": "hi", "from": {"id": 42}, "chat": {"id": 42}}}]} + ) + self.assertEqual(telegram[0].user_id, "42") + discord = parse_discord_message( + {"t": "MESSAGE_CREATE", "d": {"author": {"id": "99"}, "content": "hi", "channel_id": "c1"}} + ) + slack = parse_slack_event({"event": {"type": "message", "user": "U1", "text": "hi", "channel": "C1"}}) + assert discord is not None and slack is not None + calls: list[str] = [] + + def transport(method: str, url: str, headers: dict, payload: dict) -> dict: + calls.append(urlparse_host(url)) + joined = json.dumps({"headers": headers, "payload": payload}) + if "discord.com" in url: + self.assertIn("Bot ", headers["Authorization"]) + if "slack.com" in url: + self.assertNotIn(SLACK_TOKEN, payload["text"]) + return {"ok": True} + + ingest_payload("discord", {"t": "MESSAGE_CREATE", "d": {"author": {"id": "99", "bot": False}, "content": "ping", "channel_id": "c1"}}, lambda text: TOKEN, directory=self.channels, transport=transport) + ingest_payload("slack", {"event": {"user": "U1", "text": "ping", "channel": "C1"}}, lambda text: "ok", directory=self.channels, transport=transport) + finally: + logger.removeHandler(handler) + self.assertEqual(calls, ["discord.com", "slack.com"]) + self.assertTrue(records) + self.assertFalse(any(TOKEN in line or DISCORD_TOKEN in line or SLACK_TOKEN in line for line in records)) + self.assertNotIn(TOKEN, mask_secrets("see " + TOKEN)) + + def test_unofficial_host_and_unknown_channel_are_refused(self) -> None: + with self.assertRaises(ValueError): + official_url("telegram", "https://example.invalid/bot") + with self.assertRaises(ValueError): + update_channels([{"id": "weixin", "enabled": True}], self.channels) + listed = format_channels_command("list", directory=self.channels) + self.assertIn("telegram", listed) + self.assertIn("discord", listed) + self.assertIn("slack", listed) + self.assertNotIn(TOKEN, listed) + + def test_users_do_not_share_settings(self) -> None: + update_channels([{"id": "slack", "enabled": True, "allow_user_ids": ["U9"]}], self.channels, user_id="alice") + bob = public_channels(self.channels, user_id="bob") + alice = public_channels(self.channels, user_id="alice") + self.assertFalse(next(row for row in bob["channels"] if row["id"] == "slack")["enabled"]) + self.assertTrue(next(row for row in alice["channels"] if row["id"] == "slack")["enabled"]) + + +class TestDevices(GatewayTestCase): + def _register(self, **extra) -> None: + row = { + "id": "lamp", + "kind": "homeassistant", + "host": "192.168.1.20", + "actions": ["light.turn_on"], + "secret": "ha-token-secret-value", + } + row.update(extra) + update_devices([row], self.devices) + + def test_lan_check_and_discovery_drop_wan(self) -> None: + self.assertEqual(require_private_host("192.168.1.10"), "192.168.1.10") + self.assertEqual(require_private_host("10.0.0.2"), "10.0.0.2") + self.assertEqual(require_private_host("169.254.1.5"), "169.254.1.5") + self.assertEqual(require_private_host("fd00::1"), "fd00::1") + with self.assertRaises(WanTargetError): + require_private_host("8.8.8.8") + with self.assertRaises(WanTargetError): + require_private_host("1.1.1.1") + with self.assertRaises(WanTargetError): + update_devices([{"id": "wan", "kind": "http", "host": "8.8.8.8", "actions": ["on"]}], self.devices) + arp = parse_arp_table( + "IP address HW Flags HW address Mask Device\n" + "192.168.1.5 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0\n" + "8.8.8.8 0x1 0x2 11:22:33:44:55:66 * eth0\n" + ) + self.assertEqual([row["address"] for row in arp], ["192.168.1.5"]) + self.assertIsNone(parse_ssdp_response("HTTP/1.1 200 OK\r\nLOCATION: http://1.1.1.1/desc.xml\r\nST: upnp\r\n")) + local = parse_ssdp_response("HTTP/1.1 200 OK\r\nLOCATION: http://192.168.1.40:1400/desc.xml\r\nST: MediaRenderer\r\n") + assert local is not None + rows = discover_from_sources( + arp_text="192.168.1.5 aa:bb:cc:dd:ee:ff", + ssdp_texts=["LOCATION: http://192.168.1.40:1400/x\r\nST: lamp\r\n"], + mdns_packets=[build_mdns_address("lamp.local", "192.168.1.9"), build_mdns_address("wan.example", "8.8.8.8")], + ) + addresses = {row["address"] for row in rows} + self.assertIn("192.168.1.9", addresses) + self.assertNotIn("8.8.8.8", addresses) + self.assertIn(b"M-SEARCH", build_ssdp_probe()) + self.assertTrue(build_mdns_query().startswith(b"\x00\x00")) + + def test_actions_are_approval_gated_even_with_a_session_grant(self) -> None: + self._register() + ctx = ToolContext(workspace_root=self.root, devices_dir=self.devices) + ctx.gate_destructive_tools = True + ctx.session_grants.add("*") + calls: list[str] = [] + + def poster(method: str, url: str, headers: dict, body: bytes) -> str: + calls.append(url) + self.assertNotIn("ha-token-secret-value", url) + return "ok" + + ctx.device_poster = poster + tool = DeviceTool() + decision = tool.check_permissions({"device": "lamp", "action": "light.turn_on"}, ctx) + self.assertEqual(decision.behavior, PermissionBehavior.ASK) + self.assertIn("192.168.1.20", decision.message or "") + self.assertIn("light.turn_on", decision.message or "") + registry = ToolRegistry(tools=[DeviceTool()]) + blocked = registry.dispatch(ToolCall(name="Device", input={"device": "lamp", "action": "light.turn_on"}), ctx) + self.assertTrue(blocked.is_error) + self.assertEqual(calls, []) + denied = tool.check_permissions({"device": "lamp", "action": "light.turn_off"}, ctx) + self.assertEqual(denied.behavior, PermissionBehavior.DENY) + + def allow(tool_name: str, message: str, suggestion: str | None) -> tuple[bool, bool]: + del tool_name, message, suggestion + return True, False + + ctx.permission_handler = allow + result = registry.dispatch(ToolCall(name="Device", input={"device": "lamp", "action": "light.turn_on"}), ctx) + self.assertFalse(result.is_error) + self.assertEqual(calls, ["http://192.168.1.20:8123/api/services/light/turn_on"]) + self.assertNotIn("ha-token-secret-value", result.output["text"]) + + def test_ssh_danger_and_mqtt_stay_local(self) -> None: + update_devices( + [ + {"id": "shell", "kind": "ssh", "host": "10.1.2.3", "user": "pi", "actions": ["rm -rf /", "echo hi"]}, + {"id": "broker", "kind": "mqtt", "host": "192.168.1.8", "actions": ["lamps/on"], "secret": "mqtt-secret"}, + ], + self.devices, + ) + ctx = ToolContext(workspace_root=self.root, devices_dir=self.devices) + ran: list[list[str]] = [] + + def ssh_runner(argv: list[str]) -> str: + ran.append(argv) + return "ok" + + ctx.device_ssh = ssh_runner + danger = DeviceTool().check_permissions({"device": "shell", "action": "rm -rf /"}, ctx) + self.assertEqual(danger.behavior, PermissionBehavior.ASK) + self.assertIn("rm", (danger.message or "").lower()) + blocked = DeviceTool().run({"device": "shell", "action": "rm -rf /"}, ctx) + self.assertTrue(blocked.is_error) + self.assertEqual(ran, []) + key_ctx = ToolContext(workspace_root=self.root, devices_dir=self.devices) + from src.devices.control import approval_key + + key_ctx.approved_shell_commands.add(approval_key("shell", "echo hi")) + key_ctx.device_ssh = ssh_runner + safe = DeviceTool().run({"device": "shell", "action": "echo hi"}, key_ctx) + self.assertFalse(safe.is_error) + self.assertEqual(ran[0][0], "ssh") + self.assertEqual(ran[0][-1], "echo hi") + self.assertNotIn("-c", ran[0]) + packets: list[bytes] = [] + + def tcp(host: str, port: int, packet: bytes) -> None: + self.assertEqual(host, "192.168.1.8") + packets.append(packet) + + text = perform_action( + {"id": "broker", "kind": "mqtt", "host": "192.168.1.8", "port": 1883, "user": "", "actions": ["lamps/on"]}, + "lamps/on", + directory=self.devices, + user_id="gateway-test", + tcp_send=tcp, + ) + self.assertIn("192.168.1.8", text) + self.assertIn(b"lamps/on", packets[0]) + self.assertNotIn(b"mqtt-secret", packets[0]) + view = public_devices(self.devices) + self.assertNotIn("mqtt-secret", json.dumps(view)) + self.assertNotIn("ha-token-secret-value", json.dumps(view)) + + +class TestUiAndCommands(GatewayTestCase): + def test_help_panel_and_registry(self) -> None: + html = (ROOT / "src" / "desktop" / "web" / "index.html").read_text(encoding="utf-8") + for label in ("Telegram", "Discord", "Slack", "Home Assistant", "channels-save", "devices-save", "LAN devices"): + self.assertIn(label, html) + for banned in ("WeChat", "Feishu", "DingTalk", "GLM", "Minimax", "Zhipu"): + self.assertNotIn(banned, html) + self.assertIn("/channels", _HELP_TEXT) + self.assertIn("/devices", _HELP_TEXT) + names = [spec.name for spec in build_default_registry(include_user_tools=False).list_specs()] + self.assertEqual(names[17], "Skill") + self.assertGreater(names.index("Device"), 17) + context = create_command_context(self.root) + ok, text, error = execute_command_sync("channels", "list", context) + self.assertTrue(ok, error) + self.assertIn("slack", text or "") + ok, devices, error = execute_command_sync("devices", "list", context) + self.assertTrue(ok, error) + self.assertIn("none registered", devices or "") + + def test_import_does_not_load_channel_sdks(self) -> None: + import src.channels + import src.devices + + self.assertIn("src.channels", sys.modules) + self.assertIn("src.devices", sys.modules) + for name in ("telegram", "discord", "paho", "octop_gateway", "slack_sdk"): + self.assertNotIn(name, sys.modules) + + def test_desktop_routes_inbound_and_asks_before_control(self) -> None: + update_channels([{"id": "slack", "enabled": True, "allow_user_ids": ["U1"]}], self.channels) + update_devices( + [{"id": "plug", "kind": "http", "host": "192.168.1.30", "actions": ["/on"]}], + self.devices, + ) + server = DesktopServer(DesktopRuntime(workspace=self.root, permission_timeout_s=1.0)) + status, body = server.handle_api("GET", "/api/channels", {}, {}) + self.assertEqual(status, 200) + self.assertNotIn("token", json.dumps(body).replace("token_set", "")) + accepted = server.runtime.channel_inbound({"channel": "slack", "user_id": "U1", "text": "hello"}) + self.assertTrue(accepted["accepted"]) + self.assertIn("hello", accepted["reply"]) + dropped = server.runtime.channel_inbound({"channel": "slack", "user_id": "U2", "text": "hello"}) + self.assertFalse(dropped["accepted"]) + calls: list[str] = [] + server.runtime.tool_context.devices_dir = self.devices + + def poster(method: str, url: str, headers: dict, body: bytes) -> str: + calls.append(url) + return "ok" + + server.runtime.tool_context.device_poster = poster + + def allow(tool_name: str, message: str, suggestion: str | None) -> tuple[bool, bool]: + self.assertIn("192.168.1.30", message) + del tool_name, suggestion + return True, False + + server.runtime.tool_context.permission_handler = allow + job = ChatJob(job_id="device-job") + server.runtime._run_device_slash(job, "do plug /on") + self.assertTrue(job.done) + self.assertEqual(calls, ["http://192.168.1.30:80/on"]) + + +def urlparse_host(url: str) -> str: + from urllib.parse import urlparse + + return urlparse(url).hostname or "" + + +if __name__ == "__main__": + unittest.main() From ac304f307b0a0368cee14fab32fd698704633e1e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 04:14:28 +0000 Subject: [PATCH 2/2] Ignore Git commit-graph cleanup races on Python 3.10. The in-place upgrade test already passed; temp-directory removal then failed when Git wrote objects/info/commit-graphs after the commands returned. Co-authored-by: Admin --- tests/test_app_root.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_app_root.py b/tests/test_app_root.py index 5eb5d4e..24220d9 100644 --- a/tests/test_app_root.py +++ b/tests/test_app_root.py @@ -82,7 +82,9 @@ def test_discover_reuses_clawd_code_and_prefers_jonathan_ai(self) -> None: class TestInPlaceUpgradeAndShortcuts(unittest.TestCase): def test_second_materialize_upgrades_same_folder(self) -> None: - with tempfile.TemporaryDirectory() as tmp: + # Git 2.55 can write objects/info/commit-graphs after the commands + # return. Python 3.10's temp cleanup then sees a non-empty directory. + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp: src = Path(tmp) / "checkout" dest = Path(tmp) / "Jonathan" / "Jonathan-Ai" (src / "src").mkdir(parents=True) @@ -96,7 +98,7 @@ def test_second_materialize_upgrades_same_folder(self) -> None: self.assertFalse((Path(tmp) / "Jonathan" / "Jonathan-Ai-2").exists()) def test_upgrades_previous_clawd_code_and_forces_godesk_origin(self) -> None: - with tempfile.TemporaryDirectory() as tmp: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp: dest = Path(tmp) / "Jonathan" / "Clawd-Code" dest.mkdir(parents=True) (dest / "src").mkdir()