Summary
When the app starts with dataAccess.mode = "devTunnelWeb", devTunnel.tunnelName = "phantom-workspaces-playspace", devTunnel.accessMode = "private", and no webEndpoint, the first data-access call fails with:
Failed to connect: Web data access call to '/data/get' failed with 401:
(note the trailing 401: — the response body is empty.)
The 401 is emitted by the Microsoft dev tunnels relay (*.devtunnels.ms), not by the Workspaces server. The client is sending a GitHub OAuth identity token as X-Tunnel-Authorization: tunnel <github-token>, which the relay does not accept. The relay only honors a Microsoft-issued Connect-scope tunnel access token.
A separate issue tracks that this specific failure isn't logged before the app dies; this issue is about the 401 root cause.
Root Cause
For DataAccessMode.DevTunnelWeb with a tunnelName set and no webEndpoint, WorkspacesConfiguration.ToRepositorySource() (Phantom.Workspaces/Configuration/WorkspacesConfiguration.cs, ~lines 224-231) returns a DevTunnelNameRepositorySource — not a WebRepositorySource. UseGitHubAuthToken = true is set only on the WebRepositorySource branch (~line 225), so on the DevTunnelName path UseGitHubAuthToken == false (a red herring — this flag never gates the buggy behavior).
EntityRepository.CreateDevTunnelNameDataAccessLayerAsync (Phantom.Workspaces/EntityRepository.cs, ~lines 83-91 dispatch, ~lines 119-154 impl) resolves an endpoint via DevTunnelEndpointResolver.ResolveAsync, then constructs the request authorization via DevTunnelClientAuthorization.Resolve (~lines 140-148):
var authorization = Services.DevTunnel.DevTunnelClientAuthorization.Resolve(
resolution,
repositorySource.AccessMode,
() => Phantom.Workspaces.Llm.GitHubAuthTokenResolver.Resolve());
return new WebClientDataAccessLayer(
resolution.BaseUri.ToString(),
authorization.Token,
authorization.RefreshResolver);
The bug site is Phantom.Workspaces/Services/DevTunnel/DevTunnelClientAuthorization.cs (~lines 15-38). The logic is:
- If
resolution.TunnelAuthToken (a Microsoft Connect-scope token from the Management API) is present → use it. ✅
- Else if
accessMode == Anonymous → send no header. ✅
- Else (Private + no Connect token) → line ~37 falls back to sending the raw GitHub identity token verbatim as
X-Tunnel-Authorization: tunnel <github-oauth-token>. ❌
The dev tunnels relay does not accept GitHub OAuth tokens on X-Tunnel-Authorization; it only accepts a Microsoft tunnel Connect/Host access token. The relay responds 401 Unauthorized with an empty body — the /data/get ASP.NET endpoint has no auth middleware and cannot itself emit 401 (confirmed in Phantom.Workspaces.Data.Web.Server/WebDataAccessEndpointRouteBuilderExtensions.cs, which only returns 200/400).
Why the Connect token is missing
DevTunnelEndpointResolver (Phantom.Workspaces/Services/DevTunnel/DevTunnelEndpointResolver.cs, ~lines 23-60, esp. 47-58) reads lookup.ConnectToken for Private access. DevTunnelManagementClientWrapper.LookupByNameAsync / DiscoverSingleAsync (Phantom.Workspaces/Services/DevTunnel/DevTunnelManagementClientWrapper.cs, ~lines 230-284) request TokenScopes = [TunnelAccessScopes.Connect] (see CreateConnectRequestOptions ~lines 317-324), list tunnels with ownedTunnels = true, and read tunnel.AccessTokens[TunnelAccessScopes.Connect] into ConnectToken (see ToLookupResult ~lines 272-284).
So a valid Connect token is only minted when:
- (a) the caller's GitHub identity owns the tunnel, and
- (b) the tunnel carries both the Workspaces marker label and the exact
phantom-workspaces-playspace name label (see HasLabel filter ~line 236).
If any of these fail (or the Management API returns no AccessTokens[Connect]), resolution.TunnelAuthToken is null and the bad GitHub-identity fallback in DevTunnelClientAuthorization.cs:~37 kicks in. Note also that DevTunnelServiceFactory (Phantom.Workspaces/Services/DevTunnel/DevTunnelServiceFactory.cs, ~lines 42-56) authenticates the Management API with TunnelAuthenticationSchemes.GitHub — valid for tunnels.api.visualstudio.com (management) but not for the *.devtunnels.ms relay. Reusing the same token for both is exactly what the fallback does, and it doesn't work.
Where the 401 surfaces
Phantom.Workspaces.Data.Web.Client/WebClientDataAccessLayer.cs:
- ~lines 42-46: adds
X-Tunnel-Authorization: tunnel <token>.
- ~lines 104-112: retry-once-on-401 via
RefreshResolver.
- ~lines 136-141: on final failure, throws
WebDataAccessRequestException with the message ... failed with {status}: {body}. Empty body ⇒ the trailing 401: observed.
Related background
A separate known issue tracks that tunnelName is stored as a label on the tunnel (not Tunnel.Name), and that existing tunnels' labels must not be relabeled. Label / ownership mismatch is a plausible reason no Connect token is minted here. The fix for this bug should coordinate with that issue but must not prescribe relabeling existing tunnels.
Affected Files
| File |
Lines |
Role |
Phantom.Workspaces/Services/DevTunnel/DevTunnelClientAuthorization.cs |
~15-38 (esp. ~37) |
Bug site. Falls back to sending the GitHub identity token as tunnel authorization. |
Phantom.Workspaces/EntityRepository.cs |
~83-91, ~119-154 |
Dispatches DevTunnelNameRepositorySource and wires the authorization into WebClientDataAccessLayer. |
Phantom.Workspaces/Configuration/WorkspacesConfiguration.cs |
~27-42, ~224-231 |
Projects config → DevTunnelNameRepositorySource; DevTunnelAccessMode enum. |
Phantom.Workspaces/RepositorySource.cs |
~33-35 |
DevTunnelNameRepositorySource record. |
Phantom.Workspaces/Services/DevTunnel/DevTunnelEndpointResolver.cs |
~23-60 (esp. 47-58) |
Produces TunnelAuthToken from lookup.ConnectToken. |
Phantom.Workspaces/Services/DevTunnel/DevTunnelManagementClientWrapper.cs |
~230-284, ~317-324 |
Requests TokenScopes=[Connect], reads tunnel.AccessTokens[Connect]. |
Phantom.Workspaces/Services/DevTunnel/DevTunnelServiceFactory.cs |
~42-56 |
Authenticates Management API with TunnelAuthenticationSchemes.GitHub. |
Phantom.Workspaces.Data.Web.Client/WebClientDataAccessLayer.cs |
~42-46, ~104-112, ~136-141 |
Applies X-Tunnel-Authorization, does retry-on-401, surfaces the empty-body error. |
Phantom.Workspaces.Data.Web.Server/WebDataAccessEndpointRouteBuilderExtensions.cs |
(all) |
Confirms server never emits 401 — so the 401 is from the relay. |
Design / Fix
Present as options; the maintainer will choose.
Option A (preferred): remove the GitHub-token fallback for the relay.
- For Private access, require a valid Microsoft Connect-scope token from the Management API before attempting the data-access call.
- If none can be minted (tunnel not found / not owned / label mismatch), fail fast with a clear, actionable error naming the tunnel and label and how to fix it. Do not fall back to sending a GitHub token to the relay.
- Concretely, remove or gate the identity-token fallback at
DevTunnelClientAuthorization.cs:~37.
Option B: fix Connect-token retrieval.
- Investigate whether Connect-token retrieval is failing due to label / ownership mismatch. If the lookup should have found the tunnel but didn't mint a Connect token, fix the token request so
AccessTokens[Connect] is populated — without relabeling existing tunnels (coordinate with the related label/ownership issue).
Cross-cutting:
- Ensure the retry-on-401 refresh path (
WebClientDataAccessLayer.cs:~104-112) re-resolves a real Connect token, not the GitHub token.
- Improve the surfaced error: a 401 with empty body from a
*.devtunnels.ms host should be translated to something like:
Dev tunnel rejected the connection (401). No valid connect token was obtained for private tunnel '<name>'.
Expected Tests
Naming convention in existing dev-tunnel tests is Method_Scenario_ExpectedOutcome (PascalCase). See DevTunnelClientAuthorizationTests.cs (Resolve_PrivateMode_WithExplicitConnectToken_UsesTokenVerbatim_NoIdentityFallback, etc.) and WebClientDataAccessLayerTests.cs (GetAsync_On401_WithTokenResolver_RefreshesTokenAndRetries, etc.).
| Test Name |
Class |
What It Verifies |
Resolve_PrivateMode_WithNoConnectToken_DoesNotSendGitHubIdentityToken |
DevTunnelClientAuthorizationTests |
Private + no TunnelAuthToken must not fall back to emitting the GitHub OAuth token as X-Tunnel-Authorization. |
Resolve_PrivateMode_WithConnectTokenPresent_UsesConnectTokenVerbatim |
DevTunnelClientAuthorizationTests |
When resolution.TunnelAuthToken is non-null, it is used verbatim as the tunnel authorization. |
Resolve_PrivateMode_WithNoConnectToken_ThrowsActionableError |
DevTunnelClientAuthorizationTests |
Per Option A, missing Connect token for a Private tunnel produces a clear error naming the tunnel and label, not a silent GitHub-token fallback. |
Resolve_AnonymousMode_WithNoConnectToken_SendsNoAuthorization |
DevTunnelClientAuthorizationTests |
Regression guard for the existing Anonymous branch. |
CreateDevTunnelNameDataAccessLayerAsync_WhenConnectTokenUnavailable_FailsFastWithoutRelayCall |
EntityRepositoryTests (or existing DevTunnel dispatch tests) |
The dispatch path does not attempt to hit *.devtunnels.ms when no Connect token can be minted. |
GetAsync_WhenRelayReturns401WithEmptyBody_SurfacesActionableMessage |
WebClientDataAccessLayerTests |
An empty-body 401 from a *.devtunnels.ms host produces a descriptive error mentioning the dev tunnel and missing connect token, not a bare ... failed with 401:. |
WorkspacesConfiguration_ToRepositorySource_DevTunnelWebWithTunnelNameAndNoWebEndpoint_ReturnsDevTunnelNameRepositorySource |
DevTunnelConfigurationTests |
Regression guard clarifying that UseGitHubAuthToken is not set on this path (documents the red herring). |
Summary
When the app starts with
dataAccess.mode = "devTunnelWeb",devTunnel.tunnelName = "phantom-workspaces-playspace",devTunnel.accessMode = "private", and nowebEndpoint, the first data-access call fails with:(note the trailing
401:— the response body is empty.)The 401 is emitted by the Microsoft dev tunnels relay (
*.devtunnels.ms), not by the Workspaces server. The client is sending a GitHub OAuth identity token asX-Tunnel-Authorization: tunnel <github-token>, which the relay does not accept. The relay only honors a Microsoft-issued Connect-scope tunnel access token.A separate issue tracks that this specific failure isn't logged before the app dies; this issue is about the 401 root cause.
Root Cause
For
DataAccessMode.DevTunnelWebwith atunnelNameset and nowebEndpoint,WorkspacesConfiguration.ToRepositorySource()(Phantom.Workspaces/Configuration/WorkspacesConfiguration.cs, ~lines 224-231) returns aDevTunnelNameRepositorySource— not aWebRepositorySource.UseGitHubAuthToken = trueis set only on theWebRepositorySourcebranch (~line 225), so on the DevTunnelName pathUseGitHubAuthToken == false(a red herring — this flag never gates the buggy behavior).EntityRepository.CreateDevTunnelNameDataAccessLayerAsync(Phantom.Workspaces/EntityRepository.cs, ~lines 83-91 dispatch, ~lines 119-154 impl) resolves an endpoint viaDevTunnelEndpointResolver.ResolveAsync, then constructs the request authorization viaDevTunnelClientAuthorization.Resolve(~lines 140-148):The bug site is
Phantom.Workspaces/Services/DevTunnel/DevTunnelClientAuthorization.cs(~lines 15-38). The logic is:resolution.TunnelAuthToken(a Microsoft Connect-scope token from the Management API) is present → use it. ✅accessMode == Anonymous→ send no header. ✅X-Tunnel-Authorization: tunnel <github-oauth-token>. ❌The dev tunnels relay does not accept GitHub OAuth tokens on
X-Tunnel-Authorization; it only accepts a Microsoft tunnel Connect/Host access token. The relay responds401 Unauthorizedwith an empty body — the/data/getASP.NET endpoint has no auth middleware and cannot itself emit 401 (confirmed inPhantom.Workspaces.Data.Web.Server/WebDataAccessEndpointRouteBuilderExtensions.cs, which only returns 200/400).Why the Connect token is missing
DevTunnelEndpointResolver(Phantom.Workspaces/Services/DevTunnel/DevTunnelEndpointResolver.cs, ~lines 23-60, esp. 47-58) readslookup.ConnectTokenfor Private access.DevTunnelManagementClientWrapper.LookupByNameAsync/DiscoverSingleAsync(Phantom.Workspaces/Services/DevTunnel/DevTunnelManagementClientWrapper.cs, ~lines 230-284) requestTokenScopes = [TunnelAccessScopes.Connect](seeCreateConnectRequestOptions~lines 317-324), list tunnels withownedTunnels = true, and readtunnel.AccessTokens[TunnelAccessScopes.Connect]intoConnectToken(seeToLookupResult~lines 272-284).So a valid Connect token is only minted when:
phantom-workspaces-playspacename label (seeHasLabelfilter ~line 236).If any of these fail (or the Management API returns no
AccessTokens[Connect]),resolution.TunnelAuthTokenis null and the bad GitHub-identity fallback inDevTunnelClientAuthorization.cs:~37kicks in. Note also thatDevTunnelServiceFactory(Phantom.Workspaces/Services/DevTunnel/DevTunnelServiceFactory.cs, ~lines 42-56) authenticates the Management API withTunnelAuthenticationSchemes.GitHub— valid fortunnels.api.visualstudio.com(management) but not for the*.devtunnels.msrelay. Reusing the same token for both is exactly what the fallback does, and it doesn't work.Where the 401 surfaces
Phantom.Workspaces.Data.Web.Client/WebClientDataAccessLayer.cs:X-Tunnel-Authorization: tunnel <token>.RefreshResolver.WebDataAccessRequestExceptionwith the message... failed with {status}: {body}. Empty body ⇒ the trailing401:observed.Related background
A separate known issue tracks that
tunnelNameis stored as a label on the tunnel (notTunnel.Name), and that existing tunnels' labels must not be relabeled. Label / ownership mismatch is a plausible reason no Connect token is minted here. The fix for this bug should coordinate with that issue but must not prescribe relabeling existing tunnels.Affected Files
Phantom.Workspaces/Services/DevTunnel/DevTunnelClientAuthorization.csPhantom.Workspaces/EntityRepository.csDevTunnelNameRepositorySourceand wires the authorization intoWebClientDataAccessLayer.Phantom.Workspaces/Configuration/WorkspacesConfiguration.csDevTunnelNameRepositorySource;DevTunnelAccessModeenum.Phantom.Workspaces/RepositorySource.csDevTunnelNameRepositorySourcerecord.Phantom.Workspaces/Services/DevTunnel/DevTunnelEndpointResolver.csTunnelAuthTokenfromlookup.ConnectToken.Phantom.Workspaces/Services/DevTunnel/DevTunnelManagementClientWrapper.csTokenScopes=[Connect], readstunnel.AccessTokens[Connect].Phantom.Workspaces/Services/DevTunnel/DevTunnelServiceFactory.csTunnelAuthenticationSchemes.GitHub.Phantom.Workspaces.Data.Web.Client/WebClientDataAccessLayer.csX-Tunnel-Authorization, does retry-on-401, surfaces the empty-body error.Phantom.Workspaces.Data.Web.Server/WebDataAccessEndpointRouteBuilderExtensions.csDesign / Fix
Present as options; the maintainer will choose.
Option A (preferred): remove the GitHub-token fallback for the relay.
DevTunnelClientAuthorization.cs:~37.Option B: fix Connect-token retrieval.
AccessTokens[Connect]is populated — without relabeling existing tunnels (coordinate with the related label/ownership issue).Cross-cutting:
WebClientDataAccessLayer.cs:~104-112) re-resolves a real Connect token, not the GitHub token.*.devtunnels.mshost should be translated to something like:Expected Tests
Naming convention in existing dev-tunnel tests is
Method_Scenario_ExpectedOutcome(PascalCase). SeeDevTunnelClientAuthorizationTests.cs(Resolve_PrivateMode_WithExplicitConnectToken_UsesTokenVerbatim_NoIdentityFallback, etc.) andWebClientDataAccessLayerTests.cs(GetAsync_On401_WithTokenResolver_RefreshesTokenAndRetries, etc.).Resolve_PrivateMode_WithNoConnectToken_DoesNotSendGitHubIdentityTokenDevTunnelClientAuthorizationTestsTunnelAuthTokenmust not fall back to emitting the GitHub OAuth token asX-Tunnel-Authorization.Resolve_PrivateMode_WithConnectTokenPresent_UsesConnectTokenVerbatimDevTunnelClientAuthorizationTestsresolution.TunnelAuthTokenis non-null, it is used verbatim as the tunnel authorization.Resolve_PrivateMode_WithNoConnectToken_ThrowsActionableErrorDevTunnelClientAuthorizationTestsResolve_AnonymousMode_WithNoConnectToken_SendsNoAuthorizationDevTunnelClientAuthorizationTestsCreateDevTunnelNameDataAccessLayerAsync_WhenConnectTokenUnavailable_FailsFastWithoutRelayCallEntityRepositoryTests(or existing DevTunnel dispatch tests)*.devtunnels.mswhen no Connect token can be minted.GetAsync_WhenRelayReturns401WithEmptyBody_SurfacesActionableMessageWebClientDataAccessLayerTests*.devtunnels.mshost produces a descriptive error mentioning the dev tunnel and missing connect token, not a bare... failed with 401:.WorkspacesConfiguration_ToRepositorySource_DevTunnelWebWithTunnelNameAndNoWebEndpoint_ReturnsDevTunnelNameRepositorySourceDevTunnelConfigurationTestsUseGitHubAuthTokenis not set on this path (documents the red herring).