Lens on Windows, gcloud in WSL: GKE authentication across the boundary
In a WSL-first setup, all command-line tooling lives inside WSL: gcloud, kubectl, helm, and the kubeconfigs they generate. Windows is only there for GUI applications. That works, until a Windows GUI application needs to run one of those WSL binaries.
Lens is exactly that case. It reads a kubeconfig generated inside WSL, and to talk to a GKE cluster it must refresh access tokens by executing gke-gcloud-auth-plugin, which only exists inside WSL.
This post walks through the four distinct problems hiding in that one sentence and solves each with the mechanism designed for it, no duplicated toolchains, no hand-synced credentials. At the end: how to automate the whole setup so it survives reinstalls.
NOTE
Just want the steps? The Connect Lens to GKE through WSL guide is the condensed, copy-paste version, this post is the why behind each choice.
How GKE kubeconfigs authenticate
A kubeconfig created by gcloud container clusters get-credentials contains no credentials. Instead it has an exec stanza telling the client how to obtain one:
users:
- name: gke_my-project_europe-west4_my-cluster
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
command: gke-gcloud-auth-plugin
provideClusterInfo: trueEvery Kubernetes client (kubectl, Lens, k9s) spawns that command from its own PATH and expects an ExecCredential JSON with a token on stdout. Under the hood the plugin shells out to gcloud config config-helper --format=json, so really it is gcloud minting the token.
The consequence: whoever opens the kubeconfig must be able to find and run gke-gcloud-auth-plugin. Inside WSL that's fine. For Lens on Windows it is not:
ERROR Error while proxying request: getting credentials: exec:
executable gke-gcloud-auth-plugin not foundProblem 1: the plugin lives in WSL
The obvious fix (install the Google Cloud SDK on Windows too) means two SDK installations, two credential stores, and two places to run gcloud auth login. No thanks.
The better fix is a shim: a .cmd file on the Windows PATH, named exactly like the WSL binary, that delegates the call into WSL. Create a directory like %USERPROFILE%\bin, add it to the Windows user PATH, and drop this in as gke-gcloud-auth-plugin.cmd:
@echo off
wsl %~n0 %*%~n0 expands to the script's own filename without extension, so one template serves every shim: save the same two lines as kubectl.cmd and it runs kubectl, as helm.cmd and it runs helm.
The name-matching is the core trick. When the kubeconfig says command: gke-gcloud-auth-plugin:
- inside WSL, PATH resolution finds the real plugin;
- on Windows, PATH resolution finds
gke-gcloud-auth-plugin.cmd, which forwards into WSL.
The same kubeconfig works unmodified on both sides of the boundary.
Problem 2: non-login shells don't know gcloud
The two-line shim fails, this time with command not found inside WSL. wsl starts a non-login shell, and the Google Cloud SDK adds itself to PATH in the login profile.
The reflex fix is wsl bash -l -c "%~n0 %*", and it works, but it drags the entire interactive login profile across: prompt setup, aliases, and crucially any per-shell gcloud environment selection. In practice a leftover CLOUDSDK_ACTIVE_CONFIG_NAME pointing at a deleted named configuration broke every token refresh with a baffling "configuration not found" error, caused by shell state that had nothing to do with the cluster being refreshed.
The lesson: background plumbing should source the minimum it needs. Source only the snippet that puts the SDK on PATH, and nothing else:
@echo off
wsl bash -c ". ~/google-cloud-sdk/path.bash.inc 2>/dev/null; %~n0 %*"Problem 3: which account?
With multiple Google accounts, a clean interactive workflow selects one per shell:
export CLOUDSDK_CORE_ACCOUNT=you@company.comNothing persisted globally, every terminal explicit about its identity. Exactly what you want interactively, and exactly wrong for a background token refresh, which has no shell and no environment:
ERROR: (gcloud.config.config-helper) You do not currently have an active account
selected.The binding cluster X ↔ account A has to live somewhere a background process can find it. The kubeconfig has a standard mechanism for precisely this: exec.env. Clients are required to merge those variables into the plugin's environment. So bake the selected account into the kubeconfig right after fetching it, with yq:
KUBECONFIG="$file" gcloud container clusters get-credentials "$cluster" --zone "$zone"
yq -i ".users[].user.exec.env = [
{\"name\": \"CLOUDSDK_CORE_ACCOUNT\", \"value\": \"${CLOUDSDK_CORE_ACCOUNT}\"},
{\"name\": \"CLOUDSDK_ACTIVE_CONFIG_NAME\", \"value\": \"default\"}]" "$file"exec:
command: gke-gcloud-auth-plugin
env:
- name: CLOUDSDK_CORE_ACCOUNT
value: you@company.com
- name: CLOUDSDK_ACTIVE_CONFIG_NAME
value: defaultPinning CLOUDSDK_ACTIVE_CONFIG_NAME=default shields the refresh from whatever the file-based ~/.config/gcloud/active_config happens to point at, in gcloud's precedence order, environment beats file.
Inside WSL this is already the end of the story: kubectl injects exec.env natively when it spawns the plugin. Each cluster's kubeconfig carries its own account; switch accounts, fetch credentials again, done.
Problem 4: Windows environment variables don't cross into WSL
Lens does its part: it spawns the shim with CLOUDSDK_CORE_ACCOUNT set, in the Windows process. But wsl starts a fresh Linux environment, and Windows variables do not flow in by default. The plugin runs accountless again, with the same error as before.
The bridge is WSLENV: a Windows environment variable holding a colon-separated list of variable names to share across the boundary, each with direction flags:
| Flag | Meaning |
|---|---|
/u | only included when invoking WSL from Win32 |
/w | only included when invoking Win32 from WSL |
/p | translate the path between WSL and Win32 formats |
/l | the value is a list of paths |
For Lens → WSL we need /u, set in the Windows user environment:
[Environment]::SetEnvironmentVariable('WSLENV',
'CLOUDSDK_CORE_ACCOUNT/u:CLOUDSDK_ACTIVE_CONFIG_NAME/u', 'User')(If you already use WSLENV, append to the existing value instead of overwriting it.)
WARNING
The flag names the destination, not the source. /w reads like "Windows-to-WSL" but means the opposite, into Win32. Get it wrong and everything fails silently with the same accountless error. Ask me how I know.
WSLENV is read from the process environment at launch, so Lens must be fully restarted (quit the tray icon too) after setting it.
The full flow
Lens (Windows)
│ reads kubeconfig: exec command + env CLOUDSDK_CORE_ACCOUNT=you@company.com
└─▶ gke-gcloud-auth-plugin.cmd %USERPROFILE%\bin, on the Windows PATH
│ exec.env merged into the Windows process environment
└─▶ wsl bash -c "..." WSLENV bridges CLOUDSDK_* across (/u)
│ sources the SDK PATH snippet
└─▶ gke-gcloud-auth-plugin the real one, inside WSL
└─▶ gcloud config config-helper
reads $CLOUDSDK_CORE_ACCOUNT → token for the right account
◀── ExecCredential JSON flows back up to LensEight hops, but every piece uses the mechanism designed for it: exec.env is the standard Kubernetes way to bind credential config to a cluster entry, WSLENV is the standard Windows way to cross the Win32/WSL boundary, and name-matched shims are the established Lens-plus-WSL pattern.
Alternatives considered
Install the Google Cloud SDK on Windows too. Dismissed in Problem 1: two SDK installations, two credential stores, gcloud auth login twice, and version drift between them. The WSL-first principle (one toolchain, one auth state) is the whole point of the setup.
Install only gke-gcloud-auth-plugin on Windows. Tempting, it is a single Go binary, but the plugin is not self-contained. Unless switched to application default credentials, it shells out to gcloud config config-helper for the actual token (visible in its error messages, which carry the gcloud.config.config-helper prefix). A Windows plugin therefore needs a Windows gcloud with its own authenticated credential store, and you are back at the full-SDK alternative. The ADC escape hatch (--use_application_default_credentials) replaces the credential store with one shared credential, which kills the multi-account workflow from Problem 3.
Copy the kubeconfigs to Windows (or maintain a second set there). Moving the file does not move the problem. A kubeconfig contains no credentials, only the exec reference, so whichever side reads it still has to execute gke-gcloud-auth-plugin from its own PATH. Copying only helps if the copies are also rewritten against Windows-native tooling, which is the full-SDK alternative plus a sync problem: every credential fetch now has to update two files, and they drift the moment you forget. With name-matched shims the file location is irrelevant anyway, Lens reads the kubeconfig straight out of WSL via \\wsl$\..., and the same file works unmodified on both sides.
Set the account in gcloud's config file. gcloud config set account you@company.com writes the account into the persistent configuration, and the accountless background refresh would just work, no exec.env, no WSLENV. But it is a single global default: with clusters across multiple accounts, the last selected account silently wins for every cluster. A named configuration per account (CLOUDSDK_ACTIVE_CONFIG_NAME) fixes the collision but is still selected via an environment variable, which lands you right back at Problem 4.
Forward the variables in the shim itself. Batch expands Windows environment variables inline, so the shim could pass them across explicitly:
wsl bash -c "CLOUDSDK_CORE_ACCOUNT='%CLOUDSDK_CORE_ACCOUNT%' ... %~n0 %*"Self-contained (no global WSLENV setting, no Lens restart) but every shim now carries batch quoting fragility (if defined guards for unset variables, values with special characters), and the template stops being generic: it must know which variables matter for which tool. WSLENV is the platform mechanism for exactly this job; using it keeps the shims dumb.
Per-cluster wrapper scripts. The credentials-fetching step could write a wrapper per cluster (gke-auth-<cluster>) that exports the account and execs the plugin, with the kubeconfig pointing at the wrapper instead of the plugin. No environment bridging at all, but shims would have to be generated per cluster at fetch time instead of being created once up front, the kubeconfig loses the standard plugin name, and both Windows and WSL need per-cluster artifacts on their PATH. More moving parts, in a worse place.
Each alternative trades the boundary problem for state duplication, global mutable state, file synchronization, or runtime-generated artifacts. The chosen design keeps the account binding declarative in the kubeconfig and the boundary crossing in the platform feature built for it.
Automating it with Ansible
Everything above is a pile of small manual steps spread across two operating systems, exactly the kind of setup that silently rots until the next reinstall. The dotfilespro.roles Ansible collection automates all of it.
The wsl role owns every Windows-boundary concern. It detects which commands from wsl_windows_shims actually exist in WSL and writes a shim for each into %USERPROFILE%\bin\; ensures that directory exists and is on the Windows user PATH; and merges every variable in wsl_wslenv into the Windows user WSLENV with the /u flag, replacing stale same-name entries, so a flag correction propagates on the next run.
The Windows-side changes go through PowerShell invoked by its absolute path, since with appendWindowsPath = false no Windows executable is on the WSL PATH. Everything is idempotent: unchanged runs report ok.
---
wsl_windows_shims:
- gke-gcloud-auth-plugin
- gcloud
- kubectl
- helm
wsl_wslenv:
- CLOUDSDK_CORE_ACCOUNT
- CLOUDSDK_ACTIVE_CONFIG_NAMEThe deployed shim sources ~/.profile.d/googlecloud.sh, the minimal SDK PATH snippet from Problem 2, deployed by the googlecloud role:
@echo off
rem WSL shim — delegates %~n0 to the WSL default distro.
rem Managed by Ansible (dotfilespro.roles wsl role).
wsl bash -c ". ~/.profile.d/google.sh 2>/dev/null; %~n0 %*"The googlecloud role also provides the shell functions for the day-to-day workflow: gcp-account exports CLOUDSDK_CORE_ACCOUNT for the current shell, and gcp-kubeconfig wraps get-credentials plus the yq patch from Problem 3, the account is baked in automatically, no manual yq invocation. The ubuntu role installs yq from GitHub releases and keeps it up-to-date.
After one playbook run, connecting Lens to a new cluster is two commands in WSL:
gcp-account you@company.com # select the account for this shell
gcp-kubeconfig my-cluster # fetch credentials, account baked in via yqPoint Lens at the kubeconfig and it refreshes tokens through WSL indefinitely.
Debugging checklist
When something in the chain breaks, walk it in order:
- Is the shim found on Windows?:
where gke-gcloud-auth-pluginin cmd should print the.cmdpath. - Does the shim run?: invoke
gke-gcloud-auth-pluginin cmd directly; it should print anExecCredentialJSON or a gcloud error (a gcloud error means the shim and PATH sourcing work). - Does the env bridge work?: in a new cmd window:No output means WSLENV is missing, has the wrong flag, or the window predates the change.
set CLOUDSDK_CORE_ACCOUNT=test@example.com wsl bash -c "echo $CLOUDSDK_CORE_ACCOUNT" - What does Windows think WSLENV is?,
powershell "[Environment]::GetEnvironmentVariable('WSLENV','User')" - Is the account baked into the kubeconfig?: check for the
exec.envstanza withyq '.users[].user.exec.env' <kubeconfig>. - Restart Lens fully: the tray icon keeps the old environment alive.