Skip to content

[Bug]: Mobile DPoP requests fail with invalid_credential for thread ids containing ":" (htu not percent-encoded) #16310

Description

@deanjstone

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/mobile (root cause in packages/client-runtime)

Steps to reproduce

  1. Pair the mobile app with an environment so its HTTP requests use DPoP authorization.
  2. Create a thread whose id contains a character that encodeURIComponent escapes. Threads created through the MCP t3_thread_launch tool get ids like mcp:7dbb5e0c-….
  3. Open that thread on mobile, and if it has enough history, tap Load earlier activity.

Expected behavior

Earlier activity loads, the same as it does for threads with UUID ids.

Actual behavior

The request fails and the feed shows The environment rejected this client's credentials (invalid_credential). The client refreshes the credential and retries once, and the retry also gets a 401. The bounded snapshot request (/bounded) for the same thread fails the same way.

Server trace, mobile requests only, grouped by route, by whether the thread id needed escaping, and by status:

  5 /bounded  escaped-id=false dpop=true 200
 10 /bounded  escaped-id=true  dpop=true 401
 14 /history  escaped-id=true  dpop=true 401

Cause: the DPoP htu signed by the client doesn't match the URL the server checks.

  • The client builds the URL it signs with the raw id: environmentEndpointUrl(httpBaseUrl, /api/orchestration/threads/${input.threadId}/history). WHATWG URL leaves : unescaped in a path, so the signed htu is …/threads/mcp:7dbb…/history.
  • The HttpApi client sends the request with the path param percent-encoded: …/threads/mcp%3A7dbb…/history.
  • The server verifies the proof against the real request URL (apps/server/src/auth/dpop.ts:73, url: url.value.href). normalizeDpopHtu only strips the query and fragment, so the two never match.
new URL("https://h/api/orchestration/threads/mcp:7dbb/history").toString()
// https://h/api/orchestration/threads/mcp:7dbb/history
new URL("https://h/api/orchestration/threads/" + encodeURIComponent("mcp:7dbb") + "/history").toString()
// https://h/api/orchestration/threads/mcp%3A7dbb/history

The same pattern appears in all three thread URL builders:

  • packages/client-runtime/src/state/threadHistoryHttp.ts:31
  • packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts:38
  • packages/client-runtime/src/state/threadSnapshotHttp.ts:69

Suggested fix: use encodeURIComponent(input.threadId) in those builders so the signed URL matches the wire URL. Alternatively, have the client sign the exact URL it sends.

Impact

Major degradation or frequent failure. Every thread with such an id can't page history or load a bounded snapshot on mobile. Desktop and threads with UUID ids are unaffected.

Version or commit

main @ 5afe18e (code paths confirmed there); mobile app build T3Code/109

Environment

iOS (CFNetwork/3896, Darwin 27.0.0), environment reached directly over Tailscale HTTPS with DPoP auth; server on Linux (WSL2)

Workaround

Open the affected threads on desktop.

Activity

  1. juliusmarminge commented on Oct 6, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Thanks for the detailed trace, @deanjstone! This is the same root cause as #15772: the three thread URL builders in packages/client-runtime (threadHistoryHttp.ts, boundedThreadSnapshotHttp.ts, threadSnapshotHttp.ts) sign a URL with the raw thread id while the request percent-encodes it, so the DPoP htu check fails for any id containing :. That issue covers it for delegated-task threads over T3 Connect, and the fix is the same for your mobile/MCP case.

    The open PR #15813 switches those builders to the contract-derived URL builder so the signed and sent URLs can't diverge, which should fix both. Closing this as a duplicate of #15772; please follow along there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    duplicateThis issue or pull request already exists

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions