Skip to content

fix(client-runtime): paired iPhone requests stop sending stale cookies and show the host's refusal - #195

Merged
jayleaton merged 3 commits into
mainfrom
fix/ios-discovery-http-20261106
Nov 6, 2026
Merged

jayleaton merged 3 commits into
mainfrom
fix/ios-discovery-http-20261106

Conversation

@jayleaton

Copy link
Copy Markdown
Owner

Problem

On iOS, paired (non–T3 Connect) connections to a Studio host failed off the home Wi-Fi. The host saw the phone's ticket requests on both the tailnet route and the Tailscale Serve route rejected with 401 Session token revoked, but the app showed a descriptor timeout and kept retrying. Two client defects make this worse and hide the cause:

  1. Ambient cookies override the paired token. The server prefers a session cookie over Authorization (EnvironmentAuth.selectRequestCredential). On iOS, expo/fetch replaces global fetch and sends the shared HTTPCookieStorage jar unless credentials: "omit" is set. Any stale session cookie for the host would turn a valid Bearer request into a 401.
  2. A host's refusal is ranked below another route's timeout. When every route check misses its 500 ms head start, a slow route that reached the environment and was refused ranked below a dead route's timeout. The supervisor kept backing off and showed "timed out" instead of the refusal.

This PR does not claim to fix the off-Wi-Fi connectivity itself. The phone's TCP handshakes to the direct tailnet IP stall (SYN in, SYN-ACK out, no ACK). Whether a cookie was on the original 401 requests wasn't recorded. No production app path was found that puts a T3 session cookie into the app's jar.

Summary

 layerRemoteHttpClient(fetch)                    // web + mobile adapter boundary
-  FetchHttpClient.layer
+  FetchHttpClient.layer
+    .transform(request has Authorization -> RequestInit.credentials = "omit")
     // cookie-authenticated web requests (no Authorization) are unchanged

 connectOverRoutes: rank a route's failure
-  Blocked && check == "answered"                  -> answeredBlocked
+  Blocked && (check == "answered" || hop == "server") -> answeredBlocked
     // T3 Connect sign-in refusals (no hop / relay hop) keep their ranking

The diagnostics commit b368ca6, which shipped in TestFlight 2.2.0 (14), is included so main matches the build.

Change

  • packages/client-runtime/src/rpc/http.ts: requests that carry Authorization (Bearer or DPoP) send credentials: "omit", merged into any existing RequestInit such as cache: "no-store". WebSocket upgrades are untouched: the server checks wsTicket before cookies.
  • packages/client-runtime/src/connection/driver.ts: a ConnectionBlockedError with hop: "server" counts as an answered route.

Scope and approval

Maintainer-authorized fork fix (JJ) for the iPhone paired-connection investigation. Two small client-runtime changes at existing boundaries; no new behavior surface.

Verification

Failing first, then passing:

  • packages/client-runtime/src/authorization/layer.test.ts ("prepares a cold direct bearer connection…"): Before: expected { method: 'POST', … } to match object { credentials: 'omit', … }. After: passes.
  • packages/client-runtime/src/connection/supervisor.test.ts ("reports the environment's refusal from a slow route over a silent route's timeout"): Before: never reaches blocked (times out waiting). After: passes with lastFailure = "The environment credential is invalid."
  • The existing environmentHttpAuth.test.ts assertions that pinned credentials: undefined for bearer and DPoP requests now expect "omit".

Runtime transcript against a real server (node apps/server/src/bin.ts serve on a throwaway base dir on loopback). It used a real session cookie from /api/auth/browser-session, revoked via the CLI, and a valid CLI-issued Bearer:

curl POST /api/auth/websocket-ticket/rpc  Bearer(valid)+Cookie(revoked) -> 401 {"reason":"invalid_credential"}
curl POST /api/auth/websocket-ticket/rpc  Bearer(valid)                 -> 200
client-runtime issueRemoteWebSocketTicket via layerRemoteHttpClient, fetch applying expo/fetch's
iOS cookie rule (credentials default "include" attaches the jar cookie; "omit" attaches none):
  BEFORE (b368ca6): credentials=undefined cookieSent=true  -> EnvironmentAuthInvalidError invalid_credential
  AFTER:            credentials=omit      cookieSent=false -> ticket issued

Native source checked: expo/src/winter/fetch/fetch.ts (default credentials: "include") and ios/Expo/Fetch/ExpoURLSessionTask.swift:28-35 (omit leaves out cookies).

Focused suites: client-runtime supervisor, resolver, registry, routes, authorization layer and remote, environmentHttpAuth, shell-sync, rpc session (224 passed); mobile tracing and linkEnvironment, web linkEnvironment (pass). vp run check:changed: format, lint, knip, typecheck (36 packages), tests and react compiler all pass.

Not yet verified: real off-Wi-Fi app behavior on a device. Acceptance (direct tailnet and Serve off Wi-Fi, Wi-Fi transition, foreground and resume on a paired connection) needs a TestFlight build from this branch.

Merge Danger

Door: two-way. Revert restores the previous behavior; no persisted state or schema changes.

Blast Radius: consumers. Every web and mobile request with an explicit Authorization header stops sending cookies; web primary-environment cookie auth is unaffected. The connection supervisor now stops in "blocked" (it still retries on foreground, network change or credential change) when any route gets a real refusal from the server, instead of backing off forever behind another route's timeout.

Model and harness: Claude Opus 5.5 (Doug) via Claude Code in T3 Code.

🤖 Generated with Claude Code

jayleaton and others added 3 commits November 6, 2026 00:13
…d cookies

The server prefers a session cookie over Authorization, and iOS fetch
(expo/fetch) sends the shared cookie jar unless credentials is omit. A
stale cookie for a host would override the paired Bearer token. Requests
that carry Authorization now use credentials: omit; cookie-authenticated
web requests are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te's timeout

When every route check missed its head start, a slow route that reached
the environment and was refused ranked below a dead route's timeout, so
the app kept retrying and showed a timeout. A refusal from the
environment server now counts as an answer; sign-in refusals from T3
Connect keep their ranking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 5.0 KiB 5.0 KiB +6 B (+0.1%) 6.8 KiB ✅
Codex Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Codex Live turn WebSocket wire 1.2 KiB 1.3 KiB +6 B (+0.5%) 2.0 KiB ✅
Codex Live turn WebSocket decoded 21.6 KiB 21.6 KiB 0 B (0.0%) 24.4 KiB ✅
Codex Live turn messages 2 2 0 (0.0%) 8 ✅
Claude Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Claude Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Claude Live turn WebSocket wire 1.3 KiB 1.3 KiB 0 B (0.0%) 2.0 KiB ✅
Claude Live turn WebSocket decoded 21.9 KiB 21.9 KiB 0 B (0.0%) 24.4 KiB ✅
Claude Live turn messages 2 2 0 (0.0%) 8 ✅

Baseline: ca58b8b · PR result: 4132ed3 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@jayleaton
jayleaton marked this pull request as ready for review November 6, 2026 03:10
@jayleaton
jayleaton merged commit 2eeb0be into main Nov 6, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant