feat(photon): persist and display user phone numbers in status

Store operator and assigned iMessage numbers in `auth.json` after
setup, and surface them in `hermes photon status`. When numbers are
missing, status auto-refreshes from the dashboard without provisioning
new lines.
This commit is contained in:
underthestars-zhy 2026-06-08 20:57:12 -07:00 committed by Teknium
parent 2130ef68b3
commit b58ff93459
4 changed files with 230 additions and 3 deletions

View file

@ -27,8 +27,9 @@ 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)
``credential_pool.photon`` (device token),
``credential_pool.photon_project`` (dashboard id, spectrum id, name), and
``credential_pool.photon_user`` (operator number + assigned text line)
Reference: https://github.com/photon-hq/cli and
https://photon.codes/docs/api-reference/device-login/request-device-+-user-code
@ -205,6 +206,30 @@ def store_project_credentials(
_persist_runtime_env(spectrum_project_id, project_secret)
def store_user_numbers(
*,
phone_number: Optional[str] = None,
assigned_phone_number: Optional[str] = None,
user_id: Optional[str] = None,
dashboard_project_id: Optional[str] = None,
) -> None:
"""Persist non-secret Photon user numbers for offline ``status`` output."""
if not phone_number and not assigned_phone_number:
return
auth = _load_auth()
record: Dict[str, Any] = {"issued_at": int(time.time())}
if phone_number:
record["phone_number"] = phone_number
if assigned_phone_number:
record["assigned_phone_number"] = assigned_phone_number
if user_id:
record["user_id"] = user_id
if dashboard_project_id:
record["dashboard_project_id"] = dashboard_project_id
auth.setdefault("credential_pool", {})["photon_user"] = [record]
_save_auth(auth)
def _persist_runtime_env(spectrum_project_id: str, project_secret: str) -> None:
"""Write the SDK creds to ``~/.hermes/.env`` (canonical runtime store).
@ -766,6 +791,95 @@ def user_assigned_line(user: Optional[Dict[str, Any]]) -> Optional[str]:
return str(val) if val else None
def load_user_numbers() -> Tuple[Optional[str], Optional[str]]:
"""Return ``(operator_phone_number, assigned_phone_number)`` for status."""
auth = _load_auth()
user_entries = auth.get("credential_pool", {}).get("photon_user") or []
if isinstance(user_entries, list) and user_entries:
entry = user_entries[0] or {}
if isinstance(entry, dict):
phone = entry.get("phone_number") or entry.get("phoneNumber")
assigned = (
entry.get("assigned_phone_number")
or entry.get("assignedPhoneNumber")
)
if phone or assigned:
return (
str(phone) if phone else _configured_operator_phone(),
str(assigned) if assigned else None,
)
return _configured_operator_phone(), None
def refresh_user_numbers(
token: str, project_id: str,
) -> Tuple[Optional[str], Optional[str]]:
"""Refresh cached user numbers from Photon without provisioning anything."""
phone, cached_assigned = load_user_numbers()
user: Optional[Dict[str, Any]] = None
if phone:
user = find_user_by_phone(token, project_id, phone)
else:
users = list_users(token, project_id)
if len(users) == 1:
user = users[0]
user_id = None
assigned: Optional[str] = cached_assigned
if user:
user_id = user.get("id")
dashboard_phone = _normalize_phone(str(user.get("phoneNumber") or ""))
if E164_RE.match(dashboard_phone):
phone = dashboard_phone
assigned = user_assigned_line(user)
if not assigned:
try:
line = get_imessage_line(token, project_id, create_if_missing=False)
except Exception as e:
logger.debug("photon: could not refresh iMessage line for status: %s", e)
else:
if line and line.get("phoneNumber"):
assigned = str(line["phoneNumber"])
store_user_numbers(
phone_number=phone,
assigned_phone_number=assigned,
user_id=str(user_id) if user_id else None,
dashboard_project_id=project_id,
)
return phone, assigned
def _configured_operator_phone() -> Optional[str]:
"""Infer the operator's E.164 number from existing Photon env settings."""
home = _get_config_env_value("PHOTON_HOME_CHANNEL")
if home:
normalized = _normalize_phone(home)
if E164_RE.match(normalized):
return normalized
allowed = _get_config_env_value("PHOTON_ALLOWED_USERS")
if not allowed:
return None
candidates = []
for part in re.split(r"[,\s]+", allowed):
normalized = _normalize_phone(part)
if E164_RE.match(normalized):
candidates.append(normalized)
if len(candidates) == 1:
return candidates[0]
return None
def _get_config_env_value(key: str) -> Optional[str]:
try:
from hermes_cli.config import get_env_value
except Exception:
return os.getenv(key)
return get_env_value(key)
# ---------------------------------------------------------------------------
# Dashboard API: iMessage lines (the assigned number inventory)
@ -836,6 +950,13 @@ def print_credential_summary(emit: Any = print) -> None:
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"
phone, assigned = load_user_numbers()
labels["phone_number"] = (
phone if phone else "✗ missing (run `hermes photon setup --phone ...`)"
)
labels["assigned_phone_number"] = (
assigned if assigned else "✗ missing (run `hermes photon setup`)"
)
rows = [
"Photon iMessage status",
@ -844,6 +965,8 @@ def print_credential_summary(emit: Any = print) -> None:
" dashboard project : " + labels["dashboard_project_id"],
" spectrum project id : " + labels["spectrum_project_id"],
" project secret : " + labels["project_key"],
" my number : " + labels["phone_number"],
" assigned number : " + labels["assigned_phone_number"],
]
emit("\n".join(rows))
@ -864,9 +987,19 @@ def credential_summary() -> Dict[str, str]:
_sid, sec = load_project_credentials()
return "✓ stored" if sec else "✗ missing"
def _present_phone() -> str:
phone, _assigned = load_user_numbers()
return phone or "✗ missing (run `hermes photon setup --phone ...`)"
def _present_assigned_phone() -> str:
_phone, assigned = load_user_numbers()
return assigned or "✗ missing (run `hermes photon setup`)"
return {
"device_token": _present_token(),
"dashboard_project_id": load_dashboard_project_id() or "",
"spectrum_project_id": _present_spectrum_id(),
"project_key": _present_secret(),
"phone_number": _present_phone(),
"assigned_phone_number": _present_assigned_phone(),
}

View file

@ -183,6 +183,8 @@ def _cmd_setup(args: argparse.Namespace) -> int:
)
)
agent_number = None
registered_phone = None
registered_user_id = None
if not phone:
print(" Skipped user registration (no phone given). Re-run with --phone later.")
else:
@ -205,6 +207,8 @@ def _cmd_setup(args: argparse.Namespace) -> int:
print(f" user registration failed: {e}", file=sys.stderr)
return 1
print(" ✓ phone registered" if created else " ✓ phone already registered")
registered_phone = phone
registered_user_id = user.get("id")
# The number to text the agent is the user's assigned iMessage line
# (the dashboard's "TEXTS ON" column). On shared-number plans there is
# no dedicated entry in /lines, so this per-user field is the source of
@ -236,6 +240,16 @@ def _cmd_setup(args: argparse.Namespace) -> int:
print(color("└──────────────────────────────────────────────────────────────", Colors.GREEN))
else:
print(" No iMessage line assigned yet — check the Photon dashboard.")
if registered_phone:
try:
photon_auth.store_user_numbers(
phone_number=registered_phone,
assigned_phone_number=agent_number,
user_id=str(registered_user_id) if registered_user_id else None,
dashboard_project_id=dashboard_id,
)
except Exception as e:
print(f" (could not save Photon status metadata: {e})", file=sys.stderr)
# 6. Sidecar deps (spectrum-ts).
if args.skip_sidecar_install:
@ -280,6 +294,7 @@ def _autoconfigure_access(phone: str) -> None:
def _cmd_status(_args: argparse.Namespace) -> int:
_refresh_status_numbers()
# 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.
@ -291,6 +306,20 @@ def _cmd_status(_args: argparse.Namespace) -> int:
return 0
def _refresh_status_numbers() -> None:
phone, assigned = photon_auth.load_user_numbers()
if phone and assigned:
return
token = photon_auth.load_photon_token()
dashboard_id = photon_auth.load_dashboard_project_id()
if not token or not dashboard_id:
return
try:
photon_auth.refresh_user_numbers(token, dashboard_id)
except Exception as e:
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
return _install_sidecar()

View file

@ -40,6 +40,8 @@ _PHOTON_ENV = (
"PHOTON_PROJECT_ID",
"PHOTON_PROJECT_SECRET",
"PHOTON_DASHBOARD_PROJECT_ID",
"PHOTON_ALLOWED_USERS",
"PHOTON_HOME_CHANNEL",
)
@ -98,6 +100,62 @@ def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None:
assert "PHOTON_PROJECT_SECRET=sek-ret" in env_text
def test_store_user_numbers_round_trip(tmp_hermes_home: Path) -> None:
photon_auth.store_user_numbers(
phone_number="+15551234567",
assigned_phone_number="+16282679185",
user_id="user-uuid",
dashboard_project_id="dash-uuid",
)
phone, assigned = photon_auth.load_user_numbers()
assert phone == "+15551234567"
assert assigned == "+16282679185"
summary = photon_auth.credential_summary()
assert summary["phone_number"] == "+15551234567"
assert summary["assigned_phone_number"] == "+16282679185"
rendered: list[str] = []
photon_auth.print_credential_summary(rendered.append)
assert " my number : +15551234567" in rendered[0]
assert " assigned number : +16282679185" in rendered[0]
def test_load_user_numbers_falls_back_to_home_channel(
tmp_hermes_home: Path,
) -> None:
from hermes_cli.config import save_env_value
save_env_value("PHOTON_HOME_CHANNEL", "+15551234567")
phone, assigned = photon_auth.load_user_numbers()
assert phone == "+15551234567"
assert assigned is None
def test_refresh_user_numbers_reads_existing_assignment(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
photon_auth.store_user_numbers(phone_number="+15551234567")
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
assert kwargs.get("headers", {}).get("Authorization") == "Bearer tok"
assert url.endswith("/projects/dash/spectrum/users")
return _FakeResponse(json_body=[{
"id": "user-uuid",
"phoneNumber": "+1 (555) 123-4567",
"assignedPhoneNumber": "+16282679185",
}])
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
phone, assigned = photon_auth.refresh_user_numbers("tok", "dash")
assert phone == "+15551234567"
assert assigned == "+16282679185"
assert photon_auth.load_user_numbers() == ("+15551234567", "+16282679185")
def test_load_project_credentials_env_override(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -435,6 +493,8 @@ def test_credential_summary_no_secret_leak(
assert summary["project_key"].startswith("")
assert summary["spectrum_project_id"] == "sp-uuid"
assert summary["dashboard_project_id"] == "dash-uuid"
assert summary["phone_number"].startswith("✗ missing")
assert summary["assigned_phone_number"].startswith("✗ missing")
# ---------------------------------------------------------------------------

View file

@ -162,7 +162,10 @@ Send an iMessage to your assigned number and Hermes will reply.
hermes photon status
```
Prints:
Prints saved credentials, sidecar health, your registered number, and the
assigned iMessage line Hermes uses. When a Photon token and dashboard project
are available, `status` refreshes missing number rows from the dashboard
without provisioning new lines.
```
Photon iMessage status
@ -171,6 +174,8 @@ Photon iMessage status
dashboard project : 3c90c3cc-0d44-4b50-...
spectrum project id : sp-...
project secret : ✓ stored
my number : +15551234567
assigned number : +16282679185
node binary : /usr/bin/node
sidecar deps : ✓ installed
```