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:
Phantom
2026-03-16 11:11:19 +08:00
committed by GitHub
parent fe0678a206
commit 74c892fe1a
4 changed files with 47 additions and 3 deletions

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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'])
})
})

View File

@@ -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>