Summary
The tunnel owner ΓÇö on the same machine, same GitHub account, and the owner of the tunnel ΓÇö cannot connect a PrivateΓÇæmode dev tunnel by name. Connecting a "playspace 3 debug" client to the "playspace 3" host fails at startup with:
Failed to connect: The Management API did not return a Connect-scope tunnel token. Ensure the GitHub identity has access to the tunnel.
Because the connecting identity is the tunnel owner on the same box/account, this is not an identity/access misconfiguration ΓÇö the owning identity still receives a null Connect token and the app throws. DevTunnelEndpointResolver.ResolveAsync requires a ConnectΓÇæscope token for any nonΓÇæAnonymous (Private) access mode and throws when it is null. That directly contradicts design point #19 in docs/design/dev-tunnel-host-service.md, which states that Private connect is identityΓÇæderived and returns a null tunnelAuthToken (the client connects using its GitHub identity, with no AccessTokenSource).
Impact: Private playspace connect (e.g. "playspace 3 debug" → "playspace 3") is broken for the owner. The parallel explicit‑access‑point path (WebRepositorySource with UseGitHubAuthToken) already works by sending the GitHub identity token as X-Tunnel-Authorization, so only the tunnel‑name/Private path regressed.
Regressed by #517 (d233d835 "use API-issued connect token instead of env-var fallback") and #521 (fccf4c8e "DevTunnel access control end-to-end").
Root Cause
1. The resolver treats a null Connect token as fatal in Private mode
features\Phantom.Workspaces\Services\DevTunnel\DevTunnelEndpointResolver.cs:44-55
// Both Private and Token modes require X-Tunnel-Authorization: tunnel <connect-token>.
var tunnelAuthToken = accessMode switch
{
DevTunnelAccessMode.Anonymous => null,
_ => lookup.ConnectToken
?? throw new InvalidOperationException(
"The Management API did not return a Connect-scope tunnel token. " +
"Ensure the GitHub identity has access to the tunnel."),
};
For any nonΓÇæAnonymous mode (Private, and the retired Token mode) a null ConnectToken throws the exact userΓÇævisible message. Per design #19 a null token in Private mode is expected and valid, not fatal.
2. The connect token is obtained from the wrong Management API call, so it is null by construction
Client‑side lookup goes through LookupByNameAsync / DiscoverSingleAsync → ListWorkspacesTunnelsAsync → ListTunnelsAsync(...), then reads the token off the listed tunnel:
features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.cs:260-284
private async Task<IReadOnlyList<Tunnel>> ListWorkspacesTunnelsAsync(
TunnelRequestOptions requestOptions, CancellationToken cancellationToken)
{
var ownedTunnels = await this.managementClient
.ListTunnelsAsync(null, null, requestOptions, true, cancellationToken) // LIST, not GET
.ConfigureAwait(false);
...
}
private static DevTunnelLookupResult ToLookupResult(Tunnel tunnel)
{
...
string? connectToken = null;
tunnel.AccessTokens?.TryGetValue(TunnelAccessScopes.Connect, out connectToken); // null on a list result
...
}
CreateConnectRequestOptions sets TokenScopes = [TunnelAccessScopes.Connect] (DevTunnelManagementClientWrapper.cs:317-324), so the intent is that ListTunnelsAsync mints and returns a perΓÇætunnel Connect token in Tunnel.AccessTokens. The Dev Tunnels SDK does not populate access tokens on a list result.
SDK evidence (Microsoft.DevTunnels.Management / .Contracts 1.3.50):
TunnelRequestOptions.TokenScopes XML doc: "an optional list of token scopes that are requested when retrieving a tunnel or tunnel port object. … The service issues an access token for each scope … and returns the token(s) in the Tunnel.AccessTokens … dictionary. If the caller does not have permission to get a token for one or more scopes then a token is not returned but the overall request does not fail." — i.e. tokens are keyed to single‑object retrieval (GET), and a missing token is silent (null), never an error.
ITunnelManagementClient.ListTunnelsAsync XML doc lists only Labels and IncludePorts as options that affect the returned tunnels; it says nothing about access tokens, and the SDK implementation calls SendRequestAsync<TunnelListByRegionResponse> without PreserveAccessTokens, unlike GetTunnelAsync which passes ReadAccessTokenScopes and calls PreserveAccessTokens(tunnel, result) (cs/src/Management/TunnelManagementClient.cs). The list endpoint returns tunnel metadata, not minted perΓÇætunnel tokens.
So tunnel.AccessTokens[Connect] is null by construction on the list path ΓÇö regardless of the caller's permissions. The owner is not specialΓÇæcased; nobody gets a token here.
Notably, the host side already fetches the connect token correctly, via GetTunnelAsync with a Connect scope:
features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.cs:185-194
var withConnectToken = await this.managementClient
.GetTunnelAsync(
this.currentTunnel,
new TunnelRequestOptions { TokenScopes = [TunnelAccessScopes.Connect] },
cancellationToken)
.ConfigureAwait(false);
string? connectToken = null;
withConnectToken?.AccessTokens?.TryGetValue(TunnelAccessScopes.Connect, out connectToken);
return connectToken;
The client path was never updated to use GetTunnelAsync the same way ΓÇö it reads tokens off a list result that never carries them.
3. Private mode writes an empty ACL ΓÇö the architecture is identityΓÇæbased, not tokenΓÇæbased
features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.cs:159-172
tunnel.AccessControl = new TunnelAccessControl
{
Entries = accessMode == DevTunnelAccessMode.Anonymous
?
[
new TunnelAccessControlEntry
{
Type = TunnelAccessControlEntryType.Anonymous,
Subjects = [],
Scopes = [TunnelAccessScopes.Connect],
},
]
: [], // Private: EMPTY ACL ΓÇö no Connect ACE for anyone
};
Only the Anonymous branch adds a Connect ACE. The Private branch writes an empty ACL, and DevTunnelAccessMode.Token is retired (WorkspacesConfiguration.cs:32-38). An empty ACL plus retired Token mode is the signature of an identityΓÇæbased Private connect: the owner connects using their own GitHub identity, not a shared Connect token. This matches how the working explicitΓÇæaccessΓÇæpoint path authorizes ΓÇö it sends the GitHub auth token as X-Tunnel-Authorization: tunnel <github-token> (EntityRepository.cs:100-116, WebClientDataAccessLayer.cs:42-46).
4. Design contradiction
features\docs\design\dev-tunnel-host-service.md:485-487 (point #19):
Private connect needs no token: in Private mode IDevTunnelEndpointResolver returns a null tunnelAuthToken (identity-derived) and the client connects without any AccessTokenSource; the token-source UI is hidden for Private and shown only for Token mode.
The current resolver requires a Connect token in Private mode and throws when null ΓÇö the exact opposite of the design.
Why it fails for the owner specifically
Same machine / same account / tunnel owner rules out any accessΓÇæcontrol remediation: there is nothing to grant. The failure is mechanical and universal on the tunnelΓÇæname Private path ΓÇö ListTunnelsAsync never returns a Connect access token, so lookup.ConnectToken is always null, so ResolveAsync always throws. The owner hits it because the owner is the one connecting; adding ACEs or reΓÇæauthenticating cannot help because the token is never minted on the list call in the first place, and per design the owner shouldn't need a Connect token at all.
Downstream, DevTunnelEndpointResolution.TunnelAuthToken flows to WebClientDataAccessLayer (EntityRepository.cs:129-137). A null token there simply omits the X-Tunnel-Authorization header (WebClientDataAccessLayer.cs:42-46), so an identityΓÇæbased Private connect must additionally supply the GitHub identity token/refresh resolver the way the UseGitHubAuthToken path already does (EntityRepository.cs:100-116).
Affected Files
| File |
Line(s) |
Contribution |
features\Phantom.Workspaces\Services\DevTunnel\DevTunnelEndpointResolver.cs |
44-55 |
Throws for nonΓÇæAnonymous modes when ConnectToken is null ΓÇö the throw site; contradicts design #19. |
features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.cs |
230-284 |
LookupByNameAsync/DiscoverSingleAsync → ListTunnelsAsync → ToLookupResult reads AccessTokens[Connect] off a list result that never carries minted tokens. |
features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.cs |
317-324 |
CreateConnectRequestOptions sets TokenScopes=[Connect] on a list request, expecting a token that list does not return. |
features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.cs |
159-195 |
ApplyAccessModeAsync: Private writes an empty ACL (identityΓÇæbased); the correct GetTunnelAsyncΓÇæwithΓÇæConnectΓÇæscope token pattern already lives here (host side). |
features\Phantom.Workspaces\EntityRepository.cs |
100-141 |
Wires the resolver result into WebClientDataAccessLayer; the UseGitHubAuthToken path shows the identityΓÇætoken mechanism the Private tunnelΓÇæname path should reuse. |
features\Phantom.Workspaces.Data.Web.Client\WebClientDataAccessLayer.cs |
42-46, 104-111 |
A null token omits X-Tunnel-Authorization; identity connect needs the GitHub token + 401 refresh resolver. |
features\docs\design\dev-tunnel-host-service.md |
485-487 |
Design point #19: Private connect is identityΓÇæderived and returns a null token. |
features\Phantom.Workspaces.Tests\DevTunnelEndpointResolverTests.cs |
37-45 |
..._WhenConnectTokenIsNull_Throws currently codifies the buggy behavior; must be updated. |
Design / Fix
Fix A ΓÇö Align with design #19: null Connect token is valid in Private mode (RECOMMENDED)
Treat a null ConnectToken in Private mode as valid: return a null tunnelAuthToken and let the client connect using its GitHub identity. Remove the throw for Private mode.
// DevTunnelEndpointResolver.ResolveAsync
var tunnelAuthToken = accessMode switch
{
DevTunnelAccessMode.Anonymous => null,
// Private connect is identity-derived (design #19): a null Connect token is expected
// and valid ΓÇö the client authorizes via its GitHub identity, not a shared token.
DevTunnelAccessMode.Private => lookup.ConnectToken, // may be null; do NOT throw
#pragma warning disable CS0618
DevTunnelAccessMode.Token => lookup.ConnectToken
?? throw new InvalidOperationException(
"Token access mode requires a pre-shared Connect token."),
#pragma warning restore CS0618
_ => lookup.ConnectToken,
};
Because a null tunnelAuthToken causes WebClientDataAccessLayer to omit X-Tunnel-Authorization entirely (WebClientDataAccessLayer.cs:42-46), the Private tunnelΓÇæname path must also supply the GitHub identity token the same way the explicitΓÇæaccessΓÇæpoint path does today ΓÇö i.e. in EntityRepository.CreateDevTunnelNameDataAccessLayerAsync, when the token is null in Private mode, pass the GitHub auth token and a refresh resolver:
buildDataAccessLayer: resolution =>
{
var (token, resolver) = resolution.TunnelAuthToken is { Length: > 0 } t
? (t, (Func<string?>?)null) // explicit Connect/Token
: (GitHubAuthTokenResolver.Resolve(), // identity-derived (Private)
(Func<string?>?)(() => GitHubAuthTokenResolver.Resolve()));
return new WebClientDataAccessLayer(resolution.BaseUri.ToString(), token, resolver);
},
This mirrors the alreadyΓÇæworking WebRepositorySource + UseGitHubAuthToken flow (EntityRepository.cs:100-116), keeps Token mode behavior intact, and matches the emptyΓÇæACL/retiredΓÇæToken architecture. Recommended because the empty ACL, the retirement of Token mode, and design #19 all point at identityΓÇæbased Private connect.
Fix B ΓÇö Obtain the Connect token via the correct API (complementary / fallback)
If a minted Connect token is genuinely required, fetch it with the API that actually returns tokens ΓÇö GetTunnelAsync with TokenScopes = [Connect] ΓÇö instead of reading AccessTokens off a ListTunnelsAsync result that never carries them. This mirrors the host side (DevTunnelManagementClientWrapper.cs:185-194):
// After locating the tunnel by label via ListTunnelsAsync, mint the Connect token via GET:
var withConnectToken = await this.managementClient
.GetTunnelAsync(
located,
new TunnelRequestOptions { TokenScopes = [TunnelAccessScopes.Connect], IncludePorts = true },
cancellationToken)
.ConfigureAwait(false);
string? connectToken = null;
withConnectToken?.AccessTokens?.TryGetValue(TunnelAccessScopes.Connect, out connectToken);
ACL note: For Fix B to yield a Connect token, the tunnel must grant the caller the Connect scope. Today the Private branch of ApplyAccessModeAsync writes an empty ACL (DevTunnelManagementClientWrapper.cs:159-172); if a token is required, the host would need to add a Connect ACE for the owner/identity rather than an empty ACL. That contradicts the identityΓÇæbased intent, which is why Fix A is preferred. Even under Fix B, the resolver should not throw on a null token in Private mode (Fix A's throw removal still applies).
Recommendation: Implement Fix A (remove the PrivateΓÇæmode throw + reuse the GitHub identity token in the tunnelΓÇæname Private path). Optionally adopt Fix B's GetTunnelAsync correction if telemetry shows a minted Connect token is still wanted for crossΓÇæaccount distribution ΓÇö but the architecture (empty ACL, retired Token mode, design #19) is identityΓÇæbased, so Fix A is the correct primary fix.
Expected Tests
| Test Name |
Class |
What It Verifies |
ResolveAsync_PrivateMode_WhenConnectTokenIsNull_DoesNotThrow |
DevTunnelEndpointResolverTests |
Private mode with a null Connect token returns a resolution instead of throwing (replaces ..._WhenConnectTokenIsNull_Throws). |
ResolveAsync_PrivateMode_WhenConnectTokenIsNull_YieldsNullTunnelAuthToken |
DevTunnelEndpointResolverTests |
Private mode with a null Connect token yields a null/identity TunnelAuthToken (identityΓÇæderived connect per design #19). |
ResolveAsync_PrivateMode_ConnectsAsOwner_WithoutConnectToken |
DevTunnelEndpointResolverTests |
Owner connect succeeds (base URI resolved, no exception) when no Connect token is available. |
ResolveAsync_AnonymousMode_ReturnsNullToken |
DevTunnelEndpointResolverTests |
Anonymous mode still resolves with a null token (unchanged). |
LookupByNameAsync_PrivateMode_FetchesConnectTokenViaGetTunnelAsync |
DevTunnelManagementClientWrapperTests |
(Fix B only) When a Connect token is fetched, it comes from GetTunnelAsync with TokenScopes=[Connect], not from a ListTunnelsAsync result. |
ApplyAccessModeAsync_AnonymousMode_ReturnsAnonymousConnectToken |
DevTunnelManagementClientWrapperTests |
Anonymous mode still grants a Connect ACE and returns an anonymous Connect token (unchanged). |
CreateDevTunnelNameDataAccessLayer_PrivateMode_WithNullToken_UsesGitHubIdentityHeader |
DevTunnelHostServiceTests |
(Fix A wiring) The Private tunnelΓÇæname path with a null token authorizes via the GitHub identity token / 401 refresh resolver rather than sending no X-Tunnel-Authorization. |
Diagnosis references: throw at DevTunnelEndpointResolver.cs:44-55; token misΓÇæfetch via ListTunnelsAsync in DevTunnelManagementClientWrapper.cs:260-284; emptyΓÇæACL Private branch at DevTunnelManagementClientWrapper.cs:159-172; design contradiction at dev-tunnel-host-service.md:485-487. Regressing commits: #517 (d233d835), #521 (fccf4c8e). SDK: Microsoft.DevTunnels.Management/.Contracts 1.3.50 ΓÇö TokenScopes tokens are returned only when retrieving a single tunnel object (GET), never on a list result.
Required Integration Test
In addition to the unit tests above, this fix must be covered by an end-to-end integration test that reproduces the reported scenario (owner, same machine, same GitHub account, Private-mode tunnel) and would have caught this regression:
| Test Name |
Class |
What It Verifies |
DevTunnelPrivateConnect_OwnerSameIdentity_ConnectsWithoutConnectToken |
DevTunnelIntegrationTests |
Starting a Private-mode host tunnel and then resolving + connecting a client as the same owning GitHub identity succeeds end-to-end — the client connects using the identity-derived X-Tunnel-Authorization: tunnel <github-token> path and no Connect-scope token is required (asserts ResolveAsync returns a null tunnelAuthToken in Private mode and the relay connect completes / a request round-trips). This is the exact "playspace 3 debug" → "playspace 3" flow that currently throws "The Management API did not return a Connect-scope tunnel token." |
Requirements for the integration test:
- Exercise the real
DevTunnelManagementClientWrapper + DevTunnelEndpointResolver against the live DevTunnels service (not a mock), authenticated via the machine's GitHub identity (GITHUB_TOKEN / gh auth token), so it validates the actual ListTunnelsAsync/GetTunnelAsync token behavior rather than a stubbed ConnectToken.
- Host side creates/hosts the Private tunnel and applies access mode; client side resolves by tunnel name and connects. Assert a successful connect (and, ideally, a minimal request/response over the tunnel), proving the owner does not need a Connect-scope token in Private mode.
- Gate it so it is skippable when no GitHub tunnel credentials are available in the environment (e.g.
[Trait]/skip-if-no-credentials), consistent with how other network-dependent integration tests in the repo are guarded — but it must run in the environment where tunnel credentials exist.
- A companion assertion that Anonymous mode still returns an anonymous Connect token and connects, so the fix does not regress anonymous tunnels.
Summary
The tunnel owner ΓÇö on the same machine, same GitHub account, and the owner of the tunnel ΓÇö cannot connect a PrivateΓÇæmode dev tunnel by name. Connecting a "playspace 3 debug" client to the "playspace 3" host fails at startup with:
Because the connecting identity is the tunnel owner on the same box/account, this is not an identity/access misconfiguration ΓÇö the owning identity still receives a null Connect token and the app throws.
DevTunnelEndpointResolver.ResolveAsyncrequires a Connect‑scope token for any non‑Anonymous (Private) access mode and throws when it is null. That directly contradicts design point #19 indocs/design/dev-tunnel-host-service.md, which states that Private connect is identity‑derived and returns a nulltunnelAuthToken(the client connects using its GitHub identity, with noAccessTokenSource).Impact: Private playspace connect (e.g. "playspace 3 debug" → "playspace 3") is broken for the owner. The parallel explicit‑access‑point path (
WebRepositorySourcewithUseGitHubAuthToken) already works by sending the GitHub identity token asX-Tunnel-Authorization, so only the tunnelΓÇæname/Private path regressed.Regressed by #517 (
d233d835"use API-issued connect token instead of env-var fallback") and #521 (fccf4c8e"DevTunnel access control end-to-end").Root Cause
1. The resolver treats a null Connect token as fatal in Private mode
features\Phantom.Workspaces\Services\DevTunnel\DevTunnelEndpointResolver.cs:44-55For any nonΓÇæAnonymous mode (Private, and the retired Token mode) a null
ConnectTokenthrows the exact userΓÇævisible message. Per design #19 a null token in Private mode is expected and valid, not fatal.2. The connect token is obtained from the wrong Management API call, so it is null by construction
ClientΓÇæside lookup goes through
LookupByNameAsync/DiscoverSingleAsync→ListWorkspacesTunnelsAsync→ListTunnelsAsync(...), then reads the token off the listed tunnel:features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.cs:260-284CreateConnectRequestOptionssetsTokenScopes = [TunnelAccessScopes.Connect](DevTunnelManagementClientWrapper.cs:317-324), so the intent is thatListTunnelsAsyncmints and returns a per‑tunnel Connect token inTunnel.AccessTokens. The Dev Tunnels SDK does not populate access tokens on a list result.SDK evidence (Microsoft.DevTunnels.Management / .Contracts 1.3.50):
TunnelRequestOptions.TokenScopesXML doc: "an optional list of token scopes that are requested when retrieving a tunnel or tunnel port object. … The service issues an access token for each scope … and returns the token(s) in theTunnel.AccessTokens… dictionary. If the caller does not have permission to get a token for one or more scopes then a token is not returned but the overall request does not fail." — i.e. tokens are keyed to single‑object retrieval (GET), and a missing token is silent (null), never an error.ITunnelManagementClient.ListTunnelsAsyncXML doc lists onlyLabelsandIncludePortsas options that affect the returned tunnels; it says nothing about access tokens, and the SDK implementation callsSendRequestAsync<TunnelListByRegionResponse>withoutPreserveAccessTokens, unlikeGetTunnelAsyncwhich passesReadAccessTokenScopesand callsPreserveAccessTokens(tunnel, result)(cs/src/Management/TunnelManagementClient.cs). The list endpoint returns tunnel metadata, not minted per‑tunnel tokens.So
tunnel.AccessTokens[Connect]is null by construction on the list path ΓÇö regardless of the caller's permissions. The owner is not specialΓÇæcased; nobody gets a token here.Notably, the host side already fetches the connect token correctly, via
GetTunnelAsyncwith a Connect scope:features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.cs:185-194The client path was never updated to use
GetTunnelAsyncthe same way ΓÇö it reads tokens off a list result that never carries them.3. Private mode writes an empty ACL ΓÇö the architecture is identityΓÇæbased, not tokenΓÇæbased
features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.cs:159-172Only the Anonymous branch adds a Connect ACE. The Private branch writes an empty ACL, and
DevTunnelAccessMode.Tokenis retired (WorkspacesConfiguration.cs:32-38). An empty ACL plus retired Token mode is the signature of an identityΓÇæbased Private connect: the owner connects using their own GitHub identity, not a shared Connect token. This matches how the working explicitΓÇæaccessΓÇæpoint path authorizes ΓÇö it sends the GitHub auth token asX-Tunnel-Authorization: tunnel <github-token>(EntityRepository.cs:100-116,WebClientDataAccessLayer.cs:42-46).4. Design contradiction
features\docs\design\dev-tunnel-host-service.md:485-487(point #19):The current resolver requires a Connect token in Private mode and throws when null ΓÇö the exact opposite of the design.
Why it fails for the owner specifically
Same machine / same account / tunnel owner rules out any accessΓÇæcontrol remediation: there is nothing to grant. The failure is mechanical and universal on the tunnelΓÇæname Private path ΓÇö
ListTunnelsAsyncnever returns aConnectaccess token, solookup.ConnectTokenis always null, soResolveAsyncalways throws. The owner hits it because the owner is the one connecting; adding ACEs or reΓÇæauthenticating cannot help because the token is never minted on the list call in the first place, and per design the owner shouldn't need a Connect token at all.Downstream,
DevTunnelEndpointResolution.TunnelAuthTokenflows toWebClientDataAccessLayer(EntityRepository.cs:129-137). A null token there simply omits theX-Tunnel-Authorizationheader (WebClientDataAccessLayer.cs:42-46), so an identityΓÇæbased Private connect must additionally supply the GitHub identity token/refresh resolver the way theUseGitHubAuthTokenpath already does (EntityRepository.cs:100-116).Affected Files
features\Phantom.Workspaces\Services\DevTunnel\DevTunnelEndpointResolver.csConnectTokenis null — the throw site; contradicts design #19.features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.csLookupByNameAsync/DiscoverSingleAsync→ListTunnelsAsync→ToLookupResultreadsAccessTokens[Connect]off a list result that never carries minted tokens.features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.csCreateConnectRequestOptionssetsTokenScopes=[Connect]on a list request, expecting a token that list does not return.features\Phantom.Workspaces\Services\DevTunnel\DevTunnelManagementClientWrapper.csApplyAccessModeAsync: Private writes an empty ACL (identity‑based); the correctGetTunnelAsync‑with‑Connect‑scope token pattern already lives here (host side).features\Phantom.Workspaces\EntityRepository.csWebClientDataAccessLayer; theUseGitHubAuthTokenpath shows the identity‑token mechanism the Private tunnel‑name path should reuse.features\Phantom.Workspaces.Data.Web.Client\WebClientDataAccessLayer.csX-Tunnel-Authorization; identity connect needs the GitHub token + 401 refresh resolver.features\docs\design\dev-tunnel-host-service.mdfeatures\Phantom.Workspaces.Tests\DevTunnelEndpointResolverTests.cs..._WhenConnectTokenIsNull_Throwscurrently codifies the buggy behavior; must be updated.Design / Fix
Fix A ΓÇö Align with design #19: null Connect token is valid in Private mode (RECOMMENDED)
Treat a null
ConnectTokenin Private mode as valid: return a nulltunnelAuthTokenand let the client connect using its GitHub identity. Remove the throw for Private mode.Because a null
tunnelAuthTokencausesWebClientDataAccessLayerto omitX-Tunnel-Authorizationentirely (WebClientDataAccessLayer.cs:42-46), the Private tunnelΓÇæname path must also supply the GitHub identity token the same way the explicitΓÇæaccessΓÇæpoint path does today ΓÇö i.e. inEntityRepository.CreateDevTunnelNameDataAccessLayerAsync, when the token is null in Private mode, pass the GitHub auth token and a refresh resolver:This mirrors the alreadyΓÇæworking
WebRepositorySource+UseGitHubAuthTokenflow (EntityRepository.cs:100-116), keeps Token mode behavior intact, and matches the emptyΓÇæACL/retiredΓÇæToken architecture. Recommended because the empty ACL, the retirement of Token mode, and design #19 all point at identityΓÇæbased Private connect.Fix B ΓÇö Obtain the Connect token via the correct API (complementary / fallback)
If a minted Connect token is genuinely required, fetch it with the API that actually returns tokens ΓÇö
GetTunnelAsyncwithTokenScopes = [Connect]ΓÇö instead of readingAccessTokensoff aListTunnelsAsyncresult that never carries them. This mirrors the host side (DevTunnelManagementClientWrapper.cs:185-194):ACL note: For Fix B to yield a Connect token, the tunnel must grant the caller the Connect scope. Today the Private branch of
ApplyAccessModeAsyncwrites an empty ACL (DevTunnelManagementClientWrapper.cs:159-172); if a token is required, the host would need to add a Connect ACE for the owner/identity rather than an empty ACL. That contradicts the identityΓÇæbased intent, which is why Fix A is preferred. Even under Fix B, the resolver should not throw on a null token in Private mode (Fix A's throw removal still applies).Recommendation: Implement Fix A (remove the PrivateΓÇæmode throw + reuse the GitHub identity token in the tunnelΓÇæname Private path). Optionally adopt Fix B's
GetTunnelAsynccorrection if telemetry shows a minted Connect token is still wanted for crossΓÇæaccount distribution ΓÇö but the architecture (empty ACL, retired Token mode, design #19) is identityΓÇæbased, so Fix A is the correct primary fix.Expected Tests
ResolveAsync_PrivateMode_WhenConnectTokenIsNull_DoesNotThrowDevTunnelEndpointResolverTests..._WhenConnectTokenIsNull_Throws).ResolveAsync_PrivateMode_WhenConnectTokenIsNull_YieldsNullTunnelAuthTokenDevTunnelEndpointResolverTestsTunnelAuthToken(identityΓÇæderived connect per design #19).ResolveAsync_PrivateMode_ConnectsAsOwner_WithoutConnectTokenDevTunnelEndpointResolverTestsResolveAsync_AnonymousMode_ReturnsNullTokenDevTunnelEndpointResolverTestsLookupByNameAsync_PrivateMode_FetchesConnectTokenViaGetTunnelAsyncDevTunnelManagementClientWrapperTestsGetTunnelAsyncwithTokenScopes=[Connect], not from aListTunnelsAsyncresult.ApplyAccessModeAsync_AnonymousMode_ReturnsAnonymousConnectTokenDevTunnelManagementClientWrapperTestsCreateDevTunnelNameDataAccessLayer_PrivateMode_WithNullToken_UsesGitHubIdentityHeaderDevTunnelHostServiceTestsX-Tunnel-Authorization.Diagnosis references: throw at
DevTunnelEndpointResolver.cs:44-55; token misΓÇæfetch viaListTunnelsAsyncinDevTunnelManagementClientWrapper.cs:260-284; emptyΓÇæACL Private branch atDevTunnelManagementClientWrapper.cs:159-172; design contradiction atdev-tunnel-host-service.md:485-487. Regressing commits: #517 (d233d835), #521 (fccf4c8e). SDK: Microsoft.DevTunnels.Management/.Contracts 1.3.50 ΓÇöTokenScopestokens are returned only when retrieving a single tunnel object (GET), never on a list result.Required Integration Test
In addition to the unit tests above, this fix must be covered by an end-to-end integration test that reproduces the reported scenario (owner, same machine, same GitHub account, Private-mode tunnel) and would have caught this regression:
DevTunnelPrivateConnect_OwnerSameIdentity_ConnectsWithoutConnectTokenDevTunnelIntegrationTestsX-Tunnel-Authorization: tunnel <github-token>path and no Connect-scope token is required (assertsResolveAsyncreturns a nulltunnelAuthTokenin Private mode and the relay connect completes / a request round-trips). This is the exact "playspace 3 debug" → "playspace 3" flow that currently throws "The Management API did not return a Connect-scope tunnel token."Requirements for the integration test:
DevTunnelManagementClientWrapper+DevTunnelEndpointResolveragainst the live DevTunnels service (not a mock), authenticated via the machine's GitHub identity (GITHUB_TOKEN/gh auth token), so it validates the actualListTunnelsAsync/GetTunnelAsynctoken behavior rather than a stubbedConnectToken.[Trait]/skip-if-no-credentials), consistent with how other network-dependent integration tests in the repo are guarded — but it must run in the environment where tunnel credentials exist.