diff --git a/src/crates/services/services-integrations/src/remote_ssh/manager.rs b/src/crates/services/services-integrations/src/remote_ssh/manager.rs index 91bb4758b5..cec6b7a9d3 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/manager.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/manager.rs @@ -1234,6 +1234,10 @@ fn supervised_container_command( /// hit the supervisor itself; both `terminate_child` here and /// [`container_signal_command`] therefore fall back to `pkill -P` plus a direct /// `kill`, which reaches one generation instead of all of them. +/// +/// The supervisor must also duplicate stdin before starting the asynchronous +/// child. POSIX non-interactive shells attach `/dev/null` to an asynchronous +/// list's fd 0, so `<&0` inside that list cannot preserve streamed input. fn supervised_container_command_with_pid_file( container: &ContainerWorkspaceConfig, command: &str, @@ -1260,12 +1264,14 @@ fn supervised_container_command_with_pid_file( }}; \ trap remove_pid_file EXIT; \ trap 'terminate_child; exit 143' HUP TERM; \ + exec 9<&0 || exit 1; \ if command -v setsid >/dev/null 2>&1; then \ - setsid {quoted_shell} -lc {quoted_command} <&0 & \ + setsid {quoted_shell} -lc {quoted_command} <&9 & \ else \ - {quoted_shell} -lc {quoted_command} <&0 & \ + {quoted_shell} -lc {quoted_command} <&9 & \ fi; \ child=$!; \ + exec 9<&-; \ if [ \"$tracking\" -eq 1 ]; then \ printf '%s' \"$child\" > \"$pid_file\" || tracking=0; \ fi; \ @@ -5955,6 +5961,9 @@ mod tests { assert!(pid_file.starts_with("/tmp/.bitfun-exec-")); assert!(wrapped.contains("setsid '/bin/bash' -lc")); + assert!(wrapped.contains("exec 9<&0 || exit 1")); + assert!(wrapped.contains("<&9 &")); + assert!(wrapped.contains("exec 9<&-")); assert!(wrapped.contains("|| tracking=0")); assert!(wrapped.contains("printf '%s' \"$child\" > \"$pid_file\"")); assert!(signal.contains("[ -s \"$pid_file\" ] || exit 75")); @@ -5962,6 +5971,50 @@ mod tests { assert!(signal.contains("kill -KILL \"$pid\"")); } + #[test] + #[cfg(unix)] + fn supervised_container_command_preserves_streamed_stdin() { + use std::io::Write; + + let container = ContainerWorkspaceConfig { + name: "dev".to_string(), + access: ContainerAccess::DockerExec, + local: true, + docker_path: "docker".to_string(), + shell: "/bin/sh".to_string(), + user: None, + interactive: true, + }; + let wrapped = supervised_container_command_with_pid_file( + &container, + "read value; printf 'stdin:%s' \"$value\"", + "/tmp/.bitfun-exec-stdin-contract.pid", + ); + let mut child = std::process::Command::new("sh") + .args(["-lc", &wrapped]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn supervised command"); + child + .stdin + .take() + .expect("supervisor stdin") + .write_all(b"transport-contract\n") + .expect("write supervisor stdin"); + let output = child + .wait_with_output() + .expect("wait for supervised command"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"stdin:transport-contract"); + assert!( + String::from_utf8_lossy(&output.stderr).trim().is_empty(), + "stdin forwarding must not add command stderr" + ); + } + #[test] #[cfg(unix)] fn supervised_container_command_keeps_working_without_a_writable_pid_location() { diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss index c010073b79..898e1d59b4 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss @@ -503,3 +503,8 @@ .ssh-connection-dialog__modal-overlay { z-index: 20000; } + +/** Portalled Select menus must remain above the dialog's raised overlay. */ +.select__dropdown.ssh-connection-dialog__select-dropdown { + z-index: 20001; +} diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx index 34095991b0..aedc9e365d 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx @@ -1,5 +1,7 @@ // @vitest-environment jsdom +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -99,17 +101,19 @@ vi.mock('@/component-library', () => ({ value, onChange, className, + placeholder, suffix, }: { label?: string; value?: string; onChange?: React.ChangeEventHandler; className?: string; + placeholder?: string; suffix?: React.ReactNode; }) => ( ), @@ -117,12 +121,18 @@ vi.mock('@/component-library', () => ({ options, value, onChange, + dropdownClassName, }: { options: Array<{ label: string; value: string }>; value: string; onChange: (value: string) => void; + dropdownClassName?: string; }) => ( - onChange(event.target.value)} + data-dropdown-class-name={dropdownClassName} + > {options.map((option) => ( ))} @@ -167,6 +177,36 @@ describe('SSHConnectionDialog', () => { }); } + function setInputValue(label: string, value: string): void { + const input = container.querySelector(`input[aria-label="${label}"]`); + expect(input).not.toBeNull(); + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(input, value); + input?.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + + function setSelectValue(select: HTMLSelectElement | null, value: string): void { + expect(select).not.toBeNull(); + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set; + setter?.call(select, value); + select?.dispatchEvent(new Event('change', { bubbles: true })); + }); + } + + function findTargetSelect(): HTMLSelectElement | null { + return Array.from(container.querySelectorAll('select')).find((select) => ( + select.querySelector('option[value="localDocker"]') !== null + )) ?? null; + } + + function findConnectButton(): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent?.includes('ssh.remote.connect')); + } + it('keeps optional connection fields collapsed for a new connection', async () => { await renderDialog(); @@ -185,6 +225,30 @@ describe('SSHConnectionDialog', () => { expect(container.querySelector('input[aria-label="ssh.remote.connectTimeout"]')).not.toBeNull(); }); + it('keeps portalled select menus above the raised dialog overlay', async () => { + await renderDialog(); + + const selects = Array.from(container.querySelectorAll('select')); + expect(selects.length).toBeGreaterThan(0); + expect(selects.every((select) => ( + select.dataset.dropdownClassName === 'ssh-connection-dialog__select-dropdown' + ))).toBe(true); + + const stylesheet = readFileSync( + resolve(process.cwd(), 'src/features/ssh-remote/SSHConnectionDialog.scss'), + 'utf8', + ); + const overlayZIndex = Number(stylesheet.match( + /\.ssh-connection-dialog__modal-overlay\s*\{[^}]*z-index:\s*(\d+)/, + )?.[1]); + const selectZIndex = Number(stylesheet.match( + /\.select__dropdown\.ssh-connection-dialog__select-dropdown\s*\{[^}]*z-index:\s*(\d+)/, + )?.[1]); + + expect(overlayZIndex).toBeGreaterThan(0); + expect(selectZIndex).toBeGreaterThan(overlayZIndex); + }); + it('reveals non-default settings when editing an existing connection', async () => { sshApiMock.listSavedConnections.mockResolvedValue([ { @@ -262,27 +326,11 @@ describe('SSHConnectionDialog', () => { remoteContextMock.connect.mockResolvedValue(undefined); await renderDialog(onClose); - const setValue = (label: string, value: string) => { - const input = container.querySelector(`input[aria-label="${label}"]`); - expect(input).not.toBeNull(); - act(() => { - if (input) { - const setter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - 'value', - )?.set; - setter?.call(input, value); - input.dispatchEvent(new Event('input', { bubbles: true })); - } - }); - }; - - setValue('ssh.remote.host', 'example.test'); - setValue('ssh.remote.username', 'dev'); - setValue('ssh.remote.password', 'secret'); + setInputValue('ssh.remote.host', 'example.test'); + setInputValue('ssh.remote.username', 'dev'); + setInputValue('ssh.remote.password', 'secret'); - const connectButton = Array.from(container.querySelectorAll('button')) - .find((button) => button.textContent?.includes('ssh.remote.connect')); + const connectButton = findConnectButton(); expect(connectButton).not.toBeUndefined(); await act(async () => { connectButton?.click(); @@ -298,6 +346,69 @@ describe('SSHConnectionDialog', () => { }), { browseAfterConnect: true }, ); + expect(remoteContextMock.connect.mock.calls[0]?.[1].container).toBeUndefined(); expect(onClose).toHaveBeenCalledTimes(1); }); + + it.each([ + ['remote Docker', 'remoteDocker', false, 'auto'], + ['local Docker', 'localDocker', true, 'auto'], + ['container sshd', 'containerSshd', false, 'sshd'], + ] as const)( + 'builds the expected connection config for %s', + async (_label, targetType, local, access) => { + remoteContextMock.connect.mockResolvedValue(undefined); + await renderDialog(); + + setSelectValue(findTargetSelect(), targetType); + const containerNameInput = container.querySelector( + 'input[placeholder="ssh.remote.containerNamePlaceholder"]', + ); + expect(containerNameInput).not.toBeNull(); + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(containerNameInput, 'devbox'); + containerNameInput?.dispatchEvent(new Event('input', { bubbles: true })); + }); + + if (!local) { + setInputValue('ssh.remote.host', 'example.test'); + setInputValue('ssh.remote.username', 'dev'); + setInputValue('ssh.remote.password', 'secret'); + } + + const connectButton = findConnectButton(); + expect(connectButton).not.toBeUndefined(); + await act(async () => { + connectButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const expectedId = local + ? 'docker-local-devbox' + : 'ssh-dev@example.test-container-devbox'; + expect(remoteContextMock.connect).toHaveBeenCalledWith( + expectedId, + expect.objectContaining({ + id: expectedId, + host: local ? 'local-docker' : 'example.test', + username: local ? 'docker' : 'dev', + auth: local + ? { type: 'PrivateKey', keyPath: '' } + : { type: 'Password', password: 'secret' }, + container: { + name: 'devbox', + access, + local, + dockerPath: 'docker', + shell: '/bin/sh', + user: undefined, + interactive: true, + }, + }), + { browseAfterConnect: true }, + ); + }, + ); }); diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx index 8d4b332644..9499f35c98 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx @@ -897,6 +897,7 @@ export const SSHConnectionDialog: React.FC = ({ value={formData.targetType} onChange={(value) => handleInputChange('targetType', String(value))} size="medium" + dropdownClassName="ssh-connection-dialog__select-dropdown" /> @@ -969,6 +970,7 @@ export const SSHConnectionDialog: React.FC = ({ value={formData.containerName} onChange={(value) => handleInputChange('containerName', String(value))} size="medium" + dropdownClassName="ssh-connection-dialog__select-dropdown" /> ) : ( = ({ value={formData.containerAccess} onChange={(value) => handleInputChange('containerAccess', String(value))} size="medium" + dropdownClassName="ssh-connection-dialog__select-dropdown" />
{t('ssh.remote.containerAccessHint')} @@ -1048,6 +1051,7 @@ export const SSHConnectionDialog: React.FC = ({ value={formData.authType} onChange={(value) => handleInputChange('authType', String(value))} size="medium" + dropdownClassName="ssh-connection-dialog__select-dropdown" />