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,