fix(windows): refuse structured chat in a paired web client

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.
This commit is contained in:
Merge Sim
2026-09-04 12:30:46 -07:00
parent 107887b785
commit 72b1016d85
7 changed files with 47 additions and 2 deletions
@@ -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),
@@ -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,
@@ -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,
@@ -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<Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>>,
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()
}
}
@@ -21,6 +21,7 @@ function route(overrides: Partial<Parameters<typeof resolveAgentLaunchRoute>[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')
})
})
@@ -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' &&
@@ -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,