From 72b1016d85bfeb0e44958aceb9c7daff2cdbebb9 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 12:30:46 -0700 Subject: [PATCH] fix(windows): refuse structured chat in a paired web client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the two review-loop commits (restoring a tree byte-identical to the validated head) and closes the hole they were aiming at, without their cost. A paired web client's `platform` describes the browser's machine, not the host that will run the agent, so the Windows gate cannot be evaluated there. Before this, a browser on macOS driving a Windows runtime read "not win32", skipped the creation-time proof entirely and allowed structured chat — fail-OPEN, the dangerous direction, bypassing the guarantee this lane is built on. `isWebClient` is a required input like the other gate fields, so the compiler enumerated all seven call sites. Refusal is synchronous and fail-closed: no async round-trip, no null window, no cache to invalidate — unlike keying on an asynchronously-fetched host platform, which would have made every desktop launch wait on a round-trip to fix a paired-web-only hole. Paired web therefore gets the legacy chat until the host publishes eligibility itself; that is the proper fix and belongs in its own PR. Ablation-proven: removing the guard reddens both refusal tests; the desktop-unaffected test is a preservation check and passes either way. Gates: tc 0, oxlint 0. Known open: repos-onboarding-folder-startup.test.ts fails on this branch and passes on plain main — under investigation, NOT caused by this commit. --- .../folder-workspace-composer-submit.ts | 2 ++ .../composer-state/full-creation-execution.ts | 2 ++ .../quick-creation-execution.ts | 2 ++ .../lib/agent-launch-routing-windows-gate.ts | 10 ++++++-- .../src/lib/agent-launch-routing.test.ts | 25 +++++++++++++++++++ src/renderer/src/lib/agent-launch-routing.ts | 6 +++++ .../lib/onboarding-folder-agent-startup.ts | 2 ++ 7 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 077b7234d4a..e34e0030455 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -4,6 +4,7 @@ import { type LinkedWorkItemSummary } from '@/lib/new-workspace' import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' +import { isWebClientLocation } from '@/lib/web-client-location' import { pathUsesWslUnc, readWindowsProcessStartTimeGate @@ -157,6 +158,7 @@ export async function submitFolderWorkspaceCreate({ platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), windowsProcessStartTime: readWindowsProcessStartTimeGate(), + isWebClient: isWebClientLocation(), // The workspace has no store entry yet, but it will be created under // the group's parent, so the parent decides WSL-ness before the click. worktreeUsesWslPath: pathUsesWslUnc(projectGroup.parentPath), diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index af8bde45559..2ce96a673a2 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -32,6 +32,7 @@ import { useCallback } from 'react' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' import { translate } from '@/i18n/i18n' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' +import { isWebClientLocation } from '@/lib/web-client-location' import { readWindowsProcessStartTimeGate } from '@/lib/agent-launch-routing-windows-gate' import { toFolderWorkspaceLinkedTask } from '@/components/sidebar/folder-workspace-composer-helpers' import { CLIENT_PLATFORM, ensureAgentStartupInTerminal } from '@/lib/new-workspace' @@ -144,6 +145,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), windowsProcessStartTime: readWindowsProcessStartTimeGate(), + isWebClient: isWebClientLocation(), // The workspace has no store entry until after this decision, so the // WSL-UNC check cannot run yet; it applies on the next launch. worktreeUsesWslPath: false, diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 858f4c1d3db..4f0696133ae 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -36,6 +36,7 @@ import { useCallback } from 'react' import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' +import { isWebClientLocation } from '@/lib/web-client-location' import { readWindowsProcessStartTimeGate } from '@/lib/agent-launch-routing-windows-gate' import { useAppStore } from '@/store' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' @@ -210,6 +211,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), windowsProcessStartTime: readWindowsProcessStartTimeGate(), + isWebClient: isWebClientLocation(), // The workspace has no store entry until after this decision, so the // WSL-UNC check cannot run yet; it applies on the next launch. worktreeUsesWslPath: false, diff --git a/src/renderer/src/lib/agent-launch-routing-windows-gate.ts b/src/renderer/src/lib/agent-launch-routing-windows-gate.ts index baa1499afbd..87f8511b054 100644 --- a/src/renderer/src/lib/agent-launch-routing-windows-gate.ts +++ b/src/renderer/src/lib/agent-launch-routing-windows-gate.ts @@ -1,6 +1,7 @@ import type { AppState } from '@/store/types' import { worktreeUsesWslPath } from '@/store/terminals/terminal-workspace-routing' import { isWslUncPath } from '../../../shared/wsl-paths' +import { isWebClientLocation } from '@/lib/web-client-location' import { getCachedWindowsTerminalCapabilities, hasCachedWindowsTerminalCapabilities @@ -47,9 +48,14 @@ export function pathUsesWslUnc(path: string | null | undefined): boolean { export function readWindowsStructuredGateInputs( state: Partial>, worktreeId: string -): { windowsProcessStartTime: WindowsProcessStartTimeGate; worktreeUsesWslPath: boolean } { +): { + windowsProcessStartTime: WindowsProcessStartTimeGate + worktreeUsesWslPath: boolean + isWebClient: boolean +} { return { windowsProcessStartTime: readWindowsProcessStartTimeGate(), - worktreeUsesWslPath: readWorktreeUsesWslPath(state, worktreeId) + worktreeUsesWslPath: readWorktreeUsesWslPath(state, worktreeId), + isWebClient: isWebClientLocation() } } diff --git a/src/renderer/src/lib/agent-launch-routing.test.ts b/src/renderer/src/lib/agent-launch-routing.test.ts index 21eee372480..714e18f2fdf 100644 --- a/src/renderer/src/lib/agent-launch-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-routing.test.ts @@ -21,6 +21,7 @@ function route(overrides: Partial[0]> platform: 'darwin', hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], windowsProcessStartTime: 'unavailable' as const, + isWebClient: false, worktreeUsesWslPath: false, workspaceKind: 'git-worktree', nativeChatTranscriptIsLocalReadable: true, @@ -176,3 +177,27 @@ describe('resolveAgentLaunchRoute Windows structured gate', () => { ).toBe('legacy-native-chat') }) }) + +describe('resolveAgentLaunchRoute paired web client', () => { + // A web client's `platform` is the browser's machine, not the host that runs + // the agent, so the Windows gate cannot be evaluated. Refusing is the only + // safe answer until the host publishes eligibility itself. + it('refuses structured chat in a paired web client', () => { + expect(route({ isWebClient: true })).toBe('legacy-native-chat') + }) + + it('still refuses when the web client looks fully eligible on Windows', () => { + expect( + route({ + isWebClient: true, + platform: 'win32', + windowsProcessStartTime: 'available', + worktreeUsesWslPath: false + }) + ).toBe('legacy-native-chat') + }) + + it('leaves the desktop renderer unaffected', () => { + expect(route({ isWebClient: false })).toBe('structured-native-chat') + }) +}) diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 33cd6a92f8c..99f6287f618 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -36,6 +36,11 @@ export type AgentLaunchRoutingInput = { * the distro's runtime and must keep the legacy terminal. Required for the * same reason. */ worktreeUsesWslPath: boolean + /** True when this renderer is a paired web client. Its `platform` then + * describes the browser's machine, not the host that will run the agent, so + * the Windows gate below cannot be evaluated and must refuse rather than + * guess. Lifting this needs host-published eligibility, not a client probe. */ + isWebClient: boolean workspaceKind?: 'git-worktree' | 'folder' | 'floating' projectRuntime?: ProjectExecutionRuntimeResolution | null promptDelivery?: NativeChatLaunchPromptDelivery @@ -106,6 +111,7 @@ export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLa input.platform !== 'win32' || (input.windowsProcessStartTime === 'available' && !input.worktreeUsesWslPath) const structuredSupported = + !input.isWebClient && input.agent === 'codex' && input.promptDelivery !== 'draft' && input.workspaceKind !== 'floating' && diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 4bfdcfc464e..2a9ece797aa 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -1,4 +1,5 @@ import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { isWebClientLocation } from '@/lib/web-client-location' import { readWindowsProcessStartTimeGate } from '@/lib/agent-launch-routing-windows-gate' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' @@ -139,6 +140,7 @@ export function resolveDismissedOnboardingFolderAgentLaunch(args: { platform: getClientPlatform(), hostCapabilities: readLocalRuntimeCapabilities(), windowsProcessStartTime: readWindowsProcessStartTimeGate(), + isWebClient: isWebClientLocation(), // The workspace has no store entry until after this decision, so the // WSL-UNC check cannot run yet; it applies on the next launch. worktreeUsesWslPath: false,