Skip to content

Commit 209e2c8

Browse files
committed
fix(skills): only reject a built-in name collision on an actual rename
The built-in-name guard ran on every update that carried a `name`, without comparing it to the skill's current persisted name. Skills created before the guard existed can legitimately carry a built-in's name (they simply shadowed the built-in at read time), and the skill modal always submits the full object including the unchanged name — so every save of such a skill returned 400 with "The skill name ... is reserved by a built-in skill", with no way to fix it short of renaming. Move the guard in `updateSkill` to after the canonical row is loaded and run it only when the submitted name differs from the current one. Creating a skill with a built-in name, and renaming an existing skill into one, are still rejected. The check stays in the shared orchestration primitive because that is the only layer both the internal `/api/skills` adapter (via `performUpdateSkill`) and `updateSkillUseCase` (v2 + Copilot) pass through, and it is where the current name is in hand.
1 parent 5519d45 commit 209e2c8

2 files changed

Lines changed: 120 additions & 3 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockGetSkillActorContext, mockUpsertSkills, mockGetSkillById, mockDeleteSkill } =
7+
vi.hoisted(() => ({
8+
mockGetSkillActorContext: vi.fn(),
9+
mockUpsertSkills: vi.fn(),
10+
mockGetSkillById: vi.fn(),
11+
mockDeleteSkill: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/skills/access', () => ({
15+
getSkillActorContext: mockGetSkillActorContext,
16+
}))
17+
18+
vi.mock('@/lib/workflows/skills/operations', () => ({
19+
upsertSkills: mockUpsertSkills,
20+
getSkillById: mockGetSkillById,
21+
deleteSkill: mockDeleteSkill,
22+
}))
23+
24+
vi.mock('@/lib/posthog/server', () => ({
25+
captureServerEvent: vi.fn(),
26+
}))
27+
28+
vi.mock('@sim/audit', () => ({
29+
AuditAction: { SKILL_CREATED: 'skill.created', SKILL_UPDATED: 'skill.updated' },
30+
AuditResourceType: { SKILL: 'skill' },
31+
recordAudit: vi.fn(),
32+
}))
33+
34+
import { createSkill, updateSkill } from '@/lib/skills/orchestration/skill-lifecycle'
35+
36+
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
37+
const USER_ID = '22222222-2222-4222-8222-222222222222'
38+
const SKILL_ID = '33333333-3333-4333-8333-333333333333'
39+
40+
/** `research` is one of the shipped built-in skill names. */
41+
const BUILTIN_NAME = 'research'
42+
43+
function skillRow(name: string) {
44+
return {
45+
id: SKILL_ID,
46+
workspaceId: WORKSPACE_ID,
47+
name,
48+
description: 'desc',
49+
content: 'content',
50+
}
51+
}
52+
53+
function actorOwning(name: string) {
54+
return { skill: skillRow(name), hasWorkspaceAccess: true, canEdit: true }
55+
}
56+
57+
describe('skill lifecycle built-in name collision', () => {
58+
beforeEach(() => {
59+
vi.clearAllMocks()
60+
mockUpsertSkills.mockResolvedValue({ touched: [{ id: SKILL_ID, name: 'x' }] })
61+
mockGetSkillById.mockImplementation(async () => skillRow(BUILTIN_NAME))
62+
})
63+
64+
it('allows an update that re-sends an existing built-in-colliding name unchanged', async () => {
65+
mockGetSkillActorContext.mockResolvedValue(actorOwning(BUILTIN_NAME))
66+
67+
const row = await updateSkill({
68+
workspaceId: WORKSPACE_ID,
69+
userId: USER_ID,
70+
skillId: SKILL_ID,
71+
name: BUILTIN_NAME,
72+
description: 'updated description',
73+
content: 'updated content',
74+
})
75+
76+
expect(row.name).toBe(BUILTIN_NAME)
77+
expect(mockUpsertSkills).toHaveBeenCalledTimes(1)
78+
})
79+
80+
it('rejects renaming a skill into a built-in name', async () => {
81+
mockGetSkillActorContext.mockResolvedValue(actorOwning('my-skill'))
82+
83+
await expect(
84+
updateSkill({
85+
workspaceId: WORKSPACE_ID,
86+
userId: USER_ID,
87+
skillId: SKILL_ID,
88+
name: BUILTIN_NAME,
89+
})
90+
).rejects.toThrow(`The skill name "${BUILTIN_NAME}" is reserved by a built-in skill`)
91+
92+
expect(mockUpsertSkills).not.toHaveBeenCalled()
93+
})
94+
95+
it('rejects creating a skill with a built-in name', async () => {
96+
await expect(
97+
createSkill({
98+
workspaceId: WORKSPACE_ID,
99+
userId: USER_ID,
100+
name: BUILTIN_NAME,
101+
description: 'desc',
102+
content: 'content',
103+
})
104+
).rejects.toThrow(`The skill name "${BUILTIN_NAME}" is reserved by a built-in skill`)
105+
106+
expect(mockUpsertSkills).not.toHaveBeenCalled()
107+
})
108+
})

apps/sim/lib/skills/orchestration/skill-lifecycle.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -337,9 +337,7 @@ export async function updateSkill(
337337
}
338338

339339
const invalid =
340-
(params.name !== undefined
341-
? (fieldError(skillNameSchema, params.name) ?? builtinNameCollision(params.name))
342-
: null) ??
340+
(params.name !== undefined ? fieldError(skillNameSchema, params.name) : null) ??
343341
(params.description !== undefined
344342
? fieldError(skillDescriptionSchema, params.description)
345343
: null) ??
@@ -349,6 +347,17 @@ export async function updateSkill(
349347
const resolved = await resolveEditableSkill(params)
350348
if (!resolved.ok) throwSkillFailure(resolved.result)
351349

350+
/**
351+
* Only a rename can newly shadow a built-in, so the guard runs against the
352+
* canonical name rather than the submitted one. Rows predating the guard may
353+
* already carry a built-in's name, and the skill modal always submits the
354+
* full object — re-sending that unchanged name is not a new collision.
355+
*/
356+
if (params.name !== undefined && params.name !== resolved.skill.name) {
357+
const collision = builtinNameCollision(params.name)
358+
if (collision) throw new OrchestrationError('validation', collision)
359+
}
360+
352361
try {
353362
await upsertSkills({
354363
skills: [

0 commit comments

Comments
 (0)