Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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 for OpenShell. Podman/crun hostname composition leaves a conservative 56-char limit. */
export const SANDBOX_NAME_MAX_LENGTH = 56;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question:should it be a configuration setting rather than an hardcoded value ?

another question: should it depend on the driver being used ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it can be dynamic, yes. Should I update it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or we can leave it like this for now and create a follow up ticket to make it dynamic as it will require more testing? I'm ok with both options. What do you think?


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 @@ -375,6 +375,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(57),
};

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

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

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

test('passes agent baseImage as from option to createSandbox', async () => {
vi.mocked(agentRegistry.getAgentRegistration).mockReturnValue({
...mockAgent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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 @@ -200,6 +201,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 @@ -265,6 +265,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(57) },
});

expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
expect(screen.getByText(/must not exceed 56 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(57)}` },
});

expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled();
expect(screen.getByText(/must not exceed 56 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 @@ -28,6 +28,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 @@ -213,6 +214,18 @@ 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 getEffectiveWorkspaceNameFromSnapshot(draft: { sessionName: string; sourcePath: string }): string {
return draft.sessionName.trim() || getDefaultSessionName(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 @@ -259,7 +272,12 @@ let validationErrors = $derived.by(() => {
});
let isCurrentStepComplete = $derived.by(() => {
if (currentStepId === 'workspace') {
return wizard.draft.sessionName.trim() !== '' && wizard.draft.sourcePath.trim() !== '' && !validationErrors.name;
return (
getEffectiveWorkspaceName() !== '' &&
wizard.draft.sourcePath.trim() !== '' &&
isWorkspaceNameValid() &&
!validationErrors.name
);
}
if (currentStepId === 'agent-model') {
return hasModel;
Expand Down Expand Up @@ -407,7 +425,14 @@ 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() ||
validationErrors.name
) {
return;
}

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

Expand All @@ -417,7 +442,7 @@ async function startAsIs(): Promise<void> {
runtime: $agentWorkspaceRuntime,
agent: draftSnapshot.selectedAgent,
model: getModelId(draftSnapshot.selectedModel!),
name: draftSnapshot.sessionName || getDefaultSessionName(draftSnapshot.sourcePath),
name: getEffectiveWorkspaceNameFromSnapshot(draftSnapshot),
project: draftSnapshot.selectedProjectId,
});
resetDraft();
Expand All @@ -437,7 +462,15 @@ 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() ||
validationErrors.name
) {
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

Expand Down Expand Up @@ -470,7 +503,7 @@ async function startWorkspace(): Promise<void> {
runtime: $agentWorkspaceRuntime,
agent: draftSnapshot.selectedAgent,
model: getModelId(draftSnapshot.selectedModel!),
name: draftSnapshot.sessionName,
name: getEffectiveWorkspaceNameFromSnapshot(draftSnapshot),
skills: selectedSkillPaths.length > 0 ? selectedSkillPaths : undefined,
network,
secrets: draftSnapshot.selectedSecretIds.length > 0 ? [...draftSnapshot.selectedSecretIds] : undefined,
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(57),
});

expect(screen.getByText(/must not exceed 56 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(57)}`,
sessionName: '',
});

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

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

expect(screen.queryByText(/must not exceed 56 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
Expand Up @@ -4,6 +4,7 @@ import { Button, 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 @@ -53,6 +54,18 @@ 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)));
let nameInputError = $derived(sessionNameError ?? errors?.name ?? '');
</script>

<h2 class="text-lg font-semibold text-[var(--pd-modal-text)] mb-1">Workspace</h2>
Expand Down Expand Up @@ -133,8 +146,8 @@ function toggleProject(): void {
placeholder="e.g., Frontend Refactoring"
class="w-full"
oninput={markNameEdited}
error={errors?.name ?? ''}
aria-invalid={!!errors?.name}
error={nameInputError}
aria-invalid={nameInputError !== ''}
/>
</div>

Expand Down
Loading