cmux link: a refused Cloud stream tells the Mac why
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
- The failed check, named.
TokenVerifier::verify_explainedreturns aTokenRefusalnaming 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 isverifyplus the variant's name.NativeTokenVerifier::verifynow 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"). - 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_dialwraps the machine stream inRefusalWatch, which checks the far end's first line. A link refusal there is written to stderr (link.log) ascmux link: <host> refused the Daemon stream: Token (issuer: ...), and every byte still passes through unchanged. - App:
LineTransportalready decodestype: "refused"lines (server.busy). Forlink.refusedit keeps the stated reason, and the close text becomesrefused by the machine's link: Token: issuer: expected ..., received ...instead ofEOF. This only parses and shows the line. - An old VM sends nothing, so its behaviour is unchanged.
- Mac side:
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,verifyrefuses 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 (checkissuer, both issuers, no token), and the journal line names it.refusal_watchunit 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 warningsand the panic ratchet are clean.cmux-linkandcmux-tui --lib link::tests pass.- The CmuxNext Swift compile check passes.
Follow-ups: #36088.
Agent session: agent session
š¤ Generated with Claude Code