Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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; \
Expand Down Expand Up @@ -5955,13 +5961,60 @@ 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"));
assert!(signal.contains("kill -KILL -- \"-$pid\""));
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() {
Expand Down
5 changes: 5 additions & 0 deletions src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
155 changes: 133 additions & 22 deletions src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -99,30 +101,38 @@ vi.mock('@/component-library', () => ({
value,
onChange,
className,
placeholder,
suffix,
}: {
label?: string;
value?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
className?: string;
placeholder?: string;
suffix?: React.ReactNode;
}) => (
<label className={className}>
{label}
<input aria-label={label} value={value} onChange={onChange} />
<input aria-label={label} value={value} onChange={onChange} placeholder={placeholder} />
{suffix}
</label>
),
Select: ({
options,
value,
onChange,
dropdownClassName,
}: {
options: Array<{ label: string; value: string }>;
value: string;
onChange: (value: string) => void;
dropdownClassName?: string;
}) => (
<select value={value} onChange={(event) => onChange(event.target.value)}>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
data-dropdown-class-name={dropdownClassName}
>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
Expand Down Expand Up @@ -167,6 +177,36 @@ describe('SSHConnectionDialog', () => {
});
}

function setInputValue(label: string, value: string): void {
const input = container.querySelector<HTMLInputElement>(`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<HTMLSelectElement>('select')).find((select) => (
select.querySelector('option[value="localDocker"]') !== null
)) ?? null;
}

function findConnectButton(): HTMLButtonElement | undefined {
return Array.from(container.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent?.includes('ssh.remote.connect'));
}

it('keeps optional connection fields collapsed for a new connection', async () => {
await renderDialog();

Expand All @@ -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<HTMLSelectElement>('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([
{
Expand Down Expand Up @@ -262,27 +326,11 @@ describe('SSHConnectionDialog', () => {
remoteContextMock.connect.mockResolvedValue(undefined);
await renderDialog(onClose);

const setValue = (label: string, value: string) => {
const input = container.querySelector<HTMLInputElement>(`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<HTMLButtonElement>('button'))
.find((button) => button.textContent?.includes('ssh.remote.connect'));
const connectButton = findConnectButton();
expect(connectButton).not.toBeUndefined();
await act(async () => {
connectButton?.click();
Expand All @@ -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<HTMLInputElement>(
'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 },
);
},
);
});
4 changes: 4 additions & 0 deletions src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,7 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
value={formData.targetType}
onChange={(value) => handleInputChange('targetType', String(value))}
size="medium"
dropdownClassName="ssh-connection-dialog__select-dropdown"
/>
</div>

Expand Down Expand Up @@ -969,6 +970,7 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
value={formData.containerName}
onChange={(value) => handleInputChange('containerName', String(value))}
size="medium"
dropdownClassName="ssh-connection-dialog__select-dropdown"
/>
) : (
<Input
Expand All @@ -990,6 +992,7 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
value={formData.containerAccess}
onChange={(value) => handleInputChange('containerAccess', String(value))}
size="medium"
dropdownClassName="ssh-connection-dialog__select-dropdown"
/>
<div className="ssh-connection-dialog__hint">
{t('ssh.remote.containerAccessHint')}
Expand Down Expand Up @@ -1048,6 +1051,7 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
value={formData.authType}
onChange={(value) => handleInputChange('authType', String(value))}
size="medium"
dropdownClassName="ssh-connection-dialog__select-dropdown"
/>
</div>

Expand Down