diff --git a/packages/client/connection/tests/native-dialog-request.spec.ts b/packages/client/connection/tests/native-dialog-request.spec.ts index 8048f6da83..1a3d70dd15 100644 --- a/packages/client/connection/tests/native-dialog-request.spec.ts +++ b/packages/client/connection/tests/native-dialog-request.spec.ts @@ -1,9 +1,10 @@ +import type { IncomingHttpHeaders } from 'node:http' import { describe, expect, it } from 'vitest' import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts' function request( - remoteAddress: string, - headers: Record = { + remoteAddress: string | undefined, + headers: IncomingHttpHeaders = { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', @@ -22,10 +23,14 @@ describe('native dialog request trust', () => { expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', }))).toBe(true) + expect(isTrustedNativeDialogRequest(request('127.0.0.2', { + host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) }) it('rejects remote sockets and requests without matching browser metadata', () => { expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false) + expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false) expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site', }))).toBe(false) @@ -33,8 +38,20 @@ describe('native dialog request trust', () => { host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', }))).toBe(false) expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin', }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin', + }))).toBe(false) }) }) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 6b173b8987..dd0cf6f562 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -88,7 +88,6 @@ export function WorkspaceCreateFlow({ } const openLocalFolder = (): void => { - if (pickingFolder) return onClose() setModalKind(null) setModalError(null) @@ -109,7 +108,6 @@ export function WorkspaceCreateFlow({ } const handleSelect = (id: string): void => { - if (pickingFolder) return if (id === OPEN_LOCAL_FOLDER) { openLocalFolder() return diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 99c5e71b48..04a320139f 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -138,6 +138,15 @@ describe('WorkspacePicker', () => { await act(async () => { resolve(null); await pending }) }) + it('reports non-Error native picker failures', async () => { + const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' })) + chooseItem('Open local folder…') + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('picker unavailable') + }) + expect(b.createWorkspace).not.toHaveBeenCalled() + }) + it('closes a creation modal when the user cancels', () => { mount([]) chooseItem('Create a new workspace') diff --git a/packages/host/apiproxy/tests/native-directory-picker.spec.ts b/packages/host/apiproxy/tests/native-directory-picker.spec.ts index c33b68a236..783a67a04b 100644 --- a/packages/host/apiproxy/tests/native-directory-picker.spec.ts +++ b/packages/host/apiproxy/tests/native-directory-picker.spec.ts @@ -1,3 +1,19 @@ +type ExecFileCallback = ( + error: (Error & { code?: string | number }) | null, + stdout: string, + stderr: string, +) => void +type ExecFileMock = ( + command: string, + args: readonly string[], + options: { encoding: string; signal: AbortSignal; windowsHide: boolean }, + callback: ExecFileCallback, +) => void + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + import { describe, expect, it, vi } from 'vitest' import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts' @@ -15,6 +31,19 @@ describe('native directory picker', () => { run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)')) await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull() + + run.mockRejectedValueOnce(failure(2, 'permission denied')) + await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toThrow('command failed') + }) + + it.each([ + ['a primitive error', 'failed'], + ['an invalid code type', { code: true }], + ['a missing stderr property', { code: 1 }], + ['a non-string stderr property', { code: 1, stderr: 42 }], + ])('does not mistake %s for macOS cancellation', async (_label, reason) => { + const run = vi.fn(async () => { throw reason }) + await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason) }) it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => { @@ -32,12 +61,44 @@ describe('native directory picker', () => { await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed') }) + it('runs the default command adapter without a shell and preserves command failures', async () => { + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, 'C:\\work\\default\r\n', '') + }) + await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default') + const [command, args, options] = execFileMock.mock.calls[0]! + expect(command).toBe('powershell.exe') + expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command'])) + expect(options.encoding).toBe('utf8') + expect(options.windowsHide).toBe(true) + expect(options.signal).toBeInstanceOf(AbortSignal) + + const commandError = Object.assign(new Error('powershell failed'), { code: 7 }) + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(commandError, 'partial output', 'failure details') + }) + await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({ + message: 'powershell failed', cause: commandError, code: 7, + stdout: 'partial output', stderr: 'failure details', + }) + }) + + it('uses the current process platform when no platform override is supplied', async () => { + const run = vi.fn(async () => ({ stdout: '/default/platform\n', stderr: '' })) + await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform') + }) + it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => { const run = vi.fn() .mockRejectedValueOnce(failure('ENOENT')) .mockResolvedValueOnce({ stdout: '/home/test/project\n', stderr: '' }) await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBe('/home/test/project') expect(run.mock.calls.map(call => call[0])).toEqual(['zenity', 'kdialog']) + + const zenity = vi.fn(async () => ({ stdout: '/home/test/direct\n', stderr: '' })) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenity })) + .resolves.toBe('/home/test/direct') + expect(zenity).toHaveBeenCalledOnce() }) it('maps Linux cancellation to null and reports a missing desktop picker', async () => { @@ -47,6 +108,22 @@ describe('native directory picker', () => { const missing = vi.fn(async () => { throw failure('ENOENT') }) await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing })) .rejects.toThrow('install zenity or kdialog') + + const kdialogCancelled = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockRejectedValueOnce(failure(1)) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogCancelled })) + .resolves.toBeNull() + + const zenityFailed = vi.fn(async () => { throw failure(2) }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenityFailed })) + .rejects.toThrow('command failed') + + const kdialogFailed = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockRejectedValueOnce(failure(2)) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogFailed })) + .rejects.toThrow('command failed') }) it('does not convert caller aborts into user cancellation', async () => { @@ -55,4 +132,8 @@ describe('native directory picker', () => { const run = vi.fn(async () => { throw failure('ABORT_ERR') }) await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed') }) + + it('reports unsupported platforms', async () => { + await expect(pickNativeDirectory(signal(), { platform: 'aix' })).rejects.toThrow('unsupported on aix') + }) })