mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 14:01:30 +08:00
fix(agents): prevent deleting the last accessible directory (#13483)
### What this PR does Before this PR: Deleting the only accessible directory in Agent Settings fails silently — `resolveAccessiblePaths()` treats `[]` the same as `undefined`, replacing it with a default path. The directory reappears after deletion. After this PR: - Backend: `AgentService.updateAgent()` and `SessionService.updateSession()` reject empty `accessible_paths` with an explicit error, enforcing the existing non-empty constraint. - Frontend: The delete button is disabled (with a tooltip) when only one accessible directory remains. <img width="362" height="207" alt="image" src="https://github.com/user-attachments/assets/4cd62081-ce77-46f7-bf0c-ca37be7f76cb" /> Fixes #13469 ### Why we need it and why it was done in this way The existing `resolveAccessiblePaths()` already assumes at least one path (Claude Code session startup requires `accessible_paths[0]` as cwd). Rather than changing this contract, we enforce it at both layers: The following tradeoffs were made: - Chose to disable the button rather than hide it, so users understand the constraint. - Used existing i18n key `agent.session.accessible_paths.error.at_least_one` for the tooltip. The following alternatives were considered: - Allowing empty `accessible_paths` and handling it at runtime — rejected because session startup already depends on non-empty paths, and silently failing is worse UX. - Only fixing backend or only frontend — rejected in favor of defense-in-depth (frontend prevents the action, backend validates the invariant). ### Breaking changes None. ### Special notes for your reviewer `resolveAccessiblePaths()` in `BaseService.ts` is intentionally **not** modified — its existing behavior (fallback to default for both `undefined` and `[]`) remains correct for the **creation** path. The fix targets only the **update** path where `[]` should be rejected rather than silently replaced. ### Checklist - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle) - [x] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html) - [ ] Upgrade: Impact of this change on upgrade flows was considered and addressed if required - [x] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. Check this only when the PR introduces or changes a user-facing feature or behavior. - [x] Self-review: I have reviewed my own code (e.g., via [`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`, or GitHub UI) before requesting review from others ### Release note ```release-note Fixed a bug where deleting the last accessible directory in Agent Settings would fail silently. The delete button is now disabled when only one directory remains. ``` Signed-off-by: icarus <eurfelux@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -157,6 +157,9 @@ export class AgentService extends BaseService {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (updates.accessible_paths !== undefined) {
|
||||
if (updates.accessible_paths.length === 0) {
|
||||
throw new Error('accessible_paths must not be empty')
|
||||
}
|
||||
updates.accessible_paths = this.resolveAccessiblePaths(updates.accessible_paths, id)
|
||||
}
|
||||
|
||||
|
||||
@@ -242,6 +242,9 @@ export class SessionService extends BaseService {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (updates.accessible_paths !== undefined) {
|
||||
if (updates.accessible_paths.length === 0) {
|
||||
throw new Error('accessible_paths must not be empty')
|
||||
}
|
||||
updates.accessible_paths = this.resolveAccessiblePaths(updates.accessible_paths, existing.agent_id)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import type { AgentType, Tool } from '@types'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -9,6 +11,10 @@ vi.mock('@main/apiServer/services/mcp', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@main/utils', () => ({
|
||||
getDataPath: () => '/mock/data'
|
||||
}))
|
||||
|
||||
const mockValidateModelId = vi.fn()
|
||||
vi.mock('@main/apiServer/utils', () => ({
|
||||
validateModelId: (...args: unknown[]) => mockValidateModelId(...args)
|
||||
@@ -32,6 +38,10 @@ class TestBaseService extends BaseService {
|
||||
): Promise<void> {
|
||||
return this.validateAgentModels(agentType, models)
|
||||
}
|
||||
|
||||
public resolve(paths: string[] | undefined, id: string): string[] {
|
||||
return this.resolveAccessiblePaths(paths, id)
|
||||
}
|
||||
}
|
||||
|
||||
const buildMcpTool = (id: string): Tool => ({
|
||||
@@ -148,3 +158,21 @@ describe('BaseService.validateAgentModels', () => {
|
||||
expect(provider.apiKey).toBe('sk-existing-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseService.resolveAccessiblePaths', () => {
|
||||
const service = new TestBaseService()
|
||||
const testId = 'agent_1234567890_abcdefghi'
|
||||
const defaultPath = path.join('/mock/data', 'Agents', 'abcdefghi')
|
||||
|
||||
it('assigns a default path when paths is undefined', () => {
|
||||
expect(service.resolve(undefined, testId)).toEqual([defaultPath])
|
||||
})
|
||||
|
||||
it('assigns a default path when paths is empty array', () => {
|
||||
expect(service.resolve([], testId)).toEqual([defaultPath])
|
||||
})
|
||||
|
||||
it('passes through provided paths unchanged', () => {
|
||||
expect(service.resolve(['/some/path'], testId)).toEqual(['/some/path'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,9 +75,19 @@ export const AccessibleDirsSetting = ({ base, update }: AccessibleDirsSettingPro
|
||||
title={path}>
|
||||
{path}
|
||||
</span>
|
||||
<Button size="small" type="text" danger onClick={() => removeAccessiblePath(path)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<Tooltip
|
||||
title={
|
||||
base.accessible_paths.length <= 1 ? t('agent.session.accessible_paths.error.at_least_one') : undefined
|
||||
}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
disabled={base.accessible_paths.length <= 1}
|
||||
onClick={() => removeAccessiblePath(path)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user