fix(skill): refuse mutating MCP/ACP virtual skills and hide their card-level edit affordance

This commit is contained in:
matevip 2026-05-07 08:18:00 +08:00
parent 5cb82ed8f8
commit ad1264ff51
2 changed files with 39 additions and 7 deletions

View File

@ -249,9 +249,29 @@ public class SkillController {
@Operation(summary = "重新扫描单个技能RFC-042 §2.3.4")
@PostMapping("/{id}/rescan")
public R<SkillEntity> rescan(@PathVariable Long id) {
rejectVirtualSkillMutation(id);
return R.ok(skillService.rescanSecurity(id));
}
/**
* Mutation paths refuse virtual MCP/ACP skill ids upfront. The bridge
* synthesizes those rows on the fly from the upstream connection
* config; persisting an update against {@code mate_skill} would
* either silently no-op (no row to update) or as users have hit
* throw "技能不存在" because the lookup precedes the update. Sending
* a clear 4xx with a redirect hint is the right shape: the user
* wants the icon / display name / etc. to stick, and the only place
* those fields persist for an MCP entry is the MCP connection page.
*/
private void rejectVirtualSkillMutation(Long id) {
if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)
|| vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) {
throw new vip.mate.exception.MateClawException(
"err.skill.virtual_readonly",
"MCP/ACP 衍生技能不可在此编辑——请到 Settings ▸ MCP/ACP 连接页修改");
}
}
@Operation(summary = "获取已启用技能列表")
@GetMapping("/enabled")
public R<List<SkillEntity>> listEnabled() {
@ -321,6 +341,7 @@ public class SkillController {
@Operation(summary = "更新技能")
@PutMapping("/{id}")
public R<SkillEntity> update(@PathVariable Long id, @RequestBody SkillEntity skill) {
rejectVirtualSkillMutation(id);
skill.setId(id);
return R.ok(skillService.updateSkill(skill));
}
@ -337,6 +358,7 @@ public class SkillController {
@Operation(summary = "硬删除技能 (admin only — 物理删除 + 工作区清空)")
@DeleteMapping("/{id}")
public R<Void> delete(@PathVariable Long id) {
rejectVirtualSkillMutation(id);
skillService.hardDeleteSkill(id);
return R.ok();
}
@ -344,6 +366,7 @@ public class SkillController {
@Operation(summary = "启用/禁用技能")
@PutMapping("/{id}/toggle")
public R<SkillEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled) {
rejectVirtualSkillMutation(id);
return R.ok(skillService.toggleSkill(id, enabled));
}

View File

@ -129,6 +129,7 @@
</svg>
</button>
<button
v-if="!isSkillRowVirtual(skill)"
class="skill-btn"
:title="t('skills.actions.configure')"
@click="openEditFromCard(skill)"
@ -139,7 +140,7 @@
</svg>
</button>
<button
v-if="skill.skillType !== 'builtin'"
v-if="skill.skillType !== 'builtin' && !isSkillRowVirtual(skill)"
class="skill-btn danger"
:title="t('skills.actions.delete')"
@click="deleteSkill(skill)"
@ -740,11 +741,14 @@ const newForm = ref<{ name: string; description: string; icon: string }>({ name:
* {@link McpSkillBridge#VIRTUAL_ID_BASE} (= 9e18). The DB update path
* doesn't know about them, so the drawer hides the Edit affordance.
* Using string-length is robust against JS number precision loss past 2^53. */
const isVirtualSkill = computed(() => {
if (!detailSkill.value?.id) return false
const idStr = String(detailSkill.value.id)
function isVirtualSkillId(id: unknown): boolean {
if (id === null || id === undefined) return false
const idStr = String(id)
return idStr.length >= 19 && idStr.startsWith('9')
})
}
/** Per-row check used by the card-level configure / delete buttons. */
const isSkillRowVirtual = (skill: { id?: unknown } | null | undefined) => isVirtualSkillId(skill?.id)
const isVirtualSkill = computed(() => isVirtualSkillId(detailSkill.value?.id))
const isBuiltinDetail = computed(() => detailSkill.value?.skillType === 'builtin' || !!detailSkill.value?.builtin)
/** RFC-090 §4.4 — pre-flight dialog state. */
@ -1034,9 +1038,14 @@ async function createSkillFromModal() {
}
/** Old configure-on-card button now lands in the drawer Overview tab in
* edit mode keeps the surface contract but skips the modal trip. */
* edit mode keeps the surface contract but skips the modal trip.
* Virtual MCP/ACP-derived skills aren't editable; the card-level button
* is hidden for them, but if any other call path reaches here with a
* virtual skill, fall through to view mode rather than opening an edit
* surface that will 4xx on save. */
function openEditFromCard(skill: Skill) {
openDetailDrawer(skill, 'overview', { editIdentity: true })
const opts = isSkillRowVirtual(skill) ? undefined : { editIdentity: true }
openDetailDrawer(skill, 'overview', opts)
}
// ==================== Inline edit (Overview / Body) ====================