fix(wsl): route global CLI fallbacks to user-pinned terminalWindowsWslDistro (#9734)

This commit is contained in:
Tingde Liu 2026-07-24 15:26:25 +08:00 committed by GitHub
parent b5ae776c31
commit 1ace87c155
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 190 additions and 2 deletions

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-21

View File

@ -0,0 +1,3 @@
# wsl-default-distro-gh-fallback
Fallback to configured default WSL distro when host gh CLI is missing

View File

@ -0,0 +1,33 @@
## Context
On Windows hosts, if a user only installs the GitHub CLI (`gh`) or GitLab CLI (`glab`) inside a WSL (Windows Subsystem for Linux) distro and lacks the host executable (`gh.exe` / `glab.exe`), global CLI commands fail with `ENOENT`.
Currently, the system falls back to the first WSL distro returned by the `wsl --list` command, which might not be the distro containing the user's CLI installations or credentials. We need to allow routing these global calls through the user-pinned WSL distro configured in `Store`'s settings.
## Goals / Non-Goals
**Goals:**
- Connect the default fallback WSL distro for global cli commands to the user-pinned `terminalWindowsWslDistro` setting.
- Ensure dynamic updates when the user changes settings.
- Avoid introducing circular dependencies in `runner.ts` (so `runner.ts` must not directly import the settings store).
**Non-Goals:**
- Automatically installing `gh` or `glab` in WSL.
- Altering the WSL routing of commands that run inside a specific worktree / directory (those already carry correct `wslDistro` options).
## Decisions
### 1. In-Memory Setter for defaultWslDistroOverride in runner.ts
We will expose a setter function `setDefaultWslDistroOverride` in [runner.ts](file:///C:/Github/orca/src/main/git/runner.ts) which saves the override in a local variable. `resolveDefaultWslCli` will prioritize this variable over `getDefaultWslDistro()`.
- **Alternatives Considered**:
- Importing `persistence` directly in `runner.ts`: Rejected because it introduces circular dependencies since `persistence` and other services import git/runner helpers.
- Passing `settings` through options on every global call: Rejected because global CLI calls are initiated from various parts of the codebase (e.g. enterprise host status check, user discovery) and updating all call sites is highly intrusive and error-prone.
### 2. Main Process Initialization and Subscription in index.ts
We will wire the initial setting application and listen to configuration updates in the main entry point [index.ts](file:///C:/Github/orca/src/main/index.ts).
- **Alternatives Considered**:
- Wiring it inside `persistence.ts`: Rejected as main process lifecycle is managed in `index.ts` and settings/menu syncs are standardly registered there.
## Risks / Trade-offs
- **Risk**: Pinned WSL distro is not running or takes time to start.
- **Mitigation**: Standard `wsl.exe` command execution already handles launching of the distro if it's currently stopped, matching existing behavior.

View File

@ -0,0 +1,21 @@
## Why
On Windows hosts, if a user only installs the GitHub CLI (`gh`) or GitLab CLI (`glab`) inside a WSL (Windows Subsystem for Linux) distro and lacks the host executable (`gh.exe` / `glab.exe`), global CLI commands (which lack a repository `cwd` to derive a WSL context) fail with `ENOENT`. This is because they currently attempt host execution and fall back to the first WSL distro returned by the `wsl --list` command, which might not be the distro containing the user's CLI installations or configurations, leading to errors or missing command failures.
## What Changes
- Introduce a setter/getter mechanism to override the default WSL distro used for host command fallbacks.
- Update `resolveDefaultWslCli` in `src/main/git/runner.ts` to prioritize the overridden default WSL distro over the first listed WSL distro.
- Wire the initialization and updates of the overridden default WSL distro to follow `terminalWindowsWslDistro` from the global `Store` settings in `src/main/index.ts`.
## Capabilities
### New Capabilities
- `wsl-default-distro-gh-fallback`: Pins the fallback WSL distro for global cli command executions (like `gh` / `glab`) to match the user's `terminalWindowsWslDistro` preference.
### Modified Capabilities
## Impact
- `src/main/git/runner.ts`: Add `setDefaultWslDistroOverride(distro: string | null)` and update `resolveDefaultWslCli`.
- `src/main/index.ts`: Update `setDefaultWslDistroOverride` with initial setting at startup and hook it to settings changes.

View File

@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: Pin Fallback Distro to Preference
The system MUST route global CLI command executions (such as rate limits and project discovery) through the user-pinned `terminalWindowsWslDistro` setting if host executables are missing and a WSL environment is available.
#### Scenario: Fallback executes in pinned WSL distro
- **WHEN** the host GitHub CLI executable is missing, and the user has set `terminalWindowsWslDistro` to `Ubuntu`
- **THEN** global gh commands SHALL fall back to and execute inside the `Ubuntu` WSL distro.

View File

@ -0,0 +1,14 @@
## 1. Core Logic
- [x] 1.1 In `src/main/git/runner.ts`, declare a local module-level variable `defaultWslDistroOverride` and export the setter function `setDefaultWslDistroOverride`.
- [x] 1.2 In `src/main/git/runner.ts`, update `resolveDefaultWslCli` to prioritize `defaultWslDistroOverride` if it is not null.
## 2. Integration and Settings Synchronization
- [x] 2.1 In `src/main/index.ts`, query the initial store settings and call `setDefaultWslDistroOverride` at startup.
- [x] 2.2 In `src/main/index.ts`, update `setDefaultWslDistroOverride` inside `store.onSettingsChanged` when `terminalWindowsWslDistro` changes.
## 3. Testing and Verification
- [x] 3.1 Write a new unit/integration test in `src/main/git/runner-wsl-gh-fallback.test.ts` to assert that fallback resolves to the overridden distro if configured, and falls back to default WSL distro otherwise.
- [x] 3.2 Verify that all tests pass by running `pnpm test`.

20
openspec/config.yaml Normal file
View File

@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours

View File

@ -0,0 +1,12 @@
# wsl-default-distro-gh-fallback Specification
## Purpose
TBD - created by archiving change wsl-default-distro-gh-fallback. Update Purpose after archive.
## Requirements
### Requirement: Pin Fallback Distro to Preference
The system MUST route global CLI command executions (such as rate limits and project discovery) through the user-pinned `terminalWindowsWslDistro` setting if host executables are missing and a WSL environment is available.
#### Scenario: Fallback executes in pinned WSL distro
- **WHEN** the host GitHub CLI executable is missing, and the user has set `terminalWindowsWslDistro` to `Ubuntu`
- **THEN** global gh commands SHALL fall back to and execute inside the `Ubuntu` WSL distro.

View File

@ -20,7 +20,7 @@ vi.mock('../wsl', async (importOriginal) => ({
getDefaultWslDistro: getDefaultWslDistroMock
}))
import { ghExecFileAsync, glabExecFileAsync } from './runner'
import { ghExecFileAsync, glabExecFileAsync, setDefaultWslDistroOverride } from './runner'
import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker'
const PRIMARY_RATE_LIMIT_STDERR =
@ -48,6 +48,7 @@ describe('ghExecFileAsync WSL fallback', () => {
spawnMock.mockReset()
getDefaultWslDistroMock.mockReset()
getDefaultWslDistroMock.mockReturnValue(null)
setDefaultWslDistroOverride(null)
_resetGhRateLimitBreaker()
Object.defineProperty(process, 'platform', {
configurable: true,
@ -624,4 +625,64 @@ describe('ghExecFileAsync WSL fallback', () => {
expect(execFileMock).toHaveBeenCalledTimes(2)
})
it('resolves fallback to the overridden distro if configured, and falls back to default WSL distro otherwise', async () => {
// 1) Test with override configured (should use 'Debian' override)
setDefaultWslDistroOverride('Debian')
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }))
})
.mockImplementationOnce((binary, args, _options, callback) => {
if (binary === 'wsl.exe' && args.includes('Debian')) {
callback(null, { stdout: 'Logged in to github.com as override', stderr: '' })
return
}
callback(new Error('Wrong distro fallback'))
})
await expect(
ghExecFileAsync(['auth', 'status'])
).resolves.toEqual({ stdout: 'Logged in to github.com as override', stderr: '' })
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(execFileMock).toHaveBeenNthCalledWith(
2,
'wsl.exe',
['-d', 'Debian', '--', 'bash', '-c', "'gh' 'auth' 'status'"],
expect.any(Object),
expect.any(Function)
)
// 2) Test without override (should use default 'Ubuntu')
execFileMock.mockClear()
setDefaultWslDistroOverride(null)
execFileMock
.mockImplementationOnce((_binary, _args, _options, callback) => {
callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }))
})
.mockImplementationOnce((binary, args, _options, callback) => {
if (binary === 'wsl.exe' && args.includes('Ubuntu')) {
callback(null, { stdout: 'Logged in to github.com as default', stderr: '' })
return
}
callback(new Error('Wrong distro fallback'))
})
await expect(
ghExecFileAsync(['auth', 'status'])
).resolves.toEqual({ stdout: 'Logged in to github.com as default', stderr: '' })
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(execFileMock).toHaveBeenNthCalledWith(
2,
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "'gh' 'auth' 'status'"],
expect.any(Object),
expect.any(Function)
)
})
})

View File

@ -164,8 +164,15 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand {
}
}
let defaultWslDistroOverride: string | null = null
// Why: allow host commands fallback to route through the user's pinned WSL distro when host execution fails.
export function setDefaultWslDistroOverride(distro: string | null): void {
defaultWslDistroOverride = distro
}
function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null {
const distro = getDefaultWslDistro()
const distro = defaultWslDistroOverride ?? getDefaultWslDistro()
return distro ? resolveCommand(command, args, undefined, distro) : null
}

View File

@ -187,6 +187,7 @@ import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branc
import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output'
import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename'
import { moveWorktree } from './git/worktree'
import { setDefaultWslDistroOverride } from './git/runner'
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
import { parseWorkspaceKey } from '../shared/workspace-scope'
import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state'
@ -1829,7 +1830,13 @@ app.whenReady().then(async () => {
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
logStartupMilestone('store-loaded')
// Why: apply initial fallback WSL distro from store settings for global git/CLI calls.
setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null)
store.onSettingsChanged((updates, settings) => {
if ('terminalWindowsWslDistro' in updates) {
// Why: synchronize fallback WSL distro updates to runner.
setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null)
}
if ('showMenuBarIcon' in updates) {
// Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live.
syncMacMenuBarIcon(settings.showMenuBarIcon !== false)