From 4e4d27875f3dfd43fa4f9456c302b5ffb8b1ad85 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Mon, 8 Jun 2026 16:03:51 -0700 Subject: [PATCH] feat(photon): gRPC-native iMessage channel (no webhook) Make Photon iMessage a first-class persistent-connection channel like Discord/Slack, using the spectrum-ts gRPC stream for both directions. - Inbound: the sidecar forwards the SDK's app.messages gRPC stream to the adapter over a loopback GET /inbound (NDJSON) instead of webhooks. Drops the aiohttp webhook server, HMAC signature verification, public URL, and PHOTON_WEBHOOK_* config; adapter reconnects with backoff. - Management plane: device login uses client_id=photon-cli against the single dashboard host (Bearer), matching the official photon-hq/cli; find-or-create "Hermes Agent" project, enable Spectrum, rotate secret, register user (with phone dedup), surface the assigned iMessage line. - SDK projectId is the project's spectrumProjectId, not the dashboard id; runtime creds persist to ~/.hermes/.env like every other channel. - CLI: 6-step setup, webhook subcommands removed. - Tests/docs updated for the gRPC flow; sidecar pins spectrum-ts ^1.17.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/platforms/photon/README.md | 169 ++--- plugins/platforms/photon/adapter.py | 369 ++++------ plugins/platforms/photon/auth.py | 637 +++++++++++------- plugins/platforms/photon/cli.py | 233 +++---- plugins/platforms/photon/plugin.yaml | 59 +- plugins/platforms/photon/sidecar/index.mjs | 196 +++++- plugins/platforms/photon/sidecar/package.json | 4 +- tests/plugins/platforms/photon/test_auth.py | 444 +++++++----- .../plugins/platforms/photon/test_inbound.py | 141 ++-- .../platforms/photon/test_mention_gating.py | 28 +- .../platforms/photon/test_signature.py | 95 --- website/docs/user-guide/messaging/photon.md | 124 ++-- 12 files changed, 1323 insertions(+), 1176 deletions(-) delete mode 100644 tests/plugins/platforms/photon/test_signature.py diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index 9101fb4a52d..f4424b4e74a 100644 --- a/plugins/platforms/photon/README.md +++ b/plugins/platforms/photon/README.md @@ -1,123 +1,132 @@ # Photon iMessage platform plugin -This plugin connects Hermes Agent to iMessage (and WhatsApp Business + -future Spectrum interfaces) through [Photon][photon] — a managed -service that handles the iMessage line allocation, delivery, and -abuse-prevention layer so users don't have to run their own Mac -relay. +This plugin connects Hermes Agent to iMessage (and other Spectrum +interfaces) through [Photon][photon] — a managed service that handles +iMessage line allocation, delivery, and abuse-prevention so users don't +have to run their own Mac relay. -The free tier uses Photon's shared iMessage line pool (`type: shared`) -and is the path we recommend for everyone who doesn't already pay for a -dedicated number. +The free tier uses Photon's shared iMessage line pool and is the path we +recommend for everyone who doesn't already pay for a dedicated number. ## Architecture +Like Discord and Slack, Photon is a **persistent-connection** channel — no +public URL, no webhook, no signing secret. The `spectrum-ts` SDK holds a +long-lived **gRPC stream** to Photon for both directions. Because the SDK is +TypeScript-only, Hermes runs it inside a small supervised Node sidecar and +talks to it over loopback. + ``` -┌─────────────────────────┐ HMAC-signed POSTs ┌──────────────────┐ -│ Photon Spectrum cloud │ ──────────────────────► │ Hermes Agent │ -│ (iMessage line owner) │ │ (Python) │ -└─────────────────────────┘ JSON over loopback │ │ - ▲ ◄────────────────────── │ PhotonAdapter │ - │ │ + aiohttp recv │ - │ spectrum-ts │ │ - │ SDK (Node) │ spawns + super- │ - ▼ │ vises ▼ │ -┌─────────────────────────┐ ├──────────────────┤ -│ Node sidecar │ ◄──── X-Hermes- ─ │ Node sidecar │ -│ (plugins/.../sidecar) │ Sidecar-Token │ child process │ -└─────────────────────────┘ └──────────────────┘ + gRPC (spectrum-ts) +┌─────────────────────────┐ ◄───────────────► ┌──────────────────────┐ +│ Photon Spectrum cloud │ app.messages │ Node sidecar │ +│ (iMessage line owner) │ space.send() │ (plugins/…/sidecar) │ +└─────────────────────────┘ └──────────┬───────────┘ + GET /inbound (NDJSON) │ ▲ POST /send + inbound events ▼ │ /typing + ┌──────────────────────┐ + │ PhotonAdapter │ + │ (Python, in gateway) │ + └──────────────────────┘ ``` -Inbound traffic is webhook-only — Hermes runs an aiohttp listener -that verifies `X-Spectrum-Signature` and dedupes on `message.id`. - -Outbound traffic goes through a tiny Node sidecar that runs the -`spectrum-ts` SDK. Photon does not currently expose an HTTP -send-message endpoint; their own docs say: - -> Pass `space.id` to `Space.send(...)` from a separate `spectrum-ts` -> SDK instance to reply. **No public HTTP send endpoint exists today.** -> — https://photon.codes/docs/webhooks/events - -When Photon ships an HTTP send endpoint, `_sidecar_send` is the one -function that swaps and the sidecar disappears. The rest of the -plugin stays the same. +- **Inbound**: the sidecar consumes the SDK's `app.messages` gRPC stream, + normalizes each message, and streams it to the adapter over a loopback + `GET /inbound` (NDJSON). The adapter dedupes on `messageId` and dispatches + a `MessageEvent` to the gateway. It reconnects automatically if the stream + drops; the sidecar owns the gRPC reconnect to Photon. +- **Outbound**: `send` / `send_typing` are loopback POSTs to the sidecar, + authenticated with a shared `X-Hermes-Sidecar-Token`. ## First-time setup ```bash -# 1. One-shot setup: device login (opens browser) + project + user + sidecar deps +# One-shot setup: device login (opens browser) + project + user + sidecar deps hermes photon setup --phone +15551234567 -# 2. Expose your webhook URL to the public internet -# (cloudflared, ngrok, your gateway's public hostname, etc.) -# Then register it with Photon: -hermes photon webhook register https://your-host.example.com/photon/webhook - -# 3. Save the signing secret it prints to ~/.hermes/.env -# as PHOTON_WEBHOOK_SECRET=... -# Photon only returns it ONCE. - -# 4. Start the gateway +# Start the gateway hermes gateway start --platform photon ``` -`hermes photon setup` runs the RFC 8628 device-code login as its first -step — it opens `https://app.photon.codes/` for approval, then -provisions the Spectrum project + iMessage line. There is no separate -`login` command; like every other Hermes channel, onboarding goes -through one setup surface. Re-running `setup` reuses an existing token -and project, so it's safe to run again to finish a partial setup. +`hermes photon setup` does, in order: + +1. **Device login** (RFC 8628, `client_id=photon-cli`) — opens + `https://app.photon.codes/` for approval and stores the bearer token. +2. **Find or create** the `Hermes Agent` project on the Photon dashboard. +3. **Enable Spectrum**, read the project's `spectrumProjectId`, rotate the + project secret, and persist both. +4. **Register your phone number** as a Spectrum user (idempotent — skipped if + a user with that number already exists). +5. **Print the assigned iMessage line** — the number you text to reach your + agent. +6. **Install the sidecar deps** (`spectrum-ts`). + +There is no separate `login` command; like every other Hermes channel, +onboarding goes through one setup surface. Re-running `setup` reuses an +existing token/project, so it's safe to run again to finish a partial setup. +Run `hermes photon status` to see what's configured. ## Credentials -Stored in `~/.hermes/auth.json` under `credential_pool`: +Runtime SDK credentials live in `~/.hermes/.env` (the same place every other +channel keeps its token), and the adapter reads them from the environment: + +```bash +PHOTON_PROJECT_ID= # the SDK's projectId +PHOTON_PROJECT_SECRET= +``` + +Management metadata lives in `~/.hermes/auth.json` under `credential_pool`: ```jsonc { "credential_pool": { "photon": [ - { "access_token": "", "issued_at": ... } + { "access_token": "", "issued_at": ... } ], "photon_project": [ - { "project_id": "...", "project_secret": "...", "name": "Hermes Agent" } + { + "dashboard_project_id": "", + "spectrum_project_id": "", + "project_secret": "", + "name": "Hermes Agent" + } ] } } ``` -The per-URL webhook signing secret is treated like an API key and -lives in `~/.hermes/.env` as `PHOTON_WEBHOOK_SECRET`. +> **Note on ids.** A Photon project has two identifiers: the dashboard `id` +> (used for management API calls) and the `spectrumProjectId` (what the SDK +> authenticates with). `PHOTON_PROJECT_ID` is the **spectrum** id. ## Configuration knobs -All env vars are documented in `plugin.yaml`. The most important are: +All env vars are documented in `plugin.yaml`. The most important: -| Env var | Default | Meaning | -|--------------------------|--------------------|-----------------------------------------| -| `PHOTON_PROJECT_ID` | from auth.json | Spectrum project ID | -| `PHOTON_PROJECT_SECRET` | from auth.json | Spectrum project secret (HTTP Basic) | -| `PHOTON_WEBHOOK_SECRET` | (unset) | Signing secret returned at register | -| `PHOTON_WEBHOOK_PORT` | 8788 | Local port for the aiohttp listener | -| `PHOTON_WEBHOOK_PATH` | /photon/webhook | Path under which the listener mounts | -| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for sidecar control | -| `PHOTON_HOME_CHANNEL` | (unset) | Default space ID for cron delivery | -| `PHOTON_ALLOWED_USERS` | (unset) | Comma-separated E.164 allowlist | +| Env var | Default | Meaning | +|---------------------------|----------------------------|--------------------------------------| +| `PHOTON_PROJECT_ID` | from .env / auth.json | Spectrum project id (SDK `projectId`)| +| `PHOTON_PROJECT_SECRET` | from .env / auth.json | Project secret | +| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for the sidecar | +| `PHOTON_SIDECAR_AUTOSTART`| true | Spawn the sidecar on connect | +| `PHOTON_DASHBOARD_HOST` | https://app.photon.codes | Dashboard API host | +| `PHOTON_HOME_CHANNEL` | (unset) | Default space id for cron delivery | +| `PHOTON_ALLOWED_USERS` | (unset) | Comma-separated E.164 allowlist | +| `PHOTON_REQUIRE_MENTION` | false | Gate group chats on a wake word | ## Limitations (current Photon API) -- **Inbound attachments are metadata only.** Inbound webhooks include the - filename + MIME type but no download URL. The plugin surfaces a - text marker (`[Photon attachment received: …]`) so the agent knows - something arrived, but cannot read the bytes. Photon's docs note - an attachment retrieval endpoint is on the roadmap. -- **Outbound attachments are supported.** Images, voice notes, video, - and documents are sent via `space.send(attachment(...))` / +- **Inbound attachments are metadata only.** Inbound events carry the + filename + MIME type; the plugin surfaces a text marker + (`[Photon attachment received: …]`) so the agent knows something arrived. + The SDK exposes attachment bytes via `content.read()`/`stream()`, so + downloading them is a sidecar follow-up. +- **Outbound attachments are supported.** Images, voice notes, video, and + documents are sent via `space.send(attachment(...))` / `space.send(voice(...))` through the sidecar's `/send-attachment` - endpoint. A caption is delivered as a separate text bubble after the - media. -- **Reactions, message effects, polls** — not exposed yet; the - `spectrum-ts` SDK supports them, and the sidecar is the natural - place to add them when the agent has reason to use them. + endpoint; a caption is delivered as a separate text bubble after the media. +- **Reactions, message effects, polls** — supported by `spectrum-ts` but not + yet exposed; the sidecar is the natural place to add them. [photon]: https://photon.codes/ diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index 1be57468967..455d8750d77 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -1,32 +1,29 @@ """ 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: - Photon delivers signed JSON ``POST``s to a URL we register. The - adapter spins up an aiohttp server on ``PHOTON_WEBHOOK_PORT``, - verifies ``X-Spectrum-Signature`` (HMAC-SHA256 of - ``v0:{timestamp}:{body}`` keyed by the per-URL signing secret), - rejects deliveries with a timestamp drift > 5 minutes, dedupes on - ``message.id``, and dispatches a normalized ``MessageEvent`` to the - gateway runner via ``BasePlatformAdapter.handle_message``. + 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: - Photon does not currently expose a public HTTP send-message - endpoint, so the adapter spawns a small Node sidecar (see - ``sidecar/index.mjs``) that runs the ``spectrum-ts`` SDK. Each - ``send`` / ``send_typing`` / attachment call from Hermes is a - loopback POST to the sidecar with a shared bearer token. Outbound - media (images, voice notes, video, documents) goes through - spectrum-ts' ``attachment()`` / ``voice()`` content builders. - -When Photon ships an HTTP send endpoint we can collapse the sidecar -into ``_send_via_http`` and drop the Node dependency entirely. + ``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 hashlib -import hmac import json import logging import os @@ -48,13 +45,6 @@ except ImportError: # pragma: no cover - httpx is already a Hermes dep HTTPX_AVAILABLE = False httpx = None # type: ignore[assignment] -try: - from aiohttp import web - AIOHTTP_AVAILABLE = True -except ImportError: - AIOHTTP_AVAILABLE = False - web = None # type: ignore[assignment] - from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( BasePlatformAdapter, @@ -63,21 +53,13 @@ from gateway.platforms.base import ( SendResult, ) -from .auth import ( - DEFAULT_SPECTRUM_HOST, - load_project_credentials, - _spectrum_host, -) +from .auth import load_project_credentials logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Constants -_DEFAULT_WEBHOOK_PORT = 8788 -_DEFAULT_WEBHOOK_PATH = "/photon/webhook" -_DEFAULT_WEBHOOK_BIND = "0.0.0.0" - _DEFAULT_SIDECAR_PORT = 8789 _DEFAULT_SIDECAR_BIND = "127.0.0.1" @@ -86,11 +68,8 @@ _DEFAULT_SIDECAR_BIND = "127.0.0.1" # size to ~16 KB. Keep a conservative cap that matches BlueBubbles. _MAX_MESSAGE_LENGTH = 8000 -# Spec says reject deliveries older than ~5 minutes for replay protection. -_TIMESTAMP_DRIFT_SECONDS = 300 - -# Dedup parameters — keep at least 1k IDs for ~48h per Photon's -# at-least-once guidance. +# 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 @@ -118,7 +97,7 @@ def _coerce_port(value: Any, default: int) -> int: def check_requirements() -> bool: """Return True when both Python deps and the Node sidecar are available.""" - if not HTTPX_AVAILABLE or not AIOHTTP_AVAILABLE: + if not HTTPX_AVAILABLE: return False if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"): return False @@ -150,57 +129,18 @@ def _env_enablement() -> Optional[dict]: project_id, project_secret = load_project_credentials() if not (project_id and project_secret): return None - return { - "project_id": project_id, - "project_secret": project_secret, - "webhook_port": _coerce_port(os.getenv("PHOTON_WEBHOOK_PORT"), _DEFAULT_WEBHOOK_PORT), - "webhook_path": os.getenv("PHOTON_WEBHOOK_PATH") or _DEFAULT_WEBHOOK_PATH, - } - - -# --------------------------------------------------------------------------- -# Signature verification - -def verify_signature( - *, - body: bytes, - timestamp_header: str, - signature_header: str, - signing_secret: str, - now: Optional[float] = None, - drift: int = _TIMESTAMP_DRIFT_SECONDS, -) -> bool: - """Constant-time verify a Photon webhook signature. - - Returns True iff the timestamp is within ``drift`` of *now* AND - ``signature_header == "v0=" + hmac_sha256(secret, "v0:{ts}:{body}")``. - - Exposed at module scope so tests can exercise it without an adapter - instance. - """ - if not timestamp_header or not signature_header or not signing_secret: - return False - try: - ts = int(timestamp_header) - except ValueError: - return False - if abs((now or time.time()) - ts) > drift: - return False - if not signature_header.startswith("v0="): - return False - expected = hmac.new( - signing_secret.encode("utf-8"), - f"v0:{ts}:".encode("utf-8") + body, - hashlib.sha256, - ).hexdigest() - return hmac.compare_digest(expected, signature_header[3:]) + return {"project_id": project_id, "project_secret": project_secret} # --------------------------------------------------------------------------- # Adapter class PhotonAdapter(BasePlatformAdapter): - """Inbound: signed webhook on aiohttp. Outbound: Node sidecar via loopback HTTP.""" + """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 @@ -209,6 +149,8 @@ class PhotonAdapter(BasePlatformAdapter): 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") @@ -223,27 +165,6 @@ class PhotonAdapter(BasePlatformAdapter): or "" ) - # Webhook receiver - self._webhook_port = _coerce_port( - extra.get("webhook_port") or os.getenv("PHOTON_WEBHOOK_PORT"), - _DEFAULT_WEBHOOK_PORT, - ) - self._webhook_path = ( - extra.get("webhook_path") - or os.getenv("PHOTON_WEBHOOK_PATH") - or _DEFAULT_WEBHOOK_PATH - ) - self._webhook_bind = ( - extra.get("webhook_bind") - or os.getenv("PHOTON_WEBHOOK_BIND") - or _DEFAULT_WEBHOOK_BIND - ) - self._webhook_secret: str = ( - os.getenv("PHOTON_WEBHOOK_SECRET") - or extra.get("webhook_secret") - or "" - ) - # Sidecar self._sidecar_port = _coerce_port( extra.get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"), @@ -259,12 +180,13 @@ class PhotonAdapter(BasePlatformAdapter): self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node" # Runtime state - self._runner: Optional["web.AppRunner"] = None 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. Photon's at-least-once guarantee - # means we WILL see the same message.id more than once. + # 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] = {} # Group-chat mention gating (parity with BlueBubbles). When enabled, @@ -345,13 +267,6 @@ class PhotonAdapter(BasePlatformAdapter): # -- Connection lifecycle --------------------------------------------- async def connect(self) -> bool: - if not AIOHTTP_AVAILABLE: - self._set_fatal_error( - "MISSING_DEP", - "aiohttp not installed. Run: pip install aiohttp", - retryable=False, - ) - return False if not HTTPX_AVAILABLE: self._set_fatal_error( "MISSING_DEP", "httpx not installed", retryable=False @@ -366,19 +281,10 @@ class PhotonAdapter(BasePlatformAdapter): ) return False - # Start the aiohttp receiver first; without it the sidecar would - # be able to forward inbound traffic to a closed port. - try: - await self._start_webhook_server() - except OSError as e: - self._set_fatal_error( - "PORT_IN_USE", - f"webhook port {self._webhook_port} unavailable: {e}", - retryable=True, - ) - return False + self._http_client = httpx.AsyncClient(timeout=30.0) - # Spin up the Node sidecar (required for outbound). + # 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() @@ -388,23 +294,39 @@ class PhotonAdapter(BasePlatformAdapter): f"failed to start Photon sidecar: {e}", retryable=True, ) - await self._stop_webhook_server() + await self._http_client.aclose() + self._http_client = None return False else: - logger.info("[photon] sidecar autostart disabled — outbound will fail") + 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._http_client = httpx.AsyncClient(timeout=30.0) self._mark_connected() logger.info( - "[photon] connected — webhook at %s:%d%s, sidecar on %s:%d", - self._webhook_bind, self._webhook_port, self._webhook_path, + "[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() - await self._stop_webhook_server() if self._http_client is not None: try: await self._http_client.aclose() @@ -413,68 +335,61 @@ class PhotonAdapter(BasePlatformAdapter): self._http_client = None self._mark_disconnected() - # -- Webhook server ---------------------------------------------------- + # -- Inbound stream consumer ------------------------------------------ - async def _start_webhook_server(self) -> None: - app = web.Application() - app.router.add_post(self._webhook_path, self._handle_webhook) - app.router.add_get("/healthz", lambda _: web.Response(text="ok")) - self._runner = web.AppRunner(app) - await self._runner.setup() - site = web.TCPSite(self._runner, self._webhook_bind, self._webhook_port) - await site.start() + async def _inbound_loop(self) -> None: + """Consume the sidecar's ``/inbound`` NDJSON stream, with reconnect. - async def _stop_webhook_server(self) -> None: - if self._runner is not None: + 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: - await self._runner.cleanup() - except Exception: - pass - self._runner = None - - async def _handle_webhook(self, request: "web.Request") -> "web.Response": - body = await request.read() - if self._webhook_secret: - ts = request.headers.get("X-Spectrum-Timestamp", "") - sig = request.headers.get("X-Spectrum-Signature", "") - if not verify_signature( - body=body, - timestamp_header=ts, - signature_header=sig, - signing_secret=self._webhook_secret, - ): - logger.warning("[photon] rejected webhook with bad signature") - return web.Response(status=401, text="invalid signature") - else: - logger.warning( - "[photon] PHOTON_WEBHOOK_SECRET unset — accepting unsigned " - "deliveries. Set the per-URL signing secret returned by " - "register-webhook to enable verification." - ) + 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: - payload = json.loads(body or b"{}") + event = json.loads(line) except json.JSONDecodeError: - return web.Response(status=400, text="invalid json") - if payload.get("event") != "messages": - # Photon currently emits only `messages`; any future event - # types are ack'd 200 so they don't retry. - return web.Response(text="ok") - - msg = payload.get("message") or {} - msg_id = msg.get("id") - if not msg_id: - return web.Response(status=400, text="missing message.id") - if self._is_duplicate(msg_id): - return web.Response(text="ok (dup)") - + 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(payload) + await self._dispatch_inbound(event) except Exception: logger.exception("[photon] inbound dispatch failed") - # 200 anyway — we own the dedup; failing here would cause - # Photon to retry the same id. - return web.Response(text="ok") def _is_duplicate(self, msg_id: str) -> bool: now = time.time() @@ -488,44 +403,55 @@ class PhotonAdapter(BasePlatformAdapter): self._seen_messages[msg_id] = now return False - async def _dispatch_inbound(self, payload: Dict[str, Any]) -> None: - msg = payload.get("message") or {} - space = msg.get("space") or payload.get("space") or {} - sender = msg.get("sender") or {} - content = msg.get("content") or {} + 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", "name", "mimeType", "size"}, + "timestamp": "2026-05-14T19:06:32.000Z" + } + """ + space = event.get("space") or {} + sender = event.get("sender") or {} + content = event.get("content") or {} space_id = space.get("id") or "" - sender_id = sender.get("id") or "" if not space_id: logger.warning("[photon] inbound missing space.id") return - # Space type — Photon documents iMessage DM ids as `any;-;+E164` - # and group ids as `any;+;`. Use that as the - # heuristic; everything else is treated as DM. - chat_type = "group" if ";+;" in space_id else "dm" + # 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 - # Timestamp — ISO 8601 from the platform. - ts_str = msg.get("timestamp") or "" + ts_str = event.get("timestamp") or "" try: - timestamp = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + 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) - # Content normalization. Spectrum is a discriminated union; - # text vs attachment metadata. Attachments are metadata-only - # today (no download URL) — log + carry the name so the agent - # at least knows something was sent. - if content.get("type") == "text": + ctype = content.get("type") + if ctype == "text": text = content.get("text") or "" mtype = MessageType.TEXT - elif content.get("type") == "attachment": + elif ctype == "attachment": name = content.get("name") or "(unnamed)" mime = content.get("mimeType") or "" - text = f"[Photon attachment received: {name} ({mime}) — no download URL yet]" + text = f"[Photon attachment received: {name} ({mime})]" mtype = _attachment_message_type(mime) else: - text = f"[Photon content type not handled: {content.get('type')}]" + text = f"[Photon content type not handled: {ctype}]" mtype = MessageType.TEXT # Group-mention gating (parity with BlueBubbles). In group chats with @@ -545,18 +471,18 @@ class PhotonAdapter(BasePlatformAdapter): chat_id=space_id, chat_name=space_id, chat_type=chat_type, - user_id=sender_id or space_id, + user_id=sender_id, user_name=sender_id or None, ) - event = MessageEvent( + message_event = MessageEvent( text=text, message_type=mtype, source=source, - message_id=msg.get("id"), - raw_message=payload, + message_id=event.get("messageId"), + raw_message=event, timestamp=timestamp, ) - await self.handle_message(event) + await self.handle_message(message_event) # -- Sidecar lifecycle ------------------------------------------------- @@ -774,12 +700,10 @@ class PhotonAdapter(BasePlatformAdapter): 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 (`any;-;+E164` for DMs, - `any;+;` for groups). We surface that shape directly so - the gateway has something to show in session pickers / logs. + 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. """ - chat_type = "group" if ";+;" in chat_id else "dm" - return {"name": chat_id, "type": chat_type, "id": chat_id} + return {"name": chat_id, "type": "dm", "id": chat_id} async def _sidecar_send( self, space_id: str, text: str, *, reply_to: Optional[str] = None, @@ -889,9 +813,8 @@ def _attachment_message_type(mime: str) -> MessageType: # --------------------------------------------------------------------------- # Standalone (out-of-process) send for cron deliveries when the gateway -# is not co-resident. Spins up an ephemeral sidecar call by spawning -# the existing sidecar binary one-shot; if a live sidecar is already -# listening on the configured port we reuse it. +# 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, @@ -1011,7 +934,7 @@ def register(ctx) -> None: "Treat replies like regular text messages — short, friendly, no " "markdown rendering. Recipient identifiers are E.164 phone " "numbers; never expose them in responses unless the user asked. " - "Attachments arrive as metadata only (no download URL yet)." + "Attachments arrive as metadata only." ), ) diff --git a/plugins/platforms/photon/auth.py b/plugins/platforms/photon/auth.py index 1ff129ac77f..a946d7afcf7 100644 --- a/plugins/platforms/photon/auth.py +++ b/plugins/platforms/photon/auth.py @@ -1,27 +1,37 @@ """ -Photon Dashboard + Spectrum API client and device-code login flow. +Photon Dashboard API client + device-code login flow. This module is pure Python — it intentionally does not depend on -``spectrum-ts``. All management-plane operations (login, create -project, create user, register webhook) talk to Photon's HTTP API -directly: +``spectrum-ts``. Every management-plane operation (login, find/create +project, enable Spectrum, rotate the project secret, register a user, +list the assigned iMessage line) talks to Photon's **Dashboard API** on a +single host, exactly like the official Photon CLI (``photon-hq/cli``): Dashboard API https://app.photon.codes/api/... - OAuth bearer token from device flow + OAuth 2.0 device flow, Bearer access token - Spectrum API https://spectrum.photon.codes/projects/{id}/... - HTTP Basic with (projectId, projectSecret) +A Photon project carries two distinct identifiers: -The webhook receiver + Node sidecar in ``adapter.py`` consume the -credentials this module persists to ``~/.hermes/auth.json``. + * ``id`` — the Dashboard project id (used in API paths) + * ``spectrumProjectId`` — the Spectrum Cloud project id, populated when + Spectrum is enabled on the project -Reference docs (read at integration time): - https://photon.codes/docs/api-reference/introduction - https://photon.codes/docs/api-reference/device-login/request-device-+-user-code - https://photon.codes/docs/api-reference/device-login/exchange-device-code-for-token - https://photon.codes/docs/api-reference/projects/create-project - https://photon.codes/docs/api-reference/users/create-user - https://photon.codes/docs/webhooks/overview +The ``spectrum-ts`` SDK (run by the Node sidecar) authenticates to Spectrum +Cloud with ``(spectrumProjectId, projectSecret)`` — so the value we persist +as ``PHOTON_PROJECT_ID`` for the runtime is the **spectrumProjectId**, not +the Dashboard ``id``. The Dashboard ``id`` is kept only for management +calls. + +Credential storage mirrors every other Hermes channel: + + * runtime SDK creds -> ``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` = + spectrumProjectId, ``PHOTON_PROJECT_SECRET``) via ``save_env_value`` + * management metadata -> ``~/.hermes/auth.json`` under + ``credential_pool.photon`` (device token) and + ``credential_pool.photon_project`` (dashboard id, spectrum id, name) + +Reference: https://github.com/photon-hq/cli and +https://photon.codes/docs/api-reference/device-login/request-device-+-user-code """ from __future__ import annotations @@ -32,7 +42,7 @@ import re import time from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable, Dict, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple try: import httpx @@ -51,17 +61,20 @@ class PhotonDashboardAuthError(RuntimeError): # Hosted Photon allowlists registered device clients on the device-code # endpoint — an unregistered client_id is rejected with # `400 {"error":"invalid_client"}`. Use Photon's published CLI device -# client until the dashboard API registers Hermes as its own client_id. +# client (matches `CLI_CLIENT_ID` in photon-hq/cli) until the dashboard API +# registers Hermes as its own client_id. DEFAULT_CLIENT_ID = "photon-cli" DEFAULT_SCOPE = "openid profile email" DEFAULT_DASHBOARD_HOST = "https://app.photon.codes" -DEFAULT_SPECTRUM_HOST = "https://spectrum.photon.codes" -# Polling defaults per RFC 8628. Photon may override via `interval` / -# `expires_in` fields in the device-code response — those win. +# Default name of the project Hermes provisions for the operator. +DEFAULT_PROJECT_NAME = "Hermes Agent" + +# Polling defaults per RFC 8628. Photon overrides via `interval` / +# `expires_in` in the device-code response — those win. DEFAULT_POLL_INTERVAL = 5 -DEFAULT_POLL_TIMEOUT = 900 # 15 minutes is conservative; Photon returns expires_in +DEFAULT_POLL_TIMEOUT = 1800 # 30 min, matching the CLI's fallback E164_RE = re.compile(r"^\+[1-9]\d{6,14}$") @@ -104,7 +117,7 @@ def _save_auth(data: Dict[str, Any]) -> None: def load_photon_token() -> Optional[str]: - """Return the bearer token stored by ``login()`` or ``None``.""" + """Return the device-flow bearer token stored by ``login()`` or ``None``.""" auth = _load_auth() pool = auth.get("credential_pool", {}).get("photon") or [] if isinstance(pool, list) and pool: @@ -128,7 +141,13 @@ def store_photon_token(token: str) -> None: def load_project_credentials() -> Tuple[Optional[str], Optional[str]]: - """Return ``(project_id, project_secret)`` from auth.json + env override.""" + """Return the runtime SDK creds ``(spectrum_project_id, project_secret)``. + + Precedence: process env (``~/.hermes/.env`` is loaded into the gateway's + environment at startup) wins, then ``auth.json`` for offline / status + use. This is the pair the Node sidecar feeds to ``spectrum-ts`` — the id + is the **spectrumProjectId**, not the Dashboard id. + """ env_id = os.getenv("PHOTON_PROJECT_ID") env_sec = os.getenv("PHOTON_PROJECT_SECRET") if env_id and env_sec: @@ -137,24 +156,72 @@ def load_project_credentials() -> Tuple[Optional[str], Optional[str]]: proj = auth.get("credential_pool", {}).get("photon_project") or [] if isinstance(proj, list) and proj: entry = proj[0] - return ( - env_id or entry.get("project_id"), - env_sec or entry.get("project_secret"), - ) + # back-compat: old records used "project_id" for the spectrum id + sid = entry.get("spectrum_project_id") or entry.get("project_id") + return (env_id or sid, env_sec or entry.get("project_secret")) return env_id, env_sec -def store_project_credentials(project_id: str, project_secret: str, **extra: Any) -> None: - """Persist the Spectrum project's id+secret under ``credential_pool.photon_project``.""" +def load_dashboard_project_id() -> Optional[str]: + """Return the Dashboard project id (for management API calls).""" + env_id = os.getenv("PHOTON_DASHBOARD_PROJECT_ID") + if env_id: + return env_id auth = _load_auth() - record = { - "project_id": project_id, + proj = auth.get("credential_pool", {}).get("photon_project") or [] + if isinstance(proj, list) and proj: + return proj[0].get("dashboard_project_id") or proj[0].get("project_id") + return None + + +def store_project_credentials( + *, + spectrum_project_id: str, + project_secret: str, + dashboard_project_id: Optional[str] = None, + name: Optional[str] = None, +) -> None: + """Persist project credentials to both .env (runtime) and auth.json (mgmt). + + The runtime SDK creds land in ``~/.hermes/.env`` via the same + ``save_env_value`` helper every other channel uses, so the gateway picks + them up from the environment with zero adapter changes. A copy of the + non-secret ids (plus the secret, for offline ``status``) is written to + ``auth.json`` so management commands work even when ``.env`` hasn't been + loaded into the current process. + """ + auth = _load_auth() + record: Dict[str, Any] = { + "spectrum_project_id": spectrum_project_id, "project_secret": project_secret, "issued_at": int(time.time()), } - record.update(extra) + if dashboard_project_id: + record["dashboard_project_id"] = dashboard_project_id + if name: + record["name"] = name auth.setdefault("credential_pool", {})["photon_project"] = [record] _save_auth(auth) + _persist_runtime_env(spectrum_project_id, project_secret) + + +def _persist_runtime_env(spectrum_project_id: str, project_secret: str) -> None: + """Write the SDK creds to ``~/.hermes/.env`` (canonical runtime store). + + Isolated in its own helper so the secret value flows straight into + ``save_env_value`` without ever being bound to a printable local in a + caller — same CodeQL-clean-flow rationale as the rest of this module. + """ + try: + from hermes_cli.config import save_env_value + except ImportError: + logger.warning("photon: hermes_cli.config unavailable — skipping .env write") + return + try: + save_env_value("PHOTON_PROJECT_ID", spectrum_project_id) + save_env_value("PHOTON_PROJECT_SECRET", project_secret) + except Exception as e: # pragma: no cover - defensive + logger.warning("photon: could not write project creds to .env: %s", e) # --------------------------------------------------------------------------- @@ -181,8 +248,8 @@ def _dashboard_host() -> str: return (os.getenv("PHOTON_DASHBOARD_HOST") or DEFAULT_DASHBOARD_HOST).rstrip("/") -def _spectrum_host() -> str: - return (os.getenv("PHOTON_API_HOST") or DEFAULT_SPECTRUM_HOST).rstrip("/") +def _bearer(token: str) -> Dict[str, str]: + return {"Authorization": f"Bearer {token}"} def request_device_code( @@ -218,16 +285,22 @@ def poll_for_token( ) -> str: """Poll ``/api/auth/device/token`` until the user approves. - Returns the bearer token from the ``set-auth-token`` response header - (Photon's documented mechanism). Falls back to ``session.access_token`` - in the JSON body if the header is absent — see the API spec. + Mirrors the official CLI's polling loop: sleep first, then poll; + ``authorization_pending`` keeps the interval, ``slow_down`` adds 5s, + HTTP 429 adds 10s, and ``access_denied`` / ``expired_token`` abort. + + The bearer token comes from the response body's top-level + ``access_token`` (better-auth device-grant shape), with + ``session.access_token`` and the ``set-auth-token`` header kept as + fallbacks for API drift. """ if httpx is None: raise RuntimeError("httpx is required for Photon device login") url = f"{_dashboard_host()}/api/auth/device/token" deadline = time.time() + (timeout or code.expires_in or DEFAULT_POLL_TIMEOUT) - sleep = interval or code.interval or DEFAULT_POLL_INTERVAL + sleep = interval if interval is not None else (code.interval or DEFAULT_POLL_INTERVAL) while time.time() < deadline: + time.sleep(sleep) try: resp = httpx.post( url, @@ -240,7 +313,6 @@ def poll_for_token( ) except httpx.RequestError as e: logger.warning("photon: device-token poll failed: %s", e) - time.sleep(sleep) continue if resp.status_code == 200: body: Dict[str, Any] = {} @@ -259,34 +331,35 @@ def poll_for_token( "data.access_token, accessToken, or set-auth-token)." ) return candidates[0].token + if resp.status_code == 429: + # RFC 8628 §3.5 — treat 429 as slow_down. + sleep += 10 + if on_pending: + _safe(on_pending) + continue if resp.status_code == 400: - # RFC 8628 §3.5 — error codes are returned with 400. - body: Dict[str, Any] = {} + body = {} try: body = resp.json() or {} except json.JSONDecodeError: pass err = body.get("error") or body.get("message") or "" - if err in ("authorization_pending", "slow_down"): + if err == "authorization_pending": if on_pending: - try: - on_pending() - except Exception: - pass - if err == "slow_down": - sleep += 5 - time.sleep(sleep) + _safe(on_pending) + continue + if err == "slow_down": + sleep += 5 + if on_pending: + _safe(on_pending) continue if err in ("expired_token", "access_denied"): raise RuntimeError(f"Photon login failed: {err}") - # Unknown error — surface it raise RuntimeError(f"Photon device token error: {err or resp.text}") - # Unexpected status; log and retry logger.warning( "photon: device-token unexpected status %s: %s", resp.status_code, resp.text[:200], ) - time.sleep(sleep) raise TimeoutError("Photon device login timed out") @@ -426,6 +499,13 @@ def _validated_dashboard_token(candidates: list) -> str: raise RuntimeError("Photon did not return a usable dashboard token") +def _safe(fn: Callable[[], None]) -> None: + try: + fn() + except Exception: + pass + + def login_device_flow( *, client_id: str = DEFAULT_CLIENT_ID, @@ -434,15 +514,12 @@ def login_device_flow( ) -> str: """Run the full device-code login flow and persist the token. - Returns the bearer token. ``on_user_code`` is a callback receiving the - :class:`DeviceCode` so callers can print + optionally open the browser. + Returns the bearer token. ``on_user_code`` receives the + :class:`DeviceCode` so callers can print it + optionally open a browser. """ code = request_device_code(client_id=client_id) if on_user_code: - try: - on_user_code(code) - except Exception: - pass + _safe(lambda: on_user_code(code)) if open_browser: try: import webbrowser @@ -461,280 +538,320 @@ def login_device_flow( return token +def get_session(token: str) -> Dict[str, Any]: + """GET ``/api/auth/get-session`` — confirm the token + fetch the user.""" + if httpx is None: + raise RuntimeError("httpx is required for Photon") + url = f"{_dashboard_host()}/api/auth/get-session" + resp = httpx.get(url, headers=_bearer(token), timeout=30.0) + resp.raise_for_status() + return resp.json() or {} + + # --------------------------------------------------------------------------- -# Dashboard API: create project +# Dashboard API: projects + +def _unwrap_list(data: Any) -> List[Dict[str, Any]]: + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("data", "projects", "users", "lines", "items"): + inner = data.get(key) + if isinstance(inner, list): + return inner + return [] + + +def list_projects(token: str) -> List[Dict[str, Any]]: + """GET ``/api/projects`` — return the caller's projects.""" + if httpx is None: + raise RuntimeError("httpx is required for Photon") + url = f"{_dashboard_host()}/api/projects" + resp = httpx.get(url, headers=_bearer(token), timeout=30.0) + resp.raise_for_status() + return _unwrap_list(resp.json()) + + +def find_project_by_name(token: str, name: str) -> Optional[Dict[str, Any]]: + """Return the first project whose name matches (case-insensitive).""" + target = (name or "").strip().lower() + for proj in list_projects(token): + if (proj.get("name") or "").strip().lower() == target: + return proj + return None + + +def get_project(token: str, project_id: str) -> Dict[str, Any]: + """GET ``/api/projects/{id}`` — includes ``spectrum`` + ``spectrumProjectId``.""" + if httpx is None: + raise RuntimeError("httpx is required for Photon") + url = f"{_dashboard_host()}/api/projects/{project_id}" + resp = httpx.get(url, headers=_bearer(token), timeout=30.0) + resp.raise_for_status() + return resp.json() or {} + def create_project( token: str, *, - name: str, + name: str = DEFAULT_PROJECT_NAME, location: str = "United States", - platforms: Optional[list] = None, ) -> Dict[str, Any]: - """POST ``/api/projects/`` with ``spectrum: true`` and return the response. - - The response includes ``spectrumProjectId`` and ``projectSecret`` — those - are the HTTP Basic credentials for the Spectrum API. Photon only - returns ``projectSecret`` to project owners at creation time. - """ + """POST ``/api/projects`` with ``spectrum: true`` and return ``{success, id}``.""" if httpx is None: raise RuntimeError("httpx is required for Photon project creation") - url = f"{_dashboard_host()}/api/projects/" + url = f"{_dashboard_host()}/api/projects" body: Dict[str, Any] = { "name": name, "location": location, "spectrum": True, - "platforms": platforms or ["imessage"], + "template": False, + "observability": False, } - resp = httpx.post( - url, - json=body, - headers={"Authorization": f"Bearer {token}"}, - timeout=30.0, - ) + resp = httpx.post(url, json=body, headers=_bearer(token), timeout=30.0) resp.raise_for_status() - return resp.json() + data = resp.json() or {} + if data.get("error"): + raise RuntimeError(f"Photon create-project failed: {data['error']}") + if not data.get("id"): + raise RuntimeError("Photon create-project did not return a project id") + return data + + +def ensure_spectrum_enabled(token: str, project_id: str) -> Dict[str, Any]: + """Enable Spectrum on the project if needed; return the project dict. + + The dashboard exposes Spectrum as a toggle, so we only flip it when + ``spectrum`` is currently false, then re-fetch to pick up the freshly + populated ``spectrumProjectId``. + """ + if httpx is None: + raise RuntimeError("httpx is required for Photon") + proj = get_project(token, project_id) + if not proj.get("spectrum"): + url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/toggle" + resp = httpx.post(url, json={}, headers=_bearer(token), timeout=30.0) + resp.raise_for_status() + proj = get_project(token, project_id) + if not proj.get("spectrumProjectId"): + raise RuntimeError( + "Spectrum is enabled but the project has no spectrumProjectId yet — " + "retry in a moment, or enable Spectrum from the dashboard." + ) + return proj + + +def regenerate_project_secret(token: str, project_id: str) -> str: + """POST ``/api/projects/{id}/regenerate-secret`` → the new project secret. + + This is the only way to read a project secret (the dashboard shows it + exactly once), so callers should persist the returned value immediately. + """ + if httpx is None: + raise RuntimeError("httpx is required for Photon") + url = f"{_dashboard_host()}/api/projects/{project_id}/regenerate-secret" + resp = httpx.post(url, json={}, headers=_bearer(token), timeout=30.0) + resp.raise_for_status() + data = resp.json() or {} + if data.get("error"): + raise RuntimeError(f"Photon regenerate-secret failed: {data['error']}") + secret = data.get("projectSecret") + if not secret: + raise RuntimeError("Photon regenerate-secret returned no projectSecret") + return str(secret) # --------------------------------------------------------------------------- -# Spectrum API: create user +# Dashboard API: spectrum users + +def _normalize_phone(phone: str) -> str: + """Reduce a phone string to ``+`` and digits for dedup comparison.""" + return re.sub(r"[^\d+]", "", phone or "") + + +def list_users(token: str, project_id: str) -> List[Dict[str, Any]]: + """GET ``/api/projects/{id}/spectrum/users`` → ``SpectrumUser[]``.""" + if httpx is None: + raise RuntimeError("httpx is required for Photon") + url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/users" + resp = httpx.get(url, headers=_bearer(token), timeout=30.0) + resp.raise_for_status() + return _unwrap_list(resp.json()) + + +def find_user_by_phone( + token: str, project_id: str, phone_number: str, +) -> Optional[Dict[str, Any]]: + """Return an existing Spectrum user with the given phone number, or None.""" + target = _normalize_phone(phone_number) + for user in list_users(token, project_id): + if _normalize_phone(user.get("phoneNumber") or "") == target: + return user + return None + def create_user( + token: str, project_id: str, - project_secret: str, *, phone_number: str, - user_type: str = "shared", first_name: Optional[str] = None, last_name: Optional[str] = None, email: Optional[str] = None, - assigned_phone_number: Optional[str] = None, + send_invite: bool = False, ) -> Dict[str, Any]: - """POST ``/projects/{id}/users/`` on the Spectrum API. - - For free users we always pass ``type=shared``; Photon's Cosmos pool - assigns the iMessage line. ``assigned_phone_number`` is only valid - for the paid ``dedicated`` mode. - """ + """POST ``/api/projects/{id}/spectrum/users`` and return the created user.""" if httpx is None: raise RuntimeError("httpx is required for Photon user creation") if not E164_RE.match(phone_number): raise ValueError( f"phone_number must be E.164 (e.g. +15551234567); got {phone_number!r}" ) - url = f"{_spectrum_host()}/projects/{project_id}/users/" - body: Dict[str, Any] = {"type": user_type, "phoneNumber": phone_number} + url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/users" + body: Dict[str, Any] = {"phoneNumber": phone_number, "sendInvite": send_invite} if first_name: body["firstName"] = first_name if last_name: body["lastName"] = last_name if email: body["email"] = email - if assigned_phone_number: - body["assignedPhoneNumber"] = assigned_phone_number - resp = httpx.post( - url, - json=body, - auth=(project_id, project_secret), - timeout=30.0, - ) + resp = httpx.post(url, json=body, headers=_bearer(token), timeout=30.0) resp.raise_for_status() data = resp.json() or {} - if not data.get("succeed"): - raise RuntimeError( - f"Photon create-user failed: {data.get('message') or data}" - ) - return data.get("data") or {} + if data.get("error"): + raise RuntimeError(f"Photon create-user failed: {data['error']}") + return data.get("user") or data + + +def register_user_if_absent( + token: str, + project_id: str, + *, + phone_number: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + email: Optional[str] = None, +) -> Tuple[Dict[str, Any], bool]: + """Idempotently register a Spectrum user. + + Returns ``(user, created)`` — ``created`` is False when a user with the + same phone number already exists (the official CLI does no dedup, so we + add it here to make ``setup`` safely re-runnable). + """ + existing = find_user_by_phone(token, project_id, phone_number) + if existing is not None: + return existing, False + user = create_user( + token, project_id, + phone_number=phone_number, + first_name=first_name, + last_name=last_name, + email=email, + ) + return user, True # --------------------------------------------------------------------------- -# Spectrum API: webhook registration -# -# Endpoints from https://photon.codes/docs/webhooks/overview: -# POST /projects/{id}/webhooks/ register, returns signing secret ONCE -# GET /projects/{id}/webhooks/ list -# DELETE /projects/{id}/webhooks/{wid} remove +# Dashboard API: iMessage lines (the assigned number inventory) -def register_webhook( - project_id: str, project_secret: str, *, webhook_url: str, -) -> Dict[str, Any]: - """Register a webhook URL with Photon and return the API response. - - Photon returns the per-URL signing secret exactly once in this - response, so callers who need to persist it should hand the - response to :func:`persist_webhook_signing_secret` immediately — - that helper writes the value into ``~/.hermes/.env`` (mode 0o600, - existing entries preserved) without the secret value ever needing - to leave this module. - """ +def list_lines(token: str, project_id: str) -> List[Dict[str, Any]]: + """GET ``/api/projects/{id}/lines`` → ``[{id, platform, phoneNumber, status}]``.""" if httpx is None: - raise RuntimeError("httpx is required for Photon webhook registration") - url = f"{_spectrum_host()}/projects/{project_id}/webhooks/" + raise RuntimeError("httpx is required for Photon") + url = f"{_dashboard_host()}/api/projects/{project_id}/lines" + resp = httpx.get(url, headers=_bearer(token), timeout=30.0) + resp.raise_for_status() + return _unwrap_list(resp.json()) + + +def add_line( + token: str, project_id: str, *, platform: str = "imessage", +) -> Dict[str, Any]: + """POST ``/api/projects/{id}/lines`` to provision a new line.""" + if httpx is None: + raise RuntimeError("httpx is required for Photon") + url = f"{_dashboard_host()}/api/projects/{project_id}/lines" resp = httpx.post( - url, - json={"webhookUrl": webhook_url}, - auth=(project_id, project_secret), - timeout=30.0, + url, json={"platform": platform}, headers=_bearer(token), timeout=30.0, ) resp.raise_for_status() data = resp.json() or {} - if not data.get("succeed"): - raise RuntimeError( - f"Photon register-webhook failed: {data.get('message') or data}" - ) - return data.get("data") or {} + if data.get("error"): + raise RuntimeError(f"Photon add-line failed: {data['error']}") + return data.get("line") or data +def get_imessage_line( + token: str, project_id: str, *, create_if_missing: bool = True, +) -> Optional[Dict[str, Any]]: + """Return the project's iMessage line (the number to text the agent). + + If none exists and ``create_if_missing`` is set, provision one. Returns + ``None`` if there is no line and provisioning failed. + """ + for line in list_lines(token, project_id): + if (line.get("platform") or "").lower() == "imessage": + return line + if create_if_missing: + try: + return add_line(token, project_id, platform="imessage") + except Exception as e: + logger.warning("photon: could not auto-provision iMessage line: %s", e) + return None + return None + + +# --------------------------------------------------------------------------- +# Credential status (display-only — never emits raw secret material) + def print_credential_summary(emit: Any = print) -> None: """Pretty-print the credential status table via the *emit* callback. - Same isolation rationale as :func:`persist_webhook_signing_secret`: - all secret-bearing reads happen inside this function; the *emit* - callback only ever receives display literals like ``"✓ stored"`` - or a project UUID. No tainted variable ever escapes into the - caller's scope. Default ``emit=print`` so the function is usable - directly from a CLI handler with zero plumbing. + Every secret-bearing read is reduced to a display literal inside this + function (``"✓ stored"`` / ``"✗ missing"`` / a non-secret id); the + callback only ever receives the assembled banner string, so no tainted + value escapes into the caller's scope. """ - # Resolve every credential read into a plain display string FIRST, - # in a tight block. The intermediate `labels` dict only ever stores - # literals from a finite set ("✓ stored" / "✗ missing" / "✓ set" / - # "⚠ unset — verification disabled" / a project UUID) — never a - # credential's raw bytes. We then assemble the whole banner into - # one string and call emit() exactly once with that string, so the - # static taint analyzer sees a single sink that consumes only a - # joined literal blob. labels: Dict[str, str] = {} - if load_photon_token(): - labels["device_token"] = "✓ stored" - else: - labels["device_token"] = "✗ missing (run `hermes photon setup`)" - pid, sec = load_project_credentials() - labels["project_id"] = pid if pid else "✗ missing" + labels["device_token"] = ( + "✓ stored" if load_photon_token() + else "✗ missing (run `hermes photon setup`)" + ) + sid, sec = load_project_credentials() + labels["spectrum_project_id"] = sid if sid else "✗ missing" + labels["dashboard_project_id"] = load_dashboard_project_id() or "—" labels["project_key"] = "✓ stored" if sec else "✗ missing" - if os.getenv("PHOTON_WEBHOOK_SECRET"): - labels["webhook_key"] = "✓ set" - else: - labels["webhook_key"] = "⚠ unset — verification disabled" rows = [ "Photon iMessage status", "──────────────────────", " device token : " + labels["device_token"], - " project id : " + labels["project_id"], - " project key : " + labels["project_key"], - " webhook key : " + labels["webhook_key"], + " dashboard project : " + labels["dashboard_project_id"], + " spectrum project id : " + labels["spectrum_project_id"], + " project secret : " + labels["project_key"], ] emit("\n".join(rows)) def credential_summary() -> Dict[str, str]: - """Return a fully pre-formatted credential status dict. - - Caller-safe: every value is one of ``"✓ stored"`` / ``"✗ missing"`` - / ``"⚠ unset — verification disabled"`` / ``"✓ set"`` literals, or a - UUID for the project id. No secret-bearing string ever leaves this - function — read-and-bool-cast happens entirely inside the closure. - """ + """Return a fully pre-formatted credential status dict (no raw secrets).""" def _present_token() -> str: - return "✓ stored" if load_photon_token() else "✗ missing (run `hermes photon setup`)" + return ( + "✓ stored" if load_photon_token() + else "✗ missing (run `hermes photon setup`)" + ) - def _present_project_id() -> str: - pid, _sec = load_project_credentials() - return pid or "✗ missing" + def _present_spectrum_id() -> str: + sid, _sec = load_project_credentials() + return sid or "✗ missing" - def _present_project_secret() -> str: - _pid, sec = load_project_credentials() + def _present_secret() -> str: + _sid, sec = load_project_credentials() return "✓ stored" if sec else "✗ missing" - def _present_webhook_secret() -> str: - return "✓ set" if os.getenv("PHOTON_WEBHOOK_SECRET") else "⚠ unset — verification disabled" - return { "device_token": _present_token(), - "project_id": _present_project_id(), - "project_key": _present_project_secret(), - "webhook_key": _present_webhook_secret(), + "dashboard_project_id": load_dashboard_project_id() or "—", + "spectrum_project_id": _present_spectrum_id(), + "project_key": _present_secret(), } - - -def persist_webhook_signing_secret( - webhook_data: Dict[str, Any], - *, - on_summary: Optional[Any] = None, -) -> bool: - """Persist a webhook signing secret via Hermes' canonical .env writer. - - Delegates to :func:`hermes_cli.config.save_env_value` — the same - helper that backs every other API-key persistence path in Hermes - Agent (OpenAI key, Anthropic key, Telegram token, ...). The secret - value is read directly from ``webhook_data['signingSecret']`` (or - ``['secret']`` fallback) and handed to that helper without ever - being bound to a local in any module that prints or logs. - - Returns ``True`` on success, ``False`` if the response had no - secret OR the write failed. The optional ``on_summary`` callable - receives a plain string with no credential material, suitable for - printing — e.g. ``"Wrote to /home/u/.hermes/.env"`` or - ``"register response: {redacted dict json}"``. We do the - formatting here so callers stay clear of the taint flow CodeQL - tracks through functions that touch secrets. - """ - if not isinstance(webhook_data, dict): - return False - has_secret = bool(webhook_data.get("signingSecret") or webhook_data.get("secret")) - redacted = { - k: ("" if k in ("signingSecret", "secret") else v) - for k, v in webhook_data.items() - } - if on_summary is not None: - try: - on_summary("webhook registration response (redacted):") - on_summary(json.dumps(redacted, indent=2)) - except Exception: - pass - if not has_secret: - return False - try: - from hermes_cli.config import save_env_value - except ImportError: - return False - try: - save_env_value( - "PHOTON_WEBHOOK_SECRET", - webhook_data.get("signingSecret") or webhook_data.get("secret") or "", - ) - except Exception: - return False - if on_summary is not None: - try: - from hermes_constants import get_hermes_home - env_path = Path(get_hermes_home()) / ".env" - except Exception: - env_path = Path(os.path.expanduser("~/.hermes")) / ".env" - try: - on_summary(f"signing key saved to {env_path}") - on_summary("(Photon only returns this once — keep the file safe)") - except Exception: - pass - return True - - -def list_webhooks(project_id: str, project_secret: str) -> list: - if httpx is None: - raise RuntimeError("httpx is required for Photon webhook listing") - url = f"{_spectrum_host()}/projects/{project_id}/webhooks/" - resp = httpx.get(url, auth=(project_id, project_secret), timeout=30.0) - resp.raise_for_status() - data = resp.json() or {} - return data.get("data") or [] - - -def delete_webhook( - project_id: str, project_secret: str, *, webhook_id: str, -) -> None: - if httpx is None: - raise RuntimeError("httpx is required for Photon webhook deletion") - url = f"{_spectrum_host()}/projects/{project_id}/webhooks/{webhook_id}" - resp = httpx.delete(url, auth=(project_id, project_secret), timeout=30.0) - if resp.status_code not in (200, 204, 404): - resp.raise_for_status() diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index 615ed9db14a..ea79aabd6b6 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -7,19 +7,18 @@ Subcommands: setup full first-time setup (device login + project + user + sidecar) status show login + project + sidecar dep state install-sidecar npm install inside plugins/platforms/photon/sidecar/ - webhook register register the local webhook URL with Photon - webhook list list registered webhooks - webhook delete delete a webhook by id The device-code login runs automatically as the first step of ``setup``; there is no standalone ``login`` verb (matching how every other Hermes gateway channel onboards through a single setup surface). + +Photon uses the spectrum-ts gRPC stream for inbound — there is no webhook +to register, so there are no webhook subcommands. """ from __future__ import annotations import argparse import getpass -import json import os import shutil import subprocess @@ -38,9 +37,14 @@ def register_cli(parser: argparse.ArgumentParser) -> None: """Wire up `hermes photon ...` subcommands.""" subs = parser.add_subparsers(dest="photon_command", required=False) - p_setup = subs.add_parser("setup", help="First-time setup (device login + project + user + sidecar)") - p_setup.add_argument("--project-name", default=None, help="Project name (default: 'Hermes Agent')") - p_setup.add_argument("--phone", default=None, help="Your E.164 phone number (e.g. +15551234567)") + p_setup = subs.add_parser( + "setup", + help="First-time setup (device login + project + user + sidecar)", + ) + p_setup.add_argument("--project-name", default=None, + help="Project name (default: 'Hermes Agent')") + p_setup.add_argument("--phone", default=None, + help="Your E.164 phone number (e.g. +15551234567)") p_setup.add_argument("--first-name", default=None) p_setup.add_argument("--last-name", default=None) p_setup.add_argument("--email", default=None) @@ -52,14 +56,6 @@ def register_cli(parser: argparse.ArgumentParser) -> None: subs.add_parser("status", help="Show login + project + sidecar dep state") subs.add_parser("install-sidecar", help="Run npm install inside the sidecar directory") - p_hook = subs.add_parser("webhook", help="Manage Photon webhook registrations") - hook_subs = p_hook.add_subparsers(dest="photon_webhook_command", required=True) - p_hook_reg = hook_subs.add_parser("register", help="Register a webhook URL") - p_hook_reg.add_argument("url", help="Publicly reachable URL Photon should POST to") - hook_subs.add_parser("list", help="List registered webhooks for the current project") - p_hook_del = hook_subs.add_parser("delete", help="Delete a webhook by id") - p_hook_del.add_argument("webhook_id") - parser.set_defaults(func=dispatch) @@ -77,8 +73,6 @@ def dispatch(args: argparse.Namespace) -> int: return _cmd_status(args) if sub == "install-sidecar": return _cmd_install_sidecar(args) - if sub == "webhook": - return _cmd_webhook(args) print(f"unknown subcommand: {sub}", file=sys.stderr) return 2 @@ -122,7 +116,7 @@ def _cmd_setup(args: argparse.Namespace) -> int: # 1. Login (skip if we already have a token). token = photon_auth.load_photon_token() if not token: - print("[1/4] No Photon token found — running device login...") + print("[1/5] No Photon token found — running device login...") rc = _run_device_login(args) if rc != 0: return rc @@ -131,85 +125,121 @@ def _cmd_setup(args: argparse.Namespace) -> int: print("login completed but token was not stored", file=sys.stderr) return 1 else: - print("[1/4] Reusing existing Photon token") + print("[1/5] Reusing existing Photon token") - # 2. Create (or surface existing) project. - existing_id, existing_secret = photon_auth.load_project_credentials() - project_id: str - project_secret: str - if existing_id and existing_secret: - project_id, project_secret = existing_id, existing_secret - # `project_id` is a Photon-assigned UUID, not a secret — but we - # keep the print terse to avoid CodeQL flow noise. - print("[2/4] Reusing existing Photon project") - else: - name = args.project_name or "Hermes Agent" - print(f"[2/4] Creating Photon project '{name}' (spectrum=true, imessage)...") - try: - data = photon_auth.create_project(token, name=name) - except Exception as e: - print(f"create-project failed: {e}", file=sys.stderr) - return 1 - project_id = data.get("spectrumProjectId") or data.get("id") or "" - project_secret = data.get("projectSecret") or "" - if not project_id or not project_secret: - print( - "create-project did not return spectrumProjectId + " - "projectSecret. Re-run after enabling Spectrum on the " - "project, or open https://app.photon.codes/ to fetch the " - "secret manually.", - file=sys.stderr, - ) - return 1 - photon_auth.store_project_credentials(project_id, project_secret, name=name) - print(" ✓ project provisioned (run `hermes photon status` to see the id)") + # 2. Find or create the "Hermes Agent" project. + name = args.project_name or photon_auth.DEFAULT_PROJECT_NAME + dashboard_id = photon_auth.load_dashboard_project_id() + try: + if dashboard_id: + print("[2/5] Reusing configured Photon project") + else: + existing = photon_auth.find_project_by_name(token, name) + if existing and existing.get("id"): + dashboard_id = existing["id"] + print(f"[2/5] Found existing project '{name}'") + else: + print(f"[2/5] Creating Photon project '{name}'...") + created = photon_auth.create_project(token, name=name) + dashboard_id = created.get("id") + print(" ✓ project created") + except Exception as e: + print(f"project setup failed: {e}", file=sys.stderr) + return 1 + if not dashboard_id: + print("could not resolve a Photon project id", file=sys.stderr) + return 1 - # 3. Create a Spectrum user for the operator. + # 3. Enable Spectrum, fetch the spectrum project id, rotate the secret, + # and persist both (runtime creds -> ~/.hermes/.env, ids -> auth.json). + try: + print("[3/5] Enabling Spectrum and provisioning credentials...") + proj = photon_auth.ensure_spectrum_enabled(token, dashboard_id) + spectrum_id = proj.get("spectrumProjectId") + if not spectrum_id: + print("spectrum provisioning failed: no spectrum project id", file=sys.stderr) + return 1 + spectrum_id = str(spectrum_id) + secret = photon_auth.regenerate_project_secret(token, dashboard_id) + photon_auth.store_project_credentials( + spectrum_project_id=spectrum_id, + project_secret=secret, + dashboard_project_id=dashboard_id, + name=name, + ) + # spectrum_id is an opaque non-secret id; safe to show. + print(f" ✓ Spectrum enabled (project id {spectrum_id}) — secret saved") + except Exception as e: + print(f"spectrum provisioning failed: {e}", file=sys.stderr) + return 1 + + # 4. Register the operator's phone number as a Spectrum user (idempotent). phone = args.phone or _prompt( - "Your iMessage phone number (E.164, e.g. +15551234567): " + "[4/5] Your iMessage phone number (E.164, e.g. +15551234567): " ) if not phone: - print("[3/4] Skipped user creation (no phone given). Re-run with --phone later.") + print(" Skipped user registration (no phone given). Re-run with --phone later.") else: - print("[3/4] Creating shared Spectrum user...") + first_name = args.first_name + email = args.email + # The dashboard may require a name/email; prompt interactively when + # we have a TTY and they weren't supplied, but allow skipping. + if first_name is None: + first_name = _prompt(" First name (optional, Enter to skip): ") or None + if email is None: + email = _prompt(" Email (optional, Enter to skip): ") or None try: - photon_auth.create_user( - project_id, project_secret, + _user, created = photon_auth.register_user_if_absent( + token, dashboard_id, phone_number=phone, - first_name=args.first_name, + first_name=first_name, last_name=args.last_name, - email=args.email, + email=email, ) - except Exception as e: - print(f"create-user failed: {e}", file=sys.stderr) + except ValueError as e: + print(f" invalid phone number: {e}", file=sys.stderr) return 1 - print(" ✓ user created — check `hermes photon status` or the dashboard for the assigned iMessage line") + except Exception as e: + print(f" user registration failed: {e}", file=sys.stderr) + return 1 + print(" ✓ phone registered" if created else " ✓ phone already registered") - # 4. Sidecar deps. - if args.skip_sidecar_install: - print("[4/4] Skipping sidecar npm install (--skip-sidecar-install)") + # 5. Surface the assigned iMessage line (the number to text the agent). + try: + line = photon_auth.get_imessage_line(token, dashboard_id) + except Exception as e: + line = None + print(f" (could not fetch the assigned line: {e})", file=sys.stderr) + if line and line.get("phoneNumber"): + status = line.get("status") or "active" + print() + print("┌─ Your agent's iMessage number ───────────────────────────────") + print(f"│ 📱 {line['phoneNumber']} ({status})") + print("│ Text this number from your phone to talk to your agent.") + print("└──────────────────────────────────────────────────────────────") else: - print("[4/4] Installing Node sidecar deps (spectrum-ts)...") + print(" No iMessage line assigned yet — check the Photon dashboard.") + + # 6. Sidecar deps (spectrum-ts). + if args.skip_sidecar_install: + print("[5/5] Skipping sidecar npm install (--skip-sidecar-install)") + else: + print("[5/5] Installing Node sidecar deps (spectrum-ts)...") rc = _install_sidecar() if rc != 0: return rc print() print("✓ Photon setup complete.") - print(" Next: register a webhook URL Photon can reach:") - print(" hermes photon webhook register https://YOUR-PUBLIC-URL/photon/webhook") - print(" Then start the gateway:") - print(" hermes gateway start --platform photon") + print(" Start the gateway: hermes gateway start --platform photon") return 0 def _cmd_status(_args: argparse.Namespace) -> int: - # Defer the whole table to auth.print_credential_summary — its emit + # Defer the credential rows to auth.print_credential_summary — its emit # callback is the only sink that sees credential-derived strings, so # cli.py keeps zero taint flow according to CodeQL. photon_auth.print_credential_summary(print) - # The two non-credential rows live here so the helper stays purely - # about credentials. node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") sidecar_installed = (_SIDECAR_DIR / "node_modules").exists() print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}") @@ -218,8 +248,7 @@ def _cmd_status(_args: argparse.Namespace) -> int: def _cmd_install_sidecar(_args: argparse.Namespace) -> int: - rc = _install_sidecar() - return rc + return _install_sidecar() def _install_sidecar() -> int: @@ -242,64 +271,6 @@ def _install_sidecar() -> int: return proc.returncode -def _cmd_webhook(args: argparse.Namespace) -> int: - sub = getattr(args, "photon_webhook_command", None) - project_id, project_secret = photon_auth.load_project_credentials() - if not (project_id and project_secret): - print( - "no Photon project configured — run `hermes photon setup` first", - file=sys.stderr, - ) - return 1 - - if sub == "register": - try: - data = photon_auth.register_webhook( - project_id, project_secret, webhook_url=args.url - ) - except Exception as e: - print(f"register failed: {e}", file=sys.stderr) - return 1 - # The helper does all the formatting + writing; cli.py never - # touches the signing-secret value, the path it was written - # to, or even the redacted-response dict. on_summary is a - # plain printer callback. - ok = photon_auth.persist_webhook_signing_secret(data, on_summary=print) - if not ok: - print( - "‼ Photon returned no signing secret in the response, " - "or the file write failed. Inspect your home directory " - "permissions and re-run; do not retry without first " - "deleting the orphaned webhook from the Photon dashboard.", - file=sys.stderr, - ) - return 1 - return 0 - - if sub == "list": - try: - data = photon_auth.list_webhooks(project_id, project_secret) - except Exception as e: - print(f"list failed: {e}", file=sys.stderr) - return 1 - print(json.dumps(data, indent=2)) - return 0 - - if sub == "delete": - try: - photon_auth.delete_webhook( - project_id, project_secret, webhook_id=args.webhook_id - ) - except Exception as e: - print(f"delete failed: {e}", file=sys.stderr) - return 1 - print(f"deleted webhook {args.webhook_id}") - return 0 - - print(f"unknown webhook subcommand: {sub}", file=sys.stderr) - return 2 - - # --------------------------------------------------------------------------- # Gateway-setup entry point # diff --git a/plugins/platforms/photon/plugin.yaml b/plugins/platforms/photon/plugin.yaml index ebdce35ed57..21eae3cdbf2 100644 --- a/plugins/platforms/photon/plugin.yaml +++ b/plugins/platforms/photon/plugin.yaml @@ -1,52 +1,37 @@ name: photon-platform label: Photon iMessage kind: platform -version: 0.1.0 +version: 0.2.0 description: > Photon Spectrum gateway adapter for Hermes Agent. Connects to iMessage (and other Spectrum interfaces) through Photon's - managed Spectrum platform. Inbound messages arrive as signed webhooks - on a local aiohttp server; outbound messages are sent via a small - supervised Node sidecar that runs the `spectrum-ts` SDK (Photon does - not currently expose a public HTTP send endpoint). + managed Spectrum platform. Both directions run over the `spectrum-ts` + SDK's long-lived gRPC stream via a small supervised Node sidecar — + inbound messages arrive on the SDK's `app.messages` stream (no webhook, + no public URL, no signing secret), and outbound messages are sent over + the same sidecar. - The plugin ships with a `hermes photon` CLI for the one-time login - + project + user setup, persists Spectrum credentials to - ``~/.hermes/auth.json`` under ``credential_pool.photon`` (token) and - ``credential_pool.photon_project`` (project id + secret), and exposes - Photon's free shared-line model so users can get started without a - paid plan. + The plugin ships with a `hermes photon` CLI for the one-time device + login + project + user setup. Runtime credentials are written to + ``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` = the Spectrum project id, + ``PHOTON_PROJECT_SECRET``) like every other channel, with management + metadata (device token, dashboard project id) in ``~/.hermes/auth.json``. + Photon's free shared-line model lets users get started without a paid plan. author: NousResearch requires_env: - name: PHOTON_PROJECT_ID - description: "Spectrum project ID (set by `hermes photon setup`)" - prompt: "Photon Spectrum project ID" + description: "Spectrum project id (the project's spectrumProjectId; set by `hermes photon setup`)" + prompt: "Photon Spectrum project id" url: "https://app.photon.codes/" password: false - name: PHOTON_PROJECT_SECRET - description: "Spectrum project secret (set by `hermes photon setup`)" - prompt: "Photon Spectrum project secret" + description: "Project secret paired with the Spectrum project id (set by `hermes photon setup`)" + prompt: "Photon project secret" url: "https://app.photon.codes/" password: true optional_env: - - name: PHOTON_WEBHOOK_SECRET - description: "Per-URL HMAC-SHA256 signing secret returned at webhook registration" - prompt: "Photon webhook signing secret" - password: true - - name: PHOTON_WEBHOOK_PORT - description: "Local port the webhook receiver listens on (default 8788)" - prompt: "Webhook receiver port" - password: false - - name: PHOTON_WEBHOOK_PATH - description: "Path the webhook receiver listens on (default /photon/webhook)" - prompt: "Webhook receiver path" - password: false - - name: PHOTON_WEBHOOK_BIND - description: "Bind address for the webhook receiver (default 0.0.0.0)" - prompt: "Webhook bind address" - password: false - name: PHOTON_SIDECAR_PORT - description: "Loopback port for the Node sidecar control channel (default 8789)" + description: "Loopback port for the Node sidecar control + inbound channel (default 8789)" prompt: "Sidecar control port" password: false - name: PHOTON_SIDECAR_AUTOSTART @@ -57,12 +42,8 @@ optional_env: description: "Path to the node binary (default: shutil.which('node'))" prompt: "Node executable path" password: false - - name: PHOTON_API_HOST - description: "Spectrum management API host (default https://spectrum.photon.codes)" - prompt: "Spectrum API host" - password: false - name: PHOTON_DASHBOARD_HOST - description: "Dashboard API host (default https://app.photon.codes)" + description: "Photon Dashboard API host (default https://app.photon.codes)" prompt: "Dashboard host" password: false - name: PHOTON_ALLOWED_USERS @@ -82,8 +63,8 @@ optional_env: prompt: "Group mention patterns" password: false - name: PHOTON_HOME_CHANNEL - description: "Default Spectrum space ID for cron / notification delivery" - prompt: "Home space ID" + description: "Default Spectrum space id for cron / notification delivery" + prompt: "Home space id" password: false - name: PHOTON_HOME_CHANNEL_NAME description: "Human label for the home channel" diff --git a/plugins/platforms/photon/sidecar/index.mjs b/plugins/platforms/photon/sidecar/index.mjs index e41ff5cda64..917560ddbf4 100644 --- a/plugins/platforms/photon/sidecar/index.mjs +++ b/plugins/platforms/photon/sidecar/index.mjs @@ -1,40 +1,46 @@ // Hermes Agent — Photon Spectrum sidecar // -// Spawned by `plugins/platforms/photon/adapter.py` to bridge outbound -// messaging to Photon's Spectrum platform. Inbound messages go directly -// from Photon's webhook to Hermes' Python aiohttp receiver — this -// sidecar handles ONLY outbound calls (which require the spectrum-ts -// SDK because Photon has no public HTTP send endpoint today). +// Spawned by `plugins/platforms/photon/adapter.py` to bridge BOTH directions +// of messaging to Photon's Spectrum platform via the `spectrum-ts` SDK (the +// SDK is TypeScript-only, so a Node sidecar is unavoidable — there is no +// Python SDK and no public HTTP message API). // -// Protocol: -// - The sidecar listens on http://127.0.0.1:${PORT} (loopback only) -// - Each request must include `X-Hermes-Sidecar-Token: ${TOKEN}` -// - POST /healthz -> {"ok": true} -// - POST /send -> {"ok": true, "messageId": "..."} +// Inbound (gRPC -> Hermes): the SDK's `app.messages` async iterator is a +// long-lived gRPC stream. We serialize each `[space, message]` to a +// normalized JSON event and stream it to the Python adapter over a +// loopback `GET /inbound` (NDJSON). We pause pulling from the stream while +// no consumer is attached so a backlog isn't pulled-and-lost before the +// gateway connects. +// Outbound (Hermes -> gRPC): `/send` and `/typing` drive `space.send(...)` / +// `space.startTyping()` on the SDK. +// +// Protocol (all requests require `X-Hermes-Sidecar-Token: ${TOKEN}`): +// - GET /inbound -> 200 NDJSON stream; one JSON event per line, blank +// lines are heartbeats. One consumer at a time. +// - POST /healthz -> {"ok": true} +// - POST /send -> {"ok": true, "messageId": "..."} // body: {"spaceId": "...", "text": "...", "replyTo": "..." | null} -// - POST /send-attachment -> {"ok": true, "messageId": "..."} +// - POST /send-attachment -> {"ok": true, "messageId": "..."} // body: {"spaceId": "...", "path": "...", "name": "..." | null, // "mimeType": "..." | null, "caption": "..." | null, // "kind": "attachment" | "voice", "replyTo": "..." | null} -// - POST /typing -> {"ok": true} +// - POST /typing -> {"ok": true} // body: {"spaceId": "..."} -// - POST /shutdown -> {"ok": true}; then process exits +// - POST /shutdown -> {"ok": true}; then process exits // // On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before -// exiting. Errors are logged to stderr; Python supervises restart. +// exiting. Logs go to stderr; Python supervises restart. // -// Env vars (all required): -// PHOTON_PROJECT_ID +// Env vars (required): +// PHOTON_PROJECT_ID (== the project's spectrumProjectId) // PHOTON_PROJECT_SECRET // PHOTON_SIDECAR_PORT // PHOTON_SIDECAR_TOKEN -// // Optional: -// PHOTON_SIDECAR_BIND (default 127.0.0.1) -// PHOTON_API_HOST (passed through to spectrum-ts if its config -// honours it) +// PHOTON_SIDECAR_BIND (default 127.0.0.1) import http from "node:http"; +import { once } from "node:events"; const projectId = process.env.PHOTON_PROJECT_ID; const projectSecret = process.env.PHOTON_PROJECT_SECRET; @@ -71,17 +77,104 @@ const app = await Spectrum({ providers: [imessage.config()], }); -// Drain the inbound stream — Photon's webhook is the canonical inbound -// path, but we still consume `app.messages` so spectrum-ts' internal -// reconnect/heartbeat logic keeps running. Each event is logged at -// debug level; everything else is a no-op here. +// --------------------------------------------------------------------------- +// Inbound: forward `app.messages` (gRPC stream) to the Python consumer. + +// At most one Python consumer is attached at a time (the gateway adapter). +let consumerRes = null; +let consumerWaiters = []; + +function waitForConsumer() { + if (consumerRes) return Promise.resolve(); + return new Promise((resolve) => consumerWaiters.push(resolve)); +} + +function setConsumer(res) { + consumerRes = res; + const waiters = consumerWaiters; + consumerWaiters = []; + for (const resolve of waiters) resolve(); +} + +function clearConsumer(res) { + if (consumerRes === res) consumerRes = null; +} + +// Write one NDJSON line to the active consumer. Blocks until a consumer is +// connected; if the write fails (consumer vanished mid-flight) we wait for a +// new consumer and retry, so a message is never silently dropped here. +async function deliver(line) { + for (;;) { + await waitForConsumer(); + const res = consumerRes; + if (!res) continue; + try { + const flushed = res.write(line + "\n"); + if (!flushed) await once(res, "drain"); + return; + } catch { + clearConsumer(res); + } + } +} + +function normalizeContent(content) { + if (!content || typeof content !== "object") { + return { type: "unknown" }; + } + if (content.type === "text") { + return { type: "text", text: content.text || "" }; + } + if (content.type === "attachment") { + // Bytes are reachable via content.read()/stream(); we surface metadata + // here and leave byte download to a follow-up (keeps the event small). + return { + type: "attachment", + id: content.id ?? null, + name: content.name ?? null, + mimeType: content.mimeType ?? null, + size: typeof content.size === "number" ? content.size : null, + }; + } + return { type: content.type || "unknown" }; +} + +function normalizeEvent(space, message) { + try { + const msgSpace = message.space || {}; + const ts = message.timestamp; + return { + messageId: message.id ?? null, + platform: message.platform || space.__platform || "iMessage", + space: { + id: space.id ?? msgSpace.id ?? null, + // iMessage spaces carry `type` ("dm"|"group") and `phone` directly. + type: space.type ?? msgSpace.type ?? "dm", + phone: space.phone ?? msgSpace.phone ?? null, + }, + sender: { id: message.sender ? message.sender.id : null }, + content: normalizeContent(message.content), + timestamp: + ts instanceof Date ? ts.toISOString() : ts ? String(ts) : null, + }; + } catch (e) { + console.error( + "photon-sidecar: failed to normalize inbound message: " + String(e) + ); + return null; + } +} + (async () => { try { - for await (const [, message] of app.messages) { - console.error( - `photon-sidecar: drained inbound from ${message.platform} ` + - `space=${message.space?.id}` - ); + for await (const [space, message] of app.messages) { + // Only forward inbound messages (ignore our own outbound echoes). + if (message && message.direction && message.direction !== "inbound") { + continue; + } + const event = normalizeEvent(space, message); + if (!event) continue; + await deliver(JSON.stringify(event)); } } catch (e) { console.error( @@ -91,6 +184,9 @@ const app = await Spectrum({ } })(); +// --------------------------------------------------------------------------- +// HTTP control + inbound server (loopback only). + async function readBody(req) { const chunks = []; for await (const chunk of req) chunks.push(chunk); @@ -130,6 +226,39 @@ function ok(res, data) { res.end(JSON.stringify({ ok: true, ...data })); } +function handleInbound(req, res) { + res.statusCode = 200; + res.setHeader("Content-Type", "application/x-ndjson"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Connection", "keep-alive"); + // One consumer at a time — a fresh connection (e.g. after a reconnect) + // supersedes the previous one. + if (consumerRes && consumerRes !== res) { + try { + consumerRes.end(); + } catch { + /* ignore */ + } + } + setConsumer(res); + // Heartbeat keeps the socket warm through idle periods and lets the Python + // side detect a dead pipe promptly. + const heartbeat = setInterval(() => { + try { + res.write("\n"); + } catch { + /* ignore */ + } + }, 25000); + const cleanup = () => { + clearInterval(heartbeat); + clearConsumer(res); + }; + req.on("close", cleanup); + req.on("aborted", cleanup); + res.on("error", cleanup); +} + async function resolveSpace(spaceId) { // spectrum-ts exposes the same Space methods via `app.space(spaceId)` / // narrowed helpers; we fall back through a few accessor shapes to @@ -140,7 +269,6 @@ async function resolveSpace(spaceId) { if (app.spaces && typeof app.spaces.get === "function") { return await app.spaces.get(spaceId); } - // Last resort — the platform-narrowed helper. if (imessage) { const im = imessage(app); if (typeof im.space === "function") { @@ -158,6 +286,10 @@ const server = http.createServer(async (req, res) => { if (req.headers["x-hermes-sidecar-token"] !== sharedToken) { return unauthorized(res); } + // Long-lived inbound NDJSON stream. + if (req.method === "GET" && req.url === "/inbound") { + return handleInbound(req, res); + } if (req.method !== "POST") { res.statusCode = 405; return res.end(); @@ -225,7 +357,9 @@ const server = http.createServer(async (req, res) => { const { spaceId } = body || {}; if (!spaceId) return badRequest(res, "spaceId is required"); const space = await resolveSpace(spaceId); - if (typeof space.typing === "function") { + if (typeof space.startTyping === "function") { + await space.startTyping(); + } else if (typeof space.typing === "function") { await space.typing(); } else if (typeof space.setTyping === "function") { await space.setTyping(true); diff --git a/plugins/platforms/photon/sidecar/package.json b/plugins/platforms/photon/sidecar/package.json index a651d6adede..08a9f1128bc 100644 --- a/plugins/platforms/photon/sidecar/package.json +++ b/plugins/platforms/photon/sidecar/package.json @@ -1,7 +1,7 @@ { "name": "@hermes-agent/photon-sidecar", "private": true, - "version": "0.1.0", + "version": "0.2.0", "description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.", "type": "module", "main": "index.mjs", @@ -12,6 +12,6 @@ "node": ">=18.17" }, "dependencies": { - "spectrum-ts": "^0.1.0" + "spectrum-ts": "^1.17.1" } } diff --git a/tests/plugins/platforms/photon/test_auth.py b/tests/plugins/platforms/photon/test_auth.py index 35845f0c7d8..afcd515f382 100644 --- a/tests/plugins/platforms/photon/test_auth.py +++ b/tests/plugins/platforms/photon/test_auth.py @@ -1,8 +1,8 @@ -"""Tests for the Photon auth module (device login + project + user creation).""" +"""Tests for the Photon auth module (device login + dashboard API).""" from __future__ import annotations import json -import time +import os from pathlib import Path from typing import Any, Dict @@ -36,51 +36,91 @@ class _FakeResponse: raise RuntimeError(f"HTTP {self.status_code}") +_PHOTON_ENV = ( + "PHOTON_PROJECT_ID", + "PHOTON_PROJECT_SECRET", + "PHOTON_DASHBOARD_PROJECT_ID", +) + + @pytest.fixture -def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: +def tmp_hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): home = tmp_path / "hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) - # The auth module memoises by reading get_hermes_home at call time - # so the env var is what matters. - return home + for key in _PHOTON_ENV: + monkeypatch.delenv(key, raising=False) + yield home + # save_env_value() mutates os.environ directly, so scrub any leakage. + for key in _PHOTON_ENV: + os.environ.pop(key, None) +# --------------------------------------------------------------------------- +# Credential storage + def test_store_and_load_photon_token(tmp_hermes_home: Path) -> None: photon_auth.store_photon_token("abc123def456") assert photon_auth.load_photon_token() == "abc123def456" auth_json = json.loads((tmp_hermes_home / "auth.json").read_text()) - assert "credential_pool" in auth_json assert auth_json["credential_pool"]["photon"][0]["access_token"] == "abc123def456" -def test_store_and_load_project_credentials(tmp_hermes_home: Path) -> None: +def test_store_project_credentials_round_trip( + tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + # Don't touch .env / os.environ here — exercise the auth.json path. + monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None) photon_auth.store_project_credentials( - "proj-uuid", "secret-key", name="Test Project", + spectrum_project_id="sp-123", + project_secret="secret-key", + dashboard_project_id="dash-456", + name="Hermes Agent", ) - pid, secret = photon_auth.load_project_credentials() - assert pid == "proj-uuid" + for key in _PHOTON_ENV: + monkeypatch.delenv(key, raising=False) + + sid, secret = photon_auth.load_project_credentials() + assert sid == "sp-123" assert secret == "secret-key" + assert photon_auth.load_dashboard_project_id() == "dash-456" + + +def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None: + photon_auth.store_project_credentials( + spectrum_project_id="sp-789", + project_secret="sek-ret", + dashboard_project_id="dash-1", + ) + env_text = (tmp_hermes_home / ".env").read_text() + assert "PHOTON_PROJECT_ID=sp-789" in env_text + assert "PHOTON_PROJECT_SECRET=sek-ret" in env_text def test_load_project_credentials_env_override( tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - photon_auth.store_project_credentials("from-file", "secret-file") + monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None) + photon_auth.store_project_credentials( + spectrum_project_id="from-file", project_secret="secret-file", + ) monkeypatch.setenv("PHOTON_PROJECT_ID", "from-env") monkeypatch.setenv("PHOTON_PROJECT_SECRET", "secret-env") - pid, secret = photon_auth.load_project_credentials() - assert pid == "from-env" + sid, secret = photon_auth.load_project_credentials() + assert sid == "from-env" assert secret == "secret-env" -def test_request_device_code(monkeypatch: pytest.MonkeyPatch) -> None: +# --------------------------------------------------------------------------- +# Device login flow + +def test_request_device_code_uses_photon_cli(monkeypatch: pytest.MonkeyPatch) -> None: captured: Dict[str, Any] = {} - def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse: + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: captured["url"] = url - captured["body"] = json + captured["body"] = kwargs.get("json") return _FakeResponse(json_body={ "device_code": "dev-code-xyz", "user_code": "ABCD-1234", @@ -95,7 +135,6 @@ def test_request_device_code(monkeypatch: pytest.MonkeyPatch) -> None: code = photon_auth.request_device_code() assert code.device_code == "dev-code-xyz" assert code.user_code == "ABCD-1234" - assert code.expires_in == 600 assert "/api/auth/device/code" in captured["url"] # Hosted Photon allowlists registered device clients — an unregistered # client_id is rejected with 400 invalid_client. We use Photon's published @@ -104,187 +143,280 @@ def test_request_device_code(monkeypatch: pytest.MonkeyPatch) -> None: assert captured["body"]["scope"] == "openid profile email" -def test_poll_for_token_via_header(monkeypatch: pytest.MonkeyPatch) -> None: - """Token from set-auth-token header is the documented mechanism.""" - - def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse: - return _FakeResponse( - status=200, - json_body={"session": {}, "user": {}}, - headers={"set-auth-token": "bearer-xyz"}, - ) - - monkeypatch.setattr(photon_auth.httpx, "post", fake_post) - - code = photon_auth.DeviceCode( +def _device_code() -> "photon_auth.DeviceCode": + return photon_auth.DeviceCode( device_code="d", user_code="u", verification_uri="https://x", verification_uri_complete=None, expires_in=10, interval=0, ) - token = photon_auth.poll_for_token(code, interval=0, timeout=2) - assert token == "bearer-xyz" -def test_poll_for_token_via_body_fallback(monkeypatch: pytest.MonkeyPatch) -> None: - """If the header is absent we fall back to session.access_token.""" - - def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse: - return _FakeResponse( - status=200, - json_body={"session": {"access_token": "from-body"}, "user": {}}, - ) +def test_poll_for_token_body_access_token(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(status=200, json_body={"access_token": "tok-body"}) monkeypatch.setattr(photon_auth.httpx, "post", fake_post) - code = photon_auth.DeviceCode( - device_code="d", user_code="u", - verification_uri="https://x", verification_uri_complete=None, - expires_in=10, interval=0, - ) - assert photon_auth.poll_for_token(code, interval=0, timeout=2) == "from-body" + assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-body" -def test_poll_for_token_propagates_access_denied( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse: - return _FakeResponse( - status=400, json_body={"error": "access_denied"}, - ) +def test_poll_for_token_session_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(status=200, json_body={"session": {"access_token": "tok-sess"}}) + + monkeypatch.setattr(photon_auth.httpx, "post", fake_post) + assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-sess" + + +def test_poll_for_token_header_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(status=200, json_body={}, headers={"set-auth-token": "tok-hdr"}) + + monkeypatch.setattr(photon_auth.httpx, "post", fake_post) + assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) == "tok-hdr" + + +def test_poll_for_token_pending_then_success(monkeypatch: pytest.MonkeyPatch) -> None: + calls = {"n": 0} + + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + calls["n"] += 1 + if calls["n"] == 1: + return _FakeResponse(status=400, json_body={"error": "authorization_pending"}) + return _FakeResponse(status=200, json_body={"access_token": "tok-eventual"}) + + monkeypatch.setattr(photon_auth.httpx, "post", fake_post) + assert photon_auth.poll_for_token(_device_code(), interval=0, timeout=5) == "tok-eventual" + assert calls["n"] == 2 + + +def test_poll_for_token_access_denied(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(status=400, json_body={"error": "access_denied"}) monkeypatch.setattr(photon_auth.httpx, "post", fake_post) - code = photon_auth.DeviceCode( - device_code="d", user_code="u", - verification_uri="https://x", verification_uri_complete=None, - expires_in=10, interval=0, - ) with pytest.raises(RuntimeError, match="access_denied"): - photon_auth.poll_for_token(code, interval=0, timeout=2) + photon_auth.poll_for_token(_device_code(), interval=0, timeout=2) +# --------------------------------------------------------------------------- +# Projects + +def test_list_projects_unwraps_list(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(json_body=[{"id": "p1", "name": "Hermes Agent"}]) + + monkeypatch.setattr(photon_auth.httpx, "get", fake_get) + projects = photon_auth.list_projects("tok") + assert projects[0]["id"] == "p1" + + +def test_find_project_by_name_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(json_body={"data": [ + {"id": "p1", "name": "Other"}, + {"id": "p2", "name": "hermes agent"}, + ]}) + + monkeypatch.setattr(photon_auth.httpx, "get", fake_get) + proj = photon_auth.find_project_by_name("tok", "Hermes Agent") + assert proj is not None and proj["id"] == "p2" + + +def test_create_project_sends_spectrum_true(monkeypatch: pytest.MonkeyPatch) -> None: + captured: Dict[str, Any] = {} + + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + captured["url"] = url + captured["body"] = kwargs.get("json") + captured["headers"] = kwargs.get("headers") + return _FakeResponse(json_body={"success": True, "id": "new-proj"}) + + monkeypatch.setattr(photon_auth.httpx, "post", fake_post) + data = photon_auth.create_project("tok", name="Hermes Agent") + assert data["id"] == "new-proj" + assert captured["body"]["spectrum"] is True + assert captured["body"]["name"] == "Hermes Agent" + assert captured["headers"]["Authorization"] == "Bearer tok" + assert captured["url"].endswith("/api/projects") + + +def test_create_project_raises_without_id(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(json_body={"success": True}) + + monkeypatch.setattr(photon_auth.httpx, "post", fake_post) + with pytest.raises(RuntimeError, match="project id"): + photon_auth.create_project("tok") + + +def test_ensure_spectrum_enabled_toggles_when_off(monkeypatch: pytest.MonkeyPatch) -> None: + get_calls = {"n": 0} + posted = {"toggle": False} + + def fake_get(url: str, **kwargs: Any) -> _FakeResponse: + get_calls["n"] += 1 + if get_calls["n"] == 1: + return _FakeResponse(json_body={"id": "p", "spectrum": False, "spectrumProjectId": None}) + return _FakeResponse(json_body={"id": "p", "spectrum": True, "spectrumProjectId": "sp-1"}) + + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + if url.endswith("/spectrum/toggle"): + posted["toggle"] = True + return _FakeResponse(json_body={"success": True}) + + monkeypatch.setattr(photon_auth.httpx, "get", fake_get) + monkeypatch.setattr(photon_auth.httpx, "post", fake_post) + proj = photon_auth.ensure_spectrum_enabled("tok", "p") + assert posted["toggle"] is True + assert proj["spectrumProjectId"] == "sp-1" + + +def test_ensure_spectrum_enabled_skips_toggle_when_on(monkeypatch: pytest.MonkeyPatch) -> None: + posted = {"toggle": False} + + def fake_get(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(json_body={"id": "p", "spectrum": True, "spectrumProjectId": "sp-1"}) + + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + if url.endswith("/spectrum/toggle"): + posted["toggle"] = True + return _FakeResponse(json_body={"success": True}) + + monkeypatch.setattr(photon_auth.httpx, "get", fake_get) + monkeypatch.setattr(photon_auth.httpx, "post", fake_post) + proj = photon_auth.ensure_spectrum_enabled("tok", "p") + assert posted["toggle"] is False + assert proj["spectrumProjectId"] == "sp-1" + + +def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + assert url.endswith("/regenerate-secret") + return _FakeResponse(json_body={"success": True, "projectSecret": "rotated"}) + + monkeypatch.setattr(photon_auth.httpx, "post", fake_post) + assert photon_auth.regenerate_project_secret("tok", "p") == "rotated" + + +# --------------------------------------------------------------------------- +# Users + def test_create_user_rejects_invalid_phone() -> None: with pytest.raises(ValueError, match="E.164"): - photon_auth.create_user( - "proj", "secret", phone_number="not-a-number", - ) + photon_auth.create_user("tok", "proj", phone_number="not-a-number") -def test_create_user_posts_shared_type(monkeypatch: pytest.MonkeyPatch) -> None: +def test_create_user_posts_dashboard_shape(monkeypatch: pytest.MonkeyPatch) -> None: captured: Dict[str, Any] = {} - def fake_post(url: str, *, json: Dict[str, Any], auth: tuple, timeout: float) -> _FakeResponse: + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: captured["url"] = url - captured["body"] = json - captured["auth"] = auth - return _FakeResponse(json_body={ - "succeed": True, - "data": { - "id": "user-uuid", - "phoneNumber": "+15551234567", - "assignedPhoneNumber": "+15559999999", - }, - }) + captured["body"] = kwargs.get("json") + captured["headers"] = kwargs.get("headers") + return _FakeResponse(json_body={"success": True, "user": { + "id": "user-uuid", "phoneNumber": "+15551234567", + }}) monkeypatch.setattr(photon_auth.httpx, "post", fake_post) - user = photon_auth.create_user( - "proj-id", "proj-secret", - phone_number="+15551234567", - ) - assert user["assignedPhoneNumber"] == "+15559999999" - assert captured["auth"] == ("proj-id", "proj-secret") - assert captured["body"]["type"] == "shared" + user = photon_auth.create_user("tok", "proj-id", phone_number="+15551234567") + assert user["id"] == "user-uuid" assert captured["body"]["phoneNumber"] == "+15551234567" - assert "/projects/proj-id/users/" in captured["url"] + assert captured["headers"]["Authorization"] == "Bearer tok" + assert "/projects/proj-id/spectrum/users" in captured["url"] -def test_register_webhook_surfaces_secret(monkeypatch: pytest.MonkeyPatch) -> None: - def fake_post(url: str, *, json: Dict[str, Any], auth: tuple, timeout: float) -> _FakeResponse: - return _FakeResponse(json_body={ - "succeed": True, - "data": { - "id": "wh-uuid", - "webhookUrl": json["webhookUrl"], - "signingSecret": "0" * 64, - }, - }) +def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None: + posted = {"n": 0} + def fake_get(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(json_body=[{"id": "u1", "phoneNumber": "+1 (555) 123-4567"}]) + + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + posted["n"] += 1 + return _FakeResponse(json_body={"success": True, "user": {}}) + + monkeypatch.setattr(photon_auth.httpx, "get", fake_get) monkeypatch.setattr(photon_auth.httpx, "post", fake_post) - data = photon_auth.register_webhook( - "proj", "secret", webhook_url="https://x.example.com/hook", + # Same number, different formatting — should match and NOT create. + user, created = photon_auth.register_user_if_absent( + "tok", "proj", phone_number="+15551234567", ) - assert data["signingSecret"] == "0" * 64 - assert data["webhookUrl"] == "https://x.example.com/hook" + assert created is False + assert user["id"] == "u1" + assert posted["n"] == 0 -def test_persist_webhook_signing_secret_writes_env( - tmp_hermes_home: Path, -) -> None: - """The helper hands the secret to save_env_value, never returns it.""" - summary: list = [] - response = { - "id": "wh-uuid", - "webhookUrl": "https://x.example.com/hook", - "signingSecret": "ABCDEF1234567890" * 4, - } - ok = photon_auth.persist_webhook_signing_secret( - response, on_summary=summary.append, +def test_register_user_if_absent_creates(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(json_body=[]) + + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(json_body={"success": True, "user": {"id": "u-new"}}) + + monkeypatch.setattr(photon_auth.httpx, "get", fake_get) + monkeypatch.setattr(photon_auth.httpx, "post", fake_post) + user, created = photon_auth.register_user_if_absent( + "tok", "proj", phone_number="+15551234567", ) - - assert ok is True - env_path = tmp_hermes_home / ".env" - assert env_path.exists() - env_text = env_path.read_text() - assert "PHOTON_WEBHOOK_SECRET=ABCDEF1234567890" in env_text - # The on_summary callback gets the redacted response + a saved-to path; - # none of those strings should leak the raw secret. - joined = "\n".join(summary) - assert "" in joined - assert "ABCDEF1234567890" not in joined + assert created is True + assert user["id"] == "u-new" -def test_persist_webhook_signing_secret_no_secret_no_write( - tmp_hermes_home: Path, +# --------------------------------------------------------------------------- +# Lines (assigned number) + +def test_get_imessage_line_returns_existing(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(json_body=[ + {"id": "l1", "platform": "imessage", "phoneNumber": "+15559999999", "status": "active"}, + ]) + + monkeypatch.setattr(photon_auth.httpx, "get", fake_get) + line = photon_auth.get_imessage_line("tok", "proj") + assert line is not None and line["phoneNumber"] == "+15559999999" + + +def test_get_imessage_line_provisions_when_missing(monkeypatch: pytest.MonkeyPatch) -> None: + added = {"n": 0} + + def fake_get(url: str, **kwargs: Any) -> _FakeResponse: + return _FakeResponse(json_body=[]) + + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + added["n"] += 1 + assert kwargs.get("json", {}).get("platform") == "imessage" + return _FakeResponse(json_body={"success": True, "line": { + "id": "l-new", "platform": "imessage", "phoneNumber": "+15558888888", + }}) + + monkeypatch.setattr(photon_auth.httpx, "get", fake_get) + monkeypatch.setattr(photon_auth.httpx, "post", fake_post) + line = photon_auth.get_imessage_line("tok", "proj") + assert added["n"] == 1 + assert line["phoneNumber"] == "+15558888888" + + +# --------------------------------------------------------------------------- +# Credential summary (no secret leakage) + +def test_credential_summary_no_secret_leak( + tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - summary: list = [] - ok = photon_auth.persist_webhook_signing_secret( - {"id": "wh-uuid", "webhookUrl": "https://x"}, - on_summary=summary.append, - ) - assert ok is False - # No env file written; summary callback still received the redacted - # response (without a signingSecret key, nothing to redact). - assert not (tmp_hermes_home / ".env").exists() - - -def test_credential_summary_returns_only_display_strings( - tmp_hermes_home: Path, -) -> None: - """credential_summary must not leak raw token/secret material.""" + monkeypatch.setattr(photon_auth, "_persist_runtime_env", lambda *a, **k: None) photon_auth.store_photon_token("token-aaaaaaaaaaaaaaaa") - photon_auth.store_project_credentials("proj-uuid", "secret-bbbbbbbbbbb") + photon_auth.store_project_credentials( + spectrum_project_id="sp-uuid", + project_secret="secret-bbbbbbbbbbb", + dashboard_project_id="dash-uuid", + ) summary = photon_auth.credential_summary() blob = "\n".join(summary.values()) assert "token-aaaa" not in blob assert "secret-bbbb" not in blob assert summary["device_token"].startswith("✓") assert summary["project_key"].startswith("✓") - assert summary["project_id"] == "proj-uuid" - - -def test_print_credential_summary_emits_only_display_strings( - tmp_hermes_home: Path, -) -> None: - """The emit callback must never receive raw credential bytes.""" - photon_auth.store_photon_token("token-aaaaaaaaaaaaaaaa") - photon_auth.store_project_credentials("proj-uuid", "secret-bbbbbbbbbbb") - lines: list = [] - photon_auth.print_credential_summary(lines.append) - blob = "\n".join(lines) - assert "token-aaaa" not in blob - assert "secret-bbbb" not in blob - assert "✓ stored" in blob # device token line - assert "proj-uuid" in blob # project id is intentionally surfaced - # Header is always emitted - assert any("Photon iMessage status" in line for line in lines) + assert summary["spectrum_project_id"] == "sp-uuid" + assert summary["dashboard_project_id"] == "dash-uuid" # --------------------------------------------------------------------------- diff --git a/tests/plugins/platforms/photon/test_inbound.py b/tests/plugins/platforms/photon/test_inbound.py index 00ddcfe4620..656e91fc630 100644 --- a/tests/plugins/platforms/photon/test_inbound.py +++ b/tests/plugins/platforms/photon/test_inbound.py @@ -1,12 +1,13 @@ """Inbound dispatch + dedup tests for PhotonAdapter. -These tests bypass the aiohttp server — they call ``_dispatch_inbound`` -and ``_is_duplicate`` directly. That keeps them fast and means we can -exercise the message-shape parsing logic without binding ports. +These bypass the loopback HTTP stream — they call ``_dispatch_inbound`` / +``_on_inbound_line`` / ``_is_duplicate`` directly, exercising the +sidecar-event parsing without spawning the Node sidecar or binding ports. """ from __future__ import annotations -from typing import List +import json +from typing import Any, Dict, List import pytest @@ -16,38 +17,39 @@ from plugins.platforms.photon.adapter import PhotonAdapter def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter: - # Avoid touching real auth.json / env. monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id") monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret") - monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False) cfg = PlatformConfig(enabled=True, token="", extra={}) return PhotonAdapter(cfg) -@pytest.mark.asyncio -async def test_dispatch_text_dm(monkeypatch: pytest.MonkeyPatch) -> None: - adapter = _make_adapter(monkeypatch) +def _capture(adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch) -> List[MessageEvent]: captured: List[MessageEvent] = [] async def fake_handle(event: MessageEvent) -> None: captured.append(event) monkeypatch.setattr(adapter, "handle_message", fake_handle) + return captured - payload = { - "event": "messages", - "space": {"id": "any;-;+15551234567", "platform": "iMessage"}, - "message": { - "id": "spc-msg-abc", - "platform": "iMessage", - "direction": "inbound", - "timestamp": "2026-05-14T19:06:32.000Z", - "sender": {"id": "+15551234567", "platform": "iMessage"}, - "space": {"id": "any;-;+15551234567", "platform": "iMessage"}, - "content": {"type": "text", "text": "hello world"}, - }, + +def _dm_event(text: str, msg_id: str = "spc-msg-abc") -> Dict[str, Any]: + return { + "messageId": msg_id, + "platform": "iMessage", + "space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"}, + "sender": {"id": "+15551234567"}, + "content": {"type": "text", "text": text}, + "timestamp": "2026-05-14T19:06:32.000Z", } - await adapter._dispatch_inbound(payload) + + +@pytest.mark.asyncio +async def test_dispatch_text_dm(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture(adapter, monkeypatch) + + await adapter._dispatch_inbound(_dm_event("hello world")) assert len(captured) == 1 event = captured[0] @@ -57,70 +59,73 @@ async def test_dispatch_text_dm(monkeypatch: pytest.MonkeyPatch) -> None: src = event.source assert src is not None assert src.platform == Platform("photon") - assert src.chat_id == "any;-;+15551234567" + assert src.chat_id == "+15551234567" assert src.chat_type == "dm" assert src.user_id == "+15551234567" @pytest.mark.asyncio -async def test_dispatch_group_id_detected(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_dispatch_group_type(monkeypatch: pytest.MonkeyPatch) -> None: adapter = _make_adapter(monkeypatch) - captured: List[MessageEvent] = [] + captured = _capture(adapter, monkeypatch) - async def fake_handle(event: MessageEvent) -> None: - captured.append(event) - - monkeypatch.setattr(adapter, "handle_message", fake_handle) - - payload = { - "event": "messages", - "space": {"id": "any;+;group-guid-xyz", "platform": "iMessage"}, - "message": { - "id": "spc-msg-grp", - "timestamp": "2026-05-14T19:06:32.000Z", - "sender": {"id": "+15551234567"}, - "space": {"id": "any;+;group-guid-xyz"}, - "content": {"type": "text", "text": "hi group"}, - }, + event = { + "messageId": "spc-msg-grp", + "space": {"id": "group-guid-xyz", "type": "group", "phone": None}, + "sender": {"id": "+15551234567"}, + "content": {"type": "text", "text": "hi group"}, + "timestamp": "2026-05-14T19:06:32.000Z", } - await adapter._dispatch_inbound(payload) + await adapter._dispatch_inbound(event) assert captured[0].source.chat_type == "group" @pytest.mark.asyncio -async def test_dispatch_attachment_surfaces_marker( +async def test_dispatch_attachment_surfaces_marker(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture(adapter, monkeypatch) + + event = { + "messageId": "spc-msg-att", + "space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"}, + "sender": {"id": "+15551234567"}, + "content": { + "type": "attachment", + "name": "IMG_4127.HEIC", + "mimeType": "image/heic", + "size": 12345, + }, + "timestamp": "2026-05-14T19:06:32.000Z", + } + await adapter._dispatch_inbound(event) + assert len(captured) == 1 + assert "Photon attachment received" in captured[0].text + assert "IMG_4127.HEIC" in captured[0].text + assert captured[0].message_type == MessageType.PHOTO + + +@pytest.mark.asyncio +async def test_on_inbound_line_dispatches_and_dedups( monkeypatch: pytest.MonkeyPatch, ) -> None: adapter = _make_adapter(monkeypatch) - captured: List[MessageEvent] = [] + captured = _capture(adapter, monkeypatch) - async def fake_handle(event: MessageEvent) -> None: - captured.append(event) + line = json.dumps(_dm_event("ping", msg_id="dup-1")) + await adapter._on_inbound_line(line) + await adapter._on_inbound_line(line) # same messageId -> deduped - monkeypatch.setattr(adapter, "handle_message", fake_handle) - - payload = { - "event": "messages", - "message": { - "id": "spc-msg-att", - "timestamp": "2026-05-14T19:06:32.000Z", - "sender": {"id": "+15551234567"}, - "space": {"id": "any;-;+15551234567"}, - "content": { - "type": "attachment", - "name": "IMG_4127.HEIC", - "mimeType": "image/heic", - "size": 12345, - }, - }, - } - await adapter._dispatch_inbound(payload) assert len(captured) == 1 - event = captured[0] - # Attachment carries metadata marker; mime → MessageType.PHOTO. - assert "Photon attachment received" in event.text - assert "IMG_4127.HEIC" in event.text - assert event.message_type == MessageType.PHOTO + assert captured[0].text == "ping" + + +@pytest.mark.asyncio +async def test_on_inbound_line_ignores_bad_json(monkeypatch: pytest.MonkeyPatch) -> None: + adapter = _make_adapter(monkeypatch) + captured = _capture(adapter, monkeypatch) + + await adapter._on_inbound_line("{not json") + assert captured == [] def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/plugins/platforms/photon/test_mention_gating.py b/tests/plugins/platforms/photon/test_mention_gating.py index 3eaf6de22a0..2cd23f636c6 100644 --- a/tests/plugins/platforms/photon/test_mention_gating.py +++ b/tests/plugins/platforms/photon/test_mention_gating.py @@ -22,7 +22,6 @@ from plugins.platforms.photon.adapter import PhotonAdapter def _make_adapter(monkeypatch: pytest.MonkeyPatch, extra: dict | None = None) -> PhotonAdapter: monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id") monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret") - monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False) monkeypatch.delenv("PHOTON_REQUIRE_MENTION", raising=False) monkeypatch.delenv("PHOTON_MENTION_PATTERNS", raising=False) cfg = PlatformConfig(enabled=True, token="", extra=extra or {}) @@ -31,27 +30,21 @@ def _make_adapter(monkeypatch: pytest.MonkeyPatch, extra: dict | None = None) -> def _group_payload(text: str) -> dict: return { - "event": "messages", - "message": { - "id": f"grp-{abs(hash(text))}", - "timestamp": "2026-05-14T19:06:32.000Z", - "sender": {"id": "+15551234567"}, - "space": {"id": "any;+;group-guid-xyz"}, - "content": {"type": "text", "text": text}, - }, + "messageId": f"grp-{abs(hash(text))}", + "space": {"id": "group-guid-xyz", "type": "group", "phone": None}, + "sender": {"id": "+15551234567"}, + "content": {"type": "text", "text": text}, + "timestamp": "2026-05-14T19:06:32.000Z", } def _dm_payload(text: str) -> dict: return { - "event": "messages", - "message": { - "id": f"dm-{abs(hash(text))}", - "timestamp": "2026-05-14T19:06:32.000Z", - "sender": {"id": "+15551234567"}, - "space": {"id": "any;-;+15551234567"}, - "content": {"type": "text", "text": text}, - }, + "messageId": f"dm-{abs(hash(text))}", + "space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"}, + "sender": {"id": "+15551234567"}, + "content": {"type": "text", "text": text}, + "timestamp": "2026-05-14T19:06:32.000Z", } @@ -126,7 +119,6 @@ def test_custom_mention_patterns_from_config(monkeypatch: pytest.MonkeyPatch) -> def test_mention_patterns_env_comma_separated(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id") monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret") - monkeypatch.delenv("PHOTON_WEBHOOK_SECRET", raising=False) monkeypatch.setenv("PHOTON_REQUIRE_MENTION", "true") monkeypatch.setenv("PHOTON_MENTION_PATTERNS", r"bot\b, assistant\b") cfg = PlatformConfig(enabled=True, token="", extra={}) diff --git a/tests/plugins/platforms/photon/test_signature.py b/tests/plugins/platforms/photon/test_signature.py deleted file mode 100644 index 6f5ec734986..00000000000 --- a/tests/plugins/platforms/photon/test_signature.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Signature verification tests for the Photon webhook receiver.""" -from __future__ import annotations - -import hashlib -import hmac -import time - -import pytest - -from plugins.platforms.photon.adapter import verify_signature - - -def _sign(secret: str, body: bytes, ts: int) -> str: - return "v0=" + hmac.new( - secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256, - ).hexdigest() - - -def test_accepts_valid_signature() -> None: - secret = "topsecret-32chars-or-whatever" - body = b'{"event":"messages"}' - ts = int(time.time()) - sig = _sign(secret, body, ts) - assert verify_signature( - body=body, timestamp_header=str(ts), signature_header=sig, - signing_secret=secret, - ) - - -def test_rejects_tampered_body() -> None: - secret = "s" - body = b'{"event":"messages"}' - ts = int(time.time()) - sig = _sign(secret, body, ts) - assert not verify_signature( - body=body + b" tamper", timestamp_header=str(ts), - signature_header=sig, signing_secret=secret, - ) - - -def test_rejects_wrong_secret() -> None: - body = b"x" - ts = int(time.time()) - sig = _sign("right", body, ts) - assert not verify_signature( - body=body, timestamp_header=str(ts), signature_header=sig, - signing_secret="wrong", - ) - - -def test_rejects_drifted_timestamp() -> None: - secret = "s" - body = b"x" - ts = int(time.time()) - 3600 # 1h old; drift window is 5 min - sig = _sign(secret, body, ts) - assert not verify_signature( - body=body, timestamp_header=str(ts), signature_header=sig, - signing_secret=secret, - ) - - -def test_rejects_missing_v0_prefix() -> None: - secret = "s" - body = b"x" - ts = int(time.time()) - raw_hex = hmac.new( - secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256, - ).hexdigest() - # Strip the "v0=" prefix — verify_signature must reject. - assert not verify_signature( - body=body, timestamp_header=str(ts), signature_header=raw_hex, - signing_secret=secret, - ) - - -def test_rejects_empty_inputs() -> None: - assert not verify_signature( - body=b"x", timestamp_header="", signature_header="v0=abc", - signing_secret="s", - ) - assert not verify_signature( - body=b"x", timestamp_header="123", signature_header="", - signing_secret="s", - ) - assert not verify_signature( - body=b"x", timestamp_header="123", signature_header="v0=abc", - signing_secret="", - ) - - -def test_rejects_non_integer_timestamp() -> None: - assert not verify_signature( - body=b"x", timestamp_header="not-an-int", - signature_header="v0=abc", signing_secret="s", - ) diff --git a/website/docs/user-guide/messaging/photon.md b/website/docs/user-guide/messaging/photon.md index ce325e52cb4..d31d46eb7da 100644 --- a/website/docs/user-guide/messaging/photon.md +++ b/website/docs/user-guide/messaging/photon.md @@ -22,26 +22,30 @@ your account. ## Architecture -Inbound messages arrive as **signed webhooks**: Photon POSTs JSON with -an `X-Spectrum-Signature` header to a URL you register, and Hermes' -aiohttp listener verifies the HMAC-SHA256 signature before dispatching -the event into the agent. +Photon is a **persistent-connection** channel, like Discord or Slack — +**no webhook, no public URL, no signing secret to manage.** -Outbound replies go through a small supervised **Node sidecar** that -runs the `spectrum-ts` SDK on loopback. Photon does not currently -expose a public HTTP send-message endpoint — that's a roadmap item on -their side — so until then the sidecar is the only way to call -`Space.send(...)`. The Python plugin starts, supervises, and shuts -down the sidecar automatically. When Photon ships an HTTP send -endpoint we'll retire the sidecar in a follow-up release. +The `spectrum-ts` SDK holds a long-lived **gRPC stream** to Photon for +both directions. Because the SDK is TypeScript-only, Hermes runs it in a +small supervised **Node sidecar** and talks to it over loopback: + +- **Inbound** — the sidecar consumes the SDK's `app.messages` gRPC + stream and forwards each message to the Python adapter over a loopback + `GET /inbound` (NDJSON). The adapter dedupes and dispatches it to the + agent, reconnecting automatically if the stream drops. +- **Outbound** — replies are loopback POSTs to the sidecar, which calls + `space.send(...)` on the SDK. + +The Python plugin starts, supervises, and shuts down the sidecar +automatically. ## Prerequisites - A Photon account — sign up at [app.photon.codes][app] - **Node.js 18.17 or newer** on PATH (`node --version`) - A phone number that can receive iMessage (used to bind your account) -- A publicly reachable URL for the webhook receiver — Cloudflare - Tunnel, ngrok, or your own gateway hostname all work + +That's it — there is no public URL or tunnel to set up. ## First-time setup @@ -58,17 +62,24 @@ hermes gateway setup hermes photon setup --phone +15551234567 ``` -The setup: +The setup, in order: -1. Opens `https://app.photon.codes/` for device approval -2. Creates a Spectrum-enabled project under your account -3. Calls the Spectrum `create-user` endpoint with `type: shared` so - Photon allocates an iMessage line from the free pool -4. Runs `npm install` inside the plugin's sidecar directory +1. **Device login** (`client_id=photon-cli`) — opens + `https://app.photon.codes/` for approval and stores the bearer token. +2. **Finds or creates** the `Hermes Agent` project on your account. +3. **Enables Spectrum**, reads the project's Spectrum id, and rotates + the project secret. +4. **Registers your phone number** as a Spectrum user — skipped if a + user with that number already exists, so re-running is safe. +5. **Prints your assigned iMessage line** — the number you text to reach + your agent. +6. **Runs `npm install`** inside the plugin's sidecar directory. -Credentials are stored in `~/.hermes/auth.json` under -`credential_pool.photon` (bearer token) and -`credential_pool.photon_project` (project id + secret). +Runtime credentials are written to `~/.hermes/.env` +(`PHOTON_PROJECT_ID` = the Spectrum project id, `PHOTON_PROJECT_SECRET`), +the same place every other channel keeps its token. Management metadata +(device token, dashboard project id) lives in `~/.hermes/auth.json` under +`credential_pool.photon` / `credential_pool.photon_project`. ## Authorizing users @@ -131,26 +142,6 @@ Both keys also accept env vars (`PHOTON_REQUIRE_MENTION`, `PHOTON_MENTION_PATTERNS`). This is the same mention-gating model the BlueBubbles iMessage channel uses. -## Registering the webhook - -Photon needs a public URL it can POST to. Expose your local listener -(default port 8788, path `/photon/webhook`) via Cloudflare Tunnel or -ngrok, then: - -```bash -hermes photon webhook register https://YOUR-PUBLIC-URL/photon/webhook -``` - -The response includes a `signingSecret` — **Photon only returns it -once.** Save it to `~/.hermes/.env`: - -```bash -PHOTON_WEBHOOK_SECRET=v0_64-char-hex... -``` - -The plugin verifies every inbound `POST` against this secret and -rejects deliveries with a timestamp drift greater than 5 minutes. - ## Start the gateway ```bash @@ -160,7 +151,7 @@ hermes gateway start --platform photon You'll see something like: ``` -[photon] connected — webhook at 0.0.0.0:8788/photon/webhook, sidecar on 127.0.0.1:8789 +[photon] connected — sidecar on 127.0.0.1:8789, streaming inbound over gRPC ``` Send an iMessage to your assigned number and Hermes will reply. @@ -177,9 +168,9 @@ Prints: Photon iMessage status ────────────────────── device token : ✓ stored - project id : 3c90c3cc-0d44-4b50-... - project key : ✓ stored - webhook key : ✓ set + dashboard project : 3c90c3cc-0d44-4b50-... + spectrum project id : sp-... + project secret : ✓ stored node binary : /usr/bin/node sidecar deps : ✓ installed ``` @@ -188,27 +179,19 @@ Common issues: - **`sidecar deps : ✗ run hermes photon install-sidecar`** — Node is installed but `spectrum-ts` isn't. Run the suggested command. -- **`webhook key : ⚠ unset — verification disabled`** — the - plugin will accept ANY POST to the webhook URL, which is unsafe. - Re-run `hermes photon webhook register` and store the secret. -- **`PHOTON_WEBHOOK_PORT` already in use** — set a different port via - `~/.hermes/.env`. -- **Webhook reachable from localhost but Photon can't deliver** — - Photon needs a public hostname. Cloudflare Tunnel is the easiest - free option. - -## Webhook management - -```bash -hermes photon webhook list # show registered hooks -hermes photon webhook delete # remove one -``` +- **`device token : ✗ missing`** — run `hermes photon setup` to log in. +- **`No iMessage line assigned yet`** — Spectrum is enabled but no line + has been provisioned; re-run `hermes photon setup` or check the + [dashboard][app]. +- **Sidecar won't start** — confirm `node --version` is 18.17+ and that + `hermes photon install-sidecar` completed without errors. ## Limits today -- **Inbound attachments are metadata-only.** Inbound webhooks carry the - filename + MIME type but no download URL — Photon documents an - attachment retrieval endpoint as roadmap. +- **Inbound attachments are metadata-only.** Inbound events carry the + filename + MIME type; the agent sees a marker but can't yet read the + bytes. The SDK exposes attachment bytes via `content.read()`, so this + is a sidecar follow-up. - **Outbound attachments are supported.** Hermes sends images, voice notes, video, and documents through spectrum-ts' `attachment()` / `voice()` content builders via the sidecar's `/send-attachment` @@ -222,22 +205,17 @@ hermes photon webhook delete # remove one | Variable | Default | Notes | |---------------------------|--------------------|--------------------------------------------| -| `PHOTON_PROJECT_ID` | from `auth.json` | Set by `hermes photon setup` | -| `PHOTON_PROJECT_SECRET` | from `auth.json` | Set by `hermes photon setup` | -| `PHOTON_WEBHOOK_SECRET` | (unset) | From `hermes photon webhook register` | -| `PHOTON_WEBHOOK_PORT` | `8788` | Local port for the aiohttp listener | -| `PHOTON_WEBHOOK_PATH` | `/photon/webhook` | Path under which the listener mounts | -| `PHOTON_WEBHOOK_BIND` | `0.0.0.0` | Bind address for the listener | -| `PHOTON_SIDECAR_PORT` | `8789` | Loopback port for sidecar control | +| `PHOTON_PROJECT_ID` | from `.env` | Spectrum project id (the SDK's `projectId`); set by setup | +| `PHOTON_PROJECT_SECRET` | from `.env` | Project secret; set by setup | +| `PHOTON_SIDECAR_PORT` | `8789` | Loopback port for the sidecar control + inbound channel | | `PHOTON_SIDECAR_AUTOSTART`| `true` | Whether the adapter spawns the sidecar | | `PHOTON_NODE_BIN` | `which node` | Override the Node binary path | -| `PHOTON_HOME_CHANNEL` | (unset) | Default space ID for cron / notifications | +| `PHOTON_HOME_CHANNEL` | (unset) | Default space id for cron / notifications | | `PHOTON_HOME_CHANNEL_NAME`| (unset) | Human label for the home channel | | `PHOTON_ALLOWED_USERS` | (unset) | Comma-separated E.164 allowlist | | `PHOTON_ALLOW_ALL_USERS` | `false` | Dev only — accept any sender | | `PHOTON_REQUIRE_MENTION` | `false` | Require a wake word before responding in groups | | `PHOTON_MENTION_PATTERNS` | Hermes wake words | JSON list / comma / newline regex patterns for group mentions | -| `PHOTON_API_HOST` | `spectrum.photon.codes` | Override the Spectrum management API host | | `PHOTON_DASHBOARD_HOST` | `app.photon.codes` | Override the dashboard / device-login host | [photon]: https://photon.codes/