qwen-code/docs
Edenman 61dcf865de
feat(extensions): interactive multi-tab /extensions manager (Installed / Discover / Sources) (#4850)
* feat(extensions): multi-tab /extensions dialog (Discover/Installed/Marketplaces)

Upgrade the /extensions management dialog from a linear wizard into a
multi-tab interactive dialog aligned with Claude Code's /plugin command.

UI (packages/cli):
- Discover: pull installable plugins from configured marketplaces,
  multi-select (Space), batch install (i) with Global/Project/Local scope,
  open homepage, per-plugin details.
- Installed: plugins + standalone MCP servers grouped by
  Favorites/Local/User/Project/Disabled; Space toggles enable/disable,
  f toggles favorite, Enter opens details with an action menu
  (toggle/favorite/mark-for-update/update/uninstall).
- Marketplaces: add/list/view/remove marketplace sources
  (owner/repo, SSH, HTTP JSON, local path).
- Tabbed shell with Tab/arrow switching and a focus-lock contract so a tab
  owns Escape while in a sub-view.

Core (packages/core):
- ExtensionPreferencesStore: favorites + per-extension scope intent.
- MarketplaceRegistryStore + discoverPlugins(): persistent marketplace
  source registry and cross-source discovery.
- loadMarketplaceConfigFromSource() in marketplace.ts (GitHub/local/HTTP-JSON).
- ExtensionManager methods for marketplaces, discovery, favorites and scopes;
  preference cleanup on uninstall.

Scope mapping: Global -> User; Project/Local -> workspace-scoped enablement
(install then re-scope so the choice actually restricts where it is active).

The Errors tab is intentionally deferred per the spec.

Tests: 19 core unit tests (preferences/registry/discovery) and 11 tabbed
dialog integration tests; existing extension suites updated. typecheck,
lint and i18n checks pass.

* feat(extensions): align Discover plugin detail with Claude Code

Rework the Discover tab's Enter detail view to match Claude Code's
"Plugin details" page in both layout and interaction:

- Layout: "Plugin details" header, title, "from <marketplace>", last
  updated / version, description, "By: <author>", a "Will install:"
  component summary (Skills/Commands/Agents/MCP servers), and a trust
  warning.
- Interaction: the scope choice is now an inline action selector on the
  detail page (Install for you / for all collaborators / in this repo
  only / Open homepage / Back to plugin list), selected with Enter —
  replacing the previous i/h shortcuts and the separate scope step.
- Footer shows "Enter to select · Esc to go back" while a tab sub-view
  is open.

Core: DiscoveredPlugin now carries declared `components` and a
best-effort `lastUpdated`, surfaced by discoverPlugins().

Adds a core test for component/lastUpdated extraction and a UI test for
the detail layout + inline selector.

* feat(extensions): align Add Marketplace view with Claude Code

Match CC's "Add Marketplace" screen: a bold "Add Marketplace" header,
an "Enter marketplace source:" prompt, and an "Examples:" bullet list
(owner/repo · git@…:owner/repo.git (SSH) · https://…/marketplace.json ·
./path/to/marketplace) above a bare cursor input (placeholder removed).

Update the Marketplaces add-view tests accordingly.

* feat(extensions): fix Discover hang + add search/scrolling, align list with CC

Discover reliability and UX fixes:

- Fix the "Discovering plugins…" hang: marketplace network fetches had no
  timeout, so a slow/unreachable source could block discovery forever. Add a
  10s per-request timeout (resolve null) plus socket drain on non-200.
- Cache the fetched listing in ExtensionManager for the session so revisiting
  the tab no longer refetches over the network; `installed` flags are
  recomputed cheaply, and the cache is invalidated on add/remove marketplace.

CC-aligned Discover list:

- Windowed/scrolling viewport (no longer renders the entire 200+ list at
  once) with "↑ more above" / "↓ more below" hints, sized to the terminal.
- Type-to-search filter with a search box and a "Discover plugins (pos/total)"
  count header.
- Item layout: cursor "›", ○/●/✓ checkbox, bold title · marketplace ·
  "<N> installs", with a truncated description line.
- Space toggles selection, Enter views detail (or installs the selected set);
  the conflicting "i" shortcut was removed in favor of search.

Core: DiscoveredPlugin gains a best-effort `installs` count.

Adds tests for windowing, search filtering, and install-count extraction.

* feat(extensions): align marketplace detail with CC + Browse-to-Discover

Rework the Marketplaces tab detail to match Claude Code:

- Show marketplace name, source, "N available plugins", and the plugins
  from this marketplace that are installed ("Installed plugins (K):" with
  descriptions) — instead of dumping a truncated list of all plugins.
- Replace the ad-hoc "d to remove" hint with an action selector:
  Browse plugins (N) · Update marketplace [(last updated DATE)] ·
  Remove marketplace.
- "Browse plugins" switches to the Discover tab filtered to this
  marketplace (only its plugins); the filter clears on manual tab switch
  and is shown in the Discover header.
- "Update marketplace" re-fetches the marketplace config, stamps a fresh
  "last updated", and invalidates the discovery cache.

Core: MarketplaceSource gains lastUpdatedAt; addMarketplace stamps it and
ExtensionManager.markMarketplaceUpdated() refreshes it + clears the
discovery cache.

Adds tests for the marketplace detail layout and the Browse-to-Discover
filtering flow.

* feat(extensions): show plugin type in Installed; guide single-extension adds

- Installed list: plugin rows now show their type + version ("Extension
  v0.7.0"), parallel to MCP rows ("MCP"), instead of a bare version.
- Add Marketplace: when the source is not a Claude marketplace but is a
  valid single extension source (Gemini/Claude/git/npm), the error now
  guides the user to install it directly ("... looks like a single
  extension, not a marketplace. Install it with: /extensions install X")
  instead of the generic "expected marketplace.json" message.

* fix(extensions): resolve git@ SSH marketplace sources

The Marketplaces add flow advertises git@github.com:owner/repo.git (SSH)
as a supported format, but loadMarketplaceConfigFromSource relied on
parseGitHubRepoForReleases, which rejects the git@ scp-like form. Extract
owner/repo directly from the git@github.com:owner/repo(.git) form before
falling back to the URL parser, so SSH marketplace sources actually
resolve. Adds a regression test.

* feat(extensions): cap Discover list window at 6 items

* feat(extensions): unify 'Extension' wording, reorder tabs, expand Marketplaces tab

- Terminology: use 'Extension' instead of 'Plugin' across the dialog
  (Discover extensions, Extension details, Back to extension list, etc.).
- Tabs reordered to Installed, Discover, Marketplaces; the dialog now
  opens on Installed by default.
- Marketplaces tab is now a sources hub:
  - new 'Install new extension' action (installs a single Gemini/Qwen/
    Claude/git/npm extension directly via parseInstallSource).
  - 'Add new marketplace' annotated as a Claude plugin marketplace.
  - items grouped into 'Extensions' and 'Marketplaces' sections; an
    extension row opens a compact detail with Uninstall.

Updates the dialog tests for the new wording, tab order and layout.

* feat(extensions): update Marketplaces tab footer hint

* feat(extensions): full extension actions in both tabs + context-aware Marketplaces footer

- Add a shared ExtensionActionsView (info + components + action menu +
  scope-select + uninstall-confirm) used by both the Installed and
  Marketplaces tabs, so the Marketplaces extension detail now offers the
  full set (Enable/Disable, Favorite, Mark for Update, Update Now,
  Uninstall) instead of just Uninstall/Back.
- Add a new 'Change scope' action (Global/Project/Local) that re-scopes
  enablement (User vs workspace), available in both tabs.
- Context-aware Marketplaces footer: shows 'Enter details' for an
  extension row, 'Enter open · d remove marketplace' for a marketplace
  row, and a neutral hint for the action rows — no longer says
  'd remove marketplace' when an extension is selected.

Adds a test for the full extension actions in the Marketplaces detail.

* feat(extensions): rename Marketplaces tab to Sources, hide Favorites + fix enable/disable in Sources detail

- Rename the user-visible tab label 'Marketplaces' -> 'Sources' (TabBar +
  TABS). The in-tab 'Marketplaces' section header (grouping marketplace
  sources) is kept. Also update the Discover empty-state hint to point at
  the 'Sources' tab.
- Hide the Add/Remove Favorites action in the Sources extension detail via
  a showFavorite prop (default true; Installed keeps it).
- Fix a stale enable/disable label in the Sources extension detail:
  ExtensionActionsView re-read enablement through the manager cache keyed by
  a tick, but refreshCache() briefly empties that cache, so the read raced
  and fell back to the stale extension prop (isActive: true). It now holds
  authoritative local state (enabled/isFavorite/scope) updated optimistically
  after each action — no cache read-back. The Installed tab was immune only
  because it fed a fresh extension object each load.

Adds regression tests for the enable/disable toggle staying in sync and for
change-scope re-scoping + re-enabling a disabled extension.

* feat(extensions): group Sources action rows + show current scope in selector

- Add an 'Add new' section heading above the '+ Install new extension' and
  '+ Add new marketplace' rows on the Sources tab, so those two actions are
  grouped like the Extensions and Marketplaces sections.
- In the Change scope selector, default the cursor to the extension's current
  scope and show a 'Current: <scope>' line. Previously it always defaulted to
  Global, so after changing scope it was unclear whether the change took
  effect. Applies to both the Sources and Installed extension detail (shared
  ExtensionActionsView).

Updates tests to assert the 'Add new' section title renders and that
re-entering the scope selector reflects the now-current scope.

* fix(extensions): move uninstall note to confirm step; complete zh/zh-TW i18n

- Move the 'Note: Uninstall permanently removes this extension.' warning out
  of the detail-view action list and into the uninstall confirmation step
  (replacing the near-synonymous 'This action cannot be undone.').
- Fix the Chinese/English mix in the extensions manager: the new multi-tab UI
  added ~104 English strings that had no locale entries, so they fell back to
  the English key at runtime. Add Simplified (zh) and Traditional (zh-TW)
  translations for all of them, plus the matching en.js base keys (en.js is
  the canonical superset; zh/zh-TW require strict key parity per check-i18n).

Placeholders, keyboard tokens (Tab/Enter/Esc/Space/↑↓/·) and the ⚠ glyph are
preserved across all locales.

* feat(extensions): label Installed scope groups as 用户级/项目级/本地级

Rename the Installed-tab scope group headers from User/Project/Local to
'X level' (用户级/项目级/本地级) so the grouping reads as scope levels.
Adds the new keys to en/zh/zh-TW locales.

* feat(extensions): reuse /mcp server detail for installed MCP servers

The Installed-tab MCP item detail was a read-only view (name/type/scope/
transport/status) with a meaningless 'Enter to select' and no actions. Replace
it with McpServerActionsView, which reuses the /mcp dialog's ServerDetailStep,
ToolListStep, ToolDetailStep and AuthenticateStep so the behaviour matches
/mcp exactly: live connection status, View tools, Enable/Disable, Reconnect
(when disconnected), Re-authenticate and Clear authentication.

Handlers mirror MCPManagementDialog (mcp.excluded settings + toolRegistry
discover/disable/disconnect + MCPOAuthTokenStorage). Delete the now-unused
McpDetailView and its obsolete locale keys; add the two new status strings to
en/zh/zh-TW.

* fix(extensions): populate MCP promptCount from prompt registry

Review follow-up: buildServer hardcoded promptCount to 0, diverging from
/mcp's fetchServerData. Query the prompt registry like the original so the
reused MCPServerDisplayInfo is computed consistently.

* refactor(extensions): rename the source-management layer from marketplace to source

The Sources tab treats both single-extension sources and Claude plugin
marketplaces as 'sources', so the source-management layer is renamed for
consistency:
  MarketplaceSource        -> ExtensionSource
  marketplaceRegistry(.ts) -> sourceRegistry(.ts)
  MarketplaceRegistryStore -> SourceRegistryStore
  add/get/remove/markMarketplaceUpdated -> add/get/remove/markSourceUpdated
  loadMarketplace/updateMarketplace     -> loadSource/updateSource
  MarketplacesTab -> SourcesTab; EXTENSIONS_TABS.MARKETPLACES -> SOURCES
  + the source-detail UI handlers.

Terms that refer to the Claude marketplace manifest *format* are kept, since a
marketplace is one source type: ClaudeMarketplaceConfig,
loadMarketplaceConfigFromSource, the .claude-plugin/marketplace.json path,
DiscoveredPlugin.marketplaceName, and the in-tab 'Marketplaces' group label.

* fix(extensions): keep marketplaces.json filename so saved sources survive the rename

The source/* rename accidentally renamed the persisted registry file from
marketplaces.json to sources.json, so previously added sources (e.g. a Claude
marketplace) appeared to vanish — the data was intact in marketplaces.json but
the code read sources.json. Restore the marketplaces.json filename for
backward compatibility.

* fix(extensions): stay on the Discover detail when an install fails

Previously runInstall always returned to the list after attempting an install.
Now it only returns to the list on success; on failure it stays on the
extension detail page so the error message remains visible and the user can
retry without re-navigating.

* feat(extensions): support 'git-subdir' plugin source in Claude marketplaces

Some Claude marketplace plugins live in a subdirectory of a git repo and use a
'git-subdir' source ({url, path, ref, sha}), which resolvePluginSource didn't
handle — installing failed with 'Unsupported plugin source type'. Add the
git-subdir branch: clone the repo (pinned to ref/sha when provided) and return
the subdirectory as the plugin source.

Verified against github.com/42Crunch-AI/claude-plugins @ v1.5.5: the cloned
plugins/api-security-testing subdir is a valid plugin (.claude-plugin/plugin.json).

* feat(extensions): drop the unused 'local' install scope

Simplify the install/visibility scope model from three options (user /
project / local) down to two (user / project). The 'local' option duplicated
the workspace-level enablement of 'project' without providing a meaningfully
different storage location, so it was UI clutter rather than a real feature.

- core: ExtensionScope = 'user' | 'project'; read() filters unknown values
  via a type guard, so any stale 'local' (or otherwise invalid) entry in
  extension-preferences.json is dropped and falls back to 'user' downstream.
- UI: remove the 'local' option from the Discover install menu, the change-
  scope picker, and the Installed tab's group ordering.
- copy: rename 'Project (All Collaborators)' to 'Project (Workspace)' and
  'Install for all collaborators on this repository' to 'Install for the
  current workspace', matching the new two-tier model.
- i18n: clean up the now-unused 'Local *' keys in en / zh / zh-TW and
  retranslate the renamed keys.
- tests: update the scope-change spec and replace the legacy-scope
  migration test with one that exercises the unknown-value filter.

* feat(extensions): add Ctrl+R shortcut to refresh Discover tab

Press Ctrl+R in the Extensions Manager Discover tab to bypass the discover cache and re-fetch all marketplace sources. The refresh hint is merged into the dialog footer, and a success status is shown after the refresh completes.

* fix(extensions): keep j/k typeable in Discover search

The Discover tab list reused the global SELECTION_UP/DOWN matchers,
which include bare j/k as Vim-style navigation. Combined with
type-to-search input, that made it impossible to type j or k into the
search query.

Switch the Discover list navigation to explicit arrow keys plus
Ctrl+P/Ctrl+N, so bare j/k fall through to the printable-character
branch and append to the query. Other extension tabs (Installed,
Sources) remain pure lists and keep the Vim navigation.

* feat(extensions): install standalone Claude Code plugins from a git URL

A repo whose root holds .claude-plugin/plugin.json (no marketplace.json) is
a standalone Claude plugin. Previously installing one by git URL failed with
"Configuration file not found" because the converter only handled gemini
extensions and marketplace-based Claude plugins.

Add convertClaudePluginStandalone: read plugin.json, fold MCP servers from a
root .mcp.json into the config, collect skills/commands/agents, and write
qwen-extension.json. The marketplace path is refactored to share the build
step. MCP entries are normalized from Claude's transport shape (type:'http' +
url) to Qwen's (httpUrl), and the cloned .git is dropped from the result.

Note: cloneFromGit does not init git submodules, so submodule-provided skills are not installed yet.

* i18n(zh): relabel user-scope install as 全局安装

The Discover detail's user-scope install option now reads 全局安装(用户作用域) instead of 仅为你安装(用户作用域), which better conveys that user scope installs the extension globally.

* fix(extensions): keep the manager mounted during install consent prompts

Consent, setting-input and plugin-choice requests raised by an install
used to replace the ExtensionsManagerDialog in DialogManager, unmounting
it mid-install: the dialog remounted on the default Installed tab with
pre-install data, and the completion reload signal hit the dead
instance. Render those prompts inside the dialog instead (tab content
hidden but mounted), and gate the previously always-active key handlers
(MCP detail steps, uninstall confirm) so hidden views can't double-handle
keys while a prompt is shown.

* feat(extensions): show loading feedback for scope change and toggles

Changing an extension's scope now swaps the selector for a
"Changing scope..." line (Esc ignored while in flight), and the
Installed tab's Space toggle reports "Enabling/Disabling ..." in the
status line right away — MCP enable rediscovers tools and can take a
while. Overlapping toggle/favorite presses are ignored until the
in-flight mutation finishes.

* feat(extensions): add --scope to install and a sources CLI command group

Bring the qwen extensions CLI up to par with /extensions manage:
install --scope user|project (workspace accepted as an alias) records
the scope preference and, for project, re-scopes enablement to the
current workspace only — mirroring the Discover tab's install flow.
New sources add/list/update/remove subcommands manage the Claude
marketplace sources that power the Discover tab.

* feat(extensions): trim the Sources tab and add marketplace detail retry/refresh

Drop the redundant installed-extensions list from the Sources tab (those
live on the Installed tab); the marketplace detail still summarizes which
of its plugins are installed, now capped with a "… and N more" line so a
long list stays short. Add an R key in the marketplace detail that
re-fetches the source — surfaced in the footer as a retry on load
failure and a refresh once loaded. Reword the install row to "Install a
new extension".

* feat(extensions): nest extension-bundled MCP servers under their extension

The Installed tab now lists each extension's bundled MCP servers as
indented child rows beneath it, with Enter opening the shared MCP detail
view (tools, OAuth authenticate/clear) just like the /mcp panel.

- Skip child rows shadowed by a same-named user/project server and ones
  blocked by the MCP allow-list, matching the runtime merge semantics.
- Space only blocks the disable direction for bundled servers; an
  individually-excluded server under an active extension can be
  re-enabled. Blocked Space/favorite and Enter on a disabled extension's
  server give info feedback instead of failing silently.
- Hide the always-failing Disable action for active extension-provided
  servers in ServerDetailStep (also fixes the /mcp panel).
- Window the Installed list to the terminal height with scroll hints,
  clamped offset and group-header anchoring.
- Fall back to the list if the open detail's item disappears on reload
  so the tab can't get stuck locked.
- Hermetic tests: stub loadSettings, stabilize two flaky assertions.

* feat(extensions): per-server disable for extension MCPs and live status

Extension-bundled MCP servers can now be disabled individually, aligned
with Claude Code. The record lives in extension-preferences.json keyed
by extension name (not the global mcp.excluded list), so it never
affects same-named servers from other sources, survives restarts via
config.isMcpServerDisabled, and is cleaned up on uninstall.

- All three surfaces support the toggle: Installed tab Space, the
  extensions MCP detail view, and the /mcp panel; ServerDetailStep
  offers Disable for extension servers again.
- Installed tab MCP rows now show the live connection state (connected
  / needs authentication / connecting / disconnected) instead of a bare
  enabled flag; selected rows highlight the badge and status text too.
- "Needs authentication" (401 marker or declared OAuth with no stored
  token) renders consistently in the /mcp list and both detail views;
  a successful connect clears the sticky 401 marker in core.
- All three surfaces subscribe to MCP status changes for live updates,
  with statuses re-stamped synchronously before setState to close the
  load/listener race.
- Perf: extension preferences are cached by mtime, and the disabled
  predicate finds the owning extension without rebuilding the merged
  server map.

* docs(extensions): document interactive manager and backfill missing i18n

Add a 'The interactive extension manager' section covering the
Discover/Installed/Sources tabs, per-server MCP enable/disable, and
keybindings.

Backfill en/zh/zh-TW entries for six previously-untranslated strings
referenced via t() (npm install flags, the install command description,
'Description', and 'Delete Session') so they render localized instead of
falling back to English.

* fix(extensions): harden untrusted-marketplace handling from PR review

Address review findings on the new marketplace/plugin attack surface:

Security
- Strip ANSI/control sequences from marketplace-sourced display strings
  (Discover + Sources) so a hostile source can't manipulate the terminal
  before install consent.
- Confine git-subdir source.path to the cloned repo (reject absolute/.. /empty)
  to stop path traversal; prefer the immutable SHA pin over a named ref.
- Refuse absolute/relative local-path plugin sources from remote marketplaces.
- Reject absolute/escaping mcpServers and hooks file paths in plugin.json so
  the converter can't read arbitrary out-of-tree files.
- Only follow http(s) plugin homepages (block file:// etc. via open()).
- Cap marketplace HTTP response bodies and add a wall-clock fetch deadline
  (req.setTimeout is socket-idle only) to prevent OOM / indefinite hangs.

Correctness / robustness
- A post-install scope/enablement failure no longer marks the install failed.
- .mcp.json without an mcpServers object is skipped instead of misparsed.
- Guard Ctrl+R refresh against concurrent in-flight discovery.
- Log (not swallow) OAuth token-store lookup failures.
- InstalledTab: single-pass tool-count map (drop N+1 getAllTools) and O(1)
  row index lookup (drop per-row items.indexOf).

Tests
- Make the .git-stripping assertion meaningful and cover the absolute-path
  mcpServers guard and the .mcp.json fallback.

* fix(extensions): consent layout, per-extension update check, uninstall progress

- ConsentPrompt: render the markdown prompt in a column. The inner Box
  defaulted to flexDirection=row, so a multi-paragraph consent (e.g. an
  extension bundling several MCP servers) tiled its blocks into narrow
  vertical columns. It now stacks vertically.
- "Mark for Update" now checks only the selected extension via
  checkForExtensionUpdate (previously ran the full checkForAllExtensionUpdates
  with a discarded result), stores the result, and reports "update available"
  vs "already up to date" so the "Update Now" action shows up immediately.
- Uninstall: show an "Uninstalling ..." line while removal runs so the confirm
  prompt no longer looks frozen after Enter.
- i18n (en/zh/zh-TW): add the new strings; drop the now-unused
  "Checked ... for updates." key.

* feat(extensions): clearer update-check feedback for "Mark for Update"

Distinguish the four check outcomes instead of collapsing everything to
"up to date": update-available, up-to-date, not-updatable, and error.
For not-updatable Claude marketplace plugins, spell out the reason and
workaround (they are install-time conversions with no git remote, so they
update by reinstalling). Also show a "Checking ... for updates..." line
first, since git/github-release/npm checks hit the network.

* fix(extensions): confine resource/source paths, sanitize homepage (review round 2)

- claude-converter: route commands/skills/agents resource paths (collectResources)
  and the relative string plugin-source through path-confinement — reject
  absolute / ..-escaping values — matching the existing mcpServers/hooks guard.
- sourceRegistry: sanitize plugin.homepage like the other untrusted marketplace
  display fields (it otherwise reaches status toasts unsanitized).
- InstalledTab: construct MCPOAuthTokenStorage once instead of once per server.

* fix(extensions): confine symlink targets when converting untrusted plugins

A plugin source from an untrusted marketplace/git repo can embed a
symlink whose name stays inside the package but whose target points at a
host file (e.g. skills/leak.txt -> ~/.ssh/id_rsa). The path-confinement
checks were purely lexical (path.resolve), while the downstream
copy/read calls follow symlinks, so the target's content could be
shipped into the installed extension.

- copyDirectory: pin the package real-path root and skip symlinks whose
  resolved target escapes it (this is the bulk-copy vector at
  buildQwenExtensionFromPlugin).
- resolvePluginRelativeFile: re-verify the real path with realpathSync
  after the lexical check (covers mcpServers/hooks/single-file resources).
- collectResources: skip symlinked files in a collected folder whose
  target escapes the resource dir.
- resolvePluginSource: reject a string source that resolves outside the
  marketplace dir through a symlink.

Adds regression tests for the bulk-copy and collected-folder paths.

* fix(extensions): confine manifest reads, share path-containment helpers

Follow-up to the symlink confinement work (review round 3):

- Guard the three manifest reads that bypassed the new checks: a hostile
  clone could make marketplace.json / plugin.json / .mcp.json themselves
  symlinks to JSON-shaped host files (e.g. ~/.docker/config.json), whose
  content was parsed into the merged config. Each read now verifies the
  real path stays inside the package before reading; .mcp.json is treated
  as absent, plugin.json/marketplace.json throw.
- Hoist the containment logic into gemini-converter as exported
  isPathWithin (lexical) + realPathWithin (symlink-resolved) so the rule
  lives in one place instead of being duplicated across both converters.
- Document copyDirectory's confineRoot parameter in its JSDoc.

Adds targeted tests for the resolvePluginRelativeFile and
resolvePluginSource symlink-rejection branches and the plugin.json
manifest guard.

* fix(extensions): cap manager dialog width to the main content area

On a wide terminal the /extensions manager clipped its right-aligned
status column to a sliver (e.g. "扩…"). Root cause: the dialog sized
itself to boxWidth = columns - 4 with no cap, while the app's main
content area is capped at 100 columns (AppContainer's mainAreaWidth).
Past ~104 columns the dialog grew wider than its container and the right
edge — including the status column flexGrow pushes to the far right — was
clipped off-screen. Narrow terminals were unaffected because columns - 4
stayed within the cap.

Cap boxWidth at Math.min(columns - 4, 100) to match the main content
area, mirroring the existing DiffDialog idiom.

Adds a wide-terminal regression test that renders through a 200-column
stdout and asserts no line exceeds the content area (the uncapped dialog
produced ~196-col lines).

* fix(diff): cap /diff dialog width to the main content area

Same wide-terminal clipping as the extensions manager: DiffDialog sized
itself to Math.min(columns - 4, 110), but the app's main content area is
capped at 100 cols (AppContainer's mainAreaWidth). On a wide terminal the
dialog grew to 110 columns inside the 100-column container and its right
border/edge was clipped off-screen. Narrow terminals were unaffected
because columns - 4 stayed within the cap.

Cap dialogWidth at Math.min(columns - 4, 100) to match the container.

Adds a wide-terminal regression test (200-column stdout) asserting no
rendered line exceeds the content area.

* fix(extensions): address review round 3 — symlink/ANSI confinement, crash & data-loss hardening

Security:
- claude-converter: confine git-subdir subdir against symlink escape;
  fail strict mode on a symlinked plugin.json; sanitize untrusted
  source/path in conversion error messages
- sourceRegistry: sanitize version/category/lastUpdated and component
  names before TUI render
- gemini-converter: guard gemini-extension.json reads with realPathWithin
- SourcesTab: sanitize persisted marketplace name in list + remove-confirm

Robustness:
- SourcesTab: wrap sync removeSource in try/catch (was crashing the TUI);
  only mark a marketplace updated when the refresh actually loaded
- InstalledTab: move bundled-MCP enable write inside try/catch
- DiscoverTab: return to the list (not an arbitrary plugin's detail) after
  a failed batch install
- install.ts: roll back the User-scope disable when the Workspace enable fails
- claude-converter: reject a null/non-object plugin.json with a clear error;
  warn instead of silently skipping a symlinked .mcp.json
- extensionPreferences/sourceRegistry: quarantine a corrupt state file to
  ${path}.corrupted so the next write can't clobber recoverable data

Tests:
- cover marketplace.json/plugin.json/.mcp.json symlink guards, strict-mode
  rejection, and null plugin.json
- delete the process.stdout.columns override on non-TTY in dialog width tests

* fix(extensions): guard toggleFavorite write against unhandled rejection

* test(extensions): normalize realpathSync mock so gemini guard passes on Windows

* fix(extensions): address review round 4 — shared sanitizer, narrowed quarantine, visible warnings, tests

- consolidate the three drifted ANSI/control-char strippers into a single
  shared stripAnsiAndControl in core textUtils (fixes the C1-range gap in
  workflow-orchestrator's copy); sourceRegistry/claude-converter now delegate
- claude-converter: also sanitize the git-subdir ref/sha in the not-found error
- corruptFile: surface the quarantine on stderr (debug log is gated off, so a
  silent move would still look like data loss)
- extensionPreferences/sourceRegistry: only quarantine on a JSON parse failure,
  not on transient read errors (EACCES/EMFILE/EISDIR) that leave a valid file
- install: surface a failed scope-change rollback instead of swallowing it

Tests:
- gemini-converter: negative-path coverage for the realPathWithin guards
- new corruptFile.test (rename-aside + best-effort failure)
- stripAnsiAndControl unit tests (ANSI/OSC/C0/C1)
- install rollback + rollback-also-fails cases

* test(extensions): assert discoverPlugins strips ANSI/control chars from display fields

Locks in the untrusted-metadata sanitization that was only covered
empirically before. Feeds a hostile plugin (cursor moves, line clears,
OSC title-injection, BEL) through discoverPlugins and asserts every
rendered field — name/version/description/author/homepage/category/
lastUpdated/component names plus the marketplace name — comes out clean.

Addresses the maintainer verification note on PR #4850.

* fix(extensions): address review round 5 — scope-change rollback, version sanitization, visible read warnings

- Critical: the UI scope-change (ExtensionActionsView) and project-scope
  install (DiscoverTab) disabled User then enabled Workspace with no
  rollback — a failed Workspace enable left the extension disabled at all
  scopes (silently dead, and in DiscoverTab the outer catch swallowed it so
  the install still reported success). Mirror the CLI install.ts pattern:
  roll the User enable back on failure.
- Persist the scope preference only AFTER enablement succeeds (install.ts +
  both UI paths), so a rolled-back enable can't leave prefs pointing at a
  scope the extension isn't actually enabled at (Installed tab mislabel).
- Security: the persisted 'version' is rendered raw on the Installed tab and
  PluginDetailView — only 'name' is validated on load, so a marketplace
  plugin could inject ANSI/control sequences post-install on every render.
  Scrub via stripUnsafeCharacters at both sinks (covers already-installed
  extensions; the Discover-side sanitization doesn't reach this path).
- Transient read errors in extensionPreferences/sourceRegistry only logged
  via the gated debugLogger, so a user's favorites/scopes/sources could
  vanish with no trail. Add an stderr warning matching quarantineCorruptFile.
- Tests: assert quarantine runs on a parse error (.corrupted sibling) and
  does NOT run on a transient read error (EISDIR), for both stores.

* fix(extensions): address review round 6 — marketplace-name sanitization, rollback-failure surfacing, url-source guard, security tests

Code:
- ANSI injection via the Discover marketplace filter: the marketplace name
  (untrusted, from a remote marketplace.json) flowed through onBrowse to the
  Discover hint render unsanitized. Scrub it in handleBrowseSource — this also
  fixes the filter comparison (it is matched against the already-sanitized
  DiscoveredPlugin.marketplaceName).
- '(Tab to clear)' hint was misleading: Tab cycled tabs rather than clearing
  the marketplace filter in place. On Discover with an active filter, Tab now
  clears the filter in place, matching the hint.
- Scope-change rollback failures were silently swallowed by a bare catch in
  both the Discover batch install and ExtensionActionsView, unlike the CLI
  install.ts which surfaces them. Both now report the rollback failure so the
  user knows the extension may be disabled at all scopes (new i18n keys for
  en/zh/zh-TW).
- resolveInstallSource: the structured { source: 'url' } branch bypassed the
  local-path guard applied to string sources, letting a remote http
  marketplace redirect the installer at a local filesystem path. Apply the
  same guard.

Tests:
- marketplace fetchUrl: wall-clock deadline (stalled server) and body-size cap
  (oversized stream) now covered.
- sourceRegistry: remote-marketplace local-path rejection covered for both the
  string and { source: 'url' } source forms.
- claude-converter git-subdir: clone+sha-pin happy path plus path-escape,
  absolute-path, missing-subdir, and symlink-escape rejections covered.

Note: the bot's 'missing scope rollback' threads target a pre-631e271fb
snapshot — that rollback already landed in round 5.

---------

Co-authored-by: 克竟 <dingbingzhi.dbz@alibaba-inc.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-20 13:18:19 +08:00
..
design feat(config): add settings file change detection via chokidar watcher (#3696) (#4933) 2026-06-19 15:00:44 +08:00
developers fix(core): validate grep result limits (#5389) 2026-06-19 13:47:13 +08:00
e2e-tests feat(worktree): Phase D — startup --worktree flag + symlinkDirectories + PR refs (#4381) 2026-05-27 17:04:51 +08:00
plans feat(core)!: redesign auto-compaction thresholds with three-tier ladder (#4345) 2026-05-25 21:11:08 +08:00
superpowers feat(serve): add daemon idle detection to GET /health?deep=true (#4934) 2026-06-18 06:55:03 +00:00
users feat(extensions): interactive multi-tab /extensions manager (Installed / Discover / Sources) (#4850) 2026-06-20 13:18:19 +08:00
verification/abort-controller-refactor fix(core): stop AbortSignal listener leak in long sessions (MaxListenersExceededWarning) (#4366) 2026-05-26 14:21:49 +08:00
_meta.ts feat: refactor docs 2025-12-05 10:51:57 +08:00
declarative-agents-port.md feat(core): port declarative-agent mcpServers + hooks (CC 2.1.168 parity follow-up) (#4996) 2026-06-12 14:15:51 +08:00
index.md fix: lint issues 2025-12-19 15:52:11 +08:00
yaml-parser-replacement.md feat(core): port declarative-agent mcpServers + hooks (CC 2.1.168 parity follow-up) (#4996) 2026-06-12 14:15:51 +08:00