Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 35 additions & 0 deletions packages/api/src/agent-workspace-info.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**********************************************************************
* Copyright (C) 2026 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { expect, test } from 'vitest';

import { getSandboxNameValidationError, SANDBOX_NAME_MAX_LENGTH } from './agent-workspace-info.js';

test('accepts names at exactly the maximum length', () => {
const name = 'a'.repeat(SANDBOX_NAME_MAX_LENGTH);

expect(getSandboxNameValidationError(name)).toBeUndefined();
});

test('rejects names exceeding the maximum length', () => {
const name = 'a'.repeat(SANDBOX_NAME_MAX_LENGTH + 1);

expect(getSandboxNameValidationError(name)).toBe(
`Workspace name must not exceed ${SANDBOX_NAME_MAX_LENGTH} characters`,
);
});
10 changes: 10 additions & 0 deletions packages/api/src/agent-workspace-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ export type AgentWorkspaceMcpConfig = configComponents['schemas']['McpConfigurat

export type AgentWorkspaceMount = configComponents['schemas']['Mount'];

/** Maximum sandbox name length. Linux hostnames are limited to 64 characters. */
export const SANDBOX_NAME_MAX_LENGTH = 64;

export function getSandboxNameValidationError(name: string): string | undefined {
if (name.length > SANDBOX_NAME_MAX_LENGTH) {
return `Workspace name must not exceed ${SANDBOX_NAME_MAX_LENGTH} characters`;
}
return undefined;
}

/**
* Options for creating (initializing) a new workspace via `kdn init`.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,28 @@ describe('create – OpenShell mode', () => {
expect(result).toEqual({ id: 'my-project' });
});

test('rejects workspace names longer than the hostname limit', async () => {
const options: AgentWorkspaceCreateOptions = {
...defaultOptions,
name: 'a'.repeat(65),
};

await expect(manager.create(options)).rejects.toThrow(/must not exceed 64 characters/);
expect(openshellCli.createSandbox).not.toHaveBeenCalled();
});

test('rejects basename-derived names longer than the hostname limit', async () => {
const longBasename = 'a'.repeat(65);
const options: AgentWorkspaceCreateOptions = {
sourcePath: `/tmp/${longBasename}`,
agent: 'claude',
model: 'ramalama::granite-4::',
};

await expect(manager.create(options)).rejects.toThrow(/must not exceed 64 characters/);
expect(openshellCli.createSandbox).not.toHaveBeenCalled();
});

test('calls agent.preWorkspaceStart with correct context', async () => {
const preWorkspaceStart = vi.fn();
vi.mocked(agentRegistry.getAgentRegistration).mockReturnValue({ ...mockAgent, preWorkspaceStart });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import type {
AgentWorkspaceId,
AgentWorkspaceSummary,
} from '/@api/agent-workspace-info.js';
import { getSandboxNameValidationError } from '/@api/agent-workspace-info.js';
import { ApiSenderType } from '/@api/api-sender/api-sender-type.js';
import type { IConfigurationNode } from '/@api/configuration/models.js';
import { IConfigurationRegistry } from '/@api/configuration/models.js';
Expand Down Expand Up @@ -195,6 +196,10 @@ export class AgentWorkspaceManager implements Disposable {
uploads.push(...(await this.buildOpenshellFilesystemUploads(options.sourcePath, workspace)));

const sandboxName = options.name ?? basename(options.sourcePath);
const sandboxNameError = getSandboxNameValidationError(sandboxName);
if (sandboxNameError) {
throw new Error(sandboxNameError);
}
const env = workspace.environment
?.filter(entry => typeof entry.value === 'string' && entry.value !== '')
.reduce<Record<string, string>>((acc, entry) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,31 @@ test('Expect Continue button enabled when name and source are filled', async ()
expect(screen.getByRole('button', { name: 'Continue' })).not.toBeDisabled();
});

test('Expect Continue button disabled when workspace name exceeds hostname limit', async () => {
render(AgentWorkspaceCreate);

await fireEvent.input(screen.getByPlaceholderText('/path/to/project'), {
target: { value: '/home/user/my-repo' },
});
await fireEvent.input(screen.getByPlaceholderText('e.g., Frontend Refactoring'), {
target: { value: 'a'.repeat(65) },
});

expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
expect(screen.getByText(/must not exceed 64 characters/)).toBeInTheDocument();
});

test('Expect Continue button disabled when basename-derived name exceeds hostname limit', async () => {
render(AgentWorkspaceCreate);

await fireEvent.input(screen.getByPlaceholderText('/path/to/project'), {
target: { value: `/home/user/${'a'.repeat(65)}` },
});

expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
expect(screen.getByText(/must not exceed 64 characters/)).toBeInTheDocument();
});

test('Expect use-all-defaults button on step 1', async () => {
render(AgentWorkspaceCreate);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
AgentWorkspaceMount,
NetworkConfiguration,
} from '/@api/agent-workspace-info';
import { getSandboxNameValidationError } from '/@api/agent-workspace-info';
import { NavigationPage } from '/@api/navigation-page';
import type { DefaultWorkspaceSettings } from '/@api/onboarding-settings-info';
import type { FilesystemConfiguration, WorkspaceProjectInfo } from '/@api/workspace-project-info';
Expand Down Expand Up @@ -212,6 +213,14 @@ function getDefaultSessionName(path: string): string {
return normalized.split(/[\\/]/).filter(Boolean).at(-1) ?? '';
}

function getEffectiveWorkspaceName(): string {
return wizard.draft.sessionName.trim() || getDefaultSessionName(wizard.draft.sourcePath);
}

function isWorkspaceNameValid(): boolean {
return getSandboxNameValidationError(getEffectiveWorkspaceName()) === undefined;
}

$effect(() => {
if (wizard.draft.nameManuallyEdited) return;
const last = getDefaultSessionName(wizard.draft.sourcePath);
Expand Down Expand Up @@ -250,7 +259,7 @@ let isLastStep = $derived(wizard.draft.currentStepIndex === wizardSteps.length -
let hasModel = $derived(wizard.draft.selectedModel !== undefined);
let isCurrentStepComplete = $derived.by(() => {
if (currentStepId === 'workspace') {
return wizard.draft.sessionName.trim() !== '' && wizard.draft.sourcePath.trim() !== '';
return getEffectiveWorkspaceName() !== '' && wizard.draft.sourcePath.trim() !== '' && isWorkspaceNameValid();
}
if (currentStepId === 'agent-model') {
return hasModel;
Expand Down Expand Up @@ -397,7 +406,7 @@ function buildMountsFrom(fileAccess: string, mounts: CustomMount[]): AgentWorksp
}

async function startAsIs(): Promise<void> {
if (!wizard.draft.sourcePath.trim() || !wizard.draft.selectedModel) return;
if (!wizard.draft.sourcePath.trim() || !wizard.draft.selectedModel || !isWorkspaceNameValid()) return;

const draftSnapshot = $state.snapshot(wizard.draft);

Expand Down Expand Up @@ -427,7 +436,14 @@ async function startAsIs(): Promise<void> {
}

async function startWorkspace(): Promise<void> {
if (!wizard.draft.sessionName.trim() || !wizard.draft.sourcePath.trim() || !wizard.draft.selectedModel) return;
if (
!getEffectiveWorkspaceName() ||
!wizard.draft.sourcePath.trim() ||
!wizard.draft.selectedModel ||
!isWorkspaceNameValid()
) {
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const draftSnapshot = $state.snapshot(wizard.draft);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,36 @@ test('Expect workspace name input renders initial value', () => {
expect((screen.getByPlaceholderText('e.g., Frontend Refactoring') as HTMLInputElement).value).toBe('my-workspace');
});

test('shows validation error when workspace name exceeds hostname limit', () => {
render(AgentWorkspaceCreateStepWorkspace, {
...defaultProps,
sessionName: 'a'.repeat(65),
});

expect(screen.getByText(/must not exceed 64 characters/)).toBeInTheDocument();
expect(screen.getByPlaceholderText('e.g., Frontend Refactoring')).toHaveAttribute('aria-invalid', 'true');
});

test('shows validation error when basename-derived name exceeds hostname limit', () => {
render(AgentWorkspaceCreateStepWorkspace, {
...defaultProps,
sourcePath: `/home/user/${'a'.repeat(65)}`,
sessionName: '',
});

expect(screen.getByText(/must not exceed 64 characters/)).toBeInTheDocument();
});

test('accepts workspace name at exactly the hostname limit', () => {
render(AgentWorkspaceCreateStepWorkspace, {
...defaultProps,
sessionName: 'a'.repeat(64),
});

expect(screen.queryByText(/must not exceed 64 characters/)).not.toBeInTheDocument();
expect(screen.getByPlaceholderText('e.g., Frontend Refactoring')).toHaveAttribute('aria-invalid', 'false');
});

test('shows config-exists notification when configExists is true', () => {
render(AgentWorkspaceCreateStepWorkspace, {
...defaultProps,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script lang="ts">
import { faChevronDown, faFolder, faFolderOpen, faInfoCircle, faRocket } from '@fortawesome/free-solid-svg-icons';
import { Button, Input } from '@podman-desktop/ui-svelte';
import { Button, ErrorMessage, Input } from '@podman-desktop/ui-svelte';
import { Icon } from '@podman-desktop/ui-svelte/icons';

import { Textarea } from '/@/lib/chat/components/ui/textarea';
import { getSandboxNameValidationError } from '/@api/agent-workspace-info';
import type { WorkspaceProjectInfo } from '/@api/workspace-project-info';

interface Props {
Expand Down Expand Up @@ -51,6 +52,19 @@ function toggleDescription(): void {
function toggleProject(): void {
projectOpen = !projectOpen;
}

function getEffectiveWorkspaceName(name: string, path: string): string {
const trimmed = name.trim();
if (trimmed) {
return trimmed;
}
const normalized = path.trim().replace(/[\\/]+$/, '');
return normalized.split(/[\\/]/).filter(Boolean).at(-1) ?? '';
}

let sessionNameError = $derived(
getSandboxNameValidationError(getEffectiveWorkspaceName(sessionName, sourcePath)) ?? '',
);
</script>

<h2 class="text-lg font-semibold text-[var(--pd-modal-text)] mb-1">Workspace</h2>
Expand Down Expand Up @@ -130,8 +144,12 @@ function toggleProject(): void {
bind:value={sessionName}
placeholder="e.g., Frontend Refactoring"
class="w-full"
aria-invalid={sessionNameError !== ''}
oninput={markNameEdited}
/>
{#if sessionNameError}
<ErrorMessage error={sessionNameError} />
{/if}
</div>

<div class="rounded-xl border border-[var(--pd-content-card-border)]/85 bg-[var(--pd-content-card-bg)]/35 overflow-hidden">
Expand Down
Loading