Skip to content

fix(core): give plugins from the same repository distinct extension ids#7676

Merged
wenshao merged 2 commits into
QwenLM:mainfrom
zjunothing:fix/7568-extension-id-plugin-name
Jul 25, 2026
Merged

fix(core): give plugins from the same repository distinct extension ids#7676
wenshao merged 2 commits into
QwenLM:mainfrom
zjunothing:fix/7568-extension-id-plugin-name

Conversation

@zjunothing

Copy link
Copy Markdown
Collaborator

What this PR does

Makes extensions installed from the same repository get distinct ids: getExtensionId() now appends installMetadata.pluginName to the hash input when it is present, so dotnet and dotnet-test from https://github.com/dotnet/skills no longer collide. Because an id-formula change would otherwise strand every existing install — on the next load the store would mint a fresh default policy under the new id (silently resetting the user's activation state) and the orphaned old policy would then trip the name-conflict guard on any update or uninstall — ExtensionStore.ensureInitialized() gains a re-key step: a loaded identity whose id is unknown to the store claims the policy stored under its unique name, provided no other loaded extension still owns that entry. Activation state, workspace overrides, and artifact generation all move with the policy; the store's existing case-insensitive name-uniqueness guarantee is what makes the name-based claim safe.

Why it's needed

#7568: installing a second plugin from a multi-plugin marketplace repo fails with Extension id belongs to "dotnet", not "dotnet-test".getExtensionId() hashed only the normalized repo URL, so every plugin from one repo produced the same id and the store's id-ownership check rejected the second install. The triage confirmed the root cause and maintainer doudouOUC endorsed exactly this fix shape (append pluginName to the hash input), explicitly calling out that a migration path or backward-compatible lookup must accompany the id change for users who already have one plugin installed from such a repo — that is the re-key step above.

Reviewer Test Plan

How to verify

  1. /extensions install https://github.com/dotnet/skills → choose dotnet; repeat and choose dotnet-test. Before this PR the second install fails with the id-ownership error; after, both install and coexist.
  2. Upgrade path: with a plugin installed by a pre-fix build (policy keyed by the repo-only hash, e.g. globally disabled), run any command on this branch — the policy is re-keyed to the new id with activation state intact, and update/uninstall work.
  3. npx vitest run src/extension/ in packages/core (573/573).

Evidence (Before & After)

E2E drives the compiled getExtensionId + real ExtensionStore (temp dirs on disk, real install/uninstall commits), before/after via git stash + rebuild:

before/after E2E + tests

check unpatched (origin/main) patched
ids differ for two plugins of one repo ❌ (identical hash)
second plugin install commit Extension id belongs to "dotnet", not "dotnet-test".
store holds both plugins side by side
legacy repo-only-id policy re-keys on load; activation state (disabled) survives; uninstall under new id works ❌ (no re-key)
new unit tests (distinct ids, re-key, orphan-only re-key, no-theft guard) 3 of 4 fail ✅ 4/4

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS (Darwin 24.6), Node v22.23.1; vitest. npm run typecheck, eslint --max-warnings 0, prettier --check all clean.

Risk & Scope

  • Main risk or tradeoff: the re-key matches by extension name when the id is unknown. Names are already enforced unique (case-insensitively) on every store commit, and the claim is further restricted to entries no loaded extension owns, so a wrong-target re-key would require two different extensions sharing a name — a state the store itself rejects. Local/link installs without pluginName are byte-for-byte unaffected.
  • Not validated / out of scope: the <repo>:<pluginName> install-source shorthand parsing (marketplace.ts) is untouched; Claude-marketplace conversions keep their own plugin hashing (claude-converter.ts).
  • Breaking changes / migration notes: extension ids for plugin installs change formula, but the store self-heals on first load — no user action needed.

Linked Issues

Fixes #7568

中文说明

本 PR 做了什么

让同一仓库安装的多个扩展获得不同 id:getExtensionId()installMetadata.pluginName 存在时把它追加进哈希输入,dotnetdotnet-test 不再碰撞。由于 id 公式变更会孤立所有既有安装——下次加载时 store 会在新 id 下铸造全新默认 policy(静默重置用户的启用状态),而旧 policy 变成孤儿、在 update/uninstall 时触发名字冲突守卫——ExtensionStore.ensureInitialized() 新增重挂步骤:store 不认识其 id 的已加载 identity,可认领以其唯一名字存储的 policy(前提是没有其它已加载扩展仍持有该条目)。启用状态、workspace 覆盖、artifact generation 随 policy 一起迁移;store 既有的大小写不敏感名字唯一性保证了按名认领的安全性。

为什么需要

#7568:从多插件市场仓库安装第二个插件时报 Extension id belongs to "dotnet", not "dotnet-test".——getExtensionId() 只哈希规范化仓库 URL,同仓库所有插件产生相同 id,store 的 id 归属检查拒绝第二次安装。triage 已确认根因,维护者 doudouOUC 背书了正是本 PR 的修复形状(哈希输入追加 pluginName),并明确指出 id 变更必须配迁移路径或向后兼容查找——即上述重挂步骤。

审阅测试计划

如何验证

  1. /extensions install https://github.com/dotnet/skillsdotnet,再来一次选 dotnet-test:本 PR 之前第二次安装报 id 归属错误;之后两者共存。
  2. 升级路径:旧版本安装的插件(policy 键为仓库哈希,如全局禁用),在本分支运行任意命令——policy 重挂到新 id、启用状态保留、update/uninstall 正常。
  3. packages/corenpx vitest run src/extension/(573/573)。

证据(Before & After)

E2E 驱动编译产物 getExtensionId + 真实 ExtensionStore(磁盘临时目录、真实 install/uninstall 提交),git stash + 重建对照:未修复时两插件同 id、第二次安装报 issue 原始错误、无重挂;修复后全部通过。4 条新单测中 3 条在未修复源码上失败。

测试平台

macOS 已本地验证(✅);Windows / Linux 依赖 CI(⚠️)。

环境

macOS(Darwin 24.6)、Node v22.23.1;vitest;typecheck / eslint / prettier 全绿。

风险与范围

  • 主要风险/权衡:重挂在 id 未知时按扩展名匹配。名字在每次 store 提交时已强制唯一(大小写不敏感),且认领仅限没有任何已加载扩展持有的条目,错误重挂需要两个不同扩展同名——store 本身拒绝这种状态。无 pluginName 的 local/link 安装完全不受影响。
  • 未验证/超出范围:<repo>:<pluginName> 安装源简写解析(marketplace.ts)未改动;Claude marketplace 转换保留自己的插件哈希(claude-converter.ts)。
  • 破坏性变更/迁移说明:插件安装的扩展 id 公式变更,但 store 首次加载自愈——用户无需任何操作。

关联 Issue

Fixes #7568

🤖 Generated with Claude Code

getExtensionId() hashed only the normalized repo URL, so two plugins
installed from one marketplace repo (e.g. dotnet and dotnet-test from
github.com/dotnet/skills) collided on the same id and the second install
failed with 'Extension id belongs to "dotnet", not "dotnet-test"'. The
plugin name is now appended to the hash input when present.

Because the formula change would strand existing installs (the store
would mint a fresh default policy under the new id, resetting activation
state, and the orphaned old policy would trip the name-conflict guard on
update/uninstall), ExtensionStore.ensureInitialized now re-keys a stored
policy to a loaded identity's id when the id is unknown but the unique
name matches and no loaded extension still owns the old entry.

Fixes QwenLM#7568

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments above for the result.

Qwen Triage 已完成 —— 查看运行。结果见上方各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with a clear reproduction — issue #7568 shows installing a second plugin from https://github.com/dotnet/skills fails with Extension id belongs to "dotnet", not "dotnet-test". Maintainer doudouOUC confirmed the root cause and endorsed the fix shape (append pluginName to the hash input), explicitly calling out the need for a migration path.

Direction: aligned. The extension system is core functionality, and multi-plugin repos colliding on a single ID is a real usability blocker. The maintainer's issue comment endorses exactly this approach.

Size: 34 production lines (26 in extension-store.ts, 8 in extensionManager.ts), 109 test lines (74 + 35). Well under any threshold.

Approach: the scope feels right — two focused changes (hash disambiguation + re-key migration) that directly address the bug and its upgrade path. No unrelated edits, no drive-by refactors. The re-key is well-constrained: it only claims orphaned policies by name, and only when no loaded extension still owns them. The maintainer's suggested fix and this PR are the same shape.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,有明确复现——issue #7568 显示从 https://github.com/dotnet/skills 安装第二个插件时报 Extension id belongs to "dotnet", not "dotnet-test"。维护者 doudouOUC 确认了根因并背书了修复形状(哈希输入追加 pluginName),并明确指出需要迁移路径。

方向:对齐。扩展系统是核心功能,多插件仓库 ID 碰撞是真实的可用性阻断。维护者在 issue 评论中背书了正是这个方案。

规模:34 行生产代码(extension-store.ts 26 行,extensionManager.ts 8 行),109 行测试代码(74 + 35)。远低于任何阈值。

方案:范围合理——两个聚焦的改动(哈希消歧 + 重挂迁移)直接解决 bug 及其升级路径。无无关改动,无顺手重构。重挂逻辑约束良好:仅按名认领孤儿 policy,且仅在无已加载扩展持有时生效。维护者建议的修复与本 PR 形状一致。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at ec51b8a04bb07577a9ff70e04880a3b8256a8531 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: given the root cause (hash only uses repo URL), I would (1) append pluginName to the hash input in getExtensionId() when present, and (2) add a re-key step in ExtensionStore.ensureInitialized() that migrates orphaned policies to new IDs by matching on the unique extension name — exactly what this PR does.

Findings: no critical blockers, no convention violations.

The getExtensionId change is a clean 4-line addition: when installMetadata?.pluginName exists, suffix idValue with :${pluginName} before hashing. Local/link installs without pluginName are byte-for-byte unaffected. The : separator is safe — it can't appear in a normalized GitHub URL or a plugin name (validated by validateName to [a-zA-Z0-9-_.]).

The re-key step in ensureInitializedUnlocked runs before the legacy projection handling, which is the right placement — otherwise the legacy path would mint a fresh default policy under the new ID while the old one sits orphaned. The logic is sound: build a set of loaded IDs, then for each identity whose ID is unknown to the store, find a stale entry (ID not loaded, name matches case-insensitively) and re-key it. The case-insensitive name match aligns with the store's existing uniqueness guarantee. The policy.name = identity.name update canonicalizes the casing. The changed = true flag ensures persistence.

Downstream consumers: getExtensionId is called in 5 places within extensionManager.ts (install, update, uninstall paths) and is not re-exported from the package index — the blast radius is contained to the extension module.

Tests cover the key scenarios: distinct IDs for same-repo plugins, repo-only ID preserved without pluginName, re-key with activation state migration, case-mismatch normalization, orphan-only re-key when a sibling installs fresh, and the no-theft guard for policies still owned by a loaded extension.

Testing

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Test (windows-latest, Node 22.x) ⏭️ skipped (fork PR)
Test (macos-latest, Node 22.x) ⏭️ skipped (fork PR)
Integration Tests (CLI, No Sandbox) ⏭️ skipped

All CI checks green on the reviewed commit. Windows and macOS tests were skipped because this is a fork PR; integration tests were also skipped. The ubuntu unit suite and precheck both passed.

not verified: real-scenario E2E (CI run — the isolated @qwen-code /tmux job can be triggered by a maintainer if TUI-level verification is desired, though this change is internal to the extension store and unlikely to surface in TUI behavior). Maintainer wenshao has independently verified the fix locally with a full before/after E2E driving the compiled getExtensionId + real ExtensionStore (see their comment below).

中文说明

代码审查

独立方案: 根据根因(哈希仅使用仓库 URL),我会 (1) 在 getExtensionId() 中当 pluginName 存在时追加到哈希输入,(2) 在 ExtensionStore.ensureInitialized() 中添加重挂步骤,按唯一扩展名将孤儿 policy 迁移到新 ID——与本 PR 完全一致。

发现: 无关键阻断,无规范违反。

getExtensionId 的改动是干净的 4 行追加:当 installMetadata?.pluginName 存在时,在哈希前给 idValue 加上 :${pluginName} 后缀。无 pluginName 的 local/link 安装完全不受影响。: 分隔符是安全的——它不会出现在规范化 GitHub URL 或插件名中(validateName 限制为 [a-zA-Z0-9-_.])。

ensureInitializedUnlocked 中的重挂步骤在 legacy projection 处理之前运行,这是正确的放置位置——否则 legacy 路径会在新 ID 下铸造全新默认 policy,而旧 policy 成为孤儿。逻辑合理:构建已加载 ID 集合,然后对每个 store 不认识其 ID 的 identity,查找过期条目(ID 未加载、名字大小写不敏感匹配)并重挂。大小写不敏感的名字匹配与 store 既有的唯一性保证一致。policy.name = identity.name 更新规范化大小写。changed = true 标志确保持久化。

下游消费者:getExtensionIdextensionManager.ts 内 5 处调用(安装、更新、卸载路径),未从包索引重新导出——影响范围限于扩展模块。

测试覆盖关键场景:同仓库插件不同 ID、无 pluginName 时保留仓库 ID、带激活状态迁移的重挂、大小写不敏感规范化、兄弟插件新装时仅重挂孤儿、以及仍被已加载扩展持有的 policy 的防盗守卫。

测试

审查提交的所有 CI 检查全绿。Windows 和 macOS 测试因为是 fork PR 被跳过;集成测试也被跳过。ubuntu 单元测试套件和预检均通过。

未验证:真实场景 E2E(CI 运行——维护者可触发隔离的 @qwen-code /tmux 作业进行 TUI 级验证,但此改动在扩展 store 内部,不太可能在 TUI 行为中显现)。维护者 wenshao 已独立本地验证,驱动编译后的 getExtensionId + 真实 ExtensionStore 进行了完整的 before/after E2E(见其评论)。

Qwen Code · qwen3.8-max-preview

Reviewed at ec51b8a04bb07577a9ff70e04880a3b8256a8531 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean across every stage; would merge without hesitation.

This is a textbook bugfix: the problem is real (issue #7568, maintainer-confirmed), the fix is exactly what the maintainer prescribed, and the migration path handles the ID formula change without user action. The diff is 34 production lines — two focused edits, no scope creep, no unrelated changes. The re-key logic is well-constrained by the store's existing name-uniqueness invariant, and the six new tests cover the happy path, the migration, case normalization, the orphan-only guard, and the no-theft guard.

Going back to my independent proposal: the PR matches it exactly. I don't see a simpler path — both the hash disambiguation and the re-key are necessary, and the implementation is minimal.

CI is fully green on the reviewed commit (ubuntu tests + precheck passed; Windows/macOS/integration skipped for fork PRs). Maintainer wenshao independently verified the fix locally with a compiled E2E and A/B test discrimination — the new tests genuinely fail on base.

中文说明

置信度:5/5 —— 每个阶段都干净;毫不犹豫可以合并。

这是一个教科书式的 bugfix:问题真实存在(issue #7568,维护者确认),修复正是维护者开出的方子,迁移路径在用户无需操作的情况下处理了 ID 公式变更。diff 仅 34 行生产代码——两处聚焦编辑,无范围蔓延,无无关改动。重挂逻辑受 store 既有名字唯一性不变量良好约束,六条新测试覆盖了正常路径、迁移、大小写规范化、仅孤儿守卫和防盗守卫。

回到我的独立方案:PR 与之完全一致。我看不到更简的路径——哈希消歧和重挂都是必要的,实现是最小的。

审查提交上 CI 全绿(ubuntu 测试 + 预检通过;Windows/macOS/集成测试因 fork PR 跳过)。维护者 wenshao 已独立本地验证,使用编译产物 E2E 和 A/B 测试判别——新测试在 base 上确实失败。

Qwen Code · qwen3.8-max-preview

Reviewed at ec51b8a04bb07577a9ff70e04880a3b8256a8531 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

const staleEntry = Object.entries(existing.extensions).find(
([id, policy]) =>
!loadedIds.has(id) &&
policy.name.toLowerCase() === identity.name.toLowerCase(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The re-key migration uses case-insensitive name matching (toLowerCase()) but no test exercises a case mismatch between the stored policy name and the incoming identity name.

Failure scenario: a future refactor that simplifies to policy.name === identity.name would silently break migration for extensions stored under different casing — the stale policy would be orphaned and activation state reset with no error.

Consider adding a test that seeds the store with { id: oldId, name: 'DotNet' }, sets activation state, then calls ensureInitialized([{ id: newId, name: 'dotnet' }]) and asserts re-key with name: 'dotnet' and state preserved.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in ec51b8are-keys across a case mismatch and normalizes the stored name: seeds { id: oldId, name: 'DotNet' } with defaultActivation: 'disabled', calls ensureInitialized([{ id: newId, name: 'dotnet' }]), and asserts the policy moves to the new id with state preserved and name normalized to 'dotnet'. Store suite 59/59.

中文

已在 ec51b8a 补上——re-keys across a case mismatch and normalizes the stored name:以 DotNet 名字播种 disabled 状态的 policy,用 dotnet 身份触发 re-key,断言 policy 迁移到新 id、状态保留、名字归一为 dotnet。store 套件 59/59。

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Not reviewed: build-and-test — Test (macos-latest, Node 22.x) was skipped in CI and its suite did not run locally. Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and its suite did not run locally.

— qwen3.7-max via Qwen Code /review

Seeds a policy under 'DotNet', re-keys it via an identity named 'dotnet',
and asserts the policy moves with its activation state and the stored
name normalizes — so a future simplification to case-sensitive matching
fails a test instead of silently orphaning differently-cased installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Not reviewed: build-and-test — Test (macos-latest, Node 22.x) was skipped in CI and its suite did not run locally. Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and its suite did not run locally.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification (maintainer) — merge reference

Built and verified PR #7676 (head ec51b8a, base origin/main) in an isolated worktree on macOS (Darwin 24.6, Node v22.23.1). I reproduced the author's checks and ran an independent on-disk E2E that drives the real getExtensionId + real ExtensionStore. Result: merge-ready — every gate is green and the fix does exactly what #7568 needs.

1 · Independent before/after E2E — reproduces the issue, then fixes it

A throwaway script drives the compiled getExtensionId and a real ExtensionStore (temp dirs on disk, real commitArtifact install commits). The same script runs against origin/main and against the PR — no mocks.

E2E before/after

  • BEFORE (origin/main) reproduces 安装扩展失败 #7568 exactly: two plugins from github.com/dotnet/skills hash to the same id (5cf3a28e…), and the second install is blocked with the verbatim error Extension id belongs to "dotnet", not "dotnet-test".
  • AFTER (PR): ids diverge (2ec721fb… / d3f26aee…), both plugins install and coexist, and a legacy repo-only-id policy re-keys onto the new id on load with its activation state intact.

2 · Full extension suite green + A/B proves the tests pin the fix

tests + A/B

  • npx vitest run src/extension/574 passed (574) across 21 files (the PR body says 573; the 2nd commit's case-mismatch test brings it to 574 — benign).
  • A/B: overlaying the base version of the two source files while keeping the PR's tests makes 4 tests fail — the 3 store re-key tests + getExtensionId … distinct ids (#7568). The 2 guard tests (does not re-key … still owned by another loaded extension, keeps the repo-only id when no plugin name is recorded) correctly pass both ways. So the new tests genuinely discriminate the fix, not just add coverage.

3 · Quality gates & blast radius

Check Result
tsc --noEmit (packages/core) ✅ clean
eslint --max-warnings 0 (4 files) ✅ clean
prettier --check (4 files) ✅ clean
getExtensionId callers only the install/update/uninstall flows inside extensionManager.ts; no external snapshot test depends on the id value, so the formula change is fully contained

4 · Code audit — the migration ordering is deliberate and correct

I traced ensureInitializedUnlocked: the re-key loop runs before the legacy-projection blocks, so the subsequent "mint a fresh default (enabled) policy for an unknown id" path is short-circuited by if (existing.extensions[identity.id]) continue — that's what preserves the disabled state instead of resetting it. changed = true reaches the persist at extension-store.ts:382 (existing.generation += 1; writeSnapshotUnlocked(existing)), and the freshly re-keyed entry is protected from being re-claimed because its id is already in loadedIds. Name-based claiming is safe under the store's case-insensitive name-uniqueness invariant.

Note (not a blocker)

In my E2E Part 3, two of the migration checks (disabled-survives, override-survives) also pass on base — because the store's name-keyed legacy enablement projection re-imports the default/override for the new id even without the re-key. The check that genuinely fails on base is orphan-gone: without the fix the old repo-only-id policy is left stranded in state.json, and that orphan is precisely what later trips the Extension id belongs to… / name-conflict guard on update/uninstall. The PR's own unit tests assert snapshot.extensions[oldId] === undefined, so they pin the essential behavior directly (and fail on base, as shown above). This reinforces why the re-key is required; nothing to change.


Verdict: LGTM / merge-ready. Correct, minimal, well-tested, backward-compatible (self-heals on first load, local/link installs unaffected), and the id-formula change has no fallout beyond the extension module.

Verified locally by the maintainer at head ec51b8a. Screenshots are real Playwright captures of the actual runs; the E2E script drives the compiled sources with no mocks.

🇨🇳 中文版本(本地验证报告 · 供合并参考)

✅ 本地验证(维护者)— 合并参考

在隔离 worktree 中构建并验证了 PR #7676(head ec51b8a,base origin/main),环境 macOS(Darwin 24.6)、Node v22.23.1。除复现作者的检查外,我还跑了一个独立的磁盘级 E2E,直接驱动真实的 getExtensionId + 真实的 ExtensionStore结论:可以合并——所有关卡全绿,修复正是 #7568 所需。

1 · 独立 Before/After E2E — 先复现问题,再验证修复

脚本驱动编译后的 getExtensionId真实的 ExtensionStore(磁盘临时目录、真实 commitArtifact 安装提交)。同一脚本分别对 origin/main 与本 PR 运行,无任何 mock(见上方第一张图)。

  • BEFORE(origin/main 精确复现 安装扩展失败 #7568github.com/dotnet/skills 的两个插件哈希出相同 id(5cf3a28e…),第二个安装被拦截,报错原文 Extension id belongs to "dotnet", not "dotnet-test".
  • AFTER(本 PR):id 分化(2ec721fb… / d3f26aee…),两个插件均安装并共存;旧的仅含仓库哈希的 policy 在加载时重挂到新 id,启用状态完整保留。

2 · 扩展测试全绿 + A/B 证明测试确实钉住修复

  • npx vitest run src/extension/574 passed (574),共 21 个文件(PR 描述写 573;第二个 commit 的大小写测试使其变为 574,属正常)。
  • A/B:把两个源文件覆盖回 base 版本、保留 PR 的测试后,4 个测试失败——3 个 store 重挂测试 + getExtensionId … distinct ids (#7568);2 个守卫测试(不重挂仍被其它已加载扩展持有的条目无 pluginName 时保持仓库哈希 id)在两侧都通过。说明新测试真正具有判别力(见上方第二张图)。

3 · 质量关卡与影响范围

检查 结果
tsc --noEmit(packages/core) ✅ 通过
eslint --max-warnings 0(4 文件) ✅ 通过
prettier --check(4 文件) ✅ 通过
getExtensionId 调用方 extensionManager.ts 内的 install/update/uninstall 流程;无外部快照测试依赖该 id 值,故公式变更完全收敛

4 · 代码审查 — 迁移顺序是刻意且正确的

追踪 ensureInitializedUnlocked:重挂循环在 legacy-projection 分支之前运行,因此后续"为未知 id 铸造全新默认(enabled)policy"的路径被 if (existing.extensions[identity.id]) continue 短路——这正是保留 disabled 状态、而非重置的原因。changed = true 会走到 extension-store.ts:382 的持久化;刚重挂的条目因其 id 已在 loadedIds 中而不会被再次认领。按名认领在 store 大小写不敏感的名字唯一性不变量下是安全的。

说明(非阻塞)

E2E Part 3 中,两个迁移检查(disabled-survivesoverride-survives)在 base 上通过——因为 store 的按名键的 legacy enablement 投影即使没有重挂,也会为新 id 重新导入 default/override。真正在 base 上失败的是 orphan-gone:没有修复时,旧的仅含仓库哈希的 policy 会残留在 state.json 中成为孤儿,而这个孤儿正是之后在 update/uninstall 时触发 Extension id belongs to… / 名字冲突守卫的根源。本 PR 的单测断言 snapshot.extensions[oldId] === undefined,直接钉住了这一核心行为(并在 base 上失败)。这更印证了重挂的必要性;无需改动。


结论:LGTM / 可以合并。 正确、最小、测试充分、向后兼容(首次加载自愈,local/link 安装不受影响),id 公式变更在扩展模块之外无任何波及。

由维护者在 head ec51b8a 本地验证。截图为真实运行的 Playwright 捕获;E2E 脚本无 mock,直接驱动编译源码。

@zjunothing

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough independent verification — especially the sharp observation that orphan-gone is the truly discriminating migration check (the legacy name-keyed projection re-imports activation state on base, but the orphaned repo-hash policy is what later trips the ownership guard). That's exactly the failure mode the re-key exists to prevent. 中文:感谢细致的独立验证——特别是指出 orphan-gone 才是真正有判别力的迁移检查这一点,正是 re-key 要防的失效模式。

@wenshao

wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Jul 25, 2026
Merged via the queue into QwenLM:main with commit a76a0fe Jul 25, 2026
85 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

安装扩展失败

4 participants