diff --git a/packages/api/src/agent-workspace-info.spec.ts b/packages/api/src/agent-workspace-info.spec.ts new file mode 100644 index 000000000..2c0f7b20b --- /dev/null +++ b/packages/api/src/agent-workspace-info.spec.ts @@ -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`, + ); +}); diff --git a/packages/api/src/agent-workspace-info.ts b/packages/api/src/agent-workspace-info.ts index 3893fc77b..28041b9ab 100644 --- a/packages/api/src/agent-workspace-info.ts +++ b/packages/api/src/agent-workspace-info.ts @@ -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; + +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`. */ diff --git a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts index 5fb3efaca..00d568c71 100644 --- a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts +++ b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts @@ -338,6 +338,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, diff --git a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts index 09594f5d5..12349875a 100644 --- a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts +++ b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts @@ -48,6 +48,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'; @@ -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>((acc, entry) => { diff --git a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.spec.ts b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.spec.ts index cdab59e60..485f3a3f6 100644 --- a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.spec.ts +++ b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.spec.ts @@ -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(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); diff --git a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte index 17a7ef2f3..cdc6b3378 100644 --- a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte +++ b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte @@ -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'; @@ -212,6 +213,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); @@ -258,7 +271,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; @@ -406,7 +424,14 @@ function buildMountsFrom(fileAccess: string, mounts: CustomMount[]): AgentWorksp } async function startAsIs(): Promise { - 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); @@ -415,7 +440,7 @@ async function startAsIs(): Promise { sourcePath: draftSnapshot.sourcePath, agent: draftSnapshot.selectedAgent, model: getModelId(draftSnapshot.selectedModel!), - name: draftSnapshot.sessionName || getDefaultSessionName(draftSnapshot.sourcePath), + name: getEffectiveWorkspaceNameFromSnapshot(draftSnapshot), project: draftSnapshot.selectedProjectId, }); resetDraft(); @@ -435,7 +460,15 @@ async function startAsIs(): Promise { } async function startWorkspace(): Promise { - 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; + } const draftSnapshot = $state.snapshot(wizard.draft); @@ -467,7 +500,7 @@ async function startWorkspace(): Promise { sourcePath: draftSnapshot.sourcePath, 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, diff --git a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreateStepWorkspace.spec.ts b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreateStepWorkspace.spec.ts index 8e667da6e..70cc93c58 100644 --- a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreateStepWorkspace.spec.ts +++ b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreateStepWorkspace.spec.ts @@ -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, diff --git a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreateStepWorkspace.svelte b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreateStepWorkspace.svelte index e1aa53bcc..df9e07bce 100644 --- a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreateStepWorkspace.svelte +++ b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreateStepWorkspace.svelte @@ -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 { @@ -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 ?? '');

Workspace

@@ -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 !== ''} />