diff --git a/.assets/config/bin/wslview b/.assets/config/bin/wslview new file mode 100755 index 00000000..e305c328 --- /dev/null +++ b/.assets/config/bin/wslview @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# MSAL browser auth shim - emulate `az login` clickable-link behavior for +# Connect-AzAccount / Connect-MgGraph on headless Linux without a browser opener. +# +# Az/Graph PowerShell start the browser auth-code flow by exec'ing the first opener +# found on PATH from a list hardcoded in MSAL.NET (NetCorePlatformProxy.cs): default +# order xdg-open, gnome-open, kfmclient, microsoft-edge, wslview. `wslview` is the +# last-resort slot - reliably empty on non-desktop Linux (WSL, devcontainers, SSH +# VMs) - so filling it lets MSAL reach a browser instead of falling back to device +# code flow, now blocked by the Entra Conditional Access policy. It is only a PATH +# binary name to MSAL, not a wslu dependency (wslu archived 2025-03), so this shim +# stands regardless of wslu's status. MSAL execs `wslview `; +# we print the URL instead so it can be CTRL+Clicked, and MSAL keeps its localhost +# listener open to catch the post-auth redirect. +# +# Caveat: the redirect only completes where the browser's localhost reaches that +# listener - native on WSL (shared loopback), but over SSH it needs the redirect +# port forwarded, and in VS Code devcontainers MSAL must bind IPv4 (see the +# DOTNET_SYSTEM_NET_DISABLEIPV6 block in setup_profile_user.ps1). +# +# Installed to ~/.local/bin/wslview by setup_profile_user.ps1, only when no real +# MSAL opener (incl. wslu's wslview on Debian-based distros) is already present. +url=$1 +# %s consumes the URL as an argument so percent-encoded chars are not reparsed. +template='\n\e[96mTo sign in, CTRL+Click (or copy) this URL and authenticate in your browser:\e[0m\n\e[4m%s\e[0m\n\n' +# Write straight to the controlling terminal - MSAL swallows child stdout/stderr; +# fall back to stdout when no tty is attached (e.g. non-interactive test runs). +printf "$template" "$url" >/dev/tty 2>/dev/null || printf "$template" "$url" +exit 0 diff --git a/.assets/provision/setup_gh_repos.sh b/.assets/provision/setup_gh_repos.sh index 00d87eb3..cd72c07c 100755 --- a/.assets/provision/setup_gh_repos.sh +++ b/.assets/provision/setup_gh_repos.sh @@ -39,11 +39,26 @@ fi # *clone repositories and add them to workspace file cd ~/source/repos +# track newly-cloned vs. failed repos separately so the summary stays honest: +# a missing dir after the attempt is a real failure (network/DNS/outage), not the +# same as a rerun where the repo is already present. init both so `set -u` is safe. +cloned=false +failed=false for repo in "${gh_repos[@]}"; do IFS='/' read -ra gh_path <<<"$repo" mkdir -p "${gh_path[0]}" pushd "${gh_path[0]}" >/dev/null - git clone "https://github.com/${repo}.git" 2>/dev/null && echo $repo && cloned=true || true + # only clone when absent; a failed clone on a missing dir is a genuine error, + # distinct from the dir already existing on a rerun + if [ ! -d "${gh_path[1]}" ]; then + if git clone "https://github.com/${repo}.git" 2>/dev/null; then + echo "$repo" + cloned=true + else + printf "\e[31;1mfailed to clone %s\e[0m\n" "$repo" >&2 + failed=true + fi + fi if ! grep -qw "$repo" "$ws_path" && [ -d "${gh_path[1]}" ]; then folder="\t{\n\t\t\t\"name\": \"${gh_path[1]}\",\n\t\t\t\"path\": \"..\/repos\/${repo/\//\\\/}\"\n\t\t},\n\t" sed -i "s/\(\]\)/$folder\1/" "$ws_path" @@ -51,6 +66,6 @@ for repo in "${gh_repos[@]}"; do popd >/dev/null done -if [ -z "$cloned" ]; then +if [ "$cloned" = false ] && [ "$failed" = false ]; then printf "\e[32mall repos already cloned\e[0m\n" fi diff --git a/.assets/provision/setup_profile_user.ps1 b/.assets/provision/setup_profile_user.ps1 index b8f13484..a6cc3f67 100755 --- a/.assets/provision/setup_profile_user.ps1 +++ b/.assets/provision/setup_profile_user.ps1 @@ -47,6 +47,48 @@ for ($i = 0; ((Get-Module PSReadLine -ListAvailable).Count -eq 1) -and $i -lt 5; Install-PSResource -Name PSReadLine } +#region wslview shim for MSAL browser auth +# Az/Graph PowerShell (Connect-AzAccount / Connect-MgGraph) start the browser +# auth-code flow by exec'ing the first opener found on PATH from a list hardcoded +# in MSAL.NET (NetCorePlatformProxy.cs GetOpenTool): default order +# xdg-open, gnome-open, kfmclient, microsoft-edge, wslview (broker-configured order +# puts microsoft-edge first). `wslview` is just a PATH binary name to MSAL - NOT a +# wslu dependency (wslu was archived 2025-03), so our shim is unaffected by that. +# On headless Linux (WSL, devcontainers, SSH VMs) none exist, so MSAL falls back to +# device code flow - now blocked by the Entra Conditional Access policy. Filling +# the last-resort wslview slot with a shim makes MSAL print the sign-in URL (like +# `az login`) so it can be CTRL+Clicked, keeping the localhost listener open to +# catch the redirect - no device code. Skip on Windows (WAM) and desktop Linux +# (a real opener already works), and never shadow a real wslview (e.g. wslu). +$shimSource = '.assets/config/bin/wslview' +$wslviewPath = "$HOME/.local/bin/wslview" +# a real opener earlier in MSAL's list means wslview is never reached - skip. +# Must match every entry ahead of wslview in either MSAL order (incl. microsoft-edge). +$hasRealOpener = @('xdg-open', 'gnome-open', 'kfmclient', 'microsoft-edge').Where( + { Get-Command $_ -CommandType Application -ErrorAction SilentlyContinue }, 'First' +) +# a real wslview elsewhere on PATH (not our shim) must not be shadowed +$realWslview = Get-Command wslview -CommandType Application -ErrorAction SilentlyContinue | + Where-Object Source -NE $wslviewPath +# "headless" = Linux with no MSAL browser opener of its own - the only case we +# install the shim. On a desktop Linux with a real opener (or a real wslview), the +# environment can do interactive auth on its own, so leave it alone. (Disabling the +# WAM broker is done separately by the orchestrators, after Az is installed.) +$headlessNoOpener = $IsLinux -and -not $hasRealOpener -and -not $realWslview +if ($headlessNoOpener -and (Test-Path $shimSource -PathType Leaf)) { + # (re)install the shim only when missing or changed + $installed = (Test-Path $wslviewPath -PathType Leaf) ? [System.IO.File]::ReadAllText($wslviewPath) : '' + if ($installed -ne [System.IO.File]::ReadAllText($shimSource)) { + Write-Host 'installing wslview shim for MSAL browser auth...' + $binDir = [IO.Path]::GetDirectoryName($wslviewPath) + if (-not (Test-Path $binDir -PathType Container)) { + New-Item $binDir -ItemType Directory | Out-Null + } + & install -m 0755 $shimSource $wslviewPath + } +} +#endregion + #region $PROFILE.CurrentUserCurrentHost # load existing profile $profileContent = [System.Collections.Generic.List[string]]::new() @@ -211,6 +253,25 @@ if (Test-Path "$HOME/$openCodePath/opencode" -PathType Leaf) { } } +# set up devcontainer MSAL loopback fix +if (-not ($profileContent | Select-String 'DOTNET_SYSTEM_NET_DISABLEIPV6' -SimpleMatch -Quiet)) { + Write-Verbose 'adding devcontainer MSAL loopback fix...' + $profileContent.AddRange( + [string[]]@( + "`n#region devcontainer MSAL loopback fix" + '# Az/Graph interactive login (MSAL) binds the auth-code redirect listener to' + '# IPv6 loopback (::1), but VS Code forwards IPv4 (127.0.0.1), so the redirect' + '# hangs. Force .NET onto IPv4 inside containers so Connect-AzAccount and' + '# Connect-MgGraph complete. az login is unaffected (it binds 127.0.0.1).' + 'if ($env:REMOTE_CONTAINERS -or $env:CODESPACES) {' + " [System.Environment]::SetEnvironmentVariable('DOTNET_SYSTEM_NET_DISABLEIPV6', '1')" + '}' + '#endregion' + ) + ) + $isProfileModified = $true +} + # save profile if modified if ($isProfileModified) { [System.IO.File]::WriteAllText( diff --git a/.assets/scripts/linux_setup.sh b/.assets/scripts/linux_setup.sh index c7788bd1..bc4eff1e 100755 --- a/.assets/scripts/linux_setup.sh +++ b/.assets/scripts/linux_setup.sh @@ -288,6 +288,15 @@ if [ -f /usr/bin/pwsh ]; then Write-Host 'installing Az.ResourceGraph...' Invoke-CommandRetry { Install-PSResource Az.ResourceGraph -ErrorAction Stop } } + # disable the WAM broker: it is a Windows feature, non-functional on Linux, so + # interactive login must fall through to the browser auth-code flow (wslview + # shim). Runs here, after Az.Accounts exists. Idempotent - skip if already off. + # Avoid \$false (bash would expand it inside this double-quoted string); a truthy + # test and numeric 0 for the [bool] param are unambiguous with no escaping. + if ((Get-AzConfig -EnableLoginByWam).Value) { + Write-Host 'disabling WAM login for Az PowerShell...' + Set-AzConfig -EnableLoginByWam 0 -Scope CurrentUser | Out-Null + } " fi fi diff --git a/design/lessons.md b/design/lessons.md index 4dccf7fd..d63abcc0 100644 --- a/design/lessons.md +++ b/design/lessons.md @@ -46,3 +46,18 @@ When adding an entry, link the commit, the rule it produced (under `.claude/rule - **Symptom:** After syncing `do-common` v2.0.0 from `ps-modules`, `wsl_certs_add.ps1`, `vg_cacert_fix.ps1`, and `vg_certs_add.ps1` still called `Get-Certificate -BuildChain`, a parameter the new module version had renamed to `-PresentedChain`. The scripts throw at runtime because the old parameter no longer binds. - **Root cause:** Synced modules (see `ARCHITECTURE.md` § 6.1) are mirrored wholesale from upstream, so a renamed/removed public parameter or function lands here with no local diff to flag it. Consumers *outside* the module directory - provisioning scripts, other modules - keep the old call and break silently until run. - **Rule:** When a `modules_update.ps1` sync changes a synced module's **public surface** (renamed/removed parameter, function, or alias), grep the whole repo for call sites *outside* `modules//` and update them in the same branch. `rg -F ''` across `.assets`, `wsl`, and sibling `modules/` is the completeness check. A synced version bump is a consumer-migration task, not just a file copy. + +## 2026-07 - MSAL browser auth hangs at "Working…" - Windows process shadows the fixed reply port + +- **Commit:** `069187b` (PR #259) +- **Symptom:** `Connect-AzAccount` / `Connect-MgGraph` from bare WSL opened the browser, accepted the account pick, then hung forever at "Working…" on `login.microsoftonline.com` (URL bar never advancing to `localhost`). `az login` worked; the same commands worked in a devcontainer on the *same* WSL distro; it "worked a few hours ago." Clearing the MSAL token cache, toggling IPv6, and re-checking WSL networking all did nothing. +- **Root cause:** Az.Accounts 5.x pins a **fixed** OAuth reply port - `redirect_uri=http://localhost:8400/` (not a random port) - and uses `response_mode=form_post`. A **Windows-side** VS Code utility process (a second window connected to another Coder workspace) was already `LISTENING` on `127.0.0.1:8400`. Under WSL2 NAT + `localhostForwarding`, Windows loopback delivers `localhost:8400` to the **Windows** listener, so AAD's `form_post` callback POSTed the auth code into VS Code (piling up `CLOSE_WAIT`s) and never reached the WSL listener. The devcontainer has its own network namespace, so its 8400 wasn't shadowed; `az` uses a different reply port; the collision only appeared once the other VS Code instance grabbed 8400. +- **Diagnosis technique:** Prove the leg, don't theorize. A GET/POST from Windows (`Invoke-WebRequest`) to a *fresh* WSL port round-tripped 200, but to `8400` it timed out - isolating the fault to the port, not the method/firewall/networking. `netstat -ano | findstr :8400` (or `Get-NetTCPConnection -LocalPort 8400`) on the **Windows** side then named the owning PID. +- **Rule:** When a WSL browser-auth flow hangs *after* account selection but *before* the `localhost` redirect, suspect a **Windows-side listener on the tool's fixed reply port** before touching anything in WSL (cache, IPv6, shim, modules, networkingMode). The blank/"Working…" page is Windows↔AAD traffic that never enters WSL, so no WSL-internal change can fix it. Az PowerShell's port is 8400; check it with `Get-NetTCPConnection -State Listen -LocalPort 8400` on Windows and free it (close/restart the offending app). Unrelated aside confirmed here: `wslview` is a binary name hardcoded in MSAL.NET (`NetCorePlatformProxy.cs`), **not** a wslu dependency - wslu's 2025-03 archival does not affect the shim. + +## 2026-07 - Provisioning step gated on a not-yet-installed module silently no-ops + +- **Commit:** `25324b5` (PR #259) +- **Symptom:** `EnableLoginByWam` was never disabled on a fresh WSL setup, so Az PowerShell interactive login kept failing - even though `setup_profile_user.ps1` contained a block that ran `Update-AzConfig -EnableLoginByWam $false`. It appeared to work when re-running on an already-provisioned machine. +- **Root cause:** The block was gated on `Get-Module Az.Accounts -ListAvailable`, but `setup_profile_user.ps1` runs **before** the Az modules are installed - deliberately, because it first sets `Set-PSResourceRepository -Name PSGallery -Trusted` so the later `Install-PSResource Az` runs unattended. On a fresh box Az.Accounts doesn't exist yet, the gate is false, and the whole block is skipped with no error (`$ErrorActionPreference = 'SilentlyContinue'`). It only *looked* correct on a rerun, where Az was already present from the previous run. +- **Rule:** A provisioning step that depends on a tool/module installed *later in the same run* must live **after** that install, not be guarded by an availability check in an earlier script - the guard turns a hard ordering bug into a silent no-op that only a fresh run exposes. When adding config that needs module X, place it in the orchestrator (`linux_setup.sh` / `wsl_setup.ps1`) right after X is installed, and test on a **fresh** target, not a rerun. Reruns mask install-order bugs because prior state satisfies the guard. diff --git a/wsl/wsl_setup.ps1 b/wsl/wsl_setup.ps1 index 9b2273b7..5af99427 100644 --- a/wsl/wsl_setup.ps1 +++ b/wsl/wsl_setup.ps1 @@ -653,7 +653,16 @@ process { "`tInvoke-CommandRetry { Install-PSResource Az -WarningAction SilentlyContinue -ErrorAction Stop }`n}", 'if (-not (Get-Module -ListAvailable "Az.ResourceGraph")) {', "`tWrite-Host 'installing Az.ResourceGraph...'", - "`tInvoke-CommandRetry { Install-PSResource Az.ResourceGraph -ErrorAction Stop }`n}" + "`tInvoke-CommandRetry { Install-PSResource Az.ResourceGraph -ErrorAction Stop }`n}", + # disable the WAM broker: a Windows feature, non-functional on Linux, so + # interactive login falls through to the browser auth-code flow (wslview + # shim). Runs after Az.Accounts exists. Idempotent - skip if already off. + # Avoid $false in this string: it is re-parsed by `pwsh -c` after crossing + # wsl.exe and evaluates to empty (-> "-ne )" ParserError). Use a truthy + # test and the numeric 0 for the [bool] param instead. + 'if ((Get-AzConfig -EnableLoginByWam).Value) {', + "`tWrite-Host 'disabling WAM login for Az PowerShell...'", + "`tSet-AzConfig -EnableLoginByWam 0 -Scope CurrentUser | Out-Null`n}" ) wsl.exe --distribution $Distro -- pwsh -nop -c $cmd }