Skip to content

Commit fe7fd80

Browse files
authored
Merge pull request #6570 from simstudioai/fix/skills-builtin-name-collision
fix(skills): only reject a built-in name collision on an actual rename
2 parents 262ce32 + c8007ce commit fe7fd80

2 files changed

Lines changed: 117 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.mockResolvedValue(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: 9 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,14 @@ export async function updateSkill(
349347
const resolved = await resolveEditableSkill(params)
350348
if (!resolved.ok) throwSkillFailure(resolved.result)
351349

350+
// Only a rename can newly shadow a built-in. Rows predating the guard may already carry a
351+
// built-in's name, and the modal always resubmits the full object, so compare against the
352+
// canonical name rather than rejecting every write that echoes it back.
353+
if (params.name !== undefined && params.name !== resolved.skill.name) {
354+
const collision = builtinNameCollision(params.name)
355+
if (collision) throw new OrchestrationError('validation', collision)
356+
}
357+
352358
try {
353359
await upsertSkills({
354360
skills: [

0 commit comments

Comments
 (0)