From c58591e56d9f109274c8997cf04adc24e54bb00c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:33:15 -0700 Subject: [PATCH] fix(wsl): refuse render-driven hook checks on a stopped distro instead of booting it (#35155) hooks:check takes an optional background flag. The sidebar setup-script prompt and the setup guide's progress probe, which run on render, pass it: on a stopped or unverifiable WSL distro the check answers status 'error' without opening orca.yaml over \\\\wsl.localhost, which would boot the distro. Launch preflight and settings, user acts that start the distro anyway, are unchanged. --- .../register-worktree-hook-check-handler.ts | 8 ++- ...orktree-hook-check-background-gate.test.ts | 65 +++++++++++++++++++ src/preload/api/agent-hook-api.ts | 3 +- src/preload/api/hooks-bridge.ts | 2 +- .../setup-guide/use-setup-guide-progress.ts | 6 +- .../sidebar/SetupScriptPromptCard.tsx | 2 +- .../src/runtime/runtime-hooks-client.test.ts | 12 ++++ .../src/runtime/runtime-hooks-client.ts | 10 ++- 8 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 src/main/ipc/hooks/worktree-hook-check-background-gate.test.ts diff --git a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts index 64296ccb068..4547bcfad5d 100644 --- a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts +++ b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts @@ -3,6 +3,7 @@ import { getRepoDialableSshTargetId, type ExecutionHostId } from '../../../share import { isFolderRepo } from '../../../shared/repo-kind' import { hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks' import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' +import { blockedWslDistroForPath } from '../../wsl/wsl-unc-access-gate' import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): void { @@ -10,7 +11,7 @@ export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): v ipcMain.handle( 'hooks:check', - async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { + async (_event, args: { repoId: string; hostId?: ExecutionHostId; background?: boolean }) => { const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) if (!repo) { const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId) @@ -35,6 +36,11 @@ export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): v } } + // A render-driven check must not open a stopped distro's `\\wsl.localhost` paths (E9). + if (args.background && (await blockedWslDistroForPath(repo.path))) { + return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } + } + const has = hasHooksFile(repo.path) const hooks = has ? loadHooks(repo.path) : null // Well-formed unknown keys may come from a newer Orca; suggest updating instead of parse repair. diff --git a/src/main/ipc/hooks/worktree-hook-check-background-gate.test.ts b/src/main/ipc/hooks/worktree-hook-check-background-gate.test.ts new file mode 100644 index 00000000000..61b930ea874 --- /dev/null +++ b/src/main/ipc/hooks/worktree-hook-check-background-gate.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + hasHooksFile: vi.fn((_path: string) => false), + blocked: vi.fn<(path: string) => Promise<{ distro: string; state: 'stopped' } | null>>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => + mocks.handlers.set(channel, handler) + } +})) +vi.mock('../../hooks', () => ({ + hasHooksFile: mocks.hasHooksFile, + loadHooks: () => null, + hasUnrecognizedOrcaYamlKeys: () => false +})) +vi.mock('../../wsl/wsl-unc-access-gate', () => ({ blockedWslDistroForPath: mocks.blocked })) + +import { registerWorktreeHookCheckHandler } from './register-worktree-hook-check-handler' + +const UNC = String.raw`\\wsl.localhost\Ubuntu\home\u\app` + +function check(args: { background?: boolean }) { + const repo: Partial = { id: 'repo', path: UNC, kind: 'git', executionHostId: 'local' } + const store = { getRepos: () => [repo], getRepo: () => repo } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The handler reads only these store accessors. + registerWorktreeHookCheckHandler({ store } as never) + return mocks.handlers.get('hooks:check')!(null, { repoId: 'repo', ...args }) +} + +describe('hooks:check on a stopped WSL distro', () => { + beforeEach(() => { + mocks.hasHooksFile.mockClear() + mocks.blocked.mockReset() + mocks.blocked.mockResolvedValue({ distro: 'Ubuntu', state: 'stopped' }) + }) + + it('refuses a background check without opening orca.yaml, so it cannot boot the distro', async () => { + await expect(check({ background: true })).resolves.toEqual({ + status: 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + }) + expect(mocks.blocked).toHaveBeenCalledWith(UNC) + expect(mocks.hasHooksFile).not.toHaveBeenCalled() + }) + + it('still reads for a launch, which is a user act that starts the distro anyway', async () => { + await expect(check({})).resolves.toMatchObject({ status: 'ok' }) + expect(mocks.blocked).not.toHaveBeenCalled() + expect(mocks.hasHooksFile).toHaveBeenCalledWith(UNC) + }) + + it('reads a running distro for a background check as before', async () => { + mocks.blocked.mockResolvedValue(null) + + await expect(check({ background: true })).resolves.toMatchObject({ status: 'ok' }) + expect(mocks.hasHooksFile).toHaveBeenCalledWith(UNC) + }) +}) diff --git a/src/preload/api/agent-hook-api.ts b/src/preload/api/agent-hook-api.ts index db222d45332..c67a40ecb8a 100644 --- a/src/preload/api/agent-hook-api.ts +++ b/src/preload/api/agent-hook-api.ts @@ -3,7 +3,8 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { SetupScriptImportCandidate } from '../../shared/setup-script-imports' export type HooksApi = { - check: (args: { repoId: string; hostId?: ExecutionHostId }) => Promise<{ + /** `background`: a render-driven probe, refused (status 'error') rather than booting a stopped WSL distro. */ + check: (args: { repoId: string; hostId?: ExecutionHostId; background?: boolean }) => Promise<{ status?: 'ok' | 'error' hasHooks: boolean hooks: OrcaHooks | null diff --git a/src/preload/api/hooks-bridge.ts b/src/preload/api/hooks-bridge.ts index a4d846d235d..6c32f026125 100644 --- a/src/preload/api/hooks-bridge.ts +++ b/src/preload/api/hooks-bridge.ts @@ -3,7 +3,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { PreloadApi } from '../api-types' export const hooksApi = { - check: (args: { repoId: string; hostId?: ExecutionHostId }) => + check: (args: { repoId: string; hostId?: ExecutionHostId; background?: boolean }) => ipcRenderer.invoke('hooks:check', args), inspectSetupScriptImports: (args: { repoId: string; hostId?: ExecutionHostId }) => diff --git a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts index 7bb59f7fcac..5d82f43ab90 100644 --- a/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +++ b/src/renderer/src/components/setup-guide/use-setup-guide-progress.ts @@ -169,9 +169,9 @@ export function useSetupGuideProgress( async function refreshSetupScriptState(): Promise { for (const repo of orderedGitRepos) { - const hooksResult = await checkRuntimeHooks(getRepoExecutionHostId(repo), repo.id).catch( - () => null - ) + const hooksResult = await checkRuntimeHooks(getRepoExecutionHostId(repo), repo.id, { + background: true + }).catch(() => null) if (stale) { return } diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx index f5f832edecf..367a65185b9 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx @@ -75,7 +75,7 @@ function SetupScriptPromptCard(): React.JSX.Element | null { const hostId = getRepoExecutionHostId(repo) const inspection = await inspectSetupScriptPromptState({ repo, - checkHooks: () => checkRuntimeHooks(hostId, repo.id), + checkHooks: () => checkRuntimeHooks(hostId, repo.id, { background: true }), inspectImports: () => inspectRuntimeSetupScriptImports(hostId, repo.id) }) if (!cancelled) { diff --git a/src/renderer/src/runtime/runtime-hooks-client.test.ts b/src/renderer/src/runtime/runtime-hooks-client.test.ts index 31e3c1c6bd6..c44725672e1 100644 --- a/src/renderer/src/runtime/runtime-hooks-client.test.ts +++ b/src/renderer/src/runtime/runtime-hooks-client.test.ts @@ -73,6 +73,18 @@ describe('runtime hooks client', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + it('marks a render-driven check as background, so a stopped WSL distro is refused, not booted', async () => { + hooksCheck.mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false }) + + await checkRuntimeHooks('local', 'repo-1', { background: true }) + + expect(hooksCheck).toHaveBeenLastCalledWith({ + repoId: 'repo-1', + hostId: 'local', + background: true + }) + }) + it('routes hook operations through the repo owner runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-hooks-client.ts b/src/renderer/src/runtime/runtime-hooks-client.ts index f24af0e6ade..54624230172 100644 --- a/src/renderer/src/runtime/runtime-hooks-client.ts +++ b/src/renderer/src/runtime/runtime-hooks-client.ts @@ -30,13 +30,19 @@ export type IssueCommandReadResult = { source: 'local' | 'shared' | 'none' } +/** `background`: a render-driven probe that must not boot a stopped WSL distro (E9); launches omit it. */ export async function checkRuntimeHooks( hostId: ExecutionHostId, - repoId: string + repoId: string, + options: { background?: boolean } = {} ): Promise { const target = getHookInspectionTarget(hostId) if (target.kind !== 'environment') { - return window.api.hooks.check({ repoId, hostId }) + return window.api.hooks.check({ + repoId, + hostId, + ...(options.background ? { background: true } : {}) + }) } return callRuntimeRpc( target,