diff --git a/docs/design/extension-management-v2.md b/docs/design/extension-management-v2.md new file mode 100644 index 00000000000..34a0734926d --- /dev/null +++ b/docs/design/extension-management-v2.md @@ -0,0 +1,223 @@ +# Extension Management V2 + +## Status + +This design extends daemon protocol `v1` under the additive +`extension_management_v2` capability. The already-published +`workspace_extensions` capability and `/workspace/extensions/*` routes remain +available as a primary-workspace compatibility adapter. + +## Resource model + +An installed extension is one user-level artifact in `QWEN_HOME/extensions`. +Activation is policy, not a second copy of that artifact: + +1. An exact workspace override (`enabled` or `disabled`). +2. An internal exact `inherit` mask created while migrating legacy path rules. +3. An ordered V1 path rule. +4. The global default. + +Workspace identity uses the daemon's canonical workspace path. A workspace +route selects an existing runtime by workspace id first and canonical cwd +second. Reads are allowed for untrusted runtimes; activation changes, refresh, +and workspace-scoped install require a trusted target. Global mutation uses the +normal daemon mutation authentication and install consent, not the trust state +of whichever workspace initiated the request. + +## Store and transaction boundary + +`ExtensionStore` is the only writer of final extension directories and V2 +activation state. `ExtensionManager` remains the workspace-facing facade, but +CLI, TUI, auto-update, daemon, and SDK-backed operations delegate mutations to +the store. + +The layout is: + +```text +~/.qwen/ +├── extensions/ +└── extension-store/ + ├── lock + ├── state.json + ├── state.previous.json + ├── staging/ + ├── rollback/ + └── transactions/ +``` + +The store and artifacts share a filesystem so artifact swaps are directory +renames. An in-process mutex and a `proper-lockfile` lock serialize commits +across all V2-aware processes. Every mutation re-reads state while holding the +lock and increments a monotonic generation, preventing lost updates. + +Install/update preparation happens outside the final artifact directory. The +commit writes a `prepared` journal, moves the old artifact to rollback, moves +staging into place, and atomically writes `state.json`. That state rename is the +commit point. Before it, recovery rolls back; after it, recovery only completes +projection and cleanup. A committed policy is never rolled back because one +runtime refresh failed. If both a pre-commit operation and its rollback fail, +the caller receives both errors and the journal remains for fail-closed recovery; +the store does not continue writing through an ambiguous artifact state. + +Store files use owner-only permissions and atomic no-follow writes. Extension +ids, direct-child artifact paths, transaction paths, and names are validated. +Failures are reported with credential-redacted sources. + +## V1 migration and downgrade projection + +The first V2-aware process imports ordered rules from +`extension-enablement.json` without materializing the current set of registered +workspaces as exact overrides. V2 writes a compatible projection after each +state commit and stores its hash in `state.json`. + +If hashes differ, modification order decides the recovery direction: an older +projection is repaired from authoritative V2 state; a projection modified after +V2 state is treated as a sequential write by a downgraded binary and is +re-imported with a new generation. Concurrent V1 and V2 writers sharing one +`QWEN_HOME` are intentionally unsupported. + +Clearing a public workspace override normally deletes the exact record. If an +older path rule would then change the effective value, the store writes an +internal `inherit` mask so DELETE still means “inherit the global default.” + +## Daemon API + +The global surface is: + +```text +GET /extensions +POST /extensions/install +POST /extensions/check-updates +POST /extensions/:extensionId/update +DELETE /extensions/:extensionId +PUT /extensions/:extensionId/activation +GET /extensions/operations/:operationId +``` + +Install requires explicit consent and initial activation: + +```ts +type InitialActivation = + | { scope: 'user' } + | { scope: 'workspace'; workspaceId: string }; +``` + +The daemon install endpoint accepts HTTPS Git, GitHub Release, and npm sources +under the public network policy. SSH and local/link sources remain local CLI +features. Update preserves the extension id, +manifest name, settings, and activation policy. “Already current” is a +successful `updated: false` result. Uninstall is idempotent and removes both the +artifact and policy. + +The workspace projection is: + +```text +GET /workspaces/:workspace/extensions +PUT /workspaces/:workspace/extensions/:extensionId/activation +DELETE /workspaces/:workspace/extensions/:extensionId/activation +POST /workspaces/:workspace/extensions/refresh +``` + +It intentionally has no workspace artifact mutation routes. Projection entries +include default, exact workspace value, effective value, and source. Desired +generation and locally applied generation are top-level response fields. + +Potentially slow mutations return `202`, `Location`, and `Retry-After`. The +operation record is daemon-local memory, retains at most 100 terminal records, +and can disappear on restart. Catalog/store recovery is authoritative. SDK +polling timeout stops polling only; it never cancels accepted work. + +The daemon admits at most 10 unfinished extension operations. A daemon-wide +FIFO preparation queue runs at most two downloads, extractions, conversions, +or single-extension update checks at once. Install and update use an explicit +`prepare -> commit/dispose` lifecycle: preparation owns staging files and +revisioned credential snapshots but does not change the store, cache, runtime, +or credentials selected by the installed artifact. Prepared mutations enter a +separate single-concurrency FIFO commit queue in the order preparation +finishes. Activation and uninstall enter only the commit queue; check-updates +enters only the preparation queue. Manual refresh is serialized through the +commit queue. Its HTTP timeout releases that lane so a stalled runtime refresh +cannot permanently block later extension mutations; the already-started refresh +may still settle afterward. Sensitive settings are staged as one atomic secret +bundle under a per-prepare revision. A non-secret selector records that revision +and secure-storage backend inside the staged artifact, so only the winning +artifact commit activates a complete bundle. The store commit is therefore the +durability point and releases the commit lane immediately. Extension reload, +legacy per-key settings synchronization, manager runtime refresh, prepared-file +cleanup, and daemon runtime reconciliation run outside it. These post-commit +steps do not occupy either slot, so later commits may proceed while an earlier +generation is being applied or cleaned up. + +Disposing a prepared mutation removes its unselected credential snapshot, and a +successful commit removes the previously selected snapshot best-effort. A hard +process crash before disposal can leave an unreachable entry in the secure +backend; no artifact selector references it, so it cannot become active or be +mistaken for the committed credentials. + +The preparation deadline starts when an operation first acquires a preparation +slot, not while it waits. Abort is propagated to network operations and active +archive scanning and extraction streams. A started task continues to occupy its +slot until its underlying promise settles even if it ignores abort. Commit is +not cancellable. Prepared updates carry the target artifact generation: +unrelated extension or activation changes safely rebase, while a stale update +of the same artifact fails with `extension_conflict`. + +Remote npm metadata is streamed with a 10 MiB response cap. npm and GitHub +archives have separate 100 MiB download caps, request deadlines, redirect +limits, and archive-entry validation before extraction. + +## Runtime reconciliation + +A successful commit invalidates local status and refreshes affected runtimes. +Global artifact/default changes reconcile all runtimes in this daemon; an exact +workspace override reconciles only its target. Runtime reconciliation refreshes +extension and skill caches, extension tools, hierarchical memory, active chat +system instructions, and available commands. A failed component does not skip +the remaining refresh components; the session RPC reports the combined failure +after all components have been attempted. Runtime generation reconciliation +uses a daemon-wide FIFO shared by mutations and the generation poller. A +mutation reserves its position at the durable commit callback, so later +generations cannot refresh a runtime first even when earlier post-commit work +finishes later. +The ACP bridge bounds each session refresh at 30 seconds. If the aggregate +refresh still exceeds the route deadline, the controller releases the commit +lane without cancelling the underlying RPC. Applying generation N also +satisfies waiters for older generations, +and a late lower-generation refresh therefore cannot move the applied +generation backwards. Partial refresh failure or post-commit reload/cleanup +failure produces `succeeded_with_warnings` with workspace-specific or commit +diagnostics, without rolling back the artifact. + +Legacy workspace migration treats a committed artifact as failed only when it +could not be reloaded. Settings compatibility synchronization, cleanup, or +runtime-refresh warnings do not trigger a retry of an artifact that is already +durably installed. Update callers receive warning details; compatibility and +cleanup warnings use a distinct `updated with warnings` state, while reload or +runtime-refresh failures remain `updated, needs restart`. + +The extension file watcher observes only `extension-store/state.json` for +policy generation and continues to observe installed/linked extension content +for command, skill, agent, hook, and MCP changes. A 30-second generation poll +repairs missed filesystem events and bounds convergence for other daemons that +share the store. + +## Compatibility + +`workspace_extensions` remains the capability for the existing singular +surface. Its handlers call the same manager/coordinator and adapt responses: +project activation becomes a primary workspace override; user activation keeps +the legacy rule-clearing behavior; global mutation reconciles every local +runtime. The legacy operation endpoint maps V2 warning completion back to the +published legacy refresh-error status. + +Clients must check `extension_management_v2`; neither daemon mode nor another +workspace capability implies this API. The abandoned +`workspace_qualified_extensions` proposal is not part of the protocol. + +## Non-goals + +- Per-workspace artifact copies. +- A daemon registry or remote acknowledgement protocol. +- User cancellation of accepted operations. +- Concurrent old-binary and V2-aware writes to one `QWEN_HOME`. +- Removing the V1 adapter before a future protocol-v2 migration. diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md index 3f954191eb4..4949f495e4f 100644 --- a/docs/developers/daemon/11-capabilities-versioning.md +++ b/docs/developers/daemon/11-capabilities-versioning.md @@ -118,6 +118,8 @@ Permissions: `session_permission_vote`, `permission_vote`, **`permission_mediati Workspace read-only snapshots: `workspace_mcp`, `workspace_skills`, `workspace_providers`, `workspace_env`, `workspace_preflight`, `workspace_hooks`, `workspace_extensions`. +Extension management: `extension_management_v2` adds the global `/extensions/*` catalog/mutation/operation contract and the workspace activation projection. It is separate from the published `workspace_extensions` compatibility surface and from `workspace_qualified_rest_core`. + Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_tool_toggle`, **`workspace_settings`** (conditional), `workspace_permissions`, `workspace_init`, `workspace_github_setup`, `workspace_trust`, `workspace_mcp_restart`, `workspace_mcp_manage`, `workspace_file_read`, `workspace_file_bytes`, `workspace_file_write`, **`workspace_reload`** (conditional). MCP guardrails: **`mcp_guardrails`** (`modes: ['warn', 'enforce']`), `mcp_guardrail_events`, `mcp_server_runtime_mutation`, **`mcp_workspace_pool`** (conditional), **`mcp_pool_restart`** (conditional). diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index c472099ceb5..7dcb2cfa50a 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -189,7 +189,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'session_branch', 'rate_limit', 'workspace_reload', 'multi_workspace_sessions', 'multi_workspace_session_rewind', 'multi_workspace_session_shell', 'persistent_workspace_registration', - 'workspace_qualified_rest_core', 'workspace_persisted_transcript', + 'workspace_qualified_rest_core', 'extension_management_v2', + 'workspace_persisted_transcript', 'client_mcp_over_ws', 'cdp_tunnel_over_ws', 'browser_automation_mcp'] ``` @@ -219,7 +220,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design `session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived. -`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. On single-workspace daemons, `workspaces[]` is absent unless `multi_workspace_sessions` is also advertised, so clients use `capabilities.workspaceCwd` as the cwd selector. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and other plural core routes require a trusted workspace unless a separate capability explicitly defines a narrower read-only policy, such as `workspace_persisted_transcript`. An untrusted primary continues to receive `403 { code: "untrusted_workspace" }` from the plural catalog and transcript routes; legacy singular primary routes keep their existing compatibility behavior. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool and skill toggles, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, or channel-worker routing. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy. +`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. Newer single-workspace daemons include the primary runtime in `workspaces[]` even when `multi_workspace_sessions` is absent, allowing clients to discover the id required by workspace-qualified routes; clients should fall back to `capabilities.workspaceCwd` for older daemons that omit the array. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and other plural core routes require a trusted workspace unless a separate capability explicitly defines a narrower read-only policy, such as `workspace_persisted_transcript`. An untrusted primary continues to receive `403 { code: "untrusted_workspace" }` from the plural catalog and transcript routes; legacy singular primary routes keep their existing compatibility behavior. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool and skill toggles, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, or channel-worker routing. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy. `session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status. @@ -244,6 +245,159 @@ When `workspace_qualified_rest_core` is advertised, the same file surface is als The same tag also exposes workspace-qualified project-agent CRUD at `/workspaces/:workspace/agents` and `/workspaces/:workspace/agents/:agentType`. These plural routes only read or mutate project-level agents for the selected workspace; `global` and `user` scope requests return `400 { code: "global_scope_not_supported_for_workspace_route" }`. Workspace-less `/workspace/agents` routes retain their existing primary-workspace behavior and remain the only REST surface for user-level agent scope. +`extension_management_v2` advertises a user-level extension catalog and mutation surface at `/extensions/*`, plus workspace activation projections at `/workspaces/:workspace/extensions/*`. Artifacts are global; workspace routes expose only projection reads, exact activation overrides, and runtime refresh. Reads may target an untrusted registered workspace, while activation, refresh, and workspace-scoped install require a trusted target. Slow mutations use daemon-local operations at `/extensions/operations/:operationId`; store generation, not operation history, is authoritative across restart and across daemons. The published `workspace_extensions` capability and `/workspace/extensions/*` routes remain a primary-workspace compatibility adapter. Clients must preflight `extension_management_v2` and must not infer it from daemon mode or `workspace_qualified_rest_core`. + +### Extension Management V2 wire contract + +All routes use the daemon bearer authentication rules above. `X-Qwen-Client-Id` is optional for the V2 mutation routes; when supplied, it must identify a client registered with one of the mutation's target workspace runtimes. `:extensionId` is the lowercase 64-hex extension identity. `:workspace` resolves as an exact workspace id first and otherwise as a URL-encoded absolute cwd after canonicalization. + +| Method and path | Success | +| ------------------------------------------------------------------ | --------------------------------------------------------------------------- | +| `GET /extensions` | `200` global artifact catalog | +| `PUT /extensions/:extensionId/activation` | `202` global default-activation operation | +| `POST /extensions/install` | `202` install operation | +| `POST /extensions/check-updates` | `202` update-check operation | +| `POST /extensions/:extensionId/update` | `202` update operation | +| `DELETE /extensions/:extensionId` | `202` uninstall operation, or idempotent `204` when the extension is absent | +| `GET /extensions/operations/:operationId` | `200` operation snapshot | +| `GET /workspaces/:workspace/extensions` | `200` workspace activation projection | +| `PUT /workspaces/:workspace/extensions/:extensionId/activation` | `202` exact workspace-activation operation | +| `DELETE /workspaces/:workspace/extensions/:extensionId/activation` | `202` clear-override operation | +| `POST /workspaces/:workspace/extensions/refresh` | `202` runtime-refresh operation | + +The global catalog response is: + +```json +{ + "v": 1, + "generation": 12, + "extensions": [ + { + "id": "<64 lowercase hex characters>", + "name": "demo", + "version": "1.2.3", + "installType": "npm", + "defaultActivation": "enabled", + "workspaceOverrideCount": 1 + } + ] +} +``` + +`installType` is omitted when no install metadata is available. `defaultActivation` is `enabled` or `disabled`. `workspaceOverrideCount` excludes stored `inherit` entries. + +The workspace projection response is: + +```json +{ + "v": 1, + "workspaceId": "workspace-id", + "workspaceCwd": "/absolute/workspace", + "trusted": true, + "desiredGeneration": 12, + "appliedGeneration": 11, + "extensions": [ + { + "extensionId": "<64 lowercase hex characters>", + "name": "demo", + "version": "1.2.3", + "defaultActivation": "enabled", + "workspaceActivation": "disabled", + "effectiveActivation": "disabled", + "activationSource": "workspace_override" + } + ] +} +``` + +`workspaceActivation` is `enabled`, `disabled`, or `null` for inheritance. `activationSource` is `default`, `workspace_override`, `legacy_path_rule`, or `cli_override`. `desiredGeneration` is the durable store generation; `appliedGeneration` is the latest generation the controller recorded as applied to that workspace runtime and can temporarily lag. + +Install requires explicit consent and an initial activation: + +```json +{ + "source": "@scope/demo", + "consent": true, + "activation": { "scope": "user" }, + "ref": "optional-git-ref", + "autoUpdate": true, + "allowPreRelease": false, + "registry": "https://registry.npmjs.org" +} +``` + +For workspace-only initial activation use `{ "scope": "workspace", "workspaceId": "target-workspace-id" }`; the target must exist and be trusted. Daemon installs accept GitHub, Git, and npm sources. `ref` does not apply to npm, and `registry` applies only to npm. `ref`, `autoUpdate`, `allowPreRelease`, and `registry` are optional. + +Global and workspace activation `PUT` requests use the same body: + +```json +{ "state": "enabled" } +``` + +`state` is `enabled` or `disabled`. Update, uninstall, check-updates, clear-activation, and refresh requests have no required body. + +Every accepted asynchronous mutation returns: + +```http +HTTP/1.1 202 Accepted +Location: /extensions/operations/ +Retry-After: 1 +Content-Type: application/json + +{"accepted":true,"operationId":""} +``` + +Workspace-qualified mutations use the same global `/extensions/operations/:operationId` polling path. Operation history is process-local, keeps only a bounded number of terminal entries, and is lost on daemon restart; clients must re-read the catalog or workspace projection and compare generations when an operation id disappears. + +An operation snapshot has this shape: + +```json +{ + "v": 1, + "operationId": "", + "operation": "install", + "status": "running", + "phase": "preparing", + "createdAt": 1750000000000, + "updatedAt": 1750000000100, + "source": "owner/repository", + "name": "demo" +} +``` + +`status` transitions from `queued` to `running`, then to `succeeded`, `succeeded_with_warnings`, or `failed`. While running, `phase` is `preparing`, `committing`, or `reconciling`. Terminal success may include `result` with `status` equal to `installed`, `enabled`, `disabled`, `updated`, `uninstalled`, `checked`, or `refreshed`; reconciliation results can additionally contain `refreshed`, `failed`, and `error`. Update checks return `result.states`, keyed by extension name, with values such as `checking for updates`, `update available`, `up to date`, `not updatable`, or `error`. + +A durable commit followed by incomplete cleanup or runtime reconciliation is not reported as a failed mutation. It returns `succeeded_with_warnings` and preserves the committed result: + +```json +{ + "v": 1, + "operationId": "", + "operation": "activation", + "status": "succeeded_with_warnings", + "createdAt": 1750000000000, + "updatedAt": 1750000000200, + "result": { + "status": "disabled", + "name": "demo", + "refreshed": 1, + "failed": 1 + }, + "warnings": [ + { + "workspaceId": "workspace-id", + "workspaceCwd": "/absolute/workspace", + "code": "reconcile_slow", + "error": "Runtime reconciliation took 31000ms." + } + ] +} +``` + +Warning `workspaceId` and `code` are optional; `workspaceCwd` and `error` are always present. Clients should display warnings, refresh their catalog/projection, and must not retry the durable mutation blindly. + +Validation and authorization failures are synchronous HTTP errors using `{ "error": "...", "code": "..." }` when a stable code exists. Important cases are `400 invalid_extension_id`, `400 invalid_extension_activation`, `400 workspace_mismatch`, `403 untrusted_workspace`, `404 extension_operation_not_found`, and `429 extension_queue_full`. Install validation also returns `400` for invalid source/ref/registry options, missing consent, or missing/invalid initial activation. A mutation that fails after `202` is represented, while retained in operation history, with `status: "failed"`, `error`, and an optional stable `code`; common codes include `extension_prepare_timeout` and `extension_conflict`. HTTP `404` for an operation does not imply rollback because operation history is not durable. + `daemon_status` advertises `GET /daemon/status`, the consolidated read-only operator diagnostic snapshot documented below. @@ -586,7 +740,7 @@ Stable contract: when `v` increments the frame layout has changed in a backwards > **`workspaceCwd`** is the canonical absolute path for the daemon's primary workspace. Use it to omit `cwd` on `POST /session` (the route falls back to this primary path) and to keep old single-workspace clients compatible. Additive to v=1: pre-§02 v=1 daemons omit the field — clients that target older builds should null-check before consuming it. -> **`workspaces[]`** is present only when `features` contains `multi_workspace_sessions`. Each entry is `{ id, cwd, primary, trusted, removable? }`. The first/primary workspace remains mirrored by `workspaceCwd`; new clients choose a non-primary runtime by passing that entry's `cwd` to `POST /session`. Untrusted workspaces are advertised for diagnostics but reject fresh session creation with `403 untrusted_workspace` until trust changes. `removable` is present on daemons that support runtime removal and is true only for process-dynamic or persistence-restored secondary runtimes. +> **`workspaces[]`** lists every registered runtime. Newer single-workspace daemons include the primary runtime even when `multi_workspace_sessions` is absent so clients can discover the stable id required by workspace-qualified routes; older daemons may omit the array. Each entry is `{ id, cwd, primary, trusted, removable? }`. The first/primary workspace remains mirrored by `workspaceCwd`; new clients choose a non-primary runtime by passing that entry's `cwd` to `POST /session`. Untrusted workspaces are advertised for diagnostics but reject fresh session creation with `403 untrusted_workspace` until trust changes. `removable` is present on daemons that support runtime removal and is true only for process-dynamic or persistence-restored secondary runtimes. The workspace feature tags and `workspaces[]` are dynamic. Clients that add a workspace must fetch `/capabilities` again after the mutation completes; the daemon does not broadcast capability changes to clients that cached an earlier response. Forgetting persistence does not unload an active runtime, so that runtime remains advertised until restart. diff --git a/docs/plans/extension-management-v2.md b/docs/plans/extension-management-v2.md new file mode 100644 index 00000000000..149bf14a32f --- /dev/null +++ b/docs/plans/extension-management-v2.md @@ -0,0 +1,49 @@ +# Extension Management V2 Implementation Plan + +## Delivery order + +1. Add `ExtensionStore`, policy migration, global generation, artifact journals, + rollback/forward recovery, and V1 projection. Route all `ExtensionManager` + mutations through it and make install activation atomic. +2. Add the daemon operation coordinator, global catalog/mutation routes, + workspace projection/activation routes, targeted/all-runtime reconciliation, + and the primary-workspace V1 adapter. +3. Add SDK models and polling helpers, watcher generation recovery, CLI/TUI + callers, capability negotiation, protocol documentation, and E2E coverage. + +`extension_management_v2` is advertised only when all three layers are wired. + +## Required invariants + +- `state.json` is the only commit point and generation never decreases. +- No production caller writes a final extension directory directly. +- Install commits its initial activation policy with the artifact. +- Update preserves identity and activation; uninstall is idempotent at the API. +- Workspace routes mutate policy/runtime only, never artifact ownership. +- Global mutation reconciles all local runtimes; workspace mutation reconciles + one target. +- Runtime refresh failure cannot roll back committed global state. +- Preparation concurrency is two across legacy and V2 routes; commit + concurrency is one and FIFO by preparation completion order. +- Preparation never mutates final artifacts or runtime, and every successful + handle is committed once or disposed. +- Same-artifact stale updates fail with `extension_conflict`; unrelated + artifact and activation commits do not invalidate prepared work. +- Reconciliation occupies neither queue and applied generation never moves + backwards. +- V1 routes and capability remain usable by old SDK clients. +- Secrets are redacted from operations, responses, and logs. + +## Verification gates + +Targeted unit tests cover store migration, policy precedence, concurrent store +instances, journal recovery, CLI scope behavior, daemon routing/trust/fanout, +two-slot preparation, preparation-ready commit ordering, queued abort, +same-artifact conflicts, watcher polling, SDK paths, and operation polling. +Repository completion gates are package builds, typecheck, lint, integration +daemon route tests, and the E2E plan in +`.qwen/e2e-tests/extension-management-v2.md`. + +Before completion, audit architecture boundaries, crash/error paths, +compatibility, concurrency, redaction, tests, maintainability, and simpler +alternatives repeatedly until no new actionable issue is found. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index c1d22742332..91bc40709e3 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -378,6 +378,7 @@ describe('qwen serve — capabilities envelope', () => { 'persistent_workspace_registration', 'workspace_runtime_removal', 'workspace_qualified_rest_core', + 'extension_management_v2', 'workspace_persisted_transcript', 'voice_transcribe', ]); diff --git a/package-lock.json b/package-lock.json index 49afd855a02..454f89b3c17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8638,8 +8638,8 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { "@types/node": "*" } @@ -13265,6 +13265,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -14522,41 +14523,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extract-zip/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -22573,6 +22539,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -28376,7 +28343,6 @@ "chokidar": "^4.0.3", "diff": "^7.0.0", "dotenv": "^17.1.0", - "extract-zip": "^2.0.1", "fast-levenshtein": "^2.0.6", "fast-uri": "^3.0.6", "fdir": "^6.4.6", @@ -28403,7 +28369,8 @@ "uuid": "^9.0.1", "web-tree-sitter": "^0.24.7", "ws": "^8.18.0", - "yaml": "^2.8.1" + "yaml": "^2.8.1", + "yauzl": "^2.10.0" }, "devDependencies": { "@types/diff": "^7.0.2", @@ -28414,6 +28381,7 @@ "@types/prompts": "^2.4.9", "@types/tar": "^6.1.13", "@types/ws": "^8.5.10", + "@types/yauzl": "^2.9.1", "msw": "^2.3.4", "tree-sitter-wasms": "^0.1.13", "typescript": "^5.3.3", diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index c1fac1ffda5..7437c7720e0 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1282,6 +1282,57 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('bounds a hung session extension refresh', async () => { + vi.useFakeTimers(); + const refreshGate = deferred>(); + const handle = makeChannel({ + extMethodImpl: async (method) => + method === SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh + ? await refreshGate.promise + : {}, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + try { + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const refresh = bridge.refreshExtensionsForAllSessions(); + + await vi.advanceTimersByTimeAsync(30_000); + + await expect(refresh).resolves.toEqual({ refreshed: 0, failed: 1 }); + + const retry = bridge.refreshExtensionsForAllSessions(); + expect( + handle.agent.extMethodCalls.filter( + (call) => + call.method === + SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, + ), + ).toHaveLength(1); + await vi.advanceTimersByTimeAsync(30_000); + await expect(retry).resolves.toEqual({ refreshed: 0, failed: 1 }); + + refreshGate.resolve({}); + await vi.advanceTimersByTimeAsync(0); + await expect(bridge.refreshExtensionsForAllSessions()).resolves.toEqual({ + refreshed: 1, + failed: 0, + }); + expect( + handle.agent.extMethodCalls.filter( + (call) => + call.method === + SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, + ), + ).toHaveLength(2); + } finally { + refreshGate.resolve({}); + vi.useRealTimers(); + await bridge.shutdown(); + } + }); + it('does not refresh or broadcast extensions when no sessions are live', async () => { const bridge = makeBridge(); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 2e6b9bbcc6b..71670d3a8e5 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -1519,6 +1519,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // daemon. Cleared in the `finally` of the creator. let inFlightChannelSpawn: Promise | undefined; const byId = new Map(); + const inFlightExtensionRefreshes = new Map< + string, + { connection: ClientSideConnection; promise: Promise } + >(); const toSessionSummary = (entry: SessionEntry): BridgeSessionSummary => { let isWaitingForPermission = false; let isWaitingForUserQuestion = false; @@ -5748,12 +5752,28 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return { refreshed: 0, failed: 0 }; } try { - await Promise.race([ - withTimeout( - entry.connection.extMethod( + let inFlight = inFlightExtensionRefreshes.get(entry.sessionId); + if (!inFlight || inFlight.connection !== entry.connection) { + const promise = (async () => { + await entry.connection.extMethod( SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, { sessionId: entry.sessionId }, - ), + ); + })(); + inFlight = { connection: entry.connection, promise }; + inFlightExtensionRefreshes.set(entry.sessionId, inFlight); + const clear = () => { + if ( + inFlightExtensionRefreshes.get(entry.sessionId) === inFlight + ) { + inFlightExtensionRefreshes.delete(entry.sessionId); + } + }; + void promise.then(clear, clear); + } + await Promise.race([ + withTimeout( + inFlight.promise, 30_000, SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, ), diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 643d4989d47..ff41aaf76f4 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -1028,6 +1028,7 @@ export interface ServeExtensionCapabilities { export type ServeExtensionUpdateState = | 'checking for updates' | 'updated, needs restart' + | 'updated with warnings' | 'updating' | 'updated' | 'update available' diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 706892034da..f618b85faed 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -11410,19 +11410,26 @@ describe('sessionLanguage multi-session propagation', () => { await agentPromise; }); - it('refreshes extension commands for the live session', async () => { + it('refreshes extension state without a duplicate direct skill refresh', async () => { const extensionManager = { refreshCache: vi.fn().mockResolvedValue(undefined), refreshTools: vi.fn().mockResolvedValue(undefined), }; const skillManager = { - refreshCache: vi.fn().mockResolvedValue(undefined), + refreshCache: vi + .fn() + .mockRejectedValue(new Error('direct skill refresh should not run')), }; + const refreshHierarchicalMemory = vi.fn().mockResolvedValue(undefined); const cfg = makeConfig({ getSessionId: vi.fn().mockReturnValue('s-ext'), getExtensionManager: vi.fn().mockReturnValue(extensionManager), getSkillManager: vi.fn().mockReturnValue(skillManager), + refreshHierarchicalMemory, }); + const refreshSystemInstruction = vi.mocked( + cfg.getGeminiClient().refreshSystemInstruction, + ); const sendAvailableCommandsUpdate = vi.fn().mockResolvedValue(undefined); vi.mocked(loadSettings).mockReturnValue({ @@ -11463,15 +11470,102 @@ describe('sessionLanguage multi-session propagation', () => { ).resolves.toEqual({ ok: true }); expect(extensionManager.refreshCache).toHaveBeenCalledOnce(); - expect(skillManager.refreshCache).toHaveBeenCalledOnce(); + expect(skillManager.refreshCache).not.toHaveBeenCalled(); + expect(extensionManager.refreshTools).toHaveBeenCalledOnce(); + expect(refreshHierarchicalMemory).not.toHaveBeenCalled(); + expect(refreshSystemInstruction).toHaveBeenCalledOnce(); + expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); + expect( + extensionManager.refreshTools.mock.invocationCallOrder[0], + ).toBeLessThan(refreshSystemInstruction.mock.invocationCallOrder[0]!); + expect(refreshSystemInstruction.mock.invocationCallOrder[0]).toBeLessThan( + sendAvailableCommandsUpdate.mock.invocationCallOrder[0]!, + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('propagates extension cache refresh failures', async () => { + const cacheError = new Error('bad extension cache'); + const extensionManager = { + refreshCache: vi.fn().mockRejectedValue(cacheError), + refreshTools: vi.fn().mockResolvedValue(undefined), + }; + const skillManager = { + refreshCache: vi.fn().mockResolvedValue(undefined), + }; + const cfg = makeConfig({ + getSessionId: vi.fn().mockReturnValue('s-ext'), + getExtensionManager: vi.fn().mockReturnValue(extensionManager), + getSkillManager: vi.fn().mockReturnValue(skillManager), + }); + const sendAvailableCommandsUpdate = vi.fn().mockResolvedValue(undefined); + + vi.mocked(loadSettings).mockReturnValue({ + merged: { mcpServers: {} }, + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings); + vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config); + vi.mocked(Session).mockImplementation( + () => + ({ + getId: vi.fn().mockReturnValue('s-ext'), + getConfig: vi.fn().mockReturnValue(cfg), + sendAvailableCommandsUpdate, + installRewriter: vi.fn(), + startCronScheduler: vi.fn(), + dispose: vi.fn(), + }) as unknown as InstanceType, + ); + + const agentPromise = runAcpAgent( + makeConfig() as unknown as Config, + { merged: { mcpServers: {} } } as unknown as LoadedSettings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + + await agent.newSession({ cwd: '/ext', mcpServers: [] }); + let thrown: unknown; + try { + await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, + { + sessionId: 's-ext', + }, + ); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(AggregateError); + expect((thrown as AggregateError).errors).toEqual([cacheError]); + expect(thrown).toEqual( + expect.objectContaining({ + message: expect.stringContaining('bad extension cache'), + }), + ); + + expect(extensionManager.refreshCache).toHaveBeenCalledOnce(); + expect(skillManager.refreshCache).not.toHaveBeenCalled(); expect(extensionManager.refreshTools).toHaveBeenCalledOnce(); + expect(cfg.refreshHierarchicalMemory).not.toHaveBeenCalled(); + expect( + cfg.getGeminiClient().refreshSystemInstruction, + ).toHaveBeenCalledOnce(); expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); mockConnectionState.resolve(); await agentPromise; }); - it('still sends available commands update when extension tool refresh fails', async () => { + it('propagates extension tool refresh failures', async () => { const extensionManager = { refreshCache: vi.fn().mockResolvedValue(undefined), refreshTools: vi.fn().mockRejectedValue(new Error('bad tool schema')), @@ -11521,11 +11615,15 @@ describe('sessionLanguage multi-session propagation', () => { agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, { sessionId: 's-ext', }), - ).resolves.toEqual({ ok: true }); + ).rejects.toThrow('bad tool schema'); expect(extensionManager.refreshCache).toHaveBeenCalledOnce(); - expect(skillManager.refreshCache).toHaveBeenCalledOnce(); + expect(skillManager.refreshCache).not.toHaveBeenCalled(); expect(extensionManager.refreshTools).toHaveBeenCalledOnce(); + expect(cfg.refreshHierarchicalMemory).not.toHaveBeenCalled(); + expect( + cfg.getGeminiClient().refreshSystemInstruction, + ).toHaveBeenCalledOnce(); expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(); mockConnectionState.resolve(); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index a15a76e62e0..2db659c8ab1 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -7541,34 +7541,36 @@ class QwenAgent implements Agent { case SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh: { const sessionId = params['sessionId'] as string; const session = this.sessionOrThrow(sessionId); - const extensionManager = session.getConfig().getExtensionManager(); - const skillManager = session.getConfig().getSkillManager(); - await Promise.all([ - extensionManager.refreshCache().catch((err: unknown) => { - debugLogger.warn( - `Extension refresh failed for session ${sessionId}: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - }), - skillManager?.refreshCache().catch((err: unknown) => { - debugLogger.warn( - `Skill refresh failed after extension refresh for session ${sessionId}: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - }), - ]); - try { - await extensionManager.refreshTools(); - } catch (err) { - debugLogger.warn( - `Extension tool refresh failed for session ${sessionId}: ${ - err instanceof Error ? err.message : String(err) - }`, + const config = session.getConfig(); + const extensionManager = config.getExtensionManager(); + const errors: unknown[] = []; + const runRefresh = async (refresh: () => Promise) => { + try { + await refresh(); + } catch (error) { + errors.push(error); + } + }; + await runRefresh(async () => await extensionManager.refreshCache()); + await runRefresh(async () => await extensionManager.refreshTools()); + await runRefresh( + async () => + await config.getGeminiClient()?.refreshSystemInstruction(), + ); + await runRefresh( + async () => await session.sendAvailableCommandsUpdate(), + ); + if (errors.length > 0) { + const details = errors + .map((error) => + error instanceof Error ? error.message : String(error), + ) + .join('; '); + throw new AggregateError( + errors, + `Extension runtime refresh failed: ${details}`, ); } - await session.sendAvailableCommandsUpdate(); return { ok: true }; } case 'deleteSession': { diff --git a/packages/cli/src/commands/channel/daemon-worker.test.ts b/packages/cli/src/commands/channel/daemon-worker.test.ts index eb42aab2630..6cc86117297 100644 --- a/packages/cli/src/commands/channel/daemon-worker.test.ts +++ b/packages/cli/src/commands/channel/daemon-worker.test.ts @@ -883,6 +883,29 @@ describe('runChannelDaemonWorker', () => { ).resolves.toBeDefined(); }); + it('preserves the legacy trust behavior for a singleton workspace', async () => { + const sdk = createSdk(); + sdk.client.capabilities.mockResolvedValueOnce({ + v: 1, + mode: 'http-bridge', + features: [], + modelServices: [], + workspaceCwd: '/workspace', + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: false }, + ], + }); + + await expect( + runChannelDaemonWorker({ + daemonUrl: 'http://127.0.0.1:4170', + workspace: '/workspace', + selection: { mode: 'names', names: ['telegram'] }, + loadDaemonSdk: async () => sdk, + }), + ).resolves.toBeDefined(); + }); + it('accepts a trusted registered non-primary workspace', async () => { const sdk = createSdk(); sdk.client.capabilities.mockResolvedValueOnce({ @@ -917,6 +940,7 @@ describe('runChannelDaemonWorker', () => { workspaceCwd: '/primary', workspaces: [ { id: 'primary', cwd: '/primary', primary: true, trusted: true }, + { id: 'other', cwd: '/other', primary: false, trusted: true }, ], }); @@ -939,6 +963,7 @@ describe('runChannelDaemonWorker', () => { modelServices: [], workspaceCwd: '/primary', workspaces: [ + { id: 'primary', cwd: '/primary', primary: true, trusted: true }, { id: 'worker', cwd: '/workspace', primary: false, trusted: false }, ], }); diff --git a/packages/cli/src/commands/channel/daemon-worker.ts b/packages/cli/src/commands/channel/daemon-worker.ts index b9e6af12088..32047fc2eb9 100644 --- a/packages/cli/src/commands/channel/daemon-worker.ts +++ b/packages/cli/src/commands/channel/daemon-worker.ts @@ -302,7 +302,7 @@ export async function runChannelDaemonWorker( ); const requestedWorkspace = canonicalizeWorkspace(opts.workspace); let daemonWorkspace: string; - if (capabilities.workspaces && capabilities.workspaces.length > 0) { + if (capabilities.workspaces && capabilities.workspaces.length > 1) { // Multi-workspace daemon: the worker must target one of the registered // workspaces (matched on canonical cwd), and that workspace must be trusted // before it can create sessions. diff --git a/packages/cli/src/commands/extensions/disable.test.ts b/packages/cli/src/commands/extensions/disable.test.ts index fa1ad724d26..7e1308595a7 100644 --- a/packages/cli/src/commands/extensions/disable.test.ts +++ b/packages/cli/src/commands/extensions/disable.test.ts @@ -9,7 +9,9 @@ import { disableCommand, handleDisable } from './disable.js'; import yargs from 'yargs'; import { SettingScope } from '../../config/settings.js'; -const mockDisableExtension = vi.hoisted(() => vi.fn()); +const mockDisableExtension = vi.hoisted(() => + vi.fn().mockResolvedValue({ warnings: [] }), +); const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); const mockWriteStderrLine = vi.hoisted(() => vi.fn()); @@ -72,6 +74,21 @@ describe('extensions disable command', () => { describe('handleDisable', () => { beforeEach(() => { vi.clearAllMocks(); + mockDisableExtension.mockResolvedValue({ warnings: [] }); + }); + + it('prints committed activation warnings', async () => { + mockDisableExtension.mockResolvedValueOnce({ + warnings: [ + { code: 'extension_runtime_refresh_failed', error: 'refresh failed' }, + ], + }); + + await handleDisable({ name: 'test-extension', scope: 'user' }); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'extension_runtime_refresh_failed: refresh failed', + ); }); it('should disable an extension with user scope', async () => { diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts index 99dc00bb322..1a3b3a36595 100644 --- a/packages/cli/src/commands/extensions/disable.ts +++ b/packages/cli/src/commands/extensions/disable.ts @@ -20,13 +20,16 @@ export async function handleDisable(args: DisableArgs) { const extensionManager = await getExtensionManager(); try { const scope = resolveExtensionCommandScope(args.scope); - await extensionManager.disableExtension(args.name, scope); + const result = await extensionManager.disableExtension(args.name, scope); writeStdoutLine( t('Extension "{{name}}" successfully disabled for scope "{{scope}}".', { name: args.name, scope: args.scope || SettingScope.User, }), ); + for (const warning of result.warnings ?? []) { + writeStderrLine(`${warning.code}: ${warning.error}`); + } } catch (error) { writeStderrLine(getErrorMessage(error)); process.exit(1); diff --git a/packages/cli/src/commands/extensions/enable.test.ts b/packages/cli/src/commands/extensions/enable.test.ts index 2f595ea9314..cea3c565caf 100644 --- a/packages/cli/src/commands/extensions/enable.test.ts +++ b/packages/cli/src/commands/extensions/enable.test.ts @@ -9,8 +9,11 @@ import { enableCommand, handleEnable } from './enable.js'; import yargs from 'yargs'; import { SettingScope } from '../../config/settings.js'; -const mockEnableExtension = vi.hoisted(() => vi.fn()); +const mockEnableExtension = vi.hoisted(() => + vi.fn().mockResolvedValue({ warnings: [] }), +); const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); vi.mock('./utils.js', async (importOriginal) => { const actual = await importOriginal(); @@ -39,7 +42,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { vi.mock('../../utils/stdioHelpers.js', () => ({ writeStdoutLine: mockWriteStdoutLine, - writeStderrLine: vi.fn(), + writeStderrLine: mockWriteStderrLine, clearScreen: vi.fn(), })); @@ -82,6 +85,21 @@ describe('extensions enable command', () => { describe('handleEnable', () => { beforeEach(() => { vi.clearAllMocks(); + mockEnableExtension.mockResolvedValue({ warnings: [] }); + }); + + it('prints committed activation warnings', async () => { + mockEnableExtension.mockResolvedValueOnce({ + warnings: [ + { code: 'extension_runtime_refresh_failed', error: 'refresh failed' }, + ], + }); + + await handleEnable({ name: 'test-extension', scope: 'user' }); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'extension_runtime_refresh_failed: refresh failed', + ); }); it('should enable an extension with user scope', async () => { diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts index 8f795c6e98c..bf33f626e4f 100644 --- a/packages/cli/src/commands/extensions/enable.ts +++ b/packages/cli/src/commands/extensions/enable.ts @@ -6,7 +6,7 @@ import { type CommandModule } from 'yargs'; import { FatalConfigError, getErrorMessage } from '@qwen-code/qwen-code-core'; -import { writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; import { getExtensionManager, resolveExtensionCommandScope } from './utils.js'; import { t } from '../../i18n/index.js'; @@ -20,7 +20,7 @@ export async function handleEnable(args: EnableArgs) { try { const scope = resolveExtensionCommandScope(args.scope); - await extensionManager.enableExtension(args.name, scope); + const result = await extensionManager.enableExtension(args.name, scope); if (args.scope) { writeStdoutLine( t('Extension "{{name}}" successfully enabled for scope "{{scope}}".', { @@ -35,6 +35,9 @@ export async function handleEnable(args: EnableArgs) { }), ); } + for (const warning of result.warnings ?? []) { + writeStderrLine(`${warning.code}: ${warning.error}`); + } } catch (error) { throw new FatalConfigError(getErrorMessage(error)); } diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index d37b54e7b99..d8cba366266 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -11,8 +11,6 @@ import yargs from 'yargs'; const mockInstallExtension = vi.hoisted(() => vi.fn()); const mockRefreshCache = vi.hoisted(() => vi.fn()); const mockSetExtensionScope = vi.hoisted(() => vi.fn()); -const mockEnableExtension = vi.hoisted(() => vi.fn()); -const mockDisableExtension = vi.hoisted(() => vi.fn()); const mockParseInstallSource = vi.hoisted(() => vi.fn()); const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); const mockRequestConsentOrFail = vi.hoisted(() => vi.fn()); @@ -26,10 +24,13 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ installExtension: mockInstallExtension, refreshCache: mockRefreshCache, setExtensionScope: mockSetExtensionScope, - enableExtension: mockEnableExtension, - disableExtension: mockDisableExtension, })), parseInstallSource: mockParseInstallSource, + isExtensionCommittedWithWarningsError: (error: unknown) => + error instanceof Error && + (error as Error & { code?: string; committed?: boolean }).code === + 'extension_committed_with_warnings' && + (error as Error & { committed?: boolean }).committed === true, })); vi.mock('./consent.js', () => ({ @@ -238,6 +239,10 @@ describe('handleInstall', () => { autoUpdate: true, }), expect.any(Function), + undefined, + expect.any(String), + undefined, + { scope: 'user' }, ); expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "archive-extension" installed successfully and enabled.', @@ -293,71 +298,90 @@ describe('handleInstall', () => { processSpy.mockRestore(); }); - it('should re-scope enablement to the workspace for a project-scope install', async () => { + it('reports a committed install warning without failing', async () => { + const processSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); mockParseInstallSource.mockResolvedValue({ type: 'git', url: 'git@some-url', }); - mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' }); + mockInstallExtension.mockRejectedValue( + Object.assign( + new Error('Extension committed but could not be reloaded.'), + { + code: 'extension_committed_with_warnings', + committed: true, + identity: { id: 'extension-id', name: 'scoped-extension' }, + warnings: [], + }, + ), + ); await handleInstall({ source: 'git@some-url', scope: 'project' }); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Warning: Extension committed but could not be reloaded.', + ); expect(mockSetExtensionScope).toHaveBeenCalledWith( 'scoped-extension', 'project', ); - expect(mockDisableExtension).toHaveBeenCalledWith( + expect(processSpy).not.toHaveBeenCalled(); + + processSpy.mockRestore(); + }); + + it('commits project-scope activation with the install', async () => { + mockParseInstallSource.mockResolvedValue({ + type: 'git', + url: 'git@some-url', + }); + mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' }); + + await handleInstall({ source: 'git@some-url', scope: 'project' }); + + expect(mockSetExtensionScope).toHaveBeenCalledWith( 'scoped-extension', - 'User', + 'project', ); - expect(mockEnableExtension).toHaveBeenCalledWith( - 'scoped-extension', - 'Workspace', + expect(mockInstallExtension).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + undefined, + expect.any(String), + undefined, + { + scope: 'workspace', + workspacePath: expect.any(String), + }, ); expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "scoped-extension" installed successfully and enabled for the current workspace.', ); }); - it('rolls back the User-scope disable when the Workspace enable fails', async () => { - const processSpy = vi - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); + it('keeps a committed install successful when saving scope preference fails', async () => { mockParseInstallSource.mockResolvedValue({ type: 'git', url: 'git@some-url', }); mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' }); - // Workspace enable (first call) fails; the rollback User enable succeeds. - mockEnableExtension.mockRejectedValueOnce( - new Error('workspace enable failed'), - ); - mockEnableExtension.mockResolvedValueOnce(undefined); + mockSetExtensionScope.mockImplementationOnce(() => { + throw new Error('preference denied'); + }); await handleInstall({ source: 'git@some-url', scope: 'project' }); - expect(mockDisableExtension).toHaveBeenCalledWith( - 'scoped-extension', - 'User', - ); - // Both the failed Workspace enable and the rollback User enable were attempted. - expect(mockEnableExtension).toHaveBeenNthCalledWith( - 1, - 'scoped-extension', - 'Workspace', + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Extension "scoped-extension" installed successfully and enabled for the current workspace.', ); - expect(mockEnableExtension).toHaveBeenNthCalledWith( - 2, - 'scoped-extension', - 'User', + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Warning: Extension installed, but failed to save scope preference: preference denied', ); - // The original failure is surfaced and the command exits non-zero. - expect(mockWriteStderrLine).toHaveBeenCalledWith('workspace enable failed'); - expect(processSpy).toHaveBeenCalledWith(1); - processSpy.mockRestore(); }); - it('surfaces a rollback failure when the recovery enable also fails', async () => { + it('reports a failed project-scope install without a follow-up scope mutation', async () => { const processSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); @@ -365,20 +389,12 @@ describe('handleInstall', () => { type: 'git', url: 'git@some-url', }); - mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' }); - // Both the Workspace enable and the rollback User enable fail. - mockEnableExtension.mockRejectedValueOnce( - new Error('workspace enable failed'), - ); - mockEnableExtension.mockRejectedValueOnce(new Error('rollback failed')); + mockInstallExtension.mockRejectedValue(new Error('atomic install failed')); await handleInstall({ source: 'git@some-url', scope: 'project' }); - // A warning naming the failed rollback, plus the original error, are shown. - expect(mockWriteStderrLine).toHaveBeenCalledWith( - expect.stringContaining('failed to roll back the scope change'), - ); - expect(mockWriteStderrLine).toHaveBeenCalledWith('workspace enable failed'); + expect(mockSetExtensionScope).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).toHaveBeenCalledWith('atomic install failed'); expect(processSpy).toHaveBeenCalledWith(1); processSpy.mockRestore(); }); @@ -396,9 +412,13 @@ describe('handleInstall', () => { 'scoped-extension', 'project', ); - expect(mockEnableExtension).toHaveBeenCalledWith( - 'scoped-extension', - 'Workspace', + expect(mockInstallExtension).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + undefined, + expect.any(String), + undefined, + expect.objectContaining({ scope: 'workspace' }), ); }); @@ -415,8 +435,14 @@ describe('handleInstall', () => { 'user-extension', 'user', ); - expect(mockDisableExtension).not.toHaveBeenCalled(); - expect(mockEnableExtension).not.toHaveBeenCalled(); + expect(mockInstallExtension).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Function), + undefined, + expect.any(String), + undefined, + { scope: 'user' }, + ); expect(mockWriteStdoutLine).toHaveBeenCalledWith( 'Extension "user-extension" installed successfully and enabled.', ); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index b4c08e522db..9f2e84deabf 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -8,13 +8,14 @@ import type { CommandModule } from 'yargs'; import { ExtensionManager, + isExtensionCommittedWithWarningsError, parseInstallSource, type ExtensionScope, } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../utils/errors.js'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { isWorkspaceTrusted } from '../../config/trustedFolders.js'; -import { loadSettings, SettingScope } from '../../config/settings.js'; +import { loadSettings } from '../../config/settings.js'; import { requestConsentOrFail, requestConsentNonInteractive, @@ -38,6 +39,8 @@ function normalizeScope(scope: string | undefined): ExtensionScope { } export async function handleInstall(args: InstallArgs) { + const scope = normalizeScope(args.scope); + let extensionManager: ExtensionManager | undefined; try { const installMetadata = await parseInstallSource(args.source); @@ -82,7 +85,7 @@ export async function handleInstall(args: InstallArgs) { ? () => Promise.resolve() : requestConsentOrFail.bind(null, requestConsentNonInteractive); const workspaceDir = process.cwd(); - const extensionManager = new ExtensionManager({ + extensionManager = new ExtensionManager({ workspaceDir, locale: getCurrentLanguage(), isWorkspaceTrusted: @@ -100,46 +103,21 @@ export async function handleInstall(args: InstallArgs) { allowPreRelease: args.allowPreRelease, }, requestConsent, + undefined, + workspaceDir, + undefined, + scope === 'project' + ? { scope: 'workspace', workspacePath: workspaceDir } + : { scope: 'user' }, ); - const scope = normalizeScope(args.scope); if (args.scope) { - // installExtension auto-enables at the user (global) scope. For a - // project-scoped install, re-scope enablement to this workspace only — - // BEFORE recording the scope preference, so a failed Workspace enable - // (which rolls back to User) can't leave the prefs claiming "project". - if (scope === 'project') { - await extensionManager.disableExtension( - extension.name, - SettingScope.User, + try { + extensionManager.setExtensionScope(extension.name, scope); + } catch (scopeError) { + writeStderrLine( + `Warning: Extension installed, but failed to save scope preference: ${getErrorMessage(scopeError)}`, ); - try { - await extensionManager.enableExtension( - extension.name, - SettingScope.Workspace, - ); - } catch (enableError) { - // The User-scope disable already landed. If the Workspace enable - // fails, the extension would be left disabled everywhere — roll the - // User enable back so it isn't silently dead, then surface the error. - try { - await extensionManager.enableExtension( - extension.name, - SettingScope.User, - ); - } catch (rollbackError) { - // Rollback failed too: the extension is now disabled at every - // scope. Surface this so the user knows recovery also failed, - // before the original error is reported below. - writeStderrLine( - `Warning: failed to roll back the scope change for "${extension.name}"; it may be disabled at all scopes: ${getErrorMessage(rollbackError)}`, - ); - } - throw enableError; - } } - // Enablement succeeded (or scope is user/local with no enablement change): - // now it's safe to persist the scope preference. - extensionManager.setExtensionScope(extension.name, scope); } writeStdoutLine( scope === 'project' @@ -152,6 +130,19 @@ export async function handleInstall(args: InstallArgs) { }), ); } catch (error) { + if (isExtensionCommittedWithWarningsError(error)) { + if (args.scope && extensionManager) { + try { + extensionManager.setExtensionScope(error.identity.name, scope); + } catch (scopeError) { + writeStderrLine( + `Warning: Extension installed, but failed to save scope preference: ${getErrorMessage(scopeError)}`, + ); + } + } + writeStderrLine(`Warning: ${getErrorMessage(error)}`); + return; + } writeStderrLine(getErrorMessage(error)); process.exit(1); } diff --git a/packages/cli/src/commands/extensions/link.test.ts b/packages/cli/src/commands/extensions/link.test.ts index 9aff17c8bbb..a10d952e3da 100644 --- a/packages/cli/src/commands/extensions/link.test.ts +++ b/packages/cli/src/commands/extensions/link.test.ts @@ -96,4 +96,27 @@ describe('handleLink', () => { processExitSpy.mockRestore(); }); + + it('does not fail after a link was committed with reload warnings', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + mockInstallExtension.mockRejectedValueOnce( + Object.assign(new Error('Link committed but could not be reloaded.'), { + code: 'extension_committed_with_warnings', + committed: true, + identity: { id: 'linked-extension', name: 'linked-extension' }, + warnings: [], + }), + ); + + await handleLink({ path: '/some/local/path' }); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Warning: Link committed but could not be reloaded.', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + + processExitSpy.mockRestore(); + }); }); diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts index f03b51e460f..984d71636b6 100644 --- a/packages/cli/src/commands/extensions/link.ts +++ b/packages/cli/src/commands/extensions/link.ts @@ -5,7 +5,10 @@ */ import type { CommandModule } from 'yargs'; -import { type ExtensionInstallMetadata } from '@qwen-code/qwen-code-core'; +import { + isExtensionCommittedWithWarningsError, + type ExtensionInstallMetadata, +} from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../utils/errors.js'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { @@ -41,6 +44,10 @@ export async function handleLink(args: InstallArgs) { }), ); } catch (error) { + if (isExtensionCommittedWithWarningsError(error)) { + writeStderrLine(`Warning: ${getErrorMessage(error)}`); + return; + } writeStderrLine(getErrorMessage(error)); process.exit(1); } diff --git a/packages/cli/src/commands/extensions/uninstall.test.ts b/packages/cli/src/commands/extensions/uninstall.test.ts index e2028458780..da0f386eebb 100644 --- a/packages/cli/src/commands/extensions/uninstall.test.ts +++ b/packages/cli/src/commands/extensions/uninstall.test.ts @@ -4,11 +4,49 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; -import { uninstallCommand } from './uninstall.js'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { handleUninstall, uninstallCommand } from './uninstall.js'; import yargs from 'yargs'; +const mockRefreshCache = vi.hoisted(() => vi.fn()); +const mockUninstallExtension = vi.hoisted(() => + vi.fn().mockResolvedValue({ warnings: [] }), +); +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ExtensionManager: vi.fn(() => ({ + refreshCache: mockRefreshCache, + uninstallExtension: mockUninstallExtension, + })), + }; +}); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, +})); + +vi.mock('../../config/settings.js', () => ({ + loadSettings: vi.fn(() => ({ merged: {} })), +})); + +vi.mock('../../config/trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(() => ({ isTrusted: true })), +})); + describe('extensions uninstall command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRefreshCache.mockResolvedValue(undefined); + mockUninstallExtension.mockResolvedValue({ warnings: [] }); + }); + it('should fail if no source is provided', () => { const validationParser = yargs([]) .command(uninstallCommand) @@ -18,4 +56,24 @@ describe('extensions uninstall command', () => { 'Not enough non-option arguments: got 0, need at least 1', ); }); + + it('prints committed uninstall warnings', async () => { + mockUninstallExtension.mockResolvedValueOnce({ + warnings: [ + { + code: 'extension_preferences_cleanup_failed', + error: 'cleanup failed', + }, + ], + }); + + await handleUninstall({ name: 'test-extension' }); + + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Extension "test-extension" successfully uninstalled.', + ); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'extension_preferences_cleanup_failed: cleanup failed', + ); + }); }); diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index 222deb342ee..77544cf13d9 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -34,10 +34,13 @@ export async function handleUninstall(args: UninstallArgs) { isWorkspaceTrusted(loadSettings(workspaceDir).merged).isTrusted ?? true, }); await extensionManager.refreshCache(); - await extensionManager.uninstallExtension(args.name, false); + const result = await extensionManager.uninstallExtension(args.name, false); writeStdoutLine( t('Extension "{{name}}" successfully uninstalled.', { name: args.name }), ); + for (const warning of result.warnings ?? []) { + writeStderrLine(`${warning.code}: ${warning.error}`); + } } catch (error) { writeStderrLine(getErrorMessage(error)); process.exit(1); diff --git a/packages/cli/src/commands/extensions/update.test.ts b/packages/cli/src/commands/extensions/update.test.ts index 3916ec4e992..df95fbbe69a 100644 --- a/packages/cli/src/commands/extensions/update.test.ts +++ b/packages/cli/src/commands/extensions/update.test.ts @@ -149,6 +149,34 @@ describe('handleUpdate', () => { ); }); + it('should surface committed update warnings', async () => { + const mockExtension = { + name: 'test-extension', + installMetadata: { source: 'test' }, + }; + mockGetLoadedExtensions.mockReturnValueOnce([mockExtension]); + mockCheckForExtensionUpdate.mockResolvedValueOnce( + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + mockUpdateExtension.mockResolvedValueOnce({ + name: 'test-extension', + originalVersion: '1.0.0', + updatedVersion: '2.0.0', + warnings: [ + { + code: 'extension_settings_legacy_sync_failed', + error: 'keychain unavailable', + }, + ], + }); + + await handleUpdate({ name: 'test-extension' }); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Extension "test-extension" updated with warning extension_settings_legacy_sync_failed: keychain unavailable', + ); + }); + it('should show up to date message when versions are the same after update', async () => { const mockExtension = { name: 'test-extension', diff --git a/packages/cli/src/commands/extensions/update.ts b/packages/cli/src/commands/extensions/update.ts index 26e781cafdc..f6d22f8f1ac 100644 --- a/packages/cli/src/commands/extensions/update.ts +++ b/packages/cli/src/commands/extensions/update.ts @@ -30,6 +30,17 @@ const updateOutput = (info: ExtensionUpdateInfo) => }, ); +const updateWarningOutput = (info: ExtensionUpdateInfo) => + (info.warnings ?? []) + .map((warning) => + t('Extension "{{name}}" updated with warning {{code}}: {{error}}', { + name: info.name, + code: warning.code, + error: warning.error, + }), + ) + .join('\n'); + export async function handleUpdate(args: UpdateArgs) { const extensionManager = await getExtensionManager(); const extensions = extensionManager.getLoadedExtensions(); @@ -89,6 +100,8 @@ export async function handleUpdate(args: UpdateArgs) { t('Extension "{{name}}" is already up to date.', { name: args.name }), ); } + const warnings = updateWarningOutput(updatedExtensionInfo); + if (warnings) writeStderrLine(warnings); } catch (error) { writeStderrLine(getErrorMessage(error)); } @@ -116,6 +129,11 @@ export async function handleUpdate(args: UpdateArgs) { return; } writeStdoutLine(updateInfos.map((info) => updateOutput(info)).join('\n')); + const warnings = updateInfos + .map((info) => updateWarningOutput(info)) + .filter(Boolean) + .join('\n'); + if (warnings) writeStderrLine(warnings); } catch (error) { writeStderrLine(getErrorMessage(error)); } diff --git a/packages/cli/src/config/extension-file-watcher.test.ts b/packages/cli/src/config/extension-file-watcher.test.ts index de881f8dd48..e5a5e5368dd 100644 --- a/packages/cli/src/config/extension-file-watcher.test.ts +++ b/packages/cli/src/config/extension-file-watcher.test.ts @@ -6,9 +6,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as path from 'node:path'; -import type { Config } from '@qwen-code/qwen-code-core'; +import type { Config, ExtensionMutationEvent } from '@qwen-code/qwen-code-core'; import { ExtensionFileWatcher } from './extension-file-watcher.js'; -import type { ExtensionRefreshState } from './extension-refresh-state.js'; +import { ExtensionRefreshState } from './extension-refresh-state.js'; type EventHandler = (...args: unknown[]) => void; @@ -22,29 +22,31 @@ interface MockWatcherEntry { }; } -const { mockWatchers, mockWatch, mockExistsSync } = vi.hoisted(() => { - const mockWatchers: MockWatcherEntry[] = []; - const mockExistsSync = vi.fn().mockReturnValue(true); - const mockWatch = vi - .fn() - .mockImplementation( - (target: string | string[], options: Record) => { - const handlers: Record = {}; - const instance = { - on: vi - .fn() - .mockImplementation((event: string, handler: EventHandler) => { - handlers[event] = handler; - return instance; - }), - close: vi.fn().mockResolvedValue(undefined), - }; - mockWatchers.push({ target, options, handlers, instance }); - return instance; - }, - ); - return { mockWatchers, mockWatch, mockExistsSync }; -}); +const { mockWatchers, mockWatch, mockExistsSync, mockReadFileSync } = + vi.hoisted(() => { + const mockWatchers: MockWatcherEntry[] = []; + const mockExistsSync = vi.fn().mockReturnValue(true); + const mockReadFileSync = vi.fn().mockReturnValue('{"generation":1}'); + const mockWatch = vi + .fn() + .mockImplementation( + (target: string | string[], options: Record) => { + const handlers: Record = {}; + const instance = { + on: vi + .fn() + .mockImplementation((event: string, handler: EventHandler) => { + handlers[event] = handler; + return instance; + }), + close: vi.fn().mockResolvedValue(undefined), + }; + mockWatchers.push({ target, options, handlers, instance }); + return instance; + }, + ); + return { mockWatchers, mockWatch, mockExistsSync, mockReadFileSync }; + }); vi.mock('chokidar', () => ({ watch: mockWatch, @@ -55,6 +57,7 @@ vi.mock('node:fs', async (importOriginal) => { return { ...actual, existsSync: mockExistsSync, + readFileSync: mockReadFileSync, }; }); @@ -71,7 +74,9 @@ function configWithExtensions(extensions: unknown[]): Config { function createRefreshState(): ExtensionRefreshState { return { markExtensionContentChanged: vi.fn(), - markExtensionsChanged: vi.fn(), + markExtensionsChanged: vi.fn().mockReturnValue(true), + needsExtensionRefresh: vi.fn().mockReturnValue(false), + isSuppressed: vi.fn().mockReturnValue(false), beginSuppression: vi.fn((onSettle?: () => void) => () => onSettle?.()), } as unknown as ExtensionRefreshState; } @@ -91,6 +96,7 @@ describe('ExtensionFileWatcher', () => { vi.clearAllMocks(); mockWatchers.length = 0; mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{"generation":1}'); }); it('watches the extensions directory and linked extension sources', () => { @@ -115,7 +121,11 @@ describe('ExtensionFileWatcher', () => { watcher.startWatching(); expect(mockWatch).toHaveBeenCalledOnce(); - expect(mockWatchers[0].target).toEqual([extensionsDir, linkedSource]); + expect(mockWatchers[0].target).toEqual([ + extensionsDir, + path.join(path.dirname(extensionsDir), 'extension-store', 'state.json'), + linkedSource, + ]); expect(mockWatchers[0].options).toEqual( expect.objectContaining({ ignoreInitial: true, @@ -139,6 +149,198 @@ describe('ExtensionFileWatcher', () => { expect(refreshState.markExtensionsChanged).toHaveBeenCalledTimes(2); }); + it('marks refresh needed when another process commits a store generation', () => { + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + watcher.startWatching(); + + fireAllEvent( + 0, + 'change', + path.join(path.dirname(extensionsDir), 'extension-store', 'state.json'), + ); + + expect(refreshState.markExtensionsChanged).toHaveBeenCalledWith( + 'extension store generation changed', + ); + }); + + it('polls generation every 30 seconds to recover missed file events', () => { + vi.useFakeTimers(); + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + try { + watcher.startWatching(); + mockReadFileSync.mockReturnValue('{"generation":2}'); + + vi.advanceTimersByTime(30_000); + + expect(refreshState.markExtensionsChanged).toHaveBeenCalledWith( + 'extension store generation changed', + ); + } finally { + watcher.stopWatching(); + vi.useRealTimers(); + } + }); + + it('retries a generation suppressed after an internal mutation', () => { + vi.useFakeTimers(); + const refreshState = new ExtensionRefreshState(); + let mutationListener: ((event: ExtensionMutationEvent) => void) | undefined; + const manager = { + addMutationListener: vi.fn( + (listener: (event: ExtensionMutationEvent) => void) => { + mutationListener = listener; + return vi.fn(); + }, + ), + }; + const watcher = new ExtensionFileWatcher( + { + getExtensions: () => [], + getActiveExtensions: () => [], + getExtensionManager: () => manager, + } as unknown as Config, + extensionsDir, + refreshState, + ); + try { + watcher.startWatching(); + mutationListener?.({ + id: 1, + phase: 'start', + operation: 'installExtension', + }); + mutationListener?.({ + id: 1, + phase: 'end', + operation: 'installExtension', + }); + mockReadFileSync.mockReturnValue('{"generation":2}'); + + fireAllEvent( + mockWatchers.length - 1, + 'change', + path.join(path.dirname(extensionsDir), 'extension-store', 'state.json'), + ); + expect(refreshState.needsExtensionRefresh()).toBe(false); + + vi.advanceTimersByTime(30_000); + + expect(refreshState.needsExtensionRefresh()).toBe(true); + } finally { + watcher.stopWatching(); + vi.useRealTimers(); + } + }); + + it('retries a suppressed generation after an active reload completes', () => { + vi.useFakeTimers(); + const refreshState = new ExtensionRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + try { + watcher.startWatching(); + refreshState.markExtensionsChanged('reload pending'); + refreshState.notifyExtensionsReloadStarted(); + const endSuppression = refreshState.beginSuppression(); + mockReadFileSync.mockReturnValue('{"generation":2}'); + + vi.advanceTimersByTime(30_000); + endSuppression(); + refreshState.clearExtensionsChanged(); + expect(refreshState.needsExtensionRefresh()).toBe(false); + + vi.advanceTimersByTime(30_000); + + expect(refreshState.needsExtensionRefresh()).toBe(true); + } finally { + watcher.stopWatching(); + vi.useRealTimers(); + } + }); + + it('treats the first successful generation read as a change after an initial read failure', () => { + mockReadFileSync.mockImplementationOnce(() => { + throw new Error('state unavailable'); + }); + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + mockReadFileSync.mockReturnValue('{"generation":2}'); + + try { + watcher.startWatching(); + + expect(refreshState.markExtensionsChanged).toHaveBeenCalledWith( + 'extension store generation changed', + ); + } finally { + watcher.stopWatching(); + } + }); + + it('records state-file event generations to avoid duplicate poll refreshes', () => { + vi.useFakeTimers(); + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + try { + watcher.startWatching(); + mockReadFileSync.mockReturnValue('{"generation":2}'); + fireAllEvent( + 0, + 'change', + path.join(path.dirname(extensionsDir), 'extension-store', 'state.json'), + ); + vi.advanceTimersByTime(30_000); + + expect(refreshState.markExtensionsChanged).toHaveBeenCalledTimes(1); + } finally { + watcher.stopWatching(); + vi.useRealTimers(); + } + }); + + it('preserves the observed generation across internal restarts', () => { + const refreshState = createRefreshState(); + const watcher = new ExtensionFileWatcher( + configWithExtensions([]), + extensionsDir, + refreshState, + ); + try { + watcher.startWatching(); + mockReadFileSync.mockReturnValue('{"generation":2}'); + + watcher.restartWatching(); + + expect(refreshState.markExtensionsChanged).toHaveBeenCalledWith( + 'extension store generation changed', + ); + } finally { + watcher.stopWatching(); + } + }); + it('marks stale refresh needed for inventory and hook files', () => { const refreshState = createRefreshState(); const watcher = new ExtensionFileWatcher( @@ -301,7 +503,11 @@ describe('ExtensionFileWatcher', () => { ); watcher.startWatching(); - expect(mockWatchers[0].target).toEqual([extensionsDir, activeSource]); + expect(mockWatchers[0].target).toEqual([ + extensionsDir, + path.join(path.dirname(extensionsDir), 'extension-store', 'state.json'), + activeSource, + ]); fireAllEvent(0, 'change', `${inactiveSource}/QWEN.md`); fireAllEvent(0, 'change', `${inactiveSource}/commands/run.toml`); @@ -347,9 +553,12 @@ describe('ExtensionFileWatcher', () => { watcher.startWatching(); - expect(mockWatch).toHaveBeenCalledOnce(); - expect(mockWatchers[0].target).toBe('/home/user/.qwen'); - expect(mockWatchers[0].options).toEqual( + expect(mockWatch).toHaveBeenCalledTimes(2); + expect(mockWatchers[0].target).toEqual([ + '/home/user/.qwen/extension-store/state.json', + ]); + expect(mockWatchers[1].target).toBe('/home/user/.qwen'); + expect(mockWatchers[1].options).toEqual( expect.objectContaining({ ignoreInitial: true, followSymlinks: false, diff --git a/packages/cli/src/config/extension-file-watcher.ts b/packages/cli/src/config/extension-file-watcher.ts index 13ab68e02b4..c01f3903e86 100644 --- a/packages/cli/src/config/extension-file-watcher.ts +++ b/packages/cli/src/config/extension-file-watcher.ts @@ -42,12 +42,25 @@ export class ExtensionFileWatcher { private staleFiles = new Set(); private watching = false; private watchGeneration = 0; + private readonly storeStatePath: string; + private generationPoller?: ReturnType; + private observedStoreGeneration?: number; constructor( private readonly config: Config, private readonly extensionsDir = Storage.getUserExtensionsDir(), private readonly refreshState = new ExtensionRefreshState(), - ) {} + storeStatePath?: string, + ) { + this.storeStatePath = + storeStatePath ?? + path.join( + path.dirname(this.extensionsDir), + 'extension-store', + 'state.json', + ); + this.observedStoreGeneration = this.readStoreGeneration(); + } startWatching(): void { this.stopWatching(); @@ -99,6 +112,7 @@ export class ExtensionFileWatcher { if (!fs.existsSync(this.extensionsDir)) { this.watchExtensionsParent(); } + this.startGenerationPolling(); } stopWatching(): void { @@ -108,6 +122,8 @@ export class ExtensionFileWatcher { this.bootstrapWatcher = undefined; this.watching = false; this.watchGeneration++; + if (this.generationPoller) clearInterval(this.generationPoller); + this.generationPoller = undefined; this.mutationListenerDisposer?.(); this.mutationListenerDisposer = undefined; this.endPendingMutationSuppressions(); @@ -128,6 +144,7 @@ export class ExtensionFileWatcher { if (fs.existsSync(this.extensionsDir)) { roots.add(this.extensionsDir); } + roots.add(this.storeStatePath); for (const extension of this.config.getActiveExtensions()) { if (extension.installMetadata?.type === 'link') { const rawSource = extension.installMetadata.source; @@ -215,6 +232,17 @@ export class ExtensionFileWatcher { event: WatchEvent, changedPath: string, ): RefreshAction | false { + if (changedPath === path.resolve(this.storeStatePath)) { + const generation = this.readStoreGeneration(); + if (generation !== undefined) { + this.markStoreGenerationChanged(generation, true); + } else { + this.refreshState.markExtensionsChanged( + 'extension store generation changed', + ); + } + return false; + } if (this.staleFiles.has(changedPath)) { return 'stale'; } @@ -342,4 +370,45 @@ export class ExtensionFileWatcher { debugLogger.warn('Extension bootstrap watcher close error:', error); }); } + + private startGenerationPolling(): void { + if (this.generationPoller) clearInterval(this.generationPoller); + this.pollStoreGeneration(); + this.generationPoller = setInterval( + () => this.pollStoreGeneration(), + 30_000, + ); + this.generationPoller.unref?.(); + } + + private pollStoreGeneration(): void { + const generation = this.readStoreGeneration(); + if (generation === undefined) return; + this.markStoreGenerationChanged(generation); + } + + private markStoreGenerationChanged(generation: number, force = false): void { + const previous = this.observedStoreGeneration; + if (!force && previous === generation) return; + if (this.refreshState.isSuppressed()) return; + const marked = this.refreshState.markExtensionsChanged( + 'extension store generation changed', + ); + if (marked || this.refreshState.needsExtensionRefresh()) { + this.observedStoreGeneration = generation; + } + } + + private readStoreGeneration(): number | undefined { + try { + const parsed = JSON.parse( + fs.readFileSync(this.storeStatePath, 'utf8'), + ) as { generation?: unknown }; + return typeof parsed.generation === 'number' + ? parsed.generation + : undefined; + } catch { + return undefined; + } + } } diff --git a/packages/cli/src/config/extension-refresh-state.ts b/packages/cli/src/config/extension-refresh-state.ts index 9340eaca634..5b404ec4d0a 100644 --- a/packages/cli/src/config/extension-refresh-state.ts +++ b/packages/cli/src/config/extension-refresh-state.ts @@ -104,6 +104,10 @@ export class ExtensionRefreshState { return this.extensionRefreshNeeded; } + isSuppressed(): boolean { + return this.suppressionDepth > 0 || Date.now() < this.suppressUntil; + } + isReloadInProgress(): boolean { return this.reloadInProgress; } @@ -146,10 +150,6 @@ export class ExtensionRefreshState { this.suppressionDepth = 0; this.suppressUntil = 0; } - - private isSuppressed(): boolean { - return this.suppressionDepth > 0 || Date.now() < this.suppressUntil; - } } function isPromiseLike(value: unknown): value is PromiseLike { diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index fbeab4b8db7..c68d60c08e2 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -285,9 +285,14 @@ export const SERVE_CAPABILITY_REGISTRY = { // workspace agent CRUD, and persisted session organization surfaces. // Workspace-qualified settings also require the existing // `workspace_settings` tag because that surface depends on settings - // persistence. ACP/WebSocket, auth, voice, and extensions stay on their - // existing primary-workspace routes in this phase. + // persistence. ACP/WebSocket, auth, and voice stay on their existing + // primary-workspace routes in this phase; V2 extension management is + // advertised separately via `extension_management_v2`. workspace_qualified_rest_core: { since: 'v1' }, + // Global extension catalog/mutations plus workspace-qualified activation + // projections. This is additive to the legacy primary-workspace + // `workspace_extensions` contract. + extension_management_v2: { since: 'v1' }, // Workspace-qualified, daemon-local persisted transcript paging. The tag is // unconditional because the route also serves a trusted single-workspace // primary; authorization is evaluated for the selected runtime per request. diff --git a/packages/cli/src/serve/extension-operation-scheduler.test.ts b/packages/cli/src/serve/extension-operation-scheduler.test.ts new file mode 100644 index 00000000000..fa3868cc607 --- /dev/null +++ b/packages/cli/src/serve/extension-operation-scheduler.test.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { createFifoTaskQueue } from './extension-operation-scheduler.js'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe('createFifoTaskQueue', () => { + it('runs at most the configured number of tasks', async () => { + const queue = createFifoTaskQueue(2); + const releases = [deferred(), deferred(), deferred()]; + let active = 0; + let peak = 0; + const tasks = releases.map((release) => + queue.run(async () => { + active += 1; + peak = Math.max(peak, active); + await release.promise; + active -= 1; + }), + ); + + await vi.waitFor(() => expect(active).toBe(2)); + releases[0]!.resolve(); + await vi.waitFor(() => expect(active).toBe(2)); + releases[1]!.resolve(); + releases[2]!.resolve(); + await Promise.all(tasks); + expect(peak).toBe(2); + }); + + it('starts queued tasks in FIFO order', async () => { + const queue = createFifoTaskQueue(1); + const release = deferred(); + const started: number[] = []; + const first = queue.run(async () => { + started.push(1); + await release.promise; + }); + const second = queue.run(async () => { + started.push(2); + }); + const third = queue.run(async () => { + started.push(3); + }); + + await vi.waitFor(() => expect(started).toEqual([1])); + release.resolve(); + await Promise.all([first, second, third]); + expect(started).toEqual([1, 2, 3]); + }); + + it('removes an aborted queued task without starting it', async () => { + const queue = createFifoTaskQueue(1); + const release = deferred(); + const controller = new AbortController(); + let started = false; + const first = queue.run(async () => await release.promise); + const queued = queue.run( + async () => { + started = true; + }, + { signal: controller.signal }, + ); + + controller.abort(new Error('deadline')); + await expect(queued).rejects.toThrow('deadline'); + release.resolve(); + await first; + expect(started).toBe(false); + }); + + it('holds an active slot until a non-cooperative task settles', async () => { + const queue = createFifoTaskQueue(1); + const controller = new AbortController(); + const release = deferred(); + let secondStarted = false; + const first = queue.run(async () => await release.promise, { + signal: controller.signal, + }); + const second = queue.run(async () => { + secondStarted = true; + }); + + controller.abort(new Error('deadline')); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(secondStarted).toBe(false); + release.resolve(); + await Promise.all([first, second]); + expect(secondStarted).toBe(true); + }); + + it('can release a slot before post-commit work settles', async () => { + const queue = createFifoTaskQueue(1); + const finishPostCommit = deferred(); + let secondStarted = false; + const first = queue.runUntilReleased(async (release) => { + release(); + release(); + await finishPostCommit.promise; + }); + const second = queue.run(async () => { + secondStarted = true; + }); + + await vi.waitFor(() => expect(secondStarted).toBe(true)); + finishPostCommit.resolve(); + await Promise.all([first, second]); + }); + + it('releases an early-release task when it rejects before release', async () => { + const queue = createFifoTaskQueue(1); + let secondStarted = false; + const first = queue.runUntilReleased(async () => { + throw new Error('commit failed'); + }); + const second = queue.run(async () => { + secondStarted = true; + }); + + await expect(first).rejects.toThrow('commit failed'); + await second; + expect(secondStarted).toBe(true); + }); + + it('calls onStart only when the task acquires a slot', async () => { + const queue = createFifoTaskQueue(1); + const release = deferred(); + const onStart = vi.fn(); + const first = queue.run(async () => await release.promise); + const second = queue.run(async () => undefined, { onStart }); + + expect(onStart).not.toHaveBeenCalled(); + release.resolve(); + await Promise.all([first, second]); + expect(onStart).toHaveBeenCalledOnce(); + }); + + it('releases the slot when onStart throws', async () => { + const queue = createFifoTaskQueue(1); + let secondStarted = false; + const first = queue.run(async () => undefined, { + onStart: () => { + throw new Error('start failed'); + }, + }); + const second = queue.run(async () => { + secondStarted = true; + }); + + await expect(first).rejects.toThrow('start failed'); + await second; + expect(secondStarted).toBe(true); + }); +}); diff --git a/packages/cli/src/serve/extension-operation-scheduler.ts b/packages/cli/src/serve/extension-operation-scheduler.ts new file mode 100644 index 00000000000..6c08fe0b055 --- /dev/null +++ b/packages/cli/src/serve/extension-operation-scheduler.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface FifoTaskQueue { + run( + task: () => Promise, + options?: { signal?: AbortSignal; onStart?: () => void }, + ): Promise; + runUntilReleased( + task: (release: () => void) => Promise, + options?: { signal?: AbortSignal; onStart?: () => void }, + ): Promise; +} + +type QueuedTask = { + task: (release: () => void) => Promise; + signal?: AbortSignal; + onStart?: () => void; + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; + removeAbortListener?: () => void; +}; + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException('Aborted', 'AbortError'); +} + +export function createFifoTaskQueue(limit: number): FifoTaskQueue { + if (!Number.isInteger(limit) || limit < 1) { + throw new Error( + `Task queue limit must be a positive integer, got ${limit}.`, + ); + } + + let active = 0; + const queued: QueuedTask[] = []; + + const pump = (): void => { + while (active < limit && queued.length > 0) { + const item = queued.shift()!; + const removeAbortListener = item.removeAbortListener; + item.removeAbortListener = undefined; + removeAbortListener?.(); + if (item.signal?.aborted) { + item.reject(abortReason(item.signal)); + continue; + } + active += 1; + try { + item.onStart?.(); + } catch (error) { + active -= 1; + item.reject(error); + continue; + } + let released = false; + const release = () => { + if (released) return; + released = true; + active -= 1; + pump(); + }; + Promise.resolve() + .then(() => item.task(release)) + .then(item.resolve, item.reject) + .finally(release); + } + }; + + const enqueue = ( + task: (release: () => void) => Promise, + options: { signal?: AbortSignal; onStart?: () => void } = {}, + ): Promise => + new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(abortReason(options.signal)); + return; + } + const item: QueuedTask = { + task, + ...(options.signal ? { signal: options.signal } : {}), + ...(options.onStart ? { onStart: options.onStart } : {}), + resolve: resolve as (value: unknown) => void, + reject, + }; + if (options.signal) { + const onAbort = () => { + const index = queued.indexOf(item); + if (index < 0) return; + queued.splice(index, 1); + item.removeAbortListener?.(); + reject(abortReason(options.signal!)); + }; + options.signal.addEventListener('abort', onAbort, { once: true }); + item.removeAbortListener = () => + options.signal?.removeEventListener('abort', onAbort); + } + queued.push(item); + pump(); + }); + + const run = ( + task: () => Promise, + options?: { signal?: AbortSignal; onStart?: () => void }, + ): Promise => enqueue(async () => await task(), options); + + return { run, runUntilReleased: enqueue }; +} diff --git a/packages/cli/src/serve/routes/capabilities.ts b/packages/cli/src/serve/routes/capabilities.ts index ea37786e0b1..dfd3793a43b 100644 --- a/packages/cli/src/serve/routes/capabilities.ts +++ b/packages/cli/src/serve/routes/capabilities.ts @@ -76,19 +76,13 @@ export function registerCapabilitiesRoutes( } : {}), }, - ...(multiWorkspace - ? { - workspaces: runtimes.map((runtime) => ({ - id: runtime.workspaceId, - cwd: runtime.workspaceCwd, - primary: runtime.primary, - trusted: runtime.trusted, - ...(runtimeRemoval - ? { removable: runtime.removable === true } - : {}), - })), - } - : {}), + workspaces: runtimes.map((runtime) => ({ + id: runtime.workspaceId, + cwd: runtime.workspaceCwd, + primary: runtime.primary, + trusted: runtime.trusted, + ...(runtimeRemoval ? { removable: runtime.removable === true } : {}), + })), supportedLanguages: deps.languageCodes, }; res.status(200).json(envelope); diff --git a/packages/cli/src/serve/routes/workspace-extensions-controller.test.ts b/packages/cli/src/serve/routes/workspace-extensions-controller.test.ts new file mode 100644 index 00000000000..0a852fdcdbb --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-extensions-controller.test.ts @@ -0,0 +1,497 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ExtensionManager } from '@qwen-code/qwen-code-core'; +import type { Response } from 'express'; +import type { AcpSessionBridge } from '../acp-session-bridge.js'; +import type { DaemonWorkspaceService } from '../workspace-service/types.js'; +import { createExtensionsController } from './workspace-extensions-controller.js'; + +describe('createExtensionsController', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('releases the commit lane when a manual refresh times out', async () => { + vi.useFakeTimers(); + let refreshCalls = 0; + let releaseRefresh: + | ((result: { refreshed: number; failed: number }) => void) + | undefined; + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: { + refreshExtensionsForAllSessions: () => { + refreshCalls += 1; + if (refreshCalls > 1) { + return Promise.resolve({ refreshed: 1, failed: 0 }); + } + return new Promise<{ refreshed: number; failed: number }>( + (resolve) => { + releaseRefresh = resolve; + }, + ); + }, + } as unknown as DaemonWorkspaceService, + }); + + const outcome = controller.refreshExtensionsForAllSessions().then( + () => 'resolved', + (error: unknown) => (error instanceof Error ? error.message : 'error'), + ); + await vi.advanceTimersByTimeAsync(30_000); + + expect(await Promise.race([outcome, Promise.resolve('pending')])).toBe( + 'extension refresh timed out after 30000ms', + ); + + const nextOutcome = controller.refreshExtensionsForAllSessions().then( + (result) => result, + (error: unknown) => (error instanceof Error ? error.message : 'error'), + ); + await vi.advanceTimersByTimeAsync(0); + expect(refreshCalls).toBe(2); + await expect(nextOutcome).resolves.toEqual({ refreshed: 1, failed: 0 }); + + releaseRefresh?.({ refreshed: 0, failed: 0 }); + await vi.advanceTimersByTimeAsync(0); + }); + + it('releases the commit lane at the durable commit boundary', async () => { + let finishPostCommit!: () => void; + const postCommit = new Promise((resolve) => { + finishPostCommit = resolve; + }); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + }); + const manager = { + refreshCache: vi.fn(async () => undefined), + } as unknown as ExtensionManager; + const response = () => + ({ + status: vi.fn().mockReturnThis(), + location: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + json: vi.fn(), + }) as unknown as Response; + let firstCommitted!: () => void; + const durableCommit = new Promise((resolve) => { + firstCommitted = resolve; + }); + let finishFirstOperation!: () => void; + const firstOperationFinished = new Promise((resolve) => { + finishFirstOperation = resolve; + }); + let finishSecondOperation!: () => void; + const secondOperationFinished = new Promise((resolve) => { + finishSecondOperation = resolve; + }); + let secondStarted = false; + + controller.runQueuedExtensionMutation( + 'install', + { name: 'first' }, + response(), + async (_extensionManager, _signal, context) => { + await context!.commit(async (onCommitted) => { + onCommitted(1); + firstCommitted(); + await postCommit; + return { generation: 1 }; + }); + finishFirstOperation(); + return { status: 'installed', name: 'first' }; + }, + { manager, skipRefresh: true }, + ); + await durableCommit; + + controller.runQueuedExtensionMutation( + 'enable', + { name: 'second' }, + response(), + async (_extensionManager, _signal, context) => { + await context!.commit(async (onCommitted) => { + secondStarted = true; + onCommitted(2); + return { generation: 2 }; + }); + finishSecondOperation(); + return { status: 'enabled', name: 'second' }; + }, + { manager, skipRefresh: true }, + ); + + await vi.waitFor(() => expect(secondStarted).toBe(true)); + finishPostCommit(); + await Promise.all([firstOperationFinished, secondOperationFinished]); + }); + + it('starts the status cache lifetime after a slow refresh completes', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const refreshCache = vi + .spyOn(ExtensionManager.prototype, 'refreshCache') + .mockImplementation(async () => { + vi.setSystemTime(3_000); + }); + vi.spyOn(ExtensionManager.prototype, 'getLoadedExtensions').mockReturnValue( + [], + ); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + }); + + await controller.buildLocalExtensionsStatus(); + await controller.buildLocalExtensionsStatus(); + + expect(refreshCache).toHaveBeenCalledOnce(); + }); + + it('reports an accepted operation as running while its cache refreshes', async () => { + let finishRefresh!: () => void; + const refreshPending = new Promise((resolve) => { + finishRefresh = resolve; + }); + const manager = { + refreshCache: vi.fn(async () => await refreshPending), + } as unknown as ExtensionManager; + const responseBody = vi.fn(); + const response = { + status: vi.fn().mockReturnThis(), + location: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + json: responseBody, + } as unknown as Response; + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + }); + + controller.runQueuedExtensionMutation( + 'install', + { name: 'demo' }, + response, + async () => ({ status: 'installed', name: 'demo', updated: false }), + { manager, skipRefresh: true }, + ); + const operationId = responseBody.mock.calls[0]?.[0].operationId as string; + + await vi.waitFor(() => expect(manager.refreshCache).toHaveBeenCalledOnce()); + expect(controller.getOperation(operationId)).toMatchObject({ + status: 'running', + phase: 'preparing', + }); + + finishRefresh(); + await vi.waitFor(() => + expect(controller.getOperation(operationId)?.status).toBe('succeeded'), + ); + }); + + it('reports an operation as preparing while any parallel preparation is active', async () => { + let releaseBlocker!: () => void; + const blocker = new Promise((resolve) => { + releaseBlocker = resolve; + }); + let releaseFirst!: () => void; + const first = new Promise((resolve) => { + releaseFirst = resolve; + }); + let firstStarted!: () => void; + const firstActive = new Promise((resolve) => { + firstStarted = resolve; + }); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + }); + const manager = { + refreshCache: vi.fn(async () => undefined), + } as unknown as ExtensionManager; + const responseBody = vi.fn(); + const response = { + status: vi.fn().mockReturnThis(), + location: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + json: responseBody, + } as unknown as Response; + const held = controller.preparationQueue.run(async () => await blocker); + + controller.runQueuedExtensionMutation( + 'install', + { name: 'demo' }, + response, + async (_extensionManager, _signal, context) => { + await Promise.all([ + context!.prepare(async () => { + firstStarted(); + await first; + }), + context!.prepare(async () => undefined), + ]); + return { status: 'installed', name: 'demo', updated: false }; + }, + { manager, skipRefresh: true }, + ); + const operationId = responseBody.mock.calls[0]?.[0].operationId as string; + + await firstActive; + expect(controller.getOperation(operationId)).toMatchObject({ + status: 'running', + phase: 'preparing', + }); + + releaseFirst(); + releaseBlocker(); + await held; + await vi.waitFor(() => + expect(controller.getOperation(operationId)).toMatchObject({ + status: 'succeeded', + phase: undefined, + }), + ); + }); + + it('clears phase from every terminal operation state', async () => { + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: { + broadcastExtensionsChanged: vi.fn(), + } as unknown as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + }); + const manager = { + refreshCache: vi.fn(async () => undefined), + } as unknown as ExtensionManager; + const response = () => { + const responseBody = vi.fn(); + return { + responseBody, + value: { + status: vi.fn().mockReturnThis(), + location: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + json: responseBody, + } as unknown as Response, + }; + }; + const run = async ( + operation: Parameters[3], + ) => { + const res = response(); + controller.runQueuedExtensionMutation( + 'install', + { name: 'demo' }, + res.value, + operation, + { manager, skipRefresh: true }, + ); + const operationId = res.responseBody.mock.calls[0]?.[0] + .operationId as string; + await vi.waitFor(() => + expect(controller.getOperation(operationId)?.status).toMatch( + /^(succeeded|succeeded_with_warnings|failed)$/, + ), + ); + return controller.getOperation(operationId); + }; + + await expect( + run(async () => ({ + status: 'installed', + name: 'demo', + updated: false, + })), + ).resolves.toMatchObject({ status: 'succeeded', phase: undefined }); + await expect( + run(async (_extensionManager, _signal, context) => { + await context!.commit(async () => ({ + generation: 1, + warnings: [{ code: 'cleanup_failed', error: 'cleanup failed' }], + })); + return { status: 'installed', name: 'demo', updated: false }; + }), + ).resolves.toMatchObject({ + status: 'succeeded_with_warnings', + phase: undefined, + }); + await expect( + run(async () => { + throw new Error('prepare failed'); + }), + ).resolves.toMatchObject({ status: 'failed', phase: undefined }); + }); + + it('aborts timed-out preparation without committing and releases its slot', async () => { + vi.useFakeTimers(); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + let releaseBlocker!: () => void; + const blocker = new Promise((resolve) => { + releaseBlocker = resolve; + }); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: { + broadcastExtensionsChanged: vi.fn(), + } as unknown as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + }); + const manager = { + refreshCache: vi.fn(async () => undefined), + } as unknown as ExtensionManager; + const responseBody = vi.fn(); + const response = { + status: vi.fn().mockReturnThis(), + location: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + json: responseBody, + } as unknown as Response; + const commit = vi.fn(async () => ({ generation: 1 })); + const held = controller.preparationQueue.run(async () => await blocker); + + controller.runQueuedExtensionMutation( + 'install', + { name: 'demo' }, + response, + async (_extensionManager, _signal, context) => { + await context!.prepare( + async (signal) => + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }), + ); + await context!.commit(commit); + return { status: 'installed', name: 'demo' }; + }, + { manager, deadlineMs: 100 }, + ); + await vi.advanceTimersByTimeAsync(0); + const operationId = responseBody.mock.calls[0]?.[0].operationId as string; + let probeStarted = false; + const probe = controller.preparationQueue.run(async () => { + probeStarted = true; + }); + expect(probeStarted).toBe(false); + + await vi.advanceTimersByTimeAsync(100); + await probe; + + expect(controller.getOperation(operationId)).toMatchObject({ + status: 'failed', + code: 'extension_prepare_timeout', + }); + expect(commit).not.toHaveBeenCalled(); + expect(probeStarted).toBe(true); + + releaseBlocker(); + await held; + }); + + it('does not commit preparation that settles after its deadline', async () => { + vi.useFakeTimers(); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + let finishPreparation!: () => void; + const preparation = new Promise((resolve) => { + finishPreparation = resolve; + }); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: { + broadcastExtensionsChanged: vi.fn(), + } as unknown as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + }); + const manager = { + refreshCache: vi.fn(async () => undefined), + } as unknown as ExtensionManager; + const responseBody = vi.fn(); + const response = { + status: vi.fn().mockReturnThis(), + location: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + json: responseBody, + } as unknown as Response; + const commit = vi.fn(async () => ({ generation: 1 })); + + controller.runQueuedExtensionMutation( + 'install', + { name: 'demo' }, + response, + async (_extensionManager, _signal, context) => { + await context!.prepare(async () => await preparation); + await context!.commit(commit); + return { status: 'installed', name: 'demo' }; + }, + { manager, deadlineMs: 100 }, + ); + await vi.advanceTimersByTimeAsync(0); + const operationId = responseBody.mock.calls[0]?.[0].operationId as string; + + await vi.advanceTimersByTimeAsync(100); + finishPreparation(); + await vi.advanceTimersByTimeAsync(0); + await vi.waitFor(() => + expect(controller.getOperation(operationId)).toMatchObject({ + status: 'failed', + code: 'extension_prepare_timeout', + }), + ); + expect(commit).not.toHaveBeenCalled(); + }); + + it('releases the operation slot when the acceptance response throws', () => { + let operationId: string | undefined; + const throwingResponse = { + status: vi.fn().mockReturnThis(), + location: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + json: vi.fn((body: { operationId: string }) => { + operationId = body.operationId; + throw new Error('socket closed'); + }), + } as unknown as Response; + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + }); + + expect(() => + controller.runQueuedExtensionMutation( + 'install', + {}, + throwingResponse, + async () => ({ status: 'installed' }), + ), + ).not.toThrow(); + expect(operationId).toBeDefined(); + expect(controller.getOperation(operationId!)).toBeUndefined(); + + const response = { + status: vi.fn().mockReturnThis(), + json: vi.fn(), + } as unknown as Response; + const releases = Array.from({ length: 10 }, () => + controller.acquireOperationSlot(response), + ); + expect(releases.every(Boolean)).toBe(true); + releases.forEach((release) => release?.()); + }); +}); diff --git a/packages/cli/src/serve/routes/workspace-extensions-controller.ts b/packages/cli/src/serve/routes/workspace-extensions-controller.ts new file mode 100644 index 00000000000..ae2850a66e8 --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-extensions-controller.ts @@ -0,0 +1,945 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as crypto from 'node:crypto'; +import { + ExtensionManager, + redactUrlCredentials, + stripAnsiAndControl, + type ExtensionSetting, +} from '@qwen-code/qwen-code-core'; +import type { Request, Response } from 'express'; +import { loadSettings } from '../../config/settings.js'; +import { getWorkspaceTrustStatus } from '../../config/trustedFolders.js'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import type { AcpSessionBridge } from '../acp-session-bridge.js'; +import { parseAndValidateWorkspaceClientId } from '../server/request-helpers.js'; +import { + STATUS_SCHEMA_VERSION, + type ServeExtensionCapabilities, + type ServeExtensionEntry, + type ServeWorkspaceExtensionsStatus, +} from '@qwen-code/acp-bridge/status'; +import type { DaemonWorkspaceService } from '../workspace-service/index.js'; +import type { WorkspaceRuntime } from '../workspace-registry.js'; +import { + createFifoTaskQueue, + type FifoTaskQueue, +} from '../extension-operation-scheduler.js'; + +const MAX_UNFINISHED_EXTENSION_OPERATIONS = 10; + +const sanitizeDaemonMessage = (message: string): string => + redactUrlCredentials(stripAnsiAndControl(message)); +const EXTENSION_PREPARATION_CONCURRENCY = 2; +const EXTENSION_REFRESH_TIMEOUT_MS = 30_000; +const RECONCILE_SLOW_MS = 30_000; + +/** + * Thrown by the per-workspace install queue when it is saturated, and matched + * by the route layer to emit a 429. Shared so the throw site and the match + * site (a separate module) can never silently drift apart. + */ +export const EXTENSION_QUEUE_FULL_MESSAGE = 'Extension operation queue is full'; + +export type ExtensionMutationEvent = { + status: + | 'installed' + | 'enabled' + | 'disabled' + | 'updated' + | 'uninstalled' + | 'checked' + | 'refreshed'; + source?: string; + name?: string; + version?: string; + updated?: boolean; + reason?: string; + states?: Record; +}; + +export type ExtensionOperationStatus = { + v: 1; + operationId: string; + operation: string; + status: + | 'queued' + | 'running' + | 'succeeded' + | 'succeeded_with_warnings' + | 'failed'; + phase?: 'preparing' | 'committing' | 'reconciling'; + createdAt: number; + updatedAt: number; + source?: string; + name?: string; + result?: ExtensionMutationEvent & { + refreshed?: number; + failed?: number; + error?: string; + }; + error?: string; + code?: string; + warnings?: Array<{ + workspaceId?: string; + workspaceCwd: string; + code?: string; + error: string; + }>; +}; + +export interface ExtensionOperationContext { + prepare(task: (signal: AbortSignal) => Promise): Promise; + commit< + T extends { + generation: number; + warnings?: ReadonlyArray<{ code: string; error: string }>; + }, + >( + task: (onCommitted: (generation: number) => void) => Promise, + ): Promise; +} + +export interface RuntimeReconciliationReservation { + run(task: () => Promise): Promise; + release(): void; +} + +export type ReserveRuntimeReconciliation = + () => RuntimeReconciliationReservation; + +export interface CreateExtensionsControllerDeps { + boundWorkspace: string; + bridge: AcpSessionBridge; + workspace: DaemonWorkspaceService; + maxExtensionOperationHistory?: number; +} + +/** Shared coordinator for the legacy adapter and V2 global operations. */ +export interface ExtensionsController { + readonly boundWorkspace: string; + readonly workspace: DaemonWorkspaceService; + createExtensionManager( + workspaceDir?: string, + isWorkspaceTrusted?: boolean, + ): ExtensionManager; + buildLocalExtensionsStatus(): Promise; + refreshExtensionsForAllSessions(): Promise<{ + refreshed: number; + failed: number; + }>; + getOperation(operationId: string): ExtensionOperationStatus | undefined; + preparationQueue: FifoTaskQueue; + acquireOperationSlot(res: Response): (() => void) | undefined; + validateExtensionMutationClient( + req: Request, + res: Response, + opts?: { + requireClientId?: boolean; + bridges?: readonly AcpSessionBridge[]; + }, + ): boolean; + runQueuedExtensionMutation( + operation: string, + failureContext: { source?: string; name?: string }, + res: Response, + run: ( + extensionManager: ExtensionManager, + signal?: AbortSignal, + context?: ExtensionOperationContext, + ) => Promise, + options?: { + manager?: ExtensionManager; + refreshRuntimes?: + | readonly WorkspaceRuntime[] + | (() => readonly WorkspaceRuntime[]); + reserveRuntimeReconciliation?: ReserveRuntimeReconciliation; + operationBasePath?: string; + skipRefresh?: boolean; + deadlineMs?: number; + onRuntimeReconciled?: ( + runtime: WorkspaceRuntime, + generation: number, + ) => void; + }, + ): void; +} + +export function createExtensionsController( + deps: CreateExtensionsControllerDeps, +): ExtensionsController { + const { boundWorkspace, bridge, workspace } = deps; + const maxExtensionOperationHistory = deps.maxExtensionOperationHistory ?? 100; + + const preparationQueue = createFifoTaskQueue( + EXTENSION_PREPARATION_CONCURRENCY, + ); + const commitQueue = createFifoTaskQueue(1); + let unfinishedOperationCount = 0; + + const acquireOperationSlot = (res: Response): (() => void) | undefined => { + if (unfinishedOperationCount >= MAX_UNFINISHED_EXTENSION_OPERATIONS) { + res.status(429).json({ + error: EXTENSION_QUEUE_FULL_MESSAGE, + code: 'extension_queue_full', + }); + return undefined; + } + unfinishedOperationCount += 1; + let released = false; + return () => { + if (released) return; + released = true; + unfinishedOperationCount -= 1; + }; + }; + + const createExtensionManager = ( + workspaceDir = boundWorkspace, + trustedOverride?: boolean, + ) => + new ExtensionManager({ + workspaceDir, + isWorkspaceTrusted: + trustedOverride ?? + getWorkspaceTrustStatus(loadSettings(workspaceDir).merged, workspaceDir) + .effective.state === 'trusted', + requestConsent: () => Promise.resolve(), + networkPolicy: 'public', + requestSetting: async (setting: ExtensionSetting) => { + throw new Error( + `Extension setting "${setting.envVar}" requires interactive configuration and is not supported over the daemon install endpoint.`, + ); + }, + requestChoicePlugin: async () => { + throw new Error( + 'Marketplace plugin selection is not supported over the daemon install endpoint. Specify a plugin name in the source.', + ); + }, + }); + + const validateExtensionMutationClient = ( + req: Request, + res: Response, + opts: { + requireClientId?: boolean; + bridges?: readonly AcpSessionBridge[]; + } = {}, + ): boolean => { + const clientId = parseAndValidateWorkspaceClientId( + req, + res, + opts.bridges ?? bridge, + ); + if (clientId === null) return false; + if (clientId === undefined && opts.requireClientId !== false) { + res.status(400).json({ + error: 'Missing X-Qwen-Client-Id header', + code: 'missing_client_id', + }); + return false; + } + return true; + }; + + const extensionOperations = new Map(); + const isTerminalExtensionOperation = ( + operation: ExtensionOperationStatus, + ): boolean => operation.status !== 'queued' && operation.status !== 'running'; + const redactExtensionOperationResult = ( + event: ExtensionMutationEvent, + ): ExtensionMutationEvent => ({ + ...event, + ...(event.source ? { source: redactUrlCredentials(event.source) } : {}), + }); + const bridgeMutationEvent = (event: ExtensionMutationEvent) => { + const redacted = redactExtensionOperationResult(event); + if (event.status === 'checked' || event.status === 'refreshed') { + const { status: _status, states: _states, ...bridgeEvent } = redacted; + return bridgeEvent; + } + return redacted; + }; + const pruneExtensionOperations = (): void => { + const terminalCount = () => + [...extensionOperations.values()].filter(isTerminalExtensionOperation) + .length; + while (terminalCount() > maxExtensionOperationHistory) { + let evicted = false; + for (const [id, storedOperation] of extensionOperations) { + if (!isTerminalExtensionOperation(storedOperation)) continue; + extensionOperations.delete(id); + evicted = true; + break; + } + if (!evicted) break; + } + }; + const rememberExtensionOperation = ( + operation: ExtensionOperationStatus, + ): void => { + extensionOperations.set(operation.operationId, operation); + pruneExtensionOperations(); + }; + const updateExtensionOperation = ( + operationId: string, + patch: Partial>, + ): void => { + const current = extensionOperations.get(operationId); + if (!current) return; + extensionOperations.set(operationId, { + ...current, + ...patch, + updatedAt: Date.now(), + }); + pruneExtensionOperations(); + }; + + let extensionsStatusCache: + | { expiresAt: number; value: ServeWorkspaceExtensionsStatus } + | undefined; + + const refreshExtensionsForAllSessions = async (): Promise<{ + refreshed: number; + failed: number; + }> => { + const queueAbort = new AbortController(); + let releaseCommitLane: (() => void) | undefined; + const refresh = commitQueue.runUntilReleased( + async (release) => { + releaseCommitLane = release; + extensionsStatusCache = undefined; + return await workspace.refreshExtensionsForAllSessions(); + }, + { signal: queueAbort.signal }, + ); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + refresh, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new Error( + `extension refresh timed out after ${EXTENSION_REFRESH_TIMEOUT_MS}ms`, + ); + releaseCommitLane?.(); + queueAbort.abort(error); + reject(error); + }, EXTENSION_REFRESH_TIMEOUT_MS); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + }; + + const runQueuedExtensionMutation = ( + operation: string, + failureContext: { source?: string; name?: string }, + res: Response, + run: ( + extensionManager: ExtensionManager, + signal?: AbortSignal, + context?: ExtensionOperationContext, + ) => Promise, + options: { + manager?: ExtensionManager; + refreshRuntimes?: + | readonly WorkspaceRuntime[] + | (() => readonly WorkspaceRuntime[]); + reserveRuntimeReconciliation?: ReserveRuntimeReconciliation; + operationBasePath?: string; + skipRefresh?: boolean; + deadlineMs?: number; + onRuntimeReconciled?: ( + runtime: WorkspaceRuntime, + generation: number, + ) => void; + } = {}, + ): void => { + const releaseOperationSlot = acquireOperationSlot(res); + if (!releaseOperationSlot) return; + const operationId = crypto.randomUUID(); + const now = Date.now(); + rememberExtensionOperation({ + v: 1, + operationId, + operation, + status: 'queued', + createdAt: now, + updatedAt: now, + ...(failureContext.source + ? { source: redactUrlCredentials(failureContext.source) } + : {}), + ...(failureContext.name ? { name: failureContext.name } : {}), + }); + const operationBasePath = + options.operationBasePath ?? '/workspace/extensions/operations'; + try { + res + .status(202) + .location(`${operationBasePath}/${operationId}`) + .set('Retry-After', '1') + .json({ accepted: true, operationId }); + } catch { + extensionOperations.delete(operationId); + releaseOperationSlot(); + return; + } + void (async () => { + let deadline: ReturnType | undefined; + let committedGeneration: number | undefined; + let reconciliationReservation: + | RuntimeReconciliationReservation + | undefined; + let mutationEvent: ExtensionMutationEvent | undefined; + const commitWarnings: NonNullable = + []; + const runReconciliation = async ( + task: () => Promise, + ): Promise => { + const reservation = reconciliationReservation; + reconciliationReservation = undefined; + return reservation ? await reservation.run(task) : await task(); + }; + try { + updateExtensionOperation(operationId, { + status: 'running', + phase: 'preparing', + }); + const extensionManager = options.manager ?? createExtensionManager(); + const deadlineController = new AbortController(); + let deadlineStarted = false; + const startDeadline = () => { + if (deadlineStarted) return; + deadlineStarted = true; + if (options.deadlineMs) { + deadline = setTimeout(() => { + const error = new Error( + `Extension ${operation} exceeded its ${options.deadlineMs}ms preparation deadline.`, + ) as Error & { code: string }; + error.code = 'extension_prepare_timeout'; + deadlineController.abort(error); + }, options.deadlineMs); + deadline.unref?.(); + } + }; + let pendingPreparations = 0; + let activePreparations = 0; + const updatePreparationState = () => { + if (activePreparations > 0) { + updateExtensionOperation(operationId, { + status: 'running', + phase: 'preparing', + }); + } else if (pendingPreparations > 0) { + updateExtensionOperation(operationId, { + status: 'queued', + phase: undefined, + }); + } + }; + const context: ExtensionOperationContext = { + prepare: async ( + task: (signal: AbortSignal) => Promise, + ): Promise => { + pendingPreparations += 1; + let started = false; + updatePreparationState(); + try { + const prepared = await preparationQueue.run( + async () => { + try { + return await task(deadlineController.signal); + } finally { + activePreparations -= 1; + updatePreparationState(); + } + }, + { + signal: deadlineController.signal, + onStart: () => { + startDeadline(); + started = true; + pendingPreparations -= 1; + activePreparations += 1; + updatePreparationState(); + }, + }, + ); + deadlineController.signal.throwIfAborted(); + return prepared; + } catch (error) { + if (!started) { + pendingPreparations -= 1; + updatePreparationState(); + } + if (deadlineController.signal.aborted) { + throw deadlineController.signal.reason; + } + throw error; + } + }, + commit: async < + T extends { + generation: number; + warnings?: ReadonlyArray<{ code: string; error: string }>; + }, + >( + task: (onCommitted: (generation: number) => void) => Promise, + ): Promise => { + updateExtensionOperation(operationId, { + status: 'running', + phase: 'committing', + }); + const result = await commitQueue.runUntilReleased( + async (release) => + await task((generation) => { + reconciliationReservation ??= + options.reserveRuntimeReconciliation?.(); + committedGeneration = generation; + release(); + }), + ); + if (committedGeneration === undefined) { + reconciliationReservation ??= + options.reserveRuntimeReconciliation?.(); + committedGeneration = result.generation; + } + for (const warning of result.warnings ?? []) { + commitWarnings.push({ + workspaceCwd: boundWorkspace, + code: warning.code, + error: sanitizeDaemonMessage(warning.error).slice(0, 500), + }); + } + return result; + }, + }; + await extensionManager.refreshCache(); + const event = await run( + extensionManager, + deadlineController.signal, + context, + ); + mutationEvent = event; + if (deadline) clearTimeout(deadline); + extensionsStatusCache = undefined; + if (options.skipRefresh || event.updated === false) { + reconciliationReservation?.release(); + reconciliationReservation = undefined; + updateExtensionOperation(operationId, { + status: + commitWarnings.length > 0 + ? 'succeeded_with_warnings' + : 'succeeded', + phase: undefined, + result: redactExtensionOperationResult(event), + ...(commitWarnings.length > 0 ? { warnings: commitWarnings } : {}), + }); + return; + } + if (committedGeneration === undefined) { + committedGeneration = ( + await extensionManager.getExtensionStoreSnapshot() + ).generation; + reconciliationReservation ??= + options.reserveRuntimeReconciliation?.(); + } + updateExtensionOperation(operationId, { + status: 'running', + phase: 'reconciling', + }); + const refreshTargets = + typeof options.refreshRuntimes === 'function' + ? options.refreshRuntimes() + : options.refreshRuntimes; + if (refreshTargets) { + const results = await runReconciliation( + async () => + await Promise.all( + refreshTargets.map(async (runtime) => { + const startedAt = Date.now(); + try { + runtime.workspaceService.invalidateWorkspaceSkillsStatus(); + return { + status: 'fulfilled' as const, + result: + await runtime.bridge.refreshExtensionsForAllSessions( + bridgeMutationEvent(event), + ), + elapsedMs: Date.now() - startedAt, + }; + } catch (reason) { + return { + status: 'rejected' as const, + reason, + elapsedMs: Date.now() - startedAt, + }; + } + }), + ), + ); + let refreshed = 0; + let failed = 0; + const warnings: NonNullable = [ + ...commitWarnings, + ]; + for (let index = 0; index < results.length; index += 1) { + const settled = results[index]!; + const runtime = refreshTargets[index]!; + if (settled.status === 'fulfilled') { + refreshed += settled.result.refreshed; + failed += settled.result.failed; + if (settled.result.failed > 0) { + warnings.push({ + workspaceId: runtime.workspaceId, + workspaceCwd: runtime.workspaceCwd, + error: `${settled.result.failed} session refresh(es) failed`, + }); + } else { + options.onRuntimeReconciled?.(runtime, committedGeneration); + } + } else { + failed += 1; + const message = sanitizeDaemonMessage( + settled.reason instanceof Error + ? settled.reason.message + : String(settled.reason), + ); + warnings.push({ + workspaceId: runtime.workspaceId, + workspaceCwd: runtime.workspaceCwd, + error: message.slice(0, 500), + }); + try { + runtime.bridge.broadcastExtensionsChanged({ + ...bridgeMutationEvent(event), + refreshed: 0, + failed: 1, + error: message.slice(0, 500), + }); + } catch { + // The warning already records the refresh failure; a failed + // notification must not turn a committed mutation into a + // failed operation. + } + } + if (settled.elapsedMs > RECONCILE_SLOW_MS) { + warnings.push({ + workspaceId: runtime.workspaceId, + workspaceCwd: runtime.workspaceCwd, + code: 'reconcile_slow', + error: `Runtime reconciliation took ${settled.elapsedMs}ms.`, + }); + } + } + updateExtensionOperation(operationId, { + status: + warnings.length > 0 ? 'succeeded_with_warnings' : 'succeeded', + phase: undefined, + result: { + ...redactExtensionOperationResult(event), + refreshed, + failed, + }, + ...(warnings.length > 0 ? { warnings } : {}), + }); + } else { + try { + const { result, elapsedMs } = await runReconciliation(async () => { + workspace.invalidateWorkspaceSkillsStatus(); + const startedAt = Date.now(); + const result = await bridge.refreshExtensionsForAllSessions( + bridgeMutationEvent(event), + ); + return { result, elapsedMs: Date.now() - startedAt }; + }); + const warnings: NonNullable = + [...commitWarnings]; + if (result.failed > 0) { + warnings.push({ + workspaceCwd: boundWorkspace, + error: `${result.failed} session refresh(es) failed`, + }); + } + if (elapsedMs > RECONCILE_SLOW_MS) { + warnings.push({ + workspaceCwd: boundWorkspace, + code: 'reconcile_slow', + error: `Runtime reconciliation took ${elapsedMs}ms.`, + }); + } + updateExtensionOperation(operationId, { + status: + warnings.length > 0 ? 'succeeded_with_warnings' : 'succeeded', + phase: undefined, + result: { + ...redactExtensionOperationResult(event), + refreshed: result.refreshed, + failed: result.failed, + }, + ...(warnings.length > 0 ? { warnings } : {}), + }); + writeStderrLine( + `qwen serve: [${boundWorkspace}] extensions ${operation}: refreshed ${result.refreshed} session(s), ${result.failed} failed`, + ); + } catch (refreshErr) { + const message = sanitizeDaemonMessage( + refreshErr instanceof Error + ? refreshErr.message + : String(refreshErr), + ); + updateExtensionOperation(operationId, { + status: 'succeeded_with_warnings', + phase: undefined, + result: { + ...redactExtensionOperationResult(event), + refreshed: 0, + failed: 1, + error: message.slice(0, 500), + }, + warnings: [ + ...commitWarnings, + { + workspaceCwd: boundWorkspace, + error: message.slice(0, 500), + }, + ], + }); + try { + bridge.broadcastExtensionsChanged({ + ...bridgeMutationEvent(event), + refreshed: 0, + failed: 1, + error: message.slice(0, 500), + }); + } catch (broadcastErr) { + writeStderrLine( + `qwen serve: [${boundWorkspace}] extensions ${operation}: failed to broadcast refresh failure: ${sanitizeDaemonMessage( + broadcastErr instanceof Error + ? broadcastErr.message + : String(broadcastErr), + )}`, + ); + } + writeStderrLine( + `qwen serve: [${boundWorkspace}] extensions ${operation}: mutation succeeded but refresh failed: ${message}`, + ); + } + } + } catch (err) { + const message = sanitizeDaemonMessage( + err instanceof Error ? err.message : String(err), + ); + const code = + err && + typeof err === 'object' && + typeof (err as { code?: unknown }).code === 'string' + ? (err as { code: string }).code + : undefined; + if (committedGeneration !== undefined) { + extensionsStatusCache = undefined; + const error = + `Commit succeeded but post-commit work failed: ${message}`.slice( + 0, + 500, + ); + const warnings: NonNullable = [ + ...commitWarnings, + { + workspaceCwd: boundWorkspace, + code: 'post_commit_failed', + error, + }, + ]; + try { + workspace.invalidateWorkspaceSkillsStatus(); + } catch (invalidationError) { + warnings.push({ + workspaceCwd: boundWorkspace, + code: 'status_invalidation_failed', + error: sanitizeDaemonMessage( + invalidationError instanceof Error + ? invalidationError.message + : String(invalidationError), + ).slice(0, 500), + }); + } + updateExtensionOperation(operationId, { + status: 'succeeded_with_warnings', + phase: undefined, + ...(mutationEvent + ? { result: redactExtensionOperationResult(mutationEvent) } + : {}), + warnings, + }); + try { + bridge.broadcastExtensionsChanged({ + ...(mutationEvent + ? bridgeMutationEvent(mutationEvent) + : { + ...(failureContext.source + ? { + source: redactUrlCredentials(failureContext.source), + } + : {}), + ...(failureContext.name + ? { name: failureContext.name } + : {}), + }), + refreshed: 0, + failed: 1, + error, + }); + } catch { + // The operation record remains authoritative for this warning. + } + try { + writeStderrLine( + `qwen serve: [${boundWorkspace}] extensions ${operation}: ${error}`, + ); + } catch { + // Keep queued background work from surfacing as unhandledRejection. + } + return; + } + updateExtensionOperation(operationId, { + status: 'failed', + phase: undefined, + error: message.slice(0, 500), + ...(code ? { code } : {}), + }); + try { + bridge.broadcastExtensionsChanged({ + status: 'failed', + ...(failureContext.source + ? { source: redactUrlCredentials(failureContext.source) } + : {}), + ...(failureContext.name ? { name: failureContext.name } : {}), + refreshed: 0, + failed: 0, + error: message.slice(0, 500), + }); + } catch (broadcastErr) { + writeStderrLine( + `qwen serve: [${boundWorkspace}] extensions ${operation}: failed to broadcast failure: ${sanitizeDaemonMessage( + broadcastErr instanceof Error + ? broadcastErr.message + : String(broadcastErr), + )}`, + ); + } + try { + writeStderrLine( + `qwen serve: [${boundWorkspace}] extensions ${operation}: background task failed: ${message}`, + ); + } catch { + // Keep queued background work from surfacing as unhandledRejection. + } + } finally { + if (deadline) clearTimeout(deadline); + reconciliationReservation?.release(); + releaseOperationSlot(); + } + })(); + }; + + const buildLocalExtensionsStatus = + async (): Promise => { + const now = Date.now(); + if (extensionsStatusCache && extensionsStatusCache.expiresAt > now) { + return extensionsStatusCache.value; + } + const extensionManager = createExtensionManager(); + await extensionManager.refreshCache(); + const entries: ServeExtensionEntry[] = extensionManager + .getLoadedExtensions() + .map((ext): ServeExtensionEntry => { + const capabilities: ServeExtensionCapabilities = { + mcpServerCount: ext.mcpServers + ? Object.keys(ext.mcpServers).length + : 0, + skillCount: ext.skills?.length ?? 0, + agentCount: ext.agents?.length ?? 0, + hookCount: ext.hooks + ? Object.values(ext.hooks).reduce( + (sum, defs) => sum + (defs?.length ?? 0), + 0, + ) + : 0, + commandCount: ext.commands?.length ?? 0, + contextFileCount: ext.contextFiles.length, + channelCount: ext.channels ? Object.keys(ext.channels).length : 0, + hasSettings: (ext.settings?.length ?? 0) > 0, + }; + return { + kind: 'extension', + id: ext.id, + name: ext.name, + ...(ext.displayName ? { displayName: ext.displayName } : {}), + ...(ext.config.description + ? { description: ext.config.description } + : {}), + version: ext.version, + isActive: ext.isActive, + path: ext.path, + ...(ext.installMetadata?.source + ? { source: redactUrlCredentials(ext.installMetadata.source) } + : {}), + ...(ext.installMetadata?.type + ? { installType: ext.installMetadata.type } + : {}), + ...(ext.installMetadata?.originSource + ? { originSource: ext.installMetadata.originSource } + : {}), + ...(ext.installMetadata?.ref + ? { ref: ext.installMetadata.ref } + : {}), + ...(ext.installMetadata?.autoUpdate !== undefined + ? { autoUpdate: ext.installMetadata.autoUpdate } + : {}), + updateState: ext.installMetadata ? 'unknown' : 'not updatable', + capabilities, + details: { + mcpServers: ext.mcpServers ? Object.keys(ext.mcpServers) : [], + commands: ext.commands ?? [], + skills: ext.skills?.map((skill) => skill.name) ?? [], + agents: ext.agents?.map((agent) => agent.name) ?? [], + contextFiles: ext.contextFiles, + settings: + ext.resolvedSettings?.map((setting) => setting.name) ?? [], + }, + }; + }); + const status = { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: boundWorkspace, + initialized: true, + extensions: entries, + }; + extensionsStatusCache = { + expiresAt: Date.now() + 2_000, + value: status, + }; + return status; + }; + + return { + boundWorkspace, + workspace, + createExtensionManager, + buildLocalExtensionsStatus, + refreshExtensionsForAllSessions, + getOperation: (operationId) => extensionOperations.get(operationId), + preparationQueue, + acquireOperationSlot, + validateExtensionMutationClient, + runQueuedExtensionMutation, + }; +} diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index ce527fefd42..94bd4b95c1f 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -4,40 +4,155 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as crypto from 'node:crypto'; import { - ExtensionUpdateState, - ExtensionManager, - checkForExtensionUpdate, parseInstallSource, redactUrlCredentials, SettingScope, type Extension, type ExtensionInstallMetadata, - type ExtensionSetting, + type ExtensionManager, } from '@qwen-code/qwen-code-core'; import type { Application, Request, RequestHandler, Response } from 'express'; -import { loadSettings } from '../../config/settings.js'; -import { getWorkspaceTrustStatus } from '../../config/trustedFolders.js'; import { writeStderrLine } from '../../utils/stdioHelpers.js'; import type { AcpSessionBridge } from '../acp-session-bridge.js'; +import { createFifoTaskQueue } from '../extension-operation-scheduler.js'; import { isBlockedAuthProviderHost } from '../server/auth-provider-helpers.js'; import type { SendBridgeError } from '../server/error-response.js'; +import type { safeBody as safeBodyType } from '../server/request-helpers.js'; import { - createBuildWorkspaceCtx, - parseAndValidateWorkspaceClientId, - type safeBody as safeBodyType, -} from '../server/request-helpers.js'; -import { - STATUS_SCHEMA_VERSION, - type ServeExtensionCapabilities, - type ServeExtensionEntry, - type ServeWorkspaceExtensionsStatus, -} from '@qwen-code/acp-bridge/status'; + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; import type { DaemonWorkspaceService } from '../workspace-service/index.js'; +import { + createExtensionsController, + type ExtensionOperationContext, + type ExtensionsController, + type RuntimeReconciliationReservation, +} from './workspace-extensions-controller.js'; type SafeBody = typeof safeBodyType; +const EXTENSION_PREPARE_DEADLINE_MS = 10 * 60_000; +const EXTENSION_UPDATE_CHECK_DEADLINE_MS = 2 * 60_000; + +const parseExtensionScope = ( + body: Record, + res: Response, +): SettingScope | null => { + const scope = body['scope']; + if (scope !== 'user' && scope !== 'workspace') { + res + .status(400) + .json({ error: '`scope` must be either "user" or "workspace"' }); + return null; + } + return scope === 'user' ? SettingScope.User : SettingScope.Workspace; +}; + +const parseExtensionRegistryUrl = ( + value: string, + res: Response, +): string | null => { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + res.status(400).json({ error: '`registry` must be a valid URL' }); + return null; + } + if (parsed.protocol !== 'https:') { + res.status(400).json({ error: '`registry` must use https' }); + return null; + } + if (parsed.username || parsed.password) { + res.status(400).json({ error: '`registry` must not include credentials' }); + return null; + } + if (isBlockedAuthProviderHost(parsed.hostname)) { + res.status(400).json({ error: '`registry` host is not allowed' }); + return null; + } + return parsed.toString().replace(/\/$/, ''); +}; + +const parsePotentialSourceUrl = (source: string): URL | null => { + if (/^[a-zA-Z]:[\\/]/.test(source)) return null; + try { + return new URL(source); + } catch { + const colonIndex = source.indexOf(':'); + if (colonIndex >= 0 && source.slice(0, colonIndex).includes('/')) { + return null; + } + const sshMatch = /^(?:[^@]+@)?(\[[^\]]+\]|[^:]+):/.exec(source); + if (!sshMatch?.[1]) return null; + try { + return new URL(`ssh://${sshMatch[1]}`); + } catch { + return null; + } + } +}; + +const validateExtensionSourceHost = ( + source: string, + res: Response, +): boolean => { + const parsed = parsePotentialSourceUrl(source); + if (!parsed) return true; + if (parsed.username || parsed.password) { + res.status(400).json({ error: '`source` must not include credentials' }); + return false; + } + if (isBlockedAuthProviderHost(parsed.hostname)) { + res.status(400).json({ error: '`source` host is not allowed' }); + return false; + } + if (parsed.protocol !== 'https:') { + res.status(400).json({ error: '`source` must use https' }); + return false; + } + return true; +}; + +const validateExtensionSourceMetadata = ( + installMetadata: ExtensionInstallMetadata, +): boolean => { + if (installMetadata.type !== 'git') return true; + const parsed = parsePotentialSourceUrl(installMetadata.source); + return ( + !!parsed && + (installMetadata.networkPolicy === 'public' + ? parsed.protocol === 'https:' + : parsed.protocol === 'https:' || parsed.protocol === 'ssh:') && + !isBlockedAuthProviderHost(parsed.hostname) + ); +}; + +const findLoadedExtension = ( + extensionManager: ExtensionManager, + extensionName: string, +): Extension | undefined => { + const requested = extensionName.toLowerCase(); + const extensions = extensionManager.getLoadedExtensions(); + const byName = extensions.find( + (extension) => extension.name.toLowerCase() === requested, + ); + if (byName) return byName; + if (!extensionName.includes('://') && !extensionName.includes('@')) { + return undefined; + } + return extensions.find( + (extension) => + extension.installMetadata?.source?.toLowerCase() === requested, + ); +}; + interface RegisterWorkspaceExtensionRoutesDeps { boundWorkspace: string; bridge: AcpSessionBridge; @@ -46,8 +161,21 @@ interface RegisterWorkspaceExtensionRoutesDeps { safeBody: SafeBody; sendBridgeError: SendBridgeError; maxExtensionOperationHistory?: number; + // Enables V2 workspace projection and targeted reconciliation routes. + workspaceRegistry?: WorkspaceRegistry; } +/** + * Resolves the extensions controller for a request. Returns `null` (after + * emitting the appropriate error) when the workspace selector is unknown, or + * when a mutation targets an untrusted workspace. + */ +type ResolveController = ( + req: Request, + res: Response, + requireTrust: boolean, +) => ExtensionsController | null; + export function registerWorkspaceExtensionRoutes( app: Application, deps: RegisterWorkspaceExtensionRoutesDeps, @@ -59,529 +187,232 @@ export function registerWorkspaceExtensionRoutes( mutate, safeBody, sendBridgeError, + workspaceRegistry, } = deps; - const maxExtensionOperationHistory = deps.maxExtensionOperationHistory ?? 100; - const buildWorkspaceCtx = createBuildWorkspaceCtx(boundWorkspace); - - let extensionInstallQueue: Promise = Promise.resolve(); - let extensionInstallQueueDepth = 0; - const MAX_EXTENSION_INSTALL_QUEUE_DEPTH = 10; - const enqueueExtensionInstall = async (run: () => Promise) => { - if (extensionInstallQueueDepth >= MAX_EXTENSION_INSTALL_QUEUE_DEPTH) { - throw new Error('Extension operation queue is full'); - } - extensionInstallQueueDepth += 1; - const next = extensionInstallQueue.then(run, run).finally(() => { - extensionInstallQueueDepth -= 1; - }); - extensionInstallQueue = next.catch(() => undefined); - return next; - }; - const EXTENSION_MUTATION_TIMEOUT_MS = 10 * 60_000; - const EXTENSION_REFRESH_TIMEOUT_MS = 30_000; - const isExtensionQueueFullError = (err: unknown): boolean => - err instanceof Error && err.message === 'Extension operation queue is full'; - const sendExtensionQueueFull = (res: Response) => { - res.status(429).json({ - error: 'Extension operation queue is full', - code: 'extension_queue_full', - }); - }; - const withExtensionTimeout = async ( - promise: Promise, - timeoutMs: number, - operation: string, - ): Promise => - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error(`${operation} timed out after ${timeoutMs}ms`)); - }, timeoutMs); - promise.then( - (value) => { - clearTimeout(timeout); - resolve(value); - }, - (err: unknown) => { - clearTimeout(timeout); - reject(err); - }, - ); + const maxExtensionOperationHistory = deps.maxExtensionOperationHistory; + const controllerDeps = ( + ws: string, + wsBridge: AcpSessionBridge, + wsService: DaemonWorkspaceService, + ) => ({ + boundWorkspace: ws, + bridge: wsBridge, + workspace: wsService, + ...(maxExtensionOperationHistory === undefined + ? {} + : { maxExtensionOperationHistory }), + }); + + const primaryController = createExtensionsController( + controllerDeps(boundWorkspace, bridge, workspace), + ); + const runtimeReconciliationQueue = createFifoTaskQueue(1); + const reserveRuntimeReconciliation = (): RuntimeReconciliationReservation => { + let provideTask!: (task?: () => Promise) => void; + const task = new Promise<(() => Promise) | undefined>( + (resolve) => { + provideTask = resolve; + }, + ); + const queued = runtimeReconciliationQueue.run(async () => { + const run = await task; + return run ? await run() : undefined; }); - const createExtensionManager = () => - new ExtensionManager({ - workspaceDir: boundWorkspace, - isWorkspaceTrusted: - getWorkspaceTrustStatus( - loadSettings(boundWorkspace).merged, - boundWorkspace, - ).effective.state === 'trusted', - requestConsent: () => Promise.resolve(), - requestSetting: async (setting: ExtensionSetting) => { - throw new Error( - `Extension setting "${setting.envVar}" requires interactive configuration and is not supported over the daemon install endpoint.`, - ); + let used = false; + return { + run: async (run: () => Promise): Promise => { + if (used) throw new Error('Runtime reconciliation already released'); + used = true; + provideTask(run); + return (await queued) as T; }, - requestChoicePlugin: async () => { - throw new Error( - 'Marketplace plugin selection is not supported over the daemon install endpoint. Specify a plugin name in the source.', - ); + release: () => { + if (used) return; + used = true; + provideTask(undefined); }, - }); - const validateExtensionMutationClient = ( - req: Request, - res: Response, - route: string, - opts: { requireClientId?: boolean } = {}, - ): boolean => { - const clientId = parseAndValidateWorkspaceClientId(req, res, bridge); - if (clientId === null) return false; - if (clientId === undefined && opts.requireClientId !== false) { - res.status(400).json({ - error: 'Missing X-Qwen-Client-Id header', - code: 'missing_client_id', - }); - return false; - } - buildWorkspaceCtx(route, clientId); - return true; - }; - const parseExtensionScope = ( - body: Record, - res: Response, - ): SettingScope | null => { - const scope = body['scope']; - if (scope !== 'user' && scope !== 'workspace') { - res - .status(400) - .json({ error: '`scope` must be either "user" or "workspace"' }); - return null; - } - return scope === 'user' ? SettingScope.User : SettingScope.Workspace; - }; - const parseExtensionRegistryUrl = ( - value: string, - res: Response, - ): string | null => { - let parsed: URL; - try { - parsed = new URL(value); - } catch { - res.status(400).json({ error: '`registry` must be a valid URL' }); - return null; - } - if (parsed.protocol !== 'https:') { - res.status(400).json({ error: '`registry` must use https' }); - return null; - } - if (parsed.username || parsed.password) { - res - .status(400) - .json({ error: '`registry` must not include credentials' }); - return null; - } - if (isBlockedAuthProviderHost(parsed.hostname)) { - res.status(400).json({ error: '`registry` host is not allowed' }); - return null; - } - return parsed.toString().replace(/\/$/, ''); - }; - const parsePotentialSourceUrl = (source: string): URL | null => { - if (/^[a-zA-Z]:[\\/]/.test(source)) return null; - try { - return new URL(source); - } catch { - const sshMatch = /^(?:[^@]+@)?(\[[^\]]+\]|[^:]+):/.exec(source); - if (!sshMatch?.[1]) return null; - try { - return new URL(`ssh://${sshMatch[1]}`); - } catch { - return null; - } - } - }; - const validateExtensionSourceHost = ( - source: string, - res: Response, - ): boolean => { - const parsed = parsePotentialSourceUrl(source); - if (!parsed) return true; - if (parsed.username || parsed.password) { - res.status(400).json({ error: '`source` must not include credentials' }); - return false; - } - if (isBlockedAuthProviderHost(parsed.hostname)) { - res.status(400).json({ error: '`source` host is not allowed' }); - return false; - } - if (parsed.protocol !== 'https:' && parsed.protocol !== 'ssh:') { - res.status(400).json({ error: '`source` must use https or ssh' }); - return false; - } - return true; - }; - const validateExtensionSourceMetadata = ( - installMetadata: ExtensionInstallMetadata, - ): boolean => { - if (installMetadata.type !== 'git') return true; - const parsed = parsePotentialSourceUrl(installMetadata.source); - return ( - !!parsed && - (parsed.protocol === 'https:' || parsed.protocol === 'ssh:') && - !isBlockedAuthProviderHost(parsed.hostname) - ); - }; - const findLoadedExtension = ( - extensionManager: ExtensionManager, - extensionName: string, - ): Extension | undefined => { - const requested = extensionName.toLowerCase(); - const extensions = extensionManager.getLoadedExtensions(); - const byName = extensions.find( - (extension) => extension.name.toLowerCase() === requested, - ); - if (byName) return byName; - if (!extensionName.includes('://') && !extensionName.includes('@')) { - return undefined; - } - return extensions.find( - (extension) => - extension.installMetadata?.source?.toLowerCase() === requested, - ); - }; - type ExtensionMutationEvent = { - status: 'installed' | 'enabled' | 'disabled' | 'updated' | 'uninstalled'; - source?: string; - name?: string; - version?: string; - }; - type ExtensionOperationStatus = { - v: 1; - operationId: string; - operation: string; - status: - | 'queued' - | 'running' - | 'succeeded' - | 'succeeded_with_refresh_error' - | 'failed'; - createdAt: number; - updatedAt: number; - source?: string; - name?: string; - result?: ExtensionMutationEvent & { - refreshed?: number; - failed?: number; - error?: string; }; - error?: string; }; - const extensionOperations = new Map(); - const isTerminalExtensionOperation = ( - operation: ExtensionOperationStatus, - ): boolean => operation.status !== 'queued' && operation.status !== 'running'; - const redactExtensionOperationResult = ( - event: ExtensionMutationEvent, - ): ExtensionMutationEvent => ({ - ...event, - ...(event.source ? { source: redactUrlCredentials(event.source) } : {}), - }); - const rememberExtensionOperation = ( - operation: ExtensionOperationStatus, - ): void => { - extensionOperations.set(operation.operationId, operation); - while (extensionOperations.size > maxExtensionOperationHistory) { - let evicted = false; - for (const [id, storedOperation] of extensionOperations) { - if (!isTerminalExtensionOperation(storedOperation)) continue; - extensionOperations.delete(id); - evicted = true; - break; - } - if (!evicted) break; - } - }; - const updateExtensionOperation = ( - operationId: string, - patch: Partial>, + const appliedGenerationByWorkspaceId = new Map(); + const onRuntimeReconciled = ( + runtime: WorkspaceRuntime, + generation: number, ): void => { - const current = extensionOperations.get(operationId); - if (!current) return; - extensionOperations.set(operationId, { - ...current, - ...patch, - updatedAt: Date.now(), - }); + appliedGenerationByWorkspaceId.set(runtime.workspaceId, generation); }; - const runQueuedExtensionMutation = ( - operation: string, - failureContext: { source?: string; name?: string }, - res: Response, - run: ( - extensionManager: ExtensionManager, - ) => Promise, - ): void => { - if (extensionInstallQueueDepth >= MAX_EXTENSION_INSTALL_QUEUE_DEPTH) { - sendExtensionQueueFull(res); - return; - } - const operationId = crypto.randomUUID(); - const now = Date.now(); - rememberExtensionOperation({ - v: 1, - operationId, - operation, - status: 'queued', - createdAt: now, - updatedAt: now, - ...(failureContext.source - ? { source: redactUrlCredentials(failureContext.source) } - : {}), - ...(failureContext.name ? { name: failureContext.name } : {}), - }); - res.status(202).json({ accepted: true, operationId }); - void enqueueExtensionInstall(async () => { + const globalReconciliationOptions = () => + workspaceRegistry + ? { + refreshRuntimes: () => workspaceRegistry.list(), + reserveRuntimeReconciliation, + onRuntimeReconciled, + } + : {}; + const workspaceReconciliationOptions = () => + workspaceRegistry + ? { + refreshRuntimes: [workspaceRegistry.primary], + reserveRuntimeReconciliation, + onRuntimeReconciled, + } + : {}; + const mutationClientBridges = ( + runtimes?: + | readonly WorkspaceRuntime[] + | (() => readonly WorkspaceRuntime[]), + ): readonly AcpSessionBridge[] => + (typeof runtimes === 'function' + ? runtimes() + : (runtimes ?? workspaceRegistry?.list()) + )?.map((runtime) => runtime.bridge) ?? [bridge]; + + if (workspaceRegistry) { + let observedGeneration: number | undefined; + let reconciling = false; + const reconcileExternalGeneration = async (): Promise => { + if (reconciling) return; + reconciling = true; try { - updateExtensionOperation(operationId, { status: 'running' }); - const extensionManager = createExtensionManager(); - await extensionManager.refreshCache(); - const event = await withExtensionTimeout( - run(extensionManager), - EXTENSION_MUTATION_TIMEOUT_MS, - `extension ${operation}`, + const manager = primaryController.createExtensionManager( + boundWorkspace, + true, ); - extensionsStatusCache = undefined; - workspace.invalidateWorkspaceSkillsStatus(); - try { - const result = await bridge.refreshExtensionsForAllSessions(event); - updateExtensionOperation(operationId, { - status: 'succeeded', - result: { - ...redactExtensionOperationResult(event), - refreshed: result.refreshed, - failed: result.failed, - }, - }); - writeStderrLine( - `qwen serve: extensions ${operation}: refreshed ${result.refreshed} session(s), ${result.failed} failed`, - ); - } catch (refreshErr) { - const message = redactUrlCredentials( - refreshErr instanceof Error - ? refreshErr.message - : String(refreshErr), + const generation = (await manager.getExtensionStoreSnapshot()) + .generation; + const pendingRuntimes = workspaceRegistry + .list() + .filter( + (runtime) => + (appliedGenerationByWorkspaceId.get(runtime.workspaceId) ?? 0) !== + generation, ); - updateExtensionOperation(operationId, { - status: 'succeeded_with_refresh_error', - result: { - ...redactExtensionOperationResult(event), - refreshed: 0, - failed: 1, - error: message.slice(0, 500), - }, - }); - try { - bridge.broadcastExtensionsChanged({ - ...event, - refreshed: 0, - failed: 1, - error: message.slice(0, 500), - }); - } catch (broadcastErr) { + if (generation === observedGeneration && pendingRuntimes.length === 0) + return; + const runtimes = pendingRuntimes; + if (runtimes.length === 0) return; + const results = await runtimeReconciliationQueue.run( + async () => + await Promise.allSettled( + runtimes.map(async (runtime) => { + runtime.workspaceService.invalidateWorkspaceSkillsStatus(); + const result = + await runtime.bridge.refreshExtensionsForAllSessions(); + if (result.failed > 0) { + throw new Error( + `${result.failed} extension session refresh(es) failed`, + ); + } + }), + ), + ); + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + const workspaceId = runtimes[index]!.workspaceId; + appliedGenerationByWorkspaceId.set(workspaceId, generation); + } else { writeStderrLine( - `qwen serve: extensions ${operation}: failed to broadcast refresh failure: ${ - broadcastErr instanceof Error - ? redactUrlCredentials(broadcastErr.message) - : String(broadcastErr) - }`, + `qwen serve: extension generation reconciliation failed for workspace ${runtimes[index]!.workspaceId}: ${redactUrlCredentials( + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + )}`, ); } - writeStderrLine( - `qwen serve: extensions ${operation}: mutation succeeded but refresh failed: ${message}`, - ); - } - } catch (err) { - const message = redactUrlCredentials( - err instanceof Error ? err.message : String(err), - ); - updateExtensionOperation(operationId, { - status: 'failed', - error: message.slice(0, 500), }); - try { - bridge.broadcastExtensionsChanged({ - status: 'failed', - ...(failureContext.source - ? { source: redactUrlCredentials(failureContext.source) } - : {}), - ...(failureContext.name ? { name: failureContext.name } : {}), - refreshed: 0, - failed: 0, - error: message.slice(0, 500), - }); - } catch (broadcastErr) { - writeStderrLine( - `qwen serve: extensions ${operation}: failed to broadcast failure: ${ - broadcastErr instanceof Error - ? redactUrlCredentials(broadcastErr.message) - : String(broadcastErr) - }`, - ); - } - try { - writeStderrLine( - `qwen serve: extensions ${operation}: background task failed: ${message}`, - ); - } catch { - // Keep queued background work from surfacing as unhandledRejection. + if ( + runtimes.length === pendingRuntimes.length && + results.every((result) => result.status === 'fulfilled') + ) { + observedGeneration = generation; } - } - }).catch((err) => { - const message = redactUrlCredentials( - err instanceof Error ? err.message : String(err), - ); - updateExtensionOperation(operationId, { - status: 'failed', - error: message.slice(0, 500), - }); - try { + } catch (error) { writeStderrLine( - `qwen serve: extensions ${operation}: queued task failed: ${message}`, + `qwen serve: extension generation reconciliation failed: ${redactUrlCredentials( + error instanceof Error ? error.message : String(error), + )}`, ); - } catch { - // Last-resort guard for detached async work. - } - }); - }; - let extensionsStatusCache: - | { expiresAt: number; value: ServeWorkspaceExtensionsStatus } - | undefined; - const buildLocalExtensionsStatus = - async (): Promise => { - const now = Date.now(); - if (extensionsStatusCache && extensionsStatusCache.expiresAt > now) { - return extensionsStatusCache.value; + } finally { + reconciling = false; } - const extensionManager = createExtensionManager(); - await extensionManager.refreshCache(); - const entries: ServeExtensionEntry[] = extensionManager - .getLoadedExtensions() - .map((ext): ServeExtensionEntry => { - const capabilities: ServeExtensionCapabilities = { - mcpServerCount: ext.mcpServers - ? Object.keys(ext.mcpServers).length - : 0, - skillCount: ext.skills?.length ?? 0, - agentCount: ext.agents?.length ?? 0, - hookCount: ext.hooks - ? Object.values(ext.hooks).reduce( - (sum, defs) => sum + (defs?.length ?? 0), - 0, - ) - : 0, - commandCount: ext.commands?.length ?? 0, - contextFileCount: ext.contextFiles.length, - channelCount: ext.channels ? Object.keys(ext.channels).length : 0, - hasSettings: (ext.settings?.length ?? 0) > 0, - }; - return { - kind: 'extension', - id: ext.id, - name: ext.name, - ...(ext.displayName ? { displayName: ext.displayName } : {}), - ...(ext.config.description - ? { description: ext.config.description } - : {}), - version: ext.version, - isActive: ext.isActive, - path: ext.path, - ...(ext.installMetadata?.source - ? { source: redactUrlCredentials(ext.installMetadata.source) } - : {}), - ...(ext.installMetadata?.type - ? { installType: ext.installMetadata.type } - : {}), - ...(ext.installMetadata?.originSource - ? { originSource: ext.installMetadata.originSource } - : {}), - ...(ext.installMetadata?.ref - ? { ref: ext.installMetadata.ref } - : {}), - ...(ext.installMetadata?.autoUpdate !== undefined - ? { autoUpdate: ext.installMetadata.autoUpdate } - : {}), - updateState: ext.installMetadata ? 'unknown' : 'not updatable', - capabilities, - details: { - mcpServers: ext.mcpServers ? Object.keys(ext.mcpServers) : [], - commands: ext.commands ?? [], - skills: ext.skills?.map((skill) => skill.name) ?? [], - agents: ext.agents?.map((agent) => agent.name) ?? [], - contextFiles: ext.contextFiles, - settings: - ext.resolvedSettings?.map((setting) => setting.name) ?? [], - }, - }; - }); - const status = { - v: STATUS_SCHEMA_VERSION, - workspaceCwd: boundWorkspace, - initialized: true, - extensions: entries, - }; - extensionsStatusCache = { - expiresAt: now + 2_000, - value: status, - }; - return status; }; - // GET /workspace/extensions — read-only installed extension status. - app.get('/workspace/extensions', async (_req, res) => { - try { - buildWorkspaceCtx('GET /workspace/extensions'); - res.status(200).json(await buildLocalExtensionsStatus()); - } catch (err) { - sendBridgeError(res, err, { route: 'GET /workspace/extensions' }); - } - }); + const generationPoller = setInterval( + () => void reconcileExternalGeneration(), + 30_000, + ); + generationPoller.unref(); + ( + app.locals as { stopExtensionGenerationReconciler?: () => void } + ).stopExtensionGenerationReconciler = () => clearInterval(generationPoller); + } - app.get('/workspace/extensions/operations/:operationId', async (req, res) => { - try { - buildWorkspaceCtx('GET /workspace/extensions/operations/:operationId'); - const operationId = req.params['operationId']; - if (!operationId) { - res.status(400).json({ error: 'Missing extension operation id' }); - return; - } - const operation = extensionOperations.get(operationId); - if (!operation) { - res.status(404).json({ - error: `Extension operation "${operationId}" not found`, - code: 'extension_operation_not_found', - }); - return; + const registerFor = (base: string, resolve: ResolveController): void => { + // GET {base} — read-only installed extension status. + app.get(base, async (req, res) => { + const ctrl = resolve(req, res, false); + if (!ctrl) return; + try { + res.status(200).json(await ctrl.buildLocalExtensionsStatus()); + } catch (err) { + sendBridgeError(res, err, { route: `GET ${base}` }); } - res.status(200).json(operation); - } catch (err) { - sendBridgeError(res, err, { - route: 'GET /workspace/extensions/operations/:operationId', - }); - } - }); + }); - // POST /workspace/extensions/install — install an extension and refresh - // all active sessions asynchronously. - app.post( - '/workspace/extensions/install', - mutate({ strict: true }), - async (req, res) => { + app.get(`${base}/operations/:operationId`, async (req, res) => { + const ctrl = resolve(req, res, false); + if (!ctrl) return; try { + const operationId = req.params['operationId']; + if (!operationId) { + res.status(400).json({ error: 'Missing extension operation id' }); + return; + } + const operation = ctrl.getOperation(operationId); + if (!operation) { + res.status(404).json({ + error: `Extension operation "${operationId}" not found`, + code: 'extension_operation_not_found', + }); + return; + } if ( - !validateExtensionMutationClient( - req, - res, - 'POST /workspace/extensions/install', - ) + base === '/workspace/extensions' && + operation.status === 'succeeded_with_warnings' ) { + const warningError = + operation.warnings?.find((warning) => warning.code === undefined) + ?.error ?? + operation.warnings?.[0]?.error ?? + operation.result?.error; + const legacyOperation = { + ...operation, + status: 'succeeded_with_refresh_error' as const, + }; + if (operation.result && warningError) { + legacyOperation.result = { + ...operation.result, + error: warningError, + }; + } else if (warningError) { + legacyOperation.error = warningError; + } + res.status(200).json(legacyOperation); + return; + } + res.status(200).json(operation); + } catch (err) { + sendBridgeError(res, err, { + route: `GET ${base}/operations/:operationId`, + }); + } + }); + + // POST {base}/install — install an extension and refresh all active + // sessions asynchronously. + app.post(`${base}/install`, mutate({ strict: true }), async (req, res) => { + const ctrl = resolve(req, res, true); + if (!ctrl) return; + try { + if (!ctrl.validateExtensionMutationClient(req, res)) { return; } const body = safeBody(req); @@ -644,200 +475,361 @@ export function registerWorkspaceExtensionRoutes( return; } - runQueuedExtensionMutation( + ctrl.runQueuedExtensionMutation( 'install', { source: sourceValue }, res, - async (extensionManager) => { - const installMetadata = await parseInstallSource(sourceValue); - - if ( - installMetadata.type !== 'git' && - installMetadata.type !== 'github-release' && - installMetadata.type !== 'npm' - ) { - throw new Error( - 'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.', - ); - } - if (installMetadata.type === 'npm' && refValue) { - throw new Error('--ref is not applicable for npm extensions.'); - } - if (installMetadata.type !== 'npm' && registryValue) { - throw new Error( - '--registry is only applicable for npm extensions.', + async (extensionManager, _signal, context) => { + const prepared = await context!.prepare(async (signal) => { + const installMetadata = await parseInstallSource(sourceValue, { + networkPolicy: 'public', + }); + + if ( + installMetadata.type !== 'git' && + installMetadata.type !== 'github-release' && + installMetadata.type !== 'npm' + ) { + throw new Error( + 'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.', + ); + } + if (installMetadata.type === 'npm' && refValue) { + throw new Error('--ref is not applicable for npm extensions.'); + } + if (installMetadata.type !== 'npm' && registryValue) { + throw new Error( + '--registry is only applicable for npm extensions.', + ); + } + if (!validateExtensionSourceMetadata(installMetadata)) { + throw new Error('`source` host is not allowed'); + } + if (installMetadata.type === 'npm' && registryUrl) { + installMetadata.registryUrl = registryUrl; + } + return await extensionManager.prepareExtensionInstall({ + installMetadata: { + ...installMetadata, + ref: refValue, + autoUpdate: autoUpdateValue, + allowPreRelease: allowPreReleaseValue, + }, + initialActivation: { scope: 'user' }, + requestConsent: () => Promise.resolve(), + signal, + }); + }); + try { + const committed = await context!.commit( + async (onCommitted) => + await extensionManager.commitPreparedExtension( + prepared, + onCommitted, + ), ); + return { + status: 'installed', + source: sourceValue, + name: committed.identity.name, + version: committed.version, + }; + } finally { + await extensionManager.disposePreparedExtension(prepared); } - if (!validateExtensionSourceMetadata(installMetadata)) { - throw new Error('`source` host is not allowed'); - } - if (installMetadata.type === 'npm' && registryUrl) { - installMetadata.registryUrl = registryUrl; - } - const extension = await extensionManager.installExtension( - { - ...installMetadata, - ref: refValue, - autoUpdate: autoUpdateValue, - allowPreRelease: allowPreReleaseValue, - }, - () => Promise.resolve(), - ); - return { - status: 'installed', - source: sourceValue, - name: extension.name, - version: extension.config.version, - }; + }, + { + deadlineMs: EXTENSION_PREPARE_DEADLINE_MS, + ...globalReconciliationOptions(), }, ); } catch (err) { - sendBridgeError(res, err, { - route: 'POST /workspace/extensions/install', - }); + sendBridgeError(res, err, { route: `POST ${base}/install` }); } - }, - ); + }); - app.post( - '/workspace/extensions/check-updates', - mutate({ strict: true }), - async (req, res) => { - try { - if ( - !validateExtensionMutationClient( - req, - res, - 'POST /workspace/extensions/check-updates', - ) - ) { - return; - } - const states = await enqueueExtensionInstall(async () => - withExtensionTimeout( - (async () => { - const extensionManager = createExtensionManager(); - await extensionManager.refreshCache(); - const updateStates: Record = {}; - await extensionManager.checkForAllExtensionUpdates( - (name, state) => { - updateStates[name] = state; - }, + app.post( + `${base}/check-updates`, + mutate({ strict: true }), + async (req, res) => { + const ctrl = resolve(req, res, true); + if (!ctrl) return; + let timer: ReturnType | undefined; + let releaseOperationSlot: (() => void) | undefined; + try { + if (!ctrl.validateExtensionMutationClient(req, res)) { + return; + } + releaseOperationSlot = ctrl.acquireOperationSlot(res); + if (!releaseOperationSlot) return; + const extensionManager = ctrl.createExtensionManager(); + const updateStates: Record = Object.create(null); + const deadline = new AbortController(); + timer = setTimeout(() => { + const error = new Error( + 'Extension update check exceeded its preparation deadline.', + ) as Error & { code: string }; + error.code = 'extension_prepare_timeout'; + deadline.abort(error); + }, EXTENSION_UPDATE_CHECK_DEADLINE_MS); + timer.unref(); + let rejectRefreshOnAbort: (() => void) | undefined; + try { + await Promise.race([ + extensionManager.refreshCache(), + new Promise((_resolve, reject) => { + rejectRefreshOnAbort = () => reject(deadline.signal.reason); + deadline.signal.addEventListener( + 'abort', + rejectRefreshOnAbort, + { once: true }, + ); + }), + ]); + } finally { + if (rejectRefreshOnAbort) { + deadline.signal.removeEventListener( + 'abort', + rejectRefreshOnAbort, ); - return updateStates; - })(), - EXTENSION_REFRESH_TIMEOUT_MS, - 'extension update check', - ), - ); - res.status(200).json({ states }); - } catch (err) { - if (isExtensionQueueFullError(err)) { - sendExtensionQueueFull(res); - return; + } + } + await extensionManager.checkForAllExtensionUpdates( + (name, state) => { + updateStates[name] = state; + }, + deadline.signal, + async (task) => + await ctrl.preparationQueue.run(task, { + signal: deadline.signal, + }), + ); + const states = updateStates; + res.status(200).json({ states }); + } catch (err) { + sendBridgeError(res, err, { route: `POST ${base}/check-updates` }); + } finally { + if (timer) clearTimeout(timer); + releaseOperationSlot?.(); } - sendBridgeError(res, err, { - route: 'POST /workspace/extensions/check-updates', - }); - } - }, - ); + }, + ); - app.post( - '/workspace/extensions/refresh', - mutate({ strict: true }), - async (req, res) => { + app.post(`${base}/refresh`, mutate({ strict: true }), async (req, res) => { + const ctrl = resolve(req, res, true); + if (!ctrl) return; try { - if ( - !validateExtensionMutationClient( - req, - res, - 'POST /workspace/extensions/refresh', - ) - ) { + if (!ctrl.validateExtensionMutationClient(req, res)) { return; } - const result = await enqueueExtensionInstall(async () => - withExtensionTimeout( - workspace.refreshExtensionsForAllSessions(), - EXTENSION_REFRESH_TIMEOUT_MS, - 'extension refresh', - ), - ); - extensionsStatusCache = undefined; - res.status(200).json(result); - } catch (err) { - if (isExtensionQueueFullError(err)) { - sendExtensionQueueFull(res); - return; + const releaseOperationSlot = ctrl.acquireOperationSlot(res); + if (!releaseOperationSlot) return; + try { + const result = await ctrl.refreshExtensionsForAllSessions(); + res.status(200).json(result); + } finally { + releaseOperationSlot(); } - sendBridgeError(res, err, { - route: 'POST /workspace/extensions/refresh', - }); + } catch (err) { + sendBridgeError(res, err, { route: `POST ${base}/refresh` }); } - }, - ); + }); - app.post( - '/workspace/extensions/:name/enable', - mutate({ strict: true }), - async (req, res) => { - try { - if ( - !validateExtensionMutationClient( - req, + app.post( + `${base}/:name/enable`, + mutate({ strict: true }), + async (req, res) => { + const ctrl = resolve(req, res, true); + if (!ctrl) return; + try { + if ( + !ctrl.validateExtensionMutationClient(req, res, { + requireClientId: false, + }) + ) { + return; + } + const name = req.params['name']; + if (!name) { + res.status(400).json({ error: 'Missing extension name' }); + return; + } + const scope = parseExtensionScope(safeBody(req), res); + if (scope === null) return; + ctrl.runQueuedExtensionMutation( + 'enable', + { name }, res, - 'POST /workspace/extensions/:name/enable', - { requireClientId: false }, - ) - ) { - return; + async (extensionManager, _signal, context) => { + const extension = findLoadedExtension(extensionManager, name); + if (!extension) { + throw new Error(`Extension "${name}" not found`); + } + await context!.commit( + async (onCommitted) => + await extensionManager.enableExtension( + extension.name, + scope, + ctrl.boundWorkspace, + onCommitted, + ), + ); + return { status: 'enabled', name: extension.name }; + }, + { + ...(scope === SettingScope.User + ? globalReconciliationOptions() + : workspaceReconciliationOptions()), + }, + ); + } catch (err) { + sendBridgeError(res, err, { route: `POST ${base}/:name/enable` }); } - const name = req.params['name']; - if (!name) { - res.status(400).json({ error: 'Missing extension name' }); - return; + }, + ); + + app.post( + `${base}/:name/disable`, + mutate({ strict: true }), + async (req, res) => { + const ctrl = resolve(req, res, true); + if (!ctrl) return; + try { + if ( + !ctrl.validateExtensionMutationClient(req, res, { + requireClientId: false, + }) + ) { + return; + } + const name = req.params['name']; + if (!name) { + res.status(400).json({ error: 'Missing extension name' }); + return; + } + const scope = parseExtensionScope(safeBody(req), res); + if (scope === null) return; + ctrl.runQueuedExtensionMutation( + 'disable', + { name }, + res, + async (extensionManager, _signal, context) => { + const extension = findLoadedExtension(extensionManager, name); + if (!extension) { + throw new Error(`Extension "${name}" not found`); + } + await context!.commit( + async (onCommitted) => + await extensionManager.disableExtension( + extension.name, + scope, + ctrl.boundWorkspace, + onCommitted, + ), + ); + return { status: 'disabled', name: extension.name }; + }, + { + ...(scope === SettingScope.User + ? globalReconciliationOptions() + : workspaceReconciliationOptions()), + }, + ); + } catch (err) { + sendBridgeError(res, err, { route: `POST ${base}/:name/disable` }); } - const scope = parseExtensionScope(safeBody(req), res); - if (scope === null) return; - runQueuedExtensionMutation( - 'enable', - { name }, - res, - async (extensionManager) => { - const extension = findLoadedExtension(extensionManager, name); - if (!extension) { - throw new Error(`Extension "${name}" not found`); - } - await extensionManager.enableExtension( - extension.name, - scope, - boundWorkspace, - ); - return { status: 'enabled', name: extension.name }; - }, - ); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /workspace/extensions/:name/enable', - }); - } - }, - ); + }, + ); - app.post( - '/workspace/extensions/:name/disable', - mutate({ strict: true }), - async (req, res) => { - try { - if ( - !validateExtensionMutationClient( - req, + app.post( + `${base}/:name/update`, + mutate({ strict: true }), + async (req, res) => { + const ctrl = resolve(req, res, true); + if (!ctrl) return; + try { + if (!ctrl.validateExtensionMutationClient(req, res)) { + return; + } + const name = req.params['name']; + if (!name) { + res.status(400).json({ error: 'Missing extension name' }); + return; + } + ctrl.runQueuedExtensionMutation( + 'update', + { name }, res, - 'POST /workspace/extensions/:name/disable', - { requireClientId: false }, - ) - ) { + async (extensionManager, _signal, context) => { + const extension = findLoadedExtension(extensionManager, name); + if (!extension) { + throw new Error(`Extension "${name}" not found`); + } + let preparedResult: Awaited< + ReturnType + >; + try { + preparedResult = await context!.prepare( + async (signal) => + await extensionManager.prepareExtensionUpdate({ + extension, + signal, + }), + ); + } catch (error) { + const wrapped = new Error( + `Update check failed for extension "${extension.name}": ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error }, + ) as Error & { code?: string }; + if ( + error && + typeof error === 'object' && + typeof (error as { code?: unknown }).code === 'string' + ) { + wrapped.code = (error as { code: string }).code; + } + throw wrapped; + } + if (preparedResult.upToDate) { + throw new Error(`Extension "${extension.name}" has no update`); + } + try { + const committed = await context!.commit( + async (onCommitted) => + await extensionManager.commitPreparedExtension( + preparedResult.prepared, + onCommitted, + ), + ); + return { + status: 'updated', + name: extension.name, + version: committed.version, + }; + } finally { + await extensionManager.disposePreparedExtension( + preparedResult.prepared, + ); + } + }, + { + deadlineMs: EXTENSION_PREPARE_DEADLINE_MS, + ...globalReconciliationOptions(), + }, + ); + } catch (err) { + sendBridgeError(res, err, { route: `POST ${base}/:name/update` }); + } + }, + ); + + app.delete(`${base}/:name`, mutate({ strict: true }), async (req, res) => { + const ctrl = resolve(req, res, true); + if (!ctrl) return; + try { + if (!ctrl.validateExtensionMutationClient(req, res)) { return; } const name = req.params['name']; @@ -845,151 +837,707 @@ export function registerWorkspaceExtensionRoutes( res.status(400).json({ error: 'Missing extension name' }); return; } - const scope = parseExtensionScope(safeBody(req), res); - if (scope === null) return; - runQueuedExtensionMutation( - 'disable', + ctrl.runQueuedExtensionMutation( + 'uninstall', { name }, res, - async (extensionManager) => { + async (extensionManager, _signal, context) => { const extension = findLoadedExtension(extensionManager, name); if (!extension) { throw new Error(`Extension "${name}" not found`); } - await extensionManager.disableExtension( - extension.name, - scope, - boundWorkspace, + await context!.commit( + async (onCommitted) => + await extensionManager.uninstallExtension( + extension.name, + false, + ctrl.boundWorkspace, + onCommitted, + ), ); - return { status: 'disabled', name: extension.name }; + return { status: 'uninstalled', name: extension.name }; + }, + { + ...globalReconciliationOptions(), }, ); } catch (err) { - sendBridgeError(res, err, { - route: 'POST /workspace/extensions/:name/disable', + sendBridgeError(res, err, { route: `DELETE ${base}/:name` }); + } + }); + }; + + // Legacy singular routes bound to the primary workspace (behavior unchanged). + registerFor('/workspace/extensions', () => primaryController); + + const extensionById = ( + manager: ExtensionManager, + extensionId: string, + ): Extension | undefined => + manager + .getLoadedExtensions() + .find((extension) => extension.id === extensionId); + + const parseExtensionId = (req: Request, res: Response): string | null => { + const extensionId = req.params['extensionId']; + if (!extensionId || !/^[a-f0-9]{64}$/.test(extensionId)) { + res.status(400).json({ + error: 'Invalid extension id', + code: 'invalid_extension_id', + }); + return null; + } + return extensionId; + }; + + const parseActivationState = ( + req: Request, + res: Response, + ): 'enabled' | 'disabled' | null => { + const state = safeBody(req)['state']; + if (state !== 'enabled' && state !== 'disabled') { + res.status(400).json({ + error: '`state` must be either "enabled" or "disabled"', + code: 'invalid_extension_activation', + }); + return null; + } + return state; + }; + + const sendOperation = ( + req: Request, + res: Response, + route: string, + manager: ExtensionManager, + operation: string, + failureContext: { source?: string; name?: string }, + run: ( + extensionManager: ExtensionManager, + signal?: AbortSignal, + context?: ExtensionOperationContext, + ) => Promise<{ + status: + | 'installed' + | 'enabled' + | 'disabled' + | 'updated' + | 'uninstalled' + | 'checked' + | 'refreshed'; + source?: string; + name?: string; + version?: string; + updated?: boolean; + reason?: string; + states?: Record; + }>, + options: { + refreshRuntimes?: + | readonly WorkspaceRuntime[] + | (() => readonly WorkspaceRuntime[]); + skipRefresh?: boolean; + deadlineMs?: number; + } = {}, + ): void => { + if ( + !primaryController.validateExtensionMutationClient(req, res, { + requireClientId: false, + bridges: mutationClientBridges(options.refreshRuntimes), + }) + ) { + return; + } + primaryController.runQueuedExtensionMutation( + operation, + failureContext, + res, + run, + { + manager, + operationBasePath: '/extensions/operations', + onRuntimeReconciled, + reserveRuntimeReconciliation, + ...options, + }, + ); + }; + + app.get('/extensions', async (_req, res) => { + try { + const manager = primaryController.createExtensionManager( + boundWorkspace, + true, + ); + const snapshot = await manager.refreshCacheWithSnapshot(); + res.status(200).json({ + v: 1, + generation: snapshot.generation, + extensions: manager.getLoadedExtensions().map((extension) => { + const policy = snapshot.extensions[extension.id]; + return { + id: extension.id, + name: extension.name, + version: extension.version, + ...(extension.installMetadata?.type + ? { installType: extension.installMetadata.type } + : {}), + defaultActivation: policy?.defaultActivation ?? 'enabled', + workspaceOverrideCount: Object.values( + policy?.workspaceOverrides ?? {}, + ).filter((activation) => activation !== 'inherit').length, + }; + }), + }); + } catch (error) { + sendBridgeError(res, error, { route: 'GET /extensions' }); + } + }); + + app.get('/extensions/operations/:operationId', (req, res) => { + const operationId = req.params['operationId']; + if (!operationId) { + res.status(400).json({ error: 'Missing extension operation id' }); + return; + } + const operation = primaryController.getOperation(operationId); + if (!operation) { + res.status(404).json({ + error: `Extension operation "${operationId}" not found`, + code: 'extension_operation_not_found', + }); + return; + } + res.status(200).json(operation); + }); + + app.put( + '/extensions/:extensionId/activation', + mutate({ strict: true }), + async (req, res) => { + const extensionId = parseExtensionId(req, res); + if (!extensionId) return; + const state = parseActivationState(req, res); + if (!state) return; + const manager = primaryController.createExtensionManager( + boundWorkspace, + true, + ); + sendOperation( + req, + res, + 'PUT /extensions/:extensionId/activation', + manager, + 'activation', + { name: extensionId }, + async (extensionManager, _signal, context) => { + const extension = extensionById(extensionManager, extensionId); + if (!extension) + throw new Error(`Extension "${extensionId}" not found`); + await context!.commit( + async (onCommitted) => + await extensionManager.setExtensionDefaultActivation( + extensionId, + state, + onCommitted, + ), + ); + return { + status: state === 'enabled' ? 'enabled' : 'disabled', + name: extension.name, + }; + }, + { + ...(workspaceRegistry + ? { refreshRuntimes: () => workspaceRegistry.list() } + : {}), + }, + ); + }, + ); + + app.post('/extensions/install', mutate({ strict: true }), (req, res) => { + const body = safeBody(req); + const source = body['source']; + const activation = body['activation']; + const ref = body['ref']; + const autoUpdate = body['autoUpdate']; + const allowPreRelease = body['allowPreRelease']; + const registry = body['registry']; + if (typeof source !== 'string' || !source) { + res.status(400).json({ error: 'Missing or invalid source' }); + return; + } + if (ref !== undefined && (typeof ref !== 'string' || !ref)) { + res.status(400).json({ error: '`ref` must be a non-empty string' }); + return; + } + if (typeof ref === 'string' && ref.startsWith('-')) { + res.status(400).json({ error: '`ref` must not start with "-"' }); + return; + } + if (autoUpdate !== undefined && typeof autoUpdate !== 'boolean') { + res.status(400).json({ error: '`autoUpdate` must be a boolean' }); + return; + } + if (allowPreRelease !== undefined && typeof allowPreRelease !== 'boolean') { + res.status(400).json({ error: '`allowPreRelease` must be a boolean' }); + return; + } + if (registry !== undefined && typeof registry !== 'string') { + res.status(400).json({ error: '`registry` must be a string' }); + return; + } + const registryUrl = + typeof registry === 'string' + ? parseExtensionRegistryUrl(registry, res) + : undefined; + if (registryUrl === null) return; + if (body['consent'] !== true) { + res.status(400).json({ + error: 'Extension installation requires explicit consent', + }); + return; + } + if (!validateExtensionSourceHost(source, res)) return; + if (!activation || typeof activation !== 'object') { + res.status(400).json({ error: 'Missing initial activation' }); + return; + } + const activationRecord = activation as Record; + let initialActivation: + | { scope: 'user' } + | { scope: 'workspace'; workspacePath: string }; + if (activationRecord['scope'] === 'user') { + initialActivation = { scope: 'user' }; + } else if ( + activationRecord['scope'] === 'workspace' && + typeof activationRecord['workspaceId'] === 'string' && + workspaceRegistry + ) { + const runtime = workspaceRegistry.getByWorkspaceId( + activationRecord['workspaceId'], + ); + if (!runtime) { + res.status(400).json({ + error: 'Unknown activation workspace', + code: 'workspace_mismatch', }); + return; } + if (!requireTrustedWorkspaceRuntime(runtime, res)) return; + initialActivation = { + scope: 'workspace', + workspacePath: runtime.workspaceCwd, + }; + } else { + res.status(400).json({ error: 'Invalid initial activation' }); + return; + } + const manager = primaryController.createExtensionManager( + boundWorkspace, + true, + ); + sendOperation( + req, + res, + 'POST /extensions/install', + manager, + 'install', + { source }, + async (extensionManager, _signal, context) => { + const prepared = await context!.prepare(async (signal) => { + const metadata = await parseInstallSource(source, { + networkPolicy: 'public', + }); + if ( + metadata.type !== 'git' && + metadata.type !== 'github-release' && + metadata.type !== 'npm' + ) { + throw new Error( + 'Only GitHub, Git, and npm extension installs are supported over the daemon endpoint.', + ); + } + if (!validateExtensionSourceMetadata(metadata)) { + throw new Error('`source` host is not allowed'); + } + if (metadata.type === 'npm' && ref !== undefined) { + throw new Error('--ref is not applicable for npm extensions.'); + } + if (metadata.type !== 'npm' && registryUrl !== undefined) { + throw new Error( + '--registry is only applicable for npm extensions.', + ); + } + if (metadata.type === 'npm' && registryUrl) { + metadata.registryUrl = registryUrl; + } + return await extensionManager.prepareExtensionInstall({ + installMetadata: { + ...metadata, + ...(typeof ref === 'string' ? { ref } : {}), + ...(typeof autoUpdate === 'boolean' ? { autoUpdate } : {}), + ...(typeof allowPreRelease === 'boolean' + ? { allowPreRelease } + : {}), + }, + requestConsent: () => Promise.resolve(), + cwd: boundWorkspace, + initialActivation, + signal, + }); + }); + try { + const committed = await context!.commit( + async (onCommitted) => + await extensionManager.commitPreparedExtension( + prepared, + onCommitted, + ), + ); + return { + status: 'installed', + source, + name: committed.identity.name, + version: committed.version, + }; + } finally { + await extensionManager.disposePreparedExtension(prepared); + } + }, + { + deadlineMs: EXTENSION_PREPARE_DEADLINE_MS, + ...(workspaceRegistry + ? { refreshRuntimes: () => workspaceRegistry.list() } + : {}), + }, + ); + }); + + app.post( + '/extensions/check-updates', + mutate({ strict: true }), + (req, res) => { + const manager = primaryController.createExtensionManager( + boundWorkspace, + true, + ); + sendOperation( + req, + res, + 'POST /extensions/check-updates', + manager, + 'check-updates', + {}, + async (extensionManager, signal, context) => { + const states: Record = Object.create(null); + await extensionManager.checkForAllExtensionUpdates( + (name, state) => { + states[name] = state; + }, + signal, + async (task) => await context!.prepare(async () => await task()), + ); + return { status: 'checked', states }; + }, + { + skipRefresh: true, + deadlineMs: EXTENSION_UPDATE_CHECK_DEADLINE_MS, + }, + ); }, ); app.post( - '/workspace/extensions/:name/update', + '/extensions/:extensionId/update', mutate({ strict: true }), - async (req, res) => { - try { - if ( - !validateExtensionMutationClient( - req, - res, - 'POST /workspace/extensions/:name/update', - ) - ) { - return; - } - const name = req.params['name']; - if (!name) { - res.status(400).json({ error: 'Missing extension name' }); - return; - } - runQueuedExtensionMutation( - 'update', - { name }, - res, - async (extensionManager) => { - const extension = findLoadedExtension(extensionManager, name); - if (!extension) { - throw new Error(`Extension "${name}" not found`); - } - let updateError: unknown; - const updateState = await withExtensionTimeout( - checkForExtensionUpdate(extension, extensionManager).catch( - (err: unknown) => { - updateError = err; - return ExtensionUpdateState.ERROR; - }, - ), - EXTENSION_REFRESH_TIMEOUT_MS, - 'extension update check', + (req, res) => { + const extensionId = parseExtensionId(req, res); + if (!extensionId) return; + const manager = primaryController.createExtensionManager( + boundWorkspace, + true, + ); + sendOperation( + req, + res, + 'POST /extensions/:extensionId/update', + manager, + 'update', + { name: extensionId }, + async (extensionManager, _signal, context) => { + const extension = extensionById(extensionManager, extensionId); + if (!extension) + throw new Error(`Extension "${extensionId}" not found`); + if ( + extension.installMetadata?.type !== 'git' && + extension.installMetadata?.type !== 'archive-url' && + extension.installMetadata?.type !== 'github-release' && + extension.installMetadata?.type !== 'npm' + ) { + throw new Error( + `Extension "${extension.name}" is not remotely updatable.`, ); - if (updateState === ExtensionUpdateState.ERROR) { - const message = - updateError === undefined - ? undefined - : redactUrlCredentials( - updateError instanceof Error - ? updateError.message - : String(updateError), - ); - throw new Error( - `Update check failed for extension "${extension.name}"${ - message ? `: ${message}` : '' - }`, - ); - } - if (updateState !== ExtensionUpdateState.UPDATE_AVAILABLE) { - throw new Error(`Extension "${extension.name}" has no update`); - } - const info = await extensionManager.updateExtension( - extension, - updateState, - () => undefined, + } + const preparedResult = await context!.prepare( + async (signal) => + await extensionManager.prepareExtensionUpdate({ + extension, + signal, + }), + ); + if (preparedResult.upToDate) { + return { + status: 'checked', + name: extension.name, + updated: false, + reason: 'up_to_date', + }; + } + try { + const committed = await context!.commit( + async (onCommitted) => + await extensionManager.commitPreparedExtension( + preparedResult.prepared, + onCommitted, + ), ); return { status: 'updated', name: extension.name, - ...(info?.updatedVersion ? { version: info.updatedVersion } : {}), + updated: true, + version: committed.version, }; - }, - ); - } catch (err) { - sendBridgeError(res, err, { - route: 'POST /workspace/extensions/:name/update', - }); - } + } finally { + await extensionManager.disposePreparedExtension( + preparedResult.prepared, + ); + } + }, + { + deadlineMs: EXTENSION_PREPARE_DEADLINE_MS, + ...(workspaceRegistry + ? { refreshRuntimes: () => workspaceRegistry.list() } + : {}), + }, + ); }, ); app.delete( - '/workspace/extensions/:name', + '/extensions/:extensionId', mutate({ strict: true }), async (req, res) => { + const extensionId = parseExtensionId(req, res); + if (!extensionId) return; + const route = 'DELETE /extensions/:extensionId'; + if ( + !primaryController.validateExtensionMutationClient(req, res, { + requireClientId: false, + bridges: mutationClientBridges(), + }) + ) { + return; + } try { - if ( - !validateExtensionMutationClient( - req, - res, - 'DELETE /workspace/extensions/:name', - ) - ) { - return; - } - const name = req.params['name']; - if (!name) { - res.status(400).json({ error: 'Missing extension name' }); + const manager = primaryController.createExtensionManager( + boundWorkspace, + true, + ); + const snapshot = await manager.getExtensionStoreSnapshot(); + const policy = snapshot.extensions[extensionId]; + if (!policy) { + res.status(204).end(); return; } - runQueuedExtensionMutation( - 'uninstall', - { name }, + sendOperation( + req, res, - async (extensionManager) => { - const extension = findLoadedExtension(extensionManager, name); - if (!extension) { - throw new Error(`Extension "${name}" not found`); - } - await extensionManager.uninstallExtension( - extension.name, - false, - boundWorkspace, + route, + manager, + 'uninstall', + { name: policy.name }, + async (extensionManager, _signal, context) => { + await context!.commit( + async (onCommitted) => + await extensionManager.uninstallExtensionById( + extensionId, + false, + undefined, + onCommitted, + ), ); - return { status: 'uninstalled', name: extension.name }; + return { status: 'uninstalled', name: policy.name }; + }, + { + ...(workspaceRegistry + ? { refreshRuntimes: () => workspaceRegistry.list() } + : {}), }, ); - } catch (err) { - sendBridgeError(res, err, { - route: 'DELETE /workspace/extensions/:name', - }); + } catch (error) { + sendBridgeError(res, error, { route }); } }, ); + + if (workspaceRegistry) { + const registry = workspaceRegistry; + app.get('/workspaces/:workspace/extensions', async (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res); + if (!runtime) return; + try { + const manager = primaryController.createExtensionManager( + runtime.workspaceCwd, + runtime.trusted, + ); + const snapshot = await manager.refreshCacheWithSnapshot(); + const extensions = manager.getLoadedExtensions().map((extension) => { + const activation = manager.getExtensionActivationFromSnapshot( + extension.id, + snapshot, + runtime.workspaceCwd, + ); + return { + extensionId: extension.id, + name: extension.name, + version: extension.version, + defaultActivation: activation.default, + workspaceActivation: + activation.workspace === 'inherit' ? null : activation.workspace, + effectiveActivation: activation.effective, + activationSource: activation.source, + }; + }); + res.status(200).json({ + v: 1, + workspaceId: runtime.workspaceId, + workspaceCwd: runtime.workspaceCwd, + trusted: runtime.trusted, + desiredGeneration: snapshot.generation, + appliedGeneration: + appliedGenerationByWorkspaceId.get(runtime.workspaceId) ?? 0, + extensions, + }); + } catch (error) { + sendBridgeError(res, error, { + route: 'GET /workspaces/:workspace/extensions', + }); + } + }); + + app.put( + '/workspaces/:workspace/extensions/:extensionId/activation', + mutate({ strict: true }), + (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const extensionId = parseExtensionId(req, res); + if (!extensionId) return; + const state = parseActivationState(req, res); + if (!state) return; + const manager = primaryController.createExtensionManager( + runtime.workspaceCwd, + true, + ); + sendOperation( + req, + res, + 'PUT /workspaces/:workspace/extensions/:extensionId/activation', + manager, + 'activation', + { name: extensionId }, + async (extensionManager, _signal, context) => { + const extension = extensionById(extensionManager, extensionId); + if (!extension) { + throw new Error(`Extension "${extensionId}" not found`); + } + await context!.commit( + async (onCommitted) => + await extensionManager.setExtensionWorkspaceActivation( + extensionId, + runtime.workspaceCwd, + state, + onCommitted, + ), + ); + return { + status: state === 'enabled' ? 'enabled' : 'disabled', + name: extension.name, + }; + }, + { refreshRuntimes: [runtime] }, + ); + }, + ); + + app.delete( + '/workspaces/:workspace/extensions/:extensionId/activation', + mutate({ strict: true }), + (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const extensionId = parseExtensionId(req, res); + if (!extensionId) return; + const manager = primaryController.createExtensionManager( + runtime.workspaceCwd, + true, + ); + sendOperation( + req, + res, + 'DELETE /workspaces/:workspace/extensions/:extensionId/activation', + manager, + 'activation', + { name: extensionId }, + async (extensionManager, _signal, context) => { + const extension = extensionById(extensionManager, extensionId); + if (!extension) { + throw new Error(`Extension "${extensionId}" not found`); + } + const snapshot = await context!.commit( + async (onCommitted) => + await extensionManager.clearExtensionWorkspaceActivation( + extensionId, + runtime.workspaceCwd, + onCommitted, + ), + ); + const activation = + extensionManager.getExtensionActivationFromSnapshot( + extensionId, + snapshot, + runtime.workspaceCwd, + ); + return { status: activation.effective, name: extension.name }; + }, + { refreshRuntimes: [runtime] }, + ); + }, + ); + + app.post( + '/workspaces/:workspace/extensions/refresh', + mutate({ strict: true }), + (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res); + if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; + const manager = primaryController.createExtensionManager( + runtime.workspaceCwd, + true, + ); + sendOperation( + req, + res, + 'POST /workspaces/:workspace/extensions/refresh', + manager, + 'refresh', + {}, + async () => ({ status: 'refreshed' }), + { refreshRuntimes: [runtime] }, + ); + }, + ); + } } diff --git a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts new file mode 100644 index 00000000000..1f6776625fd --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts @@ -0,0 +1,1466 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as os from 'node:os'; +import * as path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { + ExtensionManager, + hashDaemonWorkspace, + type Extension, + type ExtensionStoreSnapshot, +} from '@qwen-code/qwen-code-core'; +import { createServeApp } from '../server.js'; +import { ClientMcpSenderRegistry } from '../acp-http/client-mcp-sender-registry.js'; +import { + canonicalizeWorkspace, + createWorkspaceFileSystemFactory, +} from '../fs/index.js'; +import type { ServeOptions } from '../types.js'; +import { + createWorkspaceRegistry, + type WorkspaceRuntime, +} from '../workspace-registry.js'; +import type { AcpSessionBridge } from '../acp-session-bridge.js'; +import type { DaemonWorkspaceService } from '../workspace-service/types.js'; + +const extensionId = 'a'.repeat(64); +const baseOpts: ServeOptions = { + hostname: '127.0.0.1', + port: 4198, + mode: 'http-bridge', +}; +const activeApps = new Set>(); + +function host(): string { + return `127.0.0.1:${baseOpts.port}`; +} + +function makeBridge(): AcpSessionBridge { + return { + permissionPolicy: 'first-responder', + knownClientIds: () => new Set(['client-1']), + publishWorkspaceEvent: vi.fn(), + refreshExtensionsForAllSessions: vi.fn(async () => ({ + refreshed: 1, + failed: 0, + })), + broadcastExtensionsChanged: vi.fn(), + getDaemonStatusSnapshot: vi.fn(() => ({ + limits: { + maxSessions: 20, + maxPendingPromptsPerSession: 5, + eventRingSize: 8000, + compactedReplayMaxBytes: 4 * 1024 * 1024, + channelIdleTimeoutMs: 0, + sessionIdleTimeoutMs: 1_800_000, + }, + sessionCount: 0, + pendingPermissionCount: 0, + channelLive: false, + permissionPolicy: 'first-responder', + sessions: [], + })), + listWorkspaceSessions: vi.fn(() => []), + getSessionSummary: vi.fn(() => { + throw new Error('not found'); + }), + sessionCount: 0, + activePromptCount: 0, + pendingPromptTotal: 0, + lastActivityAt: null, + } as unknown as AcpSessionBridge; +} + +function makeWorkspaceService(): DaemonWorkspaceService { + return { + invalidateWorkspaceSkillsStatus: vi.fn(), + refreshExtensionsForAllSessions: vi.fn(async () => ({ + refreshed: 1, + failed: 0, + })), + } as unknown as DaemonWorkspaceService; +} + +function makeRuntime( + workspaceCwd: string, + opts: { primary: boolean; trusted: boolean; workspaceId: string }, +): WorkspaceRuntime { + return { + workspaceId: opts.workspaceId, + workspaceCwd, + primary: opts.primary, + trusted: opts.trusted, + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: makeBridge(), + workspaceService: makeWorkspaceService(), + routeFileSystemFactory: createWorkspaceFileSystemFactory({ + boundWorkspaces: [workspaceCwd], + trusted: opts.trusted, + emit: () => {}, + }), + clientMcpSenderRegistry: new ClientMcpSenderRegistry(), + }; +} + +async function makeHarness(opts?: { + secondaryTrusted?: boolean; + singleWorkspace?: boolean; +}) { + const scratch = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-extension-management-v2-'), + ); + const primaryCwd = path.join(scratch, 'primary'); + const secondaryCwd = path.join(scratch, 'secondary'); + await fsp.mkdir(primaryCwd, { recursive: true }); + await fsp.mkdir(secondaryCwd, { recursive: true }); + const canonicalPrimary = canonicalizeWorkspace(primaryCwd); + const canonicalSecondary = canonicalizeWorkspace(secondaryCwd); + const primary = makeRuntime(canonicalPrimary, { + primary: true, + trusted: true, + workspaceId: 'primary-id', + }); + const secondary = makeRuntime(canonicalSecondary, { + primary: false, + trusted: opts?.secondaryTrusted ?? true, + workspaceId: hashDaemonWorkspace(canonicalSecondary), + }); + const registry = createWorkspaceRegistry( + opts?.singleWorkspace ? [primary] : [primary, secondary], + ); + const app = createServeApp( + { ...baseOpts, workspace: canonicalPrimary, token: 'secret' }, + undefined, + { + workspaceRegistry: registry, + }, + ); + activeApps.add(app); + return { app, scratch, primary, secondary, registry }; +} + +function auth(pending: request.Test): request.Test { + return pending + .set('Host', host()) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1'); +} + +function mockExtensionManager( + installType: 'archive-url' | 'local' = 'archive-url', +): Extension { + const extension = { + id: extensionId, + name: 'demo', + version: '1.0.0', + path: '/extensions/demo', + isActive: true, + config: { name: 'demo', version: '1.0.0' }, + installMetadata: { + type: installType, + source: + installType === 'archive-url' + ? 'https://example.com/demo.zip' + : '/extensions/demo.zip', + }, + contextFiles: [], + } as Extension; + const snapshot: ExtensionStoreSnapshot = { + version: 2, + generation: 7, + legacyProjectionHash: 'hash', + extensions: { + [extensionId]: { + name: 'demo', + defaultActivation: 'disabled', + workspaceOverrides: {}, + }, + }, + }; + vi.spyOn(ExtensionManager.prototype, 'refreshCache').mockResolvedValue(); + vi.spyOn( + ExtensionManager.prototype, + 'refreshCacheWithSnapshot', + ).mockResolvedValue(snapshot); + vi.spyOn(ExtensionManager.prototype, 'getLoadedExtensions').mockReturnValue([ + extension, + ]); + vi.spyOn( + ExtensionManager.prototype, + 'getExtensionStoreSnapshot', + ).mockResolvedValue(snapshot); + vi.spyOn( + ExtensionManager.prototype, + 'getExtensionActivation', + ).mockResolvedValue({ + default: 'disabled', + workspace: 'inherit', + effective: 'disabled', + source: 'default', + }); + vi.spyOn( + ExtensionManager.prototype, + 'getExtensionActivationFromSnapshot', + ).mockReturnValue({ + default: 'disabled', + workspace: 'inherit', + effective: 'disabled', + source: 'default', + }); + vi.spyOn( + ExtensionManager.prototype, + 'setExtensionDefaultActivation', + ).mockResolvedValue(snapshot); + vi.spyOn( + ExtensionManager.prototype, + 'setExtensionWorkspaceActivation', + ).mockResolvedValue(snapshot); + vi.spyOn( + ExtensionManager.prototype, + 'clearExtensionWorkspaceActivation', + ).mockResolvedValue(snapshot); + vi.spyOn( + ExtensionManager.prototype, + 'uninstallExtensionById', + ).mockResolvedValue(snapshot); + return extension; +} + +async function pollOperation( + app: ReturnType, + operationId: string, + operationBasePath = '/extensions/operations', +) { + for (let i = 0; i < 100; i++) { + const response = await auth( + request(app).get( + `${operationBasePath}/${encodeURIComponent(operationId)}`, + ), + ); + if ( + response.status === 200 && + response.body.status !== 'queued' && + response.body.status !== 'running' + ) { + return response.body; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`operation ${operationId} did not settle`); +} + +describe('extension management v2 REST', () => { + afterEach(() => { + for (const app of activeApps) { + ( + app.locals as { stopExtensionGenerationReconciler?: () => void } + ).stopExtensionGenerationReconciler?.(); + } + activeApps.clear(); + vi.restoreAllMocks(); + }); + + it('advertises extension_management_v2 but not the abandoned capability', async () => { + const h = await makeHarness({ singleWorkspace: true }); + try { + const response = await auth(request(h.app).get('/capabilities')); + expect(response.status).toBe(200); + expect(response.body.features).toContain('extension_management_v2'); + expect(response.body.features).not.toContain( + 'workspace_qualified_extensions', + ); + expect(response.body.workspaces).toEqual([ + expect.objectContaining({ + id: h.primary.workspaceId, + cwd: h.primary.workspaceCwd, + primary: true, + }), + ]); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('returns a global catalog with generation and default activation', async () => { + const h = await makeHarness(); + mockExtensionManager(); + try { + const response = await auth(request(h.app).get('/extensions')); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + v: 1, + generation: 7, + extensions: [ + { + id: extensionId, + name: 'demo', + installType: 'archive-url', + defaultActivation: 'disabled', + workspaceOverrideCount: 0, + }, + ], + }); + expect( + ExtensionManager.prototype.refreshCacheWithSnapshot, + ).toHaveBeenCalledOnce(); + expect( + ExtensionManager.prototype.getExtensionStoreSnapshot, + ).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('stops request parsing after rejecting an invalid extension id', async () => { + const h = await makeHarness(); + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + try { + const globalResponse = await auth( + request(h.app) + .put('/extensions/not-an-extension-id/activation') + .send({ state: 'invalid' }), + ); + const workspaceResponse = await auth( + request(h.app) + .put( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/not-an-extension-id/activation`, + ) + .send({ state: 'invalid' }), + ); + + expect(globalResponse.body).toMatchObject({ + code: 'invalid_extension_id', + }); + expect(workspaceResponse.body).toMatchObject({ + code: 'invalid_extension_id', + }); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining('Cannot set headers after they are sent'), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('returns the selected workspace projection, including when untrusted', async () => { + const h = await makeHarness({ secondaryTrusted: false }); + mockExtensionManager(); + try { + const response = await auth( + request(h.app).get( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions`, + ), + ); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + v: 1, + workspaceId: h.secondary.workspaceId, + workspaceCwd: h.secondary.workspaceCwd, + desiredGeneration: 7, + extensions: [ + { + extensionId, + defaultActivation: 'disabled', + workspaceActivation: null, + effectiveActivation: 'disabled', + activationSource: 'default', + }, + ], + }); + expect( + ExtensionManager.prototype.getExtensionActivationFromSnapshot, + ).toHaveBeenCalledWith( + extensionId, + expect.objectContaining({ generation: 7 }), + h.secondary.workspaceCwd, + ); + expect( + ExtensionManager.prototype.getExtensionActivation, + ).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('changes only the target workspace activation and refreshes its runtime', async () => { + const h = await makeHarness(); + mockExtensionManager(); + try { + const started = await auth( + request(h.app) + .put( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/${extensionId}/activation`, + ) + .send({ state: 'enabled' }), + ); + expect(started.status).toBe(202); + expect(started.headers['location']).toBe( + `/extensions/operations/${started.body.operationId}`, + ); + const operation = await pollOperation(h.app, started.body.operationId); + expect(operation.status).toBe('succeeded'); + expect( + ExtensionManager.prototype.setExtensionWorkspaceActivation, + ).toHaveBeenCalledWith( + extensionId, + h.secondary.workspaceCwd, + 'enabled', + expect.any(Function), + ); + expect( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + expect( + h.primary.bridge.refreshExtensionsForAllSessions, + ).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('returns the effective activation after clearing a workspace override', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.mocked( + ExtensionManager.prototype.getExtensionActivation, + ).mockResolvedValue({ + default: 'enabled', + workspace: 'inherit', + effective: 'enabled', + source: 'default', + }); + try { + const started = await auth( + request(h.app).delete( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/${extensionId}/activation`, + ), + ); + + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'succeeded', + result: { status: 'disabled', name: 'demo' }, + }); + expect( + ExtensionManager.prototype.getExtensionActivation, + ).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('reports a post-commit failure as succeeded with warnings', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + vi.mocked( + h.secondary.workspaceService.invalidateWorkspaceSkillsStatus, + ).mockImplementationOnce(() => { + throw new Error('status invalidation failed'); + }); + try { + const started = await auth( + request(h.app).delete( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/${extensionId}/activation`, + ), + ); + + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'succeeded_with_warnings', + warnings: [ + expect.objectContaining({ + error: expect.stringMatching(/status invalidation failed/), + workspaceId: h.secondary.workspaceId, + }), + ], + }); + expect( + h.primary.workspaceService.invalidateWorkspaceSkillsStatus, + ).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('includes the mutation status in post-commit failure broadcasts', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + vi.mocked( + h.secondary.workspaceService.invalidateWorkspaceSkillsStatus, + ).mockImplementationOnce(() => { + throw new Error('status invalidation failed'); + }); + try { + const started = await auth( + request(h.app).delete( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/${extensionId}/activation`, + ), + ); + + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'succeeded_with_warnings', + result: { status: 'disabled', name: 'demo' }, + }); + expect( + h.secondary.bridge.broadcastExtensionsChanged, + ).toHaveBeenCalledWith( + expect.objectContaining({ status: 'disabled', failed: 1 }), + ); + expect( + h.primary.bridge.broadcastExtensionsChanged, + ).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('keeps generation polling serialized while a runtime refresh is in flight', async () => { + vi.useFakeTimers(); + const h = await makeHarness(); + mockExtensionManager(); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + let releaseRefresh = () => {}; + const refreshGate = new Promise((resolve) => { + releaseRefresh = resolve; + }); + vi.mocked(h.secondary.bridge.refreshExtensionsForAllSessions) + .mockImplementationOnce(async () => { + await refreshGate; + return { refreshed: 1, failed: 0 }; + }) + .mockResolvedValue({ refreshed: 1, failed: 0 }); + try { + await vi.advanceTimersByTimeAsync(30_000); + expect( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(90_000); + + expect( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + expect( + h.primary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + + releaseRefresh(); + await vi.advanceTimersByTimeAsync(30_000); + + expect( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + } finally { + releaseRefresh(); + vi.useRealTimers(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('retries generation reconciliation after a runtime refresh fails', async () => { + vi.useFakeTimers(); + const h = await makeHarness(); + mockExtensionManager(); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + vi.mocked(h.secondary.bridge.refreshExtensionsForAllSessions) + .mockRejectedValueOnce(new Error('refresh failed')) + .mockResolvedValue({ refreshed: 1, failed: 0 }); + try { + await vi.advanceTimersByTimeAsync(30_000); + expect( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(30_000); + + expect( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('reconciles runtimes when the authoritative generation rolls back', async () => { + vi.useFakeTimers(); + const h = await makeHarness(); + mockExtensionManager(); + const rolledBackSnapshot: ExtensionStoreSnapshot = { + version: 2, + generation: 6, + legacyProjectionHash: 'rolled-back-hash', + extensions: { + [extensionId]: { + name: 'demo', + defaultActivation: 'disabled', + workspaceOverrides: {}, + }, + }, + }; + try { + await vi.advanceTimersByTimeAsync(30_000); + expect( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + + vi.mocked( + ExtensionManager.prototype.getExtensionStoreSnapshot, + ).mockResolvedValue(rolledBackSnapshot); + vi.mocked( + ExtensionManager.prototype.refreshCacheWithSnapshot, + ).mockResolvedValue(rolledBackSnapshot); + await vi.advanceTimersByTimeAsync(30_000); + + expect( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledTimes(2); + const projection = await auth( + request(h.app).get( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions`, + ), + ); + expect(projection.body).toMatchObject({ + desiredGeneration: 6, + appliedGeneration: 6, + }); + } finally { + vi.useRealTimers(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('reconciles a runtime added after the generation stabilizes', async () => { + vi.useFakeTimers(); + const h = await makeHarness(); + mockExtensionManager(); + try { + await vi.advanceTimersByTimeAsync(30_000); + + const lateCwd = path.join(h.scratch, 'late-stable'); + await fsp.mkdir(lateCwd, { recursive: true }); + const late = makeRuntime(canonicalizeWorkspace(lateCwd), { + primary: false, + trusted: true, + workspaceId: 'late-stable-id', + }); + h.registry.add(late); + + await vi.advanceTimersByTimeAsync(30_000); + + expect( + late.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + const projection = await auth( + request(h.app).get('/workspaces/late-stable-id/extensions'), + ); + expect(projection.body).toMatchObject({ + desiredGeneration: 7, + appliedGeneration: 7, + }); + } finally { + vi.useRealTimers(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('advances applied generation only after the workspace reconciles', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.mocked(h.secondary.bridge.refreshExtensionsForAllSessions) + .mockResolvedValueOnce({ refreshed: 0, failed: 1 }) + .mockResolvedValue({ refreshed: 1, failed: 0 }); + try { + const activation = await auth( + request(h.app) + .put( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/${extensionId}/activation`, + ) + .send({ state: 'enabled' }), + ); + expect(activation.status).toBe(202); + await expect( + pollOperation(h.app, activation.body.operationId), + ).resolves.toMatchObject({ status: 'succeeded_with_warnings' }); + + const drifted = await auth( + request(h.app).get( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions`, + ), + ); + expect(drifted.body).toMatchObject({ + desiredGeneration: 7, + appliedGeneration: 0, + }); + + const refresh = await auth( + request(h.app).post( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/refresh`, + ), + ); + expect(refresh.status).toBe(202); + await expect( + pollOperation(h.app, refresh.body.operationId), + ).resolves.toMatchObject({ status: 'succeeded' }); + + const converged = await auth( + request(h.app).get( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions`, + ), + ); + expect(converged.body).toMatchObject({ + desiredGeneration: 7, + appliedGeneration: 7, + }); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('serializes runtime reconciliation in generation order', async () => { + const h = await makeHarness(); + mockExtensionManager(); + const snapshot = (generation: number): ExtensionStoreSnapshot => ({ + version: 2, + generation, + legacyProjectionHash: 'hash', + extensions: { + [extensionId]: { + name: 'demo', + defaultActivation: 'disabled', + workspaceOverrides: {}, + }, + }, + }); + let releaseFirstCommit: (() => void) | undefined; + vi.mocked(ExtensionManager.prototype.setExtensionWorkspaceActivation) + .mockImplementationOnce( + async (_id, _workspace, _activation, committed) => { + committed?.(8); + await new Promise((resolve) => { + releaseFirstCommit = resolve; + }); + return snapshot(8); + }, + ) + .mockImplementationOnce( + async (_id, _workspace, _activation, committed) => { + committed?.(9); + return snapshot(9); + }, + ); + vi.mocked( + ExtensionManager.prototype.getExtensionStoreSnapshot, + ).mockResolvedValue(snapshot(9)); + vi.mocked( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).mockResolvedValue({ refreshed: 1, failed: 0 }); + try { + const first = await auth( + request(h.app) + .put( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/${extensionId}/activation`, + ) + .send({ state: 'enabled' }), + ); + await vi.waitFor(() => expect(releaseFirstCommit).toBeDefined()); + + const second = await auth( + request(h.app) + .put( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/${extensionId}/activation`, + ) + .send({ state: 'disabled' }), + ); + await vi.waitFor(async () => { + const operation = await auth( + request(h.app).get( + `/extensions/operations/${second.body.operationId}`, + ), + ); + expect(operation.body).toMatchObject({ + status: 'running', + phase: 'reconciling', + }); + }); + expect( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).not.toHaveBeenCalled(); + + releaseFirstCommit?.(); + await expect( + pollOperation(h.app, second.body.operationId), + ).resolves.toMatchObject({ status: 'succeeded' }); + await expect( + pollOperation(h.app, first.body.operationId), + ).resolves.toMatchObject({ status: 'succeeded' }); + expect( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledTimes(2); + const projection = await auth( + request(h.app).get( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions`, + ), + ); + expect(projection.body.appliedGeneration).toBe(9); + } finally { + releaseFirstCommit?.(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('fans a global default change out to every registered workspace', async () => { + const h = await makeHarness(); + mockExtensionManager(); + try { + const started = await auth( + request(h.app) + .put(`/extensions/${extensionId}/activation`) + .send({ state: 'disabled' }), + ); + expect(started.status).toBe(202); + const operation = await pollOperation(h.app, started.body.operationId); + expect(operation.status).toBe('succeeded'); + expect( + h.primary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + expect( + h.secondary.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('includes runtimes registered while a global mutation is committing', async () => { + const h = await makeHarness(); + mockExtensionManager(); + let commitStarted = false; + let releaseCommit = () => {}; + const commitGate = new Promise((resolve) => { + releaseCommit = resolve; + }); + vi.mocked( + ExtensionManager.prototype.setExtensionDefaultActivation, + ).mockImplementation(async () => { + commitStarted = true; + await commitGate; + return { + version: 2, + generation: 7, + legacyProjectionHash: 'hash', + extensions: { + [extensionId]: { + name: 'demo', + defaultActivation: 'disabled', + workspaceOverrides: {}, + }, + }, + }; + }); + try { + const started = await auth( + request(h.app) + .put(`/extensions/${extensionId}/activation`) + .send({ state: 'disabled' }), + ); + expect(started.status).toBe(202); + await vi.waitFor(() => expect(commitStarted).toBe(true)); + + const lateCwd = path.join(h.scratch, 'late'); + await fsp.mkdir(lateCwd, { recursive: true }); + const late = makeRuntime(canonicalizeWorkspace(lateCwd), { + primary: false, + trusted: true, + workspaceId: 'late-id', + }); + h.registry.add(late); + releaseCommit(); + + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ status: 'succeeded' }); + expect( + late.bridge.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + const projection = await auth( + request(h.app).get('/workspaces/late-id/extensions'), + ); + expect(projection.body).toMatchObject({ + desiredGeneration: 7, + appliedGeneration: 7, + }); + } finally { + releaseCommit(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('validates mutation clients against the targeted runtime set', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.spyOn(h.primary.bridge, 'knownClientIds').mockReturnValue( + new Set(['primary-client']), + ); + vi.spyOn(h.secondary.bridge, 'knownClientIds').mockReturnValue( + new Set(['secondary-client']), + ); + const secondaryAuth = (pending: request.Test) => + pending + .set('Host', host()) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'secondary-client'); + try { + const wrongRuntime = await request(h.app) + .put( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/${extensionId}/activation`, + ) + .set('Host', host()) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'primary-client') + .send({ state: 'enabled' }); + expect(wrongRuntime.status).toBe(400); + expect(wrongRuntime.body).toMatchObject({ code: 'invalid_client_id' }); + + const targeted = await secondaryAuth( + request(h.app) + .put( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/${extensionId}/activation`, + ) + .send({ state: 'enabled' }), + ); + expect(targeted.status).toBe(202); + await expect( + pollOperation(h.app, targeted.body.operationId), + ).resolves.toMatchObject({ status: 'succeeded' }); + + const global = await secondaryAuth( + request(h.app) + .put(`/extensions/${extensionId}/activation`) + .send({ state: 'disabled' }), + ); + expect(global.status).toBe(202); + await expect( + pollOperation(h.app, global.body.operationId), + ).resolves.toMatchObject({ status: 'succeeded' }); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('allows bearer-authenticated global install without a workspace client id', async () => { + const h = await makeHarness(); + mockExtensionManager(); + const prepareInstall = vi + .spyOn(ExtensionManager.prototype, 'prepareExtensionInstall') + .mockResolvedValue({} as never); + vi.spyOn( + ExtensionManager.prototype, + 'commitPreparedExtension', + ).mockResolvedValue({ + identity: { id: extensionId, name: 'demo' }, + version: '1.0.0', + generation: 7, + } as never); + vi.spyOn( + ExtensionManager.prototype, + 'disposePreparedExtension', + ).mockResolvedValue(); + try { + const started = await request(h.app) + .post('/extensions/install') + .set('Host', host()) + .set('Authorization', 'Bearer secret') + .send({ + source: '@scope/demo:plugin', + consent: true, + activation: { scope: 'user' }, + }); + + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'succeeded', + result: { status: 'installed', name: 'demo' }, + }); + expect(prepareInstall).toHaveBeenCalledWith( + expect.objectContaining({ + installMetadata: expect.objectContaining({ + source: '@scope/demo', + type: 'npm', + pluginName: 'plugin', + }), + }), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('preserves prototype-named extension update states', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.spyOn( + ExtensionManager.prototype, + 'checkForAllExtensionUpdates', + ).mockImplementation(async (onResult) => { + onResult('__proto__', 'update available' as never); + }); + try { + const legacy = await auth( + request(h.app).post('/workspace/extensions/check-updates'), + ); + expect(legacy.status).toBe(200); + expect(Object.hasOwn(legacy.body.states, '__proto__')).toBe(true); + expect(legacy.body.states['__proto__']).toBe('update available'); + + const started = await auth( + request(h.app).post('/extensions/check-updates'), + ); + expect(started.status).toBe(202); + const operation = await pollOperation(h.app, started.body.operationId); + expect(operation.status).toBe('succeeded'); + expect(Object.hasOwn(operation.result.states, '__proto__')).toBe(true); + expect(operation.result.states['__proto__']).toBe('update available'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('times out a legacy update check while its cache refresh stalls', async () => { + vi.useFakeTimers(); + const h = await makeHarness(); + mockExtensionManager(); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const stalledRefresh = new Promise(() => {}); + const refreshCache = vi + .spyOn(ExtensionManager.prototype, 'refreshCache') + .mockImplementationOnce(async () => await stalledRefresh) + .mockResolvedValue(undefined); + try { + const response = auth( + request(h.app).post('/workspace/extensions/check-updates'), + ).then((result) => result); + await vi.waitFor(() => expect(refreshCache).toHaveBeenCalledOnce()); + + await vi.advanceTimersByTimeAsync(2 * 60_000); + + await expect(response).resolves.toMatchObject({ + status: 500, + body: { code: 'extension_prepare_timeout' }, + }); + const next = await auth( + request(h.app).post('/workspace/extensions/check-updates'), + ); + expect(next.status).toBe(200); + } finally { + vi.useRealTimers(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('reports legacy global mutations as applied after runtime reconciliation', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.spyOn( + ExtensionManager.prototype, + 'prepareExtensionInstall', + ).mockResolvedValue({} as never); + vi.spyOn( + ExtensionManager.prototype, + 'commitPreparedExtension', + ).mockResolvedValue({ + identity: { id: extensionId, name: 'demo' }, + version: '1.0.0', + generation: 7, + } as never); + vi.spyOn( + ExtensionManager.prototype, + 'disposePreparedExtension', + ).mockResolvedValue(); + try { + const started = await auth( + request(h.app) + .post('/workspace/extensions/install') + .send({ source: '@scope/demo', consent: true }), + ); + + expect(started.status).toBe(202); + await expect( + pollOperation( + h.app, + started.body.operationId, + '/workspace/extensions/operations', + ), + ).resolves.toMatchObject({ status: 'succeeded' }); + + const projection = await auth( + request(h.app).get( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions`, + ), + ); + expect(projection.body).toMatchObject({ + desiredGeneration: 7, + appliedGeneration: 7, + }); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('reports legacy workspace activation mutations as applied immediately', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.spyOn(ExtensionManager.prototype, 'enableExtension').mockResolvedValue({ + generation: 7, + } as never); + vi.spyOn(ExtensionManager.prototype, 'disableExtension').mockResolvedValue({ + generation: 8, + } as never); + try { + const enable = await auth( + request(h.app) + .post('/workspace/extensions/demo/enable') + .send({ scope: 'workspace' }), + ); + expect(enable.status).toBe(202); + await expect( + pollOperation( + h.app, + enable.body.operationId, + '/workspace/extensions/operations', + ), + ).resolves.toMatchObject({ status: 'succeeded' }); + + const enabledProjection = await auth( + request(h.app).get( + `/workspaces/${encodeURIComponent(h.primary.workspaceId)}/extensions`, + ), + ); + expect(enabledProjection.body).toMatchObject({ + desiredGeneration: 7, + appliedGeneration: 7, + }); + + vi.mocked( + ExtensionManager.prototype.getExtensionStoreSnapshot, + ).mockResolvedValue({ + version: 2, + generation: 8, + legacyProjectionHash: 'hash', + extensions: {}, + }); + vi.mocked( + ExtensionManager.prototype.refreshCacheWithSnapshot, + ).mockResolvedValue({ + version: 2, + generation: 8, + legacyProjectionHash: 'hash', + extensions: {}, + }); + const disable = await auth( + request(h.app) + .post('/workspace/extensions/demo/disable') + .send({ scope: 'workspace' }), + ); + expect(disable.status).toBe(202); + await expect( + pollOperation( + h.app, + disable.body.operationId, + '/workspace/extensions/operations', + ), + ).resolves.toMatchObject({ status: 'succeeded' }); + + const disabledProjection = await auth( + request(h.app).get( + `/workspaces/${encodeURIComponent(h.primary.workspaceId)}/extensions`, + ), + ); + expect(disabledProjection.body).toMatchObject({ + desiredGeneration: 8, + appliedGeneration: 8, + }); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('updates archive URL extensions through the global V2 route', async () => { + const h = await makeHarness(); + mockExtensionManager(); + const prepared = {} as never; + const prepareUpdate = vi + .spyOn(ExtensionManager.prototype, 'prepareExtensionUpdate') + .mockResolvedValue({ upToDate: false, prepared }); + const commitPrepared = vi + .spyOn(ExtensionManager.prototype, 'commitPreparedExtension') + .mockResolvedValue({ + identity: { id: extensionId, name: 'demo' }, + version: '2.0.0', + generation: 8, + } as never); + const disposePrepared = vi + .spyOn(ExtensionManager.prototype, 'disposePreparedExtension') + .mockResolvedValue(); + try { + const started = await auth( + request(h.app).post(`/extensions/${extensionId}/update`), + ); + + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'succeeded', + result: { + status: 'updated', + name: 'demo', + updated: true, + version: '2.0.0', + }, + }); + expect(prepareUpdate).toHaveBeenCalledWith({ + extension: expect.objectContaining({ + id: extensionId, + installMetadata: expect.objectContaining({ type: 'archive-url' }), + }), + signal: expect.any(AbortSignal), + }); + expect(commitPrepared).toHaveBeenCalledWith( + prepared, + expect.any(Function), + ); + expect(disposePrepared).toHaveBeenCalledWith(prepared); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('reports an up-to-date V2 update as checked without committing', async () => { + const h = await makeHarness(); + const extension = mockExtensionManager(); + const prepareUpdate = vi + .spyOn(ExtensionManager.prototype, 'prepareExtensionUpdate') + .mockResolvedValue({ upToDate: true, extension }); + const commitPrepared = vi.spyOn( + ExtensionManager.prototype, + 'commitPreparedExtension', + ); + try { + const started = await auth( + request(h.app).post(`/extensions/${extensionId}/update`), + ); + + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'succeeded', + result: { + status: 'checked', + name: 'demo', + updated: false, + reason: 'up_to_date', + }, + }); + expect(commitPrepared).not.toHaveBeenCalled(); + } finally { + prepareUpdate.mockRestore(); + commitPrepared.mockRestore(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('preserves structured update preparation error codes', async () => { + const h = await makeHarness(); + mockExtensionManager(); + const timeout = Object.assign( + new Error('preparation timed out\n\u001b[31mforged\u001b[0m'), + { + code: 'extension_prepare_timeout', + }, + ); + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + vi.spyOn( + ExtensionManager.prototype, + 'prepareExtensionUpdate', + ).mockRejectedValue(timeout); + try { + const started = await auth( + request(h.app).post('/workspace/extensions/demo/update'), + ); + + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'failed', + code: 'extension_prepare_timeout', + error: + 'Update check failed for extension "demo": preparation timed outforged', + }); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining('\nforged'), + ); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining('\u001b'), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('still rejects non-updatable extensions through the global V2 route', async () => { + const h = await makeHarness(); + mockExtensionManager('local'); + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const prepareUpdate = vi.spyOn( + ExtensionManager.prototype, + 'prepareExtensionUpdate', + ); + try { + const started = await auth( + request(h.app).post(`/extensions/${extensionId}/update`), + ); + + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'failed', + error: 'Extension "demo" is not remotely updatable.', + }); + expect(prepareUpdate).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('validates uninstall clients before reading extension state', async () => { + const h = await makeHarness(); + mockExtensionManager(); + try { + const response = await request(h.app) + .delete(`/extensions/${extensionId}`) + .set('Host', host()) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'invalid client id'); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ code: 'invalid_client_id' }); + expect( + ExtensionManager.prototype.getExtensionStoreSnapshot, + ).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('routes uninstall store lookup failures through the bridge error handler', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.mocked( + ExtensionManager.prototype.getExtensionStoreSnapshot, + ).mockRejectedValueOnce(new Error('extension lookup failed')); + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + try { + const response = await auth( + request(h.app).delete(`/extensions/${extensionId}`), + ); + + expect(response.status).toBe(500); + expect(stderr).toHaveBeenCalledWith( + expect.stringContaining( + 'bridge error (DELETE /extensions/:extensionId)', + ), + ); + expect(stderr).not.toHaveBeenCalledWith( + expect.stringContaining('unhandled error'), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('uninstalls by store identity when the extension is not loadable', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.mocked(ExtensionManager.prototype.getLoadedExtensions).mockReturnValue( + [], + ); + try { + const started = await auth( + request(h.app).delete(`/extensions/${extensionId}`), + ); + + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ + status: 'succeeded', + result: { status: 'uninstalled', name: 'demo' }, + }); + expect( + ExtensionManager.prototype.uninstallExtensionById, + ).toHaveBeenCalledWith( + extensionId, + false, + undefined, + expect.any(Function), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('rejects workspace activation on an untrusted target', async () => { + const h = await makeHarness({ secondaryTrusted: false }); + mockExtensionManager(); + try { + const response = await auth( + request(h.app) + .put( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/${extensionId}/activation`, + ) + .send({ state: 'enabled' }), + ); + expect(response.status).toBe(403); + expect(response.body.code).toBe('untrusted_workspace'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('does not expose workspace-qualified install/update/uninstall routes', async () => { + const h = await makeHarness(); + mockExtensionManager(); + try { + const response = await auth( + request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/install`, + ) + .send({ source: 'https://github.com/example/extension' }), + ); + expect(response.status).toBe(404); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index f818a2520e0..0ed2a0ea654 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -3954,6 +3954,56 @@ describe('runQwenServe runtime startup failures', () => { ); }); + it('stops the deferred runtime extension reconciler during close', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-reconciler-close-')), + ); + vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({ + enabled: false, + sensitiveSpanAttributeMaxLength: 1024 * 1024, + }); + const bridge = makeRuntimeBridge(); + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue( + bridge as ReturnType, + ); + const stopExtensionGenerationReconciler = vi.fn(); + vi.spyOn(serverModule, 'createServeApp').mockImplementation(() => { + const runtimeApp = express(); + runtimeApp.locals['stopExtensionGenerationReconciler'] = + stopExtensionGenerationReconciler; + return runtimeApp; + }); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { + resolveOnListen: true, + deferRuntimeUntilFirstHealth: true, + runtimeStartupTimeoutMs: 0, + }, + ); + + try { + const healthRes = await fetch(`${handle.url}/health`); + expect(healthRes.status).toBe(200); + await handle.runtimeReady; + } finally { + await handle.close(); + } + + expect(stopExtensionGenerationReconciler).toHaveBeenCalledOnce(); + expect( + stopExtensionGenerationReconciler.mock.invocationCallOrder[0], + ).toBeLessThan(vi.mocked(bridge.shutdown).mock.invocationCallOrder[0]!); + }); + it('does not cancel deferred runtime once startup is already running', async () => { tmpDir = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-close-running-')), @@ -4013,6 +4063,124 @@ describe('runQwenServe runtime startup failures', () => { ); }); + it('disposes a deferred runtime app that finishes after the shutdown wait', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-close-late-app-')), + ); + let resolveTelemetry: + | ((settings: qwenCore.ResolvedTelemetrySettings) => void) + | undefined; + const telemetryPromise = new Promise( + (resolve) => { + resolveTelemetry = resolve; + }, + ); + const resolveTelemetrySettings = vi + .spyOn(qwenCore, 'resolveTelemetrySettings') + .mockReturnValue(telemetryPromise); + const bridge = makeRuntimeBridge(); + vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue( + bridge as ReturnType, + ); + const stopExtensionGenerationReconciler = vi.fn(); + const stopScheduledTaskKeepalive = vi.fn(() => { + throw new Error('keepalive dispose failed'); + }); + const stopWorkspaceGitState = vi.fn(); + const stopSubSession = vi.fn(); + const disposeEventLoopMonitor = vi.fn(); + vi.spyOn(qwenCore, 'startEventLoopLagMonitor').mockReturnValueOnce({ + snapshot: () => ({ + meanMs: 0, + p50Ms: 0, + p99Ms: 0, + maxMs: 0, + }), + dispose: disposeEventLoopMonitor, + }); + vi.spyOn(serverModule, 'createServeApp').mockImplementation(() => { + const runtimeApp = express(); + runtimeApp.locals['stopExtensionGenerationReconciler'] = + stopExtensionGenerationReconciler; + runtimeApp.locals['stopScheduledTaskKeepalive'] = + stopScheduledTaskKeepalive; + runtimeApp.locals['stopWorkspaceGitState'] = stopWorkspaceGitState; + let subSessionStoppers: Array<() => void> = []; + Object.defineProperty(runtimeApp.locals, 'subSessionStoppers', { + configurable: true, + get: () => subSessionStoppers, + set: (stoppers: Array<() => void>) => { + stoppers.push(stopSubSession); + subSessionStoppers = stoppers; + }, + }); + return runtimeApp; + }); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { + resolveOnListen: true, + deferRuntimeUntilFirstHealth: true, + runtimeStartupTimeoutMs: 0, + }, + ); + + const healthRes = await fetch(`${handle.url}/health`); + expect(healthRes.status).toBe(200); + await vi.waitFor( + () => expect(resolveTelemetrySettings).toHaveBeenCalledTimes(1), + { timeout: 500 }, + ); + + const nativeSetTimeout = globalThis.setTimeout; + let acceleratedRuntimeWait = false; + const setTimeoutSpy = vi + .spyOn(globalThis, 'setTimeout') + .mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (!acceleratedRuntimeWait && delay === 5_000) { + acceleratedRuntimeWait = true; + return nativeSetTimeout(callback, 0, ...args); + } + return nativeSetTimeout(callback, delay, ...args); + }) as typeof setTimeout); + try { + await handle.close(); + } finally { + setTimeoutSpy.mockRestore(); + } + expect(stopExtensionGenerationReconciler).not.toHaveBeenCalled(); + + resolveTelemetry?.({ + enabled: false, + sensitiveSpanAttributeMaxLength: 1024 * 1024, + }); + + await vi.waitFor( + () => expect(stopExtensionGenerationReconciler).toHaveBeenCalledOnce(), + { timeout: 1_000 }, + ); + expect(stopScheduledTaskKeepalive).toHaveBeenCalledOnce(); + expect(stopWorkspaceGitState).toHaveBeenCalledOnce(); + expect(stopSubSession).toHaveBeenCalledOnce(); + expect(disposeEventLoopMonitor).toHaveBeenCalledOnce(); + expect(bridge.shutdown).toHaveBeenCalledOnce(); + await expect(handle.runtimeReady).rejects.toThrow( + 'Daemon runtime stopped before mounting.', + ); + }); + it('does not retry deferred runtime after startup failure and later health probe', async () => { tmpDir = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-fail-once-')), diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index df75b94691a..269a980cca3 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -2395,10 +2395,28 @@ export async function runQwenServe( }); void runtimeReady.catch(() => {}); const disposeDaemonEventLoopMonitor = (): void => { - daemonEventLoopMonitor?.dispose(); + const eventLoopMonitor = daemonEventLoopMonitor; daemonEventLoopMonitor = undefined; - daemonMetricsSampler?.dispose(); + const metricsSampler = daemonMetricsSampler; daemonMetricsSampler = undefined; + try { + eventLoopMonitor?.dispose(); + } catch (err) { + daemonLog.warn( + `event loop monitor dispose error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + try { + metricsSampler?.dispose(); + } catch (err) { + daemonLog.warn( + `metrics sampler dispose error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } }; let channelWorkerManager: ChannelWorkerManager | undefined; let channelWorkerManagerStarting: Promise | undefined; @@ -2564,6 +2582,99 @@ export async function runQwenServe( () => runtimeStartupError, ); const shutdownBridges = new WeakSet(); + const disposedRuntimeApps = new WeakSet(); + const stoppedRuntimeAppProducers = new WeakSet(); + const stoppedExtensionReconcilers = new WeakSet(); + const stopExtensionReconciler = (app: Application | undefined): void => { + if (!app || stoppedExtensionReconcilers.has(app)) return; + stoppedExtensionReconcilers.add(app); + const stopExtensionGenerationReconciler = app.locals?.[ + 'stopExtensionGenerationReconciler' + ] as (() => void) | undefined; + try { + stopExtensionGenerationReconciler?.(); + } catch (err) { + daemonLog.warn( + `extension generation reconciler dispose error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }; + const stopRuntimeAppProducers = (app: Application | undefined): void => { + if (!app || stoppedRuntimeAppProducers.has(app)) return; + stoppedRuntimeAppProducers.add(app); + const locals = app.locals as { + stopScheduledTaskKeepalive?: () => void; + stopWorkspaceGitState?: () => void; + subSessionStoppers?: Array<() => void>; + }; + const stopSafely = (name: string, stop: (() => void) | undefined) => { + try { + stop?.(); + } catch (err) { + daemonLog.warn( + `${name} dispose error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }; + stopSafely('scheduled-task keepalive', locals.stopScheduledTaskKeepalive); + stopSafely('workspace git state', locals.stopWorkspaceGitState); + for (const stop of locals.subSessionStoppers ?? []) { + stopSafely('sub-session launcher', stop); + } + stopExtensionReconciler(app); + }; + const disposeRuntimeAppResources = (app: Application | undefined): void => { + if (!app || disposedRuntimeApps.has(app)) return; + disposedRuntimeApps.add(app); + stopRuntimeAppProducers(app); + + // Cancel IdP polling before disposing transports that may share its HTTP + // agents. + const deviceFlowRegistry = getDeviceFlowRegistry(app); + if (deviceFlowRegistry) { + try { + deviceFlowRegistry.dispose(); + } catch (err) { + daemonLog.warn( + `device-flow registry dispose error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + const acpHandle = app.locals?.['acpHandle'] as AcpHttpHandle | undefined; + if (acpHandle?.dispose) { + try { + acpHandle.dispose(); + } catch (err) { + daemonLog.warn( + `ACP handle dispose error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + const rateLimiter = getRateLimiter(app); + if (rateLimiter) { + try { + rateLimiter.setDraining(true); + rateLimiter.dispose(); + } catch (err) { + daemonLog.warn( + `rate limiter dispose error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + disposeDaemonEventLoopMonitor(); + }; const getRuntimeBridgesForCleanup = (): AcpSessionBridge[] => { const appForCleanup = runtimeApp ?? runtimeAppForCleanup; const registry = appForCleanup?.locals?.['workspaceRegistry'] as @@ -4558,10 +4669,12 @@ export async function runQwenServe( ): Promise => { const error = err instanceof Error ? err : new Error(String(err)); if (runtimeStartupSettled) { + disposeRuntimeAppResources(runtimeApp ?? runtimeAppForCleanup); await shutdownBridgeAfterFailedStartup(bridgeForCleanup); return; } runtimeStartupSettled = true; + disposeRuntimeAppResources(runtimeApp ?? runtimeAppForCleanup); runtimeApp = undefined; clearRuntimeStartupTimer(); const message = error.message; @@ -4830,6 +4943,7 @@ export async function runQwenServe( runtimeStarting = buildRuntime() .then(async (runtime) => { if (runtimeStartupSettled) { + disposeRuntimeAppResources(runtime.app); await shutdownBridgeAfterFailedStartup(runtime.bridge); return; } @@ -5081,63 +5195,7 @@ export async function runQwenServe( if (workspaceManagementHandle !== initiallyMountedManagement) { await workspaceManagementHandle?.sealAndWait?.(); } - // Stop the scheduled-task keepalive only after workspace - // management has sealed and every accepted operation settled. - const stopScheduledTaskKeepalive = appForCleanup?.locals?.[ - 'stopScheduledTaskKeepalive' - ] as (() => void) | undefined; - stopScheduledTaskKeepalive?.(); - const stopWorkspaceGitState = appForCleanup?.locals?.[ - 'stopWorkspaceGitState' - ] as (() => void) | undefined; - stopWorkspaceGitState?.(); - const stoppers = appForCleanup?.locals?.[ - 'subSessionStoppers' - ] as Array<() => void> | undefined; - if (stoppers) { - for (const stop of stoppers) stop(); - } - // Dispose the device-flow registry FIRST so any - // in-flight IdP poll is cancelled and timers are cleared - // before the bridge tear-down (which would otherwise race - // with the still-polling registry on shared HTTP agents). - const deviceFlowRegistry = appForCleanup - ? getDeviceFlowRegistry(appForCleanup) - : undefined; - if (deviceFlowRegistry) { - try { - deviceFlowRegistry.dispose(); - } catch (err) { - daemonLog.warn( - `device-flow registry dispose error: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - } - // Dispose ACP handle (close WebSocketServer + send close frames). - const acpHandle = appForCleanup?.locals?.['acpHandle'] as - | AcpHttpHandle - | undefined; - if (acpHandle?.dispose) { - try { - acpHandle.dispose(); - } catch (err) { - daemonLog.warn( - `ACP handle dispose error: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - } - // Dispose rate limiter (clear GC timer + buckets). - const rl = appForCleanup - ? getRateLimiter(appForCleanup) - : undefined; - if (rl) { - rl.setDraining(true); - rl.dispose(); - } + disposeRuntimeAppResources(appForCleanup); disposeDaemonEventLoopMonitor(); // The worker owns daemon-backed sessions; disconnect it before // tearing down the ACP bridge it is attached to. diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 45e7b56d6d7..3b937acdeb7 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -56,6 +56,9 @@ import { Storage, TrustGateError, type Extension, + type CommittedExtensionMutation, + type PrepareExtensionInstallOptions, + type PreparedExtensionMutation, type SessionListItem, } from '@qwen-code/qwen-code-core'; import * as qwenCore from '@qwen-code/qwen-code-core'; @@ -319,6 +322,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'workspace_extensions', 'session_branch', 'workspace_qualified_rest_core', + 'extension_management_v2', 'workspace_persisted_transcript', // Baseline (always advertised) — presence means the `/voice/stream` // endpoint exists; the WS errors if no voice model is configured. @@ -365,6 +369,7 @@ const EXPECTED_REGISTERED_FEATURES = [ f !== 'workspace_extensions' && f !== 'session_branch' && f !== 'workspace_qualified_rest_core' && + f !== 'extension_management_v2' && f !== 'workspace_persisted_transcript' && f !== 'voice_transcribe', ), @@ -404,6 +409,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'persistent_workspace_registration', 'workspace_runtime_removal', 'workspace_qualified_rest_core', + 'extension_management_v2', 'workspace_persisted_transcript', 'workspace_qualified_acp', 'client_mcp_over_ws', @@ -2819,7 +2825,14 @@ describe('createServeApp', () => { 'persistent_workspace_registration', ); expect(before.body.features).not.toContain('multi_workspace_sessions'); - expect(before.body.workspaces).toBeUndefined(); + expect(before.body.workspaces).toEqual([ + { + id: 'primary-id', + cwd: WS_BOUND, + primary: true, + trusted: true, + }, + ]); registry.add( makeWorkspaceRuntimeForTest({ @@ -3644,16 +3657,47 @@ describe('createServeApp', () => { const mockExtensionManagerMethods = (overrides?: { refreshCache?: () => Promise; - installExtension?: () => Promise; + prepareExtensionInstall?: ( + options: PrepareExtensionInstallOptions, + ) => Promise; + prepareExtensionUpdate?: (extension: Extension) => Promise; + commitPreparedExtension?: ( + prepared: PreparedExtensionMutation, + ) => Promise; + disposePreparedExtension?: ( + prepared: PreparedExtensionMutation, + ) => Promise; getLoadedExtensions?: () => Extension[]; - enableExtension?: () => Promise; - disableExtension?: () => Promise; - uninstallExtension?: () => Promise; + enableExtension?: () => Promise; + disableExtension?: () => Promise; + uninstallExtension?: () => Promise; checkForAllExtensionUpdates?: ( cb: (name: string, state: ExtensionUpdateState) => void, + signal?: AbortSignal, + schedule?: (task: () => Promise) => Promise, ) => Promise; updateExtension?: () => Promise<{ updatedVersion?: string } | undefined>; }) => { + const preparedExtensions = new Map< + PreparedExtensionMutation, + Extension + >(); + let generation = 0; + const createPrepared = ( + extension: Extension, + operation: 'install' | 'update', + ): PreparedExtensionMutation => { + const prepared = { + operation, + identity: { + id: extension.id ?? 'a'.repeat(64), + name: extension.name, + }, + version: extension.version ?? extension.config.version ?? '1.0.0', + } as PreparedExtensionMutation; + preparedExtensions.set(prepared, extension); + return prepared; + }; const spies = [ vi .spyOn(ExtensionManager.prototype, 'refreshCache') @@ -3661,10 +3705,57 @@ describe('createServeApp', () => { overrides?.refreshCache ?? (async () => undefined), ), vi - .spyOn(ExtensionManager.prototype, 'installExtension') + .spyOn(ExtensionManager.prototype, 'prepareExtensionInstall') + .mockImplementation(async function (options) { + const extension = overrides?.prepareExtensionInstall + ? await overrides.prepareExtensionInstall.call(this, options) + : testExtension('installed-ext'); + return createPrepared(extension, 'install'); + }), + vi + .spyOn(ExtensionManager.prototype, 'prepareExtensionUpdate') + .mockImplementation(async function ({ extension, signal }) { + const state = await qwenCore.checkForExtensionUpdate( + extension, + this, + signal, + ); + if (state === ExtensionUpdateState.UP_TO_DATE) { + return { upToDate: true, extension }; + } + if (state !== ExtensionUpdateState.UPDATE_AVAILABLE) { + throw new Error( + `Extension "${extension.name}" update check returned ${state}.`, + ); + } + const updated = overrides?.prepareExtensionUpdate + ? await overrides.prepareExtensionUpdate.call(this, extension) + : ({ + ...extension, + config: { ...extension.config, version: '1.2.4' }, + version: '1.2.4', + } as Extension); + return { + upToDate: false, + prepared: createPrepared(updated, 'update'), + }; + }), + vi + .spyOn(ExtensionManager.prototype, 'commitPreparedExtension') + .mockImplementation(async (prepared) => + overrides?.commitPreparedExtension + ? await overrides.commitPreparedExtension(prepared) + : { + identity: prepared.identity, + version: prepared.version, + generation: ++generation, + extension: preparedExtensions.get(prepared), + }, + ), + vi + .spyOn(ExtensionManager.prototype, 'disposePreparedExtension') .mockImplementation( - overrides?.installExtension ?? - (async () => testExtension('installed-ext')), + overrides?.disposePreparedExtension ?? (async () => undefined), ), vi .spyOn(ExtensionManager.prototype, 'getLoadedExtensions') @@ -3675,17 +3766,20 @@ describe('createServeApp', () => { vi .spyOn(ExtensionManager.prototype, 'enableExtension') .mockImplementation( - overrides?.enableExtension ?? (async () => undefined), + (overrides?.enableExtension ?? + (async () => ({ generation: ++generation }))) as never, ), vi .spyOn(ExtensionManager.prototype, 'disableExtension') .mockImplementation( - overrides?.disableExtension ?? (async () => undefined), + (overrides?.disableExtension ?? + (async () => ({ generation: ++generation }))) as never, ), vi .spyOn(ExtensionManager.prototype, 'uninstallExtension') .mockImplementation( - overrides?.uninstallExtension ?? (async () => undefined), + (overrides?.uninstallExtension ?? + (async () => ({ generation: ++generation }))) as never, ), vi .spyOn(ExtensionManager.prototype, 'checkForAllExtensionUpdates') @@ -3761,7 +3855,7 @@ describe('createServeApp', () => { ); let managerTrustedFlag: boolean | undefined; const restore = mockExtensionManagerMethods({ - async installExtension() { + async prepareExtensionInstall() { managerTrustedFlag = ( this as unknown as { isWorkspaceTrusted?: boolean } ).isWorkspaceTrusted; @@ -3817,7 +3911,7 @@ describe('createServeApp', () => { it('queues extension install and refreshes active sessions', async () => { let requestSettingError: string | undefined; const restore = mockExtensionManagerMethods({ - async installExtension() { + async prepareExtensionInstall() { const manager = this as unknown as { requestSetting?: (setting: { envVar: string }) => Promise; }; @@ -3866,14 +3960,15 @@ describe('createServeApp', () => { }); }); expect( - vi.mocked(ExtensionManager.prototype.installExtension), + vi.mocked(ExtensionManager.prototype.prepareExtensionInstall), ).toHaveBeenCalledWith( expect.objectContaining({ - ref: 'v1.2.3', - autoUpdate: true, - allowPreRelease: true, + installMetadata: expect.objectContaining({ + ref: 'v1.2.3', + autoUpdate: true, + allowPreRelease: true, + }), }), - expect.any(Function), ); expect(requestSettingError).toContain( 'requires interactive configuration', @@ -3914,7 +4009,7 @@ describe('createServeApp', () => { releaseInstall = resolve; }); const restore = mockExtensionManagerMethods({ - installExtension: async () => { + prepareExtensionInstall: async () => { await installBlocker; return testExtension('installed-ext'); }, @@ -3956,25 +4051,45 @@ describe('createServeApp', () => { .send({ source: 'https://example.com/second-ext', consent: true }); expect(second.status).toBe(202); + await vi.waitFor(async () => { + const poll = await request(app) + .get( + `/workspace/extensions/operations/${encodeURIComponent( + second.body.operationId as string, + )}`, + ) + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + expect(poll.body.status).toBe('running'); + }); + + const third = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source: 'https://example.com/third-ext', consent: true }); + expect(third.status).toBe(202); + const queued = await request(app) .get( `/workspace/extensions/operations/${encodeURIComponent( - second.body.operationId as string, + third.body.operationId as string, )}`, ) .set('Host', `127.0.0.1:${tokenOpts.port}`) .set('Authorization', 'Bearer secret'); expect(queued.status).toBe(200); expect(queued.body).toMatchObject({ - operationId: second.body.operationId, + operationId: third.body.operationId, operation: 'install', status: 'queued', - source: 'https://example.com/second-ext', + source: 'https://example.com/third-ext', }); releaseInstall!(); await vi.waitFor(() => { - expect(bridge.extensionEvents.length).toBeGreaterThanOrEqual(2); + expect(bridge.extensionEvents.length).toBeGreaterThanOrEqual(3); }); } finally { releaseInstall?.(); @@ -3982,9 +4097,163 @@ describe('createServeApp', () => { } }); + it('commits in preparation completion order', async () => { + let releaseFirst: (() => void) | undefined; + let firstStarted = false; + const commits: string[] = []; + let generation = 0; + const restore = mockExtensionManagerMethods({ + prepareExtensionInstall: async ({ installMetadata }) => { + const name = installMetadata.source.endsWith('/first-ext') + ? 'first-ext' + : 'second-ext'; + if (name === 'first-ext') { + firstStarted = true; + await new Promise((resolve) => { + releaseFirst = resolve; + }); + } + return testExtension(name); + }, + commitPreparedExtension: async (prepared) => { + commits.push(prepared.identity.name); + return { + identity: prepared.identity, + version: prepared.version, + generation: ++generation, + }; + }, + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source: 'https://example.com/first-ext', consent: true }); + await vi.waitFor(() => expect(firstStarted).toBe(true)); + + await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source: 'https://example.com/second-ext', consent: true }); + await vi.waitFor(() => expect(commits).toEqual(['second-ext'])); + + releaseFirst?.(); + await vi.waitFor(() => + expect(commits).toEqual(['second-ext', 'first-ext']), + ); + await vi.waitFor(() => expect(bridge.extensionEvents).toHaveLength(2)); + } finally { + releaseFirst?.(); + restore(); + } + }); + + it('maps coded commit warnings to the legacy refresh-error status', async () => { + const restore = mockExtensionManagerMethods({ + commitPreparedExtension: async (prepared) => ({ + identity: prepared.identity, + version: prepared.version, + generation: 3, + warnings: [ + { + code: 'extension_temp_cleanup_failed', + error: 'cleanup denied', + }, + ], + }), + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge: fakeBridge({ knownClientIds: ['client-1'] }) }, + ); + const started = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source: 'https://example.com/warning-ext', consent: true }); + + await vi.waitFor(async () => { + const operation = await request(app) + .get(`/workspace/extensions/operations/${started.body.operationId}`) + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + expect(operation.body).toMatchObject({ + status: 'succeeded_with_refresh_error', + result: { error: 'cleanup denied' }, + warnings: [ + { + code: 'extension_temp_cleanup_failed', + error: 'cleanup denied', + }, + ], + }); + }); + } finally { + restore(); + } + }); + + it('maps resultless committed warnings to a legacy top-level error', async () => { + const restore = mockExtensionManagerMethods({ + commitPreparedExtension: async (prepared) => ({ + identity: prepared.identity, + version: prepared.version, + generation: 3, + }), + disposePreparedExtension: async () => { + throw new Error('cleanup exploded'); + }, + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge: fakeBridge({ knownClientIds: ['client-1'] }) }, + ); + const started = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ source: 'https://example.com/warning-ext', consent: true }); + + await vi.waitFor(async () => { + const operation = await request(app) + .get(`/workspace/extensions/operations/${started.body.operationId}`) + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + expect(operation.body).toMatchObject({ + status: 'succeeded_with_refresh_error', + error: + 'Commit succeeded but post-commit work failed: cleanup exploded', + }); + expect(operation.body).not.toHaveProperty('result'); + }); + } finally { + restore(); + } + }); + it('evicts the oldest terminal extension operations', async () => { const restore = mockExtensionManagerMethods({ - async installExtension() { + async prepareExtensionInstall() { return testExtension('installed-ext'); }, }); @@ -4070,9 +4339,12 @@ describe('createServeApp', () => { }); it('broadcasts a failed extension install with redacted error details', async () => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true); const restore = mockExtensionManagerMethods({ - installExtension: async () => { - throw new Error('https://user:token@example.com/private-ext failed'); + prepareExtensionInstall: async () => { + throw new Error( + 'https://user:\n\tsecret@example.com/private-ext failed', + ); }, }); try { @@ -4105,7 +4377,7 @@ describe('createServeApp', () => { error: 'https://***REDACTED***@example.com/private-ext failed', }); }); - expect(bridge.extensionEvents.at(-1)?.error).not.toContain('token'); + expect(bridge.extensionEvents.at(-1)?.error).not.toContain('secret'); const poll = await request(app) .get( @@ -4123,23 +4395,45 @@ describe('createServeApp', () => { source: 'https://example.com/private-ext', error: 'https://***REDACTED***@example.com/private-ext failed', }); - expect(poll.body.error).not.toContain('token'); + expect(poll.body.error).not.toContain('secret'); + const logged = stderr.mock.calls + .map(([chunk]) => String(chunk)) + .join(''); + expect(logged).toContain( + 'https://***REDACTED***@example.com/private-ext failed', + ); + expect(logged).not.toContain('secret'); } finally { + stderr.mockRestore(); restore(); } }); it('does not report a successful extension install as failed when session refresh fails', async () => { const restore = mockExtensionManagerMethods({ - async installExtension() { + async prepareExtensionInstall() { return testExtension('installed-ext'); }, + async commitPreparedExtension(prepared) { + return { + identity: prepared.identity, + version: prepared.version, + generation: 3, + warnings: [ + { + code: 'extension_temp_cleanup_failed', + error: 'cleanup denied', + }, + ], + }; + }, }); try { const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const refreshError = 'x'.repeat(600); bridge.refreshExtensionsForAllSessions = async () => { - throw new Error('refresh broke'); + throw new Error(refreshError); }; const app = createServeApp( { ...tokenOpts, workspace: WS_BOUND }, @@ -4166,7 +4460,7 @@ describe('createServeApp', () => { name: 'installed-ext', refreshed: 0, failed: 1, - error: 'refresh broke', + error: refreshError.slice(0, 500), }); }); @@ -4187,8 +4481,11 @@ describe('createServeApp', () => { status: 'installed', refreshed: 0, failed: 1, - error: 'refresh broke', + error: refreshError.slice(0, 500), }, + warnings: expect.arrayContaining([ + expect.objectContaining({ error: refreshError.slice(0, 500) }), + ]), }); } finally { restore(); @@ -4201,7 +4498,7 @@ describe('createServeApp', () => { releaseInstall = resolve; }); const restore = mockExtensionManagerMethods({ - installExtension: async () => { + prepareExtensionInstall: async () => { await installBlocker; return testExtension('installed-ext'); }, @@ -4230,13 +4527,39 @@ describe('createServeApp', () => { Array.from({ length: 10 }, () => install()), ); const rejected = await install(); + const legacyRequests = Promise.all([ + request(app) + .post('/workspace/extensions/check-updates') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({}), + request(app) + .post('/workspace/extensions/refresh') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({}), + ]); + const releaseTimer = setTimeout(() => releaseInstall?.(), 100); + const [checkUpdatesResponse, refresh] = await legacyRequests; + clearTimeout(releaseTimer); + + releaseInstall?.(); expect(accepted.every((res) => res.status === 202)).toBe(true); expect(rejected.status).toBe(429); expect(rejected.body).toMatchObject({ code: 'extension_queue_full', }); - releaseInstall?.(); + expect(checkUpdatesResponse.status).toBe(429); + expect(checkUpdatesResponse.body).toMatchObject({ + code: 'extension_queue_full', + }); + expect(refresh.status).toBe(429); + expect(refresh.body).toMatchObject({ + code: 'extension_queue_full', + }); await vi.waitFor(() => { expect( bridge.extensionEvents.filter( @@ -4410,6 +4733,7 @@ describe('createServeApp', () => { 'http://example.com/repo', 'ftp://example.com/repo', 'file:///tmp/repo', + 'git@github.com:owner/repo.git', ]) { const res = await request(app) .post('/workspace/extensions/install') @@ -4422,7 +4746,7 @@ describe('createServeApp', () => { }); expect(res.status).toBe(400); - expect(res.body.error).toBe('`source` must use https or ssh'); + expect(res.body.error).toBe('`source` must use https'); } }); @@ -4715,21 +5039,24 @@ describe('createServeApp', () => { } }); - it('serializes check-updates behind queued extension mutations', async () => { - let releaseInstall: (() => void) | undefined; + it('shares the two preparation slots with check-updates', async () => { + const releases: Array<() => void> = []; const calls: string[] = []; const restore = mockExtensionManagerMethods({ - installExtension: async () => { - calls.push('install:start'); + prepareExtensionInstall: async () => { + const index = releases.length + 1; + calls.push(`install-${index}:start`); await new Promise((resolve) => { - releaseInstall = resolve; + releases.push(resolve); }); - calls.push('install:end'); - return testExtension('installed-ext'); + calls.push(`install-${index}:end`); + return testExtension(`installed-ext-${index}`); }, - checkForAllExtensionUpdates: async (cb) => { - calls.push('check-updates'); - cb('test-ext', ExtensionUpdateState.UP_TO_DATE); + checkForAllExtensionUpdates: async (cb, _signal, schedule) => { + await schedule!(async () => { + calls.push('check-updates'); + cb('test-ext', ExtensionUpdateState.UP_TO_DATE); + }); }, }); try { @@ -4741,17 +5068,19 @@ describe('createServeApp', () => { { bridge }, ); - await request(app) - .post('/workspace/extensions/install') - .set('Host', `127.0.0.1:${tokenOpts.port}`) - .set('Authorization', 'Bearer secret') - .set('X-Qwen-Client-Id', 'client-1') - .send({ - source: 'https://example.com/installed-ext', - consent: true, - }); + for (const name of ['first', 'second']) { + await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source: `https://example.com/${name}`, + consent: true, + }); + } await vi.waitFor(() => { - expect(calls).toEqual(['install:start']); + expect(calls).toEqual(['install-1:start', 'install-2:start']); }); const checkUpdates = request(app) @@ -4761,19 +5090,28 @@ describe('createServeApp', () => { .set('X-Qwen-Client-Id', 'client-1') .send({}) .then((response) => response); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(calls).toEqual(['install:start']); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(calls).toEqual(['install-1:start', 'install-2:start']); - releaseInstall?.(); + releases[0]?.(); + await vi.waitFor(() => expect(calls).toContain('check-updates')); const res = await checkUpdates; expect(res.status).toBe(200); expect(calls).toEqual([ - 'install:start', - 'install:end', + 'install-1:start', + 'install-2:start', + 'install-1:end', 'check-updates', ]); + releases[1]?.(); + await vi.waitFor(() => + expect(bridge.extensionEvents).toEqual([ + expect.objectContaining({ status: 'installed' }), + expect.objectContaining({ status: 'installed' }), + ]), + ); } finally { - releaseInstall?.(); + releases.forEach((release) => release()); restore(); } }); @@ -4847,10 +5185,20 @@ describe('createServeApp', () => { }); expect( vi.mocked(ExtensionManager.prototype.enableExtension), - ).toHaveBeenCalledWith('test-ext', expect.anything(), WS_BOUND); + ).toHaveBeenCalledWith( + 'test-ext', + expect.anything(), + WS_BOUND, + expect.any(Function), + ); expect( vi.mocked(ExtensionManager.prototype.disableExtension), - ).toHaveBeenCalledWith('test-ext', expect.anything(), WS_BOUND); + ).toHaveBeenCalledWith( + 'test-ext', + expect.anything(), + WS_BOUND, + expect.any(Function), + ); } finally { restore(); } @@ -4884,10 +5232,20 @@ describe('createServeApp', () => { await vi.waitFor(() => { expect( vi.mocked(ExtensionManager.prototype.enableExtension), - ).toHaveBeenCalledWith('test-ext', expect.anything(), WS_BOUND); + ).toHaveBeenCalledWith( + 'test-ext', + expect.anything(), + WS_BOUND, + expect.any(Function), + ); expect( vi.mocked(ExtensionManager.prototype.disableExtension), - ).toHaveBeenCalledWith('test-ext', expect.anything(), WS_BOUND); + ).toHaveBeenCalledWith( + 'test-ext', + expect.anything(), + WS_BOUND, + expect.any(Function), + ); }); } finally { restore(); @@ -4918,10 +5276,10 @@ describe('createServeApp', () => { }); }); - it('serializes extension refresh behind queued mutations', async () => { + it('does not block manual refresh behind preparation', async () => { let releaseInstall: (() => void) | undefined; const restore = mockExtensionManagerMethods({ - installExtension: async () => { + prepareExtensionInstall: async () => { await new Promise((resolve) => { releaseInstall = resolve; }); @@ -4948,7 +5306,7 @@ describe('createServeApp', () => { }); await vi.waitFor(() => { expect( - vi.mocked(ExtensionManager.prototype.installExtension), + vi.mocked(ExtensionManager.prototype.prepareExtensionInstall), ).toHaveBeenCalled(); }); @@ -4959,22 +5317,86 @@ describe('createServeApp', () => { .set('X-Qwen-Client-Id', 'client-1') .send({}) .then((response) => response); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(bridge.extensionEvents).toEqual([]); - - releaseInstall?.(); const res = await refresh; expect(res.status).toBe(200); expect(bridge.extensionEvents).toEqual([ - expect.objectContaining({ status: 'installed' }), expect.objectContaining({ refreshed: 1, failed: 0 }), ]); + + releaseInstall?.(); + await vi.waitFor(() => { + expect(bridge.extensionEvents).toEqual([ + expect.objectContaining({ refreshed: 1, failed: 0 }), + expect.objectContaining({ status: 'installed' }), + ]); + }); } finally { releaseInstall?.(); restore(); } }); + it('serializes manual refresh behind an extension commit', async () => { + let releaseEnable: (() => void) | undefined; + const restore = mockExtensionManagerMethods({ + enableExtension: async () => { + await new Promise((resolve) => { + releaseEnable = resolve; + }); + return { generation: 1 }; + }, + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const enable = await request(app) + .post('/workspace/extensions/test-ext/enable') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ scope: 'workspace' }); + expect(enable.status).toBe(202); + await vi.waitFor(() => { + expect( + vi.mocked(ExtensionManager.prototype.enableExtension), + ).toHaveBeenCalled(); + }); + + const refreshRequest = request(app) + .post('/workspace/extensions/refresh') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({}); + const requestStarted = new Promise((resolve) => { + refreshRequest.on('request', () => resolve()); + }); + const refresh = refreshRequest.then((response) => response); + await requestStarted; + const state = await Promise.race([ + refresh.then(() => 'settled'), + new Promise<'pending'>((resolve) => + setTimeout(() => resolve('pending'), 50), + ), + ]); + expect(state).toBe('pending'); + + releaseEnable?.(); + const res = await refresh; + expect(res.status).toBe(200); + expect(res.body).toEqual({ refreshed: 1, failed: 0 }); + } finally { + releaseEnable?.(); + restore(); + } + }); + it('rejects extension update from an unknown workspace client id', async () => { const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; const bridge = fakeBridge({ knownClientIds: ['client-1'] }); @@ -5190,7 +5612,12 @@ describe('createServeApp', () => { }); expect( vi.mocked(ExtensionManager.prototype.uninstallExtension), - ).toHaveBeenCalledWith('test-ext', false, WS_BOUND); + ).toHaveBeenCalledWith( + 'test-ext', + false, + WS_BOUND, + expect.any(Function), + ); } finally { restore(); } @@ -5224,7 +5651,12 @@ describe('createServeApp', () => { }); expect( vi.mocked(ExtensionManager.prototype.uninstallExtension), - ).toHaveBeenCalledWith('test-ext', false, WS_BOUND); + ).toHaveBeenCalledWith( + 'test-ext', + false, + WS_BOUND, + expect.any(Function), + ); } finally { restore(); } diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 1f08a3e6afc..34f33d67dac 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -1190,6 +1190,7 @@ export function createServeApp( mutate, safeBody, sendBridgeError, + workspaceRegistry, ...(deps.maxExtensionOperationHistory === undefined ? {} : { maxExtensionOperationHistory: deps.maxExtensionOperationHistory }), diff --git a/packages/cli/src/serve/server/request-helpers.ts b/packages/cli/src/serve/server/request-helpers.ts index 64e178418cd..703f198968d 100644 --- a/packages/cli/src/serve/server/request-helpers.ts +++ b/packages/cli/src/serve/server/request-helpers.ts @@ -221,7 +221,7 @@ export function validateMcpRuntimeServerName( /** * Workspace-level mutation routes validate the parsed `X-Qwen-Client-Id` - * against `bridge.knownClientIds()` so the `originatorClientId` stamped + * against the supplied bridge set so the `originatorClientId` stamped * onto fan-out events is grounded in a known identity. Returns the * validated client id (or `undefined` when no header was supplied), * `null` when a 400 has already been emitted. @@ -229,11 +229,12 @@ export function validateMcpRuntimeServerName( export function parseAndValidateWorkspaceClientId( req: Request, res: Response, - bridge: AcpSessionBridge, + bridge: AcpSessionBridge | readonly AcpSessionBridge[], ): string | undefined | null { const raw = parseClientIdHeader(req, res); if (raw === null || raw === undefined) return raw; - if (!bridge.knownClientIds().has(raw)) { + const bridges = Array.isArray(bridge) ? bridge : [bridge]; + if (!bridges.some((candidate) => candidate.knownClientIds().has(raw))) { res.status(400).json({ error: `Client id "${raw}" is not registered for this workspace`, code: 'invalid_client_id', diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index 0102e365cb7..0576927191d 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -297,9 +297,9 @@ export interface CapabilitiesEnvelope { * - Omit `cwd` on `POST /session` — the route falls back to this * path when the body has no `cwd` field. * - * When `features` contains `multi_workspace_sessions`, `workspaces[]` - * lists every registered runtime. `workspaceCwd` remains the primary - * entry for old clients. + * Newer daemons list every registered runtime in `workspaces[]`, including + * the primary runtime when `multi_workspace_sessions` is absent. + * `workspaceCwd` remains the primary entry for old clients. * * Optional at the type level (matches the SDK's `DaemonCapabilities` * type) because the field is an additive extension of the v=1 @@ -308,7 +308,7 @@ export interface CapabilitiesEnvelope { * current server code always populates it. */ workspaceCwd?: string; - /** Registered session runtimes, present only for multi-workspace daemons. */ + /** Registered session runtimes. Older single-workspace daemons may omit it. */ workspaces?: Array<{ id: string; cwd: string; diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 7d66743047c..7abc057a527 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -239,6 +239,35 @@ describe('extensionsCommand', () => { expect(mockContext.ui.reloadCommands).toHaveBeenCalled(); }); + it('shows a warning and reloads commands after a committed install warning', async () => { + mockParseInstallSource.mockResolvedValue({ + type: 'git', + source: 'https://github.com/test/extension', + }); + mockInstallExtension.mockRejectedValue( + Object.assign( + new Error('Extension committed but could not be reloaded.'), + { + code: 'extension_committed_with_warnings', + committed: true, + identity: { id: 'test-extension', name: 'test-extension' }, + warnings: [], + }, + ), + ); + + await installAction(mockContext, 'https://github.com/test/extension'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.WARNING, + text: 'Extension was installed but could not be reloaded: Extension committed but could not be reloaded.', + }, + expect.any(Number), + ); + expect(mockContext.ui.reloadCommands).toHaveBeenCalled(); + }); + it('should redact URL credentials in install progress messages', async () => { mockParseInstallSource.mockResolvedValue({ type: 'git', diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index a10a6a999c5..13f6ba9d371 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -20,6 +20,7 @@ import { redactUrlCredentials, getExtensionDisplayName, getExtensionDescription, + isExtensionCommittedWithWarningsError, } from '@qwen-code/qwen-code-core'; const debugLogger = createDebugLogger('EXTENSIONS_COMMAND'); @@ -244,6 +245,20 @@ async function installAction(context: CommandContext, args: string) { // FIXME: refresh command controlled by ui for now, cannot be auto refreshed by extensionManager context.ui.reloadCommands(); } catch (error) { + if (isExtensionCommittedWithWarningsError(error)) { + context.ui.addItem( + { + type: MessageType.WARNING, + text: t( + 'Extension was installed but could not be reloaded: {{error}}', + { error: redactUrlCredentials(getErrorMessage(error)) }, + ), + }, + Date.now(), + ); + context.ui.reloadCommands(); + return; + } context.ui.addItem( { type: MessageType.ERROR, diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 1795ca988aa..be52c3d2ee9 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -27,7 +27,7 @@ import { SettingInputPrompt } from '../SettingInputPrompt.js'; import { PluginChoicePrompt } from '../PluginChoicePrompt.js'; export interface StatusMessage { - type: 'info' | 'success' | 'error'; + type: 'info' | 'success' | 'warning' | 'error'; text: string; } @@ -256,9 +256,11 @@ export function ExtensionsManagerDialog({ color={ status.type === 'error' ? theme.status.error - : status.type === 'success' - ? theme.status.success - : theme.text.secondary + : status.type === 'warning' + ? theme.status.warning + : status.type === 'success' + ? theme.status.success + : theme.text.secondary } > {status.text} diff --git a/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx index 3de30876487..8d64b9b3911 100644 --- a/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx +++ b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx @@ -102,6 +102,7 @@ export const ExtensionListStep = ({ return theme.text.secondary; case ExtensionUpdateState.UPDATE_AVAILABLE: case ExtensionUpdateState.UPDATED_NEEDS_RESTART: + case ExtensionUpdateState.UPDATED_WITH_WARNINGS: return theme.status.warning; case ExtensionUpdateState.ERROR: return theme.status.error; diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.test.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.test.tsx new file mode 100644 index 00000000000..53afe8a30a6 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.test.tsx @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { render } from 'ink-testing-library'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import type { Config, DiscoveredPlugin } from '@qwen-code/qwen-code-core'; +import type { Key } from '../../../hooks/useKeypress.js'; +import { DiscoverTab } from './DiscoverTab.js'; + +const mockUseKeypress = vi.hoisted(() => vi.fn()); +const mockRadioButtonSelect = vi.hoisted(() => + vi.fn((_props: unknown) => null), +); +const mockParseInstallSource = vi.hoisted(() => + vi.fn(async (source: string) => ({ type: 'git' as const, source })), +); + +vi.mock('../../../hooks/useKeypress.js', () => ({ + useKeypress: mockUseKeypress, +})); + +vi.mock('../../../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ columns: 100, rows: 40 }), +})); + +vi.mock('../../shared/RadioButtonSelect.js', () => ({ + RadioButtonSelect: mockRadioButtonSelect, +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, parseInstallSource: mockParseInstallSource }; +}); + +interface SelectProps { + onSelect: (value: T) => void; +} + +function activeKeypress(): (key: Key) => void { + const call = mockUseKeypress.mock.calls.findLast( + (args) => (args[1] as { isActive: boolean }).isActive, + ); + return call?.[0] as (key: Key) => void; +} + +describe('DiscoverTab', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('installs the selected extension with workspace activation', async () => { + const plugin = { + name: 'demo', + marketplaceName: 'market', + installSource: 'owner/demo', + installed: false, + } as DiscoveredPlugin; + const manager = { + discoverPlugins: vi.fn().mockResolvedValue([plugin]), + installExtension: vi.fn().mockResolvedValue({ name: 'demo' }), + setExtensionScope: vi.fn(), + }; + const config = { + getExtensionManager: () => manager, + } as unknown as Config; + const onInstalled = vi.fn(); + + render( + , + ); + await waitFor(() => expect(manager.discoverPlugins).toHaveBeenCalled()); + + await act(async () => { + activeKeypress()({ name: 'return' } as Key); + }); + const detailSelect = mockRadioButtonSelect.mock.calls.at(-1)?.[0] as + | SelectProps<'project'> + | undefined; + await act(async () => { + detailSelect?.onSelect('project'); + }); + + await waitFor(() => expect(manager.installExtension).toHaveBeenCalled()); + expect(manager.installExtension).toHaveBeenCalledWith( + { type: 'git', source: 'owner/demo' }, + undefined, + undefined, + process.cwd(), + undefined, + { scope: 'workspace', workspacePath: process.cwd() }, + ); + expect(manager.setExtensionScope).toHaveBeenCalledWith('demo', 'project'); + expect(onInstalled).toHaveBeenCalledOnce(); + }); + + it('surfaces a warning when saving scope preference fails after install', async () => { + const plugin = { + name: 'demo', + marketplaceName: 'market', + installSource: 'owner/demo', + installed: false, + } as DiscoveredPlugin; + const manager = { + discoverPlugins: vi.fn().mockResolvedValue([plugin]), + installExtension: vi.fn().mockResolvedValue({ name: 'demo' }), + setExtensionScope: vi.fn(() => { + throw new Error('preference denied'); + }), + }; + const onStatus = vi.fn(); + render( + manager } as unknown as Config} + isActive + onLockChange={vi.fn()} + onStatus={onStatus} + onInstalled={vi.fn()} + reloadSignal={0} + />, + ); + await waitFor(() => expect(manager.discoverPlugins).toHaveBeenCalled()); + await act(async () => { + activeKeypress()({ name: 'return' } as Key); + }); + const detailSelect = mockRadioButtonSelect.mock.calls.at(-1)?.[0] as + | SelectProps<'project'> + | undefined; + + await act(async () => { + detailSelect?.onSelect('project'); + }); + + await waitFor(() => + expect(onStatus).toHaveBeenCalledWith({ + type: 'warning', + text: 'Installed 1 extension(s) with warnings: demo: preference denied', + }), + ); + }); +}); diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 779fbbcf04d..546588d8cb0 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -16,10 +16,10 @@ import { type Config, type DiscoveredPlugin, type ExtensionScope, - SettingScope, parseInstallSource, redactUrlCredentials, createDebugLogger, + isExtensionCommittedWithWarningsError, } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../../../utils/errors.js'; import type { StatusMessage } from '../ExtensionsManagerDialog.js'; @@ -215,12 +215,40 @@ export const DiscoverTab = ({ setInstalling(true); let installed = 0; const errors: string[] = []; + const warnings: string[] = []; for (const plugin of targets) { let ext; try { const metadata = await parseInstallSource(plugin.installSource); - ext = await extensionManager.installExtension(metadata); + ext = await extensionManager.installExtension( + metadata, + undefined, + undefined, + process.cwd(), + undefined, + scope === 'user' + ? { scope: 'user' } + : { scope: 'workspace', workspacePath: process.cwd() }, + ); } catch (error) { + if (isExtensionCommittedWithWarningsError(error)) { + installed++; + warnings.push( + `${plugin.name}: ${redactUrlCredentials(getErrorMessage(error))}`, + ); + try { + extensionManager.setExtensionScope(error.identity.name, scope); + } catch (scopeError) { + warnings.push( + `${plugin.name}: ${redactUrlCredentials(getErrorMessage(scopeError))}`, + ); + debugLogger.error( + 'Installed extension but failed to apply scope preference:', + scopeError, + ); + } + continue; + } errors.push( `${plugin.name}: ${redactUrlCredentials(getErrorMessage(error))}`, ); @@ -231,53 +259,11 @@ export const DiscoverTab = ({ // successful install to "failed" (which would prompt a confusing retry). installed++; try { - // installExtension auto-enables at User (global) scope. For a - // workspace-scoped choice, re-scope enablement to this workspace - // only: disable the global enable and enable for the workspace path. - if (scope !== 'user') { - await extensionManager.disableExtension( - ext.name, - SettingScope.User, - ); - try { - await extensionManager.enableExtension( - ext.name, - SettingScope.Workspace, - ); - } catch (enableError) { - // The User-scope disable already landed; roll it back so a failed - // Workspace enable doesn't leave the extension disabled at every - // scope (the outer catch only logs, so the install still reports - // success — without this the extension would be silently dead). - try { - await extensionManager.enableExtension( - ext.name, - SettingScope.User, - ); - } catch (rollbackError) { - // Rollback failed: the extension is now disabled at every scope. - // The outer catch only debug-logs, so surface it through the - // batch error list — otherwise the user is told the install - // succeeded with no hint the extension is silently dead. - debugLogger.error( - 'Scope rollback failed after install:', - rollbackError, - ); - errors.push( - t( - '{{name}}: installed, but the scope rollback failed — it may be disabled at all scopes; re-enable it from the Installed tab.', - { name: plugin.name }, - ), - ); - } - throw enableError; - } - } - // Record the scope preference only after enablement succeeds, so the - // Installed tab can't show a "Project level" extension that is - // actually enabled at User scope after a rollback. extensionManager.setExtensionScope(ext.name, scope); } catch (scopeError) { + warnings.push( + `${plugin.name}: ${redactUrlCredentials(getErrorMessage(scopeError))}`, + ); debugLogger.error( 'Installed extension but failed to apply scope preference:', scopeError, @@ -288,10 +274,19 @@ export const DiscoverTab = ({ setSelectedKeys(new Set()); if (errors.length === 0) { onStatus({ - type: 'success', - text: t('Installed {{count}} extension(s).', { - count: String(installed), - }), + type: warnings.length === 0 ? 'success' : 'warning', + text: + warnings.length === 0 + ? t('Installed {{count}} extension(s).', { + count: String(installed), + }) + : t( + 'Installed {{count}} extension(s) with warnings: {{detail}}', + { + count: String(installed), + detail: warnings.join('; '), + }, + ), }); } else { onStatus({ @@ -299,7 +294,7 @@ export const DiscoverTab = ({ text: t('Installed {{ok}}, failed {{fail}}: {{detail}}', { ok: String(installed), fail: String(errors.length), - detail: errors.join('; '), + detail: [...warnings, ...errors].join('; '), }), }); } diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.test.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.test.tsx new file mode 100644 index 00000000000..d8092023181 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.test.tsx @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { render } from 'ink-testing-library'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import type { Config, Extension } from '@qwen-code/qwen-code-core'; +import { InstalledTab } from './InstalledTab.js'; +import type { StatusMessage } from '../ExtensionsManagerDialog.js'; + +const mockUseKeypress = vi.hoisted(() => vi.fn()); + +vi.mock('../../../hooks/useKeypress.js', () => ({ + useKeypress: mockUseKeypress, +})); + +vi.mock('../../../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ rows: 24, columns: 80 }), +})); + +const extension = { + id: 'demo-id', + name: 'demo', + version: '1.0.0', + path: '/extensions/demo', + isActive: true, + mcpServers: {}, + commands: [], + skills: [], + agents: [], + resolvedSettings: [], + config: {}, + contextFiles: [], +} as unknown as Extension; + +function createManager() { + return { + refreshCache: vi.fn().mockResolvedValue(undefined), + getLoadedExtensions: vi.fn(() => [extension]), + getFavorites: vi.fn(() => []), + getExtensionScopes: vi.fn(() => ({ demo: 'user' as const })), + disableExtension: vi.fn().mockResolvedValue({ + warnings: [ + { code: 'extension_runtime_refresh_failed', error: 'refresh failed' }, + ], + }), + enableExtension: vi.fn().mockResolvedValue({ warnings: [] }), + }; +} + +describe('InstalledTab', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('surfaces activation warnings and reloads the installed list', async () => { + const manager = createManager(); + const config = { + getExtensionManager: () => manager, + getMcpServers: () => ({}), + getToolRegistry: () => undefined, + } as unknown as Config; + const statuses: Array = []; + + render( + statuses.push(status)} + extensionsUpdateState={new Map()} + reloadSignal={0} + />, + ); + + await waitFor(() => expect(manager.refreshCache).toHaveBeenCalledOnce()); + const listHandler = mockUseKeypress.mock.calls + .filter((call) => call[1]?.isActive === true) + .at(-1)?.[0] as + | ((key: { name: string; sequence: string }) => void) + | undefined; + + await act(async () => { + listHandler?.({ name: 'space', sequence: ' ' }); + }); + + await waitFor(() => + expect(manager.disableExtension).toHaveBeenCalledOnce(), + ); + await waitFor(() => expect(manager.refreshCache).toHaveBeenCalledTimes(2)); + expect(statuses).toContainEqual({ + type: 'warning', + text: '"demo" changed with warnings: refresh failed', + }); + }); +}); diff --git a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx index 4a4db387859..6aaefb455b6 100644 --- a/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/InstalledTab.tsx @@ -461,17 +461,25 @@ export const InstalledTab = ({ : t('Enabling "{{name}}"...', { name: item.name }), }); try { + let result; if (item.isActive) { - await extensionManager.disableExtension(item.name, scope); + result = await extensionManager.disableExtension(item.name, scope); } else { - await extensionManager.enableExtension(item.name, scope); + result = await extensionManager.enableExtension(item.name, scope); } + const warnings = result.warnings ?? []; onStatus({ - type: 'success', - text: t('"{{name}}" {{state}}.', { - name: item.name, - state: item.isActive ? t('disabled') : t('enabled'), - }), + type: warnings.length > 0 ? 'warning' : 'success', + text: + warnings.length > 0 + ? t('"{{name}}" changed with warnings: {{detail}}', { + name: item.name, + detail: warnings.map((warning) => warning.error).join('; '), + }) + : t('"{{name}}" {{state}}.', { + name: item.name, + state: item.isActive ? t('disabled') : t('enabled'), + }), }); await load(); } catch (error) { diff --git a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.test.tsx b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.test.tsx new file mode 100644 index 00000000000..733a0c989bc --- /dev/null +++ b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.test.tsx @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { render } from 'ink-testing-library'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { Key } from '../../../hooks/useKeypress.js'; +import type { StatusMessage } from '../ExtensionsManagerDialog.js'; +import { SourcesTab } from './SourcesTab.js'; + +const mockUseKeypress = vi.hoisted(() => vi.fn()); +const mockTextInput = vi.hoisted(() => vi.fn((_props: unknown) => null)); +const mockParseInstallSource = vi.hoisted(() => + vi.fn(async (source: string) => ({ type: 'git' as const, source })), +); + +vi.mock('../../../hooks/useKeypress.js', () => ({ + useKeypress: mockUseKeypress, +})); + +vi.mock('../../shared/TextInput.js', () => ({ TextInput: mockTextInput })); + +vi.mock('../../shared/RadioButtonSelect.js', () => ({ + RadioButtonSelect: vi.fn((_props: unknown) => null), +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, parseInstallSource: mockParseInstallSource }; +}); + +interface TextInputProps { + onChange: (value: string) => void; + onSubmit: () => void; +} + +function activeKeypress(): (key: Key) => void { + const call = mockUseKeypress.mock.calls.findLast( + (args) => (args[1] as { isActive: boolean }).isActive, + ); + return call?.[0] as (key: Key) => void; +} + +function committedWarning(): Error { + return Object.assign(new Error('committed with warnings'), { + code: 'extension_committed_with_warnings', + committed: true, + identity: { id: 'demo-id', name: 'demo' }, + warnings: [ + { code: 'extension_runtime_refresh_failed', error: 'refresh failed' }, + ], + }); +} + +describe('SourcesTab', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('reports a committed install warning without treating it as failure', async () => { + const manager = { + refreshCache: vi.fn().mockResolvedValue(undefined), + getLoadedExtensions: vi.fn(() => []), + getSources: vi.fn(() => []), + installExtension: vi.fn().mockRejectedValue(committedWarning()), + }; + const config = { + getExtensionManager: () => manager, + } as unknown as Config; + const statuses: Array = []; + const onChanged = vi.fn(); + + render( + statuses.push(status)} + onChanged={onChanged} + onBrowse={vi.fn()} + onFooter={vi.fn()} + reloadSignal={0} + />, + ); + await waitFor(() => expect(manager.refreshCache).toHaveBeenCalled()); + + await act(async () => { + activeKeypress()({ name: 'return' } as Key); + }); + let input = mockTextInput.mock.calls.at(-1)?.[0] as + | TextInputProps + | undefined; + await act(async () => { + input?.onChange('owner/demo'); + }); + input = mockTextInput.mock.calls.at(-1)?.[0] as TextInputProps | undefined; + await act(async () => { + input?.onSubmit(); + }); + + await waitFor(() => + expect(statuses).toContainEqual({ + type: 'warning', + text: 'committed with warnings', + }), + ); + expect(manager.installExtension).toHaveBeenCalledWith({ + type: 'git', + source: 'owner/demo', + }); + expect(onChanged).toHaveBeenCalledOnce(); + expect(manager.refreshCache).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx index 88da343fd0b..a65a5a119b5 100644 --- a/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/SourcesTab.tsx @@ -20,6 +20,7 @@ import { parseInstallSource, redactUrlCredentials, createDebugLogger, + isExtensionCommittedWithWarningsError, } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../../../utils/errors.js'; import { stripUnsafeCharacters } from '../../../utils/textUtils.js'; @@ -211,6 +212,16 @@ export const SourcesTab = ({ onChanged(); goToList(); } catch (error) { + if (isExtensionCommittedWithWarningsError(error)) { + onStatus({ + type: 'warning', + text: redactUrlCredentials(getErrorMessage(error)), + }); + await load(); + onChanged(); + goToList(); + return; + } onStatus({ type: 'error', text: redactUrlCredentials(getErrorMessage(error)), diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.test.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.test.tsx new file mode 100644 index 00000000000..07f5eb2ec4b --- /dev/null +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.test.tsx @@ -0,0 +1,287 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { render } from 'ink-testing-library'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { + ExtensionUpdateState, + SettingScope, + type Config, + type Extension, +} from '@qwen-code/qwen-code-core'; +import type { StatusMessage } from '../ExtensionsManagerDialog.js'; +import type { PluginDetailAction } from './PluginDetailView.js'; +import { ExtensionActionsView } from './ExtensionActionsView.js'; + +const mockPluginDetailView = vi.hoisted(() => vi.fn((_props: unknown) => null)); +const mockRadioButtonSelect = vi.hoisted(() => + vi.fn((_props: unknown) => null), +); +const mockUninstallConfirmStep = vi.hoisted(() => + vi.fn((_props: unknown) => null), +); + +vi.mock('../../../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('./PluginDetailView.js', () => ({ + PluginDetailView: mockPluginDetailView, +})); + +vi.mock('../../shared/RadioButtonSelect.js', () => ({ + RadioButtonSelect: mockRadioButtonSelect, +})); + +vi.mock('../steps/UninstallConfirmStep.js', () => ({ + UninstallConfirmStep: mockUninstallConfirmStep, +})); + +interface DetailProps { + onAction: (action: PluginDetailAction) => void; +} + +interface SelectProps { + onSelect: (scope: 'user' | 'project') => void; +} + +interface ConfirmProps { + onConfirm: (extension: Extension) => void; +} + +const extension = { + id: 'demo-id', + name: 'demo', + version: '1.0.0', + path: '/extensions/demo', + isActive: true, + installMetadata: { type: 'git', source: 'owner/demo' }, + mcpServers: {}, + commands: [], + skills: [], + agents: [], + resolvedSettings: [], + config: {}, + contextFiles: [], +} as unknown as Extension; + +function createManager() { + return { + isFavorite: vi.fn(() => false), + getExtensionScope: vi.fn(() => 'user' as const), + setExtensionActivationScope: vi.fn().mockResolvedValue({ warnings: [] }), + setExtensionScope: vi.fn(), + disableExtension: vi.fn().mockResolvedValue({ warnings: [] }), + enableExtension: vi.fn().mockResolvedValue({ warnings: [] }), + checkForExtensionUpdate: vi + .fn() + .mockResolvedValue(ExtensionUpdateState.UPDATE_AVAILABLE), + updateExtension: vi.fn().mockResolvedValue({ warnings: [] }), + uninstallExtension: vi.fn().mockResolvedValue({ warnings: [] }), + }; +} + +function renderView( + manager: ReturnType, + onStatus: (status: StatusMessage | null) => void, + onReload = vi.fn(), + onExit = vi.fn(), +) { + const config = { + getExtensionManager: () => manager, + } as unknown as Config; + render( + , + ); + return { onReload, onExit }; +} + +async function openScopeSelect(): Promise { + const detail = mockPluginDetailView.mock.calls.at(-1)?.[0] as + | DetailProps + | undefined; + await act(async () => { + detail?.onAction('change-scope'); + }); + return mockRadioButtonSelect.mock.calls.at(-1)?.[0] as unknown as SelectProps; +} + +describe('ExtensionActionsView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('commits a scope change atomically and surfaces committed warnings', async () => { + const manager = createManager(); + manager.setExtensionActivationScope.mockResolvedValueOnce({ + warnings: [ + { code: 'extension_runtime_refresh_failed', error: 'refresh failed' }, + ], + }); + const statuses: Array = []; + const { onReload } = renderView(manager, (status) => statuses.push(status)); + const select = await openScopeSelect(); + + await act(async () => { + select.onSelect('project'); + }); + + await waitFor(() => + expect(manager.setExtensionActivationScope).toHaveBeenCalledWith( + 'demo-id', + { scope: 'workspace', workspacePath: process.cwd() }, + ), + ); + expect(manager.setExtensionScope).toHaveBeenCalledWith('demo', 'project'); + expect(onReload).toHaveBeenCalledOnce(); + expect(statuses).toContainEqual({ + type: 'warning', + text: 'Set "demo" scope with warnings: refresh failed', + }); + }); + + it('does not update scope preferences when the atomic mutation fails', async () => { + const manager = createManager(); + manager.setExtensionActivationScope.mockRejectedValueOnce( + new Error('scope failed'), + ); + const statuses: Array = []; + const { onReload } = renderView(manager, (status) => statuses.push(status)); + const select = await openScopeSelect(); + + await act(async () => { + select.onSelect('project'); + }); + + await waitFor(() => + expect(statuses).toContainEqual({ + type: 'error', + text: 'scope failed', + }), + ); + expect(manager.setExtensionScope).not.toHaveBeenCalled(); + expect(onReload).not.toHaveBeenCalled(); + }); + + it('reloads committed scope changes when saving the preference fails', async () => { + const manager = createManager(); + manager.setExtensionActivationScope.mockResolvedValueOnce({}); + manager.setExtensionScope.mockImplementationOnce(() => { + throw new Error('preference denied'); + }); + const statuses: Array = []; + const { onReload } = renderView(manager, (status) => statuses.push(status)); + const select = await openScopeSelect(); + + await act(async () => { + select.onSelect('project'); + }); + + await waitFor(() => expect(onReload).toHaveBeenCalledOnce()); + expect(statuses).toContainEqual({ + type: 'warning', + text: 'Set "demo" scope with warnings: preference denied', + }); + }); + + it('surfaces committed activation warnings and reloads the view', async () => { + const manager = createManager(); + manager.disableExtension.mockResolvedValueOnce({ + warnings: [ + { code: 'extension_runtime_refresh_failed', error: 'refresh failed' }, + ], + }); + const statuses: Array = []; + const { onReload } = renderView(manager, (status) => statuses.push(status)); + const detail = mockPluginDetailView.mock.calls.at(-1)?.[0] as + | DetailProps + | undefined; + + await act(async () => { + await detail?.onAction('toggle'); + }); + + expect(manager.disableExtension).toHaveBeenCalledWith( + 'demo', + SettingScope.User, + ); + expect(onReload).toHaveBeenCalledOnce(); + expect(statuses).toContainEqual({ + type: 'warning', + text: '"demo" changed with warnings: refresh failed', + }); + }); + + it('surfaces update warnings distinctly and reloads the view', async () => { + const manager = createManager(); + manager.updateExtension.mockResolvedValueOnce({ + warnings: [ + { + code: 'extension_settings_legacy_sync_failed', + error: 'keychain unavailable', + }, + ], + }); + const statuses: Array = []; + const { onReload } = renderView(manager, (status) => statuses.push(status)); + const detail = mockPluginDetailView.mock.calls.at(-1)?.[0] as + | DetailProps + | undefined; + + await act(async () => { + await detail?.onAction('update'); + }); + + expect(onReload).toHaveBeenCalledOnce(); + expect(statuses).toContainEqual({ + type: 'warning', + text: 'Updated "demo" with warnings: extension_settings_legacy_sync_failed: keychain unavailable.', + }); + }); + + it('surfaces committed uninstall warnings and reloads before exiting', async () => { + const manager = createManager(); + manager.uninstallExtension.mockResolvedValueOnce({ + warnings: [ + { code: 'extension_runtime_refresh_failed', error: 'refresh failed' }, + ], + }); + const statuses: Array = []; + const { onReload, onExit } = renderView(manager, (status) => + statuses.push(status), + ); + const detail = mockPluginDetailView.mock.calls.at(-1)?.[0] as + | DetailProps + | undefined; + await act(async () => { + detail?.onAction('uninstall'); + }); + const confirm = mockUninstallConfirmStep.mock.calls.at(-1)?.[0] as + | ConfirmProps + | undefined; + + await act(async () => { + await confirm?.onConfirm(extension); + }); + + expect(manager.uninstallExtension).toHaveBeenCalledWith('demo', false); + expect(onReload).toHaveBeenCalledOnce(); + expect(onExit).toHaveBeenCalledOnce(); + expect(statuses).toContainEqual({ + type: 'warning', + text: 'Uninstalled "demo" with warnings: refresh failed', + }); + }); +}); diff --git a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx index ce00eff15db..c0f55445427 100644 --- a/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx +++ b/packages/cli/src/ui/components/extensions/views/ExtensionActionsView.tsx @@ -112,22 +112,39 @@ export const ExtensionActionsView = ({ const name = extension.name; try { switch (action) { - case 'toggle': + case 'toggle': { + let activationResult; if (enabled) { - await manager.disableExtension(name, settingScopeFor(scope)); + activationResult = await manager.disableExtension( + name, + settingScopeFor(scope), + ); } else { - await manager.enableExtension(name, settingScopeFor(scope)); + activationResult = await manager.enableExtension( + name, + settingScopeFor(scope), + ); } setEnabled(!enabled); + const warnings = activationResult.warnings ?? []; onStatus({ - type: 'success', - text: t('"{{name}}" {{state}}.', { - name, - state: enabled ? t('disabled') : t('enabled'), - }), + type: warnings.length > 0 ? 'warning' : 'success', + text: + warnings.length > 0 + ? t('"{{name}}" changed with warnings: {{detail}}', { + name, + detail: warnings + .map((warning) => warning.error) + .join('; '), + }) + : t('"{{name}}" {{state}}.', { + name, + state: enabled ? t('disabled') : t('enabled'), + }), }); onReload(); break; + } case 'favorite': { const now = manager.toggleFavorite(name); setIsFavorite(now); @@ -189,18 +206,31 @@ export const ExtensionActionsView = ({ } break; } - case 'update': - await manager.updateExtension( + case 'update': { + const result = await manager.updateExtension( extension, ExtensionUpdateState.UPDATE_AVAILABLE, () => {}, ); - onStatus({ - type: 'success', - text: t('Updated "{{name}}".', { name }), - }); + if (result?.warnings?.length) { + onStatus({ + type: 'warning', + text: t('Updated "{{name}}" with warnings: {{warnings}}.', { + name, + warnings: result.warnings + .map((warning) => `${warning.code}: ${warning.error}`) + .join('; '), + }), + }); + } else { + onStatus({ + type: 'success', + text: t('Updated "{{name}}".', { name }), + }); + } onReload(); break; + } case 'uninstall': setSub('uninstall-confirm'); break; @@ -220,45 +250,36 @@ export const ExtensionActionsView = ({ const name = extension.name; setScopeBusy(true); try { - // Apply enablement first: Global -> User; Project/Local -> workspace - // only. Record the scope preference only once enablement succeeds so a - // failed enable can't leave the prefs pointing at a scope the extension - // isn't actually enabled at. - if (newScope === 'user') { - await manager.enableExtension(name, SettingScope.User); - } else { - await manager.disableExtension(name, SettingScope.User); - try { - await manager.enableExtension(name, SettingScope.Workspace); - } catch (enableError) { - // The User-scope disable already landed; if the Workspace enable - // fails the extension would be disabled everywhere. Roll the User - // enable back so it isn't silently dead. - try { - await manager.enableExtension(name, SettingScope.User); - } catch (rollbackError) { - // Rollback also failed: the extension is now disabled at every - // scope. Surface that explicitly — the bare enable error wouldn't - // tell the user the extension is dead and needs manual recovery. - throw new Error( - t( - 'Could not change scope, and the rollback also failed — "{{name}}" may be disabled at all scopes. Re-enable it from the Installed tab. ({{error}})', - { name, error: getErrorMessage(rollbackError) }, - ), - ); - } - throw enableError; - } + const result = await manager.setExtensionActivationScope( + extension.id, + newScope === 'user' + ? { scope: 'user' } + : { scope: 'workspace', workspacePath: process.cwd() }, + ); + let preferenceWarning: string | undefined; + try { + manager.setExtensionScope(name, newScope); + } catch (error) { + preferenceWarning = getErrorMessage(error); } - manager.setExtensionScope(name, newScope); setScope(newScope); setEnabled(true); + const warnings = [ + ...(result.warnings ?? []).map((warning) => warning.error), + ...(preferenceWarning ? [preferenceWarning] : []), + ]; onStatus({ - type: 'success', - text: t('Set "{{name}}" scope to {{scope}}.', { - name, - scope: t(SCOPE_LABEL[newScope]), - }), + type: warnings.length > 0 ? 'warning' : 'success', + text: + warnings.length > 0 + ? t('Set "{{name}}" scope with warnings: {{detail}}', { + name, + detail: warnings.join('; '), + }) + : t('Set "{{name}}" scope to {{scope}}.', { + name, + scope: t(SCOPE_LABEL[newScope]), + }), }); onReload(); } catch (error) { @@ -275,10 +296,17 @@ export const ExtensionActionsView = ({ if (!manager) return; setUninstallBusy(true); try { - await manager.uninstallExtension(ext.name, false); + const result = await manager.uninstallExtension(ext.name, false); + const warnings = result.warnings ?? []; onStatus({ - type: 'success', - text: t('Uninstalled "{{name}}".', { name: ext.name }), + type: warnings.length > 0 ? 'warning' : 'success', + text: + warnings.length > 0 + ? t('Uninstalled "{{name}}" with warnings: {{detail}}', { + name: ext.name, + detail: warnings.map((warning) => warning.error).join('; '), + }) + : t('Uninstalled "{{name}}".', { name: ext.name }), }); onReload(); } catch (error) { diff --git a/packages/cli/src/ui/components/views/ExtensionsList.tsx b/packages/cli/src/ui/components/views/ExtensionsList.tsx index c7549163596..c6a211eec67 100644 --- a/packages/cli/src/ui/components/views/ExtensionsList.tsx +++ b/packages/cli/src/ui/components/views/ExtensionsList.tsx @@ -43,6 +43,7 @@ export const ExtensionsList = () => { break; case ExtensionUpdateState.UPDATE_AVAILABLE: case ExtensionUpdateState.UPDATED_NEEDS_RESTART: + case ExtensionUpdateState.UPDATED_WITH_WARNINGS: stateColor = 'yellow'; break; case ExtensionUpdateState.ERROR: diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts index bc0906aa334..aad26da69a4 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts @@ -362,6 +362,49 @@ describe('useExtensionUpdates', () => { ); }); + it('should surface automatic update warnings', async () => { + const extension = createMockExtension({ + name: 'test-extension', + installMetadata: { + type: 'git', + source: 'https://some.git/repo', + autoUpdate: true, + }, + }); + const addItem = vi.fn(); + const extensionManager = createMockExtensionManager( + [extension], + async (callback) => { + callback('test-extension', ExtensionUpdateState.UPDATE_AVAILABLE); + }, + { + originalVersion: '1.0.0', + updatedVersion: '1.1.0', + name: 'test-extension', + warnings: [ + { + code: 'extension_settings_legacy_sync_failed', + error: 'keychain unavailable', + }, + ], + }, + ); + + renderHook(() => + useExtensionUpdates(extensionManager, addItem, tempHomeDir), + ); + + await waitFor(() => { + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.WARNING, + text: 'Extension "test-extension" updated with warnings: extension_settings_legacy_sync_failed: keychain unavailable.', + }, + expect.any(Number), + ); + }); + }); + it('should batch update notifications for multiple extensions', async () => { const extension1 = createMockExtension({ id: 'test-extension-1-id', diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.ts index 11db90a0b23..17a3a22ac7a 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.ts @@ -292,6 +292,16 @@ export const useExtensionUpdates = ( ) .then((result) => { if (!result) return; + if (result.warnings?.length) { + addItem( + { + type: MessageType.WARNING, + text: `Extension "${getExtensionDisplayName(extension, getCurrentLanguage())}" updated with warnings: ${result.warnings.map((warning) => `${warning.code}: ${warning.error}`).join('; ')}.`, + }, + Date.now(), + ); + return; + } addItem( { type: MessageType.INFO, diff --git a/packages/cli/src/ui/state/extensions.ts b/packages/cli/src/ui/state/extensions.ts index 88f0cadcdf9..4fcdf56e466 100644 --- a/packages/cli/src/ui/state/extensions.ts +++ b/packages/cli/src/ui/state/extensions.ts @@ -9,6 +9,7 @@ import { checkExhaustive } from '../../utils/checks.js'; export enum ExtensionUpdateState { CHECKING_FOR_UPDATES = 'checking for updates', UPDATED_NEEDS_RESTART = 'updated, needs restart', + UPDATED_WITH_WARNINGS = 'updated with warnings', UPDATING = 'updating', UPDATED = 'updated', UPDATE_AVAILABLE = 'update available', diff --git a/packages/core/package.json b/packages/core/package.json index a8edbb8b61d..8e3f0ebd531 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -47,7 +47,6 @@ "chokidar": "^4.0.3", "diff": "^7.0.0", "dotenv": "^17.1.0", - "extract-zip": "^2.0.1", "fast-levenshtein": "^2.0.6", "fast-uri": "^3.0.6", "fdir": "^6.4.6", @@ -73,7 +72,8 @@ "uuid": "^9.0.1", "web-tree-sitter": "^0.24.7", "ws": "^8.18.0", - "yaml": "^2.8.1" + "yaml": "^2.8.1", + "yauzl": "^2.10.0" }, "optionalDependencies": { "@lydell/node-pty": "1.2.0-beta.10", @@ -91,6 +91,7 @@ "@types/picomatch": "^4.0.1", "@types/prompts": "^2.4.9", "@types/tar": "^6.1.13", + "@types/yauzl": "^2.9.1", "@types/ws": "^8.5.10", "msw": "^2.3.4", "tree-sitter-wasms": "^0.1.13", diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 0d386793ecc..c1d324cb8bb 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -607,6 +607,7 @@ function normalizeGitCoAuthor(value: GitCoAuthorParam | undefined): { } export type ExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini'; +export type ExtensionNetworkPolicy = 'public'; export interface ExtensionInstallMetadata { source: string; @@ -619,6 +620,7 @@ export interface ExtensionInstallMetadata { allowPreRelease?: boolean; marketplaceConfig?: ClaudeMarketplaceConfig; pluginName?: string; + networkPolicy?: ExtensionNetworkPolicy; } export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 25_000; diff --git a/packages/core/src/extension/archive-safety.test.ts b/packages/core/src/extension/archive-safety.test.ts new file mode 100644 index 00000000000..52a001de6f6 --- /dev/null +++ b/packages/core/src/extension/archive-safety.test.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as tar from 'tar'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { assertTarArchiveHasNoLinks } from './archive-safety.js'; + +describe('assertTarArchiveHasNoLinks', () => { + let root: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-tar-safety-')); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + it.runIf(process.platform !== 'win32')( + 'rejects a large link set without throwing outside the promise', + async () => { + const links = Array.from({ length: 101 }, (_, index) => `link-${index}`); + await Promise.all( + links.map(async (link) => { + await fs.symlink('missing-target', path.join(root, link)); + }), + ); + const archive = path.join(root, 'links.tar'); + await tar.c({ cwd: root, file: archive }, links); + + await expect(assertTarArchiveHasNoLinks(archive)).rejects.toThrow( + 'more than 100 unsupported link entries', + ); + }, + ); +}); diff --git a/packages/core/src/extension/archive-safety.ts b/packages/core/src/extension/archive-safety.ts new file mode 100644 index 00000000000..75a046f6443 --- /dev/null +++ b/packages/core/src/extension/archive-safety.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import { pipeline } from 'node:stream/promises'; +import * as tar from 'tar'; +import { stripAnsiAndControl } from '../utils/textUtils.js'; + +const MAX_REPORTED_ENTRY_PATH_LENGTH = 200; +const MAX_REPORTED_LINK_ENTRIES = 10; +const MAX_LINK_ENTRIES = 100; + +function formatEntryPath(entryPath: string): string { + const sanitized = stripAnsiAndControl(entryPath); + if (sanitized.length <= MAX_REPORTED_ENTRY_PATH_LENGTH) return sanitized; + return `${sanitized.slice(0, MAX_REPORTED_ENTRY_PATH_LENGTH - 3)}...`; +} + +export async function assertTarArchiveHasNoLinks( + file: string, + signal?: AbortSignal, +): Promise { + const unsupportedLinkPaths: string[] = []; + let unsupportedLinkCount = 0; + let linkLimitError: Error | undefined; + const onReadEntry = (entry: tar.ReadEntry) => { + if (entry.type === 'SymbolicLink' || entry.type === 'Link') { + unsupportedLinkCount += 1; + const unsupportedLinkPath = + formatEntryPath(entry.path) || ''; + if (unsupportedLinkPaths.length < MAX_REPORTED_LINK_ENTRIES) { + unsupportedLinkPaths.push(unsupportedLinkPath); + } + if ( + unsupportedLinkCount > MAX_LINK_ENTRIES && + linkLimitError === undefined + ) { + linkLimitError = new Error( + `Tar archive contains more than ${MAX_LINK_ENTRIES} unsupported link entries: ${unsupportedLinkPaths.join(', ')}`, + ); + } + } + }; + signal?.throwIfAborted(); + if (signal) { + try { + await pipeline(fs.createReadStream(file), tar.t({ onReadEntry }), { + signal, + }); + } catch (error) { + signal.throwIfAborted(); + throw error; + } + signal.throwIfAborted(); + } else { + await tar.t({ file, onReadEntry }); + } + if (linkLimitError) throw linkLimitError; + if (unsupportedLinkCount > 0) { + const entryLabel = + unsupportedLinkCount === 1 + ? 'unsupported link entry' + : `${unsupportedLinkCount} unsupported link entries`; + throw new Error( + `Tar archive contains ${entryLabel}: ${unsupportedLinkPaths.join(', ')}`, + ); + } +} diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index 23a339b9c88..7869758ac9c 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -415,12 +415,50 @@ describe('convertClaudePluginPackage', () => { originSource: 'Claude', }, expect.any(String), + undefined, ); expect(cloneFromGit).not.toHaveBeenCalled(); fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + it('does not fall back to cloning when conversion is aborted', async () => { + const pluginSourceDir = path.join(testDir, 'plugin-abort'); + const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify({ + name: 'test-marketplace', + owner: { name: 'Test Owner', email: 'test@example.com' }, + plugins: [ + { + name: 'remote', + version: '1.0.0', + source: 'https://github.com/owner/plugin', + strict: false, + }, + ], + } satisfies ClaudeMarketplaceConfig), + ); + const controller = new AbortController(); + const reason = new Error('conversion expired'); + vi.mocked(downloadFromGitHubRelease).mockImplementationOnce(async () => { + controller.abort(reason); + throw new Error('download failed'); + }); + + await expect( + convertClaudePluginPackage( + pluginSourceDir, + 'remote', + undefined, + controller.signal, + ), + ).rejects.toBe(reason); + expect(cloneFromGit).not.toHaveBeenCalled(); + }); + it('should use all skills from folder when config does not specify skills', async () => { // Setup: Create a plugin source with skills but no skills config const pluginSourceDir = path.join(testDir, 'plugin-source-default'); @@ -1330,11 +1368,15 @@ describe('convertClaudePluginPackage — git-subdir source', () => { sha: 'abc123', }); - const result = await convertClaudePluginPackage(extDir, 'p'); + const result = await convertClaudePluginPackage(extDir, 'p', 'public'); expect(result.config.name).toBe('p'); // The immutable sha is preferred over the named ref when both are present. - const meta = vi.mocked(cloneFromGit).mock.calls[0][0] as { ref?: string }; + const meta = vi.mocked(cloneFromGit).mock.calls[0][0] as { + ref?: string; + networkPolicy?: string; + }; expect(meta.ref).toBe('abc123'); + expect(meta.networkPolicy).toBe('public'); fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 86246942a4e..40c5e3e128e 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -451,7 +451,10 @@ export function convertClaudeToQwenConfig( export async function convertClaudePluginPackage( extensionDir: string, pluginName: string, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], + signal?: AbortSignal, ): Promise<{ config: ExtensionConfig; convertedDir: string }> { + signal?.throwIfAborted(); // Step 1: Load marketplace.json const marketplaceJsonPath = path.join( extensionDir, @@ -494,6 +497,8 @@ export async function convertClaudePluginPackage( marketplacePlugin, extensionDir, pluginDir, + networkPolicy, + signal, ); if (!fs.existsSync(pluginSource)) { @@ -1015,7 +1020,10 @@ async function resolvePluginSource( pluginConfig: ClaudeMarketplacePluginConfig, marketplaceDir: string, pluginDir: string, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const source = pluginConfig.source; // Handle string source (relative path or URL) @@ -1031,11 +1039,13 @@ async function resolvePluginSource( source, type: 'git', originSource: 'Claude', + networkPolicy, }; try { - await downloadFromGitHubRelease(installMetadata, pluginDir); + await downloadFromGitHubRelease(installMetadata, pluginDir, signal); } catch { - await cloneFromGit(installMetadata, pluginDir); + signal?.throwIfAborted(); + await cloneFromGit(installMetadata, pluginDir, signal); } return pluginDir; } @@ -1085,11 +1095,13 @@ async function resolvePluginSource( const installMetadata: ExtensionInstallMetadata = { source: `https://github.com/${source.repo}`, type: 'git', + networkPolicy, }; try { - await downloadFromGitHubRelease(installMetadata, pluginDir); + await downloadFromGitHubRelease(installMetadata, pluginDir, signal); } catch { - await cloneFromGit(installMetadata, pluginDir); + signal?.throwIfAborted(); + await cloneFromGit(installMetadata, pluginDir, signal); } return pluginDir; } @@ -1098,11 +1110,13 @@ async function resolvePluginSource( const installMetadata: ExtensionInstallMetadata = { source: source.url, type: 'git', + networkPolicy, }; try { - await downloadFromGitHubRelease(installMetadata, pluginDir); + await downloadFromGitHubRelease(installMetadata, pluginDir, signal); } catch { - await cloneFromGit(installMetadata, pluginDir); + signal?.throwIfAborted(); + await cloneFromGit(installMetadata, pluginDir, signal); } return pluginDir; } @@ -1116,8 +1130,9 @@ async function resolvePluginSource( // Prefer the immutable SHA pin when present; fall back to a named ref. ref: source.sha || source.ref, originSource: 'Claude', + networkPolicy, }; - await cloneFromGit(installMetadata, pluginDir); + await cloneFromGit(installMetadata, pluginDir, signal); // `source.path` comes from an untrusted manifest. Confine it to the cloned // repo so a value like "../../.ssh" (or an absolute path) cannot escape. if (!source.path || source.path === '.' || path.isAbsolute(source.path)) { diff --git a/packages/core/src/extension/extension-converter.ts b/packages/core/src/extension/extension-converter.ts index 8d916060ceb..d3fa57a9abd 100644 --- a/packages/core/src/extension/extension-converter.ts +++ b/packages/core/src/extension/extension-converter.ts @@ -15,7 +15,10 @@ import { convertClaudePluginPackage, convertClaudePluginStandalone, } from './claude-converter.js'; -import type { ExtensionOriginSource } from '../config/config.js'; +import type { + ExtensionNetworkPolicy, + ExtensionOriginSource, +} from '../config/config.js'; export const SUPPORTED_EXTENSION_MANIFESTS = [ EXTENSIONS_CONFIG_FILENAME, @@ -27,7 +30,10 @@ export const SUPPORTED_EXTENSION_MANIFESTS = [ export async function convertGeminiOrClaudeExtension( extensionDir: string, pluginName?: string, + networkPolicy?: ExtensionNetworkPolicy, + signal?: AbortSignal, ): Promise<{ extensionDir: string; originSource: ExtensionOriginSource }> { + signal?.throwIfAborted(); let newExtensionDir = extensionDir; let originSource: ExtensionOriginSource = 'QwenCode'; const configFilePath = path.join( @@ -42,7 +48,12 @@ export async function convertGeminiOrClaudeExtension( originSource = 'Gemini'; } else if (pluginName) { newExtensionDir = ( - await convertClaudePluginPackage(extensionDir, pluginName) + await convertClaudePluginPackage( + extensionDir, + pluginName, + networkPolicy, + signal, + ) ).convertedDir; originSource = 'Claude'; } else if ( @@ -52,5 +63,6 @@ export async function convertGeminiOrClaudeExtension( .convertedDir; originSource = 'Claude'; } + signal?.throwIfAborted(); return { extensionDir: newExtensionDir, originSource }; } diff --git a/packages/core/src/extension/extension-store.test.ts b/packages/core/src/extension/extension-store.test.ts new file mode 100644 index 00000000000..e2eebb305a9 --- /dev/null +++ b/packages/core/src/extension/extension-store.test.ts @@ -0,0 +1,1635 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import { promises as fsp } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawn } from 'node:child_process'; +import lockfile from 'proper-lockfile'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ExtensionConflictError, + ExtensionStore, + ExtensionStoreCorruptError, +} from './extension-store.js'; + +describe('ExtensionStore', () => { + let root: string; + let extensionsDir: string; + let storeDir: string; + let enablementPath: string; + + beforeEach(async () => { + root = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-extension-store-')); + extensionsDir = path.join(root, 'extensions'); + storeDir = path.join(root, 'extension-store'); + enablementPath = path.join(extensionsDir, 'extension-enablement.json'); + await fsp.mkdir(extensionsDir, { recursive: true }); + }); + + afterEach(async () => { + await fsp.rm(root, { recursive: true, force: true }); + }); + + const makeStore = () => + new ExtensionStore({ extensionsDir, storeDir, enablementPath }); + + const readQuarantinedJournal = async (journal: string): Promise => { + const prefix = `${path.basename(journal)}.corrupt-`; + const quarantined = (await fsp.readdir(path.dirname(journal))).find( + (name) => name.startsWith(prefix), + ); + expect(quarantined).toBeDefined(); + return await fsp.readFile( + path.join(path.dirname(journal), quarantined!), + 'utf8', + ); + }; + + it('imports V1 rules without materializing workspace overrides', async () => { + await fsp.writeFile( + enablementPath, + JSON.stringify({ + demo: { overrides: ['!/work/*', '/work/enabled/*'] }, + }), + ); + const store = makeStore(); + + const snapshot = await store.ensureInitialized([ + { id: 'a'.repeat(64), name: 'demo' }, + ]); + + expect(snapshot.generation).toBe(0); + expect(snapshot.extensions['a'.repeat(64)]).toEqual({ + name: 'demo', + defaultActivation: 'enabled', + workspaceOverrides: {}, + legacyPathRules: ['!/work/*', '/work/enabled/*'], + }); + expect( + store.getActivation(snapshot, 'a'.repeat(64), 'demo', '/work/disabled'), + ).toMatchObject({ effective: 'disabled', source: 'legacy_path_rule' }); + expect( + store.getActivation(snapshot, 'a'.repeat(64), 'demo', '/work/enabled'), + ).toMatchObject({ effective: 'enabled', source: 'legacy_path_rule' }); + }); + + it('preserves exact workspace overrides when the global default changes', async () => { + const store = makeStore(); + const id = 'b'.repeat(64); + await store.ensureInitialized([{ id, name: 'demo' }]); + await store.setWorkspaceActivation( + { id, name: 'demo' }, + '/workspace/a', + 'enabled', + ); + + const snapshot = await store.setDefaultActivation( + { id, name: 'demo' }, + 'disabled', + ); + + expect(snapshot.generation).toBe(2); + expect(snapshot.extensions[id]?.workspaceOverrides).toEqual({ + '/workspace/a': 'enabled', + }); + expect( + store.getActivation(snapshot, id, 'demo', '/workspace/a'), + ).toMatchObject({ effective: 'enabled', source: 'workspace_override' }); + }); + + it('uses an inherit mask when clearing an override matched by a legacy rule', async () => { + await fsp.writeFile( + enablementPath, + JSON.stringify({ demo: { overrides: ['!/workspace/*'] } }), + ); + const store = makeStore(); + const id = 'c'.repeat(64); + await store.ensureInitialized([{ id, name: 'demo' }]); + + const snapshot = await store.clearWorkspaceActivation( + { id, name: 'demo' }, + '/workspace/a', + ); + + expect(snapshot.extensions[id]?.workspaceOverrides).toEqual({ + '/workspace/a': 'inherit', + }); + expect(store.getActivation(snapshot, id, 'demo', '/workspace/a')).toEqual({ + default: 'enabled', + workspace: 'inherit', + effective: 'enabled', + source: 'default', + }); + }); + + it('serializes writes from independent store instances without losing updates', async () => { + const id = 'd'.repeat(64); + const first = makeStore(); + const second = makeStore(); + await first.ensureInitialized([{ id, name: 'demo' }]); + + await Promise.all([ + first.setWorkspaceActivation( + { id, name: 'demo' }, + '/workspace/a', + 'enabled', + ), + second.setWorkspaceActivation( + { id, name: 'demo' }, + '/workspace/b', + 'disabled', + ), + ]); + + const snapshot = await first.readSnapshot(); + expect(snapshot.generation).toBe(2); + expect(snapshot.extensions[id]?.workspaceOverrides).toEqual({ + '/workspace/a': 'enabled', + '/workspace/b': 'disabled', + }); + }); + + it('preserves a committed result when lock release reports an error', async () => { + const store = makeStore(); + const identity = { id: 'd3'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + const lock = lockfile.lock.bind(lockfile); + const lockSpy = vi + .spyOn(lockfile, 'lock') + .mockImplementation(async (...args) => { + const release = await lock(...args); + return async () => { + await release(); + throw new Error('release failed'); + }; + }); + + try { + await expect( + store.setDefaultActivation(identity, 'disabled'), + ).resolves.toMatchObject({ generation: 1 }); + } finally { + lockSpy.mockRestore(); + } + + await expect(store.readSnapshot()).resolves.toMatchObject({ + generation: 1, + extensions: { + [identity.id]: { defaultActivation: 'disabled' }, + }, + }); + }); + + it('serializes mutations from two Node processes sharing QWEN_HOME', async () => { + const id = 'd2'.repeat(32); + const store = makeStore(); + await store.ensureInitialized([{ id, name: 'demo' }]); + const moduleUrl = new URL('./extension-store.ts', import.meta.url).href; + const runChild = async (workspacePath: string, activation: string) => { + const source = ` + import { ExtensionStore } from ${JSON.stringify(moduleUrl)}; + const store = new ExtensionStore(${JSON.stringify({ extensionsDir, storeDir, enablementPath })}); + await store.setWorkspaceActivation( + ${JSON.stringify({ id, name: 'demo' })}, + ${JSON.stringify(workspacePath)}, + ${JSON.stringify(activation)}, + ); + `; + await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ['--import', 'tsx', '--input-type=module', '--eval', source], + { cwd: process.cwd(), stdio: ['ignore', 'ignore', 'pipe'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`child exited ${code}: ${stderr}`)); + }); + }); + }; + + await Promise.all([ + runChild('/workspace/process-a', 'enabled'), + runChild('/workspace/process-b', 'disabled'), + ]); + + const snapshot = await store.readSnapshot(); + expect(snapshot.generation).toBe(2); + expect(snapshot.extensions[id]?.workspaceOverrides).toEqual({ + '/workspace/process-a': 'enabled', + '/workspace/process-b': 'disabled', + }); + }); + + it('holds mutation commits while a consistent artifact snapshot is read', async () => { + const id = 'd3'.repeat(32); + const store = makeStore(); + await store.ensureInitialized([{ id, name: 'demo' }]); + let releaseRead!: () => void; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + let readStarted!: () => void; + const started = new Promise((resolve) => { + readStarted = resolve; + }); + const reading = store.readConsistent(async () => { + readStarted(); + await readGate; + return { + value: 'complete-artifact-scan', + extensions: [{ id, name: 'demo' }], + }; + }); + await started; + let mutationSettled = false; + const mutation = store + .setDefaultActivation({ id, name: 'demo' }, 'disabled') + .finally(() => { + mutationSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(mutationSettled).toBe(false); + + releaseRead(); + await expect(reading).resolves.toMatchObject({ + value: 'complete-artifact-scan', + snapshot: { generation: 0 }, + }); + await expect(mutation).resolves.toMatchObject({ generation: 1 }); + }); + + it.runIf(process.platform !== 'win32')( + 'uses one workspace key for symlink and real paths', + async () => { + const store = makeStore(); + const id = 'd1'.repeat(32); + const realWorkspace = path.join(root, 'real-workspace'); + const linkedWorkspace = path.join(root, 'linked-workspace'); + await fsp.mkdir(realWorkspace); + await fsp.symlink(realWorkspace, linkedWorkspace); + await store.ensureInitialized([{ id, name: 'demo' }]); + + const snapshot = await store.setWorkspaceActivation( + { id, name: 'demo' }, + linkedWorkspace, + 'disabled', + ); + + expect(snapshot.extensions[id]?.workspaceOverrides).toEqual({ + [fs.realpathSync.native(realWorkspace)]: 'disabled', + }); + expect( + store.getActivation(snapshot, id, 'demo', realWorkspace), + ).toMatchObject({ + effective: 'disabled', + source: 'workspace_override', + }); + }, + ); + + it.runIf(process.platform !== 'win32')( + 'matches legacy rules against symlink and canonical workspace paths', + async () => { + const realWorkspace = path.join(root, 'legacy-real-workspace'); + const linkedWorkspace = path.join(root, 'legacy-linked-workspace'); + await fsp.mkdir(realWorkspace); + await fsp.symlink(realWorkspace, linkedWorkspace); + await fsp.writeFile( + enablementPath, + JSON.stringify({ + demo: { overrides: [`!${linkedWorkspace}/*`] }, + }), + ); + const store = makeStore(); + const identity = { id: 'd2'.repeat(32), name: 'demo' }; + let snapshot = await store.ensureInitialized([identity]); + + expect( + store.getActivation( + snapshot, + identity.id, + identity.name, + linkedWorkspace, + ), + ).toMatchObject({ + effective: 'disabled', + source: 'legacy_path_rule', + }); + + snapshot = await store.setWorkspaceActivation( + identity, + linkedWorkspace, + 'enabled', + ); + expect( + store.getActivation( + snapshot, + identity.id, + identity.name, + linkedWorkspace, + ), + ).toMatchObject({ + effective: 'enabled', + source: 'workspace_override', + }); + + snapshot = await store.clearWorkspaceActivation( + identity, + linkedWorkspace, + ); + expect( + store.getActivation( + snapshot, + identity.id, + identity.name, + linkedWorkspace, + ), + ).toMatchObject({ + effective: 'enabled', + source: 'default', + }); + }, + ); + + it('writes a V1 projection after every policy mutation', async () => { + const store = makeStore(); + const id = 'e'.repeat(64); + await store.ensureInitialized([{ id, name: 'demo' }]); + + await store.setDefaultActivation({ id, name: 'demo' }, 'disabled'); + await store.setWorkspaceActivation( + { id, name: 'demo' }, + '/workspace/a', + 'enabled', + ); + + const projection = JSON.parse( + await fsp.readFile(enablementPath, 'utf8'), + ) as Record; + expect(projection['demo']?.overrides).toEqual(['!/*', '/workspace/a/']); + }); + + it('repairs an older V1 projection without changing generation', async () => { + const store = makeStore(); + const id = 'e1'.repeat(32); + await store.ensureInitialized([{ id, name: 'demo' }]); + const changed = await store.setDefaultActivation( + { id, name: 'demo' }, + 'disabled', + ); + await fsp.writeFile(enablementPath, '{}'); + const stateStat = await fsp.stat(path.join(storeDir, 'state.json')); + const older = new Date(stateStat.mtimeMs - 1_000); + await fsp.utimes(enablementPath, older, older); + + const repaired = await store.ensureInitialized([{ id, name: 'demo' }]); + + expect(repaired.generation).toBe(changed.generation); + expect(JSON.parse(await fsp.readFile(enablementPath, 'utf8'))).toEqual({ + demo: { overrides: ['!/*'] }, + }); + }); + + it('fails closed when state and a different V1 projection have equal mtimes', async () => { + const store = makeStore(); + const id = 'e6'.repeat(32); + await store.ensureInitialized([{ id, name: 'demo' }]); + await store.setDefaultActivation({ id, name: 'demo' }, 'disabled'); + await fsp.writeFile(enablementPath, '{}'); + const sameTime = new Date(Math.floor(Date.now() / 1_000) * 1_000); + await Promise.all([ + fsp.utimes(path.join(storeDir, 'state.json'), sameTime, sameTime), + fsp.utimes(enablementPath, sameTime, sameTime), + ]); + + await expect( + store.ensureInitialized([{ id, name: 'demo' }]), + ).rejects.toBeInstanceOf(ExtensionStoreCorruptError); + expect(JSON.parse(await fsp.readFile(enablementPath, 'utf8'))).toEqual({}); + }); + + it('keeps V2 reads available when an older V1 projection cannot be repaired', async () => { + const store = makeStore(); + const id = 'e5'.repeat(32); + await store.ensureInitialized([{ id, name: 'demo' }]); + const changed = await store.setDefaultActivation( + { id, name: 'demo' }, + 'disabled', + ); + await fsp.writeFile(enablementPath, '{}'); + const stateStat = await fsp.stat(path.join(storeDir, 'state.json')); + const older = new Date(stateStat.mtimeMs - 1_000); + await fsp.utimes(enablementPath, older, older); + + const projectionAgeSpy = vi + .spyOn( + store as unknown as { + legacyProjectionIsNewerThanState(): Promise; + }, + 'legacyProjectionIsNewerThanState', + ) + .mockImplementationOnce(async () => { + await fsp.rm(enablementPath); + await fsp.mkdir(enablementPath); + return false; + }); + try { + const readable = await store.ensureInitialized([{ id, name: 'demo' }]); + expect(readable).toEqual(changed); + expect((await fsp.stat(enablementPath)).isDirectory()).toBe(true); + } finally { + projectionAgeSpy.mockRestore(); + } + + await fsp.rm(enablementPath, { recursive: true }); + await fsp.writeFile(enablementPath, '{}'); + await fsp.utimes(enablementPath, older, older); + await store.ensureInitialized([{ id, name: 'demo' }]); + expect(JSON.parse(await fsp.readFile(enablementPath, 'utf8'))).toEqual({ + demo: { overrides: ['!/*'] }, + }); + }); + + it('imports a newer V1 projection as a sequential downgrade write', async () => { + const store = makeStore(); + const id = 'e2'.repeat(32); + await store.ensureInitialized([{ id, name: 'demo' }]); + await new Promise((resolve) => setTimeout(resolve, 10)); + await fsp.writeFile( + enablementPath, + JSON.stringify({ demo: { overrides: ['!/workspace/*'] } }), + ); + + const imported = await store.ensureInitialized([{ id, name: 'demo' }]); + + expect(imported.generation).toBe(1); + expect(imported.extensions[id]?.legacyPathRules).toEqual(['!/workspace/*']); + }); + + it('repairs an unchanged newer V1 projection without changing generation', async () => { + const store = makeStore(); + const id = 'e4'.repeat(32); + const initialized = await store.ensureInitialized([{ id, name: 'demo' }]); + await new Promise((resolve) => setTimeout(resolve, 10)); + await fsp.writeFile( + enablementPath, + JSON.stringify({ stale: { overrides: ['/workspace/unused'] } }), + ); + + const repaired = await store.ensureInitialized([{ id, name: 'demo' }]); + + expect(repaired.generation).toBe(initialized.generation); + expect(JSON.parse(await fsp.readFile(enablementPath, 'utf8'))).toEqual({}); + }); + + it('merges newly discovered extensions while repairing an older V1 projection', async () => { + const store = makeStore(); + const first = { id: 'e8'.repeat(32), name: 'first' }; + const second = { id: 'e9'.repeat(32), name: 'second' }; + const initialized = await store.ensureInitialized([first]); + await fsp.writeFile( + enablementPath, + JSON.stringify({ stale: { overrides: ['!/workspace/*'] } }), + ); + await fsp.utimes(enablementPath, new Date(0), new Date(0)); + + const repaired = await store.ensureInitialized([first, second]); + + expect(repaired.generation).toBe(initialized.generation + 1); + expect(repaired.extensions[second.id]).toMatchObject({ + name: second.name, + defaultActivation: 'enabled', + workspaceOverrides: {}, + }); + expect(repaired.extensions[second.id]?.legacyPathRules).toBeUndefined(); + }); + + it('preserves artifact generation across a sequential downgrade write', async () => { + const store = makeStore(); + const identity = { id: 'e3'.repeat(32), name: 'demo' }; + const staging = await store.createStagingDirectory(); + await fsp.writeFile(path.join(staging, 'version'), 'one'); + const installed = await store.commitArtifact({ + operation: 'install', + identity, + stagingDirectory: staging, + destinationDirectory: path.join(extensionsDir, identity.name), + initialActivation: { scope: 'user' }, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + await fsp.writeFile( + enablementPath, + JSON.stringify({ demo: { overrides: ['!/workspace/*'] } }), + ); + + const imported = await store.ensureInitialized([identity]); + + expect(imported.extensions[identity.id]?.artifactGeneration).toBe( + installed.extensions[identity.id]?.artifactGeneration, + ); + expect(imported.extensions[identity.id]).toMatchObject({ + defaultActivation: 'enabled', + workspaceOverrides: {}, + legacyPathRules: ['!/workspace/*'], + }); + }); + + it('preserves V2 activation policy across a sequential downgrade write', async () => { + const store = makeStore(); + const identity = { id: 'e4'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + await store.setDefaultActivation(identity, 'disabled'); + await store.setWorkspaceActivation( + identity, + '/workspace/enabled', + 'enabled', + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + await fsp.writeFile( + enablementPath, + JSON.stringify({ demo: { overrides: ['!/workspace/legacy/*'] } }), + ); + + const imported = await store.ensureInitialized([identity]); + + expect(imported.extensions[identity.id]).toMatchObject({ + defaultActivation: 'disabled', + workspaceOverrides: { '/workspace/enabled': 'enabled' }, + legacyPathRules: ['!/workspace/legacy/*'], + }); + }); + + it('does not import generated V2 rules as legacy rules', async () => { + const store = makeStore(); + const identity = { id: 'e5'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + await store.setDefaultActivation(identity, 'disabled'); + await store.setWorkspaceActivation( + identity, + '/workspace/enabled', + 'enabled', + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + await fsp.writeFile( + enablementPath, + JSON.stringify({ + demo: { + overrides: ['!/*', '/workspace/enabled/', '!/workspace/legacy/*'], + }, + }), + ); + + const imported = await store.ensureInitialized([identity]); + + expect(imported.extensions[identity.id]?.legacyPathRules).toEqual([ + '!/workspace/legacy/*', + ]); + }); + + it('imports an opposite V1 workspace rule into structured activation', async () => { + const store = makeStore(); + const identity = { id: 'ea'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + await store.setWorkspaceActivation(identity, '/workspace', 'enabled'); + await new Promise((resolve) => setTimeout(resolve, 10)); + await fsp.writeFile( + enablementPath, + JSON.stringify({ demo: { overrides: ['!/workspace/'] } }), + ); + + const imported = await store.ensureInitialized([identity]); + + expect(imported.extensions[identity.id]).toMatchObject({ + workspaceOverrides: { '/workspace': 'disabled' }, + }); + expect(imported.extensions[identity.id]?.legacyPathRules).toBeUndefined(); + expect(JSON.parse(await fsp.readFile(enablementPath, 'utf8'))).toEqual({ + demo: { overrides: ['!/workspace/'] }, + }); + }); + + it('imports newer V1 rules for policies omitted from a partial refresh', async () => { + const store = makeStore(); + const first = { id: 'e6'.repeat(32), name: 'first' }; + const second = { id: 'e7'.repeat(32), name: 'second' }; + await store.ensureInitialized([first, second]); + await new Promise((resolve) => setTimeout(resolve, 10)); + await fsp.writeFile( + enablementPath, + JSON.stringify({ + first: { overrides: ['!/workspace/first/*'] }, + second: { overrides: ['!/workspace/second/*'] }, + }), + ); + + const imported = await store.ensureInitialized([first]); + + expect(imported.extensions[first.id]?.legacyPathRules).toEqual([ + '!/workspace/first/*', + ]); + expect(imported.extensions[second.id]?.legacyPathRules).toEqual([ + '!/workspace/second/*', + ]); + }); + + it('fails closed when the V2 state is corrupt', async () => { + await fsp.mkdir(storeDir, { recursive: true }); + await fsp.writeFile(path.join(storeDir, 'state.json'), '{not-json'); + const store = makeStore(); + + await expect(store.readSnapshot()).rejects.toBeInstanceOf( + ExtensionStoreCorruptError, + ); + expect(fs.existsSync(path.join(storeDir, 'state.json'))).toBe(true); + }); + + it('commits an installed artifact and its initial activation together', async () => { + const store = makeStore(); + const identity = { id: 'f'.repeat(64), name: 'demo' }; + const staging = await store.createStagingDirectory(); + await fsp.writeFile(path.join(staging, 'qwen-extension.json'), '{}'); + + const snapshot = await store.commitArtifact({ + operation: 'install', + identity, + stagingDirectory: staging, + destinationDirectory: path.join(extensionsDir, 'demo'), + initialActivation: { + scope: 'workspace', + workspacePath: '/workspace/a', + }, + }); + + expect(snapshot.generation).toBe(1); + expect(snapshot.extensions[identity.id]).toMatchObject({ + artifactGeneration: 1, + defaultActivation: 'disabled', + workspaceOverrides: { '/workspace/a': 'enabled' }, + }); + await expect( + fsp.readFile( + path.join(extensionsDir, 'demo', 'qwen-extension.json'), + 'utf8', + ), + ).resolves.toBe('{}'); + expect(fs.existsSync(staging)).toBe(false); + }); + + it('preserves the original error when rollback also fails', async () => { + const store = makeStore(); + const identity = { id: 'fa'.repeat(32), name: 'demo' }; + await store.ensureInitialized([]); + const staging = await store.createStagingDirectory(); + await fsp.writeFile(path.join(staging, 'qwen-extension.json'), '{}'); + const primaryError = new Error('state write failed'); + const rollbackError = new Error('rollback failed'); + const internals = store as unknown as { + writeSnapshotUnlocked(snapshot: unknown): Promise; + rollbackJournal(journal: unknown): Promise; + }; + vi.spyOn(internals, 'writeSnapshotUnlocked').mockRejectedValueOnce( + primaryError, + ); + vi.spyOn(internals, 'rollbackJournal').mockRejectedValueOnce(rollbackError); + + let thrown: unknown; + try { + await store.commitArtifact({ + operation: 'install', + identity, + stagingDirectory: staging, + destinationDirectory: path.join(extensionsDir, identity.name), + initialActivation: { scope: 'user' }, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(AggregateError); + expect((thrown as AggregateError).errors).toEqual([ + primaryError, + rollbackError, + ]); + const journals = await fsp.readdir(path.join(storeDir, 'transactions')); + expect(journals.filter((name) => name.endsWith('.json'))).toHaveLength(1); + }); + + it('changes artifact generation only for artifact commits', async () => { + const store = makeStore(); + const identity = { id: '91'.repeat(32), name: 'demo' }; + const destination = path.join(extensionsDir, 'demo'); + const install = await store.createStagingDirectory(); + await fsp.writeFile(path.join(install, 'version'), 'one'); + const installed = await store.commitArtifact({ + operation: 'install', + identity, + stagingDirectory: install, + destinationDirectory: destination, + initialActivation: { scope: 'user' }, + }); + + const activated = await store.setDefaultActivation(identity, 'disabled'); + expect(activated.generation).toBe(installed.generation + 1); + expect(activated.extensions[identity.id]?.artifactGeneration).toBe( + installed.generation, + ); + + const update = await store.createStagingDirectory(); + await fsp.writeFile(path.join(update, 'version'), 'two'); + const updated = await store.commitArtifact({ + operation: 'update', + identity, + stagingDirectory: update, + destinationDirectory: destination, + expectedArtifactGeneration: installed.generation, + }); + expect(updated.extensions[identity.id]?.artifactGeneration).toBe( + updated.generation, + ); + }); + + it('does not recreate activation policy after uninstall', async () => { + const store = makeStore(); + const identity = { id: '97'.repeat(32), name: 'demo' }; + const destination = path.join(extensionsDir, identity.name); + const staging = await store.createStagingDirectory(); + await fsp.writeFile(path.join(staging, 'version'), 'one'); + await store.commitArtifact({ + operation: 'install', + identity, + stagingDirectory: staging, + destinationDirectory: destination, + initialActivation: { scope: 'user' }, + }); + await store.commitArtifact({ + operation: 'uninstall', + identity, + destinationDirectory: destination, + }); + + await expect( + store.setDefaultActivation(identity, 'disabled'), + ).rejects.toMatchObject({ code: 'extension_conflict' }); + await expect(store.readSnapshot()).resolves.toMatchObject({ + extensions: {}, + }); + }); + + it('rejects installing a renamed extension with an existing id', async () => { + const store = makeStore(); + const id = '98'.repeat(32); + const original = { id, name: 'original' }; + const install = await store.createStagingDirectory(); + await fsp.writeFile(path.join(install, 'version'), 'one'); + await store.commitArtifact({ + operation: 'install', + identity: original, + stagingDirectory: install, + destinationDirectory: path.join(extensionsDir, original.name), + initialActivation: { scope: 'user' }, + }); + const renamed = await store.createStagingDirectory(); + await fsp.writeFile(path.join(renamed, 'version'), 'two'); + + await expect( + store.commitArtifact({ + operation: 'install', + identity: { id, name: 'renamed' }, + stagingDirectory: renamed, + destinationDirectory: path.join(extensionsDir, 'renamed'), + initialActivation: { scope: 'user' }, + }), + ).rejects.toBeInstanceOf(ExtensionConflictError); + await expect( + fsp.readFile(path.join(extensionsDir, original.name, 'version'), 'utf8'), + ).resolves.toBe('one'); + expect(fs.existsSync(path.join(extensionsDir, 'renamed'))).toBe(false); + }); + + it('rejects a stale prepared update without replacing the artifact', async () => { + const store = makeStore(); + const identity = { id: '92'.repeat(32), name: 'demo' }; + const destination = path.join(extensionsDir, 'demo'); + const install = await store.createStagingDirectory(); + await fsp.writeFile(path.join(install, 'version'), 'one'); + const installed = await store.commitArtifact({ + operation: 'install', + identity, + stagingDirectory: install, + destinationDirectory: destination, + initialActivation: { scope: 'user' }, + }); + const firstUpdate = await store.createStagingDirectory(); + await fsp.writeFile(path.join(firstUpdate, 'version'), 'two'); + await store.commitArtifact({ + operation: 'update', + identity, + stagingDirectory: firstUpdate, + destinationDirectory: destination, + expectedArtifactGeneration: installed.generation, + }); + const staleUpdate = await store.createStagingDirectory(); + await fsp.writeFile(path.join(staleUpdate, 'version'), 'stale'); + + await expect( + store.commitArtifact({ + operation: 'update', + identity, + stagingDirectory: staleUpdate, + destinationDirectory: destination, + expectedArtifactGeneration: installed.generation, + }), + ).rejects.toBeInstanceOf(ExtensionConflictError); + await expect( + fsp.readFile(path.join(destination, 'version'), 'utf8'), + ).resolves.toBe('two'); + }); + + it('rebases prepared updates for different artifacts', async () => { + const store = makeStore(); + const first = { id: '95'.repeat(32), name: 'first' }; + const second = { id: '96'.repeat(32), name: 'second' }; + const install = async (identity: typeof first) => { + const staging = await store.createStagingDirectory(); + await fsp.writeFile(path.join(staging, 'version'), 'one'); + return await store.commitArtifact({ + operation: 'install', + identity, + stagingDirectory: staging, + destinationDirectory: path.join(extensionsDir, identity.name), + initialActivation: { scope: 'user' }, + }); + }; + const firstInstalled = await install(first); + const secondInstalled = await install(second); + const firstUpdate = await store.createStagingDirectory(); + const secondUpdate = await store.createStagingDirectory(); + await fsp.writeFile(path.join(firstUpdate, 'version'), 'first-updated'); + await fsp.writeFile(path.join(secondUpdate, 'version'), 'second-updated'); + + await store.commitArtifact({ + operation: 'update', + identity: first, + stagingDirectory: firstUpdate, + destinationDirectory: path.join(extensionsDir, first.name), + expectedArtifactGeneration: + firstInstalled.extensions[first.id]!.artifactGeneration, + }); + await store.commitArtifact({ + operation: 'update', + identity: second, + stagingDirectory: secondUpdate, + destinationDirectory: path.join(extensionsDir, second.name), + expectedArtifactGeneration: + secondInstalled.extensions[second.id]!.artifactGeneration, + }); + + await expect( + fsp.readFile(path.join(extensionsDir, first.name, 'version'), 'utf8'), + ).resolves.toBe('first-updated'); + await expect( + fsp.readFile(path.join(extensionsDir, second.name, 'version'), 'utf8'), + ).resolves.toBe('second-updated'); + }); + + it('replaces stale policy state when its artifact is absent', async () => { + const store = makeStore(); + const identity = { id: '93'.repeat(32), name: 'existing-policy' }; + const destination = path.join(extensionsDir, identity.name); + const initialStaging = await store.createStagingDirectory(); + await fsp.writeFile(path.join(initialStaging, 'version'), 'old artifact'); + await store.commitArtifact({ + operation: 'install', + identity, + stagingDirectory: initialStaging, + destinationDirectory: destination, + initialActivation: { + scope: 'workspace', + workspacePath: '/workspace/a', + }, + }); + await fsp.rm(destination, { recursive: true }); + const staging = await store.createStagingDirectory(); + await fsp.writeFile(path.join(staging, 'version'), 'new artifact'); + + const snapshot = await store.commitArtifact({ + operation: 'install', + identity, + stagingDirectory: staging, + destinationDirectory: destination, + initialActivation: { scope: 'user' }, + }); + + expect(snapshot.extensions[identity.id]).toMatchObject({ + defaultActivation: 'enabled', + workspaceOverrides: {}, + }); + await expect( + fsp.readFile(path.join(destination, 'version'), 'utf8'), + ).resolves.toBe('new artifact'); + }); + + it('rejects update when the artifact has no matching policy', async () => { + const store = makeStore(); + const identity = { id: '94'.repeat(32), name: 'orphan-artifact' }; + const destination = path.join(extensionsDir, identity.name); + await fsp.mkdir(destination, { recursive: true }); + const staging = await store.createStagingDirectory(); + await fsp.writeFile(path.join(staging, 'version'), 'new artifact'); + + await expect( + store.commitArtifact({ + operation: 'update', + identity, + stagingDirectory: staging, + destinationDirectory: destination, + expectedArtifactGeneration: 0, + }), + ).rejects.toMatchObject({ code: 'extension_conflict' }); + }); + + it('atomically replaces an artifact while preserving activation policy', async () => { + const store = makeStore(); + const identity = { id: 'a1'.repeat(32), name: 'demo' }; + const destination = path.join(extensionsDir, 'demo'); + await fsp.mkdir(destination); + await fsp.writeFile(path.join(destination, 'version'), 'old'); + await store.ensureInitialized([identity]); + await store.setWorkspaceActivation(identity, '/workspace/a', 'disabled'); + const staging = await store.createStagingDirectory(); + await fsp.writeFile(path.join(staging, 'version'), 'new'); + + const snapshot = await store.commitArtifact({ + operation: 'update', + identity, + stagingDirectory: staging, + destinationDirectory: destination, + }); + + expect(await fsp.readFile(path.join(destination, 'version'), 'utf8')).toBe( + 'new', + ); + expect(snapshot.extensions[identity.id]?.workspaceOverrides).toEqual({ + '/workspace/a': 'disabled', + }); + }); + + it('moves an uninstalled artifact out of view before removing its policy', async () => { + const store = makeStore(); + const identity = { id: 'b1'.repeat(32), name: 'demo' }; + const destination = path.join(extensionsDir, 'demo'); + await fsp.mkdir(destination); + await fsp.writeFile(path.join(destination, 'version'), 'old'); + await store.ensureInitialized([identity]); + + const snapshot = await store.commitArtifact({ + operation: 'uninstall', + identity, + destinationDirectory: destination, + }); + + expect(fs.existsSync(destination)).toBe(false); + expect(snapshot.extensions[identity.id]).toBeUndefined(); + }); + + it('idempotently handles concurrent uninstalls when the artifact is absent', async () => { + const store = makeStore(); + const identity = { id: 'b4'.repeat(32), name: 'demo' }; + const destination = path.join(extensionsDir, 'demo'); + await store.ensureInitialized([identity]); + + const [uninstalled, repeated] = await Promise.all([ + store.commitArtifact({ + operation: 'uninstall', + identity, + destinationDirectory: destination, + }), + store.commitArtifact({ + operation: 'uninstall', + identity, + destinationDirectory: destination, + }), + ]); + + expect(uninstalled.extensions[identity.id]).toBeUndefined(); + expect(repeated).toEqual(uninstalled); + }); + + it('allows uninstalling an extension from a snapshot with duplicate names', async () => { + const store = makeStore(); + const identity = { id: 'b2'.repeat(32), name: 'demo' }; + const duplicateId = 'b3'.repeat(32); + const destination = path.join(extensionsDir, identity.name); + await fsp.mkdir(destination); + const snapshot = await store.ensureInitialized([ + identity, + { id: duplicateId, name: 'other' }, + ]); + snapshot.extensions[duplicateId]!.name = identity.name; + await fsp.writeFile( + path.join(storeDir, 'state.json'), + JSON.stringify(snapshot), + ); + + const uninstalled = await store.commitArtifact({ + operation: 'uninstall', + identity, + destinationDirectory: destination, + }); + + expect(uninstalled.extensions[identity.id]).toBeUndefined(); + expect(uninstalled.extensions[duplicateId]?.name).toBe(identity.name); + }); + + it('rolls back an artifact-swapped transaction before the commit point', async () => { + const store = makeStore(); + const identity = { id: 'c1'.repeat(32), name: 'demo' }; + const initial = await store.ensureInitialized([identity]); + const targetSnapshot = structuredClone(initial); + targetSnapshot.generation = 1; + const transactionId = 'recover-before-commit'; + const destination = path.join(extensionsDir, 'demo'); + const backup = path.join(storeDir, 'rollback', transactionId); + const journal = path.join( + storeDir, + 'transactions', + `${transactionId}.json`, + ); + await fsp.mkdir(destination); + await fsp.writeFile(path.join(destination, 'version'), 'new'); + await fsp.mkdir(backup); + await fsp.writeFile(path.join(backup, 'version'), 'old'); + await fsp.writeFile( + journal, + JSON.stringify({ + version: 1, + transactionId, + operation: 'update', + phase: 'artifact_swapped', + destinationDirectory: destination, + stagingDirectory: path.join( + storeDir, + 'staging', + 'recover-before-commit', + ), + backupDirectory: backup, + previousGeneration: 0, + targetGeneration: 1, + targetSnapshot, + }), + ); + + await store.ensureInitialized([identity]); + + expect(await fsp.readFile(path.join(destination, 'version'), 'utf8')).toBe( + 'old', + ); + expect(fs.existsSync(journal)).toBe(false); + }); + + it.each([ + { + name: 'prepared install', + operation: 'install' as const, + phase: 'prepared' as const, + stagingExists: true, + destinationVersion: undefined, + backupVersion: undefined, + expectedDestinationVersion: undefined, + }, + { + name: 'artifact-swapped install', + operation: 'install' as const, + phase: 'artifact_swapped' as const, + stagingExists: false, + destinationVersion: 'new', + backupVersion: undefined, + expectedDestinationVersion: undefined, + }, + { + name: 'artifact-swapped uninstall', + operation: 'uninstall' as const, + phase: 'artifact_swapped' as const, + stagingExists: false, + destinationVersion: undefined, + backupVersion: 'old', + expectedDestinationVersion: 'old', + }, + ])('rolls back a fabricated $name journal', async (scenario) => { + const store = makeStore(); + const identity = { id: 'c4'.repeat(32), name: 'demo' }; + const initial = await store.ensureInitialized([identity]); + const targetSnapshot = structuredClone(initial); + targetSnapshot.generation = 1; + const transactionId = scenario.name.replaceAll(' ', '-'); + const destination = path.join(extensionsDir, identity.name); + const staging = path.join(storeDir, 'staging', transactionId); + const backup = path.join(storeDir, 'rollback', transactionId); + const journal = path.join( + storeDir, + 'transactions', + `${transactionId}.json`, + ); + if (scenario.stagingExists) { + await fsp.mkdir(staging); + await fsp.writeFile(path.join(staging, 'version'), 'staged'); + } + if (scenario.destinationVersion) { + await fsp.mkdir(destination); + await fsp.writeFile( + path.join(destination, 'version'), + scenario.destinationVersion, + ); + } + if (scenario.backupVersion) { + await fsp.mkdir(backup); + await fsp.writeFile(path.join(backup, 'version'), scenario.backupVersion); + } + await fsp.writeFile( + journal, + JSON.stringify({ + version: 1, + transactionId, + operation: scenario.operation, + phase: scenario.phase, + destinationDirectory: destination, + ...(scenario.operation === 'install' + ? { stagingDirectory: staging } + : {}), + backupDirectory: backup, + previousGeneration: 0, + targetGeneration: 1, + targetSnapshot, + }), + ); + + const recovered = await store.readSnapshot(); + + expect(recovered.generation).toBe(0); + if (scenario.expectedDestinationVersion) { + await expect( + fsp.readFile(path.join(destination, 'version'), 'utf8'), + ).resolves.toBe(scenario.expectedDestinationVersion); + } else { + expect(fs.existsSync(destination)).toBe(false); + } + expect(fs.existsSync(staging)).toBe(false); + expect(fs.existsSync(backup)).toBe(false); + expect(fs.existsSync(journal)).toBe(false); + }); + + it('recovers an artifact-swapped transaction before reading a snapshot', async () => { + const store = makeStore(); + const identity = { id: 'c2'.repeat(32), name: 'demo' }; + const initial = await store.ensureInitialized([identity]); + const targetSnapshot = structuredClone(initial); + targetSnapshot.generation = 1; + const transactionId = 'recover-before-read'; + const destination = path.join(extensionsDir, 'demo'); + const backup = path.join(storeDir, 'rollback', transactionId); + const journal = path.join( + storeDir, + 'transactions', + `${transactionId}.json`, + ); + await fsp.mkdir(destination); + await fsp.writeFile(path.join(destination, 'version'), 'new'); + await fsp.mkdir(backup); + await fsp.writeFile(path.join(backup, 'version'), 'old'); + await fsp.writeFile( + journal, + JSON.stringify({ + version: 1, + transactionId, + operation: 'update', + phase: 'artifact_swapped', + destinationDirectory: destination, + stagingDirectory: path.join(storeDir, 'staging', transactionId), + backupDirectory: backup, + previousGeneration: 0, + targetGeneration: 1, + targetSnapshot, + }), + ); + + const snapshot = await store.readSnapshot(); + + expect(snapshot.generation).toBe(0); + expect(await fsp.readFile(path.join(destination, 'version'), 'utf8')).toBe( + 'old', + ); + expect(fs.existsSync(journal)).toBe(false); + }); + + it('keeps an artifact when state reached the target generation before the journal phase', async () => { + const store = makeStore(); + const identity = { id: 'c3'.repeat(32), name: 'demo' }; + const initial = await store.ensureInitialized([identity]); + const targetSnapshot = structuredClone(initial); + targetSnapshot.generation = 1; + const transactionId = 'recover-after-state-write'; + const destination = path.join(extensionsDir, identity.name); + const backup = path.join(storeDir, 'rollback', transactionId); + const journal = path.join( + storeDir, + 'transactions', + `${transactionId}.json`, + ); + await fsp.mkdir(destination); + await fsp.writeFile(path.join(destination, 'version'), 'new'); + await fsp.mkdir(backup); + await fsp.writeFile(path.join(backup, 'version'), 'old'); + await fsp.writeFile( + path.join(storeDir, 'state.json'), + JSON.stringify(targetSnapshot), + ); + await fsp.writeFile( + journal, + JSON.stringify({ + version: 1, + transactionId, + operation: 'update', + phase: 'artifact_swapped', + destinationDirectory: destination, + stagingDirectory: path.join(storeDir, 'staging', transactionId), + backupDirectory: backup, + previousGeneration: 0, + targetGeneration: 1, + targetSnapshot, + }), + ); + + const recovered = await store.readSnapshot(); + + expect(recovered.generation).toBe(1); + expect(await fsp.readFile(path.join(destination, 'version'), 'utf8')).toBe( + 'new', + ); + expect(fs.existsSync(backup)).toBe(false); + expect(fs.existsSync(journal)).toBe(false); + }); + + it('finishes cleanup after a committed transaction', async () => { + const store = makeStore(); + const identity = { id: 'd1'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + const targetSnapshot = await store.setDefaultActivation( + identity, + 'disabled', + ); + const transactionId = 'recover-after-commit'; + const destination = path.join(extensionsDir, 'demo'); + const backup = path.join(storeDir, 'rollback', transactionId); + const journal = path.join( + storeDir, + 'transactions', + `${transactionId}.json`, + ); + await fsp.mkdir(destination); + await fsp.writeFile(path.join(destination, 'version'), 'new'); + await fsp.mkdir(backup); + await fsp.writeFile(path.join(backup, 'version'), 'old'); + await fsp.writeFile( + journal, + JSON.stringify({ + version: 1, + transactionId, + operation: 'update', + phase: 'state_committed', + destinationDirectory: destination, + stagingDirectory: path.join( + storeDir, + 'staging', + 'recover-after-commit', + ), + backupDirectory: backup, + previousGeneration: 0, + targetGeneration: 1, + targetSnapshot, + }), + ); + + await store.ensureInitialized([identity]); + + expect(await fsp.readFile(path.join(destination, 'version'), 'utf8')).toBe( + 'new', + ); + expect(fs.existsSync(backup)).toBe(false); + expect(fs.existsSync(journal)).toBe(false); + }); + + it('keeps committed cleanup failures from blocking store operations', async () => { + const store = makeStore(); + const identity = { id: 'd2'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + const targetSnapshot = await store.setDefaultActivation( + identity, + 'disabled', + ); + const transactionId = 'recover-cleanup-failure'; + const destination = path.join(extensionsDir, 'demo'); + const backup = path.join(storeDir, 'rollback', transactionId); + const journal = path.join( + storeDir, + 'transactions', + `${transactionId}.json`, + ); + await fsp.mkdir(destination); + await fsp.mkdir(backup); + await fsp.writeFile( + journal, + JSON.stringify({ + version: 1, + transactionId, + operation: 'update', + phase: 'state_committed', + destinationDirectory: destination, + stagingDirectory: path.join(storeDir, 'staging', transactionId), + backupDirectory: backup, + previousGeneration: 0, + targetGeneration: 1, + targetSnapshot, + }), + ); + const rm = fsp.rm.bind(fsp); + const rmSpy = vi + .spyOn(fsp, 'rm') + .mockImplementation(async (target, opts) => { + if (target === backup) throw new Error('cleanup denied'); + return await rm(target, opts); + }); + + try { + await expect(store.readSnapshot()).resolves.toMatchObject({ + generation: 1, + }); + await expect( + store.setDefaultActivation(identity, 'enabled'), + ).resolves.toMatchObject({ generation: 2 }); + expect(fs.existsSync(journal)).toBe(true); + } finally { + rmSpy.mockRestore(); + } + + await store.readSnapshot(); + expect(fs.existsSync(backup)).toBe(false); + expect(fs.existsSync(journal)).toBe(false); + }); + + it('quarantines a corrupt transaction journal and continues', async () => { + const store = makeStore(); + const identity = { id: 'd4'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + const transactionsDir = path.join(storeDir, 'transactions'); + const journal = path.join(transactionsDir, 'corrupt.json'); + await fsp.writeFile(journal, '{not-json'); + + await expect(store.readSnapshot()).resolves.toMatchObject({ + generation: 0, + }); + await expect( + store.setDefaultActivation(identity, 'disabled'), + ).resolves.toMatchObject({ generation: 1 }); + expect(fs.existsSync(journal)).toBe(false); + expect(await readQuarantinedJournal(journal)).toBe('{not-json'); + }); + + it('quarantines a corrupt journal while recovering corrupt state', async () => { + const store = makeStore(); + const identity = { id: 'd6'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + await store.setDefaultActivation(identity, 'disabled'); + await fsp.writeFile(path.join(storeDir, 'state.json'), '{not-json'); + const journal = path.join(storeDir, 'transactions', 'corrupt.json'); + await fsp.writeFile(journal, '{also-not-json'); + + await expect(store.readSnapshot()).resolves.toMatchObject({ + generation: 0, + extensions: { + [identity.id]: { defaultActivation: 'enabled' }, + }, + }); + expect(fs.existsSync(journal)).toBe(false); + expect(await readQuarantinedJournal(journal)).toBe('{also-not-json'); + }); + + it.each(['destination', 'backup', 'staging', 'transaction-id'] as const)( + 'quarantines a journal with a hostile %s path', + async (kind) => { + const store = makeStore(); + const identity = { id: 'd5'.repeat(32), name: 'demo' }; + const initial = await store.ensureInitialized([identity]); + const targetSnapshot = structuredClone(initial); + targetSnapshot.generation = 1; + const transactionId = `hostile-${kind}`; + const outside = path.join(root, 'outside'); + const sentinel = path.join(outside, 'sentinel'); + await fsp.mkdir(outside); + await fsp.writeFile(sentinel, 'preserve'); + const journal = path.join( + storeDir, + 'transactions', + `${transactionId}.json`, + ); + await fsp.writeFile( + journal, + JSON.stringify({ + version: 1, + transactionId: + kind === 'transaction-id' ? 'different-id' : transactionId, + operation: 'update', + phase: 'artifact_swapped', + destinationDirectory: + kind === 'destination' + ? outside + : path.join(extensionsDir, identity.name), + stagingDirectory: + kind === 'staging' + ? outside + : path.join(storeDir, 'staging', transactionId), + backupDirectory: + kind === 'backup' + ? outside + : path.join(storeDir, 'rollback', transactionId), + previousGeneration: 0, + targetGeneration: 1, + targetSnapshot, + }), + ); + + await expect(store.readSnapshot()).resolves.toMatchObject({ + generation: 0, + }); + await expect( + store.setDefaultActivation(identity, 'disabled'), + ).resolves.toMatchObject({ generation: 1 }); + expect(await fsp.readFile(sentinel, 'utf8')).toBe('preserve'); + expect(fs.existsSync(journal)).toBe(false); + expect(JSON.parse(await readQuarantinedJournal(journal))).toMatchObject({ + transactionId: + kind === 'transaction-id' ? 'different-id' : transactionId, + }); + }, + ); + + it.each(['corrupt', 'missing'] as const)( + 'recovers committed state from a journal when state.json is %s', + async (stateCondition) => { + const store = makeStore(); + const identity = { id: 'f1'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + const targetSnapshot = await store.setDefaultActivation( + identity, + 'disabled', + ); + const transactionId = 'recover-corrupt-commit'; + const destination = path.join(extensionsDir, 'demo'); + const backup = path.join(storeDir, 'rollback', transactionId); + const journal = path.join( + storeDir, + 'transactions', + `${transactionId}.json`, + ); + await fsp.mkdir(destination); + await fsp.mkdir(backup); + await fsp.writeFile( + journal, + JSON.stringify({ + version: 1, + transactionId, + operation: 'update', + phase: 'state_committed', + destinationDirectory: destination, + stagingDirectory: path.join( + storeDir, + 'staging', + 'recover-corrupt-commit', + ), + backupDirectory: backup, + previousGeneration: 0, + targetGeneration: 1, + targetSnapshot, + }), + ); + if (stateCondition === 'corrupt') { + await fsp.writeFile(path.join(storeDir, 'state.json'), '{broken'); + } else { + await fsp.rm(path.join(storeDir, 'state.json')); + } + + const recovered = await store.ensureInitialized([identity]); + + expect(recovered.generation).toBe(1); + expect(recovered.extensions[identity.id]?.defaultActivation).toBe( + 'disabled', + ); + expect(fs.existsSync(journal)).toBe(false); + }, + ); + + it('rolls back an artifact-swapped transaction when current state is corrupt', async () => { + const store = makeStore(); + const identity = { id: 'f4'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + const targetSnapshot = await store.setDefaultActivation( + identity, + 'disabled', + ); + const transactionId = 'recover-corrupt-artifact-swap'; + const destination = path.join(extensionsDir, identity.name); + const backup = path.join(storeDir, 'rollback', transactionId); + const journal = path.join( + storeDir, + 'transactions', + `${transactionId}.json`, + ); + await fsp.mkdir(destination); + await fsp.writeFile(path.join(destination, 'version'), 'new'); + await fsp.mkdir(backup); + await fsp.writeFile(path.join(backup, 'version'), 'old'); + await fsp.writeFile( + journal, + JSON.stringify({ + version: 1, + transactionId, + operation: 'update', + phase: 'artifact_swapped', + destinationDirectory: destination, + stagingDirectory: path.join(storeDir, 'staging', transactionId), + backupDirectory: backup, + previousGeneration: 0, + targetGeneration: 1, + targetSnapshot, + }), + ); + await fsp.writeFile(path.join(storeDir, 'state.json'), '{broken'); + + const recovered = await store.readSnapshot(); + + expect(recovered.generation).toBe(0); + expect(recovered.extensions[identity.id]?.defaultActivation).toBe( + 'enabled', + ); + await expect( + fsp.readFile(path.join(destination, 'version'), 'utf8'), + ).resolves.toBe('old'); + expect(fs.existsSync(journal)).toBe(false); + }); + + it.each(['corrupt', 'missing'] as const)( + 'recovers state and projection from state.previous.json when state.json is %s', + async (stateCondition) => { + const store = makeStore(); + const identity = { id: 'f2'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + await store.setDefaultActivation(identity, 'disabled'); + if (stateCondition === 'corrupt') { + await fsp.writeFile(path.join(storeDir, 'state.json'), '{broken'); + } else { + await fsp.rm(path.join(storeDir, 'state.json')); + } + await fsp.writeFile( + enablementPath, + JSON.stringify({ demo: { overrides: ['!/*'] } }), + ); + + const recovered = await store.ensureInitialized([identity]); + + expect(recovered.generation).toBe(0); + expect(recovered.extensions[identity.id]?.defaultActivation).toBe( + 'enabled', + ); + expect(JSON.parse(await fsp.readFile(enablementPath, 'utf8'))).toEqual( + {}, + ); + }, + ); + + it('fails closed when current and previous state are corrupt', async () => { + const store = makeStore(); + const identity = { id: 'f3'.repeat(32), name: 'demo' }; + await store.ensureInitialized([identity]); + await store.setDefaultActivation(identity, 'disabled'); + await fsp.writeFile(path.join(storeDir, 'state.json'), '{broken'); + await fsp.writeFile( + path.join(storeDir, 'state.previous.json'), + '{also-broken', + ); + + await expect(store.ensureInitialized([identity])).rejects.toBeInstanceOf( + ExtensionStoreCorruptError, + ); + }); +}); diff --git a/packages/core/src/extension/extension-store.ts b/packages/core/src/extension/extension-store.ts new file mode 100644 index 00000000000..4e1cb65da26 --- /dev/null +++ b/packages/core/src/extension/extension-store.ts @@ -0,0 +1,1278 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import { promises as fsp } from 'node:fs'; +import * as path from 'node:path'; +import lockfile from 'proper-lockfile'; +import { Mutex } from 'async-mutex'; +import { Storage } from '../config/storage.js'; +import { atomicWriteJSON, renameWithRetry } from '../utils/atomicFileWrite.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { Override, type AllExtensionsEnablementConfig } from './override.js'; + +const debugLogger = createDebugLogger('EXTENSION_STORE'); + +export type ExtensionActivation = 'enabled' | 'disabled'; +export type WorkspaceActivation = ExtensionActivation | 'inherit'; + +export interface ExtensionPolicy { + name: string; + artifactGeneration?: number; + defaultActivation: ExtensionActivation; + workspaceOverrides: Record; + legacyPathRules?: string[]; +} + +export interface ExtensionStoreSnapshot { + version: 2; + generation: number; + legacyProjectionHash: string; + extensions: Record; +} + +export interface ExtensionIdentity { + id: string; + name: string; +} + +export interface ExtensionActivationResult { + default: ExtensionActivation; + workspace: WorkspaceActivation; + effective: ExtensionActivation; + source: + | 'cli_override' + | 'workspace_override' + | 'legacy_path_rule' + | 'default'; +} + +export interface ExtensionStoreOptions { + extensionsDir?: string; + storeDir?: string; + enablementPath?: string; +} + +export type InitialExtensionActivation = + | { scope: 'user' } + | { scope: 'workspace'; workspacePath: string }; + +export interface CommitExtensionArtifactInput { + operation: 'install' | 'update' | 'uninstall'; + identity: ExtensionIdentity; + destinationDirectory: string; + stagingDirectory?: string; + initialActivation?: InitialExtensionActivation; + expectedArtifactGeneration?: number; +} + +interface ExtensionTransactionJournal { + version: 1; + transactionId: string; + operation: CommitExtensionArtifactInput['operation']; + phase: 'prepared' | 'artifact_swapped' | 'state_committed'; + destinationDirectory: string; + stagingDirectory?: string; + backupDirectory: string; + previousGeneration: number; + targetGeneration: number; + targetSnapshot: ExtensionStoreSnapshot; +} + +export class ExtensionStoreCorruptError extends Error { + readonly code = 'extension_store_corrupt'; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ExtensionStoreCorruptError'; + } +} + +export class ExtensionStoreBusyError extends Error { + readonly code = 'extension_store_busy'; + + constructor(storeDir: string, options?: ErrorOptions) { + super(`Extension store is busy at ${storeDir}.`, options); + this.name = 'ExtensionStoreBusyError'; + } +} + +class UnsafeRecoveredJournalError extends ExtensionStoreCorruptError {} + +export class ExtensionConflictError extends Error { + readonly code = 'extension_conflict'; + + constructor(message: string) { + super(message); + this.name = 'ExtensionConflictError'; + } +} + +const storeMutexes = new Map(); + +function getStoreMutex(storeDir: string): Mutex { + let mutex = storeMutexes.get(storeDir); + if (!mutex) { + mutex = new Mutex(); + storeMutexes.set(storeDir, mutex); + } + return mutex; +} + +function normalizeRulePath(workspacePath: string): string { + let normalized = workspacePath.replace(/\\/g, '/'); + if (!normalized.startsWith('/')) normalized = `/${normalized}`; + if (!normalized.endsWith('/')) normalized = `${normalized}/`; + return normalized; +} + +function canonicalizeWorkspacePath(workspacePath: string): string { + const resolved = path.resolve(workspacePath); + try { + return fs.realpathSync.native(resolved); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return resolved; + throw error; + } +} + +function projectionHash(projection: AllExtensionsEnablementConfig): string { + return crypto + .createHash('sha256') + .update(JSON.stringify(projection)) + .digest('hex'); +} + +function assertIdentity(identity: ExtensionIdentity): void { + if (!/^[a-f0-9]{64}$/.test(identity.id)) { + throw new Error(`Invalid extension id "${identity.id}".`); + } + if (!/^[a-zA-Z0-9-_.]+$/.test(identity.name)) { + throw new Error('Invalid extension name.'); + } +} + +function parseState( + content: string, + statePath: string, +): ExtensionStoreSnapshot { + let value: unknown; + try { + value = JSON.parse(content); + } catch (error) { + throw new ExtensionStoreCorruptError( + `Extension store state is corrupt at ${statePath}.`, + { cause: error }, + ); + } + const candidate = value as Partial | null; + const validPolicy = (extensionId: string, policy: unknown): boolean => { + if ( + !/^[a-f0-9]{64}$/.test(extensionId) || + !policy || + typeof policy !== 'object' + ) { + return false; + } + const parsed = policy as Partial; + return ( + typeof parsed.name === 'string' && + /^[a-zA-Z0-9-_.]+$/.test(parsed.name) && + (parsed.artifactGeneration === undefined || + (Number.isSafeInteger(parsed.artifactGeneration) && + parsed.artifactGeneration >= 0)) && + (parsed.defaultActivation === 'enabled' || + parsed.defaultActivation === 'disabled') && + !!parsed.workspaceOverrides && + !Array.isArray(parsed.workspaceOverrides) && + typeof parsed.workspaceOverrides === 'object' && + Object.values(parsed.workspaceOverrides).every( + (activation) => + activation === 'enabled' || + activation === 'disabled' || + activation === 'inherit', + ) && + (parsed.legacyPathRules === undefined || + (Array.isArray(parsed.legacyPathRules) && + parsed.legacyPathRules.every((rule) => typeof rule === 'string'))) + ); + }; + if ( + !candidate || + typeof candidate !== 'object' || + candidate.version !== 2 || + !Number.isSafeInteger(candidate.generation) || + candidate.generation! < 0 || + typeof candidate.legacyProjectionHash !== 'string' || + !/^[a-f0-9]{64}$/.test(candidate.legacyProjectionHash) || + !candidate.extensions || + Array.isArray(candidate.extensions) || + typeof candidate.extensions !== 'object' || + !Object.entries(candidate.extensions).every(([id, policy]) => + validPolicy(id, policy), + ) + ) { + throw new ExtensionStoreCorruptError( + `Extension store state has an invalid schema at ${statePath}.`, + ); + } + return value as ExtensionStoreSnapshot; +} + +export class ExtensionStore { + readonly extensionsDir: string; + readonly storeDir: string; + readonly enablementPath: string; + private readonly statePath: string; + private readonly previousStatePath: string; + private readonly lockPath: string; + + constructor(options: ExtensionStoreOptions = {}) { + this.extensionsDir = + options.extensionsDir ?? Storage.getUserExtensionsDir(); + this.storeDir = + options.storeDir ?? + path.join(Storage.getGlobalQwenDir(), 'extension-store'); + this.enablementPath = + options.enablementPath ?? + path.join(this.extensionsDir, 'extension-enablement.json'); + this.statePath = path.join(this.storeDir, 'state.json'); + this.previousStatePath = path.join(this.storeDir, 'state.previous.json'); + this.lockPath = path.join(this.storeDir, 'lock'); + } + + async ensureInitialized( + extensions: readonly ExtensionIdentity[], + ): Promise { + return await this.withLock( + async () => await this.ensureInitializedUnlocked(extensions), + ); + } + + async readConsistent( + readArtifacts: () => Promise<{ + value: T; + extensions: readonly ExtensionIdentity[]; + }>, + ): Promise<{ value: T; snapshot: ExtensionStoreSnapshot }> { + return await this.withLock(async () => { + const { value, extensions } = await readArtifacts(); + const snapshot = await this.ensureInitializedUnlocked(extensions); + return { value, snapshot }; + }); + } + + private async ensureInitializedUnlocked( + extensions: readonly ExtensionIdentity[], + ): Promise { + const existing = await this.readSnapshotUnlocked(); + const legacy = await this.readLegacyProjection(); + if (existing) { + let changed = false; + if (existing.legacyProjectionHash !== projectionHash(legacy)) { + if (!(await this.legacyProjectionIsNewerThanState())) { + try { + await this.writeLegacyProjectionUnlocked(existing); + } catch { + // state.json remains authoritative; a later access retries repair. + } + for (const identity of extensions) { + assertIdentity(identity); + if (existing.extensions[identity.id]) continue; + existing.extensions[identity.id] = { + name: identity.name, + defaultActivation: 'enabled', + workspaceOverrides: {}, + }; + changed = true; + } + } else { + const identities = new Map( + Object.entries(existing.extensions).map(([id, policy]) => [ + id, + { id, name: policy.name }, + ]), + ); + for (const identity of extensions) + identities.set(identity.id, identity); + for (const identity of identities.values()) { + assertIdentity(identity); + const existingPolicy = existing.extensions[identity.id]; + const { rules, activationChanged } = this.importLegacyProjection( + legacy[identity.name]?.overrides ?? [], + existingPolicy, + ); + if (existingPolicy) { + const previousRules = existingPolicy.legacyPathRules ?? []; + const policyChanged = + activationChanged || + existingPolicy.name !== identity.name || + previousRules.length !== rules.length || + previousRules.some((rule, index) => rule !== rules[index]); + if (!policyChanged) continue; + existingPolicy.name = identity.name; + if (rules.length > 0) { + existingPolicy.legacyPathRules = [...rules]; + } else { + delete existingPolicy.legacyPathRules; + } + changed = true; + } else { + existing.extensions[identity.id] = { + name: identity.name, + defaultActivation: 'enabled', + workspaceOverrides: {}, + ...(rules.length > 0 ? { legacyPathRules: [...rules] } : {}), + }; + changed = true; + } + } + if (!changed) { + try { + await this.writeLegacyProjectionUnlocked(existing); + } catch { + // state.json remains authoritative; a later access retries repair. + } + } + } + } else { + for (const identity of extensions) { + assertIdentity(identity); + if (existing.extensions[identity.id]) continue; + const rules = legacy[identity.name]?.overrides ?? []; + existing.extensions[identity.id] = { + name: identity.name, + defaultActivation: 'enabled', + workspaceOverrides: {}, + ...(rules.length > 0 ? { legacyPathRules: [...rules] } : {}), + }; + changed = true; + } + } + if (changed) { + existing.generation += 1; + await this.writeSnapshotUnlocked(existing); + } + return existing; + } + const policies: Record = {}; + for (const identity of extensions) { + assertIdentity(identity); + const rules = legacy[identity.name]?.overrides ?? []; + policies[identity.id] = { + name: identity.name, + defaultActivation: 'enabled', + workspaceOverrides: {}, + ...(rules.length > 0 ? { legacyPathRules: [...rules] } : {}), + }; + } + const snapshot: ExtensionStoreSnapshot = { + version: 2, + generation: 0, + legacyProjectionHash: projectionHash(legacy), + extensions: policies, + }; + await this.writeSnapshotUnlocked(snapshot); + return snapshot; + } + + async createStagingDirectory(): Promise { + await this.prepareDirectories(); + const stagingRoot = path.join(this.storeDir, 'staging'); + await fsp.mkdir(stagingRoot, { recursive: true, mode: 0o700 }); + return await fsp.mkdtemp(path.join(stagingRoot, 'transaction-')); + } + + async commitArtifact( + input: CommitExtensionArtifactInput, + ): Promise { + assertIdentity(input.identity); + this.assertArtifactPaths(input); + return await this.withLock(async () => { + const snapshot = + (await this.readSnapshotUnlocked()) ?? this.emptySnapshot(); + const transactionId = crypto.randomUUID(); + const transactionsDir = path.join(this.storeDir, 'transactions'); + const backupDirectory = path.join( + this.storeDir, + 'rollback', + transactionId, + ); + const journalPath = path.join(transactionsDir, `${transactionId}.json`); + const destinationExists = await this.pathExists( + input.destinationDirectory, + ); + if (input.operation === 'install' && destinationExists) { + throw new ExtensionConflictError( + `Extension "${input.identity.name}" is installed.`, + ); + } + if (input.operation === 'update' && !destinationExists) { + throw new ExtensionConflictError( + `Extension "${input.identity.name}" is not installed.`, + ); + } + if (input.operation === 'install' && !input.initialActivation) { + throw new Error('Install requires an initial activation.'); + } + if (input.operation !== 'uninstall') { + const nameConflict = Object.entries(snapshot.extensions).find( + ([extensionId, policy]) => + extensionId !== input.identity.id && + policy.name.toLowerCase() === input.identity.name.toLowerCase(), + ); + if (nameConflict) { + throw new ExtensionConflictError( + `Extension name "${input.identity.name}" conflicts with an installed extension.`, + ); + } + } + + const currentPolicy = snapshot.extensions[input.identity.id]; + if (currentPolicy && currentPolicy.name !== input.identity.name) { + throw new ExtensionConflictError( + `Extension id belongs to "${currentPolicy.name}", not "${input.identity.name}".`, + ); + } + if (input.operation === 'uninstall' && !currentPolicy) { + if (!destinationExists) return snapshot; + throw new ExtensionConflictError( + `Extension "${input.identity.name}" has no matching policy.`, + ); + } + if (input.operation === 'update' && !currentPolicy) { + throw new ExtensionConflictError( + `Extension "${input.identity.name}" is not installed.`, + ); + } + if ( + input.operation === 'update' && + input.expectedArtifactGeneration !== undefined && + (currentPolicy?.artifactGeneration ?? 0) !== + input.expectedArtifactGeneration + ) { + throw new ExtensionConflictError( + `Extension "${input.identity.name}" changed while its update was being prepared.`, + ); + } + + const targetSnapshot = structuredClone(snapshot); + if (input.operation === 'install') { + const initial = input.initialActivation!; + targetSnapshot.extensions[input.identity.id] = { + name: input.identity.name, + artifactGeneration: targetSnapshot.generation + 1, + defaultActivation: initial.scope === 'user' ? 'enabled' : 'disabled', + workspaceOverrides: + initial.scope === 'workspace' + ? { + [canonicalizeWorkspacePath(initial.workspacePath)]: 'enabled', + } + : {}, + }; + } else if (input.operation === 'uninstall') { + delete targetSnapshot.extensions[input.identity.id]; + } else { + const policy = targetSnapshot.extensions[input.identity.id]; + if (policy && policy.name !== input.identity.name) { + throw new ExtensionConflictError( + `Extension update changed name from "${policy.name}" to "${input.identity.name}".`, + ); + } + targetSnapshot.extensions[input.identity.id] = policy!; + targetSnapshot.extensions[input.identity.id]!.artifactGeneration = + targetSnapshot.generation + 1; + } + targetSnapshot.generation = snapshot.generation + 1; + targetSnapshot.legacyProjectionHash = projectionHash( + this.buildLegacyProjection(targetSnapshot), + ); + + const journal: ExtensionTransactionJournal = { + version: 1, + transactionId, + operation: input.operation, + phase: 'prepared', + destinationDirectory: input.destinationDirectory, + ...(input.stagingDirectory + ? { stagingDirectory: input.stagingDirectory } + : {}), + backupDirectory, + previousGeneration: snapshot.generation, + targetGeneration: targetSnapshot.generation, + targetSnapshot, + }; + await atomicWriteJSON(journalPath, journal, { + mode: 0o600, + forceMode: true, + noFollow: true, + }); + + let stateCommitted = false; + try { + if (destinationExists) { + await renameWithRetry( + input.destinationDirectory, + backupDirectory, + 3, + 50, + ); + } + if (input.operation !== 'uninstall') { + await renameWithRetry( + input.stagingDirectory!, + input.destinationDirectory, + 3, + 50, + ); + } + journal.phase = 'artifact_swapped'; + await atomicWriteJSON(journalPath, journal, { + mode: 0o600, + forceMode: true, + noFollow: true, + }); + + await this.writeSnapshotUnlocked(targetSnapshot); + stateCommitted = true; + journal.phase = 'state_committed'; + try { + await atomicWriteJSON(journalPath, journal, { + mode: 0o600, + forceMode: true, + noFollow: true, + }); + } catch { + // The snapshot generation is enough for recovery to recognize the + // commit even if this advisory phase update could not be persisted. + } + } catch (error) { + if (!stateCommitted) { + try { + await this.rollbackJournal(journal); + await fsp.rm(journalPath, { force: true }); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'Extension transaction failed and rollback recovery did not complete.', + { cause: error }, + ); + } + } + throw error; + } + + try { + await this.cleanupCommittedJournal(journal, journalPath); + } catch { + // The committed state is authoritative. Recovery retries cleanup on + // the next store operation without reporting a false mutation failure. + } + return targetSnapshot; + }); + } + + async readSnapshot(): Promise { + return await this.withLock(async () => { + const snapshot = await this.readSnapshotUnlocked(); + if (!snapshot) return this.emptySnapshot(); + return snapshot; + }); + } + + getActivation( + snapshot: ExtensionStoreSnapshot, + extensionId: string, + extensionName: string, + workspacePath: string, + ): ExtensionActivationResult { + const policy = snapshot.extensions[extensionId]; + if (!policy || policy.name !== extensionName) { + return { + default: 'enabled', + workspace: 'inherit', + effective: 'enabled', + source: 'default', + }; + } + const canonicalWorkspace = canonicalizeWorkspacePath(workspacePath); + const exact = policy.workspaceOverrides[canonicalWorkspace]; + if (exact === 'enabled' || exact === 'disabled') { + return { + default: policy.defaultActivation, + workspace: exact, + effective: exact, + source: 'workspace_override', + }; + } + if (exact === 'inherit') { + return { + default: policy.defaultActivation, + workspace: 'inherit', + effective: policy.defaultActivation, + source: 'default', + }; + } + let effective = policy.defaultActivation; + let matched = false; + const legacyCandidates = [ + normalizeRulePath(workspacePath), + normalizeRulePath(canonicalWorkspace), + ]; + for (const rule of policy.legacyPathRules ?? []) { + const override = Override.fromFileRule(rule); + if ( + !legacyCandidates.some((candidate) => override.matchesPath(candidate)) + ) { + continue; + } + effective = override.isDisable ? 'disabled' : 'enabled'; + matched = true; + } + return { + default: policy.defaultActivation, + workspace: 'inherit', + effective, + source: matched ? 'legacy_path_rule' : 'default', + }; + } + + async setDefaultActivation( + identity: ExtensionIdentity, + activation: ExtensionActivation, + ): Promise { + return await this.mutate(identity, (policy) => { + policy.defaultActivation = activation; + }); + } + + async setActivationScope( + identity: ExtensionIdentity, + activation: InitialExtensionActivation, + ): Promise { + return await this.mutate(identity, (policy) => { + policy.defaultActivation = + activation.scope === 'user' ? 'enabled' : 'disabled'; + policy.workspaceOverrides = + activation.scope === 'workspace' + ? { + [canonicalizeWorkspacePath(activation.workspacePath)]: 'enabled', + } + : {}; + delete policy.legacyPathRules; + }); + } + + async setWorkspaceActivation( + identity: ExtensionIdentity, + workspacePath: string, + activation: ExtensionActivation, + ): Promise { + return await this.mutate(identity, (policy) => { + policy.workspaceOverrides[canonicalizeWorkspacePath(workspacePath)] = + activation; + }); + } + + async clearWorkspaceActivation( + identity: ExtensionIdentity, + workspacePath: string, + ): Promise { + return await this.mutate(identity, (policy) => { + const canonicalWorkspace = canonicalizeWorkspacePath(workspacePath); + const legacyCandidates = [ + normalizeRulePath(workspacePath), + normalizeRulePath(canonicalWorkspace), + ]; + const legacyMatches = (policy.legacyPathRules ?? []).some((rule) => { + const override = Override.fromFileRule(rule); + return legacyCandidates.some((candidate) => + override.matchesPath(candidate), + ); + }); + if (legacyMatches) { + policy.workspaceOverrides[canonicalWorkspace] = 'inherit'; + } else { + delete policy.workspaceOverrides[canonicalWorkspace]; + } + }); + } + + async setLegacyPathActivation( + identity: ExtensionIdentity, + scopePath: string, + activation: ExtensionActivation, + ): Promise { + const canonicalScope = canonicalizeWorkspacePath(scopePath); + return await this.mutate(identity, (policy) => { + const scope = Override.fromInput(canonicalScope, true); + for (const workspacePath of Object.keys(policy.workspaceOverrides)) { + if (scope.matchesPath(normalizeRulePath(workspacePath))) { + delete policy.workspaceOverrides[workspacePath]; + } + } + const nextRule = Override.fromInput( + activation === 'disabled' ? `!${canonicalScope}` : canonicalScope, + true, + ); + const rules = (policy.legacyPathRules ?? []).filter((rule) => { + const existing = Override.fromFileRule(rule); + return ( + !existing.conflictsWith(nextRule) && + !existing.isEqualTo(nextRule) && + !existing.isChildOf(nextRule) + ); + }); + rules.push(nextRule.output()); + policy.legacyPathRules = rules; + }); + } + + private async mutate( + identity: ExtensionIdentity, + update: (policy: ExtensionPolicy) => void, + ): Promise { + assertIdentity(identity); + return await this.withLock(async () => { + const snapshot = + (await this.readSnapshotUnlocked()) ?? this.emptySnapshot(); + const policy = snapshot.extensions[identity.id]; + if (!policy) { + throw new ExtensionConflictError( + `Extension "${identity.name}" is not installed.`, + ); + } + if (policy.name !== identity.name) { + throw new Error( + `Extension id ${identity.id} belongs to "${policy.name}", not "${identity.name}".`, + ); + } + update(policy); + snapshot.extensions[identity.id] = policy; + snapshot.generation += 1; + await this.writeSnapshotUnlocked(snapshot); + return snapshot; + }); + } + + private emptySnapshot(): ExtensionStoreSnapshot { + return { + version: 2, + generation: 0, + legacyProjectionHash: projectionHash({}), + extensions: {}, + }; + } + + private async readSnapshotUnlocked(): Promise { + let content: string; + try { + content = await fsp.readFile(this.statePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + return parseState(content, this.statePath); + } + + private async readLegacyProjection(): Promise { + try { + return JSON.parse( + await fsp.readFile(this.enablementPath, 'utf8'), + ) as AllExtensionsEnablementConfig; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; + if (error instanceof SyntaxError) { + throw new ExtensionStoreCorruptError( + `Extension enablement projection is corrupt at ${this.enablementPath}.`, + { cause: error }, + ); + } + throw error; + } + } + + private buildLegacyProjection( + snapshot: ExtensionStoreSnapshot, + ): AllExtensionsEnablementConfig { + const projection = Object.create(null) as AllExtensionsEnablementConfig; + for (const policy of Object.values(snapshot.extensions)) { + const overrides: string[] = []; + if (policy.defaultActivation === 'disabled') overrides.push('!/*'); + overrides.push(...(policy.legacyPathRules ?? [])); + for (const [workspacePath, activation] of Object.entries( + policy.workspaceOverrides, + )) { + const effective = + activation === 'inherit' ? policy.defaultActivation : activation; + overrides.push( + Override.fromInput( + effective === 'disabled' ? `!${workspacePath}` : workspacePath, + false, + ).output(), + ); + } + if (overrides.length > 0) projection[policy.name] = { overrides }; + } + return projection; + } + + private importLegacyProjection( + incomingRules: readonly string[], + policy: ExtensionPolicy | undefined, + ): { rules: string[]; activationChanged: boolean } { + if (!policy) return { rules: [...incomingRules], activationChanged: false }; + const generatedRules: Array<{ + rule: Override; + workspacePath?: string; + }> = []; + if (policy.defaultActivation === 'disabled') { + generatedRules.push({ + rule: Override.fromFileRule('!/*'), + }); + } + for (const [workspacePath, activation] of Object.entries( + policy.workspaceOverrides, + )) { + const effective = + activation === 'inherit' ? policy.defaultActivation : activation; + generatedRules.push({ + rule: Override.fromInput( + effective === 'disabled' ? `!${workspacePath}` : workspacePath, + false, + ), + workspacePath, + }); + } + let activationChanged = false; + const consumed = new Set(); + const rules = incomingRules.filter((rule) => { + const incoming = Override.fromFileRule(rule); + const exactIndex = generatedRules.findIndex( + (generated, index) => + !consumed.has(index) && generated.rule.isEqualTo(incoming), + ); + if (exactIndex >= 0) { + consumed.add(exactIndex); + return false; + } + const oppositeIndex = generatedRules.findIndex( + (generated, index) => + !consumed.has(index) && + generated.rule.baseRule === incoming.baseRule && + generated.rule.includeSubdirs === incoming.includeSubdirs && + generated.rule.isDisable !== incoming.isDisable, + ); + if (oppositeIndex < 0) return true; + consumed.add(oppositeIndex); + const opposite = generatedRules[oppositeIndex]; + if (opposite.workspacePath) { + policy.workspaceOverrides[opposite.workspacePath] = incoming.isDisable + ? 'disabled' + : 'enabled'; + } else { + policy.defaultActivation = 'enabled'; + } + activationChanged = true; + return false; + }); + return { rules, activationChanged }; + } + + private async writeSnapshotUnlocked( + snapshot: ExtensionStoreSnapshot, + ): Promise { + await this.prepareDirectories(); + const projection = this.buildLegacyProjection(snapshot); + snapshot.legacyProjectionHash = projectionHash(projection); + try { + const previous = parseState( + await fsp.readFile(this.statePath, 'utf8'), + this.statePath, + ); + await atomicWriteJSON(this.previousStatePath, previous, { + mode: 0o600, + forceMode: true, + noFollow: true, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await atomicWriteJSON(this.statePath, snapshot, { + mode: 0o600, + forceMode: true, + noFollow: true, + }); + try { + await this.writeLegacyProjectionUnlocked(snapshot, projection); + } catch { + // state.json is the commit point. A later V2-aware store access repairs + // a stale projection instead of reporting a mutation failure after the + // authoritative state has already changed. + } + } + + private async writeLegacyProjectionUnlocked( + snapshot: ExtensionStoreSnapshot, + projection = this.buildLegacyProjection(snapshot), + ): Promise { + await atomicWriteJSON(this.enablementPath, projection, { + mode: 0o600, + forceMode: true, + noFollow: true, + }); + } + + private async legacyProjectionIsNewerThanState(): Promise { + try { + const [state, projection] = await Promise.all([ + fsp.stat(this.statePath, { bigint: true }), + fsp.stat(this.enablementPath, { bigint: true }), + ]); + if (projection.mtimeNs === state.mtimeNs) { + throw new ExtensionStoreCorruptError( + `Extension store state and projection disagree at the same timestamp in ${this.storeDir}.`, + ); + } + return projection.mtimeNs > state.mtimeNs; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + } + + private async prepareDirectories(): Promise { + await fsp.mkdir(this.extensionsDir, { recursive: true, mode: 0o700 }); + await fsp.mkdir(this.storeDir, { recursive: true, mode: 0o700 }); + const privateDirectories = [ + this.storeDir, + ...['staging', 'rollback', 'transactions'].map((directory) => + path.join(this.storeDir, directory), + ), + ]; + await Promise.all( + privateDirectories.slice(1).map((directory) => + fsp.mkdir(directory, { + recursive: true, + mode: 0o700, + }), + ), + ); + await Promise.all( + privateDirectories.map((directory) => fsp.chmod(directory, 0o700)), + ); + const handle = await fsp.open(this.lockPath, 'a', 0o600); + try { + await handle.chmod(0o600); + } finally { + await handle.close(); + } + } + + private async withLock(run: () => Promise): Promise { + return await getStoreMutex(this.storeDir).runExclusive(async () => { + await this.prepareDirectories(); + let release: () => Promise; + try { + release = await lockfile.lock(this.lockPath, { + stale: 60_000, + update: 5_000, + retries: { + retries: 60, + factor: 1.2, + minTimeout: 50, + maxTimeout: 500, + randomize: true, + }, + }); + } catch (error) { + throw new ExtensionStoreBusyError(this.storeDir, { cause: error }); + } + try { + await this.recoverCorruptStateUnlocked(); + await this.recoverTransactionsUnlocked(); + return await run(); + } finally { + try { + await release(); + } catch (error) { + debugLogger.warn('Failed to release extension store lock:', error); + } + } + }); + } + + private assertArtifactPaths(input: CommitExtensionArtifactInput): void { + const extensionsRoot = path.resolve(this.extensionsDir); + const destination = path.resolve(input.destinationDirectory); + if ( + path.dirname(destination) !== extensionsRoot || + destination === extensionsRoot + ) { + throw new Error('Extension destination must be a direct child.'); + } + if (input.operation === 'uninstall') { + if (input.stagingDirectory !== undefined) { + throw new Error('Uninstall does not accept a staging directory.'); + } + return; + } + if (!input.stagingDirectory) { + throw new Error(`${input.operation} requires a staging directory.`); + } + const stagingRoot = path.resolve(this.storeDir, 'staging'); + const staging = path.resolve(input.stagingDirectory); + if (path.dirname(staging) !== stagingRoot) { + throw new Error('Extension staging directory is outside the store.'); + } + } + + private async pathExists(filePath: string): Promise { + try { + await fsp.access(filePath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + } + + private async recoverTransactionsUnlocked(): Promise { + const transactionsDir = path.join(this.storeDir, 'transactions'); + const names = await fsp.readdir(transactionsDir); + const snapshot = await this.readSnapshotUnlocked(); + for (const name of names) { + if (!name.endsWith('.json')) continue; + const journalPath = path.join(transactionsDir, name); + const journal = await this.readRecoverableJournalUnlocked(journalPath); + if (!journal) continue; + if ( + journal.phase === 'state_committed' || + (snapshot?.generation ?? -1) >= journal.targetGeneration + ) { + try { + await this.cleanupCommittedJournal(journal, journalPath); + } catch { + // The authoritative state is already committed. Keep the journal so + // a later store operation can retry cleanup without blocking reads + // or unrelated mutations. + } + } else { + await this.rollbackJournal(journal); + await fsp.rm(journalPath, { force: true }); + } + } + } + + private async recoverCorruptStateUnlocked(): Promise { + let stateMissing = false; + try { + const snapshot = await this.readSnapshotUnlocked(); + if (snapshot) return; + stateMissing = true; + } catch (error) { + if (!(error instanceof ExtensionStoreCorruptError)) throw error; + } + + const candidates: Array<{ + snapshot: ExtensionStoreSnapshot; + recoveryGeneration: number; + }> = []; + const transactionsDir = path.join(this.storeDir, 'transactions'); + for (const name of await fsp.readdir(transactionsDir)) { + if (!name.endsWith('.json')) continue; + const journal = await this.readRecoverableJournalUnlocked( + path.join(transactionsDir, name), + ); + if (journal?.phase === 'state_committed') { + candidates.push({ + snapshot: journal.targetSnapshot, + recoveryGeneration: journal.targetGeneration, + }); + } + } + + let backupError: unknown; + try { + const previous = parseState( + await fsp.readFile(this.previousStatePath, 'utf8'), + this.previousStatePath, + ); + candidates.push({ + snapshot: previous, + recoveryGeneration: previous.generation, + }); + } catch (error) { + backupError = error; + } + + const latest = candidates.sort( + (left, right) => right.recoveryGeneration - left.recoveryGeneration, + )[0]; + if (latest) { + const recovered = { + ...latest.snapshot, + generation: latest.recoveryGeneration, + }; + await atomicWriteJSON(this.statePath, recovered, { + mode: 0o600, + forceMode: true, + noFollow: true, + }); + await this.writeLegacyProjectionUnlocked(recovered); + return; + } + if ( + stateMissing && + (backupError as NodeJS.ErrnoException | undefined)?.code === 'ENOENT' + ) { + return; + } + throw new ExtensionStoreCorruptError( + `Extension store state and recovery data are corrupt at ${this.storeDir}.`, + { cause: backupError }, + ); + } + + private async readJournalUnlocked( + journalPath: string, + ): Promise { + try { + const journal = JSON.parse( + await fsp.readFile(journalPath, 'utf8'), + ) as ExtensionTransactionJournal; + if ( + journal.version !== 1 || + !/^[a-zA-Z0-9-]{1,128}$/.test(journal.transactionId) || + !['install', 'update', 'uninstall'].includes(journal.operation) || + !['prepared', 'artifact_swapped', 'state_committed'].includes( + journal.phase, + ) || + !Number.isSafeInteger(journal.previousGeneration) || + journal.targetGeneration !== journal.previousGeneration + 1 + ) { + throw new Error('invalid transaction journal schema'); + } + journal.targetSnapshot = parseState( + JSON.stringify(journal.targetSnapshot), + journalPath, + ); + if (journal.targetSnapshot.generation !== journal.targetGeneration) { + throw new Error('transaction target generation does not match'); + } + this.assertRecoveredJournalPaths(journal, journalPath); + return journal; + } catch (error) { + if (error instanceof ExtensionStoreCorruptError) throw error; + throw new ExtensionStoreCorruptError( + `Extension transaction journal is corrupt at ${journalPath}.`, + { cause: error }, + ); + } + } + + private async readRecoverableJournalUnlocked( + journalPath: string, + ): Promise { + try { + return await this.readJournalUnlocked(journalPath); + } catch (error) { + if (!(error instanceof ExtensionStoreCorruptError)) throw error; + const quarantinePath = `${journalPath}.corrupt-${crypto.randomUUID()}`; + try { + await fsp.rename(journalPath, quarantinePath); + } catch (quarantineError) { + throw new ExtensionStoreCorruptError( + `Extension transaction journal is corrupt and could not be quarantined at ${journalPath}.`, + { + cause: new AggregateError([error, quarantineError]), + }, + ); + } + debugLogger.warn( + `Quarantined corrupt extension transaction journal at ${quarantinePath}:`, + error, + ); + return undefined; + } + } + + private assertRecoveredJournalPaths( + journal: ExtensionTransactionJournal, + journalPath: string, + ): void { + const extensionsRoot = path.resolve(this.extensionsDir); + const rollbackRoot = path.resolve(this.storeDir, 'rollback'); + const stagingRoot = path.resolve(this.storeDir, 'staging'); + const transactionsRoot = path.resolve(this.storeDir, 'transactions'); + if ( + path.dirname(path.resolve(journalPath)) !== transactionsRoot || + path.basename(journalPath) !== `${journal.transactionId}.json` || + path.dirname(path.resolve(journal.destinationDirectory)) !== + extensionsRoot || + path.dirname(path.resolve(journal.backupDirectory)) !== rollbackRoot || + (journal.stagingDirectory !== undefined && + path.dirname(path.resolve(journal.stagingDirectory)) !== stagingRoot) || + (journal.operation === 'uninstall' && + journal.stagingDirectory !== undefined) || + (journal.operation !== 'uninstall' && !journal.stagingDirectory) + ) { + throw new UnsafeRecoveredJournalError( + `Extension transaction ${journal.transactionId} contains unsafe paths.`, + ); + } + } + + private async rollbackJournal( + journal: ExtensionTransactionJournal, + ): Promise { + const hasBackup = await this.pathExists(journal.backupDirectory); + if ( + hasBackup || + (journal.operation === 'install' && + !(journal.stagingDirectory + ? await this.pathExists(journal.stagingDirectory) + : false)) + ) { + await fsp.rm(journal.destinationDirectory, { + recursive: true, + force: true, + }); + } + if (hasBackup) { + await renameWithRetry( + journal.backupDirectory, + journal.destinationDirectory, + 3, + 50, + ); + } + if (journal.stagingDirectory) { + await fsp.rm(journal.stagingDirectory, { + recursive: true, + force: true, + }); + } + } + + private async cleanupCommittedJournal( + journal: ExtensionTransactionJournal, + journalPath: string, + ): Promise { + await fsp.rm(journal.backupDirectory, { + recursive: true, + force: true, + }); + if (journal.stagingDirectory) { + await fsp.rm(journal.stagingDirectory, { + recursive: true, + force: true, + }); + } + await fsp.rm(journalPath, { force: true }); + } +} diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 27abc861a35..7f9b801008b 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -25,8 +25,11 @@ import { hashValue, type ExtensionConfig, type ExtensionMutationEvent, + type PreparedExtensionMutation, } from './extensionManager.js'; import type { MCPServerConfig, ExtensionInstallMetadata } from '../index.js'; +import { ExtensionStore } from './extension-store.js'; +import { ExtensionPreferencesStore } from './extensionPreferences.js'; const mockGit = { clone: vi.fn(), @@ -35,6 +38,8 @@ const mockGit = { checkout: vi.fn(), listRemote: vi.fn(), revparse: vi.fn(), + version: vi.fn(), + env: vi.fn(), path: vi.fn(), }; const mockDownloadFromArchiveUrl = vi.hoisted(() => vi.fn()); @@ -128,8 +133,11 @@ describe('extension tests', () => { let tempHomeDir: string; let tempWorkspaceDir: string; let userExtensionsDir: string; + let savedQwenHome: string | undefined; beforeEach(() => { + savedQwenHome = process.env['QWEN_HOME']; + delete process.env['QWEN_HOME']; tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'qwen-code-test-home-'), ); @@ -148,6 +156,11 @@ describe('extension tests', () => { afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); + if (savedQwenHome === undefined) { + delete process.env['QWEN_HOME']; + } else { + process.env['QWEN_HOME'] = savedQwenHome; + } vi.restoreAllMocks(); }); @@ -157,6 +170,9 @@ describe('extension tests', () => { return new ExtensionManager({ workspaceDir: tempWorkspaceDir, isWorkspaceTrusted: true, + extensionStore: new ExtensionStore({ + extensionsDir: userExtensionsDir, + }), ...options, }); } @@ -170,6 +186,461 @@ describe('extension tests', () => { ); } + it('installs and uninstalls within an injected extension store root', async () => { + const archivePath = path.join(tempWorkspaceDir, 'custom-root.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtractedExtension(destination, 'custom-root'); + }, + ); + const customExtensionsDir = path.join(tempHomeDir, 'custom-extensions'); + const manager = createExtensionManager({ + extensionStore: new ExtensionStore({ + extensionsDir: customExtensionsDir, + }), + }); + + const installed = await manager.installExtension( + { type: 'local', source: archivePath }, + async () => {}, + ); + + expect(installed.path).toBe( + path.join(customExtensionsDir, 'custom-root'), + ); + await manager.uninstallExtensionById(installed.id, true); + expect(fs.existsSync(installed.path)).toBe(false); + }); + + it('commits workspace initial activation with the installed artifact', async () => { + const archivePath = path.join(tempWorkspaceDir, 'workspace-ext.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtractedExtension(destination, 'workspace-ext'); + }, + ); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { type: 'local', source: archivePath }, + () => Promise.resolve(), + undefined, + tempWorkspaceDir, + undefined, + { scope: 'workspace', workspacePath: tempWorkspaceDir }, + ); + + const activation = await manager.getExtensionActivation( + extension.id, + tempWorkspaceDir, + ); + expect(activation).toMatchObject({ + default: 'disabled', + workspace: 'enabled', + effective: 'enabled', + }); + }); + + it('prepares without mutating the store and commits exactly once', async () => { + const archivePath = path.join(tempWorkspaceDir, 'prepared-ext.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtractedExtension(destination, 'prepared-ext'); + }, + ); + const manager = createExtensionManager(); + const events: ExtensionMutationEvent[] = []; + manager.addMutationListener((event) => events.push(event)); + await manager.refreshCache(); + const before = await manager.getExtensionStoreSnapshot(); + + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { type: 'local', source: archivePath }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + }); + + expect(fs.existsSync(path.join(userExtensionsDir, 'prepared-ext'))).toBe( + false, + ); + expect((await manager.getExtensionStoreSnapshot()).generation).toBe( + before.generation, + ); + expect(events).toEqual([]); + + const committed = await manager.commitPreparedExtension(prepared); + expect(committed.extension?.name).toBe('prepared-ext'); + expect(committed.generation).toBe(before.generation + 1); + await expect( + manager.commitPreparedExtension(prepared), + ).rejects.toMatchObject({ code: 'prepared_extension_consumed' }); + await manager.disposePreparedExtension(prepared); + await manager.disposePreparedExtension(prepared); + expect(events).toEqual([ + { id: 1, phase: 'start', operation: 'installExtension' }, + { id: 1, phase: 'end', operation: 'installExtension' }, + ]); + }); + + it('signals the durable commit before runtime refresh completes', async () => { + const archivePath = path.join(tempWorkspaceDir, 'commit-boundary.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtractedExtension(destination, 'commit-boundary'); + }, + ); + const manager = createExtensionManager(); + let finishRefresh!: () => void; + const refreshBlocked = new Promise((resolve) => { + finishRefresh = resolve; + }); + vi.spyOn(manager, 'refreshTools').mockImplementation( + async () => await refreshBlocked, + ); + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { type: 'local', source: archivePath }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + }); + const committedGenerations: number[] = []; + let settled = false; + + const committing = manager + .commitPreparedExtension(prepared, (generation) => { + committedGenerations.push(generation); + }) + .finally(() => { + settled = true; + }); + + await vi.waitFor(() => expect(committedGenerations).toHaveLength(1)); + expect(settled).toBe(false); + finishRefresh(); + await committing; + }); + + it('fully validates the staged extension before commit', async () => { + const archivePath = path.join(tempWorkspaceDir, 'invalid-context.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync( + path.join(destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ + name: 'invalid-context', + version: '1.0.0', + contextFileName: 42, + }), + ); + }, + ); + const manager = createExtensionManager(); + const before = await manager.getExtensionStoreSnapshot(); + + await expect( + manager.prepareExtensionInstall({ + installMetadata: { type: 'local', source: archivePath }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + }), + ).rejects.toThrow(); + + expect(await manager.getExtensionStoreSnapshot()).toEqual(before); + expect( + fs.existsSync(path.join(userExtensionsDir, 'invalid-context')), + ).toBe(false); + }); + + it('commits a fully validated extension without an explicit version', async () => { + const archivePath = path.join(tempWorkspaceDir, 'default-version.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync( + path.join(destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'default-version' }), + ); + }, + ); + const manager = createExtensionManager(); + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { type: 'local', source: archivePath }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + }); + + try { + const committed = await manager.commitPreparedExtension(prepared); + expect(committed.version).toBe('1.0.0'); + expect(committed.extension?.version).toBe('1.0.0'); + } finally { + await manager.disposePreparedExtension(prepared); + } + }); + + it('stops archive preparation when cancellation follows download', async () => { + const controller = new AbortController(); + const reason = new Error('preparation expired'); + mockDownloadFromArchiveUrl.mockImplementationOnce(async () => { + controller.abort(reason); + }); + const manager = createExtensionManager(); + + await expect( + manager.prepareExtensionInstall({ + installMetadata: { + type: 'archive-url', + source: 'https://example.com/extension.zip', + }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + signal: controller.signal, + }), + ).rejects.toBe(reason); + }); + + it('uses the installed path for Claude plugin root replacement', async () => { + const archivePath = path.join(tempWorkspaceDir, 'claude-ext.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + const pluginDirectory = path.join(destination, '.claude-plugin'); + fs.mkdirSync(pluginDirectory, { recursive: true }); + fs.writeFileSync( + path.join(pluginDirectory, 'plugin.json'), + JSON.stringify({ name: 'claude-ext', version: '1.0.0' }), + ); + fs.mkdirSync(path.join(destination, 'hooks')); + fs.writeFileSync( + path.join(destination, 'README.md'), + '${CLAUDE_PLUGIN_ROOT}/scripts/setup.sh', + ); + }, + ); + const manager = createExtensionManager(); + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { + type: 'local', + source: archivePath, + }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + }); + + try { + await manager.commitPreparedExtension(prepared); + expect( + fs.readFileSync( + path.join(prepared.destinationDirectory, 'README.md'), + 'utf8', + ), + ).toBe(path.join(prepared.destinationDirectory, 'scripts', 'setup.sh')); + } finally { + await manager.disposePreparedExtension(prepared); + } + }); + + it('does not report a temp cleanup warning when an immediate retry succeeds', async () => { + const archivePath = path.join(tempWorkspaceDir, 'cleanup-warning.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtractedExtension(destination, 'cleanup-warning'); + }, + ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { type: 'local', source: archivePath }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + }); + const cleanupPath = prepared.cleanupPaths[0]!; + const rm = fs.promises.rm.bind(fs.promises); + let cleanupAttempts = 0; + vi.spyOn(fs.promises, 'rm').mockImplementation( + async (target, options) => { + if (target === cleanupPath && cleanupAttempts++ === 0) { + throw new Error('cleanup denied'); + } + return await rm(target, options); + }, + ); + + const committed = await manager.commitPreparedExtension(prepared); + + expect(committed.generation).toBeGreaterThan(0); + expect(committed.warnings).toBeUndefined(); + expect(prepared.disposed).toBe(true); + await expect( + manager.disposePreparedExtension(prepared), + ).resolves.toBeUndefined(); + expect(cleanupAttempts).toBe(2); + expect(fs.existsSync(cleanupPath)).toBe(false); + }); + + it('reports deferred settings failure as a post-commit warning', async () => { + const archivePath = path.join(tempWorkspaceDir, 'settings-warning.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtractedExtension(destination, 'settings-warning'); + }, + ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { type: 'local', source: archivePath }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + }); + Object.defineProperty(prepared, 'commitSettings', { + value: vi.fn().mockRejectedValue(new Error('keychain unavailable')), + }); + + const committed = await manager.commitPreparedExtension(prepared); + + expect(committed.warnings).toContainEqual({ + code: 'extension_settings_legacy_sync_failed', + error: 'keychain unavailable', + }); + }); + + it('signals the durable commit before deferred settings finish', async () => { + const archivePath = path.join(tempWorkspaceDir, 'settings-deferred.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtractedExtension(destination, 'settings-deferred'); + }, + ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { type: 'local', source: archivePath }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + }); + let finishSettings!: () => void; + const settingsBlocked = new Promise((resolve) => { + finishSettings = resolve; + }); + const commitSettings = vi.fn(async () => await settingsBlocked); + Object.defineProperty(prepared, 'commitSettings', { + value: commitSettings, + }); + const onCommitted = vi.fn(); + + const committing = manager.commitPreparedExtension(prepared, onCommitted); + await vi.waitFor(() => expect(commitSettings).toHaveBeenCalledOnce()); + + expect(onCommitted).toHaveBeenCalledOnce(); + expect(onCommitted.mock.invocationCallOrder[0]).toBeLessThan( + commitSettings.mock.invocationCallOrder[0]!, + ); + finishSettings(); + await expect(committing).resolves.toMatchObject({ + identity: { name: 'settings-deferred' }, + }); + }); + + it('surfaces committed runtime refresh warnings after install reloads', async () => { + const archivePath = path.join(tempWorkspaceDir, 'refresh-warning.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtractedExtension(destination, 'refresh-warning'); + }, + ); + const manager = createExtensionManager(); + await manager.refreshCache(); + vi.spyOn(manager, 'refreshTools').mockRejectedValueOnce( + new Error('runtime stale'), + ); + + await expect( + manager.installExtension( + { type: 'local', source: archivePath }, + async () => {}, + ), + ).rejects.toMatchObject({ + code: 'extension_committed_with_warnings', + committed: true, + identity: { name: 'refresh-warning' }, + warnings: [ + { + code: 'extension_runtime_refresh_failed', + error: 'runtime stale', + }, + ], + }); + }); + + it('records error telemetry when a prepared install commit fails', async () => { + const archivePath = path.join(tempWorkspaceDir, 'commit-failure.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtractedExtension(destination, 'commit-failure'); + }, + ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { type: 'local', source: archivePath }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + }); + const commitSettings = vi.fn(); + Object.defineProperty(prepared, 'commitSettings', { + value: commitSettings, + }); + vi.spyOn( + ExtensionStore.prototype, + 'commitArtifact', + ).mockRejectedValueOnce(new Error('disk full')); + mockLogExtensionInstallEvent.mockClear(); + + await expect(manager.commitPreparedExtension(prepared)).rejects.toThrow( + 'disk full', + ); + expect(mockLogExtensionInstallEvent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + extension_name: 'commit-failure', + status: 'error', + }), + ); + expect(commitSettings).not.toHaveBeenCalled(); + await manager.disposePreparedExtension(prepared); + }); + + it('rejects forged prepared handles without deleting their paths', async () => { + const manager = createExtensionManager(); + const protectedPath = path.join(tempWorkspaceDir, 'keep-me'); + fs.mkdirSync(protectedPath); + const forged = { + stagingDirectory: protectedPath, + cleanupPaths: [], + disposed: false, + } as unknown as PreparedExtensionMutation; + + await expect( + manager.commitPreparedExtension(forged), + ).rejects.toMatchObject({ code: 'invalid_prepared_extension' }); + await expect( + manager.disposePreparedExtension(forged), + ).rejects.toMatchObject({ code: 'invalid_prepared_extension' }); + expect(fs.existsSync(protectedPath)).toBe(true); + }); + it('should install an extension from a local archive', async () => { const archivePath = path.join(tempWorkspaceDir, 'local-extension.zip'); fs.writeFileSync(archivePath, 'not used by mocked extractor'); @@ -193,6 +664,7 @@ describe('extension tests', () => { expect(mockExtractArchiveFile).toHaveBeenCalledWith( archivePath, expect.any(String), + undefined, ); expect(extension.name).toBe('local-archive-extension'); expect(extension.installMetadata).toMatchObject({ @@ -225,8 +697,6 @@ describe('extension tests', () => { expect(events).toEqual([ { id: 1, phase: 'start', operation: 'installExtension' }, - { id: 2, phase: 'start', operation: 'enableExtension' }, - { id: 2, phase: 'end', operation: 'enableExtension' }, { id: 1, phase: 'end', operation: 'installExtension' }, ]); }); @@ -268,12 +738,18 @@ describe('extension tests', () => { return undefined; }, ); - mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/owner/repo' }, + }, + ]); mockGit.fetch.mockResolvedValue(undefined); mockGit.checkout.mockResolvedValue(undefined); const manager = createExtensionManager(); await manager.refreshCache(); + const controller = new AbortController(); const extension = await manager.installExtension( { @@ -281,6 +757,11 @@ describe('extension tests', () => { type: 'git', }, async () => {}, + undefined, + undefined, + undefined, + { scope: 'user' }, + controller.signal, ); expect(downloadMock).toHaveBeenCalled(); @@ -352,6 +833,7 @@ describe('extension tests', () => { const manager = createExtensionManager(); await manager.refreshCache(); + const controller = new AbortController(); const extension = await manager.installExtension( { @@ -359,6 +841,11 @@ describe('extension tests', () => { type: 'archive-url', }, async () => {}, + undefined, + undefined, + undefined, + { scope: 'user' }, + controller.signal, ); expect(mockDownloadFromArchiveUrl).toHaveBeenCalledWith( @@ -367,6 +854,7 @@ describe('extension tests', () => { type: 'archive-url', }), expect.any(String), + controller.signal, ); expect(extension.name).toBe('archive-url-extension'); expect(extension.installMetadata).toMatchObject({ @@ -375,6 +863,29 @@ describe('extension tests', () => { }); }); + it('forces the manager network policy onto remote operations', async () => { + mockDownloadFromArchiveUrl.mockImplementation( + async (_metadata: ExtensionInstallMetadata, destination: string) => { + writeExtractedExtension(destination, 'policy-extension'); + }, + ); + const manager = createExtensionManager({ networkPolicy: 'public' }); + + await manager.installExtension( + { + source: 'https://example.com/policy-extension.zip', + type: 'archive-url', + }, + async () => {}, + ); + + expect(mockDownloadFromArchiveUrl).toHaveBeenCalledWith( + expect.objectContaining({ networkPolicy: 'public' }), + expect.any(String), + undefined, + ); + }); + it('should clean up the temp dir when archive URL download fails', async () => { let tempDir: string | undefined; mockDownloadFromArchiveUrl.mockImplementation( @@ -431,7 +942,7 @@ describe('extension tests', () => { }); describe('uninstallExtension', () => { - it('should emit mutation lifecycle events around uninstall', async () => { + it('returns a committed warning when preference cleanup fails', async () => { createExtension({ extensionsDir: userExtensionsDir, name: 'my-extension', @@ -442,32 +953,142 @@ describe('extension tests', () => { originSource: 'QwenCode', }, }); - const manager = createExtensionManager(); - const events: ExtensionMutationEvent[] = []; - manager.addMutationListener((event) => events.push(event)); await manager.refreshCache(); + vi.spyOn(ExtensionPreferencesStore.prototype, 'clear').mockImplementation( + () => { + throw new Error('cleanup failed'); + }, + ); - await manager.uninstallExtension('my-extension', false); + const result = await manager.uninstallExtension('my-extension', false); - expect(events).toEqual([ - { id: 1, phase: 'start', operation: 'uninstallExtension' }, - { id: 1, phase: 'end', operation: 'uninstallExtension' }, + expect(result.warnings).toEqual([ + { + code: 'extension_preferences_cleanup_failed', + error: 'cleanup failed', + }, ]); }); - }); - - describe('loadExtension', () => { - it('should include extension path in loaded extension', async () => { - const extensionDir = path.join(userExtensionsDir, 'test-extension'); - fs.mkdirSync(extensionDir, { recursive: true }); + it('returns a committed warning when uninstall runtime refresh fails', async () => { createExtension({ extensionsDir: userExtensionsDir, - name: 'test-extension', + name: 'my-extension', version: '1.0.0', + installMetadata: { + type: 'local', + source: tempWorkspaceDir, + originSource: 'QwenCode', + }, }); - + const manager = createExtensionManager(); + await manager.refreshCache(); + vi.spyOn(manager, 'refreshTools').mockRejectedValue( + new Error('refresh failed'), + ); + + const result = await manager.uninstallExtension('my-extension', false); + + expect(result.warnings).toEqual([ + { + code: 'extension_runtime_refresh_failed', + error: 'refresh failed', + }, + ]); + }); + + it('should emit mutation lifecycle events around uninstall', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + installMetadata: { + type: 'local', + source: tempWorkspaceDir, + originSource: 'QwenCode', + }, + }); + + const manager = createExtensionManager(); + const events: ExtensionMutationEvent[] = []; + manager.addMutationListener((event) => events.push(event)); + await manager.refreshCache(); + + await manager.uninstallExtension('my-extension', false); + + expect(events).toEqual([ + { id: 1, phase: 'start', operation: 'uninstallExtension' }, + { id: 1, phase: 'end', operation: 'uninstallExtension' }, + ]); + }); + + it('uninstalls a committed extension by id when it cannot be loaded', async () => { + const identity = { id: 'a9'.repeat(32), name: 'broken-extension' }; + const extensionStore = new ExtensionStore({ + extensionsDir: userExtensionsDir, + }); + await extensionStore.ensureInitialized([identity]); + const destination = path.join(userExtensionsDir, identity.name); + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync(path.join(destination, 'qwen-extension.json'), '{'); + const manager = createExtensionManager({ extensionStore }); + + const snapshot = await manager.uninstallExtensionById(identity.id, true); + + expect(snapshot.extensions[identity.id]).toBeUndefined(); + expect(fs.existsSync(destination)).toBe(false); + }); + + it('uninstalls by id using the loaded artifact directory', async () => { + const original = createExtension({ + extensionsDir: userExtensionsDir, + name: 'manifest-name', + }); + const destination = path.join(userExtensionsDir, 'artifact-directory'); + fs.renameSync(original, destination); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + + const snapshot = await manager.uninstallExtensionById(extension.id, true); + + expect(snapshot.extensions[extension.id]).toBeUndefined(); + expect(fs.existsSync(destination)).toBe(false); + }); + }); + + describe('loadExtension', () => { + it('uses the injected extension store root for discovery', async () => { + const customExtensionsDir = path.join(tempHomeDir, 'custom-extensions'); + createExtension({ + extensionsDir: customExtensionsDir, + name: 'custom-root-extension', + }); + const manager = createExtensionManager({ + extensionStore: new ExtensionStore({ + extensionsDir: customExtensionsDir, + }), + }); + + await manager.refreshCache(); + + expect(manager.getLoadedExtensions()).toHaveLength(1); + expect(manager.getLoadedExtensions()[0]?.path).toBe( + path.join(customExtensionsDir, 'custom-root-extension'), + ); + }); + + it('should include extension path in loaded extension', async () => { + const extensionDir = path.join(userExtensionsDir, 'test-extension'); + fs.mkdirSync(extensionDir, { recursive: true }); + + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + }); + const manager = createExtensionManager(); await manager.refreshCache(); const extensions = manager.getLoadedExtensions(); @@ -569,6 +1190,30 @@ describe('extension tests', () => { expect(extensions[0].config.name).toBe('good-ext'); }); + it('should skip extensions with invalid setting environment variable names', async () => { + const extensionDir = path.join(userExtensionsDir, 'bad-setting'); + fs.mkdirSync(extensionDir); + fs.writeFileSync( + path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ + name: 'bad-setting', + version: '1.0.0', + settings: [ + { + name: 'API key', + description: 'API key', + envVar: 'API_KEY\nforged', + }, + ], + }), + ); + + const manager = createExtensionManager(); + await manager.refreshCache(); + + expect(manager.getLoadedExtensions()).toEqual([]); + }); + it('should skip extensions with missing name and log a warning', async () => { // Good extension createExtension({ @@ -836,6 +1481,306 @@ describe('extension tests', () => { }); describe('enableExtension / disableExtension', () => { + it('applies V2 default and workspace activation to loaded extensions', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + + await manager.setExtensionDefaultActivation(extension.id, 'disabled'); + expect(manager.getLoadedExtensions()[0]?.isActive).toBe(false); + + await manager.setExtensionWorkspaceActivation( + extension.id, + tempWorkspaceDir, + 'enabled', + ); + expect(manager.getLoadedExtensions()[0]?.isActive).toBe(true); + + await manager.clearExtensionWorkspaceActivation( + extension.id, + tempWorkspaceDir, + ); + expect(manager.getLoadedExtensions()[0]?.isActive).toBe(false); + }); + + it('refreshes runtime tools after V2 activation changes', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + const refreshTools = vi + .spyOn(manager, 'refreshTools') + .mockResolvedValue(); + + await manager.setExtensionDefaultActivation(extension.id, 'disabled'); + await manager.setExtensionActivationScope(extension.id, { + scope: 'workspace', + workspacePath: tempWorkspaceDir, + }); + await manager.setExtensionWorkspaceActivation( + extension.id, + tempWorkspaceDir, + 'disabled', + ); + await manager.clearExtensionWorkspaceActivation( + extension.id, + tempWorkspaceDir, + ); + + expect(refreshTools).toHaveBeenCalledTimes(4); + }); + + it('returns a committed warning when activation runtime refresh fails', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + vi.spyOn(manager, 'refreshTools').mockRejectedValue( + new Error('refresh failed'), + ); + + const result = await manager.setExtensionDefaultActivation( + extension.id, + 'disabled', + ); + + expect(result.warnings).toEqual([ + { + code: 'extension_runtime_refresh_failed', + error: 'refresh failed', + }, + ]); + expect(result.extensions[extension.id]?.defaultActivation).toBe( + 'disabled', + ); + }); + + it('derives activation from the supplied store snapshot', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + + const disabledSnapshot = await manager.setExtensionDefaultActivation( + extension.id, + 'disabled', + ); + await manager.setExtensionDefaultActivation(extension.id, 'enabled'); + + expect( + manager.getExtensionActivationFromSnapshot( + extension.id, + disabledSnapshot, + tempWorkspaceDir, + ), + ).toMatchObject({ effective: 'disabled', source: 'default' }); + await expect( + manager.getExtensionActivation(extension.id, tempWorkspaceDir), + ).resolves.toMatchObject({ effective: 'enabled', source: 'default' }); + }); + + it('changes activation scope in one policy mutation', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + + const workspaceSnapshot = await manager.setExtensionActivationScope( + extension.id, + { + scope: 'workspace', + workspacePath: tempWorkspaceDir, + }, + ); + const snapshot = await manager.setExtensionActivationScope(extension.id, { + scope: 'user', + }); + + expect(snapshot.generation).toBe(workspaceSnapshot.generation + 1); + expect(snapshot.extensions[extension.id]).toMatchObject({ + defaultActivation: 'enabled', + workspaceOverrides: {}, + }); + }); + + it('emits mutation lifecycle events for V2 activation changes', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + const manager = createExtensionManager(); + const events: ExtensionMutationEvent[] = []; + manager.addMutationListener((event) => events.push(event)); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + + await manager.setExtensionDefaultActivation(extension.id, 'disabled'); + await manager.setExtensionActivationScope(extension.id, { + scope: 'workspace', + workspacePath: tempWorkspaceDir, + }); + await manager.setExtensionWorkspaceActivation( + extension.id, + tempWorkspaceDir, + 'disabled', + ); + await manager.clearExtensionWorkspaceActivation( + extension.id, + tempWorkspaceDir, + ); + + expect(events).toEqual([ + { + id: 1, + phase: 'start', + operation: 'setExtensionDefaultActivation', + }, + { + id: 1, + phase: 'end', + operation: 'setExtensionDefaultActivation', + }, + { + id: 2, + phase: 'start', + operation: 'setExtensionActivationScope', + }, + { + id: 2, + phase: 'end', + operation: 'setExtensionActivationScope', + }, + { + id: 3, + phase: 'start', + operation: 'setExtensionWorkspaceActivation', + }, + { + id: 3, + phase: 'end', + operation: 'setExtensionWorkspaceActivation', + }, + { + id: 4, + phase: 'start', + operation: 'clearExtensionWorkspaceActivation', + }, + { + id: 4, + phase: 'end', + operation: 'clearExtensionWorkspaceActivation', + }, + ]); + }); + + it('keeps the V2 state in sync after a legacy scope mutation', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + + await manager.disableExtension( + extension.name, + SettingScope.Workspace, + tempWorkspaceDir, + ); + + const activation = await manager.getExtensionActivation( + extension.id, + tempWorkspaceDir, + ); + expect(activation).toMatchObject({ + effective: 'disabled', + source: 'workspace_override', + }); + }); + + it('keeps other workspace overrides during a legacy workspace mutation', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + const otherWorkspace = path.join(os.tmpdir(), 'other-workspace'); + await manager.setExtensionWorkspaceActivation( + extension.id, + otherWorkspace, + 'enabled', + ); + + await manager.disableExtension( + extension.name, + SettingScope.Workspace, + tempWorkspaceDir, + ); + + const snapshot = await manager.getExtensionStoreSnapshot(); + expect(snapshot.extensions[extension.id]?.workspaceOverrides).toEqual({ + [otherWorkspace]: 'enabled', + [fs.realpathSync.native(tempWorkspaceDir)]: 'disabled', + }); + }); + + it('clears only child workspace overrides during a legacy user mutation', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + }); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + const outsideWorkspace = path.join(os.tmpdir(), 'outside-workspace'); + await manager.setExtensionWorkspaceActivation( + extension.id, + tempWorkspaceDir, + 'enabled', + ); + await manager.setExtensionWorkspaceActivation( + extension.id, + outsideWorkspace, + 'disabled', + ); + + await manager.disableExtension(extension.name, SettingScope.User); + + const snapshot = await manager.getExtensionStoreSnapshot(); + expect(snapshot.extensions[extension.id]?.workspaceOverrides).toEqual({ + [outsideWorkspace]: 'disabled', + }); + }); + it('should emit mutation lifecycle events around extension changes', async () => { createExtension({ extensionsDir: userExtensionsDir, @@ -1006,14 +1951,401 @@ describe('extension tests', () => { }); describe('updateExtension', () => { - it('should end mutation lifecycle events when temp directory creation fails', async () => { + it('applies the update network policy without mutating cached metadata', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + installMetadata: { + type: 'git', + source: 'https://github.com/owner/repo.git', + }, + }); + mockGit.version.mockResolvedValue({ major: 2, minor: 52 }); + mockGit.env.mockReturnValue(mockGit); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/owner/repo.git' }, + }, + ]); + mockGit.listRemote.mockResolvedValue('same-hash\tHEAD'); + mockGit.revparse.mockResolvedValue('same-hash'); + const manager = createExtensionManager({ networkPolicy: 'public' }); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + expect(extension.installMetadata?.networkPolicy).toBeUndefined(); + + await manager.checkForAllExtensionUpdates(() => {}); + + expect(extension.installMetadata?.networkPolicy).toBeUndefined(); + expect(mockGit.version).toHaveBeenCalled(); + expect(mockGit.env).toHaveBeenCalled(); + expect(mockGit.listRemote).toHaveBeenCalledWith([ + 'https://github.com/owner/repo.git', + 'HEAD', + ]); + }); + + it('rejects a stale direct update after the artifact changes', async () => { + const archivePath = path.join(tempWorkspaceDir, 'direct-update.zip'); + fs.writeFileSync(archivePath, 'archive'); + const writeExtension = (destination: string, version: string) => { + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync( + path.join(destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'my-extension', version }), + ); + }; + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtension(destination, '1.0.0'); + }, + ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const metadata = { type: 'local' as const, source: archivePath }; + const installed = await manager.installExtension( + metadata, + async () => {}, + ); + const concurrentStore = new ExtensionStore(); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeExtension(destination, '2.0.0'); + const before = await concurrentStore.readSnapshot(); + const staging = await concurrentStore.createStagingDirectory(); + writeExtension(staging, 'concurrent'); + await concurrentStore.commitArtifact({ + operation: 'update', + identity: { id: installed.id, name: installed.name }, + stagingDirectory: staging, + destinationDirectory: installed.path, + expectedArtifactGeneration: + before.extensions[installed.id]!.artifactGeneration, + }); + }, + ); + + await expect( + manager.installExtension( + metadata, + async () => {}, + undefined, + tempWorkspaceDir, + installed.config, + ), + ).rejects.toMatchObject({ code: 'extension_conflict' }); + expect( + JSON.parse( + fs.readFileSync( + path.join(installed.path, EXTENSIONS_CONFIG_FILENAME), + 'utf8', + ), + ), + ).toMatchObject({ version: 'concurrent' }); + }); + + it('marks a direct update reload failure as already committed', async () => { + const archivePath = path.join(tempWorkspaceDir, 'direct-reload.zip'); + fs.writeFileSync(archivePath, 'archive'); const extensionPath = createExtension({ extensionsDir: userExtensionsDir, name: 'my-extension', version: '1.0.0', installMetadata: { type: 'local', - source: tempWorkspaceDir, + source: archivePath, + originSource: 'QwenCode', + }, + }); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync( + path.join(destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'my-extension', version: '2.0.0' }), + ); + }, + ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + const updatedExtension = { + ...extension, + version: '2.0.0', + config: { ...extension.config, version: '2.0.0' }, + }; + vi.spyOn(manager, 'loadExtension') + .mockResolvedValueOnce(updatedExtension) + .mockResolvedValueOnce(null); + + await expect( + manager.installExtension( + { type: 'local', source: archivePath }, + async () => {}, + undefined, + tempWorkspaceDir, + extension.config, + ), + ).rejects.toMatchObject({ + code: 'extension_committed_with_warnings', + committed: true, + }); + expect( + JSON.parse( + fs.readFileSync( + path.join(extensionPath, EXTENSIONS_CONFIG_FILENAME), + 'utf8', + ), + ), + ).toMatchObject({ version: '2.0.0' }); + }); + + it('rejects an invalid staged extension before commit', async () => { + const archivePath = path.join(tempWorkspaceDir, 'install-reload.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync( + path.join(destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'my-extension', version: '1.0.0' }), + ); + }, + ); + const manager = createExtensionManager(); + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { type: 'local', source: archivePath }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + }); + fs.writeFileSync( + path.join(prepared.stagingDirectory, EXTENSIONS_CONFIG_FILENAME), + '{ invalid json', + ); + const before = await manager.getExtensionStoreSnapshot(); + + try { + await expect(manager.commitPreparedExtension(prepared)).rejects.toThrow( + 'Failed to load extension config', + ); + } finally { + await manager.disposePreparedExtension(prepared); + } + + expect(await manager.getExtensionStoreSnapshot()).toEqual(before); + expect(fs.existsSync(prepared.destinationDirectory)).toBe(false); + }); + + it('rejects staged identity changes before commit', async () => { + const archivePath = path.join(tempWorkspaceDir, 'identity-change.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync( + path.join(destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'original-name', version: '1.0.0' }), + ); + }, + ); + const manager = createExtensionManager(); + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { type: 'local', source: archivePath }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + }); + fs.writeFileSync( + path.join(prepared.stagingDirectory, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'changed-name', version: '1.0.0' }), + ); + const before = await manager.getExtensionStoreSnapshot(); + + try { + await expect(manager.commitPreparedExtension(prepared)).rejects.toThrow( + 'Prepared extension identity changed before commit.', + ); + } finally { + await manager.disposePreparedExtension(prepared); + } + + expect(await manager.getExtensionStoreSnapshot()).toEqual(before); + expect(fs.existsSync(prepared.destinationDirectory)).toBe(false); + }); + + it('reports a committed update reload failure as needing restart', async () => { + const archivePath = path.join(tempWorkspaceDir, 'reload-failure.zip'); + fs.writeFileSync(archivePath, 'archive'); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + installMetadata: { + type: 'local', + source: archivePath, + originSource: 'QwenCode', + }, + }); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync( + path.join(destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'my-extension', version: '2.0.0' }), + ); + }, + ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + const updatedExtension = { + ...extension, + version: '2.0.0', + config: { ...extension.config, version: '2.0.0' }, + }; + vi.spyOn(manager, 'loadExtension') + .mockResolvedValueOnce(updatedExtension) + .mockResolvedValueOnce(updatedExtension) + .mockResolvedValueOnce(null); + const callback = vi.fn(); + + await expect( + manager.updateExtension( + extension, + ExtensionUpdateState.UPDATE_AVAILABLE, + callback, + ), + ).resolves.toEqual({ + name: 'my-extension', + originalVersion: '1.0.0', + updatedVersion: '2.0.0', + warnings: [ + { + code: 'extension_reload_failed', + error: 'Extension not found after commit.', + }, + ], + }); + + expect(callback).toHaveBeenLastCalledWith( + 'my-extension', + ExtensionUpdateState.UPDATED_NEEDS_RESTART, + ); + expect(manager.getLoadedExtensions()).toEqual([]); + }); + + it('reports a committed update runtime warning as needing restart', async () => { + const archivePath = path.join(tempWorkspaceDir, 'refresh-update.zip'); + fs.writeFileSync(archivePath, 'archive'); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + installMetadata: { + type: 'local', + source: archivePath, + originSource: 'QwenCode', + }, + }); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync( + path.join(destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'my-extension', version: '2.0.0' }), + ); + }, + ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + vi.spyOn(manager, 'refreshTools').mockRejectedValueOnce( + new Error('runtime stale'), + ); + const callback = vi.fn(); + + await manager.updateExtension( + extension, + ExtensionUpdateState.UPDATE_AVAILABLE, + callback, + ); + + expect(callback).toHaveBeenLastCalledWith( + 'my-extension', + ExtensionUpdateState.UPDATED_NEEDS_RESTART, + ); + }); + + it('surfaces a committed settings compatibility warning distinctly', async () => { + const archivePath = path.join(tempWorkspaceDir, 'settings-update.zip'); + fs.writeFileSync(archivePath, 'archive'); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + installMetadata: { + type: 'local', + source: archivePath, + originSource: 'QwenCode', + }, + }); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync( + path.join(destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'my-extension', version: '2.0.0' }), + ); + }, + ); + const manager = createExtensionManager(); + await manager.refreshCache(); + const extension = manager.getLoadedExtensions()[0]!; + const internals = manager as unknown as { + prepareExtensionUpdateFromState( + extension: Extension, + ): Promise; + }; + const prepared = + await internals.prepareExtensionUpdateFromState(extension); + Object.defineProperty(prepared, 'commitSettings', { + value: vi.fn().mockRejectedValue(new Error('legacy sync unavailable')), + }); + vi.spyOn( + internals, + 'prepareExtensionUpdateFromState', + ).mockResolvedValueOnce(prepared); + const callback = vi.fn(); + + await expect( + manager.updateExtension( + extension, + ExtensionUpdateState.UPDATE_AVAILABLE, + callback, + ), + ).resolves.toMatchObject({ + warnings: [ + { + code: 'extension_settings_legacy_sync_failed', + error: 'legacy sync unavailable', + }, + ], + }); + expect(callback).toHaveBeenLastCalledWith( + 'my-extension', + ExtensionUpdateState.UPDATED_WITH_WARNINGS, + ); + }); + + it('should end mutation lifecycle events when temp directory creation fails', async () => { + const archivePath = path.join(tempWorkspaceDir, 'update.zip'); + fs.writeFileSync(archivePath, 'archive'); + const extensionPath = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-extension', + version: '1.0.0', + installMetadata: { + type: 'local', + source: archivePath, originSource: 'QwenCode', }, }); @@ -1048,6 +2380,52 @@ describe('extension tests', () => { }); }); + describe('performWorkspaceExtensionMigration', () => { + const extension = { + path: '/tmp/migration-source', + config: { name: 'migration-extension' }, + } as Extension; + + it('reports a committed extension that could not be reloaded', async () => { + const manager = createExtensionManager(); + vi.spyOn(manager, 'installExtension').mockRejectedValueOnce( + Object.assign(new Error('committed with warnings'), { + code: 'extension_committed_with_warnings', + committed: true, + identity: { id: 'migration-id', name: 'migration-extension' }, + warnings: [ + { code: 'extension_reload_failed', error: 'invalid manifest' }, + ], + }), + ); + + await expect( + manager.performWorkspaceExtensionMigration([extension], async () => {}), + ).resolves.toEqual(['migration-extension']); + }); + + it('does not retry a committed extension for recoverable warnings', async () => { + const manager = createExtensionManager(); + vi.spyOn(manager, 'installExtension').mockRejectedValueOnce( + Object.assign(new Error('committed with warnings'), { + code: 'extension_committed_with_warnings', + committed: true, + identity: { id: 'migration-id', name: 'migration-extension' }, + warnings: [ + { + code: 'extension_runtime_refresh_failed', + error: 'refresh delayed', + }, + ], + }), + ); + + await expect( + manager.performWorkspaceExtensionMigration([extension], async () => {}), + ).resolves.toEqual([]); + }); + }); + describe('validateExtensionOverrides', () => { it('should mark all extensions as active if no enabled extensions are provided', async () => { createExtension({ @@ -1116,6 +2494,35 @@ describe('extension tests', () => { const extensions = manager.getLoadedExtensions(); expect(extensions.every((e) => !e.isActive)).toBe(true); + await expect( + manager.getExtensionActivation(extensions[0]!.id), + ).resolves.toMatchObject({ + effective: 'disabled', + source: 'cli_override', + }); + }); + + it('should treat "none" as disabling all only when it is the sole override', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext2', + version: '1.0.0', + }); + + const manager = createExtensionManager({ + enabledExtensionOverrides: ['none', 'ext1'], + }); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(manager.isEnabled('ext1')).toBe(true); + expect(extensions.find((e) => e.name === 'ext1')?.isActive).toBe(true); + expect(extensions.find((e) => e.name === 'ext2')?.isActive).toBe(false); }); it('should handle case-insensitivity', async () => { diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 1a8f4e9db66..4f23daf605c 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -24,13 +24,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { - atomicWriteFile, - atomicWriteFileSync, -} from '../utils/atomicFileWrite.js'; +import { atomicWriteFile } from '../utils/atomicFileWrite.js'; import { getErrorMessage } from '../utils/errors.js'; import { EXTENSIONS_CONFIG_FILENAME, + EXTENSION_SETTINGS_FILENAME, INSTALL_METADATA_FILENAME, recursivelyHydrateStrings, substituteHookVariables, @@ -78,6 +76,8 @@ import { getEnvContents, maybePromptForSettings, promptForSetting, + type PreparedExtensionSettingsMutation, + validateExtensionSettingEnvVars, } from './extensionSettings.js'; import type { ExtensionSetting, @@ -99,6 +99,13 @@ import { loadSkillsFromDir } from '../skills/skill-load.js'; import { loadSubagentFromDir } from '../subagents/subagent-manager.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { refreshExtensionRuntime } from './extension-runtime-refresh.js'; +import { + ExtensionStore, + type ExtensionActivation, + type ExtensionActivationResult, + type ExtensionStoreSnapshot, + type InitialExtensionActivation, +} from './extension-store.js'; const debugLogger = createDebugLogger('EXTENSIONS'); @@ -168,6 +175,28 @@ export interface ExtensionUpdateInfo { name: string; originalVersion: string; updatedVersion: string; + warnings?: Array<{ code: string; error: string }>; +} + +export interface ExtensionCommittedWithWarningsError extends Error { + code: 'extension_committed_with_warnings'; + committed: true; + identity: { id: string; name: string }; + warnings: ReadonlyArray<{ code: string; error: string }>; +} + +export function isExtensionCommittedWithWarningsError( + error: unknown, +): error is ExtensionCommittedWithWarningsError { + const candidate = error as Partial; + return ( + error instanceof Error && + candidate.code === 'extension_committed_with_warnings' && + candidate.committed === true && + typeof candidate.identity?.id === 'string' && + typeof candidate.identity.name === 'string' && + Array.isArray(candidate.warnings) + ); } export interface ExtensionUpdateStatus { @@ -178,6 +207,7 @@ export interface ExtensionUpdateStatus { export enum ExtensionUpdateState { CHECKING_FOR_UPDATES = 'checking for updates', UPDATED_NEEDS_RESTART = 'updated, needs restart', + UPDATED_WITH_WARNINGS = 'updated with warnings', UPDATING = 'updating', UPDATED = 'updated', UPDATE_AVAILABLE = 'update available', @@ -214,6 +244,87 @@ export interface ExtensionManagerOptions { requestChoicePlugin?: ( marketplace: ClaudeMarketplaceConfig, ) => Promise; + extensionStore?: ExtensionStore; + networkPolicy?: ExtensionInstallMetadata['networkPolicy']; +} + +export interface PrepareExtensionInstallOptions { + installMetadata: ExtensionInstallMetadata; + initialActivation: InitialExtensionActivation; + requestConsent?: (options?: ExtensionRequestOptions) => Promise; + requestSetting?: (setting: ExtensionSetting) => Promise; + cwd?: string; + signal?: AbortSignal; +} + +export interface PrepareExtensionUpdateOptions { + extension: Extension; + signal?: AbortSignal; +} + +export interface PreparedExtensionMutation { + readonly operation: 'install' | 'update'; + readonly identity: { id: string; name: string }; + readonly version: string; + readonly expectedArtifactGeneration?: number; + /** @internal */ + readonly installMetadata: ExtensionInstallMetadata; + /** @internal */ + readonly config: ExtensionConfig; + /** @internal */ + readonly previousConfig?: ExtensionConfig; + /** @internal */ + readonly initialActivation: InitialExtensionActivation; + /** @internal */ + readonly stagingDirectory: string; + /** @internal */ + readonly destinationDirectory: string; + /** @internal */ + readonly currentDir: string; + /** @internal */ + readonly cleanupPaths: readonly string[]; + /** @internal */ + readonly commitSettings?: () => Promise; + /** @internal */ + readonly discardSettings?: () => Promise; + /** @internal */ + settingsActivated: boolean; + /** @internal */ + consumed: boolean; + /** @internal */ + disposed: boolean; +} + +export interface CommittedExtensionMutation { + identity: { id: string; name: string }; + version: string; + generation: number; + extension?: Extension; + warnings?: Array<{ code: string; error: string }>; +} + +export interface ExtensionStoreMutationResult extends ExtensionStoreSnapshot { + warnings?: Array<{ code: string; error: string }>; +} + +export type ExtensionCommitCallback = (generation: number) => void; + +export class PreparedExtensionConsumedError extends Error { + readonly code = 'prepared_extension_consumed'; + + constructor() { + super('Prepared extension mutation has already been consumed.'); + this.name = 'PreparedExtensionConsumedError'; + } +} + +export class InvalidPreparedExtensionError extends Error { + readonly code = 'invalid_prepared_extension'; + + constructor() { + super('Prepared extension mutation does not belong to this manager.'); + this.name = 'InvalidPreparedExtensionError'; + } } export interface ExtensionMutationEvent { @@ -320,8 +431,19 @@ export class ExtensionManager { private readonly workspaceDir: string; private readonly preferencesStore: ExtensionPreferencesStore; private readonly sourceRegistryStore: SourceRegistryStore; + private readonly extensionStore: ExtensionStore; + private readonly networkPolicy?: ExtensionInstallMetadata['networkPolicy']; + private readonly preparedMutations = new WeakSet(); private discoverCache: DiscoveredPlugin[] | null = null; + private withNetworkPolicy( + installMetadata: ExtensionInstallMetadata | undefined, + ): ExtensionInstallMetadata | undefined { + return installMetadata && this.networkPolicy + ? { ...installMetadata, networkPolicy: this.networkPolicy } + : installMetadata; + } + private config?: Config; private telemetrySettings?: TelemetrySettings; private isWorkspaceTrusted: boolean; @@ -338,7 +460,8 @@ export class ExtensionManager { this.enabledExtensionNamesOverride = options.enabledExtensionOverrides?.map((name) => name.toLowerCase()) ?? []; - this.configDir = ExtensionStorage.getUserExtensionsDir(); + this.extensionStore = options.extensionStore ?? new ExtensionStore(); + this.configDir = this.extensionStore.extensionsDir; this.configFilePath = path.join( this.configDir, 'extension-enablement.json', @@ -351,6 +474,7 @@ export class ExtensionManager { // compatibility with sources added before the source/* rename. path.join(this.configDir, 'marketplaces.json'), ); + this.networkPolicy = options.networkPolicy; this.requestSetting = options.requestSetting; this.requestChoicePlugin = options.requestChoicePlugin || (() => Promise.resolve('')); @@ -458,9 +582,21 @@ export class ExtensionManager { const extensionConfig = config[extensionName]; let enabled = true; const allOverrides = extensionConfig?.overrides ?? []; + const lexicalPath = ensureLeadingAndTrailingSlash(checkPath); + let canonicalPath = lexicalPath; + try { + canonicalPath = ensureLeadingAndTrailingSlash( + fs.realpathSync.native(path.resolve(checkPath)), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } for (const rule of allOverrides) { const override = Override.fromFileRule(rule); - if (override.matchesPath(ensureLeadingAndTrailingSlash(checkPath))) { + if ( + override.matchesPath(lexicalPath) || + override.matchesPath(canonicalPath) + ) { enabled = !override.isDisable; } } @@ -474,7 +610,8 @@ export class ExtensionManager { name: string, scope: SettingScope, cwd?: string, - ): Promise { + onCommitted?: ExtensionCommitCallback, + ): Promise { const currentDir = cwd ?? this.workspaceDir; if ( scope === SettingScope.System || @@ -491,13 +628,27 @@ export class ExtensionManager { const endMutation = this.beginMutation('enableExtension'); try { - const scopePath = - scope === SettingScope.Workspace ? currentDir : os.homedir(); - this.enableByPath(name, true, scopePath); + let snapshot: ExtensionStoreSnapshot; + if (scope === SettingScope.Workspace) { + snapshot = await this.extensionStore.setWorkspaceActivation( + { id: extension.id, name: extension.name }, + currentDir, + 'enabled', + ); + } else { + const scopePath = os.homedir(); + snapshot = await this.extensionStore.setLegacyPathActivation( + { id: extension.id, name: extension.name }, + scopePath, + 'enabled', + ); + } + onCommitted?.(snapshot.generation); const config = getTelemetryConfig(currentDir, this.telemetrySettings); logExtensionEnable(config, new ExtensionEnableEvent(name, scope)); - extension.isActive = true; - await this.refreshTools(); + this.applyStoreActivation(snapshot); + const warning = await this.refreshToolsAfterActivation(name); + return warning ? { ...snapshot, warnings: [warning] } : snapshot; } finally { endMutation(); } @@ -510,7 +661,8 @@ export class ExtensionManager { name: string, scope: SettingScope, cwd?: string, - ): Promise { + onCommitted?: ExtensionCommitCallback, + ): Promise { const currentDir = cwd ?? this.workspaceDir; const config = getTelemetryConfig(currentDir, this.telemetrySettings); if ( @@ -528,25 +680,195 @@ export class ExtensionManager { const endMutation = this.beginMutation('disableExtension'); try { - const scopePath = - scope === SettingScope.Workspace ? currentDir : os.homedir(); - this.disableByPath(name, true, scopePath); + let snapshot: ExtensionStoreSnapshot; + if (scope === SettingScope.Workspace) { + snapshot = await this.extensionStore.setWorkspaceActivation( + { id: extension.id, name: extension.name }, + currentDir, + 'disabled', + ); + } else { + const scopePath = os.homedir(); + snapshot = await this.extensionStore.setLegacyPathActivation( + { id: extension.id, name: extension.name }, + scopePath, + 'disabled', + ); + } + onCommitted?.(snapshot.generation); logExtensionDisable(config, new ExtensionDisableEvent(name, scope)); - extension.isActive = false; - await this.refreshTools(); + this.applyStoreActivation(snapshot); + const warning = await this.refreshToolsAfterActivation(name); + return warning ? { ...snapshot, warnings: [warning] } : snapshot; } finally { endMutation(); } } - /** - * Removes enablement configuration for an extension. - */ - removeEnablementConfig(extensionName: string): void { - const config = this.readEnablementConfig(); - if (config[extensionName]) { - delete config[extensionName]; - this.writeEnablementConfig(config); + async getExtensionStoreSnapshot(): Promise { + return await this.extensionStore.readSnapshot(); + } + + async getExtensionActivation( + extensionId: string, + workspacePath: string = this.workspaceDir, + ): Promise { + const snapshot = await this.extensionStore.readSnapshot(); + return this.getExtensionActivationFromSnapshot( + extensionId, + snapshot, + workspacePath, + ); + } + + getExtensionActivationFromSnapshot( + extensionId: string, + snapshot: ExtensionStoreSnapshot, + workspacePath: string = this.workspaceDir, + ): ExtensionActivationResult { + const extension = this.findExtensionById(extensionId); + const activation = this.extensionStore.getActivation( + snapshot, + extension.id, + extension.name, + workspacePath, + ); + if (this.enabledExtensionNamesOverride.length === 0) { + return activation; + } + return { + ...activation, + effective: this.isEnabled(extension.name) ? 'enabled' : 'disabled', + source: 'cli_override', + }; + } + + async setExtensionDefaultActivation( + extensionId: string, + activation: ExtensionActivation, + onCommitted?: ExtensionCommitCallback, + ): Promise { + const extension = this.findExtensionById(extensionId); + const endMutation = this.beginMutation('setExtensionDefaultActivation'); + try { + const snapshot = await this.extensionStore.setDefaultActivation( + { id: extension.id, name: extension.name }, + activation, + ); + onCommitted?.(snapshot.generation); + this.applyStoreActivation(snapshot); + const warning = await this.refreshToolsAfterActivation(extension.name); + return warning ? { ...snapshot, warnings: [warning] } : snapshot; + } finally { + endMutation(); + } + } + + async setExtensionActivationScope( + extensionId: string, + activation: InitialExtensionActivation, + onCommitted?: ExtensionCommitCallback, + ): Promise { + const extension = this.findExtensionById(extensionId); + const endMutation = this.beginMutation('setExtensionActivationScope'); + try { + const snapshot = await this.extensionStore.setActivationScope( + { id: extension.id, name: extension.name }, + activation, + ); + onCommitted?.(snapshot.generation); + this.applyStoreActivation(snapshot); + const warning = await this.refreshToolsAfterActivation(extension.name); + return warning ? { ...snapshot, warnings: [warning] } : snapshot; + } finally { + endMutation(); + } + } + + async setExtensionWorkspaceActivation( + extensionId: string, + workspacePath: string, + activation: ExtensionActivation, + onCommitted?: ExtensionCommitCallback, + ): Promise { + const extension = this.findExtensionById(extensionId); + const endMutation = this.beginMutation('setExtensionWorkspaceActivation'); + try { + const snapshot = await this.extensionStore.setWorkspaceActivation( + { id: extension.id, name: extension.name }, + workspacePath, + activation, + ); + onCommitted?.(snapshot.generation); + this.applyStoreActivation(snapshot); + const warning = await this.refreshToolsAfterActivation(extension.name); + return warning ? { ...snapshot, warnings: [warning] } : snapshot; + } finally { + endMutation(); + } + } + + async clearExtensionWorkspaceActivation( + extensionId: string, + workspacePath: string, + onCommitted?: ExtensionCommitCallback, + ): Promise { + const extension = this.findExtensionById(extensionId); + const endMutation = this.beginMutation('clearExtensionWorkspaceActivation'); + try { + const snapshot = await this.extensionStore.clearWorkspaceActivation( + { id: extension.id, name: extension.name }, + workspacePath, + ); + onCommitted?.(snapshot.generation); + this.applyStoreActivation(snapshot); + const warning = await this.refreshToolsAfterActivation(extension.name); + return warning ? { ...snapshot, warnings: [warning] } : snapshot; + } finally { + endMutation(); + } + } + + private findExtensionById(extensionId: string): Extension { + const extension = this.getLoadedExtensions().find( + (candidate) => candidate.id === extensionId, + ); + if (!extension) { + throw new Error(`Extension with id ${extensionId} does not exist.`); + } + return extension; + } + + private applyStoreActivation(snapshot: ExtensionStoreSnapshot): void { + for (const extension of this.getLoadedExtensions()) { + if (this.enabledExtensionNamesOverride.length > 0) { + extension.isActive = this.isEnabled(extension.name); + continue; + } + extension.isActive = + this.extensionStore.getActivation( + snapshot, + extension.id, + extension.name, + this.workspaceDir, + ).effective === 'enabled'; + } + } + + private async refreshToolsAfterActivation( + name: string, + ): Promise<{ code: string; error: string } | undefined> { + try { + await this.refreshTools(); + return undefined; + } catch (error) { + debugLogger.warn( + `Extension "${name}" activation changed, but runtime refresh failed: ${getErrorMessage(error)}`, + ); + return { + code: 'extension_runtime_refresh_failed', + error: getErrorMessage(error), + }; } } @@ -624,14 +946,19 @@ export class ExtensionManager { if (!trimmed) { throw new Error('Marketplace source cannot be empty.'); } - const config = await loadMarketplaceConfigFromSource(trimmed); + const config = await loadMarketplaceConfigFromSource( + trimmed, + this.networkPolicy, + ); if (!config) { // A "marketplace" is a Claude-format collection (.claude-plugin/ // marketplace.json). A single extension repo (Gemini/Claude/git/npm) is // not a marketplace — guide the user to install it directly instead. let isInstallableExtension = false; try { - await parseInstallSource(trimmed); + await parseInstallSource(trimmed, { + networkPolicy: this.networkPolicy, + }); isInstallableExtension = true; } catch { // Not a recognizable install source either. @@ -699,7 +1026,7 @@ export class ExtensionManager { } loadSource(source: string): Promise { - return loadMarketplaceConfigFromSource(source); + return loadMarketplaceConfigFromSource(source, this.networkPolicy); } /** @@ -720,44 +1047,15 @@ export class ExtensionManager { installed: installedNames.has(plugin.name), })); } - const result = await discoverPlugins(this.getSources(), installedNames); + const result = await discoverPlugins( + this.getSources(), + installedNames, + this.networkPolicy, + ); this.discoverCache = result; return result; } - private enableByPath( - extensionName: string, - includeSubdirs: boolean, - scopePath: string, - ): void { - const config = this.readEnablementConfig(); - if (!config[extensionName]) { - config[extensionName] = { overrides: [] }; - } - const override = Override.fromInput(scopePath, includeSubdirs); - const overrides = config[extensionName].overrides.filter((rule) => { - const fileOverride = Override.fromFileRule(rule); - if ( - fileOverride.conflictsWith(override) || - fileOverride.isEqualTo(override) - ) { - return false; - } - return !fileOverride.isChildOf(override); - }); - overrides.push(override.output()); - config[extensionName].overrides = overrides; - this.writeEnablementConfig(config); - } - - private disableByPath( - extensionName: string, - includeSubdirs: boolean, - scopePath: string, - ): void { - this.enableByPath(extensionName, includeSubdirs, `!${scopePath}`); - } - private readEnablementConfig(): AllExtensionsEnablementConfig { try { const content = fs.readFileSync(this.configFilePath, 'utf-8'); @@ -775,35 +1073,48 @@ export class ExtensionManager { } } - private writeEnablementConfig(config: AllExtensionsEnablementConfig): void { - fs.mkdirSync(this.configDir, { recursive: true }); - atomicWriteFileSync(this.configFilePath, JSON.stringify(config, null, 2)); - } - /** * Refreshes the extension cache from disk. */ async refreshCache(options?: { names?: string[] }): Promise { + await this.refreshCacheWithSnapshot(options); + } + + async refreshCacheWithSnapshot(options?: { + names?: string[]; + }): Promise { const requestedNames = options?.names?.filter(Boolean) ?? []; - let extensions: Extension[]; - if (requestedNames.length > 0) { - extensions = ( - await Promise.all( - requestedNames.map((name) => this.loadExtensionByName(name)), - ) - ).filter((extension): extension is Extension => extension !== null); - } else { - // Default: load all extensions from QWEN_HOME-aware user extensions dir. - extensions = await this.loadExtensionsFromExtensionsDir( - ExtensionStorage.getUserExtensionsDir(), - this.workspaceDir, - ); - } + const { value: extensions, snapshot } = + await this.extensionStore.readConsistent(async () => { + let loaded: Extension[]; + if (requestedNames.length > 0) { + loaded = ( + await Promise.all( + requestedNames.map((name) => this.loadExtensionByName(name)), + ) + ).filter((extension): extension is Extension => extension !== null); + } else { + // Default: load all extensions from QWEN_HOME-aware user extensions dir. + loaded = await this.loadExtensionsFromExtensionsDir( + this.configDir, + this.workspaceDir, + ); + } + return { + value: loaded, + extensions: loaded.map((extension) => ({ + id: extension.id, + name: extension.name, + })), + }; + }); const nextCache = new Map(); extensions.forEach((extension) => { nextCache.set(extension.name, extension); }); this.extensionCache = nextCache; + this.applyStoreActivation(snapshot); + return snapshot; } getLoadedExtensions(): Extension[] { @@ -825,7 +1136,7 @@ export class ExtensionManager { workspaceDir?: string, ): Promise { const cwd = workspaceDir ?? this.workspaceDir; - const userExtensionsDir = ExtensionStorage.getUserExtensionsDir(); + const userExtensionsDir = this.configDir; if (!fs.existsSync(userExtensionsDir)) { return null; } @@ -885,6 +1196,7 @@ export class ExtensionManager { async loadExtension( context: LoadExtensionContext, + options: { throwOnError?: boolean } = {}, ): Promise { const { extensionDir, workspaceDir } = context; if (!fs.statSync(extensionDir).isDirectory()) { @@ -1012,6 +1324,7 @@ export class ExtensionManager { return extension; } catch (e) { + if (options.throwOnError) throw e; debugLogger.warn( `Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage( e, @@ -1068,6 +1381,7 @@ export class ExtensionManager { ); } validateName(config.name); + validateExtensionSettingEnvVars(config.settings); return config; } catch (e) { throw new Error( @@ -1091,7 +1405,155 @@ export class ExtensionManager { requestSetting?: (setting: ExtensionSetting) => Promise, cwd?: string, previousExtensionConfig?: ExtensionConfig, + initialActivation: InitialExtensionActivation = { scope: 'user' }, + signal?: AbortSignal, ): Promise { + if (!previousExtensionConfig) { + const endMutation = this.beginMutation('installExtension'); + let prepared: PreparedExtensionMutation | undefined; + try { + prepared = await this.prepareExtensionInstall({ + installMetadata, + initialActivation, + ...(requestConsent ? { requestConsent } : {}), + ...(requestSetting ? { requestSetting } : {}), + ...(cwd ? { cwd } : {}), + ...(signal ? { signal } : {}), + }); + const committed = await this.commitPreparedExtensionInternal( + prepared, + false, + ); + const warnings = committed.warnings ?? []; + if (!committed.extension || warnings.length > 0) { + const reloadWarning = committed.warnings?.find( + (warning) => warning.code === 'extension_reload_failed', + ); + const firstWarning = reloadWarning ?? warnings[0]; + const error = new Error( + `Extension "${prepared.identity.name}" committed with warnings${ + firstWarning + ? `: ${firstWarning.code}: ${firstWarning.error}` + : '.' + }`, + firstWarning ? { cause: new Error(firstWarning.error) } : undefined, + ) as ExtensionCommittedWithWarningsError; + error.code = 'extension_committed_with_warnings'; + error.committed = true; + error.identity = committed.identity; + error.warnings = warnings; + throw error; + } + return committed.extension; + } finally { + if (prepared) await this.disposePreparedExtension(prepared); + endMutation(); + } + } + return (await this.installExtensionInternal( + installMetadata, + requestConsent, + requestSetting, + cwd, + previousExtensionConfig, + initialActivation, + signal, + false, + true, + )) as Extension; + } + + async prepareExtensionInstall( + options: PrepareExtensionInstallOptions, + ): Promise { + return (await this.installExtensionInternal( + { ...options.installMetadata }, + options.requestConsent, + options.requestSetting, + options.cwd, + undefined, + options.initialActivation, + options.signal, + true, + false, + )) as PreparedExtensionMutation; + } + + private async prepareExtensionUpdateFromState( + extension: Extension, + signal?: AbortSignal, + ): Promise { + const installMetadata = this.loadInstallMetadata(extension.path); + if (!installMetadata?.type || installMetadata.type === 'link') { + throw new Error(`Extension ${extension.name} cannot be updated.`); + } + const previousConfig = this.loadExtensionConfig({ + extensionDir: extension.path, + }); + return (await this.installExtensionInternal( + { ...installMetadata }, + undefined, + undefined, + undefined, + previousConfig, + { scope: 'user' }, + signal, + true, + false, + )) as PreparedExtensionMutation; + } + + async prepareExtensionUpdate( + options: PrepareExtensionUpdateOptions, + ): Promise< + | { upToDate: true; extension: Extension } + | { upToDate: false; prepared: PreparedExtensionMutation } + > { + const installMetadata = this.withNetworkPolicy( + options.extension.installMetadata, + ); + const extension = + installMetadata === options.extension.installMetadata + ? options.extension + : { ...options.extension, installMetadata }; + const state = await checkForExtensionUpdate( + extension, + this, + options.signal, + ); + if (state === ExtensionUpdateState.UP_TO_DATE) { + return { upToDate: true, extension: options.extension }; + } + if (state !== ExtensionUpdateState.UPDATE_AVAILABLE) { + throw new Error( + `Extension "${options.extension.name}" update check returned ${state}.`, + ); + } + return { + upToDate: false, + prepared: await this.prepareExtensionUpdateFromState( + options.extension, + options.signal, + ), + }; + } + + private async installExtensionInternal( + installMetadata: ExtensionInstallMetadata, + requestConsent: + | ((options?: ExtensionRequestOptions) => Promise) + | undefined, + requestSetting: + | ((setting: ExtensionSetting) => Promise) + | undefined, + cwd: string | undefined, + previousExtensionConfig: ExtensionConfig | undefined, + initialActivation: InitialExtensionActivation, + signal: AbortSignal | undefined, + prepareOnly: boolean, + emitMutation: boolean, + ): Promise { + installMetadata = this.withNetworkPolicy(installMetadata)!; const currentDir = cwd ?? this.workspaceDir; const telemetryConfig = getTelemetryConfig( currentDir, @@ -1101,12 +1563,22 @@ export class ExtensionManager { const redactedInstallSource = redactUrlCredentials(installMetadata.source); const isUpdate = !!previousExtensionConfig; + const expectedArtifactGeneration = previousExtensionConfig + ? ((await this.extensionStore.readSnapshot()).extensions[ + getExtensionId(previousExtensionConfig, installMetadata) + ]?.artifactGeneration ?? 0) + : undefined; let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; let tempDir: string | undefined; let convertedSourcePath: string | undefined; + let stagingPath: string | undefined; + let preparedSettings: PreparedExtensionSettingsMutation | undefined; - const endMutation = this.beginMutation('installExtension'); + let ownershipTransferred = false; + const endMutation = emitMutation + ? this.beginMutation('installExtension') + : () => undefined; try { if (!this.isWorkspaceTrusted) { throw new Error( @@ -1114,7 +1586,7 @@ export class ExtensionManager { ); } - const extensionsDir = ExtensionStorage.getUserExtensionsDir(); + const extensionsDir = this.configDir; await fs.promises.mkdir(extensionsDir, { recursive: true }); if ( @@ -1147,6 +1619,7 @@ export class ExtensionManager { const result = await downloadFromGitHubRelease( installMetadata, tempDir, + signal, ); if ( installMetadata.type === 'git' || @@ -1156,6 +1629,7 @@ export class ExtensionManager { installMetadata.releaseTag = result.tagName; } } catch (_error) { + signal?.throwIfAborted(); // downloadFromGitHubRelease may have written a partial archive or // extracted files into tempDir before failing (e.g. a repo whose // latest release is a source tarball that isn't a valid extension @@ -1165,7 +1639,7 @@ export class ExtensionManager { // See #6334. await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.mkdir(tempDir, { recursive: true }); - await cloneFromGit(installMetadata, tempDir); + await cloneFromGit(installMetadata, tempDir, signal); if (installMetadata.type === 'github-release') { installMetadata.type = 'git'; } @@ -1173,11 +1647,15 @@ export class ExtensionManager { localSourcePath = tempDir; } else if (installMetadata.type === 'archive-url') { tempDir = await ExtensionStorage.createTmpDir(); - await downloadFromArchiveUrl(installMetadata, tempDir); + await downloadFromArchiveUrl(installMetadata, tempDir, signal); localSourcePath = tempDir; } else if (installMetadata.type === 'npm') { tempDir = await ExtensionStorage.createTmpDir(); - const result = await downloadFromNpmRegistry(installMetadata, tempDir); + const result = await downloadFromNpmRegistry( + installMetadata, + tempDir, + signal, + ); installMetadata.releaseTag = result.version; localSourcePath = tempDir; } else if ( @@ -1185,7 +1663,7 @@ export class ExtensionManager { isSupportedArchivePath(installMetadata.source) ) { tempDir = await ExtensionStorage.createTmpDir(); - await extractArchiveFile(installMetadata.source, tempDir); + await extractArchiveFile(installMetadata.source, tempDir, signal); localSourcePath = tempDir; } else if ( installMetadata.type === 'local' || @@ -1196,13 +1674,17 @@ export class ExtensionManager { throw new Error(`Unsupported install type: ${installMetadata.type}`); } + signal?.throwIfAborted(); try { const sourceBeforeConversion = localSourcePath; const { extensionDir, originSource } = await convertGeminiOrClaudeExtension( sourceBeforeConversion, installMetadata.pluginName, + installMetadata.networkPolicy, + signal, ); + signal?.throwIfAborted(); if (extensionDir !== sourceBeforeConversion) { convertedSourcePath = extensionDir; @@ -1236,7 +1718,8 @@ export class ExtensionManager { const newExtensionName = newExtensionConfig.name; const previous = this.getLoadedExtensions().find( - (installed) => installed.name === newExtensionName, + (installed) => + installed.name.toLowerCase() === newExtensionName.toLowerCase(), ); if (isUpdate && !previous) { throw new Error( @@ -1287,46 +1770,55 @@ export class ExtensionManager { }); } - const extensionStorage = new ExtensionStorage(newExtensionName); - const destinationPath = extensionStorage.getExtensionDir(); + const destinationPath = path.join(this.configDir, newExtensionName); const extensionId = getExtensionId(newExtensionConfig, installMetadata); + if (isUpdate && previous?.id !== extensionId) { + throw new Error( + `Extension "${newExtensionName}" changed its stable id during update.`, + ); + } let previousSettings: Record | undefined; if (isUpdate) { previousSettings = await getEnvContents( previousExtensionConfig, extensionId, ); - await this.uninstallExtension(newExtensionName, isUpdate); } - await fs.promises.mkdir(destinationPath, { recursive: true }); + stagingPath = await this.extensionStore.createStagingDirectory(); + + if (installMetadata.type !== 'link') { + await copyExtension(localSourcePath, stagingPath); + } if (isUpdate) { - await maybePromptForSettings( + preparedSettings = await maybePromptForSettings( newExtensionConfig, extensionId, requestSetting || this.requestSetting || promptForSetting, previousExtensionConfig, previousSettings, + path.join(stagingPath, EXTENSION_SETTINGS_FILENAME), + true, ); } else { - await maybePromptForSettings( + preparedSettings = await maybePromptForSettings( newExtensionConfig, extensionId, requestSetting || this.requestSetting || promptForSetting, + undefined, + undefined, + path.join(stagingPath, EXTENSION_SETTINGS_FILENAME), + true, ); } - if (installMetadata.type !== 'link') { - await copyExtension(localSourcePath, destinationPath); - } - // Perform variable replacement in extension files (e.g., ${CLAUDE_PLUGIN_ROOT}) for Claude extensions - const hooksDir = path.join(destinationPath, 'hooks'); + const hooksDir = path.join(stagingPath, 'hooks'); const configHooksPath = typeof newExtensionConfig.hooks === 'string' ? path.isAbsolute(newExtensionConfig.hooks) ? newExtensionConfig.hooks - : path.join(destinationPath, newExtensionConfig.hooks) + : path.join(stagingPath, newExtensionConfig.hooks) : null; if ( @@ -1336,22 +1828,120 @@ export class ExtensionManager { fs.existsSync(configHooksPath)) ) { try { - await performVariableReplacement(destinationPath); + await performVariableReplacement(stagingPath, destinationPath); } catch (error) { debugLogger.error('Variable replacement failed', error); } } const metadataString = JSON.stringify(installMetadata, null, 2); - const metadataPath = path.join( - destinationPath, - INSTALL_METADATA_FILENAME, - ); + const metadataPath = path.join(stagingPath, INSTALL_METADATA_FILENAME); await atomicWriteFile(metadataPath, metadataString); - extension = await this.loadExtension({ extensionDir: destinationPath }); - if (!extension) { - throw new Error(`Extension not found`); + const stagedExtension = await this.loadExtension( + { extensionDir: stagingPath, workspaceDir: currentDir }, + { throwOnError: true }, + ); + if (!stagedExtension) { + throw new Error('Prepared extension could not be loaded.'); + } + + signal?.throwIfAborted(); + if (prepareOnly) { + const cleanupPaths = [ + tempDir, + convertedSourcePath !== tempDir ? convertedSourcePath : undefined, + localSourcePath !== tempDir && + localSourcePath !== convertedSourcePath && + installMetadata.type !== 'link' && + installMetadata.type !== 'local' + ? localSourcePath + : undefined, + ].filter((value): value is string => !!value); + const prepared: PreparedExtensionMutation = { + operation: isUpdate ? 'update' : 'install', + identity: { id: extensionId, name: newExtensionName }, + version: stagedExtension.version, + ...(expectedArtifactGeneration === undefined + ? {} + : { expectedArtifactGeneration }), + installMetadata, + config: newExtensionConfig, + ...(previousExtensionConfig + ? { previousConfig: previousExtensionConfig } + : {}), + initialActivation, + stagingDirectory: stagingPath, + destinationDirectory: destinationPath, + currentDir, + cleanupPaths, + ...(preparedSettings + ? { + commitSettings: preparedSettings.commit, + discardSettings: preparedSettings.discard, + } + : {}), + settingsActivated: false, + consumed: false, + disposed: false, + }; + this.preparedMutations.add(prepared); + ownershipTransferred = true; + return prepared; + } + const snapshot = await this.extensionStore.commitArtifact({ + operation: isUpdate ? 'update' : 'install', + identity: { id: extensionId, name: newExtensionName }, + stagingDirectory: stagingPath, + destinationDirectory: destinationPath, + ...(!isUpdate ? { initialActivation } : {}), + ...(expectedArtifactGeneration === undefined + ? {} + : { expectedArtifactGeneration }), + }); + await preparedSettings?.commit().catch((error) => { + debugLogger.warn( + `Extension "${newExtensionName}" settings compatibility cleanup failed: ${getErrorMessage(error)}`, + ); + }); + preparedSettings = undefined; + stagingPath = undefined; + + try { + extension = await this.loadExtension( + { + extensionDir: destinationPath, + }, + { throwOnError: true }, + ); + if (!extension) throw new Error('Extension not found after commit.'); + } catch (reloadError) { + this.extensionCache?.delete(newExtensionName); + this.applyStoreActivation(snapshot); + const warnings = [ + { + code: 'extension_reload_failed', + error: getErrorMessage(reloadError), + }, + ]; + await this.refreshTools().catch((error) => { + warnings.push({ + code: 'extension_runtime_refresh_failed', + error: getErrorMessage(error), + }); + debugLogger.warn( + `Extension "${newExtensionName}" was committed, but runtime refresh failed: ${getErrorMessage(error)}`, + ); + }); + const error = new Error( + `Extension "${newExtensionName}" committed but could not be reloaded: ${getErrorMessage(reloadError)}`, + { cause: reloadError }, + ) as ExtensionCommittedWithWarningsError; + error.code = 'extension_committed_with_warnings'; + error.committed = true; + error.identity = { id: extensionId, name: newExtensionName }; + error.warnings = warnings; + throw error; } if (this.extensionCache) { @@ -1370,7 +1960,6 @@ export class ExtensionManager { 'success', ), ); - await this.refreshTools(); } else { logExtensionInstallEvent( telemetryConfig, @@ -1381,16 +1970,36 @@ export class ExtensionManager { 'success', ), ); - await this.enableExtension( - newExtensionConfig.name, - SettingScope.User, - ); } + if (this.extensionCache) this.applyStoreActivation(snapshot); + await this.refreshTools().catch((error) => { + debugLogger.warn( + `Extension "${newExtensionName}" was installed, but runtime refresh failed: ${getErrorMessage(error)}`, + ); + }); } finally { - if (tempDir) { + if (!ownershipTransferred && preparedSettings) { + await preparedSettings.discard().catch((error) => { + debugLogger.warn( + `Failed to discard prepared extension settings: ${getErrorMessage(error)}`, + ); + }); + } + if (stagingPath && !ownershipTransferred) { + await fs.promises.rm(stagingPath, { + recursive: true, + force: true, + }); + stagingPath = undefined; + } + if (tempDir && !ownershipTransferred) { await fs.promises.rm(tempDir, { recursive: true, force: true }); } - if (convertedSourcePath && convertedSourcePath !== tempDir) { + if ( + convertedSourcePath && + convertedSourcePath !== tempDir && + !ownershipTransferred + ) { await fs.promises.rm(convertedSourcePath, { recursive: true, force: true, @@ -1401,7 +2010,8 @@ export class ExtensionManager { localSourcePath !== tempDir && localSourcePath !== convertedSourcePath && installMetadata.type !== 'link' && - installMetadata.type !== 'local' + installMetadata.type !== 'local' && + !ownershipTransferred ) { await fs.promises.rm(localSourcePath, { recursive: true, @@ -1443,7 +2053,7 @@ export class ExtensionManager { newExtensionConfig?.version ?? '', previousExtensionConfig.version, installMetadata.type, - 'error', + isExtensionCommittedWithWarningsError(error) ? 'success' : 'error', ), ); } else { @@ -1463,6 +2073,226 @@ export class ExtensionManager { } } + async commitPreparedExtension( + prepared: PreparedExtensionMutation, + onCommitted?: ExtensionCommitCallback, + ): Promise { + return await this.commitPreparedExtensionInternal( + prepared, + true, + onCommitted, + ); + } + + private async commitPreparedExtensionInternal( + prepared: PreparedExtensionMutation, + emitMutation: boolean, + onCommitted?: ExtensionCommitCallback, + ): Promise { + if (!this.preparedMutations.has(prepared)) { + throw new InvalidPreparedExtensionError(); + } + if (prepared.consumed) throw new PreparedExtensionConsumedError(); + prepared.consumed = true; + const endMutation = emitMutation + ? this.beginMutation( + prepared.operation === 'update' + ? 'updateExtension' + : 'installExtension', + ) + : () => undefined; + try { + let snapshot: ExtensionStoreSnapshot; + try { + const stagedExtension = await this.loadExtension( + { + extensionDir: prepared.stagingDirectory, + workspaceDir: prepared.currentDir, + }, + { throwOnError: true }, + ); + if (!stagedExtension) { + throw new Error('Prepared extension could not be loaded.'); + } + if ( + stagedExtension.id !== prepared.identity.id || + stagedExtension.name !== prepared.identity.name || + stagedExtension.version !== prepared.version + ) { + throw new Error('Prepared extension identity changed before commit.'); + } + snapshot = await this.extensionStore.commitArtifact({ + operation: prepared.operation, + identity: prepared.identity, + stagingDirectory: prepared.stagingDirectory, + destinationDirectory: prepared.destinationDirectory, + ...(prepared.operation === 'install' + ? { initialActivation: prepared.initialActivation } + : { + expectedArtifactGeneration: + prepared.expectedArtifactGeneration ?? 0, + }), + }); + prepared.settingsActivated = true; + } catch (error) { + const telemetryConfig = getTelemetryConfig( + prepared.currentDir, + this.telemetrySettings, + ); + if (prepared.operation === 'update' && prepared.previousConfig) { + logExtensionUpdateEvent( + telemetryConfig, + new ExtensionUpdateEvent( + prepared.identity.name, + prepared.identity.id, + prepared.version, + prepared.previousConfig.version, + prepared.installMetadata.type, + 'error', + ), + ); + } else { + logExtensionInstallEvent( + telemetryConfig, + new ExtensionInstallEvent( + prepared.identity.name, + prepared.version, + redactUrlCredentials(prepared.installMetadata.source), + 'error', + ), + ); + } + throw error; + } + const warnings: NonNullable = []; + onCommitted?.(snapshot.generation); + try { + await prepared.commitSettings?.(); + } catch (error) { + warnings.push({ + code: 'extension_settings_legacy_sync_failed', + error: getErrorMessage(error), + }); + } + let extension: Extension | undefined; + try { + extension = + (await this.loadExtension( + { + extensionDir: prepared.destinationDirectory, + }, + { throwOnError: true }, + )) ?? undefined; + if (!extension) throw new Error('Extension not found after commit.'); + this.extensionCache?.set(extension.name, extension); + this.applyStoreActivation(snapshot); + } catch (error) { + this.extensionCache?.delete(prepared.identity.name); + this.applyStoreActivation(snapshot); + warnings.push({ + code: 'extension_reload_failed', + error: getErrorMessage(error), + }); + } + + const telemetryConfig = getTelemetryConfig( + prepared.currentDir, + this.telemetrySettings, + ); + if (prepared.operation === 'update' && prepared.previousConfig) { + logExtensionUpdateEvent( + telemetryConfig, + new ExtensionUpdateEvent( + prepared.identity.name, + prepared.identity.id, + prepared.version, + prepared.previousConfig.version, + prepared.installMetadata.type, + 'success', + ), + ); + } else { + logExtensionInstallEvent( + telemetryConfig, + new ExtensionInstallEvent( + prepared.identity.name, + prepared.version, + redactUrlCredentials(prepared.installMetadata.source), + 'success', + ), + ); + } + try { + await this.refreshTools(); + } catch (error) { + warnings.push({ + code: 'extension_runtime_refresh_failed', + error: getErrorMessage(error), + }); + } + for (const error of await this.cleanupPreparedExtension(prepared)) { + warnings.push({ + code: 'extension_temp_cleanup_failed', + error: getErrorMessage(error), + }); + } + return { + identity: prepared.identity, + version: prepared.version, + generation: snapshot.generation, + ...(extension ? { extension } : {}), + ...(warnings.length > 0 ? { warnings } : {}), + }; + } finally { + endMutation(); + } + } + + async disposePreparedExtension( + prepared: PreparedExtensionMutation, + ): Promise { + if (!this.preparedMutations.has(prepared)) { + throw new InvalidPreparedExtensionError(); + } + for (const error of await this.cleanupPreparedExtension(prepared)) { + debugLogger.warn( + `Failed to clean prepared extension files: ${getErrorMessage(error)}`, + ); + } + } + + private async cleanupPreparedExtension( + prepared: PreparedExtensionMutation, + ): Promise { + if (prepared.disposed) return []; + const settingsCleanup = + !prepared.settingsActivated && prepared.discardSettings + ? await Promise.allSettled([prepared.discardSettings()]) + : []; + const settingsErrors = settingsCleanup.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + const paths = [prepared.stagingDirectory, ...prepared.cleanupPaths]; + let failedPaths = paths; + let pathErrors: unknown[] = []; + for (let attempt = 0; attempt < 2 && failedPaths.length > 0; attempt++) { + const results = await Promise.allSettled( + failedPaths.map(async (target) => + fs.promises.rm(target, { recursive: true, force: true }), + ), + ); + pathErrors = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + failedPaths = failedPaths.filter( + (_target, index) => results[index]?.status === 'rejected', + ); + } + const errors = [...settingsErrors, ...pathErrors]; + prepared.disposed = errors.length === 0; + return errors; + } + /** * Uninstalls an extension. */ @@ -1470,7 +2300,8 @@ export class ExtensionManager { extensionIdentifier: string, isUpdate: boolean, cwd?: string, - ): Promise { + onCommitted?: ExtensionCommitCallback, + ): Promise { const endMutation = this.beginMutation('uninstallExtension'); try { const currentDir = cwd ?? this.workspaceDir; @@ -1489,36 +2320,93 @@ export class ExtensionManager { if (!extension) { throw new Error(`Extension not found.`); } - const storage = new ExtensionStorage( + return await this.uninstallExtensionPolicy( + { id: extension.id, name: extension.name }, extension.installMetadata?.type === 'link' - ? extension.name - : path.basename(extension.path), + ? path.join(this.configDir, extension.name) + : extension.path, + isUpdate, + telemetryConfig, + onCommitted, ); + } finally { + endMutation(); + } + } - await fs.promises.rm(storage.getExtensionDir(), { - recursive: true, - force: true, - }); - - if (this.extensionCache) { - this.extensionCache.delete(extension.name); - } - - if (isUpdate) return; - - this.removeEnablementConfig(extension.name); - this.preferencesStore.clear(extension.name); - await this.refreshTools(); - - logExtensionUninstall( - telemetryConfig, - new ExtensionUninstallEvent(extension.name, 'success'), + async uninstallExtensionById( + extensionId: string, + isUpdate: boolean, + cwd?: string, + onCommitted?: ExtensionCommitCallback, + ): Promise { + const endMutation = this.beginMutation('uninstallExtension'); + try { + const snapshot = await this.extensionStore.readSnapshot(); + const policy = snapshot.extensions[extensionId]; + if (!policy) return snapshot; + const extension = this.getLoadedExtensions().find( + (candidate) => candidate.id === extensionId, + ); + return await this.uninstallExtensionPolicy( + { id: extensionId, name: policy.name }, + extension && extension.installMetadata?.type !== 'link' + ? extension.path + : path.join(this.configDir, policy.name), + isUpdate, + getTelemetryConfig(cwd ?? this.workspaceDir, this.telemetrySettings), + onCommitted, ); } finally { endMutation(); } } + private async uninstallExtensionPolicy( + identity: { id: string; name: string }, + destinationDirectory: string, + isUpdate: boolean, + telemetryConfig: Config, + onCommitted?: ExtensionCommitCallback, + ): Promise { + const snapshot = await this.extensionStore.commitArtifact({ + operation: 'uninstall', + identity, + destinationDirectory, + }); + onCommitted?.(snapshot.generation); + this.extensionCache?.delete(identity.name); + if (isUpdate) return snapshot; + const warnings: NonNullable = []; + try { + this.preferencesStore.clear(identity.name); + } catch (error) { + debugLogger.warn( + `Extension "${identity.name}" was uninstalled, but preference cleanup failed: ${getErrorMessage(error)}`, + ); + warnings.push({ + code: 'extension_preferences_cleanup_failed', + error: getErrorMessage(error), + }); + } + try { + await this.refreshTools(); + } catch (error) { + debugLogger.warn( + `Extension "${identity.name}" was uninstalled, but runtime refresh failed: ${getErrorMessage(error)}`, + ); + warnings.push({ + code: 'extension_runtime_refresh_failed', + error: getErrorMessage(error), + }); + } + logExtensionUninstall( + telemetryConfig, + new ExtensionUninstallEvent(identity.name, 'success'), + ); + return warnings.length > 0 ? { ...snapshot, warnings } : snapshot; + } + async performWorkspaceExtensionMigration( extensions: Extension[], requestConsent: (options?: ExtensionRequestOptions) => Promise, @@ -1538,7 +2426,15 @@ export class ExtensionManager { requestConsent, requestSetting, ); - } catch (_) { + } catch (error) { + if ( + isExtensionCommittedWithWarningsError(error) && + !error.warnings.some( + (warning) => warning.code === 'extension_reload_failed', + ) + ) { + continue; + } failedInstallNames.push(extension.config.name); } } @@ -1547,6 +2443,9 @@ export class ExtensionManager { async checkForAllExtensionUpdates( callback: (extensionName: string, state: ExtensionUpdateState) => void, + signal?: AbortSignal, + schedule: (task: () => Promise) => Promise = async (task) => + await task(), ): Promise { const extensions = this.getLoadedExtensions(); const promises: Array> = []; @@ -1555,14 +2454,30 @@ export class ExtensionManager { callback(extension.name, ExtensionUpdateState.NOT_UPDATABLE); continue; } + const installMetadata = this.withNetworkPolicy(extension.installMetadata); + const extensionForUpdate = + installMetadata === extension.installMetadata + ? extension + : { ...extension, installMetadata }; callback(extension.name, ExtensionUpdateState.CHECKING_FOR_UPDATES); promises.push( - checkForExtensionUpdate(extension, this) + schedule( + async () => + await checkForExtensionUpdate(extensionForUpdate, this, signal), + ) .then((state) => callback(extension.name, state)) - .catch(() => callback(extension.name, ExtensionUpdateState.ERROR)), + .catch(() => { + signal?.throwIfAborted(); + callback(extension.name, ExtensionUpdateState.ERROR); + }), ); } - await Promise.all(promises); + const results = await Promise.allSettled(promises); + signal?.throwIfAborted(); + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + if (rejected) throw rejected.reason; } async updateExtension( @@ -1570,6 +2485,7 @@ export class ExtensionManager { currentState: ExtensionUpdateState, callback: (extensionName: string, state: ExtensionUpdateState) => void, enableExtensionReloading: boolean = true, + signal?: AbortSignal, ): Promise { if (currentState === ExtensionUpdateState.UPDATING) { return undefined; @@ -1589,53 +2505,46 @@ export class ExtensionManager { } const endMutation = this.beginMutation('updateExtension'); const originalVersion = extension.version; - let tempDir: string | undefined; + let prepared: PreparedExtensionMutation | undefined; try { - tempDir = await ExtensionStorage.createTmpDir(); - const previousExtensionConfig = this.loadExtensionConfig({ - extensionDir: extension.path, - }); - let updatedExtension: Extension; - try { - updatedExtension = await this.installExtension( - installMetadata, - undefined, - undefined, - undefined, - previousExtensionConfig, - ); - } catch (e) { - callback(extension.name, ExtensionUpdateState.ERROR); - throw new Error( - `Updated extension not found after installation, got error:\n${redactUrlCredentials(getErrorMessage(e))}`, + prepared = await this.prepareExtensionUpdateFromState(extension, signal); + const committed = await this.commitPreparedExtensionInternal( + prepared, + false, + ); + const warnings = committed.warnings ?? []; + for (const warning of warnings) { + debugLogger.warn( + `Update of "${extension.name}" warning: ${warning.code}: ${warning.error}`, ); } - const updatedVersion = updatedExtension.version; + const updatedVersion = committed.extension?.version ?? committed.version; + const needsRestart = warnings.some( + (warning) => + warning.code === 'extension_reload_failed' || + warning.code === 'extension_runtime_refresh_failed', + ); callback( extension.name, - enableExtensionReloading - ? ExtensionUpdateState.UPDATED - : ExtensionUpdateState.UPDATED_NEEDS_RESTART, + !committed.extension || needsRestart || !enableExtensionReloading + ? ExtensionUpdateState.UPDATED_NEEDS_RESTART + : warnings.length > 0 + ? ExtensionUpdateState.UPDATED_WITH_WARNINGS + : ExtensionUpdateState.UPDATED, ); return { name: extension.name, originalVersion, updatedVersion, + ...(warnings.length > 0 ? { warnings } : {}), }; } catch (e) { - debugLogger.error( - `Error updating extension, rolling back. ${getErrorMessage(e)}`, - ); + debugLogger.error(`Error updating extension. ${getErrorMessage(e)}`); callback(extension.name, ExtensionUpdateState.ERROR); - if (tempDir) { - await copyExtension(tempDir, extension.path); - } throw e; } finally { - if (tempDir) { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - } + if (prepared) await this.disposePreparedExtension(prepared); endMutation(); } } diff --git a/packages/core/src/extension/extensionSettings.test.ts b/packages/core/src/extension/extensionSettings.test.ts index 8d29fcdd6cc..52200711c96 100644 --- a/packages/core/src/extension/extensionSettings.test.ts +++ b/packages/core/src/extension/extensionSettings.test.ts @@ -137,6 +137,352 @@ describe('extensionSettings', () => { expect(mockRequestSetting).not.toHaveBeenCalled(); }); + it('defers adding sensitive settings until commit', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { + name: 'API key', + description: 'API key', + envVar: 'API_KEY', + sensitive: true, + }, + ], + }; + const keychain = new KeychainTokenStorage( + 'Qwen Code Extensions test-ext 12345', + ); + + const commit = await maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + path.join(tempWorkspaceDir, 'staged.env'), + true, + ); + + expect(await keychain.getSecret('API_KEY')).toBeNull(); + expect( + await getScopedEnvContents(config, '12345', ExtensionSettingScope.USER), + ).toEqual({}); + fs.renameSync( + path.join(tempWorkspaceDir, '.qwen-extension-settings.json'), + path.join(extensionDir, '.qwen-extension-settings.json'), + ); + expect( + await getScopedEnvContents(config, '12345', ExtensionSettingScope.USER), + ).toEqual({ API_KEY: 'mock-API_KEY' }); + await commit?.commit(); + expect(await keychain.getSecret('API_KEY')).toBe('mock-API_KEY'); + await keychain.setSecret('API_KEY', 'rotated'); + await commit?.commit(); + expect(await keychain.getSecret('API_KEY')).toBe('rotated'); + }); + + it('isolates concurrent prepared sensitive settings snapshots', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { + name: 'API key', + description: 'API key', + envVar: 'API_KEY', + sensitive: true, + }, + ], + }; + const firstDir = path.join(tempWorkspaceDir, 'first'); + const secondDir = path.join(tempWorkspaceDir, 'second'); + fs.mkdirSync(firstDir); + fs.mkdirSync(secondDir); + + await maybePromptForSettings( + config, + '12345', + async () => 'first-secret', + undefined, + undefined, + path.join(firstDir, '.env'), + true, + ); + await maybePromptForSettings( + config, + '12345', + async () => 'second-secret', + undefined, + undefined, + path.join(secondDir, '.env'), + true, + ); + + const firstSelector = JSON.parse( + fs.readFileSync( + path.join(firstDir, '.qwen-extension-settings.json'), + 'utf8', + ), + ) as { bundleKey: string }; + const secondSelector = JSON.parse( + fs.readFileSync( + path.join(secondDir, '.qwen-extension-settings.json'), + 'utf8', + ), + ) as { bundleKey: string }; + expect(firstSelector.bundleKey).not.toBe(secondSelector.bundleKey); + const storage = mockKeychainData['Qwen Code Extensions test-ext 12345']; + expect(JSON.parse(storage![firstSelector.bundleKey]!)).toEqual({ + API_KEY: 'first-secret', + }); + expect(JSON.parse(storage![secondSelector.bundleKey]!)).toEqual({ + API_KEY: 'second-secret', + }); + }); + + it('discards an uncommitted sensitive settings snapshot', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { + name: 'API key', + description: 'API key', + envVar: 'API_KEY', + sensitive: true, + }, + ], + }; + const stagingDir = path.join(tempWorkspaceDir, 'discard'); + fs.mkdirSync(stagingDir); + const prepared = await maybePromptForSettings( + config, + '12345', + async () => 'temporary-secret', + undefined, + undefined, + path.join(stagingDir, '.env'), + true, + ); + const selector = JSON.parse( + fs.readFileSync( + path.join(stagingDir, '.qwen-extension-settings.json'), + 'utf8', + ), + ) as { bundleKey: string }; + const storage = mockKeychainData['Qwen Code Extensions test-ext 12345']!; + expect(storage[selector.bundleKey]).toBeDefined(); + + await prepared?.discard(); + + expect(storage[selector.bundleKey]).toBeUndefined(); + }); + + it('deletes the previous sensitive settings snapshot after commit', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { + name: 'API key', + description: 'API key', + envVar: 'API_KEY', + sensitive: true, + }, + ], + }; + await maybePromptForSettings( + config, + '12345', + async () => 'old-secret', + undefined, + undefined, + path.join(extensionDir, '.env'), + true, + ); + const oldSelector = JSON.parse( + fs.readFileSync( + path.join(extensionDir, '.qwen-extension-settings.json'), + 'utf8', + ), + ) as { bundleKey: string }; + const storage = mockKeychainData['Qwen Code Extensions test-ext 12345']!; + storage[`${oldSelector.bundleKey}:override:API_KEY`] = 'old-override'; + + const stagingDir = path.join(tempWorkspaceDir, 'replacement'); + fs.mkdirSync(stagingDir); + const prepared = await maybePromptForSettings( + { ...config, version: '2.0.0' }, + '12345', + async () => 'new-secret', + config, + { API_KEY: 'old-secret' }, + path.join(stagingDir, '.env'), + true, + ); + const newSelector = JSON.parse( + fs.readFileSync( + path.join(stagingDir, '.qwen-extension-settings.json'), + 'utf8', + ), + ) as { bundleKey: string }; + fs.copyFileSync( + path.join(stagingDir, '.qwen-extension-settings.json'), + path.join(extensionDir, '.qwen-extension-settings.json'), + ); + + await prepared?.commit(); + + expect(storage[oldSelector.bundleKey]).toBeUndefined(); + expect( + storage[`${oldSelector.bundleKey}:override:API_KEY`], + ).toBeUndefined(); + expect(JSON.parse(storage[newSelector.bundleKey]!)).toEqual({ + API_KEY: 'old-secret', + }); + await expect( + getScopedEnvContents(config, '12345', ExtensionSettingScope.USER), + ).resolves.toEqual({ API_KEY: 'old-secret' }); + }); + + it('does not fall back to stale legacy secrets when a selected bundle is missing', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { + name: 'API key', + description: 'API key', + envVar: 'API_KEY', + sensitive: true, + }, + ], + }; + await maybePromptForSettings( + config, + '12345', + async () => 'new-secret', + undefined, + undefined, + path.join(extensionDir, '.env'), + true, + ); + const selector = JSON.parse( + fs.readFileSync( + path.join(extensionDir, '.qwen-extension-settings.json'), + 'utf8', + ), + ) as { bundleKey: string }; + const storage = mockKeychainData['Qwen Code Extensions test-ext 12345']!; + storage['API_KEY'] = 'stale-secret'; + delete storage[selector.bundleKey]; + + await expect( + getScopedEnvContents(config, '12345', ExtensionSettingScope.USER), + ).rejects.toThrow('Stored extension settings bundle is missing.'); + }); + + it('defers clearing sensitive settings until commit', async () => { + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { + name: 'API key', + description: 'API key', + envVar: 'API_KEY', + sensitive: true, + }, + ], + }; + const keychain = new KeychainTokenStorage( + 'Qwen Code Extensions test-ext 12345', + ); + await keychain.setSecret('API_KEY', 'old-secret'); + + const commit = await maybePromptForSettings( + { name: 'test-ext', version: '2.0.0', settings: [] }, + '12345', + mockRequestSetting, + previousConfig, + { API_KEY: 'old-secret' }, + path.join(tempWorkspaceDir, 'staged.env'), + true, + ); + + expect(await keychain.getSecret('API_KEY')).toBe('old-secret'); + await commit?.commit(); + expect(await keychain.getSecret('API_KEY')).toBeNull(); + }); + + it('rejects invalid environment variable names before prompting', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { + name: 'API key', + description: 'API key', + envVar: 'API_KEY\nforged', + }, + ], + }; + + await expect( + maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + ), + ).rejects.toThrow( + 'Extension setting "envVar" must be a valid environment variable name.', + ); + expect(mockRequestSetting).not.toHaveBeenCalled(); + }); + + it('rejects invalid previous environment variable names before mutation', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '2.0.0', + settings: [ + { + name: 'Current key', + description: 'Current key', + envVar: 'API_KEY', + }, + ], + }; + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { + name: 'Previous key', + description: 'Previous key', + envVar: 'OLD_KEY\nforged', + }, + ], + }; + + await expect( + maybePromptForSettings( + config, + '12345', + mockRequestSetting, + previousConfig, + { OLD_KEY: 'previous' }, + ), + ).rejects.toThrow( + 'Extension setting "envVar" must be a valid environment variable name.', + ); + expect(mockRequestSetting).not.toHaveBeenCalled(); + expect(KeychainTokenStorage).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(extensionDir, '.env'))).toBe(false); + }); + it('should prompt for all settings if there is no previous config', async () => { const config: ExtensionConfig = { name: 'test-ext', @@ -173,6 +519,10 @@ describe('extensionSettings', () => { ], }; const previousSettings = { VAR1: 'previous-VAR1' }; + const expectedEnvPath = path.join(extensionDir, '.env'); + const symlinkTarget = path.join(tempHomeDir, 'prompt-target.env'); + await fsPromises.writeFile(symlinkTarget, 'ORIGINAL'); + await fsPromises.symlink(symlinkTarget, expectedEnvPath); await maybePromptForSettings( newConfig, @@ -185,10 +535,13 @@ describe('extensionSettings', () => { expect(mockRequestSetting).toHaveBeenCalledTimes(1); expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![1]); - const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); const expectedContent = 'VAR1=previous-VAR1\nVAR2=mock-VAR2\n'; expect(actualContent).toBe(expectedContent); + expect(fs.lstatSync(expectedEnvPath).isSymbolicLink()).toBe(false); + expect(await fsPromises.readFile(symlinkTarget, 'utf-8')).toBe( + 'ORIGINAL', + ); }); it('should clear settings if new config has no settings', async () => { @@ -219,7 +572,9 @@ describe('extensionSettings', () => { ); await userKeychain.setSecret('SENSITIVE_VAR', 'secret'); const envPath = path.join(extensionDir, '.env'); - await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1'); + const symlinkTarget = path.join(tempHomeDir, 'clear-target.env'); + await fsPromises.writeFile(symlinkTarget, 'VAR1=previous-VAR1'); + await fsPromises.symlink(symlinkTarget, envPath); await maybePromptForSettings( newConfig, @@ -232,6 +587,10 @@ describe('extensionSettings', () => { expect(mockRequestSetting).not.toHaveBeenCalled(); const actualContent = await fsPromises.readFile(envPath, 'utf-8'); expect(actualContent).toBe(''); + expect(fs.lstatSync(envPath).isSymbolicLink()).toBe(false); + expect(await fsPromises.readFile(symlinkTarget, 'utf-8')).toBe( + 'VAR1=previous-VAR1', + ); expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull(); }); @@ -628,6 +987,11 @@ describe('extensionSettings', () => { it('should update a non-sensitive setting in USER scope', async () => { mockRequestSetting.mockResolvedValue('new-value1'); + const expectedEnvPath = path.join(extensionDir, '.env'); + const symlinkTarget = path.join(tempHomeDir, 'update-target.env'); + await fsPromises.rm(expectedEnvPath); + await fsPromises.writeFile(symlinkTarget, 'VAR1=value1\n'); + await fsPromises.symlink(symlinkTarget, expectedEnvPath); await updateSetting( config, @@ -637,9 +1001,12 @@ describe('extensionSettings', () => { ExtensionSettingScope.USER, ); - const expectedEnvPath = path.join(extensionDir, '.env'); const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); expect(actualContent).toContain('VAR1=new-value1'); + expect(fs.lstatSync(expectedEnvPath).isSymbolicLink()).toBe(false); + expect(await fsPromises.readFile(symlinkTarget, 'utf-8')).toBe( + 'VAR1=value1\n', + ); }); it('should update a non-sensitive setting in WORKSPACE scope', async () => { @@ -675,6 +1042,46 @@ describe('extensionSettings', () => { expect(await userKeychain.getSecret('VAR2')).toBe('new-value2'); }); + it('synchronizes legacy sensitive settings through the current backend', async () => { + const previousStorageOverride = + process.env['QWEN_CODE_FORCE_FILE_STORAGE']; + process.env['QWEN_CODE_FORCE_FILE_STORAGE'] = 'true'; + try { + await maybePromptForSettings( + config, + '12345', + async () => 'initial-value2', + undefined, + undefined, + path.join(extensionDir, '.env'), + ); + } finally { + if (previousStorageOverride === undefined) { + delete process.env['QWEN_CODE_FORCE_FILE_STORAGE']; + } else { + process.env['QWEN_CODE_FORCE_FILE_STORAGE'] = previousStorageOverride; + } + } + + await updateSetting( + config, + '12345', + 'VAR2', + async () => 'new-value2', + ExtensionSettingScope.USER, + ); + + await fsPromises.rm( + path.join(extensionDir, '.qwen-extension-settings.json'), + ); + await expect( + getScopedEnvContents(config, '12345', ExtensionSettingScope.USER), + ).resolves.toEqual({ + VAR1: 'initial-value2', + VAR2: 'new-value2', + }); + }); + it('should update a sensitive setting in WORKSPACE scope', async () => { mockRequestSetting.mockResolvedValue('new-workspace-secret'); @@ -694,6 +1101,75 @@ describe('extensionSettings', () => { ); }); + it('surfaces authoritative sensitive setting write failures', async () => { + mockRequestSetting.mockResolvedValue('new-value2'); + vi.mocked(KeychainTokenStorage).mockImplementationOnce( + () => + ({ + isAvailable: vi.fn().mockResolvedValue(true), + setSecret: vi.fn().mockRejectedValue(new Error('write failed')), + }) as unknown as KeychainTokenStorage, + ); + + await expect( + updateSetting( + config, + '12345', + 'VAR2', + mockRequestSetting, + ExtensionSettingScope.USER, + ), + ).rejects.toThrow('write failed'); + }); + + it('does not lose concurrent user-scope sensitive setting updates', async () => { + const sensitiveConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true }, + { name: 's3', description: 'd3', envVar: 'VAR3', sensitive: true }, + ], + }; + await maybePromptForSettings( + sensitiveConfig, + '12345', + async (setting) => `initial-${setting.envVar}`, + undefined, + undefined, + path.join(extensionDir, '.env'), + true, + ); + + await Promise.all([ + updateSetting( + sensitiveConfig, + '12345', + 'VAR2', + async () => 'updated-VAR2', + ExtensionSettingScope.USER, + ), + updateSetting( + sensitiveConfig, + '12345', + 'VAR3', + async () => 'updated-VAR3', + ExtensionSettingScope.USER, + ), + ]); + + await expect( + getScopedEnvContents( + sensitiveConfig, + '12345', + ExtensionSettingScope.USER, + ), + ).resolves.toEqual({ + VAR2: 'updated-VAR2', + VAR3: 'updated-VAR3', + }); + }); + it('should leave existing, unmanaged .env variables intact when updating in WORKSPACE scope', async () => { // Setup a pre-existing .env file in the workspace with unmanaged variables const workspaceEnvPath = path.join(tempWorkspaceDir, '.env'); diff --git a/packages/core/src/extension/extensionSettings.ts b/packages/core/src/extension/extensionSettings.ts index d90c9fcba98..0fcb18261e3 100644 --- a/packages/core/src/extension/extensionSettings.ts +++ b/packages/core/src/extension/extensionSettings.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; +import { randomUUID } from 'node:crypto'; import * as dotenv from 'dotenv'; import * as path from 'node:path'; import { ExtensionStorage } from './storage.js'; @@ -13,7 +14,14 @@ import type { ExtensionConfig } from './extensionManager.js'; import prompts from 'prompts'; import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; import { HybridTokenStorage } from '../mcp/token-storage/hybrid-token-storage.js'; +import { FileTokenStorage } from '../mcp/token-storage/file-token-storage.js'; +import { KeychainTokenStorage } from '../mcp/token-storage/keychain-token-storage.js'; +import { + TokenStorageType, + type SecretStorage, +} from '../mcp/token-storage/types.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { atomicWriteFile, atomicWriteJSON } from '../utils/atomicFileWrite.js'; const debugLogger = createDebugLogger('EXT_SETTINGS'); @@ -24,6 +32,100 @@ export interface ExtensionSetting { sensitive?: boolean; } +const ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const SETTINGS_SELECTOR_FILENAME = '.qwen-extension-settings.json'; +const SETTINGS_BUNDLE_PREFIX = '$qwen:extension-settings:v2:'; + +interface ExtensionSettingsSelector { + version: 1; + backend: TokenStorageType; + bundleKey: string; +} + +function getSettingsSelectorPath( + extensionName: string, + envFilePathOverride?: string, +): string { + return path.join( + envFilePathOverride + ? path.dirname(envFilePathOverride) + : new ExtensionStorage(extensionName).getExtensionDir(), + SETTINGS_SELECTOR_FILENAME, + ); +} + +async function readSettingsSelector( + extensionName: string, +): Promise { + const selectorPath = getSettingsSelectorPath(extensionName); + if (!fsSync.existsSync(selectorPath)) return undefined; + const parsed: unknown = JSON.parse(await fs.readFile(selectorPath, 'utf8')); + if ( + !parsed || + typeof parsed !== 'object' || + !('version' in parsed) || + parsed.version !== 1 || + !('backend' in parsed) || + !Object.values(TokenStorageType).includes( + parsed.backend as TokenStorageType, + ) || + !('bundleKey' in parsed) || + typeof parsed.bundleKey !== 'string' || + !parsed.bundleKey.startsWith(SETTINGS_BUNDLE_PREFIX) + ) { + throw new Error('Stored extension settings selector is invalid.'); + } + return parsed as ExtensionSettingsSelector; +} + +function createSelectedStorage( + serviceName: string, + backend: TokenStorageType, +): SecretStorage { + return backend === TokenStorageType.KEYCHAIN + ? new KeychainTokenStorage(serviceName) + : new FileTokenStorage(serviceName); +} + +async function deleteSettingsSnapshot( + selector: ExtensionSettingsSelector, + serviceName: string, +): Promise { + const storage = createSelectedStorage(serviceName, selector.backend); + if ((await storage.getSecret(selector.bundleKey)) !== null) { + await storage.deleteSecret(selector.bundleKey); + } + const keys = await storage.listSecrets(); + for (const key of keys) { + if (key.startsWith(`${selector.bundleKey}:override:`)) { + await storage.deleteSecret(key); + } + } +} + +function parseSensitiveSettingsBundle(content: string): Record { + const parsed: unknown = JSON.parse(content); + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + Object.values(parsed).some((value) => typeof value !== 'string') + ) { + throw new Error('Stored extension settings bundle is invalid.'); + } + return parsed as Record; +} + +export function validateExtensionSettingEnvVars( + settings: readonly ExtensionSetting[] | undefined, +): void { + if (settings?.some((setting) => !ENV_VAR_NAME_PATTERN.test(setting.envVar))) { + throw new Error( + 'Extension setting "envVar" must be a valid environment variable name.', + ); + } +} + export interface ResolvedExtensionSetting { name: string; envVar: string; @@ -36,6 +138,11 @@ export enum ExtensionSettingScope { WORKSPACE = 'workspace', } +export interface PreparedExtensionSettingsMutation { + commit(): Promise; + discard(): Promise; +} + export interface ExtensionSetting { name: string; description: string; @@ -72,26 +179,54 @@ export async function maybePromptForSettings( requestSetting: (setting: ExtensionSetting) => Promise, previousExtensionConfig?: ExtensionConfig, previousSettings?: Record, -): Promise { + envFilePathOverride?: string, + deferKeychainMutations = false, +): Promise { const { name: extensionName, settings } = extensionConfig; + validateExtensionSettingEnvVars(settings); + validateExtensionSettingEnvVars(previousExtensionConfig?.settings); if ( (!settings || settings.length === 0) && (!previousExtensionConfig?.settings || previousExtensionConfig.settings.length === 0) ) { + if (envFilePathOverride) { + await fs.rm(getSettingsSelectorPath(extensionName, envFilePathOverride), { + force: true, + }); + } return; } // We assume user scope here because we don't have a way to ask the user for scope during the initial setup. // The user can change the scope later using the `settings set` command. const scope = ExtensionSettingScope.USER; - const envFilePath = getEnvFilePath(extensionName, scope); + const envFilePath = + envFilePathOverride ?? getEnvFilePath(extensionName, scope); + const selectorPath = getSettingsSelectorPath( + extensionName, + envFilePathOverride, + ); const keychain = new HybridTokenStorage( getKeychainStorageName(extensionName, extensionId, scope), ); + const serviceName = getKeychainStorageName(extensionName, extensionId, scope); + const previousSelector = await readSettingsSelector(extensionName); + const keychainMutations: Array<() => Promise> = []; + let newSelector: ExtensionSettingsSelector | undefined; if (!settings || settings.length === 0) { - await clearSettings(envFilePath, keychain); - return; + if (fsSync.existsSync(envFilePath)) { + await atomicWriteFile(envFilePath, '', { noFollow: true }); + } + await fs.rm(selectorPath, { force: true }); + keychainMutations.push(async () => await clearKeychainSettings(keychain)); + return await applyOrDeferKeychainMutations( + keychainMutations, + deferKeychainMutations, + previousSelector, + undefined, + serviceName, + ); } const settingsChanges = getSettingsChanges( @@ -106,7 +241,9 @@ export async function maybePromptForSettings( } for (const removedSensitiveSetting of settingsChanges.removeSensitive) { - await keychain.deleteSecret(removedSensitiveSetting.envVar); + keychainMutations.push( + async () => await keychain.deleteSecret(removedSensitiveSetting.envVar), + ); } for (const setting of settingsChanges.promptForSensitive.concat( @@ -117,13 +254,17 @@ export async function maybePromptForSettings( } const nonSensitiveSettings: Record = {}; + const sensitiveSettings: Record = {}; for (const setting of settings) { const value = allSettings[setting.envVar]; if (value === undefined) { continue; } if (setting.sensitive) { - await keychain.setSecret(setting.envVar, value); + sensitiveSettings[setting.envVar] = value; + keychainMutations.push( + async () => await keychain.setSecret(setting.envVar, value), + ); } else { nonSensitiveSettings[setting.envVar] = value; } @@ -131,7 +272,79 @@ export async function maybePromptForSettings( const envContent = formatEnvContent(nonSensitiveSettings); - await fs.writeFile(envFilePath, envContent); + await atomicWriteFile(envFilePath, envContent, { noFollow: true }); + if (Object.keys(sensitiveSettings).length > 0) { + const bundleKey = `${SETTINGS_BUNDLE_PREFIX}${randomUUID()}`; + await keychain.setSecret(bundleKey, JSON.stringify(sensitiveSettings)); + newSelector = { + version: 1, + backend: await keychain.getStorageType(), + bundleKey, + }; + try { + await atomicWriteJSON(selectorPath, newSelector, { + mode: 0o600, + forceMode: true, + noFollow: true, + }); + } catch (error) { + try { + await keychain.deleteSecret(bundleKey); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'Failed to stage extension settings and clean its secret snapshot.', + { cause: error }, + ); + } + throw error; + } + } else { + await fs.rm(selectorPath, { force: true }); + } + return await applyOrDeferKeychainMutations( + keychainMutations, + deferKeychainMutations, + previousSelector, + newSelector, + serviceName, + ); +} + +async function applyOrDeferKeychainMutations( + mutations: ReadonlyArray<() => Promise>, + defer: boolean, + previousSelector: ExtensionSettingsSelector | undefined, + newSelector: ExtensionSettingsSelector | undefined, + serviceName: string, +): Promise { + if (mutations.length === 0 && !previousSelector && !newSelector) { + return undefined; + } + let applied = false; + const apply = async () => { + if (applied) return; + for (const mutation of mutations) await mutation(); + applied = true; + }; + if (!defer) await apply(); + let previousDiscarded = false; + let newDiscarded = false; + return { + commit: async () => { + await apply(); + if (previousSelector && !previousDiscarded) { + await deleteSettingsSnapshot(previousSelector, serviceName); + previousDiscarded = true; + } + }, + discard: async () => { + if (newSelector && !newDiscarded) { + await deleteSettingsSnapshot(newSelector, serviceName); + newDiscarded = true; + } + }, + }; } function formatEnvContent(settings: Record): string { @@ -163,6 +376,16 @@ export async function getScopedEnvContents( const keychain = new HybridTokenStorage( getKeychainStorageName(extensionName, extensionId, scope), ); + const selector = + scope === ExtensionSettingScope.USER + ? await readSettingsSelector(extensionName) + : undefined; + const settingsStorage = selector + ? createSelectedStorage( + getKeychainStorageName(extensionName, extensionId, scope), + selector.backend, + ) + : keychain; const envFilePath = getEnvFilePath(extensionName, scope); let customEnv: Record = {}; if (fsSync.existsSync(envFilePath)) { @@ -171,12 +394,29 @@ export async function getScopedEnvContents( } if (extensionConfig.settings) { + const bundleContent = selector + ? await settingsStorage.getSecret(selector.bundleKey) + : null; + if (selector && bundleContent === null) { + throw new Error('Stored extension settings bundle is missing.'); + } + const bundle = bundleContent + ? parseSensitiveSettingsBundle(bundleContent) + : undefined; for (const setting of extensionConfig.settings) { - if (setting.sensitive) { - const secret = await keychain.getSecret(setting.envVar); - if (secret) { - customEnv[setting.envVar] = secret; - } + if (!setting.sensitive) continue; + const override = selector + ? await settingsStorage.getSecret( + `${selector.bundleKey}:override:${setting.envVar}`, + ) + : null; + const secret = + override ?? + (selector + ? bundle?.[setting.envVar] + : await settingsStorage.getSecret(setting.envVar)); + if (secret) { + customEnv[setting.envVar] = secret; } } } @@ -237,7 +477,31 @@ export async function updateSetting( ); if (settingToUpdate.sensitive) { - await keychain.setSecret(settingToUpdate.envVar, newValue); + const selector = + scope === ExtensionSettingScope.USER + ? await readSettingsSelector(extensionName) + : undefined; + const settingsStorage = selector + ? createSelectedStorage( + getKeychainStorageName(extensionName, extensionId, scope), + selector.backend, + ) + : keychain; + if (selector) { + await settingsStorage.setSecret( + `${selector.bundleKey}:override:${settingToUpdate.envVar}`, + newValue, + ); + try { + await keychain.setSecret(settingToUpdate.envVar, newValue); + } catch (error) { + debugLogger.warn( + `Failed to synchronize legacy extension setting "${settingToUpdate.envVar}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else { + await settingsStorage.setSecret(settingToUpdate.envVar, newValue); + } return; } @@ -264,7 +528,7 @@ export async function updateSetting( } const newEnvContent = formatEnvContent(nonSensitiveSettings); - await fs.writeFile(envFilePath, newEnvContent); + await atomicWriteFile(envFilePath, newEnvContent, { noFollow: true }); } interface settingsChanges { @@ -301,13 +565,7 @@ function getSettingsChanges( }; } -async function clearSettings( - envFilePath: string, - keychain: HybridTokenStorage, -) { - if (fsSync.existsSync(envFilePath)) { - await fs.writeFile(envFilePath, ''); - } +async function clearKeychainSettings(keychain: HybridTokenStorage) { if (!(await keychain.isAvailable())) { return; } diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index 57010bbdda6..8188e5c3790 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -24,7 +24,9 @@ import type { IncomingMessage } from 'node:http'; import * as fs from 'node:fs/promises'; import * as fsSync from 'node:fs'; import * as path from 'node:path'; +import { randomBytes } from 'node:crypto'; import { Readable } from 'node:stream'; +import { promises as dns } from 'node:dns'; import * as tar from 'tar'; import * as archiver from 'archiver'; import { @@ -34,6 +36,8 @@ import { } from './extensionManager.js'; import { getErrorMessage } from '../utils/errors.js'; import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; +import { ExtensionStorage } from './storage.js'; +import { assertTarArchiveHasNoLinks } from './archive-safety.js'; const mockPlatform = vi.hoisted(() => vi.fn()); const mockArch = vi.hoisted(() => vi.fn()); @@ -143,10 +147,14 @@ describe('git extension helpers', () => { getRemotes: vi.fn(), fetch: vi.fn(), checkout: vi.fn(), + version: vi.fn(), + env: vi.fn(), }; beforeEach(() => { vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); + mockGit.env.mockReturnValue(mockGit); + mockGit.version.mockResolvedValue({ major: 2, minor: 52 }); }); it('should clone, fetch and checkout a repo', async () => { @@ -160,9 +168,13 @@ describe('git extension helpers', () => { mockGit.getRemotes.mockResolvedValue([ { name: 'origin', refs: { fetch: 'http://my-repo.com' } }, ]); + const controller = new AbortController(); - await cloneFromGit(installMetadata, destination); + await cloneFromGit(installMetadata, destination, controller.signal); + expect(simpleGit).toHaveBeenCalledWith(destination, { + abort: controller.signal, + }); expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [ '-c', 'core.symlinks=true', @@ -170,7 +182,10 @@ describe('git extension helpers', () => { '1', ]); expect(mockGit.getRemotes).toHaveBeenCalledWith(true); - expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'my-ref'); + expect(mockGit.fetch).toHaveBeenCalledWith( + 'http://my-repo.com', + 'my-ref', + ); expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD'); }); @@ -230,7 +245,90 @@ describe('git extension helpers', () => { await cloneFromGit(installMetadata, destination); - expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'HEAD'); + expect(mockGit.fetch).toHaveBeenCalledWith('http://my-repo.com', 'HEAD'); + }); + + it('pins public HTTPS Git traffic and disables redirects and proxies', async () => { + const previousGitConfigCount = process.env['GIT_CONFIG_COUNT']; + process.env['GIT_CONFIG_COUNT'] = '1'; + vi.spyOn(dns, 'lookup').mockResolvedValue([ + { address: '8.8.8.8', family: 4 }, + ] as never); + const installMetadata = { + source: 'https://github.com/owner/repo.git', + type: 'git' as const, + networkPolicy: 'public' as const, + }; + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/owner/repo.git' }, + }, + ]); + + try { + await cloneFromGit(installMetadata, '/dest'); + } finally { + if (previousGitConfigCount === undefined) { + delete process.env['GIT_CONFIG_COUNT']; + } else { + process.env['GIT_CONFIG_COUNT'] = previousGitConfigCount; + } + } + + expect(simpleGit).toHaveBeenLastCalledWith('/dest', { + config: [ + 'http.curloptResolve=github.com:443:8.8.8.8', + 'http.followRedirects=false', + 'http.proxy=', + 'protocol.allow=never', + 'protocol.https.allow=always', + ], + }); + expect(mockGit.env).toHaveBeenCalledWith( + expect.objectContaining({ + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: expect.any(String), + }), + ); + expect(mockGit.env.mock.calls[0]?.[0]).not.toHaveProperty( + 'GIT_CONFIG_COUNT', + ); + expect(mockGit.fetch).toHaveBeenCalledWith( + 'https://github.com/owner/repo.git', + 'HEAD', + ); + }); + + it('rejects SSH Git traffic under the public network policy', async () => { + await expect( + cloneFromGit( + { + source: 'git@github.com:owner/repo.git', + type: 'git', + networkPolicy: 'public', + }, + '/dest', + ), + ).rejects.toThrow('must use HTTPS'); + expect(mockGit.clone).not.toHaveBeenCalled(); + }); + + it('allows SCP-like SSH Git sources without the public network policy', async () => { + const source = 'git@github.com:owner/repo.git'; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: source } }, + ]); + + await cloneFromGit({ source, type: 'git' }, '/dest'); + + expect(mockGit.clone).toHaveBeenCalledWith(source, './', [ + '-c', + 'core.symlinks=true', + '--depth', + '1', + ]); + expect(mockGit.fetch).toHaveBeenCalledWith(source, 'HEAD'); }); it('should throw if no remotes are found', async () => { @@ -342,6 +440,40 @@ describe('git extension helpers', () => { 'Failed to clone Git repository from http://my-repo.com', ); }); + + it('preserves abort errors raised after a git operation', async () => { + const installMetadata = { + source: 'http://my-repo.com', + type: 'git' as const, + }; + const controller = new AbortController(); + const reason = new Error('download cancelled'); + mockGit.clone.mockImplementationOnce(async () => { + controller.abort(reason); + }); + + await expect( + cloneFromGit(installMetadata, '/dest', controller.signal), + ).rejects.toBe(reason); + }); + + it('preserves a git failure when the signal aborts as a side effect', async () => { + const controller = new AbortController(); + mockGit.clone.mockImplementationOnce(async () => { + controller.abort(); + throw new Error('authentication failed'); + }); + + await expect( + cloneFromGit( + { source: 'http://my-repo.com', type: 'git' }, + '/dest', + controller.signal, + ), + ).rejects.toThrow( + 'Failed to clone Git repository from http://my-repo.com authentication failed', + ); + }); }); describe('checkForExtensionUpdate', () => { @@ -349,6 +481,8 @@ describe('git extension helpers', () => { getRemotes: vi.fn(), listRemote: vi.fn(), revparse: vi.fn(), + version: vi.fn(), + env: vi.fn(), }; const mockExtensionManager = { @@ -357,6 +491,8 @@ describe('git extension helpers', () => { beforeEach(() => { vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); + mockGit.version.mockResolvedValue({ major: 2, minor: 52 }); + mockGit.env.mockReturnValue(mockGit); }); function createExtension(overrides: Partial = {}): Extension { @@ -421,6 +557,67 @@ describe('git extension helpers', () => { expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); }); + it('pins public Git update checks and disables redirects and proxies', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValue([ + { address: '8.8.8.8', family: 4 }, + ] as never); + const extension = createExtension({ + installMetadata: { + type: 'git', + source: 'https://github.com/owner/repo.git', + networkPolicy: 'public', + }, + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/owner/repo.git' }, + }, + ]); + mockGit.listRemote.mockResolvedValue('same-hash\tHEAD'); + mockGit.revparse.mockResolvedValue('same-hash'); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + expect(simpleGit).toHaveBeenLastCalledWith('/ext', { + config: [ + 'http.curloptResolve=github.com:443:8.8.8.8', + 'http.followRedirects=false', + 'http.proxy=', + 'protocol.allow=never', + 'protocol.https.allow=always', + ], + }); + expect(mockGit.listRemote).toHaveBeenCalledWith([ + 'https://github.com/owner/repo.git', + 'HEAD', + ]); + }); + + it('checks SCP-like SSH Git remotes without the public network policy', async () => { + const source = 'git@github.com:owner/repo.git'; + const extension = createExtension({ + installMetadata: { type: 'git', source }, + }); + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: source } }, + ]); + mockGit.listRemote.mockResolvedValue('same-hash\tHEAD'); + mockGit.revparse.mockResolvedValue('same-hash'); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + expect(mockGit.listRemote).toHaveBeenCalledWith([source, 'HEAD']); + }); + it('should return UP_TO_DATE when remote and local hashes are the same', async () => { const extension = createExtension({ installMetadata: { @@ -606,6 +803,100 @@ describe('git extension helpers', () => { } }); + it('should propagate an abort observed after extracting a local archive', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'local-archive-abort-test-'), + ); + try { + const archivePath = path.join(tempDir, 'qwen-extension.zip'); + const archive = await createZipBuffer(tempDir, [ + { + name: EXTENSIONS_CONFIG_FILENAME, + content: JSON.stringify({ + name: 'local-archive-extension', + version: '2.0.0', + }), + }, + ]); + await fs.writeFile(archivePath, archive); + const extension = createExtension({ + version: '1.0.0', + installMetadata: { + type: 'local', + source: archivePath, + }, + }); + const mockManager = { + loadExtensionConfig: vi.fn(), + } as unknown as ExtensionManager; + const abortError = new DOMException('Aborted', 'AbortError'); + let abortChecks = 0; + const signal = { + throwIfAborted: () => { + abortChecks += 1; + if (abortChecks >= 3) throw abortError; + }, + } as unknown as AbortSignal; + + await expect( + checkForExtensionUpdate(extension, mockManager, signal), + ).rejects.toBe(abortError); + expect(mockManager.loadExtensionConfig).not.toHaveBeenCalled(); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('should clean up a converted local archive when aborted after conversion', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'local-converted-archive-abort-test-'), + ); + const convertedDir = path.join(tempDir, 'converted'); + try { + const archivePath = path.join(tempDir, 'gemini-extension.zip'); + const archive = await createZipBuffer(tempDir, [ + { + name: 'gemini-extension.json', + content: JSON.stringify({ + name: 'gemini-archive-extension', + version: '2.0.0', + }), + }, + ]); + await fs.writeFile(archivePath, archive); + vi.spyOn(ExtensionStorage, 'createTmpDir').mockImplementation( + async () => { + await fs.mkdir(convertedDir); + return convertedDir; + }, + ); + const extension = createExtension({ + version: '1.0.0', + installMetadata: { + type: 'local', + source: archivePath, + }, + }); + const abortError = new DOMException('Aborted', 'AbortError'); + let abortChecks = 0; + const signal = { + throwIfAborted: () => { + abortChecks += 1; + if (abortChecks >= 4) throw abortError; + }, + } as unknown as AbortSignal; + + await expect( + checkForExtensionUpdate(extension, {} as ExtensionManager, signal), + ).rejects.toBe(abortError); + await expect(fs.stat(convertedDir)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it('should return UPDATE_AVAILABLE for archive URL extension with different version', async () => { const tempDir = await fs.mkdtemp( path.join(os.tmpdir(), 'archive-url-update-test-'), @@ -745,6 +1036,87 @@ describe('git extension helpers', () => { await fs.rm(tempDir, { recursive: true, force: true }); }); + it('preserves the abort reason for release metadata response errors', async () => { + const responseError = new Error('response interrupted'); + const controller = new AbortController(); + const abortReason = new Error('release check cancelled'); + const response = new Readable({ + read() { + controller.abort(abortReason); + this.destroy(responseError); + }, + }) as IncomingMessage; + Object.assign(response, { statusCode: 200, headers: {} }); + mockHttpsGet.mockImplementationOnce(((_url, options, callback) => { + callResponseCallback(options, callback, response); + return createRequestMock(); + }) as typeof https.get); + + await expect( + downloadFromGitHubRelease( + { source: 'owner/repo', type: 'github-release' }, + tempDir, + controller.signal, + ), + ).rejects.toBe(abortReason); + }); + + it('preserves the abort reason for release metadata status errors', async () => { + const controller = new AbortController(); + const abortReason = new Error('release check cancelled'); + const response = createResponse('missing', 404); + mockHttpsGet.mockImplementationOnce(((_url, options, callback) => { + controller.abort(abortReason); + callResponseCallback(options, callback, response); + return createRequestMock(); + }) as typeof https.get); + + await expect( + downloadFromGitHubRelease( + { source: 'owner/repo', type: 'github-release' }, + tempDir, + controller.signal, + ), + ).rejects.toBe(abortReason); + }); + + it('times out release metadata requests', async () => { + vi.useFakeTimers(); + const request = { + on: vi.fn().mockReturnThis(), + destroy: vi.fn().mockReturnThis(), + } as unknown as ReturnType; + mockHttpsGet.mockImplementationOnce(() => request); + + try { + const download = downloadFromGitHubRelease( + { source: 'owner/repo', type: 'github-release' }, + tempDir, + ); + const outcome = download.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(120_000); + + await expect(outcome).resolves.toMatchObject({ + message: 'Timed out fetching GitHub API response', + }); + expect(request.destroy).toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('rejects invalid release metadata JSON', async () => { + mockHttpsResponses('{ invalid json'); + + await expect( + downloadFromGitHubRelease( + { source: 'owner/repo', type: 'github-release' }, + tempDir, + ), + ).rejects.toBeInstanceOf(SyntaxError); + }); + it('should explain when a release archive is missing an extension manifest', async () => { const invalidArchive = await createZipBuffer(tempDir, [ { name: 'README.md', content: 'not an extension' }, @@ -938,6 +1310,61 @@ describe('git extension helpers', () => { expect(request.destroy).toHaveBeenCalled(); }); + it('does not start an archive request when DNS outlives the deadline', async () => { + vi.useFakeTimers(); + vi.spyOn(dns, 'lookup').mockImplementation( + () => new Promise(() => undefined), + ); + + try { + const outcome = downloadFromArchiveUrl( + { + source: 'https://packages.example/extension.zip', + type: 'archive-url', + networkPolicy: 'public', + }, + tempDir, + ).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(120_000); + + await expect(outcome).resolves.toMatchObject({ + message: + 'Failed to download archive from https://packages.example/extension.zip: Timed out downloading extension archive', + }); + expect(mockHttpsGet).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('preserves the caller abort reason for archive URL downloads', async () => { + let errorHandler: ((error: Error) => void) | undefined; + const request = { + on: vi.fn((event: string, handler: (error: Error) => void) => { + if (event === 'error') errorHandler = handler; + return request; + }), + setTimeout: vi.fn().mockReturnThis(), + destroy: vi.fn().mockReturnThis(), + } as unknown as ReturnType; + mockHttpsGet.mockImplementationOnce(() => request); + const controller = new AbortController(); + const reason = new Error('download cancelled'); + + const download = downloadFromArchiveUrl( + { + source: 'https://example.com/releases/extension.zip', + type: 'archive-url', + }, + tempDir, + controller.signal, + ); + controller.abort(reason); + errorHandler?.(reason); + + await expect(download).rejects.toBe(reason); + }); + it('should reject oversized archive URL downloads', async () => { let dataHandler: ((chunk: Buffer) => void) | undefined; const response = { @@ -1111,6 +1538,59 @@ describe('git extension helpers', () => { }); }); + it('should reject same-host scheme downgrade redirects before sending a token', async () => { + const originalToken = process.env['GITHUB_TOKEN']; + process.env['GITHUB_TOKEN'] = 'secret-token'; + mockHttpsGet + .mockImplementationOnce(((_url, options, callback) => { + const response = createResponse( + JSON.stringify({ + assets: [ + { + name: 'extension.zip', + browser_download_url: + 'https://github.com/owner/repo/releases/download/v1.0.0/extension.zip', + }, + ], + tag_name: 'v1.0.0', + }), + ); + callResponseCallback(options, callback, response); + return createRequestMock(); + }) as typeof https.get) + .mockImplementationOnce(((_url, options, callback) => { + const response = createResponse(undefined, 302, { + location: + 'http://github.com/owner/repo/releases/download/v1.0.0/extension.zip', + }); + callResponseCallback(options, callback, response); + return createRequestMock(); + }) as typeof https.get); + + try { + await expect( + downloadFromGitHubRelease( + { source: 'owner/repo', type: 'github-release' }, + tempDir, + ), + ).rejects.toThrow('Unsupported download URL protocol: http:'); + } finally { + if (originalToken === undefined) { + delete process.env['GITHUB_TOKEN']; + } else { + process.env['GITHUB_TOKEN'] = originalToken; + } + } + + expect(mockHttpsGet).toHaveBeenCalledTimes(2); + const originalDownloadOptions = mockHttpsGet.mock.calls[1][1] as + | https.RequestOptions + | undefined; + expect(originalDownloadOptions?.headers).toMatchObject({ + Authorization: 'token secret-token', + }); + }); + it('should stop following redirect loops', async () => { mockHttpsGet.mockImplementation((( _url: string | URL | https.RequestOptions, @@ -1585,6 +2065,40 @@ describe('git extension helpers', () => { describe('extractFile', () => { let tempDir: string; + async function getFileSize(filePath: string): Promise { + try { + return (await fs.stat(filePath)).size; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 0; + throw error; + } + } + + async function waitForFileData(filePath: string): Promise { + for (let attempt = 0; attempt < 1_000; attempt += 1) { + if ((await getFileSize(filePath)) > 0) return; + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error(`Timed out waiting for extracted data at ${filePath}`); + } + + async function waitForStableFileSize(filePath: string): Promise { + let previousSize = -1; + let stableChecks = 0; + for (let attempt = 0; attempt < 100; attempt += 1) { + await new Promise((resolve) => setImmediate(resolve)); + const size = await getFileSize(filePath); + if (size === previousSize) { + stableChecks += 1; + if (stableChecks === 3) return size; + } else { + previousSize = size; + stableChecks = 0; + } + } + throw new Error(`Extracted data did not stop changing at ${filePath}`); + } + beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-')); }); @@ -1619,6 +2133,54 @@ describe('git extension helpers', () => { expect(content).toBe('hello tar'); }); + it('should cancel while scanning a tar archive', async () => { + const archivePath = path.join(tempDir, 'scan-cancel.tar.gz'); + const sourcePath = path.join(tempDir, 'large.bin'); + await fs.writeFile(sourcePath, randomBytes(16 * 1024 * 1024)); + await tar.c({ gzip: true, file: archivePath, cwd: tempDir }, [ + 'large.bin', + ]); + + const controller = new AbortController(); + const abortReason = new Error('cancel tar scan'); + const scan = assertTarArchiveHasNoLinks(archivePath, controller.signal); + setImmediate(() => controller.abort(abortReason)); + await expect(scan).rejects.toBe(abortReason); + }); + + it('should cancel while extracting a tar archive', async () => { + const archivePath = path.join(tempDir, 'extract-cancel.tar.gz'); + const extractionDest = path.join(tempDir, 'extracted'); + const sourcePath = path.join(tempDir, 'large.bin'); + const extractedFilePath = path.join(extractionDest, 'large.bin'); + const content = randomBytes(32 * 1024 * 1024); + await fs.mkdir(extractionDest); + await fs.writeFile(sourcePath, content); + await tar.c({ gzip: true, file: archivePath, cwd: tempDir }, [ + 'large.bin', + ]); + + const controller = new AbortController(); + const abortReason = new Error('cancel tar extraction'); + const extraction = extractFile( + archivePath, + extractionDest, + controller.signal, + ); + try { + await waitForFileData(extractedFilePath); + } catch (error) { + controller.abort(error); + await extraction.catch(() => undefined); + throw error; + } + controller.abort(abortReason); + await expect(extraction).rejects.toBe(abortReason); + expect(await waitForStableFileSize(extractedFilePath)).toBeLessThan( + content.length, + ); + }); + it.skipIf(process.platform === 'win32')( 'should reject symlink entries in tar archives', async () => { @@ -1680,6 +2242,45 @@ describe('git extension helpers', () => { expect(content).toBe('hello zip'); }); + it('should cancel while extracting a zip archive', async () => { + const archivePath = path.join(tempDir, 'extract-cancel.zip'); + const extractionDest = path.join(tempDir, 'extracted'); + const extractedFilePath = path.join(extractionDest, 'large.bin'); + const content = Buffer.alloc(64 * 1024 * 1024, 0x61); + await fs.mkdir(extractionDest); + + const output = fsSync.createWriteStream(archivePath); + const archive = archiver.create('zip'); + const streamFinished = new Promise((resolve, reject) => { + output.on('close', () => resolve(null)); + archive.on('error', reject); + }); + archive.pipe(output); + archive.append(content, { name: 'large.bin' }); + await archive.finalize(); + await streamFinished; + + const controller = new AbortController(); + const abortReason = new Error('cancel zip extraction'); + const extraction = extractFile( + archivePath, + extractionDest, + controller.signal, + ); + try { + await waitForFileData(extractedFilePath); + } catch (error) { + controller.abort(error); + await extraction.catch(() => undefined); + throw error; + } + controller.abort(abortReason); + await expect(extraction).rejects.toBe(abortReason); + expect(await waitForStableFileSize(extractedFilePath)).toBeLessThan( + content.length, + ); + }); + it('should reject symlink entries in zip archives', async () => { const archivePath = path.join(tempDir, 'symlink.zip'); const extractionDest = path.join(tempDir, 'extracted'); @@ -1706,6 +2307,36 @@ describe('git extension helpers', () => { ).rejects.toThrow(); }); + it.skipIf(process.platform === 'win32')( + 'should reject zip extraction through an existing symlink', + async () => { + const archivePath = path.join(tempDir, 'existing-symlink.zip'); + const extractionDest = path.join(tempDir, 'extracted'); + const outsideDir = path.join(tempDir, 'outside'); + await fs.mkdir(extractionDest); + await fs.mkdir(outsideDir); + await fs.symlink(outsideDir, path.join(extractionDest, 'escape')); + + const output = fsSync.createWriteStream(archivePath); + const archive = archiver.create('zip'); + const streamFinished = new Promise((resolve, reject) => { + output.on('close', () => resolve(null)); + archive.on('error', reject); + }); + archive.pipe(output); + archive.append('outside write', { name: 'escape/file.txt' }); + await archive.finalize(); + await streamFinished; + + await expect(extractFile(archivePath, extractionDest)).rejects.toThrow( + 'Refusing to extract through non-directory path', + ); + await expect( + fs.lstat(path.join(outsideDir, 'file.txt')), + ).rejects.toThrow(); + }, + ); + it('should throw an error for unsupported file types', async () => { const unsupportedFilePath = path.join(tempDir, 'test.txt'); await fs.writeFile(unsupportedFilePath, 'some content'); diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 096320639ea..2ec1d5c021a 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -4,14 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { simpleGit } from 'simple-git'; +import { simpleGit, type SimpleGit } from 'simple-git'; import { getErrorMessage } from '../utils/errors.js'; import * as os from 'node:os'; import * as https from 'node:https'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { pipeline } from 'node:stream/promises'; import * as tar from 'tar'; -import extract from 'extract-zip'; import { createDebugLogger } from '../utils/debugLogger.js'; import { ExtensionUpdateState, @@ -26,13 +26,15 @@ import { convertGeminiOrClaudeExtension, SUPPORTED_EXTENSION_MANIFESTS, } from './extension-converter.js'; +import { assertTarArchiveHasNoLinks } from './archive-safety.js'; +import { resolveNetworkTarget } from './network-policy.js'; +import { extractZipArchive } from './zip-extraction.js'; const debugLogger = createDebugLogger('EXT_GITHUB'); const SUPPORTED_ARCHIVE_EXTENSIONS = ['.tar.gz', '.zip'] as const; -const ZIP_FILE_TYPE_MASK = 0xf000; -const ZIP_SYMBOLIC_LINK_TYPE = 0xa000; const ARCHIVE_DOWNLOAD_TIMEOUT_MS = 120_000; const ARCHIVE_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024; +const MINIMUM_PINNED_GIT_VERSION = { major: 2, minor: 37 } as const; interface GithubReleaseData { assets: Asset[]; @@ -106,6 +108,52 @@ function getGitHubToken(): string | undefined { return process.env['GITHUB_TOKEN']; } +async function assertPinnedGitSupported(): Promise { + const version = await simpleGit().version(); + if ( + version.major < MINIMUM_PINNED_GIT_VERSION.major || + (version.major === MINIMUM_PINNED_GIT_VERSION.major && + version.minor < MINIMUM_PINNED_GIT_VERSION.minor) + ) { + throw new Error('Public extension Git installs require Git 2.37 or newer.'); + } +} + +function createPinnedGitConfig(curlResolve: string): string[] { + return [ + `http.curloptResolve=${curlResolve}`, + 'http.followRedirects=false', + 'http.proxy=', + 'protocol.allow=never', + 'protocol.https.allow=always', + ]; +} + +function restrictGitEnvironment( + git: SimpleGit, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], +): SimpleGit { + if (networkPolicy !== 'public') return git; + const environment: Record = { + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: os.devNull, + }; + for (const key of [ + 'PATH', + 'Path', + 'SystemRoot', + 'SYSTEMROOT', + 'WINDIR', + 'TEMP', + 'TMP', + 'TMPDIR', + ]) { + const value = process.env[key]; + if (value !== undefined) environment[key] = value; + } + return git.env(environment); +} + /** * Clones a Git repository to a specified local path. * @param installMetadata The metadata for the extension to install. @@ -114,10 +162,33 @@ function getGitHubToken(): string | undefined { export async function cloneFromGit( installMetadata: ExtensionInstallMetadata, destination: string, + signal?: AbortSignal, ): Promise { const redactedSource = redactUrlCredentials(installMetadata.source); try { - const git = simpleGit(destination); + let networkConfig: string[] = []; + if (installMetadata.networkPolicy === 'public') { + if (!/^https:/i.test(installMetadata.source)) { + throw new Error('Public extension Git installs must use HTTPS.'); + } + await assertPinnedGitSupported(); + const networkTarget = await resolveNetworkTarget( + installMetadata.source, + installMetadata.networkPolicy, + signal, + ); + networkConfig = networkTarget.curlResolve + ? createPinnedGitConfig(networkTarget.curlResolve) + : []; + } + const git = restrictGitEnvironment( + simpleGit(destination, { + ...(signal ? { abort: signal } : {}), + ...(networkConfig.length > 0 ? { config: networkConfig } : {}), + }), + installMetadata.networkPolicy, + ); + signal?.throwIfAborted(); let sourceUrl = installMetadata.source; const token = getGitHubToken(); if (token) { @@ -146,6 +217,7 @@ export async function cloneFromGit( '--depth', '1', ]); + signal?.throwIfAborted(); const remotes = await git.getRemotes(true); if (remotes.length === 0) { @@ -154,11 +226,24 @@ export async function cloneFromGit( const refToFetch = installMetadata.ref || 'HEAD'; - await git.fetch(remotes[0].name, refToFetch); + const remoteUrl = remotes[0].refs.fetch; + if (!remoteUrl) { + throw new Error(`Unable to find a fetch URL for repo ${redactedSource}`); + } + await git.fetch(remoteUrl, refToFetch); + signal?.throwIfAborted(); // Detached HEAD is expected here — we only need the fetched content. await git.checkout('FETCH_HEAD'); + signal?.throwIfAborted(); } catch (error) { + if ( + signal?.aborted && + (error === signal.reason || + (error instanceof Error && error.name === 'AbortError')) + ) { + signal.throwIfAborted(); + } const redactedErrorMessage = redactUrlCredentials(getErrorMessage(error)); throw new Error( `Failed to clone Git repository from ${redactedSource} ${redactedErrorMessage}`, @@ -201,16 +286,20 @@ async function fetchReleaseFromGithub( owner: string, repo: string, ref?: string, + signal?: AbortSignal, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], ): Promise { const endpoint = ref ? `releases/tags/${ref}` : 'releases/latest'; const url = `https://api.github.com/repos/${owner}/${repo}/${endpoint}`; - return await fetchJson(url); + return await fetchJson(url, signal, networkPolicy); } export async function checkForExtensionUpdate( extension: Extension, extensionManager: ExtensionManager, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const installMetadata = extension.installMetadata; if (installMetadata?.type === 'local') { let latestConfig: ExtensionConfig | undefined; @@ -222,20 +311,26 @@ export async function checkForExtensionUpdate( tempDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'extension-archive-update-'), ); - await extractArchiveFile(installMetadata.source, tempDir); + signal?.throwIfAborted(); + await extractArchiveFile(installMetadata.source, tempDir, signal); + signal?.throwIfAborted(); const converted = await convertGeminiOrClaudeExtension( tempDir, installMetadata.pluginName, + installMetadata.networkPolicy, + signal, ); extensionDir = converted.extensionDir; if (extensionDir !== tempDir) { convertedDir = extensionDir; } + signal?.throwIfAborted(); } latestConfig = extensionManager.loadExtensionConfig({ extensionDir, }); } catch (e) { + signal?.throwIfAborted(); debugLogger.error( `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${redactUrlCredentials(installMetadata.source)}. Error: ${redactUrlCredentials(getErrorMessage(e))}`, ); @@ -261,7 +356,7 @@ export async function checkForExtensionUpdate( return ExtensionUpdateState.UP_TO_DATE; } if (installMetadata?.type === 'npm') { - return checkNpmUpdate(installMetadata); + return checkNpmUpdate(installMetadata, signal); } if (installMetadata?.type === 'archive-url') { let tempDir: string | undefined; @@ -270,10 +365,12 @@ export async function checkForExtensionUpdate( tempDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'extension-archive-update-'), ); - await downloadFromArchiveUrl(installMetadata, tempDir); + await downloadFromArchiveUrl(installMetadata, tempDir, signal); const converted = await convertGeminiOrClaudeExtension( tempDir, installMetadata.pluginName, + installMetadata.networkPolicy, + signal, ); const extensionDir = converted.extensionDir; if (extensionDir !== tempDir) { @@ -293,6 +390,7 @@ export async function checkForExtensionUpdate( } return ExtensionUpdateState.UP_TO_DATE; } catch (error) { + signal?.throwIfAborted(); debugLogger.error( `Failed to check for update for archive URL extension "${extension.name}" from ${redactUrlCredentials(installMetadata.source)}: ${redactUrlCredentials(getErrorMessage(error))}`, ); @@ -316,8 +414,15 @@ export async function checkForExtensionUpdate( } try { if (installMetadata.type === 'git') { - const git = simpleGit(extension.path); - const remotes = await git.getRemotes(true); + if (installMetadata.networkPolicy === 'public') { + await assertPinnedGitSupported(); + } + const localGit = simpleGit( + extension.path, + signal ? { abort: signal } : undefined, + ); + const remotes = await localGit.getRemotes(true); + signal?.throwIfAborted(); if (remotes.length === 0) { debugLogger.error('No git remotes found.'); return ExtensionUpdateState.ERROR; @@ -329,10 +434,32 @@ export async function checkForExtensionUpdate( ); return ExtensionUpdateState.ERROR; } - + let networkConfig: string[] = []; + if (installMetadata.networkPolicy === 'public') { + const parsedRemote = new URL(remoteUrl); + parsedRemote.username = ''; + parsedRemote.password = ''; + const remoteTarget = await resolveNetworkTarget( + parsedRemote, + installMetadata.networkPolicy, + signal, + ); + networkConfig = remoteTarget.curlResolve + ? createPinnedGitConfig(remoteTarget.curlResolve) + : []; + } + signal?.throwIfAborted(); + const git = restrictGitEnvironment( + simpleGit(extension.path, { + ...(signal ? { abort: signal } : {}), + ...(networkConfig.length > 0 ? { config: networkConfig } : {}), + }), + installMetadata.networkPolicy, + ); const refToCheck = installMetadata.ref || 'HEAD'; const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); + signal?.throwIfAborted(); if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { debugLogger.error(`Git ref ${refToCheck} not found.`); @@ -341,6 +468,7 @@ export async function checkForExtensionUpdate( const remoteHash = lsRemoteOutput.split('\t')[0]; const localHash = await git.revparse(['HEAD']); + signal?.throwIfAborted(); if (!remoteHash) { debugLogger.error( @@ -364,6 +492,8 @@ export async function checkForExtensionUpdate( owner, repo, installMetadata.ref, + signal, + installMetadata.networkPolicy, ); if (releaseData.tag_name !== releaseTag) { return ExtensionUpdateState.UPDATE_AVAILABLE; @@ -371,6 +501,7 @@ export async function checkForExtensionUpdate( return ExtensionUpdateState.UP_TO_DATE; } } catch (error) { + signal?.throwIfAborted(); debugLogger.error( `Failed to check for updates for extension "${redactUrlCredentials(installMetadata.source)}": ${redactUrlCredentials(getErrorMessage(error))}`, ); @@ -381,11 +512,18 @@ export async function checkForExtensionUpdate( export async function downloadFromGitHubRelease( installMetadata: ExtensionInstallMetadata, destination: string, + signal?: AbortSignal, ): Promise { const { source, ref } = installMetadata; const { owner, repo } = parseGitHubRepoForReleases(source); - const releaseData = await fetchReleaseFromGithub(owner, repo, ref); + const releaseData = await fetchReleaseFromGithub( + owner, + repo, + ref, + signal, + installMetadata.networkPolicy, + ); if (!releaseData) { throw new Error(`No release data found for ${owner}/${repo} at tag ${ref}`); } @@ -421,16 +559,25 @@ export async function downloadFromGitHubRelease( } try { - await downloadFile(archiveUrl, downloadedAssetPath, { - includeGitHubToken: true, - }); + await downloadFile( + archiveUrl, + downloadedAssetPath, + { + includeGitHubToken: true, + networkPolicy: installMetadata.networkPolicy, + }, + 0, + signal, + ); } catch (error) { throw new Error( `Failed to download release from ${redactUrlCredentials(installMetadata.source)}: ${redactUrlCredentials(getErrorMessage(error))}`, ); } - await extractArchiveFile(downloadedAssetPath, destination); + signal?.throwIfAborted(); + await extractArchiveFile(downloadedAssetPath, destination, signal); + signal?.throwIfAborted(); await fs.promises.unlink(downloadedAssetPath); return { @@ -442,6 +589,7 @@ export async function downloadFromGitHubRelease( export async function downloadFromArchiveUrl( installMetadata: ExtensionInstallMetadata, destination: string, + signal?: AbortSignal, ): Promise { const archiveExtension = getSupportedArchiveExtension(installMetadata.source); if (!archiveExtension) { @@ -456,37 +604,52 @@ export async function downloadFromArchiveUrl( const downloadedAssetPath = path.join(destination, archiveName); try { - await downloadFile(installMetadata.source, downloadedAssetPath, { - includeGitHubToken: false, - }); + await downloadFile( + installMetadata.source, + downloadedAssetPath, + { + includeGitHubToken: false, + networkPolicy: installMetadata.networkPolicy, + }, + 0, + signal, + ); } catch (error) { + signal?.throwIfAborted(); throw new Error( `Failed to download archive from ${redactUrlCredentials(installMetadata.source)}: ${redactUrlCredentials(getErrorMessage(error))}`, ); } - await extractArchiveFile(downloadedAssetPath, destination); + signal?.throwIfAborted(); + await extractArchiveFile(downloadedAssetPath, destination, signal); + signal?.throwIfAborted(); await fs.promises.unlink(downloadedAssetPath); } export async function extractArchiveFile( archivePath: string, destination: string, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); if (!isSupportedArchivePath(archivePath)) { throw new Error( `Unsupported archive file for extension install: ${redactUrlCredentials(archivePath)}`, ); } try { - await extractFile(archivePath, destination); + await extractFile(archivePath, destination, signal); } catch (error) { + signal?.throwIfAborted(); throw new Error( 'Extension archive could not be extracted. Make sure it is a valid ' + `.zip or .tar.gz file. ${getErrorMessage(error)}`, ); } + signal?.throwIfAborted(); await flattenSingleExtensionDirectory(destination, archivePath); + signal?.throwIfAborted(); assertExtractedArchiveContainsExtensionSource(destination); } @@ -527,7 +690,21 @@ export function findReleaseAsset(assets: Asset[]): Asset | undefined { return undefined; } -async function fetchJson(url: string): Promise { +async function fetchJson( + url: string, + signal?: AbortSignal, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], +): Promise { + const timeoutError = new Error('Timed out fetching GitHub API response'); + const timeoutController = new AbortController(); + const hardDeadline = setTimeout( + () => timeoutController.abort(timeoutError), + ARCHIVE_DOWNLOAD_TIMEOUT_MS, + ); + hardDeadline.unref(); + const requestSignal = signal + ? AbortSignal.any([signal, timeoutController.signal]) + : timeoutController.signal; const headers: { 'User-Agent': string; Authorization?: string } = { 'User-Agent': 'gemini-cli', }; @@ -535,34 +712,102 @@ async function fetchJson(url: string): Promise { if (token) { headers.Authorization = `token ${token}`; } - return new Promise((resolve, reject) => { - https - .get(url, { headers }, (res) => { - if (res.statusCode !== 200) { - return reject( - new Error(`Request failed with status code ${res.statusCode}`), - ); - } - const chunks: Buffer[] = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - const data = Buffer.concat(chunks).toString(); - resolve(JSON.parse(data) as T); - }); - }) - .on('error', reject); + let target; + try { + target = networkPolicy + ? await resolveNetworkTarget(url, networkPolicy, requestSignal) + : { url: new URL(url) }; + requestSignal.throwIfAborted(); + } catch (error) { + clearTimeout(hardDeadline); + throw requestSignal.aborted ? requestSignal.reason : error; + } + return await new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + clearTimeout(hardDeadline); + requestSignal.removeEventListener('abort', onAbort); + }; + const finish = (value: T) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(requestSignal.aborted ? requestSignal.reason : error); + }; + let req: ReturnType | undefined; + const onAbort = () => { + req?.destroy(); + fail(requestSignal.reason); + }; + try { + req = https.get( + url, + { + headers, + signal: requestSignal, + lookup: target.lookup, + ...(target.lookup ? { agent: false } : {}), + }, + (res) => { + res.on('error', fail); + if (res.statusCode !== 200) { + res.resume(); + return fail( + new Error(`Request failed with status code ${res.statusCode}`), + ); + } + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + try { + finish(JSON.parse(Buffer.concat(chunks).toString()) as T); + } catch (error) { + fail(error); + } + }); + }, + ); + req.on('error', fail); + } catch (error) { + fail(error); + return; + } + requestSignal.addEventListener('abort', onAbort, { once: true }); + if (requestSignal.aborted) { + onAbort(); + } }); } async function downloadFile( url: string, dest: string, - options: { includeGitHubToken?: boolean } = { includeGitHubToken: false }, + options: { + includeGitHubToken?: boolean; + networkPolicy?: ExtensionInstallMetadata['networkPolicy']; + } = { includeGitHubToken: false }, redirectCount = 0, + signal?: AbortSignal, ): Promise { if (redirectCount > 10) { throw new Error('Too many redirects while downloading extension archive'); } + const timeoutError = new Error('Timed out downloading extension archive'); + const timeoutController = new AbortController(); + const hardDeadline = setTimeout( + () => timeoutController.abort(timeoutError), + ARCHIVE_DOWNLOAD_TIMEOUT_MS, + ); + hardDeadline.unref(); + const requestSignal = signal + ? AbortSignal.any([signal, timeoutController.signal]) + : timeoutController.signal; const headers: { 'User-agent': string; Authorization?: string } = { 'User-agent': 'gemini-cli', }; @@ -570,18 +815,26 @@ async function downloadFile( if (options.includeGitHubToken === true && token) { headers.Authorization = `token ${token}`; } - const parsedUrl = new URL(url); + let target; + try { + target = options.networkPolicy + ? await resolveNetworkTarget(url, options.networkPolicy, requestSignal) + : { url: new URL(url) }; + requestSignal.throwIfAborted(); + } catch (error) { + clearTimeout(hardDeadline); + throw requestSignal.aborted ? requestSignal.reason : error; + } + const parsedUrl = target.url; if (parsedUrl.protocol !== 'https:') { + clearTimeout(hardDeadline); throw new Error(`Unsupported download URL protocol: ${parsedUrl.protocol}`); } return new Promise((resolve, reject) => { let settled = false; - let hardDeadline: NodeJS.Timeout | undefined; const cleanup = () => { - if (hardDeadline) { - clearTimeout(hardDeadline); - hardDeadline = undefined; - } + clearTimeout(hardDeadline); + requestSignal.removeEventListener('abort', onAbort); }; const finish = () => { if (settled) { @@ -591,142 +844,136 @@ async function downloadFile( cleanup(); resolve(); }; - const fail = (error: Error) => { + const fail = (error: unknown) => { if (settled) { return; } settled = true; cleanup(); - reject(error); + reject(requestSignal.aborted ? requestSignal.reason : error); + }; + const onAbort = () => { + req.destroy(); + fail(requestSignal.reason); }; const req = https - .get(parsedUrl, { headers }, (res) => { - if ( - res.statusCode === 301 || - res.statusCode === 302 || - res.statusCode === 307 || - res.statusCode === 308 - ) { - if (!res.headers.location) { + .get( + url, + { + headers, + signal: requestSignal, + lookup: target.lookup, + ...(target.lookup ? { agent: false } : {}), + }, + (res) => { + if ( + res.statusCode === 301 || + res.statusCode === 302 || + res.statusCode === 307 || + res.statusCode === 308 + ) { + if (!res.headers.location) { + res.resume(); + fail(new Error('Redirect response missing location header')); + return; + } res.resume(); - fail(new Error('Redirect response missing location header')); - return; - } - res.resume(); - let redirectUrl: URL; - try { - redirectUrl = new URL(res.headers.location, url); - } catch (error) { - fail(new Error(`Invalid redirect URL: ${getErrorMessage(error)}`)); + let redirectUrl: URL; + try { + redirectUrl = new URL(res.headers.location, url); + } catch (error) { + fail( + new Error(`Invalid redirect URL: ${getErrorMessage(error)}`), + ); + return; + } + const redirectHost = redirectUrl.host; + const redirectOptions = + redirectHost === parsedUrl.host + ? options + : { ...options, includeGitHubToken: false }; + cleanup(); + downloadFile( + redirectUrl.toString(), + dest, + redirectOptions, + redirectCount + 1, + signal, + ) + .then(finish) + .catch(fail); return; } - const redirectHost = redirectUrl.host; - const redirectOptions = - redirectHost === parsedUrl.host - ? options - : { ...options, includeGitHubToken: false }; - cleanup(); - downloadFile( - redirectUrl.toString(), - dest, - redirectOptions, - redirectCount + 1, - ) - .then(finish) - .catch(fail); - return; - } - if (res.statusCode !== 200) { - res.resume(); - return fail( - new Error(`Request failed with status code ${res.statusCode}`), - ); - } - const file = fs.createWriteStream(dest); - let bytesWritten = 0; - res.on('data', (chunk: Buffer) => { - bytesWritten += chunk.length; - if (bytesWritten > ARCHIVE_DOWNLOAD_MAX_BYTES) { - res.destroy(); - file.destroy(); - fail( - new Error( - `Extension archive download exceeded maximum size of ${ARCHIVE_DOWNLOAD_MAX_BYTES} bytes`, - ), + if (res.statusCode !== 200) { + res.resume(); + return fail( + new Error(`Request failed with status code ${res.statusCode}`), ); } - }); - res.on('error', (error) => { - file.destroy(); - fail(error); - }); - file.on('error', (error) => { - res.destroy(); - fail(error); - }); - res.pipe(file); - file.on('finish', () => file.close(finish)); - }) + const file = fs.createWriteStream(dest); + let bytesWritten = 0; + res.on('data', (chunk: Buffer) => { + bytesWritten += chunk.length; + if (bytesWritten > ARCHIVE_DOWNLOAD_MAX_BYTES) { + res.destroy(); + file.destroy(); + fail( + new Error( + `Extension archive download exceeded maximum size of ${ARCHIVE_DOWNLOAD_MAX_BYTES} bytes`, + ), + ); + } + }); + res.on('error', (error) => { + file.destroy(); + fail(error); + }); + file.on('error', (error) => { + res.destroy(); + fail(error); + }); + res.pipe(file); + file.on('finish', () => file.close(finish)); + }, + ) .on('error', fail); if (!settled) { - hardDeadline = setTimeout(() => { - req.destroy(); - fail(new Error('Timed out downloading extension archive')); - }, ARCHIVE_DOWNLOAD_TIMEOUT_MS); - req.setTimeout(ARCHIVE_DOWNLOAD_TIMEOUT_MS, () => { - req.destroy(); - fail(new Error('Timed out downloading extension archive')); - }); + requestSignal.addEventListener('abort', onAbort, { once: true }); + if (requestSignal.aborted) { + onAbort(); + } else { + req.setTimeout(ARCHIVE_DOWNLOAD_TIMEOUT_MS, () => { + req.destroy(); + fail(timeoutError); + }); + } } }); } -export async function extractFile(file: string, dest: string): Promise { +export async function extractFile( + file: string, + dest: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); if (file.endsWith('.tar.gz')) { - await assertTarArchiveHasNoLinks(file); - await tar.x({ - file, - cwd: dest, - }); + await assertTarArchiveHasNoLinks(file, signal); + signal?.throwIfAborted(); + try { + await pipeline(fs.createReadStream(file), tar.x({ cwd: dest }), { + signal, + }); + } catch (error) { + signal?.throwIfAborted(); + throw error; + } } else if (file.endsWith('.zip')) { - await extract(file, { - dir: dest, - onEntry: (entry) => { - if (isZipSymlinkEntry(entry.externalFileAttributes)) { - throw new Error( - `Zip archive contains unsupported symbolic link entry: ${entry.fileName}`, - ); - } - }, - }); + await extractZipArchive(file, dest, signal); } else { throw new Error(`Unsupported file extension for extraction: ${file}`); } -} - -async function assertTarArchiveHasNoLinks(file: string): Promise { - let unsupportedLinkPath: string | undefined; - await tar.t({ - file, - onReadEntry: (entry) => { - if ( - !unsupportedLinkPath && - (entry.type === 'SymbolicLink' || entry.type === 'Link') - ) { - unsupportedLinkPath = entry.path; - } - }, - }); - if (unsupportedLinkPath) { - throw new Error( - `Tar archive contains unsupported link entry: ${unsupportedLinkPath}`, - ); - } -} - -function isZipSymlinkEntry(externalFileAttributes: number): boolean { - const mode = externalFileAttributes >>> 16; - return (mode & ZIP_FILE_TYPE_MASK) === ZIP_SYMBOLIC_LINK_TYPE; + signal?.throwIfAborted(); } async function flattenSingleExtensionDirectory( diff --git a/packages/core/src/extension/index.ts b/packages/core/src/extension/index.ts index 1b84990e2ea..96ca90d34e8 100644 --- a/packages/core/src/extension/index.ts +++ b/packages/core/src/extension/index.ts @@ -9,3 +9,4 @@ export * from './extensionPreferences.js'; export * from './npm.js'; export * from './claude-converter.js'; export * from './redaction.js'; +export * from './extension-store.js'; diff --git a/packages/core/src/extension/marketplace.test.ts b/packages/core/src/extension/marketplace.test.ts index 40f7cbb1c53..9be144c6fda 100644 --- a/packages/core/src/extension/marketplace.test.ts +++ b/packages/core/src/extension/marketplace.test.ts @@ -12,6 +12,7 @@ import { import * as fs from 'node:fs/promises'; import * as http from 'node:http'; import * as https from 'node:https'; +import { promises as dns } from 'node:dns'; // Mock dependencies vi.mock('node:fs/promises', () => ({ @@ -431,7 +432,10 @@ describe('parseInstallSource', () => { expect(result).toEqual(cfg); expect(http.get).toHaveBeenCalledWith( 'http://example.com/marketplace.json', - { headers: { 'User-Agent': 'qwen-code' } }, + { + headers: { 'User-Agent': 'qwen-code' }, + signal: expect.any(AbortSignal), + }, expect.any(Function), ); expect(https.get).not.toHaveBeenCalled(); @@ -629,5 +633,26 @@ describe('parseInstallSource', () => { vi.useRealTimers(); } }); + + it('does not start a request when DNS outlives the deadline', async () => { + vi.useFakeTimers(); + try { + vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT')); + vi.spyOn(dns, 'lookup').mockImplementation( + () => new Promise(() => undefined), + ); + + const promise = loadMarketplaceConfigFromSource( + 'https://packages.example/marketplace.json', + 'public', + ); + await vi.advanceTimersByTimeAsync(10_000); + + await expect(promise).resolves.toBeNull(); + expect(https.get).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); }); }); diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index 026403a2607..5444b18090f 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -15,6 +15,11 @@ import { isSupportedArchiveUrl, parseGitHubRepoForReleases } from './github.js'; import { isScopedNpmPackage } from './npm.js'; import { redactUrlCredentials } from './redaction.js'; import { clientForUrl } from './http-client.js'; +import { resolveNetworkTarget } from './network-policy.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { getErrorMessage } from '../utils/errors.js'; + +const debugLogger = createDebugLogger('EXT_MARKETPLACE'); export interface MarketplaceInstallOptions { marketplaceUrl: string; @@ -137,34 +142,59 @@ const MARKETPLACE_MAX_BODY_BYTES = 10 * 1024 * 1024; * oversized body so a slow/unreachable/hostile marketplace can never hang * discovery indefinitely or exhaust process memory. */ -function fetchUrl( +async function fetchUrl( url: string, headers: Record, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], ): Promise { + const deadlineController = new AbortController(); + let req: ClientRequest | undefined; + let finish: ((value: string | null) => void) | undefined; + // `req.setTimeout` only fires on socket inactivity and resets on every + // chunk, so the absolute deadline must start before DNS resolution. + const hardDeadline = setTimeout(() => { + deadlineController.abort( + new Error( + `Marketplace request timed out after ${MARKETPLACE_FETCH_TIMEOUT_MS}ms`, + ), + ); + req?.destroy(); + finish?.(null); + }, MARKETPLACE_FETCH_TIMEOUT_MS); + hardDeadline.unref(); + let target; + try { + target = await resolveNetworkTarget( + url, + networkPolicy, + deadlineController.signal, + ); + deadlineController.signal.throwIfAborted(); + } catch (error) { + clearTimeout(hardDeadline); + debugLogger.debug( + `Failed to resolve marketplace network target: ${redactUrlCredentials(getErrorMessage(error))}`, + ); + return null; + } return new Promise((resolve) => { let client: ReturnType; try { - client = clientForUrl(url); + client = clientForUrl(target.url.toString()); } catch { + clearTimeout(hardDeadline); resolve(null); return; } let settled = false; - let req: ClientRequest | undefined; const done = (value: string | null) => { if (settled) return; settled = true; clearTimeout(hardDeadline); resolve(value); }; - // `req.setTimeout` only fires on socket inactivity and resets on every - // chunk, so a server trickling bytes can keep the request alive forever. - // Pair it with an absolute wall-clock deadline. - const hardDeadline = setTimeout(() => { - req?.destroy(); - done(null); - }, MARKETPLACE_FETCH_TIMEOUT_MS); + finish = done; const onResponse = (res: IncomingMessage) => { if (res.statusCode !== 200) { @@ -188,7 +218,17 @@ function fetchUrl( }; try { - req = client.get(url, { headers }, onResponse); + req = client.get( + url, + { + headers, + signal: deadlineController.signal, + ...(target.lookup + ? { lookup: target.lookup, agent: false as const } + : {}), + }, + onResponse, + ); } catch { done(null); return; @@ -209,6 +249,7 @@ function fetchUrl( async function fetchGitHubMarketplaceConfig( owner: string, repo: string, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], ): Promise { const token = process.env['GITHUB_TOKEN']; @@ -222,7 +263,7 @@ async function fetchGitHubMarketplaceConfig( apiHeaders['Authorization'] = `token ${token}`; } - let content = await fetchUrl(apiUrl, apiHeaders); + let content = await fetchUrl(apiUrl, apiHeaders, networkPolicy); // Fallback: raw.githubusercontent.com (no rate limit, public repos only) if (!content) { @@ -230,7 +271,7 @@ async function fetchGitHubMarketplaceConfig( const rawHeaders: Record = { 'User-Agent': 'qwen-code', }; - content = await fetchUrl(rawUrl, rawHeaders); + content = await fetchUrl(rawUrl, rawHeaders, networkPolicy); } if (!content) { @@ -278,6 +319,7 @@ async function readLocalMarketplaceConfig( */ export async function loadMarketplaceConfigFromSource( source: string, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], ): Promise { const trimmed = source.trim(); const lowerTrimmed = trimmed.toLowerCase(); @@ -308,14 +350,22 @@ export async function loadMarketplaceConfigFromSource( ) { try { const { owner, repo } = parseGitHubRepoForReleases(trimmed); - const ghConfig = await fetchGitHubMarketplaceConfig(owner, repo); + const ghConfig = await fetchGitHubMarketplaceConfig( + owner, + repo, + networkPolicy, + ); if (ghConfig) { return ghConfig; } } catch { // Not a github.com repo URL — fall through to direct-JSON fetch. } - const content = await fetchUrl(trimmed, { 'User-Agent': 'qwen-code' }); + const content = await fetchUrl( + trimmed, + { 'User-Agent': 'qwen-code' }, + networkPolicy, + ); if (!content) { return null; } @@ -334,11 +384,15 @@ export async function loadMarketplaceConfigFromSource( /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i, ); if (sshMatch) { - return fetchGitHubMarketplaceConfig(sshMatch[1], sshMatch[2]); + return fetchGitHubMarketplaceConfig( + sshMatch[1], + sshMatch[2], + networkPolicy, + ); } try { const { owner, repo } = parseGitHubRepoForReleases(trimmed); - return await fetchGitHubMarketplaceConfig(owner, repo); + return await fetchGitHubMarketplaceConfig(owner, repo, networkPolicy); } catch { return null; } @@ -347,7 +401,7 @@ export async function loadMarketplaceConfigFromSource( // Priority 4: owner/repo shorthand. if (isOwnerRepoFormat(trimmed)) { const [owner, repo] = trimmed.split('/'); - return await fetchGitHubMarketplaceConfig(owner, repo); + return await fetchGitHubMarketplaceConfig(owner, repo, networkPolicy); } return null; @@ -355,6 +409,9 @@ export async function loadMarketplaceConfigFromSource( export async function parseInstallSource( source: string, + options: { + networkPolicy?: ExtensionInstallMetadata['networkPolicy']; + } = {}, ): Promise { // Step 1: Parse source into repo and optional pluginName const { repo, pluginName } = parseSourceAndPluginName(source); @@ -400,7 +457,11 @@ export async function parseInstallSource( // Try to fetch marketplace config from GitHub try { const { owner, repo: repoName } = parseGitHubRepoForReleases(repoSource); - marketplaceConfig = await fetchGitHubMarketplaceConfig(owner, repoName); + marketplaceConfig = await fetchGitHubMarketplaceConfig( + owner, + repoName, + options.networkPolicy, + ); } catch { // Not a valid GitHub URL or failed to fetch, continue without marketplace config } @@ -423,7 +484,11 @@ export async function parseInstallSource( // Try to fetch marketplace config from GitHub try { const [owner, repoName] = repo.split('/'); - marketplaceConfig = await fetchGitHubMarketplaceConfig(owner, repoName); + marketplaceConfig = await fetchGitHubMarketplaceConfig( + owner, + repoName, + options.networkPolicy, + ); } catch { // Not a valid GitHub URL or failed to fetch, continue without marketplace config } @@ -438,5 +503,9 @@ export async function parseInstallSource( installMetadata.originSource = 'Claude'; } + if (options.networkPolicy) { + installMetadata.networkPolicy = options.networkPolicy; + } + return installMetadata; } diff --git a/packages/core/src/extension/network-policy.test.ts b/packages/core/src/extension/network-policy.test.ts new file mode 100644 index 00000000000..284c5dfbf3e --- /dev/null +++ b/packages/core/src/extension/network-policy.test.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as dns } from 'node:dns'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { resolveNetworkTarget } from './network-policy.js'; + +describe('resolveNetworkTarget', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('leaves unrestricted targets unchanged', async () => { + const target = await resolveNetworkTarget('http://127.0.0.1/archive'); + + expect(target.url.toString()).toBe('http://127.0.0.1/archive'); + expect(target.lookup).toBeUndefined(); + }); + + it('requires credential-free HTTPS for public targets', async () => { + await expect( + resolveNetworkTarget('http://example.com/archive', 'public'), + ).rejects.toThrow('must use HTTPS'); + await expect( + resolveNetworkTarget('https://user:secret@example.com/archive', 'public'), + ).rejects.toThrow('must not use credentials'); + }); + + it.each([ + 'https://127.0.0.1/archive', + 'https://169.254.169.254/latest/meta-data', + 'https://[::1]/archive', + 'https://[::ffff:7f00:1]/archive', + 'https://[fec0::1]/archive', + 'https://[2001::1]/archive', + 'https://[3fff::1]/archive', + 'https://[3fff:fff:ffff:ffff:ffff:ffff:ffff:ffff]/archive', + 'https://[fc00::1]/archive', + ])('rejects blocked literal address %s', async (url) => { + await expect(resolveNetworkTarget(url, 'public')).rejects.toThrow( + 'resolved to a blocked address', + ); + }); + + it('rejects a DNS answer set containing a private address', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValue([ + { address: '8.8.8.8', family: 4 }, + { address: '10.0.0.1', family: 4 }, + ] as never); + + await expect( + resolveNetworkTarget('https://packages.example/archive', 'public'), + ).rejects.toThrow('resolved to a blocked address'); + }); + + it('pins the validated address for the connection', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValue([ + { address: '8.8.8.8', family: 4 }, + ] as never); + + const target = await resolveNetworkTarget( + 'https://packages.example:8443/archive', + 'public', + ); + const callback = vi.fn(); + target.lookup?.('packages.example', { family: 0 }, callback); + + expect(callback).toHaveBeenCalledWith(null, '8.8.8.8', 4); + expect(target.curlResolve).toBe('packages.example:8443:8.8.8.8'); + }); + + it('stops waiting for DNS when the caller aborts', async () => { + vi.spyOn(dns, 'lookup').mockImplementation( + () => new Promise(() => undefined), + ); + const controller = new AbortController(); + const reason = new Error('resolution cancelled'); + + const target = resolveNetworkTarget( + 'https://packages.example/archive', + 'public', + controller.signal, + ); + controller.abort(reason); + + await expect(target).rejects.toBe(reason); + }); + + it('allows public IPv6 targets', async () => { + const target = await resolveNetworkTarget( + 'https://[2606:4700:4700::1111]/archive', + 'public', + ); + + expect(target.curlResolve).toBe( + '[2606:4700:4700::1111]:443:[2606:4700:4700::1111]', + ); + }); +}); diff --git a/packages/core/src/extension/network-policy.ts b/packages/core/src/extension/network-policy.ts new file mode 100644 index 00000000000..a6b8f248cdf --- /dev/null +++ b/packages/core/src/extension/network-policy.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as dns } from 'node:dns'; +import { BlockList, isIP, type LookupFunction } from 'node:net'; + +import type { ExtensionNetworkPolicy } from '../config/config.js'; + +const blockedAddresses = new BlockList(); +const publicIpv6Addresses = new BlockList(); +publicIpv6Addresses.addSubnet('2000::', 3, 'ipv6'); + +for (const [address, prefix] of [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.0.2.0', 24], + ['192.88.99.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['198.51.100.0', 24], + ['203.0.113.0', 24], + ['224.0.0.0', 4], + ['240.0.0.0', 4], +] as const) { + blockedAddresses.addSubnet(address, prefix, 'ipv4'); +} + +for (const [address, prefix] of [ + ['2001::', 23], + ['2001:db8::', 32], + ['2002::', 16], + ['3fff::', 20], +] as const) { + blockedAddresses.addSubnet(address, prefix, 'ipv6'); +} + +export interface ResolvedNetworkTarget { + url: URL; + lookup?: LookupFunction; + curlResolve?: string; +} + +async function waitForAbortable( + promise: Promise, + signal?: AbortSignal, +): Promise { + if (!signal) return await promise; + signal.throwIfAborted(); + return await new Promise((resolve, reject) => { + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + callback(); + }; + const onAbort = () => finish(() => reject(signal.reason)); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + return; + } + promise.then( + (value) => finish(() => resolve(value)), + (error: unknown) => finish(() => reject(error)), + ); + }); +} + +function stripIpv6Brackets(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; +} + +function parseMappedIpv4(address: string): string | undefined { + const suffix = address.toLowerCase().slice('::ffff:'.length); + if (isIP(suffix) === 4) return suffix; + + const parts = suffix.split(':'); + if ( + parts.length !== 2 || + parts.some((part) => !/^[\da-f]{1,4}$/.test(part)) + ) { + return undefined; + } + const upper = Number.parseInt(parts[0], 16); + const lower = Number.parseInt(parts[1], 16); + return `${upper >> 8}.${upper & 0xff}.${lower >> 8}.${lower & 0xff}`; +} + +function isBlockedAddress(address: string, family: number): boolean { + if (family === 6 && address.toLowerCase().startsWith('::ffff:')) { + const mapped = parseMappedIpv4(address); + return mapped === undefined || isBlockedAddress(mapped, 4); + } + if (family === 6 && !publicIpv6Addresses.check(address, 'ipv6')) return true; + return blockedAddresses.check(address, family === 6 ? 'ipv6' : 'ipv4'); +} + +export async function resolveNetworkTarget( + value: string | URL, + policy?: ExtensionNetworkPolicy, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const url = value instanceof URL ? value : new URL(value); + if (policy !== 'public') return { url }; + if (url.protocol !== 'https:') { + throw new Error('Public extension network requests must use HTTPS.'); + } + if (url.username || url.password) { + throw new Error( + 'Public extension network requests must not use credentials.', + ); + } + + const hostname = stripIpv6Brackets(url.hostname); + const literalFamily = isIP(hostname); + const addresses = literalFamily + ? [{ address: hostname, family: literalFamily }] + : await waitForAbortable( + dns.lookup(hostname, { all: true, verbatim: true }), + signal, + ); + if (addresses.length === 0) { + throw new Error(`Extension network host did not resolve: ${hostname}`); + } + if ( + addresses.some(({ address, family }) => isBlockedAddress(address, family)) + ) { + throw new Error( + `Extension network host resolved to a blocked address: ${hostname}`, + ); + } + + const selected = addresses[0]; + const lookup: LookupFunction = (requestedHostname, options, callback) => { + if (stripIpv6Brackets(requestedHostname) !== hostname) { + const error = new Error( + 'Pinned extension lookup hostname mismatch', + ) as NodeJS.ErrnoException; + error.code = 'ENOTFOUND'; + callback(error, '', 0); + return; + } + if (options.all) { + callback(null, [selected]); + } else { + callback(null, selected.address, selected.family); + } + }; + const port = url.port || '443'; + const curlHostname = literalFamily === 6 ? `[${hostname}]` : hostname; + const curlAddress = + selected.family === 6 ? `[${selected.address}]` : selected.address; + + return { + url, + lookup, + curlResolve: `${curlHostname}:${port}:${curlAddress}`, + }; +} diff --git a/packages/core/src/extension/npm.test.ts b/packages/core/src/extension/npm.test.ts index 8830fc3af6e..295a914e001 100644 --- a/packages/core/src/extension/npm.test.ts +++ b/packages/core/src/extension/npm.test.ts @@ -13,6 +13,7 @@ import { import type { ExtensionInstallMetadata } from '../config/config.js'; import { ExtensionUpdateState } from './extensionManager.js'; import * as fs from 'node:fs'; +import { promises as dns } from 'node:dns'; vi.mock('node:fs', () => ({ readFileSync: vi.fn(), @@ -167,6 +168,7 @@ vi.mock('node:http', () => ({ })); vi.mock('tar', () => ({ + t: vi.fn(), x: vi.fn(), })); @@ -198,19 +200,90 @@ function mockNpmRegistryResponse(data: object) { } function mockNpmRegistryStatus(statusCode: number) { + const response = { + statusCode, + headers: {}, + on: vi.fn(), + resume: vi.fn(), + }; vi.mocked(https.get).mockImplementation( (_url: unknown, _options: unknown, callback: unknown) => { - const mockRes = { - statusCode, - headers: {}, - on: vi.fn(), - }; if (typeof callback === 'function') { - callback(mockRes as never); + callback(response as never); } return { on: vi.fn() } as never; }, ); + return response; +} + +function npmMetadataResponse(tarballUrl: string) { + return { + statusCode: 200, + headers: {}, + on: vi.fn((event: string, handler: (data?: Buffer) => void) => { + if (event === 'data') { + handler( + Buffer.from( + JSON.stringify({ + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { dist: { tarball: tarballUrl } }, + }, + }), + ), + ); + } + if (event === 'end') handler(); + }), + }; +} + +function mockNpmDownload(tarballUrl: string, tarballBytes?: number) { + let requestCount = 0; + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + requestCount += 1; + const mockRes = + requestCount === 1 + ? { + statusCode: 200, + headers: {}, + on: vi.fn((event: string, handler: (data?: Buffer) => void) => { + if (event === 'data') { + handler( + Buffer.from( + JSON.stringify({ + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { dist: { tarball: tarballUrl } }, + }, + }), + ), + ); + } + if (event === 'end') handler(); + }), + } + : { + statusCode: 200, + headers: {}, + on: vi.fn((event: string, handler: (chunk: Buffer) => void) => { + if (event === 'data' && tarballBytes !== undefined) { + handler({ length: tarballBytes } as Buffer); + } + }), + pipe: vi.fn(), + destroy: vi.fn(), + }; + if (typeof callback === 'function') callback(mockRes as never); + return { + on: vi.fn().mockReturnThis(), + setTimeout: vi.fn(), + destroy: vi.fn(), + } as never; + }, + ); } describe('downloadFromNpmRegistry', () => { @@ -224,12 +297,55 @@ describe('downloadFromNpmRegistry', () => { } }), close: vi.fn((callback: () => void) => callback()), + destroy: vi.fn(), } as never); + vi.mocked(tar.t).mockResolvedValue(undefined); vi.mocked(tar.x).mockResolvedValue(undefined); }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it('does not send the ambient npm token to an override registry', async () => { + vi.stubEnv('NPM_TOKEN', 'ambient-secret'); + mockNpmDownload('https://registry.example.com/pkg.tgz'); + + await downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ); + + expect(vi.mocked(https.get).mock.calls[0]?.[1]).toMatchObject({ + headers: {}, + }); + }); + + it('sends the ambient npm token to the configured registry origin', async () => { + vi.stubEnv('NPM_TOKEN', 'ambient-secret'); + mockNpmDownload('https://registry.npmjs.org/pkg.tgz'); + + await downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.npmjs.org/custom-path', + }, + '/tmp/qwen-extension', + ); + + expect(vi.mocked(https.get).mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: 'Bearer ambient-secret' }, + }); + }); + it('redacts credentialed registry URLs in metadata request errors', async () => { - mockNpmRegistryStatus(404); + const response = mockNpmRegistryStatus(404); await expect( downloadFromNpmRegistry( @@ -243,70 +359,823 @@ describe('downloadFromNpmRegistry', () => { ).rejects.toThrow( 'npm registry request failed with status 404: https://***REDACTED***@registry.example.com/@scope%2fpkg', ); + expect(response.resume).toHaveBeenCalled(); }); - it('uses the HTTPS client for uppercase HTTPS tarball URLs', async () => { + it('rejects npm metadata response stream errors', async () => { + const responseError = new Error('metadata response interrupted'); + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + if (typeof callback === 'function') { + callback({ + statusCode: 200, + headers: {}, + on: vi.fn((event: string, handler: (error?: Error) => void) => { + if (event === 'error') + queueMicrotask(() => handler(responseError)); + }), + } as never); + } + return { on: vi.fn().mockReturnThis() } as never; + }, + ); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).rejects.toBe(responseError); + }); + + it('destroys npm metadata responses that exceed the size limit', async () => { + const response = { + statusCode: 200, + headers: {}, + destroy: vi.fn(), + on: vi.fn((event: string, handler: (data?: Buffer) => void) => { + if (event === 'data') { + handler(Buffer.alloc(6 * 1024 * 1024)); + handler(Buffer.alloc(6 * 1024 * 1024)); + } + if (event === 'end') handler(); + }), + }; + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + if (typeof callback === 'function') callback(response as never); + return { on: vi.fn().mockReturnThis() } as never; + }, + ); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).rejects.toThrow('npm package metadata exceeded maximum size'); + expect(response.destroy).toHaveBeenCalledOnce(); + }); + + it('destroys non-200 npm tarball responses before rejecting', async () => { + const response = { + statusCode: 503, + headers: {}, + on: vi.fn(), + destroy: vi.fn(), + }; let requestCount = 0; + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + requestCount += 1; + if (typeof callback === 'function') { + callback( + (requestCount === 1 + ? npmMetadataResponse('https://registry.example.com/pkg.tgz') + : response) as never, + ); + } + return { + on: vi.fn().mockReturnThis(), + setTimeout: vi.fn(), + destroy: vi.fn(), + } as never; + }, + ); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).rejects.toThrow('Failed to download npm tarball: status 503'); + expect(response.destroy).toHaveBeenCalled(); + }); + + it('preserves the original reason for a pre-aborted npm download', async () => { + const controller = new AbortController(); + const reason = new Error('download cancelled'); + controller.abort(reason); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + controller.signal, + ), + ).rejects.toBe(reason); + expect(https.get).not.toHaveBeenCalled(); + expect(tar.t).not.toHaveBeenCalled(); + expect(tar.x).not.toHaveBeenCalled(); + }); + + it('uses the HTTPS client for uppercase HTTPS tarball URLs', async () => { vi.mocked(http.get).mockImplementation(() => { throw new Error('wrong client'); }); + mockNpmDownload('HTTPS://registry.example.com/@scope/pkg/-/pkg-1.0.0.tgz'); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'HTTPS://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).resolves.toEqual({ version: '1.0.0', type: 'npm' }); + expect(https.get).toHaveBeenCalledTimes(2); + expect(http.get).not.toHaveBeenCalled(); + }); + + it('rejects a public-policy tarball URL targeting the private network', async () => { + vi.spyOn(dns, 'lookup').mockResolvedValue([ + { address: '8.8.8.8', family: 4 }, + ] as never); + mockNpmRegistryResponse({ + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + dist: { tarball: 'http://127.0.0.1/internal.tgz' }, + }, + }, + }); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + networkPolicy: 'public', + }, + '/tmp/qwen-extension', + ), + ).rejects.toThrow('must use HTTPS'); + + expect(https.get).toHaveBeenCalledTimes(1); + expect(http.get).not.toHaveBeenCalled(); + }); + + it('resolves relative npm metadata redirects', async () => { + let requestCount = 0; vi.mocked(https.get).mockImplementation( - (_url: unknown, _options: unknown, callback: unknown) => { + (url: unknown, _options: unknown, callback: unknown) => { requestCount += 1; - const mockRes = + const response = requestCount === 1 ? { - statusCode: 200, - headers: {}, - on: vi.fn( - (event: string, handler: (data?: Buffer) => void) => { - if (event === 'data') { - handler( - Buffer.from( - JSON.stringify({ - 'dist-tags': { latest: '1.0.0' }, - versions: { - '1.0.0': { - dist: { - tarball: - 'HTTPS://registry.example.com/@scope/pkg/-/pkg-1.0.0.tgz', - }, - }, - }, - }), - ), - ); - } - if (event === 'end') { - handler(); - } - }, - ), + statusCode: 302, + headers: { location: '/redirected-metadata' }, + on: vi.fn(), + resume: vi.fn(), } + : requestCount === 2 + ? npmMetadataResponse('https://registry.example.com/pkg.tgz') + : { + statusCode: 200, + headers: {}, + on: vi.fn(), + pipe: vi.fn(), + destroy: vi.fn(), + }; + if (typeof callback === 'function') callback(response as never); + return { on: vi.fn().mockReturnThis(), destroy: vi.fn() } as never; + }, + ); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).resolves.toEqual({ version: '1.0.0', type: 'npm' }); + expect(vi.mocked(https.get).mock.calls[1]?.[0]).toBe( + 'https://registry.example.com/redirected-metadata', + ); + }); + + it('rejects an invalid npm metadata redirect from an async response', async () => { + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + queueMicrotask(() => { + if (typeof callback === 'function') { + callback({ + statusCode: 302, + headers: { location: 'http://[' }, + on: vi.fn(), + resume: vi.fn(), + } as never); + } + }); + return { on: vi.fn().mockReturnThis(), destroy: vi.fn() } as never; + }, + ); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).rejects.toThrow('Invalid npm redirect URL: http://['); + expect(tar.t).not.toHaveBeenCalled(); + }); + + it('stops following npm metadata redirect loops', async () => { + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + if (typeof callback === 'function') { + callback({ + statusCode: 302, + headers: { location: '/metadata-loop' }, + on: vi.fn(), + resume: vi.fn(), + } as never); + } + return { on: vi.fn().mockReturnThis(), destroy: vi.fn() } as never; + }, + ); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).rejects.toThrow('Too many redirects while fetching npm package metadata'); + expect(https.get).toHaveBeenCalledTimes(11); + }); + + it('resolves relative npm tarball redirects', async () => { + let requestCount = 0; + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + requestCount += 1; + const response = + requestCount === 1 + ? npmMetadataResponse('https://registry.example.com/pkg.tgz') + : requestCount === 2 + ? { + statusCode: 302, + headers: { location: '/pkg-final.tgz' }, + on: vi.fn(), + destroy: vi.fn(), + } + : { + statusCode: 200, + headers: {}, + on: vi.fn(), + pipe: vi.fn(), + destroy: vi.fn(), + }; + if (typeof callback === 'function') callback(response as never); + return { on: vi.fn().mockReturnThis(), destroy: vi.fn() } as never; + }, + ); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).resolves.toEqual({ version: '1.0.0', type: 'npm' }); + expect(vi.mocked(https.get).mock.calls[2]?.[0]).toBe( + 'https://registry.example.com/pkg-final.tgz', + ); + }); + + it('stops following npm tarball redirect loops', async () => { + let requestCount = 0; + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + requestCount += 1; + const response = + requestCount === 1 + ? npmMetadataResponse('https://registry.example.com/pkg.tgz') : { - statusCode: 200, - headers: {}, - pipe: vi.fn(), + statusCode: 302, + headers: { location: '/tarball-loop' }, + on: vi.fn(), + destroy: vi.fn(), }; + if (typeof callback === 'function') callback(response as never); + return { on: vi.fn().mockReturnThis(), destroy: vi.fn() } as never; + }, + ); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).rejects.toThrow('Too many redirects while downloading npm package'); + expect(https.get).toHaveBeenCalledTimes(12); + }); + + it('preserves the original abort reason during a redirected npm download', async () => { + const controller = new AbortController(); + const reason = new Error('download cancelled'); + let requestCount = 0; + let finalRequestError: ((error: Error) => void) | undefined; + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + requestCount += 1; if (typeof callback === 'function') { - callback(mockRes as never); + if (requestCount === 1) { + callback( + npmMetadataResponse( + 'https://registry.example.com/pkg.tgz', + ) as never, + ); + } else if (requestCount === 2) { + callback({ + statusCode: 302, + headers: { location: '/pkg-final.tgz' }, + on: vi.fn(), + destroy: vi.fn(), + } as never); + } } - return { on: vi.fn() } as never; + return { + on: vi.fn().mockImplementation(function ( + this: unknown, + event: string, + handler: (error: Error) => void, + ) { + if (requestCount === 3 && event === 'error') { + finalRequestError = handler; + } + return this; + }), + destroy: vi.fn(), + } as never; }, ); + const outcome = downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + controller.signal, + ); + await vi.waitFor(() => expect(requestCount).toBe(3)); + expect( + (vi.mocked(https.get).mock.calls[2]?.[1] as { signal?: AbortSignal }) + .signal?.aborted, + ).toBe(false); + + controller.abort(reason); + finalRequestError?.(new Error('request aborted')); + + await expect(outcome).rejects.toBe(reason); + expect(tar.t).not.toHaveBeenCalled(); + expect(tar.x).not.toHaveBeenCalled(); + }); + + it.each(['SymbolicLink', 'Link'] as const)( + 'rejects npm tarballs containing %s entries before extraction', + async (type) => { + mockNpmDownload('https://registry.example.com/pkg.tgz'); + vi.mocked(tar.t).mockImplementationOnce(async (options) => { + options.onReadEntry?.({ + type, + path: 'package/escape', + } as never); + }); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).rejects.toThrow( + 'Tar archive contains unsupported link entry: package/escape', + ); + expect(tar.x).not.toHaveBeenCalled(); + }, + ); + + it('sanitizes and bounds rejected tar entry paths', async () => { + mockNpmDownload('https://registry.example.com/pkg.tgz'); + vi.mocked(tar.t).mockImplementationOnce(async (options) => { + options.onReadEntry?.({ + type: 'SymbolicLink', + path: `escape\n\u001b]8;;https://example.com\u0007${'x'.repeat(300)}`, + } as never); + }); + + let message = ''; + try { + await downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).not.toContain('\n'); + expect(message).not.toContain('\r'); + expect(message).not.toContain('\u001b'); + expect(message).not.toContain('\u0007'); + expect(message).toContain('Tar archive contains unsupported link entry:'); + expect(message).toHaveLength( + 'Tar archive contains unsupported link entry: '.length + 200, + ); + expect(message.endsWith('...')).toBe(true); + }); + + it('rejects tar links whose sanitized path is empty', async () => { + mockNpmDownload('https://registry.example.com/pkg.tgz'); + vi.mocked(tar.t).mockImplementationOnce(async (options) => { + options.onReadEntry?.({ + type: 'SymbolicLink', + path: '\u001b[31m\u001b[0m\u0007', + } as never); + }); + await expect( downloadFromNpmRegistry( { source: '@scope/pkg', type: 'npm', - registryUrl: 'HTTPS://registry.example.com', + registryUrl: 'https://registry.example.com', }, '/tmp/qwen-extension', ), - ).resolves.toEqual({ version: '1.0.0', type: 'npm' }); - expect(https.get).toHaveBeenCalledTimes(2); - expect(http.get).not.toHaveBeenCalled(); + ).rejects.toThrow( + 'Tar archive contains unsupported link entry: ', + ); + expect(tar.x).not.toHaveBeenCalled(); + }); + + it('reports every rejected tar link', async () => { + mockNpmDownload('https://registry.example.com/pkg.tgz'); + vi.mocked(tar.t).mockImplementationOnce(async (options) => { + options.onReadEntry?.({ + type: 'SymbolicLink', + path: 'package/first-link', + } as never); + options.onReadEntry?.({ + type: 'Link', + path: 'package/second-link', + } as never); + }); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).rejects.toThrow( + 'Tar archive contains 2 unsupported link entries: package/first-link, package/second-link', + ); + expect(tar.x).not.toHaveBeenCalled(); + }); + + it('bounds rejected tar link collection', async () => { + mockNpmDownload('https://registry.example.com/pkg.tgz'); + vi.mocked(tar.t).mockImplementationOnce(async (options) => { + for (let index = 0; index <= 100; index += 1) { + options.onReadEntry?.({ + type: 'SymbolicLink', + path: `package/link-${index}`, + } as never); + } + }); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).rejects.toThrow('more than 100 unsupported link entries'); + expect(tar.x).not.toHaveBeenCalled(); + }); + + it('stops between tar inspection and extraction when cancelled', async () => { + const controller = new AbortController(); + const reason = new Error('inspection cancelled'); + mockNpmDownload('https://registry.example.com/pkg.tgz'); + vi.mocked(tar.t).mockImplementationOnce(async () => { + controller.abort(reason); + }); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + controller.signal, + ), + ).rejects.toBe(reason); + expect(tar.x).not.toHaveBeenCalled(); + }); + + it('rejects npm tarballs larger than 100 MB', async () => { + mockNpmDownload( + 'https://registry.example.com/pkg.tgz', + 100 * 1024 * 1024 + 1, + ); + + await expect( + downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ), + ).rejects.toThrow( + 'npm extension archive download exceeded maximum size of 104857600 bytes', + ); + expect(tar.t).not.toHaveBeenCalled(); + }); + + it('times out a stalled npm tarball download', async () => { + vi.useFakeTimers(); + let requestCount = 0; + const destroy = vi.fn(); + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + requestCount += 1; + if (requestCount === 1 && typeof callback === 'function') { + callback({ + statusCode: 200, + headers: {}, + on: vi.fn((event: string, handler: (data?: Buffer) => void) => { + if (event === 'data') { + handler( + Buffer.from( + JSON.stringify({ + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + dist: { + tarball: 'https://registry.example.com/pkg.tgz', + }, + }, + }, + }), + ), + ); + } + if (event === 'end') handler(); + }), + } as never); + } + return { + on: vi.fn().mockReturnThis(), + setTimeout: vi.fn(), + destroy, + } as never; + }, + ); + + const outcome = downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(120_000); + + await expect(outcome).resolves.toMatchObject({ + message: 'npm tarball download timed out after 120000ms', + }); + expect(destroy).toHaveBeenCalledOnce(); + }); + + it('does not start a tarball request when DNS outlives the deadline', async () => { + vi.useFakeTimers(); + let resolveTarballDns: ((value: unknown) => void) | undefined; + const lookup = vi + .spyOn(dns, 'lookup') + .mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }] as never) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveTarballDns = resolve; + }) as never, + ); + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + if (typeof callback === 'function') { + callback( + npmMetadataResponse('https://cdn.example.com/pkg.tgz') as never, + ); + } + return { on: vi.fn().mockReturnThis(), destroy: vi.fn() } as never; + }, + ); + + const outcome = downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + networkPolicy: 'public', + }, + '/tmp/qwen-extension', + ).catch((error: unknown) => error); + await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(2)); + await vi.advanceTimersByTimeAsync(120_000); + + await expect(outcome).resolves.toMatchObject({ + message: 'npm tarball download timed out after 120000ms', + }); + expect(https.get).toHaveBeenCalledOnce(); + resolveTarballDns?.([{ address: '8.8.4.4', family: 4 }]); + await Promise.resolve(); + expect(https.get).toHaveBeenCalledOnce(); + }); + + it('destroys a stalled npm response and file at the download deadline', async () => { + vi.useFakeTimers(); + let requestCount = 0; + const responseDestroy = vi.fn(); + const fileDestroy = vi.fn(); + vi.mocked(fs.createWriteStream).mockReturnValue({ + on: vi.fn(), + close: vi.fn(), + destroy: fileDestroy, + } as never); + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + requestCount += 1; + if (typeof callback === 'function') { + if (requestCount === 1) { + callback( + npmMetadataResponse( + 'https://registry.example.com/pkg.tgz', + ) as never, + ); + } else { + callback({ + statusCode: 200, + headers: {}, + on: vi.fn(), + pipe: vi.fn(), + destroy: responseDestroy, + } as never); + } + } + return { + on: vi.fn().mockReturnThis(), + destroy: vi.fn(), + } as never; + }, + ); + + const outcome = downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(120_000); + + await expect(outcome).resolves.toMatchObject({ + message: 'npm tarball download timed out after 120000ms', + }); + expect(responseDestroy).toHaveBeenCalledOnce(); + expect(fileDestroy).toHaveBeenCalledOnce(); + expect(tar.t).not.toHaveBeenCalled(); + expect(tar.x).not.toHaveBeenCalled(); + }); + + it('times out the active request across npm tarball redirects', async () => { + vi.useFakeTimers(); + let requestCount = 0; + const childDestroy = vi.fn(); + vi.mocked(https.get).mockImplementation( + (_url: unknown, _options: unknown, callback: unknown) => { + requestCount += 1; + if (requestCount === 1 && typeof callback === 'function') { + callback({ + statusCode: 200, + headers: {}, + on: vi.fn((event: string, handler: (data?: Buffer) => void) => { + if (event === 'data') { + handler( + Buffer.from( + JSON.stringify({ + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + dist: { + tarball: 'https://registry.example.com/pkg.tgz', + }, + }, + }, + }), + ), + ); + } + if (event === 'end') handler(); + }), + } as never); + } else if (requestCount === 2 && typeof callback === 'function') { + setTimeout( + () => + callback({ + statusCode: 302, + headers: { + location: 'https://cdn.example.com/pkg.tgz', + }, + on: vi.fn(), + destroy: vi.fn(), + } as never), + 119_999, + ); + } + return { + on: vi.fn().mockReturnThis(), + setTimeout: vi.fn(), + destroy: requestCount === 3 ? childDestroy : vi.fn(), + } as never; + }, + ); + + const outcome = downloadFromNpmRegistry( + { + source: '@scope/pkg', + type: 'npm', + registryUrl: 'https://registry.example.com', + }, + '/tmp/qwen-extension', + ).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(120_000); + + await expect(outcome).resolves.toMatchObject({ + message: 'npm tarball download timed out after 120000ms', + }); + expect(childDestroy).toHaveBeenCalledOnce(); + expect(tar.t).not.toHaveBeenCalled(); + expect(tar.x).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/src/extension/npm.ts b/packages/core/src/extension/npm.ts index fc8b749519d..5893b1ca1d8 100644 --- a/packages/core/src/extension/npm.ts +++ b/packages/core/src/extension/npm.ts @@ -3,6 +3,7 @@ */ import * as fs from 'node:fs'; +import type { ClientRequest, IncomingMessage } from 'node:http'; import * as path from 'node:path'; import * as os from 'node:os'; import * as tar from 'tar'; @@ -11,8 +12,14 @@ import { ExtensionUpdateState } from './extensionManager.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { redactUrlCredentials } from './redaction.js'; import { clientForUrl } from './http-client.js'; +import { assertTarArchiveHasNoLinks } from './archive-safety.js'; +import { resolveNetworkTarget } from './network-policy.js'; const debugLogger = createDebugLogger('EXT_NPM'); +const NPM_ARCHIVE_DOWNLOAD_TIMEOUT_MS = 120_000; +const NPM_ARCHIVE_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024; +const NPM_METADATA_MAX_BYTES = 10 * 1024 * 1024; +const NPM_MAX_REDIRECTS = 10; export interface NpmDownloadResult { version: string; @@ -32,6 +39,16 @@ interface NpmPackageMetadata { >; } +function resolveNpmRedirectUrl(currentUrl: string, location: string): URL { + try { + return new URL(location, currentUrl); + } catch { + throw new Error( + `Invalid npm redirect URL: ${redactUrlCredentials(location)}`, + ); + } +} + /** * Parse a scoped npm package source string into name and optional version. * Examples: @@ -122,12 +139,18 @@ export function resolveNpmRegistry( * Get npm auth token for a registry. * * Priority: - * 1. NPM_TOKEN environment variable + * 1. NPM_TOKEN environment variable for the configured registry origin * 2. Registry-specific _authToken from .npmrc */ -function getNpmAuthToken(registryUrl: string): string | undefined { +function getNpmAuthToken( + registryUrl: string, + ambientTokenRegistryUrl: string, +): string | undefined { const envToken = process.env['NPM_TOKEN']; - if (envToken) { + if ( + envToken && + new URL(registryUrl).origin === new URL(ambientTokenRegistryUrl).origin + ) { return envToken; } @@ -178,7 +201,19 @@ function getNpmAuthToken(registryUrl: string): string | undefined { return undefined; } -function fetchNpmJson(url: string, authToken?: string): Promise { +function fetchNpmJson( + url: string, + authToken?: string, + signal?: AbortSignal, + redirectCount = 0, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], +): Promise { + signal?.throwIfAborted(); + if (redirectCount > NPM_MAX_REDIRECTS) { + return Promise.reject( + new Error('Too many redirects while fetching npm package metadata'), + ); + } const headers: Record = { Accept: 'application/json', }; @@ -186,88 +221,299 @@ function fetchNpmJson(url: string, authToken?: string): Promise { headers['Authorization'] = `Bearer ${authToken}`; } - const client = clientForUrl(url); - - return new Promise((resolve, reject) => { - client - .get(url, { headers }, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - if (res.headers.location) { - // Strip auth token when redirected to a different host - const originalHost = new URL(url).host; - const redirectHost = new URL(res.headers.location).host; - const redirectToken = - redirectHost === originalHost ? authToken : undefined; - fetchNpmJson(res.headers.location, redirectToken) - .then(resolve) - .catch(reject); - return; - } - } - if (res.statusCode !== 200) { - return reject( - new Error( - `npm registry request failed with status ${res.statusCode}: ${redactUrlCredentials(url)}`, - ), - ); - } - const chunks: Buffer[] = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - try { - resolve(JSON.parse(Buffer.concat(chunks).toString()) as T); - } catch (e) { - reject(new Error(`Failed to parse npm registry response: ${e}`)); - } - }); - }) - .on('error', reject); - }); + return resolveNetworkTarget(url, networkPolicy, signal).then( + (target) => + new Promise((resolve, reject) => { + signal?.throwIfAborted(); + const client = clientForUrl(target.url.toString()); + client + .get( + url, + { + headers, + signal, + lookup: target.lookup, + ...(target.lookup ? { agent: false } : {}), + }, + (res) => { + res.on('error', (error) => { + reject(signal?.aborted ? signal.reason : error); + }); + if (res.statusCode === 301 || res.statusCode === 302) { + if (res.headers.location) { + let redirectUrl: URL; + try { + redirectUrl = resolveNpmRedirectUrl( + url, + res.headers.location, + ); + } catch (error) { + res.resume(); + reject(error); + return; + } + res.resume(); + const originalOrigin = new URL(url).origin; + const redirectToken = + redirectUrl.origin === originalOrigin + ? authToken + : undefined; + fetchNpmJson( + redirectUrl.toString(), + redirectToken, + signal, + redirectCount + 1, + networkPolicy, + ) + .then(resolve) + .catch(reject); + return; + } + } + if (res.statusCode !== 200) { + res.resume(); + return reject( + new Error( + `npm registry request failed with status ${res.statusCode}: ${redactUrlCredentials(url)}`, + ), + ); + } + const chunks: Buffer[] = []; + let totalBytes = 0; + let responseFinished = false; + res.on('data', (chunk: Buffer) => { + if (responseFinished) return; + totalBytes += chunk.length; + if (totalBytes > NPM_METADATA_MAX_BYTES) { + responseFinished = true; + res.destroy(); + reject( + new Error( + `npm package metadata exceeded maximum size of ${NPM_METADATA_MAX_BYTES} bytes`, + ), + ); + return; + } + chunks.push(chunk); + }); + res.on('end', () => { + if (responseFinished) return; + responseFinished = true; + try { + resolve(JSON.parse(Buffer.concat(chunks).toString()) as T); + } catch (e) { + reject( + new Error(`Failed to parse npm registry response: ${e}`), + ); + } + }); + }, + ) + .on('error', (error) => { + reject(signal?.aborted ? signal.reason : error); + }); + }), + ); } /** * Download a file from a URL, following redirects. */ -function downloadNpmFile( +interface NpmDownloadContext { + activeRequest?: ClientRequest; + activeResponse?: IncomingMessage; + activeFile?: fs.WriteStream; + requestGeneration: number; + timedOut: boolean; +} + +function downloadNpmFileRedirect( url: string, dest: string, + context: NpmDownloadContext, authToken?: string, + signal?: AbortSignal, + redirectCount = 0, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], ): Promise { + signal?.throwIfAborted(); + if (redirectCount > NPM_MAX_REDIRECTS) { + return Promise.reject( + new Error('Too many redirects while downloading npm package'), + ); + } const headers: Record = {}; if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } - const client = clientForUrl(url); + return resolveNetworkTarget(url, networkPolicy, signal).then( + (target) => + new Promise((resolve, reject) => { + signal?.throwIfAborted(); + const client = clientForUrl(target.url.toString()); + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + resolve(); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + reject(error); + }; + const requestGeneration = ++context.requestGeneration; + const req = client + .get( + url, + { + headers, + signal, + lookup: target.lookup, + ...(target.lookup ? { agent: false } : {}), + }, + (res) => { + if (context.timedOut) { + res.destroy(); + return; + } + context.activeResponse = res; + if (res.statusCode === 301 || res.statusCode === 302) { + if (res.headers.location) { + let redirectUrl: URL; + try { + redirectUrl = resolveNpmRedirectUrl( + url, + res.headers.location, + ); + } catch (error) { + res.destroy(); + context.activeResponse = undefined; + fail(error); + return; + } + const originalOrigin = new URL(url).origin; + const redirectToken = + redirectUrl.origin === originalOrigin + ? authToken + : undefined; + res.destroy(); + context.activeResponse = undefined; + downloadNpmFileRedirect( + redirectUrl.toString(), + dest, + context, + redirectToken, + signal, + redirectCount + 1, + networkPolicy, + ) + .then(finish) + .catch(fail); + return; + } + } + if (res.statusCode !== 200) { + res.destroy(); + context.activeResponse = undefined; + fail( + new Error( + `Failed to download npm tarball: status ${res.statusCode}`, + ), + ); + return; + } + const file = fs.createWriteStream(dest); + context.activeFile = file; + let bytesWritten = 0; + res.on('data', (chunk: Buffer) => { + bytesWritten += chunk.length; + if (bytesWritten > NPM_ARCHIVE_DOWNLOAD_MAX_BYTES) { + res.destroy(); + file.destroy(); + fail( + new Error( + `npm extension archive download exceeded maximum size of ${NPM_ARCHIVE_DOWNLOAD_MAX_BYTES} bytes`, + ), + ); + return; + } + }); + res.on('error', (error) => { + file.destroy(); + fail(error); + }); + file.on('error', (error) => { + res.destroy(); + fail(error); + }); + res.pipe(file); + file.on('finish', () => file.close(finish)); + }, + ) + .on('error', (error) => { + fail(signal?.aborted ? signal.reason : error); + }); + if (requestGeneration === context.requestGeneration) { + context.activeRequest = req; + } + }), + ); +} +function downloadNpmFile( + url: string, + dest: string, + authToken?: string, + signal?: AbortSignal, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], +): Promise { + const context: NpmDownloadContext = { + requestGeneration: 0, + timedOut: false, + }; + const timeoutController = new AbortController(); + const requestSignal = signal + ? AbortSignal.any([signal, timeoutController.signal]) + : timeoutController.signal; return new Promise((resolve, reject) => { - client - .get(url, { headers }, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - if (res.headers.location) { - // Strip auth token when redirected to a different host - const originalHost = new URL(url).host; - const redirectHost = new URL(res.headers.location).host; - const redirectToken = - redirectHost === originalHost ? authToken : undefined; - downloadNpmFile(res.headers.location, dest, redirectToken) - .then(resolve) - .catch(reject); - return; - } - } - if (res.statusCode !== 200) { - return reject( - new Error( - `Failed to download npm tarball: status ${res.statusCode}`, - ), - ); - } - const file = fs.createWriteStream(dest); - res.pipe(file); - file.on('finish', () => file.close(resolve as () => void)); - }) - .on('error', reject); + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(hardDeadline); + resolve(); + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(hardDeadline); + reject(error); + }; + const hardDeadline = setTimeout(() => { + context.timedOut = true; + const error = new Error( + `npm tarball download timed out after ${NPM_ARCHIVE_DOWNLOAD_TIMEOUT_MS}ms`, + ); + timeoutController.abort(error); + fail(error); + context.activeRequest?.destroy(); + context.activeResponse?.destroy(); + context.activeFile?.destroy(); + }, NPM_ARCHIVE_DOWNLOAD_TIMEOUT_MS); + hardDeadline.unref(); + downloadNpmFileRedirect( + url, + dest, + context, + authToken, + requestSignal, + 0, + networkPolicy, + ) + .then(finish) + .catch(fail); }); } @@ -277,18 +523,19 @@ function downloadNpmFile( export async function downloadFromNpmRegistry( installMetadata: ExtensionInstallMetadata, destination: string, + signal?: AbortSignal, ): Promise { const { name, version: requestedVersion } = parseNpmPackageSource( installMetadata.source, ); const scope = name.split('/')[0]; - const registryUrl = - installMetadata.registryUrl || resolveNpmRegistry(scope, undefined); + const configuredRegistryUrl = resolveNpmRegistry(scope, undefined); + const registryUrl = installMetadata.registryUrl || configuredRegistryUrl; // Store resolved registry for future update checks installMetadata.registryUrl = registryUrl; - const authToken = getNpmAuthToken(registryUrl); + const authToken = getNpmAuthToken(registryUrl, configuredRegistryUrl); // Fetch package metadata const encodedName = name.replaceAll('/', '%2f'); @@ -300,6 +547,9 @@ export async function downloadFromNpmRegistry( const metadata = await fetchNpmJson( metadataUrl, authToken, + signal, + 0, + installMetadata.networkPolicy, ); // Resolve version @@ -342,28 +592,41 @@ export async function downloadFromNpmRegistry( // Download tarball const tarballPath = path.join(destination, 'package.tgz'); - await downloadNpmFile(tarballUrl, tarballPath, tarballAuthToken); + await downloadNpmFile( + tarballUrl, + tarballPath, + tarballAuthToken, + signal, + installMetadata.networkPolicy, + ); + signal?.throwIfAborted(); // Extract tarball + await assertTarArchiveHasNoLinks(tarballPath); + signal?.throwIfAborted(); await tar.x({ file: tarballPath, cwd: destination, }); + signal?.throwIfAborted(); // npm tarballs contain a `package/` wrapper directory — flatten it const packageDir = path.join(destination, 'package'); if (fs.existsSync(packageDir)) { const entries = await fs.promises.readdir(packageDir); for (const entry of entries) { + signal?.throwIfAborted(); await fs.promises.rename( path.join(packageDir, entry), path.join(destination, entry), ); } + signal?.throwIfAborted(); await fs.promises.rmdir(packageDir); } // Clean up tarball + signal?.throwIfAborted(); await fs.promises.unlink(tarballPath); debugLogger.debug( @@ -381,19 +644,23 @@ export async function downloadFromNpmRegistry( */ export async function checkNpmUpdate( installMetadata: ExtensionInstallMetadata, + signal?: AbortSignal, ): Promise { try { const { name } = parseNpmPackageSource(installMetadata.source); const scope = name.split('/')[0]; - const registryUrl = - installMetadata.registryUrl || resolveNpmRegistry(scope, undefined); - const authToken = getNpmAuthToken(registryUrl); + const configuredRegistryUrl = resolveNpmRegistry(scope, undefined); + const registryUrl = installMetadata.registryUrl || configuredRegistryUrl; + const authToken = getNpmAuthToken(registryUrl, configuredRegistryUrl); const encodedName = name.replaceAll('/', '%2f'); const metadataUrl = `${registryUrl}/${encodedName}`; const metadata = await fetchNpmJson( metadataUrl, authToken, + signal, + 0, + installMetadata.networkPolicy, ); const { version: requestedVersion } = parseNpmPackageSource( @@ -425,6 +692,7 @@ export async function checkNpmUpdate( } return ExtensionUpdateState.UP_TO_DATE; } catch (error) { + signal?.throwIfAborted(); debugLogger.error( `Failed to check npm update for "${redactUrlCredentials(installMetadata.source)}": ${redactUrlCredentials(String(error))}`, ); diff --git a/packages/core/src/extension/sourceRegistry.test.ts b/packages/core/src/extension/sourceRegistry.test.ts index 5d29b5e508d..d13311cda5a 100644 --- a/packages/core/src/extension/sourceRegistry.test.ts +++ b/packages/core/src/extension/sourceRegistry.test.ts @@ -184,6 +184,23 @@ describe('discoverPlugins', () => { expect(discovered.find((p) => p.name === 'docx')!.installed).toBe(true); }); + it('passes the public network policy to marketplace loading', async () => { + vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( + config('Skills', []), + ); + + await discoverPlugins( + [{ name: 'Skills', source: 'anthropics/skills', type: 'github' }], + new Set(), + 'public', + ); + + expect(loadMarketplaceConfigFromSource).toHaveBeenCalledWith( + 'anthropics/skills', + 'public', + ); + }); + it('surfaces declared components and lastUpdated for the detail view', async () => { vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( config('Skills', [ diff --git a/packages/core/src/extension/sourceRegistry.ts b/packages/core/src/extension/sourceRegistry.ts index f26645c74a8..6a3dfbcaa2f 100644 --- a/packages/core/src/extension/sourceRegistry.ts +++ b/packages/core/src/extension/sourceRegistry.ts @@ -12,6 +12,7 @@ import { createDebugLogger } from '../utils/debugLogger.js'; import { redactUrlCredentials } from './redaction.js'; import { loadMarketplaceConfigFromSource } from './marketplace.js'; import { quarantineCorruptFile } from './corruptFile.js'; +import type { ExtensionInstallMetadata } from '../config/config.js'; import type { ClaudeMarketplaceConfig, ClaudeMarketplacePluginConfig, @@ -324,12 +325,14 @@ export class SourceRegistryStore { export async function discoverPlugins( sources: readonly ExtensionSource[], installedNames: ReadonlySet, + networkPolicy?: ExtensionInstallMetadata['networkPolicy'], ): Promise { const results = await Promise.all( sources.map(async (marketplace) => { try { const config = await loadMarketplaceConfigFromSource( marketplace.source, + networkPolicy, ); if (!config) { debugLogger.debug( diff --git a/packages/core/src/extension/variables.test.ts b/packages/core/src/extension/variables.test.ts index c16917c97f6..d22bca687bd 100644 --- a/packages/core/src/extension/variables.test.ts +++ b/packages/core/src/extension/variables.test.ts @@ -238,6 +238,40 @@ describe('performVariableReplacement', () => { expect(result).not.toContain('${CLAUDE_PLUGIN_ROOT}'); }); + it('should edit a staging directory using a separate installed path', () => { + const stagingDir = path.join(testDir, 'staging'); + const installedDir = path.join(testDir, 'installed'); + fs.mkdirSync(stagingDir, { recursive: true }); + fs.writeFileSync( + path.join(stagingDir, 'README.md'), + '${CLAUDE_PLUGIN_ROOT}/config.json', + 'utf-8', + ); + + performVariableReplacement(stagingDir, installedDir); + + expect(fs.readFileSync(path.join(stagingDir, 'README.md'), 'utf-8')).toBe( + `${installedDir}/config.json`, + ); + }); + + it('should preserve replacement metacharacters in the installed path', () => { + const stagingDir = path.join(testDir, 'staging'); + const installedDir = path.join(testDir, "installed-$&-$`-$'"); + fs.mkdirSync(stagingDir, { recursive: true }); + fs.writeFileSync( + path.join(stagingDir, 'README.md'), + '${CLAUDE_PLUGIN_ROOT}/config.json', + 'utf-8', + ); + + performVariableReplacement(stagingDir, installedDir); + + expect(fs.readFileSync(path.join(stagingDir, 'README.md'), 'utf-8')).toBe( + `${installedDir}/config.json`, + ); + }); + it('should convert ```! syntax to !{} in markdown files', () => { const extDir = path.join(testDir, 'ext'); fs.mkdirSync(extDir, { recursive: true }); diff --git a/packages/core/src/extension/variables.ts b/packages/core/src/extension/variables.ts index 63fe7e55826..5b901a554f3 100644 --- a/packages/core/src/extension/variables.ts +++ b/packages/core/src/extension/variables.ts @@ -121,9 +121,13 @@ export function substituteHookVariables( /** * Perform variable replacement in all markdown and shell script files of the extension. * This is done during the conversion phase to avoid modifying files during every extension load. - * @param extensionPath - The path to the extension directory + * @param extensionPath - The path to the extension directory to edit + * @param pluginRoot - The installed path to substitute for ${CLAUDE_PLUGIN_ROOT} */ -export function performVariableReplacement(extensionPath: string): void { +export function performVariableReplacement( + extensionPath: string, + pluginRoot = extensionPath, +): void { // Process markdown files const mdGlobPattern = '**/*.md'; const mdGlobOptions = { @@ -143,7 +147,7 @@ export function performVariableReplacement(extensionPath: string): void { // Replace ${CLAUDE_PLUGIN_ROOT} with the actual extension path const updatedContent = content.replace( /\$\{CLAUDE_PLUGIN_ROOT\}/g, - extensionPath, + () => pluginRoot, ); // Replace Markdown shell syntax ```! ... ``` with system-recognized !{...} syntax diff --git a/packages/core/src/extension/zip-extraction.test.ts b/packages/core/src/extension/zip-extraction.test.ts new file mode 100644 index 00000000000..0c289807398 --- /dev/null +++ b/packages/core/src/extension/zip-extraction.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { Entry, ZipFile } from 'yauzl'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { extractZipArchive } from './zip-extraction.js'; + +const mockOpen = vi.hoisted(() => vi.fn()); + +vi.mock('yauzl', () => ({ open: mockOpen })); + +describe('extractZipArchive', () => { + let tempDir: string; + + afterEach(async () => { + vi.clearAllMocks(); + if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('rejects entries that escape the destination', async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zip-extraction-')); + const destination = path.join(tempDir, 'destination'); + const zipFile = new EventEmitter() as ZipFile; + const entry = { + fileName: '../../outside.txt', + externalFileAttributes: 0, + versionMadeBy: 0, + } as Entry; + zipFile.readEntry = vi.fn(() => zipFile.emit('entry', entry)); + zipFile.close = vi.fn(() => zipFile.emit('close')); + mockOpen.mockImplementation( + ( + _file: string, + _options: unknown, + callback: (error: Error | null, opened?: ZipFile) => void, + ) => callback(null, zipFile), + ); + + await expect( + extractZipArchive(path.join(tempDir, 'archive.zip'), destination), + ).rejects.toThrow('Out of bound path'); + await expect(fs.stat(path.join(tempDir, 'outside.txt'))).rejects.toThrow(); + }); + + it.runIf(process.platform !== 'win32')( + 'does not create directories through an existing symbolic link', + async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zip-extraction-')); + const destination = path.join(tempDir, 'destination'); + const outside = path.join(tempDir, 'outside'); + await fs.mkdir(destination); + await fs.mkdir(outside); + await fs.symlink(outside, path.join(destination, 'link')); + const zipFile = new EventEmitter() as ZipFile; + const entry = { + fileName: 'link/sub/file.txt', + externalFileAttributes: 0, + versionMadeBy: 0, + } as Entry; + zipFile.readEntry = vi.fn(() => zipFile.emit('entry', entry)); + zipFile.close = vi.fn(() => zipFile.emit('close')); + mockOpen.mockImplementation( + ( + _file: string, + _options: unknown, + callback: (error: Error | null, opened?: ZipFile) => void, + ) => callback(null, zipFile), + ); + + await expect( + extractZipArchive(path.join(tempDir, 'archive.zip'), destination), + ).rejects.toThrow('Refusing to extract through non-directory path'); + await expect(fs.stat(path.join(outside, 'sub'))).rejects.toThrow(); + }, + ); + + it('sanitizes and bounds entry names in errors', async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zip-extraction-')); + const destination = path.join(tempDir, 'destination'); + const zipFile = new EventEmitter() as ZipFile; + const entry = { + fileName: `bad\n\u001b[31m${'x'.repeat(500)}`, + externalFileAttributes: 0xa000 << 16, + versionMadeBy: 3 << 8, + } as Entry; + zipFile.readEntry = vi.fn(() => zipFile.emit('entry', entry)); + zipFile.close = vi.fn(() => zipFile.emit('close')); + mockOpen.mockImplementation( + ( + _file: string, + _options: unknown, + callback: (error: Error | null, opened?: ZipFile) => void, + ) => callback(null, zipFile), + ); + + const error = await extractZipArchive( + path.join(tempDir, 'archive.zip'), + destination, + ).then( + () => undefined, + (reason: unknown) => reason, + ); + const message = String(error); + expect(message).not.toContain('\n'); + expect(message).not.toContain('\u001b'); + expect(message.length).toBeLessThan(300); + }); +}); diff --git a/packages/core/src/extension/zip-extraction.ts b/packages/core/src/extension/zip-extraction.ts new file mode 100644 index 00000000000..0953b891dc9 --- /dev/null +++ b/packages/core/src/extension/zip-extraction.ts @@ -0,0 +1,272 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { open, type Entry, type ZipFile } from 'yauzl'; +import { stripAnsiAndControl } from '../utils/textUtils.js'; + +const ZIP_FILE_TYPE_MASK = 0xf000; +const ZIP_DIRECTORY_TYPE = 0x4000; +const ZIP_SYMBOLIC_LINK_TYPE = 0xa000; +const ZIP_DOS_DIRECTORY_ATTRIBUTE = 16; +const MAX_REPORTED_ZIP_PATH_LENGTH = 200; + +function formatZipPath(value: string): string { + const sanitized = stripAnsiAndControl(value); + if (sanitized.length <= MAX_REPORTED_ZIP_PATH_LENGTH) return sanitized; + return `${sanitized.slice(0, MAX_REPORTED_ZIP_PATH_LENGTH - 3)}...`; +} + +function isWithinRoot(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative)) + ); +} + +function getEntryMode(entry: Entry): number { + return (entry.externalFileAttributes >>> 16) & 0xffff; +} + +function isDirectoryEntry(entry: Entry, mode: number): boolean { + if ((mode & ZIP_FILE_TYPE_MASK) === ZIP_DIRECTORY_TYPE) return true; + if (entry.fileName.endsWith('/')) return true; + const madeBy = entry.versionMadeBy >>> 8; + return ( + madeBy === 0 && entry.externalFileAttributes === ZIP_DOS_DIRECTORY_ATTRIBUTE + ); +} + +function isSymbolicLinkEntry(mode: number): boolean { + return (mode & ZIP_FILE_TYPE_MASK) === ZIP_SYMBOLIC_LINK_TYPE; +} + +function openZipFile(file: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + let aborted = false; + const onAbort = () => { + aborted = true; + reject(signal?.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + open(file, { lazyEntries: true }, (error, zipFile) => { + signal?.removeEventListener('abort', onAbort); + if (aborted || signal?.aborted) { + zipFile?.close(); + reject(signal?.reason); + } else if (error) { + reject(error); + } else { + resolve(zipFile); + } + }); + }); +} + +function openEntryStream( + zipFile: ZipFile, + entry: Entry, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + zipFile.openReadStream(entry, (error, stream) => { + if (error) { + reject(error); + } else if (signal?.aborted) { + stream.destroy(); + reject(signal.reason); + } else { + resolve(stream); + } + }); + }); +} + +async function rejectExistingSymbolicLink(destination: string): Promise { + try { + const stats = await fs.promises.lstat(destination); + if (stats.isSymbolicLink()) { + throw new Error( + `Refusing to extract through existing symbolic link: ${formatZipPath(destination)}`, + ); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +} + +async function ensureDirectoryWithinRoot( + root: string, + destination: string, + mode?: number, +): Promise { + const relative = path.relative(root, destination); + const segments = relative.split(path.sep).filter(Boolean); + let current = root; + for (const [index, segment] of segments.entries()) { + current = path.join(current, segment); + try { + const stats = await fs.promises.lstat(current); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error( + `Refusing to extract through non-directory path: ${formatZipPath(current)}`, + ); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + try { + await fs.promises.mkdir(current, { + ...(index === segments.length - 1 && mode !== undefined + ? { mode } + : {}), + }); + } catch (mkdirError) { + if ((mkdirError as NodeJS.ErrnoException).code !== 'EEXIST') { + throw mkdirError; + } + const stats = await fs.promises.lstat(current); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error( + `Refusing to extract through non-directory path: ${formatZipPath(current)}`, + ); + } + } + } + } + const canonical = await fs.promises.realpath(destination); + if (!isWithinRoot(root, canonical)) { + throw new Error( + `Out of bound path "${formatZipPath(canonical)}" found while preparing extraction`, + ); + } +} + +async function extractEntry( + zipFile: ZipFile, + entry: Entry, + root: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + if (entry.fileName.startsWith('__MACOSX/')) return; + + const mode = getEntryMode(entry); + const reportedEntryName = formatZipPath(entry.fileName); + if (isSymbolicLinkEntry(mode)) { + throw new Error( + `Zip archive contains unsupported symbolic link entry: ${reportedEntryName}`, + ); + } + + const destination = path.resolve(root, entry.fileName); + if (!isWithinRoot(root, destination)) { + throw new Error( + `Out of bound path "${formatZipPath(destination)}" found while processing file ${reportedEntryName}`, + ); + } + + const isDirectory = isDirectoryEntry(entry, mode); + const permissions = (mode || (isDirectory ? 0o755 : 0o644)) & 0o777; + const destinationDirectory = isDirectory + ? destination + : path.dirname(destination); + await ensureDirectoryWithinRoot( + root, + destinationDirectory, + isDirectory ? permissions : undefined, + ); + signal?.throwIfAborted(); + if (isDirectory) return; + + await rejectExistingSymbolicLink(destination); + signal?.throwIfAborted(); + const readStream = await openEntryStream(zipFile, entry, signal); + try { + await pipeline( + readStream, + fs.createWriteStream(destination, { mode: permissions }), + { signal }, + ); + } catch (error) { + readStream.destroy(); + signal?.throwIfAborted(); + throw error; + } +} + +function extractEntries( + zipFile: ZipFile, + root: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: unknown) => { + if (settled) return; + settled = true; + signal?.removeEventListener('abort', onAbort); + zipFile.removeListener('close', onClose); + zipFile.removeListener('entry', onEntry); + if (error === undefined) resolve(); + else reject(error); + }; + const fail = (error: unknown) => { + finish(error); + zipFile.close(); + }; + const onAbort = () => fail(signal?.reason); + const onError = (error: Error) => fail(error); + const onClose = () => { + zipFile.removeListener('error', onError); + finish(); + }; + const onEntry = (entry: Entry) => { + void extractEntry(zipFile, entry, root, signal).then( + () => { + if (!settled) zipFile.readEntry(); + }, + (error: unknown) => fail(error), + ); + }; + + signal?.addEventListener('abort', onAbort, { once: true }); + zipFile.on('error', onError); + zipFile.on('close', onClose); + zipFile.on('entry', onEntry); + if (signal?.aborted) onAbort(); + else zipFile.readEntry(); + }); +} + +export async function extractZipArchive( + file: string, + destination: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + if (!path.isAbsolute(destination)) { + throw new Error('Target directory is expected to be absolute'); + } + await fs.promises.mkdir(destination, { recursive: true }); + const root = await fs.promises.realpath(destination); + signal?.throwIfAborted(); + const zipFile = await openZipFile(file, signal); + try { + await extractEntries(zipFile, root, signal); + } catch (error) { + signal?.throwIfAborted(); + throw error; + } +} diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 9176e4ffadc..22007b700fa 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -50,7 +50,10 @@ const rootDir = join(__dirname, '..'); // plus WorkspaceDaemonClient's workspace-qualified core REST helpers (Phase 3 // file/status/settings/agents/session APIs). // Bumped from 150KB to 151KB for the paged session transcript REST helper. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 151 * 1024; +// Bumped from 151KB to 154KB for extension management v2 catalog, activation, +// mutation, and operation-polling APIs (~2.3KB). +// Bumped from 154KB to 155KB after merging workspace skill-toggle APIs. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 155 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index a423a39cd9c..ec7269b4a0f 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -129,11 +129,15 @@ import type { DaemonWorkspaceExtensionsStatus, ExtensionMutationResponse, ExtensionInstallRequest, + ExtensionManagementInstallRequest, + ExtensionActivationState, + ExtensionCatalog, ExtensionInstallResponse, ExtensionOperationStatus, ExtensionScopeRequest, ExtensionRefreshResponse, ExtensionUpdateCheckResponse, + WorkspaceExtensionProjection, DaemonWorkspaceHooksStatus, DaemonPermissionRuleType, DaemonPermissionScope, @@ -219,6 +223,7 @@ const DEFAULT_SESSION_LIST_PAGE_SIZE = 20; const DEFAULT_FETCH_TIMEOUT_MS = 30_000; const VOICE_TRANSCRIPTION_DEFAULT_TIMEOUT_MS = 65_000; const GITHUB_SETUP_DEFAULT_TIMEOUT_MS = 90_000; +const MAX_TIMER_DELAY_MS = 2_147_483_647; // Keep in sync with acp-bridge bridge.ts and CLI serve/server.ts. const DEFAULT_MAX_PENDING_PROMPTS_PER_SESSION = 5; // Server deadline + headroom so the client never races the daemon's own budget. @@ -670,6 +675,7 @@ export class DaemonClient { clientId?: string; timeoutMs?: number; mode?: 'transport' | 'rest'; + signal?: AbortSignal; } = {}, ): Promise { const hasBody = opts.body !== undefined; @@ -682,6 +688,7 @@ export class DaemonClient { opts.clientId, ), ...(hasBody ? { body: JSON.stringify(opts.body) } : {}), + ...(opts.signal ? { signal: opts.signal } : {}), }, async (res) => { if (!res.ok) throw await this.failOnError(res, label); @@ -959,6 +966,7 @@ export class DaemonClient { return await this.jsonRequest( '/workspace/extensions', 'GET /workspace/extensions', + { mode: 'rest' }, ); } @@ -969,7 +977,7 @@ export class DaemonClient { return await this.jsonRequest( '/workspace/extensions/install', 'POST /workspace/extensions/install', - { method: 'POST', body: params, clientId }, + { method: 'POST', body: params, clientId, mode: 'rest' }, ); } @@ -979,6 +987,7 @@ export class DaemonClient { return await this.jsonRequest( `/workspace/extensions/operations/${urlEncode(operationId)}`, 'GET /workspace/extensions/operations/:operationId', + { mode: 'rest' }, ); } @@ -988,7 +997,7 @@ export class DaemonClient { return await this.jsonRequest( '/workspace/extensions/check-updates', 'POST /workspace/extensions/check-updates', - { method: 'POST', body: {}, clientId }, + { method: 'POST', body: {}, clientId, mode: 'rest' }, ); } @@ -998,7 +1007,7 @@ export class DaemonClient { return await this.jsonRequest( '/workspace/extensions/refresh', 'POST /workspace/extensions/refresh', - { method: 'POST', body: {}, clientId }, + { method: 'POST', body: {}, clientId, mode: 'rest' }, ); } @@ -1010,7 +1019,7 @@ export class DaemonClient { return await this.jsonRequest( `/workspace/extensions/${urlEncode(name)}/enable`, 'POST /workspace/extensions/:name/enable', - { method: 'POST', body: params, clientId }, + { method: 'POST', body: params, clientId, mode: 'rest' }, ); } @@ -1022,7 +1031,7 @@ export class DaemonClient { return await this.jsonRequest( `/workspace/extensions/${urlEncode(name)}/disable`, 'POST /workspace/extensions/:name/disable', - { method: 'POST', body: params, clientId }, + { method: 'POST', body: params, clientId, mode: 'rest' }, ); } @@ -1033,7 +1042,7 @@ export class DaemonClient { return await this.jsonRequest( `/workspace/extensions/${urlEncode(name)}/update`, 'POST /workspace/extensions/:name/update', - { method: 'POST', body: {}, clientId }, + { method: 'POST', body: {}, clientId, mode: 'rest' }, ); } @@ -1044,10 +1053,196 @@ export class DaemonClient { return await this.jsonRequest( `/workspace/extensions/${urlEncode(name)}`, 'DELETE /workspace/extensions/:name', - { method: 'DELETE', clientId }, + { method: 'DELETE', clientId, mode: 'rest' }, + ); + } + + async extensionCatalog(): Promise { + return await this.jsonRequest( + '/extensions', + 'GET /extensions', + { mode: 'rest' }, + ); + } + + async installUserExtension( + params: ExtensionManagementInstallRequest, + clientId?: string, + ): Promise { + return await this.jsonRequest( + '/extensions/install', + 'POST /extensions/install', + { method: 'POST', body: params, clientId, mode: 'rest' }, + ); + } + + async checkUserExtensionUpdates( + clientId?: string, + ): Promise { + return await this.jsonRequest( + '/extensions/check-updates', + 'POST /extensions/check-updates', + { method: 'POST', body: {}, clientId, mode: 'rest' }, + ); + } + + async updateUserExtension( + extensionId: string, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/extensions/${urlEncode(extensionId)}/update`, + 'POST /extensions/:extensionId/update', + { method: 'POST', body: {}, clientId, mode: 'rest' }, + ); + } + + async uninstallUserExtension( + extensionId: string, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/extensions/${urlEncode(extensionId)}`, + { + method: 'DELETE', + headers: this.headers({}, clientId), + }, + async (res) => { + if (res.status === 204) { + await res.body?.cancel().catch(() => undefined); + return undefined; + } + if (!res.ok) { + throw await this.failOnError(res, 'DELETE /extensions/:extensionId'); + } + return (await res.json()) as ExtensionMutationResponse; + }, + undefined, + 'rest', + ); + } + + async setExtensionDefaultActivation( + extensionId: string, + state: ExtensionActivationState, + clientId?: string, + ): Promise { + return await this.jsonRequest( + `/extensions/${urlEncode(extensionId)}/activation`, + 'PUT /extensions/:extensionId/activation', + { method: 'PUT', body: { state }, clientId, mode: 'rest' }, + ); + } + + async extensionOperation( + operationId: string, + signal?: AbortSignal, + ): Promise { + return await this.jsonRequest( + `/extensions/operations/${urlEncode(operationId)}`, + 'GET /extensions/operations/:operationId', + signal ? { signal, mode: 'rest' } : { mode: 'rest' }, ); } + async waitForExtensionOperation( + handle: ExtensionInstallResponse, + options: { + pollIntervalMs?: number; + timeoutMs?: number; + signal?: AbortSignal; + } = {}, + ): Promise { + const pollIntervalMs = options.pollIntervalMs ?? 1_000; + const timeoutMs = options.timeoutMs ?? 10 * 60_000; + const hasDeadline = timeoutMs !== Number.POSITIVE_INFINITY; + const deadline = Date.now() + timeoutMs; + const timeoutError = () => + new Error( + `Timed out waiting for extension operation ${handle.operationId}. The server operation was not cancelled.`, + ); + for (;;) { + options.signal?.throwIfAborted(); + const pollBudgetMs = deadline - Date.now(); + if (pollBudgetMs <= 0 || Number.isNaN(pollBudgetMs)) { + throw timeoutError(); + } + let operation: ExtensionOperationStatus; + if (!hasDeadline) { + operation = await this.extensionOperation( + handle.operationId, + options.signal, + ); + } else { + const deadlineController = new AbortController(); + const pollSignal = options.signal + ? composeAbortSignals([options.signal, deadlineController.signal]) + : deadlineController.signal; + let deadlineTimer: ReturnType | undefined; + const deadlinePromise = new Promise((_, reject) => { + const expire = () => { + const error = timeoutError(); + reject(error); + deadlineController.abort(error); + }; + const schedule = () => { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + expire(); + return; + } + deadlineTimer = setTimeout( + () => { + if (Date.now() >= deadline) { + expire(); + } else { + schedule(); + } + }, + Math.min(remainingMs, MAX_TIMER_DELAY_MS), + ); + }; + schedule(); + }); + try { + operation = await Promise.race([ + this.extensionOperation(handle.operationId, pollSignal), + deadlinePromise, + ]); + } finally { + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + deadlineController.abort(); + } + } + if (operation.status !== 'queued' && operation.status !== 'running') { + return operation; + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw timeoutError(); + } + await new Promise((resolve, reject) => { + const finish = () => { + options.signal?.removeEventListener('abort', onAbort); + resolve(); + }; + const timer = setTimeout( + finish, + Math.min(pollIntervalMs, remainingMs, MAX_TIMER_DELAY_MS), + ); + const onAbort = () => { + clearTimeout(timer); + options.signal?.removeEventListener('abort', onAbort); + reject( + options.signal?.reason ?? new DOMException('Aborted', 'AbortError'), + ); + }; + options.signal?.addEventListener('abort', onAbort, { once: true }); + if (options.signal?.aborted) onAbort(); + }); + } + } + // -- Workspace files (workspace files) ------------------------------- async readWorkspaceFile( @@ -4141,6 +4336,51 @@ export class WorkspaceDaemonClient { ); } + workspaceExtensions(): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/extensions', + 'GET /workspaces/:workspace/extensions', + { mode: 'rest' }, + ); + } + + setExtensionActivation( + extensionId: string, + state: ExtensionActivationState, + clientId?: string, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/extensions/${urlEncode(extensionId)}/activation`, + 'PUT /workspaces/:workspace/extensions/:extensionId/activation', + { method: 'PUT', body: { state }, clientId, mode: 'rest' }, + ); + } + + clearExtensionActivation( + extensionId: string, + clientId?: string, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/extensions/${urlEncode(extensionId)}/activation`, + 'DELETE /workspaces/:workspace/extensions/:extensionId/activation', + { method: 'DELETE', clientId, mode: 'rest' }, + ); + } + + refreshExtensionRuntime( + clientId?: string, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/extensions/refresh', + 'POST /workspaces/:workspace/extensions/refresh', + { method: 'POST', body: {}, clientId, mode: 'rest' }, + ); + } + private get(path: string, label: string, clientId?: string): Promise { return this.client.workspaceJsonRequest( this.workspaceSelector, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 04f8ebe196e..1ed142985a0 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -552,6 +552,14 @@ export type { DaemonExtensionUpdateState, DaemonWorkspaceExtensionsStatus, ExtensionInstallRequest, + ExtensionManagementInstallRequest, + ExtensionInitialActivation, + ExtensionActivationState, + ExtensionWorkspaceActivation, + ExtensionCatalogEntry, + ExtensionCatalog, + WorkspaceExtensionProjectionEntry, + WorkspaceExtensionProjection, ExtensionInstallResponse, ExtensionMutationResponse, ExtensionOperationResult, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 88ce84a556e..668d357d422 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -120,9 +120,9 @@ export interface DaemonCapabilities { */ workspaceCwd?: string; /** - * Registered workspace runtimes. Present only when the daemon advertises - * `multi_workspace_sessions`; `workspaceCwd` remains the primary cwd for - * old clients. + * Registered workspace runtimes. Newer daemons include the primary runtime + * even in single-workspace mode so workspace-qualified features can address + * it by ID; `workspaceCwd` remains the primary cwd for old clients. */ workspaces?: DaemonWorkspaceCapability[]; } @@ -2882,6 +2882,7 @@ export type DaemonExtensionInstallType = | 'git' | 'local' | 'link' + | 'archive-url' | 'github-release' | 'npm'; @@ -2901,6 +2902,7 @@ export interface DaemonExtensionCapabilities { export type DaemonExtensionUpdateState = | 'checking for updates' | 'updated, needs restart' + | 'updated with warnings' | 'updating' | 'updated' | 'update available' @@ -2954,6 +2956,58 @@ export interface ExtensionInstallRequest { consent?: boolean; } +export type ExtensionInitialActivation = + | { scope: 'user' } + | { scope: 'workspace'; workspaceId: string }; + +export interface ExtensionManagementInstallRequest + extends ExtensionInstallRequest { + consent: true; + activation: ExtensionInitialActivation; +} + +export type ExtensionActivationState = 'enabled' | 'disabled'; +export type ExtensionWorkspaceActivation = ExtensionActivationState | null; + +export interface ExtensionCatalogEntry { + id: string; + name: string; + version: string; + installType?: DaemonExtensionInstallType; + defaultActivation: ExtensionActivationState; + workspaceOverrideCount: number; +} + +export interface ExtensionCatalog { + v: 1; + generation: number; + extensions: ExtensionCatalogEntry[]; +} + +export interface WorkspaceExtensionProjectionEntry { + extensionId: string; + name: string; + version: string; + defaultActivation: ExtensionActivationState; + workspaceActivation: ExtensionWorkspaceActivation; + effectiveActivation: ExtensionActivationState; + activationSource: + | 'cli_override' + | 'workspace_override' + | 'legacy_path_rule' + | 'default'; +} + +export interface WorkspaceExtensionProjection { + v: 1; + workspaceId: string; + workspaceCwd: string; + trusted: boolean; + desiredGeneration: number; + appliedGeneration: number; + extensions: WorkspaceExtensionProjectionEntry[]; +} + export interface ExtensionInstallResponse { accepted: true; operationId: string; @@ -2966,16 +3020,27 @@ export type ExtensionOperationState = | 'running' | 'succeeded' | 'succeeded_with_refresh_error' + | 'succeeded_with_warnings' | 'failed'; export interface ExtensionOperationResult { - status: 'installed' | 'enabled' | 'disabled' | 'updated' | 'uninstalled'; + status: + | 'installed' + | 'enabled' + | 'disabled' + | 'updated' + | 'uninstalled' + | 'checked' + | 'refreshed'; source?: string; name?: string; version?: string; refreshed?: number; failed?: number; error?: string; + updated?: boolean; + reason?: string; + states?: Record; } export interface ExtensionOperationStatus { @@ -2983,12 +3048,20 @@ export interface ExtensionOperationStatus { operationId: string; operation: string; status: ExtensionOperationState; + phase?: 'preparing' | 'committing' | 'reconciling'; createdAt: number; updatedAt: number; source?: string; name?: string; result?: ExtensionOperationResult; error?: string; + code?: string; + warnings?: Array<{ + workspaceId?: string; + workspaceCwd: string; + code?: string; + error: string; + }>; } export type ExtensionScope = 'user' | 'workspace'; diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 0497621abbe..36a84378941 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -3996,6 +3996,402 @@ describe('DaemonClient', () => { }); }); + describe('extension management v2', () => { + it('waits for an operation without cancelling it when polling completes', async () => { + let polls = 0; + const { fetch } = recordingFetch(() => { + polls += 1; + return jsonResponse(200, { + v: 1, + operationId: 'op-1', + operation: 'install', + status: polls === 1 ? 'running' : 'succeeded', + createdAt: 1, + updatedAt: 2, + }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const result = await client.waitForExtensionOperation( + { accepted: true, operationId: 'op-1' }, + { pollIntervalMs: 0, timeoutMs: 100 }, + ); + + expect(result.status).toBe('succeeded'); + expect(polls).toBe(2); + }); + + it('disposes fallback abort listeners after a settled poll', async () => { + const anyDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, 'any'); + Object.defineProperty(AbortSignal, 'any', { + configurable: true, + value: undefined, + }); + const controller = new AbortController(); + const removeEventListener = vi.spyOn( + controller.signal, + 'removeEventListener', + ); + const { fetch } = recordingFetch(() => + jsonResponse(200, { + v: 1, + operationId: 'op-1', + operation: 'install', + status: 'succeeded', + createdAt: 1, + updatedAt: 2, + }), + ); + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch, + fetchTimeoutMs: 0, + }); + + try { + await client.waitForExtensionOperation( + { accepted: true, operationId: 'op-1' }, + { timeoutMs: 100, signal: controller.signal }, + ); + expect(removeEventListener).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + expect(removeEventListener).toHaveBeenCalledTimes(1); + expect(controller.signal.aborted).toBe(false); + } finally { + if (anyDescriptor) { + Object.defineProperty(AbortSignal, 'any', anyDescriptor); + } else { + delete (AbortSignal as { any?: unknown }).any; + } + } + }); + + it('times out polling without cancelling the accepted operation', async () => { + let polls = 0; + const { fetch } = recordingFetch(() => { + polls += 1; + return jsonResponse(200, { + v: 1, + operationId: 'op-1', + operation: 'install', + status: 'running', + createdAt: 1, + updatedAt: 2, + }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.waitForExtensionOperation( + { accepted: true, operationId: 'op-1' }, + { timeoutMs: 0 }, + ), + ).rejects.toThrow('server operation was not cancelled'); + expect(polls).toBe(0); + }); + + it('aborts an in-flight poll when the operation deadline expires', async () => { + let pollSignal: AbortSignal | null | undefined; + const { fetch } = recordingFetch( + (request) => + new Promise(() => { + pollSignal = request.signal; + }), + ); + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch, + fetchTimeoutMs: 0, + }); + + await expect( + client.waitForExtensionOperation( + { accepted: true, operationId: 'op-1' }, + { timeoutMs: 10 }, + ), + ).rejects.toThrow('server operation was not cancelled'); + expect(pollSignal?.aborted).toBe(true); + }); + + it('aborts an in-flight poll when the caller aborts', async () => { + let pollSignal: AbortSignal | null | undefined; + const { fetch } = recordingFetch( + (request) => + new Promise((_resolve, reject) => { + pollSignal = request.signal; + request.signal?.addEventListener( + 'abort', + () => reject(request.signal?.reason), + { once: true }, + ); + }), + ); + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch, + fetchTimeoutMs: 0, + }); + const controller = new AbortController(); + const reason = new Error('navigation cancelled'); + + const waiting = client.waitForExtensionOperation( + { accepted: true, operationId: 'op-1' }, + { signal: controller.signal }, + ); + await vi.waitFor(() => expect(pollSignal).toBeDefined()); + controller.abort(reason); + + await expect(waiting).rejects.toBe(reason); + expect(pollSignal?.aborted).toBe(true); + }); + + it('supports an unbounded operation timeout', async () => { + let pollSignal: AbortSignal | null | undefined; + const { fetch } = recordingFetch( + (request) => + new Promise((_resolve, reject) => { + pollSignal = request.signal; + request.signal?.addEventListener( + 'abort', + () => reject(request.signal?.reason), + { once: true }, + ); + }), + ); + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch, + fetchTimeoutMs: 0, + }); + const controller = new AbortController(); + const reason = new Error('stop unbounded wait'); + const outcome = client + .waitForExtensionOperation( + { accepted: true, operationId: 'op-1' }, + { timeoutMs: Number.POSITIVE_INFINITY, signal: controller.signal }, + ) + .catch((error: unknown) => error); + + try { + await vi.waitFor(() => expect(pollSignal).toBeDefined()); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(pollSignal?.aborted).toBe(false); + controller.abort(reason); + await expect(outcome).resolves.toBe(reason); + } finally { + controller.abort(reason); + } + }); + + it('chunks operation timeouts larger than the maximum timer delay', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + let pollSignal: AbortSignal | null | undefined; + const { fetch } = recordingFetch( + (request) => + new Promise((_resolve, reject) => { + pollSignal = request.signal; + request.signal?.addEventListener( + 'abort', + () => reject(request.signal?.reason), + { once: true }, + ); + }), + ); + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch, + fetchTimeoutMs: 0, + }); + const maximumTimerDelayMs = 2_147_483_647; + const outcome = client + .waitForExtensionOperation( + { accepted: true, operationId: 'op-1' }, + { timeoutMs: maximumTimerDelayMs + 100 }, + ) + .catch((error: unknown) => error); + + try { + await vi.advanceTimersByTimeAsync(maximumTimerDelayMs); + expect(pollSignal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(99); + expect(pollSignal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await expect(outcome).resolves.toMatchObject({ + message: expect.stringContaining( + 'server operation was not cancelled', + ), + }); + expect(pollSignal?.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('caps polling intervals larger than the maximum timer delay', async () => { + vi.useFakeTimers(); + let polls = 0; + const { fetch } = recordingFetch(() => { + polls += 1; + return jsonResponse(200, { + v: 1, + operationId: 'op-1', + operation: 'install', + status: polls === 1 ? 'running' : 'succeeded', + createdAt: 1, + updatedAt: 2, + }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const maximumTimerDelayMs = 2_147_483_647; + const waiting = client.waitForExtensionOperation( + { accepted: true, operationId: 'op-1' }, + { + pollIntervalMs: maximumTimerDelayMs + 100, + timeoutMs: Number.POSITIVE_INFINITY, + }, + ); + + try { + await vi.advanceTimersByTimeAsync(maximumTimerDelayMs); + await expect(waiting).resolves.toMatchObject({ status: 'succeeded' }); + expect(polls).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it('routes global extension methods through /extensions/*', async () => { + const { fetch, calls } = recordingFetch((req) => { + if (req.url === 'http://daemon/extensions') { + return jsonResponse(200, { v: 1, generation: 1, extensions: [] }); + } + if (req.url.includes('/operations/')) { + return jsonResponse(200, { + v: 1, + operationId: 'op-1', + operation: 'install', + status: 'succeeded', + createdAt: 1, + updatedAt: 2, + }); + } + return jsonResponse(202, { accepted: true, operationId: 'op-2' }); + }); + const transportFetch = vi.fn(async () => + jsonResponse(500, { error: 'transport should not be used' }), + ); + const transport: DaemonTransport = { + type: 'acp-http', + supportsReplay: true, + connected: true, + fetch: transportFetch, + async *subscribeEvents() {}, + dispose() {}, + }; + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch, + transport, + }); + + await client.extensionCatalog(); + await client.installUserExtension( + { + source: 'owner/repo', + consent: true, + activation: { scope: 'user' }, + }, + 'client-1', + ); + await client.checkUserExtensionUpdates('client-1'); + await client.updateUserExtension('a'.repeat(64), 'client-1'); + await client.uninstallUserExtension('a'.repeat(64), 'client-1'); + await client.setExtensionDefaultActivation( + 'a'.repeat(64), + 'disabled', + 'client-1', + ); + await client.extensionOperation('op-1'); + + expect(calls.map((c) => [c.method, c.url])).toEqual([ + ['GET', 'http://daemon/extensions'], + ['POST', 'http://daemon/extensions/install'], + ['POST', 'http://daemon/extensions/check-updates'], + ['POST', `http://daemon/extensions/${'a'.repeat(64)}/update`], + ['DELETE', `http://daemon/extensions/${'a'.repeat(64)}`], + ['PUT', `http://daemon/extensions/${'a'.repeat(64)}/activation`], + ['GET', 'http://daemon/extensions/operations/op-1'], + ]); + expect(transportFetch).not.toHaveBeenCalled(); + }); + + it('treats a missing V2 extension uninstall as idempotent success', async () => { + const { fetch } = recordingFetch( + () => new Response(null, { status: 204 }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await expect( + client.uninstallUserExtension('a'.repeat(64)), + ).resolves.toBeUndefined(); + }); + + it('routes only projection and activation methods through a workspace', async () => { + const status = { + v: 1, + workspaceId: 'ws-a', + workspaceCwd: '/work/a', + trusted: true, + desiredGeneration: 1, + appliedGeneration: 1, + extensions: [], + }; + const { fetch, calls } = recordingFetch((req) => { + if (req.url.endsWith('/extensions')) return jsonResponse(200, status); + return jsonResponse(202, { accepted: true, operationId: 'op-2' }); + }); + const transportFetch = vi.fn(async () => + jsonResponse(500, { error: 'transport should not be used' }), + ); + const transport: DaemonTransport = { + type: 'acp-http', + supportsReplay: true, + connected: true, + fetch: transportFetch, + async *subscribeEvents() {}, + dispose() {}, + }; + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch, + transport, + }); + const ws = client.workspaceByCwd('/work/a'); + + await expect(ws.workspaceExtensions()).resolves.toEqual(status); + await ws.setExtensionActivation('a'.repeat(64), 'enabled', 'client-1'); + await ws.clearExtensionActivation('a'.repeat(64), 'client-1'); + await ws.refreshExtensionRuntime('client-1'); + + expect(calls.map((c) => [c.method, c.url])).toEqual([ + ['GET', 'http://daemon/workspaces/%2Fwork%2Fa/extensions'], + [ + 'PUT', + `http://daemon/workspaces/%2Fwork%2Fa/extensions/${'a'.repeat(64)}/activation`, + ], + [ + 'DELETE', + `http://daemon/workspaces/%2Fwork%2Fa/extensions/${'a'.repeat(64)}/activation`, + ], + ['POST', 'http://daemon/workspaces/%2Fwork%2Fa/extensions/refresh'], + ]); + expect(transportFetch).not.toHaveBeenCalled(); + }); + }); + describe('error coercion', () => { it('falls back to text body when the response is not JSON', async () => { const { fetch } = recordingFetch( diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index c2e42bbdde9..2f985891309 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -6267,67 +6267,67 @@ export function App({ .join(' '); const messageList = ( - + ); const btwPanel = !showMobileWelcomeFooterMiddle && btwMessage?.role === 'btw' ? ( -
- -
+
+ +
) : null; if (showMobileWelcomeFooterMiddle) { diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx index c4c9a618ab8..740228ab707 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx @@ -1536,7 +1536,13 @@ export function EnhancedTable({ } }; const clearActiveColumnOnEscape = (event: KeyboardEvent) => { - if (event.defaultPrevented || openFilterMenu || cellDialog || columnContextMenu) return; + if ( + event.defaultPrevented || + openFilterMenu || + cellDialog || + columnContextMenu + ) + return; if (event.key === 'Escape') setActiveColumn(null); }; document.addEventListener('mousedown', clearActiveColumnOnOutsideMouseDown);