You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Dev tunnel: "Dev tunnel name" field is disconnected from real tunnels (identified by labels, not SDK name); connect lookup silently fails; endpoint URL uses wrong host token #1292
The Settings → Remote access → "Dev tunnel name" field (and the identical wizard field from #1291) is disconnected from how real Phantom.Workspaces dev tunnels are actually identified, so LookupByNameAsync at connect time silently fails to find tunnels that are visibly hosted and healthy.
Concretely, the owner set "Dev tunnel name" = daemon-2 (screenshot confirms), but devtunnel list --json shows that no real tunnel has a name matching that — in fact every real Phantom.Workspaces tunnel has "name": "" (empty), and each is only distinguished by labels:
The live jolly-fog-cvqm81w tunnel carries a STALE second label (phantom-workspaces-playspace) that was written under a previous TunnelName value. EnsureTunnelAsync reuses that tunnel by persisted TunnelId without changing its labels, so LookupByNameAsync("daemon-2") matches zero tunnels even though jolly-fog-cvqm81w is the tunnel the user believes they're using.
Two defects are covered here:
Primary — Name/label mismatch, stale-label failure. The UI says "Dev tunnel name" and connect calls LookupByNameAsync(TunnelName), but tunnels are not identified by Tunnel.Name (deliberately left empty because "allow custom tunnel names" 403s on most accounts — DevTunnelManagementClientWrapper.cs:61-64). Instead they're identified by a label whose value is the raw TunnelName string. So when the user changes TunnelName, the persisted TunnelId continues to reuse the same tunnel — whose second label is now stale — and connect fails silently.
Secondary — Endpoint URL host-token construction is wrong (CONFIRMED BUG).DevTunnelEndpointResolver.cs:43 builds https://{lookup.TunnelId}-{port}.{lookup.ClusterId}.devtunnels.ms/ from the friendly tunnelId (e.g. jolly-fog-cvqm81w), producing https://jolly-fog-cvqm81w-5280.usw2.devtunnels.ms/. But the Management API's actual portUri for the same port is https://lgk3svvr-5280.usw2.devtunnels.ms/ — the host token (lgk3svvr) is not the friendly tunnel id. The correct URL is exposed via Tunnel.Endpoints[*].PortUriFormat (with TunnelEndpoint.PortToken replaced) or TunnelPort.PortForwardingUris[], and the wrapper's own GetAccessPointUrlAsync already uses these correctly (DevTunnelManagementClientWrapper.cs:197-228) — the endpoint resolver duplicates that logic and gets it wrong.
where WorkspacesMarkerLabel = "phantom-workspaces". Labels are only written on the create path (line 94); the reuse-by-TunnelId path never touches labels, so a stale second label survives across TunnelName edits. Tunnel.Name is asserted null by test DevTunnelManagementClientWrapperTests.cs:42 (Assert.Null(createdTunnel!.Name);).
vartunnel=markerTunnels.FirstOrDefault(candidate =>HasLabel(candidate,tunnelName))??thrownewInvalidOperationException($"Dev tunnel '{tunnelName}' was not found.");
with HasLabel (:291-294) doing an ordinal compare against Tunnel.Labels. DiscoverSingleAsync (:242-253) matches on the marker label only and throws "Multiple Workspaces dev tunnels were found; set a specific dev tunnel name instead of \"auto\"." when markerTunnels.Count > 1 — the message does not enumerate the candidates.
So host and connect agree on the identifier scheme (phantom-workspaces + raw TunnelName label), but the UI wording is misleading, the field is invisible in devtunnel list output, and — critically — reusing a tunnel by TunnelId allows the second label to drift out of sync with the field, causing silent connect failure.
Endpoint URL construction (secondary — CONFIRMED BUG)
DevTunnelLookupResult.TunnelId is the friendly tunnel id (e.g. jolly-fog-cvqm81w). The real per-port URI uses a different host token — devtunnel show for jolly-fog-cvqm81w+5280 returns https://lgk3svvr-5280.usw2.devtunnels.ms/. The Management API exposes the correct URLs via Tunnel.Endpoints[0].PortUriFormat (with TunnelEndpoint.PortToken replaced) and TunnelPort.PortForwardingUris, and GetAccessPointUrlAsync (:197-228) already consumes them correctly.
Design / Fix
Two independent fixes.
Fix 1 — Select or create tunnels by label; NEVER relabel an existing tunnel
Owner constraint (governing):"We should not ever replace labels on tunnels; users should be able to create multiple tunnels if that's what they really want to do." This overrides any reconcile/relabel approach.
Therefore the fix is driven entirely by matching the configured label; existing tunnels' labels are never mutated. A user who changes the configured name simply gets a new tunnel, and may end up with multiple phantom-workspaces-labelled tunnels, which is acceptable.
Host — match by label, else create. In EnsureTunnelAsync (DevTunnelManagementClientWrapper.cs:56-103), change the reuse policy so the configured label is the primary key:
If any tunnel in markerTunnels has both phantom-workspaces and the configured nameLabel, host on it. (This is the existing label-match branch at lines 77–80 — keep it, but promote it above the TunnelId branch so a stale-TunnelId situation doesn't win against a fresh label match.)
If none matches, fall through to CreateTunnelAsync(new Tunnel { Labels = BuildLabels(nameLabel) }, ...) (the existing create path at lines 92–97) — i.e. create a NEW tunnel with the configured labels.
Do not call ManagementClient.UpdateTunnelAsync on any existing tunnel to rewrite its labels. BuildLabels remains write-once at create time.
The persisted TunnelId becomes a hint for the create path (still stored on the descriptor), not an override that forces reuse of a mislabelled tunnel. In particular, the auto path (empty nameLabel) continues to behave as today: match any single phantom-workspaces tunnel.
Connect — look up strictly by label.LookupByNameAsync (:230-240) already matches by label; no change to the matcher. No fallback/reconciliation is performed against a stale TunnelId.
auto multi-candidate — actionable error, no relabeling.DiscoverSingleAsync (:242-253) should keep throwing on markerTunnels.Count > 1 (never relabel), but the message must enumerate the candidates — for each phantom-workspaces-labelled tunnel, list TunnelId + the second label (or (no label)) + port count — so the user can copy the label they want into the field. The helper text already tells users to use auto only when there is a single Workspaces tunnel; the error now reinforces that with concrete choices.
UI clarification (wizard + Settings, shared). The field is a label, not an SDK tunnel name. Either:
Rename to "Dev tunnel label" in the helper text/label surface (RemoteAccessSettingsViewModel.TunnelNameHelperText), or
Add a read-only "Current tunnel" display showing the resolved TunnelId and the second label so the user can reconcile against devtunnel list --labels.
Because the setup-wizard DevTunnelWeb field and Settings → Remote access both bind through RemoteAccessSettingsViewModel, apply the change once in the shared VM so both surfaces stay consistent. Coordinate with Setup wizard: DevTunnelWeb should require dev tunnel name (default auto), not an endpoint URL (endpoint is autodiscovered) #1291 — do not duplicate its wizard rework.
Fix 2 — Use the Management API's port URI instead of building a wrong one
In DevTunnelEndpointResolver.ResolveAsync, replace the string-interpolated URL at DevTunnelEndpointResolver.cs:43 with the API-supplied URL by threading PortUriFormat / PortForwardingUris through DevTunnelLookupResult. Sketch:
// DevTunnelLookupResult.cspublicsealedrecordDevTunnelLookupResult(stringTunnelId,stringClusterId,IReadOnlyList<int>ForwardedPorts,string?PortUriFormat,// NEW: Tunnel.Endpoints[0].PortUriFormatIReadOnlyDictionary<int,string>PortForwardingUris,// NEW: Tunnel.Ports[*].PortForwardingUris[0]string?ConnectToken);// DevTunnelManagementClientWrapper.ToLookupResult(...)// populate the two new fields from the tunnel returned by the Management API.// DevTunnelEndpointResolver.cs:43 (rewritten)varport=lookup.ForwardedPorts[0];varbaseUri=ResolvePortUri(lookup,port)??thrownewInvalidOperationException($"Dev tunnel '{lookup.TunnelId}' port {port} did not expose a public access point.");staticUri?ResolvePortUri(DevTunnelLookupResultlookup,intport){if(lookup.PortUriFormatis{Length:>0}format){returnnewUri(format.Replace(TunnelEndpoint.PortToken,port.ToString(CultureInfo.InvariantCulture),StringComparison.Ordinal));}returnlookup.PortForwardingUris.TryGetValue(port,outvarurl)?newUri(url):null;}
This eliminates the wrong {friendlyTunnelId}-{port} host token and matches what GetAccessPointUrlAsync already does correctly.
Considered / Background — REJECTED: reconcile/replace labels on the existing tunnel
An earlier version of this bug proposed "reconcile on host: when the persisted TunnelId resolves to a tunnel whose second label differs from the current TunnelName, call ManagementClient.UpdateTunnelAsync to rewrite the labels to ["phantom-workspaces", <TunnelName>]", and a symmetric "reconcile on connect" fallback that would treat a stale-labelled owned tunnel as a match. This is rejected per the owner constraint above: "We should not ever replace labels on tunnels; users should be able to create multiple tunnels if that's what they really want to do." Recorded here so future readers understand why the host path creates a new tunnel on label mismatch rather than mutating the existing one.
Two phantom-workspaces tunnels exist, one labelled daemon-2; call with nameLabel = "daemon-2" → hosts on that tunnel, UpdateTunnelAsync is never called, no CreateTunnelAsync call.
Lookup result has PortUriFormat = "https://lgk3svvr-{port}.usw2.devtunnels.ms/" → resolved URI host token is lgk3svvr-<port>, not {friendlyTunnelId}-<port>.
#1291 (closed) fixed the setup wizard's DevTunnelWeb sub-view to collect a tunnel name defaulting to auto and reused RemoteAccessSettingsViewModel.TunnelNameHelperText so wizard and Settings surface the same string. That fix assumed the "name" concept was sound — this bug shows the value is really a label. Any rename ("Dev tunnel label"), helper-text change, or "current tunnel" read-only display must be applied via the shared RemoteAccessSettingsViewModel so wizard and Settings stay consistent. Do not duplicate #1291's wizard rework.
Summary
The Settings → Remote access → "Dev tunnel name" field (and the identical wizard field from #1291) is disconnected from how real Phantom.Workspaces dev tunnels are actually identified, so
LookupByNameAsyncat connect time silently fails to find tunnels that are visibly hosted and healthy.Concretely, the owner set "Dev tunnel name" =
daemon-2(screenshot confirms), butdevtunnel list --jsonshows that no real tunnel has anamematching that — in fact every real Phantom.Workspaces tunnel has"name": ""(empty), and each is only distinguished by labels:The live
jolly-fog-cvqm81wtunnel carries a STALE second label (phantom-workspaces-playspace) that was written under a previousTunnelNamevalue.EnsureTunnelAsyncreuses that tunnel by persistedTunnelIdwithout changing its labels, soLookupByNameAsync("daemon-2")matches zero tunnels even thoughjolly-fog-cvqm81wis the tunnel the user believes they're using.Two defects are covered here:
LookupByNameAsync(TunnelName), but tunnels are not identified byTunnel.Name(deliberately left empty because "allow custom tunnel names" 403s on most accounts —DevTunnelManagementClientWrapper.cs:61-64). Instead they're identified by a label whose value is the rawTunnelNamestring. So when the user changesTunnelName, the persistedTunnelIdcontinues to reuse the same tunnel — whose second label is now stale — and connect fails silently.DevTunnelEndpointResolver.cs:43buildshttps://{lookup.TunnelId}-{port}.{lookup.ClusterId}.devtunnels.ms/from the friendly tunnelId (e.g.jolly-fog-cvqm81w), producinghttps://jolly-fog-cvqm81w-5280.usw2.devtunnels.ms/. But the Management API's actualportUrifor the same port ishttps://lgk3svvr-5280.usw2.devtunnels.ms/— the host token (lgk3svvr) is not the friendly tunnel id. The correct URL is exposed viaTunnel.Endpoints[*].PortUriFormat(withTunnelEndpoint.PortTokenreplaced) orTunnelPort.PortForwardingUris[], and the wrapper's ownGetAccessPointUrlAsyncalready uses these correctly (DevTunnelManagementClientWrapper.cs:197-228) — the endpoint resolver duplicates that logic and gets it wrong.Root Cause
Host-time labeling (what actually identifies tunnels)
DevTunnelHostService.cs:37-39handsconfiguration.TunnelNameto the wrapper:EnsureTunnelAsync(DevTunnelManagementClientWrapper.cs:56-103) currently:ListWorkspacesTunnelsAsync(...)at line 68).TunnelIdif the persisted id matches (lines 72–75).nameLabelmatches (lines 77–80:tunnel = markerTunnels.FirstOrDefault(candidate => HasLabel(candidate, nameLabel))).markerTunnels.Count > 1throws.CreateTunnelAsync(new Tunnel { Labels = BuildLabels(nameLabel) }, ...)(lines 92–97).BuildLabels(:286-289):where
WorkspacesMarkerLabel = "phantom-workspaces". Labels are only written on the create path (line 94); the reuse-by-TunnelIdpath never touches labels, so a stale second label survives acrossTunnelNameedits.Tunnel.Nameis asserted null by testDevTunnelManagementClientWrapperTests.cs:42(Assert.Null(createdTunnel!.Name);).Connect-time lookup
DevTunnelEndpointResolver.cs:28-33routes onDevTunnelNaming.IsAuto:LookupByNameAsync(DevTunnelManagementClientWrapper.cs:230-240) matches on the label, notTunnel.Name:with
HasLabel(:291-294) doing an ordinal compare againstTunnel.Labels.DiscoverSingleAsync(:242-253) matches on the marker label only and throws"Multiple Workspaces dev tunnels were found; set a specific dev tunnel name instead of \"auto\"."whenmarkerTunnels.Count > 1— the message does not enumerate the candidates.So host and connect agree on the identifier scheme (
phantom-workspaces+ rawTunnelNamelabel), but the UI wording is misleading, the field is invisible indevtunnel listoutput, and — critically — reusing a tunnel byTunnelIdallows the second label to drift out of sync with the field, causing silent connect failure.Endpoint URL construction (secondary — CONFIRMED BUG)
DevTunnelEndpointResolver.cs:43:DevTunnelLookupResult.TunnelIdis the friendly tunnel id (e.g.jolly-fog-cvqm81w). The real per-port URI uses a different host token —devtunnel showforjolly-fog-cvqm81w+5280 returnshttps://lgk3svvr-5280.usw2.devtunnels.ms/. The Management API exposes the correct URLs viaTunnel.Endpoints[0].PortUriFormat(withTunnelEndpoint.PortTokenreplaced) andTunnelPort.PortForwardingUris, andGetAccessPointUrlAsync(:197-228) already consumes them correctly.Design / Fix
Two independent fixes.
Fix 1 — Select or create tunnels by label; NEVER relabel an existing tunnel
Owner constraint (governing): "We should not ever replace labels on tunnels; users should be able to create multiple tunnels if that's what they really want to do." This overrides any reconcile/relabel approach.
Therefore the fix is driven entirely by matching the configured label; existing tunnels' labels are never mutated. A user who changes the configured name simply gets a new tunnel, and may end up with multiple
phantom-workspaces-labelled tunnels, which is acceptable.EnsureTunnelAsync(DevTunnelManagementClientWrapper.cs:56-103), change the reuse policy so the configured label is the primary key:markerTunnelshas bothphantom-workspacesand the configurednameLabel, host on it. (This is the existing label-match branch at lines 77–80 — keep it, but promote it above theTunnelIdbranch so a stale-TunnelIdsituation doesn't win against a fresh label match.)CreateTunnelAsync(new Tunnel { Labels = BuildLabels(nameLabel) }, ...)(the existing create path at lines 92–97) — i.e. create a NEW tunnel with the configured labels.ManagementClient.UpdateTunnelAsyncon any existing tunnel to rewrite its labels.BuildLabelsremains write-once at create time.TunnelIdbecomes a hint for the create path (still stored on the descriptor), not an override that forces reuse of a mislabelled tunnel. In particular, theautopath (emptynameLabel) continues to behave as today: match any singlephantom-workspacestunnel.LookupByNameAsync(:230-240) already matches by label; no change to the matcher. No fallback/reconciliation is performed against a staleTunnelId.automulti-candidate — actionable error, no relabeling.DiscoverSingleAsync(:242-253) should keep throwing onmarkerTunnels.Count > 1(never relabel), but the message must enumerate the candidates — for eachphantom-workspaces-labelled tunnel, listTunnelId+ the second label (or(no label)) + port count — so the user can copy the label they want into the field. The helper text already tells users to useautoonly when there is a single Workspaces tunnel; the error now reinforces that with concrete choices.RemoteAccessSettingsViewModel.TunnelNameHelperText), orTunnelIdand the second label so the user can reconcile againstdevtunnel list --labels.Because the setup-wizard
DevTunnelWebfield and Settings → Remote access both bind throughRemoteAccessSettingsViewModel, apply the change once in the shared VM so both surfaces stay consistent. Coordinate with Setup wizard: DevTunnelWeb should require dev tunnel name (default auto), not an endpoint URL (endpoint is autodiscovered) #1291 — do not duplicate its wizard rework.Fix 2 — Use the Management API's port URI instead of building a wrong one
In
DevTunnelEndpointResolver.ResolveAsync, replace the string-interpolated URL atDevTunnelEndpointResolver.cs:43with the API-supplied URL by threadingPortUriFormat/PortForwardingUristhroughDevTunnelLookupResult. Sketch:This eliminates the wrong
{friendlyTunnelId}-{port}host token and matches whatGetAccessPointUrlAsyncalready does correctly.Considered / Background — REJECTED: reconcile/replace labels on the existing tunnel
An earlier version of this bug proposed "reconcile on host: when the persisted
TunnelIdresolves to a tunnel whose second label differs from the currentTunnelName, callManagementClient.UpdateTunnelAsyncto rewrite the labels to["phantom-workspaces", <TunnelName>]", and a symmetric "reconcile on connect" fallback that would treat a stale-labelled owned tunnel as a match. This is rejected per the owner constraint above: "We should not ever replace labels on tunnels; users should be able to create multiple tunnels if that's what they really want to do." Recorded here so future readers understand why the host path creates a new tunnel on label mismatch rather than mutating the existing one.Expected Tests
Naming style
Subject_Scenario_ExpectedOutcome, matching existing classes (DevTunnelManagementClientWrapperTests,DevTunnelHostServiceTests,DevTunnelEndpointResolverTests).DevTunnelManagementClientWrapperTestsEnsureTunnelAsync_WhenExistingTunnelMatchesConfiguredLabel_HostsOnItWithoutMutatingLabelsphantom-workspacestunnels exist, one labelleddaemon-2; call withnameLabel = "daemon-2"→ hosts on that tunnel,UpdateTunnelAsyncis never called, noCreateTunnelAsynccall.DevTunnelManagementClientWrapperTestsEnsureTunnelAsync_WhenNoExistingTunnelMatchesConfiguredLabel_CreatesNewTunnelAndLeavesOthersUntouchedphantom-workspaces-playspaceetc.; call withnameLabel = "daemon-2"→CreateTunnelAsyncinvoked with["phantom-workspaces", "daemon-2"]; existing tunnels receive zeroUpdateTunnelAsynccalls.DevTunnelManagementClientWrapperTestsEnsureTunnelAsync_WhenPersistedTunnelIdIsStaleAndLabelMatchesAnotherTunnel_PrefersLabelMatchTunnelIdpoints at a mislabelled tunnel; a different tunnel carries the configured label → host on the label match, no relabeling.DevTunnelManagementClientWrapperTestsLookupByNameAsync_MatchesTunnelByLabel_NotByTunnelNameTunnel.Nameis empty on all candidates; label match wins.DevTunnelManagementClientWrapperTestsDiscoverSingleAsync_WhenMultipleWorkspacesTunnelsExist_ThrowsWithCandidateLabelsListedphantom-workspacestunnels → thrown message enumerates each candidate'sTunnelId+ second label; noUpdateTunnelAsyncinvoked.DevTunnelEndpointResolverTestsResolveAsync_UsesPortUriFormatFromLookupResult_NotFriendlyTunnelIdPortUriFormat = "https://lgk3svvr-{port}.usw2.devtunnels.ms/"→ resolved URI host token islgk3svvr-<port>, not{friendlyTunnelId}-<port>.DevTunnelEndpointResolverTestsResolveAsync_WhenPortUriFormatMissing_FallsBackToPortForwardingUrisPortUriFormat,PortForwardingUrishas entry for port → resolver returns that URI.DevTunnelEndpointResolverTestsResolveAsync_WhenNoPortUriExposed_ThrowsMentioningTunnelAndPortPortUriFormatnorPortForwardingUrispopulated → thrown message includes tunnel id + port.DevTunnelHostServiceTestsStartAsync_WithConfiguredLabelMatchingExistingTunnel_HostsWithoutMutatingLabelsTunnelName = "daemon-2"and an existing matching tunnel →EnsureTunnelAsyncreuses it; no label mutation.DevTunnelHostServiceTestsStartAsync_WithConfiguredLabelNotMatchingAnyTunnel_CreatesNewTunnelTunnelName = "daemon-2"and no matching tunnel → new tunnel created; pre-existing tunnels unchanged.RemoteAccessSettingsViewModelTestsTunnelNameHelperText_MentionsLabelSemanticsRemoteAccessSettingsViewModel.Relationship to #1291
#1291 (closed) fixed the setup wizard's
DevTunnelWebsub-view to collect a tunnel name defaulting toautoand reusedRemoteAccessSettingsViewModel.TunnelNameHelperTextso wizard and Settings surface the same string. That fix assumed the "name" concept was sound — this bug shows the value is really a label. Any rename ("Dev tunnel label"), helper-text change, or "current tunnel" read-only display must be applied via the sharedRemoteAccessSettingsViewModelso wizard and Settings stay consistent. Do not duplicate #1291's wizard rework.