From a1a8873edcedf2f7d1b90bc972c61e515c7f69f7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 19 May 2026 11:56:00 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20Mux=20Extension=20?= =?UTF-8?q?Platform=20v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 38 +- ...001-permissions-are-requests-not-grants.md | 25 + ...ble-v1-excludes-code-execution-surfaces.md | 26 + ...nsion-identity-vs-distribution-identity.md | 25 + ...additive-platform-with-a-demo-extension.md | 24 + .../0005-v1-platform-security-boundaries.md | 27 + docs/docs.json | 8 + docs/extensions/authoring.mdx | 136 ++ docs/extensions/release-checklist.mdx | 218 ++ docs/extensions/telemetry.mdx | 226 ++ package.json | 10 + .../mux-extension-platform-demo/README.md | 67 + packages/mux-extension-platform-demo/SKILL.md | 99 + .../mux-extension-platform-demo/extension.ts | 32 + .../mux-extension-platform-demo/package.json | 12 + rfc/extensions-platform-context.md | 143 ++ rfc/extensions-platform-prd.md | 416 ++++ scripts/bundled-extensions.ts | 257 +++ scripts/check_codex_comments.sh | 19 +- scripts/lib/coder_agents_review.sh | 18 +- scripts/wait_pr_coder_agents_review.sh | 81 +- src/browser/App.tsx | 3 + .../SkillIndicator/SkillIndicator.tsx | 1 + .../contexts/ExperimentsContext.test.tsx | 93 + src/browser/contexts/ExperimentsContext.tsx | 25 + src/browser/contexts/PolicyContext.test.tsx | 1 + .../Sections/ConsentShortcutModal.test.tsx | 150 ++ .../Sections/ConsentShortcutModal.tsx | 216 ++ .../DestructiveConfirmDialog.test.tsx | 75 + .../Sections/DestructiveConfirmDialog.tsx | 118 + .../Settings/Sections/ExtensionCard.test.tsx | 432 ++++ .../Settings/Sections/ExtensionCard.tsx | 765 +++++++ .../ExtensionsCheatSheetModal.test.tsx | 30 + .../Sections/ExtensionsCheatSheetModal.tsx | 109 + .../Sections/ExtensionsSection.test.tsx | 1106 +++++++++ .../Settings/Sections/ExtensionsSection.tsx | 1152 ++++++++++ .../Sections/GovernorSection.stories.tsx | 4 + .../Settings/Sections/KeybindsSection.tsx | 27 + .../features/Settings/Sections/dialogFocus.ts | 37 + .../Sections/extensionDiagnostics.test.ts | 371 +++ .../Settings/Sections/extensionDiagnostics.ts | 295 +++ .../features/Settings/SettingsPage.test.tsx | 1 + .../features/Settings/SettingsPage.tsx | 36 +- src/browser/hooks/useAnalytics.test.tsx | 11 +- .../hooks/useContextSwitchWarning.test.ts | 1 + .../hooks/useExtensionsPaletteSource.test.ts | 260 +++ .../hooks/useExtensionsPaletteSource.ts | 188 ++ src/browser/utils/commandIds.ts | 7 + .../StreamingMessageAggregator.skills.test.ts | 41 + .../messages/StreamingMessageAggregator.ts | 5 +- src/browser/utils/policyUi.test.ts | 1 + src/browser/utils/ui/keybinds.ts | 30 + src/cli/debug/extensions-install.test.ts | 33 + src/cli/debug/extensions-install.ts | 28 + src/cli/debug/extensions.test.ts | 286 +++ src/cli/debug/extensions.ts | 93 + src/cli/debug/index.ts | 19 + src/cli/extensions.test.ts | 155 ++ src/cli/extensions.ts | 186 ++ src/cli/index.ts | 26 + src/common/config/schemas/appConfigOnDisk.ts | 4 + src/common/extensions/approvalDrift.ts | 9 + .../extensions/conflictResolver.test.ts | 388 ++++ src/common/extensions/conflictResolver.ts | 178 ++ .../extensions/extensionPermissionKey.ts | 15 + src/common/extensions/extensionSkillSource.ts | 8 + .../extensions/extensionTelemetry.test.ts | 258 +++ src/common/extensions/extensionTelemetry.ts | 180 ++ .../extensions/globalExtensionState.test.ts | 134 ++ src/common/extensions/globalExtensionState.ts | 158 ++ .../extensions/manifestValidator.test.ts | 466 ++++ src/common/extensions/manifestValidator.ts | 443 ++++ .../extensions/permissionCalculator.test.ts | 250 +++ src/common/extensions/permissionCalculator.ts | 116 + .../extensions/projectExtensionState.test.ts | 144 ++ .../extensions/projectExtensionState.ts | 152 ++ src/common/extensions/snapshotCache.test.ts | 188 ++ src/common/extensions/snapshotCache.ts | 72 + src/common/extensions/sourceLocks.test.ts | 91 + src/common/extensions/sourceLocks.ts | 77 + src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/agentSkill.ts | 3 +- src/common/orpc/schemas/api.ts | 7 + src/common/orpc/schemas/extension.test.ts | 455 ++++ src/common/orpc/schemas/extension.ts | 257 +++ .../orpc/schemas/extensionRegistry.test.ts | 53 + src/common/orpc/schemas/extensionRegistry.ts | 235 ++ src/common/orpc/schemas/policy.test.ts | 20 + src/common/orpc/schemas/policy.ts | 8 + src/common/types/message.ts | 2 +- src/common/types/project.ts | 8 + src/common/utils/tools/tools.ts | 2 + src/node/builtinSkills/mux-docs.md | 4 + src/node/config.ts | 9 + .../bundledExtensionRootResolver.test.ts | 198 ++ .../bundledExtensionRootResolver.ts | 75 + .../bundledExtensionsAssemble.test.ts | 182 ++ .../extensionDiscoveryService.test.ts | 1929 ++++++++++++++++ .../extensions/extensionDiscoveryService.ts | 1000 +++++++++ .../extensionPathContainment.test.ts | 176 ++ .../extensions/extensionPathContainment.ts | 68 + .../extensionRegistrationDiscoveryService.ts | 568 +++++ .../extensionRegistryService.test.ts | 1990 +++++++++++++++++ .../extensions/extensionRegistryService.ts | 890 ++++++++ .../extensions/extensionRootWatcher.test.ts | 558 +++++ src/node/extensions/extensionRootWatcher.ts | 325 +++ src/node/extensions/extensionRoots.test.ts | 332 +++ src/node/extensions/extensionRoots.ts | 184 ++ .../gitExtensionSourceInstaller.test.ts | 261 +++ .../extensions/gitExtensionSourceInstaller.ts | 269 +++ .../globalExtensionStateService.test.ts | 206 ++ .../extensions/globalExtensionStateService.ts | 65 + .../projectExtensionSourceSync.test.ts | 517 +++++ .../extensions/projectExtensionSourceSync.ts | 237 ++ .../projectExtensionStateService.test.ts | 254 +++ .../projectExtensionStateService.ts | 156 ++ .../extensions/snapshotCacheService.test.ts | 270 +++ src/node/extensions/snapshotCacheService.ts | 88 + .../staticManifestExtractor.test.ts | 81 + .../extensions/staticManifestExtractor.ts | 219 ++ src/node/extensions/testExtensionRegistry.ts | 95 + src/node/orpc/context.ts | 2 + src/node/orpc/extensionsRouter.test.ts | 578 +++++ src/node/orpc/router.ts | 255 ++- .../agentSession.agentSkillSnapshot.test.ts | 54 + src/node/services/agentSession.testHarness.ts | 3 + src/node/services/agentSession.ts | 20 +- .../agentSkills/agentSkillsService.test.ts | 283 ++- .../agentSkills/agentSkillsService.ts | 158 +- .../builtInSkillContent.generated.ts | 601 +++++ src/node/services/aiService.ts | 21 + src/node/services/experimentsService.test.ts | 10 +- src/node/services/experimentsService.ts | 31 +- .../extensionTelemetryService.test.ts | 107 + .../services/extensionTelemetryService.ts | 38 + src/node/services/messageQueue.ts | 15 +- src/node/services/policyService.test.ts | 22 + src/node/services/policyService.ts | 2 + src/node/services/ptc/quickjsRuntime.test.ts | 39 + src/node/services/ptc/quickjsRuntime.ts | 144 +- src/node/services/serviceContainer.ts | 96 + .../services/streamContextBuilder.test.ts | 42 + src/node/services/streamContextBuilder.ts | 4 + src/node/services/telemetryService.ts | 24 + .../services/tools/agent_skill_list.test.ts | 71 + src/node/services/tools/agent_skill_list.ts | 20 + src/node/services/tools/agent_skill_read.ts | 1 + .../tools/agent_skill_read_file.test.ts | 201 +- .../services/tools/agent_skill_read_file.ts | 77 +- src/node/services/tools/skillFileUtils.ts | 2 +- src/node/services/tools/testHelpers.ts | 2 + src/node/services/workspaceService.ts | 13 + src/node/utils/openedFileRealpath.test.ts | 48 + src/node/utils/openedFileRealpath.ts | 20 + tests/e2e/scenarios/extensionPlatform.spec.ts | 50 + tests/ui/dom.ts | 19 + 156 files changed, 26866 insertions(+), 119 deletions(-) create mode 100644 docs/adr/0001-permissions-are-requests-not-grants.md create mode 100644 docs/adr/0002-stable-v1-excludes-code-execution-surfaces.md create mode 100644 docs/adr/0003-extension-identity-vs-distribution-identity.md create mode 100644 docs/adr/0004-v1-is-an-additive-platform-with-a-demo-extension.md create mode 100644 docs/adr/0005-v1-platform-security-boundaries.md create mode 100644 docs/extensions/authoring.mdx create mode 100644 docs/extensions/release-checklist.mdx create mode 100644 docs/extensions/telemetry.mdx create mode 100644 packages/mux-extension-platform-demo/README.md create mode 100644 packages/mux-extension-platform-demo/SKILL.md create mode 100644 packages/mux-extension-platform-demo/extension.ts create mode 100644 packages/mux-extension-platform-demo/package.json create mode 100644 rfc/extensions-platform-context.md create mode 100644 rfc/extensions-platform-prd.md create mode 100644 scripts/bundled-extensions.ts create mode 100644 src/browser/features/Settings/Sections/ConsentShortcutModal.test.tsx create mode 100644 src/browser/features/Settings/Sections/ConsentShortcutModal.tsx create mode 100644 src/browser/features/Settings/Sections/DestructiveConfirmDialog.test.tsx create mode 100644 src/browser/features/Settings/Sections/DestructiveConfirmDialog.tsx create mode 100644 src/browser/features/Settings/Sections/ExtensionCard.test.tsx create mode 100644 src/browser/features/Settings/Sections/ExtensionCard.tsx create mode 100644 src/browser/features/Settings/Sections/ExtensionsCheatSheetModal.test.tsx create mode 100644 src/browser/features/Settings/Sections/ExtensionsCheatSheetModal.tsx create mode 100644 src/browser/features/Settings/Sections/ExtensionsSection.test.tsx create mode 100644 src/browser/features/Settings/Sections/ExtensionsSection.tsx create mode 100644 src/browser/features/Settings/Sections/dialogFocus.ts create mode 100644 src/browser/features/Settings/Sections/extensionDiagnostics.test.ts create mode 100644 src/browser/features/Settings/Sections/extensionDiagnostics.ts create mode 100644 src/browser/hooks/useExtensionsPaletteSource.test.ts create mode 100644 src/browser/hooks/useExtensionsPaletteSource.ts create mode 100644 src/cli/debug/extensions-install.test.ts create mode 100644 src/cli/debug/extensions-install.ts create mode 100644 src/cli/debug/extensions.test.ts create mode 100644 src/cli/debug/extensions.ts create mode 100644 src/cli/extensions.test.ts create mode 100644 src/cli/extensions.ts create mode 100644 src/common/extensions/approvalDrift.ts create mode 100644 src/common/extensions/conflictResolver.test.ts create mode 100644 src/common/extensions/conflictResolver.ts create mode 100644 src/common/extensions/extensionPermissionKey.ts create mode 100644 src/common/extensions/extensionSkillSource.ts create mode 100644 src/common/extensions/extensionTelemetry.test.ts create mode 100644 src/common/extensions/extensionTelemetry.ts create mode 100644 src/common/extensions/globalExtensionState.test.ts create mode 100644 src/common/extensions/globalExtensionState.ts create mode 100644 src/common/extensions/manifestValidator.test.ts create mode 100644 src/common/extensions/manifestValidator.ts create mode 100644 src/common/extensions/permissionCalculator.test.ts create mode 100644 src/common/extensions/permissionCalculator.ts create mode 100644 src/common/extensions/projectExtensionState.test.ts create mode 100644 src/common/extensions/projectExtensionState.ts create mode 100644 src/common/extensions/snapshotCache.test.ts create mode 100644 src/common/extensions/snapshotCache.ts create mode 100644 src/common/extensions/sourceLocks.test.ts create mode 100644 src/common/extensions/sourceLocks.ts create mode 100644 src/common/orpc/schemas/extension.test.ts create mode 100644 src/common/orpc/schemas/extension.ts create mode 100644 src/common/orpc/schemas/extensionRegistry.test.ts create mode 100644 src/common/orpc/schemas/extensionRegistry.ts create mode 100644 src/node/extensions/bundledExtensionRootResolver.test.ts create mode 100644 src/node/extensions/bundledExtensionRootResolver.ts create mode 100644 src/node/extensions/bundledExtensionsAssemble.test.ts create mode 100644 src/node/extensions/extensionDiscoveryService.test.ts create mode 100644 src/node/extensions/extensionDiscoveryService.ts create mode 100644 src/node/extensions/extensionPathContainment.test.ts create mode 100644 src/node/extensions/extensionPathContainment.ts create mode 100644 src/node/extensions/extensionRegistrationDiscoveryService.ts create mode 100644 src/node/extensions/extensionRegistryService.test.ts create mode 100644 src/node/extensions/extensionRegistryService.ts create mode 100644 src/node/extensions/extensionRootWatcher.test.ts create mode 100644 src/node/extensions/extensionRootWatcher.ts create mode 100644 src/node/extensions/extensionRoots.test.ts create mode 100644 src/node/extensions/extensionRoots.ts create mode 100644 src/node/extensions/gitExtensionSourceInstaller.test.ts create mode 100644 src/node/extensions/gitExtensionSourceInstaller.ts create mode 100644 src/node/extensions/globalExtensionStateService.test.ts create mode 100644 src/node/extensions/globalExtensionStateService.ts create mode 100644 src/node/extensions/projectExtensionSourceSync.test.ts create mode 100644 src/node/extensions/projectExtensionSourceSync.ts create mode 100644 src/node/extensions/projectExtensionStateService.test.ts create mode 100644 src/node/extensions/projectExtensionStateService.ts create mode 100644 src/node/extensions/snapshotCacheService.test.ts create mode 100644 src/node/extensions/snapshotCacheService.ts create mode 100644 src/node/extensions/staticManifestExtractor.test.ts create mode 100644 src/node/extensions/staticManifestExtractor.ts create mode 100644 src/node/extensions/testExtensionRegistry.ts create mode 100644 src/node/orpc/extensionsRouter.test.ts create mode 100644 src/node/services/extensionTelemetryService.test.ts create mode 100644 src/node/services/extensionTelemetryService.ts create mode 100644 src/node/utils/openedFileRealpath.test.ts create mode 100644 src/node/utils/openedFileRealpath.ts create mode 100644 tests/e2e/scenarios/extensionPlatform.spec.ts diff --git a/Makefile b/Makefile index 3e7b40ad59..7801a60e37 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ MAKEFLAGS += -j endif # Common esbuild flags for CLI API bundle (ESM format for trpc-cli) -ESBUILD_CLI_FLAGS := --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:jsonc-parser --external:@trpc/server --external:ssh2 --external:cpu-features --external:@1password/sdk --external:@1password/sdk-core --banner:js="import{createRequire}from'module';globalThis.require=createRequire(import.meta.url);" +ESBUILD_CLI_FLAGS := --bundle --format=esm --platform=node --target=node20 --outfile=dist/cli/api.mjs --external:zod --external:commander --external:jsonc-parser --external:@trpc/server --external:ssh2 --external:cpu-features --external:typescript --external:@1password/sdk --external:@1password/sdk-core --banner:js="import{createRequire}from'module';globalThis.require=createRequire(import.meta.url);" # Common esbuild flags for server runtime Docker bundle. # Place runtime bundles under dist/runtime so frontend dist/*.js layers remain stable. @@ -71,6 +71,7 @@ include fmt.mk .PHONY: benchmark-terminal .PHONY: ensure-deps rebuild-native mux .PHONY: check-eager-imports check-bundle-size check-startup +.PHONY: bundled-extensions-validate bundled-extensions-build bundled-extensions-assemble # Build tools TSGO := bun run node_modules/@typescript/native-preview/bin/tsgo.js @@ -130,7 +131,7 @@ rebuild-native: node_modules/.installed ## Rebuild native modules (node-pty, Duc @echo "Native modules rebuilt successfully" # Run compiled CLI with trailing arguments (builds only if missing) -mux: ## Run the compiled mux CLI (e.g., make mux server --port 3000) +mux: bundled-extensions-assemble ## Run the compiled mux CLI (e.g., make mux server --port 3000) @test -f dist/cli/index.js -a -f dist/cli/api.mjs || $(MAKE) build-main @node dist/cli/index.js $(filter-out $@,$(MAKECMDGOALS)) @@ -149,7 +150,7 @@ help: ## Show this help message ## Development ifeq ($(OS),Windows_NT) -dev: node_modules/.installed build-main ## Start development server (Vite + nodemon watcher for Windows compatibility) +dev: node_modules/.installed build-main bundled-extensions-assemble ## Start development server (Vite + nodemon watcher for Windows compatibility) @echo "Starting dev mode (3 watchers: nodemon for main process, esbuild for api, vite for renderer)..." # On Windows, use npm run because bunx doesn't correctly pass arguments to concurrently # https://github.com/oven-sh/bun/issues/18275 @@ -159,7 +160,7 @@ dev: node_modules/.installed build-main ## Start development server (Vite + node 'npx esbuild src/cli/api.ts $(ESBUILD_CLI_FLAGS) --watch' \ "vite" else -dev: node_modules/.installed build-main build-preload ## Start development server (Vite + tsgo watcher for 10x faster type checking) +dev: node_modules/.installed build-main build-preload bundled-extensions-assemble ## Start development server (Vite + tsgo watcher for 10x faster type checking) @bun x concurrently -k \ "bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \ 'bun x esbuild src/cli/api.ts $(ESBUILD_CLI_FLAGS) --watch' \ @@ -167,7 +168,7 @@ dev: node_modules/.installed build-main build-preload ## Start development serve endif ifeq ($(OS),Windows_NT) -dev-server: node_modules/.installed build-main ## Start server mode with hot reload (backend :3000 + frontend :5173). Use VITE_HOST=0.0.0.0 VITE_ALLOWED_HOSTS= for remote access +dev-server: node_modules/.installed build-main bundled-extensions-assemble ## Start server mode with hot reload (backend :3000 + frontend :5173). Use VITE_HOST=0.0.0.0 VITE_ALLOWED_HOSTS= for remote access @echo "Starting dev-server..." @echo " Backend (IPC/WebSocket): http://$(or $(BACKEND_HOST),127.0.0.1):$(or $(BACKEND_PORT),3000)" @echo " Frontend (with HMR): http://$(or $(VITE_HOST),localhost):$(or $(VITE_PORT),5173)" @@ -180,7 +181,7 @@ dev-server: node_modules/.installed build-main ## Start server mode with hot rel "set NODE_ENV=development&& nodemon --watch dist/cli/index.js --watch dist/cli/server.js --delay 500ms dist/cli/index.js server --no-auth --host $(or $(BACKEND_HOST),127.0.0.1) --port $(or $(BACKEND_PORT),3000)" \ "set MUX_VITE_HOST=$(or $(VITE_HOST),127.0.0.1)&& set MUX_VITE_PORT=$(or $(VITE_PORT),5173)&& set MUX_VITE_ALLOWED_HOSTS=$(VITE_ALLOWED_HOSTS)&& set MUX_BACKEND_PORT=$(or $(BACKEND_PORT),3000)&& vite" else -dev-server: node_modules/.installed build-main ## Start server mode with hot reload (backend :3000 + frontend :5173). Use VITE_HOST=0.0.0.0 VITE_ALLOWED_HOSTS= for remote access +dev-server: node_modules/.installed build-main bundled-extensions-assemble ## Start server mode with hot reload (backend :3000 + frontend :5173). Use VITE_HOST=0.0.0.0 VITE_ALLOWED_HOSTS= for remote access @echo "Starting dev-server..." @echo " Backend (IPC/WebSocket): http://$(or $(BACKEND_HOST),127.0.0.1):$(or $(BACKEND_PORT),3000)" @echo " Frontend (with HMR): http://$(or $(VITE_HOST),localhost):$(or $(VITE_PORT),5173)" @@ -202,11 +203,24 @@ dev-desktop-sandbox: ## Start an isolated Electron dev instance (fresh MUX_ROOT dev-server-sandbox: ## Start an isolated dev-server instance (fresh MUX_ROOT + free ports) @bun scripts/dev-server-sandbox.ts $(DEV_SERVER_SANDBOX_ARGS) -start: node_modules/.installed build-main build-preload build-static ## Build and start Electron app +start: node_modules/.installed build-main build-preload build-static bundled-extensions-assemble ## Build and start Electron app @NODE_ENV=development bunx electron --remote-debugging-port=9222 . +## Bundled Extensions pipeline +# Source the bundled-extensions script for validate/build/assemble. Outputs land +# under build/extensions/ (the "/extensions/" path electron-builder will +# pick up via extraResources in US-020). +bundled-extensions-validate: node_modules/.installed ## Validate each packages/ manifest via the production Manifest Validator + @bun scripts/bundled-extensions.ts validate + +bundled-extensions-build: node_modules/.installed ## Run each bundled extension package's build script (no-op when absent) + @bun scripts/bundled-extensions.ts build + +bundled-extensions-assemble: node_modules/.installed bundled-extensions-validate bundled-extensions-build ## Pack and extract bundled extensions into build/extensions/ (deterministic, offline) + @bun scripts/bundled-extensions.ts assemble + ## Build targets (can run in parallel) -build: node_modules/.installed src/version.ts build-renderer build-main build-preload build-icons build-static ## Build all targets +build: node_modules/.installed src/version.ts build-renderer build-main build-preload build-icons build-static bundled-extensions-assemble ## Build all targets build-main: node_modules/.installed dist/cli/index.js dist/cli/api.mjs ## Build main process @@ -327,7 +341,7 @@ build/icon.png: docs/img/logo-white.svg scripts/generate-icons.ts ## Quality checks (can run in parallel) # Keep the default local path fast. Docs link crawling and lockfile-free bench-agent # verification stay in static-check-full so local validation remains responsive. -static-check: lint typecheck fmt-check check-eager-imports check-code-docs-links lint-shellcheck lint-hadolint ## Run fast local static checks +static-check: lint typecheck fmt-check check-eager-imports check-code-docs-links lint-shellcheck lint-hadolint bundled-extensions-validate ## Run fast local static checks static-check-full: static-check check-bench-agent check-docs-links ## Run the full CI static check suite @@ -433,11 +447,11 @@ check-deadcode: node_modules/.installed ## Check for potential dead code (manual || echo "✓ No obvious dead code found" ## Testing -test-integration: node_modules/.installed build-main ## Run all tests (unit + integration) +test-integration: node_modules/.installed build-main bundled-extensions-assemble ## Run all tests (unit + integration) @bun test src @TEST_INTEGRATION=1 bun x jest tests -test-unit: node_modules/.installed build-main ## Run unit tests +test-unit: node_modules/.installed build-main bundled-extensions-assemble ## Run unit tests @bun test src @bun test ./tests/ui/storybook/ @@ -597,7 +611,7 @@ benchmark-terminal: ## Run Terminal-Bench 2.0 with Harbor (use TB_HARBOR_PACKAGE ## Clean clean: ## Clean build artifacts @echo "Cleaning build artifacts..." - @rm -rf dist release build/icon.icns build/icon.png + @rm -rf dist release build/icon.icns build/icon.png build/extensions @echo "Done!" ## Startup Performance Checks diff --git a/docs/adr/0001-permissions-are-requests-not-grants.md b/docs/adr/0001-permissions-are-requests-not-grants.md new file mode 100644 index 0000000000..96f14e69d8 --- /dev/null +++ b/docs/adr/0001-permissions-are-requests-not-grants.md @@ -0,0 +1,25 @@ +# Effect capabilities require local approval; registration capabilities are auto-approved + +Extension Modules declare capability classes in their statically extracted `manifest.capabilities` object. Those declarations are author-controlled requests, not authority. V1 splits capabilities into **Registration Capabilities** and **Effect Capabilities**. Registration Capabilities, initially only `skills`, are auto-approved after root trust and enablement because they only register host-validated descriptors. Effect Capabilities such as shell, network, secrets, workspace files, git, or model access require explicit **Capability Approval** stored in Mux-controlled local state outside project repositories. + +## Considered Options + +- **Manifest capabilities are grants.** Rejected: a repository or downloaded source could self-authorize dangerous host APIs by changing `extension.ts`. +- **Keep the package-prototype `requestedPermissions` / Grant Record model.** Rejected: it was designed around package manifests, inferred contribution permissions, and distribution identity drift. Extension Modules need a smaller capability-class model where `ctx` is the enforcement boundary. +- **Require approval for all registration capabilities.** Rejected for skills-first v1 because skill registration is host-validated, contained, and already lower precedence than user project/global skills. +- **Trust + enable grants every declared capability.** Rejected: project-local code and fetched source must not receive shell/network/secrets authority just because a root was trusted for inspection. + +## Decision + +- The Static Manifest declares capability classes. +- Registration Capability use must be declared, but is auto-approved after trust + enablement. +- Effect Capability use requires a local Capability Approval scoped by root/project/global scope and Extension Name. +- Unapproved Effect Capability namespaces remain visible on `ctx` with `requested`, `approved`, `available`, and `reason` metadata, and throw typed errors when used. +- Capability drift is based on requested Effect Capability expansion/strengthening. Source or content changes alone do not revoke existing approvals. + +## Consequences + +- V1 can remove package-version and package-rename regrant churn. +- The Settings UI must distinguish requested/approved/unavailable Effect Capabilities from auto-approved registration capabilities. +- Project repositories may declare source locks, but cannot commit approvals or trust decisions. +- The first implementation can ship with only `capabilities.skills = true` and no dangerous Effect Capability API. diff --git a/docs/adr/0002-stable-v1-excludes-code-execution-surfaces.md b/docs/adr/0002-stable-v1-excludes-code-execution-surfaces.md new file mode 100644 index 0000000000..3e7788fa3b --- /dev/null +++ b/docs/adr/0002-stable-v1-excludes-code-execution-surfaces.md @@ -0,0 +1,26 @@ +# Extension code executes only after trust in a QuickJS-based host + +The package prototype excluded extension-authored code execution. The Extension Module architecture intentionally introduces `activate(ctx)`, but only after a trust ladder: pre-trust project-local roots are existence-only, static manifests are extracted without execution, Registration Discovery runs only after root trust, and Full Activation runs only after trust, enablement, and applicable approvals. + +## Considered Options + +- **Keep v1 purely declarative.** Rejected: the new folder/`extension.ts` model is meant to support Mux-native authoring, hot reload, and contribution registration without duplicating every contribution in a manifest. +- **Execute `extension.ts` in Node/Electron.** Rejected: extension code must not inherit ambient Node, filesystem, process, network, or renderer authority. +- **Run sandboxed Registration Discovery before trust.** Rejected: even no-op registration collection still executes extension code and can burn CPU, throw, or use any sandbox bug before user consent. +- **Require static contribution lists in the manifest.** Rejected: it duplicates registration code and diverges from the desired `activate(ctx)` authoring model. + +## Decision + +- `extension.ts` exports a statically extractable `manifest` and may export `activate(ctx)`. +- Mux never executes `extension.ts` before the relevant root is trusted. +- After trust, Mux runs **Registration Discovery** in QuickJS with collector registration APIs and unavailable effect APIs. +- After enablement and approvals, Mux runs **Full Activation** in a long-lived QuickJS Extension Host Session. +- Full Activation may publish only contributions observed during Registration Discovery. +- `activate(ctx)` may be async, but activation is bounded by timeouts and atomic cleanup. + +## Consequences + +- The PTC QuickJS runtime concepts are reusable, but an extension-host layer must add TypeScript bundling, `mux:*` virtual modules, export handling, retained handler invocation, and lifecycle/disposal management. +- Registration Discovery is a contribution contract, not a side-effect permission grant. +- Failed activation must dispose partial registrations and keep last-good activation during hot reload unless trust/capability revocation requires shutdown. +- Static analysis remains important for forbidden imports/globals, but v1 does not try to prove top-level purity. diff --git a/docs/adr/0003-extension-identity-vs-distribution-identity.md b/docs/adr/0003-extension-identity-vs-distribution-identity.md new file mode 100644 index 0000000000..15f41abb52 --- /dev/null +++ b/docs/adr/0003-extension-identity-vs-distribution-identity.md @@ -0,0 +1,25 @@ +# Extension identity is the folder name; source identity lives in locks + +The package prototype used a reverse-domain `mux.id` and separate npm Distribution Identity. Extension Modules follow the existing agent skill model instead: an extension is identified by its folder basename, and `manifest.name` in `extension.ts` is required to match that folder. Source provenance, git refs, SHAs, and content hashes live in lock/store metadata rather than the manifest. + +## Considered Options + +- **Keep reverse-domain `mux.id`.** Rejected: it conflicts with the desired skills-like folder workflow and makes local authoring heavier than necessary. +- **Use git URL or content hash as identity.** Rejected: identity would change across forks, mirrors, local edits, and lock updates. Source identity is provenance, not user-facing extension identity. +- **Use a composite folder + manifest ID.** Rejected: it adds complexity while still making folder rename semantics unclear. +- **Allow manifest name to differ from folder name.** Rejected: Mux skills already require frontmatter `name` to match the parent directory, and extensions should follow that convention. + +## Decision + +- The **Extension Name** is the kebab-case folder basename. +- `manifest.name` is required and must match the folder name. +- There is no required manifest version and no npm package identity. +- Git/source provenance is represented by **Source Identity** in global/project lock files and the content-addressed store. +- Duplicate Extension Names across roots use skill-like precedence: project-local shadows user-global, which shadows bundled. Core bundled names may be reserved and non-shadowable. + +## Consequences + +- Renaming an extension folder intentionally renames the extension; old state can be surfaced as stale local state. +- Install commands must parse `manifest.name` before choosing the target active name. +- Lock files key source entries by Extension Name but do not confer trust. +- Project-local extensions cannot inherit global approvals merely by using the same name because approvals are scoped by root/project/global scope. diff --git a/docs/adr/0004-v1-is-an-additive-platform-with-a-demo-extension.md b/docs/adr/0004-v1-is-an-additive-platform-with-a-demo-extension.md new file mode 100644 index 0000000000..276769906d --- /dev/null +++ b/docs/adr/0004-v1-is-an-additive-platform-with-a-demo-extension.md @@ -0,0 +1,24 @@ +# v1 is an additive skills-only Extension Module release with a bundled demo + +Stable v1 ships the folder-based Extension Module platform, not migrations of existing built-in features. The bundled extension set contains a single non-core demo Extension Module that registers one skill through `activate(ctx)`. The platform is always initialized because future built-in skill migrations depend on extension-contributed skills remaining available. + +## Considered Options + +- **Migration-first v1.** Rejected: moving built-in themes, layouts, runtimes, agents, tools, or secrets to the new host would combine platform risk with migration risk. +- **Ship commands/effect APIs in v1.** Rejected: commands and side-effect APIs require handler invocation, approval UX, and stronger runtime semantics. Skills are enough to validate the source/discovery/activation path. +- **Keep the npm/package prototype as a compatibility path.** Rejected: the feature is experimental and unmerged; carrying two architectures would confuse authors and double the security surface. +- **No demo extension.** Rejected: without a bundled demo, shipped builds would not exercise the end-to-end platform path by default. +- **Expose a platform kill switch.** Rejected: built-in skills may migrate onto Extensions, so a user-facing off switch would remove core functionality and create a degraded experience. + +## Decision + +- V1 contribution support is skills only. +- The Platform Demo Extension is an Extension Module folder with `extension.ts`, `manifest.name`, `capabilities.skills = true`, and `ctx.skills.register(...)`. +- No existing built-in feature is migrated in v1. +- The Extension Platform has no experiment or Governor kill switch; discovery, Registration Discovery, Full Activation, Settings UI, and skill integration are always available. + +## Consequences + +- Existing Mux behavior remains unchanged for users who never enable or install third-party extensions. +- Tests and dogfood focus on local/global/project extension source, trust gating, skill registration, shadowing, and always-on availability. +- Future releases can add commands, effect APIs, setup scripts, catalogs, immutable local snapshots, and built-in migrations through separate design passes. diff --git a/docs/adr/0005-v1-platform-security-boundaries.md b/docs/adr/0005-v1-platform-security-boundaries.md new file mode 100644 index 0000000000..1f4e939411 --- /dev/null +++ b/docs/adr/0005-v1-platform-security-boundaries.md @@ -0,0 +1,27 @@ +# v1 security boundaries: no repo-injected trust, contained source, reserved names, and cache/display separation + +Extension Modules allow code execution after trust, so v1 security boundaries focus on preventing repository-controlled files from injecting trust, preventing path/source escapes, preventing first-party impersonation, and keeping cached inspection data out of live capability decisions. + +## Considered Options + +- **Store project trust/approvals under `/.mux`.** Rejected: gitignore is not a security boundary. A repository could commit or generate local-looking trust files and inject approvals into every Mux instance that opens it. +- **Fetch/parse project extension locks before trust.** Rejected: a repo-controlled lockfile could trigger network access, source parsing, or dependency work before the user trusts the project. +- **Use package reserved prefixes (`mux.*`).** Rejected: Extension Names now follow kebab-case folder identity; the reserved first-party namespace should use folder-compatible names such as `mux-*`. +- **Trust cached snapshots for activation.** Rejected: snapshot freshness is best-effort. Live Static Manifest extraction, Registration Discovery, and Full Activation results must drive capability paths. + +## Decision + +- Project repositories may contain source locks and optional vendored extension source, but never trust, enablement, or Capability Approval state. +- Mux-owned extension security state lives outside repositories under global Mux storage. +- Project-local roots are existence-only before trust. Mux must not fetch, parse, transpile, or execute project-declared extension code before trust. +- Relative imports/resources must resolve by realpath containment inside the Extension Module root; npm/bare imports are rejected except `mux:*`. +- Non-bundled extensions cannot use reserved first-party names such as `mux-*`; bundled core names may be non-shadowable. +- Snapshot caches are display/inspection accelerators only. Activation and skill availability come from live discovery/activation. +- Extension iconography remains generic until raw SVG/HTML sanitization has a dedicated design. + +## Consequences + +- The previous project-local `.mux/extensions.local.jsonc` design is superseded for security state. Any project-scoped approvals must be keyed in global Mux state by project identity/scope. +- The Settings UI must make source locks distinct from trust/approval state. +- Source lock sync for project extensions can happen only after project/root trust. +- Tests must assert that committed project files cannot grant trust or Effect Capabilities and that pre-trust project extension code is never executed. diff --git a/docs/docs.json b/docs/docs.json index 6fadfcdd6b..c4a190d08f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -113,6 +113,14 @@ "group": "Integrations", "pages": ["integrations/vscode-extension", "integrations/acp"] }, + { + "group": "Extensions", + "pages": [ + "extensions/authoring", + "extensions/telemetry", + "extensions/release-checklist" + ] + }, { "group": "Reference", "pages": [ diff --git a/docs/extensions/authoring.mdx b/docs/extensions/authoring.mdx new file mode 100644 index 0000000000..b4b8d65e96 --- /dev/null +++ b/docs/extensions/authoring.mdx @@ -0,0 +1,136 @@ +--- +title: Authoring an Extension Module +description: Quickstart for authoring a Mux Extension Module with a static manifest and skill registration. +--- + +A Mux **Extension Module** is a folder whose basename is the **Extension Name** and +which contains an `extension.ts` entrypoint. Extension Modules do not need +`package.json`, npm publishing, or install scripts. Source/version metadata lives +in Extension Source Locks; trust, enablement, and capability approvals live in +Mux-controlled state outside project repositories. + +## Quickstart: a single-skill Extension Module + +This walkthrough produces an Extension Module with one advertised skill. + +### 1. Lay out the module + +```text +acme-review/ +├── extension.ts +└── skills/ + └── review/ + └── SKILL.md +``` + +For local authoring, scaffold the folder under Mux's local extension area: + +```sh +mux extensions create acme-review +``` + +You can also create the same layout manually under `~/.mux/extensions/local/`. + +Project repositories may vendor source under +`/.mux/extensions//`, but project-local source remains +existence-only until the project/root is trusted. + +### 2. Write `extension.ts` + +```ts +import { defineManifest } from "mux:extensions"; + +export const manifest = defineManifest({ + name: "acme-review", + displayName: "Acme Review", + description: "Review helpers for Acme.", + capabilities: { + skills: true, + }, +}); + +export function activate(ctx) { + ctx.skills.register({ + name: "review", + bodyPath: "./skills/review/SKILL.md", + }); +} +``` + +Key constraints surface as diagnostics on the Extension Card: + +- `manifest.name` must match the Extension Module folder basename. +- The Extension Name must satisfy the Extension Name schema. +- `manifest.capabilities.skills === true` is required before + `ctx.skills.register` can succeed. +- Static Manifest extraction does not execute extension code. +- Extension code may import only contained relative modules or `mux:*` virtual + modules; npm and other bare imports are rejected in v1. + +### 3. Write the skill body + +```markdown +--- +name: review +description: Review the current changes +--- + +# Review + +Instructions for the agent. +``` + +The registered skill `name` must match the `SKILL.md` frontmatter name. +`bodyPath` is resolved inside the Extension Module realpath. Absolute paths, +`..` traversal, and symlink escapes are rejected. + +### 4. Reload and approve + +Run **Reload Extensions** from the command palette. The new Extension appears in +**Settings → Extensions** under the matching root. + +Project-local roots require trust before Mux fetches, parses, transpiles, or +executes repo-controlled extension source. After trust, Mux runs Registration +Discovery to preview requested registrations. Enable the extension and approve +its requested capabilities before Full Activation publishes live skills. + +## Static Manifest reference + +| Field | Type | Required | Notes | +| -------------- | --------------------- | -------- | -------------------------------------------------- | +| `name` | Extension Name string | yes | Must match the containing folder basename. | +| `displayName` | string | no | User-facing name on the Extension Card. | +| `description` | string | no | User-facing description on the Extension Card. | +| `capabilities` | object | no | V1 supports `skills: true` for skill registration. | + +Unknown static manifest fields produce diagnostics. They do not grant authority. + +## Capabilities and approvals + +V1 exposes one registration capability: `skills`. Declaring +`capabilities.skills: true` allows `activate(ctx)` to call +`ctx.skills.register` during Registration Discovery and Full Activation. + +Effective Capabilities = `requested ∩ approved`. New requested capabilities are +never auto-approved; they surface as `Pending re-approval` on the card. Source +coordinates, content hashes, and vendored paths do not cause approval drift. + +## Discovery and activation + +Mux runs `activate(ctx)` in two modes: + +1. **Registration Discovery** after root trust. `ctx.mode === "discover"`, + `ctx.skills.register` records intended skills, and returned disposables are + no-ops. +2. **Full Activation** after trust, enablement, and capability approval. + Activation may publish only skills observed during Registration Discovery. + +Discovery or activation failures surface diagnostics and do not crash startup. + +## Source locks + +Global installs write source metadata under `~/.mux/extensions/lock.json`. +Project repositories may commit `/.mux/extensions.lock.json` and may +vendor sources under `/.mux/extensions//`. Lockfiles are +reproducibility metadata only: they cannot inject trust, enablement, or approval +state. diff --git a/docs/extensions/release-checklist.mdx b/docs/extensions/release-checklist.mdx new file mode 100644 index 0000000000..8091f55d0c --- /dev/null +++ b/docs/extensions/release-checklist.mdx @@ -0,0 +1,218 @@ +--- +title: Extension Platform Release Checklist +description: Pre-release dogfood checklist for the Mux Extension Platform with screenshot/video evidence requirements. +--- + +A reproducible sign-off pass for the Mux Extension Platform. Every step +listed below MUST be executed manually against a release build before a +v1 release ships, with the evidence below attached to the release PR. + +- **Screenshot** = a still image of the relevant UI state. +- **Video** = a short screen recording covering the full interaction + (start to stable end state). + +The Consent Shortcut flow and the always-on restart smoke always require +**video** because they exercise multi-step state transitions; everything +else is screenshot-only unless noted. + +## Before you start + +- Build the release artifact you intend to ship (Electron + dev-server). +- Use a clean macOS, Windows, and Linux profile each — the user-global + root sits at `~/.mux/extensions/` and prior dogfood runs leak state + across runs unless deleted. +- Have one third-party Extension package available locally for the + install / update / drift steps. The bundled + [Demo Extension](/extensions/authoring) is fine as a copy-paste base. + +## Checklist + +The order below matches the natural cold-start → install → drift → +recovery progression, so a single pass produces a coherent narrative for +the release PR. + +### 1. Cold start with no user-global root + +**Goal.** Section renders the initialize affordance with no errors. + +- Delete `~/.mux/extensions/`. +- Launch Mux. Open **Settings → Extensions**. +- Confirm: header shows zero error and zero warning counts; the + user-global subsection shows the **Initialize User Extensions Root** + affordance plus a 2-sentence explanation. + +**Evidence.** Screenshot of the section in this state. + +### 2. Cold start with a malformed user-global module + +**Goal.** Section surfaces module diagnostics; the app starts normally. + +- With Mux closed, create + `~/.mux/extensions/local/broken-extension/extension.ts` with a malformed + static manifest export. +- Launch Mux. Open **Settings → Extensions**. +- Confirm: the user-global subsection shows a diagnostic for the broken + Extension Module and the rest of the app remains usable. + +**Evidence.** Screenshot of the diagnostic state. + +### 3. Initialize, install, reload + +**Goal.** Newly-installed Extensions become visible without restart. + +- Click **Initialize User Extensions Root** in the action row. +- In a terminal: `create or copy a module under ~/.mux/extensions/local/`. +- Run **Reload Extensions** from the command palette. +- Confirm: the new Extension appears as a card in the user-global + subsection. + +**Evidence.** Screenshot **before** reload (no card) and **after** +reload (card visible). + +### 4. Project-local trust ladder + +**Goal.** Pre-trust shows the prerequisite card; approving trust unlocks +inspection-only mode. + +- Open a project that contains a `.mux/extensions/` directory, with + project trust unapproved. +- Confirm: the project-local subsection shows the **project-trust + prerequisite** card and renders no Extension cards. +- Approve project trust (the existing Mux project-trust flow). +- Confirm: Safe Manifest Inspection runs and Extension cards appear in + **inspection-only mode** (no Enable / Approve actions yet). + +**Evidence.** Screenshot of each state (untrusted, trusted + +inspection-only). + +### 5. Consent Shortcut flow (video) + +**Goal.** The one-click Consent Shortcut applies trust + enable + approval +in one transaction; **Review individually** falls through to the +granular ladder. + +- Click **Quick Setup** on a project-local Extension card. +- In the Consent Shortcut Modal, verify the summary lists: identity, + Extension Name, description, and the requested + capabilities. +- Confirm Quick Setup. Verify the card reaches `Enabled`. +- Repeat the install on a second Extension and click **Review + individually** instead. Verify the granular Trust → Enable → Approve + controls become visible on the card and that each step succeeds + independently. + +**Evidence.** **Video** capturing both the shortcut and granular paths +end-to-end. + +### 6. Drift surfacing on capability update + +**Goal.** Adding a new requested capability shows `Pending re-approval` +without a modal; previously available contributions remain available. + +- Update an installed Extension to request a new capability. +- Run **Reload Extensions**. +- Confirm: the card shows a `Pending re-approval` pill and the + **Review Pending Extension Capabilities** action appears in the action + row. No modal is shown. Previously available contributions are still + marked Available. + +**Evidence.** Screenshot of the card with the `Pending re-approval` pill. + +### 7. Drift surfacing on new contribution type + +**Goal.** Adding a new contribution type triggers Capability approval +drift; the new contribution stays Unavailable until re-approval. + +- Update the same Extension to add a contribution of a type it did not + previously declare (e.g., add a `themes` entry to a previously + skill-only Extension). +- Run **Reload Extensions**. +- Confirm: the card surfaces drift; the new contribution row shows + `Unavailable` until re-approval. +- Re-approve. Confirm: the new contribution becomes Available. + +**Evidence.** Screenshot of the Unavailable state pre-approval and the +Available state post-approval. + +### 8. Always-on restart smoke (video) + +**Goal.** The Extension Platform remains available across reloads and +restart-like renderer refreshes with no experiment or Governor kill switch. + +- Open **Settings → Extensions** and confirm the section is visible. +- Run **Reload Extensions** and confirm the bundled Demo Extension remains + visible. +- Reload the renderer or restart the app. +- Confirm: the **Extensions** section and previously trusted roots, enabled + Extensions, and approved capabilities are intact; no consent shortcut + fires for bundled Extensions. + +**Evidence.** **Video** covering the reload/restart round trip. + +### 9. Reserved-prefix rejection + +**Goal.** A non-bundled Extension claiming a `mux.*` identity is +rejected with `extension.identity.reserved` and contributes nothing. + +- Install (user-global or project-local) a third-party Extension whose + manifest declares `id: "mux.foo"`. +- Run **Reload Extensions**. +- Confirm: the card surfaces the `extension.identity.reserved` + diagnostic; the contributions table is empty (or every row marked + Unavailable); the Extension does not register any capability. + +**Evidence.** Screenshot of the card with the diagnostic visible. + +### 10. Debug snapshot capture + +**Goal.** `bun run debug extensions` produces structured JSON in cold, +post-install, and post-failure states; no third-party identifiers leak +into telemetry. + +- Run `bun run debug extensions` (cold), `bun run debug extensions` + (post-install), and `bun run debug extensions` (post-failure: induce + a malformed manifest and re-run). +- Optionally narrow with `--root `. +- Spot-check telemetry events captured during the same session + (PostHog console, dev tools, or whichever sink is configured): no + event payload contains a third-party Extension identifier. The + catalog this should be checked against lives at + [Extension Telemetry](/extensions/telemetry). + +**Evidence.** Output capture (text) of each run; brief note confirming +the telemetry spot-check. + +### 11. Stale Record handling + +**Goal.** Removing an installed Extension from disk leaves its Approval +Record visible with explicit **Forget** and **Keep** actions. + +- Uninstall an Extension you previously approved (delete from + `~/.mux/extensions/local/` or remove the source lock entry). +- Run **Reload Extensions**. +- Confirm: the section's **Stale Approval Records** group shows the prior + Extension as `Stale (extension not currently installed)` with both + **Forget** and **Keep** actions wired. + +**Evidence.** Screenshot of the Stale Record card. + +## Sign-off + +- All 11 steps above produce the expected evidence with no unexpected + diagnostics. +- The release PR description links to each piece of evidence and notes + the OS / build profile each step was captured on. +- A migration release does **not** alter this checklist — + `MIGRATION*` switches are exercised separately in the migration + release's own checklist. + +## Related + +- [Extension Authoring Quickstart](/extensions/authoring) — manifest + reference and copy-paste starter. +- [Extension Telemetry](/extensions/telemetry) — full v1 events + catalog used in step 10. +- [Telemetry overview](/reference/telemetry) — host-level telemetry + policy that the Extension Telemetry layer wraps. +- [Debugging](/reference/debugging) — `bun run debug` subcommands, + including the `extensions` snapshot dump used in step 10. diff --git a/docs/extensions/telemetry.mdx b/docs/extensions/telemetry.mdx new file mode 100644 index 0000000000..c5ddcc9ffe --- /dev/null +++ b/docs/extensions/telemetry.mdx @@ -0,0 +1,226 @@ +--- +title: Extension Telemetry +description: Full v1 events catalog for the Mux Extension Platform, including the provenance gate that blocks third-party identifiers from leaving your machine. +--- + +The Extension Telemetry layer wraps the host +[Telemetry service](/reference/telemetry) and applies a **closed +allowlist** plus a **provenance gate** before any Extension event +leaves the device. This page lists every v1 event, its allowlisted +fields, and the gate rules — so users (and auditors) can verify that +the only string identifiers that ever ship are bundled-Extension +identifiers under the `mux.*` reserved prefix. + +The full source is in +[`src/common/extensions/extensionTelemetry.ts`](https://github.com/coder/mux/blob/main/src/common/extensions/extensionTelemetry.ts); +this page mirrors that file so it stays auditable without reading +TypeScript. + +## Privacy guarantees + +The Extension Telemetry layer **never** emits: + +- project paths or package names +- third-party Extension identifiers (`extensionId`, `contributionId`) +- requested-capability lists +- file paths or lockfile contents +- any field not on the per-event allowlist below + +Aggregate state is surfaced via counts (`extensionCount`, +`capabilityCount`, `diagnosticCount`) instead of identifiers. + +The host-level `MUX_DISABLE_TELEMETRY=1` switch disables this layer +along with everything else; see +[Telemetry overview](/reference/telemetry). + +## The provenance gate + +Each allowlisted field is classified as one of two kinds: + +| Kind | Behavior | +| ------------ | ------------------------------------------------------------------------------------------- | +| `scalar` | Counts, durations (ms), booleans, status enums, diagnostic codes, severity. Always allowed. | +| `identifier` | `extensionId` / `contributionId` style fields. Only emitted if **both** gates below pass. | + +Identifier fields ship only if **both** of these are true: + +1. The value matches the **Reserved Extension Identity Prefix** regex + `^mux(\..*)?$`. +2. The Extension's source `rootKind === "bundled"`. + +Either gate failing strips the field. Defense-in-depth means a +third-party Extension squatting on the `mux.*` namespace is still +rejected because its `rootKind !== "bundled"`; a bundled Extension +with a non-Mux id is rejected because the regex fails. + +```mermaid +graph TD + A["Event payload"] --> B{"Field in event allowlist?"} + B -- "no" --> X["Drop field"] + B -- "yes, scalar" --> P["Keep field"] + B -- "yes, identifier" --> C{"Value matches mux.* regex?"} + C -- "no" --> X + C -- "yes" --> D{"rootKind === bundled?"} + D -- "no" --> X + D -- "yes" --> P +``` + +## v1 events catalog + +Every event in the table below is a `ExtensionTelemetryEventName`. The +"Fields" column lists the allowlisted property keys; values for +`identifier` fields are gated as described above, values for `scalar` +fields are kept as-is when they are `string | number | boolean`. + +### `extensions.discovery.completed` + +Emitted once per Discovery cycle that finishes (per root or +aggregated, per the host wiring). Used to monitor cold-start budgets +and discovery success rates. + +| Field | Kind | Notes | +| ------------------- | ------ | -------------------------------------------------------------------- | +| `durationMs` | scalar | End-to-end duration of the discovery cycle. | +| `rootCount` | scalar | Number of Extension Roots considered. | +| `extensionCount` | scalar | Number of Extensions surfaced (any state). | +| `contributionCount` | scalar | Number of Available + Inspection-only contributions in the snapshot. | +| `diagnosticCount` | scalar | Total diagnostics carried by the snapshot. | +| `cacheHit` | scalar | `true` when the inspection-path snapshot cache was warm. | + +### `extensions.discovery.failed` + +Emitted when a per-root Discovery Attempt fails (timeout, malformed +root manifest, etc.). One event per failed root. + +| Field | Kind | Notes | +| ---------------- | ------ | -------------------------------------------------------- | +| `rootKind` | scalar | One of `bundled`, `userGlobal`, `projectLocal`. | +| `diagnosticCode` | scalar | Stable diagnostic code (e.g., `root.discovery.timeout`). | +| `durationMs` | scalar | Duration before the attempt was abandoned. | + +### `extensions.migration.activated` + +Emitted when a built-in feature is migrated onto an Extension +contribution at runtime (future migration releases). Identifier +fields apply because only bundled (and therefore `mux.*`) Extensions +are eligible to drive a host migration. + +| Field | Kind | Notes | +| ------------- | ---------- | ---------------------------------------------------------------------------------------------------- | +| `extensionId` | identifier | Bundled `mux.*` id only — third-party Extensions cannot trigger this event because the gate rejects. | +| `durationMs` | scalar | Activation duration. | + +### `extensions.consent.shortcut.accepted` + +Emitted when a user confirms the Consent Shortcut. + +| Field | Kind | Notes | +| ---------- | ------ | -------------------------------------------- | +| `rootKind` | scalar | Source root of the Extension being approved. | + +### `extensions.consent.shortcut.rejected` + +Emitted when a user dismisses the Consent Shortcut without confirming. + +| Field | Kind | Notes | +| ---------- | ------ | -------------------------------------------- | +| `rootKind` | scalar | Source root of the Extension being reviewed. | + +### `extensions.approval.recorded` + +Emitted when an Approval Record is written. + +| Field | Kind | Notes | +| ----------------- | ---------- | --------------------------------------------------- | +| `extensionId` | identifier | Bundled-only via the gate. | +| `rootKind` | scalar | Source root. | +| `capabilityCount` | scalar | Total number of approved capabilities (post-merge). | + +### `extensions.approval.revoked` + +Emitted when an Approval Record is removed. + +| Field | Kind | Notes | +| ------------- | ---------- | -------------------------- | +| `extensionId` | identifier | Bundled-only via the gate. | +| `rootKind` | scalar | Source root. | + +### `extensions.enabled.toggled` + +Emitted when an Extension's enabled state changes. + +| Field | Kind | Notes | +| ------------- | ---------- | -------------------------- | +| `extensionId` | identifier | Bundled-only via the gate. | +| `rootKind` | scalar | Source root. | +| `enabled` | scalar | New enabled state. | + +### `extensions.reload.invoked` + +Emitted when **Reload Extensions** runs (palette, watcher, or +section button). + +| Field | Kind | Notes | +| ------------ | ------ | -------------------------------------------------------------------------- | +| `rootKind` | scalar | Source root being reloaded; absent / aggregated for whole-platform reload. | +| `durationMs` | scalar | Reload duration. | + +### `extensions.cache.miss` + +Emitted when the Snapshot Cache is consulted on cold start and rejects +the cached payload. + +| Field | Kind | Notes | +| -------- | ------ | --------------------------------------------------------------------------------------------------------------------- | +| `reason` | scalar | One of `appVersionMismatch`, `manifestVersionMismatch`, `stateFileMtimeMismatch`, `stateFileHashMismatch`, `missing`. | + +### `extensions.cache.hit` + +Emitted when the Snapshot Cache is consulted on cold start and the +cached payload survives validation. + +| Field | Kind | Notes | +| ------------ | ------ | -------------------------------------- | +| `durationMs` | scalar | Time saved by the cache hit (approx.). | + +### `extensions.diagnostic.emitted` + +Emitted once per Extension Diagnostic that crosses the structured-log +sink. The matrix of which diagnostics surface where is documented in +the Settings → Extensions UI; this event is the telemetry-side mirror. + +| Field | Kind | Notes | +| ---------------- | ---------- | ------------------------------------------------------------------ | +| `extensionId` | identifier | Bundled-only via the gate; absent for root-level diagnostics. | +| `contributionId` | identifier | Bundled-only via the gate; absent for extension-level diagnostics. | +| `diagnosticCode` | scalar | Stable diagnostic code (e.g., `extension.identity.conflict`). | +| `severity` | scalar | `error`, `warn`, or `info`. | +| `rootKind` | scalar | Source root. | + +## Auditing locally + +To verify in your own session: + +1. Run `bun run debug extensions` and inspect the snapshot. +2. Trigger a few Extension events (reload, run the Consent Shortcut on a + third-party Extension). +3. Watch the dev tools / PostHog console for outgoing events; confirm + that any `extensionId` or `contributionId` field present has the + `mux.*` prefix and that the host can confirm the event came from + the bundled root. + +A regression-test suite under +[`src/common/extensions/extensionTelemetry.test.ts`](https://github.com/coder/mux/blob/main/src/common/extensions/extensionTelemetry.test.ts) +asserts that the gate rejects every catalog event under a non-bundled +`rootKind` and that any field outside the per-event allowlist is +dropped. Adding a new event requires both an entry in the allowlist +table and a corresponding regression test. + +## Related + +- [Extension Authoring Quickstart](/extensions/authoring) — manifest + reference and identity rules. +- [Release Checklist](/extensions/release-checklist) — step 10 covers + the dogfood telemetry spot-check. +- [Telemetry overview](/reference/telemetry) — host-level telemetry + policy and the `MUX_DISABLE_TELEMETRY` switch. diff --git a/package.json b/package.json index 1744706b74..8058d28662 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "dist/**/*.json", "dist/**/*.png", "dist/assets/**/*", + "build/extensions/**/*", "scripts/postinstall.sh", "npm-shrinkwrap.json", "README.md", @@ -276,6 +277,15 @@ "files": [ "dist/**/*" ], + "extraResources": [ + { + "from": "build/extensions", + "to": "extensions", + "filter": [ + "**/*" + ] + } + ], "asarUnpack": [ "dist/**/*.wasm", "dist/**/*.map", diff --git a/packages/mux-extension-platform-demo/README.md b/packages/mux-extension-platform-demo/README.md new file mode 100644 index 0000000000..d2508264ac --- /dev/null +++ b/packages/mux-extension-platform-demo/README.md @@ -0,0 +1,67 @@ +# mux-platform-demo + +The **Platform Demo Extension Module** is the canonical bundled reference +implementation for Mux Extension Modules v1. + +## Why this module exists + +- **It exercises the platform end-to-end.** Every release ships this module so + Static Manifest extraction, Registration Discovery, conflict resolution, + capability calculation, and skill activation are always in use. +- **It is the docs entry point.** The contributed `mux-extensions` skill explains + the user-facing platform model from inside Mux. +- **It is the starter template for Extension authors.** Copy this directory or + mirror its `extension.ts` + `SKILL.md` shape to start a new module. + +This is **not** a Core Extension Module. Users can disable it from +**Settings → Extensions**. + +## Layout + +```text +packages/mux-extension-platform-demo/ +├── extension.ts # Static Manifest + activate(ctx) registration +├── SKILL.md # body of the contributed `mux-extensions` skill +├── package.json # repo build metadata only; not the extension manifest +└── README.md # this file +``` + +The bundled assemble step copies this directory to +`build/extensions/mux-platform-demo/`, where the folder basename is the Extension +Name and `manifest.name` must match it. + +## Manifest + +The Static Manifest lives in `extension.ts`: + +```ts +export const manifest = { + name: "mux-platform-demo", + capabilities: { skills: true }, +}; +``` + +The module registers its skill from `activate(ctx)` using +`ctx.skills.register({ name, bodyPath })`. V1 supports skill registration only; +source versioning and git refs live in Extension Source Locks, not in the +manifest. + +## Versioning + +Bundled modules are inlined into the Mux artifact and are not published to npm in +v1. The local `package.json` exists only so repository tooling can track this +fixture with the app. + +## Authoring a new Extension Module from this template + +1. Copy the `extension.ts` + skill file layout into + `~/.mux/extensions/local//` or a git repository. +2. Update `manifest.name` to match the containing folder basename. +3. Replace the contributed skill with your own `SKILL.md`. +4. Reload Extensions in Mux. + +See `docs/extensions/authoring.mdx` for the full authoring reference. + +## License + +AGPL-3.0-only — same as the Mux repository. diff --git a/packages/mux-extension-platform-demo/SKILL.md b/packages/mux-extension-platform-demo/SKILL.md new file mode 100644 index 0000000000..0d45850bf6 --- /dev/null +++ b/packages/mux-extension-platform-demo/SKILL.md @@ -0,0 +1,99 @@ +--- +name: mux-extensions +description: Explains how the Mux Extension Platform works — roots, trust, enable, grants, contributions, and skill precedence — from inside Mux. +--- + +# mux-extensions + +A guide to Mux Extension Modules — what an Extension Module is, where it lives, +how it gets activated, and how to reason about trust, capabilities, source locks, +and contributed skills. This skill is contributed by the bundled Platform Demo +Extension Module and ships with every copy of Mux. + +## What an Extension Module is + +An **Extension Module** is a directory whose basename is the **Extension Name** +and that contains an `extension.ts` file. The file exports a static `manifest` +whose `name` must match the directory name, plus an `activate(ctx)` function that +registers contributions. + +For v1, the stable contribution surface is intentionally small: + +```ts +export const manifest = { + name: "acme-review", + capabilities: { skills: true }, +}; + +export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); +} +``` + +Mux extracts the Static Manifest without executing extension code. After root +trust, Mux runs Registration Discovery in QuickJS with `ctx.mode === "discover"` +to collect intended skill registrations. After enablement and approval, Full +Activation runs in a fresh sandbox and may publish only skills observed during +Registration Discovery. + +## Where Extension Modules live + +Mux discovers modules from Extension Roots: + +| Root | Path / source | Trust | +| ------------- | ---------------------------------------------------------------- | ---------------- | +| Bundled | shipped inside the Mux app artifact | always trusted | +| User-global | `~/.mux/extensions/local//extension.ts` | always trusted | +| Fetched | `~/.mux/extensions/global//extension.ts` | always trusted | +| Project-local | `/.mux/extensions//extension.ts` or project locks | requires consent | + +Git-installed modules are pinned by Extension Source Locks and materialized into +Mux-owned active views. Project repositories may commit `.mux/extensions.lock.json` +or vendored source, but trust, enablement, and approvals live only in Mux-owned +global state. + +## The Trust → Enable → Capability ladder + +Three decisions gate every Extension Module: + +1. **Trusted Extension Root.** Project-local roots are existence-only until the + user grants extension-root trust. +2. **Enabled.** Whether the Extension Module is turned on. +3. **Capability approval.** V1 exposes only the `skills` registration capability; + no dangerous effect API is available. + +Disabling does not delete source locks or approvals. Revoking trust removes live +capability output immediately. + +## Discovery and activation + +Before project trust, Mux may show that a project declares extensions but must +not fetch, parse, transpile, or execute project-controlled extension code. After +trust, Mux may sync locked sources into the content-addressed store, statically +extract manifests, run Registration Discovery, and activate enabled skills. + +The platform is always initialized because future built-in skills may be served +through Extension Modules. Individual third-party Extensions can still be +disabled or unapproved without hiding core extension-provided functionality. + +## Skill precedence + +Extension-contributed skills follow the same identity rules as file-based custom +skills. Project-local active modules shadow user-global modules with the same +Extension Name; user-global modules shadow non-core bundled modules; core bundled +modules cannot be shadowed. + +## Where to go next + +- Create a local module under `~/.mux/extensions/local//extension.ts`. +- Pin a git source in `~/.mux/extensions/lock.json` or a project + `.mux/extensions.lock.json`. +- Use Settings → Extensions to inspect roots, enable modules, grant + capabilities, and reload after edits. +- **Extension authoring quickstart** — see `docs/extensions/authoring.mdx` + for a copy-paste manifest plus the `@coder/mux-extension-platform-demo` source + alongside this skill. + +This Extension is **not Core**: you can disable it from the Extensions +Settings Section. Doing so removes the `mux-extensions` skill from the +skills picker but leaves the rest of the platform untouched. diff --git a/packages/mux-extension-platform-demo/extension.ts b/packages/mux-extension-platform-demo/extension.ts new file mode 100644 index 0000000000..dd21655acf --- /dev/null +++ b/packages/mux-extension-platform-demo/extension.ts @@ -0,0 +1,32 @@ +import { defineManifest } from "mux:extensions"; + +export const manifest = defineManifest({ + name: "mux-platform-demo", + displayName: "Mux Platform Demo", + description: + "Reference Extension Module that contributes a single advertised skill explaining Mux Extension Modules from inside Mux.", + capabilities: { + skills: true, + }, +}); + +export function activate(ctx: { + skills: { + register(input: { + name: string; + bodyPath: string; + displayName?: string; + description?: string; + advertise?: boolean; + }): { dispose(): void }; + }; +}): void { + ctx.skills.register({ + name: "mux-extensions", + displayName: "Mux Extensions", + description: + "Explains how Mux Extension Modules work: roots, trust, enablement, source locks, contributions, and skill precedence.", + bodyPath: "./SKILL.md", + advertise: true, + }); +} diff --git a/packages/mux-extension-platform-demo/package.json b/packages/mux-extension-platform-demo/package.json new file mode 100644 index 0000000000..318299f06b --- /dev/null +++ b/packages/mux-extension-platform-demo/package.json @@ -0,0 +1,12 @@ +{ + "name": "@coder/mux-extension-platform-demo", + "version": "0.25.0", + "private": true, + "description": "Bundled Platform Demo Extension — canonical reference implementation for the Mux Extension Platform.", + "license": "AGPL-3.0-only", + "files": [ + "extension.ts", + "SKILL.md", + "README.md" + ] +} diff --git a/rfc/extensions-platform-context.md b/rfc/extensions-platform-context.md new file mode 100644 index 0000000000..1de0cde10f --- /dev/null +++ b/rfc/extensions-platform-context.md @@ -0,0 +1,143 @@ +# Mux Extension Platform + +Mux's extension platform lets users and authors add capabilities to Mux without changing the core application. The v1 architecture is source-folder based: an **Extension Module** is a folder containing an `extension.ts` entrypoint, not an npm package. + +## Language + +**Extension Module**: +A folder under an Extension Root whose basename is the extension's canonical name and that contains an `extension.ts` entrypoint. +_Avoid_: npm package extension, package dependency, plugin bundle + +**Extension Name**: +The kebab-case basename of an Extension Module folder. `manifest.name` in `extension.ts` must match this folder name. This follows the existing agent skill model where a skill directory name is the stable identity. +_Avoid_: `mux.id`, package name, reverse-domain identity + +**Extension Entrypoint**: +The `extension.ts` file inside an Extension Module. It exports a statically extractable `manifest` and may export an `activate(ctx)` function. +_Avoid_: package manifest, host entry point list, activation event table + +**Static Manifest**: +The context-free `manifest` export extracted from `extension.ts` without executing extension code. It declares identity metadata and capability requests; it does not list concrete contributions and does not carry a release version. +_Avoid_: computed manifest, runtime manifest, package.json `mux` envelope + +**Registration Capability**: +A manifest-declared capability class that lets the extension register host-visible contributions after trust and enablement. Registration capabilities are auto-approved but must be declared before use. V1 initially supports `skills` only. +_Avoid_: requested permission, operational permission + +**Effect Capability**: +A manifest-declared capability that grants authority to perform side effects such as shell execution, network access, workspace file access, secrets, model calls, or git mutations. Effect capabilities require explicit user approval and are exposed on `ctx` with availability metadata. +_Avoid_: registration capability, ambient host API + +**Capability Approval**: +A local-only user decision that approves one or more Effect Capabilities for an Extension Name in a specific root/project scope. Approvals are stored outside project repositories so repo content cannot inject trust. +_Avoid_: repo-tracked permission, manifest grant + +**Extension Root**: +A location that may contain Extension Modules. V1 roots are bundled extensions, user-global active extensions, and project-local active extensions materialized by Mux from locks, local sources, or vendored sources. +_Avoid_: npm-compatible root, package project + +**User-local Extension Source**: +Editable extension source stored under Mux's global extension area (for example `~/.mux/extensions/local//`). Mux may hot reload it during authoring. +_Avoid_: installed package, store entry + +**Extension Store**: +Mux's global content-addressed cache for fetched extension sources. Store entries are immutable and addressed by content hash. +_Avoid_: project checkout, editable source folder + +**Active Extension View**: +A Mux-controlled materialized view that maps an Extension Name to either a local source or immutable store entry for a specific global or project scope. +_Avoid_: source of truth, trust record + +**Extension Source Lock**: +A lock file that records desired extension sources and resolved revisions/content hashes. Global locks live under Mux's extension area; project locks may live in the repository. Locks are reproducibility metadata, not trust or approval state. +_Avoid_: capability approval, trust state, enablement state + +**Project Extension Lock**: +A repo-trackable source lock such as `/.mux/extensions.lock.json` that declares project-desired extensions by git source, optional subdir, requested ref, resolved SHA, and content hash. +_Avoid_: project trust file, grant record + +**Extension Security State**: +Mux-owned local state outside project repositories that stores root trust, enablement, and Capability Approvals for global and project scopes. +_Avoid_: `.mux/extensions.local.jsonc`, repo-tracked trust, committed approval + +**Registration Discovery**: +The post-trust sandbox run of `activate(ctx)` with `ctx.mode === "discover"`. Registration APIs collect descriptors and return no-op disposables; Effect Capability APIs are unavailable for side effects. Discovery defines the maximum contribution set an activation may later publish. +_Avoid_: dry run, validation execution, full activation + +**Full Activation**: +The sandbox run of `activate(ctx)` after trust, enablement, and applicable Effect Capability approvals. Full Activation creates live registrations and a long-lived Extension Host Session. +_Avoid_: registration discovery, manifest parsing + +**Extension Host Session**: +A long-lived sandbox instance for one active extension. It owns the evaluated bundle, registration handles, optional custom disposables, in-flight handler state, console attribution, timeout/abort controls, and cleanup. +_Avoid_: one-shot eval, package process + +**Extension Context (`ctx`)**: +The host API object passed to `activate(ctx)`. Registration namespaces such as `ctx.skills` are present only when declared and enabled; Effect Capability namespaces expose `requested`, `approved`, `available`, and `reason` metadata and throw typed errors when unavailable. +_Avoid_: global `mux` object, Node globals + +**Contribution Registration**: +A call made during `activate(ctx)` to register a concrete contribution, such as `ctx.skills.register({ name, bodyPath })`. Concrete contributions are not listed in the Static Manifest. +_Avoid_: manifest contribution descriptor + +**Extension Skill Registration**: +A skills-first v1 Contribution Registration. `ctx.skills.register({ name, bodyPath })` registers a skill whose referenced `SKILL.md` frontmatter `name` must match the registration name, preserving the existing skill identity rule. +_Avoid_: skill alias, manifest skill descriptor + +**Extension Shadowing**: +Skill-like precedence when multiple roots expose the same Extension Name. Project-local shadows user-global, which shadows bundled. Shadowed modules are inspectable but not activated. Core bundled names may be reserved and non-shadowable. +_Avoid_: identity conflict, duplicate package conflict + +**Source Identity**: +Provenance metadata such as source kind, git URL, optional subdir, requested ref, resolved SHA, and content hash. Source Identity is used for install/update display and lock verification, not as the canonical Extension Name. +_Avoid_: distribution identity, package version + +**Capability Drift**: +A change in requested Effect Capabilities compared with locally approved capabilities. Expansion or strengthening requires approval; source/content changes alone do not invalidate existing approvals. +_Avoid_: version drift, package rename drift + +**Immutable Store Activation**: +Activation from a content-addressed store entry whose files should not mutate. Fetched global and project-lock extensions use this in v1. Local editable extensions may activate directly in v1, with immutable snapshots as the target architecture. +_Avoid_: live package activation + +## Root and source precedence + +Extension roots use skill-like precedence: + +1. Project-local active extension view +2. User-global active extension view +3. Bundled extension view + +Within one root, duplicate Extension Names are impossible because folders are keyed by name. Across roots, higher-precedence roots shadow lower-precedence roots unless a bundled core name is reserved. + +## Trust ladder + +Project-local roots are repo-controlled and are treated as attacker-controlled until trusted. + +1. **Existence detection**: Mux can detect that a project declares or vendors extensions without reading or executing extension code. +2. **Static Manifest Inspection**: user-global and bundled modules may be statically parsed before root trust; project-local modules may be statically parsed only after project/root trust. +3. **Registration Discovery**: after root trust, Mux runs `activate(ctx)` in the sandbox with collector-only registration APIs. +4. **Full Activation**: after trust, enablement, and Effect Capability approvals, Mux runs `activate(ctx)` in activation mode and publishes live registrations. + +## Storage model + +Mux uses a PNPM-inspired split: + +```text +~/.mux/extensions/ + local/ # editable user-authored sources + store/ # immutable content-addressed fetched sources + global/ # active global view by extension name + projects/ # active project views keyed by Mux project identity + lock.json # global source lock + trust.jsonc # local-only security state for global and project scopes +``` + +Project repositories may contain source metadata and optional vendored source: + +```text +/.mux/extensions.lock.json +/.mux/extensions//extension.ts +``` + +Project repositories must never contain trust, enablement, or Capability Approval state. diff --git a/rfc/extensions-platform-prd.md b/rfc/extensions-platform-prd.md new file mode 100644 index 0000000000..63893f42a8 --- /dev/null +++ b/rfc/extensions-platform-prd.md @@ -0,0 +1,416 @@ +# PRD — Mux Extension Modules v1 + +> **Status:** Proposed replacement for the npm/package-based extension prototype. +> **Companion docs:** +> +> - Domain glossary: `rfc/extensions-platform-context.md` +> - ADR-0001: `docs/adr/0001-permissions-are-requests-not-grants.md` +> - ADR-0002: `docs/adr/0002-stable-v1-excludes-code-execution-surfaces.md` +> - ADR-0003: `docs/adr/0003-extension-identity-vs-distribution-identity.md` +> - ADR-0004: `docs/adr/0004-v1-is-an-additive-platform-with-a-demo-extension.md` +> - ADR-0005: `docs/adr/0005-v1-platform-security-boundaries.md` +> +> Terminology in **bold** is defined in `rfc/extensions-platform-context.md` and is canonical for code, comments, UI, docs, and reviews. + +--- + +## Problem Statement + +Mux needs an extension platform that supports local authoring, hot reload, git-based sourcing, and team-reproducible project extension sets without requiring authors to publish npm packages or maintain a `package.json` manifest. The package-based prototype made package identity, package version drift, and npm dependency scanning central to the design; that does not match the desired Mux-native workflow: + +- users should be able to create an extension from Mux and edit it immediately; +- organizations should be able to pin extensions by git tag, branch, SHA, or vendored source; +- project repositories should be able to declare extension sources through a lockfile without injecting trust; +- extension contribution registration should be authored in TypeScript instead of duplicated in a package manifest; +- the first shippable surface should remain small and safe: extension-contributed agent skills. + +The npm/package prototype is still experimental, so v1 should supersede it rather than carry two extension systems. + +--- + +## Solution Summary + +Ship **Mux Extension Modules v1**: a folder-based extension architecture where each **Extension Module** is a directory containing `extension.ts`. The directory basename is the **Extension Name** and `manifest.name` must match it, mirroring the existing agent skill rule that `SKILL.md` frontmatter must match its parent directory. + +`extension.ts` exports: + +```ts +import { defineManifest } from "mux:extensions"; + +export const manifest = defineManifest({ + name: "acme-review", + displayName: "Acme Review", + description: "Review helpers for Acme", + capabilities: { + skills: true, + }, +}); + +export function activate(ctx) { + ctx.skills.register({ + name: "review", + bodyPath: "./skills/review/SKILL.md", + }); +} +``` + +The **Static Manifest** is extracted without executing extension code. Concrete contributions are registered by `activate(ctx)`, first in **Registration Discovery** mode after root trust, then in **Full Activation** mode after enablement and approvals. V1 implements only skill registration through `ctx.skills.register`. + +Source/versioning moves out of the manifest and into **Extension Source Lock** files and Mux's PNPM-inspired store. Git tags, branches, SHAs, optional `//subdir` coordinates, and content hashes are source metadata, not manifest fields. + +--- + +## Goals + +1. Replace the npm/package extension prototype with folder-based Extension Modules. +2. Support Mux-native local extension authoring and hot reload. +3. Support git/source based install coordinates: tag, branch, SHA, and optional `//subdir`. +4. Keep project reproducibility through repo-trackable source locks. +5. Keep trust, enablement, and capability approvals outside project repositories. +6. Reuse the PTC QuickJS sandbox direction for post-trust extension execution. +7. Implement one contribution surface first: agent skills. +8. Preserve existing skill identity and precedence semantics. +9. Keep the Extension Platform always initialized so future built-in skill migrations remain available. + +## Non-goals for v1 + +- npm/package.json extension discovery. +- npm dependency imports from extension code. +- setup/install scripts. +- command, tool, panel, runtime, theme, layout, MCP, secret-provider, or model effect APIs. +- pre-trust execution of project-local extension code. +- source/content changes invalidating existing Effect Capability approvals. +- a public extension catalog. + +--- + +## Core User Stories + +### US-001 — Create a local extension + +A user can create an editable extension folder under Mux's global extension area and see it hot reload after edits. + +Acceptance: + +- Mux scaffolds or recognizes `~/.mux/extensions/local//extension.ts`. +- The folder name must be a valid Extension Name. +- `manifest.name` must match the folder name. +- Static Manifest diagnostics appear without crashing the app. +- A changed `extension.ts` or referenced skill file triggers rediscovery for that extension. + +### US-002 — Install an extension from git + +A user can install an extension from a git source by tag, branch, SHA, or root/subdir coordinate. + +Examples: + +```bash +mux extensions install github.com/acme/mux-review@v0.1.0 +mux extensions install github.com/acme/mux-review@main +mux extensions install github.com/acme/mux-extensions//extensions/review@abc123 +``` + +Acceptance: + +- Mux resolves the source to a commit SHA. +- Mux locates `extension.ts` at the repo root or `//subdir`. +- Mux statically extracts `manifest.name` before choosing the installed Extension Name. +- Mux stores fetched content in `~/.mux/extensions/store//`. +- Mux writes global source metadata to `~/.mux/extensions/lock.json`. + +### US-003 — Project declares extensions reproducibly + +A repository can commit `/.mux/extensions.lock.json` to declare desired extension sources. + +Acceptance: + +- Before project trust, Mux may show that the project declares extensions but must not fetch, parse remote extension code, transpile, or execute extension code. +- After project/root trust, Mux may sync locked sources into the global content-addressed store. +- Trust, enablement, and Capability Approval state is stored only outside the repo in Mux-controlled global state. +- A committed lockfile cannot cause extension execution by itself. + +### US-004 — Vendored project extensions + +A repository can vendor extension source under `/.mux/extensions//extension.ts`. + +Acceptance: + +- Vendored extension code is treated as repo-controlled and remains existence-only before trust. +- After trust, Mux statically extracts its manifest, runs Registration Discovery, and can activate it if enabled. +- Vendored source may be represented in the project lock by content hash/source metadata. + +### US-005 — Registration Discovery previews skills + +After trust, Mux runs `activate(ctx)` in discovery mode to collect intended skill registrations. + +Acceptance: + +- Discovery runs in QuickJS with bounded memory and timeout. +- `ctx.mode` is `"discover"`. +- `ctx.skills.register` collects descriptors and returns no-op disposables. +- Effect Capability APIs cannot perform side effects in discovery mode. +- Discovery failures surface diagnostics and do not crash startup. + +### US-006 — Full Activation publishes only discovered skills + +When an extension is trusted, enabled, and allowed to use its registration capabilities, Mux runs Full Activation and publishes live skills. + +Acceptance: + +- Activation runs in a fresh long-lived sandbox session. +- Activation is async-capable but bounded by timeout and abort controls. +- If activation fails, Mux disposes partial registrations and does not publish them. +- On hot reload, Mux keeps the previous good activation unless trust/capability revocation requires immediate shutdown. +- Full Activation may register only skills observed during Registration Discovery. + +### US-007 — Extension skills follow existing skill identity rules + +Extension-registered skills behave consistently with file/custom skills. + +Acceptance: + +- `ctx.skills.register({ name, bodyPath })` requires `name` to satisfy `SkillNameSchema`. +- `bodyPath` must resolve inside the Extension Module realpath. +- The referenced `SKILL.md` must pass the existing parser and size checks. +- `SKILL.md` frontmatter `name` must match the registered skill name. +- Extension skills sit below project/global custom skills and above built-ins in skill precedence. + +### US-008 — Capability model stays small in v1 + +V1 implements registration capabilities for skills only. + +Acceptance: + +- `manifest.capabilities.skills === true` is required before `ctx.skills.register` can succeed. +- Undeclared registration capability use throws a typed diagnostic error. +- Effect Capability schema may be modeled for future use but no dangerous effect API is exposed in v1. + +### US-009 — Root precedence matches skills + +Duplicate Extension Names across roots shadow by precedence. + +Acceptance: + +- Project-local active extension shadows user-global active extension of the same name. +- User-global active extension shadows bundled non-core extension of the same name. +- Shadowed extensions remain inspectable but do not activate. +- Reserved/core bundled names cannot be shadowed if marked core. + +### US-010 — Platform availability is stable + +The Extension Platform has no user-facing kill switch because future built-in skill migrations depend on extension-contributed skills remaining available. + +Acceptance: + +- The Settings section and palette actions are always reachable. +- Extension discovery and activation are initialized on startup without an experiment gate. +- Deprecated policy or experiment state cannot hide extension-provided skills. + +--- + +## Architecture + +### Source layout + +Global Mux extension storage uses a split layout: + +```text +~/.mux/extensions/ + local/ # editable user-created sources + store/ # immutable content-addressed fetched sources + global/ # active global view by Extension Name + projects/ # active project views by project key and Extension Name + lock.json # global source lock + trust.jsonc # Mux-owned security state, never repo-tracked +``` + +Project repositories may contain: + +```text +/.mux/extensions.lock.json +/.mux/extensions//extension.ts +``` + +Project repositories must not contain security state. + +### Git/source coordinates + +Supported coordinate shape: + +```text +[//subdir][@ref] +``` + +Examples: + +```text +github.com/acme/mux-review@v0.1.0 +github.com/acme/mux-review@main +github.com/acme/mux-extensions//extensions/review@abc123 +``` + +The install/sync flow resolves the ref to a SHA, validates the extension folder, computes a content hash, and materializes from the store into an active view. + +### Static Manifest extraction + +The extractor accepts only a statically analyzable manifest export, for example: + +```ts +export const manifest = defineManifest({ + name: "acme-review", + displayName: "Acme Review", + description: "Review helpers", + capabilities: { skills: true }, +}); +``` + +The manifest is context-free and must not depend on project path, environment, runtime state, source ref, previous approvals, or executed code. It has no required version field. + +### Sandbox execution + +V1 reuses the PTC QuickJS direction but introduces an extension-host layer on top: + +- TypeScript transpile/bundle for `extension.ts` and contained relative imports/resources. +- `mux:*` virtual modules only; npm/bare imports are rejected except for `mux:*`. +- Realpath containment for every resolved relative module/resource. +- Fresh sandbox for Registration Discovery. +- Long-lived sandbox session for Full Activation. +- Memory limits, activation timeouts, handler timeouts, console capture, and abort/dispose controls. + +### Registration Discovery and activation comparison + +Registration Discovery defines a maximum contribution set. Full Activation may register a subset of discovered contributions but may not register a skill absent from discovery. + +Comparison for v1 skills uses at least: + +- contribution type: `skill`; +- skill name; +- referenced `bodyPath` after normalized extension-relative resolution. + +Undiscovered full-activation registrations are activation errors. + +--- + +## Security and Trust + +### Pre-trust behavior + +- Bundled/user-global roots may be statically parsed before trust. +- Project-local roots are existence-only before trust. +- Project lockfiles do not trigger fetch, parse, transpile, or execution before project/root trust. +- No `extension.ts` code executes before root trust in any scope. + +### Approvals outside repos + +Trust, enablement, and Effect Capability approvals live under Mux-controlled global storage, keyed by global/project scope and Extension Name. They are never read from project files and cannot be injected by a repository. + +### Capability approval drift + +Effect approvals drift only when requested Effect Capabilities expand or strengthen. Source/content changes alone do not revoke existing approvals. V1 skills-only registration has no dangerous effect API. + +### Snapshot/cache boundary + +Cached snapshots may accelerate inspection UI but must not be the source of truth for live activation or skill capability decisions. Live discovery/activation results drive the Capability Path. + +--- + +## First Implementation Scope + +V1 implementation should support: + +- Extension Module discovery from active views and optional vendored project sources. +- Static Manifest extraction and validation. +- Extension Name validation and `manifest.name` mismatch diagnostics. +- Root trust gating and skill-like root precedence. +- Registration Discovery in QuickJS for `ctx.skills.register`. +- Full Activation in QuickJS for skills. +- Extension skill source integration with existing agent skill discovery/read paths. +- Global security state outside project repos. +- Source lock schemas and store layout enough to support local and fetched/global flows. +- Settings UI language updated from packages/permissions to folders/sources/capabilities. + +V1 may defer: + +- command/effect APIs; +- setup scripts; +- public catalog; +- immutable snapshots for local editable extensions; +- rich git install UI if CLI/debug commands are enough to dogfood. + +--- + +## Testing Strategy + +### Unit tests + +- manifest extraction accepts only static manifests; +- folder name and `manifest.name` mismatch rejects; +- invalid names reject with stable diagnostics; +- source lock schemas parse global/project lock examples; +- security state cannot be loaded from project paths; +- root precedence shadows by Extension Name; +- Registration Discovery collects skills and rejects undeclared `skills` capability; +- Full Activation rejects undiscovered skills; +- skill `bodyPath` containment, size, and frontmatter-name matching; +- project lock pre-trust does not fetch/parse/execute. + +### Integration tests + +- local editable extension appears after creation and hot reloads; +- fetched git source resolves to store and active view; +- project lock sync only after trust; +- vendored project extension remains existence-only pre-trust and activates after trust; +- extension skill appears in slash menu/agent skill listing; +- higher-precedence project extension shadows global extension of same name; +- extension skills remain available across reloads without any platform-level experiment gate. + +### Security regression tests + +- project repo cannot inject trust/enablement/approvals by committing files; +- relative imports/resources cannot escape extension root through `..` or symlink traversal; +- `extension.ts` top-level code and `activate` never run before trust; +- discovery mode effect APIs cannot perform side effects; +- npm/bare imports are rejected except `mux:*`. + +--- + +## Dogfooding Plan + +Dogfooding must capture reviewer-verifiable evidence. For UI-related steps, run a dev build and use `agent-browser` or Electron automation to capture screenshots and video/snapshots. + +1. **Local authoring path** + - Create `~/.mux/extensions/local/acme-review/extension.ts` and `skills/review/SKILL.md`. + - Start Mux. + - Verify the extension appears in Settings and the skill appears in the slash/skill surface. + - Edit `SKILL.md`; verify hot reload updates the skill. + - Capture Settings and skill-picker screenshots. + +2. **Project lock path** + - Add `/.mux/extensions.lock.json` pointing at a test git source/ref. + - Open the project before trust; verify Mux shows only that extensions are declared. + - Trust project extensions; verify source sync and Registration Discovery diagnostics. + - Capture before/after trust screenshots. + +3. **Vendored project source path** + - Add `/.mux/extensions/acme-review/extension.ts` and skill files. + - Verify no pre-trust execution. + - Trust, enable, activate, and verify skill availability. + +4. **Shadowing path** + - Install a global extension and a project-local extension with the same Extension Name. + - Verify project-local shadows global and global is inspectable as shadowed. + +5. **Kill switch path** + - Toggle the experiment/Governor policy off. + - Verify Settings/palette surfaces disappear or disable and extension skills are removed. + - Toggle back on and verify previous locks/security state restore behavior. + +--- + +## Open Questions + +- Exact project key used under `~/.mux/extensions/projects//` and security state. +- Whether global security state should live in `~/.mux/extensions/trust.jsonc` or be folded into existing `~/.mux/config.json`. +- Whether local editable extensions should activate directly in v1 or snapshot to store before every activation. +- Whether bundled core names are needed in skills-only v1. +- Exact CLI syntax and UI flows for install/update/sync/create. diff --git a/scripts/bundled-extensions.ts b/scripts/bundled-extensions.ts new file mode 100644 index 0000000000..0ecb61bd01 --- /dev/null +++ b/scripts/bundled-extensions.ts @@ -0,0 +1,257 @@ +#!/usr/bin/env bun + +/** + * Bundled-extensions build pipeline. + * + * Subcommands: + * validate Validate every packages//extension.ts Static Manifest + * via the production Manifest Validator (rootKind: "bundled"). + * build Run each bundled-extension package's `build` script if + * one is declared in its package.json. + * assemble [--out] Copy each bundled Extension Module into + * //. Deterministic and offline — no + * install, package.json root, or node_modules tree. + * + * Defaults: + * --out build/extensions + * + * Used by Make targets bundled-extensions-{validate,build,assemble}. + */ + +import { spawnSync } from "node:child_process"; +import { access, cp, mkdir, readFile, readdir, rm } from "node:fs/promises"; +import * as path from "node:path"; + +import { + validateStaticManifest, + type ExtensionDiagnostic, +} from "../src/common/extensions/manifestValidator"; +import { extractStaticManifestFromFile } from "../src/node/extensions/staticManifestExtractor"; + +interface BundledExtensionModule { + /** Absolute path to the package directory under packages/. */ + packageDir: string; + /** Directory name (e.g. mux-extension-platform-demo). */ + dirName: string; + /** Parsed package.json contents, retained for lockstep app-version validation. */ + pkg: Record; + /** Extension Module name from Static Manifest. */ + extensionName: string; + /** The Static Manifest object exported from extension.ts. */ + rawManifest: Record; +} + +const REPO_ROOT = path.resolve(import.meta.dir, ".."); +const PACKAGES_DIR = process.env.MUX_BUNDLED_EXTENSIONS_PACKAGES_DIR + ? path.resolve(process.env.MUX_BUNDLED_EXTENSIONS_PACKAGES_DIR) + : path.join(REPO_ROOT, "packages"); +const DEFAULT_OUT_DIR = path.join(REPO_ROOT, "build", "extensions"); + +async function readJson(filePath: string): Promise> { + const text = await readFile(filePath, "utf-8"); + const parsed: unknown = JSON.parse(text); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`${filePath} is not a JSON object`); + } + return parsed as Record; +} + +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function discoverBundledExtensionModules(): Promise { + let entries; + try { + entries = await readdir(PACKAGES_DIR, { withFileTypes: true }); + } catch { + return []; + } + const out: BundledExtensionModule[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const packageDir = path.join(PACKAGES_DIR, entry.name); + const pkgPath = path.join(packageDir, "package.json"); + const entrypointPath = path.join(packageDir, "extension.ts"); + const hasEntrypoint = await fileExists(entrypointPath); + let pkg: Record; + try { + pkg = await readJson(pkgPath); + } catch (error) { + if (!hasEntrypoint) continue; + throw new Error( + `[bundled-extensions] ${entry.name} has extension.ts but package.json could not be read: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if (!hasEntrypoint) continue; + const extraction = await extractStaticManifestFromFile(entrypointPath); + if (!extraction.ok) { + const diagnostics = extraction.diagnostics.map(formatDiagnostic).join("\n"); + throw new Error( + `[bundled-extensions] ${entry.name} has an invalid Static Manifest:\n${diagnostics}` + ); + } + const extensionName = extraction.manifest.name; + if (typeof extensionName !== "string") { + throw new Error(`[bundled-extensions] ${entry.name} Static Manifest is missing name.`); + } + out.push({ + packageDir, + dirName: entry.name, + pkg, + extensionName, + rawManifest: extraction.manifest, + }); + } + out.sort((a, b) => a.extensionName.localeCompare(b.extensionName)); + return out; +} + +function formatDiagnostic(d: ExtensionDiagnostic): string { + const ref = d.contributionRef + ? ` [${d.contributionRef.type}#${d.contributionRef.id ?? d.contributionRef.index}]` + : ""; + return ` [${d.severity}] ${d.code}${ref}: ${d.message}`; +} + +async function cmdValidate(): Promise { + const packages = await discoverBundledExtensionModules(); + if (packages.length === 0) { + console.log("[bundled-extensions] validate: no bundled Extension Modules found"); + return 0; + } + let failed = 0; + for (const p of packages) { + const result = validateStaticManifest({ + rawManifest: p.rawManifest, + extensionName: p.extensionName, + rootKind: "bundled", + }); + const tag = result.ok ? "OK" : "FAILED"; + const out = result.ok ? console.log : console.error; + out(`[bundled-extensions] validate: ${p.dirName} ${tag}`); + for (const d of result.diagnostics) out(formatDiagnostic(d)); + if (!result.ok) failed++; + } + + // Bundled extensions ship with the app, so version drift breaks release reproducibility. + const rootPkg = await readJson(path.join(REPO_ROOT, "package.json")); + const rootVersion = typeof rootPkg.version === "string" ? rootPkg.version : ""; + for (const p of packages) { + const pkgVersion = typeof p.pkg.version === "string" ? p.pkg.version : ""; + if (pkgVersion !== rootVersion) { + console.error( + `[bundled-extensions] validate: ${p.dirName} version ${pkgVersion} does not match Mux app version ${rootVersion} (lockstep required)` + ); + failed++; + } + } + + return failed === 0 ? 0 : 1; +} + +async function cmdBuild(): Promise { + const packages = await discoverBundledExtensionModules(); + for (const p of packages) { + const scripts = p.pkg.scripts; + const hasBuild = + typeof scripts === "object" && + scripts !== null && + typeof (scripts as Record).build === "string"; + if (!hasBuild) { + console.log(`[bundled-extensions] build: ${p.dirName} (no build script, skipping)`); + continue; + } + console.log(`[bundled-extensions] build: ${p.dirName} (running bun run build)`); + const result = spawnSync("bun", ["run", "build"], { + cwd: p.packageDir, + stdio: "inherit", + }); + if (result.status !== 0) { + console.error(`[bundled-extensions] build: ${p.dirName} FAILED (exit ${result.status})`); + return 1; + } + } + return 0; +} + +interface AssembleOptions { + outDir: string; +} + +async function assembleBundledExtensions( + options: AssembleOptions +): Promise<{ outDir: string; packages: BundledExtensionModule[] }> { + const packages = await discoverBundledExtensionModules(); + const outDir = path.resolve(options.outDir); + + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + + for (const p of packages) { + const targetDir = path.join(outDir, p.extensionName); + await cp(p.packageDir, targetDir, { + recursive: true, + filter: (source) => path.basename(source) !== "package.json", + }); + console.log(`[bundled-extensions] assemble: ${p.dirName} → ${targetDir}`); + } + + return { outDir, packages }; +} + +async function cmdAssemble(args: string[]): Promise { + let outDir = DEFAULT_OUT_DIR; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--out" || a === "--out-dir") { + const next = args[i + 1]; + if (!next) { + console.error("[bundled-extensions] assemble: --out requires a value"); + return 2; + } + outDir = path.resolve(next); + i++; + } else { + console.error(`[bundled-extensions] assemble: unknown argument: ${a}`); + return 2; + } + } + const { packages } = await assembleBundledExtensions({ outDir }); + if (packages.length === 0) { + console.log("[bundled-extensions] assemble: no packages found, wrote empty root"); + } + return 0; +} + +async function main(): Promise { + const [command, ...rest] = process.argv.slice(2); + switch (command) { + case "validate": + return cmdValidate(); + case "build": + return cmdBuild(); + case "assemble": + return cmdAssemble(rest); + default: + console.error(`Usage: bun scripts/bundled-extensions.ts [options]`); + return command === undefined ? 0 : 2; + } +} + +if (import.meta.main) { + main().then( + (code) => process.exit(code), + (err: unknown) => { + console.error(err instanceof Error ? (err.stack ?? err.message) : err); + process.exit(1); + } + ); +} diff --git a/scripts/check_codex_comments.sh b/scripts/check_codex_comments.sh index f580b347bc..c67bd02e8b 100755 --- a/scripts/check_codex_comments.sh +++ b/scripts/check_codex_comments.sh @@ -79,16 +79,25 @@ graphql_with_retries() { compute_codex_sets_from_arrays() { local comments_json="$1" local threads_json="$2" - - REGULAR_COMMENTS=$(jq -cn --argjson comments "$comments_json" --arg bot "$BOT_LOGIN_GRAPHQL" '[ - $comments[] + local comments_file + local threads_file + comments_file=$(mktemp) + threads_file=$(mktemp) + trap 'rm -f "$comments_file" "$threads_file"' RETURN + printf '%s\n' "$comments_json" >"$comments_file" + printf '%s\n' "$threads_json" >"$threads_file" + + REGULAR_COMMENTS=$(jq -cn --slurpfile comments "$comments_file" --arg bot "$BOT_LOGIN_GRAPHQL" '[ + $comments[0][] | select(.author.login == $bot and .isMinimized == false and (.body | test("Didn.t find any major issues|usage limits have been reached|create a Codex account") | not)) ]') - UNRESOLVED_THREADS=$(jq -cn --argjson threads "$threads_json" --arg bot "$BOT_LOGIN_GRAPHQL" '[ - $threads[] + UNRESOLVED_THREADS=$(jq -cn --slurpfile threads "$threads_file" --arg bot "$BOT_LOGIN_GRAPHQL" '[ + $threads[0][] | select(.isResolved == false and .comments.nodes[0].author.login == $bot) ]') + rm -f "$comments_file" "$threads_file" + trap - RETURN } load_result_from_cache() { diff --git a/scripts/lib/coder_agents_review.sh b/scripts/lib/coder_agents_review.sh index 50346adc5c..fb58235ddc 100755 --- a/scripts/lib/coder_agents_review.sh +++ b/scripts/lib/coder_agents_review.sh @@ -76,9 +76,13 @@ graphql_with_retries() { coder_agents_unresolved_threads_from_json() { local threads_json="$1" local bot_regex="$2" + local threads_file + local output + threads_file=$(mktemp) + printf '%s\n' "$threads_json" >"$threads_file" - jq -rn --argjson threads "$threads_json" --arg bot_regex "$bot_regex" ' - $threads[] + if output=$(jq -rn --slurpfile threads "$threads_file" --arg bot_regex "$bot_regex" ' + $threads[0][] | select(.isResolved == false and any(.comments.nodes[]?; ((.author.login // "") | test($bot_regex)))) | . as $thread | ([.comments.nodes[]? | select((.author.login // "") | test($bot_regex))] | first) as $bot_comment @@ -90,7 +94,15 @@ coder_agents_unresolved_threads_from_json() { line: ($bot_comment.line // ""), created_at: ($bot_comment.createdAt // "") } - ' + '); then + rm -f "$threads_file" + printf '%s\n' "$output" + return 0 + else + local rc=$? + rm -f "$threads_file" + return "$rc" + fi } # Prints records emitted by coder_agents_unresolved_threads_from_json. diff --git a/scripts/wait_pr_coder_agents_review.sh b/scripts/wait_pr_coder_agents_review.sh index 3abf0c7887..7209485abc 100755 --- a/scripts/wait_pr_coder_agents_review.sh +++ b/scripts/wait_pr_coder_agents_review.sh @@ -36,7 +36,7 @@ fi REQUEST_COMMAND="/coder-agents-review" # Match both the app slug and GitHub's bot-login form. BOT_LOGIN_REGEX="${CODER_AGENTS_REVIEW_BOT_LOGIN_REGEX:-^coder-agents-review(\[bot\])?$}" -CODER_AGENTS_BOT_APPROVAL_REGEX="^(no (issues|problems)( found)?[.]?|no major issues( found)?[.]?|didn.t find (any )?(major )?(issues|problems)[.]?|review complete(d)?[.]?|zero open findings[.]?|zero open findings across .* (coder-agents-review |review )?complete[.]?|Round [0-9]+[.] zero open findings[.] (coder-agents-review |review )?complete[.]?|Round [0-9]+[.] zero open findings across .* (coder-agents-review |review )?complete[.]?)$" +CODER_AGENTS_BOT_APPROVAL_REGEX="^(no (issues|problems)( found)?[.]?|no major issues( found)?[.]?|didn.t find (any )?(major )?(issues|problems)[.]?|review complete(d)?[.]?|zero open findings[.]?|zero open findings across .* (coder-agents-review |review )?complete[.]?|Round [0-9]+[.] zero open findings[.] (coder-agents-review |review )?complete[.]?|Round [0-9]+[.] zero open findings across .* (coder-agents-review |review )?complete[.]?|.*approval stands[.]?.*)$" CODER_AGENTS_BOT_NEGATIVE_BEFORE_APPROVAL_REGEX="^(Round [0-9]+ is blocked|Review failed|Failed to review|Unable to review|Cannot review|Could not review|Review timed out|Request timed out|Review cancelled|Request cancelled)" CODER_AGENTS_BOT_PROGRESS_REGEX="^(queued|started|running|in progress|reviewing|will review)[[:space:][:punct:]]*$" POLL_INTERVAL_SECS=30 @@ -444,12 +444,23 @@ check_coder_agents_status_once() { return "$load_rc" fi + local comments_file + local reviews_file + local threads_file + comments_file=$(mktemp) + reviews_file=$(mktemp) + threads_file=$(mktemp) + trap 'rm -f "$comments_file" "$reviews_file" "$threads_file"; trap - RETURN' RETURN + printf '%s\n' "$COMMENTS_JSON" >"$comments_file" + printf '%s\n' "$REVIEWS_JSON" >"$reviews_file" + printf '%s\n' "$THREADS_JSON" >"$threads_file" + request_at=$(jq -rn \ - --argjson comments "$COMMENTS_JSON" \ + --slurpfile comments "$comments_file" \ --arg command "$REQUEST_COMMAND" \ --arg bot_regex "$BOT_LOGIN_REGEX" ' [ - $comments[] + $comments[0][] | select((((.author.login // "") | test($bot_regex)) | not) and ((.body // "") | test("(?m)^\\s*/coder-agents-review\\s*$"))) ] | sort_by(.createdAt) @@ -464,22 +475,22 @@ check_coder_agents_status_once() { fi bot_activity_count=$(jq -rn \ - --argjson comments "$COMMENTS_JSON" \ - --argjson reviews "$REVIEWS_JSON" \ - --argjson threads "$THREADS_JSON" \ + --slurpfile comments "$comments_file" \ + --slurpfile reviews "$reviews_file" \ + --slurpfile threads "$threads_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" ' ([ - $comments[] + $comments[0][] | select((.author.login // "") | test($bot_regex)) ] | length) + ([ - $reviews[] + $reviews[0][] | select(((.author.login // "") | test($bot_regex)) and (.state != "DISMISSED")) ] | length) + ([ - $threads[].comments.nodes[]? + $threads[0][].comments.nodes[]? | select((.author.login // "") | test($bot_regex)) ] | length) ') @@ -502,18 +513,18 @@ check_coder_agents_status_once() { if [[ -n "$request_at" ]]; then response_count=$(jq -rn \ - --argjson comments "$COMMENTS_JSON" \ - --argjson reviews "$REVIEWS_JSON" \ - --argjson threads "$THREADS_JSON" \ + --slurpfile comments "$comments_file" \ + --slurpfile reviews "$reviews_file" \ + --slurpfile threads "$threads_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" \ --arg request_at "$request_at" ' ([ - $comments[] + $comments[0][] | select(((.author.login // "") | test($bot_regex)) and (.createdAt > $request_at)) ] | length) + ([ - $reviews[] + $reviews[0][] | select( ((.author.login // "") | test($bot_regex)) and (.state != "DISMISSED") @@ -522,7 +533,7 @@ check_coder_agents_status_once() { ] | length) + ([ - $threads[].comments.nodes[]? + $threads[0][].comments.nodes[]? | select(((.author.login // "") | test($bot_regex)) and (.createdAt > $request_at)) ] | length) ') @@ -532,11 +543,11 @@ check_coder_agents_status_once() { fi latest_issue_comment_body=$(jq -rn \ - --argjson comments "$COMMENTS_JSON" \ + --slurpfile comments "$comments_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" \ --arg request_at "$request_at" ' [ - $comments[] + $comments[0][] | select(((.author.login // "") | test($bot_regex)) and (.createdAt > $request_at)) ] | sort_by(.createdAt) @@ -544,11 +555,11 @@ check_coder_agents_status_once() { | .body // empty ') latest_issue_comment_at=$(jq -rn \ - --argjson comments "$COMMENTS_JSON" \ + --slurpfile comments "$comments_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" \ --arg request_at "$request_at" ' [ - $comments[] + $comments[0][] | select(((.author.login // "") | test($bot_regex)) and (.createdAt > $request_at)) ] | sort_by(.createdAt) @@ -557,11 +568,11 @@ check_coder_agents_status_once() { ') latest_review_body=$(jq -rn \ - --argjson reviews "$REVIEWS_JSON" \ + --slurpfile reviews "$reviews_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" \ --arg request_at "$request_at" ' [ - $reviews[] + $reviews[0][] | select( ((.author.login // "") | test($bot_regex)) and (.state != "DISMISSED") @@ -574,11 +585,11 @@ check_coder_agents_status_once() { | .body // empty ') latest_review_state=$(jq -rn \ - --argjson reviews "$REVIEWS_JSON" \ + --slurpfile reviews "$reviews_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" \ --arg request_at "$request_at" ' [ - $reviews[] + $reviews[0][] | select( ((.author.login // "") | test($bot_regex)) and (.state != "DISMISSED") @@ -591,11 +602,11 @@ check_coder_agents_status_once() { | .state // empty ') latest_review_at=$(jq -rn \ - --argjson reviews "$REVIEWS_JSON" \ + --slurpfile reviews "$reviews_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" \ --arg request_at "$request_at" ' [ - $reviews[] + $reviews[0][] | select( ((.author.login // "") | test($bot_regex)) and (.state != "DISMISSED") @@ -609,10 +620,10 @@ check_coder_agents_status_once() { ') else latest_issue_comment_body=$(jq -rn \ - --argjson comments "$COMMENTS_JSON" \ + --slurpfile comments "$comments_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" ' [ - $comments[] + $comments[0][] | select((.author.login // "") | test($bot_regex)) ] | sort_by(.createdAt) @@ -620,10 +631,10 @@ check_coder_agents_status_once() { | .body // empty ') latest_issue_comment_at=$(jq -rn \ - --argjson comments "$COMMENTS_JSON" \ + --slurpfile comments "$comments_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" ' [ - $comments[] + $comments[0][] | select((.author.login // "") | test($bot_regex)) ] | sort_by(.createdAt) @@ -632,10 +643,10 @@ check_coder_agents_status_once() { ') latest_review_body=$(jq -rn \ - --argjson reviews "$REVIEWS_JSON" \ + --slurpfile reviews "$reviews_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" ' [ - $reviews[] + $reviews[0][] | select(((.author.login // "") | test($bot_regex)) and (.state != "DISMISSED")) | . + {reviewedAt: (.submittedAt // .createdAt // "")} ] @@ -644,10 +655,10 @@ check_coder_agents_status_once() { | .body // empty ') latest_review_state=$(jq -rn \ - --argjson reviews "$REVIEWS_JSON" \ + --slurpfile reviews "$reviews_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" ' [ - $reviews[] + $reviews[0][] | select(((.author.login // "") | test($bot_regex)) and (.state != "DISMISSED")) | . + {reviewedAt: (.submittedAt // .createdAt // "")} ] @@ -656,10 +667,10 @@ check_coder_agents_status_once() { | .state // empty ') latest_review_at=$(jq -rn \ - --argjson reviews "$REVIEWS_JSON" \ + --slurpfile reviews "$reviews_file" \ --arg bot_regex "$BOT_LOGIN_REGEX" ' [ - $reviews[] + $reviews[0][] | select(((.author.login // "") | test($bot_regex)) and (.state != "DISMISSED")) | . + {reviewedAt: (.submittedAt // .createdAt // "")} ] diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 6862f3854d..8c812069ff 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -41,6 +41,7 @@ import { LEFT_SIDEBAR_MIN_WIDTH_PX, } from "@/constants/layout"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; +import { useExtensionsPaletteSource } from "./hooks/useExtensionsPaletteSource"; import { getTopLevelProjectEntries } from "@/common/utils/subProjects"; import { THINKING_LEVELS, type ThinkingLevel } from "@/common/types/thinking"; @@ -783,6 +784,8 @@ function AppInner() { return unregister; }, [registerSource, workspaceStore]); + useExtensionsPaletteSource(openSettings); + // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/src/browser/components/SkillIndicator/SkillIndicator.tsx b/src/browser/components/SkillIndicator/SkillIndicator.tsx index 30d2af5951..ff807373ae 100644 --- a/src/browser/components/SkillIndicator/SkillIndicator.tsx +++ b/src/browser/components/SkillIndicator/SkillIndicator.tsx @@ -29,6 +29,7 @@ interface SkillIndicatorProps { const SCOPE_CONFIG: Array<{ scope: AgentSkillScope; label: string }> = [ { scope: "project", label: "Project" }, { scope: "global", label: "Global" }, + { scope: "extension", label: "Extension" }, { scope: "built-in", label: "Built-in" }, ]; diff --git a/src/browser/contexts/ExperimentsContext.test.tsx b/src/browser/contexts/ExperimentsContext.test.tsx index 7fe517d69a..6098d20878 100644 --- a/src/browser/contexts/ExperimentsContext.test.tsx +++ b/src/browser/contexts/ExperimentsContext.test.tsx @@ -58,6 +58,46 @@ async function importIsolatedExperimentModules() { return tempDir; } +function createManualAsyncIterable(): { + iterable: AsyncIterable; + push: (value: T) => void; + close: () => void; +} { + const queue: T[] = []; + const waiters: Array<() => void> = []; + let closed = false; + + const wake = () => { + waiters.shift()?.(); + }; + + return { + iterable: { + async *[Symbol.asyncIterator]() { + while (!closed) { + if (queue.length === 0) { + await new Promise((resolve) => waiters.push(resolve)); + } + while (queue.length > 0) { + const value = queue.shift(); + if (value !== undefined) { + yield value; + } + } + } + }, + }, + push(value: T) { + queue.push(value); + wake(); + }, + close() { + closed = true; + while (waiters.length > 0) wake(); + }, + }; +} + let originalWindow: typeof globalThis.window; let originalDocument: typeof globalThis.document; let originalLocalStorage: typeof globalThis.localStorage; @@ -216,6 +256,59 @@ describe("ExperimentsProvider", () => { expect(getAllMock.mock.calls.length).toBeGreaterThanOrEqual(2); }); + test("refreshes remote experiments when the backend emits a change", async () => { + let callCount = 0; + const changes = createManualAsyncIterable(); + const getAllMock = mock(() => { + callCount += 1; + return Promise.resolve({ + [EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES]: { + value: callCount === 1, + source: "posthog", + }, + } satisfies Record); + }); + const onChangedMock = mock(() => Promise.resolve(changes.iterable)); + + currentClientMock = { + experiments: { + getAll: getAllMock, + onChanged: onChangedMock as unknown as APIClient["experiments"]["onChanged"], + reload: mock(() => Promise.resolve()), + }, + }; + + function Observer() { + const enabled = useExperimentValue(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES); + return
{String(enabled)}
; + } + + const { getByTestId } = render( + + + + + + ); + + await waitFor(() => { + expect(onChangedMock).toHaveBeenCalled(); + expect(getAllMock).toHaveBeenCalledTimes(1); + expect(getByTestId("enabled").textContent).toBe("true"); + }); + + act(() => { + changes.push(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES); + }); + + await waitFor(() => { + expect(getAllMock).toHaveBeenCalledTimes(2); + expect(getByTestId("enabled").textContent).toBe("false"); + }); + + changes.close(); + }); + test("syncs existing local overrides to the backend on connect", async () => { globalThis.window.localStorage.setItem( getExperimentKey(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES), diff --git a/src/browser/contexts/ExperimentsContext.tsx b/src/browser/contexts/ExperimentsContext.tsx index d54f741550..d4db1bfbfb 100644 --- a/src/browser/contexts/ExperimentsContext.tsx +++ b/src/browser/contexts/ExperimentsContext.tsx @@ -272,6 +272,31 @@ export function ExperimentsProvider(props: { children: React.ReactNode }) { void syncLocalOverrides(); }, [apiState.status, apiState.api, loadRemoteExperiments]); + useEffect(() => { + if (apiState.status !== "connected" || !apiState.api) { + return; + } + + const abortController = new AbortController(); + const signal = abortController.signal; + + (async () => { + try { + const iterator = await apiState.api.experiments.onChanged(undefined, { signal }); + for await (const _experimentId of iterator) { + if (signal.aborted) { + break; + } + void loadRemoteExperiments(); + } + } catch { + // Expected on unmount or older injected test clients. + } + })(); + + return () => abortController.abort(); + }, [apiState.status, apiState.api, loadRemoteExperiments]); + // On cold start, experiments.getAll can return { source: "cache", value: null } while // ExperimentsService refreshes from PostHog in the background. Poll a few times so the // renderer picks up remote variants without requiring a manual reload. diff --git a/src/browser/contexts/PolicyContext.test.tsx b/src/browser/contexts/PolicyContext.test.tsx index e556727cdd..efb23da7cd 100644 --- a/src/browser/contexts/PolicyContext.test.tsx +++ b/src/browser/contexts/PolicyContext.test.tsx @@ -79,6 +79,7 @@ const buildEnforcedResponse = (): PolicyGetResponse => ({ providerAccess: null, mcp: { allowUserDefined: { stdio: true, remote: true } }, runtimes: null, + extensionPlatform: null, }, }); diff --git a/src/browser/features/Settings/Sections/ConsentShortcutModal.test.tsx b/src/browser/features/Settings/Sections/ConsentShortcutModal.test.tsx new file mode 100644 index 0000000000..75bd5e0d76 --- /dev/null +++ b/src/browser/features/Settings/Sections/ConsentShortcutModal.test.tsx @@ -0,0 +1,150 @@ +import "../../../../../tests/ui/dom"; + +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { z } from "zod"; + +import { ThemeProvider } from "@/browser/contexts/ThemeContext"; +import type * as schemas from "@/common/orpc/schemas/extensionRegistry"; +import { installDom } from "../../../../../tests/ui/dom"; +import { ConsentShortcutModal } from "./ConsentShortcutModal"; + +type DiscoveredExtension = z.infer; +type CalculatePermissionsResult = z.infer; + +function makeExtension(overrides: Partial = {}): DiscoveredExtension { + return { + extensionId: "vendor.demo", + rootId: "root-1", + rootKind: "user-global", + isCore: false, + modulePath: "/path/to/pkg", + manifest: { + manifestVersion: 1, + id: "vendor.demo", + displayName: "Demo Extension", + description: undefined, + publisher: undefined, + homepage: undefined, + requestedPermissions: ["skill.register", "secrets.read", "process.spawn"], + contributions: [{ type: "skills", id: "demo.skill", index: 0, descriptor: {} }], + }, + contributions: [], + diagnostics: [], + enabled: false, + granted: false, + activated: false, + ...overrides, + }; +} + +function makePermissions( + overrides: Partial = {} +): CalculatePermissionsResult { + return { + effectivePermissions: [], + pendingNew: ["secrets.read", "process.spawn"], + contributions: [], + driftStatus: "fresh", + isStale: false, + ...overrides, + }; +} + +function renderModal(props: Partial> = {}) { + const onConfirm = mock(() => undefined); + const onReviewIndividually = mock(() => undefined); + const onClose = mock(() => undefined); + + const view = render( + + + + ); + return { view, onConfirm, onReviewIndividually, onClose }; +} + +describe("ConsentShortcutModal", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + mock.restore(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("does not render when isOpen=false", () => { + const { view } = renderModal({ isOpen: false }); + expect(view.queryByTestId("consent-shortcut-modal")).toBeNull(); + }); + + test("does not render when extension is null", () => { + const { view } = renderModal({ extension: null }); + expect(view.queryByTestId("consent-shortcut-modal")).toBeNull(); + }); + + test("renders summary with display name and effect capabilities only", () => { + const { view } = renderModal(); + expect(view.getByText(/Set up Demo Extension/)).toBeTruthy(); + expect(view.getByText("vendor.demo")).toBeTruthy(); + expect(view.queryByText(/demo-extension@1.2.3/)).toBeNull(); + // Effect capabilities appear (not the inferred .register one) + expect(view.getByText("secrets.read")).toBeTruthy(); + expect(view.getByText("process.spawn")).toBeTruthy(); + // .register is mentioned via the registration capability summary line, not as an entry + expect(view.queryByText("skill.register")).toBeNull(); + expect(view.getByText(/Plus 1 registration capability/)).toBeTruthy(); + }); + + test("renders contributions list", () => { + const { view } = renderModal(); + expect(view.getByText(/Contributions \(1\)/)).toBeTruthy(); + expect(view.getByText("demo.skill")).toBeTruthy(); + }); + + test("only mentions Trust the project-local Extensions root when requiresTrustRoot is true", () => { + const { view: a } = renderModal({ requiresTrustRoot: false }); + expect(a.queryByText(/Trust the project-local/i)).toBeNull(); + + const { view: b } = renderModal({ requiresTrustRoot: true }); + expect(b.getByText(/Trust the project-local/i)).toBeTruthy(); + }); + + test("Confirm button invokes onConfirm", () => { + const { view, onConfirm } = renderModal(); + fireEvent.click(view.getByLabelText("Confirm consent shortcut")); + expect(onConfirm).toHaveBeenCalled(); + }); + + test("Review individually link invokes onReviewIndividually", () => { + const { view, onReviewIndividually } = renderModal(); + fireEvent.click(view.getByTestId("consent-shortcut-review-individually")); + expect(onReviewIndividually).toHaveBeenCalled(); + }); + + test("Cancel button invokes onClose", () => { + const { view, onClose } = renderModal(); + fireEvent.click(view.getByLabelText("Cancel consent")); + expect(onClose).toHaveBeenCalled(); + }); + + test("backdrop click invokes onClose", () => { + const { view, onClose } = renderModal(); + fireEvent.click(view.getByLabelText("Close consent shortcut")); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/browser/features/Settings/Sections/ConsentShortcutModal.tsx b/src/browser/features/Settings/Sections/ConsentShortcutModal.tsx new file mode 100644 index 0000000000..c938e00584 --- /dev/null +++ b/src/browser/features/Settings/Sections/ConsentShortcutModal.tsx @@ -0,0 +1,216 @@ +import { useEffect, useRef } from "react"; +import { CheckCircle2, ShieldCheck, X } from "lucide-react"; +import type { z } from "zod"; + +import { trapTabKey } from "./dialogFocus"; +import { Button } from "@/browser/components/Button/Button"; +import type * as schemas from "@/common/orpc/schemas/extensionRegistry"; + +type DiscoveredExtension = z.infer; +type CalculatePermissionsResult = z.infer; + +export interface ConsentShortcutModalProps { + isOpen: boolean; + extension: DiscoveredExtension | null; + permissions: CalculatePermissionsResult | null; + /** + * When true, the modal lists "Trust the project-local root" as part of the + * single confirmation transaction. Reserved for project-local roots whose + * trust state must be approved in the same step (per spec). + */ + requiresTrustRoot: boolean; + onConfirm: () => void; + onReviewIndividually: () => void; + onClose: () => void; +} + +// Registration Capabilities are mechanical (`.register`); the summary +// highlights effect capabilities because those are the security-relevant +// approvals the user is granting. +function partitionCapabilities(capabilities: readonly string[]): { + registration: string[]; + effect: string[]; +} { + const registration: string[] = []; + const effect: string[] = []; + for (const capability of capabilities) { + if (capability.endsWith(".register")) registration.push(capability); + else effect.push(capability); + } + return { registration, effect }; +} + +export const ConsentShortcutModal: React.FC = ({ + isOpen, + extension, + permissions, + requiresTrustRoot, + onConfirm, + onReviewIndividually, + onClose, +}) => { + const confirmButtonRef = useRef(null); + const panelRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + confirmButtonRef.current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen, onClose]); + + if (!isOpen || !extension) return null; + + const displayName = extension.manifest.displayName ?? extension.manifest.id; + const { effect, registration } = partitionCapabilities(extension.manifest.requestedPermissions); + const contributions = extension.manifest.contributions; + const hasPendingNew = (permissions?.pendingNew.length ?? 0) > 0; + + return ( +
+ +
+ +
+

+ Confirming will apply the following changes in sequence: +

+ +
    + {requiresTrustRoot && ( +
  • + + Trust the project-local Extensions root. +
  • + )} +
  • + + Enable this Extension. +
  • +
  • + + + Approve the requested capabilities. + {hasPendingNew && ( + + ({permissions?.pendingNew.length} pending) + + )} + +
  • +
+ +
+

+ Effect Capabilities ({effect.length}) +

+ {effect.length === 0 ? ( +

None requested.

+ ) : ( +
    + {effect.map((capability) => ( +
  • + {capability} +
  • + ))} +
+ )} + {registration.length > 0 && ( +

+ Plus {registration.length} registration capability + {registration.length === 1 ? "" : "s"} from declared contributions. +

+ )} +
+ +
+

+ Contributions ({contributions.length}) +

+ {contributions.length === 0 ? ( +

None declared.

+ ) : ( +
    + {contributions.map((c) => ( +
  • + {c.type} + / + {c.id} +
  • + ))} +
+ )} +
+
+ +
+ +
+ + +
+
+ + + ); +}; diff --git a/src/browser/features/Settings/Sections/DestructiveConfirmDialog.test.tsx b/src/browser/features/Settings/Sections/DestructiveConfirmDialog.test.tsx new file mode 100644 index 0000000000..fde78675a1 --- /dev/null +++ b/src/browser/features/Settings/Sections/DestructiveConfirmDialog.test.tsx @@ -0,0 +1,75 @@ +import "../../../../../tests/ui/dom"; + +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +import { ThemeProvider } from "@/browser/contexts/ThemeContext"; +import { installDom } from "../../../../../tests/ui/dom"; +import { DestructiveConfirmDialog } from "./DestructiveConfirmDialog"; + +function renderDialog(props: Partial> = {}) { + const onConfirm = mock(() => undefined); + const onClose = mock(() => undefined); + const view = render( + + + + ); + return { view, onConfirm, onClose }; +} + +describe("DestructiveConfirmDialog", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + mock.restore(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("does not render when isOpen=false", () => { + const { view } = renderDialog({ isOpen: false }); + expect(view.queryByTestId("destructive-confirm-dialog")).toBeNull(); + }); + + test("renders title, description, and consequences list", () => { + const { view } = renderDialog(); + expect(view.getByText("Disable My Extension?")).toBeTruthy(); + expect(view.getByText(/Disabling stops contributions/)).toBeTruthy(); + expect(view.getByText("Contribution availability ends.")).toBeTruthy(); + expect(view.getByText("Approval record preserved.")).toBeTruthy(); + }); + + test("Confirm button uses provided label", () => { + const { view } = renderDialog({ confirmLabel: "Untrust root" }); + expect(view.getByLabelText("Confirm: Untrust root")).toBeTruthy(); + }); + + test("Confirm invokes onConfirm; Cancel invokes onClose", () => { + const { view, onConfirm, onClose } = renderDialog(); + fireEvent.click(view.getByLabelText("Confirm: Disable")); + expect(onConfirm).toHaveBeenCalled(); + fireEvent.click(view.getByLabelText("Cancel destructive action")); + expect(onClose).toHaveBeenCalled(); + }); + + test("backdrop click invokes onClose", () => { + const { view, onClose } = renderDialog(); + fireEvent.click(view.getByLabelText("Close confirmation")); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/browser/features/Settings/Sections/DestructiveConfirmDialog.tsx b/src/browser/features/Settings/Sections/DestructiveConfirmDialog.tsx new file mode 100644 index 0000000000..dad3c3a7ef --- /dev/null +++ b/src/browser/features/Settings/Sections/DestructiveConfirmDialog.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef } from "react"; +import { AlertTriangle, X } from "lucide-react"; + +import { trapTabKey } from "./dialogFocus"; +import { Button } from "@/browser/components/Button/Button"; + +export interface DestructiveConfirmDialogProps { + isOpen: boolean; + title: string; + description: string; + consequences: readonly string[]; + confirmLabel: string; + onConfirm: () => void; + onClose: () => void; +} + +export const DestructiveConfirmDialog: React.FC = ({ + isOpen, + title, + description, + consequences, + confirmLabel, + onConfirm, + onClose, +}) => { + const cancelRef = useRef(null); + const panelRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + cancelRef.current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+ +
+ +
+

{description}

+ {consequences.length > 0 && ( +
+

Consequences

+
    + {consequences.map((c, idx) => ( +
  • + + {c} +
  • + ))} +
+
+ )} +
+ +
+ + +
+ + + ); +}; diff --git a/src/browser/features/Settings/Sections/ExtensionCard.test.tsx b/src/browser/features/Settings/Sections/ExtensionCard.test.tsx new file mode 100644 index 0000000000..0eb89ff26f --- /dev/null +++ b/src/browser/features/Settings/Sections/ExtensionCard.test.tsx @@ -0,0 +1,432 @@ +import "../../../../../tests/ui/dom"; + +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { z } from "zod"; + +import { ThemeProvider } from "@/browser/contexts/ThemeContext"; +import type * as schemas from "@/common/orpc/schemas/extensionRegistry"; +import { installDom } from "../../../../../tests/ui/dom"; +import { ExtensionCard, StaleRecordCard, computeExtensionStatus } from "./ExtensionCard"; + +type DiscoveredExtension = z.infer; +type CalculatePermissionsResult = z.infer; +type ExtensionDiagnostic = z.infer; +type StaleRecord = z.infer; + +function makeExtension(overrides: Partial = {}): DiscoveredExtension { + return { + extensionId: "vendor.demo", + rootId: "root-1", + rootKind: "user-global", + isCore: false, + modulePath: "/path/to/pkg", + manifest: { + manifestVersion: 1, + id: "vendor.demo", + displayName: "Demo Extension", + description: "A demo extension for testing", + publisher: "Acme", + homepage: "https://example.com", + requestedPermissions: ["skill.register", "secrets.read"], + contributions: [ + { type: "skills", id: "demo.skill", index: 0, descriptor: {} }, + { type: "panels", id: "demo.panel", index: 0, descriptor: {} }, + ], + }, + contributions: [], + diagnostics: [], + enabled: true, + granted: true, + activated: true, + ...overrides, + }; +} + +function makePermissions( + overrides: Partial = {} +): CalculatePermissionsResult { + return { + effectivePermissions: ["skill.register", "secrets.read"], + pendingNew: [], + contributions: [ + { type: "skills", id: "demo.skill", available: true, missingPermissions: [] }, + { type: "panels", id: "demo.panel", available: true, missingPermissions: [] }, + ], + driftStatus: null, + isStale: false, + ...overrides, + }; +} + +function diag(overrides: Partial): ExtensionDiagnostic { + return { + code: overrides.code ?? "extension.identity.conflict", + severity: overrides.severity ?? "error", + message: overrides.message ?? "Conflict detected", + extensionId: overrides.extensionId, + contributionRef: overrides.contributionRef, + suggestedAction: overrides.suggestedAction, + occurredAt: overrides.occurredAt ?? 0, + }; +} + +const noopHandlers = { + onReload: () => undefined, + onEnable: () => undefined, + onDisable: () => undefined, + onGrant: () => undefined, + onRevoke: () => undefined, +}; + +function renderCard( + extension: DiscoveredExtension, + permissions: CalculatePermissionsResult | null, + inspectionOnly = false, + handlers: Partial = {} +) { + return render( + + + + ); +} + +describe("computeExtensionStatus", () => { + test("conflict outranks every other status", () => { + const ext = makeExtension({ + diagnostics: [diag({ code: "extension.identity.conflict" })], + enabled: false, + }); + expect( + computeExtensionStatus({ + extension: ext, + permissions: makePermissions({ driftStatus: "permissions-changed" }), + inspectionOnly: true, + }) + ).toBe("conflict"); + }); + + test("contribution.identity.conflict also produces conflict status", () => { + const ext = makeExtension({ + diagnostics: [diag({ code: "contribution.identity.conflict", severity: "warn" })], + }); + expect( + computeExtensionStatus({ + extension: ext, + permissions: makePermissions(), + inspectionOnly: false, + }) + ).toBe("conflict"); + }); + + test("permission drift produces pending-reapproval status", () => { + expect( + computeExtensionStatus({ + extension: makeExtension(), + permissions: makePermissions({ driftStatus: "permissions-changed" }), + inspectionOnly: false, + }) + ).toBe("pending-reapproval"); + }); + + test("aligned approval keeps the extension enabled", () => { + expect( + computeExtensionStatus({ + extension: makeExtension(), + permissions: makePermissions({ driftStatus: null }), + inspectionOnly: false, + }) + ).toBe("enabled"); + }); + + test("fresh pending permissions keep the normal enabled status", () => { + expect( + computeExtensionStatus({ + extension: makeExtension(), + permissions: makePermissions({ + driftStatus: "fresh", + effectivePermissions: [], + pendingNew: ["secrets.read"], + }), + inspectionOnly: false, + }) + ).toBe("enabled"); + }); + + test("blocking error without conflict/drift produces blocked status", () => { + const ext = makeExtension({ + diagnostics: [diag({ code: "manifest.invalid", severity: "error" })], + }); + expect( + computeExtensionStatus({ + extension: ext, + permissions: makePermissions(), + inspectionOnly: false, + }) + ).toBe("blocked"); + }); + + test("untrusted root produces inspection-only status when nothing higher applies", () => { + expect( + computeExtensionStatus({ + extension: makeExtension(), + permissions: makePermissions(), + inspectionOnly: true, + }) + ).toBe("inspection-only"); + }); + + test("happy path produces enabled status", () => { + expect( + computeExtensionStatus({ + extension: makeExtension(), + permissions: makePermissions(), + inspectionOnly: false, + }) + ).toBe("enabled"); + }); + + test("disabled extension without other signals produces disabled status", () => { + expect( + computeExtensionStatus({ + extension: makeExtension({ enabled: false }), + permissions: makePermissions(), + inspectionOnly: false, + }) + ).toBe("disabled"); + }); +}); + +describe("ExtensionCard", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + mock.restore(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("collapsed view shows display name, Extension Name, description, status pill, and chevron", () => { + const view = renderCard(makeExtension(), makePermissions()); + expect(view.getByText("Demo Extension")).toBeTruthy(); + expect(view.getByText("vendor.demo")).toBeTruthy(); + expect(view.queryByText(/demo-extension@1.2.3/)).toBeNull(); + expect(view.getByText("A demo extension for testing")).toBeTruthy(); + // Pill renders ENABLED status + expect(view.getByLabelText(/Status: Enabled/)).toBeTruthy(); + }); + + test("renders Conflict pill when identity conflict diagnostic present", () => { + const ext = makeExtension({ + diagnostics: [diag({ code: "extension.identity.conflict" })], + }); + const view = renderCard(ext, makePermissions()); + expect(view.getByLabelText(/Status: Conflict/)).toBeTruthy(); + }); + + test("renders Pending re-approval pill when driftStatus is non-fresh", () => { + const view = renderCard( + makeExtension(), + makePermissions({ driftStatus: "permissions-changed" }) + ); + expect(view.getByLabelText(/Status: Pending re-approval/)).toBeTruthy(); + }); + + test("renders Blocked pill when manifest invalid", () => { + const ext = makeExtension({ + diagnostics: [diag({ code: "manifest.invalid", severity: "error" })], + }); + const view = renderCard(ext, makePermissions()); + expect(view.getByLabelText(/Status: Blocked/)).toBeTruthy(); + }); + + test("renders Inspection only pill when inspectionOnly flag set", () => { + const view = renderCard(makeExtension(), makePermissions(), true); + expect(view.getByLabelText(/Status: Inspection only/)).toBeTruthy(); + }); + + test("expanded view shows identity, capabilities, contributions, diagnostics blocks", () => { + const ext = makeExtension({ + diagnostics: [ + diag({ code: "contribution.invalid", severity: "warn", message: "bad descriptor" }), + ], + }); + const view = renderCard(ext, makePermissions()); + const collapseToggle = view.getByText("Demo Extension").closest("button"); + expect(collapseToggle).toBeTruthy(); + fireEvent.click(collapseToggle!); + + expect(view.getByText("Identity")).toBeTruthy(); + expect(view.getByText("Capabilities")).toBeTruthy(); + expect(view.queryByText("Permissions")).toBeNull(); + expect(view.getByText("Contributions")).toBeTruthy(); + expect(view.getByText("Diagnostics")).toBeTruthy(); + // Identity block shows the manifest id + expect(view.getAllByText("vendor.demo").length).toBeGreaterThan(0); + expect(view.getByText("Module Path")).toBeTruthy(); + // Diagnostic surfaces with code + message + expect(view.getByText("contribution.invalid")).toBeTruthy(); + expect(view.getByText("bad descriptor")).toBeTruthy(); + }); + + test("agents render as inspection-only contributions", () => { + const extension = makeExtension({ + manifest: { + ...makeExtension().manifest, + contributions: [{ type: "agents", id: "demo-agent", index: 0, descriptor: {} }], + }, + }); + const view = renderCard( + extension, + makePermissions({ + contributions: [ + { type: "agents", id: "demo-agent", available: true, missingPermissions: [] }, + ], + }) + ); + fireEvent.click(view.getByText("Demo Extension").closest("button")!); + expect(view.getByText("Inspection only")).toBeTruthy(); + expect(view.queryByText("Available")).toBeNull(); + }); + + test("contributions table flags conflict via contribution.identity.conflict ref", () => { + const ext = makeExtension({ + diagnostics: [ + diag({ + code: "contribution.identity.conflict", + severity: "warn", + contributionRef: { type: "skills", id: "demo.skill" }, + }), + ], + }); + const view = renderCard(ext, makePermissions()); + const collapseToggle = view.getByText("Demo Extension").closest("button"); + fireEvent.click(collapseToggle!); + // The table should render a Conflict cell for this contribution row. + expect(view.getAllByText(/Conflict/i).length).toBeGreaterThanOrEqual(1); + }); + + test("registration capabilities are collapsed by default with explanation link", () => { + const view = renderCard(makeExtension(), makePermissions()); + fireEvent.click(view.getByText("Demo Extension").closest("button")!); + // The button text still appears even when collapsed. + expect(view.getByText(/Registration Capabilities/)).toBeTruthy(); + expect(view.getByText(/Effect Capabilities/)).toBeTruthy(); + // The explanation link is rendered. + expect(view.getByText("Why?")).toBeTruthy(); + }); + + test("Re-approve pending button appears when capability drift is detected", () => { + const view = renderCard( + makeExtension(), + makePermissions({ driftStatus: "permissions-changed" }) + ); + fireEvent.click(view.getByText("Demo Extension").closest("button")!); + expect(view.getByLabelText("Re-approve pending capabilities")).toBeTruthy(); + }); + + test("aligned approval keeps the revoke action", () => { + const view = renderCard(makeExtension(), makePermissions({ driftStatus: null })); + fireEvent.click(view.getByText("Demo Extension").closest("button")!); + expect(view.queryByLabelText("Re-approve pending capabilities")).toBeNull(); + expect(view.getByLabelText("Revoke approval")).toBeTruthy(); + }); + + test("Approve button appears when no approval record exists yet", () => { + const view = renderCard( + makeExtension(), + makePermissions({ driftStatus: "fresh", effectivePermissions: [], pendingNew: [] }) + ); + fireEvent.click(view.getByText("Demo Extension").closest("button")!); + expect(view.getByLabelText("Approve capabilities")).toBeTruthy(); + }); + + test("Revoke button appears when approval record fully aligned", () => { + const view = renderCard(makeExtension(), makePermissions({ driftStatus: null })); + fireEvent.click(view.getByText("Demo Extension").closest("button")!); + expect(view.getByLabelText("Revoke approval")).toBeTruthy(); + }); + + test("bundled extensions show policy-approved state instead of revoke", () => { + const view = renderCard( + makeExtension({ rootKind: "bundled" }), + makePermissions({ driftStatus: null }) + ); + fireEvent.click(view.getByText("Demo Extension").closest("button")!); + expect(view.getByText("Policy-enabled")).toBeTruthy(); + expect(view.queryByLabelText("Disable extension")).toBeNull(); + expect(view.getByText("Policy-approved")).toBeTruthy(); + expect(view.queryByLabelText("Revoke approval")).toBeNull(); + }); + + test("inspectionOnly disables every action button", () => { + const view = renderCard(makeExtension(), makePermissions(), true); + fireEvent.click(view.getByText("Demo Extension").closest("button")!); + expect((view.getByLabelText("Reload extension") as HTMLButtonElement).disabled).toBe(true); + // Disable button rendered because extension.enabled === true. + expect((view.getByLabelText("Disable extension") as HTMLButtonElement).disabled).toBe(true); + expect((view.getByLabelText("Revoke approval") as HTMLButtonElement).disabled).toBe(true); + }); + + test("clicking Disable invokes onDisable with rootId/extensionId", () => { + const onDisable = mock(() => undefined); + const view = renderCard(makeExtension(), makePermissions(), false, { onDisable }); + fireEvent.click(view.getByText("Demo Extension").closest("button")!); + fireEvent.click(view.getByLabelText("Disable extension")); + expect(onDisable).toHaveBeenCalledWith("root-1", "vendor.demo"); + }); +}); + +describe("StaleRecordCard", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + mock.restore(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("renders Stale Approval Record label and Forget/Keep actions", () => { + const record: StaleRecord = { + scope: "global", + projectPath: undefined, + extensionId: "vendor.gone", + approval: { + grantedPermissions: ["secrets.read"], + requestedPermissionsHash: "deadbeef", + }, + rootId: "stale-root", + }; + const onForget = mock(() => undefined); + const onKeep = mock(() => undefined); + const view = render( + + + + ); + expect(view.getByText("vendor.gone")).toBeTruthy(); + expect(view.getByText(/Stale Approval Record/)).toBeTruthy(); + + fireEvent.click(view.getByLabelText("Forget stale record")); + expect(onForget).toHaveBeenCalledWith("stale-root", "vendor.gone"); + + fireEvent.click(view.getByLabelText("Keep stale record")); + expect(onKeep).toHaveBeenCalled(); + }); +}); diff --git a/src/browser/features/Settings/Sections/ExtensionCard.tsx b/src/browser/features/Settings/Sections/ExtensionCard.tsx new file mode 100644 index 0000000000..c43e8ba3cf --- /dev/null +++ b/src/browser/features/Settings/Sections/ExtensionCard.tsx @@ -0,0 +1,765 @@ +import { useCallback, useMemo, useState } from "react"; +import { + AlertTriangle, + ArrowDownCircle, + Ban, + CheckCircle2, + ChevronDown, + ChevronRight, + Copy, + Eye, + Info, + Loader2, + Lock, + RefreshCw, + ShieldAlert, + ShieldCheck, + XCircle, +} from "lucide-react"; +import type { z } from "zod"; + +import { Button } from "@/browser/components/Button/Button"; +import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; +import { requiresReapproval } from "@/common/extensions/approvalDrift"; +import type * as extensionRegistrySchemas from "@/common/orpc/schemas/extensionRegistry"; + +type DiscoveredExtension = z.infer; +type CalculatePermissionsResult = z.infer< + typeof extensionRegistrySchemas.CalculatePermissionsResultSchema +>; +type StaleRecord = z.infer; +type ExtensionDiagnostic = z.infer; + +// v1 contribution support level by type. Skills are capability-consumed; +// the rest stay inspection-only until a consumer is wired. +const AVAILABLE_TYPES = new Set(["skills"]); + +export type ExtensionStatus = + | "conflict" + | "pending-reapproval" + | "blocked" + | "inspection-only" + | "enabled" + | "disabled"; + +const STATUS_LABEL: Record = { + conflict: "Conflict", + "pending-reapproval": "Pending re-approval", + blocked: "Blocked", + "inspection-only": "Inspection only", + enabled: "Enabled", + disabled: "Disabled", +}; + +const CONFLICT_CODES = new Set(["extension.identity.conflict", "contribution.identity.conflict"]); + +const BLOCKING_CODES = new Set([ + "manifest.invalid", + "manifest.version.unsupported", + "manifest.contributes.unknown_key", + "extension.identity.invalid", + "extension.identity.reserved", +]); + +export function getExtensionCardTestId( + extension: Pick +): string { + return `extension-card-${encodeURIComponent(extension.rootId)}-${encodeURIComponent(extension.extensionId)}`; +} + +interface ComputeStatusInput { + extension: DiscoveredExtension; + permissions: CalculatePermissionsResult | null; + inspectionOnly: boolean; +} + +function hasConflict(extension: DiscoveredExtension): boolean { + return extension.diagnostics.some((d) => CONFLICT_CODES.has(d.code)); +} + +function hasBlockingError(extension: DiscoveredExtension): boolean { + return extension.diagnostics.some((d) => d.severity === "error" && BLOCKING_CODES.has(d.code)); +} + +export function computeExtensionStatus({ + extension, + permissions, + inspectionOnly, +}: ComputeStatusInput): ExtensionStatus { + // Priority order: Conflict > Pending re-approval > Blocked > Inspection only > Enabled. + if (hasConflict(extension)) return "conflict"; + if (requiresReapproval(permissions)) return "pending-reapproval"; + if (hasBlockingError(extension)) return "blocked"; + if (inspectionOnly) return "inspection-only"; + if (!extension.enabled) return "disabled"; + return "enabled"; +} + +interface StatusPillProps { + status: ExtensionStatus; +} + +const STATUS_PILL_CLASS: Record = { + conflict: "bg-error/15 text-error border-error/40", + "pending-reapproval": "bg-warning/15 text-warning border-warning/40", + blocked: "bg-error/15 text-error border-error/40", + "inspection-only": "bg-background-tertiary text-muted border-border-medium", + enabled: "bg-accent/15 text-accent border-accent/40", + disabled: "bg-background-tertiary text-muted border-border-medium", +}; + +const STATUS_ICON: Record> = { + conflict: ShieldAlert, + "pending-reapproval": ShieldAlert, + blocked: Ban, + "inspection-only": Eye, + enabled: CheckCircle2, + disabled: XCircle, +}; + +const StatusPill: React.FC = ({ status }) => { + const Icon = STATUS_ICON[status]; + return ( + + + {STATUS_LABEL[status]} + + ); +}; + +interface DiagnosticListProps { + diagnostics: readonly ExtensionDiagnostic[]; +} + +const SEVERITY_ICON: Record< + ExtensionDiagnostic["severity"], + React.ComponentType<{ className?: string }> +> = { + error: XCircle, + warn: AlertTriangle, + info: Info, +}; + +const SEVERITY_COLOR: Record = { + error: "text-error", + warn: "text-warning", + info: "text-muted", +}; + +const DiagnosticList: React.FC = ({ diagnostics }) => { + if (diagnostics.length === 0) { + return

No diagnostics.

; + } + return ( +
    + {diagnostics.map((d, idx) => { + const Icon = SEVERITY_ICON[d.severity]; + return ( +
  • + +
    +
    + {d.code} + {d.contributionRef && ( + + {d.contributionRef.type} + {d.contributionRef.id ? `/${d.contributionRef.id}` : ""} + + )} +
    +
    {d.message}
    + {d.suggestedAction && ( +
    {d.suggestedAction}
    + )} +
    +
  • + ); + })} +
+ ); +}; + +interface ContributionsTableProps { + extension: DiscoveredExtension; + availabilityById: ReadonlyMap; +} + +const ContributionsTable: React.FC = ({ extension, availabilityById }) => { + if (extension.manifest.contributions.length === 0) { + return

No contributions declared.

; + } + const conflictRefs = new Set( + extension.diagnostics + .filter((d) => d.code === "contribution.identity.conflict" && d.contributionRef) + .map((d) => `${d.contributionRef!.type}/${d.contributionRef!.id ?? ""}`) + ); + return ( +
+ + + + + + + + + + + + {extension.manifest.contributions.map((c) => { + const availability = availabilityById.get(`${c.type}/${c.id}`); + const isAvailableType = AVAILABLE_TYPES.has(c.type); + const supportLevel = isAvailableType ? "Available" : "Inspection only"; + const conflictKey = `${c.type}/${c.id}`; + const conflicted = conflictRefs.has(conflictKey); + return ( + + + + + + + + ); + })} + +
TypeIDSupportAvailabilityNotes
{c.type}{c.id} + + {supportLevel} + + + {!isAvailableType || availability == null ? ( + + ) : availability.available ? ( + + + Available + + ) : ( + + + Missing + {availability.missingPermissions.length > 0 && ( + + ({availability.missingPermissions.join(", ")}) + + )} + + )} + + {conflicted ? ( + + + Conflict + + ) : ( + + )} +
+
+ ); +}; + +interface CapabilitiesBlockProps { + extension: DiscoveredExtension; + permissions: CalculatePermissionsResult | null; +} + +// Registration Capabilities are the `.register` entries generated from +// discovered contributions; everything else in `requestedPermissions` is an +// Effect Capability. We separate them so users can collapse the mechanical +// registration block and review effect capabilities in detail. +function partitionCapabilities(extension: DiscoveredExtension): { + registration: string[]; + effect: string[]; +} { + const registration: string[] = []; + const effect: string[] = []; + for (const capability of extension.manifest.requestedPermissions) { + if (capability.endsWith(".register")) registration.push(capability); + else effect.push(capability); + } + return { registration, effect }; +} + +type EffectCapabilityState = "approved" | "pending-new" | "revoked"; + +function effectCapabilityStateFor( + capability: string, + permissions: CalculatePermissionsResult | null +): EffectCapabilityState { + if (!permissions) return "pending-new"; + if (permissions.effectivePermissions.includes(capability)) return "approved"; + if (permissions.pendingNew.includes(capability)) return "pending-new"; + return "revoked"; +} + +const CAPABILITY_STATE_LABEL: Record = { + approved: "Approved", + "pending-new": "Pending", + revoked: "Revoked", +}; + +const CAPABILITY_STATE_CLASS: Record = { + approved: "text-accent", + "pending-new": "text-warning", + revoked: "text-muted line-through", +}; + +const CapabilitiesBlock: React.FC = ({ extension, permissions }) => { + const [registrationOpen, setRegistrationOpen] = useState(false); + const { registration, effect } = useMemo(() => partitionCapabilities(extension), [extension]); + + return ( +
+
+ + {registrationOpen && ( +
+ {registration.length === 0 ? ( +

No registration capabilities.

+ ) : ( +
    + {registration.map((capability) => ( +
  • + {capability} +
  • + ))} +
+ )} +
+ )} +
+ +
+

+ Effect Capabilities ({effect.length}) +

+ {effect.length === 0 ? ( +

No effect capabilities requested.

+ ) : ( +
    + {effect.map((capability) => { + const state = effectCapabilityStateFor(capability, permissions); + return ( +
  • + {capability} + + {CAPABILITY_STATE_LABEL[state]} + +
  • + ); + })} +
+ )} +
+
+ ); +}; + +interface IdentityBlockProps { + extension: DiscoveredExtension; +} + +const IdentityBlock: React.FC = ({ extension }) => { + const { copied, copyToClipboard } = useCopyToClipboard(); + return ( +
+
+
Extension Name
+
{extension.manifest.id}
+
+
+
Module Path
+
+ {extension.modulePath} + + {copied && copied} +
+
+ {extension.manifest.publisher && ( +
+
Publisher
+
{extension.manifest.publisher}
+
+ )} + {extension.manifest.homepage && ( + + )} +
+ ); +}; + +export interface ExtensionCardProps { + extension: DiscoveredExtension; + permissions: CalculatePermissionsResult | null; + inspectionOnly: boolean; + /** + * When true, the card is force-expanded regardless of the user's local + * toggle. The section uses this to auto-open a card after the user clicks + * "Review individually" inside the Consent Shortcut Modal. + */ + forceExpanded?: boolean; + /** + * When true, the card carries a visible focus ring used by section-local + * J/K navigation. The section is the source of truth for which card is + * focused; the card itself only renders the indicator. + */ + focused?: boolean; + onReload: (rootId: string, extensionId: string) => void | Promise; + onEnable: (rootId: string, extensionId: string) => void | Promise; + onDisable: (rootId: string, extensionId: string) => void | Promise; + onGrant: (rootId: string, extensionId: string) => void | Promise; + onRevoke: (rootId: string, extensionId: string) => void | Promise; + /** + * Opens the Consent Shortcut flow for this extension. Provided by the + * section so trust, enablement, and approval can run as one transaction. Optional so + * existing callers (and tests) can omit it. + */ + onQuickSetup?: (rootId: string, extensionId: string) => void; +} + +export const ExtensionCard: React.FC = ({ + extension, + permissions, + inspectionOnly, + forceExpanded, + focused, + onReload, + onEnable, + onDisable, + onGrant, + onRevoke, + onQuickSetup, +}) => { + const [localExpanded, setLocalExpanded] = useState(false); + const expanded = localExpanded || forceExpanded === true; + const [busy, setBusy] = useState(false); + const { copied: pathCopied, copyToClipboard } = useCopyToClipboard(); + + const status = computeExtensionStatus({ extension, permissions, inspectionOnly }); + const displayName = extension.manifest.displayName ?? extension.manifest.id; + + const availabilityById = useMemo(() => { + const map = new Map(); + if (!permissions) return map; + for (const c of permissions.contributions) { + map.set(`${c.type}/${c.id}`, { + available: c.available, + missingPermissions: c.missingPermissions, + }); + } + return map; + }, [permissions]); + + const wrapBusy = useCallback((fn: () => T | Promise) => { + void (async () => { + setBusy(true); + try { + await fn(); + } finally { + setBusy(false); + } + })(); + }, []); + + // Three-state approval button per spec: Approve / Re-approve pending / Revoke. + // - "fresh" means no approval record exists yet → "Approve". + // - capability or source-name drift requires a renewed approval. + // - aligned records and source-only updates keep the current approval revocable. + const policyGranted = extension.rootKind === "bundled"; + const grantButton: "grant" | "reapproval" | "revoke" = (() => { + if (!permissions || permissions.driftStatus === "fresh") return "grant"; + if (requiresReapproval(permissions)) return "reapproval"; + return "revoke"; + })(); + + return ( +
+ + + {expanded && ( +
+
+

+ Identity +

+ +
+ +
+

+ Capabilities +

+ +
+ +
+

+ Contributions +

+ +
+ +
+

+ Diagnostics +

+ + {inspectionOnly && ( +

+ Root is untrusted; this Extension is shown in inspection-only mode and cannot be + activated. +

+ )} +
+ +
+ + + + + {policyGranted ? ( + Policy-enabled + ) : extension.enabled ? ( + + ) : ( + + )} + + {policyGranted ? ( + Policy-approved + ) : grantButton === "reapproval" ? ( + + ) : grantButton === "revoke" ? ( + + ) : ( + <> + {onQuickSetup && ( + + )} + + + )} +
+
+ )} +
+ ); +}; + +export interface StaleRecordCardProps { + record: StaleRecord; + onForget: (rootId: string, extensionId: string) => void | Promise; + onKeep?: () => void; +} + +export const StaleRecordCard: React.FC = ({ record, onForget, onKeep }) => { + const [busy, setBusy] = useState(false); + return ( +
+
+
+ {record.extensionId} + + + Stale Approval Record + +
+

+ An approval record exists but the Extension is no longer present. Forget the record to + clear its capability approvals, or keep it in case the Extension reappears. +

+
+
+ + {onKeep && ( + + )} +
+
+ ); +}; diff --git a/src/browser/features/Settings/Sections/ExtensionsCheatSheetModal.test.tsx b/src/browser/features/Settings/Sections/ExtensionsCheatSheetModal.test.tsx new file mode 100644 index 0000000000..a9296014bc --- /dev/null +++ b/src/browser/features/Settings/Sections/ExtensionsCheatSheetModal.test.tsx @@ -0,0 +1,30 @@ +import "../../../../../tests/ui/dom"; + +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "bun:test"; + +import { ThemeProvider } from "@/browser/contexts/ThemeContext"; +import { installDom } from "../../../../../tests/ui/dom"; +import { ExtensionsCheatSheetModal } from "./ExtensionsCheatSheetModal"; + +describe("ExtensionsCheatSheetModal", () => { + afterEach(() => { + cleanup(); + }); + + test("uses approval wording for the focused extension capability shortcut", () => { + const cleanupDom = installDom(); + try { + const view = render( + + undefined} /> + + ); + + expect(view.getByText("Approve focused extension capabilities")).toBeTruthy(); + expect(view.queryByText("Grant focused extension")).toBeNull(); + } finally { + cleanupDom(); + } + }); +}); diff --git a/src/browser/features/Settings/Sections/ExtensionsCheatSheetModal.tsx b/src/browser/features/Settings/Sections/ExtensionsCheatSheetModal.tsx new file mode 100644 index 0000000000..a981915757 --- /dev/null +++ b/src/browser/features/Settings/Sections/ExtensionsCheatSheetModal.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef } from "react"; +import { X } from "lucide-react"; + +import { trapTabKey } from "./dialogFocus"; +import { Button } from "@/browser/components/Button/Button"; +import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; + +interface CheatSheetEntry { + label: string; + keybind: keyof typeof KEYBINDS; + alt?: keyof typeof KEYBINDS; +} + +const ENTRIES: readonly CheatSheetEntry[] = [ + { label: "Reload extensions", keybind: "EXTENSIONS_RELOAD" }, + { label: "Focus next / previous extension", keybind: "EXTENSIONS_NAVIGATE_NEXT" }, + { + label: "Expand the focused extension", + keybind: "EXTENSIONS_EXPAND_ENTER", + alt: "EXTENSIONS_EXPAND_SPACE", + }, + { label: "Enable / disable focused extension", keybind: "EXTENSIONS_TOGGLE_ENABLE" }, + { label: "Approve focused extension capabilities", keybind: "EXTENSIONS_GRANT" }, + { label: "Trust project-local Extensions root", keybind: "EXTENSIONS_TRUST_ROOT" }, + { label: "Show focused extension diagnostics", keybind: "EXTENSIONS_DIAGNOSTICS" }, + { label: "Toggle this cheat sheet", keybind: "EXTENSIONS_CHEATSHEET" }, +]; + +export interface ExtensionsCheatSheetModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const ExtensionsCheatSheetModal: React.FC = ({ + isOpen, + onClose, +}) => { + const closeButtonRef = useRef(null); + const panelRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + closeButtonRef.current?.focus(); + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen, onClose]); + + if (!isOpen) return null; + return ( +
+ +
+

+ Shortcuts only fire while the Extensions settings section is open and your focus is not + inside an editable element. +

+
    + {ENTRIES.map((entry) => { + const primary = formatKeybind(KEYBINDS[entry.keybind]); + const alt = entry.alt ? formatKeybind(KEYBINDS[entry.alt]) : null; + return ( +
  • + {entry.label} + + {alt ? `${primary} / ${alt}` : primary} + +
  • + ); + })} +
+ + + ); +}; diff --git a/src/browser/features/Settings/Sections/ExtensionsSection.test.tsx b/src/browser/features/Settings/Sections/ExtensionsSection.test.tsx new file mode 100644 index 0000000000..02f102a8ad --- /dev/null +++ b/src/browser/features/Settings/Sections/ExtensionsSection.test.tsx @@ -0,0 +1,1106 @@ +import "../../../../../tests/ui/dom"; + +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +import { ThemeProvider } from "@/browser/contexts/ThemeContext"; +import type { z } from "zod"; +import { installDom } from "../../../../../tests/ui/dom"; +import type * as extensionRegistrySchemas from "@/common/orpc/schemas/extensionRegistry"; + +type RegistrySnapshot = z.infer; +type RootDiscoveryResult = z.infer; +type DiscoveredExtension = z.infer; +type CalculatePermissionsResult = z.infer< + typeof extensionRegistrySchemas.CalculatePermissionsResultSchema +>; + +interface MockAPIClient { + extensions: { + list: () => Promise; + onChanged: (input: undefined, opts: { signal: AbortSignal }) => AsyncIterable; + initializeUserRoot: () => Promise; + reload: (input: { rootId?: string }) => Promise; + trustRoot: (input: { rootId: string }) => Promise; + untrustRoot: (input: { rootId: string }) => Promise; + enable: (input: { rootId: string; extensionId: string }) => Promise; + disable: (input: { rootId: string; extensionId: string }) => Promise; + approve: (input: { rootId: string; extensionId: string }) => Promise; + revokeApproval: (input: { rootId: string; extensionId: string }) => Promise; + forgetStale: (input: { rootId: string; extensionId: string }) => Promise; + }; +} + +let mockApi: MockAPIClient; + +void mock.module("@/browser/contexts/API", () => ({ + useAPI: () => ({ + api: mockApi, + status: "connected" as const, + error: null, + authenticate: () => undefined, + retry: () => undefined, + }), +})); + +import { ExtensionsSection } from "./ExtensionsSection"; +import { getExtensionCardTestId } from "./ExtensionCard"; +import { __setExtensionDiagnosticsLogSink } from "./extensionDiagnostics"; + +function neverIterator(): AsyncIterable { + return { + [Symbol.asyncIterator]: () => ({ + next: () => + new Promise>(() => { + // never resolves + }), + }), + }; +} + +function makeRoot(overrides: Partial): RootDiscoveryResult { + return { + rootId: overrides.rootId ?? "root-id", + kind: overrides.kind ?? "bundled", + path: overrides.path ?? "/path/to/root", + trusted: overrides.trusted ?? true, + rootExists: overrides.rootExists ?? true, + state: overrides.state ?? "ready", + extensions: overrides.extensions ?? [], + diagnostics: overrides.diagnostics ?? [], + }; +} + +function makeSnapshot(roots: RootDiscoveryResult[]): RegistrySnapshot { + return { + generatedAt: 0, + roots, + availableContributions: [], + resolverDiagnostics: [], + descriptors: [], + permissions: {}, + staleRecords: [], + }; +} + +function makeExtension(overrides: Partial = {}): DiscoveredExtension { + return { + extensionId: "vendor.demo", + rootId: "user-root", + rootKind: "user-global", + isCore: false, + modulePath: "/p", + manifest: { + manifestVersion: 1, + id: "vendor.demo", + displayName: "Demo Extension", + description: undefined, + publisher: undefined, + homepage: undefined, + requestedPermissions: ["secrets.read"], + contributions: [{ type: "skills", id: "demo.skill", index: 0, descriptor: {} }], + }, + contributions: [], + diagnostics: [], + enabled: false, + granted: false, + activated: false, + ...overrides, + }; +} + +function makePermissions( + overrides: Partial = {} +): CalculatePermissionsResult { + return { + effectivePermissions: [], + pendingNew: [], + contributions: [], + driftStatus: "fresh", + isStale: false, + ...overrides, + }; +} + +function setApi(snapshot: RegistrySnapshot | null) { + mockApi = { + extensions: { + list: mock(() => Promise.resolve(snapshot)), + onChanged: mock((_input, _opts) => neverIterator()), + initializeUserRoot: mock(() => Promise.resolve()), + reload: mock(() => Promise.resolve()), + trustRoot: mock(() => Promise.resolve()), + untrustRoot: mock(() => Promise.resolve()), + enable: mock(() => Promise.resolve()), + disable: mock(() => Promise.resolve()), + approve: mock(() => Promise.resolve()), + revokeApproval: mock(() => Promise.resolve()), + forgetStale: mock(() => Promise.resolve()), + }, + }; +} + +function renderSection() { + return render( + + + + ); +} + +describe("ExtensionsSection", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + __setExtensionDiagnosticsLogSink({ + error: () => undefined, + warn: () => undefined, + info: () => undefined, + }); + }); + + afterEach(() => { + cleanup(); + mock.restore(); + cleanupDom?.(); + cleanupDom = null; + __setExtensionDiagnosticsLogSink(null); + }); + + test("renders header with platform-state line and aggregate counts", async () => { + setApi( + makeSnapshot([ + makeRoot({ rootId: "bundled-root", kind: "bundled", path: "/bundled" }), + makeRoot({ + rootId: "user-root", + kind: "user-global", + path: "/user", + rootExists: true, + }), + ]) + ); + + const view = renderSection(); + + await waitFor(() => { + expect(view.getByText(/Platform state:/i)).toBeTruthy(); + }); + expect(view.getByText(/0 errors/i)).toBeTruthy(); + expect(view.getByText(/0 warnings/i)).toBeTruthy(); + expect(view.getByLabelText("Reload Extensions")).toBeTruthy(); + }); + + test("user-global root not initialized: surfaces Initialize affordance", async () => { + setApi( + makeSnapshot([ + makeRoot({ rootId: "bundled-root", kind: "bundled" }), + makeRoot({ + rootId: "user-root", + kind: "user-global", + path: "/user", + rootExists: false, + }), + ]) + ); + + const view = renderSection(); + + await waitFor(() => { + const buttons = view.getAllByLabelText("Initialize User Extensions Root"); + // Both action-row button and inline empty-state button are rendered. + expect(buttons.length).toBeGreaterThanOrEqual(1); + }); + }); + + test("user-global root initialized but empty: shows module authoring hint and Reload affordance", async () => { + setApi( + makeSnapshot([ + makeRoot({ rootId: "bundled-root", kind: "bundled" }), + makeRoot({ + rootId: "user-root", + kind: "user-global", + path: "/user/extensions/local", + rootExists: true, + }), + ]) + ); + + const view = renderSection(); + + await waitFor(() => { + expect(view.getByText(/mkdir -p \/user\/extensions\/local\/acme-review/)).toBeTruthy(); + }); + expect(view.queryByText(/bun add /)).toBeNull(); + // Action-row "Reload Extensions" + empty-state "Reload" are both present. + expect(view.getAllByLabelText(/Reload/i).length).toBeGreaterThanOrEqual(1); + }); + + test("project-local missing dir: subsection hidden entirely", async () => { + setApi( + makeSnapshot([ + makeRoot({ rootId: "bundled-root", kind: "bundled" }), + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + }), + makeRoot({ + rootId: "project-root", + kind: "project-local", + path: "/proj/.mux/extensions", + rootExists: false, + trusted: true, + }), + ]) + ); + + const view = renderSection(); + + await waitFor(() => { + expect(view.getByTestId("root-subsection-bundled-root")).toBeTruthy(); + }); + expect(view.queryByTestId("root-subsection-project-root")).toBeNull(); + }); + + test("lock-only project root states that extensions are declared but not discovered before trust", async () => { + setApi( + makeSnapshot([ + makeRoot({ rootId: "bundled-root", kind: "bundled" }), + makeRoot({ + rootId: "project-root", + kind: "project-local", + path: "/repo/.mux", + rootExists: true, + trusted: false, + }), + ]) + ); + + const view = renderSection(); + + await waitFor(() => { + expect(view.getByText(/declares extension sources/i)).toBeTruthy(); + }); + expect(view.getByText(/not fetched, parsed, or executed/i)).toBeTruthy(); + }); + + test("project-local present + root untrusted: shows Trust this root header action", async () => { + setApi( + makeSnapshot([ + makeRoot({ rootId: "bundled-root", kind: "bundled" }), + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + }), + makeRoot({ + rootId: "project-root", + kind: "project-local", + path: "/proj/.mux/extensions", + rootExists: true, + trusted: false, + }), + ]) + ); + + const view = renderSection(); + + await waitFor(() => { + expect(view.getByLabelText("Trust this root")).toBeTruthy(); + }); + }); + + test("renders three roots in fixed order: Bundled → User-global → Project-local", async () => { + setApi( + makeSnapshot([ + makeRoot({ + rootId: "project-root", + kind: "project-local", + rootExists: true, + trusted: true, + }), + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + }), + makeRoot({ rootId: "bundled-root", kind: "bundled" }), + ]) + ); + + const view = renderSection(); + + const bundledHeader = await waitFor(() => view.getByText("Bundled")); + const userHeader = view.getByText("User-global"); + const projectHeader = view.getByText("Project-local"); + + const headers = [bundledHeader, userHeader, projectHeader]; + for (let i = 1; i < headers.length; i++) { + const prev = headers[i - 1]; + const curr = headers[i]; + // DOCUMENT_POSITION_FOLLOWING (4) means `prev` precedes `curr` in document order. + const position = prev.compareDocumentPosition(curr); + expect(position & 4).toBe(4); + } + }); + + test("renders every project-local root returned by the snapshot", async () => { + setApi( + makeSnapshot([ + makeRoot({ kind: "bundled", rootId: "bundled", extensions: [] }), + makeRoot({ kind: "user-global", rootId: "user-global", extensions: [] }), + makeRoot({ + kind: "project-local", + rootId: "project-local:/repo-a", + path: "/repo-a/.mux/extensions", + extensions: [], + }), + makeRoot({ + kind: "project-local", + rootId: "project-local:/repo-b", + path: "/repo-b/.mux/extensions", + extensions: [], + }), + ]) + ); + + const view = renderSection(); + await waitFor(() => + expect(view.getByTestId("root-subsection-project-local:/repo-a")).toBeTruthy() + ); + expect(view.getByTestId("root-subsection-project-local:/repo-b")).toBeTruthy(); + }); + + test("renders every user-global root returned by the snapshot", async () => { + const localExtension = makeExtension({ + rootId: "user-global", + extensionId: "local-review", + manifest: { + ...makeExtension().manifest, + id: "local-review", + displayName: "Local Review", + }, + }); + const fetchedExtension = makeExtension({ + rootId: "user-global-fetched", + extensionId: "fetched-review", + manifest: { + ...makeExtension().manifest, + id: "fetched-review", + displayName: "Fetched Review", + }, + }); + setApi( + makeSnapshot([ + makeRoot({ kind: "bundled", rootId: "bundled", extensions: [] }), + makeRoot({ kind: "user-global", rootId: "user-global", extensions: [localExtension] }), + makeRoot({ + kind: "user-global", + rootId: "user-global-fetched", + extensions: [fetchedExtension], + }), + ]) + ); + + const view = renderSection(); + await waitFor(() => expect(view.getByText("Local Review")).toBeTruthy()); + + expect(view.getByTestId("root-subsection-user-global")).toBeTruthy(); + expect(view.getByTestId("root-subsection-user-global-fetched")).toBeTruthy(); + expect(view.getByText("Fetched Review")).toBeTruthy(); + }); + + test("Quick Setup opens Consent Shortcut Modal with extension summary", async () => { + const ext = makeExtension({ enabled: false }); + const snapshot = makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]); + snapshot.permissions = { [ext.extensionId]: makePermissions({ driftStatus: "fresh" }) }; + setApi(snapshot); + + const view = renderSection(); + + const collapseToggle = await waitFor(() => view.getByText("Demo Extension").closest("button")); + fireEvent.click(collapseToggle!); + + fireEvent.click(view.getByLabelText("Quick setup with consent shortcut")); + + expect(view.getByTestId("consent-shortcut-modal")).toBeTruthy(); + expect(view.getByText(/Set up Demo Extension/)).toBeTruthy(); + // The card uses a user-global root → the modal must NOT mention trust root. + expect(view.queryByText(/Trust the project-local/i)).toBeNull(); + }); + + test("Quick Setup clears stale errors on retry", async () => { + const ext = makeExtension({ enabled: false }); + const snapshot = makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]); + snapshot.permissions = { [ext.extensionId]: makePermissions({ driftStatus: "fresh" }) }; + setApi(snapshot); + let attempts = 0; + mockApi.extensions.approve = mock(() => { + attempts += 1; + return attempts === 1 ? Promise.reject(new Error("approval failed")) : Promise.resolve(); + }); + + const view = renderSection(); + + const collapseToggle = await waitFor(() => view.getByText("Demo Extension").closest("button")); + fireEvent.click(collapseToggle!); + fireEvent.click(view.getByLabelText("Quick setup with consent shortcut")); + fireEvent.click(view.getByLabelText("Confirm consent shortcut")); + await waitFor(() => expect(view.getByText("approval failed")).toBeTruthy()); + + fireEvent.click(view.getByLabelText("Quick setup with consent shortcut")); + fireEvent.click(view.getByLabelText("Confirm consent shortcut")); + await waitFor(() => expect(mockApi.extensions.approve).toHaveBeenCalledTimes(2)); + expect(view.queryByText("approval failed")).toBeNull(); + }); + + test("Quick Setup rolls back enable when approval fails", async () => { + const ext = makeExtension({ enabled: false }); + const snapshot = makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]); + snapshot.permissions = { [ext.extensionId]: makePermissions({ driftStatus: "fresh" }) }; + setApi(snapshot); + mockApi.extensions.approve = mock(() => Promise.reject(new Error("approval failed"))); + + const view = renderSection(); + + const collapseToggle = await waitFor(() => view.getByText("Demo Extension").closest("button")); + fireEvent.click(collapseToggle!); + fireEvent.click(view.getByLabelText("Quick setup with consent shortcut")); + fireEvent.click(view.getByLabelText("Confirm consent shortcut")); + + await waitFor(() => { + expect(mockApi.extensions.disable).toHaveBeenCalledWith({ + rootId: ext.rootId, + extensionId: ext.extensionId, + }); + }); + }); + + test("Review individually closes modal and forces card expansion", async () => { + const ext = makeExtension(); + const snapshot = makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]); + snapshot.permissions = { [ext.extensionId]: makePermissions({ driftStatus: "fresh" }) }; + setApi(snapshot); + + const view = renderSection(); + + const collapseToggle = await waitFor(() => view.getByText("Demo Extension").closest("button")); + fireEvent.click(collapseToggle!); + fireEvent.click(view.getByLabelText("Quick setup with consent shortcut")); + expect(view.getByTestId("consent-shortcut-modal")).toBeTruthy(); + + fireEvent.click(view.getByTestId("consent-shortcut-review-individually")); + expect(view.queryByTestId("consent-shortcut-modal")).toBeNull(); + // Granular flow: per-card approval button is now visible (card stays expanded). + expect(view.getByLabelText("Approve capabilities")).toBeTruthy(); + }); + + test("drift surfaces as Pending re-approval pill without opening any modal", async () => { + const ext = makeExtension({ enabled: true }); + const snapshot = makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]); + snapshot.permissions = { + [ext.extensionId]: makePermissions({ + driftStatus: "permissions-changed", + effectivePermissions: ["secrets.read"], + }), + }; + setApi(snapshot); + + const view = renderSection(); + + await waitFor(() => view.getByText("Demo Extension")); + expect(view.getByLabelText(/Status: Pending re-approval/)).toBeTruthy(); + // No Consent Shortcut Modal appears just because of drift. + expect(view.queryByTestId("consent-shortcut-modal")).toBeNull(); + }); + + test("Disable button opens destructive confirm dialog (does not directly disable)", async () => { + const ext = makeExtension({ enabled: true }); + const snapshot = makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]); + snapshot.permissions = { [ext.extensionId]: makePermissions({ driftStatus: null }) }; + setApi(snapshot); + + const view = renderSection(); + + const collapseToggle = await waitFor(() => view.getByText("Demo Extension").closest("button")); + fireEvent.click(collapseToggle!); + fireEvent.click(view.getByLabelText("Disable extension")); + expect(view.getByTestId("destructive-confirm-dialog")).toBeTruthy(); + expect(view.getByText(/Disable Demo Extension/i)).toBeTruthy(); + // Disable IPC must NOT have been called yet — confirmation gates it. + expect(mockApi.extensions.disable).not.toHaveBeenCalled(); + }); + + test("project-local trusted: shows Untrust this root header button and confirms before untrusting", async () => { + setApi( + makeSnapshot([ + makeRoot({ rootId: "bundled-root", kind: "bundled" }), + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + }), + makeRoot({ + rootId: "project-root", + kind: "project-local", + path: "/proj/.mux/extensions", + rootExists: true, + trusted: true, + }), + ]) + ); + + const view = renderSection(); + await waitFor(() => view.getByLabelText("Untrust this root")); + fireEvent.click(view.getByLabelText("Untrust this root")); + expect(view.getByTestId("destructive-confirm-dialog")).toBeTruthy(); + expect(view.getByText(/Untrust this Extensions root/)).toBeTruthy(); + expect(mockApi.extensions.untrustRoot).not.toHaveBeenCalled(); + }); + + test("Shift+? opens the keyboard cheat sheet", async () => { + setApi( + makeSnapshot([ + makeRoot({ rootId: "bundled-root", kind: "bundled" }), + makeRoot({ rootId: "user-root", kind: "user-global", rootExists: true }), + ]) + ); + const view = renderSection(); + + await waitFor(() => view.getByText(/Platform state:/i)); + expect(view.queryByTestId("extensions-cheatsheet-modal")).toBeNull(); + fireEvent.keyDown(window, { key: "?", shiftKey: true }); + expect(view.getByTestId("extensions-cheatsheet-modal")).toBeTruthy(); + }); + + test("R triggers a global reload (no rootId)", async () => { + setApi( + makeSnapshot([makeRoot({ rootId: "user-root", kind: "user-global", rootExists: true })]) + ); + const view = renderSection(); + await waitFor(() => view.getByText(/Platform state:/i)); + + fireEvent.keyDown(window, { key: "r" }); + await waitFor(() => { + // initial mount calls list(); the section's reload triggers extensions.reload({}). + expect(mockApi.extensions.reload).toHaveBeenCalledWith({}); + }); + }); + + test("J focuses the first extension card; K wraps to first when at start", async () => { + const ext = makeExtension({ enabled: false }); + const snapshot = makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]); + snapshot.permissions = { [ext.extensionId]: makePermissions({ driftStatus: "fresh" }) }; + setApi(snapshot); + + const view = renderSection(); + await waitFor(() => view.getByText("Demo Extension")); + + fireEvent.keyDown(window, { key: "j" }); + await waitFor(() => { + expect(view.getByTestId(getExtensionCardTestId(ext)).getAttribute("data-focused")).toBe( + "true" + ); + }); + }); + + test("J focuses duplicate extension IDs one card at a time", async () => { + const first = makeExtension({ + rootId: "bundled-root", + rootKind: "bundled", + }); + const second = makeExtension({ + rootId: "user-root", + rootKind: "user-global", + }); + const snapshot = makeSnapshot([ + makeRoot({ + rootId: "bundled-root", + kind: "bundled", + rootExists: true, + extensions: [first], + }), + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [second], + }), + ]); + setApi(snapshot); + + const view = renderSection(); + await waitFor(() => expect(view.getAllByText("Demo Extension")).toHaveLength(2)); + + fireEvent.keyDown(window, { key: "j" }); + await waitFor(() => { + expect(view.getByTestId(getExtensionCardTestId(first)).getAttribute("data-focused")).toBe( + "true" + ); + expect( + view.getByTestId(getExtensionCardTestId(second)).getAttribute("data-focused") + ).toBeNull(); + }); + + fireEvent.keyDown(window, { key: "j" }); + await waitFor(() => { + expect( + view.getByTestId(getExtensionCardTestId(first)).getAttribute("data-focused") + ).toBeNull(); + expect(view.getByTestId(getExtensionCardTestId(second)).getAttribute("data-focused")).toBe( + "true" + ); + }); + }); + + test("Enter on focused card toggles its expansion", async () => { + const ext = makeExtension({ enabled: false }); + const snapshot = makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]); + snapshot.permissions = { [ext.extensionId]: makePermissions({ driftStatus: "fresh" }) }; + setApi(snapshot); + + const view = renderSection(); + await waitFor(() => view.getByText("Demo Extension")); + + fireEvent.keyDown(window, { key: "j" }); + await waitFor(() => + expect(view.getByTestId(getExtensionCardTestId(ext)).getAttribute("data-focused")).toBe( + "true" + ) + ); + expect(view.queryByText(/Identity/i)).toBeNull(); + fireEvent.keyDown(window, { key: "Enter" }); + await waitFor(() => view.getByText(/Identity/i)); + }); + + test("E on focused disabled extension calls enable()", async () => { + const ext = makeExtension({ enabled: false }); + const snapshot = makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]); + snapshot.permissions = { [ext.extensionId]: makePermissions({ driftStatus: "fresh" }) }; + setApi(snapshot); + + const view = renderSection(); + await waitFor(() => view.getByText("Demo Extension")); + fireEvent.keyDown(window, { key: "j" }); + await waitFor(() => + expect(view.getByTestId(getExtensionCardTestId(ext)).getAttribute("data-focused")).toBe( + "true" + ) + ); + fireEvent.keyDown(window, { key: "e" }); + await waitFor(() => { + expect(mockApi.extensions.enable).toHaveBeenCalledWith({ + rootId: "user-root", + extensionId: ext.extensionId, + }); + }); + }); + + test("shortcuts ignore keystrokes originating in editable elements", async () => { + setApi( + makeSnapshot([makeRoot({ rootId: "user-root", kind: "user-global", rootExists: true })]) + ); + const view = renderSection(); + await waitFor(() => view.getByText(/Platform state:/i)); + + // The initial mount calls list(); reload() should not be called yet. + expect(mockApi.extensions.reload).not.toHaveBeenCalled(); + + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + fireEvent.keyDown(input, { key: "r" }); + fireEvent.keyDown(input, { key: "?", shiftKey: true }); + expect(view.queryByTestId("extensions-cheatsheet-modal")).toBeNull(); + expect(mockApi.extensions.reload).not.toHaveBeenCalled(); + input.remove(); + }); + + test("T trusts the project-local root when it exists and is untrusted", async () => { + setApi( + makeSnapshot([ + makeRoot({ + rootId: "project-root", + kind: "project-local", + path: "/proj/.mux/extensions", + rootExists: true, + trusted: false, + }), + ]) + ); + const view = renderSection(); + await waitFor(() => view.getByLabelText("Trust this root")); + fireEvent.keyDown(window, { key: "t" }); + await waitFor(() => { + expect(mockApi.extensions.trustRoot).toHaveBeenCalledWith({ rootId: "project-root" }); + }); + }); + + test("RootFailure: shows Failed pill and Retry button when root.state === 'failed'", async () => { + setApi( + makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + path: "/user", + rootExists: true, + state: "failed", + diagnostics: [ + { + code: "root.discovery.timeout", + severity: "error", + message: "discovery timed out", + occurredAt: 0, + }, + ], + }), + ]) + ); + + const view = renderSection(); + await waitFor(() => view.getByTestId("root-failed-user-global")); + const retry = view.getByTestId("root-retry-user-global"); + expect(retry).toBeTruthy(); + fireEvent.click(retry); + await waitFor(() => { + expect(mockApi.extensions.reload).toHaveBeenCalledWith({ rootId: "user-root" }); + }); + }); + + test("RootFailure: root subsection renders the diagnostic at error severity", async () => { + setApi( + makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + path: "/user", + rootExists: true, + state: "failed", + diagnostics: [ + { + code: "root.discovery.timeout", + severity: "error", + message: "discovery timed out", + occurredAt: 0, + }, + ], + }), + ]) + ); + + const view = renderSection(); + const list = await waitFor(() => view.getByTestId("root-diagnostics-user-global")); + const item = list.querySelector('[data-diagnostic-code="root.discovery.timeout"]'); + expect(item).toBeTruthy(); + expect(item?.getAttribute("data-diagnostic-severity")).toBe("error"); + }); + + test("ExtensionInvalid: blocking manifest error mirrors into root subsection diagnostics", async () => { + const ext = makeExtension({ + enabled: false, + diagnostics: [ + { + code: "manifest.invalid", + severity: "error", + message: "manifest schema failed", + occurredAt: 0, + }, + ], + }); + setApi( + makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]) + ); + + const view = renderSection(); + const list = await waitFor(() => view.getByTestId("root-diagnostics-user-global")); + expect(list.querySelector('[data-diagnostic-code="manifest.invalid"]')).toBeTruthy(); + // Header tally still counts this. + expect(view.getByText(/1 error\b/i)).toBeTruthy(); + }); + + test("IdentityConflict: surfaces in root subsection at error severity", async () => { + const ext = makeExtension({ + enabled: false, + diagnostics: [ + { + code: "extension.identity.conflict", + severity: "error", + message: "duplicate Extension identity", + occurredAt: 0, + }, + ], + }); + setApi( + makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]) + ); + + const view = renderSection(); + const list = await waitFor(() => view.getByTestId("root-diagnostics-user-global")); + const item = list.querySelector('[data-diagnostic-code="extension.identity.conflict"]'); + expect(item).toBeTruthy(); + expect(item?.getAttribute("data-diagnostic-severity")).toBe("error"); + }); + + test("resolver diagnostics only attach to the owning root card", async () => { + const userExt = makeExtension({ rootId: "user-root", rootKind: "user-global" }); + const projectExt = makeExtension({ rootId: "project-root", rootKind: "project-local" }); + const snapshot = makeSnapshot([ + makeRoot({ rootId: "user-root", kind: "user-global", extensions: [userExt] }), + makeRoot({ rootId: "project-root", kind: "project-local", extensions: [projectExt] }), + ]); + snapshot.permissions = { + [userExt.extensionId]: makePermissions({ driftStatus: null }), + [`${projectExt.rootId}\0${projectExt.extensionId}`]: makePermissions({ driftStatus: null }), + }; + snapshot.resolverDiagnostics = [ + { + code: "extension.identity.conflict", + severity: "error", + message: "user-global conflict", + rootId: userExt.rootId, + extensionId: userExt.extensionId, + occurredAt: 0, + }, + ]; + setApi(snapshot); + + const view = renderSection(); + await waitFor(() => view.getAllByText("Demo Extension")); + + expect( + view.getByTestId(getExtensionCardTestId(userExt)).querySelector('[data-status="conflict"]') + ).toBeTruthy(); + expect( + view.getByTestId(getExtensionCardTestId(projectExt)).querySelector('[data-status="conflict"]') + ).toBeNull(); + }); + + test("resolver conflicts feed extension card status", async () => { + const ext = makeExtension({ enabled: true, granted: true }); + const snapshot = makeSnapshot([ + makeRoot({ kind: "user-global", rootId: "user-root", extensions: [ext] }), + ]); + snapshot.permissions = { [ext.extensionId]: makePermissions({ driftStatus: null }) }; + snapshot.resolverDiagnostics = [ + { + code: "contribution.identity.conflict", + severity: "warn", + message: "Contribution conflict", + extensionId: ext.extensionId, + contributionRef: { type: "skills", id: "demo.skill" }, + occurredAt: 0, + }, + ]; + setApi(snapshot); + + const view = renderSection(); + await waitFor(() => expect(view.getByText("Conflict")).toBeTruthy()); + fireEvent.click(view.getByText("Demo Extension")); + expect(view.getByText("contribution.identity.conflict")).toBeTruthy(); + }); + + test("ContributionInvalid: stays on the card at warn severity (not mirrored on root)", async () => { + const ext = makeExtension({ + enabled: true, + diagnostics: [ + { + code: "contribution.invalid", + severity: "warn", + message: "bad contribution", + contributionRef: { type: "skills", id: "demo.skill" }, + occurredAt: 0, + }, + ], + }); + setApi( + makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]) + ); + + const view = renderSection(); + await waitFor(() => view.getByText("Demo Extension")); + // Root subsection diagnostics should NOT include the contribution-invalid code. + const rootList = view.queryByTestId("root-diagnostics-user-global"); + if (rootList) { + expect(rootList.querySelector('[data-diagnostic-code="contribution.invalid"]')).toBeNull(); + } + // Header tally still counts the warning. + expect(view.getByText(/1 warning\b/i)).toBeTruthy(); + }); + + test("ContributionConflict: stays on the card at warn severity", async () => { + const ext = makeExtension({ + enabled: true, + diagnostics: [ + { + code: "contribution.identity.conflict", + severity: "warn", + message: "duplicate contribution id", + contributionRef: { type: "skills", id: "demo.skill" }, + occurredAt: 0, + }, + ], + }); + setApi( + makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: true, + extensions: [ext], + }), + ]) + ); + + const view = renderSection(); + await waitFor(() => view.getByText("Demo Extension")); + const rootList = view.queryByTestId("root-diagnostics-user-global"); + if (rootList) { + expect( + rootList.querySelector('[data-diagnostic-code="contribution.identity.conflict"]') + ).toBeNull(); + } + }); + + test("aggregates errors and warnings across roots and resolver diagnostics", async () => { + const snapshot = makeSnapshot([ + makeRoot({ + rootId: "bundled-root", + kind: "bundled", + diagnostics: [ + { + code: "x", + severity: "error", + message: "boom", + occurredAt: 0, + }, + ], + extensions: [ + { + extensionId: "vendor.foo", + rootId: "bundled-root", + rootKind: "bundled", + isCore: false, + modulePath: "/p", + manifest: { + manifestVersion: 1, + id: "vendor.foo", + displayName: undefined, + description: undefined, + publisher: undefined, + homepage: undefined, + requestedPermissions: [], + contributions: [], + }, + contributions: [], + diagnostics: [ + { + code: "y", + severity: "warn", + message: "minor", + occurredAt: 0, + }, + ], + enabled: true, + granted: true, + activated: true, + }, + ], + }), + ]); + snapshot.resolverDiagnostics = [{ code: "z", severity: "info", message: "fyi", occurredAt: 0 }]; + setApi(snapshot); + + const view = renderSection(); + + await waitFor(() => { + expect(view.getByText(/1 error\b/i)).toBeTruthy(); + }); + // Spec: info diagnostics never appear in the header. Only error + warn counts. + expect(view.getByText(/1 warning\b/i)).toBeTruthy(); + }); +}); diff --git a/src/browser/features/Settings/Sections/ExtensionsSection.tsx b/src/browser/features/Settings/Sections/ExtensionsSection.tsx new file mode 100644 index 0000000000..87055d606a --- /dev/null +++ b/src/browser/features/Settings/Sections/ExtensionsSection.tsx @@ -0,0 +1,1152 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + AlertTriangle, + ChevronDown, + ChevronRight, + Copy, + Info, + Keyboard, + Loader2, + Plus, + RefreshCw, + RotateCw, + ShieldAlert, + ShieldCheck, + ShieldOff, + XCircle, +} from "lucide-react"; +import type { z } from "zod"; + +import { Button } from "@/browser/components/Button/Button"; +import { useAPI } from "@/browser/contexts/API"; +import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; +import { stopKeyboardPropagation } from "@/browser/utils/events"; +import { + formatKeybind, + isEditableElement, + KEYBINDS, + matchesKeybind, +} from "@/browser/utils/ui/keybinds"; +import { requiresReapproval } from "@/common/extensions/approvalDrift"; +import { extensionPermissionKey } from "@/common/extensions/extensionPermissionKey"; +import type * as extensionRegistrySchemas from "@/common/orpc/schemas/extensionRegistry"; +import { ConsentShortcutModal } from "./ConsentShortcutModal"; +import { DestructiveConfirmDialog } from "./DestructiveConfirmDialog"; +import { ExtensionCard, getExtensionCardTestId, StaleRecordCard } from "./ExtensionCard"; +import { ExtensionsCheatSheetModal } from "./ExtensionsCheatSheetModal"; +import { logSnapshotDiagnostics, rootSubsectionDiagnostics } from "./extensionDiagnostics"; + +type ExtensionDiagnostic = z.infer; +type RegistrySnapshot = z.infer; +type RootDiscoveryResult = z.infer; +type RootKind = z.infer; +type DiscoveredExtension = z.infer; +type CalculatePermissionsResult = z.infer< + typeof extensionRegistrySchemas.CalculatePermissionsResultSchema +>; +type StaleRecord = z.infer; + +const ROOT_LABELS: Record = { + bundled: "Bundled", + "user-global": "User-global", + "project-local": "Project-local", +}; + +interface AggregateCounts { + errors: number; + warnings: number; +} + +function aggregateDiagnostics(snapshot: RegistrySnapshot | null): AggregateCounts { + if (!snapshot) return { errors: 0, warnings: 0 }; + + let errors = 0; + let warnings = 0; + + const tally = (severity: string) => { + if (severity === "error") errors++; + else if (severity === "warn") warnings++; + }; + + for (const root of snapshot.roots) { + for (const d of root.diagnostics) tally(d.severity); + for (const ext of root.extensions) { + for (const d of ext.diagnostics) tally(d.severity); + } + } + for (const d of snapshot.resolverDiagnostics) tally(d.severity); + + return { errors, warnings }; +} + +function describePlatformState(snapshot: RegistrySnapshot | null): string { + if (!snapshot) return "Loading…"; + + const states = snapshot.roots.map((r) => r.state); + if (states.some((s) => s === "running" || s === "pending")) return "Discovering…"; + if (states.some((s) => s === "failed")) return "Discovery completed with failures"; + if (states.length === 0) return "No extension roots configured"; + return "Ready"; +} + +function findRoot(snapshot: RegistrySnapshot | null, kind: RootKind): RootDiscoveryResult | null { + if (!snapshot) return null; + return snapshot.roots.find((r) => r.kind === kind) ?? null; +} + +function extensionCardKey(extension: Pick): string { + return extensionPermissionKey(extension.rootId, extension.extensionId); +} + +function getRootSections( + snapshot: RegistrySnapshot | null +): Array<{ key: string; kind: RootKind; root: RootDiscoveryResult | null }> { + const roots = snapshot?.roots ?? []; + const userGlobalRoots = roots.filter((root) => root.kind === "user-global"); + return [ + { key: "bundled", kind: "bundled" as const, root: findRoot(snapshot, "bundled") }, + ...(userGlobalRoots.length > 0 + ? userGlobalRoots.map((root) => ({ key: root.rootId, kind: "user-global" as const, root })) + : [{ key: "user-global", kind: "user-global" as const, root: null }]), + ...roots + .filter((root) => root.kind === "project-local") + .map((root) => ({ key: root.rootId, kind: "project-local" as const, root })), + ]; +} + +function hasPendingPermissionDrift(snapshot: RegistrySnapshot | null): boolean { + if (!snapshot) return false; + return Object.values(snapshot.permissions).some((result) => requiresReapproval(result)); +} + +interface ExtensionActionHandlers { + onReload: (rootId: string, extensionId: string) => void | Promise; + onEnable: (rootId: string, extensionId: string) => void | Promise; + onDisable: (rootId: string, extensionId: string) => void | Promise; + onGrant: (rootId: string, extensionId: string) => void | Promise; + onRevoke: (rootId: string, extensionId: string) => void | Promise; + onQuickSetup: (rootId: string, extensionId: string) => void; +} + +interface RootSubsectionProps extends ExtensionActionHandlers { + kind: RootKind; + root: RootDiscoveryResult | null; + isInitializing: boolean; + permissions: Record; + resolverDiagnostics: readonly ExtensionDiagnostic[]; + expandedExtensionKey: string | null; + focusedExtensionKey: string | null; + onReloadRoot: (rootId?: string) => void; + onInitializeUserRoot: () => void; + onTrustRoot: (rootId: string) => void; + onUntrustRoot: (rootId: string) => void; +} + +const RootSubsection: React.FC = ({ + kind, + root, + isInitializing, + permissions, + resolverDiagnostics, + expandedExtensionKey, + focusedExtensionKey, + onReloadRoot, + onInitializeUserRoot, + onTrustRoot, + onUntrustRoot, + onReload, + onEnable, + onDisable, + onGrant, + onRevoke, + onQuickSetup, +}) => { + const [collapsed, setCollapsed] = useState(false); + + // Project-local: missing dir → hide subsection entirely. + if (kind === "project-local" && !root?.rootExists) { + return null; + } + + const label = ROOT_LABELS[kind]; + const rootDomId = root?.rootId ?? kind; + const baseExtensions = root?.extensions ?? []; + const extensions = baseExtensions.map((ext: DiscoveredExtension) => { + const diagnostics: ExtensionDiagnostic[] = resolverDiagnostics.filter( + (diag) => + diag.extensionId === ext.extensionId && (diag.rootId == null || diag.rootId === ext.rootId) + ); + return diagnostics.length > 0 + ? { ...ext, diagnostics: [...ext.diagnostics, ...diagnostics] } + : ext; + }); + const rootWithResolverDiagnostics = root ? { ...root, extensions } : null; + const rootUntrusted = root != null && kind === "project-local" && !root.trusted; + const rootTrusted = root != null && kind === "project-local" && root.trusted; + const inspectionOnly = rootUntrusted; + + return ( +
+
+ +
+ {root?.state === "failed" && ( + + )} + {root && rootUntrusted && ( + + )} + {root && rootTrusted && ( + + )} +
+
+ + {!collapsed && ( +
+ {root?.path &&

{root.path}

} + + + + + + {extensions.length > 0 && ( +
+ {rootUntrusted && ( +

+ Project-local root is untrusted. Cards are shown in inspection-only mode. +

+ )} + {extensions.map((ext) => ( + + ))} +
+ )} +
+ )} +
+ ); +}; + +interface RootDiagnosticsProps { + root: RootDiscoveryResult | null; + kind: RootKind; +} + +const SEVERITY_ICON = { + error: XCircle, + warn: AlertTriangle, + info: Info, +} as const; + +const SEVERITY_COLOR = { + error: "text-error", + warn: "text-warning", + info: "text-muted", +} as const; + +const RootDiagnostics: React.FC = ({ root, kind }) => { + if (!root) return null; + // Mirror extension-level error / conflict / root-level diagnostics so the + // root subsection can summarize blocking issues without forcing the user to + // expand every card. Unclassified codes still flow through their snapshot + // surface (root.diagnostics already prints; cards already print their own). + const items = rootSubsectionDiagnostics(root); + if (items.length === 0) return null; + + return ( +
    + {items.map((d, idx) => { + const Icon = SEVERITY_ICON[d.severity]; + return ( +
  • + +
    +
    + {d.code} + {d.extensionId && ( + {d.extensionId} + )} +
    +
    {d.message}
    + {d.suggestedAction && ( +
    {d.suggestedAction}
    + )} +
    +
  • + ); + })} +
+ ); +}; + +interface RootEmptyStateProps { + kind: RootKind; + root: RootDiscoveryResult | null; + isInitializing: boolean; + onReload: (rootId?: string) => void; + onInitializeUserRoot: () => void; +} + +function isProjectLockInspectionRoot(root: RootDiscoveryResult): boolean { + return root.path.endsWith("/.mux") || root.path.endsWith("\\.mux"); +} + +const RootEmptyState: React.FC = ({ + kind, + root, + isInitializing, + onReload, + onInitializeUserRoot, +}) => { + if (!root) return null; + if (root.extensions.length > 0) return null; + + if (kind === "bundled") { + // Spec: bundled is never empty in v1; but if it appears empty (e.g. dev-server + // before assembly), fall through and avoid surfacing a confusing CTA. + return ( +

+ No bundled extensions detected. The packaged app should always include at least one. +

+ ); + } + + if (kind === "user-global") { + if (!root.rootExists) { + return ( +
+

+ No user-global Extensions root has been initialized yet. Mux can create the directory so + you can drop Extension Modules into it; this only sets up the folder and never approves + any capabilities. +

+ +
+ ); + } + return ( +
+

+ User-global root is initialized but contains no Extension Modules. Create a module folder, + add extension.ts, then reload: +

+
+          {`mkdir -p ${root.path}/acme-review && $EDITOR ${root.path}/acme-review/extension.ts`}
+        
+ +
+ ); + } + + // project-local: present + project untrusted (root.trusted === false at the root level + // is shown via the header trust button; an empty project-local root just shows a hint). + if (kind === "project-local") { + if (!root.trusted && isProjectLockInspectionRoot(root)) { + return ( +
+

+ This project declares extension sources in{" "} + .mux/extensions.lock.json. Before trust, Mux shows + the declaration only; sources are not fetched, parsed, or executed. +

+
+ ); + } + if (!root.trusted) { + return ( +
+

+ This project's local Extensions root has not been trusted. Trust the project (or + this root) to allow Mux to discover Extensions from the + .mux/extensions directory. +

+
+ ); + } + return ( +

+ Project-local root is trusted but contains no Extensions yet. +

+ ); + } + + return null; +}; + +interface ConsentTarget { + extension: DiscoveredExtension; + permissions: CalculatePermissionsResult | null; + /** Project-local + untrusted root → transaction includes Trust Root. */ + requiresTrustRoot: boolean; +} + +type DestructiveAction = + | { kind: "disable"; rootId: string; extensionId: string; displayName: string } + | { kind: "revoke"; rootId: string; extensionId: string; displayName: string } + | { kind: "untrustRoot"; rootId: string; rootPath: string }; + +export const ExtensionsSection: React.FC = () => { + const { api } = useAPI(); + const { copied, copyToClipboard } = useCopyToClipboard(); + const [snapshot, setSnapshot] = useState(null); + const [isReloading, setIsReloading] = useState(false); + const [isInitializing, setIsInitializing] = useState(false); + const [actionError, setActionError] = useState(null); + const [consentTarget, setConsentTarget] = useState(null); + const [destructiveAction, setDestructiveAction] = useState(null); + // Root-scoped keys keep conflicted duplicate extension IDs independently navigable. + const [expandedExtensionKey, setExpandedExtensionKey] = useState(null); + const [focusedExtensionKey, setFocusedExtensionKey] = useState(null); + const [cheatSheetOpen, setCheatSheetOpen] = useState(false); + const sectionRef = useRef(null); + + const refresh = useCallback(async () => { + if (!api) return; + try { + const next = await api.extensions.list(); + setSnapshot(next ?? null); + } catch (err) { + setActionError(err instanceof Error ? err.message : "Failed to load extensions"); + } + }, [api]); + + // Each snapshot replacement triggers a one-shot pass that emits structured + // log entries for every matrix-relevant diagnostic and derived state. We key + // on `generatedAt` so a re-render with the same snapshot does not duplicate + // log lines, and so previous-snapshot diagnostics never leak across. + const lastLoggedRef = useRef(null); + useEffect(() => { + if (!snapshot) return; + if (lastLoggedRef.current === snapshot.generatedAt) return; + lastLoggedRef.current = snapshot.generatedAt; + logSnapshotDiagnostics(snapshot); + }, [snapshot]); + + useEffect(() => { + if (!api) return; + const abort = new AbortController(); + void refresh(); + (async () => { + try { + const iter = await api.extensions.onChanged(undefined, { signal: abort.signal }); + for await (const _ of iter) { + if (abort.signal.aborted) break; + void refresh(); + } + } catch { + // Expected on unmount. + } + })(); + return () => abort.abort(); + }, [api, refresh]); + + const handleReload = useCallback( + async (rootId?: string) => { + if (!api) return; + setIsReloading(true); + setActionError(null); + try { + await api.extensions.reload(rootId ? { rootId } : {}); + } catch (err) { + setActionError(err instanceof Error ? err.message : "Failed to reload extensions"); + } finally { + setIsReloading(false); + } + }, + [api] + ); + + const handleInitializeUserRoot = useCallback(async () => { + if (!api) return; + setIsInitializing(true); + setActionError(null); + try { + await api.extensions.initializeUserRoot(); + } catch (err) { + setActionError( + err instanceof Error ? err.message : "Failed to initialize user extensions root" + ); + } finally { + setIsInitializing(false); + } + }, [api]); + + const handleTrustRoot = useCallback( + async (rootId: string) => { + if (!api) return; + setActionError(null); + try { + await api.extensions.trustRoot({ rootId }); + } catch (err) { + setActionError(err instanceof Error ? err.message : "Failed to trust extension root"); + } + }, + [api] + ); + + // Per-extension action wrappers shared across cards. Each runs against the + // extensions IPC then triggers a refresh; failures surface in the section's + // action-error banner so a single card cannot swallow them silently. + const runExtensionAction = useCallback( + async (label: string, action: () => Promise) => { + if (!api) return; + setActionError(null); + try { + await action(); + } catch (err) { + setActionError(err instanceof Error ? err.message : `Failed to ${label}`); + } + }, + [api] + ); + + const handleEnableExtension = useCallback( + (rootId: string, extensionId: string) => + runExtensionAction("enable extension", () => api!.extensions.enable({ rootId, extensionId })), + [api, runExtensionAction] + ); + const handleGrantExtension = useCallback( + (rootId: string, extensionId: string) => + runExtensionAction("approve extension capabilities", () => + api!.extensions.approve({ rootId, extensionId }) + ), + [api, runExtensionAction] + ); + const handleReloadExtension = useCallback( + (rootId: string, _extensionId: string) => + runExtensionAction("reload extension", () => api!.extensions.reload({ rootId })), + [api, runExtensionAction] + ); + const handleForgetStale = useCallback( + (rootId: string, extensionId: string) => + runExtensionAction("forget stale record", () => + api!.extensions.forgetStale({ rootId, extensionId }) + ), + [api, runExtensionAction] + ); + + // Destructive actions never bypass confirmation: clicking the per-card + // Disable / Revoke buttons (or the project-local Untrust header button) opens + // a confirmation dialog that lists the consequences before invoking the IPC. + const findExtension = useCallback( + (rootId: string, extensionId: string): DiscoveredExtension | null => { + if (!snapshot) return null; + for (const root of snapshot.roots) { + if (root.rootId !== rootId) continue; + return root.extensions.find((e) => e.extensionId === extensionId) ?? null; + } + return null; + }, + [snapshot] + ); + + const handleRequestDisable = useCallback( + (rootId: string, extensionId: string) => { + const ext = findExtension(rootId, extensionId); + const displayName = ext?.manifest.displayName ?? extensionId; + setDestructiveAction({ kind: "disable", rootId, extensionId, displayName }); + }, + [findExtension] + ); + const handleRequestRevoke = useCallback( + (rootId: string, extensionId: string) => { + const ext = findExtension(rootId, extensionId); + const displayName = ext?.manifest.displayName ?? extensionId; + setDestructiveAction({ kind: "revoke", rootId, extensionId, displayName }); + }, + [findExtension] + ); + const handleRequestUntrustRoot = useCallback( + (rootId: string) => { + const root = snapshot?.roots.find((r) => r.rootId === rootId); + setDestructiveAction({ + kind: "untrustRoot", + rootId, + rootPath: root?.path ?? rootId, + }); + }, + [snapshot] + ); + + const handleConfirmDestructive = useCallback(async () => { + if (!api || !destructiveAction) return; + const action = destructiveAction; + setDestructiveAction(null); + if (action.kind === "disable") { + await runExtensionAction("disable extension", () => + api.extensions.disable({ rootId: action.rootId, extensionId: action.extensionId }) + ); + } else if (action.kind === "revoke") { + await runExtensionAction("revoke extension approvals", () => + api.extensions.revokeApproval({ rootId: action.rootId, extensionId: action.extensionId }) + ); + } else { + await runExtensionAction("untrust extension root", () => + api.extensions.untrustRoot({ rootId: action.rootId }) + ); + } + }, [api, destructiveAction, runExtensionAction]); + + // Quick Setup opens the Consent Shortcut Modal. The section computes whether + // the affected root needs trusting in the same transaction so the modal can + // accurately summarize consequences. + const handleQuickSetup = useCallback( + (rootId: string, extensionId: string) => { + const ext = findExtension(rootId, extensionId); + if (!ext) return; + const root = snapshot?.roots.find((r) => r.rootId === rootId) ?? null; + const requiresTrustRoot = root?.kind === "project-local" && !root.trusted; + setConsentTarget({ + extension: ext, + permissions: + snapshot?.permissions[extensionPermissionKey(rootId, extensionId)] ?? + snapshot?.permissions[extensionId] ?? + null, + requiresTrustRoot, + }); + }, + [findExtension, snapshot] + ); + + const handleConsentConfirm = useCallback(async () => { + if (!api || !consentTarget) return; + const target = consentTarget; + setActionError(null); + setConsentTarget(null); + let trustedRoot = false; + let enabledExtension = false; + try { + if (target.requiresTrustRoot) { + await api.extensions.trustRoot({ rootId: target.extension.rootId }); + trustedRoot = true; + } + if (!target.extension.enabled) { + await api.extensions.enable({ + rootId: target.extension.rootId, + extensionId: target.extension.extensionId, + }); + enabledExtension = true; + } + await api.extensions.approve({ + rootId: target.extension.rootId, + extensionId: target.extension.extensionId, + }); + } catch (err) { + if (enabledExtension) { + try { + await api.extensions.disable({ + rootId: target.extension.rootId, + extensionId: target.extension.extensionId, + }); + } catch { + // Preserve the original setup error; the user can retry or disable manually. + } + } + if (trustedRoot) { + try { + await api.extensions.untrustRoot({ rootId: target.extension.rootId }); + } catch { + // Preserve the original setup error; the user can retry or untrust manually. + } + } + setActionError(err instanceof Error ? err.message : "Failed to apply consent shortcut"); + } + }, [api, consentTarget]); + + const handleConsentReviewIndividually = useCallback(() => { + if (!consentTarget) return; + setExpandedExtensionKey(extensionCardKey(consentTarget.extension)); + setConsentTarget(null); + }, [consentTarget]); + + const aggregate = useMemo(() => aggregateDiagnostics(snapshot), [snapshot]); + const platformState = describePlatformState(snapshot); + const driftPending = useMemo(() => hasPendingPermissionDrift(snapshot), [snapshot]); + + const orderedExtensions = useMemo(() => { + if (!snapshot) return [] as readonly DiscoveredExtension[]; + const out: DiscoveredExtension[] = []; + for (const section of getRootSections(snapshot)) { + if (!section.root) continue; + for (const ext of section.root.extensions) out.push(ext); + } + return out; + }, [snapshot]); + + const userGlobalRoot = findRoot(snapshot, "user-global"); + const userRootMissing = userGlobalRoot != null && !userGlobalRoot.rootExists; + + const firstUntrustedProjectLocalRoot = snapshot?.roots.find( + (root) => root.kind === "project-local" && root.rootExists && !root.trusted + ); + + const handleTrustProjectLocal = useCallback(() => { + if (!firstUntrustedProjectLocalRoot) return; + void handleTrustRoot(firstUntrustedProjectLocalRoot.rootId); + }, [firstUntrustedProjectLocalRoot, handleTrustRoot]); + + // Section-local keyboard shortcuts. Listener runs only while this component + // is mounted (i.e., the user is on the Extensions settings tab); editable + // elements bypass shortcuts so typing in any input still works. + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented) return; + if (isEditableElement(e.target)) return; + if (consentTarget || destructiveAction) return; + + if (matchesKeybind(e, KEYBINDS.EXTENSIONS_CHEATSHEET)) { + e.preventDefault(); + stopKeyboardPropagation(e); + setCheatSheetOpen((prev) => !prev); + return; + } + + // Cheat sheet swallows all other shortcuts so users can dismiss it + // before issuing the next command without surprises. + if (cheatSheetOpen) return; + + if (matchesKeybind(e, KEYBINDS.EXTENSIONS_RELOAD)) { + e.preventDefault(); + stopKeyboardPropagation(e); + void handleReload(); + return; + } + if (matchesKeybind(e, KEYBINDS.EXTENSIONS_TRUST_ROOT)) { + e.preventDefault(); + stopKeyboardPropagation(e); + handleTrustProjectLocal(); + return; + } + if (orderedExtensions.length === 0) return; + + if (matchesKeybind(e, KEYBINDS.EXTENSIONS_NAVIGATE_NEXT)) { + e.preventDefault(); + stopKeyboardPropagation(e); + const idx = orderedExtensions.findIndex( + (ext) => extensionCardKey(ext) === focusedExtensionKey + ); + const next = + orderedExtensions[Math.min(idx + 1, orderedExtensions.length - 1)] ?? + orderedExtensions[0]; + if (next) setFocusedExtensionKey(extensionCardKey(next)); + return; + } + if (matchesKeybind(e, KEYBINDS.EXTENSIONS_NAVIGATE_PREV)) { + e.preventDefault(); + stopKeyboardPropagation(e); + const idx = orderedExtensions.findIndex( + (ext) => extensionCardKey(ext) === focusedExtensionKey + ); + const prev = orderedExtensions[Math.max(idx - 1, 0)] ?? orderedExtensions[0]; + if (prev) setFocusedExtensionKey(extensionCardKey(prev)); + return; + } + if (!focusedExtensionKey) return; + const focused = orderedExtensions.find( + (ext) => extensionCardKey(ext) === focusedExtensionKey + ); + if (!focused) return; + + if ( + matchesKeybind(e, KEYBINDS.EXTENSIONS_EXPAND_ENTER) || + matchesKeybind(e, KEYBINDS.EXTENSIONS_EXPAND_SPACE) + ) { + e.preventDefault(); + stopKeyboardPropagation(e); + const focusedKey = extensionCardKey(focused); + setExpandedExtensionKey((current) => (current === focusedKey ? null : focusedKey)); + return; + } + if (matchesKeybind(e, KEYBINDS.EXTENSIONS_TOGGLE_ENABLE)) { + e.preventDefault(); + stopKeyboardPropagation(e); + if (focused.enabled) handleRequestDisable(focused.rootId, focused.extensionId); + else void handleEnableExtension(focused.rootId, focused.extensionId); + return; + } + if (matchesKeybind(e, KEYBINDS.EXTENSIONS_GRANT)) { + e.preventDefault(); + stopKeyboardPropagation(e); + handleQuickSetup(focused.rootId, focused.extensionId); + return; + } + if (matchesKeybind(e, KEYBINDS.EXTENSIONS_DIAGNOSTICS)) { + e.preventDefault(); + stopKeyboardPropagation(e); + // Force-expand so the diagnostics block is visible, then scroll the + // focused card into view. Section uses `expandedExtensionKey` as the + // single force-expand slot so other expansions stay user-driven. + setExpandedExtensionKey(extensionCardKey(focused)); + const el = sectionRef.current?.querySelector( + `[data-testid="${getExtensionCardTestId(focused)}"]` + ); + el?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + return; + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [ + cheatSheetOpen, + consentTarget, + destructiveAction, + focusedExtensionKey, + handleEnableExtension, + handleQuickSetup, + handleReload, + handleRequestDisable, + handleTrustProjectLocal, + orderedExtensions, + ]); + + // Auto-scroll the newly focused card into view (J/K navigation). + useEffect(() => { + if (!focusedExtensionKey) return; + const focused = orderedExtensions.find((ext) => extensionCardKey(ext) === focusedExtensionKey); + if (!focused) return; + const el = sectionRef.current?.querySelector( + `[data-testid="${getExtensionCardTestId(focused)}"]` + ); + el?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }, [focusedExtensionKey, orderedExtensions]); + + // Resolve the root path the action row's copy button targets. + // Prefer user-global; fall back to bundled then project-local. + const primaryRootPath = + userGlobalRoot?.path ?? + findRoot(snapshot, "bundled")?.path ?? + findRoot(snapshot, "project-local")?.path ?? + ""; + + return ( +
+ {/* Header: platform-state line + aggregate counts (errors + warnings only). */} +
+
+

Extensions

+ +
+
+ + Platform state: {platformState} + + + + {aggregate.errors} {aggregate.errors === 1 ? "error" : "errors"} + + + + {aggregate.warnings} {aggregate.warnings === 1 ? "warning" : "warnings"} + +
+
+ + {/* Action row */} +
+ + + {userRootMissing && ( + + )} + + + + {driftPending && ( + + )} +
+ + {actionError && ( +
+ + {actionError} +
+ )} + + {snapshot === null ? ( +
+ Loading extension registry… +
+ ) : ( +
+ {getRootSections(snapshot).map(({ key, kind, root }) => ( + void handleReload(rootId)} + onInitializeUserRoot={() => void handleInitializeUserRoot()} + onTrustRoot={(rootId) => void handleTrustRoot(rootId)} + onUntrustRoot={handleRequestUntrustRoot} + onReload={handleReloadExtension} + onEnable={handleEnableExtension} + onDisable={handleRequestDisable} + onGrant={handleGrantExtension} + onRevoke={handleRequestRevoke} + onQuickSetup={handleQuickSetup} + /> + ))} +
+ )} + + {snapshot?.staleRecords && snapshot.staleRecords.length > 0 && ( +
+

Stale Approval Records

+

+ Approval records that no longer match a present Extension. Forget the record to clear + its capability approvals, or keep it for the case where the Extension reappears. +

+
+ {snapshot.staleRecords.map((record: StaleRecord) => ( + void handleForgetStale(rootId, extensionId)} + /> + ))} +
+
+ )} + + void handleConsentConfirm()} + onReviewIndividually={handleConsentReviewIndividually} + onClose={() => setConsentTarget(null)} + /> + + {destructiveAction && ( + void handleConfirmDestructive()} + onClose={() => setDestructiveAction(null)} + /> + )} + + setCheatSheetOpen(false)} /> +
+ ); +}; + +interface DestructiveDialogCopy { + title: string; + description: string; + consequences: readonly string[]; + confirmLabel: string; +} + +function describeDestructiveAction(action: DestructiveAction): DestructiveDialogCopy { + switch (action.kind) { + case "disable": + return { + title: `Disable ${action.displayName}?`, + description: + "Disabling this Extension stops its contributions from being available until it is re-enabled.", + consequences: [ + "All contributions from this Extension become unavailable.", + "Existing approval record is preserved; re-enabling restores the prior capability approvals.", + ], + confirmLabel: "Disable", + }; + case "revoke": + return { + title: `Revoke approvals for ${action.displayName}?`, + description: + "Revoking approvals withdraws every effect capability previously approved for this Extension.", + consequences: [ + "Effect capabilities return to the unapproved state.", + "The Extension stays enabled but cannot use revoked capabilities until re-approved.", + "Registration capabilities are unaffected.", + ], + confirmLabel: "Revoke approval", + }; + case "untrustRoot": + return { + title: "Untrust this Extensions root?", + description: `Untrusting ${action.rootPath} switches every Extension under that root into inspection-only mode.`, + consequences: [ + "Extensions in this root run in inspection-only mode.", + "Project-wide trust is revoked, so repo-controlled hooks and scripts stay disabled.", + "Any active enablement / approval state is suspended until the root is trusted again.", + "Existing approval records remain on disk and are restored when trust is re-applied.", + ], + confirmLabel: "Untrust root", + }; + } +} diff --git a/src/browser/features/Settings/Sections/GovernorSection.stories.tsx b/src/browser/features/Settings/Sections/GovernorSection.stories.tsx index 4284490e53..e73b5c0bb6 100644 --- a/src/browser/features/Settings/Sections/GovernorSection.stories.tsx +++ b/src/browser/features/Settings/Sections/GovernorSection.stories.tsx @@ -72,6 +72,7 @@ export const EnrolledWithPolicy: Story = { providerAccess: [{ id: "anthropic", allowedModels: ["claude-sonnet-4-20250514"] }], mcp: { allowUserDefined: { stdio: false, remote: true } }, runtimes: ["local", "worktree", "ssh"], + extensionPlatform: null, }, }, }) @@ -107,6 +108,7 @@ export const EnrolledEnvOverride: Story = { providerAccess: [{ id: "anthropic", allowedModels: null }], mcp: { allowUserDefined: { stdio: true, remote: true } }, runtimes: null, + extensionPlatform: null, }, }, }) @@ -127,6 +129,7 @@ export const PolicyBlocked: Story = { providerAccess: [], mcp: { allowUserDefined: { stdio: false, remote: false } }, runtimes: ["local"], + extensionPlatform: null, }, }, }) @@ -159,6 +162,7 @@ export const RichPolicy: Story = { ], mcp: { allowUserDefined: { stdio: true, remote: false } }, runtimes: ["local", "worktree"], + extensionPlatform: null, }, }, }) diff --git a/src/browser/features/Settings/Sections/KeybindsSection.tsx b/src/browser/features/Settings/Sections/KeybindsSection.tsx index 490d4485f3..d380c8199b 100644 --- a/src/browser/features/Settings/Sections/KeybindsSection.tsx +++ b/src/browser/features/Settings/Sections/KeybindsSection.tsx @@ -81,6 +81,16 @@ const KEYBIND_LABELS: Record = { TOGGLE_PLAN_ANNOTATE: "Toggle plan annotate mode", // Easter egg keybind; intentionally omitted from KEYBIND_GROUPS. TOGGLE_POWER_MODE: "", + EXTENSIONS_RELOAD: "Reload extensions", + EXTENSIONS_NAVIGATE_NEXT: "Focus next extension", + EXTENSIONS_NAVIGATE_PREV: "Focus previous extension", + EXTENSIONS_EXPAND_ENTER: "Expand focused extension", + EXTENSIONS_EXPAND_SPACE: "Expand focused extension (alt)", + EXTENSIONS_TOGGLE_ENABLE: "Enable / disable focused extension", + EXTENSIONS_GRANT: "Approve focused extension capabilities", + EXTENSIONS_TRUST_ROOT: "Trust project-local root", + EXTENSIONS_DIAGNOSTICS: "Show focused extension diagnostics", + EXTENSIONS_CHEATSHEET: "Show extensions cheat sheet", }; /** Groups for organizing keybinds in the UI */ @@ -183,13 +193,30 @@ const KEYBIND_GROUPS: Array<{ label: string; keys: Array label: "External", keys: ["OPEN_TERMINAL", "OPEN_IN_EDITOR"], }, + { + label: "Extensions", + keys: [ + "EXTENSIONS_RELOAD", + "EXTENSIONS_NAVIGATE_NEXT", + "EXTENSIONS_NAVIGATE_PREV", + "EXTENSIONS_EXPAND_ENTER", + "EXTENSIONS_TOGGLE_ENABLE", + "EXTENSIONS_GRANT", + "EXTENSIONS_TRUST_ROOT", + "EXTENSIONS_DIAGNOSTICS", + "EXTENSIONS_CHEATSHEET", + ], + }, ]; +const EXTENSIONS_KEYBIND_ALTERNATES: Array = ["EXTENSIONS_EXPAND_SPACE"]; + // Some actions have multiple equivalent shortcuts; render alternates on the same row. const KEYBIND_DISPLAY_ALTERNATES: Partial< Record> > = { OPEN_COMMAND_PALETTE: ["OPEN_COMMAND_PALETTE_ACTIONS"], + EXTENSIONS_EXPAND_ENTER: EXTENSIONS_KEYBIND_ALTERNATES, }; export function KeybindsSection() { diff --git a/src/browser/features/Settings/Sections/dialogFocus.ts b/src/browser/features/Settings/Sections/dialogFocus.ts new file mode 100644 index 0000000000..64a603bd57 --- /dev/null +++ b/src/browser/features/Settings/Sections/dialogFocus.ts @@ -0,0 +1,37 @@ +import type React from "react"; + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +function isVisible(element: HTMLElement): boolean { + return element.offsetParent !== null || element === document.activeElement; +} + +export function trapTabKey(container: HTMLElement | null, event: React.KeyboardEvent): void { + if (event.key !== "Tab") return; + const focusable = Array.from( + container?.querySelectorAll(FOCUSABLE_SELECTOR) ?? [] + ).filter(isVisible); + if (focusable.length === 0) { + event.preventDefault(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + return; + } + if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } +} diff --git a/src/browser/features/Settings/Sections/extensionDiagnostics.test.ts b/src/browser/features/Settings/Sections/extensionDiagnostics.test.ts new file mode 100644 index 0000000000..4cc9848d7b --- /dev/null +++ b/src/browser/features/Settings/Sections/extensionDiagnostics.test.ts @@ -0,0 +1,371 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { z } from "zod"; + +import type * as extensionRegistrySchemas from "@/common/orpc/schemas/extensionRegistry"; +import { + __setExtensionDiagnosticsLogSink, + classifyDiagnostic, + logSnapshotDiagnostics, +} from "./extensionDiagnostics"; + +type RegistrySnapshot = z.infer; +type RootDiscoveryResult = z.infer; +type DiscoveredExtension = z.infer; +type ExtensionDiagnostic = z.infer; + +interface CapturedEntry { + severity: "error" | "warn" | "info"; + args: unknown[]; +} + +function makeRoot(overrides: Partial = {}): RootDiscoveryResult { + return { + rootId: overrides.rootId ?? "root-id", + kind: overrides.kind ?? "user-global", + path: overrides.path ?? "/path/to/root", + trusted: overrides.trusted ?? true, + rootExists: overrides.rootExists ?? true, + state: overrides.state ?? "ready", + extensions: overrides.extensions ?? [], + diagnostics: overrides.diagnostics ?? [], + }; +} + +function makeExtension(overrides: Partial = {}): DiscoveredExtension { + return { + extensionId: "vendor.demo", + rootId: "user-root", + rootKind: "user-global", + isCore: false, + modulePath: "/p", + manifest: { + manifestVersion: 1, + id: "vendor.demo", + displayName: "Demo Extension", + description: undefined, + publisher: undefined, + homepage: undefined, + requestedPermissions: [], + contributions: [], + }, + contributions: [], + diagnostics: [], + enabled: false, + granted: false, + activated: false, + ...overrides, + }; +} + +function makeSnapshot(roots: RootDiscoveryResult[]): RegistrySnapshot { + return { + generatedAt: 1000, + roots, + availableContributions: [], + resolverDiagnostics: [], + descriptors: [], + permissions: {}, + staleRecords: [], + }; +} + +function diag(overrides: Partial): ExtensionDiagnostic { + return { + code: overrides.code ?? "unknown.code", + severity: overrides.severity ?? "error", + message: overrides.message ?? "boom", + extensionId: overrides.extensionId, + contributionRef: overrides.contributionRef, + suggestedAction: overrides.suggestedAction, + occurredAt: overrides.occurredAt ?? 0, + }; +} + +describe("classifyDiagnostic", () => { + test("maps blocking manifest errors to extension-invalid", () => { + expect(classifyDiagnostic(diag({ code: "manifest.invalid" }))).toBe("extension-invalid"); + expect(classifyDiagnostic(diag({ code: "manifest.version.unsupported" }))).toBe( + "extension-invalid" + ); + expect(classifyDiagnostic(diag({ code: "extension.identity.invalid" }))).toBe( + "extension-invalid" + ); + expect(classifyDiagnostic(diag({ code: "extension.identity.reserved" }))).toBe( + "extension-invalid" + ); + expect(classifyDiagnostic(diag({ code: "extension.package.invalid" }))).toBe( + "extension-invalid" + ); + }); + + test("maps contribution.invalid family to contribution-invalid", () => { + expect(classifyDiagnostic(diag({ code: "contribution.invalid" }))).toBe("contribution-invalid"); + expect(classifyDiagnostic(diag({ code: "contribution.body.missing" }))).toBe( + "contribution-invalid" + ); + expect(classifyDiagnostic(diag({ code: "contribution.body.invalid" }))).toBe( + "contribution-invalid" + ); + expect(classifyDiagnostic(diag({ code: "contribution.body.timeout" }))).toBe( + "contribution-invalid" + ); + }); + + test("maps identity conflicts to identity-conflict / contribution-conflict", () => { + expect(classifyDiagnostic(diag({ code: "extension.identity.conflict" }))).toBe( + "identity-conflict" + ); + expect(classifyDiagnostic(diag({ code: "contribution.identity.conflict" }))).toBe( + "contribution-conflict" + ); + }); + + test("maps root failure codes to root-failure", () => { + expect(classifyDiagnostic(diag({ code: "root.discovery.timeout" }))).toBe("root-failure"); + expect(classifyDiagnostic(diag({ code: "root.package.invalid" }))).toBe("root-failure"); + }); + + test("returns null for codes outside the matrix", () => { + expect(classifyDiagnostic(diag({ code: "manifest.unknown_field", severity: "info" }))).toBe( + null + ); + expect(classifyDiagnostic(diag({ code: "totally.unknown" }))).toBe(null); + }); +}); + +describe("logSnapshotDiagnostics", () => { + let captured: CapturedEntry[] = []; + + beforeEach(() => { + captured = []; + __setExtensionDiagnosticsLogSink({ + error: (...args) => captured.push({ severity: "error", args }), + warn: (...args) => captured.push({ severity: "warn", args }), + info: (...args) => captured.push({ severity: "info", args }), + }); + }); + + afterEach(() => { + __setExtensionDiagnosticsLogSink(null); + }); + + function logFields(entry: CapturedEntry): Record { + return entry.args[1] as Record; + } + + test("RootFailure: logs at error and includes rootId + code", () => { + logSnapshotDiagnostics( + makeSnapshot([ + makeRoot({ + rootId: "root-1", + kind: "user-global", + state: "failed", + diagnostics: [ + diag({ + code: "root.discovery.timeout", + severity: "error", + message: "timeout", + occurredAt: 50, + }), + ], + }), + ]) + ); + const errors = captured.filter((c) => c.severity === "error"); + expect(errors).toHaveLength(1); + const fields = logFields(errors[0]); + expect(fields.kind).toBe("root-failure"); + expect(fields.code).toBe("root.discovery.timeout"); + expect(fields.rootId).toBe("root-1"); + expect(fields.component).toBe("extensions"); + }); + + test("RootInitMissing: logs at info when user-global root does not exist", () => { + logSnapshotDiagnostics( + makeSnapshot([ + makeRoot({ + rootId: "user-root", + kind: "user-global", + rootExists: false, + }), + ]) + ); + const infos = captured.filter( + (c) => c.severity === "info" && (logFields(c).kind as string) === "root-init-missing" + ); + expect(infos).toHaveLength(1); + expect(logFields(infos[0]).code).toBe("root.init.missing"); + expect(logFields(infos[0]).rootId).toBe("user-root"); + }); + + test("ExtensionInvalid: logs at error and includes extensionId", () => { + const ext = makeExtension({ + diagnostics: [ + diag({ + code: "manifest.invalid", + severity: "error", + message: "bad manifest", + extensionId: "vendor.demo", + }), + ], + }); + logSnapshotDiagnostics( + makeSnapshot([ + makeRoot({ + rootId: "root-1", + extensions: [ext], + }), + ]) + ); + const errors = captured.filter((c) => c.severity === "error"); + expect(errors.some((e) => logFields(e).kind === "extension-invalid")).toBe(true); + const fields = errors.find((e) => logFields(e).kind === "extension-invalid")!; + expect(logFields(fields).extensionId).toBe("vendor.demo"); + expect(logFields(fields).rootId).toBe("root-1"); + }); + + test("ContributionInvalid: logs at warn", () => { + const ext = makeExtension({ + diagnostics: [ + diag({ + code: "contribution.invalid", + severity: "warn", + message: "bad contribution", + }), + ], + }); + logSnapshotDiagnostics(makeSnapshot([makeRoot({ rootId: "r", extensions: [ext] })])); + const warns = captured.filter( + (c) => c.severity === "warn" && logFields(c).kind === "contribution-invalid" + ); + expect(warns).toHaveLength(1); + expect(logFields(warns[0]).code).toBe("contribution.invalid"); + }); + + test("IdentityConflict: logs at error", () => { + const ext = makeExtension({ + diagnostics: [ + diag({ + code: "extension.identity.conflict", + severity: "error", + message: "duplicate identity", + }), + ], + }); + logSnapshotDiagnostics(makeSnapshot([makeRoot({ rootId: "r", extensions: [ext] })])); + const errors = captured.filter( + (c) => c.severity === "error" && logFields(c).kind === "identity-conflict" + ); + expect(errors).toHaveLength(1); + }); + + test("ContributionConflict: logs at warn", () => { + const ext = makeExtension({ + diagnostics: [ + diag({ + code: "contribution.identity.conflict", + severity: "warn", + message: "dup contribution", + contributionRef: { type: "skills", id: "demo" }, + }), + ], + }); + logSnapshotDiagnostics(makeSnapshot([makeRoot({ rootId: "r", extensions: [ext] })])); + const warns = captured.filter( + (c) => c.severity === "warn" && logFields(c).kind === "contribution-conflict" + ); + expect(warns).toHaveLength(1); + expect(logFields(warns[0]).contributionId).toBe("demo"); + }); + + test("Drift: logs at info when permissions are non-fresh", () => { + const ext = makeExtension(); + const snapshot = makeSnapshot([makeRoot({ rootId: "r", extensions: [ext] })]); + snapshot.permissions = { + [ext.extensionId]: { + effectivePermissions: [], + pendingNew: ["secrets.read"], + contributions: [], + driftStatus: "permissions-changed", + isStale: false, + }, + }; + logSnapshotDiagnostics(snapshot); + const infos = captured.filter((c) => c.severity === "info" && logFields(c).kind === "drift"); + expect(infos).toHaveLength(1); + expect(logFields(infos[0]).extensionId).toBe(ext.extensionId); + expect(logFields(infos[0]).rootId).toBe("r"); + expect(String(logFields(infos[0]).message)).toContain("Capability approvals"); + expect(String(logFields(infos[0]).message)).toContain("awaiting re-approval"); + expect(String(logFields(infos[0]).message)).not.toContain("Operational permissions"); + expect(String(logFields(infos[0]).message)).not.toContain("re-grant"); + }); + + test("SupportLevelInspectionOnly: logs at info for untrusted project-local root", () => { + logSnapshotDiagnostics( + makeSnapshot([ + makeRoot({ + rootId: "proj-root", + kind: "project-local", + rootExists: true, + trusted: false, + }), + ]) + ); + const infos = captured.filter( + (c) => + c.severity === "info" && + logFields(c).kind === "support-level-inspection-only" && + logFields(c).code === "root.inspection_only" + ); + expect(infos).toHaveLength(1); + }); + + test("SupportLevelInspectionOnly: logs at info for non-skill contributions", () => { + const ext = makeExtension({ + manifest: { + manifestVersion: 1, + id: "vendor.demo", + displayName: "Demo", + description: undefined, + publisher: undefined, + homepage: undefined, + requestedPermissions: [], + contributions: [ + { type: "themes", id: "demo.theme", index: 0, descriptor: {} }, + { type: "agents", id: "demo-agent", index: 1, descriptor: {} }, + { type: "skills", id: "demo.skill", index: 2, descriptor: {} }, + ], + }, + }); + logSnapshotDiagnostics(makeSnapshot([makeRoot({ rootId: "r", extensions: [ext] })])); + const infos = captured.filter( + (c) => + c.severity === "info" && + logFields(c).kind === "support-level-inspection-only" && + logFields(c).code === "contribution.support_level.inspection_only" + ); + // Theme and agent contributions are inspection-only; skills is capability-consumed. + expect(infos).toHaveLength(2); + expect(infos.map((info) => logFields(info).contributionId).sort()).toEqual([ + "demo-agent", + "demo.theme", + ]); + }); + + test("structured fields: every entry carries component=extensions plus rootId, extensionId, contributionId, code", () => { + const ext = makeExtension({ + diagnostics: [diag({ code: "manifest.invalid", severity: "error", message: "x" })], + }); + logSnapshotDiagnostics(makeSnapshot([makeRoot({ rootId: "root-A", extensions: [ext] })])); + expect(captured.length).toBeGreaterThan(0); + for (const entry of captured) { + const fields = logFields(entry); + expect(fields.component).toBe("extensions"); + expect(typeof fields.code).toBe("string"); + expect("rootId" in fields).toBe(true); + expect("extensionId" in fields).toBe(true); + expect("contributionId" in fields).toBe(true); + } + }); +}); diff --git a/src/browser/features/Settings/Sections/extensionDiagnostics.ts b/src/browser/features/Settings/Sections/extensionDiagnostics.ts new file mode 100644 index 0000000000..7f90f250a2 --- /dev/null +++ b/src/browser/features/Settings/Sections/extensionDiagnostics.ts @@ -0,0 +1,295 @@ +import type { z } from "zod"; + +import { requiresReapproval } from "@/common/extensions/approvalDrift"; +import { + extensionIdFromPermissionKey, + rootIdFromPermissionKey, +} from "@/common/extensions/extensionPermissionKey"; +import type * as extensionRegistrySchemas from "@/common/orpc/schemas/extensionRegistry"; + +type RegistrySnapshot = z.infer; +type RootDiscoveryResult = z.infer; +type ExtensionDiagnostic = z.infer; +/** + * Diagnostic kinds defined by the v1 surfacing matrix. Each kind maps to a + * fixed log severity and a fixed set of UI surfaces (header / root subsection + * / card). See US-025 for the full matrix. + */ +export type DiagnosticKind = + | "root-failure" + | "root-init-missing" + | "extension-invalid" + | "contribution-invalid" + | "identity-conflict" + | "contribution-conflict" + | "drift" + | "support-level-inspection-only"; + +const ROOT_FAILURE_CODES = new Set(["root.discovery.timeout", "root.package.invalid"]); + +// v1 contribution types that are capability-consumed end-to-end. Anything else +// is inspection-only in v1, mirroring the support level rendered on the card. +const AVAILABLE_TYPES = new Set(["skills"]); + +const EXTENSION_INVALID_CODES = new Set([ + "manifest.invalid", + "manifest.version.unsupported", + "manifest.contributes.unknown_key", + "extension.identity.invalid", + "extension.identity.reserved", + "extension.package.invalid", + "extension.missing", + "extension.state.malformed", + "extension.state.schema_version.unsupported", + "extension.state.record.invalid", +]); + +const CONTRIBUTION_INVALID_CODES = new Set([ + "contribution.invalid", + "contribution.body.missing", + "contribution.body.invalid", + "contribution.body.timeout", +]); + +/** + * Classify a backend-issued diagnostic into one of the matrix kinds. Returns + * null when the code does not map to any kind in the matrix (e.g., + * `manifest.unknown_field`, which is purely informational and only logged). + */ +export function classifyDiagnostic(diagnostic: ExtensionDiagnostic): DiagnosticKind | null { + if (diagnostic.code === "extension.identity.conflict") return "identity-conflict"; + if (diagnostic.code === "contribution.identity.conflict") return "contribution-conflict"; + if (ROOT_FAILURE_CODES.has(diagnostic.code)) return "root-failure"; + if (EXTENSION_INVALID_CODES.has(diagnostic.code)) return "extension-invalid"; + if (CONTRIBUTION_INVALID_CODES.has(diagnostic.code)) return "contribution-invalid"; + return null; +} + +export interface DiagnosticLogContext { + rootId: string | null; + extensionId?: string | null; + contributionId?: string | null; +} + +interface StructuredLogFields { + component: "extensions"; + code: string; + kind: DiagnosticKind | "unclassified"; + rootId: string | null; + extensionId: string | null; + contributionId: string | null; + message: string; + suggestedAction?: string; + occurredAt: number; +} + +interface LogSink { + error: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; +} + +const defaultSink: LogSink = { + error: (...args) => console.error(...args), + warn: (...args) => console.warn(...args), + info: (...args) => console.info(...args), +}; + +let sink: LogSink = defaultSink; + +/** + * Test seam: replace the log sink so unit tests can assert on structured + * fields without coupling to console internals. + */ +export function __setExtensionDiagnosticsLogSink(next: LogSink | null): void { + sink = next ?? defaultSink; +} + +function emit(severity: "error" | "warn" | "info", fields: StructuredLogFields): void { + const tag = `[extensions/${fields.kind}]`; + if (severity === "error") sink.error(tag, fields); + else if (severity === "warn") sink.warn(tag, fields); + else sink.info(tag, fields); +} + +function buildFields( + diagnostic: ExtensionDiagnostic, + kind: DiagnosticKind | "unclassified", + context: DiagnosticLogContext +): StructuredLogFields { + return { + component: "extensions", + code: diagnostic.code, + kind, + rootId: context.rootId, + extensionId: context.extensionId ?? diagnostic.extensionId ?? null, + contributionId: context.contributionId ?? diagnostic.contributionRef?.id ?? null, + message: diagnostic.message, + suggestedAction: diagnostic.suggestedAction ?? undefined, + occurredAt: diagnostic.occurredAt, + }; +} + +/** + * Log a single backend-issued diagnostic at its declared severity. Unknown + * codes still land in the log so support tickets can attribute them, just + * tagged as "unclassified". + */ +export function logDiagnostic( + diagnostic: ExtensionDiagnostic, + context: DiagnosticLogContext +): void { + const kind = classifyDiagnostic(diagnostic) ?? "unclassified"; + emit(diagnostic.severity, buildFields(diagnostic, kind, context)); +} + +interface SyntheticEvent { + kind: DiagnosticKind; + code: string; + severity: "error" | "warn" | "info"; + message: string; + rootId: string | null; + extensionId?: string | null; + contributionId?: string | null; + occurredAt: number; +} + +function emitSynthetic(event: SyntheticEvent): void { + emit(event.severity, { + component: "extensions", + code: event.code, + kind: event.kind, + rootId: event.rootId, + extensionId: event.extensionId ?? null, + contributionId: event.contributionId ?? null, + message: event.message, + occurredAt: event.occurredAt, + }); +} + +/** + * Walk a snapshot and emit one structured log entry per matrix-relevant + * diagnostic and per derived state (RootInitMissing, RootFailure without a + * specific code, Drift, SupportLevelInspectionOnly). Designed to be called + * once per snapshot replacement; previous-snapshot diagnostics never leak + * because we operate on the new snapshot only. + */ +export function logSnapshotDiagnostics(snapshot: RegistrySnapshot): void { + const now = snapshot.generatedAt; + + for (const root of snapshot.roots) { + for (const diagnostic of root.diagnostics) { + logDiagnostic(diagnostic, { rootId: root.rootId }); + } + + if (root.kind === "user-global" && !root.rootExists) { + emitSynthetic({ + kind: "root-init-missing", + code: "root.init.missing", + severity: "info", + message: `User-global Extensions root has not been initialized at ${root.path}.`, + rootId: root.rootId, + occurredAt: now, + }); + } + + if (root.kind === "project-local" && root.rootExists && !root.trusted) { + emitSynthetic({ + kind: "support-level-inspection-only", + code: "root.inspection_only", + severity: "info", + message: `Project-local Extensions root at ${root.path} is untrusted; contained Extensions are inspection-only.`, + rootId: root.rootId, + occurredAt: now, + }); + } + + if (root.state === "failed" && root.diagnostics.length === 0) { + emitSynthetic({ + kind: "root-failure", + code: "root.discovery.failed", + severity: "error", + message: `Extension Root discovery failed for ${root.path}.`, + rootId: root.rootId, + occurredAt: now, + }); + } + + for (const ext of root.extensions) { + for (const diagnostic of ext.diagnostics) { + logDiagnostic(diagnostic, { + rootId: root.rootId, + extensionId: ext.extensionId, + }); + } + for (const contribution of ext.manifest.contributions) { + if (!AVAILABLE_TYPES.has(contribution.type)) { + emitSynthetic({ + kind: "support-level-inspection-only", + code: "contribution.support_level.inspection_only", + severity: "info", + message: `Contribution ${contribution.type}/${contribution.id} is recognized but not capability-consumed in v1; shown in inspection-only mode.`, + rootId: root.rootId, + extensionId: ext.extensionId, + contributionId: contribution.id, + occurredAt: now, + }); + } + } + } + } + + for (const diagnostic of snapshot.resolverDiagnostics) { + logDiagnostic(diagnostic, { rootId: null }); + } + + for (const [permissionKey, permissions] of Object.entries(snapshot.permissions)) { + if (!permissions) continue; + if (requiresReapproval(permissions)) { + const extensionId = extensionIdFromPermissionKey(permissionKey); + emitSynthetic({ + kind: "drift", + code: "permissions.drift", + severity: "info", + message: `Capability approvals for ${extensionId} have drifted (${permissions.driftStatus ?? "pending-new"}); awaiting re-approval.`, + rootId: + rootIdFromPermissionKey(permissionKey) ?? findRootIdForExtension(snapshot, extensionId), + extensionId, + occurredAt: now, + }); + } + } +} + +function findRootIdForExtension(snapshot: RegistrySnapshot, extensionId: string): string | null { + for (const root of snapshot.roots) { + if (root.extensions.some((e) => e.extensionId === extensionId)) return root.rootId; + } + return null; +} + +const ROOT_MIRROR_KINDS: ReadonlySet = new Set([ + "extension-invalid", + "identity-conflict", + "root-failure", +]); + +/** + * Pull the matrix-classified diagnostics off a root for the Diagnostics panel + * inside RootSubsection. Blocking error kinds (extension-invalid / + * identity-conflict / root-failure) are mirrored from cards into the root + * list so the user can find them without expanding every card; warn-severity + * contribution kinds stay on the card to avoid duplicate rendering. + */ +export function rootSubsectionDiagnostics( + root: RootDiscoveryResult +): readonly ExtensionDiagnostic[] { + const collected: ExtensionDiagnostic[] = [...root.diagnostics]; + for (const ext of root.extensions) { + for (const d of ext.diagnostics) { + const kind = classifyDiagnostic(d); + if (kind && ROOT_MIRROR_KINDS.has(kind)) collected.push(d); + } + } + return collected; +} diff --git a/src/browser/features/Settings/SettingsPage.test.tsx b/src/browser/features/Settings/SettingsPage.test.tsx index 5a449af8df..c9493e4cf2 100644 --- a/src/browser/features/Settings/SettingsPage.test.tsx +++ b/src/browser/features/Settings/SettingsPage.test.tsx @@ -8,6 +8,7 @@ describe("SettingsPage", () => { expect(labels).not.toContain("Goals"); expect(labels).not.toContain("Heartbeat"); + expect(labels).toContain("Extensions"); expect(labels).toContain("Experiments"); }); diff --git a/src/browser/features/Settings/SettingsPage.tsx b/src/browser/features/Settings/SettingsPage.tsx index 703d46950e..cf348adea0 100644 --- a/src/browser/features/Settings/SettingsPage.tsx +++ b/src/browser/features/Settings/SettingsPage.tsx @@ -15,6 +15,7 @@ import { ShieldCheck, Server, Lock, + Puzzle, } from "lucide-react"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { useOnboardingPause } from "@/browser/features/SplashScreens/SplashScreenProvider"; @@ -35,6 +36,7 @@ import { ExperimentsSection } from "./Sections/ExperimentsSection"; import { ServerAccessSection } from "./Sections/ServerAccessSection"; import { KeybindsSection } from "./Sections/KeybindsSection"; import { SecuritySection } from "./Sections/SecuritySection"; +import { ExtensionsSection } from "./Sections/ExtensionsSection"; import type { SettingsSection } from "./types"; const LEGACY_EXPERIMENT_SETTINGS_SECTION_IDS = new Set(["goals", "heartbeat"]); @@ -120,19 +122,32 @@ interface SettingsSectionRedirect { } export function getSettingsSections(governorEnabled: boolean): SettingsSection[] { - if (!governorEnabled) { - return BASE_SECTIONS; - } - - return [ - ...BASE_SECTIONS, + const mcpIndex = BASE_SECTIONS.findIndex((s) => s.id === "mcp"); + const insertAt = mcpIndex >= 0 ? mcpIndex + 1 : BASE_SECTIONS.length; + let sections: SettingsSection[] = [ + ...BASE_SECTIONS.slice(0, insertAt), { - id: "governor", - label: "Governor", - icon: , - component: GovernorSection, + id: "extensions", + label: "Extensions", + icon: , + component: ExtensionsSection, }, + ...BASE_SECTIONS.slice(insertAt), ]; + + if (governorEnabled) { + sections = [ + ...sections, + { + id: "governor", + label: "Governor", + icon: , + component: GovernorSection, + }, + ]; + } + + return sections; } export function getSettingsSectionRedirect( @@ -192,6 +207,7 @@ export function SettingsPage(props: SettingsPageProps) { window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [close]); + const sections = getSettingsSections(governorEnabled); const currentSection = sections.find((section) => section.id === activeSection) ?? sections[0]; const SectionComponent = currentSection.component; diff --git a/src/browser/hooks/useAnalytics.test.tsx b/src/browser/hooks/useAnalytics.test.tsx index bb39281e82..28918d1ed5 100644 --- a/src/browser/hooks/useAnalytics.test.tsx +++ b/src/browser/hooks/useAnalytics.test.tsx @@ -4,7 +4,7 @@ import { access, copyFile, readFile, rm, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { GlobalWindow } from "happy-dom"; +import { installDom } from "../../../tests/ui/dom"; import { RPCLink as HTTPRPCLink } from "@orpc/client/fetch"; import { createORPCClient } from "@orpc/client"; import type { RouterClient } from "@orpc/server"; @@ -266,15 +266,14 @@ function requireAnalyticsServiceCalls(): AnalyticsServiceCalls { } describe("useAnalytics hooks", () => { + let cleanupDom: (() => void) | null = null; let server: OrpcServer | null = null; beforeEach(async () => { isolatedModulePaths = await importIsolatedAnalyticsModules(); mock.restore(); await ensureBuiltInSkillContentStub(); - - globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis; - globalThis.document = globalThis.window.document; + cleanupDom = installDom(); const analyticsStub = createAnalyticsServiceStub(summaryFixture); analyticsServiceCalls = analyticsStub.calls; @@ -302,8 +301,8 @@ describe("useAnalytics hooks", () => { analyticsServiceCalls = null; await server?.close(); server = null; - globalThis.window = undefined as unknown as Window & typeof globalThis; - globalThis.document = undefined as unknown as Document; + cleanupDom?.(); + cleanupDom = null; for (const modulePath of isolatedModulePaths) { await rm(modulePath, { force: true }); diff --git a/src/browser/hooks/useContextSwitchWarning.test.ts b/src/browser/hooks/useContextSwitchWarning.test.ts index b79d40f440..0563af7904 100644 --- a/src/browser/hooks/useContextSwitchWarning.test.ts +++ b/src/browser/hooks/useContextSwitchWarning.test.ts @@ -72,6 +72,7 @@ const createPolicyChurnClient = () => { providerAccess: null, mcp: { allowUserDefined: { stdio: true, remote: true } }, runtimes: null, + extensionPlatform: null, }, }), onChanged: () => Promise.resolve(policyEvents()), diff --git a/src/browser/hooks/useExtensionsPaletteSource.test.ts b/src/browser/hooks/useExtensionsPaletteSource.test.ts new file mode 100644 index 0000000000..b39bcd34c8 --- /dev/null +++ b/src/browser/hooks/useExtensionsPaletteSource.test.ts @@ -0,0 +1,260 @@ +import { installDom } from "../../../tests/ui/dom"; + +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { z } from "zod"; + +import type * as extensionRegistrySchemas from "@/common/orpc/schemas/extensionRegistry"; +import { CommandIds } from "@/browser/utils/commandIds"; +import { APIProvider, type APIClient } from "@/browser/contexts/API"; +import { + CommandRegistryProvider, + useCommandRegistry, +} from "@/browser/contexts/CommandRegistryContext"; +import { + buildExtensionsPaletteCommands, + useExtensionsPaletteSource, +} from "./useExtensionsPaletteSource"; + +type RegistrySnapshot = z.infer; + +function makeSnapshot(overrides: Partial = {}): RegistrySnapshot { + return { + generatedAt: 0, + roots: [], + availableContributions: [], + resolverDiagnostics: [], + descriptors: [], + permissions: {}, + staleRecords: [], + ...overrides, + }; +} + +type RootDiscoveryResult = z.infer; + +function makeRoot(overrides: Partial = {}): RootDiscoveryResult { + return { + rootId: overrides.rootId ?? "root-id", + kind: overrides.kind ?? "user-global", + path: overrides.path ?? "/some/path", + trusted: overrides.trusted ?? true, + rootExists: overrides.rootExists ?? true, + state: overrides.state ?? "ready", + extensions: overrides.extensions ?? [], + diagnostics: overrides.diagnostics ?? [], + }; +} + +const fakeApi = { extensions: {} } as unknown as Parameters< + typeof buildExtensionsPaletteCommands +>[0]["api"]; + +function visibleIds(commands: ReturnType): string[] { + return commands.filter((c) => !c.visible || c.visible()).map((c) => c.id); +} + +function emptyAsyncIterable(): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + // Empty async iterable used by hook tests. + }, + }; +} + +function renderPaletteHook(input: { + api: APIClient; + onOpenSettings: ((section?: string) => void) | undefined; +}) { + const wrapper: React.FC<{ children: React.ReactNode }> = (props) => + React.createElement( + APIProvider, + { client: input.api } as React.ComponentProps, + React.createElement(CommandRegistryProvider, null, props.children) + ); + return renderHook( + () => { + useExtensionsPaletteSource(input.onOpenSettings); + return useCommandRegistry(); + }, + { wrapper } + ); +} + +let cleanupDom: (() => void) | null = null; + +beforeEach(() => { + cleanupDom = installDom(); +}); + +afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; +}); + +describe("buildExtensionsPaletteCommands", () => { + test("disabled hook registers no commands and does not subscribe to extension snapshots", async () => { + const list = mock(() => Promise.resolve(makeSnapshot())); + const onChanged = mock(() => Promise.resolve(emptyAsyncIterable())); + const api = { extensions: { list, onChanged } } as unknown as APIClient; + + const { result } = renderPaletteHook({ api, onOpenSettings: undefined }); + + await waitFor(() => expect(result.current.getActions()).toEqual([])); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(list).not.toHaveBeenCalled(); + expect(onChanged).not.toHaveBeenCalled(); + }); + + test("always exposes Open Settings and Reload, regardless of snapshot state", () => { + const cmds = buildExtensionsPaletteCommands({ + api: null, + snapshot: null, + onOpenSettings: () => undefined, + }); + const ids = visibleIds(cmds); + expect(ids).toContain(CommandIds.extensionsOpenSettings()); + expect(ids).toContain(CommandIds.extensionsReload()); + }); + + test("Initialize User Root only visible when user-global root is missing", () => { + const presentRoots = makeSnapshot({ + roots: [makeRoot({ kind: "user-global", rootExists: true })], + }); + expect( + visibleIds( + buildExtensionsPaletteCommands({ + api: fakeApi, + snapshot: presentRoots, + onOpenSettings: () => undefined, + }) + ) + ).not.toContain(CommandIds.extensionsInitializeUserRoot()); + + const missing = makeSnapshot({ + roots: [makeRoot({ kind: "user-global", rootExists: false })], + }); + expect( + visibleIds( + buildExtensionsPaletteCommands({ + api: fakeApi, + snapshot: missing, + onOpenSettings: () => undefined, + }) + ) + ).toContain(CommandIds.extensionsInitializeUserRoot()); + }); + + test("Initialize User Root command calls initializeUserRoot", async () => { + const initializeUserRoot = mock(() => Promise.resolve()); + const api = { + extensions: { + initializeUserRoot, + }, + } as unknown as Parameters[0]["api"]; + const missing = makeSnapshot({ + roots: [makeRoot({ kind: "user-global", rootExists: false })], + }); + const command = buildExtensionsPaletteCommands({ + api, + snapshot: missing, + onOpenSettings: () => undefined, + }).find((c) => c.id === CommandIds.extensionsInitializeUserRoot()); + + await command?.run(); + + expect(initializeUserRoot).toHaveBeenCalled(); + }); + + test("Review Pending only visible when at least one extension has drift", () => { + const noDrift = makeSnapshot({ + permissions: { + "vendor.demo": { + effectivePermissions: [], + pendingNew: [], + contributions: [], + driftStatus: "fresh", + isStale: false, + }, + }, + }); + expect( + visibleIds( + buildExtensionsPaletteCommands({ + api: fakeApi, + snapshot: noDrift, + onOpenSettings: () => undefined, + }) + ) + ).not.toContain(CommandIds.extensionsReviewPending()); + + const withDrift = makeSnapshot({ + permissions: { + "vendor.demo": { + effectivePermissions: [], + pendingNew: ["secrets.read"], + contributions: [], + driftStatus: "permissions-changed", + isStale: false, + }, + }, + }); + expect( + visibleIds( + buildExtensionsPaletteCommands({ + api: fakeApi, + snapshot: withDrift, + onOpenSettings: () => undefined, + }) + ) + ).toContain(CommandIds.extensionsReviewPending()); + }); + + test("Review Pending command uses capability approval wording", () => { + const withDrift = makeSnapshot({ + permissions: { + "vendor.demo": { + effectivePermissions: [], + pendingNew: ["secrets.read"], + contributions: [], + driftStatus: "permissions-changed", + isStale: false, + }, + }, + }); + const command = buildExtensionsPaletteCommands({ + api: fakeApi, + snapshot: withDrift, + onOpenSettings: () => undefined, + }).find((c) => c.id === CommandIds.extensionsReviewPending()); + + expect(command?.title).toBe("Review Pending Extension Capabilities"); + expect(command?.subtitle).toBe( + "Open Extensions settings and surface capability approval drift" + ); + expect(command?.keywords).toContain("capabilities"); + }); + + test("root path command copies the primary path", async () => { + const writeText = mock(() => Promise.resolve()); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const snapshot = makeSnapshot({ + roots: [makeRoot({ kind: "user-global", path: "/u/.mux/extensions" })], + }); + const command = buildExtensionsPaletteCommands({ + api: fakeApi, + snapshot, + onOpenSettings: () => undefined, + }).find((c) => c.id === CommandIds.extensionsShowRootPath()); + + if (!command) throw new Error("Expected root path command"); + await command.run(); + + expect(writeText).toHaveBeenCalledWith("/u/.mux/extensions"); + }); +}); diff --git a/src/browser/hooks/useExtensionsPaletteSource.ts b/src/browser/hooks/useExtensionsPaletteSource.ts new file mode 100644 index 0000000000..4b9795b37d --- /dev/null +++ b/src/browser/hooks/useExtensionsPaletteSource.ts @@ -0,0 +1,188 @@ +import { useEffect, useRef } from "react"; +import type { z } from "zod"; + +import { requiresReapproval } from "@/common/extensions/approvalDrift"; +import type { APIClient } from "@/browser/contexts/API"; +import { useAPI } from "@/browser/contexts/API"; +import { + useCommandRegistry, + type CommandAction, + type CommandSource, +} from "@/browser/contexts/CommandRegistryContext"; +import { CommandIds } from "@/browser/utils/commandIds"; +import { COMMAND_SECTIONS } from "@/browser/utils/commands/sources"; +import type * as extensionRegistrySchemas from "@/common/orpc/schemas/extensionRegistry"; + +type RegistrySnapshot = z.infer; + +interface ResolvedRoots { + userMissing: boolean; + primaryPath: string | null; + driftPending: boolean; +} + +function resolveRoots(snapshot: RegistrySnapshot | null): ResolvedRoots { + if (!snapshot) return { userMissing: false, primaryPath: null, driftPending: false }; + + const user = snapshot.roots.find((r) => r.kind === "user-global") ?? null; + const bundled = snapshot.roots.find((r) => r.kind === "bundled") ?? null; + const project = snapshot.roots.find((r) => r.kind === "project-local") ?? null; + + const driftPending = Object.values(snapshot.permissions).some((result) => + requiresReapproval(result) + ); + + return { + userMissing: user != null && !user.rootExists, + primaryPath: user?.path ?? bundled?.path ?? project?.path ?? null, + driftPending, + }; +} + +interface BuildCommandsParams { + api: APIClient | null; + snapshot: RegistrySnapshot | null; + onOpenSettings: (section?: string) => void; +} + +export function buildExtensionsPaletteCommands({ + api, + snapshot, + onOpenSettings, +}: BuildCommandsParams): CommandAction[] { + const roots = resolveRoots(snapshot); + const list: CommandAction[] = []; + + list.push({ + id: CommandIds.extensionsOpenSettings(), + title: "Open Settings: Extensions", + subtitle: "Manage installed extensions", + section: COMMAND_SECTIONS.SETTINGS, + keywords: ["extension", "extensions", "plugin", "addon", "add-on"], + run: () => onOpenSettings("extensions"), + }); + + list.push({ + id: CommandIds.extensionsReload(), + title: "Reload Extensions", + subtitle: "Re-discover all roots", + section: COMMAND_SECTIONS.SETTINGS, + keywords: ["extension", "extensions", "reload", "rediscover", "refresh"], + enabled: () => api != null, + run: async () => { + if (!api) return; + try { + await api.extensions.reload({}); + } catch { + /* surfaced in settings UI */ + } + }, + }); + + list.push({ + id: CommandIds.extensionsInitializeUserRoot(), + title: "Initialize User Extensions Root", + subtitle: "Create the user-global extensions directory", + section: COMMAND_SECTIONS.SETTINGS, + keywords: ["extension", "extensions", "initialize", "user", "root"], + visible: () => roots.userMissing, + enabled: () => api != null, + run: async () => { + if (!api) return; + try { + await api.extensions.initializeUserRoot(); + } catch { + /* surfaced in settings UI */ + } + }, + }); + + list.push({ + id: CommandIds.extensionsShowRootPath(), + title: "Copy Extensions Root Path", + subtitle: roots.primaryPath ?? undefined, + section: COMMAND_SECTIONS.SETTINGS, + keywords: ["extension", "extensions", "root", "path", "copy", "clipboard"], + enabled: () => roots.primaryPath != null, + run: async () => { + const path = roots.primaryPath; + if (!path) return; + try { + await navigator.clipboard.writeText(path); + } catch { + /* clipboard may be unavailable; UI alternative exists in settings */ + } + }, + }); + + list.push({ + id: CommandIds.extensionsReviewPending(), + title: "Review Pending Extension Capabilities", + subtitle: "Open Extensions settings and surface capability approval drift", + section: COMMAND_SECTIONS.SETTINGS, + keywords: ["extension", "extensions", "capabilities", "approval", "drift", "review", "pending"], + visible: () => roots.driftPending, + run: () => onOpenSettings("extensions"), + }); + + return list; +} + +/** + * Subscribe to extension snapshot updates and register a command-palette source + * exposing the Extensions section's operations. The source remains registered + * across snapshot changes; only the captured snapshot ref updates so command + * visibility/run reflects current state on each palette open. + */ +export function useExtensionsPaletteSource( + onOpenSettings: ((section?: string) => void) | undefined +): void { + const { api } = useAPI(); + const { registerSource } = useCommandRegistry(); + const platformEnabled = onOpenSettings !== undefined; + const snapshotRef = useRef(null); + const onOpenSettingsRef = useRef(onOpenSettings); + onOpenSettingsRef.current = onOpenSettings; + + useEffect(() => { + if (!api || !platformEnabled) { + snapshotRef.current = null; + return; + } + const abort = new AbortController(); + const refresh = async () => { + try { + snapshotRef.current = (await api.extensions.list()) ?? null; + } catch { + /* expected on shutdown */ + } + }; + void refresh(); + (async () => { + try { + const iter = await api.extensions.onChanged(undefined, { signal: abort.signal }); + for await (const _ of iter) { + if (abort.signal.aborted) break; + void refresh(); + } + } catch { + /* expected on unmount */ + } + })(); + return () => abort.abort(); + }, [api, platformEnabled]); + + useEffect(() => { + if (!platformEnabled) return; + const source: CommandSource = () => { + const cb = onOpenSettingsRef.current; + if (!cb) return []; + return buildExtensionsPaletteCommands({ + api, + snapshot: snapshotRef.current, + onOpenSettings: cb, + }); + }; + return registerSource(source); + }, [api, platformEnabled, registerSource]); +} diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 7dc58c7e57..6981a9fdd1 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -88,6 +88,13 @@ export const CommandIds = { // Help commands helpKeybinds: () => "help:keybinds" as const, + + // Extensions commands + extensionsOpenSettings: () => "ext:open-settings" as const, + extensionsReload: () => "ext:reload" as const, + extensionsInitializeUserRoot: () => "ext:initialize-user-root" as const, + extensionsShowRootPath: () => "ext:show-root-path" as const, + extensionsReviewPending: () => "ext:review-pending" as const, } as const; /** diff --git a/src/browser/utils/messages/StreamingMessageAggregator.skills.test.ts b/src/browser/utils/messages/StreamingMessageAggregator.skills.test.ts index a513e022dc..edd051bc86 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.skills.test.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.skills.test.ts @@ -522,6 +522,47 @@ describe("Agent skill snapshot association", () => { }); }); + // Regression: the local AgentSkillSnapshotMetadataSchema once narrowed + // scope to ["project","global","built-in"], silently dropping every + // extension-scope snapshot at parse time. UserMessageContent then saw no + // agentSkillSnapshot and rendered the slash command as plain text without + // the AgentSkillBadge or hover preview. Keep this test passing whenever + // the snapshot scope enum widens. + it("attaches agentSkillSnapshot for extension-scope skills", () => { + const aggregator = createAggregator(); + const snapshot = createSkillSnapshotMessage({ + skillName: "mux-extensions", + scope: "extension", + historySequence: 1, + body: "# Body", + frontmatterYaml: "name: mux-extensions\ndescription: Demo extension skill", + }); + const invocation = createSkillInvocationMessage({ + skillName: "mux-extensions", + scope: "extension", + historySequence: 2, + }); + + aggregator.loadHistoricalMessages([snapshot, invocation]); + + const displayed = aggregator.getDisplayedMessages(); + expect(displayed).toHaveLength(1); + + const message = displayed[0]; + if (message?.type !== "user") { + throw new Error("Expected displayed user message"); + } + + expect(message.agentSkill).toEqual({ + skillName: "mux-extensions", + scope: "extension", + snapshot: { + frontmatterYaml: "name: mux-extensions\ndescription: Demo extension skill", + body: "# Body", + }, + }); + }); + it("uses the latest snapshot available at each invocation turn", () => { const aggregator = createAggregator(); const firstFrontmatter = "name: pull-requests\ndescription: First"; diff --git a/src/browser/utils/messages/StreamingMessageAggregator.ts b/src/browser/utils/messages/StreamingMessageAggregator.ts index e6897ebc06..bf973f4ccc 100644 --- a/src/browser/utils/messages/StreamingMessageAggregator.ts +++ b/src/browser/utils/messages/StreamingMessageAggregator.ts @@ -90,9 +90,12 @@ const AgentStatusSchema = z.object({ // Synthetic agent-skill snapshot messages include metadata.agentSkillSnapshot. // We use this to keep the SkillIndicator in sync for /{skillName} invocations. +// The enum mirrors AgentSkillScopeSchema in src/common/orpc/schemas/agentSkill.ts +// — keep them in sync, since a narrower local enum here silently drops valid +// snapshots and breaks the inline / slash skill highlighting + hover preview. const AgentSkillSnapshotMetadataSchema = z.object({ skillName: z.string().min(1), - scope: z.enum(["project", "global", "built-in"]), + scope: z.enum(["project", "global", "extension", "built-in"]), sha256: z.string().optional(), frontmatterYaml: z.string().optional(), }); diff --git a/src/browser/utils/policyUi.test.ts b/src/browser/utils/policyUi.test.ts index 0762bd68be..ed0375438e 100644 --- a/src/browser/utils/policyUi.test.ts +++ b/src/browser/utils/policyUi.test.ts @@ -15,6 +15,7 @@ function buildPolicy(overrides: Partial): EffectivePolicy { providerAccess: null, mcp: { allowUserDefined: { stdio: true, remote: true } }, runtimes: null, + extensionPlatform: null, ...overrides, }; } diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index a59a2ddc6b..b16cc76bef 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -515,6 +515,36 @@ export const KEYBINDS = { TOGGLE_PLAN_ANNOTATE: { key: "a", shift: true }, TOGGLE_POWER_MODE: { key: "F12", shift: true }, + + /** Extensions section: reload all roots */ + EXTENSIONS_RELOAD: { key: "r" }, + + /** Extensions section: focus next extension card */ + EXTENSIONS_NAVIGATE_NEXT: { key: "j" }, + + /** Extensions section: focus previous extension card */ + EXTENSIONS_NAVIGATE_PREV: { key: "k" }, + + /** Extensions section: expand the focused card (Enter alias) */ + EXTENSIONS_EXPAND_ENTER: { key: "Enter" }, + + /** Extensions section: expand the focused card (Space alias) */ + EXTENSIONS_EXPAND_SPACE: { key: " " }, + + /** Extensions section: enable/disable the focused extension */ + EXTENSIONS_TOGGLE_ENABLE: { key: "e" }, + + /** Extensions section: open the grant flow for the focused extension */ + EXTENSIONS_GRANT: { key: "g" }, + + /** Extensions section: trust the project-local Extensions root */ + EXTENSIONS_TRUST_ROOT: { key: "t" }, + + /** Extensions section: scroll the focused card to its diagnostics block */ + EXTENSIONS_DIAGNOSTICS: { key: "d" }, + + /** Extensions section: open the keyboard cheat sheet */ + EXTENSIONS_CHEATSHEET: { key: "?", shift: true }, } as const; /** diff --git a/src/cli/debug/extensions-install.test.ts b/src/cli/debug/extensions-install.test.ts new file mode 100644 index 0000000000..05bc986804 --- /dev/null +++ b/src/cli/debug/extensions-install.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; + +import { runDebugExtensionInstall } from "./extensions-install"; + +describe("debug extensions-install", () => { + test("installs the coordinate into the configured Mux root and prints JSON", async () => { + const writes: string[] = []; + const result = await runDebugExtensionInstall({ + coordinate: "/repo//ext@main", + muxRootDir: "/tmp/mux-home", + write: (chunk) => { + writes.push(chunk); + }, + install: (input) => + Promise.resolve({ + extensionName: "acme-review", + resolvedSha: "a".repeat(40), + contentHash: "sha256:abc1234567890123456789012345678901234567890", + storePath: `${input.muxRootDir}/extensions/store/hash`, + activePath: `${input.muxRootDir}/extensions/global/acme-review`, + }), + }); + + expect(result.extensionName).toBe("acme-review"); + expect(JSON.parse(writes.join(""))).toEqual({ + extensionName: "acme-review", + resolvedSha: "a".repeat(40), + contentHash: "sha256:abc1234567890123456789012345678901234567890", + storePath: "/tmp/mux-home/extensions/store/hash", + activePath: "/tmp/mux-home/extensions/global/acme-review", + }); + }); +}); diff --git a/src/cli/debug/extensions-install.ts b/src/cli/debug/extensions-install.ts new file mode 100644 index 0000000000..7d435ab46f --- /dev/null +++ b/src/cli/debug/extensions-install.ts @@ -0,0 +1,28 @@ +import { defaultConfig } from "@/node/config"; +import { + installGitExtensionSource, + type InstallGitExtensionSourceInput, + type InstallGitExtensionSourceResult, +} from "@/node/extensions/gitExtensionSourceInstaller"; + +export interface RunDebugExtensionInstallInput { + coordinate: string; + muxRootDir?: string; + write?: (chunk: string) => void; + install?: (input: InstallGitExtensionSourceInput) => Promise; +} + +export async function debugExtensionInstallCommand(coordinate: string): Promise { + await runDebugExtensionInstall({ coordinate }); +} + +export async function runDebugExtensionInstall( + input: RunDebugExtensionInstallInput +): Promise { + const muxRootDir = input.muxRootDir ?? defaultConfig.rootDir; + const install = input.install ?? installGitExtensionSource; + const result = await install({ coordinate: input.coordinate, muxRootDir }); + const write = input.write ?? ((chunk: string) => process.stdout.write(chunk)); + write(`${JSON.stringify(result, null, 2)}\n`); + return result; +} diff --git a/src/cli/debug/extensions.test.ts b/src/cli/debug/extensions.test.ts new file mode 100644 index 0000000000..f74fdca9cd --- /dev/null +++ b/src/cli/debug/extensions.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, test } from "bun:test"; + +import { extensionPermissionKey } from "@/common/extensions/extensionPermissionKey"; +import { hashRequestedPermissions } from "@/common/extensions/permissionCalculator"; +import type { ApprovalRecord } from "@/common/extensions/globalExtensionState"; +import type { ValidatedManifest } from "@/common/extensions/manifestValidator"; +import type { + DiscoveredExtension, + ExtensionRootDescriptor, + RootDiscoveryResult, +} from "@/node/extensions/extensionDiscoveryService"; +import type { DiscoverFn } from "@/node/extensions/extensionRegistryService"; +import { createTestExtensionRegistry } from "@/node/extensions/testExtensionRegistry"; + +import { formatSnapshotForDebug } from "./extensions"; + +const FROZEN_NOW = 1_700_000_000_000; + +const SAMPLE_APPROVAL: ApprovalRecord = { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: hashRequestedPermissions(["skill.register"]), +}; + +function makeManifest( + id: string, + contributions: ReadonlyArray<{ type: string; id: string }> +): ValidatedManifest { + return { + manifestVersion: 1, + id, + requestedPermissions: contributions.map((c) => `${c.type.replace(/s$/, "")}.register`), + contributions: contributions.map((c, index) => ({ + type: c.type, + id: c.id, + index, + descriptor: { descriptorVersion: 1, id: c.id }, + })), + }; +} + +function makeExtension(opts: { + extensionId: string; + rootId: string; + rootKind: ExtensionRootDescriptor["kind"]; + contributions?: ReadonlyArray<{ type: string; id: string }>; +}): DiscoveredExtension { + const contributions = (opts.contributions ?? []).map((c, index) => ({ + type: c.type, + id: c.id, + index, + activated: true, + })); + return { + extensionId: opts.extensionId, + rootId: opts.rootId, + rootKind: opts.rootKind, + isCore: false, + modulePath: `/fake/${opts.extensionId}`, + manifest: makeManifest(opts.extensionId, opts.contributions ?? []), + contributions, + diagnostics: [], + enabled: true, + granted: true, + activated: true, + }; +} + +function discoveryStub(rootResults: readonly RootDiscoveryResult[]): DiscoverFn { + return (input) => + Promise.resolve({ generatedAt: input.now ?? FROZEN_NOW, roots: [...rootResults] }); +} + +describe("debug extensions — formatSnapshotForDebug", () => { + test("cold state: no reload yet → snapshot is null", async () => { + const env = await createTestExtensionRegistry({ + roots: () => [], + now: () => FROZEN_NOW, + }); + try { + // No reload() call: this is the genuine cold-start state. + const out = formatSnapshotForDebug(env.registry.getSnapshot()); + expect(out).toEqual({ generatedAt: null, filterRootId: null, snapshot: null }); + } finally { + await env.cleanup(); + } + }); + + test("post-install: reload populates roots, extensions, contributions, permissions", async () => { + const userGlobalRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + const env = await createTestExtensionRegistry({ + roots: () => [userGlobalRoot], + discoverFn: discoveryStub([ + { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + trusted: true, + rootExists: true, + state: "ready", + extensions: [ + makeExtension({ + extensionId: "author.skill", + rootId: "user-global", + rootKind: "user-global", + contributions: [{ type: "skills", id: "demo" }], + }), + ], + diagnostics: [], + }, + ]), + now: () => FROZEN_NOW, + }); + try { + await env.globalState.setApproval("author.skill", SAMPLE_APPROVAL); + await env.registry.reload(); + const out = formatSnapshotForDebug(env.registry.getSnapshot()); + + expect(out.generatedAt).toBe(FROZEN_NOW); + expect(out.filterRootId).toBeNull(); + expect(out.snapshot).not.toBeNull(); + expect(out.snapshot!.roots).toHaveLength(1); + const root = out.snapshot!.roots[0]; + expect(root.rootId).toBe("user-global"); + expect(root.state).toBe("ready"); + expect(root.extensions).toHaveLength(1); + expect(root.extensions[0]).toMatchObject({ + extensionId: "author.skill", + }); + expect(out.snapshot!.availableContributions).toHaveLength(1); + expect(out.snapshot!.availableContributions[0]).toMatchObject({ + type: "skills", + id: "demo", + extensionId: "author.skill", + }); + // Approval record content is included verbatim — approved capabilities + // are diagnostic, not secret. + const permEntry = + out.snapshot!.permissions[extensionPermissionKey("user-global", "author.skill")]; + if (permEntry == null) throw new Error("missing permissions entry"); + expect(Array.isArray(permEntry.effectivePermissions)).toBe(true); + expect(Array.isArray(permEntry.contributions)).toBe(true); + } finally { + await env.cleanup(); + } + }); + + test("post-failure: discovery yields a failed root surfaces state + diagnostics", async () => { + const userGlobalRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + const env = await createTestExtensionRegistry({ + roots: () => [userGlobalRoot], + discoverFn: discoveryStub([ + { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + trusted: true, + rootExists: true, + state: "failed", + extensions: [], + diagnostics: [ + { + code: "root.discovery.timeout", + severity: "error", + message: "Discovery timed out", + occurredAt: FROZEN_NOW, + }, + ], + }, + ]), + now: () => FROZEN_NOW, + }); + try { + await env.registry.reload(); + const out = formatSnapshotForDebug(env.registry.getSnapshot()); + + expect(out.snapshot!.roots).toHaveLength(1); + expect(out.snapshot!.roots[0].state).toBe("failed"); + expect(out.snapshot!.roots[0].diagnostics).toHaveLength(1); + expect(out.snapshot!.roots[0].diagnostics[0]).toMatchObject({ + code: "root.discovery.timeout", + severity: "error", + }); + expect(out.snapshot!.roots[0].extensions).toHaveLength(0); + } finally { + await env.cleanup(); + } + }); + + test("--root filter narrows to the matching root only", async () => { + const userGlobalRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + const projectRoot: ExtensionRootDescriptor = { + rootId: "project-local:/fake/proj", + kind: "project-local", + path: "/fake/proj/.mux/extensions", + trusted: true, + }; + const env = await createTestExtensionRegistry({ + roots: () => [userGlobalRoot, projectRoot], + discoverFn: discoveryStub([ + { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + trusted: true, + rootExists: true, + state: "ready", + extensions: [ + makeExtension({ + extensionId: "global.skill", + rootId: "user-global", + rootKind: "user-global", + contributions: [{ type: "skills", id: "global-demo" }], + }), + ], + diagnostics: [], + }, + { + rootId: "project-local:/fake/proj", + kind: "project-local", + path: "/fake/proj/.mux/extensions", + trusted: true, + rootExists: true, + state: "ready", + extensions: [ + makeExtension({ + extensionId: "local.skill", + rootId: "project-local:/fake/proj", + rootKind: "project-local", + contributions: [{ type: "skills", id: "local-demo" }], + }), + ], + diagnostics: [], + }, + ]), + now: () => FROZEN_NOW, + }); + try { + await env.registry.reload(); + const out = formatSnapshotForDebug(env.registry.getSnapshot(), { + rootId: "user-global", + }); + + expect(out.filterRootId).toBe("user-global"); + expect(out.snapshot!.roots).toHaveLength(1); + expect(out.snapshot!.roots[0].rootId).toBe("user-global"); + expect(out.snapshot!.descriptors.every((d) => d.rootId === "user-global")).toBe(true); + expect(out.snapshot!.availableContributions.every((c) => c.rootId === "user-global")).toBe( + true + ); + expect(Object.keys(out.snapshot!.permissions)).toEqual([ + extensionPermissionKey("user-global", "global.skill"), + ]); + } finally { + await env.cleanup(); + } + }); + + test("--root filter on unknown rootId yields empty roots without crashing", () => { + const out = formatSnapshotForDebug( + { + generatedAt: FROZEN_NOW, + roots: [], + availableContributions: [], + resolverDiagnostics: [], + descriptors: [], + permissions: {}, + staleRecords: [], + }, + { rootId: "nope" } + ); + expect(out.filterRootId).toBe("nope"); + expect(out.snapshot!.roots).toEqual([]); + }); +}); diff --git a/src/cli/debug/extensions.ts b/src/cli/debug/extensions.ts new file mode 100644 index 0000000000..73294fb393 --- /dev/null +++ b/src/cli/debug/extensions.ts @@ -0,0 +1,93 @@ +/** + * Debug command: print the current Extension Snapshot as JSON. + * + * Usage: bun debug extensions [--root ] + * + * Mirrors ServiceContainer's root discovery wiring so the output matches + * what `extensions.list` would return over IPC, including stale approval + * records derived from `~/.mux/config.json` and Mux-owned project extension + * state under `~/.mux/extensions/project-state`. + * + * Approval records hold an approved capability allowlist and a non-reversible + * capability-set hash — neither is a credential, so the snapshot passes through + * verbatim apart from the optional --root filter. + */ +import { rootIdFromPermissionKey } from "@/common/extensions/extensionPermissionKey"; +import { defaultConfig } from "@/node/config"; +import { + ExtensionRegistry, + type RegistrySnapshot, +} from "@/node/extensions/extensionRegistryService"; +import { createExtensionRootsProvider } from "@/node/extensions/extensionRoots"; +import { GlobalExtensionStateService } from "@/node/extensions/globalExtensionStateService"; +import { + getProjectExtensionStateRoot, + ProjectExtensionStateService, +} from "@/node/extensions/projectExtensionStateService"; + +export interface DebugExtensionsOptions { + rootId?: string; +} + +export interface DebugExtensionsOutput { + generatedAt: number | null; + filterRootId: string | null; + snapshot: RegistrySnapshot | null; +} + +export async function debugExtensionsCommand(options: DebugExtensionsOptions = {}): Promise { + const projectState = new ProjectExtensionStateService( + getProjectExtensionStateRoot(defaultConfig.rootDir) + ); + const registry = new ExtensionRegistry({ + roots: createExtensionRootsProvider({ config: defaultConfig, projectState }), + globalState: new GlobalExtensionStateService(defaultConfig), + projectState, + }); + await registry.reload(); + const output = formatSnapshotForDebug(registry.getSnapshot(), options); + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +} + +export function formatSnapshotForDebug( + snapshot: RegistrySnapshot | null, + options: DebugExtensionsOptions = {} +): DebugExtensionsOutput { + const filterRootId = options.rootId ?? null; + if (snapshot == null) { + return { generatedAt: null, filterRootId, snapshot: null }; + } + if (filterRootId == null) { + return { generatedAt: snapshot.generatedAt, filterRootId, snapshot }; + } + + const matchedRoot = snapshot.roots.find((r) => r.rootId === filterRootId); + const matchedExtensionIds = new Set((matchedRoot?.extensions ?? []).map((e) => e.extensionId)); + + const filteredPermissions: RegistrySnapshot["permissions"] = Object.fromEntries( + Object.entries(snapshot.permissions).filter(([permissionKey]) => { + const rootId = rootIdFromPermissionKey(permissionKey); + if (rootId !== null) return rootId === filterRootId; + return matchedExtensionIds.has(permissionKey); + }) + ); + + const filtered: RegistrySnapshot = { + generatedAt: snapshot.generatedAt, + roots: matchedRoot ? [matchedRoot] : [], + availableContributions: snapshot.availableContributions.filter( + (c) => c.rootId === filterRootId + ), + // Resolver diagnostics that pin to a specific extension travel with that + // extension; global-scope resolver diagnostics (no extensionId) are kept + // so cross-root conflicts remain visible under any --root filter. + resolverDiagnostics: snapshot.resolverDiagnostics.filter( + (d) => d.extensionId == null || matchedExtensionIds.has(d.extensionId) + ), + descriptors: snapshot.descriptors.filter((d) => d.rootId === filterRootId), + permissions: filteredPermissions, + staleRecords: snapshot.staleRecords.filter((s) => s.rootId === filterRootId), + }; + + return { generatedAt: snapshot.generatedAt, filterRootId, snapshot: filtered }; +} diff --git a/src/cli/debug/index.ts b/src/cli/debug/index.ts index 1cd4986049..9190639a15 100644 --- a/src/cli/debug/index.ts +++ b/src/cli/debug/index.ts @@ -4,6 +4,8 @@ import { parseArgs } from "util"; import { listWorkspacesCommand } from "./list-workspaces"; import { costsCommand } from "./costs"; import { sendMessageCommand } from "./send-message"; +import { debugExtensionInstallCommand } from "./extensions-install"; +import { debugExtensionsCommand } from "./extensions"; const { positionals, values } = parseArgs({ args: process.argv.slice(2), @@ -14,6 +16,7 @@ const { positionals, values } = parseArgs({ all: { type: "boolean", short: "a" }, edit: { type: "string", short: "e" }, message: { type: "string", short: "m" }, + root: { type: "string", short: "r" }, }, allowPositionals: true, }); @@ -48,10 +51,26 @@ switch (command) { sendMessageCommand(workspaceId, values.edit, values.message); break; } + case "extensions-install": { + const coordinate = positionals[1]; + if (!coordinate) { + console.error("Error: git coordinate required"); + console.log("Usage: bun debug extensions-install [//subdir]@"); + process.exit(1); + } + await debugExtensionInstallCommand(coordinate); + break; + } + case "extensions": { + await debugExtensionsCommand({ rootId: values.root }); + break; + } default: console.log("Usage:"); console.log(" bun debug list-workspaces"); console.log(" bun debug costs "); console.log(" bun debug send-message [--edit ] [--message ]"); + console.log(" bun debug extensions-install [//subdir]@"); + console.log(" bun debug extensions [--root ]"); process.exit(1); } diff --git a/src/cli/extensions.test.ts b/src/cli/extensions.test.ts new file mode 100644 index 0000000000..d430a76969 --- /dev/null +++ b/src/cli/extensions.test.ts @@ -0,0 +1,155 @@ +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; + +import { describe, expect, test } from "bun:test"; + +import { runExtensionsCommand, runInstallAliasCommand } from "./extensions"; + +describe("mux extensions", () => { + test("prints extension command help", async () => { + const writes: string[] = []; + const result = await runExtensionsCommand({ + args: ["--help"], + write: (chunk) => writes.push(chunk), + }); + + expect(result).toEqual({ type: "help" }); + expect(writes.join("")).toContain("Usage: mux extensions"); + }); +}); + +describe("mux extensions install", () => { + test("prints install help without installing", async () => { + const writes: string[] = []; + const result = await runExtensionsCommand({ + args: ["install", "--help"], + write: (chunk) => writes.push(chunk), + install: () => { + throw new Error("install should not run for help"); + }, + }); + + expect(result).toEqual({ type: "help" }); + expect(writes.join("")).toContain("Usage: mux extensions install"); + }); + + test("installs a git coordinate into the configured Mux root and prints JSON", async () => { + const writes: string[] = []; + const result = await runExtensionsCommand({ + args: ["install", "/repo//ext@main"], + muxRootDir: "/tmp/mux-home", + write: (chunk) => writes.push(chunk), + install: (input) => + Promise.resolve({ + extensionName: "acme-review", + resolvedSha: "a".repeat(40), + contentHash: "sha256:abc1234567890123456789012345678901234567890", + storePath: `${input.muxRootDir}/extensions/store/hash`, + activePath: `${input.muxRootDir}/extensions/global/acme-review`, + }), + }); + + expect(result).toEqual({ + extensionName: "acme-review", + resolvedSha: "a".repeat(40), + contentHash: "sha256:abc1234567890123456789012345678901234567890", + storePath: "/tmp/mux-home/extensions/store/hash", + activePath: "/tmp/mux-home/extensions/global/acme-review", + }); + expect(JSON.parse(writes.join(""))).toEqual(result); + }); +}); + +describe("mux install", () => { + test("prints alias help without installing", async () => { + const writes: string[] = []; + const result = await runInstallAliasCommand({ + args: ["--help"], + write: (chunk) => writes.push(chunk), + install: () => { + throw new Error("install should not run for help"); + }, + }); + + expect(result).toEqual({ type: "help" }); + expect(writes.join("")).toContain("Usage: mux install"); + }); + + test("aliases mux extensions install", async () => { + const writes: string[] = []; + const result = await runInstallAliasCommand({ + args: ["/repo//ext@main"], + muxRootDir: "/tmp/mux-home", + write: (chunk) => writes.push(chunk), + install: (input) => + Promise.resolve({ + extensionName: "acme-review", + resolvedSha: "b".repeat(40), + contentHash: "sha256:def1234567890123456789012345678901234567890", + storePath: `${input.muxRootDir}/extensions/store/hash`, + activePath: `${input.muxRootDir}/extensions/global/acme-review`, + }), + }); + + expect(result).toEqual({ + extensionName: "acme-review", + resolvedSha: "b".repeat(40), + contentHash: "sha256:def1234567890123456789012345678901234567890", + storePath: "/tmp/mux-home/extensions/store/hash", + activePath: "/tmp/mux-home/extensions/global/acme-review", + }); + expect(JSON.parse(writes.join(""))).toEqual(result); + }); +}); + +describe("mux extensions create", () => { + test("prints create help without scaffolding", async () => { + const writes: string[] = []; + const result = await runExtensionsCommand({ + args: ["create", "--help"], + write: (chunk) => writes.push(chunk), + }); + + expect(result).toEqual({ type: "help" }); + expect(writes.join("")).toContain("Usage: mux extensions create"); + }); + + test("scaffolds an editable local Extension Module", async () => { + const muxRootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-extensions-create-")); + try { + const writes: string[] = []; + const result = await runExtensionsCommand({ + args: ["create", "acme-review"], + muxRootDir, + write: (chunk) => writes.push(chunk), + }); + + expect(result).toEqual({ + extensionName: "acme-review", + modulePath: path.join(muxRootDir, "extensions", "local", "acme-review"), + entrypointPath: path.join(muxRootDir, "extensions", "local", "acme-review", "extension.ts"), + skillPath: path.join( + muxRootDir, + "extensions", + "local", + "acme-review", + "skills", + "acme-review", + "SKILL.md" + ), + }); + expect(JSON.parse(writes.join(""))).toEqual(result); + if (!("entrypointPath" in result)) throw new Error("expected create result"); + const entrypointStat = await fs.stat(result.entrypointPath); + expect(entrypointStat.isFile()).toBe(true); + const entrypoint = await fs.readFile(result.entrypointPath, "utf-8"); + expect(entrypoint).toContain('name: "acme-review"'); + expect(entrypoint).toContain('bodyPath: "./skills/acme-review/SKILL.md"'); + const skill = await fs.readFile(result.skillPath, "utf-8"); + expect(skill).toContain("name: acme-review"); + } finally { + await fs.rm(muxRootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/cli/extensions.ts b/src/cli/extensions.ts new file mode 100644 index 0000000000..c4fd2aae8c --- /dev/null +++ b/src/cli/extensions.ts @@ -0,0 +1,186 @@ +import * as fs from "fs/promises"; +import * as path from "path"; + +import { ExtensionNameSchema } from "@/common/orpc/schemas/extension"; +import { defaultConfig } from "@/node/config"; +import { + installGitExtensionSource, + type InstallGitExtensionSourceInput, + type InstallGitExtensionSourceResult, +} from "@/node/extensions/gitExtensionSourceInstaller"; + +import { getArgsAfterSplice } from "./argv"; + +export interface CreateLocalExtensionModuleResult { + extensionName: string; + modulePath: string; + entrypointPath: string; + skillPath: string; +} + +export interface InstallHelpResult { + type: "help"; +} + +export type RunExtensionsCommandResult = + | InstallGitExtensionSourceResult + | CreateLocalExtensionModuleResult + | InstallHelpResult; + +export interface RunExtensionsCommandInput { + args: readonly string[]; + muxRootDir?: string; + write?: (chunk: string) => void; + install?: (input: InstallGitExtensionSourceInput) => Promise; +} + +const EXTENSIONS_HELP = `Usage: mux extensions [//subdir]@ | create > +`; +const EXTENSIONS_INSTALL_HELP = `Usage: mux extensions install [//subdir]@ +`; +const EXTENSIONS_CREATE_HELP = `Usage: mux extensions create +`; + +function isHelpArg(value: string | undefined): boolean { + return value === "--help" || value === "-h"; +} + +export async function runExtensionsCommand( + input: RunExtensionsCommandInput +): Promise { + const [command, value] = input.args; + const muxRootDir = input.muxRootDir ?? defaultConfig.rootDir; + const write = input.write ?? ((chunk: string) => process.stdout.write(chunk)); + + if (isHelpArg(command)) { + write(EXTENSIONS_HELP); + return { type: "help" }; + } + + if (command === "install") { + if (isHelpArg(value)) { + write(EXTENSIONS_INSTALL_HELP); + return { type: "help" }; + } + if (value == null || value.trim() === "") { + throw new Error("Git extension coordinate required."); + } + const install = input.install ?? installGitExtensionSource; + const result = await install({ coordinate: value, muxRootDir }); + write(`${JSON.stringify(result, null, 2)}\n`); + return result; + } + + if (command === "create") { + if (isHelpArg(value)) { + write(EXTENSIONS_CREATE_HELP); + return { type: "help" }; + } + if (value == null || value.trim() === "") { + throw new Error("Extension Name required."); + } + const result = await createLocalExtensionModule({ extensionName: value, muxRootDir }); + write(`${JSON.stringify(result, null, 2)}\n`); + return result; + } + + throw new Error( + "Usage: mux extensions [//subdir]@ | create >" + ); +} + +async function createLocalExtensionModule(input: { + extensionName: string; + muxRootDir: string; +}): Promise { + const parsedName = ExtensionNameSchema.safeParse(input.extensionName); + if (!parsedName.success) { + throw new Error(`Invalid Extension Name: ${parsedName.error.message}`); + } + + const extensionName = parsedName.data; + const localRoot = path.join(input.muxRootDir, "extensions", "local"); + const modulePath = path.join(localRoot, extensionName); + const skillDir = path.join(modulePath, "skills", extensionName); + const entrypointPath = path.join(modulePath, "extension.ts"); + const skillPath = path.join(skillDir, "SKILL.md"); + + await fs.mkdir(localRoot, { recursive: true }); + await fs.mkdir(modulePath); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(entrypointPath, extensionEntrypointTemplate(extensionName), { flag: "wx" }); + await fs.writeFile(skillPath, skillTemplate(extensionName), { flag: "wx" }); + + return { extensionName, modulePath, entrypointPath, skillPath }; +} + +function extensionEntrypointTemplate(extensionName: string): string { + return `import { defineManifest } from "mux:extensions"; + +export const manifest = defineManifest({ + name: "${extensionName}", + displayName: "${extensionName}", + description: "Describe what this extension contributes.", + capabilities: { + skills: true, + }, +}); + +export function activate(ctx) { + ctx.skills.register({ + name: "${extensionName}", + bodyPath: "./skills/${extensionName}/SKILL.md", + }); +} +`; +} + +function skillTemplate(extensionName: string): string { + return `--- +name: ${extensionName} +description: Describe when to use this extension skill. +--- + +# ${extensionName} + +Write instructions for the agent here. +`; +} + +const INSTALL_ALIAS_HELP = `Usage: mux install [//subdir]@ + +Alias for: mux extensions install [//subdir]@ +`; + +export async function runInstallAliasCommand( + input: RunExtensionsCommandInput +): Promise { + const [firstArg] = input.args; + if (isHelpArg(firstArg)) { + const write = input.write ?? ((chunk: string) => process.stdout.write(chunk)); + write(INSTALL_ALIAS_HELP); + return { type: "help" }; + } + return runExtensionsCommand({ + ...input, + args: ["install", ...input.args], + }); +} + +export async function installAliasCommandMain(): Promise { + try { + await runInstallAliasCommand({ args: getArgsAfterSplice() }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +export async function extensionsCommandMain(): Promise { + try { + await runExtensionsCommand({ args: getArgsAfterSplice() }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 8616890091..f25a92c39e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -25,6 +25,10 @@ process.umask(0o077); */ import { Command } from "commander"; import { VERSION } from "../version"; +import type { + extensionsCommandMain as importedExtensionsCommandMain, + installAliasCommandMain as importedInstallAliasCommandMain, +} from "./extensions"; import { CLI_GLOBAL_FLAGS, detectCliEnvironment, @@ -68,6 +72,26 @@ if (subcommand === "run") { // The .mjs extension is critical for Node.js to treat it as ESM. // eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call void new Function("return import('./api.mjs')")(); +} else if (subcommand === "install") { + process.argv.splice(env.firstArgIndex, 1); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { installAliasCommandMain } = require("./extensions") as { + installAliasCommandMain: typeof importedInstallAliasCommandMain; + }; + Promise.resolve(installAliasCommandMain()).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} else if (subcommand === "extensions") { + process.argv.splice(env.firstArgIndex, 1); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { extensionsCommandMain } = require("./extensions") as { + extensionsCommandMain: typeof importedExtensionsCommandMain; + }; + Promise.resolve(extensionsCommandMain()).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); } else if ( subcommand === "desktop" || (env.isElectron && (subcommand === undefined || isElectronLaunchArg(subcommand, env))) @@ -115,9 +139,11 @@ if (subcommand === "run") { if (isCommandAvailable("run", env)) { program.command("run").description("Run a one-off agent task"); } + program.command("install ").description("Install a Mux Extension Module"); program.command("server").description("Start the HTTP/WebSocket ORPC server"); program.command("acp").description("ACP stdio interface for editor integration"); program.command("api").description("Interact with the mux API via a running server"); + program.command("extensions").description("Manage Mux Extension Modules"); if (isCommandAvailable("desktop", env)) { program .command("desktop") diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index 3c05ad233a..d3216b524e 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -132,6 +132,10 @@ export const AppConfigOnDiskSchema = z runtimeEnablement: RuntimeEnablementOverridesSchema.optional(), defaultRuntime: RuntimeEnablementIdSchema.optional(), onePasswordAccountName: z.string().optional(), + // Raw extensions block. The Global Extension State Store (US-008) owns + // schema validation + self-healing for this block; Config persists the + // value verbatim so unknown future schemaVersions are preserved on disk. + extensions: z.unknown().optional(), }) .passthrough(); diff --git a/src/common/extensions/approvalDrift.ts b/src/common/extensions/approvalDrift.ts new file mode 100644 index 0000000000..fd147e7246 --- /dev/null +++ b/src/common/extensions/approvalDrift.ts @@ -0,0 +1,9 @@ +import type { CalculatePermissionsResult } from "./permissionCalculator"; + +export function requiresReapproval( + permissions: CalculatePermissionsResult | null | undefined +): boolean { + if (!permissions || permissions.driftStatus === "fresh") return false; + if (permissions.pendingNew.length > 0) return true; + return permissions.driftStatus === "permissions-changed"; +} diff --git a/src/common/extensions/conflictResolver.test.ts b/src/common/extensions/conflictResolver.test.ts new file mode 100644 index 0000000000..b3e29da37f --- /dev/null +++ b/src/common/extensions/conflictResolver.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, test } from "bun:test"; +import { + resolveConflicts, + type CandidateExtension, + type ResolveConflictsInput, +} from "./conflictResolver"; + +const FROZEN_NOW = 1_700_000_000_000; + +function input(candidates: CandidateExtension[], overrides: Partial = {}) { + return { candidates, now: FROZEN_NOW, ...overrides }; +} + +describe("resolveConflicts — no conflicts", () => { + test("disjoint extensions and contributions all become available with no diagnostics", () => { + const result = resolveConflicts( + input([ + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "foo-skill" }], + }, + { + extensionId: "publisher.bar", + rootKind: "project-local", + rootId: "project-local:/repo", + contributions: [{ type: "agents", id: "bar-agent" }], + }, + ]) + ); + expect(result.diagnostics).toEqual([]); + expect(result.availableContributions).toEqual([ + { + type: "skills", + id: "foo-skill", + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + }, + { + type: "agents", + id: "bar-agent", + extensionId: "publisher.bar", + rootKind: "project-local", + rootId: "project-local:/repo", + }, + ]); + }); +}); + +describe("resolveConflicts — Extension Identity Conflict", () => { + test("project-local identity shadowing keeps user-global identity available for other projects", () => { + const result = resolveConflicts( + input([ + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "global-skill" }], + }, + { + extensionId: "publisher.foo", + rootKind: "project-local", + rootId: "project-local:/repo", + contributions: [{ type: "skills", id: "project-skill" }], + }, + ]) + ); + + expect(result.availableContributions.map((c) => `${c.rootId}:${c.id}`).sort()).toEqual([ + "project-local:/repo:project-skill", + "user-global:global-skill", + ]); + }); + + test("same extension identity in different project-local roots is scoped per project", () => { + const result = resolveConflicts( + input([ + { + extensionId: "publisher.foo", + rootKind: "project-local", + rootId: "project-local:/repo-a", + contributions: [{ type: "skills", id: "a" }], + }, + { + extensionId: "publisher.foo", + rootKind: "project-local", + rootId: "project-local:/repo-b", + contributions: [{ type: "skills", id: "b" }], + }, + ]) + ); + + expect(result.diagnostics).toEqual([]); + expect(result.availableContributions.map((c) => `${c.rootId}:${c.id}`).sort()).toEqual([ + "project-local:/repo-a:a", + "project-local:/repo-b:b", + ]); + }); + + test("identity tie at the same precedence level yields zero contributions from any candidate", () => { + const result = resolveConflicts( + input([ + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "a" }], + }, + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "b" }], + }, + ]) + ); + expect(result.availableContributions).toEqual([]); + expect(result.diagnostics.filter((d) => d.code === "extension.identity.conflict")).toHaveLength( + 2 + ); + }); + + test("Core Extension wins identity conflict over higher-precedence non-core root", () => { + // Core Extension contributions cannot be shadowed even by a project-local + // root that would otherwise outrank a bundled root. + const result = resolveConflicts( + input([ + { + extensionId: "mux.platformdemo", + rootKind: "bundled", + rootId: "bundled", + isCore: true, + contributions: [{ type: "skills", id: "core-skill" }], + }, + { + extensionId: "mux.platformdemo", + rootKind: "project-local", + rootId: "project-local:/repo", + contributions: [{ type: "skills", id: "squatter-skill" }], + }, + ]) + ); + expect(result.availableContributions).toEqual([ + { + type: "skills", + id: "core-skill", + extensionId: "mux.platformdemo", + rootKind: "bundled", + rootId: "bundled", + }, + ]); + expect(result.diagnostics.filter((d) => d.code === "extension.identity.conflict")).toHaveLength( + 2 + ); + }); + + test("non-conflicting extensions still produce their contributions even when others conflict", () => { + const result = resolveConflicts( + input([ + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "foo-a" }], + }, + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "foo-b" }], + }, + { + extensionId: "publisher.bar", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "bar-a" }], + }, + ]) + ); + expect(result.availableContributions).toEqual([ + { + type: "skills", + id: "bar-a", + extensionId: "publisher.bar", + rootKind: "user-global", + rootId: "user-global", + }, + ]); + }); +}); + +describe("resolveConflicts — Contribution Identity Conflict", () => { + test("user-global and project-local contribution identities resolve in separate project scopes", () => { + const result = resolveConflicts( + input([ + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "shared-skill" }], + }, + { + extensionId: "publisher.bar", + rootKind: "project-local", + rootId: "project-local:/repo", + contributions: [{ type: "skills", id: "shared-skill" }], + }, + ]) + ); + + expect(result.diagnostics).toEqual([]); + expect(result.availableContributions.map((c) => `${c.rootId}:${c.id}`).sort()).toEqual([ + "project-local:/repo:shared-skill", + "user-global:shared-skill", + ]); + }); + + test("same contribution identity in different project-local roots is scoped per project", () => { + const result = resolveConflicts( + input([ + { + extensionId: "publisher.foo-a", + rootKind: "project-local", + rootId: "project-local:/repo-a", + contributions: [{ type: "skills", id: "shared-skill" }], + }, + { + extensionId: "publisher.foo-b", + rootKind: "project-local", + rootId: "project-local:/repo-b", + contributions: [{ type: "skills", id: "shared-skill" }], + }, + ]) + ); + + expect(result.diagnostics).toEqual([]); + expect(result.availableContributions.map((c) => `${c.rootId}:${c.id}`).sort()).toEqual([ + "project-local:/repo-a:shared-skill", + "project-local:/repo-b:shared-skill", + ]); + }); + + test("contribution-id collision tied at same precedence drops both contributions", () => { + const result = resolveConflicts( + input([ + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "shared-skill" }], + }, + { + extensionId: "publisher.bar", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "shared-skill" }], + }, + ]) + ); + expect(result.availableContributions).toEqual([]); + expect( + result.diagnostics.filter((d) => d.code === "contribution.identity.conflict") + ).toHaveLength(2); + }); + + test("collision across different contribution types is NOT a conflict (different namespaces)", () => { + const result = resolveConflicts( + input([ + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "x" }], + }, + { + extensionId: "publisher.bar", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "agents", id: "x" }], + }, + ]) + ); + expect(result.diagnostics).toEqual([]); + expect(result.availableContributions).toHaveLength(2); + }); + + test("Core Extension contribution cannot be shadowed by a contribution-id collision from any other root", () => { + const result = resolveConflicts( + input([ + { + extensionId: "mux.platformdemo", + rootKind: "bundled", + rootId: "bundled", + isCore: true, + contributions: [{ type: "skills", id: "shared-skill" }], + }, + { + extensionId: "publisher.squatter", + rootKind: "project-local", + rootId: "project-local:/repo", + contributions: [{ type: "skills", id: "shared-skill" }], + }, + ]) + ); + // Core Extension's contribution survives; squatter's is dropped. + expect(result.availableContributions).toEqual([ + { + type: "skills", + id: "shared-skill", + extensionId: "mux.platformdemo", + rootKind: "bundled", + rootId: "bundled", + }, + ]); + expect( + result.diagnostics.filter((d) => d.code === "contribution.identity.conflict") + ).toHaveLength(2); + }); + + test("a candidate dropped by extension-identity conflict does not contribute to contribution-id conflicts downstream", () => { + // publisher.foo at user-global is dropped by the identity conflict; its + // would-be contribution `shared-skill` therefore must NOT appear in the + // contribution-id conflict against publisher.bar's `shared-skill`. + const result = resolveConflicts( + input([ + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "shared-skill" }], + }, + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "alt-skill" }], + }, + { + extensionId: "publisher.bar", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "shared-skill" }], + }, + ]) + ); + expect( + result.diagnostics.filter((d) => d.code === "contribution.identity.conflict") + ).toHaveLength(0); + expect( + result.availableContributions.find( + (c) => c.id === "shared-skill" && c.extensionId === "publisher.bar" + ) + ).toBeDefined(); + }); +}); + +describe("resolveConflicts — diagnostic record shape", () => { + test("every diagnostic carries code, severity, message, and occurredAt", () => { + const result = resolveConflicts( + input( + [ + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "x" }], + }, + { + extensionId: "publisher.foo", + rootKind: "user-global", + rootId: "user-global", + contributions: [{ type: "skills", id: "x" }], + }, + ], + { now: 42 } + ) + ); + expect(result.diagnostics.length).toBeGreaterThan(0); + for (const d of result.diagnostics) { + expect(typeof d.code).toBe("string"); + expect(["error", "warn", "info"]).toContain(d.severity); + expect(typeof d.message).toBe("string"); + expect(d.occurredAt).toBe(42); + } + }); +}); diff --git a/src/common/extensions/conflictResolver.ts b/src/common/extensions/conflictResolver.ts new file mode 100644 index 0000000000..313a66a0a3 --- /dev/null +++ b/src/common/extensions/conflictResolver.ts @@ -0,0 +1,178 @@ +import type { ExtensionDiagnostic, RootKind } from "./manifestValidator"; + +export interface CandidateContribution { + type: string; + id: string; +} + +export interface CandidateExtension { + extensionId: string; + rootId: string; + rootKind: RootKind; + /** Bundled-only flag. A Core Extension's contributions cannot be shadowed. */ + isCore?: boolean; + contributions: CandidateContribution[]; +} + +export interface AvailableContribution { + type: string; + id: string; + extensionId: string; + rootId: string; + rootKind: RootKind; +} + +export interface ResolveConflictsInput { + candidates: readonly CandidateExtension[]; + /** Override the diagnostic timestamp for deterministic tests. */ + now?: number; +} + +export interface ResolveConflictsResult { + availableContributions: AvailableContribution[]; + diagnostics: ExtensionDiagnostic[]; +} + +// Core Extension contributions cannot be shadowed; otherwise project-local +// outranks user-global, and user-global outranks non-core bundled. +function precedenceScore(c: CandidateExtension): number { + if (c.rootKind === "bundled" && c.isCore) return 4; + if (c.rootKind === "project-local") return 3; + if (c.rootKind === "user-global") return 2; + return 1; +} + +function uniqueRootList(group: readonly CandidateExtension[]): string { + return Array.from(new Set(group.map((c) => c.rootKind))).join(", "); +} + +function projectScopeKey(candidate: { rootId: string; rootKind: RootKind }): string { + return candidate.rootKind === "project-local" ? `project:${candidate.rootId}` : "global"; +} + +function splitByProjectScope( + group: readonly T[], + getCandidate: (item: T) => { rootId: string; rootKind: RootKind } +): T[][] { + const byScope = new Map(); + for (const item of group) { + const key = projectScopeKey(getCandidate(item)); + const list = byScope.get(key) ?? []; + list.push(item); + byScope.set(key, list); + } + return Array.from(byScope.values()); +} + +export function resolveConflicts(input: ResolveConflictsInput): ResolveConflictsResult { + const occurredAt = input.now ?? Date.now(); + const diagnostics: ExtensionDiagnostic[] = []; + + const byExtensionId = new Map(); + for (const c of input.candidates) { + const list = byExtensionId.get(c.extensionId) ?? []; + list.push(c); + byExtensionId.set(c.extensionId, list); + } + + // Survivors of the Extension Identity Conflict pass; their contributions + // proceed to contribution-level resolution. Losers' contributions are dropped. + const survivors: CandidateExtension[] = []; + for (const [extensionId, group] of byExtensionId) { + if (group.length === 1) { + survivors.push(group[0]); + continue; + } + const hasCore = group.some((candidate) => candidate.rootKind === "bundled" && candidate.isCore); + const identityGroups = hasCore ? [group] : splitByProjectScope(group, (candidate) => candidate); + for (const identityGroup of identityGroups) { + if (identityGroup.length === 1) { + survivors.push(identityGroup[0]); + continue; + } + const roots = uniqueRootList(identityGroup); + // One diagnostic per affected candidate so the per-card surfacing path can + // attach it to each conflicting Extension; the message names every involved + // root so the user can resolve without expanding cards. + for (const c of identityGroup) { + diagnostics.push({ + code: "extension.identity.conflict", + severity: "error", + message: `Extension identity "${extensionId}" from ${c.rootKind} conflicts with claims from: ${roots}.`, + rootId: c.rootId, + extensionId, + occurredAt, + }); + } + // Highest precedence wins; a tie at the top drops every conflicting + // candidate so neither side silently shadows the other. + const top = Math.max(...identityGroup.map(precedenceScore)); + const winners = identityGroup.filter((c) => precedenceScore(c) === top); + if (winners.length === 1) { + survivors.push(winners[0]); + } + } + } + + // Contribution Identity scope is per-type, so `skills/foo` and `agents/foo` + // do not collide; the key embeds both. The "::" separator is unreachable + // by the kebab-case ContributionIdSchema so it cannot occur inside a real id. + interface Owned { + contribution: CandidateContribution; + owner: CandidateExtension; + } + const byContributionKey = new Map(); + for (const owner of survivors) { + for (const contribution of owner.contributions) { + const key = `${contribution.type}::${contribution.id}`; + const list = byContributionKey.get(key) ?? []; + list.push({ contribution, owner }); + byContributionKey.set(key, list); + } + } + + function toAvailable({ contribution, owner }: Owned): AvailableContribution { + return { + type: contribution.type, + id: contribution.id, + extensionId: owner.extensionId, + rootId: owner.rootId, + rootKind: owner.rootKind, + }; + } + + const availableContributions: AvailableContribution[] = []; + for (const group of byContributionKey.values()) { + const hasCore = group.some((item) => item.owner.rootKind === "bundled" && item.owner.isCore); + const contributionGroups = hasCore ? [group] : splitByProjectScope(group, (item) => item.owner); + + for (const contributionGroup of contributionGroups) { + if (contributionGroup.length === 1) { + availableContributions.push(toAvailable(contributionGroup[0])); + continue; + } + const { type, id } = contributionGroup[0].contribution; + const claimants = contributionGroup + .map((g) => `${g.owner.extensionId}@${g.owner.rootKind}`) + .join(", "); + for (const { owner } of contributionGroup) { + diagnostics.push({ + code: "contribution.identity.conflict", + severity: "warn", + message: `Contribution "${type}/${id}" claimed by multiple Extensions: ${claimants}.`, + rootId: owner.rootId, + extensionId: owner.extensionId, + contributionRef: { type, id }, + occurredAt, + }); + } + const top = Math.max(...contributionGroup.map((g) => precedenceScore(g.owner))); + const winners = contributionGroup.filter((g) => precedenceScore(g.owner) === top); + if (winners.length === 1) { + availableContributions.push(toAvailable(winners[0])); + } + } + } + + return { availableContributions, diagnostics }; +} diff --git a/src/common/extensions/extensionPermissionKey.ts b/src/common/extensions/extensionPermissionKey.ts new file mode 100644 index 0000000000..0796702255 --- /dev/null +++ b/src/common/extensions/extensionPermissionKey.ts @@ -0,0 +1,15 @@ +const EXTENSION_PERMISSION_KEY_SEPARATOR = "\0"; + +export function extensionPermissionKey(rootId: string, extensionId: string): string { + return `${rootId}${EXTENSION_PERMISSION_KEY_SEPARATOR}${extensionId}`; +} + +export function extensionIdFromPermissionKey(key: string): string { + const separatorIndex = key.lastIndexOf(EXTENSION_PERMISSION_KEY_SEPARATOR); + return separatorIndex === -1 ? key : key.slice(separatorIndex + 1); +} + +export function rootIdFromPermissionKey(key: string): string | null { + const separatorIndex = key.lastIndexOf(EXTENSION_PERMISSION_KEY_SEPARATOR); + return separatorIndex === -1 ? null : key.slice(0, separatorIndex); +} diff --git a/src/common/extensions/extensionSkillSource.ts b/src/common/extensions/extensionSkillSource.ts new file mode 100644 index 0000000000..d627b36015 --- /dev/null +++ b/src/common/extensions/extensionSkillSource.ts @@ -0,0 +1,8 @@ +export interface ExtensionSkillSource { + name: string; + displayName: string; + description: string; + advertise: boolean; + bodyAbsolutePath: string; + extensionId: string; +} diff --git a/src/common/extensions/extensionTelemetry.test.ts b/src/common/extensions/extensionTelemetry.test.ts new file mode 100644 index 0000000000..80a77947eb --- /dev/null +++ b/src/common/extensions/extensionTelemetry.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, test } from "bun:test"; + +import { + EXTENSION_TELEMETRY_EVENTS, + EXTENSION_TELEMETRY_FIELD_ALLOWLIST, + type ExtensionTelemetryEventName, + type ExtensionTelemetryProvenance, + gateExtensionTelemetryEvent, +} from "./extensionTelemetry"; +import type { RootKind } from "./manifestValidator"; + +const BUNDLED: ExtensionTelemetryProvenance = { rootKind: "bundled" }; +const USER_GLOBAL: ExtensionTelemetryProvenance = { rootKind: "user-global" }; +const PROJECT_LOCAL: ExtensionTelemetryProvenance = { rootKind: "project-local" }; + +const SCALAR_FIELD_VALUES: Record = { + rootKind: "bundled", + diagnosticCode: "extension.identity.invalid", + severity: "warn", + reason: "appVersion", +}; + +const FORBIDDEN_FIELDS: Record = { + projectPath: "/home/user/secret-project", + packageName: "@scope/super-secret-package", + requestedPermissions: ["network", "skill.register"], + filePath: "/etc/passwd", + lockfileContents: "lockfile-data", +}; + +// Identifier fields are seeded with values matching the reserved prefix so +// the test can prove the rootKind gate (not the regex gate) rejects them +// under non-bundled provenance. +function buildRichProperties(event: ExtensionTelemetryEventName): Record { + const allowlist = EXTENSION_TELEMETRY_FIELD_ALLOWLIST[event]; + const properties: Record = { ...FORBIDDEN_FIELDS }; + for (const [field, kind] of Object.entries(allowlist)) { + if (kind === "identifier") { + properties[field] = field === "contributionId" ? "mux.platform.demo-skill" : "mux.demo"; + } else if (field in SCALAR_FIELD_VALUES) { + properties[field] = SCALAR_FIELD_VALUES[field]; + } else if (field.toLowerCase().includes("count") || field === "durationMs") { + properties[field] = 42; + } else { + properties[field] = true; + } + } + return properties; +} + +describe("gateExtensionTelemetryEvent — allowlist", () => { + test("drops fields outside the per-event allowlist regardless of provenance", () => { + const result = gateExtensionTelemetryEvent({ + event: "extensions.discovery.completed", + properties: { + durationMs: 100, + rootCount: 2, + // Forbidden: + projectPath: "/home/user/secret", + packageName: "@scope/pkg", + requestedPermissions: ["network"], + filePath: "/etc/passwd", + lockfileContents: "lock data", + unknownField: "anything", + }, + provenance: BUNDLED, + }); + expect(result.properties).toEqual({ durationMs: 100, rootCount: 2 }); + }); + + test("preserves scalar values (numbers, booleans, status enum strings)", () => { + const result = gateExtensionTelemetryEvent({ + event: "extensions.discovery.failed", + properties: { rootKind: "user-global", diagnosticCode: "extension.missing", durationMs: 0 }, + provenance: BUNDLED, + }); + expect(result.properties).toEqual({ + rootKind: "user-global", + diagnosticCode: "extension.missing", + durationMs: 0, + }); + }); + + test("drops scalar fields with non-primitive values", () => { + const result = gateExtensionTelemetryEvent({ + event: "extensions.discovery.completed", + properties: { + durationMs: 100, + rootCount: { sneaky: "object" }, + extensionCount: ["array", "not", "allowed"], + }, + provenance: BUNDLED, + }); + expect(result.properties).toEqual({ durationMs: 100 }); + }); +}); + +describe("gateExtensionTelemetryEvent — identifier gates", () => { + test("emits identifier when both gates pass (mux.* + bundled)", () => { + const result = gateExtensionTelemetryEvent({ + event: "extensions.approval.recorded", + properties: { extensionId: "mux.platform.demo", rootKind: "bundled", capabilityCount: 3 }, + provenance: BUNDLED, + }); + expect(result.properties).toEqual({ + extensionId: "mux.platform.demo", + rootKind: "bundled", + capabilityCount: 3, + }); + }); + + test("strips identifier when value matches mux.* but rootKind is user-global (third-party squatter)", () => { + const result = gateExtensionTelemetryEvent({ + event: "extensions.approval.recorded", + properties: { extensionId: "mux.evil", rootKind: "user-global", capabilityCount: 1 }, + provenance: USER_GLOBAL, + }); + expect(result.properties).toEqual({ rootKind: "user-global", capabilityCount: 1 }); + expect(result.properties.extensionId).toBeUndefined(); + }); + + test("strips identifier when value matches mux.* but rootKind is project-local", () => { + const result = gateExtensionTelemetryEvent({ + event: "extensions.enabled.toggled", + properties: { extensionId: "mux.platform.demo", rootKind: "project-local", enabled: true }, + provenance: PROJECT_LOCAL, + }); + expect(result.properties.extensionId).toBeUndefined(); + expect(result.properties).toEqual({ rootKind: "project-local", enabled: true }); + }); + + test("strips identifier when rootKind is bundled but value does not match mux.* (third-party id smuggled in)", () => { + const result = gateExtensionTelemetryEvent({ + event: "extensions.approval.recorded", + properties: { extensionId: "evil.demo", rootKind: "bundled", capabilityCount: 1 }, + provenance: BUNDLED, + }); + expect(result.properties.extensionId).toBeUndefined(); + expect(result.properties).toEqual({ rootKind: "bundled", capabilityCount: 1 }); + }); + + test("strips identifier when value is the literal `muxbar` (not a mux. namespace)", () => { + // `muxbar` is not in the reserved namespace — only `mux` (bare) or `mux.*`. + const result = gateExtensionTelemetryEvent({ + event: "extensions.migration.activated", + properties: { extensionId: "muxbar", durationMs: 5 }, + provenance: BUNDLED, + }); + expect(result.properties.extensionId).toBeUndefined(); + expect(result.properties).toEqual({ durationMs: 5 }); + }); + + test("emits bare 'mux' as a valid reserved identity (matches ^mux(\\..*)?$)", () => { + const result = gateExtensionTelemetryEvent({ + event: "extensions.migration.activated", + properties: { extensionId: "mux", durationMs: 5 }, + provenance: BUNDLED, + }); + expect(result.properties).toEqual({ extensionId: "mux", durationMs: 5 }); + }); + + test("strips identifier when value is non-string", () => { + const result = gateExtensionTelemetryEvent({ + event: "extensions.approval.recorded", + properties: { extensionId: 42, rootKind: "bundled" }, + provenance: BUNDLED, + }); + expect(result.properties.extensionId).toBeUndefined(); + expect(result.properties).toEqual({ rootKind: "bundled" }); + }); +}); + +describe("gateExtensionTelemetryEvent — per-event security regression for v1 catalog", () => { + const NON_BUNDLED_PROVENANCES: ReadonlyArray<{ name: string; rootKind: RootKind }> = [ + { name: "user-global", rootKind: "user-global" }, + { name: "project-local", rootKind: "project-local" }, + ]; + + for (const event of EXTENSION_TELEMETRY_EVENTS) { + for (const { name, rootKind } of NON_BUNDLED_PROVENANCES) { + test(`event ${event} drops identifier fields under rootKind=${name}`, () => { + const result = gateExtensionTelemetryEvent({ + event, + properties: buildRichProperties(event), + provenance: { rootKind }, + }); + + expect(result.properties.extensionId).toBeUndefined(); + expect(result.properties.contributionId).toBeUndefined(); + for (const forbidden of Object.keys(FORBIDDEN_FIELDS)) { + expect(result.properties[forbidden]).toBeUndefined(); + } + + const allowlist = EXTENSION_TELEMETRY_FIELD_ALLOWLIST[event]; + for (const [key, value] of Object.entries(result.properties)) { + expect(allowlist[key]).toBe("scalar"); + const t = typeof value; + expect(t === "number" || t === "boolean" || t === "string").toBe(true); + } + }); + } + + test(`event ${event} preserves scalar fields under rootKind=bundled`, () => { + const result = gateExtensionTelemetryEvent({ + event, + properties: buildRichProperties(event), + provenance: BUNDLED, + }); + const allowlist = EXTENSION_TELEMETRY_FIELD_ALLOWLIST[event]; + for (const [field, kind] of Object.entries(allowlist)) { + if (kind === "scalar") { + expect(result.properties[field]).toBeDefined(); + } + } + for (const forbidden of Object.keys(FORBIDDEN_FIELDS)) { + expect(result.properties[forbidden]).toBeUndefined(); + } + }); + } +}); + +describe("extension telemetry event names", () => { + test("uses approval terminology instead of grant terminology", () => { + expect(EXTENSION_TELEMETRY_EVENTS).toContain("extensions.approval.recorded"); + expect(EXTENSION_TELEMETRY_EVENTS).toContain("extensions.approval.revoked"); + expect(EXTENSION_TELEMETRY_EVENTS).not.toContain("extensions.grant.recorded"); + expect(EXTENSION_TELEMETRY_EVENTS).not.toContain("extensions.grant.revoked"); + }); +}); + +describe("gateExtensionTelemetryEvent — v1 catalog completeness", () => { + test("every event has an allowlist entry", () => { + for (const event of EXTENSION_TELEMETRY_EVENTS) { + expect(EXTENSION_TELEMETRY_FIELD_ALLOWLIST[event]).toBeDefined(); + } + }); + + test("no allowlisted field name overlaps with the never-emit set", () => { + const FORBIDDEN = new Set([ + "projectPath", + "packageName", + "requestedPermissions", + "filePath", + "filePaths", + "lockfile", + "lockfileContents", + "lockfileContent", + "manifestJson", + "packageJson", + ]); + for (const event of EXTENSION_TELEMETRY_EVENTS) { + const allowlist = EXTENSION_TELEMETRY_FIELD_ALLOWLIST[event]; + for (const field of Object.keys(allowlist)) { + expect(FORBIDDEN.has(field)).toBe(false); + } + } + }); +}); diff --git a/src/common/extensions/extensionTelemetry.ts b/src/common/extensions/extensionTelemetry.ts new file mode 100644 index 0000000000..a29ec4b875 --- /dev/null +++ b/src/common/extensions/extensionTelemetry.ts @@ -0,0 +1,180 @@ +/** + * Extension Telemetry Layer — privacy-preserving allowlist for Extension events. + * + * Wraps the host TelemetryService so identifier strings (extensionId, + * contributionId) only appear in telemetry when BOTH provenance gates pass: + * (a) the value matches the Reserved Extension Identity Prefix (^mux(\..*)?$) + * (b) the source rootKind === 'bundled' + * + * Either gate failing strips the field. A third-party extension squatting + * on the `mux.*` namespace is still rejected because rootKind !== 'bundled'; + * a bundled Extension with a non-Mux id is rejected because the regex fails. + * + * Field names outside each event's allowlist are dropped silently — that is + * the defense against accidentally emitting project paths, package names, + * requested-capability lists, or file paths. + */ + +import type { RootKind } from "@/common/extensions/manifestValidator"; +import { RESERVED_EXTENSION_IDENTITY_PREFIX_REGEX } from "@/common/extensions/manifestValidator"; + +export type ExtensionTelemetryEventName = + | "extensions.discovery.completed" + | "extensions.discovery.failed" + | "extensions.migration.activated" + | "extensions.consent.shortcut.accepted" + | "extensions.consent.shortcut.rejected" + | "extensions.approval.recorded" + | "extensions.approval.revoked" + | "extensions.enabled.toggled" + | "extensions.reload.invoked" + | "extensions.cache.miss" + | "extensions.cache.hit" + | "extensions.diagnostic.emitted"; + +export const EXTENSION_TELEMETRY_EVENTS: readonly ExtensionTelemetryEventName[] = [ + "extensions.discovery.completed", + "extensions.discovery.failed", + "extensions.migration.activated", + "extensions.consent.shortcut.accepted", + "extensions.consent.shortcut.rejected", + "extensions.approval.recorded", + "extensions.approval.revoked", + "extensions.enabled.toggled", + "extensions.reload.invoked", + "extensions.cache.miss", + "extensions.cache.hit", + "extensions.diagnostic.emitted", +]; + +/** + * Field classification within an event's allowlist. + * + * - `scalar`: counts, durations (ms), booleans, status enums, diagnostic + * codes, severity. Always-allowed; no provenance check applied. Numbers, + * booleans, and short enum strings are accepted as-is. + * - `identifier`: extensionId / contributionId style fields. Gated on + * (matches Reserved Extension Identity Prefix) AND (rootKind === 'bundled'). + * Either gate failing drops the field. + */ +export type ExtensionTelemetryFieldKind = "scalar" | "identifier"; + +/** + * Closed per-event allowlist. Any field not listed here is silently dropped. + * + * Forbidden categories (project paths, package names, third-party extension + * identities, requested-capability lists, file paths, lockfile contents) are + * absent from every entry by construction; aggregate counts (e.g. + * `capabilityCount`) are how aggregate state is exposed instead. + */ +export const EXTENSION_TELEMETRY_FIELD_ALLOWLIST: Readonly< + Record>> +> = { + "extensions.discovery.completed": { + durationMs: "scalar", + rootCount: "scalar", + extensionCount: "scalar", + contributionCount: "scalar", + diagnosticCount: "scalar", + cacheHit: "scalar", + }, + "extensions.discovery.failed": { + rootKind: "scalar", + diagnosticCode: "scalar", + durationMs: "scalar", + }, + "extensions.migration.activated": { + extensionId: "identifier", + durationMs: "scalar", + }, + "extensions.consent.shortcut.accepted": { + rootKind: "scalar", + }, + "extensions.consent.shortcut.rejected": { + rootKind: "scalar", + }, + "extensions.approval.recorded": { + extensionId: "identifier", + rootKind: "scalar", + capabilityCount: "scalar", + }, + "extensions.approval.revoked": { + extensionId: "identifier", + rootKind: "scalar", + }, + "extensions.enabled.toggled": { + extensionId: "identifier", + rootKind: "scalar", + enabled: "scalar", + }, + "extensions.reload.invoked": { + rootKind: "scalar", + durationMs: "scalar", + }, + "extensions.cache.miss": { + reason: "scalar", + }, + "extensions.cache.hit": { + durationMs: "scalar", + }, + "extensions.diagnostic.emitted": { + extensionId: "identifier", + contributionId: "identifier", + diagnosticCode: "scalar", + severity: "scalar", + rootKind: "scalar", + }, +}; + +export interface ExtensionTelemetryProvenance { + /** + * The rootKind of the Extension that triggered this event. For host-internal + * events without a single owning Extension (cache miss/hit, platform toggle, + * consent shortcut) the rootKind is still required so identifier-class + * fields cannot leak even if a caller mistakenly populates one — it has to + * be 'bundled' to allow identifier emission. + */ + rootKind: RootKind; +} + +export interface GatedExtensionTelemetryEvent { + event: ExtensionTelemetryEventName; + properties: Record; +} + +export interface GateExtensionTelemetryEventInput { + event: ExtensionTelemetryEventName; + properties: Readonly>; + provenance: ExtensionTelemetryProvenance; +} + +/** + * Pure provenance gate. Returns the sanitized payload that may be forwarded + * to PostHog — drops any field not in the per-event allowlist, drops scalar + * fields whose values aren't string|number|boolean, and drops identifier + * fields unless BOTH the regex gate and the rootKind === 'bundled' gate pass. + */ +export function gateExtensionTelemetryEvent( + input: GateExtensionTelemetryEventInput +): GatedExtensionTelemetryEvent { + const allowlist = EXTENSION_TELEMETRY_FIELD_ALLOWLIST[input.event]; + const properties: Record = {}; + + for (const [key, value] of Object.entries(input.properties)) { + const kind = allowlist[key]; + if (kind === undefined) continue; + if (kind === "scalar") { + if (typeof value === "number" || typeof value === "boolean" || typeof value === "string") { + properties[key] = value; + } + continue; + } + // identifier + if (typeof value !== "string") continue; + if (!RESERVED_EXTENSION_IDENTITY_PREFIX_REGEX.test(value)) continue; + if (input.provenance.rootKind !== "bundled") continue; + properties[key] = value; + } + + return { event: input.event, properties }; +} diff --git a/src/common/extensions/globalExtensionState.test.ts b/src/common/extensions/globalExtensionState.test.ts new file mode 100644 index 0000000000..a236a01ab0 --- /dev/null +++ b/src/common/extensions/globalExtensionState.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; +import { + normalizeGlobalExtensionState, + GLOBAL_EXTENSION_STATE_SCHEMA_VERSION, +} from "./globalExtensionState"; + +const NOW = 1_700_000_000_000; + +describe("normalizeGlobalExtensionState", () => { + test("missing/undefined block normalizes to empty state with no diagnostics", () => { + const { state, diagnostics } = normalizeGlobalExtensionState(undefined, { now: NOW }); + expect(state).toEqual({ + schemaVersion: GLOBAL_EXTENSION_STATE_SCHEMA_VERSION, + extensions: {}, + }); + expect(diagnostics).toEqual([]); + }); + + test("malformed (non-object) block normalizes to empty state with info diagnostic", () => { + const { state, diagnostics, schemaVersionMismatch } = normalizeGlobalExtensionState(42, { + now: NOW, + }); + expect(state).toEqual({ + schemaVersion: GLOBAL_EXTENSION_STATE_SCHEMA_VERSION, + extensions: {}, + }); + expect(schemaVersionMismatch).toBe(false); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: "extension.state.malformed", + severity: "info", + occurredAt: NOW, + }); + }); + + test("valid schemaVersion=1 block round-trips approval records unchanged", () => { + const raw = { + schemaVersion: 1, + extensions: { + "publisher.alpha": { enabled: true }, + "publisher.beta": { + enabled: false, + approval: { + grantedPermissions: ["network", "skill.register"], + requestedPermissionsHash: "abc123", + }, + }, + }, + }; + const { state, diagnostics, schemaVersionMismatch } = normalizeGlobalExtensionState(raw, { + now: NOW, + }); + expect(diagnostics).toEqual([]); + expect(schemaVersionMismatch).toBe(false); + expect(state).toEqual({ + schemaVersion: 1, + extensions: { + "publisher.alpha": { enabled: true }, + "publisher.beta": { + enabled: false, + approval: { + grantedPermissions: ["network", "skill.register"], + requestedPermissionsHash: "abc123", + }, + }, + }, + }); + }); + + test("legacy grant records normalize to approval records without source identity", () => { + const raw = { + schemaVersion: 1, + extensions: { + "publisher.beta": { + grant: { + grantedPermissions: ["skill.register"], + approvedDistributionIdentity: { name: "@pub/beta", version: "1.2.3" }, + requestedPermissionsHash: "abc123", + }, + }, + }, + }; + + const { state, diagnostics } = normalizeGlobalExtensionState(raw, { now: NOW }); + + expect(diagnostics).toEqual([]); + expect(state.extensions["publisher.beta"]).toEqual({ + approval: { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: "abc123", + }, + }); + }); + + test("unknown future schemaVersion → empty runtime state with info diagnostic and mismatch flag", () => { + const raw = { + schemaVersion: 99, + extensions: { "publisher.alpha": { enabled: true } }, + }; + const { state, diagnostics, schemaVersionMismatch } = normalizeGlobalExtensionState(raw, { + now: NOW, + }); + expect(state).toEqual({ + schemaVersion: GLOBAL_EXTENSION_STATE_SCHEMA_VERSION, + extensions: {}, + }); + expect(schemaVersionMismatch).toBe(true); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: "extension.state.schema_version.unsupported", + severity: "info", + occurredAt: NOW, + }); + }); + + test("per-record validation failure drops only the bad record with info diagnostic", () => { + const raw = { + schemaVersion: 1, + extensions: { + "publisher.good": { enabled: true }, + "publisher.bad": { enabled: "yes" }, // invalid: enabled must be boolean + "Bad..ID": { enabled: true }, // invalid identity + }, + }; + const { state, diagnostics } = normalizeGlobalExtensionState(raw, { now: NOW }); + expect(state.extensions).toEqual({ "publisher.good": { enabled: true } }); + expect(diagnostics).toHaveLength(2); + for (const d of diagnostics) { + expect(d.code).toBe("extension.state.record.invalid"); + expect(d.severity).toBe("info"); + expect(d.occurredAt).toBe(NOW); + } + }); +}); diff --git a/src/common/extensions/globalExtensionState.ts b/src/common/extensions/globalExtensionState.ts new file mode 100644 index 0000000000..8d6b6e80b8 --- /dev/null +++ b/src/common/extensions/globalExtensionState.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; +import { ExtensionRuntimeIdSchema } from "@/common/orpc/schemas/extension"; +import type { ExtensionDiagnostic } from "./manifestValidator"; + +export const GLOBAL_EXTENSION_STATE_SCHEMA_VERSION = 1 as const; + +const LegacyDistributionIdentitySchema = z + .object({ + name: z.string().min(1), + version: z.string().min(1), + }) + .strict(); + +export const ApprovalRecordSchema = z + .object({ + grantedPermissions: z.array(z.string()), + requestedPermissionsHash: z.string().min(1), + // Legacy pre-v1 state stored distribution metadata on approval records. + // Accept and strip it so old approvals survive the v1 boundary. + approvedDistributionIdentity: LegacyDistributionIdentitySchema.optional(), + }) + .strict() + .transform(({ grantedPermissions, requestedPermissionsHash }) => ({ + grantedPermissions, + requestedPermissionsHash, + })); + +export const ExtensionStateRecordSchema = z + .object({ + enabled: z.boolean().optional(), + approval: ApprovalRecordSchema.optional(), + grant: ApprovalRecordSchema.optional(), + }) + .strict() + .transform(({ enabled, approval, grant }) => ({ + ...(enabled !== undefined ? { enabled } : {}), + ...((approval ?? grant) ? { approval: approval ?? grant } : {}), + })); + +export const GlobalExtensionStateSchema = z + .object({ + schemaVersion: z.literal(GLOBAL_EXTENSION_STATE_SCHEMA_VERSION), + extensions: z.record(ExtensionRuntimeIdSchema, ExtensionStateRecordSchema).optional(), + }) + .strict(); + +export type ApprovalRecord = z.infer; +export type ExtensionStateRecord = z.infer; + +export interface NormalizedGlobalExtensionState { + schemaVersion: typeof GLOBAL_EXTENSION_STATE_SCHEMA_VERSION; + extensions: Record; +} + +export interface NormalizeGlobalExtensionStateResult { + state: NormalizedGlobalExtensionState; + diagnostics: ExtensionDiagnostic[]; + // True when the on-disk block carries an unknown schemaVersion. Callers + // must preserve the original block on disk (no destructive write). + schemaVersionMismatch: boolean; +} + +export interface NormalizeGlobalExtensionStateOptions { + now?: number; +} + +function emptyResult( + extra?: Partial +): NormalizeGlobalExtensionStateResult { + return { + state: { schemaVersion: GLOBAL_EXTENSION_STATE_SCHEMA_VERSION, extensions: {} }, + diagnostics: [], + schemaVersionMismatch: false, + ...extra, + }; +} + +export function normalizeGlobalExtensionState( + raw: unknown, + options: NormalizeGlobalExtensionStateOptions = {} +): NormalizeGlobalExtensionStateResult { + const now = options.now ?? Date.now(); + + if (raw == null) { + return emptyResult(); + } + + if (typeof raw !== "object" || Array.isArray(raw)) { + return emptyResult({ + diagnostics: [ + { + code: "extension.state.malformed", + severity: "info", + message: "Global extension state block is malformed; treating as empty.", + occurredAt: now, + }, + ], + }); + } + + const obj = raw as Record; + const schemaVersion = obj.schemaVersion; + + if (schemaVersion !== GLOBAL_EXTENSION_STATE_SCHEMA_VERSION) { + return emptyResult({ + schemaVersionMismatch: true, + diagnostics: [ + { + code: "extension.state.schema_version.unsupported", + severity: "info", + message: `Global extension state schemaVersion ${String( + schemaVersion + )} is not supported by this build; treating as empty and preserving the file on disk.`, + occurredAt: now, + }, + ], + }); + } + + const diagnostics: ExtensionDiagnostic[] = []; + const extensions: Record = {}; + const rawExtensions = obj.extensions; + + if (rawExtensions != null && typeof rawExtensions === "object" && !Array.isArray(rawExtensions)) { + for (const [extensionId, rawRecord] of Object.entries( + rawExtensions as Record + )) { + const idOk = ExtensionRuntimeIdSchema.safeParse(extensionId); + if (!idOk.success) { + diagnostics.push({ + code: "extension.state.record.invalid", + severity: "info", + message: `Dropping global extension state record with invalid Extension Identity "${extensionId}".`, + occurredAt: now, + }); + continue; + } + const recordOk = ExtensionStateRecordSchema.safeParse(rawRecord); + if (!recordOk.success) { + diagnostics.push({ + code: "extension.state.record.invalid", + severity: "info", + message: `Dropping malformed global extension state record for "${extensionId}".`, + extensionId, + occurredAt: now, + }); + continue; + } + extensions[extensionId] = recordOk.data; + } + } + + return { + state: { schemaVersion: GLOBAL_EXTENSION_STATE_SCHEMA_VERSION, extensions }, + diagnostics, + schemaVersionMismatch: false, + }; +} diff --git a/src/common/extensions/manifestValidator.test.ts b/src/common/extensions/manifestValidator.test.ts new file mode 100644 index 0000000000..2981908ade --- /dev/null +++ b/src/common/extensions/manifestValidator.test.ts @@ -0,0 +1,466 @@ +import { describe, expect, test } from "bun:test"; +import { validateManifest, validateStaticManifest, type RootKind } from "./manifestValidator"; + +const FROZEN_NOW = 1_700_000_000_000; + +function input(overrides: { rawMux?: unknown; pkg?: unknown; rootKind?: RootKind; now?: number }) { + return { + rawMux: { manifestVersion: 1, id: "publisher.foo", contributes: {} }, + pkg: { name: "@publisher/mux-foo", version: "0.1.0" }, + rootKind: "user-global" as RootKind, + now: FROZEN_NOW, + ...overrides, + }; +} + +describe("validateStaticManifest", () => { + test("accepts static Extension Module manifest matching its folder name", () => { + const result = validateStaticManifest({ + rawManifest: { + name: "acme-review", + displayName: "Acme Review", + description: "Review helpers", + capabilities: { skills: true }, + }, + extensionName: "acme-review", + rootKind: "user-global", + now: FROZEN_NOW, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest).toMatchObject({ + id: "acme-review", + displayName: "Acme Review", + description: "Review helpers", + requestedPermissions: [], + contributions: [], + }); + }); + + test("preserves explicit requested permissions from static Extension Module manifest", () => { + const result = validateStaticManifest({ + rawManifest: { + name: "acme-review", + capabilities: { skills: true }, + requestedPermissions: ["network", "network"], + }, + extensionName: "acme-review", + rootKind: "user-global", + now: FROZEN_NOW, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.requestedPermissions).toEqual(["network"]); + expect( + result.diagnostics.some((diagnostic) => diagnostic.code === "manifest.unknown_field") + ).toBe(false); + }); + + test("rejects manifest name mismatch", () => { + const result = validateStaticManifest({ + rawManifest: { name: "other-review", capabilities: { skills: true } }, + extensionName: "acme-review", + rootKind: "user-global", + now: FROZEN_NOW, + }); + + expect(result.ok).toBe(false); + expect(result.diagnostics[0]).toMatchObject({ + code: "extension.name.mismatch", + severity: "error", + occurredAt: FROZEN_NOW, + }); + }); + + test("rejects invalid folder names before trusting manifest content", () => { + const result = validateStaticManifest({ + rawManifest: { name: "acme-review", capabilities: { skills: true } }, + extensionName: "Acme_Review", + rootKind: "project-local", + now: FROZEN_NOW, + }); + + expect(result.ok).toBe(false); + expect(result.diagnostics[0]).toMatchObject({ code: "extension.name.invalid" }); + }); + + test("rejects unknown capability keys", () => { + const result = validateStaticManifest({ + rawManifest: { name: "acme-review", capabilities: { skills: true, shell: true } }, + extensionName: "acme-review", + rootKind: "user-global", + now: FROZEN_NOW, + }); + + expect(result.ok).toBe(false); + expect( + result.diagnostics.some((diagnostic) => diagnostic.code === "manifest.capability.unknown") + ).toBe(true); + }); +}); + +describe("validateManifest envelope", () => { + test("accepts a minimal valid manifest from user-global root", () => { + const result = validateManifest(input({})); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.id).toBe("publisher.foo"); + expect(result.manifest.manifestVersion).toBe(1); + expect(result.manifest.requestedPermissions).toEqual([]); + expect(result.diagnostics).toEqual([]); + }); + + test("rejects unknown manifestVersion with manifest.version.unsupported error", () => { + const result = validateManifest( + input({ rawMux: { manifestVersion: 2, id: "publisher.foo", contributes: {} } }) + ); + expect(result.ok).toBe(false); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + code: "manifest.version.unsupported", + severity: "error", + occurredAt: FROZEN_NOW, + }); + }); + + test("rejects manifest with invalid identity regex", () => { + const result = validateManifest( + input({ rawMux: { manifestVersion: 1, id: "NoDots", contributes: {} } }) + ); + expect(result.ok).toBe(false); + expect(result.diagnostics.some((d) => d.code === "extension.identity.invalid")).toBe(true); + }); + + test("rejects unknown contributes top-level keys with manifest.contributes.unknown_key", () => { + const result = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + contributes: { widgets: [] }, + }, + }) + ); + expect(result.ok).toBe(false); + const codes = result.diagnostics.map((d) => d.code); + expect(codes).toContain("manifest.contributes.unknown_key"); + const widgetDiag = result.diagnostics.find( + (d) => d.code === "manifest.contributes.unknown_key" + ); + expect(widgetDiag?.severity).toBe("error"); + expect(widgetDiag?.message).toContain("widgets"); + }); + + test("rejects known contributes keys when the value is not an array", () => { + const result = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + contributes: { skills: { id: "demo", body: "SKILL.md" } }, + }, + }) + ); + + expect(result.ok).toBe(false); + const diagnostic = result.diagnostics.find( + (d) => d.code === "manifest.contributes.invalid_list" + ); + expect(diagnostic).toMatchObject({ severity: "error", extensionId: "publisher.foo" }); + expect(diagnostic?.message).toContain("contributes.skills"); + }); + + test("emits info diagnostic for unknown optional manifest fields including icon", () => { + const result = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + contributes: {}, + icon: "icon.png", + futureField: { whatever: true }, + }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + const infoCodes = result.diagnostics.filter((d) => d.severity === "info").map((d) => d.code); + expect(infoCodes).toContain("manifest.unknown_field"); + const fields = result.diagnostics + .filter((d) => d.code === "manifest.unknown_field") + .map((d) => d.message); + expect(fields.some((m) => m.includes("icon"))).toBe(true); + expect(fields.some((m) => m.includes("futureField"))).toBe(true); + }); + + test("accepts only http(s) homepage values", () => { + const good = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + homepage: "https://example.com/docs", + contributes: {}, + }, + }) + ); + expect(good.ok).toBe(true); + if (good.ok) { + expect(good.manifest.homepage).toBe("https://example.com/docs"); + } + + const bad = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + homepage: "javascript:alert(1)", + contributes: {}, + }, + }) + ); + expect(bad.ok).toBe(true); + if (!bad.ok) return; + expect(bad.manifest.homepage).toBeUndefined(); + expect( + bad.diagnostics.some((d) => d.code === "manifest.homepage.invalid" && d.severity === "warn") + ).toBe(true); + }); +}); + +describe("Reserved Extension Identity Prefix", () => { + test("non-bundled root claiming `mux.evil` is rejected with extension.identity.reserved", () => { + const result = validateManifest( + input({ + rootKind: "user-global", + rawMux: { + manifestVersion: 1, + id: "mux.evil", + contributes: { skills: [{ id: "x", body: "x.md" }] }, + }, + }) + ); + expect(result.ok).toBe(false); + const reserved = result.diagnostics.find((d) => d.code === "extension.identity.reserved"); + expect(reserved).toBeDefined(); + expect(reserved?.severity).toBe("error"); + expect(reserved?.extensionId).toBe("mux.evil"); + }); + + test("non-bundled root claiming bare `mux` (no dot) is also reserved", () => { + // The bare token "mux" fails the identity regex (needs a dotted segment) + // but the validator must still treat any mux/mux.* claim from a non-bundled + // root as reserved so a regex tweak doesn't open the boundary. + const result = validateManifest( + input({ + rootKind: "project-local", + rawMux: { manifestVersion: 1, id: "mux", contributes: {} }, + }) + ); + expect(result.ok).toBe(false); + expect(result.diagnostics.some((d) => d.code === "extension.identity.reserved")).toBe(true); + }); + + test("bundled root may claim `mux.platformDemo`", () => { + const result = validateManifest( + input({ + rootKind: "bundled", + rawMux: { manifestVersion: 1, id: "mux.platformdemo", contributes: {} }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.id).toBe("mux.platformdemo"); + }); + + test("non-bundled identity that merely contains `mux` as a sub-segment is allowed", () => { + const result = validateManifest( + input({ + rootKind: "user-global", + rawMux: { manifestVersion: 1, id: "publisher.muxfoo", contributes: {} }, + }) + ); + expect(result.ok).toBe(true); + }); +}); + +describe("Registration Capabilities", () => { + test("declared skills materializes skill.register and merges with explicit effect capabilities", () => { + const result = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + contributes: { + skills: [{ id: "my-skill", body: "skills/my-skill/SKILL.md" }], + }, + requestedPermissions: ["network"], + }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.requestedPermissions).toContain("network"); + expect(result.manifest.requestedPermissions).toContain("skill.register"); + }); + + test("declared agents and themes each materialize their own registration capability", () => { + const result = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + contributes: { + agents: [{ id: "my-agent", body: "agents/my-agent.md" }], + themes: [{ id: "my-theme", tokens: { background: "#000" } }], + }, + }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.requestedPermissions).toContain("agent.register"); + expect(result.manifest.requestedPermissions).toContain("theme.register"); + }); + + test("an empty contribution list does not infer a registration capability", () => { + const result = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + contributes: { skills: [] }, + }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.requestedPermissions).not.toContain("skill.register"); + }); + + test("dedupes when an author redundantly lists the inferred permission explicitly", () => { + const result = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + contributes: { + skills: [{ id: "my-skill", body: "skills/my-skill/SKILL.md" }], + }, + requestedPermissions: ["skill.register", "network"], + }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + const skillRegisters = result.manifest.requestedPermissions.filter( + (p) => p === "skill.register" + ); + expect(skillRegisters).toHaveLength(1); + }); + + test("provisional descriptor types also infer their register permissions", () => { + const result = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + contributes: { + tools: [{ id: "my-tool" }], + mcpServers: [{ id: "my-mcp" }], + }, + }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.requestedPermissions).toContain("tool.register"); + expect(result.manifest.requestedPermissions).toContain("mcpServer.register"); + }); +}); + +describe("per-contribution descriptor handling", () => { + test("a single bad contribution emits a contribution-level warn diagnostic without invalidating the manifest", () => { + const result = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + contributes: { + skills: [ + { id: "good-skill", body: "skills/good/SKILL.md" }, + { id: "bad-skill", body: "../escape.md" }, + ], + }, + }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + const warn = result.diagnostics.find((d) => d.code === "contribution.invalid"); + expect(warn).toBeDefined(); + expect(warn?.severity).toBe("warn"); + expect(warn?.contributionRef).toMatchObject({ type: "skills", index: 1 }); + // skill.register is still inferred because at least one valid skill remains. + expect(result.manifest.requestedPermissions).toContain("skill.register"); + }); + + test("unknown descriptor version on a single contribution does not invalidate other contributions", () => { + const result = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + contributes: { + skills: [ + { id: "ok", body: "skills/ok.md" }, + { id: "bad", body: "skills/bad.md", descriptorVersion: 99 }, + ], + }, + }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + const diag = result.diagnostics.find((d) => d.code === "contribution.invalid"); + expect(diag).toBeDefined(); + expect(diag?.contributionRef).toMatchObject({ type: "skills", index: 1, id: "bad" }); + }); + + test("when ALL contributions of a type are invalid, the inferred register permission is not emitted", () => { + const result = validateManifest( + input({ + rawMux: { + manifestVersion: 1, + id: "publisher.foo", + contributes: { + skills: [{ id: "bad", body: "/etc/passwd" }], + }, + }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.requestedPermissions).not.toContain("skill.register"); + }); +}); + +describe("diagnostic record shape", () => { + test("every diagnostic carries code, severity, message, and occurredAt", () => { + const result = validateManifest( + input({ + rawMux: { manifestVersion: 999, id: "publisher.foo", contributes: {} }, + now: 42, + }) + ); + expect(result.diagnostics.length).toBeGreaterThan(0); + for (const d of result.diagnostics) { + expect(typeof d.code).toBe("string"); + expect(["error", "warn", "info"]).toContain(d.severity); + expect(typeof d.message).toBe("string"); + expect(d.occurredAt).toBe(42); + } + }); +}); diff --git a/src/common/extensions/manifestValidator.ts b/src/common/extensions/manifestValidator.ts new file mode 100644 index 0000000000..c8797684a8 --- /dev/null +++ b/src/common/extensions/manifestValidator.ts @@ -0,0 +1,443 @@ +import type { ZodType } from "zod"; +import { + AgentDescriptorSchema, + AgentLifecycleHookDescriptorSchema, + CommandDescriptorSchema, + ExtensionIdentityRegex, + ExtensionModuleManifestSchema, + ExtensionNameSchema, + LayoutDescriptorSchema, + McpServerDescriptorSchema, + PanelDescriptorSchema, + RuntimeDriverDescriptorSchema, + RuntimePresetDescriptorSchema, + SecretProviderDescriptorSchema, + SkillDescriptorSchema, + ThemeDescriptorSchema, + ToolDescriptorSchema, +} from "@/common/orpc/schemas/extension"; + +export type RootKind = "bundled" | "user-global" | "project-local"; + +export type ExtensionDiagnosticSeverity = "error" | "warn" | "info"; + +export interface ExtensionDiagnosticContributionRef { + type: string; + // The position of the contribution inside its owning manifest's + // `contributes[type]` array. Omitted when the diagnostic spans contributions + // from multiple manifests (e.g., cross-Extension contribution-id conflicts). + index?: number; + id?: string; +} + +export interface ExtensionDiagnostic { + code: string; + severity: ExtensionDiagnosticSeverity; + message: string; + rootId?: string; + extensionId?: string; + contributionRef?: ExtensionDiagnosticContributionRef; + suggestedAction?: string; + occurredAt: number; +} + +export interface ValidatedContribution { + type: string; + id: string; + // Position within the original `contributes[type]` array; useful for + // diagnostic refs and stable ordering in downstream consumers. + index: number; + // The post-preprocess validated descriptor. Shape depends on `type`; consumers + // that need typed access narrow via `type` and re-cast against the matching + // descriptor schema export. + descriptor: Record; +} + +export interface ValidatedManifest { + manifestVersion: 1; + id: string; + displayName?: string; + description?: string; + publisher?: string; + homepage?: string; + // Effect Capabilities plus Registration Capabilities materialized from + // declared contributions (e.g., `skill.register`). + requestedPermissions: string[]; + // Validated contributions in declaration order, grouped per type. Only + // contributions whose descriptor schema accepted the input are included; + // invalid descriptors are reported in `diagnostics` and excluded here. + contributions: ValidatedContribution[]; +} + +export type ManifestValidationResult = + | { ok: true; manifest: ValidatedManifest; diagnostics: ExtensionDiagnostic[] } + | { ok: false; diagnostics: ExtensionDiagnostic[] }; + +export interface ValidateManifestInput { + rawMux: unknown; + pkg: unknown; + rootKind: RootKind; + /** Override the diagnostic timestamp for deterministic tests. */ + now?: number; +} + +export interface ValidateStaticManifestInput { + rawManifest: unknown; + extensionName: string; + rootKind: RootKind; + /** Override the diagnostic timestamp for deterministic tests. */ + now?: number; +} + +// Reserved Extension Identity Prefix per ADR-0005. Checked before envelope +// validation so a regex/schema regression in one place can't open the boundary. +// Also consumed by the Extension Telemetry Layer (US-016) to gate identifier +// fields so only Mux-controlled identities can ever appear in telemetry. +export const RESERVED_EXTENSION_IDENTITY_PREFIX_REGEX = /^mux(\..*)?$/; + +const KNOWN_TOP_LEVEL_FIELDS = new Set([ + "manifestVersion", + "id", + "contributes", + "displayName", + "description", + "publisher", + "homepage", + "requestedPermissions", +]); + +const KNOWN_STATIC_MANIFEST_FIELDS = new Set([ + "name", + "displayName", + "description", + "capabilities", + "requestedPermissions", +]); +const KNOWN_STATIC_CAPABILITY_FIELDS = new Set(["skills"]); + +// `singular` is the prefix used to materialize the type's Registration +// Capability (`.register`). +export const CONTRIBUTION_TYPES: ReadonlyArray<{ + key: string; + singular: string; + schema: ZodType; +}> = [ + { key: "skills", singular: "skill", schema: SkillDescriptorSchema }, + { key: "agents", singular: "agent", schema: AgentDescriptorSchema }, + { key: "themes", singular: "theme", schema: ThemeDescriptorSchema }, + { key: "layouts", singular: "layout", schema: LayoutDescriptorSchema }, + { key: "runtimePresets", singular: "runtimePreset", schema: RuntimePresetDescriptorSchema }, + { key: "commands", singular: "command", schema: CommandDescriptorSchema }, + { key: "runtimeDrivers", singular: "runtimeDriver", schema: RuntimeDriverDescriptorSchema }, + { key: "tools", singular: "tool", schema: ToolDescriptorSchema }, + { key: "mcpServers", singular: "mcpServer", schema: McpServerDescriptorSchema }, + { key: "panels", singular: "panel", schema: PanelDescriptorSchema }, + { + key: "agentLifecycleHooks", + singular: "agentLifecycleHook", + schema: AgentLifecycleHookDescriptorSchema, + }, + { key: "secretProviders", singular: "secretProvider", schema: SecretProviderDescriptorSchema }, +]; + +const KNOWN_CONTRIBUTES_KEYS = new Set(CONTRIBUTION_TYPES.map((c) => c.key)); + +// Maps a contributes-key (e.g., "skills") to its Registration Capability +// (e.g., "skill.register"). Exposed for downstream consumers +// (e.g., the Registry Service) that need to drive the capability calculator. +export const CONTRIBUTION_TYPE_REGISTRATION_PERMISSIONS: Readonly> = + Object.fromEntries(CONTRIBUTION_TYPES.map((c) => [c.key, `${c.singular}.register`])); + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseHttpUrl(value: string): string | null { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + +export function validateStaticManifest( + input: ValidateStaticManifestInput +): ManifestValidationResult { + const occurredAt = input.now ?? Date.now(); + const diagnostics: ExtensionDiagnostic[] = []; + + if (!ExtensionNameSchema.safeParse(input.extensionName).success) { + diagnostics.push({ + code: "extension.name.invalid", + severity: "error", + message: `Extension Module folder name ${JSON.stringify( + input.extensionName + )} must be kebab-case and match the Extension Name rules.`, + extensionId: input.extensionName, + occurredAt, + }); + return { ok: false, diagnostics }; + } + + if (!isPlainObject(input.rawManifest)) { + diagnostics.push({ + code: "manifest.invalid", + severity: "error", + message: "Static Manifest export must be an object literal.", + extensionId: input.extensionName, + occurredAt, + }); + return { ok: false, diagnostics }; + } + + const raw = input.rawManifest; + for (const key of Object.keys(raw)) { + if (!KNOWN_STATIC_MANIFEST_FIELDS.has(key)) { + diagnostics.push({ + code: "manifest.unknown_field", + severity: "info", + message: `Unknown optional static manifest field "${key}" (value ignored).`, + extensionId: input.extensionName, + occurredAt, + }); + } + } + + if (isPlainObject(raw.capabilities)) { + for (const key of Object.keys(raw.capabilities)) { + if (!KNOWN_STATIC_CAPABILITY_FIELDS.has(key)) { + diagnostics.push({ + code: "manifest.capability.unknown", + severity: "error", + message: `Unknown static manifest capability "${key}". V1 supports only the skills registration capability.`, + extensionId: input.extensionName, + occurredAt, + }); + } + } + } + + const parsed = ExtensionModuleManifestSchema.safeParse(raw); + if (!parsed.success) { + diagnostics.push({ + code: "manifest.invalid", + severity: "error", + message: parsed.error.message, + extensionId: input.extensionName, + occurredAt, + }); + return { ok: false, diagnostics }; + } + + if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) { + return { ok: false, diagnostics }; + } + + if (parsed.data.name !== input.extensionName) { + diagnostics.push({ + code: "extension.name.mismatch", + severity: "error", + message: `Static Manifest name "${parsed.data.name}" must match Extension Module folder name "${input.extensionName}".`, + extensionId: parsed.data.name, + occurredAt, + }); + return { ok: false, diagnostics }; + } + + const explicitRequested = Array.isArray(raw.requestedPermissions) + ? raw.requestedPermissions.filter((p): p is string => typeof p === "string") + : []; + + const manifest: ValidatedManifest = { + manifestVersion: 1, + id: parsed.data.name, + requestedPermissions: Array.from(new Set(explicitRequested)), + contributions: [], + }; + if (typeof parsed.data.displayName === "string") manifest.displayName = parsed.data.displayName; + if (typeof parsed.data.description === "string") manifest.description = parsed.data.description; + + return { ok: true, manifest, diagnostics }; +} + +export function validateManifest(input: ValidateManifestInput): ManifestValidationResult { + const occurredAt = input.now ?? Date.now(); + const diagnostics: ExtensionDiagnostic[] = []; + + if (!isPlainObject(input.rawMux)) { + diagnostics.push({ + code: "manifest.invalid", + severity: "error", + message: "Manifest `mux` field must be an object.", + occurredAt, + }); + return { ok: false, diagnostics }; + } + const raw = input.rawMux; + const rawId = typeof raw.id === "string" ? raw.id : undefined; + + // Reserved-prefix gate runs before any other identity/envelope diagnostic so + // the boundary holds even if envelope validation regresses (ADR-0005). + if ( + input.rootKind !== "bundled" && + rawId !== undefined && + RESERVED_EXTENSION_IDENTITY_PREFIX_REGEX.test(rawId) + ) { + diagnostics.push({ + code: "extension.identity.reserved", + severity: "error", + message: `Extension identity "${rawId}" uses the reserved mux/mux.* prefix; only bundled Extensions may claim it.`, + extensionId: rawId, + occurredAt, + }); + return { ok: false, diagnostics }; + } + + if (raw.manifestVersion !== 1) { + diagnostics.push({ + code: "manifest.version.unsupported", + severity: "error", + message: `Unsupported manifestVersion ${JSON.stringify(raw.manifestVersion)}; this host accepts manifestVersion 1.`, + extensionId: rawId, + occurredAt, + }); + return { ok: false, diagnostics }; + } + + if (rawId === undefined || !ExtensionIdentityRegex.test(rawId)) { + diagnostics.push({ + code: "extension.identity.invalid", + severity: "error", + message: `Extension identity ${JSON.stringify(rawId)} does not match the required dotted reverse-domain format.`, + occurredAt, + }); + return { ok: false, diagnostics }; + } + + const contributes = raw.contributes; + if (!isPlainObject(contributes)) { + diagnostics.push({ + code: "manifest.invalid", + severity: "error", + message: "Manifest `contributes` must be an object.", + extensionId: rawId, + occurredAt, + }); + return { ok: false, diagnostics }; + } + + let hasUnknownContributesKey = false; + for (const key of Object.keys(contributes)) { + if (!KNOWN_CONTRIBUTES_KEYS.has(key)) { + hasUnknownContributesKey = true; + diagnostics.push({ + code: "manifest.contributes.unknown_key", + severity: "error", + message: `Unknown top-level contributes key "${key}". The v1 contributes shape is closed; adding a new contribution type is a host-version change.`, + extensionId: rawId, + occurredAt, + }); + } + } + let hasInvalidContributesList = false; + for (const { key } of CONTRIBUTION_TYPES) { + const value = contributes[key]; + if (value === undefined || Array.isArray(value)) continue; + hasInvalidContributesList = true; + diagnostics.push({ + code: "manifest.contributes.invalid_list", + severity: "error", + message: `contributes.${key} must be an array of contribution descriptors.`, + extensionId: rawId, + occurredAt, + }); + } + if (hasUnknownContributesKey || hasInvalidContributesList) { + return { ok: false, diagnostics }; + } + + for (const key of Object.keys(raw)) { + if (!KNOWN_TOP_LEVEL_FIELDS.has(key)) { + diagnostics.push({ + code: "manifest.unknown_field", + severity: "info", + message: `Unknown optional manifest field "${key}" (value ignored).`, + extensionId: rawId, + occurredAt, + }); + } + } + + // A type's Registration Capability is materialized only when at least one + // descriptor of that type validates, so an all-invalid list does not get + // capability-grade authority via the inferred capability path. + const inferredPerms: string[] = []; + const validatedContributions: ValidatedContribution[] = []; + for (const { key, singular, schema } of CONTRIBUTION_TYPES) { + const list: unknown = contributes[key]; + if (!Array.isArray(list)) continue; + let validCount = 0; + for (let i = 0; i < list.length; i++) { + const item: unknown = list[i]; + const parsed = schema.safeParse(item); + if (parsed.success) { + validCount++; + validatedContributions.push({ + type: key, + // Schema preprocess already injects descriptorVersion=1 when missing + // and the discriminated union guarantees `id` is a non-empty string. + id: (parsed.data as { id: string }).id, + index: i, + descriptor: parsed.data as Record, + }); + continue; + } + const contributionRef: ExtensionDiagnosticContributionRef = { type: key, index: i }; + if (isPlainObject(item) && typeof item.id === "string") { + contributionRef.id = item.id; + } + diagnostics.push({ + code: "contribution.invalid", + severity: "warn", + message: parsed.error.message, + extensionId: rawId, + contributionRef, + occurredAt, + }); + } + if (validCount > 0) { + inferredPerms.push(`${singular}.register`); + } + } + + const explicitRequested = Array.isArray(raw.requestedPermissions) + ? raw.requestedPermissions.filter((p): p is string => typeof p === "string") + : []; + const requestedPermissions = Array.from(new Set([...explicitRequested, ...inferredPerms])); + + const manifest: ValidatedManifest = { + manifestVersion: 1, + id: rawId, + requestedPermissions, + contributions: validatedContributions, + }; + if (typeof raw.displayName === "string") manifest.displayName = raw.displayName; + if (typeof raw.description === "string") manifest.description = raw.description; + if (typeof raw.publisher === "string") manifest.publisher = raw.publisher; + if (typeof raw.homepage === "string") { + const homepage = parseHttpUrl(raw.homepage); + if (homepage) { + manifest.homepage = homepage; + } else { + diagnostics.push({ + code: "manifest.homepage.invalid", + severity: "warn", + message: "mux.homepage must be an http:// or https:// URL when provided.", + occurredAt, + }); + } + } + + return { ok: true, manifest, diagnostics }; +} diff --git a/src/common/extensions/permissionCalculator.test.ts b/src/common/extensions/permissionCalculator.test.ts new file mode 100644 index 0000000000..936af1a782 --- /dev/null +++ b/src/common/extensions/permissionCalculator.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from "bun:test"; +import { DriftStatusSchema } from "@/common/orpc/schemas/extensionRegistry"; +import { + calculatePermissions, + hashRequestedPermissions, + requiresReapproval, + type CalculatePermissionsInput, + type ContributionPermissionRequirement, + type ApprovalRecord, +} from "./permissionCalculator"; + +const SKILL_CONTRIB: ContributionPermissionRequirement = { + type: "skills", + id: "my-skill", + registrationPermission: "skill.register", +}; + +const AGENT_CONTRIB: ContributionPermissionRequirement = { + type: "agents", + id: "my-agent", + registrationPermission: "agent.register", +}; + +function manifest(overrides: Partial> = {}) { + return { + requestedPermissions: ["skill.register"], + contributions: [SKILL_CONTRIB], + ...overrides, + }; +} + +function approval(overrides: Partial = {}): ApprovalRecord { + const requestedPermissions = ["skill.register"]; + return { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: hashRequestedPermissions(requestedPermissions), + ...overrides, + }; +} + +describe("hashRequestedPermissions", () => { + test("is order-independent and dedup-stable", () => { + expect(hashRequestedPermissions(["a", "b", "c"])).toBe( + hashRequestedPermissions(["c", "b", "a"]) + ); + expect(hashRequestedPermissions(["a", "a", "b"])).toBe(hashRequestedPermissions(["a", "b"])); + }); + + test("differs when the underlying set differs", () => { + expect(hashRequestedPermissions(["a", "b"])).not.toBe(hashRequestedPermissions(["a", "c"])); + }); +}); + +describe("calculatePermissions — fresh state", () => { + test("no approval record yields driftStatus 'fresh' and no effective permissions", () => { + const result = calculatePermissions({ manifest: manifest() }); + expect(result.driftStatus).toBe("fresh"); + expect(result.effectivePermissions).toEqual([]); + expect(result.pendingNew).toEqual(["skill.register"]); + expect(result.isStale).toBe(false); + }); + + test("fresh state marks every contribution unavailable with the missing registration perm listed", () => { + const result = calculatePermissions({ manifest: manifest() }); + expect(result.contributions).toEqual([ + { + type: "skills", + id: "my-skill", + available: false, + missingPermissions: ["skill.register"], + }, + ]); + }); +}); + +describe("calculatePermissions — aligned approval record", () => { + test("matching approval record yields no drift, full effective permissions, available contributions", () => { + const result = calculatePermissions({ manifest: manifest(), approvalRecord: approval() }); + expect(result.driftStatus).toBeNull(); + expect(result.effectivePermissions).toEqual(["skill.register"]); + expect(result.pendingNew).toEqual([]); + expect(result.contributions[0]).toMatchObject({ id: "my-skill", available: true }); + expect(result.contributions[0].missingPermissions).toEqual([]); + }); + + test("Effective Permissions are strictly the intersection of requested and granted", () => { + // Granted superset (e.g. user previously granted operational `network` that + // the new manifest no longer requests) — effective is the intersection. + const result = calculatePermissions({ + manifest: manifest({ requestedPermissions: ["skill.register"] }), + approvalRecord: approval({ grantedPermissions: ["skill.register", "network"] }), + }); + expect(result.effectivePermissions).toEqual(["skill.register"]); + expect(result.effectivePermissions).not.toContain("network"); + }); +}); + +describe("calculatePermissions — drift transitions", () => { + test("permissions-changed when a new requested permission is added in the update", () => { + // Prior approval covered only skill.register; new manifest also wants + // agent.register (e.g. user added a new agent contribution). The new perm + // is pendingNew, never auto-granted, and the agent contribution stays + // unavailable until re-approval. + const result = calculatePermissions({ + manifest: manifest({ + requestedPermissions: ["skill.register", "agent.register"], + contributions: [SKILL_CONTRIB, AGENT_CONTRIB], + }), + approvalRecord: approval(), + }); + expect(result.driftStatus).toBe("permissions-changed"); + expect(result.pendingNew).toEqual(["agent.register"]); + expect(result.effectivePermissions).toEqual(["skill.register"]); + const agent = result.contributions.find((c) => c.id === "my-agent"); + expect(agent?.available).toBe(false); + expect(agent?.missingPermissions).toEqual(["agent.register"]); + const skill = result.contributions.find((c) => c.id === "my-skill"); + expect(skill?.available).toBe(true); + }); + + test("requested capability changes still drift", () => { + const result = calculatePermissions({ + manifest: manifest({ + requestedPermissions: ["skill.register", "network"], + }), + approvalRecord: approval(), + }); + expect(result.driftStatus).toBe("permissions-changed"); + expect(result.pendingNew).toEqual(["network"]); + }); +}); + +describe("DriftStatusSchema", () => { + test("accepts only capability-approval states, not source identity drift states", () => { + expect(DriftStatusSchema.safeParse("fresh").success).toBe(true); + expect(DriftStatusSchema.safeParse("permissions-changed").success).toBe(true); + expect(DriftStatusSchema.safeParse("version-changed").success).toBe(false); + expect(DriftStatusSchema.safeParse("package-renamed").success).toBe(false); + }); +}); + +describe("requiresReapproval", () => { + test("ignores first-time approvals", () => { + expect(requiresReapproval(calculatePermissions({ manifest: manifest() }))).toBe(false); + }); + + test("requires consent for requested capability drift only", () => { + expect( + requiresReapproval( + calculatePermissions({ + manifest: manifest({ requestedPermissions: ["skill.register", "network"] }), + approvalRecord: approval(), + }) + ) + ).toBe(true); + }); +}); + +describe("calculatePermissions — vanished extension", () => { + test("approval record with no current manifest yields isStale and no contributions", () => { + const result = calculatePermissions({ approvalRecord: approval() }); + expect(result.isStale).toBe(true); + expect(result.contributions).toEqual([]); + expect(result.effectivePermissions).toEqual([]); + expect(result.pendingNew).toEqual([]); + expect(result.driftStatus).toBeNull(); + }); +}); + +describe("calculatePermissions — descriptor-version evolution", () => { + test("same Extension Identity, same permissions, updated descriptor version reports no drift", () => { + // Manifest Validator collapses descriptorVersion bumps into the same + // requested-permission set (registration perms are inferred from + // contribution *types*, not descriptor versions). Permission Calculator + // therefore should see no drift across a pure descriptor-version evolution. + const result = calculatePermissions({ + manifest: manifest({ + // Same contribution type list, same permissions; the per-contribution + // descriptor version evolution is invisible here. + requestedPermissions: ["skill.register"], + contributions: [SKILL_CONTRIB], + }), + approvalRecord: approval(), + }); + expect(result.driftStatus).toBeNull(); + expect(result.pendingNew).toEqual([]); + }); +}); + +describe("calculatePermissions — new contribution type triggers Inferred Registration Permission delta", () => { + test("adding a new contribution type adds its register permission as pendingNew, not effective", () => { + // Prior approval covered only skill.register. Manifest now also declares an + // agent contribution, which the validator inferred as agent.register. + const result = calculatePermissions({ + manifest: manifest({ + requestedPermissions: ["skill.register", "agent.register"], + contributions: [SKILL_CONTRIB, AGENT_CONTRIB], + }), + approvalRecord: approval(), + }); + expect(result.pendingNew).toContain("agent.register"); + expect(result.effectivePermissions).not.toContain("agent.register"); + const agent = result.contributions.find((c) => c.id === "my-agent"); + expect(agent?.available).toBe(false); + }); +}); + +describe("calculatePermissions — contribution-level operational permissions", () => { + test("contribution stays unavailable until BOTH registration and contribution-level operational perms are effective", () => { + const networkySkill: ContributionPermissionRequirement = { + ...SKILL_CONTRIB, + usesPermissions: ["network"], + }; + const result = calculatePermissions({ + manifest: manifest({ + requestedPermissions: ["skill.register", "network"], + contributions: [networkySkill], + }), + approvalRecord: approval({ + grantedPermissions: ["skill.register"], + requestedPermissionsHash: hashRequestedPermissions(["skill.register", "network"]), + }), + }); + // Approved hash matches current request, so no drift — but `network` was + // never actually granted, so the contribution stays unavailable. + const skill = result.contributions[0]; + expect(skill.available).toBe(false); + expect(skill.missingPermissions).toEqual(["network"]); + }); + + test("contribution becomes available once both registration and operational perms are granted", () => { + const networkySkill: ContributionPermissionRequirement = { + ...SKILL_CONTRIB, + usesPermissions: ["network"], + }; + const requested = ["skill.register", "network"]; + const result = calculatePermissions({ + manifest: manifest({ + requestedPermissions: requested, + contributions: [networkySkill], + }), + approvalRecord: approval({ + grantedPermissions: ["skill.register", "network"], + requestedPermissionsHash: hashRequestedPermissions(requested), + }), + }); + expect(result.contributions[0].available).toBe(true); + expect(result.contributions[0].missingPermissions).toEqual([]); + }); +}); diff --git a/src/common/extensions/permissionCalculator.ts b/src/common/extensions/permissionCalculator.ts new file mode 100644 index 0000000000..2141ab90ea --- /dev/null +++ b/src/common/extensions/permissionCalculator.ts @@ -0,0 +1,116 @@ +import crypto from "crypto"; + +export type DriftStatus = "fresh" | "permissions-changed"; + +export interface ApprovalRecord { + grantedPermissions: string[]; + requestedPermissionsHash: string; +} + +export interface ContributionPermissionRequirement { + type: string; + id: string; + registrationPermission: string; + usesPermissions?: string[]; +} + +export interface CalculatePermissionsInput { + manifest?: { + requestedPermissions: string[]; + contributions: ContributionPermissionRequirement[]; + }; + approvalRecord?: ApprovalRecord; +} + +export interface ContributionAvailability { + type: string; + id: string; + available: boolean; + missingPermissions: string[]; +} + +export interface CalculatePermissionsResult { + effectivePermissions: string[]; + pendingNew: string[]; + contributions: ContributionAvailability[]; + driftStatus: DriftStatus | null; + isStale: boolean; +} + +// Order-independent and dedup-stable hash of the requested capability set, so +// the approval record stores a single canonical fingerprint regardless of how +// the manifest happens to order its capabilities. +export function hashRequestedPermissions(perms: readonly string[]): string { + const canonical = Array.from(new Set(perms)).sort().join("\n"); + return crypto.createHash("sha256").update(canonical).digest("hex"); +} + +function intersect(a: readonly string[], b: readonly string[]): string[] { + const set = new Set(b); + return Array.from(new Set(a)).filter((p) => set.has(p)); +} + +function difference(a: readonly string[], b: readonly string[]): string[] { + const set = new Set(b); + return Array.from(new Set(a)).filter((p) => !set.has(p)); +} + +// Capability drift is based only on requested capability changes. Source +// identity changes (git ref/content/distribution metadata) must not invalidate +// existing approvals by themselves. +function computeDrift(currentRequestedHash: string, record: ApprovalRecord): DriftStatus | null { + if (currentRequestedHash !== record.requestedPermissionsHash) return "permissions-changed"; + return null; +} + +export { requiresReapproval } from "./approvalDrift"; + +export function calculatePermissions(input: CalculatePermissionsInput): CalculatePermissionsResult { + const { manifest, approvalRecord } = input; + + // Vanished extension: an approval record exists but the Extension is no + // longer present in the current snapshot. Surface as a Stale Approval Record; + // do not synthesize contributions or drift from a missing manifest. + if (!manifest) { + return { + effectivePermissions: [], + pendingNew: [], + contributions: [], + driftStatus: null, + isStale: true, + }; + } + + const requested = manifest.requestedPermissions; + const granted = approvalRecord?.grantedPermissions ?? []; + const effectiveSet = new Set(intersect(requested, granted)); + const effectivePermissions = Array.from(effectiveSet); + + const pendingNew = difference(requested, granted); + + const contributions: ContributionAvailability[] = manifest.contributions.map((c) => { + const required = [c.registrationPermission, ...(c.usesPermissions ?? [])]; + const missing = required.filter((p) => !effectiveSet.has(p)); + return { + type: c.type, + id: c.id, + available: missing.length === 0, + missingPermissions: missing, + }; + }); + + let driftStatus: DriftStatus | null; + if (!approvalRecord) { + driftStatus = "fresh"; + } else { + driftStatus = computeDrift(hashRequestedPermissions(requested), approvalRecord); + } + + return { + effectivePermissions, + pendingNew, + contributions, + driftStatus, + isStale: false, + }; +} diff --git a/src/common/extensions/projectExtensionState.test.ts b/src/common/extensions/projectExtensionState.test.ts new file mode 100644 index 0000000000..31fedc745b --- /dev/null +++ b/src/common/extensions/projectExtensionState.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { + normalizeProjectExtensionState, + PROJECT_EXTENSION_STATE_SCHEMA_VERSION, +} from "./projectExtensionState"; + +const NOW = 1_700_000_000_000; + +describe("normalizeProjectExtensionState", () => { + test("missing/undefined block normalizes to empty state with no diagnostics", () => { + const { state, diagnostics, schemaVersionMismatch } = normalizeProjectExtensionState( + undefined, + { now: NOW } + ); + expect(state).toEqual({ + schemaVersion: PROJECT_EXTENSION_STATE_SCHEMA_VERSION, + rootTrusted: false, + extensions: {}, + }); + expect(diagnostics).toEqual([]); + expect(schemaVersionMismatch).toBe(false); + }); + + test("malformed (non-object) block normalizes to empty state with info diagnostic", () => { + const { state, diagnostics, schemaVersionMismatch } = normalizeProjectExtensionState(42, { + now: NOW, + }); + expect(state).toEqual({ + schemaVersion: PROJECT_EXTENSION_STATE_SCHEMA_VERSION, + rootTrusted: false, + extensions: {}, + }); + expect(schemaVersionMismatch).toBe(false); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: "extension.state.malformed", + severity: "info", + occurredAt: NOW, + }); + }); + + test("valid schemaVersion=1 block round-trips records and rootTrusted unchanged", () => { + const raw = { + schemaVersion: 1, + rootTrusted: true, + extensions: { + "publisher.alpha": { enabled: true }, + "publisher.beta": { + enabled: false, + approval: { + grantedPermissions: ["network", "skill.register"], + requestedPermissionsHash: "abc123", + }, + }, + }, + }; + const { state, diagnostics, schemaVersionMismatch } = normalizeProjectExtensionState(raw, { + now: NOW, + }); + expect(diagnostics).toEqual([]); + expect(schemaVersionMismatch).toBe(false); + expect(state).toEqual({ + schemaVersion: 1, + rootTrusted: true, + extensions: { + "publisher.alpha": { enabled: true }, + "publisher.beta": { + enabled: false, + approval: { + grantedPermissions: ["network", "skill.register"], + requestedPermissionsHash: "abc123", + }, + }, + }, + }); + }); + + test("rootTrusted defaults to false when omitted; true survives round-trip", () => { + const blockWithoutTrust = { schemaVersion: 1, extensions: {} }; + expect(normalizeProjectExtensionState(blockWithoutTrust, { now: NOW }).state.rootTrusted).toBe( + false + ); + + const blockTrusted = { schemaVersion: 1, rootTrusted: true, extensions: {} }; + expect(normalizeProjectExtensionState(blockTrusted, { now: NOW }).state.rootTrusted).toBe(true); + }); + + test("unknown future schemaVersion → empty runtime state with info diagnostic and mismatch flag", () => { + const raw = { + schemaVersion: 99, + rootTrusted: true, + extensions: { "publisher.alpha": { enabled: true } }, + }; + const { state, diagnostics, schemaVersionMismatch } = normalizeProjectExtensionState(raw, { + now: NOW, + }); + expect(state).toEqual({ + schemaVersion: PROJECT_EXTENSION_STATE_SCHEMA_VERSION, + rootTrusted: false, + extensions: {}, + }); + expect(schemaVersionMismatch).toBe(true); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: "extension.state.schema_version.unsupported", + severity: "info", + occurredAt: NOW, + }); + }); + + test("per-record validation failure drops only the bad record with info diagnostic", () => { + const raw = { + schemaVersion: 1, + rootTrusted: true, + extensions: { + "publisher.good": { enabled: true }, + "publisher.bad": { enabled: "yes" }, // invalid: enabled must be boolean + "Bad..ID": { enabled: true }, // invalid identity + }, + }; + const { state, diagnostics } = normalizeProjectExtensionState(raw, { now: NOW }); + expect(state.rootTrusted).toBe(true); + expect(state.extensions).toEqual({ "publisher.good": { enabled: true } }); + expect(diagnostics).toHaveLength(2); + for (const d of diagnostics) { + expect(d.code).toBe("extension.state.record.invalid"); + expect(d.severity).toBe("info"); + expect(d.occurredAt).toBe(NOW); + } + }); + + test("non-boolean rootTrusted is rejected at the schema gate; falls back to false", () => { + const raw = { + schemaVersion: 1, + rootTrusted: "yes", + extensions: { "publisher.alpha": { enabled: true } }, + }; + const { state, diagnostics } = normalizeProjectExtensionState(raw, { now: NOW }); + expect(state.rootTrusted).toBe(false); + // The whole block is treated as malformed (not just the rootTrusted field) + // since the top-level schema is .strict() and rootTrusted is the gate. + expect(diagnostics.some((d) => d.code === "extension.state.malformed")).toBe(true); + }); +}); diff --git a/src/common/extensions/projectExtensionState.ts b/src/common/extensions/projectExtensionState.ts new file mode 100644 index 0000000000..3aefa2744f --- /dev/null +++ b/src/common/extensions/projectExtensionState.ts @@ -0,0 +1,152 @@ +import { z } from "zod"; +import { ExtensionRuntimeIdSchema } from "@/common/orpc/schemas/extension"; +import { ExtensionStateRecordSchema, type ExtensionStateRecord } from "./globalExtensionState"; +import type { ExtensionDiagnostic } from "./manifestValidator"; + +export const PROJECT_EXTENSION_STATE_SCHEMA_VERSION = 1 as const; + +export const ProjectExtensionStateSchema = z + .object({ + schemaVersion: z.literal(PROJECT_EXTENSION_STATE_SCHEMA_VERSION), + rootTrusted: z.boolean().optional(), + extensions: z.record(ExtensionRuntimeIdSchema, ExtensionStateRecordSchema).optional(), + }) + .strict(); + +export interface NormalizedProjectExtensionState { + schemaVersion: typeof PROJECT_EXTENSION_STATE_SCHEMA_VERSION; + rootTrusted: boolean; + extensions: Record; +} + +export interface NormalizeProjectExtensionStateResult { + state: NormalizedProjectExtensionState; + diagnostics: ExtensionDiagnostic[]; + // True when the on-disk block carries an unknown schemaVersion. Callers + // must preserve the original block on disk (no destructive write) until an + // explicit user mutation rewrites it at the current schemaVersion. + schemaVersionMismatch: boolean; +} + +export interface NormalizeProjectExtensionStateOptions { + now?: number; +} + +function emptyResult( + extra?: Partial +): NormalizeProjectExtensionStateResult { + return { + state: { + schemaVersion: PROJECT_EXTENSION_STATE_SCHEMA_VERSION, + rootTrusted: false, + extensions: {}, + }, + diagnostics: [], + schemaVersionMismatch: false, + ...extra, + }; +} + +export function normalizeProjectExtensionState( + raw: unknown, + options: NormalizeProjectExtensionStateOptions = {} +): NormalizeProjectExtensionStateResult { + const now = options.now ?? Date.now(); + + if (raw == null) { + return emptyResult(); + } + + if (typeof raw !== "object" || Array.isArray(raw)) { + return emptyResult({ + diagnostics: [ + { + code: "extension.state.malformed", + severity: "info", + message: "Project-local extension state block is malformed; treating as empty.", + occurredAt: now, + }, + ], + }); + } + + const obj = raw as Record; + const schemaVersion = obj.schemaVersion; + + if (schemaVersion !== PROJECT_EXTENSION_STATE_SCHEMA_VERSION) { + return emptyResult({ + schemaVersionMismatch: true, + diagnostics: [ + { + code: "extension.state.schema_version.unsupported", + severity: "info", + message: `Project-local extension state schemaVersion ${String( + schemaVersion + )} is not supported by this build; treating as empty and preserving the file on disk.`, + occurredAt: now, + }, + ], + }); + } + + // Empty state never implies trust: a non-boolean rootTrusted means the + // file is malformed at the gate that decides trust, so treat as empty + // rather than coercing. + if (obj.rootTrusted !== undefined && typeof obj.rootTrusted !== "boolean") { + return emptyResult({ + diagnostics: [ + { + code: "extension.state.malformed", + severity: "info", + message: "Project-local extension state has non-boolean rootTrusted; treating as empty.", + occurredAt: now, + }, + ], + }); + } + + const rootTrusted = obj.rootTrusted === true; + + const diagnostics: ExtensionDiagnostic[] = []; + const extensions: Record = {}; + const rawExtensions = obj.extensions; + + if (rawExtensions != null && typeof rawExtensions === "object" && !Array.isArray(rawExtensions)) { + for (const [extensionId, rawRecord] of Object.entries( + rawExtensions as Record + )) { + const idOk = ExtensionRuntimeIdSchema.safeParse(extensionId); + if (!idOk.success) { + diagnostics.push({ + code: "extension.state.record.invalid", + severity: "info", + message: `Dropping project-local extension state record with invalid Extension Identity "${extensionId}".`, + occurredAt: now, + }); + continue; + } + const recordOk = ExtensionStateRecordSchema.safeParse(rawRecord); + if (!recordOk.success) { + diagnostics.push({ + code: "extension.state.record.invalid", + severity: "info", + message: `Dropping malformed project-local extension state record for "${extensionId}".`, + extensionId, + occurredAt: now, + }); + continue; + } + extensions[extensionId] = recordOk.data; + } + } + + return { + state: { + schemaVersion: PROJECT_EXTENSION_STATE_SCHEMA_VERSION, + rootTrusted, + extensions, + }, + diagnostics, + schemaVersionMismatch: false, + }; +} diff --git a/src/common/extensions/snapshotCache.test.ts b/src/common/extensions/snapshotCache.test.ts new file mode 100644 index 0000000000..0cf4f39db8 --- /dev/null +++ b/src/common/extensions/snapshotCache.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "bun:test"; +import { + SNAPSHOT_CACHE_VERSION, + validateSnapshotCache, + type StateFileFingerprint, +} from "./snapshotCache"; + +const APP_VERSION = "1.2.3"; + +const EMPTY_FINGERPRINTS: StateFileFingerprint[] = []; + +function buildValidCacheBlob(snapshot: unknown = { availableContributions: [] }): unknown { + return { + cacheVersion: SNAPSHOT_CACHE_VERSION, + appVersion: APP_VERSION, + manifestVersion: 1, + stateFileFingerprints: EMPTY_FINGERPRINTS, + snapshot, + }; +} + +describe("validateSnapshotCache", () => { + test("validates a well-formed cache against matching live fingerprints", () => { + const result = validateSnapshotCache({ + raw: buildValidCacheBlob({ availableContributions: [{ type: "skill", id: "x" }] }), + appVersion: APP_VERSION, + liveFingerprints: EMPTY_FINGERPRINTS, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.snapshot).toEqual({ availableContributions: [{ type: "skill", id: "x" }] }); + } + }); + + test("invalidates a malformed (non-object) blob with reason 'shape'", () => { + const result = validateSnapshotCache({ + raw: "not an object", + appVersion: APP_VERSION, + liveFingerprints: EMPTY_FINGERPRINTS, + }); + expect(result).toEqual({ ok: false, reason: "shape" }); + }); + + test("invalidates a missing cacheVersion / unknown shape with reason 'shape'", () => { + const result = validateSnapshotCache({ + raw: { snapshot: {} }, + appVersion: APP_VERSION, + liveFingerprints: EMPTY_FINGERPRINTS, + }); + expect(result).toEqual({ ok: false, reason: "shape" }); + }); + + test("invalidates an unknown future cacheVersion with reason 'shape'", () => { + const result = validateSnapshotCache({ + raw: { ...(buildValidCacheBlob() as object), cacheVersion: 999 }, + appVersion: APP_VERSION, + liveFingerprints: EMPTY_FINGERPRINTS, + }); + expect(result).toEqual({ ok: false, reason: "shape" }); + }); + + test("invalidates when appVersion does not match the running build", () => { + const result = validateSnapshotCache({ + raw: buildValidCacheBlob(), + appVersion: "9.9.9", + liveFingerprints: EMPTY_FINGERPRINTS, + }); + expect(result).toEqual({ ok: false, reason: "appVersion" }); + }); + + test("invalidates when manifestVersion is anything other than 1", () => { + const blob = { ...(buildValidCacheBlob() as object), manifestVersion: 2 }; + const result = validateSnapshotCache({ + raw: blob, + appVersion: APP_VERSION, + liveFingerprints: EMPTY_FINGERPRINTS, + }); + // Discriminator literal 1 → schema reject → "shape"; either way, invalidation is silent. + expect(result.ok).toBe(false); + }); + + test("invalidates when a recorded state file's mtime drifted", () => { + const fingerprint: StateFileFingerprint = { + path: "/tmp/state.json", + exists: true, + mtimeMs: 100, + sha256: "a".repeat(64), + }; + const result = validateSnapshotCache({ + raw: buildValidCacheBlob() as Record & { + stateFileFingerprints: StateFileFingerprint[]; + }, + appVersion: APP_VERSION, + liveFingerprints: [fingerprint], + }); + // Cache was written with no fingerprints, but live now has one → mismatch. + expect(result).toEqual({ ok: false, reason: "stateFiles" }); + }); + + test("invalidates when a recorded state file's sha256 drifted", () => { + const cached: StateFileFingerprint = { + path: "/tmp/state.json", + exists: true, + mtimeMs: 100, + sha256: "a".repeat(64), + }; + const live: StateFileFingerprint = { ...cached, sha256: "b".repeat(64) }; + const blob = { + ...(buildValidCacheBlob() as object), + stateFileFingerprints: [cached], + }; + const result = validateSnapshotCache({ + raw: blob, + appVersion: APP_VERSION, + liveFingerprints: [live], + }); + expect(result).toEqual({ ok: false, reason: "stateFiles" }); + }); + + test("invalidates when the cache records a state file that no longer exists live", () => { + const cached: StateFileFingerprint = { + path: "/tmp/state.json", + exists: true, + mtimeMs: 100, + sha256: "a".repeat(64), + }; + const blob = { + ...(buildValidCacheBlob() as object), + stateFileFingerprints: [cached], + }; + const result = validateSnapshotCache({ + raw: blob, + appVersion: APP_VERSION, + liveFingerprints: [], + }); + expect(result).toEqual({ ok: false, reason: "stateFiles" }); + }); + + test("invalidates when a state file existence flag drifted (was missing, now present)", () => { + const cached: StateFileFingerprint = { + path: "/tmp/state.json", + exists: false, + mtimeMs: 0, + sha256: "", + }; + const live: StateFileFingerprint = { + path: "/tmp/state.json", + exists: true, + mtimeMs: 200, + sha256: "c".repeat(64), + }; + const blob = { + ...(buildValidCacheBlob() as object), + stateFileFingerprints: [cached], + }; + const result = validateSnapshotCache({ + raw: blob, + appVersion: APP_VERSION, + liveFingerprints: [live], + }); + expect(result).toEqual({ ok: false, reason: "stateFiles" }); + }); + + test("validates regardless of fingerprint ordering", () => { + const a: StateFileFingerprint = { + path: "/tmp/a.json", + exists: true, + mtimeMs: 1, + sha256: "a".repeat(64), + }; + const b: StateFileFingerprint = { + path: "/tmp/b.json", + exists: true, + mtimeMs: 2, + sha256: "b".repeat(64), + }; + const blob = { + ...(buildValidCacheBlob() as object), + stateFileFingerprints: [a, b], + }; + const result = validateSnapshotCache({ + raw: blob, + appVersion: APP_VERSION, + liveFingerprints: [b, a], + }); + expect(result.ok).toBe(true); + }); +}); diff --git a/src/common/extensions/snapshotCache.ts b/src/common/extensions/snapshotCache.ts new file mode 100644 index 0000000000..78d57d0991 --- /dev/null +++ b/src/common/extensions/snapshotCache.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +export const SNAPSHOT_CACHE_VERSION = 1 as const; +export const SNAPSHOT_CACHE_MANIFEST_VERSION = 1 as const; + +export const StateFileFingerprintSchema = z + .object({ + path: z.string().min(1), + exists: z.boolean(), + mtimeMs: z.number(), + sha256: z.string(), + }) + .strict(); + +export type StateFileFingerprint = z.infer; + +export const SnapshotCacheSchema = z + .object({ + cacheVersion: z.literal(SNAPSHOT_CACHE_VERSION), + appVersion: z.string().min(1), + manifestVersion: z.literal(SNAPSHOT_CACHE_MANIFEST_VERSION), + stateFileFingerprints: z.array(StateFileFingerprintSchema), + snapshot: z.unknown(), + }) + .strict(); + +export type SnapshotCache = z.infer; + +export interface ValidateSnapshotCacheInput { + raw: unknown; + appVersion: string; + liveFingerprints: readonly StateFileFingerprint[]; +} + +export type SnapshotCacheInvalidationReason = "shape" | "appVersion" | "stateFiles"; + +export type ValidateSnapshotCacheResult = + | { ok: true; snapshot: unknown } + | { ok: false; reason: SnapshotCacheInvalidationReason }; + +export function validateSnapshotCache( + input: ValidateSnapshotCacheInput +): ValidateSnapshotCacheResult { + const parsed = SnapshotCacheSchema.safeParse(input.raw); + if (!parsed.success) { + return { ok: false, reason: "shape" }; + } + const { data } = parsed; + if (data.appVersion !== input.appVersion) { + return { ok: false, reason: "appVersion" }; + } + if (!fingerprintsMatch(data.stateFileFingerprints, input.liveFingerprints)) { + return { ok: false, reason: "stateFiles" }; + } + return { ok: true, snapshot: data.snapshot }; +} + +function fingerprintsMatch( + cached: readonly StateFileFingerprint[], + live: readonly StateFileFingerprint[] +): boolean { + if (cached.length !== live.length) return false; + const cachedByPath = new Map(cached.map((f) => [f.path, f])); + for (const f of live) { + const c = cachedByPath.get(f.path); + if (!c) return false; + if (c.exists !== f.exists || c.mtimeMs !== f.mtimeMs || c.sha256 !== f.sha256) { + return false; + } + } + return true; +} diff --git a/src/common/extensions/sourceLocks.test.ts b/src/common/extensions/sourceLocks.test.ts new file mode 100644 index 0000000000..c2903d2c83 --- /dev/null +++ b/src/common/extensions/sourceLocks.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; + +import { GlobalExtensionSourceLockSchema, ProjectExtensionSourceLockSchema } from "./sourceLocks"; + +describe("GlobalExtensionSourceLockSchema", () => { + test("parses git source locks with resolved SHA, optional subdir, and content hash", () => { + const parsed = GlobalExtensionSourceLockSchema.parse({ + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "git", + url: "https://github.com/acme/mux-extensions.git", + ref: "main", + resolvedSha: "0123456789abcdef0123456789abcdef01234567", + subdir: "extensions/review", + contentHash: "sha256:abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz234567", + }, + }, + }, + }); + + expect(parsed.extensions["acme-review"].source.type).toBe("git"); + }); + + test("rejects Windows absolute git subdirectories", () => { + const parsed = GlobalExtensionSourceLockSchema.safeParse({ + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "git", + url: "https://github.com/acme/mux-extensions.git", + ref: "main", + resolvedSha: "0123456789abcdef0123456789abcdef01234567", + subdir: "C:\\Users\\alice\\review", + contentHash: "sha256:abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz234567", + }, + }, + }, + }); + + expect(parsed.success).toBe(false); + }); + + test("rejects repository lockfiles that try to carry trust or approval state", () => { + const parsed = GlobalExtensionSourceLockSchema.safeParse({ + schemaVersion: 1, + rootTrusted: true, + extensions: {}, + }); + + expect(parsed.success).toBe(false); + }); +}); + +describe("ProjectExtensionSourceLockSchema", () => { + test("parses vendored project extension source locks", () => { + const parsed = ProjectExtensionSourceLockSchema.parse({ + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "vendored", + path: ".mux/extensions/acme-review", + contentHash: "sha256:abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz234567", + }, + }, + }, + }); + + expect(parsed.extensions["acme-review"].source.type).toBe("vendored"); + }); + + test("rejects Windows absolute vendored paths", () => { + const parsed = ProjectExtensionSourceLockSchema.safeParse({ + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "vendored", + path: "C:\\Users\\alice\\review", + contentHash: "sha256:abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz234567", + }, + }, + }, + }); + + expect(parsed.success).toBe(false); + }); +}); diff --git a/src/common/extensions/sourceLocks.ts b/src/common/extensions/sourceLocks.ts new file mode 100644 index 0000000000..7068a733bd --- /dev/null +++ b/src/common/extensions/sourceLocks.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import { ExtensionNameSchema } from "@/common/orpc/schemas/extension"; + +export const EXTENSION_SOURCE_LOCK_SCHEMA_VERSION = 1 as const; + +const WINDOWS_ABSOLUTE_PATH_REGEX = /^[A-Za-z]:[/\\]/; +const ContentHashSchema = z.string().regex(/^sha256:[a-zA-Z0-9+/=_-]{32,}$/); +const GitShaSchema = z.string().regex(/^[0-9a-f]{40}$/); +const RelativeSourceSubdirSchema = z + .string() + .min(1) + .refine((value) => !value.includes("\0"), { message: "must not contain null bytes" }) + .refine((value) => !WINDOWS_ABSOLUTE_PATH_REGEX.test(value), { + message: "must be a relative path", + }) + .refine((value) => !value.startsWith("/") && !value.startsWith("\\"), { + message: "must be a relative path", + }) + .refine((value) => !value.split(/[\\/]/).includes(".."), { + message: "must not contain .. segments", + }); + +export const GitExtensionSourceLockSchema = z + .object({ + type: z.literal("git"), + url: z.string().min(1), + ref: z.string().min(1), + resolvedSha: GitShaSchema, + subdir: RelativeSourceSubdirSchema.nullish(), + contentHash: ContentHashSchema, + }) + .strict(); + +export const VendoredExtensionSourceLockSchema = z + .object({ + type: z.literal("vendored"), + path: RelativeSourceSubdirSchema, + contentHash: ContentHashSchema, + }) + .strict(); + +export const GlobalExtensionSourceLockEntrySchema = z + .object({ + source: GitExtensionSourceLockSchema, + }) + .strict(); + +export const ProjectExtensionSourceLockEntrySchema = z + .object({ + source: z.discriminatedUnion("type", [ + GitExtensionSourceLockSchema, + VendoredExtensionSourceLockSchema, + ]), + }) + .strict(); + +export const GlobalExtensionSourceLockSchema = z + .object({ + schemaVersion: z.literal(EXTENSION_SOURCE_LOCK_SCHEMA_VERSION), + extensions: z.record(ExtensionNameSchema, GlobalExtensionSourceLockEntrySchema), + }) + .strict(); + +export const ProjectExtensionSourceLockSchema = z + .object({ + schemaVersion: z.literal(EXTENSION_SOURCE_LOCK_SCHEMA_VERSION), + extensions: z.record(ExtensionNameSchema, ProjectExtensionSourceLockEntrySchema), + }) + .strict(); + +export type GitExtensionSourceLock = z.infer; +export type VendoredExtensionSourceLock = z.infer; +export type GlobalExtensionSourceLockEntry = z.infer; +export type ProjectExtensionSourceLockEntry = z.infer; +export type GlobalExtensionSourceLock = z.infer; +export type ProjectExtensionSourceLock = z.infer; diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 651822c8dc..fe2c65ab4b 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -282,6 +282,7 @@ export { tasks, experiments, ExperimentValueSchema, + extensions, telemetry, TelemetryEventSchema, signing, diff --git a/src/common/orpc/schemas/agentSkill.ts b/src/common/orpc/schemas/agentSkill.ts index e1ba7a93a3..d4e08a72e4 100644 --- a/src/common/orpc/schemas/agentSkill.ts +++ b/src/common/orpc/schemas/agentSkill.ts @@ -1,6 +1,7 @@ import { z } from "zod"; -export const AgentSkillScopeSchema = z.enum(["project", "global", "built-in"]); +// Extension-contributed skills sit below project/global custom skills and above built-ins. +export const AgentSkillScopeSchema = z.enum(["project", "global", "extension", "built-in"]); /** * Skill name per agentskills.io diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 77068e7f87..bc9f9d6aec 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -129,6 +129,10 @@ export const experiments = { }), output: z.void(), }, + onChanged: { + input: z.void(), + output: eventIterator(z.enum(EXPERIMENT_IDS)), + }, reload: { input: z.void(), output: z.void(), @@ -2591,3 +2595,6 @@ export const ssh = { }, }, }; + +// Re-export the extensions registry namespace from the dedicated schema file. +export { extensions } from "./extensionRegistry"; diff --git a/src/common/orpc/schemas/extension.test.ts b/src/common/orpc/schemas/extension.test.ts new file mode 100644 index 0000000000..47fea6bde0 --- /dev/null +++ b/src/common/orpc/schemas/extension.test.ts @@ -0,0 +1,455 @@ +import { describe, expect, test } from "bun:test"; +import { + AgentDescriptorSchema, + AgentLifecycleHookDescriptorSchema, + CommandDescriptorSchema, + CommandTargetIdSchema, + ContributionIdSchema, + ExtensionContributesV1Schema, + ExtensionIdentitySchema, + ExtensionManifestSchema, + ExtensionManifestV1Schema, + LayoutDescriptorSchema, + McpServerDescriptorSchema, + PanelDescriptorSchema, + RelativeBodyPathSchema, + RuntimeDriverDescriptorSchema, + RuntimePresetDescriptorSchema, + SecretProviderDescriptorSchema, + SkillDescriptorSchema, + ThemeDescriptorSchema, + ToolDescriptorSchema, +} from "./extension"; + +function manifest(overrides: Record = {}): unknown { + return { + manifestVersion: 1, + id: "publisher.foo", + contributes: {}, + ...overrides, + }; +} + +describe("ExtensionIdentitySchema", () => { + test("accepts dotted reverse-domain ids", () => { + for (const id of ["publisher.foo", "mux.platformdemo", "a.b.c", "foo.bar-baz"]) { + expect(ExtensionIdentitySchema.safeParse(id).success).toBe(true); + } + }); + + test("rejects ids without a dotted segment", () => { + for (const id of ["foo", "FOO.bar", "foo.", ".foo", "foo..bar", "foo.-bar", "foo.bar_baz"]) { + expect(ExtensionIdentitySchema.safeParse(id).success).toBe(false); + } + }); +}); + +describe("ExtensionManifestSchema (v1 envelope)", () => { + test("parses a minimal valid v1 manifest", () => { + expect(ExtensionManifestSchema.safeParse(manifest()).success).toBe(true); + }); + + test("requires manifestVersion, id, and contributes", () => { + expect( + ExtensionManifestSchema.safeParse({ id: "publisher.foo", contributes: {} }).success + ).toBe(false); + expect(ExtensionManifestSchema.safeParse({ manifestVersion: 1, contributes: {} }).success).toBe( + false + ); + expect( + ExtensionManifestSchema.safeParse({ manifestVersion: 1, id: "publisher.foo" }).success + ).toBe(false); + }); + + test("rejects unknown manifestVersion values via discriminated schema", () => { + for (const version of [0, 2, "1", null]) { + const result = ExtensionManifestSchema.safeParse( + manifest({ manifestVersion: version as unknown }) + ); + expect(result.success).toBe(false); + } + }); + + test("accepts optional envelope fields when present", () => { + const parsed = ExtensionManifestV1Schema.parse( + manifest({ + displayName: "Foo", + description: "Demo extension", + publisher: "publisher", + homepage: "https://example.com", + requestedPermissions: ["network", "shell.execute"], + }) + ); + expect(parsed.displayName).toBe("Foo"); + expect(parsed.requestedPermissions).toEqual(["network", "shell.execute"]); + }); + + test("accepts unknown optional manifest fields without rejection (icon tolerated)", () => { + const result = ExtensionManifestV1Schema.safeParse( + manifest({ + icon: "icon.png", + futureField: { whatever: true }, + }) + ); + expect(result.success).toBe(true); + }); +}); + +describe("ExtensionContributesV1Schema (closed shape)", () => { + test("accepts an empty contributes block", () => { + expect(ExtensionContributesV1Schema.safeParse({}).success).toBe(true); + }); + + test("accepts known contribution-type keys", () => { + const result = ExtensionContributesV1Schema.safeParse({ + skills: [], + agents: [], + themes: [], + layouts: [], + runtimePresets: [], + commands: [], + runtimeDrivers: [], + tools: [], + mcpServers: [], + panels: [], + agentLifecycleHooks: [], + secretProviders: [], + }); + expect(result.success).toBe(true); + }); + + test("rejects unknown top-level keys inside contributes", () => { + const result = ExtensionContributesV1Schema.safeParse({ widgets: [] }); + expect(result.success).toBe(false); + }); +}); + +describe("ContributionIdSchema (kebab-case)", () => { + test("accepts kebab-case ids", () => { + for (const id of ["foo", "foo-bar", "a1", "a-b-c-d"]) { + expect(ContributionIdSchema.safeParse(id).success).toBe(true); + } + }); + + test("rejects invalid ids", () => { + for (const id of ["", "Foo", "foo_bar", "-foo", "foo-", "foo--bar", "foo bar"]) { + expect(ContributionIdSchema.safeParse(id).success).toBe(false); + } + }); +}); + +describe("RelativeBodyPathSchema", () => { + test("accepts relative paths", () => { + for (const p of ["SKILL.md", "skills/foo/SKILL.md", "agents/my-agent.md", "a/b/c.md"]) { + expect(RelativeBodyPathSchema.safeParse(p).success).toBe(true); + } + }); + + test("rejects absolute paths", () => { + for (const p of ["/etc/passwd", "/skills/foo.md", "C:/Users/foo.md", "C:\\Users\\foo.md"]) { + expect(RelativeBodyPathSchema.safeParse(p).success).toBe(false); + } + }); + + test("rejects parent-traversal segments", () => { + for (const p of ["../foo.md", "skills/../escape.md", "skills\\..\\escape.md", ".."]) { + expect(RelativeBodyPathSchema.safeParse(p).success).toBe(false); + } + }); + + test("rejects null bytes", () => { + expect(RelativeBodyPathSchema.safeParse("foo\0bar.md").success).toBe(false); + }); +}); + +describe("CommandTargetIdSchema", () => { + test("accepts mux-namespaced target ids", () => { + for (const id of ["mux.workspace", "mux.workspace.create", "mux.chat.send"]) { + expect(CommandTargetIdSchema.safeParse(id).success).toBe(true); + } + }); + + test("rejects non-mux ids and malformed segments", () => { + for (const id of [ + "workspace.create", + "MUX.workspace", + "mux", + "mux.", + "mux.Workspace", + "mux.foo-bar", + "mux.foo..bar", + ]) { + expect(CommandTargetIdSchema.safeParse(id).success).toBe(false); + } + }); +}); + +describe("SkillDescriptorSchema", () => { + const minimal = { id: "my-skill", body: "skills/my-skill/SKILL.md" } as const; + + test("defaults missing descriptorVersion to 1", () => { + const result = SkillDescriptorSchema.safeParse(minimal); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.descriptorVersion).toBe(1); + } + }); + + test("accepts explicit descriptorVersion 1 with optional fields", () => { + const result = SkillDescriptorSchema.safeParse({ + ...minimal, + descriptorVersion: 1, + displayName: "My Skill", + description: "Demo", + advertise: false, + }); + expect(result.success).toBe(true); + }); + + test("rejects unknown descriptorVersion", () => { + for (const v of [0, 2, "1", null]) { + const result = SkillDescriptorSchema.safeParse({ ...minimal, descriptorVersion: v }); + expect(result.success).toBe(false); + } + }); + + test("tolerates additive optional fields at v1 (passthrough)", () => { + const result = SkillDescriptorSchema.safeParse({ + ...minimal, + futureField: { whatever: true }, + }); + expect(result.success).toBe(true); + }); + + test("rejects ids longer than agent skill names", () => { + const overlongSkillId = "a".repeat(65); + expect(SkillDescriptorSchema.safeParse({ id: overlongSkillId, body: "SKILL.md" }).success).toBe( + false + ); + }); + + test("rejects body paths with traversal or absolute prefix", () => { + expect(SkillDescriptorSchema.safeParse({ id: "my-skill", body: "../escape.md" }).success).toBe( + false + ); + expect(SkillDescriptorSchema.safeParse({ id: "my-skill", body: "/etc/passwd" }).success).toBe( + false + ); + }); + + test("requires id and body", () => { + expect(SkillDescriptorSchema.safeParse({ id: "my-skill" }).success).toBe(false); + expect(SkillDescriptorSchema.safeParse({ body: "skills/x/SKILL.md" }).success).toBe(false); + }); +}); + +describe("AgentDescriptorSchema", () => { + const minimal = { id: "my-agent", body: "agents/my-agent.md" } as const; + + test("defaults missing descriptorVersion to 1", () => { + const result = AgentDescriptorSchema.safeParse(minimal); + expect(result.success).toBe(true); + }); + + test("rejects unknown descriptorVersion", () => { + expect(AgentDescriptorSchema.safeParse({ ...minimal, descriptorVersion: 2 }).success).toBe( + false + ); + }); + + test("rejects body paths with traversal", () => { + expect( + AgentDescriptorSchema.safeParse({ id: "my-agent", body: "agents/../escape.md" }).success + ).toBe(false); + }); + + test("tolerates additive optional fields", () => { + expect(AgentDescriptorSchema.safeParse({ ...minimal, badge: "beta" }).success).toBe(true); + }); +}); + +describe("ThemeDescriptorSchema", () => { + const minimal = { + id: "my-theme", + tokens: { background: "#000000", foreground: "#ffffff" }, + } as const; + + test("defaults missing descriptorVersion to 1", () => { + expect(ThemeDescriptorSchema.safeParse(minimal).success).toBe(true); + }); + + test("rejects unknown descriptorVersion", () => { + expect(ThemeDescriptorSchema.safeParse({ ...minimal, descriptorVersion: 2 }).success).toBe( + false + ); + }); + + test("rejects unknown token keys (curated whitelist)", () => { + const result = ThemeDescriptorSchema.safeParse({ + id: "my-theme", + tokens: { notARealToken: "#000000" }, + }); + expect(result.success).toBe(false); + }); + + test("accepts subset of curated tokens", () => { + expect( + ThemeDescriptorSchema.safeParse({ + id: "my-theme", + tokens: { accent: "hsl(210 70% 40%)" }, + }).success + ).toBe(true); + }); + + test("rejects empty token values", () => { + expect( + ThemeDescriptorSchema.safeParse({ + id: "my-theme", + tokens: { background: "" }, + }).success + ).toBe(false); + }); +}); + +describe("LayoutDescriptorSchema", () => { + test("rejects unknown descriptorVersion", () => { + const result = LayoutDescriptorSchema.safeParse({ + id: "my-layout", + descriptorVersion: 2, + preset: {}, + }); + expect(result.success).toBe(false); + }); + + test("requires a valid id", () => { + const result = LayoutDescriptorSchema.safeParse({ + id: "Bad ID", + preset: { + id: "p1", + name: "P1", + leftSidebarCollapsed: false, + rightSidebar: { + collapsed: false, + width: { mode: "px", value: 360 }, + layout: { + version: 1, + nextId: 1, + focusedTabsetId: "ts1", + root: { type: "tabset", id: "ts1", tabs: ["costs"], activeTab: "costs" }, + }, + }, + }, + }); + expect(result.success).toBe(false); + }); +}); + +describe("RuntimePresetDescriptorSchema", () => { + test("accepts a minimal local runtime preset and defaults version", () => { + const result = RuntimePresetDescriptorSchema.safeParse({ + id: "my-runtime", + runtime: { type: "local" }, + }); + expect(result.success).toBe(true); + }); + + test("rejects unknown descriptorVersion", () => { + expect( + RuntimePresetDescriptorSchema.safeParse({ + id: "my-runtime", + descriptorVersion: 2, + runtime: { type: "local" }, + }).success + ).toBe(false); + }); +}); + +describe("CommandDescriptorSchema", () => { + const minimal = { + id: "my-command", + target: "mux.workspace.create", + title: "Create Workspace", + } as const; + + test("defaults missing descriptorVersion to 1", () => { + expect(CommandDescriptorSchema.safeParse(minimal).success).toBe(true); + }); + + test("rejects unknown descriptorVersion", () => { + expect(CommandDescriptorSchema.safeParse({ ...minimal, descriptorVersion: 2 }).success).toBe( + false + ); + }); + + test("rejects target ids outside the mux.* namespace", () => { + expect( + CommandDescriptorSchema.safeParse({ ...minimal, target: "evil.workspace.create" }).success + ).toBe(false); + }); + + test("requires a non-empty title", () => { + expect(CommandDescriptorSchema.safeParse({ ...minimal, title: "" }).success).toBe(false); + }); + + test("tolerates additive optional fields", () => { + expect(CommandDescriptorSchema.safeParse({ ...minimal, keybind: "cmd+k" }).success).toBe(true); + }); +}); + +// Provisional Descriptors (US-003) — inspection-only contribution types. +// Same descriptor-version envelope as available types: default-1, reject +// unknown versions, passthrough for additive fields, descriptor-only +// (no executable handler/view/runtime fields). +const provisionalCases = [ + ["RuntimeDriverDescriptorSchema", RuntimeDriverDescriptorSchema], + ["ToolDescriptorSchema", ToolDescriptorSchema], + ["McpServerDescriptorSchema", McpServerDescriptorSchema], + ["PanelDescriptorSchema", PanelDescriptorSchema], + ["AgentLifecycleHookDescriptorSchema", AgentLifecycleHookDescriptorSchema], + ["SecretProviderDescriptorSchema", SecretProviderDescriptorSchema], +] as const; + +describe.each(provisionalCases)("%s (provisional descriptor)", (_name, schema) => { + const minimal = { id: "my-thing" } as const; + + test("defaults missing descriptorVersion to 1", () => { + const result = schema.safeParse(minimal); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as { descriptorVersion: number }).descriptorVersion).toBe(1); + } + }); + + test("accepts explicit descriptorVersion 1 with optional inspection metadata", () => { + const result = schema.safeParse({ + ...minimal, + descriptorVersion: 1, + displayName: "My Thing", + description: "Demo provisional descriptor", + }); + expect(result.success).toBe(true); + }); + + test("rejects unknown descriptorVersion", () => { + for (const v of [0, 2, "1", null]) { + const result = schema.safeParse({ ...minimal, descriptorVersion: v }); + expect(result.success).toBe(false); + } + }); + + test("rejects invalid id", () => { + expect(schema.safeParse({ id: "Bad ID" }).success).toBe(false); + }); + + test("requires id", () => { + expect(schema.safeParse({}).success).toBe(false); + }); + + test("tolerates additive optional fields at v1 (passthrough)", () => { + const result = schema.safeParse({ + ...minimal, + futureField: { whatever: true }, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/common/orpc/schemas/extension.ts b/src/common/orpc/schemas/extension.ts new file mode 100644 index 0000000000..39d6792d52 --- /dev/null +++ b/src/common/orpc/schemas/extension.ts @@ -0,0 +1,257 @@ +import { z } from "zod"; +import { SkillNameSchema } from "./agentSkill"; +import { LayoutPresetSchema } from "./uiLayouts"; +import { RuntimeConfigSchema } from "./runtime"; + +export const ExtensionIdentityRegex = /^[a-z0-9]+(?:\.[a-z0-9][a-z0-9-]*)+$/; + +export const ExtensionIdentitySchema = z.string().regex(ExtensionIdentityRegex); + +// Extension Modules are identified by their kebab-case folder basename. Keep +// this aligned with agent skill names so a module name can safely appear in UI, +// state keys, and filesystem-backed active views. +export const ExtensionNameSchema = SkillNameSchema; + +// Transitional API key schema while package-based Extension Identities are being +// retired in favor of Extension Names. New Extension Module code should use +// ExtensionNameSchema; existing persisted/package records still parse here. +export const ExtensionRuntimeIdSchema = z.union([ExtensionNameSchema, ExtensionIdentitySchema]); + +export const ExtensionModuleCapabilitiesSchema = z + .object({ + skills: z.literal(true).nullish(), + }) + .strict(); + +export const ExtensionModuleManifestSchema = z + .object({ + name: ExtensionNameSchema, + displayName: z.string().nullish(), + description: z.string().nullish(), + capabilities: ExtensionModuleCapabilitiesSchema.nullish(), + }) + .passthrough(); + +// Per-contribution identifier (kebab-case, mirrors agentskills.io naming). +export const ContributionIdSchema = z + .string() + .min(1) + .max(128) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + +// Schema-level validation of relative body paths. Filesystem-level symlink +// containment is enforced separately by the Path Containment helper (US-007). +export const RelativeBodyPathSchema = z + .string() + .min(1) + .refine((p) => !p.includes("\0"), { message: "must not contain null bytes" }) + .refine((p) => !p.startsWith("/") && !p.startsWith("\\") && !/^[A-Za-z]:[/\\]/.test(p), { + message: "must be a relative path (no absolute paths)", + }) + .refine((p) => !p.split(/[\\/]/).includes(".."), { + message: "must not contain .. segments", + }); + +// Mux-owned Command Target id (e.g., mux.workspace.create). Validator may +// further check the value against the live Command Target registry. +export const CommandTargetIdSchema = z + .string() + .min(1) + .regex(/^mux\.[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$/); + +// Curated theme tokens. Adding a new token here is an explicit platform +// decision, not something Extensions can opt into via passthrough. +export const ThemeTokenKeySchema = z.enum([ + "background", + "backgroundSecondary", + "foreground", + "border", + "accent", + "accentForeground", + "muted", + "mutedForeground", + "surfacePrimary", + "surfaceSecondary", + "surfaceTertiary", + "destructive", + "destructiveForeground", + "success", + "successForeground", +]); + +export const ThemeTokensSchema = z.partialRecord(ThemeTokenKeySchema, z.string().min(1)); + +// Build a descriptor schema for a contribution type: +// - V1 object requires `descriptorVersion: 1` plus the type-specific fields +// - `.passthrough()` lets additive optional fields stay at v1 (the validator +// emits info diagnostics for unrecognized keys; US-004) +// - Discriminated union rejects unknown descriptorVersion values cleanly +// - Preprocess injects `descriptorVersion: 1` when absent so authors do not +// need to repeat the literal in every contribution +function makeDescriptorSchema(fields: T) { + const v1 = z.object({ descriptorVersion: z.literal(1), ...fields }).passthrough(); + return z.preprocess( + (input) => { + if (input === null || typeof input !== "object" || Array.isArray(input)) return input; + const obj = input as Record; + return "descriptorVersion" in obj ? obj : { ...obj, descriptorVersion: 1 }; + }, + z.discriminatedUnion("descriptorVersion", [v1]) + ); +} + +export const SkillDescriptorSchema = makeDescriptorSchema({ + // Extension skills flow into the agent skill registry, whose public names are + // capped by SkillNameSchema. Keep manifest validation aligned so a skill that + // validates here cannot be silently skipped later by skill discovery. + id: SkillNameSchema, + body: RelativeBodyPathSchema, + displayName: z.string().nullish(), + description: z.string().nullish(), + advertise: z.boolean().nullish(), +}); + +export const AgentDescriptorSchema = makeDescriptorSchema({ + id: ContributionIdSchema, + body: RelativeBodyPathSchema, + displayName: z.string().nullish(), + description: z.string().nullish(), +}); + +export const ThemeDescriptorSchema = makeDescriptorSchema({ + id: ContributionIdSchema, + displayName: z.string().nullish(), + tokens: ThemeTokensSchema, +}); + +export const LayoutDescriptorSchema = makeDescriptorSchema({ + id: ContributionIdSchema, + displayName: z.string().nullish(), + preset: LayoutPresetSchema, +}); + +export const RuntimePresetDescriptorSchema = makeDescriptorSchema({ + id: ContributionIdSchema, + displayName: z.string().nullish(), + runtime: RuntimeConfigSchema, +}); + +export const CommandDescriptorSchema = makeDescriptorSchema({ + id: ContributionIdSchema, + target: CommandTargetIdSchema, + title: z.string().min(1), + description: z.string().nullish(), +}); + +// Provisional Descriptors (inspection-only contribution types). +// +// The six schemas below cover Runtime Driver, Tool, MCP Server, Panel, +// Agent Lifecycle Hook, and Secret Provider. They are descriptor-only: +// no executable handler reference, no view/render hook, no runtime config — +// just identity plus inspection metadata so authors can declare them in v1 +// manifests and Mux can surface them in the Extensions Settings Section. +// +// Per ADR-0002 and the v1 contribution support level table, these types +// remain `inspection-only` until Mux defines a Host API. Their schemas may +// evolve in **breaking** ways before reaching `available` Contribution +// Support Level *without bumping descriptorVersion*; authors targeting +// Provisional Descriptors must accept that schemas may change. +// +// `.passthrough()` (via makeDescriptorSchema) tolerates additive optional +// fields at v1; the Manifest Validator (US-004) emits info-severity +// diagnostics for unrecognized keys. + +export const RuntimeDriverDescriptorSchema = makeDescriptorSchema({ + id: ContributionIdSchema, + displayName: z.string().nullish(), + description: z.string().nullish(), +}); + +export const ToolDescriptorSchema = makeDescriptorSchema({ + id: ContributionIdSchema, + displayName: z.string().nullish(), + description: z.string().nullish(), +}); + +export const McpServerDescriptorSchema = makeDescriptorSchema({ + id: ContributionIdSchema, + displayName: z.string().nullish(), + description: z.string().nullish(), +}); + +export const PanelDescriptorSchema = makeDescriptorSchema({ + id: ContributionIdSchema, + displayName: z.string().nullish(), + description: z.string().nullish(), +}); + +export const AgentLifecycleHookDescriptorSchema = makeDescriptorSchema({ + id: ContributionIdSchema, + displayName: z.string().nullish(), + description: z.string().nullish(), +}); + +export const SecretProviderDescriptorSchema = makeDescriptorSchema({ + id: ContributionIdSchema, + displayName: z.string().nullish(), + description: z.string().nullish(), +}); + +export type ExtensionModuleManifest = z.infer; + +export type SkillDescriptor = z.infer; +export type AgentDescriptor = z.infer; +export type ThemeDescriptor = z.infer; +export type LayoutDescriptor = z.infer; +export type RuntimePresetDescriptor = z.infer; +export type CommandDescriptor = z.infer; +export type RuntimeDriverDescriptor = z.infer; +export type ToolDescriptor = z.infer; +export type McpServerDescriptor = z.infer; +export type PanelDescriptor = z.infer; +export type AgentLifecycleHookDescriptor = z.infer; +export type SecretProviderDescriptor = z.infer; +export type ThemeTokenKey = z.infer; + +// Contribution lists stay as `unknown[]` at the envelope level so the Manifest +// Validator (US-004) can apply each type's descriptor schema per-element and +// emit contribution-level diagnostics without failing the whole manifest. +const ContributionDescriptorListSchema = z.array(z.unknown()).nullish(); + +export const ExtensionContributesV1Schema = z + .object({ + skills: ContributionDescriptorListSchema, + agents: ContributionDescriptorListSchema, + themes: ContributionDescriptorListSchema, + layouts: ContributionDescriptorListSchema, + runtimePresets: ContributionDescriptorListSchema, + commands: ContributionDescriptorListSchema, + runtimeDrivers: ContributionDescriptorListSchema, + tools: ContributionDescriptorListSchema, + mcpServers: ContributionDescriptorListSchema, + panels: ContributionDescriptorListSchema, + agentLifecycleHooks: ContributionDescriptorListSchema, + secretProviders: ContributionDescriptorListSchema, + }) + .strict(); + +export const ExtensionManifestV1Schema = z + .object({ + manifestVersion: z.literal(1), + id: ExtensionIdentitySchema, + contributes: ExtensionContributesV1Schema, + displayName: z.string().nullish(), + description: z.string().nullish(), + publisher: z.string().nullish(), + homepage: z.string().nullish(), + requestedPermissions: z.array(z.string()).nullish(), + }) + .passthrough(); + +export const ExtensionManifestSchema = z.discriminatedUnion("manifestVersion", [ + ExtensionManifestV1Schema, +]); + +export type ExtensionContributesV1 = z.infer; +export type ExtensionManifestV1 = z.infer; +export type ExtensionManifest = z.infer; diff --git a/src/common/orpc/schemas/extensionRegistry.test.ts b/src/common/orpc/schemas/extensionRegistry.test.ts new file mode 100644 index 0000000000..91bd1625c6 --- /dev/null +++ b/src/common/orpc/schemas/extensionRegistry.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; + +import { + DiscoveredExtensionSchema, + UnavailableReasonSchema, + extensions, +} from "./extensionRegistry"; + +describe("DiscoveredExtensionSchema", () => { + test("uses Extension Module path fields instead of package source identity fields", () => { + const parsed = DiscoveredExtensionSchema.parse({ + extensionId: "acme-review", + rootId: "user-global", + rootKind: "user-global", + isCore: false, + modulePath: "/tmp/acme-review", + manifest: { + manifestVersion: 1, + id: "acme-review", + requestedPermissions: [], + contributions: [], + }, + contributions: [], + diagnostics: [], + enabled: true, + granted: true, + activated: true, + }); + + expect(parsed.modulePath).toBe("/tmp/acme-review"); + expect("packagePath" in parsed).toBe(false); + expect("packageName" in parsed).toBe(false); + expect("packageVersion" in parsed).toBe(false); + }); +}); + +describe("UnavailableReasonSchema", () => { + test("uses approval terminology for capability drift", () => { + expect(UnavailableReasonSchema.safeParse("pending-reapproval").success).toBe(true); + expect(UnavailableReasonSchema.safeParse("pending-regrant").success).toBe(false); + }); +}); + +describe("extensions approval routes", () => { + test("uses approve/revokeApproval route names instead of grant/revoke", () => { + const routeNames = Object.keys(extensions); + + expect(routeNames).toContain("approve"); + expect(routeNames).toContain("revokeApproval"); + expect(routeNames).not.toContain("grant"); + expect(routeNames).not.toContain("revoke"); + }); +}); diff --git a/src/common/orpc/schemas/extensionRegistry.ts b/src/common/orpc/schemas/extensionRegistry.ts new file mode 100644 index 0000000000..ee381c5c44 --- /dev/null +++ b/src/common/orpc/schemas/extensionRegistry.ts @@ -0,0 +1,235 @@ +/** + * Schemas for the extensions ORPC API surface. + * + * These mirror the runtime types exported by + * `src/node/extensions/extensionRegistryService.ts` and the manifest / + * discovery / permission / conflict-resolver modules they depend on. + * + * Mutators in this API target Extensions with `{ rootId, extensionId }`; + * `rootId` is opaque to the IPC and resolved on the backend against the + * registry's current snapshot. For project-local roots the `rootId` includes + * the project path so multi-project hosts disambiguate. Stale records expose a + * synthetic `rootId` so the same `{ rootId, extensionId }` pair works through + * `forgetStale`. + */ +import { eventIterator } from "@orpc/server"; +import { z } from "zod"; +import { ExtensionNameSchema, ExtensionRuntimeIdSchema } from "./extension"; + +export const RootKindSchema = z.enum(["bundled", "user-global", "project-local"]); + +export const ExtensionDiagnosticSeveritySchema = z.enum(["error", "warn", "info"]); + +export const ExtensionDiagnosticContributionRefSchema = z.object({ + type: z.string(), + index: z.number().int().nonnegative().nullish(), + id: z.string().nullish(), +}); + +export const ExtensionDiagnosticSchema = z.object({ + code: z.string(), + severity: ExtensionDiagnosticSeveritySchema, + message: z.string(), + rootId: z.string().nullish(), + extensionId: z.string().nullish(), + contributionRef: ExtensionDiagnosticContributionRefSchema.nullish(), + suggestedAction: z.string().nullish(), + occurredAt: z.number(), +}); + +export const ValidatedContributionSchema = z.object({ + type: z.string(), + id: z.string(), + index: z.number().int().nonnegative(), + // Descriptor shape varies per contribution type; the manifest validator has + // already accepted it against the matching descriptor schema, so we surface + // it as a record. + descriptor: z.record(z.string(), z.unknown()), +}); + +export const ValidatedManifestSchema = z.object({ + manifestVersion: z.literal(1), + id: z.string(), + displayName: z.string().nullish(), + description: z.string().nullish(), + publisher: z.string().nullish(), + homepage: z.string().nullish(), + requestedPermissions: z.array(z.string()), + contributions: z.array(ValidatedContributionSchema), +}); + +export const DiscoveredContributionSchema = z.object({ + type: z.string(), + id: z.string(), + index: z.number().int().nonnegative(), + bodyPath: z.string().nullish(), + activated: z.boolean(), +}); + +export const DiscoveredExtensionSchema = z.object({ + extensionId: ExtensionRuntimeIdSchema, + rootId: z.string(), + rootKind: RootKindSchema, + isCore: z.boolean(), + modulePath: z.string(), + manifest: ValidatedManifestSchema, + contributions: z.array(DiscoveredContributionSchema), + diagnostics: z.array(ExtensionDiagnosticSchema), + enabled: z.boolean(), + granted: z.boolean(), + activated: z.boolean(), +}); + +export const RootDiscoveryStateSchema = z.enum(["pending", "running", "ready", "failed"]); + +export const RootDiscoveryResultSchema = z.object({ + rootId: z.string(), + kind: RootKindSchema, + path: z.string(), + trusted: z.boolean(), + rootExists: z.boolean(), + state: RootDiscoveryStateSchema, + extensions: z.array(DiscoveredExtensionSchema), + diagnostics: z.array(ExtensionDiagnosticSchema), +}); + +export const AvailableContributionSchema = z.object({ + type: z.string(), + id: z.string(), + extensionId: ExtensionRuntimeIdSchema, + rootId: z.string(), + rootKind: RootKindSchema, +}); + +export const UnavailableReasonSchema = z.enum([ + "untrusted-root", + "disabled", + "ungranted", + "missing-permissions", + "pending-reapproval", + "body-failed", + "not-activated", + "inspection-only", + "conflict", +]); + +export const InspectionDescriptorSchema = z.object({ + type: z.string(), + id: z.string(), + extensionId: ExtensionRuntimeIdSchema, + rootId: z.string(), + rootKind: RootKindSchema, + available: z.boolean(), + unavailableReasons: z.array(UnavailableReasonSchema), + missingPermissions: z.array(z.string()), +}); + +export const ApprovalRecordSchema = z.object({ + grantedPermissions: z.array(z.string()), + requestedPermissionsHash: z.string(), +}); + +export const DriftStatusSchema = z.enum(["fresh", "permissions-changed"]); + +export const ContributionAvailabilitySchema = z.object({ + type: z.string(), + id: z.string(), + available: z.boolean(), + missingPermissions: z.array(z.string()), +}); + +export const CalculatePermissionsResultSchema = z.object({ + effectivePermissions: z.array(z.string()), + pendingNew: z.array(z.string()), + contributions: z.array(ContributionAvailabilitySchema), + driftStatus: DriftStatusSchema.nullable(), + isStale: z.boolean(), +}); + +export const StaleRecordSchema = z.object({ + scope: z.enum(["global", "project-local"]), + projectPath: z.string().nullish(), + extensionId: ExtensionRuntimeIdSchema, + approval: ApprovalRecordSchema, + rootId: z.string(), +}); + +export const RegistrySnapshotSchema = z.object({ + generatedAt: z.number(), + roots: z.array(RootDiscoveryResultSchema), + availableContributions: z.array(AvailableContributionSchema), + resolverDiagnostics: z.array(ExtensionDiagnosticSchema), + descriptors: z.array(InspectionDescriptorSchema), + permissions: z.record(z.string(), CalculatePermissionsResultSchema), + staleRecords: z.array(StaleRecordSchema), +}); + +export const GitExtensionInstallResultSchema = z + .object({ + extensionName: ExtensionNameSchema, + resolvedSha: z.string().regex(/^[0-9a-f]{40}$/u), + contentHash: z.string().min(1), + storePath: z.string().min(1), + activePath: z.string().min(1), + }) + .strict(); + +const RootIdInputSchema = z.object({ rootId: z.string().min(1) }).strict(); +const ExtensionTargetSchema = z + .object({ rootId: z.string().min(1), extensionId: ExtensionRuntimeIdSchema }) + .strict(); + +export const extensions = { + // Returns the current registry snapshot. `null` until the first reload (e.g., + // before the registry has been initialized). + list: { + input: z.void(), + output: RegistrySnapshotSchema.nullable(), + }, + // Subscription: emits when the live snapshot is replaced. Frontend re-fetches + // `list` on each notification. Multicasts to multiple subscribers. + onChanged: { + input: z.void(), + output: eventIterator(z.void()), + }, + installGitSource: { + input: z.object({ coordinate: z.string().min(1) }).strict(), + output: GitExtensionInstallResultSchema, + }, + initializeUserRoot: { + input: z.void(), + output: z.void(), + }, + reload: { + input: z.object({ rootId: z.string().min(1).nullish() }).strict(), + output: z.void(), + }, + trustRoot: { + input: RootIdInputSchema, + output: z.void(), + }, + untrustRoot: { + input: RootIdInputSchema, + output: z.void(), + }, + enable: { + input: ExtensionTargetSchema, + output: z.void(), + }, + disable: { + input: ExtensionTargetSchema, + output: z.void(), + }, + approve: { + input: ExtensionTargetSchema, + output: z.void(), + }, + revokeApproval: { + input: ExtensionTargetSchema, + output: z.void(), + }, + forgetStale: { + input: ExtensionTargetSchema, + output: z.void(), + }, +}; diff --git a/src/common/orpc/schemas/policy.test.ts b/src/common/orpc/schemas/policy.test.ts index 7bdace94ae..23d67acbe6 100644 --- a/src/common/orpc/schemas/policy.test.ts +++ b/src/common/orpc/schemas/policy.test.ts @@ -40,8 +40,28 @@ describe("policy provider ids", () => { ], mcp: { allowUserDefined: { stdio: true, remote: true } }, runtimes: null, + extensionPlatform: null, }); expect(parsed.success).toBe(true); }); + + test("PolicyFileSchema accepts deprecated extensionPlatform for compatibility", () => { + const parsed = PolicyFileSchema.safeParse({ + policy_format_version: "0.1", + extensionPlatform: false, + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.extensionPlatform).toBe(false); + } + }); + + test("PolicyFileSchema rejects unknown top-level fields", () => { + const parsed = PolicyFileSchema.safeParse({ + policy_format_version: "0.1", + extensionPlatfrom: false, + }); + expect(parsed.success).toBe(false); + }); }); diff --git a/src/common/orpc/schemas/policy.ts b/src/common/orpc/schemas/policy.ts index 10515ba72a..938b810d6c 100644 --- a/src/common/orpc/schemas/policy.ts +++ b/src/common/orpc/schemas/policy.ts @@ -73,6 +73,11 @@ export const PolicyFileSchema = z // Empty/undefined means "allow all". runtimes: z.array(PolicyRuntimeAccessSchema).optional(), + + // Deprecated compatibility field. Extension Platform is always initialized; + // older policies that still include this key continue to parse, but the + // value is ignored by clients. + extensionPlatform: z.boolean().optional(), }) .strict(); export type PolicyFile = z.infer; @@ -114,6 +119,9 @@ export const EffectivePolicySchema = z // null means "allow all runtimes". runtimes: z.array(PolicyRuntimeIdSchema).nullable(), + + // Deprecated compatibility field. Always ignored by clients. + extensionPlatform: z.boolean().nullable(), }) .strict(); export type EffectivePolicy = z.infer; diff --git a/src/common/types/message.ts b/src/common/types/message.ts index e551ad7607..b0cc8d709b 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -349,7 +349,7 @@ export type MuxMessageMetadata = MuxMessageMetadataBase & /** The original /{skillName} invocation as typed by user (for display) */ rawCommand: string; skillName: string; - scope: "project" | "global" | "built-in"; + scope: "project" | "global" | "extension" | "built-in"; } | { type: "plan-display"; // Ephemeral plan display from /plan command diff --git a/src/common/types/project.ts b/src/common/types/project.ts index cf735a605e..617ba9cc01 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -190,4 +190,12 @@ export interface ProjectsConfig { /** Optional 1Password account name used for desktop SDK account selection. */ onePasswordAccountName?: string; + + /** + * Raw extensions block of ~/.mux/config.json. Owned by the Global Extension + * State Store (US-008): validation, self-healing, and schemaVersion + * handling all live there. Config simply round-trips this value so unknown + * future schemaVersions stay on disk untouched. + */ + extensions?: unknown; } diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index d11cc20e75..057eaf121a 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -1,3 +1,4 @@ +import type { ExtensionSkillSource } from "@/common/extensions/extensionSkillSource"; import { type ImageModel, type LanguageModel, type Tool } from "ai"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { cloneToolPreservingDescriptors } from "@/common/utils/tools/cloneToolPreservingDescriptors"; @@ -160,6 +161,7 @@ export interface ToolConfiguration { availableSubagents?: AgentDefinitionDescriptor[]; /** Available skills for the agent_skill_read tool description (dynamic context) */ availableSkills?: AgentSkillDescriptor[]; + extensionSkills?: ExtensionSkillSource[]; /** Whether the project is trusted for hook/script execution */ trusted?: boolean; /** Analytics service for raw SQL queries against DuckDB analytics data */ diff --git a/src/node/builtinSkills/mux-docs.md b/src/node/builtinSkills/mux-docs.md index 786ad58e5b..12252cdca4 100644 --- a/src/node/builtinSkills/mux-docs.md +++ b/src/node/builtinSkills/mux-docs.md @@ -100,6 +100,10 @@ Use this index to find a page's: - **Integrations** - VS Code Extension (`/integrations/vscode-extension`) → `references/docs/integrations/vscode-extension.mdx` — Pair Mux workspaces with VS Code and Cursor editors - ACP (Editor Integrations) (`/integrations/acp`) → `references/docs/integrations/acp.mdx` — Connect Mux to Zed, Neovim, and JetBrains via the Agent Client Protocol + - **Extensions** + - Authoring an Extension Module (`/extensions/authoring`) → `references/docs/extensions/authoring.mdx` — Quickstart for authoring a Mux Extension Module with a static manifest and skill registration. + - Extension Telemetry (`/extensions/telemetry`) → `references/docs/extensions/telemetry.mdx` — Full v1 events catalog for the Mux Extension Platform, including the provenance gate that blocks third-party identifiers from leaving your machine. + - Extension Platform Release Checklist (`/extensions/release-checklist`) → `references/docs/extensions/release-checklist.mdx` — Pre-release dogfood checklist for the Mux Extension Platform with screenshot/video evidence requirements. - **Reference** - Debugging (`/reference/debugging`) → `references/docs/reference/debugging.mdx` — View live backend logs and diagnose issues - Telemetry (`/reference/telemetry`) → `references/docs/reference/telemetry.mdx` — What Mux collects, what it doesn’t, and how to disable it diff --git a/src/node/config.ts b/src/node/config.ts index 918c172bb6..071798a723 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -913,6 +913,7 @@ export class Config { defaultRuntime, runtimeEnablement, onePasswordAccountName: parseOptionalNonEmptyString(parsed.onePasswordAccountName), + extensions: parsed.extensions, }; } } catch (error) { @@ -1161,6 +1162,14 @@ export class Config { data.onePasswordAccountName = onePasswordAccountName; } + // Round-trip the extensions block verbatim. The Global Extension State + // Store (US-008) owns shape validation and self-healing; persisting + // unknown future schemaVersions on disk is a hard requirement so older + // builds never destroy decisions made by newer builds. + if (config.extensions !== undefined) { + data.extensions = config.extensions; + } + await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8"); } catch (error) { log.error("Error saving config:", error); diff --git a/src/node/extensions/bundledExtensionRootResolver.test.ts b/src/node/extensions/bundledExtensionRootResolver.test.ts new file mode 100644 index 0000000000..0b0693009f --- /dev/null +++ b/src/node/extensions/bundledExtensionRootResolver.test.ts @@ -0,0 +1,198 @@ +import * as fs from "fs"; +import { access, mkdir, writeFile } from "fs/promises"; +import { constants } from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + BUNDLED_EXTENSIONS_DEV_SUBDIR, + BUNDLED_EXTENSIONS_PACKAGED_SUBDIR, + detectBundledExtensionRoot, + resolveBundledExtensionRoot, +} from "./bundledExtensionRootResolver"; + +async function pathExists(p: string): Promise { + try { + await access(p, constants.F_OK); + return true; + } catch { + return false; + } +} + +describe("resolveBundledExtensionRoot — pure resolution", () => { + test("dev mode resolves to /build/extensions", () => { + const result = resolveBundledExtensionRoot({ + isPackagedElectron: false, + repoRoot: "/repo", + resourcesPath: undefined, + }); + expect(result.mode).toBe("dev"); + expect(result.path).toBe(path.join("/repo", BUNDLED_EXTENSIONS_DEV_SUBDIR)); + }); + + test("packaged mode resolves to /extensions", () => { + const result = resolveBundledExtensionRoot({ + isPackagedElectron: true, + repoRoot: "/repo", + resourcesPath: "/Applications/Mux.app/Contents/Resources", + }); + expect(result.mode).toBe("packaged"); + expect(result.path).toBe( + path.join("/Applications/Mux.app/Contents/Resources", BUNDLED_EXTENSIONS_PACKAGED_SUBDIR) + ); + }); + + test("packaged mode without resourcesPath throws", () => { + expect(() => + resolveBundledExtensionRoot({ + isPackagedElectron: true, + repoRoot: "/repo", + resourcesPath: undefined, + }) + ).toThrow(/resourcesPath/iu); + }); + + test("dev mode without repoRoot throws", () => { + expect(() => + resolveBundledExtensionRoot({ + isPackagedElectron: false, + repoRoot: "", + resourcesPath: undefined, + }) + ).toThrow(/repoRoot/iu); + }); +}); + +describe("resolveBundledExtensionRoot — dev fixture filesystem", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-bundled-resolver-dev-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("returns a path that exists when build/extensions is assembled", async () => { + const extensionsDir = path.join(tempDir, "build", "extensions"); + const demoDir = path.join( + extensionsDir, + "node_modules", + "@coder", + "mux-extension-platform-demo" + ); + await mkdir(demoDir, { recursive: true }); + await writeFile( + path.join(extensionsDir, "package.json"), + JSON.stringify({ dependencies: { "@coder/mux-extension-platform-demo": "0.0.1" } }) + ); + await writeFile( + path.join(demoDir, "package.json"), + JSON.stringify({ name: "@coder/mux-extension-platform-demo", version: "0.0.1" }) + ); + + const result = resolveBundledExtensionRoot({ + isPackagedElectron: false, + repoRoot: tempDir, + resourcesPath: undefined, + }); + + expect(result.path).toBe(extensionsDir); + expect(await pathExists(result.path)).toBe(true); + expect( + await pathExists( + path.join( + result.path, + "node_modules", + "@coder", + "mux-extension-platform-demo", + "package.json" + ) + ) + ).toBe(true); + }); + + test("returns the path even when build/extensions does not exist (caller decides how to handle)", async () => { + const result = resolveBundledExtensionRoot({ + isPackagedElectron: false, + repoRoot: tempDir, + resourcesPath: undefined, + }); + + expect(result.path).toBe(path.join(tempDir, "build", "extensions")); + expect(await pathExists(result.path)).toBe(false); + }); +}); + +describe("resolveBundledExtensionRoot — packaged fixture filesystem", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-bundled-resolver-pkg-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("returns a path that exists when the resources/extensions tree is assembled", async () => { + const extensionsDir = path.join(tempDir, "extensions"); + const nodeModules = path.join(extensionsDir, "node_modules"); + const demoDir = path.join(nodeModules, "@coder", "mux-extension-platform-demo"); + await mkdir(demoDir, { recursive: true }); + await writeFile( + path.join(demoDir, "package.json"), + JSON.stringify({ name: "@coder/mux-extension-platform-demo", version: "0.0.1" }) + ); + + const result = resolveBundledExtensionRoot({ + isPackagedElectron: true, + repoRoot: "/unused", + resourcesPath: tempDir, + }); + + expect(result.path).toBe(extensionsDir); + expect(await pathExists(result.path)).toBe(true); + expect( + await pathExists( + path.join( + result.path, + "node_modules", + "@coder", + "mux-extension-platform-demo", + "package.json" + ) + ) + ).toBe(true); + }); + + test("ignores repoRoot in packaged mode", () => { + const result = resolveBundledExtensionRoot({ + isPackagedElectron: true, + repoRoot: "/should/be/ignored", + resourcesPath: tempDir, + }); + + expect(result.path.startsWith(tempDir)).toBe(true); + expect(result.path.includes("/should/be/ignored")).toBe(false); + }); +}); + +describe("detectBundledExtensionRoot — process-driven detection", () => { + test("under bun (no electron), resolves dev-mode path off the module location", () => { + const originalCwd = process.cwd(); + const tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), "mux-bundled-resolver-cwd-")); + try { + process.chdir(tempCwd); + const result = detectBundledExtensionRoot(); + expect(result.mode).toBe("dev"); + expect(result.path).toBe(path.join(originalCwd, BUNDLED_EXTENSIONS_DEV_SUBDIR)); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempCwd, { recursive: true, force: true }); + } + }); +}); diff --git a/src/node/extensions/bundledExtensionRootResolver.ts b/src/node/extensions/bundledExtensionRootResolver.ts new file mode 100644 index 0000000000..a153c8fdcb --- /dev/null +++ b/src/node/extensions/bundledExtensionRootResolver.ts @@ -0,0 +1,75 @@ +import * as path from "node:path"; + +// Dev mode uses the same assembled Extension Root shape as packaged Electron: +// direct child Extension Module folders under build/extensions. Packaged +// Electron copies that tree to process.resourcesPath/extensions via +// extraResources. Discovery Service consumes the resolved path without branching +// on environment. +export const BUNDLED_EXTENSIONS_DEV_SUBDIR = path.join("build", "extensions"); +export const BUNDLED_EXTENSIONS_PACKAGED_SUBDIR = "extensions"; + +export type BundledExtensionRootMode = "dev" | "packaged"; + +export interface ResolvedBundledExtensionRoot { + mode: BundledExtensionRootMode; + path: string; +} + +export interface BundledExtensionRootEnv { + isPackagedElectron: boolean; + repoRoot: string; + resourcesPath: string | undefined; +} + +export function resolveBundledExtensionRoot( + env: BundledExtensionRootEnv +): ResolvedBundledExtensionRoot { + if (env.isPackagedElectron) { + if (!env.resourcesPath) { + throw new Error( + "Cannot resolve bundled extension root: resourcesPath is required in packaged Electron mode" + ); + } + return { + mode: "packaged", + path: path.join(env.resourcesPath, BUNDLED_EXTENSIONS_PACKAGED_SUBDIR), + }; + } + if (!env.repoRoot) { + throw new Error("Cannot resolve bundled extension root: repoRoot is required in dev mode"); + } + return { + mode: "dev", + path: path.join(env.repoRoot, BUNDLED_EXTENSIONS_DEV_SUBDIR), + }; +} + +// Mirrors `detectCliEnvironment()` in src/cli/argv.ts; inlined because node/ +// cannot import from cli/ (local/no-cross-boundary-imports). +function detectIsPackagedElectron(): boolean { + return "electron" in process.versions && !process.defaultApp; +} + +function pathBeforeSegment(dir: string, segment: string): string | null { + const parts = dir.split(path.sep); + const index = parts.lastIndexOf(segment); + if (index === -1) return null; + const candidate = parts.slice(0, index).join(path.sep); + return candidate.length > 0 ? candidate : path.parse(dir).root; +} + +function getModuleRepoRoot(): string { + return ( + pathBeforeSegment(__dirname, "dist") ?? + pathBeforeSegment(__dirname, "src") ?? + path.resolve(__dirname, "..", "..", "..") + ); +} + +export function detectBundledExtensionRoot(): ResolvedBundledExtensionRoot { + return resolveBundledExtensionRoot({ + isPackagedElectron: detectIsPackagedElectron(), + resourcesPath: process.resourcesPath, + repoRoot: getModuleRepoRoot(), + }); +} diff --git a/src/node/extensions/bundledExtensionsAssemble.test.ts b/src/node/extensions/bundledExtensionsAssemble.test.ts new file mode 100644 index 0000000000..0e756d541d --- /dev/null +++ b/src/node/extensions/bundledExtensionsAssemble.test.ts @@ -0,0 +1,182 @@ +import * as fs from "fs"; +import { access, mkdir, readFile, writeFile } from "fs/promises"; +import { spawnSync } from "node:child_process"; +import { constants } from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { resolveBundledExtensionRoot } from "./bundledExtensionRootResolver"; +import { discoverExtensions, type ExtensionRootDescriptor } from "./extensionDiscoveryService"; + +// Test runs from the repo root (`bun test src` invocation pattern). Relying on +// process.cwd() keeps the test compatible with the CommonJS tsconfig.main.json +// (no import.meta.dir). +const REPO_ROOT = process.cwd(); +const ASSEMBLE_SCRIPT = path.join(REPO_ROOT, "scripts", "bundled-extensions.ts"); +const DEMO_EXTENSION_NAME = "mux-platform-demo"; + +async function pathExists(p: string): Promise { + try { + await access(p, constants.F_OK); + return true; + } catch { + return false; + } +} + +describe("bundled-extensions-assemble", () => { + let tempDir: string; + let outDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-bundled-asm-")); + outDir = path.join(tempDir, "extensions"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("validate fails when a bundled extension package has malformed package.json", async () => { + const packagesDir = path.join(tempDir, "packages"); + const badPackageDir = path.join(packagesDir, "bad-extension"); + await mkdir(badPackageDir, { recursive: true }); + await writeFile(path.join(badPackageDir, "package.json"), "{ nope\n"); + await writeFile( + path.join(badPackageDir, "extension.ts"), + "export const manifest = { name: 'bad-extension', capabilities: { skills: true } };\n" + ); + + const result = spawnSync("bun", [ASSEMBLE_SCRIPT, "validate"], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { ...process.env, MUX_BUNDLED_EXTENSIONS_PACKAGES_DIR: packagesDir }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("bad-extension"); + expect(result.stderr).toContain("package.json"); + }); + + test("validate fails when a bundled package has a malformed extension.ts", async () => { + const packagesDir = path.join(tempDir, "packages"); + const badPackageDir = path.join(packagesDir, "bad-extension"); + const rootPkg = JSON.parse(await readFile(path.join(REPO_ROOT, "package.json"), "utf-8")) as { + version: string; + }; + await mkdir(badPackageDir, { recursive: true }); + await writeFile( + path.join(badPackageDir, "package.json"), + JSON.stringify({ name: "bad-extension", version: rootPkg.version }, null, 2) + ); + await writeFile( + path.join(badPackageDir, "extension.ts"), + "export const manifest = createManifestDynamically();\n" + ); + + const result = spawnSync("bun", [ASSEMBLE_SCRIPT, "validate"], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { ...process.env, MUX_BUNDLED_EXTENSIONS_PACKAGES_DIR: packagesDir }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("bad-extension"); + expect(result.stderr).toContain("Static Manifest"); + }); + + test("assembled directory tree contains the Demo Extension and resolves correctly", async () => { + // Run the production assemble pipeline against an isolated out-dir. This + // is the same code path Make wires into build / static-check / dev / test. + const result = spawnSync("bun", [ASSEMBLE_SCRIPT, "assemble", "--out", outDir], { + cwd: REPO_ROOT, + encoding: "utf-8", + }); + expect(result.status).toBe(0); + + // Tree shape: /mux-platform-demo/{extension.ts,SKILL.md} + const demoModuleDir = path.join(outDir, DEMO_EXTENSION_NAME); + expect(await pathExists(path.join(demoModuleDir, "extension.ts"))).toBe(true); + expect(await pathExists(path.join(demoModuleDir, "SKILL.md"))).toBe(true); + + // Discovery Service: the assembled root must validate end-to-end as a + // bundled root and surface the demo extension's `mux-extensions` skill. + const root: ExtensionRootDescriptor = { + rootId: "bundled", + kind: "bundled", + path: outDir, + }; + const grantedExtensionIds = new Set([DEMO_EXTENSION_NAME]); + const snapshot = await discoverExtensions({ + roots: [root], + state: { + isEnabled: () => true, + getApprovalRecord: ({ extensionId }) => + grantedExtensionIds.has(extensionId) + ? { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: "test", + } + : undefined, + }, + }); + + expect(snapshot.roots).toHaveLength(1); + const rootResult = snapshot.roots[0]; + expect(rootResult.state).toBe("ready"); + expect(rootResult.rootExists).toBe(true); + expect(rootResult.diagnostics).toEqual([]); + + const demo = rootResult.extensions.find((e) => e.extensionId === DEMO_EXTENSION_NAME); + expect(demo).toBeDefined(); + expect(demo?.activated).toBe(true); + + const skill = demo?.contributions.find((c) => c.type === "skills" && c.id === "mux-extensions"); + expect(skill).toBeDefined(); + expect(skill?.activated).toBe(true); + expect(skill?.bodyPath).toBe("./SKILL.md"); + }, 30_000); + + test("packaged mode: resolver + assembled extraResources tree discovers Demo Extension", async () => { + // Simulate the packaged Electron layout: electron-builder's extraResources + // copies build/extensions into /extensions. We assemble into + // /extensions, then treat tempDir as the resourcesPath and assert + // the resolver lands on the assembled tree which Discovery can read. + const result = spawnSync("bun", [ASSEMBLE_SCRIPT, "assemble", "--out", outDir], { + cwd: REPO_ROOT, + encoding: "utf-8", + }); + expect(result.status).toBe(0); + + const resolved = resolveBundledExtensionRoot({ + isPackagedElectron: true, + repoRoot: "/unused", + resourcesPath: tempDir, + }); + expect(resolved.mode).toBe("packaged"); + expect(resolved.path).toBe(outDir); + expect(await pathExists(resolved.path)).toBe(true); + + const root: ExtensionRootDescriptor = { + rootId: "bundled", + kind: "bundled", + path: resolved.path, + }; + const snapshot = await discoverExtensions({ + roots: [root], + state: { + isEnabled: () => true, + getApprovalRecord: () => ({ + grantedPermissions: ["skill.register"], + requestedPermissionsHash: "test", + }), + }, + }); + + expect(snapshot.roots[0].state).toBe("ready"); + const demo = snapshot.roots[0].extensions.find((e) => e.extensionId === DEMO_EXTENSION_NAME); + expect(demo).toBeDefined(); + expect(demo?.activated).toBe(true); + }, 30_000); +}); diff --git a/src/node/extensions/extensionDiscoveryService.test.ts b/src/node/extensions/extensionDiscoveryService.test.ts new file mode 100644 index 0000000000..05e17d357f --- /dev/null +++ b/src/node/extensions/extensionDiscoveryService.test.ts @@ -0,0 +1,1929 @@ +import * as fs from "fs"; +import * as fsPromises from "fs/promises"; +import { mkdir, symlink, writeFile } from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; + +import { + PER_FILE_TIMEOUT_MS_DEFAULT, + PER_ROOT_TIMEOUT_MS_DEFAULT, + discoverExtensions, + type ExtensionRootDescriptor, +} from "./extensionDiscoveryService"; +import { MAX_FILE_SIZE } from "@/node/services/tools/fileCommon"; +import { QuickJSRuntime, QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime"; +import { hashRequestedPermissions } from "@/common/extensions/permissionCalculator"; +import type { ApprovalRecord } from "@/common/extensions/globalExtensionState"; + +const FROZEN_NOW = 1_700_000_000_000; + +interface PackageOpts { + name: string; + version?: string; + mux?: Record; +} + +async function writePackage( + packagePath: string, + opts: PackageOpts, + files?: Record +): Promise { + await mkdir(packagePath, { recursive: true }); + await writeFile( + path.join(packagePath, "package.json"), + JSON.stringify( + { + name: opts.name, + version: opts.version ?? "0.1.0", + ...(opts.mux !== undefined ? { mux: opts.mux } : {}), + }, + null, + 2 + ) + ); + if (files) { + for (const [rel, content] of Object.entries(files)) { + const filePath = path.join(packagePath, rel); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + } +} + +async function writeExtensionModule( + rootPath: string, + name: string, + extensionTs: string, + files?: Record +): Promise { + const modulePath = path.join(rootPath, name); + await mkdir(modulePath, { recursive: true }); + await writeFile(path.join(modulePath, "extension.ts"), extensionTs); + if (files) { + for (const [rel, content] of Object.entries(files)) { + const filePath = path.join(modulePath, rel); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + } + } +} + +function extensionTs(name: string): string { + return ` + import { defineManifest } from "mux:extensions"; + export const manifest = defineManifest({ + name: "${name}", + displayName: "${name}", + capabilities: { skills: true }, + }); + + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + `; +} + +async function writeRootPackage( + rootPath: string, + dependencies: Record +): Promise { + await mkdir(rootPath, { recursive: true }); + await writeFile( + path.join(rootPath, "package.json"), + JSON.stringify({ name: "mux-extension-root", version: "0.0.0", dependencies }, null, 2) + ); +} + +function rootDescriptor( + partial: Partial & { + rootId: string; + kind: ExtensionRootDescriptor["kind"]; + path: string; + } +): ExtensionRootDescriptor { + return { ...partial }; +} + +const SAMPLE_GRANT: ApprovalRecord = { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: hashRequestedPermissions(["skill.register"]), +}; + +describe("discoverExtensions — root existence", () => { + let tempDir: string; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-discovery-exists-")); + }); + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("missing root directory yields rootExists=false, state=ready, no diagnostics", async () => { + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ + rootId: "user-global", + kind: "user-global", + path: path.join(tempDir, "does-not-exist"), + }), + ], + now: FROZEN_NOW, + }); + expect(snapshot.roots).toHaveLength(1); + expect(snapshot.roots[0]).toMatchObject({ + rootExists: false, + state: "ready", + extensions: [], + diagnostics: [], + }); + }); + + test("existing root with no package.json yields ready+empty (no candidates)", async () => { + await mkdir(tempDir, { recursive: true }); + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + expect(snapshot.roots[0]).toMatchObject({ + rootExists: true, + state: "ready", + extensions: [], + diagnostics: [], + }); + }); + + test("package.json-only Extension Roots are ignored", async () => { + await writeRootPackage(tempDir, { "@legacy/package-extension": "0.1.0" }); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0]).toMatchObject({ + rootExists: true, + state: "ready", + extensions: [], + diagnostics: [], + }); + }); +}); + +describe("discoverExtensions — Extension Modules", () => { + let tempDir: string; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-discovery-modules-")); + }); + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("trusted user-global root discovers direct child folders with extension.ts", async () => { + await writeExtensionModule(tempDir, "acme-review", extensionTs("acme-review")); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0].state).toBe("ready"); + expect(snapshot.roots[0].extensions).toHaveLength(1); + expect(snapshot.roots[0].extensions[0]).toMatchObject({ + extensionId: "acme-review", + manifest: { id: "acme-review" }, + }); + }); + + test("Registration Discovery supports contained relative TypeScript imports", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + import { skillName } from "./helpers/skill"; + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: skillName, bodyPath: "./skills/review/SKILL.md" }); + } + `, + { + "helpers/skill.ts": `export const skillName = "review";`, + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.activated).toBe(true); + expect(extension.contributions[0]).toMatchObject({ + type: "skills", + id: "review", + activated: true, + }); + }); + + test("Registration Discovery ignores require text in comments and strings", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + const note = 'example: require("child_process") is not executed'; + // require("fs") is documentation, not an import. + export function activate(ctx) { + if (!note) throw new Error("expected note"); + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.activated).toBe(true); + expect( + extension.diagnostics.some( + (diagnostic) => diagnostic.code === "extension.discovery.import_unsupported" + ) + ).toBe(false); + }); + + test("Registration Discovery rejects relative imports that escape the Extension Module", async () => { + await writeFile(path.join(tempDir, "outside.ts"), `export const skillName = "review";`); + await writeExtensionModule( + tempDir, + "acme-review", + ` + import { skillName } from "../outside"; + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: skillName, bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.contributions).toEqual([]); + expect( + extension.diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.discovery.failed" && + diagnostic.message.includes("outside the Extension Module") + ) + ).toBe(true); + }); + + test("Registration Discovery rejects relative modules swapped to escaping symlinks before read", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + import { registerReview } from "./helper"; + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + registerReview(ctx); + } + `, + { + "helper.ts": + "export function registerReview(ctx) { throw new Error('inside helper should not execute'); }\n", + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + const helperPath = path.join(tempDir, "acme-review", "helper.ts"); + const outsideHelperPath = path.join(tempDir, "outside-helper.ts"); + await writeFile( + outsideHelperPath, + `export function registerReview(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + }\n` + ); + + const originalStat = fsPromises.stat; + const statSpy = spyOn(fsPromises, "stat"); + let swapped = false; + statSpy.mockImplementation((async (target: Parameters[0]) => { + const result = await originalStat(target); + if (!swapped && String(target) === helperPath) { + swapped = true; + await fsPromises.rm(helperPath, { force: true }); + await fsPromises.symlink(outsideHelperPath, helperPath); + } + return result; + }) as unknown as typeof fsPromises.stat); + + try { + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.contributions).toEqual([]); + expect( + extension.diagnostics.some( + (diagnostic) => diagnostic.code === "extension.discovery.read_failed" + ) + ).toBe(true); + } finally { + statSpy.mockRestore(); + } + }); + + test("Registration Discovery rejects oversized relative modules before reading", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + import { skillName } from "./huge"; + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: skillName, bodyPath: "./skills/review/SKILL.md" }); + } + `, + { + "huge.ts": `export const skillName = "review";\n${"x".repeat(MAX_FILE_SIZE + 1)}`, + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.contributions).toEqual([]); + expect( + extension.diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.discovery.read_failed" && + diagnostic.message.includes("too large") + ) + ).toBe(true); + }); + + test("Registration Discovery rejects npm and bare imports before execution", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + import leftPad from "left-pad"; + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: leftPad("review", 6), bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.contributions).toEqual([]); + expect( + extension.diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.discovery.import_unsupported" && + diagnostic.message.includes('"left-pad"') + ) + ).toBe(true); + }); + + test("trusted modules run Registration Discovery and preview registered skills", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + import { defineManifest } from "mux:extensions"; + export const manifest = defineManifest({ + name: "acme-review", + capabilities: { skills: true }, + }); + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0].extensions).toHaveLength(1); + const [extension] = snapshot.roots[0].extensions; + expect(extension.contributions).toEqual([ + { + type: "skills", + id: "review", + index: 0, + bodyPath: "./skills/review/SKILL.md", + activated: false, + }, + ]); + expect(extension.manifest.contributions[0]).toMatchObject({ + type: "skills", + id: "review", + descriptor: { id: "review", body: "./skills/review/SKILL.md" }, + }); + }); + + test("Registration Discovery runtime startup failure is scoped to one extension", async () => { + await writeExtensionModule(tempDir, "aaa-bad", extensionTs("aaa-bad")); + await writeExtensionModule(tempDir, "zzz-good", extensionTs("zzz-good")); + + let createCalls = 0; + const createSpy = spyOn(QuickJSRuntimeFactory.prototype, "create").mockImplementation(() => { + createCalls++; + if (createCalls === 1) return Promise.reject(new Error("quickjs unavailable")); + return QuickJSRuntime.create(); + }); + + try { + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + const root = snapshot.roots[0]; + expect(root.state).toBe("ready"); + expect(root.extensions.map((extension) => extension.extensionId).sort()).toEqual([ + "aaa-bad", + "zzz-good", + ]); + const failed = root.extensions.find((extension) => extension.extensionId === "aaa-bad"); + expect( + failed?.diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.discovery.failed" && + diagnostic.message.includes("quickjs unavailable") + ) + ).toBe(true); + const healthy = root.extensions.find((extension) => extension.extensionId === "zzz-good"); + expect(healthy?.manifest.contributions.map((contribution) => contribution.id)).toEqual([ + "review", + ]); + } finally { + createSpy.mockRestore(); + } + }); + + test("enabled and granted modules activate discovered skill bodies", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.activated).toBe(true); + expect(extension.contributions[0]).toMatchObject({ + type: "skills", + id: "review", + activated: true, + bodyPath: "./skills/review/SKILL.md", + }); + expect(extension.contributions[0].bodyRealPath).toBe( + path.join(tempDir, "acme-review", "skills", "review", "SKILL.md") + ); + }); + + test("Registration Discovery preserves static requested permissions", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + requestedPermissions: ["network"], + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.manifest.requestedPermissions).toEqual(["network", "skill.register"]); + expect(extension.activated).toBe(false); + }); + + test("Full Activation requires an approval that covers current requested permissions", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + if (ctx.mode === "activate") throw new Error("stale approval should not activate"); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + const staleApproval: ApprovalRecord = { + grantedPermissions: [], + requestedPermissionsHash: hashRequestedPermissions([]), + }; + const incompleteApproval: ApprovalRecord = { + grantedPermissions: [], + requestedPermissionsHash: hashRequestedPermissions(["skill.register"]), + }; + + for (const approval of [staleApproval, incompleteApproval]) { + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => approval, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.granted).toBe(true); + expect(extension.activated).toBe(false); + expect(extension.contributions[0]).toMatchObject({ + type: "skills", + id: "review", + activated: false, + }); + expect( + extension.diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.activation.failed" && + diagnostic.message.includes("stale approval should not activate") + ) + ).toBe(false); + } + }); + + test("Full Activation skips activation-only registrations that requested no permissions", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + if (ctx.mode === "discover") return; + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + throw new Error("activation-only code should not run"); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + const emptyApproval: ApprovalRecord = { + grantedPermissions: [], + requestedPermissionsHash: hashRequestedPermissions([]), + }; + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => emptyApproval, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.manifest.requestedPermissions).toEqual([]); + expect(extension.manifest.contributions).toEqual([]); + expect(extension.activated).toBe(false); + expect( + extension.diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.activation.failed" && + diagnostic.message.includes("activation-only code should not run") + ) + ).toBe(false); + }); + + test("Activation Discovery awaits async activate before publishing discovered skill bodies", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export async function activate(ctx) { + await Promise.resolve(); + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.activated).toBe(true); + expect(extension.contributions[0]).toMatchObject({ + type: "skills", + id: "review", + activated: true, + bodyPath: "./skills/review/SKILL.md", + }); + }); + + test("Full Activation runs in a fresh sandbox separate from Registration Discovery", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + let activationRuns = 0; + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + activationRuns += 1; + if (ctx.mode === "discover") { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + return; + } + if (activationRuns === 1) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.activated).toBe(true); + expect(extension.contributions[0]).toMatchObject({ + type: "skills", + id: "review", + activated: true, + }); + }); + + test("Full Activation honors disposed skill registrations without treating discovery as disposed", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + const registration = ctx.skills.register({ + name: "review", + bodyPath: "./skills/review/SKILL.md", + }); + if (ctx.mode === "activate") registration.dispose(); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.manifest.contributions).toHaveLength(1); + expect(extension.activated).toBe(true); + expect(extension.contributions).toEqual([ + { + type: "skills", + id: "review", + index: 0, + bodyPath: "./skills/review/SKILL.md", + activated: false, + }, + ]); + }); + + test("activation rejects SKILL.md frontmatter names that do not match the registered skill", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + `, + { + "skills/review/SKILL.md": "---\nname: other\ndescription: Wrong name\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.activated).toBe(false); + expect(extension.contributions[0].activated).toBe(false); + expect( + extension.diagnostics.some( + (diagnostic) => + diagnostic.code === "contribution.body.invalid" && + diagnostic.message.includes("frontmatter.name") + ) + ).toBe(true); + }); + + test("activation does not validate a skill body after it is swapped to an escaping symlink", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + `, + { + "skills/review/SKILL.md": "---\nname: other\ndescription: Wrong name\n---\n# Review\n", + } + ); + const bodyPath = path.join(tempDir, "acme-review", "skills", "review", "SKILL.md"); + const outsidePath = path.join(tempDir, "outside.md"); + await writeFile( + outsidePath, + "---\nname: review\ndescription: Outside helper\n---\noutside secret" + ); + + const originalStat = fsPromises.stat; + const statSpy = spyOn(fsPromises, "stat"); + statSpy.mockImplementation((async (target: Parameters[0]) => { + const result = await originalStat(target); + if (String(target) === bodyPath) { + await fsPromises.rm(bodyPath, { force: true }); + await fsPromises.symlink(outsidePath, bodyPath); + } + return result; + }) as unknown as typeof fsPromises.stat); + + const originalOpen = fsPromises.open; + const openSpy = spyOn(fsPromises, "open"); + openSpy.mockImplementation((( + target: Parameters[0], + flags?: Parameters[1], + mode?: Parameters[2] + ) => + originalOpen( + String(target) === bodyPath ? outsidePath : target, + flags, + mode + )) as typeof fsPromises.open); + + try { + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.activated).toBe(false); + expect(extension.contributions[0].activated).toBe(false); + expect( + extension.diagnostics.some((diagnostic) => diagnostic.code === "contribution.body.invalid") + ).toBe(true); + } finally { + openSpy.mockRestore(); + statSpy.mockRestore(); + } + }); + + test("Full Activation failures use activation diagnostics without dropping discovery preview", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + if (ctx.mode === "activate") throw new Error("activation exploded"); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.manifest.contributions).toHaveLength(1); + expect(extension.activated).toBe(false); + expect(extension.contributions[0]).toMatchObject({ + type: "skills", + id: "review", + activated: false, + }); + expect( + extension.diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.activation.failed" && + diagnostic.message.includes("activation exploded") + ) + ).toBe(true); + expect( + extension.diagnostics.some((diagnostic) => diagnostic.code === "extension.discovery.failed") + ).toBe(false); + }); + + test("Full Activation console output does not prevent publishing discovered skills", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + if (ctx.mode === "activate") console.warn("activation warning"); + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.activated).toBe(true); + expect(extension.contributions[0].activated).toBe(true); + expect( + extension.diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.activation.console" && + diagnostic.message.includes("activation warning") + ) + ).toBe(true); + }); + + test("Full Activation rejects skills that were not observed during Registration Discovery", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + if (ctx.mode === "discover") { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + return; + } + ctx.skills.register({ name: "surprise", bodyPath: "./skills/surprise/SKILL.md" }); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + "skills/surprise/SKILL.md": + "---\nname: surprise\ndescription: Surprise helper\n---\n# Surprise\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.activated).toBe(false); + expect(extension.contributions).toEqual([ + { + type: "skills", + id: "review", + index: 0, + bodyPath: "./skills/review/SKILL.md", + activated: false, + }, + ]); + expect( + extension.diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.activation.undiscovered" && + diagnostic.message.includes("surprise") + ) + ).toBe(true); + }); + + test("Full Activation accepts the same discovered skill after normalizing bodyPath", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + if (ctx.mode === "discover") { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + return; + } + ctx.skills.register({ name: "review", bodyPath: "skills/review/SKILL.md" }); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.activated).toBe(true); + expect(extension.contributions).toEqual([ + { + type: "skills", + id: "review", + index: 0, + bodyPath: "./skills/review/SKILL.md", + bodyRealPath: path.join(tempDir, "acme-review", "skills", "review", "SKILL.md"), + activated: true, + }, + ]); + expect( + extension.diagnostics.some( + (diagnostic) => diagnostic.code === "extension.activation.undiscovered" + ) + ).toBe(false); + }); + + test("Registration Discovery surfaces sandbox console output as diagnostics", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + if (ctx.mode === "discover") { + console.log("discovering", ctx.mode); + console.warn("watch", { count: 1 }); + console.error("problem"); + } + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect( + extension.diagnostics.map((diagnostic) => ({ + code: diagnostic.code, + severity: diagnostic.severity, + message: diagnostic.message, + })) + ).toEqual([ + { + code: "extension.discovery.console", + severity: "info", + message: "Registration Discovery console.log: discovering discover", + }, + { + code: "extension.discovery.console", + severity: "warn", + message: 'Registration Discovery console.warn: watch {"count":1}', + }, + { + code: "extension.discovery.console", + severity: "error", + message: "Registration Discovery console.error: problem", + }, + ]); + }); + + test("Registration Discovery rejects skill registration without manifest capabilities.skills", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { name: "acme-review" }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0].extensions).toHaveLength(1); + const [extension] = snapshot.roots[0].extensions; + expect(extension.contributions).toEqual([]); + expect( + extension.diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.capability.undeclared" && + diagnostic.severity === "error" && + diagnostic.extensionId === "acme-review" + ) + ).toBe(true); + }); + + test("Registration Discovery and Full Activation expose only skills registration APIs", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + + function assertNoDangerousEffectApis(ctx) { + const ctxKeys = Object.keys(ctx).sort().join(","); + const skillKeys = Object.keys(ctx.skills).sort().join(","); + if (ctxKeys !== "mode,skills") throw new Error("unexpected ctx keys: " + ctxKeys); + if (skillKeys !== "register") throw new Error("unexpected skills keys: " + skillKeys); + + const exposed = []; + for (const name of ["fs", "process", "secrets", "fetch"]) { + if (typeof ctx[name] !== "undefined") exposed.push("ctx." + name); + } + for (const name of ["fetch", "process", "require", "Bun", "Deno"]) { + if (typeof globalThis[name] !== "undefined") exposed.push("globalThis." + name); + } + if (exposed.length > 0) { + throw new Error("dangerous effect API exposed: " + exposed.join(",")); + } + } + + export function activate(ctx) { + assertNoDangerousEffectApis(ctx); + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const [extension] = snapshot.roots[0].extensions; + expect(extension.activated).toBe(true); + expect(extension.contributions[0]).toMatchObject({ + type: "skills", + id: "review", + activated: true, + }); + expect( + extension.diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.discovery.failed" || + diagnostic.code === "extension.activation.failed" + ) + ).toBe(false); + }); + + test("manifest.name must match the module folder basename", async () => { + await writeExtensionModule(tempDir, "acme-review", extensionTs("other-review")); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0].extensions).toEqual([]); + expect( + snapshot.roots[0].diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.name.mismatch" && diagnostic.severity === "error" + ) + ).toBe(true); + }); + + test("invalid module folder names are diagnosed", async () => { + await writeExtensionModule(tempDir, "Acme_Review", extensionTs("acme-review")); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0].extensions).toEqual([]); + expect( + snapshot.roots[0].diagnostics.some( + (diagnostic) => + diagnostic.code === "extension.name.invalid" && diagnostic.extensionId === "Acme_Review" + ) + ).toBe(true); + }); + + test("untrusted project-local module root does not read extension.ts", async () => { + await writeExtensionModule(tempDir, "acme-review", extensionTs("acme-review")); + const readSpy = spyOn(fsPromises, "readFile"); + + try { + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ + rootId: "project-local", + kind: "project-local", + path: tempDir, + trusted: false, + }), + ], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0]).toMatchObject({ + rootExists: true, + trusted: false, + extensions: [], + diagnostics: [], + }); + const reads = readSpy.mock.calls.filter((args) => { + const target = args[0]; + return typeof target === "string" && target.startsWith(tempDir); + }); + expect(reads).toEqual([]); + } finally { + readSpy.mockRestore(); + } + }); + + test("non-static module manifests are rejected without crashing discovery", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + `const name = "acme-review"; export const manifest = defineManifest({ name });` + ); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0].extensions).toEqual([]); + expect( + snapshot.roots[0].diagnostics.some( + (diagnostic) => diagnostic.code === "manifest.static.unsupported" + ) + ).toBe(true); + }); +}); + +describe("discoverExtensions — pre-trust project-local existence-only", () => { + let tempDir: string; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-discovery-pretrust-")); + }); + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("untrusted project-local root with package.json present is NOT read (verified via spyOn)", async () => { + await writeRootPackage(tempDir, { "@author/skill": "0.1.0" }); + const pkgPath = path.join(tempDir, "node_modules", "@author", "skill"); + await writePackage(pkgPath, { + name: "@author/skill", + mux: { + manifestVersion: 1, + id: "author.skill", + contributes: { skills: [{ id: "demo", body: "SKILL.md" }] }, + }, + }); + + // Spy on the filesystem read used by manifest inspection. The pre-trust + // gate must skip this read entirely; only the existence stat is allowed. + const readSpy = spyOn(fsPromises, "readFile"); + + try { + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ + rootId: "project-local", + kind: "project-local", + path: tempDir, + trusted: false, + }), + ], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0]).toMatchObject({ + rootExists: true, + trusted: false, + state: "ready", + extensions: [], + diagnostics: [], + }); + const reads = readSpy.mock.calls.filter((args) => { + const target = args[0]; + return typeof target === "string" && target.startsWith(tempDir); + }); + expect(reads).toEqual([]); + } finally { + readSpy.mockRestore(); + } + }); + + test("trusted project-local root discovers Extension Modules", async () => { + await writeExtensionModule(tempDir, "author-skill", extensionTs("author-skill")); + + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ + rootId: "project-local", + kind: "project-local", + path: tempDir, + trusted: true, + }), + ], + now: FROZEN_NOW, + }); + expect(snapshot.roots[0].state).toBe("ready"); + expect(snapshot.roots[0].extensions).toHaveLength(1); + expect(snapshot.roots[0].extensions[0].extensionId).toBe("author-skill"); + }); +}); + +describe("discoverExtensions — bundled root with demo extension", () => { + let tempDir: string; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-discovery-bundled-")); + }); + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + async function writeBundledDemoModule(extensionsDir: string): Promise { + await writeExtensionModule( + extensionsDir, + "mux-platform-demo", + ` + export const manifest = { + name: "mux-platform-demo", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "mux-extensions", bodyPath: "./SKILL.md" }); + } + `, + { + "SKILL.md": + "---\nname: mux-extensions\ndescription: Mux extensions demo\n---\n# mux-extensions skill body", + } + ); + } + + test("bundled root with mux-platform-demo discovers the demo skill", async () => { + const extensionsDir = path.join(tempDir, "extensions"); + await writeBundledDemoModule(extensionsDir); + + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ + rootId: "bundled", + kind: "bundled", + path: extensionsDir, + isCore: true, + }), + ], + now: FROZEN_NOW, + }); + + const root = snapshot.roots[0]; + expect(root.state).toBe("ready"); + expect(root.trusted).toBe(true); + expect(root.extensions).toHaveLength(1); + const ext = root.extensions[0]; + expect(ext).toMatchObject({ + extensionId: "mux-platform-demo", + isCore: true, + enabled: true, + }); + expect(ext.contributions).toHaveLength(1); + expect(ext.contributions[0]).toMatchObject({ + type: "skills", + id: "mux-extensions", + bodyPath: "./SKILL.md", + }); + // Bundled Extensions are policy-granted, so the Demo Extension activates + // on a fresh install with no persisted approval record. + expect(ext.granted).toBe(true); + expect(ext.activated).toBe(true); + expect(ext.contributions[0].activated).toBe(true); + }); + + test("bundled root with grant runs Activation Discovery and reads SKILL.md", async () => { + const extensionsDir = path.join(tempDir, "extensions"); + await writeBundledDemoModule(extensionsDir); + + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ + rootId: "bundled", + kind: "bundled", + path: extensionsDir, + isCore: true, + }), + ], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const ext = snapshot.roots[0].extensions[0]; + expect(ext.granted).toBe(true); + expect(ext.activated).toBe(true); + expect(ext.contributions[0].activated).toBe(true); + }); +}); + +describe("discoverExtensions — failure modes", () => { + let tempDir: string; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-discovery-fail-")); + }); + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("malformed static manifest yields error diagnostics; other Extension Modules in the root still discovered", async () => { + await writeExtensionModule( + tempDir, + "bad-module", + `export const manifest = { name: "wrong-name", capabilities: { skills: true } };` + ); + await writeExtensionModule(tempDir, "good-module", extensionTs("good-module")); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "ug", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + const root = snapshot.roots[0]; + expect(root.state).toBe("ready"); + const ids = root.extensions.map((e) => e.extensionId); + expect(ids).toEqual(["good-module"]); + const codes = root.diagnostics.map((d) => d.code); + expect(codes).toContain("extension.name.mismatch"); + }); + + test("symlinked Extension Module folders cannot resolve outside the Extension Root", async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-discovery-outside-module-")); + try { + await writeExtensionModule(outsideDir, "acme-review", extensionTs("acme-review"), { + "skills/review/SKILL.md": "---\nname: review\ndescription: Outside helper\n---\n# Review\n", + }); + await symlink(path.join(outsideDir, "acme-review"), path.join(tempDir, "acme-review"), "dir"); + + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ + rootId: "project-local:/repo", + kind: "project-local", + path: tempDir, + trusted: true, + }), + ], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0].extensions).toEqual([]); + expect(snapshot.roots[0].diagnostics).toHaveLength(1); + expect(snapshot.roots[0].diagnostics[0]).toMatchObject({ + code: "extension.module.outside_root", + extensionId: "acme-review", + }); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + test("symlinked extension.ts files cannot resolve outside the Extension Module", async () => { + const outsideEntrypoint = path.join(tempDir, "outside-extension.ts"); + await mkdir(path.join(tempDir, "acme-review"), { recursive: true }); + await writeFile( + outsideEntrypoint, + `export const manifest = { name: "acme-review", capabilities: { skills: true } }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + await symlink(outsideEntrypoint, path.join(tempDir, "acme-review", "extension.ts")); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "ug", kind: "user-global", path: tempDir })], + now: FROZEN_NOW, + }); + + const root = snapshot.roots[0]; + expect(root.extensions).toEqual([]); + expect(root.diagnostics).toHaveLength(1); + expect(root.diagnostics[0]).toMatchObject({ + code: "extension.entrypoint.invalid", + extensionId: "acme-review", + severity: "error", + }); + }); + + test("symlinked Contributed Path is rejected at activation", async () => { + const outside = path.join(tempDir, "outside.md"); + await mkdir(tempDir, { recursive: true }); + await writeFile(outside, "outside content"); + await writeExtensionModule(tempDir, "author-sym", extensionTs("author-sym")); + await mkdir(path.join(tempDir, "author-sym", "skills", "review"), { recursive: true }); + await symlink(outside, path.join(tempDir, "author-sym", "skills", "review", "SKILL.md")); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "ug", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const root = snapshot.roots[0]; + expect(root.state).toBe("ready"); + const ext = root.extensions[0]; + expect(ext.activated).toBe(false); + expect(ext.contributions[0].activated).toBe(false); + const symlinkDiag = ext.diagnostics.find((d) => d.code === "contribution.body.invalid"); + expect(symlinkDiag).toBeDefined(); + expect(symlinkDiag?.contributionRef).toMatchObject({ type: "skills", id: "review" }); + }); + + test("oversized Contributed Path is rejected before body read", async () => { + await writeExtensionModule(tempDir, "author-big", extensionTs("author-big"), { + "skills/review/SKILL.md": "x".repeat(MAX_FILE_SIZE + 1), + }); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "ug", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + now: FROZEN_NOW, + }); + + const ext = snapshot.roots[0].extensions[0]; + expect(ext.activated).toBe(false); + expect(ext.contributions[0].activated).toBe(false); + const sizeDiag = ext.diagnostics.find((d) => d.code === "contribution.body.invalid"); + expect(sizeDiag?.message).toContain("File is too large"); + }); + + test("per-root timeout suppresses late activation session publication", async () => { + await writeExtensionModule( + tempDir, + "acme-review", + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + `, + { + "skills/review/SKILL.md": "---\nname: review\ndescription: Review helper\n---\n# Review\n", + } + ); + const lateSessions: unknown[] = []; + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "user-global", kind: "user-global", path: tempDir })], + state: { + isEnabled: () => true, + getApprovalRecord: () => SAMPLE_GRANT, + }, + perRootTimeoutMs: 1, + now: FROZEN_NOW, + activationSessionSink: (record) => lateSessions.push(record), + }); + + expect(snapshot.roots[0]).toMatchObject({ state: "failed" }); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(lateSessions).toHaveLength(0); + }); + + test("per-root timeout produces failed state with root.discovery.timeout error", async () => { + await writeExtensionModule(tempDir, "author-foo", extensionTs("author-foo")); + + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "ug", kind: "user-global", path: tempDir })], + perRootTimeoutMs: 0, // immediate timeout + now: FROZEN_NOW, + }); + const root = snapshot.roots[0]; + expect(root.state).toBe("failed"); + const codes = root.diagnostics.map((d) => d.code); + expect(codes).toContain("root.discovery.timeout"); + expect(root.extensions).toEqual([]); + }); +}); + +describe("discoverExtensions — root isolation", () => { + let bundledTmp: string; + let userTmp: string; + beforeEach(() => { + bundledTmp = fs.mkdtempSync(path.join(os.tmpdir(), "mux-discovery-iso-bundled-")); + userTmp = fs.mkdtempSync(path.join(os.tmpdir(), "mux-discovery-iso-user-")); + }); + afterEach(() => { + fs.rmSync(bundledTmp, { recursive: true, force: true }); + fs.rmSync(userTmp, { recursive: true, force: true }); + }); + + test("unreadable root becomes failed without dropping healthy roots", async () => { + const blockedRoot = bundledTmp; + const healthyRoot = userTmp; + await writeExtensionModule(healthyRoot, "author-good", extensionTs("author-good")); + + const realStat: (filePath: Parameters[0]) => Promise = + fsPromises.stat; + const mockStat = (async (filePath: Parameters[0]) => { + if (filePath.toString() === blockedRoot) { + const error = new Error("permission denied") as NodeJS.ErrnoException; + error.code = "EACCES"; + return Promise.reject(error); + } + return realStat(filePath); + }) as typeof fsPromises.stat; + const statSpy = spyOn(fsPromises, "stat").mockImplementation(mockStat); + + try { + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ rootId: "blocked", kind: "user-global", path: blockedRoot }), + rootDescriptor({ rootId: "healthy", kind: "user-global", path: healthyRoot }), + ], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0].state).toBe("failed"); + expect(snapshot.roots[0].diagnostics[0]?.code).toBe("root.access.failed"); + expect(snapshot.roots[1].extensions.map((ext) => ext.extensionId)).toEqual(["author-good"]); + } finally { + statSpy.mockRestore(); + } + }); + + test("entrypoint stat failure is diagnosed without dropping healthy modules", async () => { + await writeExtensionModule(bundledTmp, "author-bad", extensionTs("author-bad")); + await writeExtensionModule(bundledTmp, "author-good", extensionTs("author-good")); + const badEntrypointPath = path.join(bundledTmp, "author-bad", "extension.ts"); + + const realStat = fsPromises.stat; + const statSpy = spyOn(fsPromises, "stat").mockImplementation((( + target: Parameters[0] + ) => { + if (String(target) === badEntrypointPath) { + const error = new Error("permission denied") as NodeJS.ErrnoException; + error.code = "EACCES"; + return Promise.reject(error); + } + return realStat(target); + }) as typeof fsPromises.stat); + + try { + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "bundled", kind: "bundled", path: bundledTmp })], + now: FROZEN_NOW, + }); + + const root = snapshot.roots[0]; + expect(root.state).toBe("ready"); + expect(root.extensions.map((extension) => extension.extensionId)).toEqual(["author-good"]); + expect(root.diagnostics[0]).toMatchObject({ + code: "extension.entrypoint.read_failed", + extensionId: "author-bad", + severity: "error", + }); + expect(root.diagnostics[0]?.message).toContain("permission denied"); + } finally { + statSpy.mockRestore(); + } + }); + + test("module inspection failure is diagnosed without dropping healthy modules", async () => { + await writeExtensionModule(bundledTmp, "author-bad", extensionTs("author-bad")); + await writeExtensionModule(bundledTmp, "author-good", extensionTs("author-good")); + const badModulePath = path.join(bundledTmp, "author-bad"); + + const realRealpath = fsPromises.realpath; + const realpathSpy = spyOn(fsPromises, "realpath").mockImplementation((( + target: Parameters[0] + ) => { + if (String(target) === badModulePath) { + const error = new Error("permission denied") as NodeJS.ErrnoException; + error.code = "EACCES"; + return Promise.reject(error); + } + return realRealpath(target); + }) as typeof fsPromises.realpath); + + try { + const snapshot = await discoverExtensions({ + roots: [rootDescriptor({ rootId: "bundled", kind: "bundled", path: bundledTmp })], + now: FROZEN_NOW, + }); + + const root = snapshot.roots[0]; + expect(root.state).toBe("ready"); + expect(root.extensions.map((extension) => extension.extensionId)).toEqual(["author-good"]); + expect(root.diagnostics[0]).toMatchObject({ + code: "extension.module.read_failed", + extensionId: "author-bad", + severity: "error", + }); + expect(root.diagnostics[0]?.message).toContain("permission denied"); + } finally { + realpathSpy.mockRestore(); + } + }); + + test("root realpath failure becomes a failed root without dropping healthy roots", async () => { + const blockedRoot = bundledTmp; + const healthyRoot = userTmp; + await writeExtensionModule(blockedRoot, "author-blocked", extensionTs("author-blocked")); + await writeExtensionModule(healthyRoot, "author-good", extensionTs("author-good")); + + const realRealpath = fsPromises.realpath; + const realpathSpy = spyOn(fsPromises, "realpath").mockImplementation((( + target: Parameters[0] + ) => { + if (String(target) === blockedRoot) { + return Promise.reject(new Error("root disappeared")); + } + return realRealpath(target); + }) as typeof fsPromises.realpath); + + try { + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ rootId: "blocked", kind: "user-global", path: blockedRoot }), + rootDescriptor({ rootId: "healthy", kind: "user-global", path: healthyRoot }), + ], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0].state).toBe("failed"); + expect(snapshot.roots[0].diagnostics[0]?.code).toBe("root.read.failed"); + expect(snapshot.roots[0].diagnostics[0]?.message).toContain("root disappeared"); + expect(snapshot.roots[1].extensions.map((ext) => ext.extensionId)).toEqual(["author-good"]); + } finally { + realpathSpy.mockRestore(); + } + }); + + test("a failed root contributes no Extensions but does not affect other roots", async () => { + await writeExtensionModule(bundledTmp, "mux-demo", extensionTs("mux-demo")); + + // User-global: path exists but is not a directory, so readdir fails. + await fsPromises.rm(userTmp, { recursive: true, force: true }); + await writeFile(userTmp, "not a directory"); + + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ rootId: "bundled", kind: "bundled", path: bundledTmp }), + rootDescriptor({ rootId: "user-global", kind: "user-global", path: userTmp }), + ], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0].state).toBe("ready"); + expect(snapshot.roots[0].extensions).toHaveLength(1); + expect(snapshot.roots[1].state).toBe("failed"); + expect(snapshot.roots[1].diagnostics.some((d) => d.code === "root.read.failed")).toBe(true); + }); + + test("bundled root + failed user-global root keeps bundled extensions", async () => { + await writeExtensionModule(bundledTmp, "mux-demo", extensionTs("mux-demo")); + + await fsPromises.rm(userTmp, { recursive: true, force: true }); + await writeFile(userTmp, "not a directory"); + + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ rootId: "bundled", kind: "bundled", path: bundledTmp }), + rootDescriptor({ rootId: "user-global", kind: "user-global", path: userTmp }), + ], + now: FROZEN_NOW, + }); + + expect(snapshot.roots[0].state).toBe("ready"); + expect(snapshot.roots[0].extensions).toHaveLength(1); + expect(snapshot.roots[0].extensions[0].extensionId).toBe("mux-demo"); + + expect(snapshot.roots[1].state).toBe("failed"); + expect(snapshot.roots[1].diagnostics.some((d) => d.code === "root.read.failed")).toBe(true); + }); +}); + +describe("discoverExtensions — defaults & shape", () => { + test("default per-root timeout is 10s and per-file timeout is 5s", () => { + expect(PER_ROOT_TIMEOUT_MS_DEFAULT).toBe(10_000); + expect(PER_FILE_TIMEOUT_MS_DEFAULT).toBe(5_000); + }); + + test("snapshot includes generatedAt = now() override and preserves root order", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-discovery-shape-")); + try { + const snapshot = await discoverExtensions({ + roots: [ + rootDescriptor({ + rootId: "missing-1", + kind: "user-global", + path: path.join(tempDir, "missing-1"), + }), + rootDescriptor({ + rootId: "missing-2", + kind: "project-local", + path: path.join(tempDir, "missing-2"), + }), + ], + now: FROZEN_NOW, + }); + expect(snapshot.generatedAt).toBe(FROZEN_NOW); + expect(snapshot.roots.map((r) => r.rootId)).toEqual(["missing-1", "missing-2"]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/node/extensions/extensionDiscoveryService.ts b/src/node/extensions/extensionDiscoveryService.ts new file mode 100644 index 0000000000..0d6c005734 --- /dev/null +++ b/src/node/extensions/extensionDiscoveryService.ts @@ -0,0 +1,1000 @@ +import { constants as fsConstants, type Dirent } from "fs"; +import * as fsPromises from "fs/promises"; +import * as path from "path"; + +import { + validateStaticManifest, + type ExtensionDiagnostic, + type RootKind, + type ValidatedContribution, + type ValidatedManifest, +} from "@/common/extensions/manifestValidator"; +import type { ApprovalRecord } from "@/common/extensions/globalExtensionState"; +import { hashRequestedPermissions } from "@/common/extensions/permissionCalculator"; +import { ensureExtensionPathContained } from "@/node/extensions/extensionPathContainment"; +import { ExtensionNameSchema } from "@/common/orpc/schemas/extension"; +import { + discoverExtensionRegistrations, + type ExtensionActivationSession, +} from "@/node/extensions/extensionRegistrationDiscoveryService"; +import { + extractStaticManifestFromSource, + type StaticManifestExtractionResult, +} from "@/node/extensions/staticManifestExtractor"; +import { validateFileSize } from "@/node/services/tools/fileCommon"; +import { parseSkillMarkdown } from "@/node/services/agentSkills/parseSkillMarkdown"; +import { realpathOpenedFile } from "@/node/utils/openedFileRealpath"; +import { SkillNameSchema } from "@/common/orpc/schemas/agentSkill"; +import { hasErrorCode } from "@/node/services/tools/skillFileUtils"; + +export const PER_ROOT_TIMEOUT_MS_DEFAULT = 10_000; +export const PER_FILE_TIMEOUT_MS_DEFAULT = 5_000; + +export type RootDiscoveryState = "pending" | "running" | "ready" | "failed"; + +export interface ExtensionRootDescriptor { + /** Stable id used by the IPC layer; for project-local roots embeds the project path. */ + rootId: string; + kind: RootKind; + /** Absolute filesystem path to the Extension Root. */ + path: string; + /** Bundled-only: marks Core Extensions whose contributions cannot be shadowed. */ + isCore?: boolean; + /** Project-local-only: trust gate. Bundled and user-global roots are treated as trusted. */ + trusted?: boolean; +} + +export interface DiscoveryStateLookupContext { + rootId: string; + rootKind: RootKind; + extensionId: string; + isBundled: boolean; +} + +export interface DiscoveryStateLookup { + /** + * Returns whether the Extension is enabled. Discovery defaults to bundled=true, + * non-bundled=false when no lookup is supplied. + */ + isEnabled?: (ctx: DiscoveryStateLookupContext) => boolean; + /** + * Returns the persisted approval record for an Extension, if any. Discovery + * uses presence-of-record as a precondition for Activation Discovery; the + * capability calculator is the source of truth for effective capabilities. + */ + getApprovalRecord?: (ctx: DiscoveryStateLookupContext) => ApprovalRecord | undefined; +} + +export interface DiscoveredContribution { + type: string; + id: string; + index: number; + /** Extension Module-relative path for body-bearing types (skills, agents). */ + bodyPath?: string; + /** Contained, symlink-checked absolute path validated during Activation Discovery. */ + bodyRealPath?: string; + /** + * True iff Activation Discovery validated this contribution's referenced + * declarative file (or the type has no referenced files). For pre-activation + * Extensions (untrusted root, disabled, ungranted), this is `false`. + */ + activated: boolean; +} + +export interface DiscoveredExtension { + extensionId: string; + rootId: string; + rootKind: RootKind; + isCore: boolean; + /** Absolute filesystem path of the Extension Module on disk. */ + modulePath: string; + manifest: ValidatedManifest; + contributions: DiscoveredContribution[]; + diagnostics: ExtensionDiagnostic[]; + enabled: boolean; + /** + * True when a persisted approval record exists. Effective capability gating + * is the capability calculator's job; Discovery Service only signals whether + * Activation was eligible to run. + */ + granted: boolean; + /** + * True iff every body-bearing contribution validated its file on disk. + * Implies `enabled && granted && rootTrusted`. + */ + activated: boolean; +} + +export interface RootDiscoveryResult { + rootId: string; + kind: RootKind; + path: string; + trusted: boolean; + /** False when the root directory does not exist on disk. */ + rootExists: boolean; + state: RootDiscoveryState; + extensions: DiscoveredExtension[]; + diagnostics: ExtensionDiagnostic[]; +} + +export interface ExtensionSnapshot { + generatedAt: number; + roots: RootDiscoveryResult[]; +} + +export interface ActivationSessionSinkRecord { + rootId: string; + extensionId: string; + session: ExtensionActivationSession; +} + +export type ActivationSessionSink = (record: ActivationSessionSinkRecord) => void; + +export interface DiscoverExtensionsInput { + roots: readonly ExtensionRootDescriptor[]; + state?: DiscoveryStateLookup; + perRootTimeoutMs?: number; + perFileTimeoutMs?: number; + /** Receives successful Full Activation sandboxes for registry-owned disposal. */ + activationSessionSink?: ActivationSessionSink; + /** Override `Date.now()` for deterministic diagnostics. */ + now?: number; +} + +const TIMEOUT_SENTINEL = Symbol("discovery.timeout"); + +function isContainedRealPath(rootRealPath: string, candidateRealPath: string): boolean { + const relativePath = path.relative(rootRealPath, candidateRealPath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +async function pathExists(p: string): Promise { + try { + await fsPromises.stat(p); + return true; + } catch (error) { + if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR")) return false; + throw error; + } +} + +async function withTimeout( + createPromise: () => Promise, + timeoutMs: number +): Promise { + // 0 (or negative) is the degenerate-but-useful "always time out" mode used + // by tests; do not even start the work or it can keep touching cleaned-up + // fixture roots after the caller has already received a timeout result. + if (timeoutMs <= 0) return TIMEOUT_SENTINEL; + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + createPromise(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export async function discoverExtensions( + input: DiscoverExtensionsInput +): Promise { + const now = input.now ?? Date.now(); + const perRootTimeoutMs = input.perRootTimeoutMs ?? PER_ROOT_TIMEOUT_MS_DEFAULT; + const perFileTimeoutMs = input.perFileTimeoutMs ?? PER_FILE_TIMEOUT_MS_DEFAULT; + + const roots = await Promise.all( + input.roots.map((root) => + discoverRoot(root, { + activationSessionSink: input.activationSessionSink, + state: input.state, + now, + perRootTimeoutMs, + perFileTimeoutMs, + }) + ) + ); + + return { generatedAt: now, roots }; +} + +interface RootContext { + state?: DiscoveryStateLookup; + activationSessionSink?: ActivationSessionSink; + now: number; + perRootTimeoutMs: number; + perFileTimeoutMs: number; +} + +async function discoverRoot( + root: ExtensionRootDescriptor, + ctx: RootContext +): Promise { + const trusted = root.kind === "project-local" ? root.trusted === true : true; + const result = (overrides: Partial): RootDiscoveryResult => ({ + rootId: root.rootId, + kind: root.kind, + path: root.path, + trusted, + rootExists: true, + state: "ready", + extensions: [], + diagnostics: [], + ...overrides, + }); + + // Phase 1: Existence detection. Cheap; runs unconditionally. + let rootPathExists: boolean; + try { + rootPathExists = await pathExists(root.path); + } catch (error) { + return result({ + state: "failed", + diagnostics: [ + { + code: "root.access.failed", + severity: "error", + message: `Failed to access Extension Root ${root.path}: ${(error as Error).message ?? String(error)}`, + occurredAt: ctx.now, + }, + ], + }); + } + if (!rootPathExists) { + return result({ rootExists: false }); + } + + // Pre-trust gate: project-local existence detection only. Discovery must NOT + // read package.json from an untrusted project-local root. + if (root.kind === "project-local" && !trusted) { + return result({}); + } + + let rootTimedOut = false; + const rootCtx: RootContext = ctx.activationSessionSink + ? { + ...ctx, + activationSessionSink: (record) => { + if (rootTimedOut) { + record.session.dispose(); + return; + } + ctx.activationSessionSink?.(record); + }, + } + : ctx; + const raceResult = await withTimeout( + () => discoverRootInner(root, rootCtx, result), + ctx.perRootTimeoutMs + ); + + if (raceResult === TIMEOUT_SENTINEL) { + rootTimedOut = true; + return result({ + state: "failed", + diagnostics: [ + { + code: "root.discovery.timeout", + severity: "error", + message: `Extension Root discovery exceeded the ${ctx.perRootTimeoutMs}ms timeout (${root.kind} ${root.path}).`, + occurredAt: ctx.now, + }, + ], + }); + } + + return raceResult; +} + +async function discoverRootInner( + root: ExtensionRootDescriptor, + ctx: RootContext, + result: (overrides: Partial) => RootDiscoveryResult +): Promise { + const moduleDiscovery = await discoverExtensionModules(root, ctx); + if (moduleDiscovery !== null) { + return result({ + state: moduleDiscovery.failed ? "failed" : "ready", + extensions: moduleDiscovery.extensions, + diagnostics: moduleDiscovery.diagnostics, + }); + } + + // Extension Modules v1 intentionally ignores package.json/npm roots. A root + // contributes only direct child folders that contain extension.ts. + return result({}); +} + +interface ModuleDiscoveryResult { + extensions: DiscoveredExtension[]; + diagnostics: ExtensionDiagnostic[]; + failed: boolean; +} + +async function discoverExtensionModules( + root: ExtensionRootDescriptor, + ctx: RootContext +): Promise { + if (root.kind === "project-local" && root.trusted !== true) { + return { failed: false, extensions: [], diagnostics: [] }; + } + + let entries: Dirent[]; + try { + entries = await fsPromises.readdir(root.path, { withFileTypes: true }); + } catch (error) { + return { + failed: true, + extensions: [], + diagnostics: [ + { + code: "root.read.failed", + severity: "error", + message: `Failed to list Extension Root ${root.path}: ${(error as Error).message ?? String(error)}`, + occurredAt: ctx.now, + }, + ], + }; + } + + let rootRealPath: string; + try { + rootRealPath = await fsPromises.realpath(root.path); + } catch (error) { + return { + failed: true, + extensions: [], + diagnostics: [ + { + code: "root.read.failed", + severity: "error", + message: `Failed to resolve Extension Root ${root.path}: ${error instanceof Error ? error.message : String(error)}`, + occurredAt: ctx.now, + }, + ], + }; + } + const diagnostics: ExtensionDiagnostic[] = []; + const extensions: DiscoveredExtension[] = []; + let sawEntrypoint = false; + + for (const entry of entries) { + if (entry.name === "node_modules") continue; + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const modulePath = path.join(root.path, entry.name); + let moduleRealPath: string; + let moduleStat; + try { + moduleRealPath = await fsPromises.realpath(modulePath); + if (!isContainedRealPath(rootRealPath, moduleRealPath)) { + diagnostics.push({ + code: "extension.module.outside_root", + severity: "error", + message: `Extension Module folder ${JSON.stringify(entry.name)} resolves outside the Extension Root.`, + extensionId: entry.name, + occurredAt: ctx.now, + }); + continue; + } + moduleStat = await fsPromises.stat(moduleRealPath); + } catch (error) { + if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR")) continue; + diagnostics.push({ + code: "extension.module.read_failed", + severity: "error", + message: `Failed to inspect Extension Module folder ${JSON.stringify(entry.name)}: ${error instanceof Error ? error.message : String(error)}`, + extensionId: entry.name, + occurredAt: ctx.now, + }); + continue; + } + if (!moduleStat.isDirectory()) continue; + + const entrypointPath = path.join(moduleRealPath, "extension.ts"); + let hasEntrypoint: boolean; + try { + hasEntrypoint = await pathExists(entrypointPath); + } catch (error) { + diagnostics.push({ + code: "extension.entrypoint.read_failed", + severity: "error", + message: `Failed to access extension.ts for ${JSON.stringify(entry.name)}: ${error instanceof Error ? error.message : String(error)}`, + extensionId: entry.name, + occurredAt: ctx.now, + }); + continue; + } + if (!hasEntrypoint) continue; + sawEntrypoint = true; + + if (!ExtensionNameSchema.safeParse(entry.name).success) { + diagnostics.push({ + code: "extension.name.invalid", + severity: "error", + message: `Extension Module folder name ${JSON.stringify( + entry.name + )} must be kebab-case and match the Extension Name rules.`, + extensionId: entry.name, + occurredAt: ctx.now, + }); + continue; + } + + const candidate = await discoverCandidateExtensionModule(root, entry.name, moduleRealPath, ctx); + diagnostics.push(...candidate.rootDiagnostics); + if (candidate.extension) extensions.push(candidate.extension); + } + + if (!sawEntrypoint && diagnostics.length === 0) return null; + return { failed: false, extensions, diagnostics }; +} + +function staticManifestHasSkillsCapability(rawManifest: Record): boolean { + const capabilities = rawManifest.capabilities; + return isPlainObject(capabilities) && capabilities.skills === true; +} + +function normalizeRegistrationBodyPath(bodyPath: string): string { + return path.posix.normalize(bodyPath.replace(/\\/gu, "/")); +} + +function registrationKey(contribution: ValidatedContribution): string { + const body = + typeof contribution.descriptor.body === "string" + ? normalizeRegistrationBodyPath(contribution.descriptor.body) + : ""; + return `${contribution.type}\0${contribution.id}\0${body}`; +} + +function isExecutionConsoleDiagnostic(diagnostic: ExtensionDiagnostic): boolean { + return ( + diagnostic.code === "extension.discovery.console" || + diagnostic.code === "extension.activation.console" + ); +} + +function activationUndiscoveredDiagnostics( + extensionId: string, + discovered: readonly ValidatedContribution[], + activated: readonly ValidatedContribution[], + now: number +): ExtensionDiagnostic[] { + const discoveredKeys = new Set(discovered.map(registrationKey)); + return activated + .filter((contribution) => !discoveredKeys.has(registrationKey(contribution))) + .map((contribution) => ({ + code: "extension.activation.undiscovered", + severity: "error" as const, + message: `Full Activation registered ${contribution.type}/${contribution.id}, which was not observed during Registration Discovery.`, + extensionId, + contributionRef: { + type: contribution.type, + index: contribution.index, + id: contribution.id, + }, + occurredAt: now, + })); +} + +function approvalCoversRequestedPermissions( + approval: ApprovalRecord | undefined, + requestedPermissions: readonly string[] +): boolean { + if (!approval) return false; + if (approval.requestedPermissionsHash !== hashRequestedPermissions(requestedPermissions)) + return false; + const granted = new Set(approval.grantedPermissions); + return Array.from(new Set(requestedPermissions)).every((permission) => granted.has(permission)); +} + +async function extractContainedStaticManifest(input: { + modulePath: string; + entrypointPath: string; + extensionName: string; + now: number; +}): Promise { + let moduleRealPath: string; + let entrypointRealPath: string; + try { + [moduleRealPath, entrypointRealPath] = await Promise.all([ + fsPromises.realpath(input.modulePath), + fsPromises.realpath(input.entrypointPath), + ]); + } catch (error) { + return { + ok: false, + diagnostics: [ + { + code: "extension.entrypoint.read_failed", + severity: "error", + message: `Failed to access extension.ts: ${error instanceof Error ? error.message : String(error)}`, + extensionId: input.extensionName, + occurredAt: input.now, + }, + ], + }; + } + + if (!isContainedRealPath(moduleRealPath, entrypointRealPath)) { + return { + ok: false, + diagnostics: [ + { + code: "extension.entrypoint.invalid", + severity: "error", + message: "extension.ts resolves outside the Extension Module.", + extensionId: input.extensionName, + occurredAt: input.now, + }, + ], + }; + } + + const noFollow = fsConstants.O_NOFOLLOW ?? 0; + let handle: Awaited>; + try { + handle = await fsPromises.open(entrypointRealPath, fsConstants.O_RDONLY | noFollow); + } catch (error) { + return { + ok: false, + diagnostics: [ + { + code: "extension.entrypoint.read_failed", + severity: "error", + message: `Failed to read extension.ts: ${error instanceof Error ? error.message : String(error)}`, + extensionId: input.extensionName, + occurredAt: input.now, + }, + ], + }; + } + + try { + const openedRealPath = await realpathOpenedFile(handle, entrypointRealPath); + if ( + path.normalize(openedRealPath) !== path.normalize(entrypointRealPath) || + !isContainedRealPath(moduleRealPath, openedRealPath) + ) { + return { + ok: false, + diagnostics: [ + { + code: "extension.entrypoint.invalid", + severity: "error", + message: "Opened extension.ts resolves outside its validated path.", + extensionId: input.extensionName, + occurredAt: input.now, + }, + ], + }; + } + + const stat = await handle.stat(); + if (!stat.isFile()) { + return { + ok: false, + diagnostics: [ + { + code: "extension.entrypoint.invalid", + severity: "error", + message: "extension.ts must be a regular file.", + extensionId: input.extensionName, + occurredAt: input.now, + }, + ], + }; + } + + const sizeValidation = validateFileSize({ + size: stat.size, + modifiedTime: stat.mtime, + isDirectory: false, + }); + if (sizeValidation) { + return { + ok: false, + diagnostics: [ + { + code: "extension.entrypoint.read_failed", + severity: "error", + message: `Failed to read extension.ts: ${sizeValidation.error}`, + extensionId: input.extensionName, + occurredAt: input.now, + }, + ], + }; + } + + return extractStaticManifestFromSource( + await handle.readFile("utf-8"), + entrypointRealPath, + input.now + ); + } catch (error) { + return { + ok: false, + diagnostics: [ + { + code: "extension.entrypoint.read_failed", + severity: "error", + message: `Failed to read extension.ts: ${error instanceof Error ? error.message : String(error)}`, + extensionId: input.extensionName, + occurredAt: input.now, + }, + ], + }; + } finally { + await handle.close(); + } +} + +async function discoverCandidateExtensionModule( + root: ExtensionRootDescriptor, + extensionName: string, + modulePath: string, + ctx: RootContext +): Promise { + const entrypointPath = path.join(modulePath, "extension.ts"); + const extraction = await extractContainedStaticManifest({ + modulePath, + entrypointPath, + extensionName, + now: ctx.now, + }); + if (!extraction.ok) { + return { + rootDiagnostics: extraction.diagnostics.map((diagnostic) => ({ + ...diagnostic, + extensionId: diagnostic.extensionId ?? extensionName, + })), + }; + } + + const validation = validateStaticManifest({ + rawManifest: extraction.manifest, + extensionName, + rootKind: root.kind, + now: ctx.now, + }); + if (!validation.ok) { + return { rootDiagnostics: validation.diagnostics }; + } + + const registrationDiscovery = await discoverExtensionRegistrations({ + extensionName: validation.manifest.id, + entrypointPath, + allowSkills: staticManifestHasSkillsCapability(extraction.manifest), + now: ctx.now, + timeoutMs: ctx.perFileTimeoutMs, + }); + const registrationRequestedPermissions = + registrationDiscovery.contributions.length > 0 ? ["skill.register"] : []; + const manifest: ValidatedManifest = { + ...validation.manifest, + requestedPermissions: Array.from( + new Set([...validation.manifest.requestedPermissions, ...registrationRequestedPermissions]) + ), + contributions: registrationDiscovery.contributions, + }; + const contributions = manifest.contributions.map(toDiscoveredContribution); + const isBundled = root.kind === "bundled"; + const stateCtx: DiscoveryStateLookupContext = { + rootId: root.rootId, + rootKind: root.kind, + extensionId: manifest.id, + isBundled, + }; + const enabled = ctx.state?.isEnabled?.(stateCtx) ?? isBundled; + const approvalRecord = ctx.state?.getApprovalRecord?.(stateCtx); + const granted = isBundled || approvalRecord !== undefined; + const approvedForCurrentPermissions = + isBundled || approvalCoversRequestedPermissions(approvalRecord, manifest.requestedPermissions); + // Full Activation validates registrations already observed during Registration + // Discovery; it must not become a second discovery pass that can execute + // mode-gated permission asks before the user approves them. + const activationEligible = + registrationDiscovery.contributions.length > 0 && + (root.kind === "bundled" || root.kind === "user-global" || root.trusted === true) && + enabled && + approvedForCurrentPermissions; + + const extensionDiagnostics: ExtensionDiagnostic[] = [ + ...validation.diagnostics, + ...registrationDiscovery.diagnostics, + ]; + let activated = false; + if (activationEligible) { + const activationDiscovery = await discoverExtensionRegistrations({ + extensionName: manifest.id, + entrypointPath, + allowSkills: staticManifestHasSkillsCapability(extraction.manifest), + mode: "activate", + now: ctx.now, + timeoutMs: ctx.perFileTimeoutMs, + }); + const activationSession = activationDiscovery.activationSession; + let keepActivationSession = false; + try { + extensionDiagnostics.push(...activationDiscovery.diagnostics); + const undiscoveredDiagnostics = activationUndiscoveredDiagnostics( + manifest.id, + manifest.contributions, + activationDiscovery.contributions, + ctx.now + ); + extensionDiagnostics.push(...undiscoveredDiagnostics); + + const activationFailureDiagnostics = activationDiscovery.diagnostics.filter( + (diagnostic) => !isExecutionConsoleDiagnostic(diagnostic) + ); + if (activationFailureDiagnostics.length === 0 && undiscoveredDiagnostics.length === 0) { + let allActivated = true; + const activatedContributionKeys = new Set( + activationDiscovery.contributions.map(registrationKey) + ); + for (let i = 0; i < contributions.length; i++) { + if (!activatedContributionKeys.has(registrationKey(manifest.contributions[i]))) continue; + const activationResult = await activateContribution( + manifest.id, + modulePath, + manifest.contributions[i], + contributions[i], + ctx + ); + contributions[i] = activationResult.contribution; + if (!activationResult.contribution.activated) allActivated = false; + extensionDiagnostics.push(...activationResult.diagnostics); + } + activated = allActivated; + } + if (activated && activationSession && ctx.activationSessionSink) { + ctx.activationSessionSink({ + rootId: root.rootId, + extensionId: manifest.id, + session: activationSession, + }); + keepActivationSession = true; + } + } finally { + if (!keepActivationSession) activationSession?.dispose(); + } + } + + return { + rootDiagnostics: [], + extension: { + extensionId: manifest.id, + rootId: root.rootId, + rootKind: root.kind, + isCore: root.isCore === true, + modulePath, + manifest, + contributions, + diagnostics: extensionDiagnostics, + enabled, + granted, + activated, + }, + }; +} + +interface CandidateResult { + extension?: DiscoveredExtension; + /** Root-level diagnostics for malformed Extension Modules. */ + rootDiagnostics: ExtensionDiagnostic[]; +} + +function toDiscoveredContribution(c: ValidatedContribution): DiscoveredContribution { + const bodyPath = needsBodyFile(c.type) + ? typeof c.descriptor.body === "string" + ? c.descriptor.body + : undefined + : undefined; + return { + type: c.type, + id: c.id, + index: c.index, + bodyPath, + activated: false, + }; +} + +function needsBodyFile(type: string): boolean { + return type === "skills" || type === "agents"; +} + +async function readActivationBodyFile(realPath: string): Promise<{ + content: string | null; + sizeValidationError?: string; +}> { + const noFollow = fsConstants.O_NOFOLLOW ?? 0; + const handle = await fsPromises.open(realPath, fsConstants.O_RDONLY | noFollow); + try { + const openedRealPath = await realpathOpenedFile(handle, realPath); + if (path.normalize(openedRealPath) !== path.normalize(realPath)) { + throw new Error("Opened body file resolves outside its validated path."); + } + + const stat = await handle.stat(); + const sizeValidation = validateFileSize({ + size: stat.size, + modifiedTime: stat.mtime, + isDirectory: stat.isDirectory(), + }); + if (sizeValidation) { + return { content: null, sizeValidationError: sizeValidation.error }; + } + + return { content: await handle.readFile("utf-8") }; + } finally { + await handle.close(); + } +} + +interface ActivationResult { + contribution: DiscoveredContribution; + diagnostics: ExtensionDiagnostic[]; +} + +async function activateContribution( + extensionId: string, + modulePath: string, + validated: ValidatedContribution, + contribution: DiscoveredContribution, + ctx: RootContext +): Promise { + const diagnostics: ExtensionDiagnostic[] = []; + + // Descriptor-only types validated at manifest time; nothing more to read. + if (!needsBodyFile(contribution.type)) { + return { + contribution: { ...contribution, activated: true }, + diagnostics, + }; + } + + const bodyPath = contribution.bodyPath; + if (!bodyPath) { + diagnostics.push({ + code: "contribution.body.missing", + severity: "warn", + message: `Contribution "${contribution.type}/${contribution.id}" is missing a body path.`, + extensionId, + contributionRef: { type: contribution.type, index: validated.index, id: contribution.id }, + occurredAt: ctx.now, + }); + return { contribution, diagnostics }; + } + + // ensureExtensionPathContained rejects on invalid paths; race the timeout + // and translate either failure into a contribution-level diagnostic so a + // single bad body never derails Activation Discovery for siblings. + const containResult = await withTimeout( + () => + ensureExtensionPathContained(modulePath, bodyPath).then( + (v) => ({ ok: true as const, value: v }), + (err: unknown) => ({ + ok: false as const, + error: err instanceof Error ? err : new Error(String(err)), + }) + ), + ctx.perFileTimeoutMs + ); + if (containResult === TIMEOUT_SENTINEL) { + diagnostics.push({ + code: "contribution.body.timeout", + severity: "error", + message: `Reading body for "${contribution.type}/${contribution.id}" exceeded the ${ctx.perFileTimeoutMs}ms timeout.`, + extensionId, + contributionRef: { type: contribution.type, index: validated.index, id: contribution.id }, + occurredAt: ctx.now, + }); + return { contribution, diagnostics }; + } + if (!containResult.ok) { + diagnostics.push({ + code: "contribution.body.invalid", + severity: "warn", + message: `Body path for "${contribution.type}/${contribution.id}" is invalid: ${containResult.error.message}`, + extensionId, + contributionRef: { type: contribution.type, index: validated.index, id: contribution.id }, + occurredAt: ctx.now, + }); + return { contribution, diagnostics }; + } + + const bodyResult = await withTimeout( + () => + readActivationBodyFile(containResult.value.realPath).then( + (v) => ({ ok: true as const, value: v }), + (err: unknown) => ({ + ok: false as const, + error: err instanceof Error ? err : new Error(String(err)), + }) + ), + ctx.perFileTimeoutMs + ); + if (bodyResult === TIMEOUT_SENTINEL) { + diagnostics.push({ + code: "contribution.body.timeout", + severity: "error", + message: `Reading body for "${contribution.type}/${contribution.id}" exceeded the ${ctx.perFileTimeoutMs}ms timeout.`, + extensionId, + contributionRef: { type: contribution.type, index: validated.index, id: contribution.id }, + occurredAt: ctx.now, + }); + return { contribution, diagnostics }; + } + if (!bodyResult.ok) { + diagnostics.push({ + code: "contribution.body.invalid", + severity: "warn", + message: `Failed to read body for "${contribution.type}/${contribution.id}": ${bodyResult.error.message}`, + extensionId, + contributionRef: { type: contribution.type, index: validated.index, id: contribution.id }, + occurredAt: ctx.now, + }); + return { contribution, diagnostics }; + } + if (bodyResult.value.sizeValidationError) { + diagnostics.push({ + code: "contribution.body.invalid", + severity: "warn", + message: `Body for "${contribution.type}/${contribution.id}" is invalid: ${bodyResult.value.sizeValidationError}`, + extensionId, + contributionRef: { type: contribution.type, index: validated.index, id: contribution.id }, + occurredAt: ctx.now, + }); + return { contribution, diagnostics }; + } + const bodyContent = bodyResult.value.content; + if (bodyContent == null) { + throw new Error("readActivationBodyFile returned no content without a validation error"); + } + + if (contribution.type === "skills") { + const skillName = SkillNameSchema.safeParse(contribution.id); + if (!skillName.success) { + diagnostics.push({ + code: "contribution.body.invalid", + severity: "warn", + message: `Skill contribution name "${contribution.id}" is invalid: ${skillName.error.message}`, + extensionId, + contributionRef: { type: contribution.type, index: validated.index, id: contribution.id }, + occurredAt: ctx.now, + }); + return { contribution, diagnostics }; + } + try { + parseSkillMarkdown({ + content: bodyContent, + byteSize: Buffer.byteLength(bodyContent, "utf-8"), + directoryName: skillName.data, + }); + } catch (error) { + diagnostics.push({ + code: "contribution.body.invalid", + severity: "warn", + message: `Body for "${contribution.type}/${contribution.id}" is invalid: ${ + error instanceof Error ? error.message : String(error) + }`, + extensionId, + contributionRef: { type: contribution.type, index: validated.index, id: contribution.id }, + occurredAt: ctx.now, + }); + return { contribution, diagnostics }; + } + } + + return { + contribution: { ...contribution, bodyRealPath: containResult.value.realPath, activated: true }, + diagnostics, + }; +} diff --git a/src/node/extensions/extensionPathContainment.test.ts b/src/node/extensions/extensionPathContainment.test.ts new file mode 100644 index 0000000000..a13b710dc2 --- /dev/null +++ b/src/node/extensions/extensionPathContainment.test.ts @@ -0,0 +1,176 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { describe, expect, test } from "@jest/globals"; + +import { TestTempDir } from "@/node/services/tools/testHelpers"; + +import { ensureExtensionPathContained } from "./extensionPathContainment"; + +describe("ensureExtensionPathContained — relative-only enforcement", () => { + test("resolves a simple relative path that exists inside the package", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + const target = path.join(pkg, "SKILL.md"); + await fs.writeFile(target, "# skill"); + + const result = await ensureExtensionPathContained(pkg, "SKILL.md"); + + expect(result.normalizedRelativePath).toBe("SKILL.md"); + expect(result.resolvedPath).toBe(target); + expect(result.realPath).toBe(await fs.realpath(target)); + }); + + test("rejects an absolute Unix-style path", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + + await expect(ensureExtensionPathContained(pkg, "/etc/passwd")).rejects.toThrow( + /must be relative/iu + ); + }); + + test("rejects a Windows-drive absolute path", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + + await expect(ensureExtensionPathContained(pkg, "C:/foo")).rejects.toThrow(/must be relative/iu); + }); + + test("rejects a tilde-prefixed home path", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + + await expect(ensureExtensionPathContained(pkg, "~/secret")).rejects.toThrow( + /must be relative/iu + ); + }); + + test("rejects an empty path", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + + await expect(ensureExtensionPathContained(pkg, "")).rejects.toThrow(/required/iu); + }); +}); + +describe("ensureExtensionPathContained — traversal rejection", () => { + test("rejects a leading-dotdot path", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + + await expect(ensureExtensionPathContained(pkg, "../sibling")).rejects.toThrow(/traversal/iu); + }); + + test("rejects an embedded-dotdot path that escapes root after normalization", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + + await expect(ensureExtensionPathContained(pkg, "skills/../../escape")).rejects.toThrow( + /traversal/iu + ); + }); + + test("rejects a bare-dotdot path", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + + await expect(ensureExtensionPathContained(pkg, "..")).rejects.toThrow(/traversal/iu); + }); +}); + +describe("ensureExtensionPathContained — internal-symlink rejection", () => { + test("rejects when the leaf file is a symlink (even if the link points inside the package)", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + const realFile = path.join(pkg, "real.md"); + await fs.writeFile(realFile, "# real"); + const linkFile = path.join(pkg, "alias.md"); + await fs.symlink(realFile, linkFile); + + await expect(ensureExtensionPathContained(pkg, "alias.md")).rejects.toThrow(/symlink/iu); + }); + + test("rejects when an intermediate directory along the path is a symlink", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + const realDir = path.join(pkg, "real-dir"); + await fs.mkdir(realDir, { recursive: true }); + await fs.writeFile(path.join(realDir, "body.md"), "# body"); + const linkDir = path.join(pkg, "link-dir"); + await fs.symlink(realDir, linkDir); + + await expect(ensureExtensionPathContained(pkg, "link-dir/body.md")).rejects.toThrow( + /symlink/iu + ); + }); + + test("does not lstat the leaf when allowMissingLeaf is set and the leaf does not exist", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + + const result = await ensureExtensionPathContained(pkg, "future.md", { + allowMissingLeaf: true, + }); + expect(result.normalizedRelativePath).toBe("future.md"); + }); + + test("rejects when an existing intermediate dir is a symlink even with allowMissingLeaf", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + const realDir = path.join(pkg, "real-dir"); + await fs.mkdir(realDir, { recursive: true }); + const linkDir = path.join(pkg, "link-dir"); + await fs.symlink(realDir, linkDir); + + await expect( + ensureExtensionPathContained(pkg, "link-dir/missing.md", { allowMissingLeaf: true }) + ).rejects.toThrow(/symlink/iu); + }); +}); + +describe("ensureExtensionPathContained — realpath containment with allow-missing-leaf", () => { + test("rejects without allowMissingLeaf when leaf does not exist", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + await fs.mkdir(pkg, { recursive: true }); + + await expect(ensureExtensionPathContained(pkg, "ghost.md")).rejects.toThrow(); + }); + + test("accepts a deep nested path under existing real directories", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + const nested = path.join(pkg, "skills", "intro"); + await fs.mkdir(nested, { recursive: true }); + await fs.writeFile(path.join(nested, "SKILL.md"), "# intro"); + + const result = await ensureExtensionPathContained(pkg, "skills/intro/SKILL.md"); + expect(result.normalizedRelativePath).toBe("skills/intro/SKILL.md"); + }); + + test("accepts a missing leaf with allowMissingLeaf even under an existing nested dir", async () => { + using tempDir = new TestTempDir("ext-path-contain"); + const pkg = path.join(tempDir.path, "ext"); + const nested = path.join(pkg, "skills", "intro"); + await fs.mkdir(nested, { recursive: true }); + + const result = await ensureExtensionPathContained(pkg, "skills/intro/SKILL.md", { + allowMissingLeaf: true, + }); + expect(result.normalizedRelativePath).toBe("skills/intro/SKILL.md"); + expect(result.resolvedPath).toBe(path.join(nested, "SKILL.md")); + }); +}); diff --git a/src/node/extensions/extensionPathContainment.ts b/src/node/extensions/extensionPathContainment.ts new file mode 100644 index 0000000000..f6c7533d7d --- /dev/null +++ b/src/node/extensions/extensionPathContainment.ts @@ -0,0 +1,68 @@ +import * as path from "node:path"; + +import { + ensurePathContained, + isAbsolutePathAny, + lstatIfExists, +} from "@/node/services/tools/skillFileUtils"; + +export interface ContainedExtensionPath { + resolvedPath: string; + realPath: string; + normalizedRelativePath: string; +} + +export async function ensureExtensionPathContained( + moduleRoot: string, + contributedPath: string, + options?: { allowMissingLeaf?: boolean } +): Promise { + if (!contributedPath) { + throw new Error("contributedPath is required"); + } + + if (isAbsolutePathAny(contributedPath) || contributedPath.startsWith("~")) { + throw new Error( + `Invalid contributed path (must be relative to the Extension Module): ${contributedPath}` + ); + } + + const resolvedPath = path.resolve(moduleRoot, contributedPath); + const lexicalRelative = path.relative(moduleRoot, resolvedPath); + if ( + lexicalRelative === ".." || + lexicalRelative.startsWith(`..${path.sep}`) || + path.isAbsolute(lexicalRelative) + ) { + throw new Error(`Invalid contributed path (path traversal): ${contributedPath}`); + } + + await rejectInternalSymlinks(moduleRoot, lexicalRelative, contributedPath); + + const realPath = await ensurePathContained(moduleRoot, resolvedPath, { + allowMissing: options?.allowMissingLeaf, + }); + + const normalizedRelativePath = lexicalRelative.replaceAll(path.sep, "/"); + + return { resolvedPath, realPath, normalizedRelativePath }; +} + +async function rejectInternalSymlinks( + moduleRoot: string, + lexicalRelative: string, + inputForError: string +): Promise { + const segments = lexicalRelative.split(path.sep).filter((s) => s.length > 0); + let cursor = moduleRoot; + for (const segment of segments) { + cursor = path.join(cursor, segment); + const stat = await lstatIfExists(cursor); + if (stat == null) { + return; + } + if (stat.isSymbolicLink()) { + throw new Error(`Invalid contributed path (segment is a symlink): ${inputForError}`); + } + } +} diff --git a/src/node/extensions/extensionRegistrationDiscoveryService.ts b/src/node/extensions/extensionRegistrationDiscoveryService.ts new file mode 100644 index 0000000000..03473e09d0 --- /dev/null +++ b/src/node/extensions/extensionRegistrationDiscoveryService.ts @@ -0,0 +1,568 @@ +import { constants as fsConstants } from "fs"; +import * as fsPromises from "fs/promises"; +import * as path from "path"; + +import { z } from "zod"; +import ts from "typescript"; + +import type { + ExtensionDiagnostic, + ValidatedContribution, +} from "@/common/extensions/manifestValidator"; +import { RelativeBodyPathSchema } from "@/common/orpc/schemas/extension"; +import { SkillNameSchema } from "@/common/orpc/schemas/agentSkill"; +import { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime"; + +import { realpathOpenedFile } from "@/node/utils/openedFileRealpath"; +import { validateFileSize } from "@/node/services/tools/fileCommon"; +import type { IJSRuntime } from "@/node/services/ptc/runtime"; +import type { PTCConsoleRecord } from "@/node/services/ptc/types"; + +const DISCOVERY_TIMEOUT_MS_DEFAULT = 2_000; +const DISCOVERY_MEMORY_BYTES_DEFAULT = 16 * 1024 * 1024; + +const SkillRegistrationSchema = z + .object({ + name: SkillNameSchema, + bodyPath: RelativeBodyPathSchema, + displayName: z.string().nullish(), + description: z.string().nullish(), + advertise: z.boolean().nullish(), + }) + .strict(); + +export interface ExtensionActivationSession { + abort(): void; + dispose(): void; +} + +class QuickJSExtensionActivationSession implements ExtensionActivationSession { + private disposed = false; + + constructor(private readonly runtime: IJSRuntime) {} + + abort(): void { + if (this.disposed) return; + this.runtime.abort(); + } + + dispose(): void { + if (this.disposed) return; + // Full Activation owns a long-lived QuickJS context. Aborting first makes + // teardown explicit if future v1-safe handlers are still running. + this.runtime.abort(); + this.runtime.dispose(); + this.disposed = true; + } +} + +export interface DiscoverExtensionRegistrationsInput { + extensionName: string; + entrypointPath: string; + allowSkills: boolean; + mode?: "discover" | "activate"; + now?: number; + timeoutMs?: number; +} + +export interface DiscoverExtensionRegistrationsResult { + contributions: ValidatedContribution[]; + diagnostics: ExtensionDiagnostic[]; + activationSession?: ExtensionActivationSession; +} + +function diagnostic( + code: string, + message: string, + extensionId: string, + occurredAt: number, + severity: ExtensionDiagnostic["severity"] = "error" +): ExtensionDiagnostic { + return { code, severity, message, extensionId, occurredAt }; +} + +function formatTsDiagnostic(tsDiagnostic: ts.Diagnostic): string { + return ts.flattenDiagnosticMessageText(tsDiagnostic.messageText, "\n"); +} + +interface TranspiledExtensionModule { + id: string; + code: string; +} + +class RegistrationBundleError extends Error { + constructor(readonly diagnostics: ExtensionDiagnostic[]) { + super(diagnostics.map((item) => item.message).join("\n")); + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === code + ); +} + +function toModuleId(rootRealPath: string, fileRealPath: string): string { + const relativePath = path.relative(rootRealPath, fileRealPath); + if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + throw new Error(`Extension relative import escapes Extension Module: ${fileRealPath}`); + } + return relativePath.split(path.sep).join("/"); +} + +function candidateModulePaths(basePath: string): string[] { + if (path.extname(basePath)) return [basePath]; + return [ + `${basePath}.ts`, + `${basePath}.tsx`, + `${basePath}.js`, + `${basePath}.jsx`, + path.join(basePath, "index.ts"), + path.join(basePath, "index.tsx"), + path.join(basePath, "index.js"), + path.join(basePath, "index.jsx"), + ]; +} + +async function resolveRelativeModule(input: { + fromFileRealPath: string; + rootRealPath: string; + specifier: string; +}): Promise { + const basePath = path.resolve(path.dirname(input.fromFileRealPath), input.specifier); + for (const candidatePath of candidateModulePaths(basePath)) { + let realPath: string; + try { + realPath = await fsPromises.realpath(candidatePath); + } catch (error) { + if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR")) continue; + throw error; + } + + const relativePath = path.relative(input.rootRealPath, realPath); + if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + throw new Error( + `Relative import ${JSON.stringify(input.specifier)} resolves outside the Extension Module.` + ); + } + + const stat = await fsPromises.stat(realPath); + if (stat.isFile()) return realPath; + } + + throw new Error( + `Cannot resolve relative import ${JSON.stringify(input.specifier)} from ${input.fromFileRealPath}.` + ); +} + +function getRequireSpecifiers(transpiledCode: string): string[] { + const sourceFile = ts.createSourceFile( + "extension.js", + transpiledCode, + ts.ScriptTarget.ES2020, + true, + ts.ScriptKind.JS + ); + const specifiers: string[] = []; + + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "require" + ) { + const [specifier] = node.arguments; + if (specifier && ts.isStringLiteral(specifier)) specifiers.push(specifier.text); + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return specifiers; +} + +function isContainedRealPath(rootRealPath: string, candidateRealPath: string): boolean { + const relativePath = path.relative(rootRealPath, candidateRealPath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +async function readContainedModuleSource( + rootRealPath: string, + fileRealPath: string +): Promise { + const noFollow = fsConstants.O_NOFOLLOW ?? 0; + const handle = await fsPromises.open(fileRealPath, fsConstants.O_RDONLY | noFollow); + try { + const openedRealPath = await realpathOpenedFile(handle, fileRealPath); + if ( + path.normalize(openedRealPath) !== path.normalize(fileRealPath) || + !isContainedRealPath(rootRealPath, openedRealPath) + ) { + throw new Error("Opened Extension module resolves outside its validated path."); + } + + const stat = await handle.stat(); + const sizeValidation = validateFileSize({ + size: stat.size, + modifiedTime: stat.mtime, + isDirectory: stat.isDirectory(), + }); + if (sizeValidation) throw new Error(sizeValidation.error); + return handle.readFile("utf-8"); + } finally { + await handle.close(); + } +} + +async function buildTranspiledModuleGraph(input: { + entrypointPath: string; + extensionName: string; + occurredAt: number; +}): Promise<{ entrypointId: string; modules: TranspiledExtensionModule[] }> { + const rootRealPath = await fsPromises.realpath(path.dirname(input.entrypointPath)); + const modules = new Map(); + + const compileModule = async (fileRealPath: string): Promise => { + const id = toModuleId(rootRealPath, fileRealPath); + if (modules.has(id)) return; + + let source: string; + try { + source = await readContainedModuleSource(rootRealPath, fileRealPath); + } catch (error) { + throw new RegistrationBundleError([ + diagnostic( + "extension.discovery.read_failed", + `Failed to read Extension module ${id}: ${ + error instanceof Error ? error.message : String(error) + }`, + input.extensionName, + input.occurredAt + ), + ]); + } + + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + reportDiagnostics: true, + }); + const transpileDiagnostics = transpiled.diagnostics ?? []; + if (transpileDiagnostics.length > 0) { + throw new RegistrationBundleError( + transpileDiagnostics.map((tsDiagnostic) => + diagnostic( + "extension.discovery.transpile_failed", + formatTsDiagnostic(tsDiagnostic), + input.extensionName, + input.occurredAt + ) + ) + ); + } + + modules.set(id, { id, code: transpiled.outputText }); + for (const specifier of getRequireSpecifiers(transpiled.outputText)) { + if (!specifier.startsWith(".")) { + if (specifier.startsWith("mux:")) continue; + throw new RegistrationBundleError([ + diagnostic( + "extension.discovery.import_unsupported", + `Extension imports must be mux:* virtual modules or contained relative imports in v1; bare import ${JSON.stringify(specifier)} is not allowed.`, + input.extensionName, + input.occurredAt + ), + ]); + } + const dependencyRealPath = await resolveRelativeModule({ + fromFileRealPath: fileRealPath, + rootRealPath, + specifier, + }); + await compileModule(dependencyRealPath); + } + }; + + const entrypointRealPath = await fsPromises.realpath(input.entrypointPath); + await compileModule(entrypointRealPath); + return { + entrypointId: toModuleId(rootRealPath, entrypointRealPath), + modules: [...modules.values()], + }; +} + +function buildDiscoveryCode(input: { + entrypointId: string; + modules: readonly TranspiledExtensionModule[]; + mode: "discover" | "activate"; +}): string { + const moduleDefinitions = input.modules + .map( + (item) => `${JSON.stringify(item.id)}: function(module, exports, require) {\n${item.code}\n}` + ) + .join(",\n"); + + return ` +function defineManifest(value) { return value; } +const __mux_moduleDefinitions = { ${moduleDefinitions} }; +const __mux_moduleCache = {}; +function __mux_dirname(id) { + const index = id.lastIndexOf("/"); + return index === -1 ? "" : id.slice(0, index); +} +function __mux_normalize(value) { + const parts = []; + for (const part of value.split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + if (parts.length === 0) throw new Error("Relative import escapes the Extension Module"); + parts.pop(); + continue; + } + parts.push(part); + } + return parts.join("/"); +} +function __mux_resolve(fromId, request) { + if (!request.startsWith(".")) { + throw new Error("Extension Registration Discovery supports only mux:* virtual imports and contained relative imports in v1: " + request); + } + const dir = __mux_dirname(fromId); + const base = __mux_normalize((dir ? dir + "/" : "") + request); + const explicitExtension = /\\.[cm]?[jt]sx?$/.test(base); + const candidates = explicitExtension + ? [base] + : [ + base + ".ts", + base + ".tsx", + base + ".js", + base + ".jsx", + base + "/index.ts", + base + "/index.tsx", + base + "/index.js", + base + "/index.jsx", + ]; + for (const candidate of candidates) { + if (__mux_moduleDefinitions[candidate]) return candidate; + } + throw new Error("Cannot resolve Extension relative import " + request + " from " + fromId); +} +function __mux_loadModule(id) { + if (__mux_moduleCache[id]) return __mux_moduleCache[id].exports; + const factory = __mux_moduleDefinitions[id]; + if (!factory) throw new Error("Unknown Extension module " + id); + const module = { exports: {} }; + __mux_moduleCache[id] = module; + factory(module, module.exports, function require(request) { + if (request === "mux:extensions") return { defineManifest }; + return __mux_loadModule(__mux_resolve(id, request)); + }); + return module.exports; +} +const __mux_entrypoint = __mux_loadModule(${JSON.stringify(input.entrypointId)}); +const __mux_activate = + typeof __mux_entrypoint.activate === "function" ? __mux_entrypoint.activate : undefined; +return (async function __mux_run_activation() { + if (__mux_activate) { + await __mux_activate({ + mode: ${JSON.stringify(input.mode)}, + skills: { + register(input) { + const token = __mux_register_skill(input); + return { + dispose() { + if (${JSON.stringify(input.mode)} === "activate") __mux_dispose_skill(token); + } + }; + }, + }, + }); + } + return true; +})(); +`; +} + +function executionFailureCode(input: { message: string; mode: "discover" | "activate" }): string { + if (input.message.includes("manifest.capabilities.skills")) { + return "extension.capability.undeclared"; + } + return input.mode === "activate" ? "extension.activation.failed" : "extension.discovery.failed"; +} + +function consoleSeverity(level: PTCConsoleRecord["level"]): ExtensionDiagnostic["severity"] { + if (level === "log") return "info"; + if (level === "warn") return "warn"; + return "error"; +} + +function formatConsoleArg(value: unknown): string { + if (typeof value === "string") return value; + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (typeof value === "number" || typeof value === "boolean") return value.toString(); + if (typeof value === "bigint") return value.toString(); + if (typeof value === "symbol") + return value.description ? `Symbol(${value.description})` : "Symbol()"; + if (typeof value === "function") return "[function]"; + try { + const json = JSON.stringify(value); + return json ?? Object.prototype.toString.call(value); + } catch { + return Object.prototype.toString.call(value); + } +} + +function consoleDiagnostics(input: { + records: readonly PTCConsoleRecord[]; + mode: "discover" | "activate"; + extensionName: string; + occurredAt: number; +}): ExtensionDiagnostic[] { + const phase = input.mode === "activate" ? "Full Activation" : "Registration Discovery"; + const code = + input.mode === "activate" ? "extension.activation.console" : "extension.discovery.console"; + return input.records.map((record) => ({ + code, + severity: consoleSeverity(record.level), + message: `${phase} console.${record.level}: ${record.args.map(formatConsoleArg).join(" ")}`, + extensionId: input.extensionName, + occurredAt: input.occurredAt, + })); +} + +export async function discoverExtensionRegistrations( + input: DiscoverExtensionRegistrationsInput +): Promise { + const occurredAt = input.now ?? Date.now(); + let moduleGraph: { entrypointId: string; modules: TranspiledExtensionModule[] }; + try { + moduleGraph = await buildTranspiledModuleGraph({ + entrypointPath: input.entrypointPath, + extensionName: input.extensionName, + occurredAt, + }); + } catch (error) { + if (error instanceof RegistrationBundleError) { + return { contributions: [], diagnostics: error.diagnostics }; + } + return { + contributions: [], + diagnostics: [ + diagnostic( + "extension.discovery.failed", + error instanceof Error ? error.message : String(error), + input.extensionName, + occurredAt + ), + ], + }; + } + + const disposedContributionIndexes = new Set(); + const contributions: ValidatedContribution[] = []; + const mode = input.mode ?? "discover"; + let runtime: IJSRuntime; + try { + runtime = await new QuickJSRuntimeFactory().create(); + } catch (error) { + return { + contributions: [], + diagnostics: [ + diagnostic( + mode === "activate" ? "extension.activation.failed" : "extension.discovery.failed", + `Failed to initialize extension sandbox: ${error instanceof Error ? error.message : String(error)}`, + input.extensionName, + occurredAt + ), + ], + }; + } + let keepRuntime = false; + try { + runtime.setLimits({ + memoryBytes: DISCOVERY_MEMORY_BYTES_DEFAULT, + timeoutMs: input.timeoutMs ?? DISCOVERY_TIMEOUT_MS_DEFAULT, + }); + runtime.registerFunction("__mux_register_skill", (registrationInput: unknown) => { + if (!input.allowSkills) { + throw new Error( + "manifest.capabilities.skills must be true before ctx.skills.register is used" + ); + } + const parsed = SkillRegistrationSchema.safeParse(registrationInput); + if (!parsed.success) { + throw new Error(parsed.error.message); + } + const index = contributions.length; + const descriptor: Record = { + descriptorVersion: 1, + id: parsed.data.name, + body: parsed.data.bodyPath, + }; + if (typeof parsed.data.displayName === "string") + descriptor.displayName = parsed.data.displayName; + if (typeof parsed.data.description === "string") + descriptor.description = parsed.data.description; + if (typeof parsed.data.advertise === "boolean") descriptor.advertise = parsed.data.advertise; + contributions.push({ + type: "skills", + id: parsed.data.name, + index, + descriptor, + }); + return Promise.resolve(index); + }); + runtime.registerFunction("__mux_dispose_skill", (token: unknown) => { + if (input.mode === "activate" && typeof token === "number") { + disposedContributionIndexes.add(token); + } + return Promise.resolve(null); + }); + + const result = await runtime.eval( + buildDiscoveryCode({ + entrypointId: moduleGraph.entrypointId, + modules: moduleGraph.modules, + mode, + }) + ); + const capturedConsole = consoleDiagnostics({ + records: result.consoleOutput, + mode, + extensionName: input.extensionName, + occurredAt, + }); + if (!result.success) { + const message = + result.error ?? + (mode === "activate" ? "Full Activation failed." : "Registration Discovery failed."); + const code = executionFailureCode({ message, mode }); + return { + contributions: [], + diagnostics: [ + ...capturedConsole, + diagnostic(code, message, input.extensionName, occurredAt), + ], + }; + } + const activeContributions = contributions.filter( + (_, index) => !disposedContributionIndexes.has(index) + ); + const activationSession = + mode === "activate" ? new QuickJSExtensionActivationSession(runtime) : undefined; + keepRuntime = activationSession !== undefined; + return { + contributions: activeContributions, + diagnostics: capturedConsole, + activationSession, + }; + } finally { + if (!keepRuntime) runtime.dispose(); + } +} diff --git a/src/node/extensions/extensionRegistryService.test.ts b/src/node/extensions/extensionRegistryService.test.ts new file mode 100644 index 0000000000..a87f022f81 --- /dev/null +++ b/src/node/extensions/extensionRegistryService.test.ts @@ -0,0 +1,1990 @@ +import * as fs from "fs"; +import * as fsp from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; + +import { extensionPermissionKey } from "@/common/extensions/extensionPermissionKey"; +import { hashRequestedPermissions } from "@/common/extensions/permissionCalculator"; +import { ExtensionRegistry, type DiscoverFn } from "./extensionRegistryService"; +import { createTestExtensionRegistry } from "./testExtensionRegistry"; +import { + discoverExtensions, + type DiscoveredExtension, + type ExtensionRootDescriptor, + type RootDiscoveryResult, +} from "./extensionDiscoveryService"; +import { staleProjectLocalRootId } from "./extensionRegistryService"; +import { Config } from "@/node/config"; +import { GlobalExtensionStateService } from "./globalExtensionStateService"; +import { + getProjectExtensionStateRoot, + ProjectExtensionStateService, +} from "./projectExtensionStateService"; +import type { ApprovalRecord } from "@/common/extensions/globalExtensionState"; +import type { ValidatedManifest } from "@/common/extensions/manifestValidator"; + +const FROZEN_NOW = 1_700_000_000_000; + +const SAMPLE_GRANT: ApprovalRecord = { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: hashRequestedPermissions(["skill.register"]), +}; + +function makeManifest( + id: string, + contributions: Array<{ type: string; id: string }> = [] +): ValidatedManifest { + const inferred = Array.from(new Set(contributions.map((c) => `${singularOf(c.type)}.register`))); + return { + manifestVersion: 1, + id, + requestedPermissions: inferred, + contributions: contributions.map((c, index) => ({ + type: c.type, + id: c.id, + index, + // Body field is descriptor-shape-specific; skills/agents need it for + // Activation Discovery + agentSkillsService merge. Other descriptor + // types ignore it, so it's harmless in fixtures. + descriptor: { descriptorVersion: 1, id: c.id, body: `${c.id}.md` }, + })), + }; +} + +function singularOf(type: string): string { + if (type.endsWith("s")) return type.slice(0, -1); + return type; +} + +function makeExtension(opts: { + extensionId: string; + rootId: string; + rootKind: ExtensionRootDescriptor["kind"]; + isCore?: boolean; + enabled?: boolean; + granted?: boolean; + activated?: boolean; + contributions?: Array<{ + type: string; + id: string; + activated?: boolean; + bodyPath?: string; + bodyRealPath?: string; + }>; +}): DiscoveredExtension { + const contributions = (opts.contributions ?? []).map((c, index) => { + const activated = c.activated ?? opts.activated ?? true; + const bodyPath = c.bodyPath ?? `${c.id}.md`; + return { + type: c.type, + id: c.id, + index, + bodyPath, + bodyRealPath: activated + ? (c.bodyRealPath ?? `/fake/${opts.extensionId}/${bodyPath}`) + : undefined, + activated, + }; + }); + return { + extensionId: opts.extensionId, + rootId: opts.rootId, + rootKind: opts.rootKind, + isCore: opts.isCore ?? false, + modulePath: `/fake/${opts.extensionId}`, + manifest: makeManifest( + opts.extensionId, + (opts.contributions ?? []).map((c) => ({ type: c.type, id: c.id })) + ), + contributions, + diagnostics: [], + enabled: opts.enabled ?? true, + granted: opts.granted ?? true, + activated: opts.activated ?? true, + }; +} + +function makeRoot( + rootDesc: ExtensionRootDescriptor, + extensions: DiscoveredExtension[], + trusted = true +): RootDiscoveryResult { + return { + rootId: rootDesc.rootId, + kind: rootDesc.kind, + path: rootDesc.path, + trusted, + rootExists: true, + state: "ready", + extensions, + diagnostics: [], + }; +} + +function stubDiscoverFn( + buildRoots: (input: { roots: readonly ExtensionRootDescriptor[] }) => RootDiscoveryResult[] +): DiscoverFn { + return (input) => + Promise.resolve({ generatedAt: input.now ?? FROZEN_NOW, roots: buildRoots(input) }); +} + +describe("ExtensionRegistry — basic snapshot lifecycle", () => { + let env: Awaited>; + + beforeEach(async () => { + env = await createTestExtensionRegistry(); + }); + + afterEach(async () => { + await env.cleanup(); + }); + + test("getSnapshot() returns null before reload()", () => { + expect(env.registry.getSnapshot()).toBeNull(); + }); + + test("reload keeps Full Activation sessions alive until an extension deactivates", async () => { + const root: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + const abortSession = mock(() => undefined); + const disposeSession = mock(() => undefined); + let reloadCount = 0; + const discoverFn: DiscoverFn = (input) => { + reloadCount++; + const extensions = + reloadCount === 1 + ? [ + makeExtension({ + extensionId: "author.skill", + rootId: root.rootId, + rootKind: root.kind, + contributions: [{ type: "skills", id: "demo" }], + }), + ] + : []; + if (extensions.length > 0) { + input.activationSessionSink?.({ + rootId: root.rootId, + extensionId: "author.skill", + session: { abort: abortSession, dispose: disposeSession }, + }); + } + return Promise.resolve({ + generatedAt: input.now ?? FROZEN_NOW, + roots: [makeRoot(root, extensions)], + }); + }; + const env = await createTestExtensionRegistry({ + roots: () => [root], + discoverFn, + now: () => FROZEN_NOW, + }); + try { + await env.registry.reload(); + expect(disposeSession).not.toHaveBeenCalled(); + + await env.registry.reload(); + expect(disposeSession).toHaveBeenCalledTimes(1); + } finally { + await env.cleanup(); + } + }); + + test("reload still publishes the live snapshot when optional cache write fails", async () => { + const root: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + const env = await createTestExtensionRegistry({ + roots: () => [root], + withSnapshotCache: true, + discoverFn: stubDiscoverFn(({ roots }) => [makeRoot(roots[0], [])]), + now: () => FROZEN_NOW, + }); + try { + if (!env.snapshotCache) throw new Error("Expected snapshot cache"); + spyOn(env.snapshotCache, "write").mockRejectedValue(new Error("disk full")); + let changed = false; + env.registry.onChanged(() => { + changed = true; + }); + + await env.registry.reload(); + + expect(changed).toBe(true); + expect(env.registry.getSnapshot()?.roots[0].rootId).toBe(root.rootId); + } finally { + await env.cleanup(); + } + }); + + test("reload surfaces malformed global extension state diagnostics on the user-global root", async () => { + const root: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + const env = await createTestExtensionRegistry({ + roots: () => [root], + discoverFn: stubDiscoverFn(({ roots }) => [ + makeRoot(roots[0], [ + makeExtension({ + extensionId: "author.skill", + rootId: root.rootId, + rootKind: root.kind, + contributions: [{ type: "skills", id: "demo" }], + }), + ]), + ]), + now: () => FROZEN_NOW, + }); + try { + await fsp.writeFile( + path.join(env.tempDir, "config.json"), + JSON.stringify({ + extensions: { + schemaVersion: 1, + extensions: { "author.skill": { enabled: "broken" } }, + }, + }), + "utf-8" + ); + + await env.registry.reload(); + + const diagnostic = env.registry + .getSnapshot() + ?.roots[0].diagnostics.find((d) => d.code === "extension.state.record.invalid"); + expect(diagnostic).toMatchObject({ + rootId: root.rootId, + extensionId: "author.skill", + }); + } finally { + await env.cleanup(); + } + }); + + test("reload surfaces malformed project-local extension state diagnostics on the project root", async () => { + const projectPath = await fsp.mkdtemp(path.join(os.tmpdir(), "mux-ext-state-diag-project-")); + const root: ExtensionRootDescriptor = { + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: projectPath, + trusted: true, + }; + const env = await createTestExtensionRegistry({ + roots: () => [root], + discoverFn: stubDiscoverFn(({ roots }) => [ + makeRoot(roots[0], [ + makeExtension({ + extensionId: "author.skill", + rootId: root.rootId, + rootKind: root.kind, + contributions: [{ type: "skills", id: "demo" }], + }), + ]), + ]), + now: () => FROZEN_NOW, + }); + try { + const stateFilePath = env.projectState.filePathFor(projectPath); + await fsp.mkdir(path.dirname(stateFilePath), { recursive: true }); + await fsp.writeFile( + stateFilePath, + JSON.stringify({ + schemaVersion: 1, + rootTrusted: true, + extensions: { "author.skill": { enabled: "broken" } }, + }), + "utf-8" + ); + + await env.registry.reload(); + + const diagnostic = env.registry + .getSnapshot() + ?.roots[0].diagnostics.find((d) => d.code === "extension.state.record.invalid"); + expect(diagnostic).toMatchObject({ + rootId: root.rootId, + extensionId: "author.skill", + }); + } finally { + await env.cleanup(); + await fsp.rm(projectPath, { recursive: true, force: true }); + } + }); + + test("getContributions() returns [] before reload()", () => { + expect(env.registry.getContributions("skills")).toEqual([]); + }); + + test("reload() with no roots produces an empty snapshot and emits onChanged", async () => { + let fired = 0; + env.registry.onChanged(() => { + fired += 1; + }); + await env.registry.reload(); + const snap = env.registry.getSnapshot(); + expect(snap).not.toBeNull(); + expect(snap!.roots).toEqual([]); + expect(snap!.availableContributions).toEqual([]); + expect(snap!.descriptors).toEqual([]); + expect(fired).toBe(1); + }); +}); + +describe("ExtensionRegistry — capability vs inspection paths", () => { + let env: Awaited>; + const bundledRoot: ExtensionRootDescriptor = { + rootId: "bundled", + kind: "bundled", + path: "/fake/bundled", + isCore: true, + }; + + beforeEach(async () => { + env = await createTestExtensionRegistry({ + roots: () => [bundledRoot], + discoverFn: stubDiscoverFn(({ roots }) => [ + makeRoot(roots[0], [ + makeExtension({ + extensionId: "mux.platformdemo", + rootId: "bundled", + rootKind: "bundled", + isCore: true, + granted: true, + activated: true, + contributions: [{ type: "skills", id: "demo", activated: true }], + }), + ]), + ]), + now: () => FROZEN_NOW, + }); + // Seed grant so calculator emits effective permission for skill.register. + await env.globalState.setApproval("mux-platform-demo", { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: "abc", + }); + }); + + afterEach(async () => { + await env.cleanup(); + }); + + test("getContributions returns the resolved contribution after reload", async () => { + await env.registry.reload(); + const contribs = env.registry.getContributions("skills"); + expect(contribs).toHaveLength(1); + expect(contribs[0]).toMatchObject({ + type: "skills", + id: "demo", + extensionId: "mux.platformdemo", + rootKind: "bundled", + }); + }); + + test("getDescriptors mirrors the same contribution as available=true", async () => { + await env.registry.reload(); + const descs = env.registry.getDescriptors("skills"); + expect(descs).toHaveLength(1); + expect(descs[0]).toMatchObject({ + type: "skills", + id: "demo", + extensionId: "mux.platformdemo", + available: true, + unavailableReasons: [], + }); + }); + + test("agents remain inspection-only until an agent consumer is wired", async () => { + const env = await createTestExtensionRegistry({ + roots: () => [{ rootId: "user-global", kind: "user-global", path: "/fake/user-global" }], + discoverFn: stubDiscoverFn(({ roots }) => [ + makeRoot(roots[0], [ + makeExtension({ + extensionId: "author.agent", + rootId: "user-global", + rootKind: "user-global", + contributions: [{ type: "agents", id: "helper" }], + }), + ]), + ]), + now: () => FROZEN_NOW, + }); + try { + await env.registry.reload(); + await env.registry.setEnabled( + { kind: "global", rootId: "user-global", rootKind: "user-global" }, + "author.agent", + true + ); + await env.registry.setApproval( + { kind: "global", rootId: "user-global", rootKind: "user-global" }, + "author.agent" + ); + + expect(env.registry.getContributions("agents")).toEqual([]); + expect(env.registry.getDescriptors("agents")[0]?.unavailableReasons).toContain( + "inspection-only" + ); + } finally { + await env.cleanup(); + } + }); + + test("getContributions ignores unrelated types", async () => { + await env.registry.reload(); + expect(env.registry.getContributions("agents")).toEqual([]); + }); +}); + +// Bundled Extensions are policy-granted, not user-consented. A fresh-install +// Demo Extension must be available with no manual grant. +describe("ExtensionRegistry — bundled policy auto-grant", () => { + let env: Awaited>; + const bundledRoot: ExtensionRootDescriptor = { + rootId: "bundled", + kind: "bundled", + path: "/fake/bundled", + isCore: true, + }; + + beforeEach(async () => { + env = await createTestExtensionRegistry({ + roots: () => [bundledRoot], + // Discovery is exercised through the real production gate so the + // `granted` field reflects what the live runReload() composition sees, + // not a stub that bypasses it. + discoverFn: (input) => { + const ctx = { + rootId: bundledRoot.rootId, + rootKind: bundledRoot.kind, + extensionId: "mux.platformdemo", + isBundled: true, + }; + const enabled = input.state?.isEnabled?.(ctx) ?? true; + const granted = input.state?.getApprovalRecord?.(ctx) !== undefined || ctx.isBundled; + const activated = enabled && granted; + return { + generatedAt: input.now ?? FROZEN_NOW, + roots: [ + makeRoot(bundledRoot, [ + makeExtension({ + extensionId: "mux.platformdemo", + rootId: bundledRoot.rootId, + rootKind: bundledRoot.kind, + isCore: true, + enabled, + granted, + activated, + contributions: [{ type: "skills", id: "demo", activated }], + }), + ]), + ], + }; + }, + now: () => FROZEN_NOW, + }); + }); + + afterEach(async () => { + await env.cleanup(); + }); + + test("contributes Available without any persisted approval record", async () => { + await env.registry.reload(); + const contribs = env.registry.getContributions("skills"); + expect(contribs).toHaveLength(1); + expect(contribs[0]).toMatchObject({ id: "demo", extensionId: "mux.platformdemo" }); + + const snapshot = env.registry.getSnapshot(); + const perms = snapshot?.permissions[extensionPermissionKey("bundled", "mux.platformdemo")]; + // The synthesized policy grant must include the inferred registration + // permission so the contribution clears `missing-permissions`, AND + // driftStatus stays null — a synthesized grant always matches the + // distribution it was synthesized from, so drift never accrues for + // bundled Extensions across version bumps. + expect(perms?.contributions[0]).toMatchObject({ available: true, missingPermissions: [] }); + expect(perms?.effectivePermissions).toContain("skill.register"); + expect(perms?.driftStatus).toBeNull(); + expect(perms?.pendingNew).toEqual([]); + }); + + test("setEnabled ignores bundled roots instead of writing user-global state", async () => { + const extensionId = "author.skill"; + const env = await createTestExtensionRegistry({ + roots: () => [{ rootId: "bundled", kind: "bundled", path: "/fake/bundled" }], + discoverFn: stubDiscoverFn(({ roots }) => [ + makeRoot(roots[0], [ + makeExtension({ + extensionId, + rootId: roots[0].rootId, + rootKind: roots[0].kind, + contributions: [{ type: "skills", id: "demo" }], + }), + ]), + ]), + now: () => FROZEN_NOW, + }); + try { + await env.registry.reload(); + await env.registry.setEnabled( + { kind: "global", rootId: "bundled", rootKind: "bundled" }, + extensionId, + false + ); + + expect(env.globalState.load().state.extensions[extensionId]).toBeUndefined(); + expect(env.registry.getSnapshot()?.roots[0].extensions[0].enabled).toBe(true); + } finally { + await env.cleanup(); + } + }); + + test("bundled extensions ignore user-global state records with the same identity", async () => { + const extensionId = "author.skill"; + const bundledRoot: ExtensionRootDescriptor = { + rootId: "bundled", + kind: "bundled", + path: "/fake/bundled", + }; + const env = await createTestExtensionRegistry({ + roots: () => [bundledRoot], + discoverFn: () => ({ + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(bundledRoot, [ + makeExtension({ + extensionId, + rootId: bundledRoot.rootId, + rootKind: bundledRoot.kind, + contributions: [{ type: "skills", id: "demo" }], + }), + ]), + ], + }), + now: () => FROZEN_NOW, + }); + try { + await env.globalState.setEnabled(extensionId, false); + await env.registry.reload(); + + expect(env.registry.getSnapshot()?.roots[0].extensions[0].enabled).toBe(true); + expect(env.registry.getContributions("skills")).toHaveLength(1); + } finally { + await env.cleanup(); + } + }); + + test("getSkillSources returns absolute body paths for available skill contributions", async () => { + await env.registry.reload(); + const sources = env.registry.getSkillSources(); + expect(sources).toHaveLength(1); + expect(sources[0]).toMatchObject({ + name: "demo", + extensionId: "mux.platformdemo", + advertise: true, + }); + expect(sources[0].bodyAbsolutePath).toBe("/fake/mux.platformdemo/demo.md"); + }); + + test("getSkillSources matches availability by rootId when duplicate extension identities exist", async () => { + const projectPath = path.join( + os.tmpdir(), + `mux-test-extension-project-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + const projectRoot: ExtensionRootDescriptor = { + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: projectPath, + trusted: true, + }; + const userRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/user", + }; + const duplicateId = "publisher.duplicate"; + const env = await createTestExtensionRegistry({ + roots: () => [userRoot, projectRoot], + discoverFn: () => ({ + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(userRoot, [ + makeExtension({ + extensionId: duplicateId, + rootId: userRoot.rootId, + rootKind: userRoot.kind, + contributions: [ + { type: "skills", id: "same-skill", bodyRealPath: "/user/same-skill.md" }, + ], + }), + ]), + makeRoot(projectRoot, [ + makeExtension({ + extensionId: duplicateId, + rootId: projectRoot.rootId, + rootKind: projectRoot.kind, + contributions: [ + { type: "skills", id: "same-skill", bodyRealPath: "/project/same-skill.md" }, + ], + }), + ]), + ], + }), + now: () => FROZEN_NOW, + }); + try { + await env.globalState.setEnabled(duplicateId, true); + await env.projectState.setRootTrusted(projectRoot.path, true); + await env.projectState.setEnabled(projectRoot.path, duplicateId, true); + await env.registry.reload(); + await env.registry.setApproval({ kind: "global" }, duplicateId); + await env.registry.setApproval({ kind: "project-local", projectPath }, duplicateId); + + const sources = env.registry.getSkillSources(projectPath); + expect(sources).toHaveLength(1); + expect(sources[0]?.bodyAbsolutePath).toBe("/project/same-skill.md"); + } finally { + await env.cleanup(); + await fsp.rm(projectPath, { recursive: true, force: true }); + } + }); + + test("getSkillSources scopes project-local Extension Name shadowing to the active project", async () => { + const projectPath = await fsp.mkdtemp(path.join(os.tmpdir(), "mux-ext-scoped-identity-")); + const otherProjectPath = path.join(os.tmpdir(), `mux-ext-other-${Date.now()}`); + const projectRoot: ExtensionRootDescriptor = { + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: projectPath, + trusted: true, + }; + const userRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/user", + }; + const duplicateId = "publisher.scoped"; + const env = await createTestExtensionRegistry({ + roots: () => [userRoot, projectRoot], + discoverFn: () => ({ + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(userRoot, [ + makeExtension({ + extensionId: duplicateId, + rootId: userRoot.rootId, + rootKind: userRoot.kind, + contributions: [ + { type: "skills", id: "global-only", bodyRealPath: "/user/global-only.md" }, + ], + }), + ]), + makeRoot(projectRoot, [ + makeExtension({ + extensionId: duplicateId, + rootId: projectRoot.rootId, + rootKind: projectRoot.kind, + contributions: [ + { + type: "skills", + id: "project-only", + bodyRealPath: "/project/project-only.md", + }, + ], + }), + ]), + ], + }), + now: () => FROZEN_NOW, + }); + try { + await env.globalState.setEnabled(duplicateId, true); + await env.projectState.setRootTrusted(projectRoot.path, true); + await env.projectState.setEnabled(projectRoot.path, duplicateId, true); + await env.registry.reload(); + await env.registry.setApproval( + { kind: "global", rootId: userRoot.rootId, rootKind: "user-global" }, + duplicateId + ); + await env.registry.setApproval({ kind: "project-local", projectPath }, duplicateId); + + expect(env.registry.getSkillSources(projectPath).map((source) => source.name)).toEqual([ + "project-only", + ]); + expect(env.registry.getSkillSources(otherProjectPath).map((source) => source.name)).toEqual([ + "global-only", + ]); + } finally { + await env.cleanup(); + await fsp.rm(projectPath, { recursive: true, force: true }); + } + }); + + test("getSkillSources prefers active project-local skill id over global skill id", async () => { + const projectPath = await fsp.mkdtemp(path.join(os.tmpdir(), "mux-ext-skills-project-id-")); + const projectRoot: ExtensionRootDescriptor = { + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: projectPath, + trusted: true, + }; + const userRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/user", + }; + const env = await createTestExtensionRegistry({ + roots: () => [userRoot, projectRoot], + discoverFn: () => ({ + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(userRoot, [ + makeExtension({ + extensionId: "publisher.global", + rootId: userRoot.rootId, + rootKind: userRoot.kind, + contributions: [ + { type: "skills", id: "shared-skill", bodyRealPath: "/user/shared-skill.md" }, + ], + }), + ]), + makeRoot(projectRoot, [ + makeExtension({ + extensionId: "publisher.project", + rootId: projectRoot.rootId, + rootKind: projectRoot.kind, + contributions: [ + { type: "skills", id: "shared-skill", bodyRealPath: "/project/shared-skill.md" }, + ], + }), + ]), + ], + }), + now: () => FROZEN_NOW, + }); + try { + await env.globalState.setEnabled("publisher.global", true); + await env.projectState.setRootTrusted(projectRoot.path, true); + await env.projectState.setEnabled(projectRoot.path, "publisher.project", true); + await env.registry.reload(); + await env.registry.setApproval( + { kind: "global", rootId: userRoot.rootId, rootKind: "user-global" }, + "publisher.global" + ); + await env.registry.setApproval({ kind: "project-local", projectPath }, "publisher.project"); + + const sources = env.registry.getSkillSources(projectPath); + expect(sources).toHaveLength(1); + expect(sources[0]?.bodyAbsolutePath).toBe("/project/shared-skill.md"); + } finally { + await env.cleanup(); + await fsp.rm(projectPath, { recursive: true, force: true }); + } + }); + + test("getSkillSources keeps global skill when project-local copy is inactive", async () => { + const projectPath = await fsp.mkdtemp( + path.join(os.tmpdir(), "mux-ext-skills-inactive-project-") + ); + const projectRoot: ExtensionRootDescriptor = { + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: projectPath, + trusted: true, + }; + const userRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/user", + }; + const duplicateId = "publisher.inactive"; + const env = await createTestExtensionRegistry({ + roots: () => [userRoot, projectRoot], + discoverFn: () => ({ + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(userRoot, [ + makeExtension({ + extensionId: duplicateId, + rootId: userRoot.rootId, + rootKind: userRoot.kind, + contributions: [ + { type: "skills", id: "same-skill", bodyRealPath: "/user/same-skill.md" }, + ], + }), + ]), + makeRoot(projectRoot, [ + makeExtension({ + extensionId: duplicateId, + rootId: projectRoot.rootId, + rootKind: projectRoot.kind, + enabled: false, + granted: false, + activated: false, + contributions: [{ type: "skills", id: "same-skill", activated: false }], + }), + ]), + ], + }), + now: () => FROZEN_NOW, + }); + try { + await env.globalState.setEnabled(duplicateId, true); + await env.projectState.setRootTrusted(projectRoot.path, true); + await env.registry.reload(); + await env.registry.setApproval( + { kind: "global", rootId: userRoot.rootId, rootKind: "user-global" }, + duplicateId + ); + + const sources = env.registry.getSkillSources(projectPath); + expect(sources).toHaveLength(1); + expect(sources[0]?.bodyAbsolutePath).toBe("/user/same-skill.md"); + } finally { + await env.cleanup(); + await fsp.rm(projectPath, { recursive: true, force: true }); + } + }); + + test("getSkillSources uses the activation-validated body path", async () => { + const env = await createTestExtensionRegistry({ + roots: () => [bundledRoot], + discoverFn: () => ({ + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(bundledRoot, [ + makeExtension({ + extensionId: "mux.platformdemo", + rootId: bundledRoot.rootId, + rootKind: bundledRoot.kind, + isCore: true, + contributions: [ + { + type: "skills", + id: "demo", + bodyPath: "raw-demo.md", + bodyRealPath: "/validated/package/raw-demo.md", + }, + ], + }), + ]), + ], + }), + now: () => FROZEN_NOW, + }); + try { + await env.registry.reload(); + expect(env.registry.getSkillSources()[0]?.bodyAbsolutePath).toBe( + "/validated/package/raw-demo.md" + ); + } finally { + await env.cleanup(); + } + }); +}); + +describe("ExtensionRegistry — state transitions", () => { + let env: Awaited>; + const root: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + + beforeEach(async () => { + env = await createTestExtensionRegistry({ + roots: () => [root], + discoverFn: (input) => { + const enabled = + input.state?.isEnabled?.({ + rootId: root.rootId, + rootKind: root.kind, + extensionId: "author.skill", + isBundled: false, + }) ?? false; + const grant = input.state?.getApprovalRecord?.({ + rootId: root.rootId, + rootKind: root.kind, + extensionId: "author.skill", + isBundled: false, + }); + return { + generatedAt: input.now ?? FROZEN_NOW, + roots: [ + makeRoot(root, [ + makeExtension({ + extensionId: "author.skill", + rootId: root.rootId, + rootKind: root.kind, + enabled, + granted: grant !== undefined, + activated: enabled && grant !== undefined, + contributions: [ + { type: "skills", id: "alpha", activated: enabled && grant !== undefined }, + ], + }), + ]), + ], + }; + }, + now: () => FROZEN_NOW, + }); + }); + + afterEach(async () => { + await env.cleanup(); + }); + + test("enable + grant promotes the contribution into the Capability Path", async () => { + await env.registry.setEnabled({ kind: "global" }, "author.skill", true); + expect(env.registry.getContributions("skills")).toEqual([]); + await env.registry.setApproval({ kind: "global" }, "author.skill"); + const contribs = env.registry.getContributions("skills"); + expect(contribs).toHaveLength(1); + expect(contribs[0]).toMatchObject({ type: "skills", id: "alpha" }); + }); + + test("Full Activation subset keeps omitted discovered skills inspectable without body-failed", async () => { + const env = await createTestExtensionRegistry({ + roots: () => [root], + discoverFn: (input) => { + const enabled = + input.state?.isEnabled?.({ + rootId: root.rootId, + rootKind: root.kind, + extensionId: "author.skill", + isBundled: false, + }) ?? false; + const grant = input.state?.getApprovalRecord?.({ + rootId: root.rootId, + rootKind: root.kind, + extensionId: "author.skill", + isBundled: false, + }); + return { + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(root, [ + makeExtension({ + extensionId: "author.skill", + rootId: root.rootId, + rootKind: root.kind, + enabled, + granted: grant !== undefined, + activated: enabled && grant !== undefined, + contributions: [ + { type: "skills", id: "active", activated: enabled && grant !== undefined }, + { type: "skills", id: "omitted", activated: false }, + ], + }), + ]), + ], + }; + }, + now: () => FROZEN_NOW, + }); + try { + await env.registry.setEnabled({ kind: "global" }, "author.skill", true); + await env.registry.setApproval({ kind: "global" }, "author.skill"); + + expect(env.registry.getContributions("skills")).toEqual([ + { + type: "skills", + id: "active", + extensionId: "author.skill", + rootId: "user-global", + rootKind: "user-global", + }, + ]); + const omitted = env.registry.getDescriptors("skills").find((desc) => desc.id === "omitted"); + expect(omitted?.available).toBe(false); + expect(omitted?.unavailableReasons).toContain("not-activated"); + expect(omitted?.unavailableReasons).not.toContain("body-failed"); + } finally { + await env.cleanup(); + } + }); + + test("body activation failure marks the descriptor unavailable", async () => { + const env = await createTestExtensionRegistry({ + roots: () => [root], + discoverFn: (input) => { + const enabled = + input.state?.isEnabled?.({ + rootId: root.rootId, + rootKind: root.kind, + extensionId: "author.skill", + isBundled: false, + }) ?? false; + const grant = input.state?.getApprovalRecord?.({ + rootId: root.rootId, + rootKind: root.kind, + extensionId: "author.skill", + isBundled: false, + }); + return { + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(root, [ + makeExtension({ + extensionId: "author.skill", + rootId: root.rootId, + rootKind: root.kind, + enabled, + granted: grant !== undefined, + activated: false, + contributions: [{ type: "skills", id: "alpha", activated: false }], + }), + ]), + ], + }; + }, + now: () => FROZEN_NOW, + }); + try { + await env.registry.setEnabled({ kind: "global" }, "author.skill", true); + await env.registry.setApproval({ kind: "global" }, "author.skill"); + + const descs = env.registry.getDescriptors("skills"); + expect(descs).toHaveLength(1); + expect(descs[0].available).toBe(false); + expect(descs[0].unavailableReasons).toContain("body-failed"); + } finally { + await env.cleanup(); + } + }); + + test("body activation failure does not publish partially activated siblings", async () => { + const env = await createTestExtensionRegistry({ + roots: () => [root], + discoverFn: (input) => { + const enabled = + input.state?.isEnabled?.({ + rootId: root.rootId, + rootKind: root.kind, + extensionId: "author.skill", + isBundled: false, + }) ?? false; + const grant = input.state?.getApprovalRecord?.({ + rootId: root.rootId, + rootKind: root.kind, + extensionId: "author.skill", + isBundled: false, + }); + return { + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(root, [ + makeExtension({ + extensionId: "author.skill", + rootId: root.rootId, + rootKind: root.kind, + enabled, + granted: grant !== undefined, + activated: false, + contributions: [ + { type: "skills", id: "broken", activated: false }, + { type: "skills", id: "healthy", activated: enabled && grant !== undefined }, + ], + }), + ]), + ], + }; + }, + now: () => FROZEN_NOW, + }); + try { + await env.registry.setEnabled({ kind: "global" }, "author.skill", true); + await env.registry.setApproval({ kind: "global" }, "author.skill"); + + expect(env.registry.getContributions("skills")).toEqual([]); + const descs = env.registry.getDescriptors("skills"); + expect(descs.find((d) => d.id === "broken")?.unavailableReasons).toContain("body-failed"); + expect(descs.find((d) => d.id === "healthy")?.unavailableReasons).toContain("body-failed"); + } finally { + await env.cleanup(); + } + }); + + test("disable removes from Capability Path but Inspection Path retains with reason", async () => { + await env.registry.setEnabled({ kind: "global" }, "author.skill", true); + await env.registry.setApproval({ kind: "global" }, "author.skill"); + expect(env.registry.getContributions("skills")).toHaveLength(1); + + await env.registry.setEnabled({ kind: "global" }, "author.skill", false); + expect(env.registry.getContributions("skills")).toEqual([]); + const descs = env.registry.getDescriptors("skills"); + expect(descs).toHaveLength(1); + expect(descs[0].available).toBe(false); + expect(descs[0].unavailableReasons).toContain("disabled"); + }); + + test("removeApproval drops Capability Path; Inspection Path shows ungranted", async () => { + await env.registry.setEnabled({ kind: "global" }, "author.skill", true); + await env.registry.setApproval({ kind: "global" }, "author.skill"); + expect(env.registry.getContributions("skills")).toHaveLength(1); + + await env.registry.removeApproval({ kind: "global" }, "author.skill"); + expect(env.registry.getContributions("skills")).toEqual([]); + const descs = env.registry.getDescriptors("skills"); + expect(descs[0].unavailableReasons).toContain("ungranted"); + }); + + test("setApproval reloads before live manifest lookup when only cache/empty live state exists", async () => { + await env.registry.setApproval( + { kind: "global", rootId: "user-global", rootKind: "user-global" }, + "author.skill" + ); + + expect(env.globalState.load().state.extensions["author.skill"]?.approval).toBeDefined(); + expect(env.registry.getSnapshot()?.roots[0].extensions[0].extensionId).toBe("author.skill"); + }); + + test("setApproval persists live capability approval fields", async () => { + await env.registry.reload(); + await env.registry.setApproval( + { kind: "global", rootId: "user-global", rootKind: "user-global" }, + "author.skill" + ); + + const persisted = env.globalState.load().state.extensions["author.skill"]?.approval; + expect(persisted).toEqual({ + grantedPermissions: ["skill.register"], + requestedPermissionsHash: hashRequestedPermissions(["skill.register"]), + }); + }); + + test("approval records without source identity keep the Capability Path available", async () => { + await env.registry.setEnabled({ kind: "global" }, "author.skill", true); + await env.globalState.setApproval("author.skill", SAMPLE_GRANT); + await env.registry.reload(); + + expect(env.registry.getContributions("skills")).toHaveLength(1); + const descs = env.registry.getDescriptors("skills"); + expect(descs[0].available).toBe(true); + expect(descs[0].unavailableReasons).not.toContain("pending-reapproval"); + }); + + test("setApproval hashes the user-global manifest when bundled root shares the extension identity", async () => { + const extensionId = "author.same"; + const bundledRoot: ExtensionRootDescriptor = { + rootId: "bundled", + kind: "bundled", + path: "/fake/bundled", + }; + const userRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + const env = await createTestExtensionRegistry({ + roots: () => [bundledRoot, userRoot], + discoverFn: () => ({ + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(bundledRoot, [ + makeExtension({ + extensionId, + rootId: bundledRoot.rootId, + rootKind: bundledRoot.kind, + contributions: [{ type: "agents", id: "bundled-agent" }], + }), + ]), + makeRoot(userRoot, [ + makeExtension({ + extensionId, + rootId: userRoot.rootId, + rootKind: userRoot.kind, + contributions: [{ type: "skills", id: "user-skill" }], + }), + ]), + ], + }), + now: () => FROZEN_NOW, + }); + try { + await env.registry.reload(); + await env.registry.setApproval( + { kind: "global", rootId: userRoot.rootId, rootKind: "user-global" }, + extensionId + ); + + const persisted = env.globalState.load().state.extensions[extensionId]?.approval; + expect(persisted?.requestedPermissionsHash).toBe( + hashRequestedPermissions(["skill.register"]) + ); + const permissions = + env.registry.getSnapshot()?.permissions[ + extensionPermissionKey(userRoot.rootId, extensionId) + ]; + expect(permissions?.driftStatus).toBeNull(); + } finally { + await env.cleanup(); + } + }); + + test("setApproval rejects extensions missing from the live scope", async () => { + await env.registry.reload(); + + let error: unknown; + try { + await env.registry.setApproval({ kind: "global" }, "author.missing"); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/not installed in the requested scope/); + expect(env.globalState.load().state.extensions["author.missing"]?.approval).toBeUndefined(); + }); + + test("setApproval stores a canonical requestedPermissionsHash from the live manifest", async () => { + await env.registry.setEnabled({ kind: "global" }, "author.skill", true); + await env.registry.setApproval({ kind: "global" }, "author.skill"); + + const persisted = env.globalState.load().state.extensions["author.skill"]?.approval; + expect(persisted?.requestedPermissionsHash).not.toBe(""); + expect(persisted?.requestedPermissionsHash).toMatch(/^[0-9a-f]{64}$/); + + const snapshot = env.registry.getSnapshot(); + expect( + snapshot?.permissions[extensionPermissionKey("user-global", "author.skill")]?.driftStatus + ).toBeNull(); + }); +}); + +describe("ExtensionRegistry — trust transitions", () => { + let env: Awaited>; + let projectPath: string; + const projectRootId = "project::/p"; + + beforeEach(async () => { + env = await createTestExtensionRegistry({ + roots: async () => { + const trusted = await env.projectState.isRootTrusted(projectPath); + return [ + { + rootId: projectRootId, + kind: "project-local", + path: projectPath, + trusted, + }, + ]; + }, + discoverFn: (input) => { + const root = input.roots[0]; + if (root.kind === "project-local" && root.trusted !== true) { + return { + generatedAt: input.now ?? FROZEN_NOW, + roots: [ + { + rootId: root.rootId, + kind: "project-local", + path: root.path, + trusted: false, + rootExists: true, + state: "ready", + extensions: [], + diagnostics: [], + }, + ], + }; + } + return { + generatedAt: input.now ?? FROZEN_NOW, + roots: [makeRoot(root, [], true)], + }; + }, + now: () => FROZEN_NOW, + }); + projectPath = path.join(env.tempDir, "project"); + await fsp.mkdir(projectPath, { recursive: true }); + }); + + afterEach(async () => { + await env.cleanup(); + }); + + test("trustRoot flips persisted state and reloads", async () => { + await env.registry.reload(); + expect(env.registry.getSnapshot()!.roots[0].trusted).toBe(false); + + await env.registry.trustRoot(projectPath); + expect(env.registry.getSnapshot()!.roots[0].trusted).toBe(true); + expect(await env.projectState.isRootTrusted(projectPath)).toBe(true); + }); + + test("untrustRoot flips trusted=false but preserves approval records", async () => { + await env.projectState.setRootTrusted(projectPath, true); + await env.projectState.setApproval(projectPath, "author.skill", SAMPLE_GRANT); + await env.registry.reload(); + expect(env.registry.getSnapshot()!.roots[0].trusted).toBe(true); + + await env.registry.untrustRoot(projectPath); + expect(env.registry.getSnapshot()!.roots[0].trusted).toBe(false); + const stateAfter = (await env.projectState.load(projectPath)).state; + expect(stateAfter.rootTrusted).toBe(false); + expect(stateAfter.extensions["author.skill"]?.approval).toEqual(SAMPLE_GRANT); + }); +}); + +describe("ExtensionRegistry — atomic snapshot replacement", () => { + test("getSnapshot() during in-flight reload returns the previous coherent snapshot", async () => { + const root: ExtensionRootDescriptor = { + rootId: "bundled", + kind: "bundled", + path: "/fake/bundled", + }; + let callCount = 0; + let signalStarted: (() => void) | undefined; + let signalRelease: (() => void) | undefined; + const startedPromise = new Promise((resolve) => { + signalStarted = resolve; + }); + const releasePromise = new Promise((resolve) => { + signalRelease = resolve; + }); + + const env = await createTestExtensionRegistry({ + roots: () => [root], + discoverFn: async (input) => { + callCount += 1; + if (callCount === 2) { + signalStarted!(); + await releasePromise; + } + return { + generatedAt: input.now ?? FROZEN_NOW + callCount, + roots: [ + makeRoot(root, [ + makeExtension({ + extensionId: `mux.snap${callCount}`, + rootId: root.rootId, + rootKind: "bundled", + granted: false, + activated: false, + contributions: [], + }), + ]), + ], + }; + }, + now: () => FROZEN_NOW, + }); + try { + await env.registry.reload(); + expect(env.registry.getSnapshot()?.roots[0].extensions[0].extensionId).toBe("mux.snap1"); + + // Begin a second reload; await its discoverFn entry before assertion. + const second = env.registry.reload(); + await startedPromise; + // The previous snapshot remains visible until the new one is computed. + expect(env.registry.getSnapshot()?.roots[0].extensions[0].extensionId).toBe("mux.snap1"); + signalRelease!(); + await second; + expect(env.registry.getSnapshot()?.roots[0].extensions[0].extensionId).toBe("mux.snap2"); + } finally { + await env.cleanup(); + } + }); +}); + +describe("ExtensionRegistry — per-root reload", () => { + test("reloadRoot performs a full coherent reload", async () => { + const bundled: ExtensionRootDescriptor = { + rootId: "bundled", + kind: "bundled", + path: "/fake/bundled", + }; + const userGlobal: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user", + }; + let bundledCount = 0; + let userCount = 0; + const env = await createTestExtensionRegistry({ + roots: () => [bundled, userGlobal], + discoverFn: (input) => { + const roots = input.roots.map((r) => { + if (r.rootId === bundled.rootId) bundledCount += 1; + else userCount += 1; + return makeRoot(r, []); + }); + return { generatedAt: FROZEN_NOW, roots }; + }, + now: () => FROZEN_NOW, + }); + try { + await env.registry.reload(); + expect(bundledCount).toBe(1); + expect(userCount).toBe(1); + + await env.registry.reloadRoot(userGlobal.rootId); + expect(bundledCount).toBe(2); + expect(userCount).toBe(2); + } finally { + await env.cleanup(); + } + }); + + test("reloadRoot before any reload falls back to a full reload", async () => { + const bundled: ExtensionRootDescriptor = { + rootId: "bundled", + kind: "bundled", + path: "/fake/bundled", + }; + let totalRoots = 0; + const env = await createTestExtensionRegistry({ + roots: () => [bundled], + discoverFn: (input) => { + totalRoots += input.roots.length; + return { + generatedAt: FROZEN_NOW, + roots: input.roots.map((r) => makeRoot(r, [])), + }; + }, + now: () => FROZEN_NOW, + }); + try { + await env.registry.reloadRoot(bundled.rootId); + // Cold-start fall-through ran a full reload (one root). + expect(totalRoots).toBe(1); + expect(env.registry.getSnapshot()).not.toBeNull(); + } finally { + await env.cleanup(); + } + }); +}); + +describe("ExtensionRegistry — multi-window propagation", () => { + test("a mutation through registry A becomes visible in registry B after reload", async () => { + const tempDir = path.join( + os.tmpdir(), + `mux-registry-multi-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await fsp.mkdir(tempDir, { recursive: true }); + try { + const root: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user", + }; + const sharedDiscover: DiscoverFn = (input) => { + const grant = input.state?.getApprovalRecord?.({ + rootId: root.rootId, + rootKind: root.kind, + extensionId: "author.skill", + isBundled: false, + }); + const enabled = + input.state?.isEnabled?.({ + rootId: root.rootId, + rootKind: root.kind, + extensionId: "author.skill", + isBundled: false, + }) ?? false; + return { + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(root, [ + makeExtension({ + extensionId: "author.skill", + rootId: root.rootId, + rootKind: root.kind, + enabled, + granted: grant !== undefined, + activated: enabled && grant !== undefined, + contributions: [ + { type: "skills", id: "alpha", activated: enabled && grant !== undefined }, + ], + }), + ]), + ], + }; + }; + + const cfgA = new Config(tempDir); + const cfgB = new Config(tempDir); + const aReg = new ExtensionRegistry({ + roots: () => [root], + globalState: new GlobalExtensionStateService(cfgA), + projectState: new ProjectExtensionStateService(getProjectExtensionStateRoot(tempDir)), + discoverFn: sharedDiscover, + now: () => FROZEN_NOW, + }); + const bReg = new ExtensionRegistry({ + roots: () => [root], + globalState: new GlobalExtensionStateService(cfgB), + projectState: new ProjectExtensionStateService(getProjectExtensionStateRoot(tempDir)), + discoverFn: sharedDiscover, + now: () => FROZEN_NOW, + }); + await aReg.reload(); + await bReg.reload(); + expect(aReg.getContributions("skills")).toEqual([]); + expect(bReg.getContributions("skills")).toEqual([]); + + await aReg.setEnabled({ kind: "global" }, "author.skill", true); + await aReg.setApproval({ kind: "global" }, "author.skill"); + expect(aReg.getContributions("skills")).toHaveLength(1); + + // B has not yet reloaded; it still sees the prior snapshot. + expect(bReg.getContributions("skills")).toEqual([]); + + await bReg.reload(); + expect(bReg.getContributions("skills")).toHaveLength(1); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe("ExtensionRegistry — capability vs inspection independence", () => { + test("Capability Path ignores a mutated cache; Inspection Path reads cache only on cold start", async () => { + const env = await createTestExtensionRegistry({ + roots: () => [], + discoverFn: () => ({ generatedAt: FROZEN_NOW, roots: [] }), + withSnapshotCache: true, + appVersion: "0.0.0-test", + now: () => FROZEN_NOW, + }); + try { + const cachePath = path.join(env.tempDir, "extension-snapshot.cache.json"); + const stateFilePath = path.join(env.tempDir, "config.json"); + + // Hand-craft a cache file that *claims* a contribution exists. The + // Capability Path must never consult this; the Inspection Path may + // surface it during cold start. + const fakeCache = { + cacheVersion: 1, + appVersion: "0.0.0-test", + manifestVersion: 1, + stateFileFingerprints: [{ path: stateFilePath, exists: false, mtimeMs: 0, sha256: "" }], + snapshot: { + generatedAt: 1, + roots: [], + availableContributions: [ + { + type: "skills", + id: "fake-from-cache", + extensionId: "evil.cache", + rootId: "evil", + rootKind: "user-global", + }, + ], + resolverDiagnostics: [], + descriptors: [ + { + type: "skills", + id: "fake-from-cache", + extensionId: "evil.cache", + rootId: "evil", + rootKind: "user-global", + available: true, + unavailableReasons: [], + missingPermissions: [], + }, + ], + permissions: {}, + staleRecords: [], + }, + }; + await fsp.writeFile(cachePath, JSON.stringify(fakeCache)); + + await env.registry.loadFromCache(); + + // Cold-start: Capability Path is empty (no live snapshot yet) — even + // though a cached snapshot is present. + expect(env.registry.getContributions("skills")).toEqual([]); + + // Inspection Path uses the cached snapshot. + const cachedDescs = env.registry.getDescriptors("skills"); + expect(cachedDescs).toHaveLength(1); + expect(cachedDescs[0].id).toBe("fake-from-cache"); + + // Once we run a real reload, Inspection Path switches to the live + // (empty) snapshot and the cache lie disappears. + await env.registry.reload(); + expect(env.registry.getDescriptors("skills")).toEqual([]); + expect(env.registry.getContributions("skills")).toEqual([]); + } finally { + await env.cleanup(); + } + }); +}); + +describe("ExtensionRegistry — stale records", () => { + test("approval records for vanished Extensions surface in Inspection Path; Forget removes them", async () => { + const env = await createTestExtensionRegistry({ + roots: () => [], + discoverFn: () => ({ generatedAt: FROZEN_NOW, roots: [] }), + now: () => FROZEN_NOW, + }); + try { + // Seed an approval record for an Extension that does not exist in any root. + await env.globalState.setApproval("vanished.author.skill", SAMPLE_GRANT); + await env.registry.reload(); + const stale = env.registry.getStaleRecords(); + expect(stale).toHaveLength(1); + expect(stale[0]).toMatchObject({ + scope: "global", + extensionId: "vanished.author.skill", + approval: SAMPLE_GRANT, + }); + + // Forget removes the persisted record (explicit user action — never auto). + await env.registry.forgetStale({ kind: "global" }, "vanished.author.skill"); + expect(env.registry.getStaleRecords()).toEqual([]); + expect(env.globalState.load().state.extensions["vanished.author.skill"]).toBeUndefined(); + } finally { + await env.cleanup(); + } + }); + + test("global stale records remain visible when the same extension is live only as bundled", async () => { + const extensionId = "mux.platformdemo"; + const bundledRoot: ExtensionRootDescriptor = { + rootId: "bundled", + kind: "bundled", + path: "/fake/bundled", + }; + const env = await createTestExtensionRegistry({ + roots: () => [bundledRoot], + discoverFn: () => ({ + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(bundledRoot, [ + makeExtension({ + extensionId, + rootId: bundledRoot.rootId, + rootKind: bundledRoot.kind, + contributions: [{ type: "skills", id: "mux-extensions" }], + }), + ]), + ], + }), + now: () => FROZEN_NOW, + }); + try { + await env.globalState.setApproval(extensionId, SAMPLE_GRANT); + await env.registry.reload(); + + const staleRecords = env.registry.getStaleRecords(); + expect(staleRecords).toHaveLength(1); + expect(staleRecords[0]?.scope).toBe("global"); + expect(staleRecords[0]?.extensionId).toBe(extensionId); + } finally { + await env.cleanup(); + } + }); + + test("project-local approvals are not stale while the root is untrusted", async () => { + const projectPath = path.join(os.tmpdir(), `mux-stale-untrusted-project-${Date.now()}`); + const projectRoot: ExtensionRootDescriptor = { + rootId: staleProjectLocalRootId(projectPath), + kind: "project-local", + path: projectPath, + trusted: false, + }; + const env = await createTestExtensionRegistry({ + roots: () => [projectRoot], + discoverFn: () => ({ + generatedAt: FROZEN_NOW, + roots: [makeRoot(projectRoot, [], false)], + }), + now: () => FROZEN_NOW, + }); + try { + await env.projectState.setApproval(projectPath, "author.skill", SAMPLE_GRANT); + await env.registry.reload(); + + expect(env.registry.getStaleRecords()).toEqual([]); + } finally { + await env.cleanup(); + } + }); + + test("project-local stale records remain visible when the same extension is live globally", async () => { + const projectPath = path.join(os.tmpdir(), `mux-stale-project-${Date.now()}`); + const userRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + const projectRoot: ExtensionRootDescriptor = { + rootId: staleProjectLocalRootId(projectPath), + kind: "project-local", + path: path.join(projectPath, ".mux", "extensions"), + trusted: true, + }; + const extensionId = "publisher.shared"; + const env = await createTestExtensionRegistry({ + roots: () => [userRoot, projectRoot], + discoverFn: () => ({ + generatedAt: FROZEN_NOW, + roots: [ + makeRoot(userRoot, [ + makeExtension({ + extensionId, + rootId: userRoot.rootId, + rootKind: userRoot.kind, + contributions: [{ type: "skills", id: "shared-skill" }], + }), + ]), + ], + }), + now: () => FROZEN_NOW, + }); + try { + await env.globalState.setEnabled(extensionId, true); + await env.globalState.setApproval(extensionId, SAMPLE_GRANT); + await env.projectState.setRootTrusted(projectPath, true); + await env.projectState.setEnabled(projectPath, extensionId, true); + await env.projectState.setApproval(projectPath, extensionId, SAMPLE_GRANT); + await env.registry.reload(); + + const staleRecords = env.registry.getStaleRecords(); + expect(staleRecords).toHaveLength(1); + expect(staleRecords[0]?.scope).toBe("project-local"); + expect(staleRecords[0]?.projectPath).toBe(projectPath); + expect(staleRecords[0]?.extensionId).toBe(extensionId); + } finally { + await env.cleanup(); + await fsp.rm(projectPath, { recursive: true, force: true }); + } + }); +}); + +describe("ExtensionRegistry — discovers real extensions when wired to discoverExtensions", () => { + test("discovers a fixture-bundled root through the real discovery pipeline", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-registry-real-discovery-")); + try { + const extensionsDir = path.join(tempDir, "extensions"); + const demoPath = path.join(extensionsDir, "mux-platform-demo"); + await fsp.mkdir(demoPath, { recursive: true }); + await fsp.writeFile( + path.join(demoPath, "extension.ts"), + ` + export const manifest = { + name: "mux-platform-demo", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "demo", bodyPath: "./SKILL.md" }); + } + ` + ); + await fsp.writeFile( + path.join(demoPath, "SKILL.md"), + "---\nname: demo\ndescription: Demo skill\n---\n# demo" + ); + + const env = await createTestExtensionRegistry({ + roots: () => [ + { + rootId: "bundled", + kind: "bundled", + path: extensionsDir, + isCore: true, + }, + ], + discoverFn: discoverExtensions, + now: () => FROZEN_NOW, + }); + try { + // Seed grant so activation passes for the bundled core ext. + await env.globalState.setEnabled("mux-platform-demo", true); + await env.globalState.setApproval("mux-platform-demo", { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: "hash", + }); + await env.registry.reload(); + const contribs = env.registry.getContributions("skills"); + expect(contribs).toHaveLength(1); + expect(contribs[0].id).toBe("demo"); + } finally { + await env.cleanup(); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("does not advertise previous activation when hot reload breaks a skill body", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-registry-hot-reload-")); + try { + const extensionsDir = path.join(tempDir, "extensions"); + const modulePath = path.join(extensionsDir, "acme-review"); + const skillPath = path.join(modulePath, "skills", "review", "SKILL.md"); + await fsp.mkdir(path.dirname(skillPath), { recursive: true }); + await fsp.writeFile( + path.join(modulePath, "extension.ts"), + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + await fsp.writeFile( + skillPath, + "---\nname: review\ndescription: Review helper\n---\n# Review\n" + ); + + const env = await createTestExtensionRegistry({ + roots: () => [ + { + rootId: "user-global", + kind: "user-global", + path: extensionsDir, + }, + ], + discoverFn: discoverExtensions, + now: () => FROZEN_NOW, + }); + try { + await env.globalState.setEnabled("acme-review", true); + await env.globalState.setApproval("acme-review", { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: hashRequestedPermissions(["skill.register"]), + }); + + await env.registry.reload(); + expect(env.registry.getContributions("skills")).toHaveLength(1); + + await fsp.writeFile(skillPath, "---\nname: other\ndescription: Broken\n---\n# Broken\n"); + await env.registry.reload(); + + expect(env.registry.getContributions("skills")).toHaveLength(0); + } finally { + await env.cleanup(); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("does not restore previous good activation when Full Activation disposes a skill", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-registry-hot-reload-dispose-")); + try { + const extensionsDir = path.join(tempDir, "extensions"); + const modulePath = path.join(extensionsDir, "acme-review"); + const skillPath = path.join(modulePath, "skills", "review", "SKILL.md"); + await fsp.mkdir(path.dirname(skillPath), { recursive: true }); + await fsp.writeFile( + skillPath, + "---\nname: review\ndescription: Review helper\n---\n# Review\n" + ); + await fsp.writeFile( + path.join(modulePath, "extension.ts"), + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + + const env = await createTestExtensionRegistry({ + roots: () => [{ rootId: "user-global", kind: "user-global", path: extensionsDir }], + discoverFn: discoverExtensions, + now: () => FROZEN_NOW, + }); + try { + await env.globalState.setEnabled("acme-review", true); + await env.globalState.setApproval("acme-review", { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: hashRequestedPermissions(["skill.register"]), + }); + await env.registry.reload(); + expect(env.registry.getContributions("skills")).toHaveLength(1); + + await fsp.writeFile( + path.join(modulePath, "extension.ts"), + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + const registration = ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + if (ctx.mode === "activate") registration.dispose(); + } + ` + ); + await env.registry.reload(); + + expect(env.registry.getContributions("skills")).toEqual([]); + } finally { + await env.cleanup(); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test("capability revocation shuts down previous hot-reload activation fallback", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-registry-hot-reload-revoke-")); + try { + const extensionsDir = path.join(tempDir, "extensions"); + const modulePath = path.join(extensionsDir, "acme-review"); + const skillPath = path.join(modulePath, "skills", "review", "SKILL.md"); + await fsp.mkdir(path.dirname(skillPath), { recursive: true }); + await fsp.writeFile( + path.join(modulePath, "extension.ts"), + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + await fsp.writeFile( + skillPath, + "---\nname: review\ndescription: Review helper\n---\n# Review\n" + ); + + const env = await createTestExtensionRegistry({ + roots: () => [{ rootId: "user-global", kind: "user-global", path: extensionsDir }], + discoverFn: discoverExtensions, + now: () => FROZEN_NOW, + }); + try { + await env.globalState.setEnabled("acme-review", true); + await env.globalState.setApproval("acme-review", { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: hashRequestedPermissions(["skill.register"]), + }); + await env.registry.reload(); + expect(env.registry.getContributions("skills")).toHaveLength(1); + + await env.globalState.removeApproval("acme-review"); + await fsp.writeFile(skillPath, "---\nname: other\ndescription: Broken\n---\n# Broken\n"); + await env.registry.reload(); + + expect(env.registry.getContributions("skills")).toEqual([]); + } finally { + await env.cleanup(); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/node/extensions/extensionRegistryService.ts b/src/node/extensions/extensionRegistryService.ts new file mode 100644 index 0000000000..79930cad62 --- /dev/null +++ b/src/node/extensions/extensionRegistryService.ts @@ -0,0 +1,890 @@ +import { EventEmitter } from "events"; + +import { extensionPermissionKey } from "@/common/extensions/extensionPermissionKey"; +import { + resolveConflicts, + type AvailableContribution, + type CandidateContribution, + type CandidateExtension, +} from "@/common/extensions/conflictResolver"; +import type { + ExtensionStateRecord, + ApprovalRecord, +} from "@/common/extensions/globalExtensionState"; +import { + CONTRIBUTION_TYPE_REGISTRATION_PERMISSIONS, + type ExtensionDiagnostic, + type RootKind, +} from "@/common/extensions/manifestValidator"; +import { + calculatePermissions, + hashRequestedPermissions, + requiresReapproval, + type CalculatePermissionsResult, + type ContributionPermissionRequirement, +} from "@/common/extensions/permissionCalculator"; +import type { NormalizedProjectExtensionState } from "@/common/extensions/projectExtensionState"; +import type { NormalizedGlobalExtensionState } from "@/common/extensions/globalExtensionState"; +import { + discoverExtensions, + type DiscoverExtensionsInput, + type DiscoveredContribution, + type DiscoveredExtension, + type DiscoveryStateLookup, + type DiscoveryStateLookupContext, + type ExtensionRootDescriptor, + type ExtensionSnapshot as DiscoverySnapshot, + type RootDiscoveryResult, +} from "@/node/extensions/extensionDiscoveryService"; +import type { ExtensionSkillSource } from "@/common/extensions/extensionSkillSource"; +import type { ExtensionActivationSession } from "@/node/extensions/extensionRegistrationDiscoveryService"; +import type { GlobalExtensionStateService } from "@/node/extensions/globalExtensionStateService"; +import type { ProjectExtensionStateService } from "@/node/extensions/projectExtensionStateService"; +import { log } from "@/node/services/log"; +import type { SnapshotCacheService } from "@/node/extensions/snapshotCacheService"; + +export type ExtensionScope = + | { kind: "global"; rootId?: string; rootKind?: Extract } + | { kind: "project-local"; projectPath: string }; + +export type UnavailableReason = + | "untrusted-root" + | "disabled" + | "ungranted" + | "missing-permissions" + | "pending-reapproval" + | "body-failed" + | "not-activated" + | "inspection-only" + | "conflict"; + +export interface InspectionDescriptor { + type: string; + id: string; + extensionId: string; + rootId: string; + rootKind: RootKind; + available: boolean; + unavailableReasons: UnavailableReason[]; + missingPermissions: string[]; +} + +export interface StaleRecord { + scope: "global" | "project-local"; + // Set when scope === "project-local". + projectPath?: string; + extensionId: string; + approval: ApprovalRecord; + // Synthetic rootId so the IPC layer can target stale records with the same + // `{ rootId, extensionId }` shape used for live roots. Stale records have no + // live root; we mint a stable id from scope so the frontend can call + // `forgetStale({ rootId, extensionId })` without a separate API. + rootId: string; +} + +// Synthetic rootId for global stale records. Live global roots (bundled, +// user-global) have their own rootIds supplied by their producer; this is only +// used for stale records that have no matching live root. +export const STALE_GLOBAL_ROOT_ID = "global"; + +type ActivationSessionMap = Map; + +const PROJECT_LOCAL_ROOT_PREFIX = "project-local:"; + +export function staleProjectLocalRootId(projectPath: string): string { + return `${PROJECT_LOCAL_ROOT_PREFIX}${projectPath}`; +} + +function projectPathFromProjectLocalRoot(root: ExtensionRootDescriptor): string { + if (root.rootId.startsWith(PROJECT_LOCAL_ROOT_PREFIX)) { + return root.rootId.slice(PROJECT_LOCAL_ROOT_PREFIX.length); + } + return root.path.replace(/[/\\]\.mux[/\\]extensions$/u, ""); +} + +export interface RegistrySnapshot { + generatedAt: number; + roots: RootDiscoveryResult[]; + // Capability Path output: the resolved, post-conflict, permission-gated + // contributions. Consumers MUST read this through getContributions() so the + // Inspection Path's cache fallback never bleeds into capability decisions. + availableContributions: AvailableContribution[]; + resolverDiagnostics: ExtensionDiagnostic[]; + // Inspection Path payload: per-contribution availability + reasons across + // every root, even ones the Capability Path filtered out. + descriptors: InspectionDescriptor[]; + permissions: Record; + staleRecords: StaleRecord[]; +} + +export type DiscoverFn = ( + input: DiscoverExtensionsInput +) => DiscoverySnapshot | Promise; + +export type RootsFn = () => + | readonly ExtensionRootDescriptor[] + | Promise; + +export type StateFilePathsFn = () => readonly string[] | Promise; + +export interface ExtensionRegistryOptions { + /** Enumerates the active Extension Roots for this reload. */ + roots: RootsFn; + globalState: GlobalExtensionStateService; + projectState: ProjectExtensionStateService; + /** Optional Snapshot Cache used for cold-start hydration of the Inspection Path. */ + snapshotCache?: SnapshotCacheService; + /** + * Returns the on-disk paths whose fingerprints control snapshot-cache validity. + * Required when `snapshotCache` is set. + */ + stateFilePaths?: StateFilePathsFn; + perRootTimeoutMs?: number; + perFileTimeoutMs?: number; + now?: () => number; + /** Override discovery for tests; defaults to the production `discoverExtensions`. */ + discoverFn?: DiscoverFn; +} + +interface PreloadedState { + globalState: NormalizedGlobalExtensionState; + projectStates: Map; + inspectableProjectRootIds: Set; + rootIdToProjectPath: Map; + stateDiagnosticsByRootId: Map; + stateLookup: DiscoveryStateLookup; +} + +// Owns the live Extension Snapshot. The Capability Path (`getContributions`) +// reads only from `liveSnapshot`, never from any cache; the Inspection Path +// (`getDescriptors`) may fall back to a cached snapshot during cold start so +// the Settings UI can render before the first live discovery completes. +// +// Concurrency: +// - Mutators serialize through `enqueue`; in-process callers see snapshot +// replacement as a single atomic ref assignment. +// - Across processes, last-writer-wins is delivered by the underlying state +// services' atomic temp-rename writes; the Registry does not lock. +export class ExtensionRegistry { + private liveSnapshot: RegistrySnapshot | null = null; + private cachedSnapshot: RegistrySnapshot | null = null; + private writeQueue: Promise = Promise.resolve(); + private activationSessions: ActivationSessionMap = new Map(); + private readonly emitter = new EventEmitter(); + + constructor(private readonly options: ExtensionRegistryOptions) {} + + onChanged(callback: () => void): () => void { + this.emitter.on("changed", callback); + return () => { + this.emitter.off("changed", callback); + }; + } + + dispose(): void { + this.disposeActivationSessions(); + this.emitter.removeAllListeners(); + } + + getSnapshot(): RegistrySnapshot | null { + return this.liveSnapshot; + } + + getCachedSnapshot(): RegistrySnapshot | null { + return this.cachedSnapshot; + } + + // Capability Path: returns post-conflict, permission-gated contributions + // from the live snapshot only. Returns [] before the first live discovery + // even if a cached snapshot is available. + getContributions(type: string): AvailableContribution[] { + if (!this.liveSnapshot) return []; + return this.liveSnapshot.availableContributions.filter((c) => c.type === type); + } + + // Inspection Path: live first; cache fallback during cold start. Includes + // unavailable contributions with reasons so the Settings UI can explain + // why a contribution is not active. + getDescriptors(type: string): InspectionDescriptor[] { + const snap = this.liveSnapshot ?? this.cachedSnapshot; + if (!snap) return []; + return snap.descriptors.filter((d) => d.type === type); + } + + // Capability Path resolved skill sources: returns absolute body paths plus + // the metadata `agentSkillsService` needs to surface extension-provided + // skills in the slash menu. Drops skills whose contribution is unavailable + // (so the agentSkills service never sees a permission-gated skill) and + // skills with descriptor shapes that don't satisfy the resolver — keeping + // the merge into the host skill registry resilient to authoring errors. + getSkillSources(projectPath?: string): ExtensionSkillSource[] { + if (!this.liveSnapshot) return []; + const available = new Set( + this.liveSnapshot.availableContributions + .filter((c) => c.type === "skills") + .map((c) => `${c.rootId}\0${c.extensionId}\0${c.id}`) + ); + const projectLocalSkillIds = new Set(); + const projectLocalExtensionIds = new Set(); + if (projectPath) { + for (const root of this.liveSnapshot.roots) { + if ( + root.kind !== "project-local" || + projectPathFromProjectLocalRoot(root) !== projectPath + ) { + continue; + } + for (const ext of root.extensions) { + if ( + ext.contributions.some( + (contribution) => + contribution.type === "skills" && + available.has(`${ext.rootId}\0${ext.extensionId}\0${contribution.id}`) + ) + ) { + // Extension Name shadowing is project-scoped: hide the lower-precedence + // global extension only while resolving skills for this project. + projectLocalExtensionIds.add(ext.extensionId); + for (const contribution of ext.contributions) { + if ( + contribution.type === "skills" && + available.has(`${ext.rootId}\0${ext.extensionId}\0${contribution.id}`) + ) { + projectLocalSkillIds.add(contribution.id); + } + } + } + } + } + } + const sources: ExtensionSkillSource[] = []; + for (const root of this.liveSnapshot.roots) { + if (root.kind === "project-local" && projectPathFromProjectLocalRoot(root) !== projectPath) { + continue; + } + const rootShadowedByProjectLocal = root.kind !== "project-local"; + for (const ext of root.extensions) { + for (const contribution of ext.contributions) { + if (contribution.type !== "skills") continue; + if (rootShadowedByProjectLocal && projectLocalExtensionIds.has(ext.extensionId)) continue; + if (rootShadowedByProjectLocal && projectLocalSkillIds.has(contribution.id)) continue; + if (!available.has(`${ext.rootId}\0${ext.extensionId}\0${contribution.id}`)) continue; + if (!contribution.bodyRealPath) continue; + const manifestContribution = ext.manifest.contributions.find( + (c) => c.index === contribution.index && c.type === contribution.type + ); + const descriptor = manifestContribution?.descriptor; + if (!descriptor) continue; + const id = typeof descriptor.id === "string" ? descriptor.id : null; + if (!id) continue; + const description = + typeof descriptor.description === "string" ? descriptor.description : ""; + // Authors may set advertise:false to keep a skill invokable but + // hidden from the advertised slash-menu index, mirroring SKILL.md + // frontmatter semantics for project / global skills. + const advertise = typeof descriptor.advertise === "boolean" ? descriptor.advertise : true; + const displayName = + typeof descriptor.displayName === "string" ? descriptor.displayName : id; + sources.push({ + name: id, + displayName, + description, + advertise, + bodyAbsolutePath: contribution.bodyRealPath, + extensionId: ext.extensionId, + }); + } + } + } + return sources; + } + + getStaleRecords(): StaleRecord[] { + const snap = this.liveSnapshot ?? this.cachedSnapshot; + return snap?.staleRecords ?? []; + } + + // Translate an IPC-supplied `rootId` to an `ExtensionScope`. Looks up the + // current live snapshot first (so live roots resolve to their kind/path), + // then falls back to stale records (which carry their own synthetic rootId). + // Returns `null` when the rootId isn't recognized so callers can throw a + // meaningful 404-style error. + resolveScopeByRootId(rootId: string): ExtensionScope | null { + const live = this.liveSnapshot ?? this.cachedSnapshot; + const liveRoot = live?.roots.find((r) => r.rootId === rootId); + if (liveRoot) { + if (liveRoot.kind === "project-local") { + return { kind: "project-local", projectPath: projectPathFromProjectLocalRoot(liveRoot) }; + } + return { kind: "global", rootId: liveRoot.rootId, rootKind: liveRoot.kind }; + } + const stale = (live?.staleRecords ?? []).find((s) => s.rootId === rootId); + if (stale) { + if (stale.scope === "project-local" && stale.projectPath) { + return { kind: "project-local", projectPath: stale.projectPath }; + } + return { kind: "global", rootId: stale.rootId }; + } + return null; + } + + async loadFromCache(): Promise { + const cache = this.options.snapshotCache; + const pathsFn = this.options.stateFilePaths; + if (!cache || !pathsFn) return; + const paths = await pathsFn(); + const snap = await cache.read(paths); + if (snap) this.cachedSnapshot = snap; + } + + async reload(): Promise { + return this.enqueue(() => this.runReload()); + } + + /** + * Public API keeps a root-scoped shape for IPC compatibility, but the current + * implementation performs a full coherent reload so non-target roots don't + * retain stale enablement/approval state. + */ + async reloadRoot(rootId: string): Promise { + return this.enqueue(() => this.runReloadRoot(rootId)); + } + + async isProjectRootTrusted(projectPath: string): Promise { + return this.options.projectState.isRootTrusted(projectPath); + } + + async setProjectRootTrusted(projectPath: string, trusted: boolean): Promise { + await this.options.projectState.setRootTrusted(projectPath, trusted); + } + + async trustRoot(projectPath: string): Promise { + return this.enqueue(async () => { + await this.options.projectState.setRootTrusted(projectPath, true); + await this.runReload(); + }); + } + + async untrustRoot(projectPath: string): Promise { + return this.enqueue(async () => { + await this.options.projectState.setRootTrusted(projectPath, false); + await this.runReload(); + }); + } + + async setEnabled(scope: ExtensionScope, extensionId: string, enabled: boolean): Promise { + return this.enqueue(async () => { + if (scope.kind === "global" && scope.rootKind === "bundled") { + await this.runReload(); + return; + } + if (scope.kind === "global") { + await this.options.globalState.setEnabled(extensionId, enabled); + } else { + await this.options.projectState.setEnabled(scope.projectPath, extensionId, enabled); + } + await this.runReload(); + }); + } + + async setApproval(scope: ExtensionScope, extensionId: string): Promise { + return this.enqueue(async () => { + // The backend is authoritative for approval material. The IPC request names + // only the target Extension; we derive capabilities from the live manifest + // so stale renderer state cannot approve a different capability set. + if (!this.liveSnapshot) { + await this.runReload(); + } + const liveApprovalMaterial = this.lookupLiveApprovalMaterial(scope, extensionId); + if (liveApprovalMaterial === undefined) { + throw new Error( + `Cannot approve capabilities for ${extensionId}: Extension is not installed in the requested scope.` + ); + } + const normalized: ApprovalRecord = { + grantedPermissions: [...liveApprovalMaterial.requestedPermissions], + requestedPermissionsHash: hashRequestedPermissions( + liveApprovalMaterial.requestedPermissions + ), + }; + if (scope.kind === "global") { + await this.options.globalState.setApproval(extensionId, normalized); + } else { + await this.options.projectState.setApproval(scope.projectPath, extensionId, normalized); + } + await this.runReload(); + }); + } + + // Reads the current snapshot to find the live manifest fields for + // `extensionId`. Approvals require a live manifest so callers cannot + // pre-approve capabilities for an Extension that has not been reviewed. + private lookupLiveApprovalMaterial( + scope: ExtensionScope, + extensionId: string + ): + | { + requestedPermissions: readonly string[]; + } + | undefined { + const snapshot = this.liveSnapshot; + if (!snapshot) return undefined; + for (const root of snapshot.roots) { + for (const ext of root.extensions) { + if (ext.extensionId !== extensionId) continue; + if (scope.kind === "global") { + if (ext.rootKind === "project-local") continue; + if (scope.rootId && ext.rootId !== scope.rootId) continue; + } + if (scope.kind === "project-local") { + if (ext.rootKind !== "project-local") continue; + if (projectPathFromProjectLocalRoot(root) !== scope.projectPath) continue; + } + return { + requestedPermissions: ext.manifest.requestedPermissions, + }; + } + } + return undefined; + } + + async removeApproval(scope: ExtensionScope, extensionId: string): Promise { + return this.enqueue(async () => { + if (scope.kind === "global") { + await this.options.globalState.removeApproval(extensionId); + } else { + await this.options.projectState.removeApproval(scope.projectPath, extensionId); + } + await this.runReload(); + }); + } + + async forgetStale(scope: ExtensionScope, extensionId: string): Promise { + return this.enqueue(async () => { + if (scope.kind === "global") { + await this.options.globalState.forget(extensionId); + } else { + await this.options.projectState.forget(scope.projectPath, extensionId); + } + await this.runReload(); + }); + } + + // Serialize mutations so an in-flight reload doesn't observe a partial + // snapshot. Failures detach from the queue without poisoning subsequent ops. + private enqueue(fn: () => Promise): Promise { + const next = this.writeQueue.then(fn, fn); + this.writeQueue = next.catch(() => undefined); + return next; + } + + private async runReload(): Promise { + const now = this.options.now?.() ?? Date.now(); + const roots = await this.options.roots(); + const preloaded = await this.preloadState(roots); + const discover = this.options.discoverFn ?? discoverExtensions; + const activationSessions: ActivationSessionMap = new Map(); + let next: RegistrySnapshot; + try { + const discovery = await discover({ + roots, + state: preloaded.stateLookup, + perRootTimeoutMs: this.options.perRootTimeoutMs, + perFileTimeoutMs: this.options.perFileTimeoutMs, + activationSessionSink: ({ rootId, extensionId, session }) => { + const key = activationSessionKey(rootId, extensionId); + activationSessions.get(key)?.dispose(); + activationSessions.set(key, session); + }, + now, + }); + next = this.composeSnapshot(discovery, now, preloaded); + } catch (error) { + disposeActivationSessionMap(activationSessions); + throw error; + } + this.replaceActivationSessions(next, activationSessions); + this.liveSnapshot = next; + await this.maybeWriteCache(next); + this.emitter.emit("changed"); + } + + private async runReloadRoot(_rootId: string): Promise { + return this.runReload(); + } + + private async preloadState(roots: readonly ExtensionRootDescriptor[]): Promise { + const globalResult = this.options.globalState.load(); + const globalState = globalResult.state; + const projectStates = new Map(); + const inspectableProjectRootIds = new Set(); + const rootIdToProjectPath = new Map(); + const stateDiagnosticsByRootId = new Map(); + for (const root of roots) { + if (root.kind !== "user-global") continue; + stateDiagnosticsByRootId.set( + root.rootId, + globalResult.diagnostics.map((diagnostic) => ({ ...diagnostic, rootId: root.rootId })) + ); + } + for (const root of roots) { + if (root.kind !== "project-local") continue; + if (root.trusted === true) inspectableProjectRootIds.add(root.rootId); + const projectPath = projectPathFromProjectLocalRoot(root); + rootIdToProjectPath.set(root.rootId, projectPath); + if (!projectStates.has(projectPath)) { + const result = await this.options.projectState.load(projectPath); + projectStates.set(projectPath, result.state); + stateDiagnosticsByRootId.set( + root.rootId, + result.diagnostics.map((diagnostic) => ({ ...diagnostic, rootId: root.rootId })) + ); + } + } + + const recordFor = (ctx: DiscoveryStateLookupContext): ExtensionStateRecord | undefined => { + if (ctx.rootKind === "project-local") { + const projectPath = rootIdToProjectPath.get(ctx.rootId); + if (!projectPath) return undefined; + return projectStates.get(projectPath)?.extensions[ctx.extensionId]; + } + if (ctx.rootKind === "bundled") return undefined; + return globalState.extensions[ctx.extensionId]; + }; + + const stateLookup: DiscoveryStateLookup = { + isEnabled: (ctx) => recordFor(ctx)?.enabled ?? ctx.isBundled, + getApprovalRecord: (ctx) => recordFor(ctx)?.approval, + }; + + return { + globalState, + projectStates, + inspectableProjectRootIds, + rootIdToProjectPath, + stateDiagnosticsByRootId, + stateLookup, + }; + } + + private composeSnapshot( + discovery: DiscoverySnapshot, + now: number, + preloaded: PreloadedState + ): RegistrySnapshot { + const permissions: Record = {}; + const candidates: CandidateExtension[] = []; + const descriptors: InspectionDescriptor[] = []; + const liveExtensionKeys = new Set(); + + const rootsWithStateDiagnostics = discovery.roots.map((root) => { + const diagnostics = preloaded.stateDiagnosticsByRootId.get(root.rootId) ?? []; + if (diagnostics.length === 0) return root; + return { ...root, diagnostics: [...root.diagnostics, ...diagnostics] }; + }); + const rootsWithActivationFallback = rootsWithStateDiagnostics.map((root) => + applyPreviousGoodActivations(root) + ); + + for (const root of rootsWithActivationFallback) { + for (const ext of root.extensions) { + liveExtensionKeys.add(staleRecordKey(root.rootId, root.kind, ext.extensionId)); + const grant = lookupApproval(ext, root.rootId, preloaded); + const permResult = computePermissions(ext, grant); + permissions[extensionPermissionKey(ext.rootId, ext.extensionId)] = permResult; + + const isTrusted = root.trusted; + for (const c of ext.contributions) { + const reasons = collectUnavailableReasons(ext, c, permResult, isTrusted); + const permEntry = permResult.contributions.find( + (pc) => pc.type === c.type && pc.id === c.id + ); + descriptors.push({ + type: c.type, + id: c.id, + extensionId: ext.extensionId, + rootId: ext.rootId, + rootKind: ext.rootKind, + available: reasons.length === 0, + unavailableReasons: reasons, + missingPermissions: permEntry?.missingPermissions ?? [], + }); + } + + const allowed: CandidateContribution[] = []; + if (!ext.activated) continue; + for (const c of ext.contributions) { + if (!c.activated) continue; + const permEntry = permResult.contributions.find( + (pc) => pc.type === c.type && pc.id === c.id + ); + if (c.type !== "skills") continue; + if (requiresReapproval(permResult)) continue; + if (permEntry?.available) allowed.push({ type: c.type, id: c.id }); + } + if (allowed.length === 0) continue; + candidates.push({ + extensionId: ext.extensionId, + rootId: ext.rootId, + rootKind: ext.rootKind, + isCore: ext.isCore, + contributions: allowed, + }); + } + } + + const conflict = resolveConflicts({ candidates, now }); + + // Annotate descriptors that lost a Conflict Resolver pass with the + // "conflict" reason so the Settings UI can render the explanation + // alongside other unavailability reasons. + const availableContributionKeys = new Set( + conflict.availableContributions.map((c) => + availableContributionKey(c.rootId, c.extensionId, c.type, c.id) + ) + ); + const conflictByExtension = new Map>(); + for (const diag of conflict.diagnostics) { + if ( + diag.code !== "extension.identity.conflict" && + diag.code !== "contribution.identity.conflict" + ) { + continue; + } + if (!diag.extensionId) continue; + const key = `${diag.rootId ?? ""}\0${diag.extensionId}`; + const set = conflictByExtension.get(key) ?? new Set(); + const ref = diag.contributionRef; + // For extension-identity conflicts, mark every contribution; for + // contribution-identity conflicts, mark only the referenced one. + set.add(ref ? `${ref.type}::${ref.id ?? ""}` : "*"); + conflictByExtension.set(key, set); + } + for (const d of descriptors) { + const marks = + conflictByExtension.get(`${d.rootId}\0${d.extensionId}`) ?? + conflictByExtension.get(`\0${d.extensionId}`); + if (!marks) continue; + if ( + (marks.has("*") || marks.has(`${d.type}::${d.id}`)) && + !availableContributionKeys.has( + availableContributionKey(d.rootId, d.extensionId, d.type, d.id) + ) + ) { + if (!d.unavailableReasons.includes("conflict")) { + d.unavailableReasons.push("conflict"); + } + d.available = false; + } + } + + const staleRecords = computeStaleRecords(preloaded, liveExtensionKeys); + + return { + generatedAt: now, + roots: rootsWithActivationFallback, + availableContributions: conflict.availableContributions, + resolverDiagnostics: conflict.diagnostics, + descriptors, + permissions, + staleRecords, + }; + } + + private replaceActivationSessions( + snapshot: RegistrySnapshot, + discoveredSessions: ActivationSessionMap + ): void { + const liveKeys = activeActivationSessionKeys(snapshot); + const nextSessions: ActivationSessionMap = new Map(); + + for (const [key, session] of discoveredSessions) { + if (liveKeys.has(key)) { + nextSessions.set(key, session); + } else { + session.dispose(); + } + } + + for (const [key, session] of this.activationSessions) { + if (nextSessions.has(key)) { + session.dispose(); + } else if (liveKeys.has(key)) { + // Hot reload can keep the previous good skill body when only the new + // declarative file failed validation. Preserve the matching sandbox too + // so Full Activation remains explicitly owned until the extension stops. + nextSessions.set(key, session); + } else { + session.dispose(); + } + } + + this.activationSessions = nextSessions; + } + + private disposeActivationSessions(): void { + disposeActivationSessionMap(this.activationSessions); + this.activationSessions = new Map(); + } + + private async maybeWriteCache(snapshot: RegistrySnapshot): Promise { + const cache = this.options.snapshotCache; + const pathsFn = this.options.stateFilePaths; + if (!cache || !pathsFn) return; + try { + const paths = await pathsFn(); + await cache.write(snapshot, paths); + } catch (error) { + log.warn("[extensions] Failed to write snapshot cache", { error }); + } + } +} + +function applyPreviousGoodActivations(root: RootDiscoveryResult): RootDiscoveryResult { + return root; +} + +function activeActivationSessionKeys(snapshot: RegistrySnapshot): Set { + const keys = new Set(); + for (const root of snapshot.roots) { + for (const extension of root.extensions) { + if (!extension.activated) continue; + keys.add(activationSessionKey(extension.rootId, extension.extensionId)); + } + } + return keys; +} + +function activationSessionKey(rootId: string, extensionId: string): string { + return `${rootId}\0${extensionId}`; +} + +function disposeActivationSessionMap(sessions: ActivationSessionMap): void { + for (const session of sessions.values()) { + session.dispose(); + } + sessions.clear(); +} + +function availableContributionKey( + rootId: string, + extensionId: string, + type: string, + id: string +): string { + return `${rootId}\0${extensionId}\0${type}\0${id}`; +} + +function lookupApproval( + ext: DiscoveredExtension, + rootId: string, + preloaded: PreloadedState +): ApprovalRecord | undefined { + if (ext.rootKind === "bundled") { + return undefined; + } + if (ext.rootKind === "project-local") { + const projectPath = preloaded.rootIdToProjectPath.get(rootId); + if (!projectPath) return undefined; + return preloaded.projectStates.get(projectPath)?.extensions[ext.extensionId]?.approval; + } + return preloaded.globalState.extensions[ext.extensionId]?.approval; +} + +// Bundled Extension approvals are policy-derived and recomputed on every reload; +// they are never persisted. +function synthesizePolicyApproval(ext: DiscoveredExtension): ApprovalRecord { + return { + grantedPermissions: [...ext.manifest.requestedPermissions], + requestedPermissionsHash: hashRequestedPermissions(ext.manifest.requestedPermissions), + }; +} + +function computePermissions( + ext: DiscoveredExtension, + approval: ApprovalRecord | undefined +): CalculatePermissionsResult { + const requirements: ContributionPermissionRequirement[] = ext.manifest.contributions.map((c) => ({ + type: c.type, + id: c.id, + registrationPermission: + CONTRIBUTION_TYPE_REGISTRATION_PERMISSIONS[c.type] ?? `${c.type}.register`, + })); + const effectiveApproval = + approval ?? (ext.rootKind === "bundled" ? synthesizePolicyApproval(ext) : undefined); + return calculatePermissions({ + manifest: { + requestedPermissions: ext.manifest.requestedPermissions, + contributions: requirements, + }, + approvalRecord: effectiveApproval, + }); +} + +function collectUnavailableReasons( + ext: DiscoveredExtension, + contribution: DiscoveredContribution, + permResult: CalculatePermissionsResult, + rootTrusted: boolean +): UnavailableReason[] { + const reasons: UnavailableReason[] = []; + if (!rootTrusted) reasons.push("untrusted-root"); + if (!ext.enabled) reasons.push("disabled"); + if (!ext.granted) reasons.push("ungranted"); + if (contribution.type !== "skills") reasons.push("inspection-only"); + const permEntry = permResult.contributions.find( + (pc) => pc.type === contribution.type && pc.id === contribution.id + ); + if (requiresReapproval(permResult)) reasons.push("pending-reapproval"); + if (permEntry && !permEntry.available) reasons.push("missing-permissions"); + if (ext.enabled && ext.granted && rootTrusted) { + if (!ext.activated) reasons.push("body-failed"); + else if (!contribution.activated) reasons.push("not-activated"); + } + return reasons; +} + +function staleRecordKey(rootId: string, rootKind: RootKind, extensionId: string): string { + if (rootKind === "project-local") return `${rootId}\0${extensionId}`; + if (rootKind === "user-global") return `${STALE_GLOBAL_ROOT_ID}\0${extensionId}`; + return `bundled\0${extensionId}`; +} + +function computeStaleRecords( + preloaded: PreloadedState, + liveExtensionKeys: ReadonlySet +): StaleRecord[] { + const stale: StaleRecord[] = []; + for (const [extensionId, record] of Object.entries(preloaded.globalState.extensions)) { + if ( + record.approval && + !liveExtensionKeys.has(staleRecordKey(STALE_GLOBAL_ROOT_ID, "user-global", extensionId)) + ) { + stale.push({ + scope: "global", + extensionId, + approval: record.approval, + rootId: STALE_GLOBAL_ROOT_ID, + }); + } + } + for (const [projectPath, projectState] of preloaded.projectStates) { + const rootId = staleProjectLocalRootId(projectPath); + if (!preloaded.inspectableProjectRootIds.has(rootId)) continue; + for (const [extensionId, record] of Object.entries(projectState.extensions)) { + if ( + record.approval && + !liveExtensionKeys.has(staleRecordKey(rootId, "project-local", extensionId)) + ) { + stale.push({ + scope: "project-local", + projectPath, + extensionId, + approval: record.approval, + rootId, + }); + } + } + } + return stale; +} diff --git a/src/node/extensions/extensionRootWatcher.test.ts b/src/node/extensions/extensionRootWatcher.test.ts new file mode 100644 index 0000000000..740770df43 --- /dev/null +++ b/src/node/extensions/extensionRootWatcher.test.ts @@ -0,0 +1,558 @@ +import { EventEmitter } from "events"; +import * as fs from "fs"; +import { mkdir, writeFile } from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { ExtensionRootWatcher, type WatchFn } from "./extensionRootWatcher"; +import type { ExtensionRootDescriptor } from "./extensionDiscoveryService"; + +const noOp = (): void => undefined; + +class FakeFSWatcher extends EventEmitter { + closed = false; + constructor(public readonly watchedPath: string) { + super(); + } + close(): void { + this.closed = true; + this.removeAllListeners(); + } + emitChange(filename: string | null): void { + this.emit("change", "change", filename); + } + emitError(err: Error): void { + this.emit("error", err); + } +} + +interface FakeWatchHarness { + watchFn: WatchFn; + watchers: FakeFSWatcher[]; + failPaths: Set; +} + +function createFakeWatch(): FakeWatchHarness { + const watchers: FakeFSWatcher[] = []; + const failPaths = new Set(); + + const watchFn: WatchFn = (target, _opts, listener) => { + if (failPaths.has(String(target))) { + throw new Error(`fs.watch failed for ${String(target)}`); + } + const watcher = new FakeFSWatcher(String(target)); + if (listener) watcher.on("change", listener); + watchers.push(watcher); + return watcher as unknown as fs.FSWatcher; + }; + + return { watchFn, watchers, failPaths }; +} + +const bundledRoot: ExtensionRootDescriptor = { + rootId: "bundled", + kind: "bundled", + path: "/fake/bundled", + isCore: true, +}; + +const userGlobalRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", +}; + +function projectRoot(opts: { trusted: boolean; suffix?: string }): ExtensionRootDescriptor { + return { + rootId: `project-local${opts.suffix ?? ""}`, + kind: "project-local", + path: `/fake/project${opts.suffix ?? ""}`, + trusted: opts.trusted, + }; +} + +const silentLog = { debug: noOp }; + +describe("ExtensionRootWatcher — eligibility", () => { + test("does not watch bundled roots", async () => { + const harness = createFakeWatch(); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + await watcher.setRoots([bundledRoot]); + expect(harness.watchers).toHaveLength(0); + }); + + test("watches user-global root unconditionally", async () => { + const harness = createFakeWatch(); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + await watcher.setRoots([userGlobalRoot]); + expect(harness.watchers).toHaveLength(1); + expect(harness.watchers[0].watchedPath).toBe(userGlobalRoot.path); + }); + + test("does not watch untrusted project-local roots", async () => { + const harness = createFakeWatch(); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + await watcher.setRoots([projectRoot({ trusted: false })]); + expect(harness.watchers).toHaveLength(0); + }); + + test("watches trusted project-local roots", async () => { + const harness = createFakeWatch(); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + await watcher.setRoots([projectRoot({ trusted: true })]); + expect(harness.watchers).toHaveLength(1); + }); +}); + +describe("ExtensionRootWatcher — Extension Modules", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join( + os.tmpdir(), + `mux-root-watcher-modules-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await mkdir(path.join(tempDir, "acme-review"), { recursive: true }); + await writeFile( + path.join(tempDir, "acme-review", "extension.ts"), + "export const manifest = {};" + ); + }); + + afterEach(async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }); + + test("watches direct Extension Module folders that contain extension.ts", async () => { + const harness = createFakeWatch(); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + + await watcher.setRoots([{ rootId: "user-global", kind: "user-global", path: tempDir }]); + + expect(harness.watchers.map((fake) => fake.watchedPath).sort()).toEqual( + [path.join(tempDir, "acme-review"), tempDir].sort() + ); + }); + + test("does not watch unrelated nested directories inside Extension Modules", async () => { + await mkdir(path.join(tempDir, "acme-review", "node_modules", "pkg"), { recursive: true }); + await writeFile(path.join(tempDir, "acme-review", "node_modules", "pkg", "index.js"), ""); + const harness = createFakeWatch(); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + + await watcher.setRoots([{ rootId: "user-global", kind: "user-global", path: tempDir }]); + + expect(harness.watchers.map((fake) => fake.watchedPath)).not.toContain( + path.join(tempDir, "acme-review", "node_modules") + ); + expect(harness.watchers.map((fake) => fake.watchedPath)).not.toContain( + path.join(tempDir, "acme-review", "node_modules", "pkg") + ); + }); + + test("reloads when an Extension Module folder is created", async () => { + const harness = createFakeWatch(); + let calls = 0; + using watcher = new ExtensionRootWatcher({ + onChange: () => { + calls += 1; + }, + watchFn: harness.watchFn, + debounceMs: 20, + log: silentLog, + }); + + await watcher.setRoots([{ rootId: "user-global", kind: "user-global", path: tempDir }]); + harness.watchers[0].emitChange("other-review"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(calls).toBe(1); + }); + + test("reloads when an Extension Module manifest changes", async () => { + const harness = createFakeWatch(); + let calls = 0; + using watcher = new ExtensionRootWatcher({ + onChange: () => { + calls += 1; + }, + watchFn: harness.watchFn, + debounceMs: 20, + log: silentLog, + }); + + await watcher.setRoots([{ rootId: "user-global", kind: "user-global", path: tempDir }]); + const moduleWatcher = harness.watchers.find( + (fake) => fake.watchedPath === path.join(tempDir, "acme-review") + ); + expect(moduleWatcher).toBeDefined(); + + moduleWatcher!.emitChange("extension.ts"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(calls).toBe(1); + }); + + test("reloads when a referenced Extension Module SKILL.md changes", async () => { + await mkdir(path.join(tempDir, "acme-review", "skills", "review"), { recursive: true }); + await writeFile( + path.join(tempDir, "acme-review", "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review helper\n---\n# Review\n" + ); + const harness = createFakeWatch(); + let calls = 0; + using watcher = new ExtensionRootWatcher({ + onChange: () => { + calls += 1; + }, + watchFn: harness.watchFn, + debounceMs: 20, + log: silentLog, + }); + + await watcher.setRoots([{ rootId: "user-global", kind: "user-global", path: tempDir }]); + const skillDirWatcher = harness.watchers.find( + (fake) => fake.watchedPath === path.join(tempDir, "acme-review", "skills", "review") + ); + expect(skillDirWatcher).toBeDefined(); + + skillDirWatcher!.emitChange("SKILL.md"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(calls).toBe(1); + }); + + test("reloads when an Extension Module skill body uses a custom filename", async () => { + await mkdir(path.join(tempDir, "acme-review", "skills", "review"), { recursive: true }); + await writeFile( + path.join(tempDir, "acme-review", "skills", "review", "review-body.md"), + "---\nname: review\ndescription: Review helper\n---\n# Review\n" + ); + const harness = createFakeWatch(); + let calls = 0; + using watcher = new ExtensionRootWatcher({ + onChange: () => { + calls += 1; + }, + watchFn: harness.watchFn, + debounceMs: 20, + log: silentLog, + }); + + await watcher.setRoots([{ rootId: "user-global", kind: "user-global", path: tempDir }]); + const skillDirWatcher = harness.watchers.find( + (fake) => fake.watchedPath === path.join(tempDir, "acme-review", "skills", "review") + ); + expect(skillDirWatcher).toBeDefined(); + + skillDirWatcher!.emitChange("review-body.md"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(calls).toBe(1); + }); +}); + +describe("ExtensionRootWatcher — project lockfiles", () => { + test("watches the project .mux directory even when the active root is materialized elsewhere", async () => { + const harness = createFakeWatch(); + const projectPath = "/fake/project-with-lock"; + const activeRootPath = "/fake/mux/extensions/projects/project-key"; + let calls = 0; + using watcher = new ExtensionRootWatcher({ + onChange: () => { + calls += 1; + }, + watchFn: harness.watchFn, + debounceMs: 20, + log: silentLog, + }); + + await watcher.setRoots([ + { + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: activeRootPath, + trusted: true, + }, + ]); + + const lockDirWatcher = harness.watchers.find( + (fake) => fake.watchedPath === path.join(projectPath, ".mux") + ); + expect(lockDirWatcher).toBeDefined(); + + lockDirWatcher!.emitChange("extensions.lock.json"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(calls).toBe(1); + }); +}); + +describe("ExtensionRootWatcher — trust transitions", () => { + test("untrusting a project-local root tears down its watcher", async () => { + const harness = createFakeWatch(); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + const trusted = projectRoot({ trusted: true }); + await watcher.setRoots([trusted]); + expect(harness.watchers).toHaveLength(1); + expect(harness.watchers[0].closed).toBe(false); + + await watcher.setRoots([{ ...trusted, trusted: false }]); + expect(harness.watchers).toHaveLength(1); + expect(harness.watchers[0].closed).toBe(true); + }); + + test("trusting a project-local root starts a watcher", async () => { + const harness = createFakeWatch(); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + const untrusted = projectRoot({ trusted: false }); + await watcher.setRoots([untrusted]); + expect(harness.watchers).toHaveLength(0); + + await watcher.setRoots([{ ...untrusted, trusted: true }]); + expect(harness.watchers).toHaveLength(1); + expect(harness.watchers[0].closed).toBe(false); + }); + + test("changing a root path for the same project-local root restarts watchers", async () => { + const harness = createFakeWatch(); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + const firstRoot: ExtensionRootDescriptor = { + rootId: "project-local:/fake/project", + kind: "project-local", + path: "/fake/mux/extensions/projects/project-key", + trusted: true, + }; + const secondRoot: ExtensionRootDescriptor = { + ...firstRoot, + path: "/fake/project/.mux/extensions", + }; + + await watcher.setRoots([firstRoot]); + expect(harness.watchers[0].watchedPath).toBe(firstRoot.path); + + await watcher.setRoots([secondRoot]); + + expect(harness.watchers[0].closed).toBe(true); + expect(harness.watchers.some((item) => item.watchedPath === secondRoot.path)).toBe(true); + }); + + test("repeated setRoots with identical eligible roots does not restart watchers", async () => { + const harness = createFakeWatch(); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + await watcher.setRoots([userGlobalRoot]); + await watcher.setRoots([userGlobalRoot]); + expect(harness.watchers).toHaveLength(1); + expect(harness.watchers[0].closed).toBe(false); + }); +}); + +describe("ExtensionRootWatcher — debounce", () => { + test("collapses rapid module events into a single onChange after the debounce window", async () => { + const harness = createFakeWatch(); + let calls = 0; + using watcher = new ExtensionRootWatcher({ + onChange: () => { + calls += 1; + }, + watchFn: harness.watchFn, + debounceMs: 30, + log: silentLog, + }); + await watcher.setRoots([userGlobalRoot]); + + harness.watchers[0].emitChange("acme-review"); + harness.watchers[0].emitChange("other-review"); + harness.watchers[0].emitChange("acme-review"); + + expect(calls).toBe(0); + await new Promise((r) => setTimeout(r, 60)); + expect(calls).toBe(1); + }); + + test("ignores events for unrelated filenames", async () => { + const harness = createFakeWatch(); + let calls = 0; + using watcher = new ExtensionRootWatcher({ + onChange: () => { + calls += 1; + }, + watchFn: harness.watchFn, + debounceMs: 20, + log: silentLog, + }); + await watcher.setRoots([userGlobalRoot]); + + harness.watchers[0].emitChange("README.md"); + harness.watchers[0].emitChange("node_modules"); + harness.watchers[0].emitChange("package.json"); + harness.watchers[0].emitChange("bun.lock"); + + await new Promise((r) => setTimeout(r, 50)); + expect(calls).toBe(0); + }); + + test("re-fires onChange for events that arrive after a previous flush", async () => { + const harness = createFakeWatch(); + let calls = 0; + using watcher = new ExtensionRootWatcher({ + onChange: () => { + calls += 1; + }, + watchFn: harness.watchFn, + debounceMs: 20, + log: silentLog, + }); + await watcher.setRoots([userGlobalRoot]); + + harness.watchers[0].emitChange("acme-review"); + await new Promise((r) => setTimeout(r, 40)); + expect(calls).toBe(1); + + harness.watchers[0].emitChange("acme-review"); + await new Promise((r) => setTimeout(r, 40)); + expect(calls).toBe(2); + }); + + test("close cancels a pending debounced callback", async () => { + const harness = createFakeWatch(); + let calls = 0; + const watcher = new ExtensionRootWatcher({ + onChange: () => { + calls += 1; + }, + watchFn: harness.watchFn, + debounceMs: 20, + log: silentLog, + }); + await watcher.setRoots([userGlobalRoot]); + harness.watchers[0].emitChange("acme-review"); + watcher.close(); + await new Promise((r) => setTimeout(r, 40)); + expect(calls).toBe(0); + }); +}); + +describe("ExtensionRootWatcher — graceful degradation", () => { + test("fs.watch throwing for one root does not prevent watching the others", async () => { + const harness = createFakeWatch(); + harness.failPaths.add(userGlobalRoot.path); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + await watcher.setRoots([userGlobalRoot, projectRoot({ trusted: true })]); + expect(harness.watchers).toHaveLength(1); + expect(harness.watchers[0].watchedPath).toBe("/fake/project"); + }); + + test("fs.watch failure logs at debug and does not throw", async () => { + const harness = createFakeWatch(); + harness.failPaths.add(userGlobalRoot.path); + const debugCalls: unknown[][] = []; + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: { debug: (...args: unknown[]) => debugCalls.push(args) }, + }); + await watcher.setRoots([userGlobalRoot]); + expect(debugCalls.length).toBeGreaterThan(0); + }); + + test("watcher 'error' event closes the watcher and degrades silently", async () => { + const harness = createFakeWatch(); + using watcher = new ExtensionRootWatcher({ + onChange: noOp, + watchFn: harness.watchFn, + log: silentLog, + }); + await watcher.setRoots([userGlobalRoot]); + expect(() => harness.watchers[0].emitError(new Error("boom"))).not.toThrow(); + expect(harness.watchers[0].closed).toBe(true); + }); +}); + +describe("ExtensionRootWatcher — real fs integration", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join( + os.tmpdir(), + `mux-root-watcher-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }); + + test("real Extension Module creation triggers a debounced reload", async () => { + let calls = 0; + const watcher = new ExtensionRootWatcher({ + onChange: () => { + calls += 1; + }, + debounceMs: 30, + log: silentLog, + }); + const root: ExtensionRootDescriptor = { + rootId: "real-user-global", + kind: "user-global", + path: tempDir, + }; + await watcher.setRoots([root]); + + await mkdir(path.join(tempDir, "acme-review"), { recursive: true }); + await writeFile( + path.join(tempDir, "acme-review", "extension.ts"), + "export const manifest = {};" + ); + + await new Promise((r) => setTimeout(r, 200)); + watcher.close(); + expect(calls).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/node/extensions/extensionRootWatcher.ts b/src/node/extensions/extensionRootWatcher.ts new file mode 100644 index 0000000000..762b164c45 --- /dev/null +++ b/src/node/extensions/extensionRootWatcher.ts @@ -0,0 +1,325 @@ +import * as fs from "fs"; +import * as path from "path"; + +import type { ExtensionRootDescriptor } from "@/node/extensions/extensionDiscoveryService"; +import { log as defaultLog, type Logger } from "@/node/services/log"; + +export const ROOT_WATCHER_DEBOUNCE_MS_DEFAULT = 500; + +export type WatchFn = ( + target: string, + options: fs.WatchOptions, + listener: (event: fs.WatchEventType, filename: string | null) => void +) => fs.FSWatcher; + +export interface ExtensionRootWatcherOptions { + /** Invoked once per debounce window when an eligible event is observed. */ + onChange: () => void; + /** Override the 500ms default; useful for tests. */ + debounceMs?: number; + /** Override fs.watch for tests / forced graceful-degradation paths. */ + watchFn?: WatchFn; + /** Inject a logger; defaults to the project-wide logger. */ + log?: Pick; +} + +// Watches the root manifest + lockfile for each eligible Extension Root. +// Bundled roots are never watched (they can only change via app upgrade); +// project-local roots are watched only while their Trusted Extension Root flag +// is set, and watchers are torn down when the flag flips off. fs.watch failures +// degrade silently to a debug log; the user falls back to manual Reload. +interface ActiveRootWatcher { + rootPath: string; + rootWatchers: fs.FSWatcher[]; + moduleWatchers: Map; +} + +export class ExtensionRootWatcher { + private readonly active = new Map(); + private debounceTimer: NodeJS.Timeout | undefined; + private readonly debounceMs: number; + private readonly watchFn: WatchFn; + private readonly onChange: () => void; + private readonly log: Pick; + private closed = false; + + constructor(options: ExtensionRootWatcherOptions) { + this.onChange = options.onChange; + this.debounceMs = options.debounceMs ?? ROOT_WATCHER_DEBOUNCE_MS_DEFAULT; + this.watchFn = + options.watchFn ?? + ((target, watchOptions, listener) => + fs.watch(target, { ...watchOptions, encoding: "utf8" }, listener)); + this.log = options.log ?? defaultLog; + } + + /** + * Reconciles the active watcher set against the current Extension Roots. + * Idempotent: re-passing identical eligible roots leaves their watchers in + * place. Roots that become ineligible (untrusted project-local, vanished, + * or now bundled) have their watchers closed. + */ + async setRoots(roots: readonly ExtensionRootDescriptor[]): Promise { + if (this.closed) return; + const eligible = new Map(); + for (const root of roots) { + if (isWatchableRoot(root)) eligible.set(rootKey(root), root); + } + + for (const [key, activeRoot] of this.active) { + if (eligible.has(key)) continue; + closeActiveRoot(activeRoot); + this.active.delete(key); + } + + for (const [key, root] of eligible) { + const activeRoot = this.active.get(key); + if (activeRoot) { + if (activeRoot.rootPath !== root.path) { + closeActiveRoot(activeRoot); + this.active.delete(key); + await this.startWatcher(key, root); + continue; + } + await this.reconcileModuleWatchers(activeRoot); + continue; + } + await this.startWatcher(key, root); + } + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const activeRoot of this.active.values()) closeActiveRoot(activeRoot); + this.active.clear(); + if (this.debounceTimer !== undefined) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + } + + [Symbol.dispose](): void { + this.close(); + } + + private async startWatcher(key: string, root: ExtensionRootDescriptor): Promise { + let watcher: fs.FSWatcher; + try { + watcher = this.watchFn( + root.path, + { persistent: false, recursive: false }, + (_event, filename) => this.onRootWatchEvent(filename) + ); + } catch (error) { + this.log.debug("Extension Root Watcher: fs.watch failed", root.path, error); + return; + } + + const activeRoot: ActiveRootWatcher = { + rootPath: root.path, + rootWatchers: [watcher], + moduleWatchers: new Map(), + }; + + watcher.on("error", (error) => { + this.log.debug("Extension Root Watcher: error event", root.path, error); + // After an error fs.watch stops delivering events, so close + drop the + // entry; future setRoots() calls will retry. We don't reconnect eagerly. + if (this.active.get(key) === activeRoot) { + closeActiveRoot(activeRoot); + this.active.delete(key); + } + }); + + this.startProjectLockWatcher(root, activeRoot); + this.active.set(key, activeRoot); + await this.reconcileModuleWatchers(activeRoot); + } + + private startProjectLockWatcher( + root: ExtensionRootDescriptor, + activeRoot: ActiveRootWatcher + ): void { + const projectPath = projectPathFromProjectLocalRootId(root.rootId); + if (root.kind !== "project-local" || projectPath === null) return; + + const lockDir = path.join(projectPath, ".mux"); + if (path.resolve(lockDir) === path.resolve(root.path)) return; + + try { + const watcher = this.watchFn( + lockDir, + { persistent: false, recursive: false }, + (_event, filename) => this.onRootWatchEvent(filename) + ); + watcher.on("error", (error) => { + this.log.debug("Extension Root Watcher: project lock error event", lockDir, error); + watcher.close(); + }); + activeRoot.rootWatchers.push(watcher); + } catch (error) { + // Project lockfile changes trigger source sync. If watching the repo .mux + // directory fails, module hot reload still works and users can Reload. + this.log.debug("Extension Root Watcher: project lock fs.watch failed", lockDir, error); + } + } + + private async reconcileModuleWatchers(activeRoot: ActiveRootWatcher): Promise { + const moduleWatchPaths = await findExtensionModuleWatchPaths(activeRoot.rootPath); + const wanted = new Set(moduleWatchPaths); + + for (const [modulePath, watcher] of activeRoot.moduleWatchers) { + if (wanted.has(modulePath)) continue; + watcher.close(); + activeRoot.moduleWatchers.delete(modulePath); + } + + for (const modulePath of moduleWatchPaths) { + if (activeRoot.moduleWatchers.has(modulePath)) continue; + let watcher: fs.FSWatcher; + try { + watcher = this.watchFn( + modulePath, + { persistent: false, recursive: false }, + (_event, filename) => this.onModuleWatchEvent(filename) + ); + } catch (error) { + this.log.debug("Extension Root Watcher: module fs.watch failed", modulePath, error); + continue; + } + watcher.on("error", (error) => { + this.log.debug("Extension Root Watcher: module error event", modulePath, error); + watcher.close(); + activeRoot.moduleWatchers.delete(modulePath); + }); + activeRoot.moduleWatchers.set(modulePath, watcher); + } + } + + private onRootWatchEvent(filename: string | null): void { + // Some platforms deliver `null` for the filename; treat as relevant so we + // never miss a real change. Otherwise filter to a direct Extension Module + // directory / manifest path; package-manager metadata is ignored in v1. + if (filename !== null && !isRelevantRootFilename(filename)) return; + this.scheduleReload(); + } + + private onModuleWatchEvent(filename: string | null): void { + // Module directory watchers exist so editing /extension.ts or any + // contained skill body path triggers rediscovery even where root fs.watch is not recursive. + if (filename !== null && !isRelevantModuleFilename(filename)) return; + this.scheduleReload(); + } + + private scheduleReload(): void { + if (this.closed) return; + if (this.debounceTimer !== undefined) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + this.debounceTimer = undefined; + try { + this.onChange(); + } catch (error) { + this.log.debug("Extension Root Watcher: onChange threw", error); + } + }, this.debounceMs); + } +} + +function closeActiveRoot(activeRoot: ActiveRootWatcher): void { + for (const rootWatcher of activeRoot.rootWatchers) rootWatcher.close(); + for (const moduleWatcher of activeRoot.moduleWatchers.values()) moduleWatcher.close(); + activeRoot.moduleWatchers.clear(); +} + +async function findExtensionModuleWatchPaths(rootPath: string): Promise { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(rootPath, { withFileTypes: true }); + } catch { + return []; + } + + const watchPaths: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const modulePath = path.join(rootPath, entry.name); + try { + const stat = await fs.promises.stat(modulePath); + if (!stat.isDirectory()) continue; + await fs.promises.access(path.join(modulePath, "extension.ts")); + } catch { + continue; + } + watchPaths.push(modulePath); + const skillsPath = path.join(modulePath, "skills"); + if (await directoryExists(skillsPath)) { + watchPaths.push(skillsPath); + watchPaths.push(...(await findNestedDirectoryPaths(skillsPath))); + } + } + return watchPaths; +} + +async function directoryExists(targetPath: string): Promise { + try { + const stat = await fs.promises.stat(targetPath); + return stat.isDirectory(); + } catch { + return false; + } +} + +async function findNestedDirectoryPaths(rootPath: string): Promise { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(rootPath, { withFileTypes: true }); + } catch { + return []; + } + + const dirs: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const dirPath = path.join(rootPath, entry.name); + dirs.push(dirPath); + dirs.push(...(await findNestedDirectoryPaths(dirPath))); + } + return dirs; +} + +function isRelevantModuleFilename(filename: string): boolean { + return filename.split(/[\\/]/u).some(Boolean); +} + +function isExtensionModuleName(filename: string): boolean { + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(filename); +} + +function isRelevantRootFilename(filename: string): boolean { + const parts = filename.split(/[\\/]/u).filter(Boolean); + if (parts.length === 1) { + return ( + isExtensionModuleName(parts[0]) || + parts[0] === "extensions.lock.json" || + parts[0] === "lock.json" + ); + } + return parts.length === 2 && isExtensionModuleName(parts[0]) && parts[1] === "extension.ts"; +} + +function projectPathFromProjectLocalRootId(rootId: string): string | null { + const prefix = "project-local:"; + if (!rootId.startsWith(prefix)) return null; + return rootId.slice(prefix.length); +} + +function rootKey(root: ExtensionRootDescriptor): string { + return `${root.kind}:${root.rootId}`; +} + +function isWatchableRoot(root: ExtensionRootDescriptor): boolean { + if (root.kind === "bundled") return false; + if (root.kind === "project-local") return root.trusted === true; + return true; +} diff --git a/src/node/extensions/extensionRoots.test.ts b/src/node/extensions/extensionRoots.test.ts new file mode 100644 index 0000000000..b856c0a94f --- /dev/null +++ b/src/node/extensions/extensionRoots.test.ts @@ -0,0 +1,332 @@ +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { Config } from "@/node/config"; +import { ProjectExtensionStateService } from "./projectExtensionStateService"; +import { + createExtensionRootsProvider, + getFetchedGlobalExtensionRootPath, + getUserGlobalExtensionRootPath, +} from "./extensionRoots"; +import { getProjectExtensionActiveRootPath } from "./projectExtensionSourceSync"; + +let tempDir: string; + +beforeEach(() => { + tempDir = path.join(os.tmpdir(), `mux-extension-roots-${crypto.randomUUID()}`); +}); + +afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); +}); + +describe("getUserGlobalExtensionRootPath", () => { + test("points local authoring at ~/.mux/extensions/local", () => { + const config = new Config(tempDir); + + expect(getUserGlobalExtensionRootPath(config)).toBe(path.join(tempDir, "extensions", "local")); + }); + + test("enumerates an untrusted project-local root when only extensions.lock.json exists", async () => { + const config = new Config(tempDir); + const projectPath = path.join(tempDir, "project"); + await fs.mkdir(path.join(projectPath, ".mux"), { recursive: true }); + await fs.writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + JSON.stringify({ schemaVersion: 1, extensions: {} }) + ); + const cfg = config.loadConfigOrDefault(); + cfg.projects.set(projectPath, { workspaces: [], trusted: false }); + await config.saveConfig(cfg); + const provider = createExtensionRootsProvider({ + config, + projectState: new ProjectExtensionStateService(path.join(tempDir, "project-state")), + }); + + const roots = await provider(); + + expect(roots).toContainEqual({ + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: path.join(projectPath, ".mux"), + trusted: false, + }); + }); + + test("lock-only project roots point at an existing inspection path before trust", async () => { + const config = new Config(tempDir); + const projectPath = path.join(tempDir, "project"); + await fs.mkdir(path.join(projectPath, ".mux"), { recursive: true }); + await fs.writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + JSON.stringify({ schemaVersion: 1, extensions: {} }) + ); + const cfg = config.loadConfigOrDefault(); + cfg.projects.set(projectPath, { workspaces: [], trusted: false }); + await config.saveConfig(cfg); + const provider = createExtensionRootsProvider({ + config, + projectState: new ProjectExtensionStateService(path.join(tempDir, "project-state")), + }); + + const roots = await provider(); + + const projectRoot = roots.find((root) => root.rootId === `project-local:${projectPath}`); + expect(projectRoot).toBeDefined(); + expect((await fs.stat(projectRoot!.path)).isDirectory()).toBe(true); + let repoExtensionRootExists = true; + try { + await fs.access(path.join(projectPath, ".mux", "extensions")); + } catch { + repoExtensionRootExists = false; + } + expect(repoExtensionRootExists).toBe(false); + + expect(roots).toContainEqual({ + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: path.join(projectPath, ".mux"), + trusted: false, + }); + }); + + test("syncs project source locks after both project and extension root trust are set", async () => { + const config = new Config(tempDir); + const projectPath = path.join(tempDir, "project"); + await fs.mkdir(path.join(projectPath, ".mux"), { recursive: true }); + await fs.writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + JSON.stringify({ schemaVersion: 1, extensions: {} }) + ); + const cfg = config.loadConfigOrDefault(); + cfg.projects.set(projectPath, { workspaces: [], trusted: true }); + await config.saveConfig(cfg); + const projectState = new ProjectExtensionStateService(path.join(tempDir, "project-state")); + await projectState.setRootTrusted(projectPath, true); + const syncCalls: unknown[] = []; + const provider = createExtensionRootsProvider({ + config, + projectState, + syncProjectLocks: (input) => { + syncCalls.push(input); + return Promise.resolve(); + }, + }); + + await provider(); + + expect(syncCalls).toEqual([ + { + projectPath, + muxRootDir: tempDir, + trusted: true, + }, + ]); + }); + + test("does not resync unchanged project source locks when the active view exists", async () => { + const config = new Config(tempDir); + const projectPath = path.join(tempDir, "project"); + const lockPath = path.join(projectPath, ".mux", "extensions.lock.json"); + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + await fs.writeFile(lockPath, JSON.stringify({ schemaVersion: 1, extensions: {} })); + const cfg = config.loadConfigOrDefault(); + cfg.projects.set(projectPath, { workspaces: [], trusted: true }); + await config.saveConfig(cfg); + const projectState = new ProjectExtensionStateService(path.join(tempDir, "project-state")); + await projectState.setRootTrusted(projectPath, true); + const syncCalls: unknown[] = []; + const provider = createExtensionRootsProvider({ + config, + projectState, + syncProjectLocks: (input) => { + syncCalls.push(input); + return fs.mkdir(getProjectExtensionActiveRootPath(tempDir, projectPath), { + recursive: true, + }); + }, + }); + + await provider(); + await provider(); + + expect(syncCalls).toHaveLength(1); + }); + + test("resyncs unchanged project source locks when the active view is tampered", async () => { + const config = new Config(tempDir); + const projectPath = path.join(tempDir, "project"); + const lockPath = path.join(projectPath, ".mux", "extensions.lock.json"); + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + await fs.writeFile(lockPath, JSON.stringify({ schemaVersion: 1, extensions: {} })); + const cfg = config.loadConfigOrDefault(); + cfg.projects.set(projectPath, { workspaces: [], trusted: true }); + await config.saveConfig(cfg); + const projectState = new ProjectExtensionStateService(path.join(tempDir, "project-state")); + await projectState.setRootTrusted(projectPath, true); + const activeRootPath = getProjectExtensionActiveRootPath(tempDir, projectPath); + const syncCalls: unknown[] = []; + const provider = createExtensionRootsProvider({ + config, + projectState, + syncProjectLocks: (input) => { + syncCalls.push(input); + return fs.mkdir(activeRootPath, { recursive: true }); + }, + }); + + await provider(); + await fs.mkdir(path.join(activeRootPath, "tampered-extension"), { recursive: true }); + await provider(); + + expect(syncCalls).toHaveLength(2); + }); + + test("keeps project root inspectable when active source validation fails", async () => { + const config = new Config(tempDir); + const projectPath = path.join(tempDir, "project"); + await fs.mkdir(path.join(projectPath, ".mux"), { recursive: true }); + await fs.writeFile(path.join(projectPath, ".mux", "extensions.lock.json"), "not json"); + await fs.mkdir(getProjectExtensionActiveRootPath(tempDir, projectPath), { recursive: true }); + const cfg = config.loadConfigOrDefault(); + cfg.projects.set(projectPath, { workspaces: [], trusted: true }); + await config.saveConfig(cfg); + const projectState = new ProjectExtensionStateService(path.join(tempDir, "project-state")); + await projectState.setRootTrusted(projectPath, true); + const provider = createExtensionRootsProvider({ config, projectState }); + + const roots = await provider(); + + expect(roots).toContainEqual({ + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: path.join(projectPath, ".mux"), + trusted: false, + }); + }); + + test("keeps project root inspectable but non-activating when trusted project source sync fails", async () => { + const config = new Config(tempDir); + const projectPath = path.join(tempDir, "project"); + await fs.mkdir(path.join(projectPath, ".mux"), { recursive: true }); + await fs.writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + JSON.stringify({ schemaVersion: 1, extensions: {} }) + ); + const cfg = config.loadConfigOrDefault(); + cfg.projects.set(projectPath, { workspaces: [], trusted: true }); + await config.saveConfig(cfg); + const projectState = new ProjectExtensionStateService(path.join(tempDir, "project-state")); + await projectState.setRootTrusted(projectPath, true); + const provider = createExtensionRootsProvider({ + config, + projectState, + syncProjectLocks: () => Promise.reject(new Error("sync failed")), + }); + + const roots = await provider(); + + expect(roots).toContainEqual({ + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: path.join(projectPath, ".mux"), + trusted: false, + }); + }); + + test("does not discover stale lock-backed active views when trusted project source sync fails", async () => { + const config = new Config(tempDir); + const projectPath = path.join(tempDir, "project"); + await fs.mkdir(path.join(projectPath, ".mux"), { recursive: true }); + await fs.writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + JSON.stringify({ schemaVersion: 1, extensions: {} }) + ); + const activeRootPath = getProjectExtensionActiveRootPath(tempDir, projectPath); + await fs.mkdir(path.join(activeRootPath, "stale-review"), { recursive: true }); + await fs.writeFile( + path.join(activeRootPath, "stale-review", "extension.ts"), + "export const manifest = { name: 'stale-review', capabilities: { skills: true } };\n" + ); + await fs.mkdir(path.join(projectPath, ".mux", "extensions"), { recursive: true }); + await fs.writeFile( + path.join(projectPath, ".mux", "extensions", "extension.ts"), + "export const manifest = { name: 'extensions', capabilities: { skills: true } };\n" + ); + const cfg = config.loadConfigOrDefault(); + cfg.projects.set(projectPath, { workspaces: [], trusted: true }); + await config.saveConfig(cfg); + const projectState = new ProjectExtensionStateService(path.join(tempDir, "project-state")); + await projectState.setRootTrusted(projectPath, true); + const provider = createExtensionRootsProvider({ + config, + projectState, + syncProjectLocks: () => Promise.reject(new Error("sync failed")), + }); + + const roots = await provider(); + + expect(roots).toContainEqual({ + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: path.join(projectPath, ".mux"), + trusted: false, + }); + }); + + test("ignores stale project active views after the project lock is removed", async () => { + const config = new Config(tempDir); + const projectPath = path.join(tempDir, "project"); + const repoRootPath = path.join(projectPath, ".mux", "extensions"); + await fs.mkdir(path.join(repoRootPath, "current-review"), { recursive: true }); + await fs.writeFile( + path.join(repoRootPath, "current-review", "extension.ts"), + "export const manifest = { name: 'current-review', capabilities: { skills: true } };\n" + ); + const activeRootPath = getProjectExtensionActiveRootPath(tempDir, projectPath); + await fs.mkdir(path.join(activeRootPath, "stale-review"), { recursive: true }); + await fs.writeFile( + path.join(activeRootPath, "stale-review", "extension.ts"), + "export const manifest = { name: 'stale-review', capabilities: { skills: true } };\n" + ); + const cfg = config.loadConfigOrDefault(); + cfg.projects.set(projectPath, { workspaces: [], trusted: true }); + await config.saveConfig(cfg); + const projectState = new ProjectExtensionStateService(path.join(tempDir, "project-state")); + await projectState.setRootTrusted(projectPath, true); + const provider = createExtensionRootsProvider({ config, projectState }); + + const roots = await provider(); + + expect(roots).toContainEqual({ + rootId: `project-local:${projectPath}`, + kind: "project-local", + path: repoRootPath, + trusted: true, + }); + }); + + test("enumerates fetched global active view alongside local authoring root", async () => { + const config = new Config(tempDir); + await fs.mkdir(getFetchedGlobalExtensionRootPath(config), { recursive: true }); + const provider = createExtensionRootsProvider({ + config, + projectState: new ProjectExtensionStateService(path.join(tempDir, "project-state")), + }); + + const roots = await provider(); + + expect(roots).toContainEqual({ + rootId: "user-global", + kind: "user-global", + path: path.join(tempDir, "extensions", "local"), + }); + expect(roots).toContainEqual({ + rootId: "user-global-fetched", + kind: "user-global", + path: path.join(tempDir, "extensions", "global"), + }); + }); +}); diff --git a/src/node/extensions/extensionRoots.ts b/src/node/extensions/extensionRoots.ts new file mode 100644 index 0000000000..f099c497d7 --- /dev/null +++ b/src/node/extensions/extensionRoots.ts @@ -0,0 +1,184 @@ +import * as fs from "fs/promises"; +import * as path from "path"; + +import type { Config } from "@/node/config"; +import { detectBundledExtensionRoot } from "@/node/extensions/bundledExtensionRootResolver"; +import type { ExtensionRootDescriptor } from "@/node/extensions/extensionDiscoveryService"; +import { + areProjectExtensionActiveSourcesCurrent, + getProjectExtensionActiveRootPath, + syncProjectExtensionLockSources, + type SyncProjectExtensionLockSourcesInput, +} from "@/node/extensions/projectExtensionSourceSync"; +import { log } from "@/node/services/log"; +import type { ProjectExtensionStateService } from "@/node/extensions/projectExtensionStateService"; + +export const USER_GLOBAL_EXTENSION_ROOT_ID = "user-global"; +export const BUNDLED_EXTENSION_ROOT_ID = "bundled"; +const PROJECT_LOCAL_ROOT_PREFIX = "project-local:"; + +export function projectLocalRootId(projectPath: string): string { + return `${PROJECT_LOCAL_ROOT_PREFIX}${projectPath}`; +} + +export function projectPathFromProjectLocalRootId(rootId: string): string | null { + if (!rootId.startsWith(PROJECT_LOCAL_ROOT_PREFIX)) return null; + return rootId.slice(PROJECT_LOCAL_ROOT_PREFIX.length); +} + +export function getFetchedGlobalExtensionRootPath(config: Config): string { + return path.join(config.rootDir, "extensions", "global"); +} + +export function getUserGlobalExtensionRootPath(config: Config): string { + return path.join(config.rootDir, "extensions", "local"); +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function pathIsDirectory(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + return stat.isDirectory(); + } catch { + return false; + } +} + +interface FileFingerprint { + size: number; + mtimeMs: number; +} + +async function fileFingerprint(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + if (!stat.isFile()) return null; + return { size: stat.size, mtimeMs: stat.mtimeMs }; + } catch { + return null; + } +} + +function sameFingerprint( + a: FileFingerprint | null | undefined, + b: FileFingerprint | null +): boolean { + return a != null && b !== null && a.size === b.size && a.mtimeMs === b.mtimeMs; +} + +interface ExtensionRootsProviderOptions { + config: Config; + projectState: ProjectExtensionStateService; + syncProjectLocks?: (input: SyncProjectExtensionLockSourcesInput) => Promise; +} + +export function createExtensionRootsProvider(options: ExtensionRootsProviderOptions) { + const resolvedBundledRoot = (() => { + try { + return detectBundledExtensionRoot(); + } catch { + return null; + } + })(); + + const syncedLockFingerprints = new Map(); + + return async (): Promise => { + const roots: ExtensionRootDescriptor[] = []; + if (resolvedBundledRoot) { + roots.push({ + rootId: BUNDLED_EXTENSION_ROOT_ID, + kind: "bundled", + path: resolvedBundledRoot.path, + }); + } + + roots.push({ + rootId: USER_GLOBAL_EXTENSION_ROOT_ID, + kind: "user-global", + path: getUserGlobalExtensionRootPath(options.config), + }); + + const fetchedGlobalRootPath = getFetchedGlobalExtensionRootPath(options.config); + if (await pathIsDirectory(fetchedGlobalRootPath)) { + roots.push({ + rootId: "user-global-fetched", + kind: "user-global", + path: fetchedGlobalRootPath, + }); + } + + const cfg = options.config.loadConfigOrDefault(); + for (const [projectPath, projectConfig] of cfg.projects) { + const repoRootPath = path.join(projectPath, ".mux", "extensions"); + const activeRootPath = getProjectExtensionActiveRootPath(options.config.rootDir, projectPath); + const lockPath = path.join(projectPath, ".mux", "extensions.lock.json"); + const activeRootExistsBeforeSync = await pathIsDirectory(activeRootPath); + const repoRootExists = await pathIsDirectory(repoRootPath); + const lockExists = await pathExists(lockPath); + const stateExists = await pathExists(options.projectState.filePathFor(projectPath)); + const lockBackedActiveRootExists = lockExists && activeRootExistsBeforeSync; + if (!lockBackedActiveRootExists && !repoRootExists && !lockExists && !stateExists) continue; + + const projectState = await options.projectState.load(projectPath); + const trusted = projectConfig.trusted === true && projectState.state.rootTrusted; + let projectLockSyncFailed = false; + if (trusted && lockExists) { + try { + const lockFingerprint = await fileFingerprint(lockPath); + const previousFingerprint = syncedLockFingerprints.get(projectPath); + const activeSourcesCurrent = + activeRootExistsBeforeSync && + (await areProjectExtensionActiveSourcesCurrent({ + projectPath, + muxRootDir: options.config.rootDir, + })); + const shouldSyncProjectLock = + !activeSourcesCurrent || !sameFingerprint(previousFingerprint, lockFingerprint); + if (shouldSyncProjectLock) { + await (options.syncProjectLocks ?? syncProjectExtensionLockSources)({ + projectPath, + muxRootDir: options.config.rootDir, + trusted, + }); + if (lockFingerprint) syncedLockFingerprints.set(projectPath, lockFingerprint); + } + } catch (error) { + syncedLockFingerprints.delete(projectPath); + projectLockSyncFailed = true; + log.warn("[extensions] Failed to sync project Extension Source Lock", { + projectPath, + error, + }); + } + } + const activeRootExists = + lockExists && !projectLockSyncFailed && (await pathIsDirectory(activeRootPath)); + const inspectionPath = projectLockSyncFailed + ? path.dirname(lockPath) + : activeRootExists + ? activeRootPath + : repoRootExists + ? repoRootPath + : lockExists + ? path.dirname(lockPath) + : repoRootPath; + roots.push({ + rootId: projectLocalRootId(projectPath), + kind: "project-local", + path: inspectionPath, + trusted: trusted && !projectLockSyncFailed, + }); + } + + return roots; + }; +} diff --git a/src/node/extensions/gitExtensionSourceInstaller.test.ts b/src/node/extensions/gitExtensionSourceInstaller.test.ts new file mode 100644 index 0000000000..ff09dadb8b --- /dev/null +++ b/src/node/extensions/gitExtensionSourceInstaller.test.ts @@ -0,0 +1,261 @@ +import { execFile } from "child_process"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { promisify } from "util"; + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { GlobalExtensionSourceLockSchema } from "@/common/extensions/sourceLocks"; + +import { installGitExtensionSource, normalizeGitUrl } from "./gitExtensionSourceInstaller"; + +const execFileAsync = promisify(execFile); + +let tempDir: string; + +async function git(args: readonly string[], cwd: string): Promise { + const { stdout } = await execFileAsync( + "git", + ["-c", "commit.gpgsign=false", "-c", "gpg.format=", "-c", "user.signingkey=", ...args], + { cwd } + ); + return stdout.trim(); +} + +async function writeFile(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); +} + +beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-git-extension-install-")); +}); + +afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); +}); + +describe("normalizeGitUrl", () => { + test("does not append .git twice for GitHub shorthand", () => { + expect(normalizeGitUrl("github.com/acme/review")).toBe("https://github.com/acme/review.git"); + expect(normalizeGitUrl("github.com/acme/review.git")).toBe( + "https://github.com/acme/review.git" + ); + }); +}); + +describe("installGitExtensionSource", () => { + test("installs a git subdir source into store/global views and writes the global lock", async () => { + const repoPath = path.join(tempDir, "repo"); + const muxRoot = path.join(tempDir, "mux-home"); + await fs.mkdir(repoPath, { recursive: true }); + await git(["init", "--initial-branch", "main"], repoPath); + await git(["config", "user.email", "mux@example.com"], repoPath); + await git(["config", "user.name", "Mux Test"], repoPath); + await writeFile( + path.join(repoPath, "extensions", "review", "extension.ts"), + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + await writeFile( + path.join(repoPath, "extensions", "review", "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review helper\n---\n# Review\n" + ); + await git(["add", "."], repoPath); + await git(["commit", "-m", "add extension"], repoPath); + const resolvedSha = await git(["rev-parse", "HEAD"], repoPath); + + const result = await installGitExtensionSource({ + coordinate: `${repoPath}//extensions/review@main`, + muxRootDir: muxRoot, + now: 123, + }); + + expect(result.extensionName).toBe("acme-review"); + expect(result.resolvedSha).toBe(resolvedSha); + expect(result.contentHash.startsWith("sha256:")).toBe(true); + const [storeEntrypoint, activeEntrypoint] = await Promise.all([ + fs.stat(path.join(result.storePath, "extension.ts")), + fs.stat(path.join(result.activePath, "extension.ts")), + ]); + expect(storeEntrypoint.isFile()).toBe(true); + expect(activeEntrypoint.isFile()).toBe(true); + + const lock = GlobalExtensionSourceLockSchema.parse( + JSON.parse(await fs.readFile(path.join(muxRoot, "extensions", "lock.json"), "utf-8")) + ); + expect(lock).toEqual({ + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "git", + url: repoPath, + ref: "main", + resolvedSha, + subdir: "extensions/review", + contentHash: result.contentHash, + }, + }, + }, + }); + }); + + test("repairs a corrupted content-addressed store entry before materializing the active view", async () => { + const repoPath = path.join(tempDir, "repo"); + const muxRoot = path.join(tempDir, "mux-home"); + await fs.mkdir(repoPath, { recursive: true }); + await git(["init", "--initial-branch", "main"], repoPath); + await git(["config", "user.email", "mux@example.com"], repoPath); + await git(["config", "user.name", "Mux Test"], repoPath); + await writeFile( + path.join(repoPath, "extension.ts"), + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + await writeFile( + path.join(repoPath, "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review helper\n---\n# Review\n" + ); + await git(["add", "."], repoPath); + await git(["commit", "-m", "add extension"], repoPath); + + const first = await installGitExtensionSource({ + coordinate: `${repoPath}@main`, + muxRootDir: muxRoot, + now: 123, + }); + await fs.writeFile( + path.join(first.storePath, "extension.ts"), + "export const manifest = { name: 'corrupted-store', capabilities: { skills: true } };\n" + ); + + const second = await installGitExtensionSource({ + coordinate: `${repoPath}@main`, + muxRootDir: muxRoot, + now: 123, + }); + + expect(second.storePath).toBe(first.storePath); + const [storedEntrypoint, activeEntrypoint] = await Promise.all([ + fs.readFile(path.join(second.storePath, "extension.ts"), "utf-8"), + fs.readFile(path.join(second.activePath, "extension.ts"), "utf-8"), + ]); + expect(storedEntrypoint).toContain("ctx.skills.register"); + expect(activeEntrypoint).toBe(storedEntrypoint); + }); + + test("rejects git sources whose extension.ts is a symlink", async () => { + const repoPath = path.join(tempDir, "repo"); + const outsidePath = path.join(tempDir, "outside-extension.ts"); + const muxRoot = path.join(tempDir, "mux-home"); + await fs.mkdir(repoPath, { recursive: true }); + await git(["init", "--initial-branch", "main"], repoPath); + await git(["config", "user.email", "mux@example.com"], repoPath); + await git(["config", "user.name", "Mux Test"], repoPath); + await writeFile( + outsidePath, + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + ` + ); + await fs.symlink(outsidePath, path.join(repoPath, "extension.ts")); + await git(["add", "."], repoPath); + await git(["commit", "-m", "add symlinked extension"], repoPath); + + let error: unknown; + try { + await installGitExtensionSource({ + coordinate: `${repoPath}@main`, + muxRootDir: muxRoot, + now: 123, + }); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(Error); + expect(error instanceof Error ? error.message : "").toContain("extension.ts"); + let activeViewExists = true; + try { + await fs.access(path.join(muxRoot, "extensions", "global", "acme-review")); + } catch { + activeViewExists = false; + } + expect(activeViewExists).toBe(false); + }); + + test("clones dash-prefixed relative repository coordinates with an option delimiter", async () => { + const repoName = `-mux-extension-repo-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const repoPath = path.join(os.tmpdir(), repoName); + const muxRoot = path.join(tempDir, "mux-home"); + try { + await fs.mkdir(repoPath, { recursive: true }); + await git(["init", "--initial-branch", "main"], repoPath); + await git(["config", "commit.gpgsign", "false"], repoPath); + await git(["config", "user.email", "mux@example.com"], repoPath); + await git(["config", "user.name", "Mux Test"], repoPath); + await writeFile( + path.join(repoPath, "extension.ts"), + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + await writeFile( + path.join(repoPath, "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review helper\n---\n# Review\n" + ); + await git(["add", "."], repoPath); + await git(["commit", "-m", "add extension"], repoPath); + + const result = await installGitExtensionSource({ + coordinate: `${repoName}@main`, + muxRootDir: muxRoot, + now: 123, + }); + + expect(result.extensionName).toBe("acme-review"); + } finally { + await fs.rm(repoPath, { recursive: true, force: true }); + } + }); + + test("rejects Windows absolute git subdirs before cloning", async () => { + let error: unknown; + try { + await installGitExtensionSource({ + coordinate: "https://example.invalid/acme/review.git//C:\\Users\\alice\\review@main", + muxRootDir: path.join(tempDir, "mux-home"), + }); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(Error); + expect(error instanceof Error ? error.message : "").toContain("contained relative path"); + }); +}); diff --git a/src/node/extensions/gitExtensionSourceInstaller.ts b/src/node/extensions/gitExtensionSourceInstaller.ts new file mode 100644 index 0000000000..fa9831614e --- /dev/null +++ b/src/node/extensions/gitExtensionSourceInstaller.ts @@ -0,0 +1,269 @@ +import { execFile } from "child_process"; +import { createHash } from "crypto"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { promisify } from "util"; + +import { + EXTENSION_SOURCE_LOCK_SCHEMA_VERSION, + GlobalExtensionSourceLockSchema, + type GlobalExtensionSourceLock, +} from "@/common/extensions/sourceLocks"; +import { validateStaticManifest } from "@/common/extensions/manifestValidator"; +import { extractStaticManifestFromFile } from "@/node/extensions/staticManifestExtractor"; + +const execFileAsync = promisify(execFile); + +const WINDOWS_ABSOLUTE_PATH_REGEX = /^[A-Za-z]:[/\\]/; + +export interface InstallGitExtensionSourceInput { + coordinate: string; + muxRootDir: string; + activeRootDir?: string; + writeGlobalLock?: boolean; + expectedExtensionName?: string; + expectedContentHash?: string; + now?: number; +} + +export interface InstallGitExtensionSourceResult { + extensionName: string; + resolvedSha: string; + contentHash: string; + storePath: string; + activePath: string; +} + +interface ParsedGitCoordinate { + url: string; + ref: string; + subdir?: string; +} + +export async function installGitExtensionSource( + input: InstallGitExtensionSourceInput +): Promise { + const parsed = parseGitCoordinate(input.coordinate); + const cloneDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-extension-git-")); + try { + await git(["clone", "--quiet", "--", parsed.url, cloneDir], os.tmpdir()); + await git(["checkout", "--quiet", parsed.ref], cloneDir); + const resolvedSha = await git(["rev-parse", "HEAD"], cloneDir); + const sourcePath = parsed.subdir ? path.join(cloneDir, parsed.subdir) : cloneDir; + await assertContainedDirectory(cloneDir, sourcePath); + + const entrypointPath = path.join(sourcePath, "extension.ts"); + const entrypointRealPath = await assertContainedRegularFile( + sourcePath, + entrypointPath, + "extension.ts" + ); + const extraction = await extractStaticManifestFromFile( + entrypointRealPath, + input.now ?? Date.now() + ); + if (!extraction.ok) { + throw new Error(extraction.diagnostics.map((diagnostic) => diagnostic.message).join("\n")); + } + const rawName = extraction.manifest.name; + if (typeof rawName !== "string") { + throw new Error("Static Manifest must include a string manifest.name."); + } + const validation = validateStaticManifest({ + rawManifest: extraction.manifest, + extensionName: rawName, + rootKind: "user-global", + now: input.now ?? Date.now(), + }); + if (!validation.ok) { + throw new Error(validation.diagnostics.map((diagnostic) => diagnostic.message).join("\n")); + } + if (input.expectedExtensionName && validation.manifest.id !== input.expectedExtensionName) { + throw new Error( + `Extension Source Lock expected ${input.expectedExtensionName}, but manifest.name is ${validation.manifest.id}.` + ); + } + + const contentHash = await hashDirectory(sourcePath); + if (input.expectedContentHash && contentHash !== input.expectedContentHash) { + throw new Error( + `Extension Source Lock expected ${input.expectedContentHash}, but fetched content hashed to ${contentHash}.` + ); + } + const extensionsRoot = path.join(input.muxRootDir, "extensions"); + const storePath = path.join(extensionsRoot, "store", contentHash.replace(/:/gu, "-")); + const activeRootDir = input.activeRootDir ?? path.join(extensionsRoot, "global"); + const activePath = path.join(activeRootDir, validation.manifest.id); + await materializeStoreDirectory({ sourcePath, storePath, contentHash }); + await fs.rm(activePath, { recursive: true, force: true }); + await copyDirectory(storePath, activePath); + + if (input.writeGlobalLock !== false) { + const lockPath = path.join(extensionsRoot, "lock.json"); + const lock = await readGlobalLock(lockPath); + lock.extensions[validation.manifest.id] = { + source: { + type: "git", + url: parsed.url, + ref: parsed.ref, + resolvedSha, + ...(parsed.subdir ? { subdir: parsed.subdir } : {}), + contentHash, + }, + }; + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + await fs.writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`); + } + + return { + extensionName: validation.manifest.id, + resolvedSha, + contentHash, + storePath, + activePath, + }; + } finally { + await fs.rm(cloneDir, { recursive: true, force: true }); + } +} + +function parseGitCoordinate(coordinate: string): ParsedGitCoordinate { + const refIndex = coordinate.lastIndexOf("@"); + if (refIndex <= 0 || refIndex === coordinate.length - 1) { + throw new Error("Git extension coordinates must include @ref."); + } + const source = coordinate.slice(0, refIndex); + const ref = coordinate.slice(refIndex + 1); + const subdirMarkerStart = source.includes("://") ? source.indexOf("://") + 3 : 0; + const subdirIndex = source.indexOf("//", subdirMarkerStart); + const url = subdirIndex === -1 ? source : source.slice(0, subdirIndex); + const subdir = subdirIndex === -1 ? undefined : source.slice(subdirIndex + 2); + if (!url) throw new Error("Git extension coordinates must include a git URL or path."); + if ( + subdir !== undefined && + (!subdir || + path.isAbsolute(subdir) || + WINDOWS_ABSOLUTE_PATH_REGEX.test(subdir) || + subdir.split(/[\\/]/u).includes("..")) + ) { + throw new Error("Git extension coordinate subdir must be a contained relative path."); + } + return { url: normalizeGitUrl(url), ref, ...(subdir ? { subdir } : {}) }; +} + +export function normalizeGitUrl(url: string): string { + if (!url.startsWith("github.com/")) return url; + return url.endsWith(".git") ? `https://${url}` : `https://${url}.git`; +} + +async function git(args: readonly string[], cwd: string): Promise { + const { stdout } = await execFileAsync("git", [...args], { cwd, maxBuffer: 1024 * 1024 }); + return stdout.trim(); +} + +async function assertContainedDirectory(rootPath: string, candidatePath: string): Promise { + const [rootRealPath, candidateRealPath] = await Promise.all([ + fs.realpath(rootPath), + fs.realpath(candidatePath), + ]); + const relative = path.relative(rootRealPath, candidateRealPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Git extension subdir resolved outside the cloned source."); + } + const stat = await fs.stat(candidateRealPath); + if (!stat.isDirectory()) throw new Error("Git extension source path is not a directory."); +} + +async function assertContainedRegularFile( + rootPath: string, + candidatePath: string, + label: string +): Promise { + const [rootRealPath, linkStat] = await Promise.all([ + fs.realpath(rootPath), + fs.lstat(candidatePath), + ]); + if (!linkStat.isFile()) { + throw new Error(`${label} must be a regular file inside the Extension source.`); + } + + const candidateRealPath = await fs.realpath(candidatePath); + const relative = path.relative(rootRealPath, candidateRealPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`${label} resolved outside the Extension source.`); + } + return candidateRealPath; +} + +async function hashDirectory(rootPath: string): Promise { + const hash = createHash("sha256"); + for (const filePath of await listFiles(rootPath)) { + const relativePath = path.relative(rootPath, filePath).split(path.sep).join("/"); + hash.update(relativePath); + hash.update("\0"); + hash.update(await fs.readFile(filePath)); + hash.update("\0"); + } + return `sha256:${hash.digest("base64url")}`; +} + +async function listFiles(rootPath: string): Promise { + const entries = await fs.readdir(rootPath, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + if (entry.name === ".git") continue; + const entryPath = path.join(rootPath, entry.name); + if (entry.isDirectory()) { + files.push(...(await listFiles(entryPath))); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + return files.sort(); +} + +async function materializeStoreDirectory(input: { + sourcePath: string; + storePath: string; + contentHash: string; +}): Promise { + let shouldCopy = false; + try { + const existingHash = await hashDirectory(input.storePath); + shouldCopy = existingHash !== input.contentHash; + } catch { + shouldCopy = true; + } + + if (!shouldCopy) return; + await fs.rm(input.storePath, { recursive: true, force: true }); + await copyDirectory(input.sourcePath, input.storePath); +} + +async function copyDirectory(sourcePath: string, destinationPath: string): Promise { + await fs.mkdir(destinationPath, { recursive: true }); + const entries = await fs.readdir(sourcePath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === ".git") continue; + const sourceEntry = path.join(sourcePath, entry.name); + const destinationEntry = path.join(destinationPath, entry.name); + if (entry.isDirectory()) { + await copyDirectory(sourceEntry, destinationEntry); + } else if (entry.isFile()) { + await fs.copyFile(sourceEntry, destinationEntry); + } + } +} + +async function readGlobalLock(lockPath: string): Promise { + try { + const content = await fs.readFile(lockPath, "utf-8"); + return GlobalExtensionSourceLockSchema.parse(JSON.parse(content)); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return { schemaVersion: EXTENSION_SOURCE_LOCK_SCHEMA_VERSION, extensions: {} }; + } + throw error; + } +} diff --git a/src/node/extensions/globalExtensionStateService.test.ts b/src/node/extensions/globalExtensionStateService.test.ts new file mode 100644 index 0000000000..f18345d15f --- /dev/null +++ b/src/node/extensions/globalExtensionStateService.test.ts @@ -0,0 +1,206 @@ +import * as fs from "fs"; +import { mkdir, readFile, readdir, writeFile, access } from "fs/promises"; +import { constants } from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Config } from "@/node/config"; +import { GLOBAL_EXTENSION_STATE_SCHEMA_VERSION } from "@/common/extensions/globalExtensionState"; +import { GlobalExtensionStateService } from "./globalExtensionStateService"; + +async function pathExists(p: string): Promise { + try { + await access(p, constants.F_OK); + return true; + } catch { + return false; + } +} + +describe("GlobalExtensionStateService", () => { + let tempDir: string; + let config: Config; + let service: GlobalExtensionStateService; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-global-ext-state-")); + config = new Config(tempDir); + service = new GlobalExtensionStateService(config); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("load() with no config file returns empty state and no diagnostics", () => { + const result = service.load(); + expect(result.state).toEqual({ + schemaVersion: GLOBAL_EXTENSION_STATE_SCHEMA_VERSION, + extensions: {}, + }); + expect(result.diagnostics).toEqual([]); + expect(result.schemaVersionMismatch).toBe(false); + }); + + test("setEnabled round-trips through disk", async () => { + await service.setEnabled("publisher.alpha", false); + const result = service.load(); + expect(result.state.extensions["publisher.alpha"]).toEqual({ enabled: false }); + expect(result.diagnostics).toEqual([]); + }); + + test("setApproval persists approval permissions hash", async () => { + const approval = { + grantedPermissions: ["network", "skill.register"], + requestedPermissionsHash: "deadbeef", + }; + await service.setApproval("publisher.alpha", approval); + const result = service.load(); + expect(result.state.extensions["publisher.alpha"]).toEqual({ approval }); + }); + + test("removeApproval drops approval but preserves enablement", async () => { + await service.setEnabled("publisher.alpha", false); + await service.setApproval("publisher.alpha", { + grantedPermissions: [], + requestedPermissionsHash: "abc", + }); + await service.removeApproval("publisher.alpha"); + const result = service.load(); + expect(result.state.extensions["publisher.alpha"]).toEqual({ enabled: false }); + }); + + test("forget removes the entire record", async () => { + await service.setEnabled("publisher.alpha", true); + await service.forget("publisher.alpha"); + const result = service.load(); + expect(result.state.extensions["publisher.alpha"]).toBeUndefined(); + }); + + test("atomic temp-rename preserves unrelated config fields", async () => { + await config.editConfig((cfg) => { + cfg.defaultProjectDir = "/tmp/projects"; + cfg.viewedSplashScreens = ["intro"]; + return cfg; + }); + await service.setEnabled("publisher.alpha", true); + const reloaded = config.loadConfigOrDefault(); + expect(reloaded.defaultProjectDir).toBe("/tmp/projects"); + expect(reloaded.viewedSplashScreens).toEqual(["intro"]); + expect(service.load().state.extensions["publisher.alpha"]).toEqual({ enabled: true }); + }); + + test("write produces no .tmp leftovers (atomic rename)", async () => { + await service.setEnabled("publisher.alpha", true); + const entries = await readdir(tempDir); + const leftovers = entries.filter((f) => f.startsWith("config.json") && f !== "config.json"); + expect(leftovers).toEqual([]); + }); + + test("invariant: empty/missing state never implies enabled for non-bundled", () => { + expect(service.isEnabled("publisher.unknown", { isBundled: false })).toBe(false); + }); + + test("invariant: empty state defaults bundled extensions to enabled: true", () => { + expect(service.isEnabled("mux.platformdemo", { isBundled: true })).toBe(true); + }); + + test("invariant: empty state never implies approvals", () => { + const grant = service.load().state.extensions["publisher.alpha"]?.approval; + expect(grant).toBeUndefined(); + }); + + test("malformed bundled state restores default enabled: true for bundled", async () => { + await mkdir(tempDir, { recursive: true }); + await writeFile( + path.join(tempDir, "config.json"), + JSON.stringify({ + extensions: { + schemaVersion: 1, + extensions: { "mux.platformdemo": { enabled: "broken" } }, + }, + }), + "utf-8" + ); + const fresh = new GlobalExtensionStateService(new Config(tempDir)); + const { state, diagnostics } = fresh.load(); + expect(state.extensions["mux.platformdemo"]).toBeUndefined(); + expect(diagnostics.some((d) => d.code === "extension.state.record.invalid")).toBe(true); + expect(fresh.isEnabled("mux.platformdemo", { isBundled: true })).toBe(true); + }); + + test("recovery: missing/malformed file → empty state, file is not deleted", async () => { + await mkdir(tempDir, { recursive: true }); + const cfgPath = path.join(tempDir, "config.json"); + await writeFile(cfgPath, "{ this is not valid json", "utf-8"); + const fresh = new GlobalExtensionStateService(new Config(tempDir)); + const { state } = fresh.load(); + expect(state.extensions).toEqual({}); + expect(await pathExists(cfgPath)).toBe(true); + }); + + test("recovery: unknown future schemaVersion → empty runtime state, file preserved on disk", async () => { + await mkdir(tempDir, { recursive: true }); + const cfgPath = path.join(tempDir, "config.json"); + const futureBlock = { + schemaVersion: 99, + extensions: { "publisher.future": { enabled: true, somethingNew: 1 } }, + }; + await writeFile(cfgPath, JSON.stringify({ extensions: futureBlock }), "utf-8"); + + const fresh = new GlobalExtensionStateService(new Config(tempDir)); + const result = fresh.load(); + expect(result.schemaVersionMismatch).toBe(true); + expect(result.state.extensions).toEqual({}); + expect( + result.diagnostics.some((d) => d.code === "extension.state.schema_version.unsupported") + ).toBe(true); + + const onDisk = JSON.parse(await readFile(cfgPath, "utf-8")) as { extensions?: unknown }; + expect(onDisk.extensions).toEqual(futureBlock); + }); + + test("recovery: per-record validation failure drops only the bad record with info diagnostic", async () => { + await mkdir(tempDir, { recursive: true }); + await writeFile( + path.join(tempDir, "config.json"), + JSON.stringify({ + extensions: { + schemaVersion: 1, + extensions: { + "publisher.good": { enabled: true }, + "publisher.bad": { enabled: 1234 }, + }, + }, + }), + "utf-8" + ); + const fresh = new GlobalExtensionStateService(new Config(tempDir)); + const { state, diagnostics } = fresh.load(); + expect(state.extensions).toEqual({ "publisher.good": { enabled: true } }); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: "extension.state.record.invalid", + severity: "info", + extensionId: "publisher.bad", + }); + }); + + test("schemaVersion mismatch + subsequent setEnabled rewrites block to current schemaVersion", async () => { + await mkdir(tempDir, { recursive: true }); + const cfgPath = path.join(tempDir, "config.json"); + await writeFile( + cfgPath, + JSON.stringify({ + extensions: { schemaVersion: 99, extensions: { "publisher.future": { enabled: true } } }, + }), + "utf-8" + ); + const fresh = new GlobalExtensionStateService(new Config(tempDir)); + expect(fresh.load().schemaVersionMismatch).toBe(true); + await fresh.setEnabled("publisher.alpha", true); + const after = fresh.load(); + expect(after.schemaVersionMismatch).toBe(false); + expect(after.state.extensions).toEqual({ "publisher.alpha": { enabled: true } }); + }); +}); diff --git a/src/node/extensions/globalExtensionStateService.ts b/src/node/extensions/globalExtensionStateService.ts new file mode 100644 index 0000000000..0d0c873497 --- /dev/null +++ b/src/node/extensions/globalExtensionStateService.ts @@ -0,0 +1,65 @@ +import type { Config } from "@/node/config"; +import { + GLOBAL_EXTENSION_STATE_SCHEMA_VERSION, + normalizeGlobalExtensionState, + type ExtensionStateRecord, + type ApprovalRecord, + type NormalizeGlobalExtensionStateResult, +} from "@/common/extensions/globalExtensionState"; + +// Persists the extensions block of ~/.mux/config.json via Config's atomic +// write-temp-then-rename. Validation and self-healing live in +// normalizeGlobalExtensionState (pure module). +// +// Invariants: +// - Empty/missing/malformed state never implies trust or approvals. +// - Bundled Extensions default to enabled=true when no record exists. +// - Unknown future schemaVersion values are preserved on disk on load; only +// an explicit mutation rewrites the block to the current schemaVersion. +export class GlobalExtensionStateService { + constructor(private readonly config: Config) {} + + load(): NormalizeGlobalExtensionStateResult { + const cfg = this.config.loadConfigOrDefault(); + return normalizeGlobalExtensionState(cfg.extensions); + } + + isEnabled(extensionId: string, { isBundled }: { isBundled: boolean }): boolean { + const { state } = this.load(); + return state.extensions[extensionId]?.enabled ?? isBundled; + } + + async setEnabled(extensionId: string, enabled: boolean): Promise { + await this.mutateRecord(extensionId, (record) => ({ ...record, enabled })); + } + + async setApproval(extensionId: string, approval: ApprovalRecord): Promise { + await this.mutateRecord(extensionId, (record) => ({ ...record, approval })); + } + + async removeApproval(extensionId: string): Promise { + await this.mutateRecord(extensionId, ({ enabled }) => ({ enabled })); + } + + async forget(extensionId: string): Promise { + await this.mutateRecord(extensionId, () => null); + } + + private async mutateRecord( + extensionId: string, + fn: (current: ExtensionStateRecord) => ExtensionStateRecord | null + ): Promise { + await this.config.editConfig((cfg) => { + const { state } = normalizeGlobalExtensionState(cfg.extensions); + const next = fn(state.extensions[extensionId] ?? {}); + const extensions = { ...state.extensions }; + if (next == null || (next.enabled === undefined && next.approval === undefined)) { + delete extensions[extensionId]; + } else { + extensions[extensionId] = next; + } + cfg.extensions = { schemaVersion: GLOBAL_EXTENSION_STATE_SCHEMA_VERSION, extensions }; + return cfg; + }); + } +} diff --git a/src/node/extensions/projectExtensionSourceSync.test.ts b/src/node/extensions/projectExtensionSourceSync.test.ts new file mode 100644 index 0000000000..84582cad5a --- /dev/null +++ b/src/node/extensions/projectExtensionSourceSync.test.ts @@ -0,0 +1,517 @@ +import { execFile } from "child_process"; +import { createHash } from "crypto"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { promisify } from "util"; + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; + +import { ProjectExtensionSourceLockSchema } from "@/common/extensions/sourceLocks"; +import { installGitExtensionSource } from "./gitExtensionSourceInstaller"; +import { + getProjectExtensionActiveRootPath, + syncProjectExtensionLockSources, +} from "./projectExtensionSourceSync"; + +const execFileAsync = promisify(execFile); + +let tempDir: string; + +async function git(args: readonly string[], cwd: string): Promise { + const { stdout } = await execFileAsync("git", [...args], { cwd }); + return stdout.trim(); +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function writeFile(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); +} + +async function createGitExtensionRepo(): Promise<{ + repoPath: string; + resolvedSha: string; + contentHash: string; +}> { + const repoPath = path.join(tempDir, "repo"); + await fs.mkdir(repoPath, { recursive: true }); + await git(["init", "--initial-branch", "main"], repoPath); + await git(["config", "commit.gpgsign", "false"], repoPath); + await git(["config", "user.email", "mux@example.com"], repoPath); + await git(["config", "user.name", "Mux Test"], repoPath); + await writeFile( + path.join(repoPath, "extensions", "review", "extension.ts"), + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + await writeFile( + path.join(repoPath, "extensions", "review", "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review helper\n---\n# Review\n" + ); + await git(["add", "."], repoPath); + await git(["commit", "-m", "add extension"], repoPath); + const resolvedSha = await git(["rev-parse", "HEAD"], repoPath); + const installed = await installGitExtensionSource({ + coordinate: `${repoPath}//extensions/review@${resolvedSha}`, + muxRootDir: path.join(tempDir, "bootstrap-mux"), + now: 123, + }); + return { repoPath, resolvedSha, contentHash: installed.contentHash }; +} + +async function hashDirectory(rootPath: string): Promise { + const hash = createHash("sha256"); + for (const filePath of await listFiles(rootPath)) { + const relativePath = path.relative(rootPath, filePath).split(path.sep).join("/"); + hash.update(relativePath); + hash.update("\0"); + hash.update(await fs.readFile(filePath)); + hash.update("\0"); + } + return `sha256:${hash.digest("base64url")}`; +} + +async function listFiles(rootPath: string): Promise { + const entries = await fs.readdir(rootPath, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const entryPath = path.join(rootPath, entry.name); + if (entry.isDirectory()) { + files.push(...(await listFiles(entryPath))); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + return files.sort(); +} + +beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-project-extension-sync-")); +}); + +afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); +}); + +describe("syncProjectExtensionLockSources", () => { + test("does not parse or materialize project source locks before trust", async () => { + const muxRootDir = path.join(tempDir, "mux-home"); + const projectPath = path.join(tempDir, "project"); + await writeFile(path.join(projectPath, ".mux", "extensions.lock.json"), "not valid json"); + + const result = await syncProjectExtensionLockSources({ + projectPath, + muxRootDir, + trusted: false, + now: 123, + }); + + expect(result).toEqual({ synced: [] }); + expect(await pathExists(path.join(muxRootDir, "extensions", "store"))).toBe(false); + expect(await pathExists(getProjectExtensionActiveRootPath(muxRootDir, projectPath))).toBe( + false + ); + }); + + test("syncs trusted project git locks into the project active view", async () => { + const muxRootDir = path.join(tempDir, "mux-home"); + const projectPath = path.join(tempDir, "project"); + const { repoPath, resolvedSha, contentHash } = await createGitExtensionRepo(); + const lock = ProjectExtensionSourceLockSchema.parse({ + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "git", + url: repoPath, + ref: "main", + resolvedSha, + subdir: "extensions/review", + contentHash, + }, + }, + }, + }); + await writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + `${JSON.stringify(lock, null, 2)}\n` + ); + + const result = await syncProjectExtensionLockSources({ + projectPath, + muxRootDir, + trusted: true, + now: 123, + }); + + expect(result.synced).toEqual([ + { + extensionName: "acme-review", + contentHash, + activePath: path.join( + getProjectExtensionActiveRootPath(muxRootDir, projectPath), + "acme-review" + ), + }, + ]); + const entrypointStat = await fs.stat( + path.join( + getProjectExtensionActiveRootPath(muxRootDir, projectPath), + "acme-review", + "extension.ts" + ) + ); + expect(entrypointStat.isFile()).toBe(true); + }); + + test("materializes trusted vendored project locks into the project active view", async () => { + const muxRootDir = path.join(tempDir, "mux-home"); + const projectPath = path.join(tempDir, "project"); + const vendoredPath = path.join(projectPath, ".mux", "extensions", "acme-review"); + await writeFile( + path.join(vendoredPath, "extension.ts"), + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + await writeFile( + path.join(vendoredPath, "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Vendored review helper\n---\n# Review\n" + ); + const contentHash = await hashDirectory(vendoredPath); + const lock = ProjectExtensionSourceLockSchema.parse({ + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "vendored", + path: ".mux/extensions/acme-review", + contentHash, + }, + }, + }, + }); + await writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + `${JSON.stringify(lock, null, 2)}\n` + ); + + const result = await syncProjectExtensionLockSources({ + projectPath, + muxRootDir, + trusted: true, + now: 123, + }); + + const activePath = path.join( + getProjectExtensionActiveRootPath(muxRootDir, projectPath), + "acme-review" + ); + expect(result.synced).toEqual([{ extensionName: "acme-review", contentHash, activePath }]); + const entrypointStat = await fs.stat(path.join(activePath, "extension.ts")); + expect(entrypointStat.isFile()).toBe(true); + }); + + test("does not rewrite unchanged vendored active views", async () => { + const muxRootDir = path.join(tempDir, "mux-home"); + const projectPath = path.join(tempDir, "project"); + const vendoredPath = path.join(projectPath, ".mux", "extensions", "acme-review"); + await writeFile( + path.join(vendoredPath, "extension.ts"), + "export const manifest = { name: 'acme-review', capabilities: { skills: true } };\n" + ); + await writeFile( + path.join(vendoredPath, "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review helper\n---\n# Review\n" + ); + const contentHash = await hashDirectory(vendoredPath); + await writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + `${JSON.stringify( + { + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "vendored", + path: ".mux/extensions/acme-review", + contentHash, + }, + }, + }, + }, + null, + 2 + )}\n` + ); + await syncProjectExtensionLockSources({ projectPath, muxRootDir, trusted: true }); + const activePath = path.join( + getProjectExtensionActiveRootPath(muxRootDir, projectPath), + "acme-review" + ); + const originalRm = fs.rm; + const rmSpy = spyOn(fs, "rm"); + rmSpy.mockImplementation((( + target: Parameters[0], + options?: Parameters[1] + ) => originalRm(target, options)) as typeof fs.rm); + + try { + await syncProjectExtensionLockSources({ projectPath, muxRootDir, trusted: true }); + expect(rmSpy.mock.calls.some(([target]) => String(target) === activePath)).toBe(false); + } finally { + rmSpy.mockRestore(); + } + }); + + test("rebuilds project active roots that are not directories", async () => { + const muxRootDir = path.join(tempDir, "mux-home"); + const projectPath = path.join(tempDir, "project"); + const vendoredPath = path.join(projectPath, ".mux", "extensions", "acme-review"); + await writeFile( + path.join(vendoredPath, "extension.ts"), + "export const manifest = { name: 'acme-review', capabilities: { skills: true } };\n" + ); + const contentHash = await hashDirectory(vendoredPath); + await writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + `${JSON.stringify( + { + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "vendored", + path: ".mux/extensions/acme-review", + contentHash, + }, + }, + }, + }, + null, + 2 + )}\n` + ); + const activeRootPath = getProjectExtensionActiveRootPath(muxRootDir, projectPath); + await writeFile(activeRootPath, "not a directory"); + + await syncProjectExtensionLockSources({ projectPath, muxRootDir, trusted: true }); + + expect((await fs.stat(activeRootPath)).isDirectory()).toBe(true); + expect(await pathExists(path.join(activeRootPath, "acme-review", "extension.ts"))).toBe(true); + }); + + test("rebuilds vendored active views that are not hashable directories", async () => { + const muxRootDir = path.join(tempDir, "mux-home"); + const projectPath = path.join(tempDir, "project"); + const vendoredPath = path.join(projectPath, ".mux", "extensions", "acme-review"); + await writeFile( + path.join(vendoredPath, "extension.ts"), + "export const manifest = { name: 'acme-review', capabilities: { skills: true } };\n" + ); + const contentHash = await hashDirectory(vendoredPath); + await writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + `${JSON.stringify( + { + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "vendored", + path: ".mux/extensions/acme-review", + contentHash, + }, + }, + }, + }, + null, + 2 + )}\n` + ); + const activePath = path.join( + getProjectExtensionActiveRootPath(muxRootDir, projectPath), + "acme-review" + ); + await writeFile(activePath, "not a directory"); + + await syncProjectExtensionLockSources({ projectPath, muxRootDir, trusted: true }); + + expect((await fs.stat(activePath)).isDirectory()).toBe(true); + expect(await pathExists(path.join(activePath, "extension.ts"))).toBe(true); + }); + + test("rejects vendored project locks when the source path changes after containment", async () => { + const muxRootDir = path.join(tempDir, "mux-home"); + const projectPath = path.join(tempDir, "project"); + const vendoredPath = path.join(projectPath, ".mux", "extensions", "acme-review"); + const outsidePath = path.join(tempDir, "outside-extension"); + await writeFile( + path.join(vendoredPath, "extension.ts"), + "export const manifest = { name: 'acme-review', capabilities: { skills: true } };\n" + ); + await writeFile( + path.join(outsidePath, "extension.ts"), + "export const manifest = { name: 'acme-review', capabilities: { skills: true } };\n" + ); + await writeFile(path.join(outsidePath, "secret.txt"), "outside secret"); + const contentHash = await hashDirectory(outsidePath); + const lock = ProjectExtensionSourceLockSchema.parse({ + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "vendored", + path: ".mux/extensions/acme-review", + contentHash, + }, + }, + }, + }); + await writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + `${JSON.stringify(lock, null, 2)}\n` + ); + + const originalStat = fs.stat; + const statSpy = spyOn(fs, "stat"); + let swapped = false; + statSpy.mockImplementation((async (target: Parameters[0]) => { + const result = await originalStat(target); + if (!swapped && String(target) === vendoredPath) { + swapped = true; + await fs.rm(vendoredPath, { recursive: true, force: true }); + await fs.symlink(outsidePath, vendoredPath, "dir"); + } + return result; + }) as unknown as typeof fs.stat); + + try { + let error: unknown; + try { + await syncProjectExtensionLockSources({ projectPath, muxRootDir, trusted: true }); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(Error); + expect(error instanceof Error ? error.message : "").toMatch(/changed|outside/); + const activePath = path.join( + getProjectExtensionActiveRootPath(muxRootDir, projectPath), + "acme-review" + ); + expect(await pathExists(path.join(activePath, "secret.txt"))).toBe(false); + } finally { + statSpy.mockRestore(); + } + }); + + test("rejects vendored project locks when the source path changes before copy", async () => { + const muxRootDir = path.join(tempDir, "mux-home"); + const projectPath = path.join(tempDir, "project"); + const vendoredPath = path.join(projectPath, ".mux", "extensions", "acme-review"); + const outsidePath = path.join(tempDir, "outside-extension"); + await writeFile( + path.join(vendoredPath, "extension.ts"), + "export const manifest = { name: 'acme-review', capabilities: { skills: true } };\n" + ); + await writeFile( + path.join(outsidePath, "extension.ts"), + "export const manifest = { name: 'acme-review', capabilities: { skills: true } };\n" + ); + await writeFile(path.join(outsidePath, "secret.txt"), "outside secret"); + const contentHash = await hashDirectory(vendoredPath); + const lock = ProjectExtensionSourceLockSchema.parse({ + schemaVersion: 1, + extensions: { + "acme-review": { + source: { + type: "vendored", + path: ".mux/extensions/acme-review", + contentHash, + }, + }, + }, + }); + await writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + `${JSON.stringify(lock, null, 2)}\n` + ); + const activePath = path.join( + getProjectExtensionActiveRootPath(muxRootDir, projectPath), + "acme-review" + ); + + const originalRm = fs.rm; + const rmSpy = spyOn(fs, "rm"); + let swapped = false; + rmSpy.mockImplementation((async ( + target: Parameters[0], + options?: Parameters[1] + ) => { + const result = await originalRm(target, options); + if (!swapped && String(target) === activePath) { + swapped = true; + await originalRm(vendoredPath, { recursive: true, force: true }); + await fs.symlink(outsidePath, vendoredPath, "dir"); + } + return result; + }) as unknown as typeof fs.rm); + + try { + let error: unknown; + try { + await syncProjectExtensionLockSources({ projectPath, muxRootDir, trusted: true }); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(Error); + expect(error instanceof Error ? error.message : "").toMatch(/changed|outside/); + expect(await pathExists(path.join(activePath, "secret.txt"))).toBe(false); + } finally { + rmSpy.mockRestore(); + } + }); + + test("removes active view entries that are no longer declared by the project lock", async () => { + const muxRootDir = path.join(tempDir, "mux-home"); + const projectPath = path.join(tempDir, "project"); + const staleActivePath = path.join( + getProjectExtensionActiveRootPath(muxRootDir, projectPath), + "stale-review" + ); + await writeFile( + path.join(staleActivePath, "extension.ts"), + "export const manifest = { name: 'stale-review', capabilities: { skills: true } };\n" + ); + await writeFile( + path.join(projectPath, ".mux", "extensions.lock.json"), + `${JSON.stringify({ schemaVersion: 1, extensions: {} }, null, 2)}\n` + ); + + await syncProjectExtensionLockSources({ projectPath, muxRootDir, trusted: true }); + + expect(await pathExists(staleActivePath)).toBe(false); + }); +}); diff --git a/src/node/extensions/projectExtensionSourceSync.ts b/src/node/extensions/projectExtensionSourceSync.ts new file mode 100644 index 0000000000..c315c5593c --- /dev/null +++ b/src/node/extensions/projectExtensionSourceSync.ts @@ -0,0 +1,237 @@ +import { createHash } from "crypto"; +import * as fs from "fs/promises"; +import * as path from "path"; + +import { ProjectExtensionSourceLockSchema } from "@/common/extensions/sourceLocks"; +import { installGitExtensionSource } from "@/node/extensions/gitExtensionSourceInstaller"; + +export interface SyncProjectExtensionLockSourcesInput { + projectPath: string; + muxRootDir: string; + trusted: boolean; + now?: number; +} + +export interface SyncedProjectExtensionSource { + extensionName: string; + contentHash: string; + activePath: string; +} + +export interface SyncProjectExtensionLockSourcesResult { + synced: SyncedProjectExtensionSource[]; +} + +export function getProjectExtensionActiveRootPath(muxRootDir: string, projectPath: string): string { + return path.join(muxRootDir, "extensions", "projects", projectKey(projectPath)); +} + +export async function areProjectExtensionActiveSourcesCurrent(input: { + projectPath: string; + muxRootDir: string; +}): Promise { + const lockPath = path.join(input.projectPath, ".mux", "extensions.lock.json"); + let raw: string; + try { + raw = await fs.readFile(lockPath, "utf-8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return true; + throw error; + } + + const lock = ProjectExtensionSourceLockSchema.parse(JSON.parse(raw)); + const activeRootDir = getProjectExtensionActiveRootPath(input.muxRootDir, input.projectPath); + const declaredNames = new Set(Object.keys(lock.extensions)); + let entries: Array<{ name: string }>; + try { + entries = await fs.readdir(activeRootDir, { withFileTypes: true }); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return declaredNames.size === 0; + } + if ( + error instanceof Error && + "code" in error && + (error.code === "ENOTDIR" || error.code === "ELOOP") + ) { + return false; + } + throw error; + } + + for (const entry of entries) { + if (!declaredNames.has(entry.name)) return false; + } + for (const [extensionName, entry] of Object.entries(lock.extensions)) { + const activePath = path.join(activeRootDir, extensionName); + if ((await hashDirectoryIfPresent(activePath)) !== entry.source.contentHash) return false; + } + return true; +} + +export async function syncProjectExtensionLockSources( + input: SyncProjectExtensionLockSourcesInput +): Promise { + if (!input.trusted) return { synced: [] }; + + const lockPath = path.join(input.projectPath, ".mux", "extensions.lock.json"); + let raw: string; + try { + raw = await fs.readFile(lockPath, "utf-8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return { synced: [] }; + } + throw error; + } + + const lock = ProjectExtensionSourceLockSchema.parse(JSON.parse(raw)); + const activeRootDir = getProjectExtensionActiveRootPath(input.muxRootDir, input.projectPath); + await removeUndeclaredActiveExtensions(activeRootDir, new Set(Object.keys(lock.extensions))); + const synced: SyncedProjectExtensionSource[] = []; + + for (const [extensionName, entry] of Object.entries(lock.extensions)) { + if (entry.source.type === "git") { + const subdir = entry.source.subdir ? `//${entry.source.subdir}` : ""; + const coordinate = `${entry.source.url}${subdir}@${entry.source.resolvedSha}`; + const result = await installGitExtensionSource({ + coordinate, + muxRootDir: input.muxRootDir, + activeRootDir, + writeGlobalLock: false, + expectedExtensionName: extensionName, + expectedContentHash: entry.source.contentHash, + now: input.now, + }); + synced.push({ + extensionName, + contentHash: result.contentHash, + activePath: result.activePath, + }); + continue; + } + + const sourcePath = path.join(input.projectPath, entry.source.path); + await assertContainedDirectory(input.projectPath, sourcePath); + const contentHash = await hashDirectory(sourcePath); + if (contentHash !== entry.source.contentHash) { + throw new Error( + `Extension Source Lock expected ${entry.source.contentHash}, but vendored content hashed to ${contentHash}.` + ); + } + const activePath = path.join(activeRootDir, extensionName); + if ((await hashDirectoryIfPresent(activePath)) !== contentHash) { + await fs.rm(activePath, { recursive: true, force: true }); + await assertContainedDirectory(input.projectPath, sourcePath); + await copyDirectory(sourcePath, activePath); + } + synced.push({ extensionName, contentHash, activePath }); + } + + return { synced }; +} + +async function hashDirectoryIfPresent(rootPath: string): Promise { + try { + return await hashDirectory(rootPath); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR" || error.code === "ELOOP") + ) { + return null; + } + throw error; + } +} + +async function removeUndeclaredActiveExtensions( + activeRootDir: string, + declaredExtensionNames: ReadonlySet +): Promise { + const entries = await fs + .readdir(activeRootDir, { withFileTypes: true }) + .catch(async (error: unknown) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; + if ( + error instanceof Error && + "code" in error && + (error.code === "ENOTDIR" || error.code === "ELOOP") + ) { + await fs.rm(activeRootDir, { recursive: true, force: true }); + return []; + } + throw error; + }); + + for (const entry of entries) { + if (declaredExtensionNames.has(entry.name)) continue; + await fs.rm(path.join(activeRootDir, entry.name), { recursive: true, force: true }); + } +} + +async function assertContainedDirectory(rootPath: string, candidatePath: string): Promise { + const [rootRealPath, candidateRealPath] = await Promise.all([ + fs.realpath(rootPath), + fs.realpath(candidatePath), + ]); + const relative = path.relative(rootRealPath, candidateRealPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Vendored Extension Source Lock path resolved outside the project."); + } + const stat = await fs.stat(candidateRealPath); + if (!stat.isDirectory()) + throw new Error("Vendored Extension Source Lock path is not a directory."); + + const currentCandidateRealPath = await fs.realpath(candidatePath); + if (path.normalize(currentCandidateRealPath) !== path.normalize(candidateRealPath)) { + throw new Error("Vendored Extension Source Lock path changed during containment validation."); + } +} + +async function hashDirectory(rootPath: string): Promise { + const hash = createHash("sha256"); + for (const filePath of await listFiles(rootPath)) { + const relativePath = path.relative(rootPath, filePath).split(path.sep).join("/"); + hash.update(relativePath); + hash.update("\0"); + hash.update(await fs.readFile(filePath)); + hash.update("\0"); + } + return `sha256:${hash.digest("base64url")}`; +} + +async function listFiles(rootPath: string): Promise { + const entries = await fs.readdir(rootPath, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + if (entry.name === ".git") continue; + const entryPath = path.join(rootPath, entry.name); + if (entry.isDirectory()) { + files.push(...(await listFiles(entryPath))); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + return files.sort(); +} + +async function copyDirectory(sourcePath: string, destinationPath: string): Promise { + await fs.mkdir(destinationPath, { recursive: true }); + const entries = await fs.readdir(sourcePath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === ".git") continue; + const sourceEntry = path.join(sourcePath, entry.name); + const destinationEntry = path.join(destinationPath, entry.name); + if (entry.isDirectory()) { + await copyDirectory(sourceEntry, destinationEntry); + } else if (entry.isFile()) { + await fs.copyFile(sourceEntry, destinationEntry); + } + } +} + +function projectKey(projectPath: string): string { + return createHash("sha256").update(path.resolve(projectPath)).digest("hex").slice(0, 24); +} diff --git a/src/node/extensions/projectExtensionStateService.test.ts b/src/node/extensions/projectExtensionStateService.test.ts new file mode 100644 index 0000000000..e0956ea49b --- /dev/null +++ b/src/node/extensions/projectExtensionStateService.test.ts @@ -0,0 +1,254 @@ +import * as fs from "fs"; +import { mkdir, readFile, readdir, writeFile, access } from "fs/promises"; +import { constants } from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { PROJECT_EXTENSION_STATE_SCHEMA_VERSION } from "@/common/extensions/projectExtensionState"; +import { ProjectExtensionStateService } from "./projectExtensionStateService"; + +async function pathExists(p: string): Promise { + try { + await access(p, constants.F_OK); + return true; + } catch { + return false; + } +} + +describe("ProjectExtensionStateService", () => { + let projectDir: string; + let stateDir: string; + let service: ProjectExtensionStateService; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-project-ext-state-project-")); + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-project-ext-state-store-")); + service = new ProjectExtensionStateService(stateDir); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(stateDir, { recursive: true, force: true }); + }); + + test("load() with no file returns empty state and no diagnostics", async () => { + const result = await service.load(projectDir); + expect(result.state).toEqual({ + schemaVersion: PROJECT_EXTENSION_STATE_SCHEMA_VERSION, + rootTrusted: false, + extensions: {}, + }); + expect(result.diagnostics).toEqual([]); + expect(result.schemaVersionMismatch).toBe(false); + }); + + test("setRootTrusted(true) round-trips through disk", async () => { + await service.setRootTrusted(projectDir, true); + const result = await service.load(projectDir); + expect(result.state.rootTrusted).toBe(true); + expect(await service.isRootTrusted(projectDir)).toBe(true); + }); + + test("setEnabled round-trips through disk", async () => { + await service.setEnabled(projectDir, "publisher.alpha", false); + const result = await service.load(projectDir); + expect(result.state.extensions["publisher.alpha"]).toEqual({ enabled: false }); + }); + + test("setApproval persists approval permissions hash", async () => { + const approval = { + grantedPermissions: ["network", "skill.register"], + requestedPermissionsHash: "deadbeef", + }; + await service.setApproval(projectDir, "publisher.alpha", approval); + const result = await service.load(projectDir); + expect(result.state.extensions["publisher.alpha"]).toEqual({ approval }); + }); + + test("removeApproval drops approval but preserves enablement", async () => { + await service.setEnabled(projectDir, "publisher.alpha", false); + await service.setApproval(projectDir, "publisher.alpha", { + grantedPermissions: [], + requestedPermissionsHash: "abc", + }); + await service.removeApproval(projectDir, "publisher.alpha"); + const result = await service.load(projectDir); + expect(result.state.extensions["publisher.alpha"]).toEqual({ enabled: false }); + }); + + test("forget removes the entire record", async () => { + await service.setEnabled(projectDir, "publisher.alpha", true); + await service.forget(projectDir, "publisher.alpha"); + const result = await service.load(projectDir); + expect(result.state.extensions["publisher.alpha"]).toBeUndefined(); + }); + + test("write produces no .tmp leftovers (atomic rename)", async () => { + await service.setEnabled(projectDir, "publisher.alpha", true); + const stateFileDir = path.dirname(service.filePathFor(projectDir)); + const entries = await readdir(stateFileDir); + const leftovers = entries.filter( + (f) => f.startsWith("extensions.local.jsonc") && f !== "extensions.local.jsonc" + ); + expect(leftovers).toEqual([]); + }); + + test("untrust round-trip: setRootTrusted(true) then setRootTrusted(false) reflects on load (caller can clear watcher)", async () => { + await service.setRootTrusted(projectDir, true); + expect((await service.load(projectDir)).state.rootTrusted).toBe(true); + + await service.setRootTrusted(projectDir, false); + const after = await service.load(projectDir); + expect(after.state.rootTrusted).toBe(false); + }); + + test("untrust preserves approval records on disk", async () => { + await service.setRootTrusted(projectDir, true); + await service.setApproval(projectDir, "publisher.alpha", { + grantedPermissions: ["network"], + requestedPermissionsHash: "abc", + }); + await service.setRootTrusted(projectDir, false); + const after = await service.load(projectDir); + expect(after.state.rootTrusted).toBe(false); + expect(after.state.extensions["publisher.alpha"]?.approval).toBeDefined(); + }); + + test("invariant: empty/missing state never implies trust", async () => { + expect(await service.isRootTrusted(projectDir)).toBe(false); + }); + + test("recovery: malformed (non-JSON) file → empty state, file is not deleted", async () => { + const filePath = service.filePathFor(projectDir); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, "{ this is not valid json", "utf-8"); + + const { state } = await service.load(projectDir); + expect(state).toEqual({ + schemaVersion: PROJECT_EXTENSION_STATE_SCHEMA_VERSION, + rootTrusted: false, + extensions: {}, + }); + expect(await pathExists(filePath)).toBe(true); + }); + + test("recovery: unknown future schemaVersion → empty runtime state, file preserved on disk", async () => { + const filePath = service.filePathFor(projectDir); + await mkdir(path.dirname(filePath), { recursive: true }); + const futureBlock = { + schemaVersion: 99, + rootTrusted: true, + extensions: { "publisher.future": { enabled: true, somethingNew: 1 } }, + }; + await writeFile(filePath, JSON.stringify(futureBlock), "utf-8"); + + const result = await service.load(projectDir); + expect(result.schemaVersionMismatch).toBe(true); + expect(result.state.rootTrusted).toBe(false); + expect(result.state.extensions).toEqual({}); + expect( + result.diagnostics.some((d) => d.code === "extension.state.schema_version.unsupported") + ).toBe(true); + + const onDisk = JSON.parse(await readFile(filePath, "utf-8")) as unknown; + expect(onDisk).toEqual(futureBlock); + }); + + test("recovery: per-record validation failure drops only the bad record with info diagnostic", async () => { + const filePath = service.filePathFor(projectDir); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile( + filePath, + JSON.stringify({ + schemaVersion: 1, + extensions: { + "publisher.good": { enabled: true }, + "publisher.bad": { enabled: 1234 }, + }, + }), + "utf-8" + ); + const { state, diagnostics } = await service.load(projectDir); + expect(state.extensions).toEqual({ "publisher.good": { enabled: true } }); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: "extension.state.record.invalid", + severity: "info", + extensionId: "publisher.bad", + }); + }); + + test("schemaVersion mismatch + subsequent setEnabled rewrites file at current schemaVersion", async () => { + const filePath = service.filePathFor(projectDir); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile( + filePath, + JSON.stringify({ + schemaVersion: 99, + rootTrusted: true, + extensions: { "publisher.future": { enabled: true } }, + }), + "utf-8" + ); + expect((await service.load(projectDir)).schemaVersionMismatch).toBe(true); + + await service.setEnabled(projectDir, "publisher.alpha", true); + const after = await service.load(projectDir); + expect(after.schemaVersionMismatch).toBe(false); + expect(after.state.extensions).toEqual({ "publisher.alpha": { enabled: true } }); + // rootTrusted from the unknown schema is treated as empty; the write does + // not silently retain it. + expect(after.state.rootTrusted).toBe(false); + }); + + test("state file lives outside the project repository", async () => { + await service.setRootTrusted(projectDir, true); + const filePath = service.filePathFor(projectDir); + + expect(filePath.startsWith(projectDir + path.sep)).toBe(false); + expect(filePath.startsWith(stateDir + path.sep)).toBe(true); + expect(await pathExists(path.join(projectDir, ".mux", "extensions.local.jsonc"))).toBe(false); + }); + + test("committed-looking repo state file cannot inject trust", async () => { + const repoStatePath = path.join(projectDir, ".mux", "extensions.local.jsonc"); + await mkdir(path.dirname(repoStatePath), { recursive: true }); + await writeFile( + repoStatePath, + JSON.stringify({ + schemaVersion: PROJECT_EXTENSION_STATE_SCHEMA_VERSION, + rootTrusted: true, + extensions: { "publisher.injected": { enabled: true } }, + }), + "utf-8" + ); + + const result = await service.load(projectDir); + expect(result.state.rootTrusted).toBe(false); + expect(result.state.extensions).toEqual({}); + }); + + test("non-git project: write succeeds without touching the project tree", async () => { + await service.setRootTrusted(projectDir, true); + expect((await service.load(projectDir)).state.rootTrusted).toBe(true); + expect(await pathExists(path.join(projectDir, ".git"))).toBe(false); + expect(await pathExists(path.join(projectDir, ".mux"))).toBe(false); + }); + + test("file is JSON-parseable (jsonc reads JSON)", async () => { + await service.setRootTrusted(projectDir, true); + await service.setEnabled(projectDir, "publisher.alpha", true); + + const filePath = service.filePathFor(projectDir); + const content = await readFile(filePath, "utf-8"); + const parsed = JSON.parse(content) as { + schemaVersion: number; + rootTrusted?: boolean; + extensions?: Record; + }; + expect(parsed.schemaVersion).toBe(1); + expect(parsed.rootTrusted).toBe(true); + expect(parsed.extensions?.["publisher.alpha"]).toEqual({ enabled: true }); + }); +}); diff --git a/src/node/extensions/projectExtensionStateService.ts b/src/node/extensions/projectExtensionStateService.ts new file mode 100644 index 0000000000..3e89ef37c7 --- /dev/null +++ b/src/node/extensions/projectExtensionStateService.ts @@ -0,0 +1,156 @@ +import * as path from "path"; +import crypto from "crypto"; +import { mkdir, readFile } from "fs/promises"; +import * as jsonc from "jsonc-parser"; +import writeFileAtomic from "write-file-atomic"; +import { + PROJECT_EXTENSION_STATE_SCHEMA_VERSION, + normalizeProjectExtensionState, + type NormalizeProjectExtensionStateResult, +} from "@/common/extensions/projectExtensionState"; +import type { + ExtensionStateRecord, + ApprovalRecord, +} from "@/common/extensions/globalExtensionState"; +import { log } from "@/node/services/log"; + +const PROJECT_EXTENSION_STATE_FILE = "extensions.local.jsonc"; + +interface ProjectExtensionStateOnDisk { + schemaVersion: typeof PROJECT_EXTENSION_STATE_SCHEMA_VERSION; + rootTrusted?: boolean; + extensions?: Record; +} + +export function getProjectExtensionStateRoot(muxRoot: string): string { + return path.join(muxRoot, "extensions", "project-state"); +} + +function projectStateKey(projectPath: string): string { + const canonical = path.resolve(projectPath); + return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 24); +} + +// Persists per-project Trusted Extension Root flag plus per-Extension +// enablement and approval records under Mux-owned global state via +// write-file-atomic. Validation and self-healing live in the pure +// normalizeProjectExtensionState module. +// +// Invariants: +// - Empty/missing/malformed state never implies trust or approvals. +// - rootTrusted defaults to false when no record exists. +// - Unknown future schemaVersion values are preserved on disk on load; only +// an explicit mutation rewrites the file at the current schemaVersion. +// - Project repositories are never consulted for this state. Gitignore is not +// a security boundary; keeping approvals under Mux-owned storage prevents a +// repo from injecting extension trust by committing `.mux` files. +export class ProjectExtensionStateService { + private writeQueue: Promise = Promise.resolve(); + + constructor(private readonly stateRoot: string) {} + + filePathFor(projectPath: string): string { + return path.join(this.stateRoot, projectStateKey(projectPath), PROJECT_EXTENSION_STATE_FILE); + } + + async load(projectPath: string): Promise { + const raw = await this.readRaw(projectPath); + return normalizeProjectExtensionState(raw); + } + + async isRootTrusted(projectPath: string): Promise { + return (await this.load(projectPath)).state.rootTrusted; + } + + async setRootTrusted(projectPath: string, trusted: boolean): Promise { + await this.enqueue(async () => { + const { state } = await this.load(projectPath); + await this.write(projectPath, trusted, state.extensions); + }); + } + + async setEnabled(projectPath: string, extensionId: string, enabled: boolean): Promise { + await this.mutateRecord(projectPath, extensionId, (record) => ({ ...record, enabled })); + } + + async setApproval( + projectPath: string, + extensionId: string, + approval: ApprovalRecord + ): Promise { + await this.mutateRecord(projectPath, extensionId, (record) => ({ ...record, approval })); + } + + async removeApproval(projectPath: string, extensionId: string): Promise { + await this.mutateRecord(projectPath, extensionId, ({ enabled }) => ({ enabled })); + } + + async forget(projectPath: string, extensionId: string): Promise { + await this.mutateRecord(projectPath, extensionId, () => null); + } + + private async enqueue(fn: () => Promise): Promise { + const next = this.writeQueue.then(fn, fn); + this.writeQueue = next.catch(() => undefined); + return next; + } + + private async readRaw(projectPath: string): Promise { + const filePath = this.filePathFor(projectPath); + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch { + return undefined; + } + const errors: jsonc.ParseError[] = []; + const parsed: unknown = jsonc.parse(content, errors) as unknown; + if (errors.length > 0) { + log.warn("[extensions] Failed to parse project extension state (JSONC parse errors)", { + filePath, + errorCount: errors.length, + }); + return {}; + } + return parsed; + } + + private async mutateRecord( + projectPath: string, + extensionId: string, + fn: (current: ExtensionStateRecord) => ExtensionStateRecord | null + ): Promise { + await this.enqueue(async () => { + const { state } = await this.load(projectPath); + const next = fn(state.extensions[extensionId] ?? {}); + const extensions = { ...state.extensions }; + if (next == null || (next.enabled === undefined && next.approval === undefined)) { + delete extensions[extensionId]; + } else { + extensions[extensionId] = next; + } + await this.write(projectPath, state.rootTrusted, extensions); + }); + } + + private async write( + projectPath: string, + rootTrusted: boolean, + extensions: Record + ): Promise { + const filePath = this.filePathFor(projectPath); + await mkdir(path.dirname(filePath), { recursive: true }); + + const onDisk: ProjectExtensionStateOnDisk = { + schemaVersion: PROJECT_EXTENSION_STATE_SCHEMA_VERSION, + }; + if (rootTrusted) { + onDisk.rootTrusted = true; + } + if (Object.keys(extensions).length > 0) { + onDisk.extensions = extensions; + } + + await writeFileAtomic(filePath, JSON.stringify(onDisk, null, 2) + "\n", "utf-8"); + } +} diff --git a/src/node/extensions/snapshotCacheService.test.ts b/src/node/extensions/snapshotCacheService.test.ts new file mode 100644 index 0000000000..8d0494c08d --- /dev/null +++ b/src/node/extensions/snapshotCacheService.test.ts @@ -0,0 +1,270 @@ +import * as fs from "fs"; +import { access, readFile, readdir, stat, unlink, writeFile, utimes } from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { SnapshotCacheService } from "./snapshotCacheService"; + +const APP_VERSION = "1.2.3"; + +interface FakeContribution { + type: string; + id: string; + extensionId: string; +} + +interface FakeSnapshot { + availableContributions: FakeContribution[]; +} + +// Minimal stand-in for the Extension Registry Service (US-013). The Capability +// Path reads only from `liveSnapshot`, never from any cache. The Inspection +// Path is allowed to fall back to the cache if no live snapshot is available +// yet (cold start). This module exists to prove the boundary in tests; the +// real registry replaces it in US-013. +class TestRegistry { + liveSnapshot: FakeSnapshot | null = null; + constructor(private readonly cache: SnapshotCacheService) {} + + // Capability Path: NEVER reads the cache. Returns empty when no live + // snapshot has been published yet. + getContributions(type: string): FakeContribution[] { + if (!this.liveSnapshot) return []; + return this.liveSnapshot.availableContributions.filter((c) => c.type === type); + } + + // Inspection Path: live first; cache is only a cold-start fallback. + async getDescriptors( + type: string, + stateFilePaths: readonly string[] + ): Promise { + if (this.liveSnapshot) { + return this.liveSnapshot.availableContributions.filter((c) => c.type === type); + } + const cached = await this.cache.read(stateFilePaths); + if (!cached) return []; + return cached.availableContributions.filter((c) => c.type === type); + } +} + +describe("SnapshotCacheService", () => { + let muxHome: string; + let cachePath: string; + let stateFile: string; + let service: SnapshotCacheService; + + beforeEach(() => { + muxHome = fs.mkdtempSync(path.join(os.tmpdir(), "mux-snapshot-cache-")); + cachePath = path.join(muxHome, "extension-snapshot.cache.json"); + stateFile = path.join(muxHome, "config.json"); + service = new SnapshotCacheService({ cacheFilePath: cachePath, appVersion: APP_VERSION }); + }); + + afterEach(() => { + fs.rmSync(muxHome, { recursive: true, force: true }); + }); + + test("read() returns null when no cache file exists", async () => { + const result = await service.read([]); + expect(result).toBeNull(); + }); + + test("write() creates the cache file via atomic write (no .tmp leftovers)", async () => { + await service.write({ availableContributions: [] }, []); + const entries = await readdir(muxHome); + const tmpEntries = entries.filter((e) => e.includes(".tmp") || e.endsWith("~")); + expect(tmpEntries).toEqual([]); + expect(entries).toContain("extension-snapshot.cache.json"); + }); + + test("write() then read() round-trips the snapshot payload verbatim", async () => { + const snapshot: FakeSnapshot = { + availableContributions: [ + { type: "skill", id: "alpha", extensionId: "publisher.alpha" }, + { type: "agent", id: "beta", extensionId: "publisher.alpha" }, + ], + }; + await service.write(snapshot, []); + const result = await service.read([]); + expect(result).toEqual(snapshot); + }); + + test("write() records mtime+sha256 fingerprints of every contributing state file", async () => { + await writeFile(stateFile, JSON.stringify({ schemaVersion: 1 }), "utf-8"); + await service.write({ availableContributions: [] }, [stateFile]); + + const blob = JSON.parse(await readFile(cachePath, "utf-8")) as { + stateFileFingerprints: Array<{ + path: string; + exists: boolean; + mtimeMs: number; + sha256: string; + }>; + }; + expect(blob.stateFileFingerprints).toHaveLength(1); + expect(blob.stateFileFingerprints[0]).toMatchObject({ + path: stateFile, + exists: true, + }); + expect(blob.stateFileFingerprints[0].sha256).toMatch(/^[0-9a-f]{64}$/); + expect(blob.stateFileFingerprints[0].mtimeMs).toBeGreaterThan(0); + }); + + test("read() returns null silently when appVersion mismatches", async () => { + await service.write({ availableContributions: [] }, []); + const stale = new SnapshotCacheService({ cacheFilePath: cachePath, appVersion: "9.9.9" }); + const result = await stale.read([]); + expect(result).toBeNull(); + }); + + test("read() returns null silently when cacheVersion is unknown (future format)", async () => { + await service.write({ availableContributions: [] }, []); + const blob = JSON.parse(await readFile(cachePath, "utf-8")) as Record; + blob.cacheVersion = 999; + await writeFile(cachePath, JSON.stringify(blob), "utf-8"); + expect(await service.read([])).toBeNull(); + }); + + test("read() returns null silently when manifestVersion is not 1", async () => { + await service.write({ availableContributions: [] }, []); + const blob = JSON.parse(await readFile(cachePath, "utf-8")) as Record; + blob.manifestVersion = 2; + await writeFile(cachePath, JSON.stringify(blob), "utf-8"); + expect(await service.read([])).toBeNull(); + }); + + test("read() returns null silently when a state file's mtime drifted", async () => { + await writeFile(stateFile, "v1", "utf-8"); + await service.write({ availableContributions: [] }, [stateFile]); + // Touch the file to drift the mtime; sha256 may also change if content does. + const future = new Date(Date.now() + 60_000); + await utimes(stateFile, future, future); + expect(await service.read([stateFile])).toBeNull(); + }); + + test("read() returns null silently when a state file's content (hash) drifted", async () => { + await writeFile(stateFile, "v1", "utf-8"); + await service.write({ availableContributions: [] }, [stateFile]); + // Replace content but force the same mtime back so only sha256 differs. + const before = await stat(stateFile); + await writeFile(stateFile, "v2-different-content", "utf-8"); + await utimes(stateFile, before.atime, before.mtime); + expect(await service.read([stateFile])).toBeNull(); + }); + + test("read() returns null silently when a state file existed at write time but is gone", async () => { + await writeFile(stateFile, "v1", "utf-8"); + await service.write({ availableContributions: [] }, [stateFile]); + await unlink(stateFile); + expect(await service.read([stateFile])).toBeNull(); + }); + + test("read() returns null silently when a state file is added after write", async () => { + // Cache written with the file missing; later, the file exists. + await service.write({ availableContributions: [] }, [stateFile]); + await writeFile(stateFile, "v1", "utf-8"); + expect(await service.read([stateFile])).toBeNull(); + }); + + test("read() returns null silently for a corrupted (non-JSON) cache file", async () => { + await writeFile(cachePath, "{not json", "utf-8"); + expect(await service.read([])).toBeNull(); + }); + + test("read() returns null silently when shape is mutated to be unrecognized", async () => { + await service.write({ availableContributions: [] }, []); + await writeFile(cachePath, JSON.stringify({ unrelated: "data" }), "utf-8"); + expect(await service.read([])).toBeNull(); + }); + + test("cold-start first-paint contract: Inspection Path renders from cache before live snapshot exists", async () => { + const seedSnapshot: FakeSnapshot = { + availableContributions: [{ type: "skill", id: "alpha", extensionId: "publisher.alpha" }], + }; + await service.write(seedSnapshot, []); + + const registry = new TestRegistry(service); + // Cold start: liveSnapshot is null. Inspection Path must serve cached descriptors. + expect(await registry.getDescriptors("skill", [])).toEqual(seedSnapshot.availableContributions); + + // Capability Path returns empty until live discovery publishes a snapshot. + expect(registry.getContributions("skill")).toEqual([]); + + // Once live discovery completes, Inspection Path follows live state. + registry.liveSnapshot = { availableContributions: [] }; + expect(await registry.getDescriptors("skill", [])).toEqual([]); + }); + + test("security regression: a mutated cache claiming a fake contribution does NOT influence Capability Path", async () => { + const realSnapshot: FakeSnapshot = { + availableContributions: [{ type: "skill", id: "real", extensionId: "publisher.alpha" }], + }; + await service.write(realSnapshot, []); + + // Attacker (or stale-cache) mutation: inject an availability claim for a + // contribution that was never in the live registry. + const blob = JSON.parse(await readFile(cachePath, "utf-8")) as { + snapshot: FakeSnapshot; + }; + blob.snapshot.availableContributions.push({ + type: "skill", + id: "evil", + extensionId: "evil.attacker", + }); + await writeFile(cachePath, JSON.stringify(blob), "utf-8"); + + const registry = new TestRegistry(service); + registry.liveSnapshot = realSnapshot; + + // Capability Path: ignores the cache entirely → no fake contribution. + const skills = registry.getContributions("skill"); + expect(skills.find((c) => c.id === "evil")).toBeUndefined(); + expect(skills.map((c) => c.id)).toEqual(["real"]); + }); + + test("write() overwrites a previous cache file in place", async () => { + await service.write( + { availableContributions: [{ type: "skill", id: "v1", extensionId: "x.x" }] }, + [] + ); + await service.write( + { availableContributions: [{ type: "skill", id: "v2", extensionId: "x.x" }] }, + [] + ); + const cached = await service.read([]); + expect(cached?.availableContributions[0]?.id).toBe("v2"); + }); + + test("write() ensures the cache parent directory exists", async () => { + const nested = path.join(muxHome, "nested", "subdir", "extension-snapshot.cache.json"); + const nestedService = new SnapshotCacheService({ + cacheFilePath: nested, + appVersion: APP_VERSION, + }); + await nestedService.write({ availableContributions: [] }, []); + await access(nested); + }); + + test("multiple state files: cache stays valid while every fingerprint matches", async () => { + const stateA = path.join(muxHome, "stateA.json"); + const stateB = path.join(muxHome, "stateB.json"); + await writeFile(stateA, "A", "utf-8"); + await writeFile(stateB, "B", "utf-8"); + await service.write({ availableContributions: [] }, [stateA, stateB]); + const cached = await service.read([stateA, stateB]); + expect(cached).toEqual({ availableContributions: [] }); + }); + + test("multiple state files: drift on any single file invalidates the cache", async () => { + const stateA = path.join(muxHome, "stateA.json"); + const stateB = path.join(muxHome, "stateB.json"); + await writeFile(stateA, "A", "utf-8"); + await writeFile(stateB, "B", "utf-8"); + await service.write({ availableContributions: [] }, [stateA, stateB]); + // Drift only B. + const before = await stat(stateB); + await writeFile(stateB, "B-changed", "utf-8"); + await utimes(stateB, before.atime, before.mtime); + expect(await service.read([stateA, stateB])).toBeNull(); + }); +}); diff --git a/src/node/extensions/snapshotCacheService.ts b/src/node/extensions/snapshotCacheService.ts new file mode 100644 index 0000000000..b3fe1b083d --- /dev/null +++ b/src/node/extensions/snapshotCacheService.ts @@ -0,0 +1,88 @@ +import * as path from "path"; +import { mkdir, readFile, stat } from "fs/promises"; +import { createHash } from "crypto"; +import writeFileAtomic from "write-file-atomic"; +import { + SNAPSHOT_CACHE_MANIFEST_VERSION, + SNAPSHOT_CACHE_VERSION, + validateSnapshotCache, + type SnapshotCache, + type StateFileFingerprint, +} from "@/common/extensions/snapshotCache"; + +export interface SnapshotCacheServiceOptions { + cacheFilePath: string; + appVersion: string; +} + +// Inspection Path only: the Capability Path (getContributions(type)) MUST +// NOT consume read() output as authority. A stale or attacker-mutated cache +// cannot grant capabilities. See the security regression test in +// snapshotCacheService.test.ts. +export class SnapshotCacheService { + constructor(private readonly options: SnapshotCacheServiceOptions) {} + + async read(stateFilePaths: readonly string[]): Promise { + const raw = await this.readRaw(); + if (raw === undefined) return null; + + const liveFingerprints = await fingerprintFiles(stateFilePaths); + const result = validateSnapshotCache({ + raw, + appVersion: this.options.appVersion, + liveFingerprints, + }); + if (!result.ok) return null; + return result.snapshot as TSnapshot; + } + + async write(snapshot: unknown, stateFilePaths: readonly string[]): Promise { + const stateFileFingerprints = await fingerprintFiles(stateFilePaths); + const payload: SnapshotCache = { + cacheVersion: SNAPSHOT_CACHE_VERSION, + appVersion: this.options.appVersion, + manifestVersion: SNAPSHOT_CACHE_MANIFEST_VERSION, + stateFileFingerprints, + snapshot, + }; + await mkdir(path.dirname(this.options.cacheFilePath), { recursive: true }); + await writeFileAtomic( + this.options.cacheFilePath, + JSON.stringify(payload, null, 2) + "\n", + "utf-8" + ); + } + + private async readRaw(): Promise { + try { + const content = await readFile(this.options.cacheFilePath, "utf-8"); + return JSON.parse(content) as unknown; + } catch { + return undefined; + } + } +} + +async function fingerprintFiles(paths: readonly string[]): Promise { + return Promise.all(paths.map(fingerprintFile)); +} + +function missingFingerprint(filePath: string): StateFileFingerprint { + return { path: filePath, exists: false, mtimeMs: 0, sha256: "" }; +} + +async function fingerprintFile(filePath: string): Promise { + try { + const st = await stat(filePath); + if (!st.isFile()) return missingFingerprint(filePath); + const content = await readFile(filePath); + return { + path: filePath, + exists: true, + mtimeMs: st.mtimeMs, + sha256: createHash("sha256").update(content).digest("hex"), + }; + } catch { + return missingFingerprint(filePath); + } +} diff --git a/src/node/extensions/staticManifestExtractor.test.ts b/src/node/extensions/staticManifestExtractor.test.ts new file mode 100644 index 0000000000..25b95ecceb --- /dev/null +++ b/src/node/extensions/staticManifestExtractor.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; + +import { extractStaticManifestFromSource } from "./staticManifestExtractor"; + +const NOW = 1_700_000_000_000; + +describe("extractStaticManifestFromSource", () => { + test("extracts a defineManifest object without executing extension code", () => { + const result = extractStaticManifestFromSource( + ` + import { defineManifest } from "mux:extensions"; + throw new Error("must not execute"); + export const manifest = defineManifest({ + name: "acme-review", + displayName: "Acme Review", + description: "Review helpers", + capabilities: { skills: true }, + }); + `, + "extension.ts", + NOW + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest).toEqual({ + name: "acme-review", + displayName: "Acme Review", + description: "Review helpers", + capabilities: { skills: true }, + }); + }); + + test("extracts literal arrays in static manifests", () => { + const result = extractStaticManifestFromSource( + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + requestedPermissions: ["network", "filesystem"], + }; + `, + "extension.ts", + NOW + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.requestedPermissions).toEqual(["network", "filesystem"]); + }); + + test("rejects dynamic manifest values", () => { + const result = extractStaticManifestFromSource( + ` + const name = "acme-review"; + export const manifest = defineManifest({ name, capabilities: { skills: true } }); + `, + "extension.ts", + NOW + ); + + expect(result.ok).toBe(false); + expect( + result.diagnostics.some( + (diagnostic) => + diagnostic.code === "manifest.static.unsupported" && diagnostic.occurredAt === NOW + ) + ).toBe(true); + }); + + test("requires an exported manifest binding", () => { + const result = extractStaticManifestFromSource( + `const manifest = { name: "acme-review" };`, + "extension.ts", + NOW + ); + + expect(result.ok).toBe(false); + expect(result.diagnostics[0]).toMatchObject({ code: "manifest.static.missing" }); + }); +}); diff --git a/src/node/extensions/staticManifestExtractor.ts b/src/node/extensions/staticManifestExtractor.ts new file mode 100644 index 0000000000..7c8d418e05 --- /dev/null +++ b/src/node/extensions/staticManifestExtractor.ts @@ -0,0 +1,219 @@ +import * as fsPromises from "fs/promises"; + +import ts from "typescript"; + +import { validateFileSize } from "@/node/services/tools/fileCommon"; +import type { ExtensionDiagnostic } from "@/common/extensions/manifestValidator"; + +export type StaticManifestExtractionResult = + | { ok: true; manifest: Record; diagnostics: ExtensionDiagnostic[] } + | { ok: false; diagnostics: ExtensionDiagnostic[] }; + +interface SourceFileWithParseDiagnostics extends ts.SourceFile { + parseDiagnostics?: readonly ts.Diagnostic[]; +} + +function getParseDiagnostics(sourceFile: ts.SourceFile): readonly ts.Diagnostic[] { + return (sourceFile as SourceFileWithParseDiagnostics).parseDiagnostics ?? []; +} + +function diagnostic( + code: string, + message: string, + now: number, + severity: ExtensionDiagnostic["severity"] = "error" +): ExtensionDiagnostic { + return { code, severity, message, occurredAt: now }; +} + +function unwrapExpression(expression: ts.Expression): ts.Expression { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isSatisfiesExpression(current) + ) { + current = current.expression; + } + return current; +} + +function manifestInitializerToObjectLiteral( + initializer: ts.Expression +): ts.ObjectLiteralExpression | null { + const expression = unwrapExpression(initializer); + if (ts.isObjectLiteralExpression(expression)) return expression; + if (!ts.isCallExpression(expression)) return null; + const callee = unwrapExpression(expression.expression); + if (!ts.isIdentifier(callee) || callee.text !== "defineManifest") return null; + if (expression.arguments.length !== 1) return null; + const [arg] = expression.arguments; + const unwrappedArg = unwrapExpression(arg); + return ts.isObjectLiteralExpression(unwrappedArg) ? unwrappedArg : null; +} + +function propertyNameToString(name: ts.PropertyName): string | null { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) + return name.text; + return null; +} + +function expressionToStaticValue(expression: ts.Expression): unknown { + const unwrapped = unwrapExpression(expression); + if (ts.isStringLiteral(unwrapped) || ts.isNoSubstitutionTemplateLiteral(unwrapped)) + return unwrapped.text; + if (unwrapped.kind === ts.SyntaxKind.TrueKeyword) return true; + if (unwrapped.kind === ts.SyntaxKind.FalseKeyword) return false; + if (unwrapped.kind === ts.SyntaxKind.NullKeyword) return null; + if (ts.isNumericLiteral(unwrapped)) return Number(unwrapped.text); + if (ts.isArrayLiteralExpression(unwrapped)) { + return unwrapped.elements.map((element) => { + if (ts.isSpreadElement(element)) { + throw new Error("Static Manifest arrays may only contain literal values."); + } + return expressionToStaticValue(element); + }); + } + if (ts.isObjectLiteralExpression(unwrapped)) return objectLiteralToRecord(unwrapped); + throw new Error( + "Static Manifest values must be literal strings, booleans, null, numbers, arrays, or objects." + ); +} + +function objectLiteralToRecord(objectLiteral: ts.ObjectLiteralExpression): Record { + const record: Record = {}; + for (const property of objectLiteral.properties) { + if (!ts.isPropertyAssignment(property)) { + throw new Error("Static Manifest objects may only contain property assignments."); + } + const key = propertyNameToString(property.name); + if (key === null) { + throw new Error("Static Manifest property names must be identifiers or string literals."); + } + if (Object.prototype.hasOwnProperty.call(record, key)) { + throw new Error(`Static Manifest contains duplicate property "${key}".`); + } + record[key] = expressionToStaticValue(property.initializer); + } + return record; +} + +function findExportedManifest(sourceFile: ts.SourceFile): ts.Expression | null { + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + const hasExport = statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword + ); + if (!hasExport) continue; + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "manifest") continue; + return declaration.initializer ?? null; + } + } + return null; +} + +export function extractStaticManifestFromSource( + source: string, + fileName: string, + now: number = Date.now() +): StaticManifestExtractionResult { + const sourceFile = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const parseDiagnostics = getParseDiagnostics(sourceFile); + if (parseDiagnostics.length > 0) { + return { + ok: false, + diagnostics: parseDiagnostics.map((parseDiagnostic) => + diagnostic( + "manifest.static.parse_error", + ts.flattenDiagnosticMessageText(parseDiagnostic.messageText, "\n"), + now + ) + ), + }; + } + + const initializer = findExportedManifest(sourceFile); + if (initializer === null) { + return { + ok: false, + diagnostics: [ + diagnostic( + "manifest.static.missing", + "extension.ts must export `const manifest = defineManifest({ ... })` or a static object literal.", + now + ), + ], + }; + } + + const objectLiteral = manifestInitializerToObjectLiteral(initializer); + if (objectLiteral === null) { + return { + ok: false, + diagnostics: [ + diagnostic( + "manifest.static.unsupported", + "Static Manifest export must be a literal object or defineManifest({...}) call.", + now + ), + ], + }; + } + + try { + return { ok: true, manifest: objectLiteralToRecord(objectLiteral), diagnostics: [] }; + } catch (error) { + return { + ok: false, + diagnostics: [ + diagnostic( + "manifest.static.unsupported", + error instanceof Error ? error.message : String(error), + now + ), + ], + }; + } +} + +export async function extractStaticManifestFromFile( + filePath: string, + now: number = Date.now() +): Promise { + let source: string; + try { + const handle = await fsPromises.open(filePath, "r"); + try { + const stat = await handle.stat(); + const sizeValidation = validateFileSize({ + size: stat.size, + modifiedTime: stat.mtime, + isDirectory: stat.isDirectory(), + }); + if (sizeValidation) throw new Error(sizeValidation.error); + source = await handle.readFile("utf-8"); + } finally { + await handle.close(); + } + } catch (error) { + return { + ok: false, + diagnostics: [ + diagnostic( + "extension.entrypoint.read_failed", + `Failed to read extension.ts: ${error instanceof Error ? error.message : String(error)}`, + now + ), + ], + }; + } + return extractStaticManifestFromSource(source, filePath, now); +} diff --git a/src/node/extensions/testExtensionRegistry.ts b/src/node/extensions/testExtensionRegistry.ts new file mode 100644 index 0000000000..3bfc8bee7e --- /dev/null +++ b/src/node/extensions/testExtensionRegistry.ts @@ -0,0 +1,95 @@ +/** + * Shared test helper: creates a real ExtensionRegistry backed by temp dirs. + * + * Mirrors the HistoryService convention (createTestHistoryService): no mocks; + * a real registry composed over real GlobalExtensionStateService / + * ProjectExtensionStateService instances and a temp `~/.mux`. Tests inject + * roots via the `roots` option and may override `discoverFn` for stubbed + * discovery. + */ +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; + +import { Config } from "@/node/config"; +import { GlobalExtensionStateService } from "@/node/extensions/globalExtensionStateService"; +import { + getProjectExtensionStateRoot, + ProjectExtensionStateService, +} from "@/node/extensions/projectExtensionStateService"; +import { SnapshotCacheService } from "@/node/extensions/snapshotCacheService"; +import { + ExtensionRegistry, + type DiscoverFn, + type ExtensionRegistryOptions, + type RootsFn, +} from "@/node/extensions/extensionRegistryService"; + +export interface CreateTestExtensionRegistryOptions { + roots?: RootsFn; + discoverFn?: DiscoverFn; + withSnapshotCache?: boolean; + appVersion?: string; + now?: () => number; + perRootTimeoutMs?: number; + perFileTimeoutMs?: number; +} + +export interface TestExtensionRegistry { + registry: ExtensionRegistry; + config: Config; + globalState: GlobalExtensionStateService; + projectState: ProjectExtensionStateService; + snapshotCache?: SnapshotCacheService; + tempDir: string; + cleanup: () => Promise; +} + +export async function createTestExtensionRegistry( + options: CreateTestExtensionRegistryOptions = {} +): Promise { + const tempDir = path.join( + os.tmpdir(), + `mux-test-extension-registry-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await fs.mkdir(tempDir, { recursive: true }); + + const config = new Config(tempDir); + const globalState = new GlobalExtensionStateService(config); + const projectState = new ProjectExtensionStateService(getProjectExtensionStateRoot(tempDir)); + + let snapshotCache: SnapshotCacheService | undefined; + let stateFilePaths: ExtensionRegistryOptions["stateFilePaths"]; + if (options.withSnapshotCache) { + snapshotCache = new SnapshotCacheService({ + cacheFilePath: path.join(tempDir, "extension-snapshot.cache.json"), + appVersion: options.appVersion ?? "0.0.0-test", + }); + stateFilePaths = () => [path.join(tempDir, "config.json")]; + } + + const registry = new ExtensionRegistry({ + roots: options.roots ?? (() => []), + globalState, + projectState, + snapshotCache, + stateFilePaths, + now: options.now, + perRootTimeoutMs: options.perRootTimeoutMs, + perFileTimeoutMs: options.perFileTimeoutMs, + discoverFn: options.discoverFn, + }); + + return { + registry, + config, + globalState, + projectState, + snapshotCache, + tempDir, + cleanup: async () => { + registry.dispose(); + await fs.rm(tempDir, { recursive: true, force: true }); + }, + }; +} diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index c6d725147b..a134384841 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -43,6 +43,7 @@ import type { AnalyticsService } from "@/node/services/analytics/analyticsServic import type { DesktopBridgeServer } from "@/node/services/desktop/DesktopBridgeServer"; import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; import type { DesktopTokenManager } from "@/node/services/desktop/DesktopTokenManager"; +import type { ExtensionRegistry } from "@/node/extensions/extensionRegistryService"; export interface ORPCContext { config: Config; @@ -89,5 +90,6 @@ export interface ORPCContext { desktopSessionManager: DesktopSessionManager; desktopTokenManager: DesktopTokenManager; desktopBridgeServer: DesktopBridgeServer; + extensionRegistry: ExtensionRegistry; headers?: IncomingHttpHeaders; } diff --git a/src/node/orpc/extensionsRouter.test.ts b/src/node/orpc/extensionsRouter.test.ts new file mode 100644 index 0000000000..68861e8c37 --- /dev/null +++ b/src/node/orpc/extensionsRouter.test.ts @@ -0,0 +1,578 @@ +import { execFile } from "child_process"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { promisify } from "util"; +import { afterEach, beforeEach, describe, expect, test } from "@jest/globals"; +import { createRouterClient, ORPCError } from "@orpc/server"; + +import { + createTestExtensionRegistry, + type TestExtensionRegistry, +} from "@/node/extensions/testExtensionRegistry"; +import type { + DiscoveredExtension, + ExtensionRootDescriptor, + RootDiscoveryResult, +} from "@/node/extensions/extensionDiscoveryService"; +import type { DiscoverFn } from "@/node/extensions/extensionRegistryService"; +import type { ValidatedManifest } from "@/common/extensions/manifestValidator"; +import { hashRequestedPermissions } from "@/common/extensions/permissionCalculator"; +import type { ApprovalRecord } from "@/common/extensions/globalExtensionState"; +import type { ORPCContext } from "./context"; +import { router } from "./router"; + +const execFileAsync = promisify(execFile); + +const FROZEN_NOW = 1_700_000_000_000; + +const SAMPLE_GRANT: ApprovalRecord = { + grantedPermissions: ["skill.register"], + requestedPermissionsHash: hashRequestedPermissions(["skill.register"]), +}; + +async function git(args: readonly string[], cwd: string): Promise { + const { stdout } = await execFileAsync("git", [...args], { cwd }); + return stdout.trim(); +} + +async function writeFile(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); +} + +function makeManifest( + id: string, + contributions: Array<{ type: string; id: string }> +): ValidatedManifest { + return { + manifestVersion: 1, + id, + requestedPermissions: contributions.map((c) => `${singularOf(c.type)}.register`), + contributions: contributions.map((c, index) => ({ + type: c.type, + id: c.id, + index, + descriptor: { descriptorVersion: 1, id: c.id }, + })), + }; +} + +function singularOf(type: string): string { + if (type.endsWith("s")) return type.slice(0, -1); + return type; +} + +function makeExtension(opts: { + extensionId: string; + rootId: string; + rootKind: ExtensionRootDescriptor["kind"]; + enabled?: boolean; + granted?: boolean; + activated?: boolean; + contributions?: Array<{ type: string; id: string }>; +}): DiscoveredExtension { + const contribs = (opts.contributions ?? []).map((c, index) => ({ + type: c.type, + id: c.id, + index, + activated: opts.activated ?? true, + })); + return { + extensionId: opts.extensionId, + rootId: opts.rootId, + rootKind: opts.rootKind, + isCore: false, + modulePath: `/fake/${opts.extensionId}`, + manifest: makeManifest(opts.extensionId, opts.contributions ?? []), + contributions: contribs, + diagnostics: [], + enabled: opts.enabled ?? true, + granted: opts.granted ?? true, + activated: opts.activated ?? true, + }; +} + +function makeRoot( + rootDesc: ExtensionRootDescriptor, + extensions: DiscoveredExtension[], + trusted = true +): RootDiscoveryResult { + return { + rootId: rootDesc.rootId, + kind: rootDesc.kind, + path: rootDesc.path, + trusted, + rootExists: true, + state: "ready", + extensions, + diagnostics: [], + }; +} + +function stubDiscoverFn( + buildRoots: (input: { roots: readonly ExtensionRootDescriptor[] }) => RootDiscoveryResult[] +): DiscoverFn { + return (input) => + Promise.resolve({ generatedAt: input.now ?? FROZEN_NOW, roots: buildRoots(input) }); +} + +function makeContext(env: TestExtensionRegistry): ORPCContext { + // Only the extensionRegistry field is exercised by the extensions IPC routes, + // so a partial context with the registry plugged in is sufficient. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- IPC handlers under test only read context.extensionRegistry. + return { config: env.config, extensionRegistry: env.registry } as ORPCContext; +} + +describe("extensions IPC — list / reload / onChanged", () => { + let env: TestExtensionRegistry; + + const userGlobalRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + + beforeEach(async () => { + env = await createTestExtensionRegistry({ + roots: () => [userGlobalRoot], + discoverFn: stubDiscoverFn(({ roots }) => [ + makeRoot(roots[0], [ + makeExtension({ + extensionId: "author.skill", + rootId: "user-global", + rootKind: "user-global", + contributions: [{ type: "skills", id: "demo" }], + }), + ]), + ]), + now: () => FROZEN_NOW, + }); + await env.globalState.setApproval("author.skill", SAMPLE_GRANT); + }); + + afterEach(async () => { + await env.cleanup(); + }); + + test("list returns null before any reload", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + const snap = await client.extensions.list(); + expect(snap).toBeNull(); + }); + + test("reload populates the live snapshot and list returns it", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + await client.extensions.reload({}); + + const snap = await client.extensions.list(); + expect(snap).not.toBeNull(); + expect(snap!.roots).toHaveLength(1); + expect(snap!.roots[0].rootId).toBe("user-global"); + expect(snap!.availableContributions).toHaveLength(1); + expect(snap!.availableContributions[0]).toMatchObject({ + type: "skills", + id: "demo", + extensionId: "author.skill", + }); + }); + + test("initializeUserRoot creates the local Extension Module authoring root", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + + await client.extensions.initializeUserRoot(); + + const rootPath = path.join(env.config.rootDir, "extensions", "local"); + const stat = await fs.stat(rootPath); + expect(stat.isDirectory()).toBe(true); + await expect( + fs.access(path.join(env.config.rootDir, "extensions", "package.json")) + ).rejects.toThrow(); + }); + + test("installGitSource installs a git Extension Module and refreshes the registry", async () => { + const repoPath = path.join(env.tempDir, "repo"); + await fs.mkdir(repoPath, { recursive: true }); + await git(["init", "--initial-branch", "main"], repoPath); + await git(["config", "user.email", "mux@example.com"], repoPath); + await git(["config", "user.name", "Mux Test"], repoPath); + await writeFile( + path.join(repoPath, "extension.ts"), + ` + export const manifest = { + name: "acme-review", + capabilities: { skills: true }, + }; + export function activate(ctx) { + ctx.skills.register({ name: "review", bodyPath: "./skills/review/SKILL.md" }); + } + ` + ); + await writeFile( + path.join(repoPath, "skills", "review", "SKILL.md"), + "---\nname: review\ndescription: Review helper\n---\n# Review\n" + ); + await git(["add", "."], repoPath); + await git(["commit", "-m", "add extension"], repoPath); + const resolvedSha = await git(["rev-parse", "HEAD"], repoPath); + const client = createRouterClient(router(), { context: makeContext(env) }); + + expect(env.registry.getSnapshot()).toBeNull(); + const result = await client.extensions.installGitSource({ coordinate: `${repoPath}@main` }); + + expect(result).toMatchObject({ + extensionName: "acme-review", + resolvedSha, + activePath: path.join(env.config.rootDir, "extensions", "global", "acme-review"), + }); + expect(result.contentHash.startsWith("sha256:")).toBe(true); + const entrypointStat = await fs.stat(path.join(result.activePath, "extension.ts")); + expect(entrypointStat.isFile()).toBe(true); + expect(env.registry.getSnapshot()).not.toBeNull(); + }); + + test("reload({ rootId }) routes through reloadRoot and produces a snapshot", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + await client.extensions.reload({}); + await client.extensions.reload({ rootId: "user-global" }); + + const snap = await client.extensions.list(); + expect(snap!.roots).toHaveLength(1); + }); + + test("onChanged multicasts to multiple subscribers and emits per snapshot replacement", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + const ctrl1 = new AbortController(); + const ctrl2 = new AbortController(); + + const collect = async (it: AsyncIterable): Promise => { + let count = 0; + try { + for await (const _ of it) { + count += 1; + if (count >= 2) break; + } + } catch { + // Iterator throws on abort; counts already captured. + } + return count; + }; + + const it1 = await client.extensions.onChanged(undefined, { signal: ctrl1.signal }); + const it2 = await client.extensions.onChanged(undefined, { signal: ctrl2.signal }); + + const p1 = collect(it1); + const p2 = collect(it2); + + await client.extensions.reload({}); + await client.extensions.reload({}); + + const [c1, c2] = await Promise.race([ + Promise.all([p1, p2]), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out waiting for extension events")), 1000) + ), + ]); + ctrl1.abort(); + ctrl2.abort(); + + expect(c1).toBeGreaterThanOrEqual(2); + expect(c2).toBeGreaterThanOrEqual(2); + }); +}); + +describe("extensions IPC — trust / untrust", () => { + let env: TestExtensionRegistry; + let projectPath: string; + let projectRootId: string; + let projectRoot: ExtensionRootDescriptor; + + beforeEach(async () => { + projectPath = await fs.mkdtemp(path.join(os.tmpdir(), "mux-ext-ipc-trust-")); + projectRootId = `project-local:${projectPath}`; + projectRoot = { + rootId: projectRootId, + kind: "project-local", + path: projectPath, + trusted: true, + }; + env = await createTestExtensionRegistry({ + roots: async () => { + const projectTrusted = env.config.loadConfigOrDefault().projects.get(projectPath)?.trusted; + const rootTrusted = await env.projectState.isRootTrusted(projectPath); + return [{ ...projectRoot, trusted: projectTrusted === true && rootTrusted }]; + }, + discoverFn: stubDiscoverFn(({ roots }) => [ + makeRoot(roots[0], [], roots[0].trusted ?? false), + ]), + now: () => FROZEN_NOW, + }); + await env.config.editConfig((config) => { + config.projects.set(projectPath, { workspaces: [], trusted: true }); + return config; + }); + // Seed initial trust so the snapshot has the root before we toggle. + await env.projectState.setRootTrusted(projectPath, true); + }); + + afterEach(async () => { + await env.cleanup(); + await fs.rm(projectPath, { recursive: true, force: true }); + }); + + test("trustRoot flips on-disk trust, project trust, and re-runs reload", async () => { + await env.projectState.setRootTrusted(projectPath, false); + await env.config.editConfig((config) => { + config.projects.set(projectPath, { workspaces: [], trusted: false }); + return config; + }); + await env.registry.reload(); + + const client = createRouterClient(router(), { context: makeContext(env) }); + await client.extensions.trustRoot({ rootId: projectRootId }); + + expect(env.config.loadConfigOrDefault().projects.get(projectPath)?.trusted).toBe(true); + expect(await env.projectState.isRootTrusted(projectPath)).toBe(true); + const snap = await client.extensions.list(); + expect(snap!.roots[0].trusted).toBe(true); + }); + + test("trustRoot rolls back project trust when extension trust fails", async () => { + await env.projectState.setRootTrusted(projectPath, false); + await env.config.editConfig((config) => { + config.projects.set(projectPath, { workspaces: [], trusted: false }); + return config; + }); + await env.registry.reload(); + const originalTrustRoot = env.registry.trustRoot.bind(env.registry); + env.registry.trustRoot = (async (pathToTrust: string) => { + await env.projectState.setRootTrusted(pathToTrust, true); + throw new Error("trust failed"); + }) as typeof env.registry.trustRoot; + const client = createRouterClient(router(), { context: makeContext(env) }); + + try { + await expect(client.extensions.trustRoot({ rootId: projectRootId })).rejects.toBeInstanceOf( + Error + ); + expect(env.config.loadConfigOrDefault().projects.get(projectPath)?.trusted).toBe(false); + expect(await env.projectState.isRootTrusted(projectPath)).toBe(false); + } finally { + env.registry.trustRoot = originalTrustRoot; + } + }); + + test("untrustRoot flips on-disk trust, project trust, and re-runs reload", async () => { + await env.registry.reload(); + const client = createRouterClient(router(), { context: makeContext(env) }); + await client.extensions.untrustRoot({ rootId: projectRootId }); + + expect(env.config.loadConfigOrDefault().projects.get(projectPath)?.trusted).toBe(false); + expect(await env.projectState.isRootTrusted(projectPath)).toBe(false); + }); + + test("trustRoot rejects rootIds that aren't project-local", async () => { + // Seed with a user-global root that should never accept trust mutations. + const userRootId = "user-global"; + const env2 = await createTestExtensionRegistry({ + roots: () => [{ rootId: userRootId, kind: "user-global", path: "/fake/user-global" }], + discoverFn: stubDiscoverFn(({ roots }) => [makeRoot(roots[0], [])]), + now: () => FROZEN_NOW, + }); + await env2.registry.reload(); + const client = createRouterClient(router(), { context: makeContext(env2) }); + + await expect(client.extensions.trustRoot({ rootId: userRootId })).rejects.toBeInstanceOf( + ORPCError + ); + await env2.cleanup(); + }); + + test("trustRoot rejects unknown rootIds", async () => { + await env.registry.reload(); + const client = createRouterClient(router(), { context: makeContext(env) }); + await expect(client.extensions.trustRoot({ rootId: "no-such-root" })).rejects.toBeInstanceOf( + ORPCError + ); + }); +}); + +describe("extensions IPC — enable / disable / approve / revokeApproval", () => { + let env: TestExtensionRegistry; + const userRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + + beforeEach(async () => { + env = await createTestExtensionRegistry({ + roots: () => [userRoot], + discoverFn: stubDiscoverFn(({ roots }) => [ + makeRoot(roots[0], [ + makeExtension({ + extensionId: "author.skill", + rootId: "user-global", + rootKind: "user-global", + contributions: [{ type: "skills", id: "demo" }], + }), + ]), + ]), + now: () => FROZEN_NOW, + }); + await env.registry.reload(); + }); + + afterEach(async () => { + await env.cleanup(); + }); + + test("enable / disable update the global state record", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + + await client.extensions.disable({ rootId: "user-global", extensionId: "author.skill" }); + expect(env.globalState.load().state.extensions["author.skill"]?.enabled).toBe(false); + + await client.extensions.enable({ rootId: "user-global", extensionId: "author.skill" }); + expect(env.globalState.load().state.extensions["author.skill"]?.enabled).toBe(true); + }); + + test("approve / revokeApproval persist the approval record", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + + await client.extensions.approve({ + rootId: "user-global", + extensionId: "author.skill", + }); + const persisted = env.globalState.load().state.extensions["author.skill"]?.approval; + // Registry derives the approval record from the live manifest so drift + // detection works correctly; assert everything except the hash and verify + // the hash is a canonical SHA-256 instead. + expect(persisted).toMatchObject({ + grantedPermissions: SAMPLE_GRANT.grantedPermissions, + }); + expect(persisted?.requestedPermissionsHash).toMatch(/^[0-9a-f]{64}$/); + + await client.extensions.revokeApproval({ rootId: "user-global", extensionId: "author.skill" }); + expect(env.globalState.load().state.extensions["author.skill"]?.approval).toBeUndefined(); + }); + + test("enable rejects unknown rootIds", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + await expect( + client.extensions.enable({ rootId: "missing", extensionId: "author.skill" }) + ).rejects.toBeInstanceOf(ORPCError); + }); +}); + +describe("extensions IPC — project-local routing", () => { + let env: TestExtensionRegistry; + let projectPath: string; + let projectRootId: string; + let projectRoot: ExtensionRootDescriptor; + + beforeEach(async () => { + projectPath = await fs.mkdtemp(path.join(os.tmpdir(), "mux-ext-ipc-proj-")); + projectRootId = `project-local:${projectPath}`; + projectRoot = { + rootId: projectRootId, + kind: "project-local", + path: projectPath, + trusted: true, + }; + env = await createTestExtensionRegistry({ + roots: () => [projectRoot], + discoverFn: stubDiscoverFn(({ roots }) => [ + makeRoot( + roots[0], + [ + makeExtension({ + extensionId: "author.skill", + rootId: projectRootId, + rootKind: "project-local", + contributions: [{ type: "skills", id: "demo" }], + }), + ], + true + ), + ]), + now: () => FROZEN_NOW, + }); + await env.projectState.setRootTrusted(projectPath, true); + await env.registry.reload(); + }); + + afterEach(async () => { + await env.cleanup(); + await fs.rm(projectPath, { recursive: true, force: true }); + }); + + test("disable on a project-local root targets the project-local store, not global", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + await client.extensions.disable({ rootId: projectRootId, extensionId: "author.skill" }); + + const projectState = await env.projectState.load(projectPath); + expect(projectState.state.extensions["author.skill"]?.enabled).toBe(false); + expect(env.globalState.load().state.extensions["author.skill"]).toBeUndefined(); + }); + + test("approve on a project-local root persists to the project-local store", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + await client.extensions.approve({ + rootId: projectRootId, + extensionId: "author.skill", + }); + + const projectState = await env.projectState.load(projectPath); + const persisted = projectState.state.extensions["author.skill"]?.approval; + expect(persisted).toMatchObject({ + grantedPermissions: SAMPLE_GRANT.grantedPermissions, + }); + expect(persisted?.requestedPermissionsHash).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +describe("extensions IPC — forgetStale", () => { + let env: TestExtensionRegistry; + const userRoot: ExtensionRootDescriptor = { + rootId: "user-global", + kind: "user-global", + path: "/fake/user-global", + }; + + beforeEach(async () => { + env = await createTestExtensionRegistry({ + roots: () => [userRoot], + // Stub returns no extensions, but we'll seed a global grant so the + // record becomes stale. + discoverFn: stubDiscoverFn(({ roots }) => [makeRoot(roots[0], [])]), + now: () => FROZEN_NOW, + }); + await env.globalState.setApproval("vanished.ext", SAMPLE_GRANT); + await env.registry.reload(); + }); + + afterEach(async () => { + await env.cleanup(); + }); + + test("snapshot exposes a stale record with synthetic rootId", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + const snap = await client.extensions.list(); + expect(snap!.staleRecords).toHaveLength(1); + expect(snap!.staleRecords[0]).toMatchObject({ + scope: "global", + extensionId: "vanished.ext", + rootId: "global", + }); + }); + + test("forgetStale targeted by { rootId, extensionId } removes the grant", async () => { + const client = createRouterClient(router(), { context: makeContext(env) }); + await client.extensions.forgetStale({ rootId: "global", extensionId: "vanished.ext" }); + + const snap = await client.extensions.list(); + expect(snap!.staleRecords).toEqual([]); + expect(env.globalState.load().state.extensions["vanished.ext"]).toBeUndefined(); + }); +}); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index c32aed23ab..914a02b1c8 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1,7 +1,7 @@ import { os, ORPCError } from "@orpc/server"; import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBehavior"; import * as schemas from "@/common/orpc/schemas"; -import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import { EXPERIMENT_IDS, type ExperimentId } from "@/common/constants/experiments"; import type { ORPCContext } from "./context"; import { OnePasswordService } from "@/node/services/onePasswordService"; import { @@ -99,6 +99,8 @@ import { } from "@/node/services/subagentTranscriptArtifacts"; import { getErrorMessage } from "@/common/utils/errors"; import { isProjectTrusted } from "@/node/utils/projectTrust"; +import { installGitExtensionSource } from "@/node/extensions/gitExtensionSourceInstaller"; +import type { ExtensionScope } from "@/node/extensions/extensionRegistryService"; const RAW_QUERY_USER_ERROR_PATTERNS = [ /^parser error:/i, @@ -117,6 +119,29 @@ function shouldExposeRawQueryError(error: unknown): boolean { return RAW_QUERY_USER_ERROR_PATTERNS.some((pattern) => pattern.test(message)); } +function resolveExtensionScopeOrThrow(context: ORPCContext, rootId: string): ExtensionScope { + const scope = context.extensionRegistry.resolveScopeByRootId(rootId); + if (!scope) { + throw new ORPCError("NOT_FOUND", { + message: `rootId ${JSON.stringify(rootId)} does not match any Extension Root or Stale Record.`, + }); + } + return scope; +} + +function resolveProjectLocalExtensionScopeOrThrow( + context: ORPCContext, + rootId: string +): Extract { + const scope = resolveExtensionScopeOrThrow(context, rootId); + if (scope.kind !== "project-local") { + throw new ORPCError("NOT_FOUND", { + message: `rootId ${JSON.stringify(rootId)} does not resolve to a project-local Extension Root.`, + }); + } + return scope; +} + /** * Resolves runtime and discovery path for agent operations. * - When workspaceId is provided: uses workspace's runtime config (SSH, local, worktree) @@ -129,6 +154,7 @@ async function resolveAgentDiscoveryContext( ): Promise<{ runtime: ReturnType; discoveryPath: string; + projectPath: string; metadata?: WorkspaceMetadata; }> { if (!input.projectPath && !input.workspaceId) { @@ -147,7 +173,7 @@ async function resolveAgentDiscoveryContext( const discoveryPath = input.disableWorkspaceAgents ? metadata.projectPath : runtime.getWorkspacePath(metadata.projectPath, metadata.name); - return { runtime, discoveryPath, metadata }; + return { runtime, discoveryPath, projectPath: metadata.projectPath, metadata }; } // No workspace - use local runtime with project path @@ -155,7 +181,24 @@ async function resolveAgentDiscoveryContext( { type: "local", srcBaseDir: context.config.srcDir }, { projectPath: input.projectPath! } ); - return { runtime, discoveryPath: input.projectPath! }; + return { runtime, discoveryPath: input.projectPath!, projectPath: input.projectPath! }; +} + +async function setProjectTrustForExtensionRoot( + context: ORPCContext, + projectPath: string, + trusted: boolean +): Promise { + await context.config.editConfig((config) => { + const normalizedPath = stripTrailingSlashes(projectPath); + let project = config.projects.get(normalizedPath); + if (!project) { + project = { workspaces: [] }; + config.projects.set(normalizedPath, project); + } + project.trusted = trusted; + return config; + }); } function isImageGenerationToolExperimentEnabled(context: ORPCContext): boolean { @@ -1556,8 +1599,13 @@ export const router = (authToken?: string) => { if (input.workspaceId) { await context.aiService.waitForInit(input.workspaceId); } - const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input); - const skills = await discoverAgentSkills(runtime, discoveryPath); + const { runtime, discoveryPath, projectPath } = await resolveAgentDiscoveryContext( + context, + input + ); + const skills = await discoverAgentSkills(runtime, discoveryPath, { + extensionSkills: context.extensionRegistry.getSkillSources(projectPath), + }); return filterUnavailableImagegenSkills( skills, isImageGenerationToolExperimentEnabled(context) @@ -1571,8 +1619,13 @@ export const router = (authToken?: string) => { if (input.workspaceId) { await context.aiService.waitForInit(input.workspaceId); } - const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input); - const diagnostics = await discoverAgentSkillsDiagnostics(runtime, discoveryPath); + const { runtime, discoveryPath, projectPath } = await resolveAgentDiscoveryContext( + context, + input + ); + const diagnostics = await discoverAgentSkillsDiagnostics(runtime, discoveryPath, { + extensionSkills: context.extensionRegistry.getSkillSources(projectPath), + }); return { ...diagnostics, skills: filterUnavailableImagegenSkills( @@ -1589,8 +1642,13 @@ export const router = (authToken?: string) => { if (input.workspaceId) { await context.aiService.waitForInit(input.workspaceId); } - const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input); - const result = await readAgentSkill(runtime, discoveryPath, input.skillName); + const { runtime, discoveryPath, projectPath } = await resolveAgentDiscoveryContext( + context, + input + ); + const result = await readAgentSkill(runtime, discoveryPath, input.skillName, { + extensionSkills: context.extensionRegistry.getSkillSources(projectPath), + }); assertImagegenSkillAvailable(context, result); return result.package; }), @@ -5034,6 +5092,62 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => { await context.experimentsService.setOverride(input.experimentId, input.enabled); }), + onChanged: t + .input(schemas.experiments.onChanged.input) + .output(schemas.experiments.onChanged.output) + .handler(async function* ({ context, signal }) { + let resolveNext: (() => void) | null = null; + const pendingExperimentIds: ExperimentId[] = []; + let ended = false; + + const push = (experimentId: ExperimentId) => { + if (ended) return; + pendingExperimentIds.push(experimentId); + if (resolveNext) { + const resolve = resolveNext; + resolveNext = null; + resolve(); + } + }; + + const unsubscribe = context.experimentsService.onExperimentChanged(push); + + const onAbort = () => { + if (ended) return; + ended = true; + if (resolveNext) { + const resolve = resolveNext; + resolveNext = null; + resolve(); + } + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + try { + while (!ended) { + if (pendingExperimentIds.length > 0) { + const experimentId = pendingExperimentIds.shift(); + if (experimentId) yield experimentId; + continue; + } + + await new Promise((resolve) => { + resolveNext = resolve; + }); + } + } finally { + ended = true; + signal?.removeEventListener("abort", onAbort); + unsubscribe(); + } + }), reload: t .input(schemas.experiments.reload.input) .output(schemas.experiments.reload.output) @@ -5273,6 +5387,129 @@ export const router = (authToken?: string) => { ), })), }, + extensions: { + list: t + .input(schemas.extensions.list.input) + .output(schemas.extensions.list.output) + .handler( + ({ context }) => + context.extensionRegistry.getSnapshot() ?? context.extensionRegistry.getCachedSnapshot() + ), + onChanged: t + .input(schemas.extensions.onChanged.input) + .output(schemas.extensions.onChanged.output) + .handler(async function* ({ context, signal }) { + const queue = createAsyncEventQueue(); + const unsubscribe = context.extensionRegistry.onChanged(() => queue.push(undefined)); + const onAbort = () => queue.end(); + signal?.addEventListener("abort", onAbort, { once: true }); + try { + yield* queue.iterate(); + } finally { + signal?.removeEventListener("abort", onAbort); + unsubscribe(); + queue.end(); + } + }), + installGitSource: t + .input(schemas.extensions.installGitSource.input) + .output(schemas.extensions.installGitSource.output) + .handler(async ({ context, input }) => { + const result = await installGitExtensionSource({ + coordinate: input.coordinate, + muxRootDir: context.config.rootDir, + }); + // Git installs mutate the fetched global active view, so refresh the + // registry immediately instead of waiting for a watcher tick. + await context.extensionRegistry.reload(); + return result; + }), + initializeUserRoot: t + .input(schemas.extensions.initializeUserRoot.input) + .output(schemas.extensions.initializeUserRoot.output) + .handler(async ({ context }) => { + const rootPath = path.join(context.config.rootDir, "extensions", "local"); + await fsPromises.mkdir(rootPath, { recursive: true }); + await context.extensionRegistry.reloadRoot("user-global"); + }), + reload: t + .input(schemas.extensions.reload.input) + .output(schemas.extensions.reload.output) + .handler(async ({ context, input }) => { + if (input.rootId != null) { + await context.extensionRegistry.reloadRoot(input.rootId); + } else { + await context.extensionRegistry.reload(); + } + }), + trustRoot: t + .input(schemas.extensions.trustRoot.input) + .output(schemas.extensions.trustRoot.output) + .handler(async ({ context, input }) => { + const scope = resolveProjectLocalExtensionScopeOrThrow(context, input.rootId); + const previousProjectTrust = + context.config + .loadConfigOrDefault() + .projects.get(stripTrailingSlashes(scope.projectPath))?.trusted === true; + const previousExtensionRootTrust = await context.extensionRegistry.isProjectRootTrusted( + scope.projectPath + ); + await setProjectTrustForExtensionRoot(context, scope.projectPath, true); + try { + await context.extensionRegistry.trustRoot(scope.projectPath); + } catch (error) { + await setProjectTrustForExtensionRoot(context, scope.projectPath, previousProjectTrust); + await context.extensionRegistry.setProjectRootTrusted( + scope.projectPath, + previousExtensionRootTrust + ); + throw error; + } + }), + untrustRoot: t + .input(schemas.extensions.untrustRoot.input) + .output(schemas.extensions.untrustRoot.output) + .handler(async ({ context, input }) => { + const scope = resolveProjectLocalExtensionScopeOrThrow(context, input.rootId); + await setProjectTrustForExtensionRoot(context, scope.projectPath, false); + await context.extensionRegistry.untrustRoot(scope.projectPath); + }), + enable: t + .input(schemas.extensions.enable.input) + .output(schemas.extensions.enable.output) + .handler(async ({ context, input }) => { + const scope = resolveExtensionScopeOrThrow(context, input.rootId); + await context.extensionRegistry.setEnabled(scope, input.extensionId, true); + }), + disable: t + .input(schemas.extensions.disable.input) + .output(schemas.extensions.disable.output) + .handler(async ({ context, input }) => { + const scope = resolveExtensionScopeOrThrow(context, input.rootId); + await context.extensionRegistry.setEnabled(scope, input.extensionId, false); + }), + approve: t + .input(schemas.extensions.approve.input) + .output(schemas.extensions.approve.output) + .handler(async ({ context, input }) => { + const scope = resolveExtensionScopeOrThrow(context, input.rootId); + await context.extensionRegistry.setApproval(scope, input.extensionId); + }), + revokeApproval: t + .input(schemas.extensions.revokeApproval.input) + .output(schemas.extensions.revokeApproval.output) + .handler(async ({ context, input }) => { + const scope = resolveExtensionScopeOrThrow(context, input.rootId); + await context.extensionRegistry.removeApproval(scope, input.extensionId); + }), + forgetStale: t + .input(schemas.extensions.forgetStale.input) + .output(schemas.extensions.forgetStale.output) + .handler(async ({ context, input }) => { + const scope = resolveExtensionScopeOrThrow(context, input.rootId); + await context.extensionRegistry.forgetStale(scope, input.extensionId); + }), + }, ssh: { prompt: { subscribe: t diff --git a/src/node/services/agentSession.agentSkillSnapshot.test.ts b/src/node/services/agentSession.agentSkillSnapshot.test.ts index b2da249e16..8fb6de588c 100644 --- a/src/node/services/agentSession.agentSkillSnapshot.test.ts +++ b/src/node/services/agentSession.agentSkillSnapshot.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import type { ExtensionSkillSource } from "@/common/extensions/extensionSkillSource"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { Ok } from "@/common/types/result"; @@ -47,6 +48,7 @@ describe("AgentSession.sendMessage (agent skill snapshots)", () => { workspacePath: string; workspaceId?: string; runtimeConfig?: FrontendWorkspaceMetadata["runtimeConfig"]; + getExtensionSkillSources?: (projectPath: string) => readonly ExtensionSkillSource[]; }) { const workspaceId = args.workspaceId ?? "ws-test"; const workspaceMeta: FrontendWorkspaceMetadata = { @@ -59,6 +61,7 @@ describe("AgentSession.sendMessage (agent skill snapshots)", () => { } as unknown as FrontendWorkspaceMetadata; const { session, historyService, cleanup } = await createAgentSessionHarness({ workspaceId, + getExtensionSkillSources: args.getExtensionSkillSources, aiServiceOverrides: { getWorkspaceMetadata: mock((_workspaceId: string) => Promise.resolve(Ok(workspaceMeta))), }, @@ -465,6 +468,57 @@ describe("AgentSession.sendMessage (agent skill snapshots)", () => { expect(appendToHistory.mock.calls).toHaveLength(0); }); + // Regression for the user-reported bug: `/mux-extensions` returned + // "Agent skill not found" because agentSession's slash dispatch called + // readAgentSkill without the extensionSkills source list, even though the + // agentSkills.list IPC merge happily surfaced the skill in the slash menu. + it("resolves a slash invocation against an extension-contributed skill", async () => { + const { workspacePath } = await createTestWorkspaceWithSkills({ skills: [] }); + + // Stand up a fake extension skill body on disk; the ServiceContainer + // wires getExtensionSkillSources from ExtensionRegistry.getSkillSources(), + // which returns absolute body paths in this same shape. + const extPkgDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-ext-skill-")); + const bodyPath = path.join(extPkgDir, "SKILL.md"); + await fs.writeFile( + bodyPath, + "---\nname: mux-extensions\ndescription: Test extension skill\n---\n\nFollow the extension skill body.\n", + "utf-8" + ); + + const { session, appendToHistory, messages } = await createSessionHarness({ + workspacePath, + getExtensionSkillSources: () => [ + { + name: "mux-extensions", + displayName: "Mux Extensions", + description: "Test extension skill", + advertise: true, + bodyAbsolutePath: bodyPath, + extensionId: "mux.platformdemo", + }, + ], + }); + + const result = await session.sendMessage("explain extensions", { + model: "anthropic:claude-3-5-sonnet-latest", + agentId: "exec", + muxMetadata: { + type: "agent-skill", + rawCommand: "/mux-extensions explain extensions", + skillName: "mux-extensions", + scope: "extension", + }, + }); + + expect(result.success).toBe(true); + expect(appendToHistory.mock.calls).toHaveLength(2); + const [snapshotMessage] = messages; + expect(snapshotMessage.metadata?.agentSkillSnapshot?.skillName).toBe("mux-extensions"); + const snapshotText = snapshotMessage.parts.find((p) => p.type === "text")?.text; + expect(snapshotText).toContain("Follow the extension skill body."); + }); + it("dedupes against recent history per-skill", async () => { const { workspacePath } = await createTestWorkspaceWithSkills({ skills: [ diff --git a/src/node/services/agentSession.testHarness.ts b/src/node/services/agentSession.testHarness.ts index 0dad9eb81e..cb1edc9b77 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -5,6 +5,7 @@ import type { WorkspaceChatMessage } from "@/common/orpc/types"; import type { MuxMessage } from "@/common/types/message"; import { Ok } from "@/common/types/result"; import type { Config } from "@/node/config"; +import type { ExtensionSkillSource } from "@/common/extensions/extensionSkillSource"; import type { AIService } from "@/node/services/aiService"; import { AgentSession } from "@/node/services/agentSession"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; @@ -65,6 +66,7 @@ export interface AgentSessionHarnessOptions { backgroundProcessManager?: BackgroundProcessManager; backgroundProcessManagerOverrides?: Partial; captureEvents?: boolean; + getExtensionSkillSources?: (projectPath: string) => readonly ExtensionSkillSource[]; } export interface AgentSessionHarness { @@ -105,6 +107,7 @@ export async function createAgentSessionHarness( aiService, initStateManager, backgroundProcessManager, + getExtensionSkillSources: options.getExtensionSkillSources, }); const events: WorkspaceChatMessage[] = []; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8e36ccfde0..45e9d90826 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -79,6 +79,7 @@ import { } from "@/node/runtime/runtimeHelpers"; import { hasNonEmptyPlanFile } from "@/node/utils/runtime/helpers"; import { isExecLikeEditingCapableInResolvedChain } from "@/common/utils/agentTools"; +import type { ExtensionSkillSource } from "@/common/extensions/extensionSkillSource"; import { readAgentDefinition, resolveAgentFrontmatter, @@ -345,6 +346,12 @@ interface AgentSessionOptions { onCompactionComplete?: () => void; /** Called when post-compaction context state may have changed (plan/file edits) */ onPostCompactionStateChange?: () => void; + /** + * Resolves the live Extension Snapshot's skill contributions so slash-skill + * dispatch can read extension-provided SKILL.md bodies. Re-evaluated on every + * lookup so extension reload events take effect immediately. + */ + getExtensionSkillSources?: (projectPath: string) => readonly ExtensionSkillSource[]; } enum TurnPhase { @@ -368,6 +375,9 @@ export class AgentSession { private readonly keepBackgroundProcesses: boolean; private readonly onCompactionComplete?: () => void; private readonly onPostCompactionStateChange?: () => void; + private readonly getExtensionSkillSources?: ( + projectPath: string + ) => readonly ExtensionSkillSource[]; private readonly emitter = new EventEmitter(); private readonly aiListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; @@ -531,6 +541,7 @@ export class AgentSession { keepBackgroundProcesses, onCompactionComplete, onPostCompactionStateChange, + getExtensionSkillSources, } = options; assert(typeof workspaceId === "string", "workspaceId must be a string"); @@ -548,6 +559,7 @@ export class AgentSession { this.keepBackgroundProcesses = keepBackgroundProcesses ?? false; this.onCompactionComplete = onCompactionComplete; this.onPostCompactionStateChange = onPostCompactionStateChange; + this.getExtensionSkillSources = getExtensionSkillSources; this.compactionHandler = new CompactionHandler({ workspaceId: this.workspaceId, @@ -5862,7 +5874,13 @@ export class AgentSession { let resolved: Awaited>; try { - resolved = await readAgentSkill(runtime, skillDiscoveryPath, parsedName.data); + // Inject the live extension skill sources so /skill-name dispatch + // resolves bundled / user-global / project-local extension skills, + // not just project + global + built-in. The list is re-evaluated on + // every call so extension reloads take effect immediately. + resolved = await readAgentSkill(runtime, skillDiscoveryPath, parsedName.data, { + extensionSkills: this.getExtensionSkillSources?.(metadata.projectPath), + }); } catch (error) { if (ref.source === "slash") { throw error; diff --git a/src/node/services/agentSkills/agentSkillsService.test.ts b/src/node/services/agentSkills/agentSkillsService.test.ts index a70fa56ec1..0da83e6bd1 100644 --- a/src/node/services/agentSkills/agentSkillsService.test.ts +++ b/src/node/services/agentSkills/agentSkillsService.test.ts @@ -1,13 +1,14 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { SkillNameSchema } from "@/common/orpc/schemas"; import { DevcontainerRuntime } from "@/node/runtime/DevcontainerRuntime"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; import { RemoteRuntime, type SpawnResult } from "@/node/runtime/RemoteRuntime"; import { resolveSkillStorageContext } from "@/node/services/agentSkills/skillStorageContext"; +import { MAX_FILE_SIZE } from "@/node/services/tools/fileCommon"; import { DisposableTempDir } from "@/node/services/tempDir"; import { discoverAgentSkills, @@ -591,6 +592,286 @@ describe("agentSkillsService", () => { expect(resolved.skillDir).toBe(""); }); + test("extension-contributed skills appear in discovery and resolve via readAgentSkill", async () => { + using project = new DisposableTempDir("agent-skills-project"); + using global = new DisposableTempDir("agent-skills-global"); + using extPkg = new DisposableTempDir("agent-skills-ext"); + + const projectSkillsRoot = path.join(project.path, ".mux", "skills"); + const globalSkillsRoot = global.path; + const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot }; + const runtime = new LocalRuntime(project.path); + + // The body lives at packagePath/SKILL.md — exactly what + // ExtensionRegistry.getSkillSources() returns. + const bodyPath = path.join(extPkg.path, "SKILL.md"); + await fs.writeFile( + bodyPath, + `--- +name: mux-extensions +description: Demo extension skill +--- +Body content from extension +`, + "utf-8" + ); + + const extensionSkills = [ + { + name: "mux-extensions", + displayName: "Mux Extensions", + description: "Demo extension skill", + advertise: true, + bodyAbsolutePath: bodyPath, + extensionId: "mux.platformdemo", + }, + ]; + + const skills = await discoverAgentSkills(runtime, project.path, { roots, extensionSkills }); + const found = skills.find((s) => s.name === "mux-extensions"); + expect(found).toBeDefined(); + expect(found!.scope).toBe("extension"); + + const name = SkillNameSchema.parse("mux-extensions"); + const resolved = await readAgentSkill(runtime, project.path, name, { roots, extensionSkills }); + expect(resolved.package.scope).toBe("extension"); + expect(resolved.package.frontmatter.name).toBe("mux-extensions"); + expect(resolved.package.body).toContain("Body content from extension"); + }); + + test("readAgentSkill synthesizes frontmatter for manifest-backed extension bodies", async () => { + using project = new DisposableTempDir("agent-skills-project"); + using global = new DisposableTempDir("agent-skills-global"); + using extPkg = new DisposableTempDir("agent-skills-ext"); + + const roots = { + projectRoot: path.join(project.path, ".mux", "skills"), + globalRoot: global.path, + }; + const bodyPath = path.join(extPkg.path, "SKILL.md"); + await fs.writeFile(bodyPath, "Plain manifest-backed body", "utf-8"); + + const runtime = new LocalRuntime(project.path); + const name = SkillNameSchema.parse("mux-extensions"); + const resolved = await readAgentSkill(runtime, project.path, name, { + roots, + extensionSkills: [ + { + name, + displayName: "Mux Extensions", + description: "Demo extension skill", + advertise: true, + bodyAbsolutePath: bodyPath, + extensionId: "mux.platformdemo", + }, + ], + }); + + expect(resolved.package.scope).toBe("extension"); + expect(resolved.package.frontmatter).toMatchObject({ + name: "mux-extensions", + description: "Demo extension skill", + advertise: true, + }); + expect(resolved.package.body).toBe("Plain manifest-backed body"); + }); + + test("readAgentSkill falls back when an extension skill body disappears", async () => { + using project = new DisposableTempDir("agent-skills-project"); + using global = new DisposableTempDir("agent-skills-global"); + + const roots = { + projectRoot: path.join(project.path, ".mux", "skills"), + globalRoot: global.path, + }; + const runtime = new LocalRuntime(project.path); + const name = SkillNameSchema.parse("mux-docs"); + + const resolved = await readAgentSkill(runtime, project.path, name, { + roots, + extensionSkills: [ + { + name, + displayName: "Mux Docs", + description: "stale extension descriptor", + advertise: true, + bodyAbsolutePath: path.join(project.path, "missing-extension-skill.md"), + extensionId: "publisher.docs", + }, + ], + }); + + expect(resolved.package.scope).toBe("built-in"); + }); + + test("readAgentSkill falls back when an extension skill body becomes a symlink", async () => { + using project = new DisposableTempDir("agent-skills-project"); + using global = new DisposableTempDir("agent-skills-global"); + using extPkg = new DisposableTempDir("agent-skills-ext"); + + const roots = { + projectRoot: path.join(project.path, ".mux", "skills"), + globalRoot: global.path, + }; + const bodyPath = path.join(extPkg.path, "SKILL.md"); + const outsidePath = path.join(project.path, "outside.md"); + await fs.writeFile(bodyPath, "Plain manifest-backed body", "utf-8"); + await fs.writeFile(outsidePath, "outside content", "utf-8"); + await fs.unlink(bodyPath); + await fs.symlink(outsidePath, bodyPath); + + const runtime = new LocalRuntime(project.path); + const name = SkillNameSchema.parse("mux-docs"); + const resolved = await readAgentSkill(runtime, project.path, name, { + roots, + extensionSkills: [ + { + name, + displayName: "Mux Docs", + description: "symlinked extension descriptor", + advertise: true, + bodyAbsolutePath: bodyPath, + extensionId: "publisher.docs", + }, + ], + }); + + expect(resolved.package.scope).toBe("built-in"); + }); + + test("readAgentSkill falls back when an opened extension skill body resolves elsewhere", async () => { + using project = new DisposableTempDir("agent-skills-project"); + using global = new DisposableTempDir("agent-skills-global"); + using extPkg = new DisposableTempDir("agent-skills-ext"); + + const roots = { + projectRoot: path.join(project.path, ".mux", "skills"), + globalRoot: global.path, + }; + const bodyPath = path.join(extPkg.path, "SKILL.md"); + const outsidePath = path.join(project.path, "outside.md"); + await fs.writeFile( + bodyPath, + "---\nname: mux-docs\ndescription: Extension docs\n---\ntrusted extension body", + "utf-8" + ); + await fs.writeFile( + outsidePath, + "---\nname: mux-docs\ndescription: Outside docs\n---\noutside secret", + "utf-8" + ); + + const originalOpen = fs.open; + const openSpy = spyOn(fs, "open"); + openSpy.mockImplementation((( + target: Parameters[0], + flags?: Parameters[1], + mode?: Parameters[2] + ) => + originalOpen( + String(target) === bodyPath ? outsidePath : target, + flags, + mode + )) as typeof fs.open); + + try { + const runtime = new LocalRuntime(project.path); + const name = SkillNameSchema.parse("mux-docs"); + const resolved = await readAgentSkill(runtime, project.path, name, { + roots, + extensionSkills: [ + { + name, + displayName: "Mux Docs", + description: "body opened outside the validated path", + advertise: true, + bodyAbsolutePath: bodyPath, + extensionId: "publisher.docs", + }, + ], + }); + + expect(resolved.package.scope).toBe("built-in"); + expect(resolved.package.body).not.toContain("outside secret"); + } finally { + openSpy.mockRestore(); + } + }); + + test("readAgentSkill falls back when an extension skill body becomes oversized", async () => { + using project = new DisposableTempDir("agent-skills-project"); + using global = new DisposableTempDir("agent-skills-global"); + using extPkg = new DisposableTempDir("agent-skills-ext"); + + const roots = { + projectRoot: path.join(project.path, ".mux", "skills"), + globalRoot: global.path, + }; + const bodyPath = path.join(extPkg.path, "SKILL.md"); + await fs.writeFile(bodyPath, "x".repeat(MAX_FILE_SIZE + 1), "utf-8"); + + const runtime = new LocalRuntime(project.path); + const name = SkillNameSchema.parse("mux-docs"); + const resolved = await readAgentSkill(runtime, project.path, name, { + roots, + extensionSkills: [ + { + name, + displayName: "Mux Docs", + description: "oversized extension descriptor", + advertise: true, + bodyAbsolutePath: bodyPath, + extensionId: "publisher.docs", + }, + ], + }); + + expect(resolved.package.scope).toBe("built-in"); + }); + + test("project skills shadow extension skills of the same name", async () => { + using project = new DisposableTempDir("agent-skills-project"); + using global = new DisposableTempDir("agent-skills-global"); + using extPkg = new DisposableTempDir("agent-skills-ext"); + + const projectSkillsRoot = path.join(project.path, ".mux", "skills"); + const globalSkillsRoot = global.path; + const roots = { projectRoot: projectSkillsRoot, globalRoot: globalSkillsRoot }; + const runtime = new LocalRuntime(project.path); + + await writeSkill(projectSkillsRoot, "shared-name", "from project"); + const bodyPath = path.join(extPkg.path, "SKILL.md"); + await fs.writeFile( + bodyPath, + `--- +name: shared-name +description: from extension +--- +ext body +`, + "utf-8" + ); + + const extensionSkills = [ + { + name: "shared-name", + displayName: "Shared Name", + description: "from extension", + advertise: true, + bodyAbsolutePath: bodyPath, + extensionId: "publisher.skills", + }, + ]; + + const skills = await discoverAgentSkills(runtime, project.path, { roots, extensionSkills }); + const sharedName = skills.find((s) => s.name === "shared-name"); + // Project precedence > extension precedence (PRD: project > global > + // extension > built-in). The user's own `/.mux/skills` always + // wins so installing an extension can't silently rewrite a hand-edited + // skill. + expect(sharedName?.scope).toBe("project"); + }); + test("project/global skills override built-in skills", async () => { using project = new DisposableTempDir("agent-skills-project"); using global = new DisposableTempDir("agent-skills-global"); diff --git a/src/node/services/agentSkills/agentSkillsService.ts b/src/node/services/agentSkills/agentSkillsService.ts index 89bb4bd970..d55583a038 100644 --- a/src/node/services/agentSkills/agentSkillsService.ts +++ b/src/node/services/agentSkills/agentSkillsService.ts @@ -1,3 +1,5 @@ +import * as path from "node:path"; +import { constants as fsConstants } from "node:fs"; import * as fs from "node:fs/promises"; import type { Runtime } from "@/node/runtime/Runtime"; @@ -19,10 +21,12 @@ import type { AgentSkillScope, SkillName, } from "@/common/types/agentSkill"; +import type { ExtensionSkillSource } from "@/common/extensions/extensionSkillSource"; import { log } from "@/node/services/log"; import { validateFileSize } from "@/node/services/tools/fileCommon"; import { ensureRuntimePathWithinWorkspace } from "@/node/services/tools/runtimeSkillPathUtils"; import { ensurePathContained, hasErrorCode } from "@/node/services/tools/skillFileUtils"; +import { realpathOpenedFile } from "@/node/utils/openedFileRealpath"; import { AgentSkillParseError, parseSkillMarkdown } from "./parseSkillMarkdown"; import { getBuiltInSkillByName, getBuiltInSkillDescriptors } from "./builtInSkillDefinitions"; import type { ProjectSkillContainment } from "./skillStorageContext"; @@ -333,6 +337,7 @@ export async function discoverAgentSkills( containment?: ProjectSkillContainment; projectContainmentRoot?: string | null; dedupeByName?: boolean; + extensionSkills?: readonly ExtensionSkillSource[]; } ): Promise { if (!workspacePath) { @@ -416,9 +421,38 @@ export async function discoverAgentSkills( } } + // Extensions sit between user-authored (project/global) and built-in: a + // project skill can shadow an extension skill of the same name (so users + // keep editorial control), and an extension can shadow a built-in. + for (const ext of options?.extensionSkills ?? []) { + const parsed = SkillNameSchema.safeParse(ext.name); + if (!parsed.success) { + log.warn(`Skipping invalid extension skill name '${ext.name}'`); + continue; + } + const descriptor: AgentSkillDescriptor = { + name: parsed.data, + description: ext.description || ext.name, + scope: "extension", + advertise: ext.advertise, + }; + const validated = AgentSkillDescriptorSchema.safeParse(descriptor); + if (!validated.success) { + log.warn(`Invalid extension skill descriptor '${ext.name}': ${validated.error.message}`); + continue; + } + if (dedupeByName) { + if (!byName.has(validated.data.name)) { + byName.set(validated.data.name, validated.data); + } + continue; + } + discoveredSkills.push(validated.data); + } + for (const builtIn of getBuiltInSkillDescriptors()) { if (dedupeByName) { - // Built-ins are lowest precedence and are omitted when overridden by project/global skills. + // Built-ins are lowest precedence and are omitted when overridden by project/global/extension skills. if (!byName.has(builtIn.name)) { byName.set(builtIn.name, builtIn); } @@ -444,6 +478,7 @@ export async function discoverAgentSkillsDiagnostics( roots?: AgentSkillsRoots; containment?: ProjectSkillContainment; projectContainmentRoot?: string | null; + extensionSkills?: readonly ExtensionSkillSource[]; } ): Promise { if (!workspacePath) { @@ -536,7 +571,21 @@ export async function discoverAgentSkillsDiagnostics( } } - // Add built-in skills (lowest precedence - only if not overridden by project/global) + for (const ext of options?.extensionSkills ?? []) { + const parsed = SkillNameSchema.safeParse(ext.name); + if (!parsed.success) continue; + if (byName.has(parsed.data)) continue; + const descriptor: AgentSkillDescriptor = { + name: parsed.data, + description: ext.description || ext.name, + scope: "extension", + advertise: ext.advertise, + }; + const validated = AgentSkillDescriptorSchema.safeParse(descriptor); + if (validated.success) byName.set(validated.data.name, validated.data); + } + + // Add built-in skills (lowest precedence - only if not overridden by project/global/extension) for (const builtIn of getBuiltInSkillDescriptors()) { if (!byName.has(builtIn.name)) { byName.set(builtIn.name, builtIn); @@ -548,7 +597,8 @@ export async function discoverAgentSkillsDiagnostics( const scopeOrder: Readonly> = { project: 0, global: 1, - "built-in": 2, + extension: 2, + "built-in": 3, }; invalidSkills.sort((a, b) => { @@ -615,6 +665,59 @@ async function readAgentSkillFromDir( }; } +async function readExtensionSkillBody(bodyAbsolutePath: string): Promise<{ + content: string; + byteSize: number; +}> { + const linkStat = await fs.lstat(bodyAbsolutePath); + if (linkStat.isSymbolicLink()) { + throw new Error(`Extension skill body cannot be a symlink: ${bodyAbsolutePath}`); + } + + const realPath = await fs.realpath(bodyAbsolutePath); + if (path.normalize(realPath) !== path.normalize(bodyAbsolutePath)) { + throw new Error(`Extension skill body escaped its validated path: ${bodyAbsolutePath}`); + } + + const noFollow = fsConstants.O_NOFOLLOW ?? 0; + const handle = await fs.open(realPath, fsConstants.O_RDONLY | noFollow); + try { + const openedRealPath = await realpathOpenedFile(handle, realPath); + if (path.normalize(openedRealPath) !== path.normalize(realPath)) { + throw new Error( + `Extension skill body opened outside its validated path: ${bodyAbsolutePath}` + ); + } + + const stat = await handle.stat(); + if (stat.isDirectory()) { + throw new Error(`Extension skill body is a directory: ${bodyAbsolutePath}`); + } + const sizeValidation = validateFileSize({ + size: stat.size, + modifiedTime: stat.mtime, + isDirectory: stat.isDirectory(), + }); + if (sizeValidation) { + throw new Error(sizeValidation.error); + } + + const content = await handle.readFile("utf8"); + const byteSize = Buffer.byteLength(content, "utf8"); + const readSizeValidation = validateFileSize({ + size: byteSize, + modifiedTime: stat.mtime, + isDirectory: false, + }); + if (readSizeValidation) { + throw new Error(readSizeValidation.error); + } + return { content, byteSize }; + } finally { + await handle.close(); + } +} + export async function readAgentSkill( runtime: Runtime, workspacePath: string, @@ -623,6 +726,7 @@ export async function readAgentSkill( roots?: AgentSkillsRoots; containment?: ProjectSkillContainment; projectContainmentRoot?: string | null; + extensionSkills?: readonly ExtensionSkillSource[]; } ): Promise { if (!workspacePath) { @@ -676,6 +780,54 @@ export async function readAgentSkill( } } + // Extension-contributed skills sit between user-authored and built-in: + // checked only after no project/global skill of the same name resolved. + // The body lives on the host filesystem (extensions live alongside the + // app, not inside the workspace runtime), so we read it directly via + // node:fs and bypass the workspace runtime's containment rules. + const extensionSkill = (options?.extensionSkills ?? []).find((s) => s.name === name); + if (extensionSkill) { + try { + const { content, byteSize } = await readExtensionSkillBody(extensionSkill.bodyAbsolutePath); + const parsed = content.startsWith("---") + ? parseSkillMarkdown({ + content, + byteSize, + directoryName: name, + }) + : { + frontmatter: { + name, + description: + extensionSkill.description.trim() || + extensionSkill.displayName.trim() || + extensionSkill.name, + advertise: extensionSkill.advertise, + }, + body: content, + }; + const pkg: AgentSkillPackage = { + scope: "extension", + directoryName: name, + frontmatter: parsed.frontmatter, + body: parsed.body, + }; + const validated = AgentSkillPackageSchema.safeParse(pkg); + if (!validated.success) { + throw new Error( + `Invalid extension skill package for '${name}': ${validated.error.message}` + ); + } + return { + package: validated.data, + skillDir: path.dirname(extensionSkill.bodyAbsolutePath), + sourceRuntime: null, + }; + } catch (error) { + log.warn(`Skipping unavailable extension skill '${name}': ${getErrorMessage(error)}`); + } + } + // Check built-in skills as fallback const builtIn = getBuiltInSkillByName(name); if (builtIn) { diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 2fb05b3bd4..3e11cea09c 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -3549,6 +3549,14 @@ export const BUILTIN_SKILL_FILES: Record> = { ' "pages": ["integrations/vscode-extension", "integrations/acp"]', " },", " {", + ' "group": "Extensions",', + ' "pages": [', + ' "extensions/authoring",', + ' "extensions/telemetry",', + ' "extensions/release-checklist"', + " ]", + " },", + " {", ' "group": "Reference",', ' "pages": [', ' "reference/debugging",', @@ -3620,6 +3628,595 @@ export const BUILTIN_SKILL_FILES: Record> = { "}", "", ].join("\n"), + "references/docs/extensions/authoring.mdx": [ + "---", + "title: Authoring an Extension Module", + "description: Quickstart for authoring a Mux Extension Module with a static manifest and skill registration.", + "---", + "", + "A Mux **Extension Module** is a folder whose basename is the **Extension Name** and", + "which contains an `extension.ts` entrypoint. Extension Modules do not need", + "`package.json`, npm publishing, or install scripts. Source/version metadata lives", + "in Extension Source Locks; trust, enablement, and capability approvals live in", + "Mux-controlled state outside project repositories.", + "", + "## Quickstart: a single-skill Extension Module", + "", + "This walkthrough produces an Extension Module with one advertised skill.", + "", + "### 1. Lay out the module", + "", + "```text", + "acme-review/", + "├── extension.ts", + "└── skills/", + " └── review/", + " └── SKILL.md", + "```", + "", + "For local authoring, scaffold the folder under Mux's local extension area:", + "", + "```sh", + "mux extensions create acme-review", + "```", + "", + "You can also create the same layout manually under `~/.mux/extensions/local/`.", + "", + "Project repositories may vendor source under", + "`/.mux/extensions//`, but project-local source remains", + "existence-only until the project/root is trusted.", + "", + "### 2. Write `extension.ts`", + "", + "```ts", + 'import { defineManifest } from "mux:extensions";', + "", + "export const manifest = defineManifest({", + ' name: "acme-review",', + ' displayName: "Acme Review",', + ' description: "Review helpers for Acme.",', + " capabilities: {", + " skills: true,", + " },", + "});", + "", + "export function activate(ctx) {", + " ctx.skills.register({", + ' name: "review",', + ' bodyPath: "./skills/review/SKILL.md",', + " });", + "}", + "```", + "", + "Key constraints surface as diagnostics on the Extension Card:", + "", + "- `manifest.name` must match the Extension Module folder basename.", + "- The Extension Name must satisfy the Extension Name schema.", + "- `manifest.capabilities.skills === true` is required before", + " `ctx.skills.register` can succeed.", + "- Static Manifest extraction does not execute extension code.", + "- Extension code may import only contained relative modules or `mux:*` virtual", + " modules; npm and other bare imports are rejected in v1.", + "", + "### 3. Write the skill body", + "", + "```markdown", + "---", + "name: review", + "description: Review the current changes", + "---", + "", + "# Review", + "", + "Instructions for the agent.", + "```", + "", + "The registered skill `name` must match the `SKILL.md` frontmatter name.", + "`bodyPath` is resolved inside the Extension Module realpath. Absolute paths,", + "`..` traversal, and symlink escapes are rejected.", + "", + "### 4. Reload and approve", + "", + "Run **Reload Extensions** from the command palette. The new Extension appears in", + "**Settings → Extensions** under the matching root.", + "", + "Project-local roots require trust before Mux fetches, parses, transpiles, or", + "executes repo-controlled extension source. After trust, Mux runs Registration", + "Discovery to preview requested registrations. Enable the extension and approve", + "its requested capabilities before Full Activation publishes live skills.", + "", + "## Static Manifest reference", + "", + "| Field | Type | Required | Notes |", + "| -------------- | --------------------- | -------- | -------------------------------------------------- |", + "| `name` | Extension Name string | yes | Must match the containing folder basename. |", + "| `displayName` | string | no | User-facing name on the Extension Card. |", + "| `description` | string | no | User-facing description on the Extension Card. |", + "| `capabilities` | object | no | V1 supports `skills: true` for skill registration. |", + "", + "Unknown static manifest fields produce diagnostics. They do not grant authority.", + "", + "## Capabilities and approvals", + "", + "V1 exposes one registration capability: `skills`. Declaring", + "`capabilities.skills: true` allows `activate(ctx)` to call", + "`ctx.skills.register` during Registration Discovery and Full Activation.", + "", + "Effective Capabilities = `requested ∩ approved`. New requested capabilities are", + "never auto-approved; they surface as `Pending re-approval` on the card. Source", + "coordinates, content hashes, and vendored paths do not cause approval drift.", + "", + "## Discovery and activation", + "", + "Mux runs `activate(ctx)` in two modes:", + "", + '1. **Registration Discovery** after root trust. `ctx.mode === "discover"`,', + " `ctx.skills.register` records intended skills, and returned disposables are", + " no-ops.", + "2. **Full Activation** after trust, enablement, and capability approval.", + " Activation may publish only skills observed during Registration Discovery.", + "", + "Discovery or activation failures surface diagnostics and do not crash startup.", + "", + "## Source locks", + "", + "Global installs write source metadata under `~/.mux/extensions/lock.json`.", + "Project repositories may commit `/.mux/extensions.lock.json` and may", + "vendor sources under `/.mux/extensions//`. Lockfiles are", + "reproducibility metadata only: they cannot inject trust, enablement, or approval", + "state.", + "", + ].join("\n"), + "references/docs/extensions/release-checklist.mdx": [ + "---", + "title: Extension Platform Release Checklist", + "description: Pre-release dogfood checklist for the Mux Extension Platform with screenshot/video evidence requirements.", + "---", + "", + "A reproducible sign-off pass for the Mux Extension Platform. Every step", + "listed below MUST be executed manually against a release build before a", + "v1 release ships, with the evidence below attached to the release PR.", + "", + "- **Screenshot** = a still image of the relevant UI state.", + "- **Video** = a short screen recording covering the full interaction", + " (start to stable end state).", + "", + "The Consent Shortcut flow and the always-on restart smoke always require", + "**video** because they exercise multi-step state transitions; everything", + "else is screenshot-only unless noted.", + "", + "## Before you start", + "", + "- Build the release artifact you intend to ship (Electron + dev-server).", + "- Use a clean macOS, Windows, and Linux profile each — the user-global", + " root sits at `~/.mux/extensions/` and prior dogfood runs leak state", + " across runs unless deleted.", + "- Have one third-party Extension package available locally for the", + " install / update / drift steps. The bundled", + " [Demo Extension](/extensions/authoring) is fine as a copy-paste base.", + "", + "## Checklist", + "", + "The order below matches the natural cold-start → install → drift →", + "recovery progression, so a single pass produces a coherent narrative for", + "the release PR.", + "", + "### 1. Cold start with no user-global root", + "", + "**Goal.** Section renders the initialize affordance with no errors.", + "", + "- Delete `~/.mux/extensions/`.", + "- Launch Mux. Open **Settings → Extensions**.", + "- Confirm: header shows zero error and zero warning counts; the", + " user-global subsection shows the **Initialize User Extensions Root**", + " affordance plus a 2-sentence explanation.", + "", + "**Evidence.** Screenshot of the section in this state.", + "", + "### 2. Cold start with a malformed user-global module", + "", + "**Goal.** Section surfaces module diagnostics; the app starts normally.", + "", + "- With Mux closed, create", + " `~/.mux/extensions/local/broken-extension/extension.ts` with a malformed", + " static manifest export.", + "- Launch Mux. Open **Settings → Extensions**.", + "- Confirm: the user-global subsection shows a diagnostic for the broken", + " Extension Module and the rest of the app remains usable.", + "", + "**Evidence.** Screenshot of the diagnostic state.", + "", + "### 3. Initialize, install, reload", + "", + "**Goal.** Newly-installed Extensions become visible without restart.", + "", + "- Click **Initialize User Extensions Root** in the action row.", + "- In a terminal: `create or copy a module under ~/.mux/extensions/local/`.", + "- Run **Reload Extensions** from the command palette.", + "- Confirm: the new Extension appears as a card in the user-global", + " subsection.", + "", + "**Evidence.** Screenshot **before** reload (no card) and **after**", + "reload (card visible).", + "", + "### 4. Project-local trust ladder", + "", + "**Goal.** Pre-trust shows the prerequisite card; approving trust unlocks", + "inspection-only mode.", + "", + "- Open a project that contains a `.mux/extensions/` directory, with", + " project trust unapproved.", + "- Confirm: the project-local subsection shows the **project-trust", + " prerequisite** card and renders no Extension cards.", + "- Approve project trust (the existing Mux project-trust flow).", + "- Confirm: Safe Manifest Inspection runs and Extension cards appear in", + " **inspection-only mode** (no Enable / Approve actions yet).", + "", + "**Evidence.** Screenshot of each state (untrusted, trusted +", + "inspection-only).", + "", + "### 5. Consent Shortcut flow (video)", + "", + "**Goal.** The one-click Consent Shortcut applies trust + enable + approval", + "in one transaction; **Review individually** falls through to the", + "granular ladder.", + "", + "- Click **Quick Setup** on a project-local Extension card.", + "- In the Consent Shortcut Modal, verify the summary lists: identity,", + " Extension Name, description, and the requested", + " capabilities.", + "- Confirm Quick Setup. Verify the card reaches `Enabled`.", + "- Repeat the install on a second Extension and click **Review", + " individually** instead. Verify the granular Trust → Enable → Approve", + " controls become visible on the card and that each step succeeds", + " independently.", + "", + "**Evidence.** **Video** capturing both the shortcut and granular paths", + "end-to-end.", + "", + "### 6. Drift surfacing on capability update", + "", + "**Goal.** Adding a new requested capability shows `Pending re-approval`", + "without a modal; previously available contributions remain available.", + "", + "- Update an installed Extension to request a new capability.", + "- Run **Reload Extensions**.", + "- Confirm: the card shows a `Pending re-approval` pill and the", + " **Review Pending Extension Capabilities** action appears in the action", + " row. No modal is shown. Previously available contributions are still", + " marked Available.", + "", + "**Evidence.** Screenshot of the card with the `Pending re-approval` pill.", + "", + "### 7. Drift surfacing on new contribution type", + "", + "**Goal.** Adding a new contribution type triggers Capability approval", + "drift; the new contribution stays Unavailable until re-approval.", + "", + "- Update the same Extension to add a contribution of a type it did not", + " previously declare (e.g., add a `themes` entry to a previously", + " skill-only Extension).", + "- Run **Reload Extensions**.", + "- Confirm: the card surfaces drift; the new contribution row shows", + " `Unavailable` until re-approval.", + "- Re-approve. Confirm: the new contribution becomes Available.", + "", + "**Evidence.** Screenshot of the Unavailable state pre-approval and the", + "Available state post-approval.", + "", + "### 8. Always-on restart smoke (video)", + "", + "**Goal.** The Extension Platform remains available across reloads and", + "restart-like renderer refreshes with no experiment or Governor kill switch.", + "", + "- Open **Settings → Extensions** and confirm the section is visible.", + "- Run **Reload Extensions** and confirm the bundled Demo Extension remains", + " visible.", + "- Reload the renderer or restart the app.", + "- Confirm: the **Extensions** section and previously trusted roots, enabled", + " Extensions, and approved capabilities are intact; no consent shortcut", + " fires for bundled Extensions.", + "", + "**Evidence.** **Video** covering the reload/restart round trip.", + "", + "### 9. Reserved-prefix rejection", + "", + "**Goal.** A non-bundled Extension claiming a `mux.*` identity is", + "rejected with `extension.identity.reserved` and contributes nothing.", + "", + "- Install (user-global or project-local) a third-party Extension whose", + ' manifest declares `id: "mux.foo"`.', + "- Run **Reload Extensions**.", + "- Confirm: the card surfaces the `extension.identity.reserved`", + " diagnostic; the contributions table is empty (or every row marked", + " Unavailable); the Extension does not register any capability.", + "", + "**Evidence.** Screenshot of the card with the diagnostic visible.", + "", + "### 10. Debug snapshot capture", + "", + "**Goal.** `bun run debug extensions` produces structured JSON in cold,", + "post-install, and post-failure states; no third-party identifiers leak", + "into telemetry.", + "", + "- Run `bun run debug extensions` (cold), `bun run debug extensions`", + " (post-install), and `bun run debug extensions` (post-failure: induce", + " a malformed manifest and re-run).", + "- Optionally narrow with `--root `.", + "- Spot-check telemetry events captured during the same session", + " (PostHog console, dev tools, or whichever sink is configured): no", + " event payload contains a third-party Extension identifier. The", + " catalog this should be checked against lives at", + " [Extension Telemetry](/extensions/telemetry).", + "", + "**Evidence.** Output capture (text) of each run; brief note confirming", + "the telemetry spot-check.", + "", + "### 11. Stale Record handling", + "", + "**Goal.** Removing an installed Extension from disk leaves its Approval", + "Record visible with explicit **Forget** and **Keep** actions.", + "", + "- Uninstall an Extension you previously approved (delete from", + " `~/.mux/extensions/local/` or remove the source lock entry).", + "- Run **Reload Extensions**.", + "- Confirm: the section's **Stale Approval Records** group shows the prior", + " Extension as `Stale (extension not currently installed)` with both", + " **Forget** and **Keep** actions wired.", + "", + "**Evidence.** Screenshot of the Stale Record card.", + "", + "## Sign-off", + "", + "- All 11 steps above produce the expected evidence with no unexpected", + " diagnostics.", + "- The release PR description links to each piece of evidence and notes", + " the OS / build profile each step was captured on.", + "- A migration release does **not** alter this checklist —", + " `MIGRATION*` switches are exercised separately in the migration", + " release's own checklist.", + "", + "## Related", + "", + "- [Extension Authoring Quickstart](/extensions/authoring) — manifest", + " reference and copy-paste starter.", + "- [Extension Telemetry](/extensions/telemetry) — full v1 events", + " catalog used in step 10.", + "- [Telemetry overview](/reference/telemetry) — host-level telemetry", + " policy that the Extension Telemetry layer wraps.", + "- [Debugging](/reference/debugging) — `bun run debug` subcommands,", + " including the `extensions` snapshot dump used in step 10.", + "", + ].join("\n"), + "references/docs/extensions/telemetry.mdx": [ + "---", + "title: Extension Telemetry", + "description: Full v1 events catalog for the Mux Extension Platform, including the provenance gate that blocks third-party identifiers from leaving your machine.", + "---", + "", + "The Extension Telemetry layer wraps the host", + "[Telemetry service](/reference/telemetry) and applies a **closed", + "allowlist** plus a **provenance gate** before any Extension event", + "leaves the device. This page lists every v1 event, its allowlisted", + "fields, and the gate rules — so users (and auditors) can verify that", + "the only string identifiers that ever ship are bundled-Extension", + "identifiers under the `mux.*` reserved prefix.", + "", + "The full source is in", + "[`src/common/extensions/extensionTelemetry.ts`](https://github.com/coder/mux/blob/main/src/common/extensions/extensionTelemetry.ts);", + "this page mirrors that file so it stays auditable without reading", + "TypeScript.", + "", + "## Privacy guarantees", + "", + "The Extension Telemetry layer **never** emits:", + "", + "- project paths or package names", + "- third-party Extension identifiers (`extensionId`, `contributionId`)", + "- requested-capability lists", + "- file paths or lockfile contents", + "- any field not on the per-event allowlist below", + "", + "Aggregate state is surfaced via counts (`extensionCount`,", + "`capabilityCount`, `diagnosticCount`) instead of identifiers.", + "", + "The host-level `MUX_DISABLE_TELEMETRY=1` switch disables this layer", + "along with everything else; see", + "[Telemetry overview](/reference/telemetry).", + "", + "## The provenance gate", + "", + "Each allowlisted field is classified as one of two kinds:", + "", + "| Kind | Behavior |", + "| ------------ | ------------------------------------------------------------------------------------------- |", + "| `scalar` | Counts, durations (ms), booleans, status enums, diagnostic codes, severity. Always allowed. |", + "| `identifier` | `extensionId` / `contributionId` style fields. Only emitted if **both** gates below pass. |", + "", + "Identifier fields ship only if **both** of these are true:", + "", + "1. The value matches the **Reserved Extension Identity Prefix** regex", + " `^mux(\\..*)?$`.", + '2. The Extension\'s source `rootKind === "bundled"`.', + "", + "Either gate failing strips the field. Defense-in-depth means a", + "third-party Extension squatting on the `mux.*` namespace is still", + 'rejected because its `rootKind !== "bundled"`; a bundled Extension', + "with a non-Mux id is rejected because the regex fails.", + "", + "```mermaid", + "graph TD", + ' A["Event payload"] --> B{"Field in event allowlist?"}', + ' B -- "no" --> X["Drop field"]', + ' B -- "yes, scalar" --> P["Keep field"]', + ' B -- "yes, identifier" --> C{"Value matches mux.* regex?"}', + ' C -- "no" --> X', + ' C -- "yes" --> D{"rootKind === bundled?"}', + ' D -- "no" --> X', + ' D -- "yes" --> P', + "```", + "", + "## v1 events catalog", + "", + "Every event in the table below is a `ExtensionTelemetryEventName`. The", + '"Fields" column lists the allowlisted property keys; values for', + "`identifier` fields are gated as described above, values for `scalar`", + "fields are kept as-is when they are `string | number | boolean`.", + "", + "### `extensions.discovery.completed`", + "", + "Emitted once per Discovery cycle that finishes (per root or", + "aggregated, per the host wiring). Used to monitor cold-start budgets", + "and discovery success rates.", + "", + "| Field | Kind | Notes |", + "| ------------------- | ------ | -------------------------------------------------------------------- |", + "| `durationMs` | scalar | End-to-end duration of the discovery cycle. |", + "| `rootCount` | scalar | Number of Extension Roots considered. |", + "| `extensionCount` | scalar | Number of Extensions surfaced (any state). |", + "| `contributionCount` | scalar | Number of Available + Inspection-only contributions in the snapshot. |", + "| `diagnosticCount` | scalar | Total diagnostics carried by the snapshot. |", + "| `cacheHit` | scalar | `true` when the inspection-path snapshot cache was warm. |", + "", + "### `extensions.discovery.failed`", + "", + "Emitted when a per-root Discovery Attempt fails (timeout, malformed", + "root manifest, etc.). One event per failed root.", + "", + "| Field | Kind | Notes |", + "| ---------------- | ------ | -------------------------------------------------------- |", + "| `rootKind` | scalar | One of `bundled`, `userGlobal`, `projectLocal`. |", + "| `diagnosticCode` | scalar | Stable diagnostic code (e.g., `root.discovery.timeout`). |", + "| `durationMs` | scalar | Duration before the attempt was abandoned. |", + "", + "### `extensions.migration.activated`", + "", + "Emitted when a built-in feature is migrated onto an Extension", + "contribution at runtime (future migration releases). Identifier", + "fields apply because only bundled (and therefore `mux.*`) Extensions", + "are eligible to drive a host migration.", + "", + "| Field | Kind | Notes |", + "| ------------- | ---------- | ---------------------------------------------------------------------------------------------------- |", + "| `extensionId` | identifier | Bundled `mux.*` id only — third-party Extensions cannot trigger this event because the gate rejects. |", + "| `durationMs` | scalar | Activation duration. |", + "", + "### `extensions.consent.shortcut.accepted`", + "", + "Emitted when a user confirms the Consent Shortcut.", + "", + "| Field | Kind | Notes |", + "| ---------- | ------ | -------------------------------------------- |", + "| `rootKind` | scalar | Source root of the Extension being approved. |", + "", + "### `extensions.consent.shortcut.rejected`", + "", + "Emitted when a user dismisses the Consent Shortcut without confirming.", + "", + "| Field | Kind | Notes |", + "| ---------- | ------ | -------------------------------------------- |", + "| `rootKind` | scalar | Source root of the Extension being reviewed. |", + "", + "### `extensions.approval.recorded`", + "", + "Emitted when an Approval Record is written.", + "", + "| Field | Kind | Notes |", + "| ----------------- | ---------- | --------------------------------------------------- |", + "| `extensionId` | identifier | Bundled-only via the gate. |", + "| `rootKind` | scalar | Source root. |", + "| `capabilityCount` | scalar | Total number of approved capabilities (post-merge). |", + "", + "### `extensions.approval.revoked`", + "", + "Emitted when an Approval Record is removed.", + "", + "| Field | Kind | Notes |", + "| ------------- | ---------- | -------------------------- |", + "| `extensionId` | identifier | Bundled-only via the gate. |", + "| `rootKind` | scalar | Source root. |", + "", + "### `extensions.enabled.toggled`", + "", + "Emitted when an Extension's enabled state changes.", + "", + "| Field | Kind | Notes |", + "| ------------- | ---------- | -------------------------- |", + "| `extensionId` | identifier | Bundled-only via the gate. |", + "| `rootKind` | scalar | Source root. |", + "| `enabled` | scalar | New enabled state. |", + "", + "### `extensions.reload.invoked`", + "", + "Emitted when **Reload Extensions** runs (palette, watcher, or", + "section button).", + "", + "| Field | Kind | Notes |", + "| ------------ | ------ | -------------------------------------------------------------------------- |", + "| `rootKind` | scalar | Source root being reloaded; absent / aggregated for whole-platform reload. |", + "| `durationMs` | scalar | Reload duration. |", + "", + "### `extensions.cache.miss`", + "", + "Emitted when the Snapshot Cache is consulted on cold start and rejects", + "the cached payload.", + "", + "| Field | Kind | Notes |", + "| -------- | ------ | --------------------------------------------------------------------------------------------------------------------- |", + "| `reason` | scalar | One of `appVersionMismatch`, `manifestVersionMismatch`, `stateFileMtimeMismatch`, `stateFileHashMismatch`, `missing`. |", + "", + "### `extensions.cache.hit`", + "", + "Emitted when the Snapshot Cache is consulted on cold start and the", + "cached payload survives validation.", + "", + "| Field | Kind | Notes |", + "| ------------ | ------ | -------------------------------------- |", + "| `durationMs` | scalar | Time saved by the cache hit (approx.). |", + "", + "### `extensions.diagnostic.emitted`", + "", + "Emitted once per Extension Diagnostic that crosses the structured-log", + "sink. The matrix of which diagnostics surface where is documented in", + "the Settings → Extensions UI; this event is the telemetry-side mirror.", + "", + "| Field | Kind | Notes |", + "| ---------------- | ---------- | ------------------------------------------------------------------ |", + "| `extensionId` | identifier | Bundled-only via the gate; absent for root-level diagnostics. |", + "| `contributionId` | identifier | Bundled-only via the gate; absent for extension-level diagnostics. |", + "| `diagnosticCode` | scalar | Stable diagnostic code (e.g., `extension.identity.conflict`). |", + "| `severity` | scalar | `error`, `warn`, or `info`. |", + "| `rootKind` | scalar | Source root. |", + "", + "## Auditing locally", + "", + "To verify in your own session:", + "", + "1. Run `bun run debug extensions` and inspect the snapshot.", + "2. Trigger a few Extension events (reload, run the Consent Shortcut on a", + " third-party Extension).", + "3. Watch the dev tools / PostHog console for outgoing events; confirm", + " that any `extensionId` or `contributionId` field present has the", + " `mux.*` prefix and that the host can confirm the event came from", + " the bundled root.", + "", + "A regression-test suite under", + "[`src/common/extensions/extensionTelemetry.test.ts`](https://github.com/coder/mux/blob/main/src/common/extensions/extensionTelemetry.test.ts)", + "asserts that the gate rejects every catalog event under a non-bundled", + "`rootKind` and that any field outside the per-event allowlist is", + "dropped. Adding a new event requires both an entry in the allowlist", + "table and a corresponding regression test.", + "", + "## Related", + "", + "- [Extension Authoring Quickstart](/extensions/authoring) — manifest", + " reference and identity rules.", + "- [Release Checklist](/extensions/release-checklist) — step 10 covers", + " the dogfood telemetry spot-check.", + "- [Telemetry overview](/reference/telemetry) — host-level telemetry", + " policy and the `MUX_DISABLE_TELEMETRY` switch.", + "", + ].join("\n"), "references/docs/getting-started/mux-gateway.mdx": [ "---", "title: Mux Gateway", @@ -6662,6 +7259,10 @@ export const BUILTIN_SKILL_FILES: Record> = { " - **Integrations**", " - VS Code Extension (`/integrations/vscode-extension`) → `references/docs/integrations/vscode-extension.mdx` — Pair Mux workspaces with VS Code and Cursor editors", " - ACP (Editor Integrations) (`/integrations/acp`) → `references/docs/integrations/acp.mdx` — Connect Mux to Zed, Neovim, and JetBrains via the Agent Client Protocol", + " - **Extensions**", + " - Authoring an Extension Module (`/extensions/authoring`) → `references/docs/extensions/authoring.mdx` — Quickstart for authoring a Mux Extension Module with a static manifest and skill registration.", + " - Extension Telemetry (`/extensions/telemetry`) → `references/docs/extensions/telemetry.mdx` — Full v1 events catalog for the Mux Extension Platform, including the provenance gate that blocks third-party identifiers from leaving your machine.", + " - Extension Platform Release Checklist (`/extensions/release-checklist`) → `references/docs/extensions/release-checklist.mdx` — Pre-release dogfood checklist for the Mux Extension Platform with screenshot/video evidence requirements.", " - **Reference**", " - Debugging (`/reference/debugging`) → `references/docs/reference/debugging.mdx` — View live backend logs and diagnose issues", " - Telemetry (`/reference/telemetry`) → `references/docs/reference/telemetry.mdx` — What Mux collects, what it doesn’t, and how to disable it", diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index ac9a4fc954..ab92738a65 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -11,6 +11,7 @@ import { Ok, Err } from "@/common/types/result"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import type { SendMessageOptions, ProvidersConfigMap } from "@/common/orpc/types"; +import type { ExtensionSkillSource } from "@/common/extensions/extensionSkillSource"; import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest"; import { ADVISOR_DEFAULT_MAX_USES_PER_TURN, @@ -412,6 +413,18 @@ export class AIService extends EventEmitter { this.streamManager.setMCPServerManager(manager); } + // Resolves the live Extension Snapshot's skill contributions for the + // tool layer (agent_skill_read / agent_skill_read_file). Set after + // construction by ServiceContainer; re-evaluated per stream so extension + // reload events take effect immediately. + private getExtensionSkillSources?: (projectPath: string) => readonly ExtensionSkillSource[]; + + setExtensionSkillSourcesProvider( + provider: ((projectPath: string) => readonly ExtensionSkillSource[]) | undefined + ): void { + this.getExtensionSkillSources = provider; + } + setTaskService(taskService: TaskService): void { this.taskService = taskService; } @@ -1279,6 +1292,9 @@ export class AIService extends EventEmitter { return desktopCapabilityPromise; }; + const extensionSkills = this.getExtensionSkillSources + ? Array.from(this.getExtensionSkillSources(metadata.projectPath)) + : undefined; const imageGenerationDirectToolAvailable = imageGenerationExperimentEnabled && experiments?.programmaticToolCallingExclusive !== true && @@ -1304,6 +1320,7 @@ export class AIService extends EventEmitter { mcpServers, muxScope, loadDesktopCapability, + extensionSkills, advisorToolAvailable, imageGenerationToolAvailable: imageGenerationDirectToolAvailable, }); @@ -1684,6 +1701,10 @@ export class AIService extends EventEmitter { // Dynamic context for tool descriptions (moved from system prompt for better model attention) availableSubagents: agentDefinitions, availableSkills, + // Mirrors agentSession's slash-skill dispatch: the model's + // agent_skill_read tool needs the same extension-skill list so + // /skill-name and tool-call resolution stay in sync. + extensionSkills, // Trust gating: only run hooks/scripts when the full shared workspace runtime is trusted. trusted: sharedExecutionTrusted, }, diff --git a/src/node/services/experimentsService.test.ts b/src/node/services/experimentsService.test.ts index ae12f66608..cd74b31ad8 100644 --- a/src/node/services/experimentsService.test.ts +++ b/src/node/services/experimentsService.test.ts @@ -87,6 +87,8 @@ describe("ExperimentsService", () => { cacheTtlMs: 0, }); + const changed = mock((_experimentId: string) => undefined); + const unsubscribe = service.onExperimentChanged(changed); await service.initialize(); await service.refreshExperiment(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING); @@ -107,6 +109,8 @@ describe("ExperimentsService", () => { EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING, "test" ); + expect(changed).toHaveBeenCalledWith(EXPERIMENT_IDS.PROGRAMMATIC_TOOL_CALLING); + unsubscribe(); }); test("persists backend overrides and applies them before remote gating", async () => { @@ -118,6 +122,8 @@ describe("ExperimentsService", () => { } as unknown as TelemetryService; const service = new ExperimentsService({ telemetryService, muxHome: tempDir }); + const changed = mock((_experimentId: string) => undefined); + const unsubscribe = service.onExperimentChanged(changed); await service.initialize(); await service.setOverride(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES, true); @@ -130,6 +136,8 @@ describe("ExperimentsService", () => { EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES, true ); + expect(changed).toHaveBeenCalledWith(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES); + unsubscribe(); const cacheFilePath = path.join(tempDir, "feature_flags.json"); const disk = JSON.parse(await fs.readFile(cacheFilePath, "utf-8")) as { @@ -215,7 +223,7 @@ describe("ExperimentsService", () => { expect(setFeatureFlagVariant).toHaveBeenCalledWith(EXPERIMENT_IDS.PORTABLE_DESKTOP, null); }); - test("returns disabled when telemetry is disabled", async () => { + test("falls back to false for default-off experiments when telemetry is disabled", async () => { const telemetryService = { getPostHogClient: mock(() => null), getDistinctId: mock(() => null), diff --git a/src/node/services/experimentsService.ts b/src/node/services/experimentsService.ts index 9b3cb78dd1..7b56d00141 100644 --- a/src/node/services/experimentsService.ts +++ b/src/node/services/experimentsService.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import assert from "@/common/utils/assert"; import { EXPERIMENTS, @@ -16,6 +17,8 @@ import { getErrorMessage } from "@/common/utils/errors"; export type { ExperimentValue }; +type ExperimentChangedCallback = (experimentId: ExperimentId) => void; + interface CachedVariant { value: string | boolean; fetchedAtMs: number; @@ -54,6 +57,7 @@ export class ExperimentsService { private readonly cacheTtlMs: number; private readonly platform: NodeJS.Platform; + private readonly emitter = new EventEmitter(); private readonly cachedVariants = new Map(); private readonly overrides = new Map(); private readonly refreshInFlight = new Map>(); @@ -73,6 +77,15 @@ export class ExperimentsService { this.platform = options.platform ?? process.platform; } + onExperimentChanged(callback: ExperimentChangedCallback): () => void { + this.emitter.on("experimentChanged", callback); + return () => this.emitter.off("experimentChanged", callback); + } + + private emitExperimentChanged(experimentId: ExperimentId): void { + this.emitter.emit("experimentChanged", experimentId); + } + private isExperimentSupported(experimentId: ExperimentId): boolean { return isExperimentSupportedOnPlatform(experimentId, this.platform); } @@ -156,6 +169,7 @@ export class ExperimentsService { this.overrides.delete(experimentId); this.telemetryService.setFeatureFlagVariant(this.getFlagKey(experimentId), null); await this.writeCacheToDisk(); + this.emitExperimentChanged(experimentId); return; } @@ -172,6 +186,7 @@ export class ExperimentsService { } await this.writeCacheToDisk(); + this.emitExperimentChanged(experimentId); } getExperimentValue(experimentId: ExperimentId): ExperimentValue { @@ -208,22 +223,26 @@ export class ExperimentsService { * Convert an experiment assignment to a boolean gate. * * NOTE: This intentionally does not block on network calls. + * + * When neither an explicit override nor a remote assignment is available + * (telemetry disabled, cold cache, or unsupported platform), fall back to + * `enabledByDefault` — mirroring the renderer's `useExperimentValue` in + * dev-server / `MUX_E2E=1` builds where PostHog never resolves. */ isExperimentEnabled(experimentId: ExperimentId): boolean { const value = this.getExperimentValue(experimentId).value; - // PostHog can return either boolean flags or string variants. if (typeof value === "boolean") { return value; } if (typeof value === "string") { - // For now, treat variant "test" as enabled for experiments with control/test variants. - // If we add experiments with different variant semantics, add a mapping per experiment. + // Variant flags map "test" → enabled. Add per-experiment mappings here + // when an experiment ships with non-control/test variants. return value === "test"; } - return false; + return EXPERIMENTS[experimentId]?.enabledByDefault ?? false; } async refreshAll(): Promise { @@ -275,6 +294,7 @@ export class ExperimentsService { return; } + const previous = this.cachedVariants.get(experimentId)?.value; const cached: CachedVariant = { value, fetchedAtMs: Date.now(), @@ -287,6 +307,9 @@ export class ExperimentsService { } await this.writeCacheToDisk(); + if (previous !== value && !this.overrides.has(experimentId)) { + this.emitExperimentChanged(experimentId); + } } catch (error) { log.debug("Failed to refresh experiment from PostHog", { experimentId, diff --git a/src/node/services/extensionTelemetryService.test.ts b/src/node/services/extensionTelemetryService.test.ts new file mode 100644 index 0000000000..e30ae9e234 --- /dev/null +++ b/src/node/services/extensionTelemetryService.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; + +import { ExtensionTelemetryLayer } from "./extensionTelemetryService"; +import type { ExtensionTelemetryEventName } from "@/common/extensions/extensionTelemetry"; + +interface RecordedCall { + event: ExtensionTelemetryEventName; + properties: Record; +} + +function createRecordingSink(): { + calls: RecordedCall[]; + captureExtensionEvent: ( + event: ExtensionTelemetryEventName, + properties: Record + ) => void; +} { + const calls: RecordedCall[] = []; + return { + calls, + captureExtensionEvent(event, properties) { + calls.push({ event, properties: { ...properties } }); + }, + }; +} + +describe("ExtensionTelemetryLayer", () => { + test("forwards gated payload to underlying sink", () => { + const sink = createRecordingSink(); + const layer = new ExtensionTelemetryLayer(sink); + + layer.capture({ + event: "extensions.discovery.completed", + properties: { durationMs: 150, rootCount: 3, extensionCount: 7, cacheHit: false }, + provenance: { rootKind: "bundled" }, + }); + + expect(sink.calls).toHaveLength(1); + expect(sink.calls[0]).toEqual({ + event: "extensions.discovery.completed", + properties: { durationMs: 150, rootCount: 3, extensionCount: 7, cacheHit: false }, + }); + }); + + test("strips identifier fields under non-bundled provenance before forwarding", () => { + const sink = createRecordingSink(); + const layer = new ExtensionTelemetryLayer(sink); + + layer.capture({ + event: "extensions.approval.recorded", + properties: { extensionId: "mux.evil", rootKind: "user-global", capabilityCount: 2 }, + provenance: { rootKind: "user-global" }, + }); + + expect(sink.calls).toHaveLength(1); + expect(sink.calls[0].properties.extensionId).toBeUndefined(); + expect(sink.calls[0].properties).toEqual({ rootKind: "user-global", capabilityCount: 2 }); + }); + + test("forwards an empty property bag when every input field is rejected", () => { + const sink = createRecordingSink(); + const layer = new ExtensionTelemetryLayer(sink); + + layer.capture({ + event: "extensions.cache.hit", + properties: { + // Forbidden fields the gate must drop: + projectPath: "/home/user/secret", + packageName: "@scope/pkg", + requestedPermissions: ["network"], + }, + provenance: { rootKind: "user-global" }, + }); + + expect(sink.calls).toHaveLength(1); + expect(sink.calls[0]).toEqual({ + event: "extensions.cache.hit", + properties: {}, + }); + }); + + test("emits identifier when both provenance gates pass", () => { + const sink = createRecordingSink(); + const layer = new ExtensionTelemetryLayer(sink); + + layer.capture({ + event: "extensions.diagnostic.emitted", + properties: { + extensionId: "mux.platform.demo", + contributionId: "mux.platform.demo-skill", + diagnosticCode: "extension.identity.invalid", + severity: "warn", + rootKind: "bundled", + }, + provenance: { rootKind: "bundled" }, + }); + + expect(sink.calls).toHaveLength(1); + expect(sink.calls[0].properties).toEqual({ + extensionId: "mux.platform.demo", + contributionId: "mux.platform.demo-skill", + diagnosticCode: "extension.identity.invalid", + severity: "warn", + rootKind: "bundled", + }); + }); +}); diff --git a/src/node/services/extensionTelemetryService.ts b/src/node/services/extensionTelemetryService.ts new file mode 100644 index 0000000000..dcc5c21625 --- /dev/null +++ b/src/node/services/extensionTelemetryService.ts @@ -0,0 +1,38 @@ +/** + * Extension Telemetry Layer (node wrapper). + * + * Composes a TelemetryService with the pure provenance gate + * (`gateExtensionTelemetryEvent`) so every Extension event passes through + * the allowlist + identifier-gating before reaching PostHog. + * + * Callers should never bypass this layer for Extension events; it enforces + * the telemetry privacy invariants before data reaches PostHog. + */ + +import { + type ExtensionTelemetryEventName, + type ExtensionTelemetryProvenance, + gateExtensionTelemetryEvent, +} from "@/common/extensions/extensionTelemetry"; + +export interface ExtensionTelemetryCapture { + event: ExtensionTelemetryEventName; + properties: Readonly>; + provenance: ExtensionTelemetryProvenance; +} + +interface ExtensionTelemetrySink { + captureExtensionEvent( + event: ExtensionTelemetryEventName, + properties: Record + ): void; +} + +export class ExtensionTelemetryLayer { + constructor(private readonly telemetry: ExtensionTelemetrySink) {} + + capture(input: ExtensionTelemetryCapture): void { + const gated = gateExtensionTelemetryEvent(input); + this.telemetry.captureExtensionEvent(gated.event, gated.properties); + } +} diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index b9c9c30a44..e84775c6ea 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -7,12 +7,15 @@ interface CompactionMetadata { rawCommand: string; } -// Type guard for agent skill metadata (for display + batching constraints) +// Type guard for agent skill metadata (for display + batching constraints). +// The scope union mirrors AgentSkillScopeSchema (src/common/orpc/schemas/agentSkill.ts) +// — keep them in sync, since a narrower scope here silently rejects queued +// extension-skill invocations and they get treated as plain messages. interface AgentSkillMetadata { type: "agent-skill"; rawCommand: string; skillName: string; - scope: "project" | "global" | "built-in"; + scope: "project" | "global" | "extension" | "built-in"; } function isAgentSkillMetadata(meta: unknown): meta is AgentSkillMetadata { @@ -21,7 +24,13 @@ function isAgentSkillMetadata(meta: unknown): meta is AgentSkillMetadata { if (obj.type !== "agent-skill") return false; if (typeof obj.rawCommand !== "string") return false; if (typeof obj.skillName !== "string") return false; - if (obj.scope !== "project" && obj.scope !== "global" && obj.scope !== "built-in") return false; + if ( + obj.scope !== "project" && + obj.scope !== "global" && + obj.scope !== "extension" && + obj.scope !== "built-in" + ) + return false; return true; } diff --git a/src/node/services/policyService.test.ts b/src/node/services/policyService.test.ts index e974ed99c5..b0f264954b 100644 --- a/src/node/services/policyService.test.ts +++ b/src/node/services/policyService.test.ts @@ -418,4 +418,26 @@ describe("PolicyService", () => { await new Promise((resolve) => server.close(() => resolve())); } }); + + test("policy with unknown top-level fields blocks startup", async () => { + await writeFile( + policyPath, + JSON.stringify({ + policy_format_version: "0.1", + extensionPlatfrom: false, + }), + "utf-8" + ); + process.env.MUX_POLICY_FILE = policyPath; + + const service = new PolicyService(config); + await service.initialize(); + + const status = service.getStatus(); + expect(status.state).toBe("blocked"); + if (status.state === "blocked") { + expect(status.reason).toContain("Unrecognized key"); + } + service.dispose(); + }); }); diff --git a/src/node/services/policyService.ts b/src/node/services/policyService.ts index 09e8ef9f21..8b13d8108a 100644 --- a/src/node/services/policyService.ts +++ b/src/node/services/policyService.ts @@ -454,6 +454,8 @@ export class PolicyService { runtimes: parsed.runtimes && parsed.runtimes.length > 0 ? parsed.runtimes.map((r) => r.id) : null, + + extensionPlatform: parsed.extensionPlatform ?? null, }; this.updateState({ source: schemaSource, status: { state: "enforced" }, policy: effective }); diff --git a/src/node/services/ptc/quickjsRuntime.test.ts b/src/node/services/ptc/quickjsRuntime.test.ts index 26ad335230..f1e1551892 100644 --- a/src/node/services/ptc/quickjsRuntime.test.ts +++ b/src/node/services/ptc/quickjsRuntime.test.ts @@ -51,6 +51,19 @@ describe("QuickJSRuntime", () => { expect(result.result).toBeNull(); }); + it("resolves promises returned by guest async functions", async () => { + const result = await runtime.eval(` + async function loadValue() { + await Promise.resolve(); + return "ready"; + } + return loadValue(); + `); + + expect(result.success).toBe(true); + expect(result.result).toBe("ready"); + }); + it("handles syntax errors", async () => { const result = await runtime.eval("return {{{;"); expect(result.success).toBe(false); @@ -292,6 +305,32 @@ describe("QuickJSRuntime", () => { expect(result.error).toContain("timeout"); }); + it("includes queue wait in timeout limits", async () => { + const otherRuntime = await QuickJSRuntime.create(); + try { + runtime.registerFunction("slowOp", async () => { + await new Promise((resolve) => setTimeout(resolve, 120)); + return "done"; + }); + runtime.setLimits({ timeoutMs: 500 }); + otherRuntime.setLimits({ timeoutMs: 20 }); + + const firstEval = runtime.eval(` + slowOp(); + return "first"; + `); + const queuedAt = Date.now(); + const secondResult = await otherRuntime.eval(`return "second";`); + + expect(secondResult.success).toBe(false); + expect(secondResult.error).toContain("timeout"); + expect(Date.now() - queuedAt).toBeLessThan(100); + await firstEval; + } finally { + otherRuntime.dispose(); + } + }); + it("aborts signal when timeout fires during async host function", async () => { // This tests the setTimeout-based timeout's effect on the abort signal. // The interrupt handler only fires during QuickJS execution, but when diff --git a/src/node/services/ptc/quickjsRuntime.ts b/src/node/services/ptc/quickjsRuntime.ts index 0e5a981fa2..a3e6280c0c 100644 --- a/src/node/services/ptc/quickjsRuntime.ts +++ b/src/node/services/ptc/quickjsRuntime.ts @@ -8,6 +8,7 @@ import { newQuickJSAsyncWASMModuleFromVariant, type QuickJSAsyncContext, + type QuickJSAsyncWASMModule, type QuickJSHandle, } from "quickjs-emscripten-core"; import { QuickJSAsyncFFI } from "@jitl/quickjs-wasmfile-release-asyncify/ffi"; @@ -41,30 +42,49 @@ export class QuickJSRuntime implements IJSRuntime { private toolCalls: PTCToolCallRecord[] = []; private consoleOutput: PTCConsoleRecord[] = []; + // The asyncify QuickJS module supports only one suspended execution at a + // time. We intentionally reuse the WASM module to avoid repeated + // instantiation crashes under Bun coverage, and serialize evals to preserve + // asyncify's single-flight invariant across contexts. + private static evalQueue: Promise = Promise.resolve(); + private static modulePromise: Promise | undefined; + private constructor(private readonly ctx: QuickJSAsyncContext) {} static async create(): Promise { - // Create the async variant manually due to bun's package export resolution issues. - // The self-referential import in the variant package doesn't resolve correctly. - const variant = { - type: "async" as const, - importFFI: () => Promise.resolve(QuickJSAsyncFFI), - // eslint-disable-next-line @typescript-eslint/require-await -- sync require wrapped for interface - importModuleLoader: async () => { - // Use require() with the named export path since bun's dynamic import() - // doesn't resolve package exports correctly from within node_modules - // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment - const mod = require("@jitl/quickjs-wasmfile-release-asyncify/emscripten-module"); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return - return mod.default ?? mod; - }, - }; - - const QuickJS = await newQuickJSAsyncWASMModuleFromVariant(variant); + const QuickJS = await QuickJSRuntime.getModule(); const ctx = QuickJS.newContext(); return new QuickJSRuntime(ctx); } + private static getModule(): Promise { + if (!QuickJSRuntime.modulePromise) { + // Create the async variant manually due to bun's package export resolution + // issues. Reuse the WASM module so coverage-heavy tests and extension + // reloads don't repeatedly instantiate QuickJS and stress Bun's WASM GC. + const variant = { + type: "async" as const, + importFFI: () => Promise.resolve(QuickJSAsyncFFI), + // eslint-disable-next-line @typescript-eslint/require-await -- sync require wrapped for interface + importModuleLoader: async () => { + // Use require() with the named export path since bun's dynamic import() + // doesn't resolve package exports correctly from within node_modules + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const mod = require("@jitl/quickjs-wasmfile-release-asyncify/emscripten-module"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return + return mod.default ?? mod; + }, + }; + QuickJSRuntime.modulePromise = newQuickJSAsyncWASMModuleFromVariant(variant).catch( + (error) => { + QuickJSRuntime.modulePromise = undefined; + throw error; + } + ); + } + return QuickJSRuntime.modulePromise; + } + setLimits(limits: RuntimeLimits): void { this.limits = limits; @@ -241,7 +261,57 @@ export class QuickJSRuntime implements IJSRuntime { async eval(code: string): Promise { this.assertNotDisposed("eval"); + const queuedAt = Date.now(); + const timeoutMs = this.limits.timeoutMs ?? DEFAULT_TIMEOUT_MS; + let timeoutId: NodeJS.Timeout | undefined; + let cancelledBeforeStart = false; + let started = false; + + return new Promise((resolve, reject) => { + timeoutId = setTimeout(() => { + if (started) return; + cancelledBeforeStart = true; + resolve(this.timeoutResult(timeoutMs, queuedAt)); + }, timeoutMs); + + const run = async () => { + if (cancelledBeforeStart) return; + started = true; + if (timeoutId !== undefined) clearTimeout(timeoutId); + const waitedMs = Date.now() - queuedAt; + const remainingTimeoutMs = timeoutMs - waitedMs; + if (remainingTimeoutMs <= 0) { + resolve(this.timeoutResult(timeoutMs, queuedAt)); + return; + } + const previousLimits = this.limits; + this.limits = { ...this.limits, timeoutMs: remainingTimeoutMs }; + try { + resolve(await this.evalInner(code)); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } finally { + this.limits = previousLimits; + } + }; + + const next = QuickJSRuntime.evalQueue.then(run, run); + QuickJSRuntime.evalQueue = next.catch(() => undefined); + }); + } + + private timeoutResult(timeoutMs: number, startedAt: number): PTCExecutionResult { + return { + success: false, + error: `Execution timeout (${timeoutMs}ms exceeded)`, + toolCalls: [], + consoleOutput: [], + duration_ms: Date.now() - startedAt, + }; + } + private async evalInner(code: string): Promise { + this.assertNotDisposed("eval"); const execStartTime = Date.now(); this.abortController = new AbortController(); this.toolCalls = []; @@ -305,10 +375,44 @@ export class QuickJSRuntime implements IJSRuntime { }; } - // With asyncify, evalCodeAsync suspends until async host functions complete. - // The result is already resolved - no need to resolve the promise. - const value: unknown = this.ctx.dump(evalResult.value) as unknown; + // Resolve promise-like results from async functions inside QuickJS. Asyncify + // makes host calls suspend, but guest async functions still enqueue QuickJS + // promise jobs that must be executed before their returned value is safe to + // inspect. + const resolvedPromise = this.ctx.resolvePromise(evalResult.value); + const jobsResult = this.ctx.runtime.executePendingJobs(); + if (jobsResult.error) { + const errObj: unknown = jobsResult.error.context.dump(jobsResult.error) as unknown; + jobsResult.dispose(); + evalResult.value.dispose(); + const duration_ms = Date.now() - execStartTime; + return { + success: false, + error: this.getErrorMessage(errObj, deadline, timeoutMs), + toolCalls: this.toolCalls, + consoleOutput: this.consoleOutput, + duration_ms, + }; + } + jobsResult.dispose(); + + const promiseResult = await resolvedPromise; evalResult.value.dispose(); + if (promiseResult.error) { + const errObj: unknown = this.ctx.dump(promiseResult.error) as unknown; + promiseResult.error.dispose(); + const duration_ms = Date.now() - execStartTime; + return { + success: false, + error: this.getErrorMessage(errObj, deadline, timeoutMs), + toolCalls: this.toolCalls, + consoleOutput: this.consoleOutput, + duration_ms, + }; + } + + const value: unknown = this.ctx.dump(promiseResult.value) as unknown; + promiseResult.value.dispose(); return { success: true, diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index e84442876b..2e02a1cc69 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -69,6 +69,19 @@ import { ServerAuthService } from "@/node/services/serverAuthService"; import { DesktopBridgeServer } from "@/node/services/desktop/DesktopBridgeServer"; import { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; import { DesktopTokenManager } from "@/node/services/desktop/DesktopTokenManager"; +import { + createExtensionRootsProvider, + projectPathFromProjectLocalRootId, +} from "@/node/extensions/extensionRoots"; +import { ExtensionRootWatcher } from "@/node/extensions/extensionRootWatcher"; +import { ExtensionRegistry } from "@/node/extensions/extensionRegistryService"; +import { SnapshotCacheService } from "@/node/extensions/snapshotCacheService"; +import { GlobalExtensionStateService } from "@/node/extensions/globalExtensionStateService"; +import { + getProjectExtensionStateRoot, + ProjectExtensionStateService, +} from "@/node/extensions/projectExtensionStateService"; +import { VERSION } from "@/version"; import type { ORPCContext } from "@/node/orpc/context"; import type { ExternalSecretResolver } from "@/common/types/secrets"; @@ -128,6 +141,9 @@ export class ServiceContainer { public readonly desktopSessionManager: DesktopSessionManager; public readonly desktopTokenManager: DesktopTokenManager; public readonly desktopBridgeServer: DesktopBridgeServer; + private readonly extensionRootWatcher: ExtensionRootWatcher; + private readonly refreshExtensionRootWatcher: () => Promise; + public readonly extensionRegistry: ExtensionRegistry; public readonly sshPromptService = new SshPromptService(); private readonly ptyService: PTYService; public readonly idleCompactionService: IdleCompactionService; @@ -319,6 +335,63 @@ export class ServiceContainer { this.serverAuthService = new ServerAuthService(config); + const globalExtensionState = new GlobalExtensionStateService(config); + const projectExtensionState = new ProjectExtensionStateService( + getProjectExtensionStateRoot(config.rootDir) + ); + const getExtensionRoots = createExtensionRootsProvider({ + config, + projectState: projectExtensionState, + }); + + const snapshotCache = new SnapshotCacheService({ + cacheFilePath: path.join(config.rootDir, "extension-snapshot.cache.json"), + appVersion: VERSION.git_describe, + }); + + this.extensionRegistry = new ExtensionRegistry({ + roots: getExtensionRoots, + globalState: globalExtensionState, + projectState: projectExtensionState, + snapshotCache, + stateFilePaths: async () => { + const roots = await getExtensionRoots(); + return [ + path.join(config.rootDir, "config.json"), + ...roots + .map((root) => projectPathFromProjectLocalRootId(root.rootId)) + .filter((projectPath): projectPath is string => projectPath !== null) + .map((projectPath) => projectExtensionState.filePathFor(projectPath)), + ]; + }, + }); + this.extensionRootWatcher = new ExtensionRootWatcher({ + onChange: () => { + void this.extensionRegistry.reload().catch((error: unknown) => { + log.warn("Extension root watcher reload failed", { error }); + }); + }, + }); + this.refreshExtensionRootWatcher = async () => { + // Extensions are always initialized: built-in skills may migrate onto this + // platform, so hiding the platform would remove core functionality. + await this.extensionRootWatcher.setRoots(await getExtensionRoots()); + }; + this.extensionRegistry.onChanged(() => { + void this.refreshExtensionRootWatcher().catch((error: unknown) => { + log.warn("Extension root watcher refresh failed", { error }); + }); + }); + + // Wire the live registry into AgentSession's slash-skill dispatch and + // the model's tool layer (agent_skill_read / agent_skill_read_file). + // Re-evaluated per call so extension reload events take effect immediately + // without rebuilding sessions. + const getExtensionSkillSources = (projectPath: string) => + this.extensionRegistry.getSkillSources(projectPath); + this.workspaceService.setExtensionSkillSourcesProvider(getExtensionSkillSources); + this.aiService.setExtensionSkillSourcesProvider(getExtensionSkillSources); + const workspaceLifecycleHooks = new WorkspaceLifecycleHooks(); const worktreeArchiveSnapshotService = new WorktreeArchiveSnapshotService(this.config); this.workspaceService.setWorktreeArchiveSnapshotService(worktreeArchiveSnapshotService); @@ -446,9 +519,27 @@ export class ServiceContainer { await recordStep("policyService.initialize", () => this.policyService.initialize()); await recordStep("experimentsService.initialize", () => this.experimentsService.initialize()); + + const refreshExtensionsAfterCoreServices = (): void => { + // Extension root enumeration can sync project source locks and clone git + // sources. Keep that optional work out of the startup critical path. + void (async () => { + await recordStep("extensionRootWatcher.refresh", () => this.refreshExtensionRootWatcher()); + await recordStep("extensionRegistry.loadFromCache", () => + this.extensionRegistry.loadFromCache() + ); + // Trigger an initial Extension discovery so the Settings UI renders the + // bundled root + demo extension instead of hanging on "Loading…". + await recordStep("extensionRegistry.reload", () => this.extensionRegistry.reload()); + })().catch((error: unknown) => { + log.warn("Initial extension refresh failed", { error }); + }); + }; + // Kick off non-task chat restart recovery eagerly; task workspaces recover in TaskService.initialize(). await recordStep("workspaceService.initialize", () => this.workspaceService.initialize()); await recordStep("taskService.initialize", () => this.taskService.initialize()); + refreshExtensionsAfterCoreServices(); const idleCompactionStartedAt = Date.now(); // Start idle compaction checker @@ -530,6 +621,7 @@ export class ServiceContainer { desktopSessionManager: this.desktopSessionManager, desktopTokenManager: this.desktopTokenManager, desktopBridgeServer: this.desktopBridgeServer, + extensionRegistry: this.extensionRegistry, }; } @@ -547,6 +639,8 @@ export class ServiceContainer { await this.browserBridgeServer.stop(); this.browserSessionStateHub.dispose(); this.browserBridgeTokenManager.dispose(); + this.extensionRootWatcher.close(); + this.extensionRegistry.dispose(); await this.analyticsService.dispose(); await this.telemetryService.shutdown(); } @@ -578,6 +672,8 @@ export class ServiceContainer { await this.browserBridgeServer.stop(); this.browserSessionStateHub.dispose(); this.browserBridgeTokenManager.dispose(); + this.extensionRootWatcher.close(); + this.extensionRegistry.dispose(); await this.analyticsService.dispose(); this.policyService.dispose(); this.mcpServerManager.dispose(); diff --git a/src/node/services/streamContextBuilder.test.ts b/src/node/services/streamContextBuilder.test.ts index cff2d10538..18a16d21a9 100644 --- a/src/node/services/streamContextBuilder.test.ts +++ b/src/node/services/streamContextBuilder.test.ts @@ -81,6 +81,7 @@ async function buildSystemContextForTest(args: { isSubagentWorkspace: boolean; effectiveAdditionalInstructions?: string; planFilePath?: string; + extensionSkills?: Parameters[0]["extensionSkills"]; imageGenerationToolAvailable?: boolean; }) { return buildStreamSystemContext({ @@ -96,6 +97,7 @@ async function buildSystemContextForTest(args: { modelString: "openai:gpt-5.2", cfg: args.cfg, providersConfig: null, + extensionSkills: args.extensionSkills, mcpServers: {}, imageGenerationToolAvailable: args.imageGenerationToolAvailable, }); @@ -417,6 +419,46 @@ describe("buildStreamSystemContext", () => { ); }); + test("includes extension skills in available skill tool context", async () => { + using tempRoot = new DisposableTempDir("stream-system-context"); + + const projectPath = path.join(tempRoot.path, "project"); + const muxHome = path.join(tempRoot.path, "mux-home"); + await fs.mkdir(projectPath, { recursive: true }); + await fs.mkdir(muxHome, { recursive: true }); + + const metadata = createWorkspaceMetadata({ + id: "top-level-ws", + name: "top-level-workspace", + projectName: "project", + projectPath, + }); + const cfg = createProjectsConfig({ + projectPath, + workspaces: [{ id: metadata.id, name: metadata.name }], + }); + + const result = await buildSystemContextForTest({ + runtime: new TestRuntime(projectPath, muxHome), + metadata, + workspacePath: projectPath, + cfg, + isSubagentWorkspace: false, + extensionSkills: [ + { + name: "mux-extensions", + displayName: "Mux Extensions", + description: "Demo extension skill", + advertise: true, + bodyAbsolutePath: path.join(tempRoot.path, "SKILL.md"), + extensionId: "mux.platformdemo", + }, + ], + }); + + expect(result.availableSkills?.some((skill) => skill.name === "mux-extensions")).toBe(true); + }); + test("omits ancestor plan paths for top-level workspaces", async () => { using tempRoot = new DisposableTempDir("stream-system-context"); diff --git a/src/node/services/streamContextBuilder.ts b/src/node/services/streamContextBuilder.ts index 645b3abb5e..a3852d8133 100644 --- a/src/node/services/streamContextBuilder.ts +++ b/src/node/services/streamContextBuilder.ts @@ -25,6 +25,7 @@ import type { AgentDefinitionScope } from "@/common/types/agentDefinition"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import type { ProvidersConfigMap } from "@/common/orpc/types"; import type { TaskSettings } from "@/common/types/tasks"; +import type { ExtensionSkillSource } from "@/common/extensions/extensionSkillSource"; import type { Runtime } from "@/node/runtime/Runtime"; import { isPlanLikeInResolvedChain } from "@/common/utils/agentTools"; import { getPlanFilePath } from "@/common/utils/planStorage"; @@ -244,6 +245,7 @@ export interface BuildStreamSystemContextOptions { mcpServers: Parameters[5]; muxScope?: MuxToolScope; loadDesktopCapability?: () => Promise; + extensionSkills?: readonly ExtensionSkillSource[]; /** Whether the advisor tool is available for the current agent */ advisorToolAvailable?: boolean; /** Whether the image_generate tool is available as a direct tool for the current agent. */ @@ -470,6 +472,7 @@ export async function buildStreamSystemContext( mcpServers, muxScope, loadDesktopCapability, + extensionSkills, advisorToolAvailable, imageGenerationToolAvailable, } = opts; @@ -538,6 +541,7 @@ export async function buildStreamSystemContext( availableSkills = await discoverAgentSkills(skillCtx.runtime, skillCtx.workspacePath, { roots: skillCtx.roots, containment: skillCtx.containment, + extensionSkills, }); } catch (error) { workspaceLog.warn("Failed to discover agent skills for tool description", { error }); diff --git a/src/node/services/telemetryService.ts b/src/node/services/telemetryService.ts index d5fbf2ef73..cfd242ff67 100644 --- a/src/node/services/telemetryService.ts +++ b/src/node/services/telemetryService.ts @@ -20,6 +20,7 @@ import * as path from "path"; import { getMuxHome } from "@/common/constants/paths"; import { VERSION } from "@/version"; import type { TelemetryEventPayload, BaseTelemetryProperties } from "@/common/telemetry/payload"; +import type { ExtensionTelemetryEventName } from "@/common/extensions/extensionTelemetry"; // Default configuration (public keys, safe to commit) const DEFAULT_POSTHOG_KEY = "phc_vF1bLfiD5MXEJkxojjsmV5wgpLffp678yhJd3w9Sl4G"; @@ -306,6 +307,29 @@ export class TelemetryService { }); } + /** + * Forward a pre-sanitized Extension event to PostHog. + * + * Callers MUST run the payload through `gateExtensionTelemetryEvent()` first + * (see `ExtensionTelemetryLayer`). The gate is what guarantees identifying + * strings (extensionId, contributionId) only appear when the value matches + * the Reserved Extension Identity Prefix AND the source rootKind is + * 'bundled'. This method does NOT re-validate the payload. + */ + captureExtensionEvent( + event: ExtensionTelemetryEventName, + properties: Record + ): void { + if (isTelemetryDisabledByEnv(process.env) || !this.client || !this.distinctId) { + return; + } + this.client.capture({ + distinctId: this.distinctId, + event, + properties: { ...this.getBaseProperties(), ...properties }, + }); + } + /** * Shutdown telemetry and flush any pending events. * Should be called on app close. diff --git a/src/node/services/tools/agent_skill_list.test.ts b/src/node/services/tools/agent_skill_list.test.ts index 5cfcaff540..7cb1dfdc3b 100644 --- a/src/node/services/tools/agent_skill_list.test.ts +++ b/src/node/services/tools/agent_skill_list.test.ts @@ -161,6 +161,77 @@ describe("agent_skill_list", () => { }); }); + // Regression: agent_skill_list's local-host branch hand-walks the + // ~/.mux/skills + ~/.agents/skills + project skills filesystem, so + // extension-contributed skills (which live inside Extension package + // directories, not under any of those roots) were invisible to the + // model even with `includeUnadvertised: true`. The tool now appends + // extension skills from ToolConfiguration.extensionSkills with + // scope: "extension". + it("includes extension-contributed skills when extensionSkills is provided", async () => { + using project = new TestTempDir("test-agent-skill-list-ext-project"); + using muxHome = new TestTempDir("test-agent-skill-list-ext-home"); + using extPkg = new TestTempDir("test-agent-skill-list-ext-pkg"); + + await withMuxRoot(muxHome.path, async () => { + await writeSkill(path.join(project.path, ".mux", "skills"), "project-only", { + description: "from project", + }); + + const tool = createAgentSkillListTool( + createTestToolConfig(project.path, { + muxScope: { + type: "project", + muxHome: muxHome.path, + projectRoot: project.path, + projectStorageAuthority: "host-local", + }, + extensionSkills: [ + { + name: "mux-extensions", + displayName: "Mux Extensions", + description: "Demo extension skill", + advertise: true, + bodyAbsolutePath: path.join(extPkg.path, "SKILL.md"), + extensionId: "mux.platformdemo", + }, + { + name: "secret-extension-skill", + displayName: "Secret Extension Skill", + description: "Hidden by default", + advertise: false, + bodyAbsolutePath: path.join(extPkg.path, "secret.md"), + extensionId: "publisher.hidden", + }, + ], + }) + ); + const advertised = (await tool.execute!({}, mockToolCallOptions)) as AgentSkillListToolResult; + expect(advertised.success).toBe(true); + if (!advertised.success) return; + expect(getSkill(advertised.skills, "mux-extensions")).toMatchObject({ + name: "mux-extensions", + description: "Demo extension skill", + scope: "extension", + }); + // Unadvertised extension skills are hidden by default, just like + // unadvertised project / global skills. + expect(advertised.skills.find((s) => s.name === "secret-extension-skill")).toBeUndefined(); + + const everything = (await tool.execute!( + { includeUnadvertised: true }, + mockToolCallOptions + )) as AgentSkillListToolResult; + expect(everything.success).toBe(true); + if (!everything.success) return; + expect(getSkill(everything.skills, "secret-extension-skill")).toMatchObject({ + name: "secret-extension-skill", + scope: "extension", + advertise: false, + }); + }); + }); + it("returns only the winning descriptor when project skills shadow global skills", async () => { using project = new TestTempDir("test-agent-skill-list-shadow-project"); using muxHome = new TestTempDir("test-agent-skill-list-shadow-home"); diff --git a/src/node/services/tools/agent_skill_list.ts b/src/node/services/tools/agent_skill_list.ts index 42f11c65ea..bea09324bf 100644 --- a/src/node/services/tools/agent_skill_list.ts +++ b/src/node/services/tools/agent_skill_list.ts @@ -172,6 +172,7 @@ export const createAgentSkillListTool: ToolFactory = (config: ToolConfiguration) roots, containment: skillCtx.containment, dedupeByName: false, + extensionSkills: config.extensionSkills, }); const skills = discovered .filter((skill) => skill.scope !== "built-in") @@ -274,6 +275,25 @@ export const createAgentSkillListTool: ToolFactory = (config: ToolConfiguration) } } + // Extension skills live in packages, not under the hand-walked roots above. + // Append them after project/global skills so local customizations win. + if (config.extensionSkills) { + const knownNames = new Set(skills.map((s) => s.name)); + for (const ext of config.extensionSkills) { + if (knownNames.has(ext.name)) continue; + const descriptorResult = AgentSkillDescriptorSchema.safeParse({ + name: ext.name, + description: ext.description || ext.name, + scope: "extension", + advertise: ext.advertise, + }); + if (descriptorResult.success) { + skills.push(descriptorResult.data); + knownNames.add(ext.name); + } + } + } + skills.sort((a, b) => a.name.localeCompare(b.name)); return { diff --git a/src/node/services/tools/agent_skill_read.ts b/src/node/services/tools/agent_skill_read.ts index 324d852cc9..47033405c6 100644 --- a/src/node/services/tools/agent_skill_read.ts +++ b/src/node/services/tools/agent_skill_read.ts @@ -86,6 +86,7 @@ export const createAgentSkillReadTool: ToolFactory = (config: ToolConfiguration) { roots: skillCtx.roots, containment: skillCtx.containment, + extensionSkills: config.extensionSkills, } ); if ( diff --git a/src/node/services/tools/agent_skill_read_file.test.ts b/src/node/services/tools/agent_skill_read_file.test.ts index 8e11abcf67..6f2514bdd9 100644 --- a/src/node/services/tools/agent_skill_read_file.test.ts +++ b/src/node/services/tools/agent_skill_read_file.test.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { describe, it, expect } from "bun:test"; +import { describe, it, expect, spyOn } from "bun:test"; import { AgentSkillReadFileToolResultSchema } from "@/common/utils/tools/toolDefinitions"; import { createAgentSkillReadFileTool } from "./agent_skill_read_file"; import { @@ -72,6 +72,205 @@ describe("agent_skill_read_file", () => { } }); + it("allows reading files from extension-contributed skills", async () => { + using tempDir = new TestTempDir("test-agent-skill-read-file-extension"); + const skillDir = path.join(tempDir.path, "extension-skill"); + await fs.mkdir(skillDir, { recursive: true }); + const skillFilePath = path.join(skillDir, "SKILL.md"); + await fs.writeFile( + skillFilePath, + "---\nname: mux-extensions\ndescription: Demo extension skill\n---\nBody", + "utf-8" + ); + await fs.writeFile(path.join(skillDir, "notes.md"), "extension notes", "utf-8"); + + const tool = createAgentSkillReadFileTool( + createTestToolConfig(tempDir.path, { + extensionSkills: [ + { + name: "mux-extensions", + displayName: "Mux Extensions", + description: "Demo extension skill", + advertise: true, + bodyAbsolutePath: skillFilePath, + extensionId: "mux.platformdemo", + }, + ], + }) + ); + + const raw: unknown = await Promise.resolve( + tool.execute!( + { name: "mux-extensions", filePath: "notes.md", offset: 1, limit: 5 }, + mockToolCallOptions + ) + ); + + const parsed = AgentSkillReadFileToolResultSchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (!parsed.success) throw new Error(parsed.error.message); + expect(parsed.data.success).toBe(true); + if (parsed.data.success) { + expect(parsed.data.content).toContain("extension notes"); + } + }); + + it("rejects extension reference directories instead of returning empty content", async () => { + using tempDir = new TestTempDir("test-agent-skill-read-file-extension-directory"); + const skillDir = path.join(tempDir.path, "extension-skill"); + await fs.mkdir(path.join(skillDir, "refs"), { recursive: true }); + const skillFilePath = path.join(skillDir, "SKILL.md"); + await fs.writeFile( + skillFilePath, + "---\nname: mux-extensions\ndescription: Demo extension skill\n---\nBody", + "utf-8" + ); + + const tool = createAgentSkillReadFileTool( + createTestToolConfig(tempDir.path, { + extensionSkills: [ + { + name: "mux-extensions", + displayName: "Mux Extensions", + description: "Demo extension skill", + advertise: true, + bodyAbsolutePath: skillFilePath, + extensionId: "mux.platformdemo", + }, + ], + }) + ); + + const raw: unknown = await Promise.resolve( + tool.execute!({ name: "mux-extensions", filePath: "refs" }, mockToolCallOptions) + ); + + const parsed = AgentSkillReadFileToolResultSchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (!parsed.success) throw new Error(parsed.error.message); + expect(parsed.data.success).toBe(false); + if (!parsed.data.success) { + expect(parsed.data.error).toMatch(/directory/i); + } + }); + + it("does not follow extension reference files swapped to symlinks after containment", async () => { + using tempDir = new TestTempDir("test-agent-skill-read-file-extension-race"); + const skillDir = path.join(tempDir.path, "extension-skill"); + const outsideDir = path.join(tempDir.path, "outside"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + const skillFilePath = path.join(skillDir, "SKILL.md"); + const notesPath = path.join(skillDir, "notes.md"); + const secretPath = path.join(outsideDir, "secret.txt"); + await fs.writeFile( + skillFilePath, + "---\nname: mux-extensions\ndescription: Demo extension skill\n---\nBody", + "utf-8" + ); + await fs.writeFile(notesPath, "extension notes", "utf-8"); + await fs.writeFile(secretPath, "outside secret", "utf-8"); + const originalStat = fs.stat; + const statSpy = spyOn(fs, "stat"); + statSpy.mockImplementation((async (target: Parameters[0]) => { + const result = await originalStat(target); + if (String(target) === notesPath) { + await fs.rm(notesPath, { force: true }); + await fs.symlink(secretPath, notesPath); + } + return result; + }) as unknown as typeof fs.stat); + + try { + const tool = createAgentSkillReadFileTool( + createTestToolConfig(tempDir.path, { + extensionSkills: [ + { + name: "mux-extensions", + displayName: "Mux Extensions", + description: "Demo extension skill", + advertise: true, + bodyAbsolutePath: skillFilePath, + extensionId: "mux.platformdemo", + }, + ], + }) + ); + + const raw: unknown = await Promise.resolve( + tool.execute!({ name: "mux-extensions", filePath: "notes.md" }, mockToolCallOptions) + ); + + const parsed = AgentSkillReadFileToolResultSchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (!parsed.success) throw new Error(parsed.error.message); + expect(parsed.data.success).toBe(false); + if (!parsed.data.success) { + expect(parsed.data.error).not.toContain("outside secret"); + } + } finally { + statSpy.mockRestore(); + } + }); + + it("does not follow extension reference parent directories swapped after containment", async () => { + using tempDir = new TestTempDir("test-agent-skill-read-file-extension-parent-race"); + const skillDir = path.join(tempDir.path, "extension-skill"); + const refsDir = path.join(skillDir, "refs"); + const outsideDir = path.join(tempDir.path, "outside"); + await fs.mkdir(refsDir, { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + const skillFilePath = path.join(skillDir, "SKILL.md"); + const notesPath = path.join(refsDir, "notes.md"); + await fs.writeFile( + skillFilePath, + "---\nname: mux-extensions\ndescription: Demo extension skill\n---\nBody", + "utf-8" + ); + const outsideNotesPath = path.join(outsideDir, "notes.md"); + await fs.writeFile(notesPath, "extension notes", "utf-8"); + await fs.writeFile(outsideNotesPath, "outside secret", "utf-8"); + const originalOpen = fs.open; + const openSpy = spyOn(fs, "open"); + openSpy.mockImplementation((( + target: Parameters[0], + flags?: Parameters[1] + ) => { + return originalOpen(String(target) === notesPath ? outsideNotesPath : target, flags); + }) as unknown as typeof fs.open); + + try { + const tool = createAgentSkillReadFileTool( + createTestToolConfig(tempDir.path, { + extensionSkills: [ + { + name: "mux-extensions", + displayName: "Mux Extensions", + description: "Demo extension skill", + advertise: true, + bodyAbsolutePath: skillFilePath, + extensionId: "mux.platformdemo", + }, + ], + }) + ); + + const raw: unknown = await Promise.resolve( + tool.execute!({ name: "mux-extensions", filePath: "refs/notes.md" }, mockToolCallOptions) + ); + + const parsed = AgentSkillReadFileToolResultSchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (!parsed.success) throw new Error(parsed.error.message); + expect(parsed.data.success).toBe(false); + if (!parsed.data.success) { + expect(parsed.data.error).not.toContain("outside secret"); + } + } finally { + openSpy.mockRestore(); + } + }); + it("blocks built-in imagegen skill files when the image generation tool is unavailable", async () => { using tempDir = new TestTempDir("test-agent-skill-read-file-imagegen-disabled"); const baseConfig = createTestToolConfig(tempDir.path, { diff --git a/src/node/services/tools/agent_skill_read_file.ts b/src/node/services/tools/agent_skill_read_file.ts index 422039e435..3f60bd5c54 100644 --- a/src/node/services/tools/agent_skill_read_file.ts +++ b/src/node/services/tools/agent_skill_read_file.ts @@ -1,3 +1,5 @@ +import { constants as fsConstants } from "node:fs"; +import * as fs from "node:fs/promises"; import { tool } from "ai"; import type { AgentSkillReadFileToolResult } from "@/common/types/tools"; @@ -16,7 +18,8 @@ import { readBuiltInSkillFile } from "@/node/services/agentSkills/builtInSkillDe import { RemoteRuntime } from "@/node/runtime/RemoteRuntime"; import { RuntimeError } from "@/node/runtime/Runtime"; import { readFileString } from "@/node/utils/runtime/helpers"; -import { resolveContainedSkillFilePath } from "./skillFileUtils"; +import { realpathOpenedFile } from "@/node/utils/openedFileRealpath"; +import { isPathInsideRoot, resolveContainedSkillFilePath } from "./skillFileUtils"; import { resolveContainedSkillFilePathOnRuntime } from "./runtimeSkillPathUtils"; function readContentWithFileReadLimits(input: { @@ -95,6 +98,51 @@ function readContentWithFileReadLimits(input: { }; } +async function readContainedExtensionSkillFile(input: { + skillDir: string; + filePath: string; +}): Promise<{ content: string; size: number; modifiedTime: Date; isDirectory: boolean }> { + const { resolvedPath } = await resolveContainedSkillFilePath(input.skillDir, input.filePath); + // Revalidate via realpath after any preflight stat so a writable extension root + // cannot swap the file to an escaping symlink between containment and read. + await fs.stat(resolvedPath); + const [rootRealPath, targetRealPath] = await Promise.all([ + fs.realpath(input.skillDir), + fs.realpath(resolvedPath), + ]); + if (!isPathInsideRoot(rootRealPath, targetRealPath)) { + throw new Error( + "Path resolves outside the extension skill directory after symlink resolution." + ); + } + + const noFollow = fsConstants.O_NOFOLLOW ?? 0; + const handle = await fs.open(targetRealPath, fsConstants.O_RDONLY | noFollow); + try { + const openedRealPath = await realpathOpenedFile(handle, targetRealPath); + if (!isPathInsideRoot(rootRealPath, openedRealPath)) { + throw new Error("Opened file resolves outside the extension skill directory."); + } + const stat = await handle.stat(); + if (stat.isDirectory()) { + throw new Error(`Path is a directory, not a file: ${input.filePath}`); + } + if (!stat.isFile()) { + throw new Error(`Path is not a regular file: ${input.filePath}`); + } + const sizeValidation = validateFileSize({ + size: stat.size, + modifiedTime: stat.mtime, + isDirectory: false, + }); + if (sizeValidation) throw new Error(sizeValidation.error); + const content = await handle.readFile("utf8"); + return { content, size: stat.size, modifiedTime: stat.mtime, isDirectory: false }; + } finally { + await handle.close(); + } +} + /** * Agent Skill read_file tool factory. * Reads a file within a skill directory with the same output limits as file_read. @@ -142,6 +190,7 @@ export const createAgentSkillReadFileTool: ToolFactory = (config: ToolConfigurat { roots: skillCtx.roots, containment: skillCtx.containment, + extensionSkills: config.extensionSkills, } ); @@ -169,6 +218,32 @@ export const createAgentSkillReadFileTool: ToolFactory = (config: ToolConfigurat }); } + if (resolvedSkill.package.scope === "extension") { + try { + const file = await readContainedExtensionSkillFile({ + skillDir: resolvedSkill.skillDir, + filePath, + }); + const sizeValidation = validateFileSize({ + size: file.size, + modifiedTime: file.modifiedTime, + isDirectory: file.isDirectory, + }); + if (sizeValidation) { + return { success: false, error: sizeValidation.error }; + } + return readContentWithFileReadLimits({ + fullContent: file.content, + fileSize: file.size, + modifiedTime: file.modifiedTime.toISOString(), + offset, + limit, + }); + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + } + const skillRuntime = resolvedSkill.sourceRuntime; if (skillRuntime == null) { throw new Error( diff --git a/src/node/services/tools/skillFileUtils.ts b/src/node/services/tools/skillFileUtils.ts index ed63e7c3f5..6d4ba9af0a 100644 --- a/src/node/services/tools/skillFileUtils.ts +++ b/src/node/services/tools/skillFileUtils.ts @@ -63,7 +63,7 @@ function resolveSkillFilePath( }; } -async function lstatIfExists(targetPath: string): Promise { +export async function lstatIfExists(targetPath: string): Promise { try { return await fsPromises.lstat(targetPath); } catch (error) { diff --git a/src/node/services/tools/testHelpers.ts b/src/node/services/tools/testHelpers.ts index 7ceaa693d0..5847ab27d0 100644 --- a/src/node/services/tools/testHelpers.ts +++ b/src/node/services/tools/testHelpers.ts @@ -387,6 +387,7 @@ export function createTestToolConfig( sessionsDir?: string; runtime?: Runtime; muxScope?: MuxToolScope; + extensionSkills?: ToolConfiguration["extensionSkills"]; } ): ToolConfiguration { return { @@ -399,6 +400,7 @@ export function createTestToolConfig( type: "global", muxHome: tempDir, }, + extensionSkills: options?.extensionSkills, }; } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5170a5001e..24fab75758 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -15,6 +15,7 @@ import { delegatedToolCallManager } from "@/node/services/delegatedToolCallManag import { log } from "@/node/services/log"; import { isPathInsideDir } from "@/node/utils/pathUtils"; import { AgentSession } from "@/node/services/agentSession"; +import type { ExtensionSkillSource } from "@/common/extensions/extensionSkillSource"; import type { HistoryService } from "@/node/services/historyService"; import type { AIService } from "@/node/services/aiService"; import type { InitStateManager } from "@/node/services/initStateManager"; @@ -1281,6 +1282,11 @@ export class WorkspaceService extends EventEmitter { private terminalService?: TerminalService; private desktopSessionManager?: DesktopSessionManager; private readonly sessionTimingService?: SessionTimingService; + // Resolves extension-contributed skills for /skill-name dispatch inside + // AgentSession. Set after construction by ServiceContainer to avoid the + // WorkspaceService → ExtensionRegistry → AIService → WorkspaceService + // cycle that direct injection would create. + private getExtensionSkillSources?: (projectPath: string) => readonly ExtensionSkillSource[]; private workspaceLifecycleHooks?: WorkspaceLifecycleHooks; private worktreeArchiveSnapshotService?: WorktreeArchiveSnapshotLifecycleService; private taskService?: TaskService; @@ -1871,6 +1877,12 @@ export class WorkspaceService extends EventEmitter { }); } + setExtensionSkillSourcesProvider( + provider: ((projectPath: string) => readonly ExtensionSkillSource[]) | undefined + ): void { + this.getExtensionSkillSources = provider; + } + private createSession(workspaceId: string): AgentSession { return new AgentSession({ workspaceId, @@ -1888,6 +1900,7 @@ export class WorkspaceService extends EventEmitter { onPostCompactionStateChange: () => { this.schedulePostCompactionMetadataRefresh(workspaceId); }, + getExtensionSkillSources: this.getExtensionSkillSources, }); } diff --git a/src/node/utils/openedFileRealpath.test.ts b/src/node/utils/openedFileRealpath.test.ts new file mode 100644 index 0000000000..674f91936e --- /dev/null +++ b/src/node/utils/openedFileRealpath.test.ts @@ -0,0 +1,48 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; + +import { realpathOpenedFile } from "./openedFileRealpath"; + +let tempDir: string; + +beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-opened-file-realpath-")); +}); + +afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); +}); + +describe("realpathOpenedFile", () => { + test("fails closed when the opened file descriptor cannot be canonicalized", async () => { + const filePath = path.join(tempDir, "file.txt"); + await fs.writeFile(filePath, "safe", "utf-8"); + const handle = await fs.open(filePath, "r"); + const originalRealpath = fs.realpath; + const realpathSpy = spyOn(fs, "realpath"); + realpathSpy.mockImplementation((async (target: Parameters[0]) => { + const targetPath = String(target); + if (targetPath.startsWith("/proc/self/fd/") || targetPath.startsWith("/dev/fd/")) { + throw new Error("fd namespace unavailable"); + } + return originalRealpath(target); + }) as typeof fs.realpath); + + try { + let error: unknown; + try { + await realpathOpenedFile(handle, filePath); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(Error); + expect(error instanceof Error ? error.message : "").toMatch(/opened file descriptor/i); + } finally { + realpathSpy.mockRestore(); + await handle.close(); + } + }); +}); diff --git a/src/node/utils/openedFileRealpath.ts b/src/node/utils/openedFileRealpath.ts new file mode 100644 index 0000000000..23ef563e51 --- /dev/null +++ b/src/node/utils/openedFileRealpath.ts @@ -0,0 +1,20 @@ +import * as fs from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; + +export async function realpathOpenedFile( + handle: Pick, + fallbackPath: string +): Promise { + for (const fdPath of [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]) { + try { + return await fs.realpath(fdPath); + } catch { + // Linux exposes /proc/self/fd; macOS exposes /dev/fd. If neither + // handle-bound path exists, fail closed rather than validating a fallback + // path that may no longer identify the already-opened file. + } + } + throw new Error( + `Unable to resolve opened file descriptor realpath for ${fallbackPath}; refusing path fallback for TOCTOU safety.` + ); +} diff --git a/tests/e2e/scenarios/extensionPlatform.spec.ts b/tests/e2e/scenarios/extensionPlatform.spec.ts new file mode 100644 index 0000000000..37b586f37b --- /dev/null +++ b/tests/e2e/scenarios/extensionPlatform.spec.ts @@ -0,0 +1,50 @@ +import { electronTest as test, electronExpect as expect } from "../electronTest"; + +test.skip( + ({ browserName }) => browserName !== "chromium", + "Electron scenario runs on chromium only" +); + +const DEMO_DISPLAY_NAME = "Mux Platform Demo"; + +// Smoke test for the Extension Platform. Verifies, end-to-end, that the bundled +// Demo Extension's `mux-extensions` skill is discoverable as a card in the +// Extensions Settings Section without any manual setup. Extensions intentionally +// have no experiment kill switch because built-in skills may migrate onto this +// platform and must remain available. +test.describe("Extension Platform smoke", () => { + test("Demo Extension is discoverable", async ({ ui, page }) => { + await ui.projects.openFirstWorkspace(); + + await page.evaluate(async () => { + if (!window.__ORPC_CLIENT__) { + throw new Error("ORPC client not initialized"); + } + await window.__ORPC_CLIENT__.extensions.reload({}); + }); + + await ui.settings.open(); + const extensionsTab = page.getByRole("button", { name: "Extensions", exact: true }); + await expect(extensionsTab).toBeVisible(); + await extensionsTab.click(); + + // The bundled Demo Extension card surfaces the friendly displayName from + // its manifest, regardless of whether the user has granted it yet — the + // card is the entry point for granting. + await expect(page.getByText(DEMO_DISPLAY_NAME, { exact: false })).toBeVisible({ + timeout: 15_000, + }); + + await page.reload(); + await page.waitForLoadState("domcontentloaded"); + await ui.projects.openFirstWorkspace(); + await ui.settings.open(); + + const restoredExtensionsTab = page.getByRole("button", { name: "Extensions", exact: true }); + await expect(restoredExtensionsTab).toBeVisible({ timeout: 5_000 }); + await restoredExtensionsTab.click(); + await expect(page.getByText(DEMO_DISPLAY_NAME, { exact: false })).toBeVisible({ + timeout: 15_000, + }); + }); +}); diff --git a/tests/ui/dom.ts b/tests/ui/dom.ts index 60a8f05b5b..c7bda87666 100644 --- a/tests/ui/dom.ts +++ b/tests/ui/dom.ts @@ -27,6 +27,23 @@ interface DomGlobalsSnapshot { // Some Radix internals decide at module-eval time whether to enable useLayoutEffect based // on `globalThis.document`. See the bootstrap at the bottom of this module. +export function ensureDomInstalled(): void { + if (globalThis.window !== undefined && globalThis.document?.body !== undefined) return; + // Some older hook tests still clear window/document after importing this module. + // Reinstall a process baseline instead of relying on module bootstrap to run again. + installDom(); +} + +function installBaselineDomWhenPreviousWasMissing(previous: DomGlobalsSnapshot): boolean { + if (previous.window !== undefined && previous.document !== undefined) return false; + // tests/ui/dom is imported once for the whole Bun process. If an earlier + // non-UI test cleared the DOM globals, restoring that missing snapshot would + // leave later UI tests with no document even though this module's bootstrap has + // already run. Keep a baseline Happy DOM alive after this helper is imported. + installDom(); + return true; +} + export function installDom(): () => void { const previous: DomGlobalsSnapshot = { window: globalThis.window, @@ -202,6 +219,8 @@ export function installDom(): () => void { return () => { domWindow.close(); + if (installBaselineDomWhenPreviousWasMissing(previous)) return; + (globalThis as unknown as { Element?: unknown }).Element = previous.Element; globalThis.window = previous.window; (globalThis as unknown as { DocumentFragment?: unknown }).DocumentFragment =