cmux link: a refused Cloud stream tells the Mac why

Merged
TE
2d ago
+540-73

Builds on #35996 (merged), which added the receiver's per-stream journal line.

What failed

A production Cloud chat (chat-90007152) failed with cmux-tui connection closed: EOF. Reading the receiver showed every daemon stream was refused in serve_host_inbound: the token's iss was cmux:cloud:prod while the receiver's policy expects cmux:cloud:production. The backend fix for the issuer is a separate change. Each refusal closed the stream silently, so the Mac saw a bare EOF, and the verifier reported every check as the same Invalid, so nothing said it was the issuer.

What this changes

  1. The failed check, named. TokenVerifier::verify_explained returns a TokenRefusal naming the specific check: issuer, receiver_generation, audience, epoch, team, subject, network_binding_invalid, iat_in_future, lifetime, jti, header_typ, kid, signature, claims_malformed, peer_not_in_policy, services, no_policy, policy_expired, and so on. Its default is verify plus the variant's name. NativeTokenVerifier::verify now calls it and maps the result back to the old variant, so the checks, their order and what they accept are unchanged. An issuer mismatch includes both issuer strings (public names, not secrets). The journal line from #35996 gets the check appended, e.g. refused: token Invalid: issuer (expected "cmux:cloud:production", received "cmux:cloud:prod").
  2. A refusal line to the dialer. Before closing a refused stream, the receiver writes one line in the daemon protocol's existing refusal shape: {"type":"refused","code":"link.refused","reason":"Token","check":"issuer","detail":"..."}. It never contains token or key bytes. It is sent only before any service bytes and is capped at 1 s; a served stream sends nothing extra.
    • Mac side: serve_cloud_dial wraps the machine stream in RefusalWatch, which checks the far end's first line. A link refusal there is written to stderr (link.log) as cmux link: <host> refused the Daemon stream: Token (issuer: ...), and every byte still passes through unchanged.
    • App: LineTransport already decodes type: "refused" lines (server.busy). For link.refused it keeps the stated reason, and the close text becomes refused by the machine's link: Token: issuer: expected ..., received ... instead of EOF. This only parses and shows the line.
    • An old VM sends nothing, so its behaviour is unchanged.

The lease-tolerant renewal this PR first carried was dropped once #36014 (local admission) landed the same behaviour.

Tests

  • native_token_security: each mismatched claim names its check, verify refuses exactly as before, the issuer detail carries both issuers, and no reason contains the token.
  • host_native_token_tests: an issuer mismatch through the real inbound path writes the refusal line (check issuer, both issuers, no token), and the journal line names it.
  • refusal_watch unit tests:
    • a refusal split across reads is reported and passed on byte for byte;
    • service bytes, a non-refusal first line and a bare EOF are never reported.
  • Updated tests (handshake lifetime, elapsed transport): a refused pre-auth stream now reads one refusal line, then EOF.
  • Fleet:
    • cargo fmt --check, clippy -D warnings and the panic ratchet are clean.
    • cmux-link and cmux-tui --lib link:: tests pass.
    • The CmuxNext Swift compile check passes.

Follow-ups: #36088.

Agent session: agent session

šŸ¤– Generated with Claude Code