diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index e3c03938af40..8d5238e75f0a 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -1,679 +1,688 @@ -"""OpenAI-compatible shim that forwards Hermes requests to `copilot --acp`. - -This adapter lets Hermes treat the GitHub Copilot ACP server as a chat-style -backend. Each request starts a short-lived ACP session, sends the formatted -conversation as a single prompt, collects text chunks, and converts the result -back into the minimal shape Hermes expects from an OpenAI client. -""" - -from __future__ import annotations - -import json -import os -import queue -import re -import shlex -import subprocess -import threading -import time -from collections import deque -from pathlib import Path -from types import SimpleNamespace -from typing import Any - -from agent.file_safety import get_read_block_error, is_write_denied -from agent.redact import redact_sensitive_text - -ACP_MARKER_BASE_URL = "acp://copilot" -_DEFAULT_TIMEOUT_SECONDS = 900.0 - -_TOOL_CALL_BLOCK_RE = re.compile(r"\s*(\{.*?\})\s*", re.DOTALL) -_TOOL_CALL_JSON_RE = re.compile(r"\{\s*\"id\"\s*:\s*\"[^\"]+\"\s*,\s*\"type\"\s*:\s*\"function\"\s*,\s*\"function\"\s*:\s*\{.*?\}\s*\}", re.DOTALL) - -# Stderr fingerprint of the deprecated `gh copilot` CLI extension -# (https://github.blog/changelog/2025-09-25-upcoming-deprecation-of-gh-copilot-cli-extension). -# We require BOTH the literal product name ("gh-copilot") AND a deprecation -# marker, so generic stderr from the NEW `@github/copilot` CLI — whose repo -# is github.com/github/copilot-cli and which legitimately mentions "copilot-cli" -# in its own banners and error messages — doesn't get misclassified as the -# deprecated extension. -_DEPRECATION_REQUIRED = ("gh-copilot",) -_DEPRECATION_MARKERS = ( - "has been deprecated", - "no commands will be executed", -) - - -def _is_gh_copilot_deprecation_message(stderr_text: str) -> bool: - """True iff stderr looks like the deprecated gh-copilot extension's banner.""" - - lower = stderr_text.lower() - if not any(req in lower for req in _DEPRECATION_REQUIRED): - return False - return any(marker in lower for marker in _DEPRECATION_MARKERS) - - -def _resolve_command() -> str: - return ( - os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip() - or os.getenv("COPILOT_CLI_PATH", "").strip() - or "copilot" - ) - - -def _resolve_args() -> list[str]: - raw = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip() - if not raw: - return ["--acp", "--stdio"] - return shlex.split(raw) - - -def _resolve_home_dir() -> str: - """Return a stable HOME for child ACP processes.""" - home = os.environ.get("HOME", "").strip() - if home: - return home - - expanded = os.path.expanduser("~") - if expanded and expanded != "~": - return expanded - - try: - import pwd - - resolved = pwd.getpwuid(os.getuid()).pw_dir.strip() # windows-footgun: ok — POSIX fallback inside try/except (pwd import fails on Windows) - if resolved: - return resolved - except Exception: - pass - - # Last resort: /tmp (writable on any POSIX system). Avoids crashing the - # subprocess with no HOME; callers can set HERMES_HOME explicitly if they - # need a different writable dir. - return "/tmp" - - -def _build_subprocess_env() -> dict[str, str]: - env = os.environ.copy() - home = _resolve_home_dir() - env["HOME"] = home - from hermes_constants import apply_subprocess_home_env - apply_subprocess_home_env(env) - return env - - -def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]: - return { - "jsonrpc": "2.0", - "id": message_id, - "error": { - "code": code, - "message": message, - }, - } - - -def _permission_denied(message_id: Any) -> dict[str, Any]: - return { - "jsonrpc": "2.0", - "id": message_id, - "result": { - "outcome": { - "outcome": "cancelled", - } - }, - } - - -def _format_messages_as_prompt( - messages: list[dict[str, Any]], - model: str | None = None, - tools: list[dict[str, Any]] | None = None, - tool_choice: Any = None, -) -> str: - sections: list[str] = [ - "You are being used as the active ACP agent backend for Hermes.", - "Use ACP capabilities to complete tasks.", - "IMPORTANT: If you take an action with a tool, you MUST output tool calls using {...} blocks with JSON exactly in OpenAI function-call shape.", - "If no tool is needed, answer normally.", - ] - if model: - sections.append(f"Hermes requested model hint: {model}") - - if isinstance(tools, list) and tools: - tool_specs: list[dict[str, Any]] = [] - for t in tools: - if not isinstance(t, dict): - continue - fn = t.get("function") or {} - if not isinstance(fn, dict): - continue - name = fn.get("name") - if not isinstance(name, str) or not name.strip(): - continue - tool_specs.append( - { - "name": name.strip(), - "description": fn.get("description", ""), - "parameters": fn.get("parameters", {}), - } - ) - if tool_specs: - sections.append( - "Available tools (OpenAI function schema). " - "When using a tool, emit ONLY {...} with one JSON object " - "containing id/type/function{name,arguments}. arguments must be a JSON string.\n" - + json.dumps(tool_specs, ensure_ascii=False) - ) - - if tool_choice is not None: - sections.append(f"Tool choice hint: {json.dumps(tool_choice, ensure_ascii=False)}") - - transcript: list[str] = [] - for message in messages: - if not isinstance(message, dict): - continue - role = str(message.get("role") or "unknown").strip().lower() - if role == "tool": - role = "tool" - elif role not in {"system", "user", "assistant"}: - role = "context" - - content = message.get("content") - rendered = _render_message_content(content) - if not rendered: - continue - - label = { - "system": "System", - "user": "User", - "assistant": "Assistant", - "tool": "Tool", - "context": "Context", - }.get(role, role.title()) - transcript.append(f"{label}:\n{rendered}") - - if transcript: - sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript)) - - sections.append("Continue the conversation from the latest user request.") - return "\n\n".join(section.strip() for section in sections if section and section.strip()) - - -def _render_message_content(content: Any) -> str: - if content is None: - return "" - if isinstance(content, str): - return content.strip() - if isinstance(content, dict): - if "text" in content: - return str(content.get("text") or "").strip() - if "content" in content and isinstance(content.get("content"), str): - return str(content.get("content") or "").strip() - return json.dumps(content, ensure_ascii=True) - if isinstance(content, list): - parts: list[str] = [] - for item in content: - if isinstance(item, str): - parts.append(item) - elif isinstance(item, dict): - text = item.get("text") - if isinstance(text, str) and text.strip(): - parts.append(text.strip()) - return "\n".join(parts).strip() - return str(content).strip() - - -def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]: - if not isinstance(text, str) or not text.strip(): - return [], "" - - extracted: list[SimpleNamespace] = [] - consumed_spans: list[tuple[int, int]] = [] - - def _try_add_tool_call(raw_json: str) -> None: - try: - obj = json.loads(raw_json) - except Exception: - return - if not isinstance(obj, dict): - return - fn = obj.get("function") - if not isinstance(fn, dict): - return - fn_name = fn.get("name") - if not isinstance(fn_name, str) or not fn_name.strip(): - return - fn_args = fn.get("arguments", "{}") - if not isinstance(fn_args, str): - fn_args = json.dumps(fn_args, ensure_ascii=False) - call_id = obj.get("id") - if not isinstance(call_id, str) or not call_id.strip(): - call_id = f"acp_call_{len(extracted)+1}" - - extracted.append( - SimpleNamespace( - id=call_id, - call_id=call_id, - response_item_id=None, - type="function", - function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args), - ) - ) - - for m in _TOOL_CALL_BLOCK_RE.finditer(text): - raw = m.group(1) - _try_add_tool_call(raw) - consumed_spans.append((m.start(), m.end())) - - # Only try bare-JSON fallback when no XML blocks were found. - if not extracted: - for m in _TOOL_CALL_JSON_RE.finditer(text): - raw = m.group(0) - _try_add_tool_call(raw) - consumed_spans.append((m.start(), m.end())) - - if not consumed_spans: - return extracted, text.strip() - - consumed_spans.sort() - merged: list[tuple[int, int]] = [] - for start, end in consumed_spans: - if not merged or start > merged[-1][1]: - merged.append((start, end)) - else: - merged[-1] = (merged[-1][0], max(merged[-1][1], end)) - - parts: list[str] = [] - cursor = 0 - for start, end in merged: - if cursor < start: - parts.append(text[cursor:start]) - cursor = max(cursor, end) - if cursor < len(text): - parts.append(text[cursor:]) - - cleaned = "\n".join(p.strip() for p in parts if p and p.strip()).strip() - return extracted, cleaned - - - -def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path: - candidate = Path(path_text) - if not candidate.is_absolute(): - raise PermissionError("ACP file-system paths must be absolute.") - resolved = candidate.resolve() - root = Path(cwd).resolve() - try: - resolved.relative_to(root) - except ValueError as exc: - raise PermissionError(f"Path '{resolved}' is outside the session cwd '{root}'.") from exc - return resolved - - -class _ACPChatCompletions: - def __init__(self, client: "CopilotACPClient"): - self._client = client - - def create(self, **kwargs: Any) -> Any: - return self._client._create_chat_completion(**kwargs) - - -class _ACPChatNamespace: - def __init__(self, client: "CopilotACPClient"): - self.completions = _ACPChatCompletions(client) - - -class CopilotACPClient: - """Minimal OpenAI-client-compatible facade for Copilot ACP.""" - - def __init__( - self, - *, - api_key: str | None = None, - base_url: str | None = None, - default_headers: dict[str, str] | None = None, - acp_command: str | None = None, - acp_args: list[str] | None = None, - acp_cwd: str | None = None, - command: str | None = None, - args: list[str] | None = None, - **_: Any, - ): - self.api_key = api_key or "copilot-acp" - self.base_url = base_url or ACP_MARKER_BASE_URL - self._default_headers = dict(default_headers or {}) - self._acp_command = acp_command or command or _resolve_command() - self._acp_args = list(acp_args or args or _resolve_args()) - self._acp_cwd = str(Path(acp_cwd or os.getcwd()).resolve()) - self.chat = _ACPChatNamespace(self) - self.is_closed = False - self._active_process: subprocess.Popen[str] | None = None - self._active_process_lock = threading.Lock() - - def close(self) -> None: - proc: subprocess.Popen[str] | None - with self._active_process_lock: - proc = self._active_process - self._active_process = None - self.is_closed = True - if proc is None: - return - try: - proc.terminate() - proc.wait(timeout=2) - except Exception: - try: - proc.kill() - except Exception: - pass - - def _create_chat_completion( - self, - *, - model: str | None = None, - messages: list[dict[str, Any]] | None = None, - timeout: float | None = None, - tools: list[dict[str, Any]] | None = None, - tool_choice: Any = None, - **_: Any, - ) -> Any: - prompt_text = _format_messages_as_prompt( - messages or [], - model=model, - tools=tools, - tool_choice=tool_choice, - ) - # Normalise timeout: run_agent.py may pass an httpx.Timeout object - # (used natively by the OpenAI SDK) rather than a plain float. - if timeout is None: - _effective_timeout = _DEFAULT_TIMEOUT_SECONDS - elif isinstance(timeout, (int, float)): - _effective_timeout = float(timeout) - else: - # httpx.Timeout or similar — pick the largest component so the - # subprocess has enough wall-clock time for the full response. - _candidates = [ - getattr(timeout, attr, None) - for attr in ("read", "write", "connect", "pool", "timeout") - ] - _numeric = [float(v) for v in _candidates if isinstance(v, (int, float))] - _effective_timeout = max(_numeric) if _numeric else _DEFAULT_TIMEOUT_SECONDS - - response_text, reasoning_text = self._run_prompt( - prompt_text, - timeout_seconds=_effective_timeout, - ) - - tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text) - - usage = SimpleNamespace( - prompt_tokens=0, - completion_tokens=0, - total_tokens=0, - prompt_tokens_details=SimpleNamespace(cached_tokens=0), - ) - assistant_message = SimpleNamespace( - content=cleaned_text, - tool_calls=tool_calls, - reasoning=reasoning_text or None, - reasoning_content=reasoning_text or None, - reasoning_details=None, - ) - finish_reason = "tool_calls" if tool_calls else "stop" - choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason) - return SimpleNamespace( - choices=[choice], - usage=usage, - model=model or "copilot-acp", - ) - - def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]: - try: - proc = subprocess.Popen( - [self._acp_command] + self._acp_args, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, - cwd=self._acp_cwd, - env=_build_subprocess_env(), - ) - except FileNotFoundError as exc: - raise RuntimeError( - f"Could not start Copilot ACP command '{self._acp_command}'. " - "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH." - ) from exc - - if proc.stdin is None or proc.stdout is None: - proc.kill() - raise RuntimeError("Copilot ACP process did not expose stdin/stdout pipes.") - - self.is_closed = False - with self._active_process_lock: - self._active_process = proc - - inbox: queue.Queue[dict[str, Any]] = queue.Queue() - stderr_tail: deque[str] = deque(maxlen=40) - - def _stdout_reader() -> None: - if proc.stdout is None: - return - for line in proc.stdout: - try: - inbox.put(json.loads(line)) - except Exception: - inbox.put({"raw": line.rstrip("\n")}) - - def _stderr_reader() -> None: - if proc.stderr is None: - return - for line in proc.stderr: - stderr_tail.append(line.rstrip("\n")) - - out_thread = threading.Thread(target=_stdout_reader, daemon=True) - err_thread = threading.Thread(target=_stderr_reader, daemon=True) - out_thread.start() - err_thread.start() - - next_id = 0 - - def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | None = None, reasoning_parts: list[str] | None = None) -> Any: - nonlocal next_id - next_id += 1 - request_id = next_id - payload = { - "jsonrpc": "2.0", - "id": request_id, - "method": method, - "params": params, - } - proc.stdin.write(json.dumps(payload) + "\n") - proc.stdin.flush() - - deadline = time.monotonic() + timeout_seconds - while time.monotonic() < deadline: - if proc.poll() is not None: - break - try: - msg = inbox.get(timeout=0.1) - except queue.Empty: - continue - - if self._handle_server_message( - msg, - process=proc, - cwd=self._acp_cwd, - text_parts=text_parts, - reasoning_parts=reasoning_parts, - ): - continue - - if msg.get("id") != request_id: - continue - if "error" in msg: - err = msg.get("error") or {} - raise RuntimeError( - f"Copilot ACP {method} failed: {err.get('message') or err}" - ) - return msg.get("result") - - stderr_text = "\n".join(stderr_tail).strip() - if proc.poll() is not None and stderr_text: - if _is_gh_copilot_deprecation_message(stderr_text): - raise RuntimeError( - "Hermes ACP mode requires the NEW GitHub Copilot CLI " - "(github.com/github/copilot-cli), but the binary it just " - "spawned is the deprecated `gh copilot` extension.\n\n" - "Install the new CLI:\n" - " npm install -g @github/copilot\n" - " # then verify with: copilot --help\n\n" - "If `copilot` already resolves to the new CLI but you still see this,\n" - "point Hermes at it explicitly:\n" - " export HERMES_COPILOT_ACP_COMMAND=/path/to/new/copilot\n\n" - "Alternative: use the `copilot` provider (no ACP, hits the Copilot API\n" - "directly with a Copilot subscription token) via `hermes setup`.\n\n" - f"Original error:\n{stderr_text}" - ) - raise RuntimeError(f"Copilot ACP process exited early: {stderr_text}") - raise TimeoutError(f"Timed out waiting for Copilot ACP response to {method}.") - - try: - _request( - "initialize", - { - "protocolVersion": 1, - "clientCapabilities": { - "fs": { - "readTextFile": True, - "writeTextFile": True, - } - }, - "clientInfo": { - "name": "hermes-agent", - "title": "Hermes Agent", - "version": "0.0.0", - }, - }, - ) - session = _request( - "session/new", - { - "cwd": self._acp_cwd, - "mcpServers": [], - }, - ) or {} - session_id = str(session.get("sessionId") or "").strip() - if not session_id: - raise RuntimeError("Copilot ACP did not return a sessionId.") - - text_parts: list[str] = [] - reasoning_parts: list[str] = [] - _request( - "session/prompt", - { - "sessionId": session_id, - "prompt": [ - { - "type": "text", - "text": prompt_text, - } - ], - }, - text_parts=text_parts, - reasoning_parts=reasoning_parts, - ) - return "".join(text_parts), "".join(reasoning_parts) - finally: - self.close() - - def _handle_server_message( - self, - msg: dict[str, Any], - *, - process: subprocess.Popen[str], - cwd: str, - text_parts: list[str] | None, - reasoning_parts: list[str] | None, - ) -> bool: - method = msg.get("method") - if not isinstance(method, str): - return False - - if method == "session/update": - params = msg.get("params") or {} - update = params.get("update") or {} - kind = str(update.get("sessionUpdate") or "").strip() - content = update.get("content") or {} - chunk_text = "" - if isinstance(content, dict): - chunk_text = str(content.get("text") or "") - if kind == "agent_message_chunk" and chunk_text and text_parts is not None: - text_parts.append(chunk_text) - elif kind == "agent_thought_chunk" and chunk_text and reasoning_parts is not None: - reasoning_parts.append(chunk_text) - return True - - if process.stdin is None: - return True - - message_id = msg.get("id") - params = msg.get("params") or {} - - if method == "session/request_permission": - response = _permission_denied(message_id) - elif method == "fs/read_text_file": - try: - path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) - block_error = get_read_block_error(str(path)) - if block_error: - raise PermissionError(block_error) - try: - content = path.read_text() - except FileNotFoundError: - content = "" - line = params.get("line") - limit = params.get("limit") - if isinstance(line, int) and line > 1: - lines = content.splitlines(keepends=True) - start = line - 1 - end = start + limit if isinstance(limit, int) and limit > 0 else None - content = "".join(lines[start:end]) - if content: - content = redact_sensitive_text(content, force=True) - response = { - "jsonrpc": "2.0", - "id": message_id, - "result": { - "content": content, - }, - } - except Exception as exc: - response = _jsonrpc_error(message_id, -32602, str(exc)) - elif method == "fs/write_text_file": - try: - path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) - if is_write_denied(str(path)): - raise PermissionError( - f"Write denied: '{path}' is a protected system/credential file." - ) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(str(params.get("content") or "")) - response = { - "jsonrpc": "2.0", - "id": message_id, - "result": None, - } - except Exception as exc: - response = _jsonrpc_error(message_id, -32602, str(exc)) - else: - response = _jsonrpc_error( - message_id, - -32601, - f"ACP client method '{method}' is not supported by Hermes yet.", - ) - - process.stdin.write(json.dumps(response) + "\n") - process.stdin.flush() - return True +"""OpenAI-compatible shim that forwards Hermes requests to `copilot --acp`. + +This adapter lets Hermes treat the GitHub Copilot ACP server as a chat-style +backend. Each request starts a short-lived ACP session, sends the formatted +conversation as a single prompt, collects text chunks, and converts the result +back into the minimal shape Hermes expects from an OpenAI client. +""" + +from __future__ import annotations + +import json +import os +import queue +import re +import shlex +import subprocess +import threading +import time +from collections import deque +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +from agent.file_safety import get_read_block_error, is_write_denied +from agent.redact import redact_sensitive_text + +ACP_MARKER_BASE_URL = "acp://copilot" +_DEFAULT_TIMEOUT_SECONDS = 900.0 + +_TOOL_CALL_BLOCK_RE = re.compile(r"\s*(\{.*?\})\s*", re.DOTALL) +_TOOL_CALL_JSON_RE = re.compile(r"\{\s*\"id\"\s*:\s*\"[^\"]+\"\s*,\s*\"type\"\s*:\s*\"function\"\s*,\s*\"function\"\s*:\s*\{.*?\}\s*\}", re.DOTALL) + +# Stderr fingerprint of the deprecated `gh copilot` CLI extension +# (https://github.blog/changelog/2025-09-25-upcoming-deprecation-of-gh-copilot-cli-extension). +# We require BOTH the literal product name ("gh-copilot") AND a deprecation +# marker, so generic stderr from the NEW `@github/copilot` CLI — whose repo +# is github.com/github/copilot-cli and which legitimately mentions "copilot-cli" +# in its own banners and error messages — doesn't get misclassified as the +# deprecated extension. +_DEPRECATION_REQUIRED = ("gh-copilot",) +_DEPRECATION_MARKERS = ( + "has been deprecated", + "no commands will be executed", +) + + +def _is_gh_copilot_deprecation_message(stderr_text: str) -> bool: + """True iff stderr looks like the deprecated gh-copilot extension's banner.""" + + lower = stderr_text.lower() + if not any(req in lower for req in _DEPRECATION_REQUIRED): + return False + return any(marker in lower for marker in _DEPRECATION_MARKERS) + + +def _resolve_command() -> str: + return ( + os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip() + or os.getenv("COPILOT_CLI_PATH", "").strip() + or "copilot" + ) + + +def _resolve_args() -> list[str]: + raw = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip() + if not raw: + return ["--acp", "--stdio"] + return shlex.split(raw) + + +def _resolve_home_dir() -> str: + """Return a stable HOME for child ACP processes.""" + home = os.environ.get("HOME", "").strip() + if home: + return home + + expanded = os.path.expanduser("~") + if expanded and expanded != "~": + return expanded + + try: + import pwd + + resolved = pwd.getpwuid(os.getuid()).pw_dir.strip() # windows-footgun: ok — POSIX fallback inside try/except (pwd import fails on Windows) + if resolved: + return resolved + except Exception: + pass + + # Last resort: /tmp (writable on any POSIX system). Avoids crashing the + # subprocess with no HOME; callers can set HERMES_HOME explicitly if they + # need a different writable dir. + return "/tmp" + + +def _build_subprocess_env() -> dict[str, str]: + env = os.environ.copy() + home = _resolve_home_dir() + env["HOME"] = home + from hermes_constants import apply_subprocess_home_env + apply_subprocess_home_env(env) + return env + + +def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": message_id, + "error": { + "code": code, + "message": message, + }, + } + + +def _permission_denied(message_id: Any) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": message_id, + "result": { + "outcome": { + "outcome": "cancelled", + } + }, + } + + +def _format_messages_as_prompt( + messages: list[dict[str, Any]], + model: str | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, +) -> str: + sections: list[str] = [ + "You are being used as the active ACP agent backend for Hermes.", + "Use ACP capabilities to complete tasks.", + "IMPORTANT: If you take an action with a tool, you MUST output tool calls using {...} blocks with JSON exactly in OpenAI function-call shape.", + "If no tool is needed, answer normally.", + ] + if model: + sections.append(f"Hermes requested model hint: {model}") + + if isinstance(tools, list) and tools: + tool_specs: list[dict[str, Any]] = [] + for t in tools: + if not isinstance(t, dict): + continue + fn = t.get("function") or {} + if not isinstance(fn, dict): + continue + name = fn.get("name") + if not isinstance(name, str) or not name.strip(): + continue + tool_specs.append( + { + "name": name.strip(), + "description": fn.get("description", ""), + "parameters": fn.get("parameters", {}), + } + ) + if tool_specs: + sections.append( + "Available tools (OpenAI function schema). " + "When using a tool, emit ONLY {...} with one JSON object " + "containing id/type/function{name,arguments}. arguments must be a JSON string.\n" + + json.dumps(tool_specs, ensure_ascii=False) + ) + + if tool_choice is not None: + sections.append(f"Tool choice hint: {json.dumps(tool_choice, ensure_ascii=False)}") + + transcript: list[str] = [] + for message in messages: + if not isinstance(message, dict): + continue + role = str(message.get("role") or "unknown").strip().lower() + if role == "tool": + role = "tool" + elif role not in {"system", "user", "assistant"}: + role = "context" + + content = message.get("content") + rendered = _render_message_content(content) + if not rendered: + continue + + label = { + "system": "System", + "user": "User", + "assistant": "Assistant", + "tool": "Tool", + "context": "Context", + }.get(role, role.title()) + transcript.append(f"{label}:\n{rendered}") + + if transcript: + sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript)) + + sections.append("Continue the conversation from the latest user request.") + return "\n\n".join(section.strip() for section in sections if section and section.strip()) + + +def _render_message_content(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content.strip() + if isinstance(content, dict): + if "text" in content: + return str(content.get("text") or "").strip() + if "content" in content and isinstance(content.get("content"), str): + return str(content.get("content") or "").strip() + return json.dumps(content, ensure_ascii=True) + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + text = item.get("text") + if isinstance(text, str) and text.strip(): + parts.append(text.strip()) + return "\n".join(parts).strip() + return str(content).strip() + + +def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]: + if not isinstance(text, str) or not text.strip(): + return [], "" + + extracted: list[SimpleNamespace] = [] + consumed_spans: list[tuple[int, int]] = [] + + def _try_add_tool_call(raw_json: str) -> None: + try: + obj = json.loads(raw_json) + except Exception: + return + if not isinstance(obj, dict): + return + fn = obj.get("function") + if not isinstance(fn, dict): + return + fn_name = fn.get("name") + if not isinstance(fn_name, str) or not fn_name.strip(): + return + fn_args = fn.get("arguments", "{}") + if not isinstance(fn_args, str): + fn_args = json.dumps(fn_args, ensure_ascii=False) + call_id = obj.get("id") + if not isinstance(call_id, str) or not call_id.strip(): + call_id = f"acp_call_{len(extracted)+1}" + + extracted.append( + SimpleNamespace( + id=call_id, + call_id=call_id, + response_item_id=None, + type="function", + function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args), + ) + ) + + for m in _TOOL_CALL_BLOCK_RE.finditer(text): + raw = m.group(1) + _try_add_tool_call(raw) + consumed_spans.append((m.start(), m.end())) + + # Only try bare-JSON fallback when no XML blocks were found. + if not extracted: + for m in _TOOL_CALL_JSON_RE.finditer(text): + raw = m.group(0) + _try_add_tool_call(raw) + consumed_spans.append((m.start(), m.end())) + + if not consumed_spans: + return extracted, text.strip() + + consumed_spans.sort() + merged: list[tuple[int, int]] = [] + for start, end in consumed_spans: + if not merged or start > merged[-1][1]: + merged.append((start, end)) + else: + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + + parts: list[str] = [] + cursor = 0 + for start, end in merged: + if cursor < start: + parts.append(text[cursor:start]) + cursor = max(cursor, end) + if cursor < len(text): + parts.append(text[cursor:]) + + cleaned = "\n".join(p.strip() for p in parts if p and p.strip()).strip() + return extracted, cleaned + + + +def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path: + candidate = Path(path_text) + if not candidate.is_absolute(): + raise PermissionError("ACP file-system paths must be absolute.") + resolved = candidate.resolve() + root = Path(cwd).resolve() + try: + resolved.relative_to(root) + except ValueError as exc: + raise PermissionError(f"Path '{resolved}' is outside the session cwd '{root}'.") from exc + return resolved + + +class _ACPChatCompletions: + def __init__(self, client: "CopilotACPClient"): + self._client = client + + def create(self, **kwargs: Any) -> Any: + return self._client._create_chat_completion(**kwargs) + + +class _ACPChatNamespace: + def __init__(self, client: "CopilotACPClient"): + self.completions = _ACPChatCompletions(client) + + +class CopilotACPClient: + """Minimal OpenAI-client-compatible facade for Copilot ACP.""" + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + default_headers: dict[str, str] | None = None, + acp_command: str | None = None, + acp_args: list[str] | None = None, + acp_cwd: str | None = None, + command: str | None = None, + args: list[str] | None = None, + **_: Any, + ): + self.api_key = api_key or "copilot-acp" + self.base_url = base_url or ACP_MARKER_BASE_URL + self._default_headers = dict(default_headers or {}) + self._acp_command = acp_command or command or _resolve_command() + self._acp_args = list(acp_args or args or _resolve_args()) + self._acp_cwd = str(Path(acp_cwd or os.getcwd()).resolve()) + self.chat = _ACPChatNamespace(self) + self.is_closed = False + self._active_process: subprocess.Popen[str] | None = None + self._active_process_lock = threading.Lock() + + def close(self) -> None: + proc: subprocess.Popen[str] | None + with self._active_process_lock: + proc = self._active_process + self._active_process = None + self.is_closed = True + if proc is None: + return + try: + proc.terminate() + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + + def _create_chat_completion( + self, + *, + model: str | None = None, + messages: list[dict[str, Any]] | None = None, + timeout: float | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, + **_: Any, + ) -> Any: + prompt_text = _format_messages_as_prompt( + messages or [], + model=model, + tools=tools, + tool_choice=tool_choice, + ) + # Normalise timeout: run_agent.py may pass an httpx.Timeout object + # (used natively by the OpenAI SDK) rather than a plain float. + if timeout is None: + _effective_timeout = _DEFAULT_TIMEOUT_SECONDS + elif isinstance(timeout, (int, float)): + _effective_timeout = float(timeout) + else: + # httpx.Timeout or similar — pick the largest component so the + # subprocess has enough wall-clock time for the full response. + _candidates = [ + getattr(timeout, attr, None) + for attr in ("read", "write", "connect", "pool", "timeout") + ] + _numeric = [float(v) for v in _candidates if isinstance(v, (int, float))] + _effective_timeout = max(_numeric) if _numeric else _DEFAULT_TIMEOUT_SECONDS + + response_text, reasoning_text = self._run_prompt( + prompt_text, + timeout_seconds=_effective_timeout, + ) + + tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text) + + usage = SimpleNamespace( + prompt_tokens=0, + completion_tokens=0, + total_tokens=0, + prompt_tokens_details=SimpleNamespace(cached_tokens=0), + ) + assistant_message = SimpleNamespace( + content=cleaned_text, + tool_calls=tool_calls, + reasoning=reasoning_text or None, + reasoning_content=reasoning_text or None, + reasoning_details=None, + ) + finish_reason = "tool_calls" if tool_calls else "stop" + choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason) + return SimpleNamespace( + choices=[choice], + usage=usage, + model=model or "copilot-acp", + ) + + def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]: + try: + _extra: dict = {} + if sys.platform == "win32": + from hermes_cli._subprocess_compat import windows_hide_flags + _si = subprocess.STARTUPINFO() + _si.dwFlags |= subprocess.STARTF_USESHOWWINDOW + _si.wShowWindow = 0 # SW_HIDE + _extra["creationflags"] = windows_hide_flags() + _extra["startupinfo"] = _si + proc = subprocess.Popen( + [self._acp_command] + self._acp_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + cwd=self._acp_cwd, + env=_build_subprocess_env(), + **_extra, + ) + except FileNotFoundError as exc: + raise RuntimeError( + f"Could not start Copilot ACP command '{self._acp_command}'. " + "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH." + ) from exc + + if proc.stdin is None or proc.stdout is None: + proc.kill() + raise RuntimeError("Copilot ACP process did not expose stdin/stdout pipes.") + + self.is_closed = False + with self._active_process_lock: + self._active_process = proc + + inbox: queue.Queue[dict[str, Any]] = queue.Queue() + stderr_tail: deque[str] = deque(maxlen=40) + + def _stdout_reader() -> None: + if proc.stdout is None: + return + for line in proc.stdout: + try: + inbox.put(json.loads(line)) + except Exception: + inbox.put({"raw": line.rstrip("\n")}) + + def _stderr_reader() -> None: + if proc.stderr is None: + return + for line in proc.stderr: + stderr_tail.append(line.rstrip("\n")) + + out_thread = threading.Thread(target=_stdout_reader, daemon=True) + err_thread = threading.Thread(target=_stderr_reader, daemon=True) + out_thread.start() + err_thread.start() + + next_id = 0 + + def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | None = None, reasoning_parts: list[str] | None = None) -> Any: + nonlocal next_id + next_id += 1 + request_id = next_id + payload = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + } + proc.stdin.write(json.dumps(payload) + "\n") + proc.stdin.flush() + + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if proc.poll() is not None: + break + try: + msg = inbox.get(timeout=0.1) + except queue.Empty: + continue + + if self._handle_server_message( + msg, + process=proc, + cwd=self._acp_cwd, + text_parts=text_parts, + reasoning_parts=reasoning_parts, + ): + continue + + if msg.get("id") != request_id: + continue + if "error" in msg: + err = msg.get("error") or {} + raise RuntimeError( + f"Copilot ACP {method} failed: {err.get('message') or err}" + ) + return msg.get("result") + + stderr_text = "\n".join(stderr_tail).strip() + if proc.poll() is not None and stderr_text: + if _is_gh_copilot_deprecation_message(stderr_text): + raise RuntimeError( + "Hermes ACP mode requires the NEW GitHub Copilot CLI " + "(github.com/github/copilot-cli), but the binary it just " + "spawned is the deprecated `gh copilot` extension.\n\n" + "Install the new CLI:\n" + " npm install -g @github/copilot\n" + " # then verify with: copilot --help\n\n" + "If `copilot` already resolves to the new CLI but you still see this,\n" + "point Hermes at it explicitly:\n" + " export HERMES_COPILOT_ACP_COMMAND=/path/to/new/copilot\n\n" + "Alternative: use the `copilot` provider (no ACP, hits the Copilot API\n" + "directly with a Copilot subscription token) via `hermes setup`.\n\n" + f"Original error:\n{stderr_text}" + ) + raise RuntimeError(f"Copilot ACP process exited early: {stderr_text}") + raise TimeoutError(f"Timed out waiting for Copilot ACP response to {method}.") + + try: + _request( + "initialize", + { + "protocolVersion": 1, + "clientCapabilities": { + "fs": { + "readTextFile": True, + "writeTextFile": True, + } + }, + "clientInfo": { + "name": "hermes-agent", + "title": "Hermes Agent", + "version": "0.0.0", + }, + }, + ) + session = _request( + "session/new", + { + "cwd": self._acp_cwd, + "mcpServers": [], + }, + ) or {} + session_id = str(session.get("sessionId") or "").strip() + if not session_id: + raise RuntimeError("Copilot ACP did not return a sessionId.") + + text_parts: list[str] = [] + reasoning_parts: list[str] = [] + _request( + "session/prompt", + { + "sessionId": session_id, + "prompt": [ + { + "type": "text", + "text": prompt_text, + } + ], + }, + text_parts=text_parts, + reasoning_parts=reasoning_parts, + ) + return "".join(text_parts), "".join(reasoning_parts) + finally: + self.close() + + def _handle_server_message( + self, + msg: dict[str, Any], + *, + process: subprocess.Popen[str], + cwd: str, + text_parts: list[str] | None, + reasoning_parts: list[str] | None, + ) -> bool: + method = msg.get("method") + if not isinstance(method, str): + return False + + if method == "session/update": + params = msg.get("params") or {} + update = params.get("update") or {} + kind = str(update.get("sessionUpdate") or "").strip() + content = update.get("content") or {} + chunk_text = "" + if isinstance(content, dict): + chunk_text = str(content.get("text") or "") + if kind == "agent_message_chunk" and chunk_text and text_parts is not None: + text_parts.append(chunk_text) + elif kind == "agent_thought_chunk" and chunk_text and reasoning_parts is not None: + reasoning_parts.append(chunk_text) + return True + + if process.stdin is None: + return True + + message_id = msg.get("id") + params = msg.get("params") or {} + + if method == "session/request_permission": + response = _permission_denied(message_id) + elif method == "fs/read_text_file": + try: + path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) + block_error = get_read_block_error(str(path)) + if block_error: + raise PermissionError(block_error) + try: + content = path.read_text() + except FileNotFoundError: + content = "" + line = params.get("line") + limit = params.get("limit") + if isinstance(line, int) and line > 1: + lines = content.splitlines(keepends=True) + start = line - 1 + end = start + limit if isinstance(limit, int) and limit > 0 else None + content = "".join(lines[start:end]) + if content: + content = redact_sensitive_text(content, force=True) + response = { + "jsonrpc": "2.0", + "id": message_id, + "result": { + "content": content, + }, + } + except Exception as exc: + response = _jsonrpc_error(message_id, -32602, str(exc)) + elif method == "fs/write_text_file": + try: + path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd) + if is_write_denied(str(path)): + raise PermissionError( + f"Write denied: '{path}' is a protected system/credential file." + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(str(params.get("content") or "")) + response = { + "jsonrpc": "2.0", + "id": message_id, + "result": None, + } + except Exception as exc: + response = _jsonrpc_error(message_id, -32602, str(exc)) + else: + response = _jsonrpc_error( + message_id, + -32601, + f"ACP client method '{method}' is not supported by Hermes yet.", + ) + + process.stdin.write(json.dumps(response) + "\n") + process.stdin.flush() + return True diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 4e2b2ddd7c3d..f99c619dcc71 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -1,847 +1,852 @@ -""" -Shell-script hooks bridge. - -Reads the ``hooks:`` block from ``cli-config.yaml``, prompts the user for -consent on first use of each ``(event, command)`` pair, and registers -callbacks on the existing plugin hook manager so every existing -``invoke_hook()`` site dispatches to the configured shell scripts — with -zero changes to call sites. - -Design notes ------------- -* Python plugins and shell hooks compose naturally: both flow through - :func:`hermes_cli.plugins.invoke_hook` and its aggregators. Python - plugins are registered first (via ``discover_and_load()``) so their - block decisions win ties over shell-hook blocks. -* Subprocess execution uses ``shlex.split(os.path.expanduser(command))`` - with ``shell=False`` — no shell injection footguns. Users that need - pipes/redirection wrap their logic in a script. -* First-use consent is gated by the allowlist under - ``~/.hermes/shell-hooks-allowlist.json``. Non-TTY callers must pass - ``accept_hooks=True`` (resolved from ``--accept-hooks``, - ``HERMES_ACCEPT_HOOKS``, or ``hooks_auto_accept: true`` in config) - for registration to succeed without a prompt. -* Registration is idempotent — safe to invoke from both the CLI entry - point (``hermes_cli/main.py``) and the gateway entry point - (``gateway/run.py``). - -Wire protocol -------------- -**stdin** (JSON, piped to the script):: - - { - "hook_event_name": "pre_tool_call", - "tool_name": "terminal", - "tool_input": {"command": "rm -rf /"}, - "session_id": "sess_abc123", - "cwd": "/home/user/project", - "extra": {...} # event-specific kwargs - } - -**stdout** (JSON, optional — anything else is ignored):: - - # Block a pre_tool_call (either shape accepted; normalised internally): - {"decision": "block", "reason": "Forbidden command"} # Claude-Code-style - {"action": "block", "message": "Forbidden command"} # Hermes-canonical - - # Inject context for pre_llm_call: - {"context": "Today is Friday"} - - # Silent no-op: - -""" - -from __future__ import annotations - -import difflib -import json -import logging -import os -import re -import shlex -import subprocess -import sys -import tempfile -import threading -import time -from contextlib import contextmanager -from dataclasses import dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple - -try: - import fcntl # POSIX only; Windows falls back to best-effort without flock. -except ImportError: # pragma: no cover - fcntl = None # type: ignore[assignment] - -from hermes_constants import get_hermes_home -from utils import atomic_replace - -logger = logging.getLogger(__name__) - -DEFAULT_TIMEOUT_SECONDS = 60 -MAX_TIMEOUT_SECONDS = 300 -ALLOWLIST_FILENAME = "shell-hooks-allowlist.json" -_DEFAULT_BLOCK_MESSAGE = "Blocked by shell hook." - -# (event, matcher, command) triples that have been wired to the plugin -# manager in the current process. Matcher is part of the key because -# the same script can legitimately register for different matchers under -# the same event (e.g. one entry per tool the user wants to gate). -# Second registration attempts for the exact same triple become no-ops -# so the CLI and gateway can both call register_from_config() safely. -_registered: Set[Tuple[str, Optional[str], str]] = set() -_registered_lock = threading.Lock() - -# Intra-process lock for allowlist read-modify-write on platforms that -# lack ``fcntl`` (non-POSIX). Kept separate from ``_registered_lock`` -# because ``register_from_config`` already holds ``_registered_lock`` when -# it triggers ``_record_approval`` — reusing it here would self-deadlock -# (``threading.Lock`` is non-reentrant). POSIX callers use the sibling -# ``.lock`` file via ``fcntl.flock`` and bypass this. -_allowlist_write_lock = threading.Lock() - - -@dataclass -class ShellHookSpec: - """Parsed and validated representation of a single ``hooks:`` entry.""" - - event: str - command: str - matcher: Optional[str] = None - timeout: int = DEFAULT_TIMEOUT_SECONDS - compiled_matcher: Optional[re.Pattern] = field(default=None, repr=False) - - def __post_init__(self) -> None: - # Strip whitespace introduced by YAML quirks (e.g. multi-line string - # folding) — a matcher of " terminal" would otherwise silently fail - # to match "terminal" without any diagnostic. - if isinstance(self.matcher, str): - stripped = self.matcher.strip() - self.matcher = stripped if stripped else None - if self.matcher: - try: - self.compiled_matcher = re.compile(self.matcher) - except re.error as exc: - logger.warning( - "shell hook matcher %r is invalid (%s) — treating as " - "literal equality", self.matcher, exc, - ) - self.compiled_matcher = None - - def matches_tool(self, tool_name: Optional[str]) -> bool: - if not self.matcher: - return True - if tool_name is None: - return False - if self.compiled_matcher is not None: - return self.compiled_matcher.fullmatch(tool_name) is not None - # compiled_matcher is None only when the regex failed to compile, - # in which case we already warned and fall back to literal equality. - return tool_name == self.matcher - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - -def register_from_config( - cfg: Optional[Dict[str, Any]], - *, - accept_hooks: bool = False, -) -> List[ShellHookSpec]: - """Register every configured shell hook on the plugin manager. - - ``cfg`` is the full parsed config dict (``hermes_cli.config.load_config`` - output). The ``hooks:`` key is read out of it. Missing, empty, or - non-dict ``hooks`` is treated as zero configured hooks. - - ``accept_hooks=True`` skips the TTY consent prompt — the caller is - promising that the user has opted in via a flag, env var, or config - setting. ``HERMES_ACCEPT_HOOKS=1`` and ``hooks_auto_accept: true`` are - also honored inside this function so either CLI or gateway call sites - pick them up. - - Returns the list of :class:`ShellHookSpec` entries that ended up wired - up on the plugin manager. Skipped entries (unknown events, malformed, - not allowlisted, already registered) are logged but not returned. - """ - if not isinstance(cfg, dict): - return [] - - effective_accept = _resolve_effective_accept(cfg, accept_hooks) - - specs = _parse_hooks_block(cfg.get("hooks")) - if not specs: - return [] - - registered: List[ShellHookSpec] = [] - - # Import lazily — avoids circular imports at module-load time. - from hermes_cli.plugins import get_plugin_manager - - manager = get_plugin_manager() - - # Idempotence + allowlist read happen under the lock; the TTY - # prompt runs outside so other threads aren't parked on a blocking - # input(). Mutation re-takes the lock with a defensive idempotence - # re-check in case two callers ever race through the prompt. - for spec in specs: - key = (spec.event, spec.matcher, spec.command) - with _registered_lock: - if key in _registered: - continue - already_allowlisted = _is_allowlisted(spec.event, spec.command) - - if not already_allowlisted: - if not _prompt_and_record( - spec.event, spec.command, accept_hooks=effective_accept, - ): - logger.warning( - "shell hook for %s (%s) not allowlisted — skipped. " - "Use --accept-hooks / HERMES_ACCEPT_HOOKS=1 / " - "hooks_auto_accept: true, or approve at the TTY " - "prompt next run.", - spec.event, spec.command, - ) - continue - - with _registered_lock: - if key in _registered: - continue - manager._hooks.setdefault(spec.event, []).append(_make_callback(spec)) - _registered.add(key) - registered.append(spec) - logger.info( - "shell hook registered: %s -> %s (matcher=%s, timeout=%ds)", - spec.event, spec.command, spec.matcher, spec.timeout, - ) - - return registered - - -def iter_configured_hooks(cfg: Optional[Dict[str, Any]]) -> List[ShellHookSpec]: - """Return the parsed ``ShellHookSpec`` entries from config without - registering anything. Used by ``hermes hooks list`` and ``doctor``.""" - if not isinstance(cfg, dict): - return [] - return _parse_hooks_block(cfg.get("hooks")) - - -def reset_for_tests() -> None: - """Clear the idempotence set. Test-only helper.""" - with _registered_lock: - _registered.clear() - - -# --------------------------------------------------------------------------- -# Config parsing -# --------------------------------------------------------------------------- - -def _parse_hooks_block(hooks_cfg: Any) -> List[ShellHookSpec]: - """Normalise the ``hooks:`` dict into a flat list of ``ShellHookSpec``. - - Malformed entries warn-and-skip — we never raise from config parsing - because a broken hook must not crash the agent. - """ - from hermes_cli.plugins import VALID_HOOKS - - if not isinstance(hooks_cfg, dict): - return [] - - specs: List[ShellHookSpec] = [] - - for event_name, entries in hooks_cfg.items(): - if event_name not in VALID_HOOKS: - suggestion = difflib.get_close_matches( - str(event_name), VALID_HOOKS, n=1, cutoff=0.6, - ) - if suggestion: - logger.warning( - "unknown hook event %r in hooks: config — did you mean %r?", - event_name, suggestion[0], - ) - else: - logger.warning( - "unknown hook event %r in hooks: config (valid: %s)", - event_name, ", ".join(sorted(VALID_HOOKS)), - ) - continue - - if entries is None: - continue - - if not isinstance(entries, list): - logger.warning( - "hooks.%s must be a list of hook definitions; got %s", - event_name, type(entries).__name__, - ) - continue - - for i, raw in enumerate(entries): - spec = _parse_single_entry(event_name, i, raw) - if spec is not None: - specs.append(spec) - - return specs - - -def _parse_single_entry( - event: str, index: int, raw: Any, -) -> Optional[ShellHookSpec]: - if not isinstance(raw, dict): - logger.warning( - "hooks.%s[%d] must be a mapping with a 'command' key; got %s", - event, index, type(raw).__name__, - ) - return None - - command = raw.get("command") - if not isinstance(command, str) or not command.strip(): - logger.warning( - "hooks.%s[%d] is missing a non-empty 'command' field", - event, index, - ) - return None - - matcher = raw.get("matcher") - if matcher is not None and not isinstance(matcher, str): - logger.warning( - "hooks.%s[%d].matcher must be a string regex; ignoring", - event, index, - ) - matcher = None - - if matcher is not None and event not in {"pre_tool_call", "post_tool_call"}: - logger.warning( - "hooks.%s[%d].matcher=%r will be ignored at runtime — the " - "matcher field is only honored for pre_tool_call / " - "post_tool_call. The hook will fire on every %s event.", - event, index, matcher, event, - ) - matcher = None - - timeout_raw = raw.get("timeout", DEFAULT_TIMEOUT_SECONDS) - try: - timeout = int(timeout_raw) - except (TypeError, ValueError): - logger.warning( - "hooks.%s[%d].timeout must be an int (got %r); using default %ds", - event, index, timeout_raw, DEFAULT_TIMEOUT_SECONDS, - ) - timeout = DEFAULT_TIMEOUT_SECONDS - - if timeout < 1: - logger.warning( - "hooks.%s[%d].timeout must be >=1; using default %ds", - event, index, DEFAULT_TIMEOUT_SECONDS, - ) - timeout = DEFAULT_TIMEOUT_SECONDS - - if timeout > MAX_TIMEOUT_SECONDS: - logger.warning( - "hooks.%s[%d].timeout=%ds exceeds max %ds; clamping", - event, index, timeout, MAX_TIMEOUT_SECONDS, - ) - timeout = MAX_TIMEOUT_SECONDS - - return ShellHookSpec( - event=event, - command=command.strip(), - matcher=matcher, - timeout=timeout, - ) - - -# --------------------------------------------------------------------------- -# Subprocess callback -# --------------------------------------------------------------------------- - -_TOP_LEVEL_PAYLOAD_KEYS = {"tool_name", "args", "session_id", "parent_session_id"} - - -def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]: - """Run ``spec.command`` as a subprocess with ``stdin_json`` on stdin. - - Returns a diagnostic dict with the same keys for every outcome - (``returncode``, ``stdout``, ``stderr``, ``timed_out``, - ``elapsed_seconds``, ``error``). This is the single place the - subprocess is actually invoked — both the live callback path - (:func:`_make_callback`) and the CLI test helper (:func:`run_once`) - go through it. - """ - result: Dict[str, Any] = { - "returncode": None, - "stdout": "", - "stderr": "", - "timed_out": False, - "elapsed_seconds": 0.0, - "error": None, - } - try: - argv = shlex.split(os.path.expanduser(spec.command)) - except ValueError as exc: - result["error"] = f"command {spec.command!r} cannot be parsed: {exc}" - return result - if not argv: - result["error"] = "empty command" - return result - - t0 = time.monotonic() - try: - proc = subprocess.run( - argv, - input=stdin_json, - capture_output=True, - timeout=spec.timeout, - text=True, - shell=False, - ) - except subprocess.TimeoutExpired: - result["timed_out"] = True - result["elapsed_seconds"] = round(time.monotonic() - t0, 3) - return result - except FileNotFoundError: - result["error"] = "command not found" - return result - except PermissionError: - result["error"] = "command not executable" - return result - except Exception as exc: # pragma: no cover — defensive - result["error"] = str(exc) - return result - - result["returncode"] = proc.returncode - result["stdout"] = proc.stdout or "" - result["stderr"] = proc.stderr or "" - result["elapsed_seconds"] = round(time.monotonic() - t0, 3) - return result - - -def _make_callback(spec: ShellHookSpec) -> Callable[..., Optional[Dict[str, Any]]]: - """Build the closure that ``invoke_hook()`` will call per firing.""" - - def _callback(**kwargs: Any) -> Optional[Dict[str, Any]]: - # Matcher gate — only meaningful for tool-scoped events. - if spec.event in {"pre_tool_call", "post_tool_call"}: - if not spec.matches_tool(kwargs.get("tool_name")): - return None - - r = _spawn(spec, _serialize_payload(spec.event, kwargs)) - - if r["error"]: - logger.warning( - "shell hook failed (event=%s command=%s): %s", - spec.event, spec.command, r["error"], - ) - return None - if r["timed_out"]: - logger.warning( - "shell hook timed out after %.2fs (event=%s command=%s)", - r["elapsed_seconds"], spec.event, spec.command, - ) - return None - - stderr = r["stderr"].strip() - if stderr: - logger.debug( - "shell hook stderr (event=%s command=%s): %s", - spec.event, spec.command, stderr[:400], - ) - # Non-zero exits: log but still parse stdout so scripts that - # signal failure via exit code can also return a block directive. - if r["returncode"] != 0: - logger.warning( - "shell hook exited %d (event=%s command=%s); stderr=%s", - r["returncode"], spec.event, spec.command, stderr[:400], - ) - return _parse_response(spec.event, r["stdout"]) - - _callback.__name__ = f"shell_hook[{spec.event}:{spec.command}]" - _callback.__qualname__ = _callback.__name__ - return _callback - - -def _serialize_payload(event: str, kwargs: Dict[str, Any]) -> str: - """Render the stdin JSON payload. Unserialisable values are - stringified via ``default=str`` rather than dropped.""" - extras = {k: v for k, v in kwargs.items() if k not in _TOP_LEVEL_PAYLOAD_KEYS} - try: - cwd = str(Path.cwd()) - except OSError: - cwd = "" - payload = { - "hook_event_name": event, - "tool_name": kwargs.get("tool_name"), - "tool_input": kwargs.get("args") if isinstance(kwargs.get("args"), dict) else None, - "session_id": kwargs.get("session_id") or kwargs.get("parent_session_id") or "", - "cwd": cwd, - "extra": extras, - } - return json.dumps(payload, ensure_ascii=False, default=str) - - -def _block_message(primary: Any, secondary: Any) -> str: - """Return a validated string block message, falling back to the default. - - Accepts two candidate fields (primary wins over secondary) so callers - can express field-priority differences between the two hook wire formats - without duplicating the type-check logic. - """ - raw = primary or secondary - return raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE - - -def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]: - """Translate stdout JSON into a Hermes wire-shape dict. - - For ``pre_tool_call`` the Claude-Code-style ``{"decision": "block", - "reason": "..."}`` payload is translated into the canonical Hermes - ``{"action": "block", "message": "..."}`` shape expected by - :func:`hermes_cli.plugins.get_pre_tool_call_block_message`. This is - the single most important correctness invariant in this module — - skipping the translation silently breaks every ``pre_tool_call`` - block directive. - - For ``pre_llm_call``, ``{"context": "..."}`` is passed through - unchanged to match the existing plugin-hook contract. - - Anything else returns ``None``. - """ - stdout = (stdout or "").strip() - if not stdout: - return None - - try: - data = json.loads(stdout) - except json.JSONDecodeError: - logger.warning( - "shell hook stdout was not valid JSON (event=%s): %s", - event, stdout[:200], - ) - return None - - if not isinstance(data, dict): - return None - - if event == "pre_tool_call": - if data.get("action") == "block": - return {"action": "block", "message": _block_message(data.get("message"), data.get("reason"))} - if data.get("decision") == "block": - return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))} - return None - - context = data.get("context") - if isinstance(context, str) and context.strip(): - return {"context": context} - - return None - - -# --------------------------------------------------------------------------- -# Allowlist / consent -# --------------------------------------------------------------------------- - -def allowlist_path() -> Path: - """Path to the per-user shell-hook allowlist file.""" - return get_hermes_home() / ALLOWLIST_FILENAME - - -def load_allowlist() -> Dict[str, Any]: - """Return the parsed allowlist, or an empty skeleton if absent.""" - try: - raw = json.loads(allowlist_path().read_text()) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return {"approvals": []} - if not isinstance(raw, dict): - return {"approvals": []} - approvals = raw.get("approvals") - if not isinstance(approvals, list): - raw["approvals"] = [] - return raw - - -def save_allowlist(data: Dict[str, Any]) -> None: - """Atomically persist the allowlist via per-process ``mkstemp`` + - ``os.replace``. Cross-process read-modify-write races are handled - by :func:`_locked_update_approvals` (``fcntl.flock``). On OSError - the failure is logged; the in-process hook still registers but - the approval won't survive across runs.""" - p = allowlist_path() - try: - p.parent.mkdir(parents=True, exist_ok=True) - fd, tmp_path = tempfile.mkstemp( - prefix=f"{p.name}.", suffix=".tmp", dir=str(p.parent), - ) - try: - with os.fdopen(fd, "w") as fh: - fh.write(json.dumps(data, indent=2, sort_keys=True)) - atomic_replace(tmp_path, p) - except Exception: - try: - os.unlink(tmp_path) - except OSError: - pass - raise - except OSError as exc: - logger.warning( - "Failed to persist shell hook allowlist to %s: %s. " - "The approval is in-memory for this run, but the next " - "startup will re-prompt (or skip registration on non-TTY " - "runs without --accept-hooks / HERMES_ACCEPT_HOOKS).", - p, exc, - ) - - -def _is_allowlisted(event: str, command: str) -> bool: - data = load_allowlist() - return any( - isinstance(e, dict) - and e.get("event") == event - and e.get("command") == command - for e in data.get("approvals", []) - ) - - -@contextmanager -def _locked_update_approvals() -> Iterator[Dict[str, Any]]: - """Serialise read-modify-write on the allowlist across processes. - - Holds an exclusive ``flock`` on a sibling lock file for the duration - of the update so concurrent ``_record_approval``/``revoke`` callers - cannot clobber each other's changes (the race Codex reproduced with - 20–50 simultaneous writers). Falls back to an in-process lock on - platforms without ``fcntl``. - """ - p = allowlist_path() - p.parent.mkdir(parents=True, exist_ok=True) - lock_path = p.with_suffix(p.suffix + ".lock") - - if fcntl is None: # pragma: no cover — non-POSIX fallback - with _allowlist_write_lock: - data = load_allowlist() - yield data - save_allowlist(data) - return - - with open(lock_path, "a+", encoding="utf-8") as lock_fh: - fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX) - try: - data = load_allowlist() - yield data - save_allowlist(data) - finally: - try: - fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN) - except (OSError, IOError): - pass - - -def _prompt_and_record( - event: str, command: str, *, accept_hooks: bool, -) -> bool: - """Decide whether to approve an unseen ``(event, command)`` pair. - Returns ``True`` iff the approval was granted and recorded. - """ - if accept_hooks: - _record_approval(event, command) - logger.info( - "shell hook auto-approved via --accept-hooks / env / config: " - "%s -> %s", event, command, - ) - return True - - if not sys.stdin.isatty(): - return False - - print( - f"\n⚠ Hermes is about to register a shell hook that will run a\n" - f" command on your behalf.\n\n" - f" Event: {event}\n" - f" Command: {command}\n\n" - f" Commands run with your full user credentials. Only approve\n" - f" commands you trust." - ) - try: - answer = input("Allow this hook to run? [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - print() # keep the terminal tidy after ^C - return False - - if answer in {"y", "yes"}: - _record_approval(event, command) - return True - - return False - - -def _record_approval(event: str, command: str) -> None: - entry = { - "event": event, - "command": command, - "approved_at": _utc_now_iso(), - "script_mtime_at_approval": script_mtime_iso(command), - } - with _locked_update_approvals() as data: - data["approvals"] = [ - e for e in data.get("approvals", []) - if not ( - isinstance(e, dict) - and e.get("event") == event - and e.get("command") == command - ) - ] + [entry] - - -def _utc_now_iso() -> str: - return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z") - - -def revoke(command: str) -> int: - """Remove every allowlist entry matching ``command``. - - Returns the number of entries removed. Does not unregister any - callbacks that are already live on the plugin manager in the current - process — restart the CLI / gateway to drop them. - """ - with _locked_update_approvals() as data: - before = len(data.get("approvals", [])) - data["approvals"] = [ - e for e in data.get("approvals", []) - if not (isinstance(e, dict) and e.get("command") == command) - ] - after = len(data["approvals"]) - return before - after - - -_SCRIPT_EXTENSIONS: Tuple[str, ...] = ( - ".sh", ".bash", ".zsh", ".fish", - ".py", ".pyw", - ".rb", ".pl", ".lua", - ".js", ".mjs", ".cjs", ".ts", -) - - -def _command_script_path(command: str) -> str: - """Return the script path from ``command`` for doctor / drift checks. - - Prefers a token ending in a known script extension, then a token - containing ``/`` or leading ``~``, then the first token. Handles - ``python3 /path/hook.py``, ``/usr/bin/env bash hook.sh``, and the - common bare-path form. - """ - try: - parts = shlex.split(command) - except ValueError: - return command - if not parts: - return command - for part in parts: - if part.lower().endswith(_SCRIPT_EXTENSIONS): - return part - for part in parts: - if "/" in part or part.startswith("~"): - return part - return parts[0] - - -# --------------------------------------------------------------------------- -# Helpers for accept-hooks resolution -# --------------------------------------------------------------------------- - -def _resolve_effective_accept( - cfg: Dict[str, Any], accept_hooks_arg: bool, -) -> bool: - """Combine all three opt-in channels into a single boolean. - - Precedence (any truthy source flips us on): - 1. ``--accept-hooks`` flag (CLI) / explicit argument - 2. ``HERMES_ACCEPT_HOOKS`` env var - 3. ``hooks_auto_accept: true`` in ``cli-config.yaml`` - """ - if accept_hooks_arg: - return True - env = os.environ.get("HERMES_ACCEPT_HOOKS", "").strip().lower() - if env in {"1", "true", "yes", "on"}: - return True - cfg_val = cfg.get("hooks_auto_accept", False) - if isinstance(cfg_val, bool): - return cfg_val - if isinstance(cfg_val, str): - return cfg_val.strip().lower() in {"1", "true", "yes", "on"} - return False - - -# --------------------------------------------------------------------------- -# Introspection (used by `hermes hooks` CLI) -# --------------------------------------------------------------------------- - -def allowlist_entry_for(event: str, command: str) -> Optional[Dict[str, Any]]: - """Return the allowlist record for this pair, if any.""" - for e in load_allowlist().get("approvals", []): - if ( - isinstance(e, dict) - and e.get("event") == event - and e.get("command") == command - ): - return e - return None - - -def script_mtime_iso(command: str) -> Optional[str]: - """ISO-8601 mtime of the resolved script path, or ``None`` if the - script is missing.""" - path = _command_script_path(command) - if not path: - return None - try: - expanded = os.path.expanduser(path) - return datetime.fromtimestamp( - os.path.getmtime(expanded), tz=timezone.utc, - ).isoformat().replace("+00:00", "Z") - except OSError: - return None - - -def script_is_executable(command: str) -> bool: - """Return ``True`` iff ``command`` is runnable as configured. - - For a bare invocation (``/path/hook.sh``) the script itself must be - executable. For interpreter-prefixed commands (``python3 - /path/hook.py``, ``/usr/bin/env bash hook.sh``) the script just has - to be readable — the interpreter doesn't care about the ``X_OK`` - bit. Mirrors what ``_spawn`` would actually do at runtime.""" - path = _command_script_path(command) - if not path: - return False - expanded = os.path.expanduser(path) - if not os.path.isfile(expanded): - return False - try: - argv = shlex.split(command) - except ValueError: - return False - is_bare_invocation = bool(argv) and argv[0] == path - required = os.X_OK if is_bare_invocation else os.R_OK - return os.access(expanded, required) - - -def run_once( - spec: ShellHookSpec, kwargs: Dict[str, Any], -) -> Dict[str, Any]: - """Fire a single shell-hook invocation with a synthetic payload. - Used by ``hermes hooks test`` and ``hermes hooks doctor``. - - ``kwargs`` is the same dict that :func:`hermes_cli.plugins.invoke_hook` - would pass at runtime. It is routed through :func:`_serialize_payload` - so the synthetic stdin exactly matches what a real hook firing would - produce — otherwise scripts tested via ``hermes hooks test`` could - diverge silently from production behaviour. - - Returns the :func:`_spawn` diagnostic dict plus a ``parsed`` field - holding the canonical Hermes-wire-shape response.""" - stdin_json = _serialize_payload(spec.event, kwargs) - result = _spawn(spec, stdin_json) - result["parsed"] = _parse_response(spec.event, result["stdout"]) - return result +""" +Shell-script hooks bridge. + +Reads the ``hooks:`` block from ``cli-config.yaml``, prompts the user for +consent on first use of each ``(event, command)`` pair, and registers +callbacks on the existing plugin hook manager so every existing +``invoke_hook()`` site dispatches to the configured shell scripts — with +zero changes to call sites. + +Design notes +------------ +* Python plugins and shell hooks compose naturally: both flow through + :func:`hermes_cli.plugins.invoke_hook` and its aggregators. Python + plugins are registered first (via ``discover_and_load()``) so their + block decisions win ties over shell-hook blocks. +* Subprocess execution uses ``shlex.split(os.path.expanduser(command))`` + with ``shell=False`` — no shell injection footguns. Users that need + pipes/redirection wrap their logic in a script. +* First-use consent is gated by the allowlist under + ``~/.hermes/shell-hooks-allowlist.json``. Non-TTY callers must pass + ``accept_hooks=True`` (resolved from ``--accept-hooks``, + ``HERMES_ACCEPT_HOOKS``, or ``hooks_auto_accept: true`` in config) + for registration to succeed without a prompt. +* Registration is idempotent — safe to invoke from both the CLI entry + point (``hermes_cli/main.py``) and the gateway entry point + (``gateway/run.py``). + +Wire protocol +------------- +**stdin** (JSON, piped to the script):: + + { + "hook_event_name": "pre_tool_call", + "tool_name": "terminal", + "tool_input": {"command": "rm -rf /"}, + "session_id": "sess_abc123", + "cwd": "/home/user/project", + "extra": {...} # event-specific kwargs + } + +**stdout** (JSON, optional — anything else is ignored):: + + # Block a pre_tool_call (either shape accepted; normalised internally): + {"decision": "block", "reason": "Forbidden command"} # Claude-Code-style + {"action": "block", "message": "Forbidden command"} # Hermes-canonical + + # Inject context for pre_llm_call: + {"context": "Today is Friday"} + + # Silent no-op: + +""" + +from __future__ import annotations + +import difflib +import json +import logging +import os +import re +import shlex +import subprocess +import sys +import tempfile +import threading +import time +from contextlib import contextmanager +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple + +try: + import fcntl # POSIX only; Windows falls back to best-effort without flock. +except ImportError: # pragma: no cover + fcntl = None # type: ignore[assignment] + +from hermes_constants import get_hermes_home +from utils import atomic_replace + +logger = logging.getLogger(__name__) + +DEFAULT_TIMEOUT_SECONDS = 60 +MAX_TIMEOUT_SECONDS = 300 +ALLOWLIST_FILENAME = "shell-hooks-allowlist.json" +_DEFAULT_BLOCK_MESSAGE = "Blocked by shell hook." + +# (event, matcher, command) triples that have been wired to the plugin +# manager in the current process. Matcher is part of the key because +# the same script can legitimately register for different matchers under +# the same event (e.g. one entry per tool the user wants to gate). +# Second registration attempts for the exact same triple become no-ops +# so the CLI and gateway can both call register_from_config() safely. +_registered: Set[Tuple[str, Optional[str], str]] = set() +_registered_lock = threading.Lock() + +# Intra-process lock for allowlist read-modify-write on platforms that +# lack ``fcntl`` (non-POSIX). Kept separate from ``_registered_lock`` +# because ``register_from_config`` already holds ``_registered_lock`` when +# it triggers ``_record_approval`` — reusing it here would self-deadlock +# (``threading.Lock`` is non-reentrant). POSIX callers use the sibling +# ``.lock`` file via ``fcntl.flock`` and bypass this. +_allowlist_write_lock = threading.Lock() + + +@dataclass +class ShellHookSpec: + """Parsed and validated representation of a single ``hooks:`` entry.""" + + event: str + command: str + matcher: Optional[str] = None + timeout: int = DEFAULT_TIMEOUT_SECONDS + compiled_matcher: Optional[re.Pattern] = field(default=None, repr=False) + + def __post_init__(self) -> None: + # Strip whitespace introduced by YAML quirks (e.g. multi-line string + # folding) — a matcher of " terminal" would otherwise silently fail + # to match "terminal" without any diagnostic. + if isinstance(self.matcher, str): + stripped = self.matcher.strip() + self.matcher = stripped if stripped else None + if self.matcher: + try: + self.compiled_matcher = re.compile(self.matcher) + except re.error as exc: + logger.warning( + "shell hook matcher %r is invalid (%s) — treating as " + "literal equality", self.matcher, exc, + ) + self.compiled_matcher = None + + def matches_tool(self, tool_name: Optional[str]) -> bool: + if not self.matcher: + return True + if tool_name is None: + return False + if self.compiled_matcher is not None: + return self.compiled_matcher.fullmatch(tool_name) is not None + # compiled_matcher is None only when the regex failed to compile, + # in which case we already warned and fall back to literal equality. + return tool_name == self.matcher + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def register_from_config( + cfg: Optional[Dict[str, Any]], + *, + accept_hooks: bool = False, +) -> List[ShellHookSpec]: + """Register every configured shell hook on the plugin manager. + + ``cfg`` is the full parsed config dict (``hermes_cli.config.load_config`` + output). The ``hooks:`` key is read out of it. Missing, empty, or + non-dict ``hooks`` is treated as zero configured hooks. + + ``accept_hooks=True`` skips the TTY consent prompt — the caller is + promising that the user has opted in via a flag, env var, or config + setting. ``HERMES_ACCEPT_HOOKS=1`` and ``hooks_auto_accept: true`` are + also honored inside this function so either CLI or gateway call sites + pick them up. + + Returns the list of :class:`ShellHookSpec` entries that ended up wired + up on the plugin manager. Skipped entries (unknown events, malformed, + not allowlisted, already registered) are logged but not returned. + """ + if not isinstance(cfg, dict): + return [] + + effective_accept = _resolve_effective_accept(cfg, accept_hooks) + + specs = _parse_hooks_block(cfg.get("hooks")) + if not specs: + return [] + + registered: List[ShellHookSpec] = [] + + # Import lazily — avoids circular imports at module-load time. + from hermes_cli.plugins import get_plugin_manager + + manager = get_plugin_manager() + + # Idempotence + allowlist read happen under the lock; the TTY + # prompt runs outside so other threads aren't parked on a blocking + # input(). Mutation re-takes the lock with a defensive idempotence + # re-check in case two callers ever race through the prompt. + for spec in specs: + key = (spec.event, spec.matcher, spec.command) + with _registered_lock: + if key in _registered: + continue + already_allowlisted = _is_allowlisted(spec.event, spec.command) + + if not already_allowlisted: + if not _prompt_and_record( + spec.event, spec.command, accept_hooks=effective_accept, + ): + logger.warning( + "shell hook for %s (%s) not allowlisted — skipped. " + "Use --accept-hooks / HERMES_ACCEPT_HOOKS=1 / " + "hooks_auto_accept: true, or approve at the TTY " + "prompt next run.", + spec.event, spec.command, + ) + continue + + with _registered_lock: + if key in _registered: + continue + manager._hooks.setdefault(spec.event, []).append(_make_callback(spec)) + _registered.add(key) + registered.append(spec) + logger.info( + "shell hook registered: %s -> %s (matcher=%s, timeout=%ds)", + spec.event, spec.command, spec.matcher, spec.timeout, + ) + + return registered + + +def iter_configured_hooks(cfg: Optional[Dict[str, Any]]) -> List[ShellHookSpec]: + """Return the parsed ``ShellHookSpec`` entries from config without + registering anything. Used by ``hermes hooks list`` and ``doctor``.""" + if not isinstance(cfg, dict): + return [] + return _parse_hooks_block(cfg.get("hooks")) + + +def reset_for_tests() -> None: + """Clear the idempotence set. Test-only helper.""" + with _registered_lock: + _registered.clear() + + +# --------------------------------------------------------------------------- +# Config parsing +# --------------------------------------------------------------------------- + +def _parse_hooks_block(hooks_cfg: Any) -> List[ShellHookSpec]: + """Normalise the ``hooks:`` dict into a flat list of ``ShellHookSpec``. + + Malformed entries warn-and-skip — we never raise from config parsing + because a broken hook must not crash the agent. + """ + from hermes_cli.plugins import VALID_HOOKS + + if not isinstance(hooks_cfg, dict): + return [] + + specs: List[ShellHookSpec] = [] + + for event_name, entries in hooks_cfg.items(): + if event_name not in VALID_HOOKS: + suggestion = difflib.get_close_matches( + str(event_name), VALID_HOOKS, n=1, cutoff=0.6, + ) + if suggestion: + logger.warning( + "unknown hook event %r in hooks: config — did you mean %r?", + event_name, suggestion[0], + ) + else: + logger.warning( + "unknown hook event %r in hooks: config (valid: %s)", + event_name, ", ".join(sorted(VALID_HOOKS)), + ) + continue + + if entries is None: + continue + + if not isinstance(entries, list): + logger.warning( + "hooks.%s must be a list of hook definitions; got %s", + event_name, type(entries).__name__, + ) + continue + + for i, raw in enumerate(entries): + spec = _parse_single_entry(event_name, i, raw) + if spec is not None: + specs.append(spec) + + return specs + + +def _parse_single_entry( + event: str, index: int, raw: Any, +) -> Optional[ShellHookSpec]: + if not isinstance(raw, dict): + logger.warning( + "hooks.%s[%d] must be a mapping with a 'command' key; got %s", + event, index, type(raw).__name__, + ) + return None + + command = raw.get("command") + if not isinstance(command, str) or not command.strip(): + logger.warning( + "hooks.%s[%d] is missing a non-empty 'command' field", + event, index, + ) + return None + + matcher = raw.get("matcher") + if matcher is not None and not isinstance(matcher, str): + logger.warning( + "hooks.%s[%d].matcher must be a string regex; ignoring", + event, index, + ) + matcher = None + + if matcher is not None and event not in {"pre_tool_call", "post_tool_call"}: + logger.warning( + "hooks.%s[%d].matcher=%r will be ignored at runtime — the " + "matcher field is only honored for pre_tool_call / " + "post_tool_call. The hook will fire on every %s event.", + event, index, matcher, event, + ) + matcher = None + + timeout_raw = raw.get("timeout", DEFAULT_TIMEOUT_SECONDS) + try: + timeout = int(timeout_raw) + except (TypeError, ValueError): + logger.warning( + "hooks.%s[%d].timeout must be an int (got %r); using default %ds", + event, index, timeout_raw, DEFAULT_TIMEOUT_SECONDS, + ) + timeout = DEFAULT_TIMEOUT_SECONDS + + if timeout < 1: + logger.warning( + "hooks.%s[%d].timeout must be >=1; using default %ds", + event, index, DEFAULT_TIMEOUT_SECONDS, + ) + timeout = DEFAULT_TIMEOUT_SECONDS + + if timeout > MAX_TIMEOUT_SECONDS: + logger.warning( + "hooks.%s[%d].timeout=%ds exceeds max %ds; clamping", + event, index, timeout, MAX_TIMEOUT_SECONDS, + ) + timeout = MAX_TIMEOUT_SECONDS + + return ShellHookSpec( + event=event, + command=command.strip(), + matcher=matcher, + timeout=timeout, + ) + + +# --------------------------------------------------------------------------- +# Subprocess callback +# --------------------------------------------------------------------------- + +_TOP_LEVEL_PAYLOAD_KEYS = {"tool_name", "args", "session_id", "parent_session_id"} + + +def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]: + """Run ``spec.command`` as a subprocess with ``stdin_json`` on stdin. + + Returns a diagnostic dict with the same keys for every outcome + (``returncode``, ``stdout``, ``stderr``, ``timed_out``, + ``elapsed_seconds``, ``error``). This is the single place the + subprocess is actually invoked — both the live callback path + (:func:`_make_callback`) and the CLI test helper (:func:`run_once`) + go through it. + """ + result: Dict[str, Any] = { + "returncode": None, + "stdout": "", + "stderr": "", + "timed_out": False, + "elapsed_seconds": 0.0, + "error": None, + } + try: + argv = shlex.split(os.path.expanduser(spec.command)) + except ValueError as exc: + result["error"] = f"command {spec.command!r} cannot be parsed: {exc}" + return result + if not argv: + result["error"] = "empty command" + return result + + t0 = time.monotonic() + try: + _extra: dict = {} + if sys.platform == "win32": + from hermes_cli._subprocess_compat import windows_hide_flags + _extra["creationflags"] = windows_hide_flags() + proc = subprocess.run( + argv, + input=stdin_json, + capture_output=True, + timeout=spec.timeout, + text=True, + shell=False, + **_extra, + ) + except subprocess.TimeoutExpired: + result["timed_out"] = True + result["elapsed_seconds"] = round(time.monotonic() - t0, 3) + return result + except FileNotFoundError: + result["error"] = "command not found" + return result + except PermissionError: + result["error"] = "command not executable" + return result + except Exception as exc: # pragma: no cover — defensive + result["error"] = str(exc) + return result + + result["returncode"] = proc.returncode + result["stdout"] = proc.stdout or "" + result["stderr"] = proc.stderr or "" + result["elapsed_seconds"] = round(time.monotonic() - t0, 3) + return result + + +def _make_callback(spec: ShellHookSpec) -> Callable[..., Optional[Dict[str, Any]]]: + """Build the closure that ``invoke_hook()`` will call per firing.""" + + def _callback(**kwargs: Any) -> Optional[Dict[str, Any]]: + # Matcher gate — only meaningful for tool-scoped events. + if spec.event in {"pre_tool_call", "post_tool_call"}: + if not spec.matches_tool(kwargs.get("tool_name")): + return None + + r = _spawn(spec, _serialize_payload(spec.event, kwargs)) + + if r["error"]: + logger.warning( + "shell hook failed (event=%s command=%s): %s", + spec.event, spec.command, r["error"], + ) + return None + if r["timed_out"]: + logger.warning( + "shell hook timed out after %.2fs (event=%s command=%s)", + r["elapsed_seconds"], spec.event, spec.command, + ) + return None + + stderr = r["stderr"].strip() + if stderr: + logger.debug( + "shell hook stderr (event=%s command=%s): %s", + spec.event, spec.command, stderr[:400], + ) + # Non-zero exits: log but still parse stdout so scripts that + # signal failure via exit code can also return a block directive. + if r["returncode"] != 0: + logger.warning( + "shell hook exited %d (event=%s command=%s); stderr=%s", + r["returncode"], spec.event, spec.command, stderr[:400], + ) + return _parse_response(spec.event, r["stdout"]) + + _callback.__name__ = f"shell_hook[{spec.event}:{spec.command}]" + _callback.__qualname__ = _callback.__name__ + return _callback + + +def _serialize_payload(event: str, kwargs: Dict[str, Any]) -> str: + """Render the stdin JSON payload. Unserialisable values are + stringified via ``default=str`` rather than dropped.""" + extras = {k: v for k, v in kwargs.items() if k not in _TOP_LEVEL_PAYLOAD_KEYS} + try: + cwd = str(Path.cwd()) + except OSError: + cwd = "" + payload = { + "hook_event_name": event, + "tool_name": kwargs.get("tool_name"), + "tool_input": kwargs.get("args") if isinstance(kwargs.get("args"), dict) else None, + "session_id": kwargs.get("session_id") or kwargs.get("parent_session_id") or "", + "cwd": cwd, + "extra": extras, + } + return json.dumps(payload, ensure_ascii=False, default=str) + + +def _block_message(primary: Any, secondary: Any) -> str: + """Return a validated string block message, falling back to the default. + + Accepts two candidate fields (primary wins over secondary) so callers + can express field-priority differences between the two hook wire formats + without duplicating the type-check logic. + """ + raw = primary or secondary + return raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE + + +def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]: + """Translate stdout JSON into a Hermes wire-shape dict. + + For ``pre_tool_call`` the Claude-Code-style ``{"decision": "block", + "reason": "..."}`` payload is translated into the canonical Hermes + ``{"action": "block", "message": "..."}`` shape expected by + :func:`hermes_cli.plugins.get_pre_tool_call_block_message`. This is + the single most important correctness invariant in this module — + skipping the translation silently breaks every ``pre_tool_call`` + block directive. + + For ``pre_llm_call``, ``{"context": "..."}`` is passed through + unchanged to match the existing plugin-hook contract. + + Anything else returns ``None``. + """ + stdout = (stdout or "").strip() + if not stdout: + return None + + try: + data = json.loads(stdout) + except json.JSONDecodeError: + logger.warning( + "shell hook stdout was not valid JSON (event=%s): %s", + event, stdout[:200], + ) + return None + + if not isinstance(data, dict): + return None + + if event == "pre_tool_call": + if data.get("action") == "block": + return {"action": "block", "message": _block_message(data.get("message"), data.get("reason"))} + if data.get("decision") == "block": + return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))} + return None + + context = data.get("context") + if isinstance(context, str) and context.strip(): + return {"context": context} + + return None + + +# --------------------------------------------------------------------------- +# Allowlist / consent +# --------------------------------------------------------------------------- + +def allowlist_path() -> Path: + """Path to the per-user shell-hook allowlist file.""" + return get_hermes_home() / ALLOWLIST_FILENAME + + +def load_allowlist() -> Dict[str, Any]: + """Return the parsed allowlist, or an empty skeleton if absent.""" + try: + raw = json.loads(allowlist_path().read_text()) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {"approvals": []} + if not isinstance(raw, dict): + return {"approvals": []} + approvals = raw.get("approvals") + if not isinstance(approvals, list): + raw["approvals"] = [] + return raw + + +def save_allowlist(data: Dict[str, Any]) -> None: + """Atomically persist the allowlist via per-process ``mkstemp`` + + ``os.replace``. Cross-process read-modify-write races are handled + by :func:`_locked_update_approvals` (``fcntl.flock``). On OSError + the failure is logged; the in-process hook still registers but + the approval won't survive across runs.""" + p = allowlist_path() + try: + p.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp( + prefix=f"{p.name}.", suffix=".tmp", dir=str(p.parent), + ) + try: + with os.fdopen(fd, "w") as fh: + fh.write(json.dumps(data, indent=2, sort_keys=True)) + atomic_replace(tmp_path, p) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + except OSError as exc: + logger.warning( + "Failed to persist shell hook allowlist to %s: %s. " + "The approval is in-memory for this run, but the next " + "startup will re-prompt (or skip registration on non-TTY " + "runs without --accept-hooks / HERMES_ACCEPT_HOOKS).", + p, exc, + ) + + +def _is_allowlisted(event: str, command: str) -> bool: + data = load_allowlist() + return any( + isinstance(e, dict) + and e.get("event") == event + and e.get("command") == command + for e in data.get("approvals", []) + ) + + +@contextmanager +def _locked_update_approvals() -> Iterator[Dict[str, Any]]: + """Serialise read-modify-write on the allowlist across processes. + + Holds an exclusive ``flock`` on a sibling lock file for the duration + of the update so concurrent ``_record_approval``/``revoke`` callers + cannot clobber each other's changes (the race Codex reproduced with + 20–50 simultaneous writers). Falls back to an in-process lock on + platforms without ``fcntl``. + """ + p = allowlist_path() + p.parent.mkdir(parents=True, exist_ok=True) + lock_path = p.with_suffix(p.suffix + ".lock") + + if fcntl is None: # pragma: no cover — non-POSIX fallback + with _allowlist_write_lock: + data = load_allowlist() + yield data + save_allowlist(data) + return + + with open(lock_path, "a+", encoding="utf-8") as lock_fh: + fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX) + try: + data = load_allowlist() + yield data + save_allowlist(data) + finally: + try: + fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN) + except (OSError, IOError): + pass + + +def _prompt_and_record( + event: str, command: str, *, accept_hooks: bool, +) -> bool: + """Decide whether to approve an unseen ``(event, command)`` pair. + Returns ``True`` iff the approval was granted and recorded. + """ + if accept_hooks: + _record_approval(event, command) + logger.info( + "shell hook auto-approved via --accept-hooks / env / config: " + "%s -> %s", event, command, + ) + return True + + if not sys.stdin.isatty(): + return False + + print( + f"\n⚠ Hermes is about to register a shell hook that will run a\n" + f" command on your behalf.\n\n" + f" Event: {event}\n" + f" Command: {command}\n\n" + f" Commands run with your full user credentials. Only approve\n" + f" commands you trust." + ) + try: + answer = input("Allow this hook to run? [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() # keep the terminal tidy after ^C + return False + + if answer in {"y", "yes"}: + _record_approval(event, command) + return True + + return False + + +def _record_approval(event: str, command: str) -> None: + entry = { + "event": event, + "command": command, + "approved_at": _utc_now_iso(), + "script_mtime_at_approval": script_mtime_iso(command), + } + with _locked_update_approvals() as data: + data["approvals"] = [ + e for e in data.get("approvals", []) + if not ( + isinstance(e, dict) + and e.get("event") == event + and e.get("command") == command + ) + ] + [entry] + + +def _utc_now_iso() -> str: + return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z") + + +def revoke(command: str) -> int: + """Remove every allowlist entry matching ``command``. + + Returns the number of entries removed. Does not unregister any + callbacks that are already live on the plugin manager in the current + process — restart the CLI / gateway to drop them. + """ + with _locked_update_approvals() as data: + before = len(data.get("approvals", [])) + data["approvals"] = [ + e for e in data.get("approvals", []) + if not (isinstance(e, dict) and e.get("command") == command) + ] + after = len(data["approvals"]) + return before - after + + +_SCRIPT_EXTENSIONS: Tuple[str, ...] = ( + ".sh", ".bash", ".zsh", ".fish", + ".py", ".pyw", + ".rb", ".pl", ".lua", + ".js", ".mjs", ".cjs", ".ts", +) + + +def _command_script_path(command: str) -> str: + """Return the script path from ``command`` for doctor / drift checks. + + Prefers a token ending in a known script extension, then a token + containing ``/`` or leading ``~``, then the first token. Handles + ``python3 /path/hook.py``, ``/usr/bin/env bash hook.sh``, and the + common bare-path form. + """ + try: + parts = shlex.split(command) + except ValueError: + return command + if not parts: + return command + for part in parts: + if part.lower().endswith(_SCRIPT_EXTENSIONS): + return part + for part in parts: + if "/" in part or part.startswith("~"): + return part + return parts[0] + + +# --------------------------------------------------------------------------- +# Helpers for accept-hooks resolution +# --------------------------------------------------------------------------- + +def _resolve_effective_accept( + cfg: Dict[str, Any], accept_hooks_arg: bool, +) -> bool: + """Combine all three opt-in channels into a single boolean. + + Precedence (any truthy source flips us on): + 1. ``--accept-hooks`` flag (CLI) / explicit argument + 2. ``HERMES_ACCEPT_HOOKS`` env var + 3. ``hooks_auto_accept: true`` in ``cli-config.yaml`` + """ + if accept_hooks_arg: + return True + env = os.environ.get("HERMES_ACCEPT_HOOKS", "").strip().lower() + if env in {"1", "true", "yes", "on"}: + return True + cfg_val = cfg.get("hooks_auto_accept", False) + if isinstance(cfg_val, bool): + return cfg_val + if isinstance(cfg_val, str): + return cfg_val.strip().lower() in {"1", "true", "yes", "on"} + return False + + +# --------------------------------------------------------------------------- +# Introspection (used by `hermes hooks` CLI) +# --------------------------------------------------------------------------- + +def allowlist_entry_for(event: str, command: str) -> Optional[Dict[str, Any]]: + """Return the allowlist record for this pair, if any.""" + for e in load_allowlist().get("approvals", []): + if ( + isinstance(e, dict) + and e.get("event") == event + and e.get("command") == command + ): + return e + return None + + +def script_mtime_iso(command: str) -> Optional[str]: + """ISO-8601 mtime of the resolved script path, or ``None`` if the + script is missing.""" + path = _command_script_path(command) + if not path: + return None + try: + expanded = os.path.expanduser(path) + return datetime.fromtimestamp( + os.path.getmtime(expanded), tz=timezone.utc, + ).isoformat().replace("+00:00", "Z") + except OSError: + return None + + +def script_is_executable(command: str) -> bool: + """Return ``True`` iff ``command`` is runnable as configured. + + For a bare invocation (``/path/hook.sh``) the script itself must be + executable. For interpreter-prefixed commands (``python3 + /path/hook.py``, ``/usr/bin/env bash hook.sh``) the script just has + to be readable — the interpreter doesn't care about the ``X_OK`` + bit. Mirrors what ``_spawn`` would actually do at runtime.""" + path = _command_script_path(command) + if not path: + return False + expanded = os.path.expanduser(path) + if not os.path.isfile(expanded): + return False + try: + argv = shlex.split(command) + except ValueError: + return False + is_bare_invocation = bool(argv) and argv[0] == path + required = os.X_OK if is_bare_invocation else os.R_OK + return os.access(expanded, required) + + +def run_once( + spec: ShellHookSpec, kwargs: Dict[str, Any], +) -> Dict[str, Any]: + """Fire a single shell-hook invocation with a synthetic payload. + Used by ``hermes hooks test`` and ``hermes hooks doctor``. + + ``kwargs`` is the same dict that :func:`hermes_cli.plugins.invoke_hook` + would pass at runtime. It is routed through :func:`_serialize_payload` + so the synthetic stdin exactly matches what a real hook firing would + produce — otherwise scripts tested via ``hermes hooks test`` could + diverge silently from production behaviour. + + Returns the :func:`_spawn` diagnostic dict plus a ``parsed`` field + holding the canonical Hermes-wire-shape response.""" + stdin_json = _serialize_payload(spec.event, kwargs) + result = _spawn(spec, stdin_json) + result["parsed"] = _parse_response(spec.event, result["stdout"]) + return result diff --git a/cron/scheduler.py b/cron/scheduler.py index 359069966195..be7630c25233 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -965,7 +965,24 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: ) argv = [_bash, str(path)] else: - argv = [sys.executable, str(path)] + if sys.platform == "win32": + # On Windows, wrap Python scripts so hermes_bootstrap is + # imported first. The bootstrap monkey-patches subprocess.Popen + # to inject CREATE_NO_WINDOW -- without it, any subprocess call + # inside the script (e.g. spawning pwsh) flashes a console + # window because the cron child process does not load bootstrap + # on its own. + _script_path = str(path).replace("\\", "\\\\") + argv = [ + sys.executable, "-c", + ( + "import hermes_bootstrap; " + f"exec(compile(open(r'{_script_path}').read()," + f" r'{_script_path}', 'exec'))" + ), + ] + else: + argv = [sys.executable, str(path)] try: popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {} diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py index 890336c3448e..354b3898684c 100644 --- a/hermes_bootstrap.py +++ b/hermes_bootstrap.py @@ -1,129 +1,159 @@ -"""Windows UTF-8 bootstrap for Hermes entry points. - -Python on Windows has two long-standing text-encoding footguns: - -1. ``sys.stdout`` / ``sys.stderr`` are bound to the console code page - (``cp1252`` on US-locale installs), so ``print("café")`` crashes with - ``UnicodeEncodeError: 'charmap' codec can't encode character``. - -2. Child processes spawned via ``subprocess`` don't know to use UTF-8 - unless ``PYTHONUTF8`` and/or ``PYTHONIOENCODING`` are set in their - environment — so any Python subprocess (the execute_code sandbox, - delegation children, linter subprocesses, etc.) inherits the same - cp1252 defaults and hits the same UnicodeEncodeError. - -This module fixes both on Windows *only* — POSIX is untouched. It -should be imported at the very top of every Hermes entry point -(``hermes``, ``hermes-agent``, ``hermes-acp``, ``python -m gateway.run``, -``batch_runner.py``, ``cron/scheduler.py``) before any other imports -that might do file I/O or print to stdout. - -What this module does on Windows: - - - Sets ``os.environ["PYTHONUTF8"] = "1"`` (PEP 540 UTF-8 mode) so - every child process we spawn uses UTF-8 for ``open()`` and stdio. - - Sets ``os.environ["PYTHONIOENCODING"] = "utf-8"`` for belt-and- - suspenders — some tools read this instead of / in addition to - ``PYTHONUTF8``. - - Reconfigures ``sys.stdout`` / ``sys.stderr`` to UTF-8 in the current - process, using the ``reconfigure()`` API (Python 3.7+). This fixes - ``print("café")`` in the parent without a re-exec. - -What this module does NOT do: - - - It does not re-exec Python with ``-X utf8``, so ``open()`` calls in - the *current* process still default to locale encoding. Those need - an explicit ``encoding="utf-8"`` at the call site (lint rule - ``PLW1514`` / ``PYI058``). Ruff is the right tool for that sweep. - -What this module does on POSIX: - - - Nothing. POSIX systems are already UTF-8 by default in 99% of cases, - and we don't want to touch ``LANG``/``LC_*`` behavior that users may - have configured intentionally. If someone hits a C/POSIX locale on - Linux, they can export ``PYTHONUTF8=1`` themselves — we won't override. - -Idempotent: safe to call multiple times. ``_bootstrap_once`` guards -against double-reconfigure. -""" - -from __future__ import annotations - -import os -import sys - -_IS_WINDOWS = sys.platform == "win32" -_bootstrap_applied = False - - -def apply_windows_utf8_bootstrap() -> bool: - """Apply the Windows UTF-8 bootstrap if we're on Windows. - - Returns True if bootstrap was applied (i.e. we're on Windows and - haven't already done this), False otherwise. The return value is - advisory — callers normally don't need it, but tests may want to - assert the path was taken. - - Idempotent: subsequent calls after the first are a no-op. - """ - global _bootstrap_applied - - if not _IS_WINDOWS: - return False - if _bootstrap_applied: - return False - - # 1. Child processes inherit these and run in UTF-8 mode. - # We use setdefault() rather than overwriting so the user can - # explicitly opt out by setting PYTHONUTF8=0 in their environment - # (or PYTHONIOENCODING=something-else) if they really want to. - os.environ.setdefault("PYTHONUTF8", "1") - os.environ.setdefault("PYTHONIOENCODING", "utf-8") - - # 2. Reconfigure the current process's stdio to UTF-8. Needed - # because os.environ changes don't retroactively rebind sys.stdout - # — those were bound at interpreter startup based on the console - # code page. ``reconfigure`` is a TextIOWrapper method since 3.7. - # - # errors="replace" means that if we ever *read* something from - # stdin that isn't UTF-8 (unlikely but possible with piped input - # from legacy tools), we'll get U+FFFD replacement chars rather - # than a crash. Output is pure UTF-8. - for stream_name in ("stdout", "stderr"): - stream = getattr(sys, stream_name, None) - if stream is None: - continue - reconfigure = getattr(stream, "reconfigure", None) - if reconfigure is None: - # Not a TextIOWrapper (could be redirected to a BytesIO in - # tests, or a non-standard stream in some embedded cases). - # Skip silently — the env-var fix is still in effect for - # child processes, which is the bigger win. - continue - try: - reconfigure(encoding="utf-8", errors="replace") - except (OSError, ValueError): - # Already closed, or someone replaced it with something - # non-reconfigurable. Non-fatal. - pass - - # stdin is reconfigured separately with errors="replace" too — input - # from a legacy pipe shouldn't crash the process. - stdin = getattr(sys, "stdin", None) - if stdin is not None: - reconfigure = getattr(stdin, "reconfigure", None) - if reconfigure is not None: - try: - reconfigure(encoding="utf-8", errors="replace") - except (OSError, ValueError): - pass - - _bootstrap_applied = True - return True - - -# Apply on import — entry points just need ``import hermes_bootstrap`` -# (or ``from hermes_bootstrap import apply_windows_utf8_bootstrap``) at -# the very top of their module, before importing anything else. The -# import side effect does the right thing. -apply_windows_utf8_bootstrap() +"""Windows UTF-8 bootstrap for Hermes entry points. + +Python on Windows has two long-standing text-encoding footguns: + +1. ``sys.stdout`` / ``sys.stderr`` are bound to the console code page + (``cp1252`` on US-locale installs), so ``print("café")`` crashes with + ``UnicodeEncodeError: 'charmap' codec can't encode character``. + +2. Child processes spawned via ``subprocess`` don't know to use UTF-8 + unless ``PYTHONUTF8`` and/or ``PYTHONIOENCODING`` are set in their + environment — so any Python subprocess (the execute_code sandbox, + delegation children, linter subprocesses, etc.) inherits the same + cp1252 defaults and hits the same UnicodeEncodeError. + +This module fixes both on Windows *only* — POSIX is untouched. It +should be imported at the very top of every Hermes entry point +(``hermes``, ``hermes-agent``, ``hermes-acp``, ``python -m gateway.run``, +``batch_runner.py``, ``cron/scheduler.py``) before any other imports +that might do file I/O or print to stdout. + +What this module does on Windows: + + - Sets ``os.environ["PYTHONUTF8"] = "1"`` (PEP 540 UTF-8 mode) so + every child process we spawn uses UTF-8 for ``open()`` and stdio. + - Sets ``os.environ["PYTHONIOENCODING"] = "utf-8"`` for belt-and- + suspenders — some tools read this instead of / in addition to + ``PYTHONUTF8``. + - Reconfigures ``sys.stdout`` / ``sys.stderr`` to UTF-8 in the current + process, using the ``reconfigure()`` API (Python 3.7+). This fixes + ``print("café")`` in the parent without a re-exec. + +What this module does NOT do: + + - It does not re-exec Python with ``-X utf8``, so ``open()`` calls in + the *current* process still default to locale encoding. Those need + an explicit ``encoding="utf-8"`` at the call site (lint rule + ``PLW1514`` / ``PYI058``). Ruff is the right tool for that sweep. + +What this module does on POSIX: + + - Nothing. POSIX systems are already UTF-8 by default in 99% of cases, + and we don't want to touch ``LANG``/``LC_*`` behavior that users may + have configured intentionally. If someone hits a C/POSIX locale on + Linux, they can export ``PYTHONUTF8=1`` themselves — we won't override. + +Idempotent: safe to call multiple times. ``_bootstrap_once`` guards +against double-reconfigure. +""" + +from __future__ import annotations + +import os +import sys + +_IS_WINDOWS = sys.platform == "win32" +_bootstrap_applied = False + + +def apply_windows_utf8_bootstrap() -> bool: + """Apply the Windows UTF-8 bootstrap if we're on Windows. + + Returns True if bootstrap was applied (i.e. we're on Windows and + haven't already done this), False otherwise. The return value is + advisory — callers normally don't need it, but tests may want to + assert the path was taken. + + Idempotent: subsequent calls after the first are a no-op. + """ + global _bootstrap_applied + + if not _IS_WINDOWS: + return False + if _bootstrap_applied: + return False + + # 1. Child processes inherit these and run in UTF-8 mode. + # We use setdefault() rather than overwriting so the user can + # explicitly opt out by setting PYTHONUTF8=0 in their environment + # (or PYTHONIOENCODING=something-else) if they really want to. + os.environ.setdefault("PYTHONUTF8", "1") + os.environ.setdefault("PYTHONIOENCODING", "utf-8") + + # 2. Reconfigure the current process's stdio to UTF-8. Needed + # because os.environ changes don't retroactively rebind sys.stdout + # — those were bound at interpreter startup based on the console + # code page. ``reconfigure`` is a TextIOWrapper method since 3.7. + # + # errors="replace" means that if we ever *read* something from + # stdin that isn't UTF-8 (unlikely but possible with piped input + # from legacy tools), we'll get U+FFFD replacement chars rather + # than a crash. Output is pure UTF-8. + for stream_name in ("stdout", "stderr"): + stream = getattr(sys, stream_name, None) + if stream is None: + continue + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + # Not a TextIOWrapper (could be redirected to a BytesIO in + # tests, or a non-standard stream in some embedded cases). + # Skip silently — the env-var fix is still in effect for + # child processes, which is the bigger win. + continue + try: + reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + # Already closed, or someone replaced it with something + # non-reconfigurable. Non-fatal. + pass + + # stdin is reconfigured separately with errors="replace" too — input + # from a legacy pipe shouldn't crash the process. + stdin = getattr(sys, "stdin", None) + if stdin is not None: + reconfigure = getattr(stdin, "reconfigure", None) + if reconfigure is not None: + try: + reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + pass + + _bootstrap_applied = True + return True + + +# Apply on import — entry points just need ``import hermes_bootstrap`` +# (or ``from hermes_bootstrap import apply_windows_utf8_bootstrap``) at +# the very top of their module, before importing anything else. The +# import side effect does the right thing. +apply_windows_utf8_bootstrap() + + +def _patch_subprocess_no_window() -> None: + """Monkey-patch subprocess.Popen on Windows to always set CREATE_NO_WINDOW. + + Prevents console window flashes when the gateway (running under pythonw.exe, + which has no console) spawns child processes (copilot CLI, git, rg, ffprobe, + etc.). Without this flag every console-subsystem .exe briefly shows a black + window before hiding itself. + + Only active on win32. Uses the lowest-level hook point (the class __init__) + so it covers every caller — our code, third-party libs, asyncio subprocesses. + """ + if not (hasattr(__import__("sys"), "platform") and __import__("sys").platform == "win32"): + return + import subprocess as _sp + + _CREATE_NO_WINDOW = 0x08000000 + _orig_popen_init = _sp.Popen.__init__ + + def _patched_popen_init(self, args, **kwargs): + flags = kwargs.get("creationflags", 0) or 0 + if not (flags & _CREATE_NO_WINDOW): + kwargs["creationflags"] = flags | _CREATE_NO_WINDOW + _orig_popen_init(self, args, **kwargs) + + _sp.Popen.__init__ = _patched_popen_init + + +_patch_subprocess_no_window() diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index e5dfd358ed61..aaf79a5064d1 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -1,1529 +1,1536 @@ -""" -Photon Spectrum (iMessage) platform adapter for Hermes Agent. - -Both directions of traffic flow through a small supervised Node sidecar -(see ``sidecar/index.mjs``) that runs the ``spectrum-ts`` SDK — the SDK is -TypeScript-only and there is no public HTTP message API, so a sidecar is -unavoidable. - -Inbound: - The SDK's ``app.messages`` is a long-lived **gRPC** stream. The sidecar - serializes each message to a normalized JSON event and streams it to this - adapter over a loopback ``GET /inbound`` (NDJSON). A background task here - consumes that stream, dedupes on ``messageId``, and dispatches a - ``MessageEvent`` to the gateway via ``BasePlatformAdapter.handle_message``. - No webhook, no public URL, no signing secret. - -Outbound: - ``send`` / ``send_typing`` are loopback POSTs to the sidecar's control - endpoints, authenticated with a shared bearer token. Outbound media - (images, voice notes, video, documents) goes through spectrum-ts' - ``attachment()`` / ``voice()`` content builders via the sidecar's - ``/send-attachment`` endpoint. -""" -from __future__ import annotations - -import asyncio -import base64 -import json -import logging -import os -import re -import secrets -import shutil -import signal -import subprocess -import sys -import time -from datetime import datetime, timezone -from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional - -if TYPE_CHECKING: - # Type checkers see ``httpx`` as the always-imported module, so every use - # site type-checks cleanly. The runtime fallback below keeps the optional - # dependency truly optional (each use site is guarded by HTTPX_AVAILABLE). - import httpx - HTTPX_AVAILABLE = True -else: - try: - import httpx - HTTPX_AVAILABLE = True - except ImportError: # pragma: no cover - httpx is already a Hermes dep - HTTPX_AVAILABLE = False - httpx = None - -from gateway.config import Platform, PlatformConfig -from gateway.platforms.base import ( - BasePlatformAdapter, - MessageEvent, - MessageType, - ProcessingOutcome, - SendResult, -) -from gateway.platforms.helpers import strip_markdown - -from .auth import load_project_credentials - -logger = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Constants - -_DEFAULT_SIDECAR_PORT = 8789 -_DEFAULT_SIDECAR_BIND = "127.0.0.1" - -# Photon iMessage messages from the SDK side have no documented hard -# limit, but the underlying iMessage protocol limits practical message -# size to ~16 KB. Keep a conservative cap that matches BlueBubbles. -_MAX_MESSAGE_LENGTH = 8000 - -# Dedup parameters — the gRPC stream is at-least-once, and a sidecar -# reconnect can replay, so keep at least 1k ids for ~48h. -_DEDUP_MAX_SIZE = 4000 -_DEDUP_WINDOW_SECONDS = 48 * 3600 - -_SIDECAR_DIR = Path(__file__).parent / "sidecar" - -# Group-chat mention wake words. When ``require_mention`` is enabled, group -# messages are ignored unless they match one of these patterns — same -# behavior and defaults as the BlueBubbles iMessage channel so the two -# iMessage adapters gate group chats identically. -_DEFAULT_MENTION_PATTERNS = [ - r"(? int: - try: - return int(value) - except (TypeError, ValueError): - return default - - -def check_requirements() -> bool: - """Return True when both Python deps and the Node sidecar are available.""" - if not HTTPX_AVAILABLE: - return False - if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"): - return False - if not (_SIDECAR_DIR / "node_modules").exists(): - # spectrum-ts not installed yet — `hermes photon setup` will - # install it. check_fn still returns False so the gateway - # surfaces the missing-deps state in `hermes setup` / status. - return False - return True - - -def validate_config(cfg: PlatformConfig) -> bool: - extra = cfg.extra or {} - project_id = extra.get("project_id") or os.getenv("PHOTON_PROJECT_ID") - project_secret = extra.get("project_secret") or os.getenv("PHOTON_PROJECT_SECRET") - if not project_id or not project_secret: - # Fall back to auth.json - stored_id, stored_sec = load_project_credentials() - return bool(stored_id and stored_sec) - return True - - -def is_connected(cfg: PlatformConfig) -> bool: - return validate_config(cfg) - - -def _env_enablement() -> Optional[dict]: - """Seed PlatformConfig.extra from env so env-only setups appear in status. - - The special ``home_channel`` key is handled by the core plugin hook and - becomes a proper ``HomeChannel`` on ``PlatformConfig``. - """ - project_id, project_secret = load_project_credentials() - if not (project_id and project_secret): - return None - seed: dict = {"project_id": project_id, "project_secret": project_secret} - home = os.getenv("PHOTON_HOME_CHANNEL", "").strip() - if home: - seed["home_channel"] = { - "chat_id": home, - "name": os.getenv("PHOTON_HOME_CHANNEL_NAME", "Home"), - } - return seed - - -def _markdown_enabled() -> bool: - """Send agent replies as markdown (spectrum-ts ``markdown()`` builder). - - iMessage renders it natively; other Spectrum platforms degrade to - readable plain text. On-device rendering can't be unit-tested, so - ``PHOTON_MARKDOWN=false`` is the kill-switch back to stripped plain - text without a release. - """ - return os.getenv("PHOTON_MARKDOWN", "true").strip().lower() not in { - "false", "0", "no", - } - - -# --------------------------------------------------------------------------- -# Adapter - -class PhotonAdapter(BasePlatformAdapter): - """Bidirectional bridge to Photon Spectrum via the Node spectrum-ts sidecar. - - Inbound: consume the sidecar's ``/inbound`` gRPC stream. - Outbound: loopback POSTs to the sidecar's control channel. - """ - - MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH - - def __init__(self, config: PlatformConfig): - super().__init__(config, Platform("photon")) - extra = config.extra or {} - - # Project credentials (env wins, then config.extra, then auth.json). - # ``project_id`` here is the project's spectrumProjectId — the value - # the spectrum-ts SDK authenticates with. - stored_id, stored_sec = load_project_credentials() - self._project_id: str = ( - os.getenv("PHOTON_PROJECT_ID") - or extra.get("project_id") - or stored_id - or "" - ) - self._project_secret: str = ( - os.getenv("PHOTON_PROJECT_SECRET") - or extra.get("project_secret") - or stored_sec - or "" - ) - - # Sidecar - self._sidecar_port = _coerce_port( - extra.get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"), - _DEFAULT_SIDECAR_PORT, - ) - self._sidecar_bind = _DEFAULT_SIDECAR_BIND - self._sidecar_token = ( - os.getenv("PHOTON_SIDECAR_TOKEN") or secrets.token_hex(16) - ) - self._autostart_sidecar = str( - os.getenv("PHOTON_SIDECAR_AUTOSTART", "true") - ).lower() not in ("0", "false", "no") - self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node" - - # With markdown on, format_message preserves fences and the sidecar's - # markdown() builder renders them (or degrades them readably). - self.supports_code_blocks = _markdown_enabled() - - # Runtime state - self._sidecar_proc: Optional[subprocess.Popen] = None - self._sidecar_supervisor_task: Optional[asyncio.Task] = None - self._inbound_task: Optional[asyncio.Task] = None - self._inbound_running = False - self._http_client: Optional["httpx.AsyncClient"] = None - # Lightweight in-memory dedup. The gRPC stream is at-least-once, so we - # may see the same messageId more than once (e.g. after a reconnect). - self._seen_messages: Dict[str, float] = {} - # Ids of messages WE sent (bounded, insertion-order eviction). Inbound - # reaction events are only routed to the agent when they target one of - # these — a tapback on a human↔human message is not addressed to us. - self._sent_message_ids: Dict[str, float] = {} - # Latest inbound message id per chat (bounded). Lets the agent-facing - # react action default to "the message that triggered me" without - # requiring the model to thread message ids through tool calls. - self._last_inbound_by_chat: Dict[str, str] = {} - - # Group-chat mention gating (parity with BlueBubbles). When enabled, - # group messages are ignored unless they match a wake word; DMs are - # always processed. Config key wins, then env var. - _require_mention = extra.get("require_mention") - if _require_mention is None: - _require_mention = os.getenv("PHOTON_REQUIRE_MENTION") - self.require_mention = str(_require_mention).strip().lower() in { - "true", "1", "yes", "on", - } - self._mention_patterns = self._compile_mention_patterns( - extra["mention_patterns"] - if "mention_patterns" in extra - else os.getenv("PHOTON_MENTION_PATTERNS") - ) - - # -- Group-mention gating (parity with BlueBubbles) ------------------- - - @staticmethod - def _compile_mention_patterns(raw: Any) -> "list[re.Pattern]": - """Compile group-mention wake words from config/env. - - ``raw`` is a list (config or env JSON), a string (env var: JSON - list, or comma/newline-separated), or None (use Hermes defaults). - Mirrors the BlueBubbles implementation so both iMessage channels - accept the same configuration shapes. - """ - if raw is None: - patterns = list(_DEFAULT_MENTION_PATTERNS) - elif isinstance(raw, str): - text = raw.strip() - try: - loaded = json.loads(text) if text else [] - except Exception: - loaded = None - patterns = loaded if isinstance(loaded, list) else [ - part.strip() - for line in text.splitlines() - for part in line.split(",") - ] - elif isinstance(raw, list): - patterns = raw - else: - patterns = [raw] - - compiled: "list[re.Pattern]" = [] - for pattern in patterns: - text = str(pattern).strip() - if not text: - continue - try: - compiled.append(re.compile(text, re.IGNORECASE)) - except re.error as exc: - logger.warning("[photon] Invalid mention pattern %r: %s", text, exc) - return compiled - - def _message_matches_mention_patterns(self, text: str) -> bool: - if not text or not self._mention_patterns: - return False - return any(pattern.search(text) for pattern in self._mention_patterns) - - def _clean_mention_text(self, text: str) -> str: - """Strip a leading wake word before dispatch. - - Custom mention patterns are regexes, so we only strip a leading - match to avoid deleting ordinary words later in the prompt. - """ - if not text: - return text - for pattern in self._mention_patterns: - match = pattern.match(text.lstrip()) - if match: - cleaned = text.lstrip()[match.end():].lstrip(" ,:-") - return cleaned or text - return text - - # -- Connection lifecycle --------------------------------------------- - - async def connect(self) -> bool: - if not HTTPX_AVAILABLE: - self._set_fatal_error( - "MISSING_DEP", "httpx not installed", retryable=False - ) - return False - if not self._project_id or not self._project_secret: - self._set_fatal_error( - "MISSING_CREDENTIALS", - "PHOTON_PROJECT_ID and PHOTON_PROJECT_SECRET are required. " - "Run: hermes photon setup", - retryable=False, - ) - return False - - client = httpx.AsyncClient(timeout=30.0) - self._http_client = client - - # The sidecar holds the gRPC stream for BOTH directions, so it is - # required now (not just for outbound). - if self._autostart_sidecar: - try: - await self._start_sidecar() - except Exception as e: - self._set_fatal_error( - "SIDECAR_FAILED", - f"failed to start Photon sidecar: {e}", - retryable=True, - ) - await client.aclose() - self._http_client = None - return False - else: - logger.warning( - "[photon] sidecar autostart disabled — inbound + outbound will fail" - ) - - # Start consuming the inbound gRPC stream from the sidecar. - self._inbound_running = True - self._inbound_task = asyncio.get_event_loop().create_task( - self._inbound_loop() - ) - - self._mark_connected() - logger.info( - "[photon] connected — sidecar on %s:%d, streaming inbound over gRPC", - self._sidecar_bind, self._sidecar_port, - ) - return True - - async def disconnect(self) -> None: - self._inbound_running = False - if self._inbound_task is not None: - self._inbound_task.cancel() - try: - await self._inbound_task - except asyncio.CancelledError: - pass - except Exception: - pass - self._inbound_task = None - await self._stop_sidecar() - if self._http_client is not None: - try: - await self._http_client.aclose() - except Exception: - pass - self._http_client = None - self._mark_disconnected() - - # -- Inbound stream consumer ------------------------------------------ - - async def _inbound_loop(self) -> None: - """Consume the sidecar's ``/inbound`` NDJSON stream, with reconnect. - - The sidecar owns the gRPC reconnect/heartbeat to Photon; this loop - only has to re-open the loopback HTTP stream if it drops (e.g. the - sidecar restarts). - """ - client = self._http_client - if client is None: - return - url = f"http://{self._sidecar_bind}:{self._sidecar_port}/inbound" - headers = {"X-Hermes-Sidecar-Token": self._sidecar_token} - backoff = 1.0 - while self._inbound_running: - try: - async with client.stream( - "GET", url, headers=headers, timeout=None, - ) as resp: - if resp.status_code != 200: - raise RuntimeError(f"/inbound returned {resp.status_code}") - backoff = 1.0 # reset on a successful connect - async for line in resp.aiter_lines(): - if not self._inbound_running: - break - line = line.strip() - if not line: - continue # heartbeat - await self._on_inbound_line(line) - except asyncio.CancelledError: - raise - except Exception as e: - if not self._inbound_running: - break - logger.warning( - "[photon] inbound stream dropped (%s); reconnecting in %.1fs", - e, backoff, - ) - await asyncio.sleep(backoff) - backoff = min(backoff * 2, 30.0) - - async def _on_inbound_line(self, line: str) -> None: - try: - event = json.loads(line) - except json.JSONDecodeError: - logger.debug("[photon] skipping non-JSON inbound line") - return - msg_id = event.get("messageId") - if msg_id and self._is_duplicate(msg_id): - return - try: - await self._dispatch_inbound(event) - except Exception: - logger.exception("[photon] inbound dispatch failed") - - def _is_duplicate(self, msg_id: str) -> bool: - now = time.time() - seen = self._seen_messages - t = seen.get(msg_id) - if t is not None and now - t < _DEDUP_WINDOW_SECONDS: - return True # seen, unexpired - # New or expired: record and enforce a HARD size bound (evict oldest, - # insertion-order) so a burst of unique ids within the window can't grow - # the dict without limit — not just the expired-only prune. - if msg_id in seen: - del seen[msg_id] # refresh insertion order - seen[msg_id] = now - if len(seen) > _DEDUP_MAX_SIZE: - for old in list(seen.keys())[: len(seen) - _DEDUP_MAX_SIZE]: - del seen[old] - return False - - async def _dispatch_inbound(self, event: Dict[str, Any]) -> None: - """Normalize a sidecar inbound event and dispatch it to the gateway. - - Event shape (from ``sidecar/index.mjs``):: - - { - "messageId": "...", - "platform": "iMessage", - "space": {"id": "...", "type": "dm"|"group", "phone": "+E164"}, - "sender": {"id": "+E164"}, - "content": {"type": "text", "text": "..."} - | {"type": "attachment"|"voice", "id", "name", - "mimeType", "size", "duration"?, "data"?, - "encoding"?} - | {"type": "reaction", "emoji": "❤️", - "targetMessageId": "..." | null, - "targetDirection": "inbound"|"outbound" | null}, - "timestamp": "2026-05-14T19:06:32.000Z" - - Attachment and voice content carry the bytes inline as base64 ``data`` - (with ``encoding == "base64"``) when the sidecar could read them - within its size cap; otherwise only metadata is present and we surface - a marker. - } - """ - space = event.get("space") or {} - sender = event.get("sender") or {} - content = event.get("content") or {} - - space_id = space.get("id") or "" - if not space_id: - logger.warning("[photon] inbound missing space.id") - return - - # iMessage spaces carry their type directly — no id string-sniffing. - chat_type = "group" if space.get("type") == "group" else "dm" - sender_id = sender.get("id") or space.get("phone") or space_id - - ts_str = event.get("timestamp") or "" - try: - timestamp = ( - datetime.fromisoformat(ts_str.replace("Z", "+00:00")) - if ts_str - else datetime.now(tz=timezone.utc) - ) - except ValueError: - timestamp = datetime.now(tz=timezone.utc) - - # Media attachments (local cached paths) handed to the agent via the - # gateway's image-routing path, exactly like the BlueBubbles channel. - media_urls: List[str] = [] - media_types: List[str] = [] - - ctype = content.get("type") - if ctype == "reaction": - # Route only tapbacks on messages WE sent — those are implicitly - # addressed to the bot (feishu precedent: synthetic text event). - # Reactions on human↔human messages are not for us. Checked before - # the mention gate: a tapback never carries a wake word. - target_id = content.get("targetMessageId") - is_ours = content.get("targetDirection") == "outbound" or ( - target_id and target_id in self._sent_message_ids - ) - if not is_ours: - logger.debug( - "[photon] ignoring reaction on a message we didn't send" - ) - return - emoji = content.get("emoji") or "" - source = self.build_source( - chat_id=space_id, - chat_name=space_id, - chat_type=chat_type, - user_id=sender_id, - user_name=sender_id or None, - ) - await self.handle_message( - MessageEvent( - text=f"reaction:added:{emoji}", - message_type=MessageType.TEXT, - source=source, - message_id=event.get("messageId"), - raw_message=event, - timestamp=timestamp, - ) - ) - return - # Anything past here is a real (reactable) message — remember it as - # the chat's latest inbound so `add_reaction` can target it when the - # caller doesn't pass an explicit message id. Recorded before the - # mention gate: a reaction to a non-wake-word group message is valid. - self._record_last_inbound(space_id, event.get("messageId")) - if ctype == "text": - text = content.get("text") or "" - mtype = MessageType.TEXT - elif ctype in {"attachment", "voice"}: - is_voice = ctype == "voice" - name = content.get("name") or ("voice" if is_voice else "(unnamed)") - mime = content.get("mimeType") or "" - mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime) - cached = _cache_inbound_attachment( - content, name, mime, force_audio=is_voice - ) - if cached: - media_urls.append(cached) - media_types.append( - mime or ("audio/mp4" if is_voice else "application/octet-stream") - ) - # The real bytes are attached, so the agent sees the media - # itself — a short marker is enough text, and it keeps group - # mention-gating consistent with plain messages. - text = "(voice)" if is_voice else "(attachment)" - else: - # No bytes (over the sidecar cap, a failed read, or a caching - # failure) — fall back to a metadata marker so the agent still - # knows something arrived. - label = "voice" if is_voice else "attachment" - duration = content.get("duration") - duration_text = ( - f", duration: {duration}s" - if isinstance(duration, (int, float)) - else "" - ) - text = ( - f"[Photon {label} received: {name} " - f"({mime or 'unknown MIME'}{duration_text})]" - ) - else: - text = f"[Photon content type not handled: {ctype}]" - mtype = MessageType.TEXT - - # Group-mention gating (parity with BlueBubbles). In group chats with - # require_mention enabled, drop messages that don't hit a wake word; - # strip the leading wake word from the ones that do. DMs are never - # gated. - if chat_type == "group" and self.require_mention: - if not self._message_matches_mention_patterns(text): - logger.debug( - "[photon] ignoring group message " - "(require_mention=true, no mention pattern matched)" - ) - return - text = self._clean_mention_text(text) - - source = self.build_source( - chat_id=space_id, - chat_name=space_id, - chat_type=chat_type, - user_id=sender_id, - user_name=sender_id or None, - ) - message_event = MessageEvent( - text=text, - message_type=mtype, - source=source, - message_id=event.get("messageId"), - raw_message=event, - timestamp=timestamp, - media_urls=media_urls, - media_types=media_types, - ) - await self.handle_message(message_event) - - # -- Sidecar lifecycle ------------------------------------------------- - - @staticmethod - def _find_listener_pids(port: int) -> List[int]: - """PIDs listening on a local TCP port (empty if none/undeterminable).""" - try: - out = subprocess.run( # noqa: S603, S607 - ["lsof", "-ti", f"tcp:{port}", "-sTCP:LISTEN"], - capture_output=True, text=True, timeout=5.0, check=False, - ) - except (OSError, subprocess.TimeoutExpired): - return [] - return [int(tok) for tok in out.stdout.split() if tok.strip().isdigit()] - - @staticmethod - def _pid_is_sidecar(pid: int) -> bool: - """True if ``pid``'s command line is a Photon sidecar process.""" - try: - out = subprocess.run( # noqa: S603, S607 - ["ps", "-p", str(pid), "-o", "command="], - capture_output=True, text=True, timeout=5.0, check=False, - ) - except (OSError, subprocess.TimeoutExpired): - return False - # Checkout-agnostic: any Hermes checkout's sidecar entry point. - return "photon/sidecar/index.mjs" in out.stdout - - @staticmethod - def _pid_alive(pid: int) -> bool: - try: - os.kill(pid, 0) # windows-footgun: ok — only called from _reap_stale_sidecar which win32-guards early - return True - except OSError: - return False - - async def _reap_stale_sidecar(self) -> None: - """Kill an orphaned sidecar squatting our port before spawning ours. - - A hard gateway exit (crash, SIGKILL, supervisor restart) used to leave - the detached sidecar running with a token the new gateway doesn't - know, so it can't be told to ``/shutdown`` — and every replacement - spawn died on EADDRINUSE, failing each reconnect attempt. The - stdin-EOF watch prevents new orphans; this reclaims the port from - orphans that predate it (or survived it). Listeners are verified by - command line before being signalled. - """ - if sys.platform == "win32": # lsof/ps; orphaning is a POSIX-only path - return - try: - async with httpx.AsyncClient(timeout=2.0) as client: - await client.post( - f"http://{self._sidecar_bind}:{self._sidecar_port}/healthz", - headers={"X-Hermes-Sidecar-Token": self._sidecar_token}, - ) - except httpx.RequestError: - return # nothing listening — the normal case - pids = self._find_listener_pids(self._sidecar_port) - stale = [pid for pid in pids if self._pid_is_sidecar(pid)] - foreign = [pid for pid in pids if pid not in stale] - if not stale: - raise RuntimeError( - f"port {self._sidecar_port} is in use by another process " - f"(pids: {foreign or 'unknown'}, not a Photon sidecar) — " - f"free it or set PHOTON_SIDECAR_PORT to a different port" - ) - for pid in stale: - logger.warning( - "[photon] reaping orphaned sidecar (pid %d) on port %d", - pid, self._sidecar_port, - ) - try: - os.kill(pid, signal.SIGTERM) - except OSError: - pass - deadline = time.time() + 3.0 - while time.time() < deadline and any(self._pid_alive(p) for p in stale): - await asyncio.sleep(0.1) - for pid in stale: - if self._pid_alive(pid): - try: - os.kill(pid, signal.SIGKILL) # windows-footgun: ok — unreachable on win32 (early return above) - except OSError: - pass - # Give the OS a beat to release the listening socket. - await asyncio.sleep(0.2) - if foreign: - raise RuntimeError( - f"port {self._sidecar_port} is also held by non-sidecar " - f"processes (pids: {foreign}) — free it or set " - f"PHOTON_SIDECAR_PORT to a different port" - ) - - async def _start_sidecar(self) -> None: - if not (_SIDECAR_DIR / "node_modules").exists(): - raise RuntimeError( - f"Photon sidecar deps not installed. Run: " - f"cd {_SIDECAR_DIR} && npm install (or `hermes photon setup`)" - ) - await self._reap_stale_sidecar() - - env = os.environ.copy() - env["PHOTON_PROJECT_ID"] = self._project_id - env["PHOTON_PROJECT_SECRET"] = self._project_secret - env["PHOTON_SIDECAR_PORT"] = str(self._sidecar_port) - env["PHOTON_SIDECAR_BIND"] = self._sidecar_bind - env["PHOTON_SIDECAR_TOKEN"] = self._sidecar_token - # The sidecar exits when its stdin (the pipe below) hits EOF, so a - # gateway death of ANY kind — including SIGKILL, where disconnect() - # never runs — can't leave it orphaned on the port. - env["PHOTON_SIDECAR_WATCH_STDIN"] = "1" - - self._sidecar_proc = subprocess.Popen( # noqa: S603 - [self._node_bin, str(_SIDECAR_DIR / "index.mjs")], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=env, - start_new_session=(sys.platform != "win32"), - ) - - # Pump sidecar stderr/stdout into our logger so users see crashes. - loop = asyncio.get_event_loop() - self._sidecar_supervisor_task = loop.create_task( - self._supervise_sidecar(self._sidecar_proc) - ) - - # Wait for /healthz to come up — give it up to 15s on cold start. - deadline = time.time() + 15.0 - last_err: Optional[Exception] = None - async with httpx.AsyncClient(timeout=2.0) as client: - while time.time() < deadline: - if self._sidecar_proc.poll() is not None: - raise RuntimeError( - f"Photon sidecar exited with code " - f"{self._sidecar_proc.returncode} before becoming ready" - ) - try: - resp = await client.post( - f"http://{self._sidecar_bind}:{self._sidecar_port}/healthz", - headers={"X-Hermes-Sidecar-Token": self._sidecar_token}, - ) - if resp.status_code == 200: - return - except httpx.RequestError as e: - last_err = e - await asyncio.sleep(0.2) - raise RuntimeError( - f"Photon sidecar did not become ready within 15s: {last_err}" - ) - - async def _supervise_sidecar(self, proc: subprocess.Popen) -> None: - """Pump the sidecar's stdout/stderr into our logger.""" - if proc.stdout is None: # subprocess was launched without stdout=PIPE - return - stdout = proc.stdout - loop = asyncio.get_event_loop() - try: - while True: - line = await loop.run_in_executor(None, stdout.readline) - if not line: - break - logger.info("[photon-sidecar] %s", line.decode("utf-8", "replace").rstrip()) - except Exception as e: # pragma: no cover - defensive - logger.warning("[photon-sidecar] supervisor exited: %s", e) - - async def _stop_sidecar(self) -> None: - proc = self._sidecar_proc - if proc is None: - return - try: - # Closing our end of the stdin pipe is itself a shutdown signal - # (the sidecar watches for EOF), and covers the case where the - # HTTP call below can't get through. - if proc.stdin is not None: - try: - proc.stdin.close() - except Exception: - pass - # Polite shutdown first. - if self._http_client is not None: - try: - await self._http_client.post( - f"http://{self._sidecar_bind}:{self._sidecar_port}/shutdown", - headers={"X-Hermes-Sidecar-Token": self._sidecar_token}, - timeout=2.0, - ) - except Exception: - pass - try: - proc.wait(timeout=3.0) - except subprocess.TimeoutExpired: - if sys.platform != "win32": - try: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) # windows-footgun: ok - except (ProcessLookupError, PermissionError): - proc.terminate() - else: - proc.terminate() - try: - proc.wait(timeout=2.0) - except subprocess.TimeoutExpired: - proc.kill() - finally: - self._sidecar_proc = None - if self._sidecar_supervisor_task is not None: - self._sidecar_supervisor_task.cancel() - self._sidecar_supervisor_task = None - - # -- Outbound ---------------------------------------------------------- - - async def send( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: - return await self._sidecar_send(chat_id, self.format_message(content)) - - # -- Outbound media (parity with the BlueBubbles iMessage channel) ----- - # - # Photon ships outbound attachments via spectrum-ts' `attachment()` / - # `voice()` content builders. The sidecar's `/send-attachment` endpoint - # wraps `space.send(attachment(path, {...}))`. These overrides mirror - # BlueBubbles: URL-based helpers cache to a local path first, file-based - # helpers pass the path straight through. - - async def send_image( - self, - chat_id: str, - image_url: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: - try: - from gateway.platforms.base import cache_image_from_url - - local_path = await cache_image_from_url(image_url) - except Exception: - # Couldn't fetch the URL — fall back to sending it as text. - return await super().send_image(chat_id, image_url, caption, reply_to) - return await self._sidecar_send_attachment( - chat_id, local_path, caption=caption, - ) - - async def send_image_file( - self, - chat_id: str, - image_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs, - ) -> SendResult: - return await self._sidecar_send_attachment( - chat_id, image_path, caption=caption, - ) - - async def send_voice( - self, - chat_id: str, - audio_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs, - ) -> SendResult: - return await self._sidecar_send_attachment( - chat_id, audio_path, caption=caption, kind="voice", - ) - - async def send_video( - self, - chat_id: str, - video_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs, - ) -> SendResult: - return await self._sidecar_send_attachment( - chat_id, video_path, caption=caption, - ) - - async def send_document( - self, - chat_id: str, - file_path: str, - caption: Optional[str] = None, - file_name: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs, - ) -> SendResult: - return await self._sidecar_send_attachment( - chat_id, file_path, name=file_name, caption=caption, - ) - - async def send_animation( - self, - chat_id: str, - animation_url: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: - # iMessage renders GIFs inline as ordinary image attachments. - return await self.send_image( - chat_id, animation_url, caption, reply_to, metadata, - ) - - async def send_typing(self, chat_id: str, metadata=None) -> None: - try: - await self._sidecar_call( - "/typing", {"spaceId": chat_id, "state": "start"} - ) - except Exception as e: - logger.debug("[photon] send_typing failed: %s", e) - - async def stop_typing(self, chat_id: str) -> None: - try: - await self._sidecar_call( - "/typing", {"spaceId": chat_id, "state": "stop"} - ) - except Exception as e: - logger.debug("[photon] stop_typing failed: %s", e) - - # -- Reactions (tapbacks) ----------------------------------------------- - # - # Same lifecycle-hook pattern as Telegram/Discord: 👀 while processing, - # swapped for 👍/👎 on completion. Opt-in via PHOTON_REACTIONS — iMessage - # is a personal-texting channel, and a tapback on every text is noisy. - - _SENT_IDS_MAX = 1000 - _LAST_INBOUND_CHATS_MAX = 200 - - def _record_sent_message(self, message_id: Optional[str]) -> None: - if not message_id: - return - sent = self._sent_message_ids - if message_id in sent: - del sent[message_id] # refresh insertion order - sent[message_id] = time.time() - if len(sent) > self._SENT_IDS_MAX: - for old in list(sent.keys())[: len(sent) - self._SENT_IDS_MAX]: - del sent[old] - - # A DM space is addressable two ways — the chat GUID (`any;-;+1555...`) - # that inbound events carry, and the bare E.164 phone that home-channel - # config typically uses. The sidecar's resolveSpace treats them as the - # same space; normalize to the bare phone so the last-inbound tracker - # does too (mirrors phoneTargetFromSpaceId in sidecar/index.mjs). - _DM_CHAT_GUID_RE = re.compile(r"^any;-;(\+\d{6,})$") - - @classmethod - def _normalize_chat_key(cls, chat_id: str) -> str: - match = cls._DM_CHAT_GUID_RE.match(chat_id) - return match.group(1) if match else chat_id - - def _record_last_inbound( - self, chat_id: Optional[str], message_id: Optional[str] - ) -> None: - if not chat_id or not message_id: - return - key = self._normalize_chat_key(chat_id) - last = self._last_inbound_by_chat - if key in last: - del last[key] # refresh insertion order - last[key] = message_id - if len(last) > self._LAST_INBOUND_CHATS_MAX: - for old in list(last.keys())[ - : len(last) - self._LAST_INBOUND_CHATS_MAX - ]: - del last[old] - - def _reactions_enabled(self) -> bool: - return os.getenv("PHOTON_REACTIONS", "false").strip().lower() in { - "true", "1", "yes", "on", - } - - async def _add_reaction( - self, chat_id: str, message_id: str, emoji: str - ) -> bool: - """Tapback ``emoji`` onto a message. Soft-fails (False), never raises.""" - try: - await self._sidecar_call( - "/react", - {"spaceId": chat_id, "messageId": message_id, "emoji": emoji}, - ) - return True - except Exception as e: - logger.debug("[photon] add_reaction failed: %s", e) - return False - - async def _remove_reaction(self, chat_id: str, message_id: str) -> bool: - """Retract our tapback from a message. Soft-fails (False), never raises. - - The sidecar tracks one reaction handle per target message; after a - sidecar restart the handle is gone and removal is best-effort (the - stale tapback self-heals when the next reaction replaces it). - """ - try: - await self._sidecar_call( - "/unreact", {"spaceId": chat_id, "messageId": message_id}, - ) - return True - except Exception as e: - logger.debug("[photon] remove_reaction failed: %s", e) - return False - - # -- Agent-facing reactions (send_message action="react") --------------- - # - # Unlike the lifecycle hooks below, these are deliberate agent intents, - # so they are NOT gated by PHOTON_REACTIONS (that env var exists to mute - # the automatic per-message tapback noise, not explicit requests). - - async def add_reaction( - self, - chat_id: str, - emoji: str, - message_id: Optional[str] = None, - ) -> Dict[str, Any]: - """Tapback ``emoji`` onto a message in ``chat_id``. - - Without ``message_id``, targets the chat's most recent inbound - message (typically the one the agent is responding to). iMessage - maps ❤️👍👎😂‼️❓ to native tapbacks; anything else uses Apple's - custom-emoji reaction. - """ - target = message_id or self._last_inbound_by_chat.get( - self._normalize_chat_key(chat_id) - ) - if not target: - return { - "success": False, - "error": "no message to react to — pass message_id (no " - "inbound message seen in this chat since the gateway started)", - } - ok = await self._add_reaction(chat_id, target, emoji) - if not ok: - return { - "success": False, - "error": "reaction failed (see gateway debug log)", - } - return {"success": True, "message_id": target} - - async def remove_reaction( - self, chat_id: str, message_id: Optional[str] = None - ) -> Dict[str, Any]: - """Retract our tapback from a message (best-effort).""" - target = message_id or self._last_inbound_by_chat.get( - self._normalize_chat_key(chat_id) - ) - if not target: - return { - "success": False, - "error": "no message to unreact — pass message_id", - } - ok = await self._remove_reaction(chat_id, target) - if not ok: - return { - "success": False, - "error": "unreact failed (see gateway debug log)", - } - return {"success": True, "message_id": target} - - async def on_processing_start(self, event: MessageEvent) -> None: - """Tapback 👀 on the triggering message while the agent works.""" - if not self._reactions_enabled(): - return - chat_id = getattr(event.source, "chat_id", None) - message_id = getattr(event, "message_id", None) - if chat_id and message_id: - await self._add_reaction(chat_id, message_id, "\U0001f440") - - async def on_processing_complete( - self, event: MessageEvent, outcome: ProcessingOutcome - ) -> None: - """Swap the 👀 progress tapback for a 👍/👎 result. - - Remove-then-add rather than a bare replace: deterministic whether the - platform replaces a sender's previous tapback or stacks them, and it - keeps the sidecar's reaction-handle slot coherent. - """ - if not self._reactions_enabled(): - return - chat_id = getattr(event.source, "chat_id", None) - message_id = getattr(event, "message_id", None) - if not chat_id or not message_id: - return - await self._remove_reaction(chat_id, message_id) - if outcome == ProcessingOutcome.SUCCESS: - await self._add_reaction(chat_id, message_id, "\U0001f44d") - elif outcome == ProcessingOutcome.FAILURE: - await self._add_reaction(chat_id, message_id, "\U0001f44e") - # CANCELLED: leave the message unreacted. - - async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: - """Return whatever we know about a Spectrum space id. - - Photon's ``space.id`` is opaque; the inbound event also carries the - DM/group type, but here we only have the id, so infer conservatively. - """ - return {"name": chat_id, "type": "dm", "id": chat_id} - - def format_message(self, content: str) -> str: - # Markdown is passed through verbatim — the sidecar sends it with the - # markdown() builder and iMessage renders it. The strip path remains - # as the PHOTON_MARKDOWN=false kill-switch. - if _markdown_enabled(): - return content - return strip_markdown(content) - - async def _send_with_retry( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - metadata: Any = None, - max_retries: int = 2, - base_delay: float = 2.0, - ) -> SendResult: - """Retry sends without the generic Markdown banner. - - Photon replies are markdown (rendered by iMessage) or stripped plain - text under ``PHOTON_MARKDOWN=false`` — either way the gateway's - generic banner never applies. - """ - text = self.format_message(content) - result = await self.send( - chat_id=chat_id, - content=text, - reply_to=reply_to, - metadata=metadata, - ) - if result.success: - return result - - error_str = result.error or "" - is_network = result.retryable or self._is_retryable_error(error_str) - if not is_network and self._is_timeout_error(error_str): - return result - - if is_network: - for attempt in range(1, max_retries + 1): - delay = base_delay * (2 ** (attempt - 1)) - logger.warning( - "[photon] Send failed (attempt %d/%d, retrying in %.1fs): %s", - attempt, max_retries, delay, error_str, - ) - await asyncio.sleep(delay) - result = await self.send( - chat_id=chat_id, - content=text, - reply_to=reply_to, - metadata=metadata, - ) - if result.success: - return result - error_str = result.error or "" - if not (result.retryable or self._is_retryable_error(error_str)): - break - else: - logger.error( - "[photon] Failed to deliver response after %d retries: %s", - max_retries, error_str, - ) - return result - - logger.warning( - "[photon] Send failed: %s - retrying plain-text message", - error_str, - ) - fallback_result = await self.send( - chat_id=chat_id, - content=text[: self.MAX_MESSAGE_LENGTH], - reply_to=reply_to, - metadata=metadata, - ) - if not fallback_result.success: - logger.error("[photon] Plain-text retry also failed: %s", fallback_result.error) - return fallback_result - - async def _sidecar_send(self, space_id: str, text: str) -> SendResult: - if len(text) > self.MAX_MESSAGE_LENGTH: - logger.warning( - "[photon] truncating outbound from %d to %d chars", - len(text), self.MAX_MESSAGE_LENGTH, - ) - text = text[: self.MAX_MESSAGE_LENGTH] - body: Dict[str, Any] = {"spaceId": space_id, "text": text} - # Omit the key when disabled so an older sidecar (pre-`format`) - # keeps accepting the body during a half-upgraded restart. - if _markdown_enabled(): - body["format"] = "markdown" - try: - data = await self._sidecar_call("/send", body) - except Exception as e: - return SendResult(success=False, error=str(e)) - self._record_sent_message(data.get("messageId")) - return SendResult(success=True, message_id=data.get("messageId")) - - async def _sidecar_send_attachment( - self, - space_id: str, - path: str, - *, - name: Optional[str] = None, - mime_type: Optional[str] = None, - caption: Optional[str] = None, - kind: str = "attachment", - ) -> SendResult: - """POST a local file to the sidecar's ``/send-attachment`` endpoint. - - ``kind`` is ``"voice"`` for audio sent as a voice note (downgrades - to a plain audio attachment on platforms without voice notes), - otherwise ``"attachment"``. spectrum-ts infers ``name`` and - ``mimeType`` from the file extension; we only pass overrides when - Hermes supplied them. - """ - # Defense-in-depth: re-validate the path before handing it to the - # Node sidecar. The gateway already filters MEDIA paths, but - # send_*_file / cron callers may pass arbitrary strings. - safe_path = self.validate_media_delivery_path(str(path)) - if not safe_path: - return SendResult( - success=False, error=f"unsafe or missing attachment path: {path}" - ) - if not mime_type: - import mimetypes - - guessed, _ = mimetypes.guess_type(safe_path) - mime_type = guessed or None - body: Dict[str, Any] = { - "spaceId": space_id, - "path": safe_path, - "kind": "voice" if kind == "voice" else "attachment", - } - if name: - body["name"] = name - if mime_type: - body["mimeType"] = mime_type - if caption: - body["caption"] = caption - try: - data = await self._sidecar_call("/send-attachment", body) - except Exception as e: - return SendResult(success=False, error=str(e)) - self._record_sent_message(data.get("messageId")) - return SendResult(success=True, message_id=data.get("messageId")) - - async def _sidecar_call(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: - # Guard: adapter not yet connected (no sidecar address known). - if self._http_client is None: - raise RuntimeError("Photon adapter not connected") - # Use a fresh client per call so this method is safe when invoked from - # a worker thread that owns a different event loop than the one the - # persistent _http_client was created on (e.g. via _run_async in - # send_message_tool). The inbound streaming loop continues to use - # _http_client directly — it always runs on the gateway's loop. - url = f"http://{self._sidecar_bind}:{self._sidecar_port}{path}" - headers = {"X-Hermes-Sidecar-Token": self._sidecar_token} - async with httpx.AsyncClient(timeout=30.0) as client: - resp = await client.post(url, json=body, headers=headers) - if resp.status_code != 200: - raise RuntimeError( - f"Photon sidecar {path} returned {resp.status_code}: {resp.text[:200]}" - ) - data = resp.json() or {} - if not data.get("ok"): - raise RuntimeError( - f"Photon sidecar {path} reported error: {data.get('error')}" - ) - return data - - -# --------------------------------------------------------------------------- -# Helpers - -def _attachment_message_type(mime: str) -> MessageType: - mime = (mime or "").lower() - if mime.startswith("image/"): - return MessageType.PHOTO - if mime.startswith("video/"): - return MessageType.VIDEO - if mime.startswith("audio/"): - return MessageType.AUDIO - if mime.startswith("application/"): - return MessageType.DOCUMENT - return MessageType.DOCUMENT - - -# MIME → file-extension maps for caching inbound attachment bytes. These mirror -# the BlueBubbles iMessage channel so both adapters name cached media the same. -_IMAGE_EXT_BY_MIME = { - "image/jpeg": ".jpg", - "image/png": ".png", - "image/gif": ".gif", - "image/webp": ".webp", - "image/heic": ".jpg", - "image/heif": ".jpg", - "image/tiff": ".jpg", -} -_AUDIO_EXT_BY_MIME = { - "audio/mp3": ".mp3", - "audio/mpeg": ".mp3", - "audio/ogg": ".ogg", - "audio/wav": ".wav", - "audio/x-caf": ".mp3", - "audio/mp4": ".m4a", - "audio/aac": ".m4a", -} - - -def _cache_inbound_attachment( - content: Dict[str, Any], - name: str, - mime: str, - *, - force_audio: bool = False, -) -> Optional[str]: - """Decode a base64-inlined inbound attachment and cache it locally. - - The sidecar inlines the attachment bytes as ``content["data"]`` (base64). - We decode them and route to the shared media cache by MIME type, returning - the cached absolute path so the caller can populate ``media_urls`` (which - the gateway then hands to the model). Returns ``None`` when there are no - bytes (over the sidecar's inline cap or a failed read) or when caching - fails, so the caller can fall back to a text marker. - """ - data_b64 = content.get("data") - if not data_b64: - return None - try: - raw = base64.b64decode(data_b64) - except (ValueError, TypeError) as exc: - logger.warning("[photon] failed to decode inbound attachment bytes: %s", exc) - return None - - from gateway.platforms.base import ( - cache_audio_from_bytes, - cache_document_from_bytes, - cache_image_from_bytes, - ) - - mime = (mime or "").lower() - # Prefer the real extension from the filename; fall back to the MIME map. - suffix = Path(name).suffix if name else "" - try: - if mime.startswith("image/"): - ext = suffix or _IMAGE_EXT_BY_MIME.get(mime, ".jpg") - try: - return cache_image_from_bytes(raw, ext) - except ValueError: - # Bytes don't look like a supported image (e.g. HEIC magic) — - # still deliver them as a document rather than dropping them. - return cache_document_from_bytes(raw, name) - if force_audio or mime.startswith("audio/"): - ext = suffix or _AUDIO_EXT_BY_MIME.get( - mime, ".m4a" if force_audio else ".mp3" - ) - return cache_audio_from_bytes(raw, ext) - # Video, application/*, and everything else → document cache. - return cache_document_from_bytes(raw, name) - except Exception as exc: - logger.warning("[photon] failed to cache inbound attachment %s: %s", name, exc) - return None - - -# --------------------------------------------------------------------------- -# Standalone (out-of-process) send for cron deliveries when the gateway -# is not co-resident. Reuses a live sidecar already listening on the -# configured port (cron processes cannot spawn the sidecar themselves). - -async def _standalone_send( - pconfig: PlatformConfig, - chat_id: str, - message: str, - *, - thread_id: Optional[str] = None, # noqa: ARG001 — Spectrum has no threads yet - media_files: Optional[list] = None, - force_document: bool = False, # noqa: ARG001 — iMessage auto-detects file kind -) -> Dict[str, Any]: - if not HTTPX_AVAILABLE: - return {"error": "httpx not installed"} - port = _coerce_port( - (pconfig.extra or {}).get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"), - _DEFAULT_SIDECAR_PORT, - ) - token = os.getenv("PHOTON_SIDECAR_TOKEN") - if not token: - return { - "error": ( - "Photon standalone send requires a running sidecar with " - "PHOTON_SIDECAR_TOKEN set in the environment. Cron processes " - "cannot spawn the sidecar themselves." - ) - } - base = f"http://{_DEFAULT_SIDECAR_BIND}:{port}" - headers = {"X-Hermes-Sidecar-Token": token} - last_message_id: Optional[str] = None - try: - async with httpx.AsyncClient(timeout=30.0) as client: - # 1. Text body first (if any), so it leads the conversation. - if message: - send_body: Dict[str, Any] = { - "spaceId": chat_id, - "text": message[:_MAX_MESSAGE_LENGTH], - } - if _markdown_enabled(): - send_body["format"] = "markdown" - resp = await client.post( - f"{base}/send", json=send_body, headers=headers, - ) - if resp.status_code != 200: - return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"} - data = resp.json() or {} - if not data.get("ok"): - return {"error": data.get("error") or "sidecar reported failure"} - last_message_id = data.get("messageId") - - # 2. Each attachment as a separate /send-attachment call. - # media_files is List[Tuple[path, is_voice]] (see - # BasePlatformAdapter.filter_media_delivery_paths). - import mimetypes - - for media_path, is_voice in media_files or []: - safe_path = BasePlatformAdapter.validate_media_delivery_path(str(media_path)) - if not safe_path: - logger.warning("[photon] standalone send skipping unsafe path") - continue - guessed, _ = mimetypes.guess_type(safe_path) - att_body: Dict[str, Any] = { - "spaceId": chat_id, - "path": safe_path, - "kind": "voice" if is_voice else "attachment", - } - if guessed: - att_body["mimeType"] = guessed - resp = await client.post( - f"{base}/send-attachment", json=att_body, headers=headers, - ) - if resp.status_code != 200: - return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"} - data = resp.json() or {} - if not data.get("ok"): - return {"error": data.get("error") or "sidecar reported failure"} - last_message_id = data.get("messageId") or last_message_id - - return {"success": True, "message_id": last_message_id} - except Exception as e: - return {"error": f"Photon standalone send failed: {e}"} - - -# --------------------------------------------------------------------------- -# Plugin entry point - -def register(ctx) -> None: - """Called by the Hermes plugin loader at startup.""" - # Local import to avoid argparse work at module load; reused for both the - # gateway-setup hook and the `hermes photon` CLI command below. - from . import cli as _cli - - ctx.register_platform( - name="photon", - label="iMessage via Photon", - adapter_factory=lambda cfg: PhotonAdapter(cfg), - check_fn=check_requirements, - validate_config=validate_config, - is_connected=is_connected, - required_env=["PHOTON_PROJECT_ID", "PHOTON_PROJECT_SECRET"], - install_hint=( - "Run: hermes photon setup (logs in via device flow, creates a " - "Spectrum project, links your phone number, installs the " - "spectrum-ts sidecar)." - ), - # Surfaces Photon in `hermes gateway setup` alongside every other - # channel — same unified onboarding wizard, no Photon-only detour. - setup_fn=_cli.gateway_setup, - env_enablement_fn=_env_enablement, - cron_deliver_env_var="PHOTON_HOME_CHANNEL", - standalone_sender_fn=_standalone_send, - allowed_users_env="PHOTON_ALLOWED_USERS", - allow_all_env="PHOTON_ALLOW_ALL_USERS", - max_message_length=_MAX_MESSAGE_LENGTH, - emoji="📱", - # iMessage carries E.164 phone numbers — treat session descriptions - # as PII-sensitive so they get redacted before reaching the LLM - # (matches the BlueBubbles iMessage channel in _PII_SAFE_PLATFORMS). - pii_safe=True, - allow_update_command=True, - platform_hint=( - "You are communicating via Photon Spectrum (iMessage). " - "Treat replies like regular text messages — short and friendly. " - "Markdown is rendered (bold, italics, lists, code), but keep " - "formatting light and conversational. Recipient identifiers are " - "E.164 phone numbers; never expose them in responses unless the " - "user asked. Attachments arrive as metadata only." - ), - ) - - # Register CLI subcommands — `hermes photon ...` - ctx.register_cli_command( - name="photon", - help="Set up and manage the Photon iMessage integration", - setup_fn=_cli.register_cli, - handler_fn=_cli.dispatch, - ) +""" +Photon Spectrum (iMessage) platform adapter for Hermes Agent. + +Both directions of traffic flow through a small supervised Node sidecar +(see ``sidecar/index.mjs``) that runs the ``spectrum-ts`` SDK — the SDK is +TypeScript-only and there is no public HTTP message API, so a sidecar is +unavoidable. + +Inbound: + The SDK's ``app.messages`` is a long-lived **gRPC** stream. The sidecar + serializes each message to a normalized JSON event and streams it to this + adapter over a loopback ``GET /inbound`` (NDJSON). A background task here + consumes that stream, dedupes on ``messageId``, and dispatches a + ``MessageEvent`` to the gateway via ``BasePlatformAdapter.handle_message``. + No webhook, no public URL, no signing secret. + +Outbound: + ``send`` / ``send_typing`` are loopback POSTs to the sidecar's control + endpoints, authenticated with a shared bearer token. Outbound media + (images, voice notes, video, documents) goes through spectrum-ts' + ``attachment()`` / ``voice()`` content builders via the sidecar's + ``/send-attachment`` endpoint. +""" +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import os +import re +import secrets +import shutil +import signal +import subprocess +import sys +import time + +if sys.platform == "win32": + from hermes_cli._subprocess_compat import windows_hide_flags as _windows_hide_flags +else: + def _windows_hide_flags() -> int: # type: ignore[misc] + return 0 +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +if TYPE_CHECKING: + # Type checkers see ``httpx`` as the always-imported module, so every use + # site type-checks cleanly. The runtime fallback below keeps the optional + # dependency truly optional (each use site is guarded by HTTPX_AVAILABLE). + import httpx + HTTPX_AVAILABLE = True +else: + try: + import httpx + HTTPX_AVAILABLE = True + except ImportError: # pragma: no cover - httpx is already a Hermes dep + HTTPX_AVAILABLE = False + httpx = None + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + ProcessingOutcome, + SendResult, +) +from gateway.platforms.helpers import strip_markdown + +from .auth import load_project_credentials + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants + +_DEFAULT_SIDECAR_PORT = 8789 +_DEFAULT_SIDECAR_BIND = "127.0.0.1" + +# Photon iMessage messages from the SDK side have no documented hard +# limit, but the underlying iMessage protocol limits practical message +# size to ~16 KB. Keep a conservative cap that matches BlueBubbles. +_MAX_MESSAGE_LENGTH = 8000 + +# Dedup parameters — the gRPC stream is at-least-once, and a sidecar +# reconnect can replay, so keep at least 1k ids for ~48h. +_DEDUP_MAX_SIZE = 4000 +_DEDUP_WINDOW_SECONDS = 48 * 3600 + +_SIDECAR_DIR = Path(__file__).parent / "sidecar" + +# Group-chat mention wake words. When ``require_mention`` is enabled, group +# messages are ignored unless they match one of these patterns — same +# behavior and defaults as the BlueBubbles iMessage channel so the two +# iMessage adapters gate group chats identically. +_DEFAULT_MENTION_PATTERNS = [ + r"(? int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def check_requirements() -> bool: + """Return True when both Python deps and the Node sidecar are available.""" + if not HTTPX_AVAILABLE: + return False + if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"): + return False + if not (_SIDECAR_DIR / "node_modules").exists(): + # spectrum-ts not installed yet — `hermes photon setup` will + # install it. check_fn still returns False so the gateway + # surfaces the missing-deps state in `hermes setup` / status. + return False + return True + + +def validate_config(cfg: PlatformConfig) -> bool: + extra = cfg.extra or {} + project_id = extra.get("project_id") or os.getenv("PHOTON_PROJECT_ID") + project_secret = extra.get("project_secret") or os.getenv("PHOTON_PROJECT_SECRET") + if not project_id or not project_secret: + # Fall back to auth.json + stored_id, stored_sec = load_project_credentials() + return bool(stored_id and stored_sec) + return True + + +def is_connected(cfg: PlatformConfig) -> bool: + return validate_config(cfg) + + +def _env_enablement() -> Optional[dict]: + """Seed PlatformConfig.extra from env so env-only setups appear in status. + + The special ``home_channel`` key is handled by the core plugin hook and + becomes a proper ``HomeChannel`` on ``PlatformConfig``. + """ + project_id, project_secret = load_project_credentials() + if not (project_id and project_secret): + return None + seed: dict = {"project_id": project_id, "project_secret": project_secret} + home = os.getenv("PHOTON_HOME_CHANNEL", "").strip() + if home: + seed["home_channel"] = { + "chat_id": home, + "name": os.getenv("PHOTON_HOME_CHANNEL_NAME", "Home"), + } + return seed + + +def _markdown_enabled() -> bool: + """Send agent replies as markdown (spectrum-ts ``markdown()`` builder). + + iMessage renders it natively; other Spectrum platforms degrade to + readable plain text. On-device rendering can't be unit-tested, so + ``PHOTON_MARKDOWN=false`` is the kill-switch back to stripped plain + text without a release. + """ + return os.getenv("PHOTON_MARKDOWN", "true").strip().lower() not in { + "false", "0", "no", + } + + +# --------------------------------------------------------------------------- +# Adapter + +class PhotonAdapter(BasePlatformAdapter): + """Bidirectional bridge to Photon Spectrum via the Node spectrum-ts sidecar. + + Inbound: consume the sidecar's ``/inbound`` gRPC stream. + Outbound: loopback POSTs to the sidecar's control channel. + """ + + MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform("photon")) + extra = config.extra or {} + + # Project credentials (env wins, then config.extra, then auth.json). + # ``project_id`` here is the project's spectrumProjectId — the value + # the spectrum-ts SDK authenticates with. + stored_id, stored_sec = load_project_credentials() + self._project_id: str = ( + os.getenv("PHOTON_PROJECT_ID") + or extra.get("project_id") + or stored_id + or "" + ) + self._project_secret: str = ( + os.getenv("PHOTON_PROJECT_SECRET") + or extra.get("project_secret") + or stored_sec + or "" + ) + + # Sidecar + self._sidecar_port = _coerce_port( + extra.get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"), + _DEFAULT_SIDECAR_PORT, + ) + self._sidecar_bind = _DEFAULT_SIDECAR_BIND + self._sidecar_token = ( + os.getenv("PHOTON_SIDECAR_TOKEN") or secrets.token_hex(16) + ) + self._autostart_sidecar = str( + os.getenv("PHOTON_SIDECAR_AUTOSTART", "true") + ).lower() not in ("0", "false", "no") + self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node" + + # With markdown on, format_message preserves fences and the sidecar's + # markdown() builder renders them (or degrades them readably). + self.supports_code_blocks = _markdown_enabled() + + # Runtime state + self._sidecar_proc: Optional[subprocess.Popen] = None + self._sidecar_supervisor_task: Optional[asyncio.Task] = None + self._inbound_task: Optional[asyncio.Task] = None + self._inbound_running = False + self._http_client: Optional["httpx.AsyncClient"] = None + # Lightweight in-memory dedup. The gRPC stream is at-least-once, so we + # may see the same messageId more than once (e.g. after a reconnect). + self._seen_messages: Dict[str, float] = {} + # Ids of messages WE sent (bounded, insertion-order eviction). Inbound + # reaction events are only routed to the agent when they target one of + # these — a tapback on a human↔human message is not addressed to us. + self._sent_message_ids: Dict[str, float] = {} + # Latest inbound message id per chat (bounded). Lets the agent-facing + # react action default to "the message that triggered me" without + # requiring the model to thread message ids through tool calls. + self._last_inbound_by_chat: Dict[str, str] = {} + + # Group-chat mention gating (parity with BlueBubbles). When enabled, + # group messages are ignored unless they match a wake word; DMs are + # always processed. Config key wins, then env var. + _require_mention = extra.get("require_mention") + if _require_mention is None: + _require_mention = os.getenv("PHOTON_REQUIRE_MENTION") + self.require_mention = str(_require_mention).strip().lower() in { + "true", "1", "yes", "on", + } + self._mention_patterns = self._compile_mention_patterns( + extra["mention_patterns"] + if "mention_patterns" in extra + else os.getenv("PHOTON_MENTION_PATTERNS") + ) + + # -- Group-mention gating (parity with BlueBubbles) ------------------- + + @staticmethod + def _compile_mention_patterns(raw: Any) -> "list[re.Pattern]": + """Compile group-mention wake words from config/env. + + ``raw`` is a list (config or env JSON), a string (env var: JSON + list, or comma/newline-separated), or None (use Hermes defaults). + Mirrors the BlueBubbles implementation so both iMessage channels + accept the same configuration shapes. + """ + if raw is None: + patterns = list(_DEFAULT_MENTION_PATTERNS) + elif isinstance(raw, str): + text = raw.strip() + try: + loaded = json.loads(text) if text else [] + except Exception: + loaded = None + patterns = loaded if isinstance(loaded, list) else [ + part.strip() + for line in text.splitlines() + for part in line.split(",") + ] + elif isinstance(raw, list): + patterns = raw + else: + patterns = [raw] + + compiled: "list[re.Pattern]" = [] + for pattern in patterns: + text = str(pattern).strip() + if not text: + continue + try: + compiled.append(re.compile(text, re.IGNORECASE)) + except re.error as exc: + logger.warning("[photon] Invalid mention pattern %r: %s", text, exc) + return compiled + + def _message_matches_mention_patterns(self, text: str) -> bool: + if not text or not self._mention_patterns: + return False + return any(pattern.search(text) for pattern in self._mention_patterns) + + def _clean_mention_text(self, text: str) -> str: + """Strip a leading wake word before dispatch. + + Custom mention patterns are regexes, so we only strip a leading + match to avoid deleting ordinary words later in the prompt. + """ + if not text: + return text + for pattern in self._mention_patterns: + match = pattern.match(text.lstrip()) + if match: + cleaned = text.lstrip()[match.end():].lstrip(" ,:-") + return cleaned or text + return text + + # -- Connection lifecycle --------------------------------------------- + + async def connect(self) -> bool: + if not HTTPX_AVAILABLE: + self._set_fatal_error( + "MISSING_DEP", "httpx not installed", retryable=False + ) + return False + if not self._project_id or not self._project_secret: + self._set_fatal_error( + "MISSING_CREDENTIALS", + "PHOTON_PROJECT_ID and PHOTON_PROJECT_SECRET are required. " + "Run: hermes photon setup", + retryable=False, + ) + return False + + client = httpx.AsyncClient(timeout=30.0) + self._http_client = client + + # The sidecar holds the gRPC stream for BOTH directions, so it is + # required now (not just for outbound). + if self._autostart_sidecar: + try: + await self._start_sidecar() + except Exception as e: + self._set_fatal_error( + "SIDECAR_FAILED", + f"failed to start Photon sidecar: {e}", + retryable=True, + ) + await client.aclose() + self._http_client = None + return False + else: + logger.warning( + "[photon] sidecar autostart disabled — inbound + outbound will fail" + ) + + # Start consuming the inbound gRPC stream from the sidecar. + self._inbound_running = True + self._inbound_task = asyncio.get_event_loop().create_task( + self._inbound_loop() + ) + + self._mark_connected() + logger.info( + "[photon] connected — sidecar on %s:%d, streaming inbound over gRPC", + self._sidecar_bind, self._sidecar_port, + ) + return True + + async def disconnect(self) -> None: + self._inbound_running = False + if self._inbound_task is not None: + self._inbound_task.cancel() + try: + await self._inbound_task + except asyncio.CancelledError: + pass + except Exception: + pass + self._inbound_task = None + await self._stop_sidecar() + if self._http_client is not None: + try: + await self._http_client.aclose() + except Exception: + pass + self._http_client = None + self._mark_disconnected() + + # -- Inbound stream consumer ------------------------------------------ + + async def _inbound_loop(self) -> None: + """Consume the sidecar's ``/inbound`` NDJSON stream, with reconnect. + + The sidecar owns the gRPC reconnect/heartbeat to Photon; this loop + only has to re-open the loopback HTTP stream if it drops (e.g. the + sidecar restarts). + """ + client = self._http_client + if client is None: + return + url = f"http://{self._sidecar_bind}:{self._sidecar_port}/inbound" + headers = {"X-Hermes-Sidecar-Token": self._sidecar_token} + backoff = 1.0 + while self._inbound_running: + try: + async with client.stream( + "GET", url, headers=headers, timeout=None, + ) as resp: + if resp.status_code != 200: + raise RuntimeError(f"/inbound returned {resp.status_code}") + backoff = 1.0 # reset on a successful connect + async for line in resp.aiter_lines(): + if not self._inbound_running: + break + line = line.strip() + if not line: + continue # heartbeat + await self._on_inbound_line(line) + except asyncio.CancelledError: + raise + except Exception as e: + if not self._inbound_running: + break + logger.warning( + "[photon] inbound stream dropped (%s); reconnecting in %.1fs", + e, backoff, + ) + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 30.0) + + async def _on_inbound_line(self, line: str) -> None: + try: + event = json.loads(line) + except json.JSONDecodeError: + logger.debug("[photon] skipping non-JSON inbound line") + return + msg_id = event.get("messageId") + if msg_id and self._is_duplicate(msg_id): + return + try: + await self._dispatch_inbound(event) + except Exception: + logger.exception("[photon] inbound dispatch failed") + + def _is_duplicate(self, msg_id: str) -> bool: + now = time.time() + seen = self._seen_messages + t = seen.get(msg_id) + if t is not None and now - t < _DEDUP_WINDOW_SECONDS: + return True # seen, unexpired + # New or expired: record and enforce a HARD size bound (evict oldest, + # insertion-order) so a burst of unique ids within the window can't grow + # the dict without limit — not just the expired-only prune. + if msg_id in seen: + del seen[msg_id] # refresh insertion order + seen[msg_id] = now + if len(seen) > _DEDUP_MAX_SIZE: + for old in list(seen.keys())[: len(seen) - _DEDUP_MAX_SIZE]: + del seen[old] + return False + + async def _dispatch_inbound(self, event: Dict[str, Any]) -> None: + """Normalize a sidecar inbound event and dispatch it to the gateway. + + Event shape (from ``sidecar/index.mjs``):: + + { + "messageId": "...", + "platform": "iMessage", + "space": {"id": "...", "type": "dm"|"group", "phone": "+E164"}, + "sender": {"id": "+E164"}, + "content": {"type": "text", "text": "..."} + | {"type": "attachment"|"voice", "id", "name", + "mimeType", "size", "duration"?, "data"?, + "encoding"?} + | {"type": "reaction", "emoji": "❤️", + "targetMessageId": "..." | null, + "targetDirection": "inbound"|"outbound" | null}, + "timestamp": "2026-05-14T19:06:32.000Z" + + Attachment and voice content carry the bytes inline as base64 ``data`` + (with ``encoding == "base64"``) when the sidecar could read them + within its size cap; otherwise only metadata is present and we surface + a marker. + } + """ + space = event.get("space") or {} + sender = event.get("sender") or {} + content = event.get("content") or {} + + space_id = space.get("id") or "" + if not space_id: + logger.warning("[photon] inbound missing space.id") + return + + # iMessage spaces carry their type directly — no id string-sniffing. + chat_type = "group" if space.get("type") == "group" else "dm" + sender_id = sender.get("id") or space.get("phone") or space_id + + ts_str = event.get("timestamp") or "" + try: + timestamp = ( + datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + if ts_str + else datetime.now(tz=timezone.utc) + ) + except ValueError: + timestamp = datetime.now(tz=timezone.utc) + + # Media attachments (local cached paths) handed to the agent via the + # gateway's image-routing path, exactly like the BlueBubbles channel. + media_urls: List[str] = [] + media_types: List[str] = [] + + ctype = content.get("type") + if ctype == "reaction": + # Route only tapbacks on messages WE sent — those are implicitly + # addressed to the bot (feishu precedent: synthetic text event). + # Reactions on human↔human messages are not for us. Checked before + # the mention gate: a tapback never carries a wake word. + target_id = content.get("targetMessageId") + is_ours = content.get("targetDirection") == "outbound" or ( + target_id and target_id in self._sent_message_ids + ) + if not is_ours: + logger.debug( + "[photon] ignoring reaction on a message we didn't send" + ) + return + emoji = content.get("emoji") or "" + source = self.build_source( + chat_id=space_id, + chat_name=space_id, + chat_type=chat_type, + user_id=sender_id, + user_name=sender_id or None, + ) + await self.handle_message( + MessageEvent( + text=f"reaction:added:{emoji}", + message_type=MessageType.TEXT, + source=source, + message_id=event.get("messageId"), + raw_message=event, + timestamp=timestamp, + ) + ) + return + # Anything past here is a real (reactable) message — remember it as + # the chat's latest inbound so `add_reaction` can target it when the + # caller doesn't pass an explicit message id. Recorded before the + # mention gate: a reaction to a non-wake-word group message is valid. + self._record_last_inbound(space_id, event.get("messageId")) + if ctype == "text": + text = content.get("text") or "" + mtype = MessageType.TEXT + elif ctype in {"attachment", "voice"}: + is_voice = ctype == "voice" + name = content.get("name") or ("voice" if is_voice else "(unnamed)") + mime = content.get("mimeType") or "" + mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime) + cached = _cache_inbound_attachment( + content, name, mime, force_audio=is_voice + ) + if cached: + media_urls.append(cached) + media_types.append( + mime or ("audio/mp4" if is_voice else "application/octet-stream") + ) + # The real bytes are attached, so the agent sees the media + # itself — a short marker is enough text, and it keeps group + # mention-gating consistent with plain messages. + text = "(voice)" if is_voice else "(attachment)" + else: + # No bytes (over the sidecar cap, a failed read, or a caching + # failure) — fall back to a metadata marker so the agent still + # knows something arrived. + label = "voice" if is_voice else "attachment" + duration = content.get("duration") + duration_text = ( + f", duration: {duration}s" + if isinstance(duration, (int, float)) + else "" + ) + text = ( + f"[Photon {label} received: {name} " + f"({mime or 'unknown MIME'}{duration_text})]" + ) + else: + text = f"[Photon content type not handled: {ctype}]" + mtype = MessageType.TEXT + + # Group-mention gating (parity with BlueBubbles). In group chats with + # require_mention enabled, drop messages that don't hit a wake word; + # strip the leading wake word from the ones that do. DMs are never + # gated. + if chat_type == "group" and self.require_mention: + if not self._message_matches_mention_patterns(text): + logger.debug( + "[photon] ignoring group message " + "(require_mention=true, no mention pattern matched)" + ) + return + text = self._clean_mention_text(text) + + source = self.build_source( + chat_id=space_id, + chat_name=space_id, + chat_type=chat_type, + user_id=sender_id, + user_name=sender_id or None, + ) + message_event = MessageEvent( + text=text, + message_type=mtype, + source=source, + message_id=event.get("messageId"), + raw_message=event, + timestamp=timestamp, + media_urls=media_urls, + media_types=media_types, + ) + await self.handle_message(message_event) + + # -- Sidecar lifecycle ------------------------------------------------- + + @staticmethod + def _find_listener_pids(port: int) -> List[int]: + """PIDs listening on a local TCP port (empty if none/undeterminable).""" + try: + out = subprocess.run( # noqa: S603, S607 + ["lsof", "-ti", f"tcp:{port}", "-sTCP:LISTEN"], + capture_output=True, text=True, timeout=5.0, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + return [int(tok) for tok in out.stdout.split() if tok.strip().isdigit()] + + @staticmethod + def _pid_is_sidecar(pid: int) -> bool: + """True if ``pid``'s command line is a Photon sidecar process.""" + try: + out = subprocess.run( # noqa: S603, S607 + ["ps", "-p", str(pid), "-o", "command="], + capture_output=True, text=True, timeout=5.0, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False + # Checkout-agnostic: any Hermes checkout's sidecar entry point. + return "photon/sidecar/index.mjs" in out.stdout + + @staticmethod + def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) # windows-footgun: ok — only called from _reap_stale_sidecar which win32-guards early + return True + except OSError: + return False + + async def _reap_stale_sidecar(self) -> None: + """Kill an orphaned sidecar squatting our port before spawning ours. + + A hard gateway exit (crash, SIGKILL, supervisor restart) used to leave + the detached sidecar running with a token the new gateway doesn't + know, so it can't be told to ``/shutdown`` — and every replacement + spawn died on EADDRINUSE, failing each reconnect attempt. The + stdin-EOF watch prevents new orphans; this reclaims the port from + orphans that predate it (or survived it). Listeners are verified by + command line before being signalled. + """ + if sys.platform == "win32": # lsof/ps; orphaning is a POSIX-only path + return + try: + async with httpx.AsyncClient(timeout=2.0) as client: + await client.post( + f"http://{self._sidecar_bind}:{self._sidecar_port}/healthz", + headers={"X-Hermes-Sidecar-Token": self._sidecar_token}, + ) + except httpx.RequestError: + return # nothing listening — the normal case + pids = self._find_listener_pids(self._sidecar_port) + stale = [pid for pid in pids if self._pid_is_sidecar(pid)] + foreign = [pid for pid in pids if pid not in stale] + if not stale: + raise RuntimeError( + f"port {self._sidecar_port} is in use by another process " + f"(pids: {foreign or 'unknown'}, not a Photon sidecar) — " + f"free it or set PHOTON_SIDECAR_PORT to a different port" + ) + for pid in stale: + logger.warning( + "[photon] reaping orphaned sidecar (pid %d) on port %d", + pid, self._sidecar_port, + ) + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + deadline = time.time() + 3.0 + while time.time() < deadline and any(self._pid_alive(p) for p in stale): + await asyncio.sleep(0.1) + for pid in stale: + if self._pid_alive(pid): + try: + os.kill(pid, signal.SIGKILL) # windows-footgun: ok — unreachable on win32 (early return above) + except OSError: + pass + # Give the OS a beat to release the listening socket. + await asyncio.sleep(0.2) + if foreign: + raise RuntimeError( + f"port {self._sidecar_port} is also held by non-sidecar " + f"processes (pids: {foreign}) — free it or set " + f"PHOTON_SIDECAR_PORT to a different port" + ) + + async def _start_sidecar(self) -> None: + if not (_SIDECAR_DIR / "node_modules").exists(): + raise RuntimeError( + f"Photon sidecar deps not installed. Run: " + f"cd {_SIDECAR_DIR} && npm install (or `hermes photon setup`)" + ) + await self._reap_stale_sidecar() + + env = os.environ.copy() + env["PHOTON_PROJECT_ID"] = self._project_id + env["PHOTON_PROJECT_SECRET"] = self._project_secret + env["PHOTON_SIDECAR_PORT"] = str(self._sidecar_port) + env["PHOTON_SIDECAR_BIND"] = self._sidecar_bind + env["PHOTON_SIDECAR_TOKEN"] = self._sidecar_token + # The sidecar exits when its stdin (the pipe below) hits EOF, so a + # gateway death of ANY kind — including SIGKILL, where disconnect() + # never runs — can't leave it orphaned on the port. + env["PHOTON_SIDECAR_WATCH_STDIN"] = "1" + + self._sidecar_proc = subprocess.Popen( # noqa: S603 + [self._node_bin, str(_SIDECAR_DIR / "index.mjs")], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + start_new_session=(sys.platform != "win32"), + **({"creationflags": _windows_hide_flags()} if sys.platform == "win32" else {}), + ) + + # Pump sidecar stderr/stdout into our logger so users see crashes. + loop = asyncio.get_event_loop() + self._sidecar_supervisor_task = loop.create_task( + self._supervise_sidecar(self._sidecar_proc) + ) + + # Wait for /healthz to come up — give it up to 15s on cold start. + deadline = time.time() + 15.0 + last_err: Optional[Exception] = None + async with httpx.AsyncClient(timeout=2.0) as client: + while time.time() < deadline: + if self._sidecar_proc.poll() is not None: + raise RuntimeError( + f"Photon sidecar exited with code " + f"{self._sidecar_proc.returncode} before becoming ready" + ) + try: + resp = await client.post( + f"http://{self._sidecar_bind}:{self._sidecar_port}/healthz", + headers={"X-Hermes-Sidecar-Token": self._sidecar_token}, + ) + if resp.status_code == 200: + return + except httpx.RequestError as e: + last_err = e + await asyncio.sleep(0.2) + raise RuntimeError( + f"Photon sidecar did not become ready within 15s: {last_err}" + ) + + async def _supervise_sidecar(self, proc: subprocess.Popen) -> None: + """Pump the sidecar's stdout/stderr into our logger.""" + if proc.stdout is None: # subprocess was launched without stdout=PIPE + return + stdout = proc.stdout + loop = asyncio.get_event_loop() + try: + while True: + line = await loop.run_in_executor(None, stdout.readline) + if not line: + break + logger.info("[photon-sidecar] %s", line.decode("utf-8", "replace").rstrip()) + except Exception as e: # pragma: no cover - defensive + logger.warning("[photon-sidecar] supervisor exited: %s", e) + + async def _stop_sidecar(self) -> None: + proc = self._sidecar_proc + if proc is None: + return + try: + # Closing our end of the stdin pipe is itself a shutdown signal + # (the sidecar watches for EOF), and covers the case where the + # HTTP call below can't get through. + if proc.stdin is not None: + try: + proc.stdin.close() + except Exception: + pass + # Polite shutdown first. + if self._http_client is not None: + try: + await self._http_client.post( + f"http://{self._sidecar_bind}:{self._sidecar_port}/shutdown", + headers={"X-Hermes-Sidecar-Token": self._sidecar_token}, + timeout=2.0, + ) + except Exception: + pass + try: + proc.wait(timeout=3.0) + except subprocess.TimeoutExpired: + if sys.platform != "win32": + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) # windows-footgun: ok + except (ProcessLookupError, PermissionError): + proc.terminate() + else: + proc.terminate() + try: + proc.wait(timeout=2.0) + except subprocess.TimeoutExpired: + proc.kill() + finally: + self._sidecar_proc = None + if self._sidecar_supervisor_task is not None: + self._sidecar_supervisor_task.cancel() + self._sidecar_supervisor_task = None + + # -- Outbound ---------------------------------------------------------- + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + return await self._sidecar_send(chat_id, self.format_message(content)) + + # -- Outbound media (parity with the BlueBubbles iMessage channel) ----- + # + # Photon ships outbound attachments via spectrum-ts' `attachment()` / + # `voice()` content builders. The sidecar's `/send-attachment` endpoint + # wraps `space.send(attachment(path, {...}))`. These overrides mirror + # BlueBubbles: URL-based helpers cache to a local path first, file-based + # helpers pass the path straight through. + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + try: + from gateway.platforms.base import cache_image_from_url + + local_path = await cache_image_from_url(image_url) + except Exception: + # Couldn't fetch the URL — fall back to sending it as text. + return await super().send_image(chat_id, image_url, caption, reply_to) + return await self._sidecar_send_attachment( + chat_id, local_path, caption=caption, + ) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + return await self._sidecar_send_attachment( + chat_id, image_path, caption=caption, + ) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + return await self._sidecar_send_attachment( + chat_id, audio_path, caption=caption, kind="voice", + ) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + return await self._sidecar_send_attachment( + chat_id, video_path, caption=caption, + ) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs, + ) -> SendResult: + return await self._sidecar_send_attachment( + chat_id, file_path, name=file_name, caption=caption, + ) + + async def send_animation( + self, + chat_id: str, + animation_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + # iMessage renders GIFs inline as ordinary image attachments. + return await self.send_image( + chat_id, animation_url, caption, reply_to, metadata, + ) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + try: + await self._sidecar_call( + "/typing", {"spaceId": chat_id, "state": "start"} + ) + except Exception as e: + logger.debug("[photon] send_typing failed: %s", e) + + async def stop_typing(self, chat_id: str) -> None: + try: + await self._sidecar_call( + "/typing", {"spaceId": chat_id, "state": "stop"} + ) + except Exception as e: + logger.debug("[photon] stop_typing failed: %s", e) + + # -- Reactions (tapbacks) ----------------------------------------------- + # + # Same lifecycle-hook pattern as Telegram/Discord: 👀 while processing, + # swapped for 👍/👎 on completion. Opt-in via PHOTON_REACTIONS — iMessage + # is a personal-texting channel, and a tapback on every text is noisy. + + _SENT_IDS_MAX = 1000 + _LAST_INBOUND_CHATS_MAX = 200 + + def _record_sent_message(self, message_id: Optional[str]) -> None: + if not message_id: + return + sent = self._sent_message_ids + if message_id in sent: + del sent[message_id] # refresh insertion order + sent[message_id] = time.time() + if len(sent) > self._SENT_IDS_MAX: + for old in list(sent.keys())[: len(sent) - self._SENT_IDS_MAX]: + del sent[old] + + # A DM space is addressable two ways — the chat GUID (`any;-;+1555...`) + # that inbound events carry, and the bare E.164 phone that home-channel + # config typically uses. The sidecar's resolveSpace treats them as the + # same space; normalize to the bare phone so the last-inbound tracker + # does too (mirrors phoneTargetFromSpaceId in sidecar/index.mjs). + _DM_CHAT_GUID_RE = re.compile(r"^any;-;(\+\d{6,})$") + + @classmethod + def _normalize_chat_key(cls, chat_id: str) -> str: + match = cls._DM_CHAT_GUID_RE.match(chat_id) + return match.group(1) if match else chat_id + + def _record_last_inbound( + self, chat_id: Optional[str], message_id: Optional[str] + ) -> None: + if not chat_id or not message_id: + return + key = self._normalize_chat_key(chat_id) + last = self._last_inbound_by_chat + if key in last: + del last[key] # refresh insertion order + last[key] = message_id + if len(last) > self._LAST_INBOUND_CHATS_MAX: + for old in list(last.keys())[ + : len(last) - self._LAST_INBOUND_CHATS_MAX + ]: + del last[old] + + def _reactions_enabled(self) -> bool: + return os.getenv("PHOTON_REACTIONS", "false").strip().lower() in { + "true", "1", "yes", "on", + } + + async def _add_reaction( + self, chat_id: str, message_id: str, emoji: str + ) -> bool: + """Tapback ``emoji`` onto a message. Soft-fails (False), never raises.""" + try: + await self._sidecar_call( + "/react", + {"spaceId": chat_id, "messageId": message_id, "emoji": emoji}, + ) + return True + except Exception as e: + logger.debug("[photon] add_reaction failed: %s", e) + return False + + async def _remove_reaction(self, chat_id: str, message_id: str) -> bool: + """Retract our tapback from a message. Soft-fails (False), never raises. + + The sidecar tracks one reaction handle per target message; after a + sidecar restart the handle is gone and removal is best-effort (the + stale tapback self-heals when the next reaction replaces it). + """ + try: + await self._sidecar_call( + "/unreact", {"spaceId": chat_id, "messageId": message_id}, + ) + return True + except Exception as e: + logger.debug("[photon] remove_reaction failed: %s", e) + return False + + # -- Agent-facing reactions (send_message action="react") --------------- + # + # Unlike the lifecycle hooks below, these are deliberate agent intents, + # so they are NOT gated by PHOTON_REACTIONS (that env var exists to mute + # the automatic per-message tapback noise, not explicit requests). + + async def add_reaction( + self, + chat_id: str, + emoji: str, + message_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Tapback ``emoji`` onto a message in ``chat_id``. + + Without ``message_id``, targets the chat's most recent inbound + message (typically the one the agent is responding to). iMessage + maps ❤️👍👎😂‼️❓ to native tapbacks; anything else uses Apple's + custom-emoji reaction. + """ + target = message_id or self._last_inbound_by_chat.get( + self._normalize_chat_key(chat_id) + ) + if not target: + return { + "success": False, + "error": "no message to react to — pass message_id (no " + "inbound message seen in this chat since the gateway started)", + } + ok = await self._add_reaction(chat_id, target, emoji) + if not ok: + return { + "success": False, + "error": "reaction failed (see gateway debug log)", + } + return {"success": True, "message_id": target} + + async def remove_reaction( + self, chat_id: str, message_id: Optional[str] = None + ) -> Dict[str, Any]: + """Retract our tapback from a message (best-effort).""" + target = message_id or self._last_inbound_by_chat.get( + self._normalize_chat_key(chat_id) + ) + if not target: + return { + "success": False, + "error": "no message to unreact — pass message_id", + } + ok = await self._remove_reaction(chat_id, target) + if not ok: + return { + "success": False, + "error": "unreact failed (see gateway debug log)", + } + return {"success": True, "message_id": target} + + async def on_processing_start(self, event: MessageEvent) -> None: + """Tapback 👀 on the triggering message while the agent works.""" + if not self._reactions_enabled(): + return + chat_id = getattr(event.source, "chat_id", None) + message_id = getattr(event, "message_id", None) + if chat_id and message_id: + await self._add_reaction(chat_id, message_id, "\U0001f440") + + async def on_processing_complete( + self, event: MessageEvent, outcome: ProcessingOutcome + ) -> None: + """Swap the 👀 progress tapback for a 👍/👎 result. + + Remove-then-add rather than a bare replace: deterministic whether the + platform replaces a sender's previous tapback or stacks them, and it + keeps the sidecar's reaction-handle slot coherent. + """ + if not self._reactions_enabled(): + return + chat_id = getattr(event.source, "chat_id", None) + message_id = getattr(event, "message_id", None) + if not chat_id or not message_id: + return + await self._remove_reaction(chat_id, message_id) + if outcome == ProcessingOutcome.SUCCESS: + await self._add_reaction(chat_id, message_id, "\U0001f44d") + elif outcome == ProcessingOutcome.FAILURE: + await self._add_reaction(chat_id, message_id, "\U0001f44e") + # CANCELLED: leave the message unreacted. + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return whatever we know about a Spectrum space id. + + Photon's ``space.id`` is opaque; the inbound event also carries the + DM/group type, but here we only have the id, so infer conservatively. + """ + return {"name": chat_id, "type": "dm", "id": chat_id} + + def format_message(self, content: str) -> str: + # Markdown is passed through verbatim — the sidecar sends it with the + # markdown() builder and iMessage renders it. The strip path remains + # as the PHOTON_MARKDOWN=false kill-switch. + if _markdown_enabled(): + return content + return strip_markdown(content) + + async def _send_with_retry( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Any = None, + max_retries: int = 2, + base_delay: float = 2.0, + ) -> SendResult: + """Retry sends without the generic Markdown banner. + + Photon replies are markdown (rendered by iMessage) or stripped plain + text under ``PHOTON_MARKDOWN=false`` — either way the gateway's + generic banner never applies. + """ + text = self.format_message(content) + result = await self.send( + chat_id=chat_id, + content=text, + reply_to=reply_to, + metadata=metadata, + ) + if result.success: + return result + + error_str = result.error or "" + is_network = result.retryable or self._is_retryable_error(error_str) + if not is_network and self._is_timeout_error(error_str): + return result + + if is_network: + for attempt in range(1, max_retries + 1): + delay = base_delay * (2 ** (attempt - 1)) + logger.warning( + "[photon] Send failed (attempt %d/%d, retrying in %.1fs): %s", + attempt, max_retries, delay, error_str, + ) + await asyncio.sleep(delay) + result = await self.send( + chat_id=chat_id, + content=text, + reply_to=reply_to, + metadata=metadata, + ) + if result.success: + return result + error_str = result.error or "" + if not (result.retryable or self._is_retryable_error(error_str)): + break + else: + logger.error( + "[photon] Failed to deliver response after %d retries: %s", + max_retries, error_str, + ) + return result + + logger.warning( + "[photon] Send failed: %s - retrying plain-text message", + error_str, + ) + fallback_result = await self.send( + chat_id=chat_id, + content=text[: self.MAX_MESSAGE_LENGTH], + reply_to=reply_to, + metadata=metadata, + ) + if not fallback_result.success: + logger.error("[photon] Plain-text retry also failed: %s", fallback_result.error) + return fallback_result + + async def _sidecar_send(self, space_id: str, text: str) -> SendResult: + if len(text) > self.MAX_MESSAGE_LENGTH: + logger.warning( + "[photon] truncating outbound from %d to %d chars", + len(text), self.MAX_MESSAGE_LENGTH, + ) + text = text[: self.MAX_MESSAGE_LENGTH] + body: Dict[str, Any] = {"spaceId": space_id, "text": text} + # Omit the key when disabled so an older sidecar (pre-`format`) + # keeps accepting the body during a half-upgraded restart. + if _markdown_enabled(): + body["format"] = "markdown" + try: + data = await self._sidecar_call("/send", body) + except Exception as e: + return SendResult(success=False, error=str(e)) + self._record_sent_message(data.get("messageId")) + return SendResult(success=True, message_id=data.get("messageId")) + + async def _sidecar_send_attachment( + self, + space_id: str, + path: str, + *, + name: Optional[str] = None, + mime_type: Optional[str] = None, + caption: Optional[str] = None, + kind: str = "attachment", + ) -> SendResult: + """POST a local file to the sidecar's ``/send-attachment`` endpoint. + + ``kind`` is ``"voice"`` for audio sent as a voice note (downgrades + to a plain audio attachment on platforms without voice notes), + otherwise ``"attachment"``. spectrum-ts infers ``name`` and + ``mimeType`` from the file extension; we only pass overrides when + Hermes supplied them. + """ + # Defense-in-depth: re-validate the path before handing it to the + # Node sidecar. The gateway already filters MEDIA paths, but + # send_*_file / cron callers may pass arbitrary strings. + safe_path = self.validate_media_delivery_path(str(path)) + if not safe_path: + return SendResult( + success=False, error=f"unsafe or missing attachment path: {path}" + ) + if not mime_type: + import mimetypes + + guessed, _ = mimetypes.guess_type(safe_path) + mime_type = guessed or None + body: Dict[str, Any] = { + "spaceId": space_id, + "path": safe_path, + "kind": "voice" if kind == "voice" else "attachment", + } + if name: + body["name"] = name + if mime_type: + body["mimeType"] = mime_type + if caption: + body["caption"] = caption + try: + data = await self._sidecar_call("/send-attachment", body) + except Exception as e: + return SendResult(success=False, error=str(e)) + self._record_sent_message(data.get("messageId")) + return SendResult(success=True, message_id=data.get("messageId")) + + async def _sidecar_call(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: + # Guard: adapter not yet connected (no sidecar address known). + if self._http_client is None: + raise RuntimeError("Photon adapter not connected") + # Use a fresh client per call so this method is safe when invoked from + # a worker thread that owns a different event loop than the one the + # persistent _http_client was created on (e.g. via _run_async in + # send_message_tool). The inbound streaming loop continues to use + # _http_client directly — it always runs on the gateway's loop. + url = f"http://{self._sidecar_bind}:{self._sidecar_port}{path}" + headers = {"X-Hermes-Sidecar-Token": self._sidecar_token} + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, json=body, headers=headers) + if resp.status_code != 200: + raise RuntimeError( + f"Photon sidecar {path} returned {resp.status_code}: {resp.text[:200]}" + ) + data = resp.json() or {} + if not data.get("ok"): + raise RuntimeError( + f"Photon sidecar {path} reported error: {data.get('error')}" + ) + return data + + +# --------------------------------------------------------------------------- +# Helpers + +def _attachment_message_type(mime: str) -> MessageType: + mime = (mime or "").lower() + if mime.startswith("image/"): + return MessageType.PHOTO + if mime.startswith("video/"): + return MessageType.VIDEO + if mime.startswith("audio/"): + return MessageType.AUDIO + if mime.startswith("application/"): + return MessageType.DOCUMENT + return MessageType.DOCUMENT + + +# MIME → file-extension maps for caching inbound attachment bytes. These mirror +# the BlueBubbles iMessage channel so both adapters name cached media the same. +_IMAGE_EXT_BY_MIME = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/heic": ".jpg", + "image/heif": ".jpg", + "image/tiff": ".jpg", +} +_AUDIO_EXT_BY_MIME = { + "audio/mp3": ".mp3", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "audio/x-caf": ".mp3", + "audio/mp4": ".m4a", + "audio/aac": ".m4a", +} + + +def _cache_inbound_attachment( + content: Dict[str, Any], + name: str, + mime: str, + *, + force_audio: bool = False, +) -> Optional[str]: + """Decode a base64-inlined inbound attachment and cache it locally. + + The sidecar inlines the attachment bytes as ``content["data"]`` (base64). + We decode them and route to the shared media cache by MIME type, returning + the cached absolute path so the caller can populate ``media_urls`` (which + the gateway then hands to the model). Returns ``None`` when there are no + bytes (over the sidecar's inline cap or a failed read) or when caching + fails, so the caller can fall back to a text marker. + """ + data_b64 = content.get("data") + if not data_b64: + return None + try: + raw = base64.b64decode(data_b64) + except (ValueError, TypeError) as exc: + logger.warning("[photon] failed to decode inbound attachment bytes: %s", exc) + return None + + from gateway.platforms.base import ( + cache_audio_from_bytes, + cache_document_from_bytes, + cache_image_from_bytes, + ) + + mime = (mime or "").lower() + # Prefer the real extension from the filename; fall back to the MIME map. + suffix = Path(name).suffix if name else "" + try: + if mime.startswith("image/"): + ext = suffix or _IMAGE_EXT_BY_MIME.get(mime, ".jpg") + try: + return cache_image_from_bytes(raw, ext) + except ValueError: + # Bytes don't look like a supported image (e.g. HEIC magic) — + # still deliver them as a document rather than dropping them. + return cache_document_from_bytes(raw, name) + if force_audio or mime.startswith("audio/"): + ext = suffix or _AUDIO_EXT_BY_MIME.get( + mime, ".m4a" if force_audio else ".mp3" + ) + return cache_audio_from_bytes(raw, ext) + # Video, application/*, and everything else → document cache. + return cache_document_from_bytes(raw, name) + except Exception as exc: + logger.warning("[photon] failed to cache inbound attachment %s: %s", name, exc) + return None + + +# --------------------------------------------------------------------------- +# Standalone (out-of-process) send for cron deliveries when the gateway +# is not co-resident. Reuses a live sidecar already listening on the +# configured port (cron processes cannot spawn the sidecar themselves). + +async def _standalone_send( + pconfig: PlatformConfig, + chat_id: str, + message: str, + *, + thread_id: Optional[str] = None, # noqa: ARG001 — Spectrum has no threads yet + media_files: Optional[list] = None, + force_document: bool = False, # noqa: ARG001 — iMessage auto-detects file kind +) -> Dict[str, Any]: + if not HTTPX_AVAILABLE: + return {"error": "httpx not installed"} + port = _coerce_port( + (pconfig.extra or {}).get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"), + _DEFAULT_SIDECAR_PORT, + ) + token = os.getenv("PHOTON_SIDECAR_TOKEN") + if not token: + return { + "error": ( + "Photon standalone send requires a running sidecar with " + "PHOTON_SIDECAR_TOKEN set in the environment. Cron processes " + "cannot spawn the sidecar themselves." + ) + } + base = f"http://{_DEFAULT_SIDECAR_BIND}:{port}" + headers = {"X-Hermes-Sidecar-Token": token} + last_message_id: Optional[str] = None + try: + async with httpx.AsyncClient(timeout=30.0) as client: + # 1. Text body first (if any), so it leads the conversation. + if message: + send_body: Dict[str, Any] = { + "spaceId": chat_id, + "text": message[:_MAX_MESSAGE_LENGTH], + } + if _markdown_enabled(): + send_body["format"] = "markdown" + resp = await client.post( + f"{base}/send", json=send_body, headers=headers, + ) + if resp.status_code != 200: + return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"} + data = resp.json() or {} + if not data.get("ok"): + return {"error": data.get("error") or "sidecar reported failure"} + last_message_id = data.get("messageId") + + # 2. Each attachment as a separate /send-attachment call. + # media_files is List[Tuple[path, is_voice]] (see + # BasePlatformAdapter.filter_media_delivery_paths). + import mimetypes + + for media_path, is_voice in media_files or []: + safe_path = BasePlatformAdapter.validate_media_delivery_path(str(media_path)) + if not safe_path: + logger.warning("[photon] standalone send skipping unsafe path") + continue + guessed, _ = mimetypes.guess_type(safe_path) + att_body: Dict[str, Any] = { + "spaceId": chat_id, + "path": safe_path, + "kind": "voice" if is_voice else "attachment", + } + if guessed: + att_body["mimeType"] = guessed + resp = await client.post( + f"{base}/send-attachment", json=att_body, headers=headers, + ) + if resp.status_code != 200: + return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"} + data = resp.json() or {} + if not data.get("ok"): + return {"error": data.get("error") or "sidecar reported failure"} + last_message_id = data.get("messageId") or last_message_id + + return {"success": True, "message_id": last_message_id} + except Exception as e: + return {"error": f"Photon standalone send failed: {e}"} + + +# --------------------------------------------------------------------------- +# Plugin entry point + +def register(ctx) -> None: + """Called by the Hermes plugin loader at startup.""" + # Local import to avoid argparse work at module load; reused for both the + # gateway-setup hook and the `hermes photon` CLI command below. + from . import cli as _cli + + ctx.register_platform( + name="photon", + label="iMessage via Photon", + adapter_factory=lambda cfg: PhotonAdapter(cfg), + check_fn=check_requirements, + validate_config=validate_config, + is_connected=is_connected, + required_env=["PHOTON_PROJECT_ID", "PHOTON_PROJECT_SECRET"], + install_hint=( + "Run: hermes photon setup (logs in via device flow, creates a " + "Spectrum project, links your phone number, installs the " + "spectrum-ts sidecar)." + ), + # Surfaces Photon in `hermes gateway setup` alongside every other + # channel — same unified onboarding wizard, no Photon-only detour. + setup_fn=_cli.gateway_setup, + env_enablement_fn=_env_enablement, + cron_deliver_env_var="PHOTON_HOME_CHANNEL", + standalone_sender_fn=_standalone_send, + allowed_users_env="PHOTON_ALLOWED_USERS", + allow_all_env="PHOTON_ALLOW_ALL_USERS", + max_message_length=_MAX_MESSAGE_LENGTH, + emoji="📱", + # iMessage carries E.164 phone numbers — treat session descriptions + # as PII-sensitive so they get redacted before reaching the LLM + # (matches the BlueBubbles iMessage channel in _PII_SAFE_PLATFORMS). + pii_safe=True, + allow_update_command=True, + platform_hint=( + "You are communicating via Photon Spectrum (iMessage). " + "Treat replies like regular text messages — short and friendly. " + "Markdown is rendered (bold, italics, lists, code), but keep " + "formatting light and conversational. Recipient identifiers are " + "E.164 phone numbers; never expose them in responses unless the " + "user asked. Attachments arrive as metadata only." + ), + ) + + # Register CLI subcommands — `hermes photon ...` + ctx.register_cli_command( + name="photon", + help="Set up and manage the Photon iMessage integration", + setup_fn=_cli.register_cli, + handler_fn=_cli.dispatch, + )