Files
hermes-agent/tests
Ben Barclay f97a4102dd fix(relay): accept a provision response that issues no secret (lockstep with gateway-gateway#223) (#104767)
* fix(relay): accept a provision response that issues no secret

Lockstep with gateway-gateway #223 (finding F-004). The connector used to return
the stored per-gateway secret from POST /relay/provision on EVERY replay, to
anyone reaching the endpoint with the right gatewayId. It now returns credential
material only to a caller that proves recorded ownership or possession, and
answers secretIssued:false otherwise with secret/deliveryKey ABSENT. This
gateway treats that as a hard failure, so #223 cannot merge until this ships.

1. A secret-less response is a VALUE, not an error.

_post_provision() raised on any response without a secret. Two legitimate
responses hit that raise once #223 lands:
  - every platform after the first in a multi-platform boot. All platforms share
    ONE gatewayId, so the first POST mints the secret and the rest are replays of
    an unchanged binding. self_provision_relay() catches RuntimeError per
    platform and continues, so a Telegram+Discord gateway would front Telegram
    and leave Discord unfronted with a warning.
  - a re-provision by a caller that legitimately no longer holds the secret.
    Refusing to hand it back IS F-004; /relay/rotate is the way back.

Only a malformed body raises now. No secret AND no secretIssued key is still the
old unexpected-response case (a pre-F-004 connector), so that keeps failing
loudly -- which is what makes this deployable in either order.

2. Only a response that CARRIES credentials may write them.

A separate bug that change 1 exposes rather than causes. The guard tested the
same env var it wrote: once a withheld response reaches it, it writes an empty
secret -- which then reads as "still unset" on the next pass, masking itself,
while GATEWAY_RELAY_ID and _DELIVERY_KEY have ALREADY been stamped from a
response carrying no credentials. Guard on the secret being present.

Tests: 5 invariants in tests/gateway/relay/test_provision_secret_optional.py.
Proven red on base -- reverting gateway/relay/__init__.py to origin/main turns 2
of the 5 red, one per fix, then green with the fix restored. My first draft of
the multi-platform test passed on base because it had the FIRST platform issue
the secret, which satisfies the old guard so it never re-enters and the bug
never fires; it proved nothing until the ordering was fixed and a direct
write-rule test added.

tests/gateway/relay: 303 passed, 0 failed. The 6 failures in the wider
tests/gateway run (systemd_notify, wecom_callback, shutdown_forensics,
scale_to_zero) reproduce identically on clean origin/main -- pre-existing.

* fix(relay): fail closed on the F-004 discriminator; a fully-withheld boot is not a provision

Two review findings on the parent commit, each with a red-on-parent test.

1. `_post_provision()` accepted ANY present `secretIssued` value when `secret`
   was absent (`true`, `"false"`, `0`, …) and settled them as a successful
   provision. The only credential-less shape the connector emits is literal
   JSON `false`, so match the discriminator by value: `is not False` raises.

2. `self_provision_relay()` returned True and logged the INFO "self-provisioned"
   line when every platform answered `secretIssued:false` — no credential in
   hand, WS upgrade about to be refused, nothing in the boot log to say why.
   Now: WARNING naming `/relay/rotate` / pin `GATEWAY_RELAY_SECRET`, return False.

tests/gateway/relay/: 308 passed, 0 failed.
2026-09-10 12:10:39 +10:00
..