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) <noreply@anthropic.com>
This commit is contained in:
underthestars-zhy 2026-06-08 16:03:51 -07:00 committed by Teknium
parent c3420d91ad
commit 4e4d27875f
12 changed files with 1323 additions and 1176 deletions

View file

@ -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=<spectrumProjectId> # the SDK's projectId
PHOTON_PROJECT_SECRET=<projectSecret>
```
Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
```jsonc
{
"credential_pool": {
"photon": [
{ "access_token": "<dashboard-bearer>", "issued_at": ... }
{ "access_token": "<device-bearer>", "issued_at": ... }
],
"photon_project": [
{ "project_id": "...", "project_secret": "...", "name": "Hermes Agent" }
{
"dashboard_project_id": "<dashboard id>",
"spectrum_project_id": "<spectrumProjectId>",
"project_secret": "<projectSecret>",
"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/

View file

@ -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;+;<chat-guid>`. 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;+;<guid>` 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."
),
)

View file

@ -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: ("<redacted>" 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()

View file

@ -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
#

View file

@ -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"

View file

@ -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);

View file

@ -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"
}
}

View file

@ -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 "<redacted>" 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"
# ---------------------------------------------------------------------------

View file

@ -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:

View file

@ -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={})

View file

@ -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",
)

View file

@ -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 <webhook-id> # 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 <webhook-id> # 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/