From df652331c5021bb5e8e2b53bbaa269d0c6550f38 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Oct 2026 00:13:15 -0500 Subject: [PATCH] =?UTF-8?q?Reconstruct=20batch=20104/124:=202026-10-14=20?= =?UTF-8?q?=E2=86=92=202026-10-15=20(sessions,=20chat,=20agenthost,=20exte?= =?UTF-8?q?nsions/markdown-language-features)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed reconstruction of 50 upstream commits (4e4b35ed933..ffb8e4b144d). 256 files changed, 25254 insertions(+), 2162 deletions(-) sessions: - sessions: align bottom panel tabs with editor tabs (#341806) - sessions: preserve remote tags in recent folders (#341654) - sessions: reuse worktree cleanup measurements (#341814) - sessions: stabilize bottom panel tab geometry - sessions: stabilize bottom panel tab geometry (#341849) - Recommend connectors below the new chat composer (#341844) - sessions: Prevent overlapping rows during chat hydration (#341866) - sessions: Add file search to empty Files editor (#341843) misc: - Initial plan - Support video and native context-menu media paste in Markdown (#341552) - Skip serializing bulk edits when debug logging is off (#341765) - Omit connector names from connection-action telemetry (#341684) - Make copilot work in any terminal with a native Copilot CLI shim (#341667) - Support presentation preferences for Markdown embedded editors (#341742) chat: - Remove connector name from telemetry - chat: pick cloud sandbox repository in the renderer without the Copilot extension (#341695) - Fix incomplete Mission Control sandbox session discovery (#341823) - chat: grow migration tree rows to fit MCP warnings - chat: grow migration tree rows to fit MCP warnings (#341853) - chat: keep agents/sessionOpen and log chat open milestones on every surface (#341733) agenthost: - agentHost: anchor Copilot customizations to each chat's own folders (#341694) - Agents - more cleaup for the branch picker default value (#341766) - agentHost: detach an archived chat's folders from its session (#341782) - Fix Copilot plugin command state (#341787) - agentHost: pass telemetry flag to runtime (#341661) - Agents - session changes should always use file edit tracking (#341801) extensions/markdown-language-features: - Support video and native context-menu media paste in Markdown - Use lazy bounded Markdown clipboard media transfers - Use the published Markdown editor native-paste fixes - Support presentation preferences for Markdown embedded editors - Consume published Markdown editor presentation controls terminal: - Mark variables in zsh integration functions as local (#290298) - Remove wall-clock throttle from PromptInputModel sync (#341637) - Keep Bash environment reporting variables local (#341796) - Don't reattach restored terminal editors to another window's terminal (#341692) agents window: - Agents window: align terminal instance list styling - Agents window: align terminal instance list styling (#341568) webview: - Restore webview input focus before native clipboard commands bulkedit: - Skip serializing bulk edits when debug logging is off dependencies: - Update Foundry Local SDK to 2.1.0 (#341640) extensions/media-preview: - Fix image preview copy performance by using a worker for encoding (#341783) tunnel: - Prevent truncated responses through managed port-forwarding sockets (#338875) fix: - fix: path, URI, JSON edit utility bugs (#334259) canvases: - canvases: refactor: share definition refresh logic across windows (#341826) ci: - ci: run ESLint after parallel compile checks (#339153) Co-Authored-By: Claude Opus 5.5 --- .agents/skills/launch/scripts/launch.ps1 | 2 +- .github/workflows/pr-linux-cli-test.yml | 53 +- .github/workflows/pr.yml | 20 +- .gitignore | 1 + .vscode/settings.json | 3 +- .../alpine/product-build-alpine-cli.yml | 28 + .../alpine/product-build-alpine.yml | 19 + .../copilot-shim/copilot-shim-compile.yml | 167 ++ .../copilot-shim/copilot-shim-quality.yml | 16 + .../darwin/product-build-darwin-cli.yml | 20 + .../steps/product-build-darwin-compile.yml | 29 +- .../linux/product-build-linux-cli.yml | 29 + .../steps/product-build-linux-package.yml | 25 +- .../oss/cargo-section4.test.ts | 28 +- .../win32/product-build-win32-cli.yml | 26 + .../win32/product-build-win32.yml | 14 +- .../win32/steps/product-build-win32-sign.yml | 25 +- build/gulpfile.vscode.win32.ts | 4 + build/lib/policies/policyData.jsonc | 18 + build/lib/test/dictationRuntime.test.ts | 10 +- build/win32/code.iss | 19 +- build/win32/copilot.iss | 1163 ++++++++++++ build/win32/i18n/messages.en.isl | 35 + copilot_shim/.cargo/config.toml | 6 + copilot_shim/Cargo.lock | 116 ++ copilot_shim/Cargo.toml | 26 + copilot_shim/REQUIREMENTS.md | 1283 ++++++++++++++ copilot_shim/SHIM_POST_INSTALL.md | 215 +++ copilot_shim/build.rs | 197 +++ copilot_shim/rustfmt.toml | 1 + copilot_shim/src/app.rs | 1554 +++++++++++++++++ copilot_shim/src/candidate.rs | 1105 ++++++++++++ copilot_shim/src/command.rs | 336 ++++ copilot_shim/src/identity.rs | 51 + copilot_shim/src/install.rs | 1501 ++++++++++++++++ copilot_shim/src/invocation.rs | 591 +++++++ copilot_shim/src/legacy.rs | 68 + copilot_shim/src/lib.rs | 52 + copilot_shim/src/main.rs | 9 + copilot_shim/src/model.rs | 238 +++ copilot_shim/src/platform/mod.rs | 326 ++++ copilot_shim/src/platform/unix.rs | 65 + copilot_shim/src/platform/windows.rs | 453 +++++ copilot_shim/src/prompt.rs | 112 ++ copilot_shim/src/runtime.rs | 361 ++++ copilot_shim/src/setup/mod.rs | 1080 ++++++++++++ copilot_shim/src/setup/windows.rs | 805 +++++++++ copilot_shim/src/supervisor.rs | 617 +++++++ copilot_shim/src/version.rs | 106 ++ copilot_shim/tests/app.rs | 259 +++ copilot_shim/tests/conpty.rs | 1176 +++++++++++++ .../tests/fixtures/legacy/git-bash-wrapper.sh | 2 + .../tests/fixtures/legacy/npm-wrapper.cmd | 4 + .../tests/fixtures/legacy/npm-wrapper.ps1 | 3 + .../tests/fixtures/legacy/posix-launcher.sh | 3 + .../fixtures/legacy/windows-bootstrapper.ps1 | 6 + .../tests/fixtures/legacy/windows-wrapper.cmd | 2 + copilot_shim/tests/pty.rs | 660 +++++++ extensions/copilot/.esbuild.mts | 1 - extensions/copilot/.vscodeignore | 1 - .../vscode-node/copilotCLINativeShim.ts | 217 +++ .../vscode-node/copilotCLIShim.ps1 | 236 --- .../vscode-node/copilotCLIShim.ts | 223 --- .../copilotCLITerminalIntegration.ts | 271 +-- .../test/copilotCLIChatSessions.spec.ts | 1 - .../test/copilotCLINativeShim.spec.ts | 193 ++ .../copilotCLITerminalIntegration.spec.ts | 259 ++- .../CONTRIBUTING.md | 16 + .../markdown-language-features/README.md | 15 - .../markdown-editor-src/clipboardMedia.ts | 16 + .../markdown-editor-src/editor.ts | 7 +- .../imagePasteController.ts | 65 +- .../package-lock.json | 8 +- .../markdown-language-features/package.json | 3 +- .../schemas/package.schema.json | 11 + .../scripts/clipboardMedia.test.mts | 48 + .../codeBlockEditorPresentation.test.mts | 51 + .../markdownEditorMediaTransfer.test.mts | 129 ++ .../src/markdownExtensions.ts | 11 + .../src/preview/markdownEditorImagePaste.ts | 34 +- .../preview/markdownEditorMediaTransfer.ts | 76 + .../src/preview/markdownEditorProtocol.ts | 29 +- .../src/preview/markdownEditorProvider.ts | 19 +- .../src/test/markdownEditorBridge.test.ts | 1 + .../src/test/markdownEditorImagePaste.test.ts | 104 +- .../src/test/markdownEditorProvider.test.ts | 12 +- .../src/test/markdownEditorRpc.test.ts | 34 +- .../media-preview/media/imagePreview.js | 106 +- .../media-preview/src/imagePreview/index.ts | 2 +- package-lock.json | 8 +- package.json | 4 +- src/vs/base/common/jsonEdit.ts | 2 +- src/vs/base/common/product.ts | 5 + src/vs/base/common/uri.ts | 4 +- src/vs/base/test/common/jsonEdit.test.ts | 6 + src/vs/base/test/common/uri.test.ts | 6 + .../accessibility/browser/accessibleView.ts | 1 + src/vs/platform/agentHost/AGENTS.md | 2 + .../common/agentHostChangesetService.ts | 2 +- .../common/agentHostFileSystemService.ts | 1 + .../agentHost/common/agentHostGitService.ts | 1 + .../common/agentHostPolicySupport.ts | 3 + .../agentHost/common/cloudSandboxAgentHost.ts | 13 +- .../agentHost/node/agentChatContext.ts | 57 +- .../node/agentHostChangesetCoordinator.ts | 8 - ...gentHostChangesetFileMonitorCoordinator.ts | 4 +- .../node/agentHostChangesetService.ts | 4 +- .../agentHost/node/agentHostGitService.ts | 15 +- .../node/agentHostTelemetryService.ts | 9 + .../platform/agentHost/node/agentService.ts | 74 +- .../agentHost/node/agentServiceComposition.ts | 5 + .../archivedChatWorkspaceService.ts | 23 + .../chatArchive/chatArchiveContribution.ts | 8 +- .../checkpointAndChangesetContribution.ts | 5 +- .../agentHost/node/copilot/copilotAgent.ts | 774 ++++++-- .../node/copilot/copilotAgentSession.ts | 360 ++-- .../copilotCustomizationCommandDisplay.ts | 58 +- .../node/copilot/copilotSessionLauncher.ts | 32 +- .../copilotSlashCommandCompletionProvider.ts | 32 +- .../node/shared/worktreeIsolation.ts | 73 +- .../test/common/sessionTestHelpers.ts | 1 + .../agentHostChangesetCoordinator.test.ts | 114 +- .../node/agentHostChangesetService.test.ts | 30 +- .../node/agentHostChangesetStrategy.test.ts | 60 +- .../agentHostCommitOperationHandler.test.ts | 1 + ...HostDiscardChangesOperationHandler.test.ts | 1 + .../agentHostGitService.integrationTest.ts | 24 + ...entHostPullRequestOperationHandler.test.ts | 1 + .../test/node/agentHostSeams.test.ts | 23 + ...gentHostSessionStateOwnershipGuard.test.ts | 2 +- .../node/agentHostTelemetryService.test.ts | 5 + .../node/agentHostToolCallTelemetry.test.ts | 2 + .../node/agentHostTurnHangTelemetry.test.ts | 2 + .../test/node/agentHostTurnTelemetry.test.ts | 2 + .../agentHost/test/node/agentService.test.ts | 179 +- .../test/node/agentSideEffects.test.ts | 2 + .../test/node/chatContributions.test.ts | 19 +- .../agentHost/test/node/copilotAgent.test.ts | 1060 ++++++++++- .../test/node/copilotGitProject.test.ts | 1 + .../test/node/copilotSessionLauncher.test.ts | 94 +- .../test/node/copilotSlashCommand.test.ts | 127 +- ...ilotSlashCommandCompletionProvider.test.ts | 24 +- ...der-edits-from-default-and-peer-chats.yaml | 89 - .../test/node/e2e/suites/changesetSuite.ts | 18 +- .../protocol/sessionDiffs.integrationTest.ts | 133 +- .../node/shared/worktreeIsolation.test.ts | 80 +- .../quickinput/browser/quickAccess.ts | 9 +- .../platform/quickinput/common/quickAccess.ts | 5 + .../commandDetection/promptInputModel.ts | 2 - src/vs/platform/terminal/common/terminal.ts | 12 +- .../platform/terminal/node/ptyHostService.ts | 4 +- src/vs/platform/terminal/node/ptyService.ts | 12 +- .../commandDetection/promptInputModel.test.ts | 18 + .../terminal/test/node/ptyService.test.ts | 28 +- src/vs/platform/tunnel/node/tunnelService.ts | 11 +- .../tunnel/test/node/tunnelService.test.ts | 93 + src/vs/sessions/browser/media/workbench.css | 6 +- src/vs/sessions/browser/menus.ts | 1 + .../browser/parts/media/panelPart.css | 134 +- src/vs/sessions/browser/parts/panelPart.ts | 42 +- .../common/agentHostSessionWorkspace.ts | 36 +- src/vs/sessions/common/sessionConfig.ts | 2 + .../electron-browser/sessionCanvasService.ts | 57 +- .../contrib/chat/browser/chat.contribution.ts | 10 +- .../sessions/contrib/chat/browser/chatView.ts | 95 +- .../contrib/chat/browser/media/chatWidget.css | 35 +- .../media/newChatConnectorRecommendations.css | 130 ++ .../browser/media/newChatMigrationNotice.css | 16 +- .../newChatConnectorRecommendations.ts | 201 +++ .../contrib/chat/browser/newChatWidget.ts | 37 +- .../browser/sessionsChatAccessibilityHelp.ts | 8 +- .../sessions/contrib/chat/common/constants.ts | 1 + .../chat/test/browser/chatView.test.ts | 74 +- .../newChatConnectorRecommendations.test.ts | 213 +++ .../test/browser/newChatWidget.fixture.ts | 252 ++- .../sessionsChatAccessibilityHelp.test.ts | 25 +- .../browser/emptyFileEditor.contribution.ts | 37 +- .../contrib/editor/browser/emptyFileEditor.ts | 123 +- .../browser/emptyFileEditorAccessibility.ts | 39 + .../editor/browser/emptyFileEditorSearch.ts | 399 +++++ .../editor/browser/media/emptyFileEditor.css | 35 +- .../test/browser/editor.contribution.test.ts | 623 ++++++- .../browser/baseAgentHostSessionsProvider.ts | 1 + .../localAgentHostSessionsProvider.test.ts | 48 + .../browser/sessionWorktreeCleanupService.ts | 37 +- .../sessionWorktreeCleanupService.test.ts | 66 +- .../sessions/browser/sessions.contribution.ts | 1 + .../sessions/browser/sessionsActions.ts | 16 +- .../browser/updateSessionFoldersAction.ts | 74 + .../sessions/browser/views/sessionsList.ts | 8 +- .../test/browser/sessionsList.test.ts | 58 +- .../test/browser/terminalTabsStyles.test.ts | 88 + .../electron-browser/actions/vscodeActions.ts | 31 +- .../test/browser/parts/panelPart.test.ts | 291 ++- .../common/agentHostSessionWorkspace.test.ts | 30 + .../electron-browser/vscodeActions.test.ts | 165 +- src/vs/workbench/browser/part.ts | 3 +- .../browser/accessibilityConfiguration.ts | 5 + .../bulkEdit/browser/bulkEditService.ts | 17 +- .../canvases/browser/editorCanvasActions.ts | 78 +- .../canvases/common/canvasDefinitionStore.ts | 97 + .../test/common/canvasDefinitionStore.test.ts | 233 +++ .../agentHost/syncedCustomizationBundler.ts | 7 +- .../browser/agentSessions/repositoryPicker.ts | 5 + .../aiCustomizationManagement.contribution.ts | 16 +- .../aiCustomizationManagementEditor.ts | 35 +- .../copilotConnectorsService.ts | 3 - .../customizationMigrationDashboard.ts | 5 +- .../chat/browser/chat.shared.contribution.ts | 19 +- .../remoteAgentHost/cloudSandboxApiService.ts | 43 +- .../cloudSandboxRepositoryPicker.ts | 102 ++ .../editorCloudSandboxContribution.ts | 25 +- .../contrib/chat/browser/widget/chatWidget.ts | 4 + .../browser/widgetHosts/editor/chatEditor.ts | 50 +- .../widgetHosts/editor/chatEditorInput.ts | 5 + .../widgetHosts/viewPane/chatViewPane.ts | 149 +- .../common/aiCustomizationWorkspaceService.ts | 2 + .../chat/common/chatOpenDiagnosticsService.ts | 149 ++ .../common/chatService/chatServiceImpl.ts | 12 +- .../contrib/chat/common/constants.ts | 1 + .../contrib/chat/common/enablement.ts | 39 +- .../common/plugins/agentPluginEnablement.ts | 8 + .../common/plugins/agentPluginServiceImpl.ts | 4 + .../aiCustomizationManagementEditor.test.ts | 12 +- .../copilotConnectorsService.test.ts | 4 - .../chatEditing/chatEditingService.test.ts | 2 + .../cloudSandboxApiService.test.ts | 144 +- .../cloudSandboxRepositoryPicker.test.ts | 148 ++ .../editorCloudSandboxContribution.test.ts | 23 +- .../test/browser/widget/chatWidget.test.ts | 2 + .../editor/chatEditorInput.test.ts | 101 +- .../widgetHosts/viewPane/chatViewPane.test.ts | 91 +- .../common/chatOpenDiagnosticsService.test.ts | 139 ++ .../common/chatService/chatService.test.ts | 2 + .../plugins/agentPluginEnablement.test.ts | 27 + .../copilotCliAgentPluginDiscovery.test.ts | 34 +- .../browser/inlineChatSessionService.test.ts | 2 + .../search/browser/anythingQuickAccess.ts | 109 +- .../workbench/contrib/search/common/search.ts | 20 +- .../browser/terminalProcessManager.ts | 5 +- .../common/scripts/shellIntegration-bash.sh | 3 + .../common/scripts/shellIntegration-rc.zsh | 3 + .../electron-browser/localTerminalBackend.ts | 16 +- .../browser/terminalProcessManager.test.ts | 55 +- .../localTerminalBackend.test.ts | 85 + .../contrib/webview/browser/pre/index.html | 19 +- .../test/node/webviewShellMessaging.test.ts | 60 + .../common/agentHostFileSystemService.ts | 4 +- .../chat/chatFixtureUtils.ts | 2 + .../editor/inlineChatZoneWidget.fixture.ts | 2 + ...aiCustomizationManagementEditor.fixture.ts | 19 +- ...customizationMigrationDashboard.fixture.ts | 41 +- .../sessions/emptyStates.fixture.ts | 257 ++- src/vs/workbench/test/browser/part.test.ts | 29 +- .../test/browser/quickAccess.test.ts | 43 + .../blocks-ci-screenshots.md | 23 +- 256 files changed, 25254 insertions(+), 2162 deletions(-) create mode 100644 build/azure-pipelines/copilot-shim/copilot-shim-compile.yml create mode 100644 build/azure-pipelines/copilot-shim/copilot-shim-quality.yml create mode 100644 build/win32/copilot.iss create mode 100644 copilot_shim/.cargo/config.toml create mode 100644 copilot_shim/Cargo.lock create mode 100644 copilot_shim/Cargo.toml create mode 100644 copilot_shim/REQUIREMENTS.md create mode 100644 copilot_shim/SHIM_POST_INSTALL.md create mode 100644 copilot_shim/build.rs create mode 100644 copilot_shim/rustfmt.toml create mode 100644 copilot_shim/src/app.rs create mode 100644 copilot_shim/src/candidate.rs create mode 100644 copilot_shim/src/command.rs create mode 100644 copilot_shim/src/identity.rs create mode 100644 copilot_shim/src/install.rs create mode 100644 copilot_shim/src/invocation.rs create mode 100644 copilot_shim/src/legacy.rs create mode 100644 copilot_shim/src/lib.rs create mode 100644 copilot_shim/src/main.rs create mode 100644 copilot_shim/src/model.rs create mode 100644 copilot_shim/src/platform/mod.rs create mode 100644 copilot_shim/src/platform/unix.rs create mode 100644 copilot_shim/src/platform/windows.rs create mode 100644 copilot_shim/src/prompt.rs create mode 100644 copilot_shim/src/runtime.rs create mode 100644 copilot_shim/src/setup/mod.rs create mode 100644 copilot_shim/src/setup/windows.rs create mode 100644 copilot_shim/src/supervisor.rs create mode 100644 copilot_shim/src/version.rs create mode 100644 copilot_shim/tests/app.rs create mode 100644 copilot_shim/tests/conpty.rs create mode 100644 copilot_shim/tests/fixtures/legacy/git-bash-wrapper.sh create mode 100644 copilot_shim/tests/fixtures/legacy/npm-wrapper.cmd create mode 100644 copilot_shim/tests/fixtures/legacy/npm-wrapper.ps1 create mode 100644 copilot_shim/tests/fixtures/legacy/posix-launcher.sh create mode 100644 copilot_shim/tests/fixtures/legacy/windows-bootstrapper.ps1 create mode 100644 copilot_shim/tests/fixtures/legacy/windows-wrapper.cmd create mode 100644 copilot_shim/tests/pty.rs create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLINativeShim.ts delete mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIShim.ps1 delete mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIShim.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLINativeShim.spec.ts create mode 100644 extensions/markdown-language-features/markdown-editor-src/clipboardMedia.ts create mode 100644 extensions/markdown-language-features/scripts/clipboardMedia.test.mts create mode 100644 extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts create mode 100644 extensions/markdown-language-features/scripts/markdownEditorMediaTransfer.test.mts create mode 100644 extensions/markdown-language-features/src/preview/markdownEditorMediaTransfer.ts create mode 100644 src/vs/platform/agentHost/node/chatContributions/chatArchive/archivedChatWorkspaceService.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-session-changeset-aggregates-provider-edits-from-default-and-peer-chats.yaml create mode 100644 src/vs/platform/tunnel/test/node/tunnelService.test.ts create mode 100644 src/vs/sessions/contrib/chat/browser/media/newChatConnectorRecommendations.css create mode 100644 src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts create mode 100644 src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts create mode 100644 src/vs/sessions/contrib/editor/browser/emptyFileEditorAccessibility.ts create mode 100644 src/vs/sessions/contrib/editor/browser/emptyFileEditorSearch.ts create mode 100644 src/vs/sessions/contrib/sessions/browser/updateSessionFoldersAction.ts create mode 100644 src/vs/sessions/contrib/terminal/test/browser/terminalTabsStyles.test.ts create mode 100644 src/vs/workbench/contrib/canvases/common/canvasDefinitionStore.ts create mode 100644 src/vs/workbench/contrib/canvases/test/common/canvasDefinitionStore.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts create mode 100644 src/vs/workbench/contrib/chat/common/chatOpenDiagnosticsService.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxRepositoryPicker.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/common/chatOpenDiagnosticsService.test.ts create mode 100644 src/vs/workbench/contrib/terminal/test/electron-browser/localTerminalBackend.test.ts diff --git a/.agents/skills/launch/scripts/launch.ps1 b/.agents/skills/launch/scripts/launch.ps1 index 9102dd92a0fbcd..70c073f77a2098 100644 --- a/.agents/skills/launch/scripts/launch.ps1 +++ b/.agents/skills/launch/scripts/launch.ps1 @@ -33,7 +33,7 @@ function Exit-Usage([string]$message) { } function Get-UsableNode([string]$repoPath) { - $command = Get-Command node -CommandType Application -ErrorAction SilentlyContinue + $command = Get-Command node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 $nvmrcPath = Join-Path $repoPath '.nvmrc' $requiredVersion = if (Test-Path -LiteralPath $nvmrcPath -PathType Leaf) { (Get-Content -LiteralPath $nvmrcPath -Raw).Trim().TrimStart('v') diff --git a/.github/workflows/pr-linux-cli-test.yml b/.github/workflows/pr-linux-cli-test.yml index 21f3ba72420dff..beedaebf535524 100644 --- a/.github/workflows/pr-linux-cli-test.yml +++ b/.github/workflows/pr-linux-cli-test.yml @@ -7,6 +7,22 @@ on: rustup_toolchain: type: string required: true + working_directory: + type: string + required: false + default: cli + format: + type: boolean + required: false + default: false + locked: + type: boolean + required: false + default: false + all_targets: + type: boolean + required: false + default: false jobs: linux-cli-test: @@ -33,16 +49,45 @@ jobs: rustup update $RUSTUP_TOOLCHAIN rustup component add clippy + - name: Install Rust formatting component + if: ${{ inputs.format }} + run: rustup component add rustfmt + - name: Check Rust versions run: | set -e rustc --version cargo --version + - name: Check formatting + if: ${{ inputs.format }} + run: cargo fmt --check + working-directory: ${{ inputs.working_directory }} + - name: Clippy lint - run: cargo clippy -- -D warnings - working-directory: cli + run: | + set -e + args=() + if [[ "$CARGO_ALL_TARGETS" == "true" ]]; then + args+=(--all-targets) + fi + if [[ "$CARGO_LOCKED" == "true" ]]; then + args+=(--locked) + fi + cargo clippy "${args[@]}" -- -D warnings + working-directory: ${{ inputs.working_directory }} + env: + CARGO_ALL_TARGETS: ${{ inputs.all_targets }} + CARGO_LOCKED: ${{ inputs.locked }} - name: ๐Ÿงช Run unit tests - run: cargo test - working-directory: cli + run: | + set -e + args=() + if [[ "$CARGO_LOCKED" == "true" ]]; then + args+=(--locked) + fi + cargo test "${args[@]}" + working-directory: ${{ inputs.working_directory }} + env: + CARGO_LOCKED: ${{ inputs.locked }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b4f1182cb0a8f4..9eac17d7f290b2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -90,16 +90,21 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Check Markdown editor manifests + - name: Check Markdown editor manifests and clipboard run: | npm run markdown-editor-package-json-check npm --prefix extensions/markdown-language-features run test-markdown-editor-package-json + npm --prefix extensions/markdown-language-features run test-markdown-editor-clipboard - name: Compile & Hygiene - run: npm exec -- npm-run-all2 -lp core-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts + run: npm exec -- npm-run-all2 -lp core-ci hygiene valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Avoid overlapping ESLint's worker memory with the parallel compilation checks. + - name: ESLint + run: npm run eslint + - name: Check Codex protocol client is in sync run: | git fetch --no-tags --depth=1 origin "$CODEX_SYNC_BASE" || true @@ -117,6 +122,17 @@ jobs: job_name: CLI rustup_toolchain: 1.88 + linux-copilot-shim-tests: + name: Linux Copilot Shim + uses: ./.github/workflows/pr-linux-cli-test.yml + with: + job_name: Copilot-Shim + rustup_toolchain: 1.88 + working_directory: copilot_shim + format: true + locked: true + all_targets: true + linux-electron-unit-tests: name: Linux uses: ./.github/workflows/pr-linux-test.yml diff --git a/.gitignore b/.gitignore index 17a28b4011e7f3..65c53291976d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ vscode.db /.profile-oss /cli/target /cli/openssl +/copilot_shim/target product.overrides.json *.snap.actual *.tsbuildinfo diff --git a/.vscode/settings.json b/.vscode/settings.json index a2bfe480ee7be9..c4361494582653 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -192,7 +192,8 @@ "emmet.excludeLanguages": [], "gulp.autoDetect": "off", "rust-analyzer.linkedProjects": [ - "cli/Cargo.toml" + "cli/Cargo.toml", + "copilot_shim/Cargo.toml" ], "conventionalCommits.scopes": [ "tree", diff --git a/build/azure-pipelines/alpine/product-build-alpine-cli.yml b/build/azure-pipelines/alpine/product-build-alpine-cli.yml index 0bbe00bb623de2..b5bcb2c5bfbb85 100644 --- a/build/azure-pipelines/alpine/product-build-alpine-cli.yml +++ b/build/azure-pipelines/alpine/product-build-alpine-cli.yml @@ -29,6 +29,12 @@ jobs: sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/cli sbomPackageName: "VS Code Alpine $(VSCODE_ARCH) CLI" sbomPackageVersion: $(Build.SourceVersion) + # The Alpine job adds the Copilot shim to the server builds. + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)/unsigned_copilot_shim_alpine_$(VSCODE_ARCH).tar.gz + artifactName: unsigned_copilot_shim_alpine_$(VSCODE_ARCH) + displayName: Publish unsigned Copilot shim + sbomEnabled: false steps: - template: ../common/checkout.yml@self @@ -130,3 +136,25 @@ jobs: OPENSSL_STATIC: "1" SYSROOT_ARCH: arm64 IS_MUSL: "1" + + - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + - template: ../copilot-shim/copilot-shim-compile.yml@self + parameters: + COPILOT_SHIM_TARGET: x86_64-unknown-linux-musl + COPILOT_SHIM_ARTIFACT: unsigned_copilot_shim_alpine_x64 + COPILOT_SHIM_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + COPILOT_SHIM_ENV: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc + CC_x86_64_unknown_linux_musl: musl-gcc + CXX_x86_64_unknown_linux_musl: musl-g++ + + - ${{ if eq(parameters.VSCODE_ARCH, 'arm64') }}: + - template: ../copilot-shim/copilot-shim-compile.yml@self + parameters: + COPILOT_SHIM_TARGET: aarch64-unknown-linux-musl + COPILOT_SHIM_ARTIFACT: unsigned_copilot_shim_alpine_arm64 + COPILOT_SHIM_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + COPILOT_SHIM_ENV: + GITHUB_TOKEN: "$(github-token-code-oss)" + SYSROOT_ARCH: arm64 + IS_MUSL: "1" diff --git a/build/azure-pipelines/alpine/product-build-alpine.yml b/build/azure-pipelines/alpine/product-build-alpine.yml index 4f593a842cbb75..31b4aa89a107f9 100644 --- a/build/azure-pipelines/alpine/product-build-alpine.yml +++ b/build/azure-pipelines/alpine/product-build-alpine.yml @@ -203,6 +203,11 @@ jobs: - script: node build/azure-pipelines/distro/mixin-quality.ts displayName: Mixin distro quality + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/waitForArtifacts.ts "--producer=unsigned_copilot_shim_alpine_$(VSCODE_ARCH)=Alpine CLI (${{ upper(parameters.VSCODE_ARCH) }})" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Wait for Copilot shim artifact (background) + - template: ../common/install-builtin-extensions.yml@self - script: node build/npm/electronTypes.ts @@ -229,11 +234,25 @@ jobs: SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Download Copilot VSIX + - script: npx deemon --attach -- node build/azure-pipelines/common/waitForArtifacts.ts "--producer=unsigned_copilot_shim_alpine_$(VSCODE_ARCH)=Alpine CLI (${{ upper(parameters.VSCODE_ARCH) }})" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Wait for Copilot shim artifact + + - task: DownloadPipelineArtifact@2 + inputs: + artifact: unsigned_copilot_shim_alpine_$(VSCODE_ARCH) + patterns: "**" + path: $(Build.ArtifactStagingDirectory)/copilot-shim + displayName: Download Copilot shim + - script: | set -e TARGET=$([ "$VSCODE_ARCH" == "x64" ] && echo "linux-alpine" || echo "alpine-arm64") # TODO@joaomoreno npm run gulp vscode-reh-$TARGET-min-ci (cd .. && mv vscode-reh-$TARGET vscode-server-$TARGET) # TODO@joaomoreno + tar -xzf $(Build.ArtifactStagingDirectory)/copilot-shim/*.tar.gz -C $(Build.ArtifactStagingDirectory)/copilot-shim + install -D -m 755 $(Build.ArtifactStagingDirectory)/copilot-shim/copilot ../vscode-server-$TARGET/bin/copilot-shim/copilot ARCHIVE_PATH="$(Build.ArtifactStagingDirectory)/out/server/vscode-server-$TARGET.tar.gz" DIR_PATH="$(realpath ../vscode-server-$TARGET)" mkdir -p $(dirname $ARCHIVE_PATH) diff --git a/build/azure-pipelines/copilot-shim/copilot-shim-compile.yml b/build/azure-pipelines/copilot-shim/copilot-shim-compile.yml new file mode 100644 index 00000000000000..1b5cdf1847d537 --- /dev/null +++ b/build/azure-pipelines/copilot-shim/copilot-shim-compile.yml @@ -0,0 +1,167 @@ +# Builds the native Copilot CLI shim in a VS Code CLI job, after cli-compile.yml has installed Rust and sccache, and +# stages it as .zip (Windows, macOS) or .tar.gz (Linux, Alpine). The shim is published +# unsigned; the product build places it in bin/copilot-shim of the client and server builds before signing them. +# The build shares the CLI's sccache directory and cache, because the sccache server that the CLI build started keeps +# serving its own directory for the rest of the job. +parameters: + - name: COPILOT_SHIM_TARGET + type: string + - name: COPILOT_SHIM_ARTIFACT + type: string + - name: COPILOT_SHIM_ENV + type: object + default: {} + - name: COPILOT_SHIM_CHECK_ONLY + type: boolean + default: false + +steps: + - ${{ if contains(parameters.COPILOT_SHIM_TARGET, '-linux-') }}: + - script: | + set -e + if [ -n "$SYSROOT_ARCH" ]; then + export VSCODE_SYSROOT_DIR=$(Build.SourcesDirectory)/.build/sysroots + node -e 'import { getVSCodeSysroot } from "../build/linux/debian/install-sysroot.ts"; (async () => { await getVSCodeSysroot(process.env["SYSROOT_ARCH"], process.env["IS_MUSL"] === "1"); })()' + if [ "$SYSROOT_ARCH" = "arm64" ]; then + if [ "$IS_MUSL" = "1" ]; then + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="$VSCODE_SYSROOT_DIR/output/bin/aarch64-linux-musl-gcc" + export CC_aarch64_unknown_linux_musl="$VSCODE_SYSROOT_DIR/output/bin/aarch64-linux-musl-gcc" + export CXX_aarch64_unknown_linux_musl="$VSCODE_SYSROOT_DIR/output/bin/aarch64-linux-musl-g++" + else + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-gcc" + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS="-C link-arg=--sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + export CC_aarch64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/bin/aarch64-linux-gnu-gcc --sysroot=$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + export PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/usr/lib/aarch64-linux-gnu/pkgconfig:$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot/usr/share/pkgconfig" + export PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/sysroot" + export OBJDUMP="$VSCODE_SYSROOT_DIR/aarch64-linux-gnu/aarch64-linux-gnu/bin/objdump" + fi + elif [ "$SYSROOT_ARCH" = "amd64" ]; then + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER="$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/bin/x86_64-linux-gnu-gcc" + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="-C link-arg=--sysroot=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot -C link-arg=-L$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot/usr/lib/x86_64-linux-gnu" + export CC_x86_64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/bin/x86_64-linux-gnu-gcc --sysroot=$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot" + export PKG_CONFIG_LIBDIR_x86_64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot/usr/lib/x86_64-linux-gnu/pkgconfig:$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot/usr/share/pkgconfig" + export PKG_CONFIG_SYSROOT_DIR_x86_64_unknown_linux_gnu="$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot" + export OBJDUMP="$VSCODE_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/bin/objdump" + elif [ "$SYSROOT_ARCH" = "armhf" ]; then + export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER="$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/bin/arm-rpi-linux-gnueabihf-gcc" + export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_RUSTFLAGS="-C link-arg=--sysroot=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot" + export CC_armv7_unknown_linux_gnueabihf="$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/bin/arm-rpi-linux-gnueabihf-gcc --sysroot=$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot" + export PKG_CONFIG_LIBDIR_armv7_unknown_linux_gnueabihf="$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot/usr/lib/arm-rpi-linux-gnueabihf/pkgconfig:$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot/usr/share/pkgconfig" + export PKG_CONFIG_SYSROOT_DIR_armv7_unknown_linux_gnueabihf="$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/sysroot" + export OBJDUMP="$VSCODE_SYSROOT_DIR/arm-rpi-linux-gnueabihf/arm-rpi-linux-gnueabihf/bin/objdump" + fi + fi + + cargo build --release --locked --target ${{ parameters.COPILOT_SHIM_TARGET }} --bin copilot + + if [ "$VERIFY_GLIBC" = "1" ]; then + maximum_glibc_version="2.28" + while IFS= read -r line; do + if [[ "$line" == *"GLIBC_"* ]]; then + version=$(echo "$line" | awk '{print $5}' | tr -d '()') + version=${version#*_} + if [[ $(printf "%s\n%s" "$version" "$maximum_glibc_version" | sort -V | tail -n1) == "$version" ]]; then + maximum_glibc_version=$version + fi + fi + done < <("$OBJDUMP" -T "$PWD/target/${{ parameters.COPILOT_SHIM_TARGET }}/release/copilot") + if [ "$maximum_glibc_version" != "2.28" ]; then + echo "Error: Copilot shim has dependency on GLIBC > 2.28, found $maximum_glibc_version" + exit 1 + fi + echo "Copilot shim has no GLIBC dependency newer than 2.28." + fi + displayName: Compile ${{ parameters.COPILOT_SHIM_TARGET }} + workingDirectory: $(Build.SourcesDirectory)/copilot_shim + env: + RUSTC_WRAPPER: sccache + SCCACHE_DIR: $(Pipeline.Workspace)/sccache + ${{ each pair in parameters.COPILOT_SHIM_ENV }}: + ${{ pair.key }}: ${{ pair.value }} + + - ${{ else }}: + - script: cargo build --release --locked --target ${{ parameters.COPILOT_SHIM_TARGET }} --bin copilot + displayName: Compile ${{ parameters.COPILOT_SHIM_TARGET }} + workingDirectory: $(Build.SourcesDirectory)/copilot_shim + env: + RUSTC_WRAPPER: sccache + SCCACHE_DIR: $(Pipeline.Workspace)/sccache + ${{ each pair in parameters.COPILOT_SHIM_ENV }}: + ${{ pair.key }}: ${{ pair.value }} + + - ${{ if not(parameters.COPILOT_SHIM_CHECK_ONLY) }}: + - ${{ if contains(parameters.COPILOT_SHIM_TARGET, '-windows-') }}: + - pwsh: | + $stageDirectory = "$(Build.ArtifactStagingDirectory)/${{ parameters.COPILOT_SHIM_ARTIFACT }}" + New-Item -ItemType Directory -Force -Path $stageDirectory + Copy-Item "$(Build.SourcesDirectory)/copilot_shim/target/${{ parameters.COPILOT_SHIM_TARGET }}/release/copilot.exe" "$stageDirectory/copilot.exe" + $stagedEntries = @(Get-ChildItem -Path $stageDirectory) + if ($stagedEntries.Count -ne 1 -or $stagedEntries[0].Name -ne "copilot.exe") { + throw "Copilot shim staging must contain exactly one root copilot.exe" + } + displayName: Stage Copilot shim + + - task: ArchiveFiles@2 + displayName: Archive Copilot shim + inputs: + rootFolderOrFile: $(Build.ArtifactStagingDirectory)/${{ parameters.COPILOT_SHIM_ARTIFACT }}/copilot.exe + includeRootFolder: false + archiveType: zip + archiveFile: $(Build.ArtifactStagingDirectory)/${{ parameters.COPILOT_SHIM_ARTIFACT }}.zip + + - pwsh: | + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead("$(Build.ArtifactStagingDirectory)/${{ parameters.COPILOT_SHIM_ARTIFACT }}.zip") + try { + $entries = @($archive.Entries) + if ($entries.Count -ne 1 -or $entries[0].FullName -ne "copilot.exe" -or $entries[0].Length -eq 0) { + throw "Copilot shim archive must contain exactly one non-empty root copilot.exe" + } + } finally { + $archive.Dispose() + } + displayName: Validate Copilot shim archive + + - ${{ else }}: + - script: | + set -e + stage_directory="$(Build.ArtifactStagingDirectory)/${{ parameters.COPILOT_SHIM_ARTIFACT }}" + mkdir -p "$stage_directory" + install -m 755 "$(Build.SourcesDirectory)/copilot_shim/target/${{ parameters.COPILOT_SHIM_TARGET }}/release/copilot" "$stage_directory/copilot" + test "$(find "$stage_directory" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" = "1" + test -x "$stage_directory/copilot" + displayName: Stage Copilot shim + + - task: ArchiveFiles@2 + displayName: Archive Copilot shim + inputs: + rootFolderOrFile: $(Build.ArtifactStagingDirectory)/${{ parameters.COPILOT_SHIM_ARTIFACT }}/copilot + includeRootFolder: false + ${{ if contains(parameters.COPILOT_SHIM_TARGET, '-darwin') }}: + archiveType: zip + archiveFile: $(Build.ArtifactStagingDirectory)/${{ parameters.COPILOT_SHIM_ARTIFACT }}.zip + ${{ else }}: + archiveType: tar + tarCompression: gz + archiveFile: $(Build.ArtifactStagingDirectory)/${{ parameters.COPILOT_SHIM_ARTIFACT }}.tar.gz + + - script: | + set -e + validation_directory="$(Build.ArtifactStagingDirectory)/${{ parameters.COPILOT_SHIM_ARTIFACT }}-validation" + rm -rf "$validation_directory" + mkdir -p "$validation_directory" + if [[ "${{ parameters.COPILOT_SHIM_TARGET }}" == *"-darwin" ]]; then + unzip -q "$(Build.ArtifactStagingDirectory)/${{ parameters.COPILOT_SHIM_ARTIFACT }}.zip" -d "$validation_directory" + else + tar -xzf "$(Build.ArtifactStagingDirectory)/${{ parameters.COPILOT_SHIM_ARTIFACT }}.tar.gz" -C "$validation_directory" + fi + test "$(find "$validation_directory" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" = "1" + test -f "$validation_directory/copilot" + test -x "$validation_directory/copilot" + displayName: Validate Copilot shim archive + + - script: sccache --show-stats + displayName: Copilot shim sccache stats + condition: succeededOrFailed() + env: + SCCACHE_DIR: $(Pipeline.Workspace)/sccache diff --git a/build/azure-pipelines/copilot-shim/copilot-shim-quality.yml b/build/azure-pipelines/copilot-shim/copilot-shim-quality.yml new file mode 100644 index 00000000000000..ba318d3ab63263 --- /dev/null +++ b/build/azure-pipelines/copilot-shim/copilot-shim-quality.yml @@ -0,0 +1,16 @@ +steps: + - script: rustup component add rustfmt clippy + displayName: Install Rust quality components + condition: and(succeeded(), eq(variables['CARGO_REGISTRY'], 'none')) + + - script: cargo fmt --check + displayName: Check Copilot shim formatting + workingDirectory: $(Build.SourcesDirectory)/copilot_shim + + - script: cargo test --locked + displayName: Test Copilot shim + workingDirectory: $(Build.SourcesDirectory)/copilot_shim + + - script: cargo clippy --all-targets --locked -- -D warnings + displayName: Lint Copilot shim + workingDirectory: $(Build.SourcesDirectory)/copilot_shim diff --git a/build/azure-pipelines/darwin/product-build-darwin-cli.yml b/build/azure-pipelines/darwin/product-build-darwin-cli.yml index 1617670ffc8fef..bdaf4de66ada1f 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-cli.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-cli.yml @@ -92,6 +92,15 @@ jobs: OPENSSL_LIB_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm64-osx/lib OPENSSL_INCLUDE_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm64-osx/include + - template: ../copilot-shim/copilot-shim-compile.yml@self + parameters: + ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + COPILOT_SHIM_TARGET: x86_64-apple-darwin + ${{ if eq(parameters.VSCODE_ARCH, 'arm64') }}: + COPILOT_SHIM_TARGET: aarch64-apple-darwin + COPILOT_SHIM_ARTIFACT: unsigned_copilot_shim_darwin_$(VSCODE_ARCH) + COPILOT_SHIM_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + - ${{ if not(parameters.VSCODE_CHECK_ONLY) }}: - template: ../common/publish-artifact.yml@self parameters: @@ -100,6 +109,14 @@ jobs: displayName: Publish unsigned CLI sbomEnabled: false + # The macOS compile job adds the Copilot shim to the client and server builds, which signs it. + - template: ../common/publish-artifact.yml@self + parameters: + targetPath: $(Build.ArtifactStagingDirectory)/unsigned_copilot_shim_darwin_$(VSCODE_ARCH).zip + artifactName: unsigned_copilot_shim_darwin_$(VSCODE_ARCH) + displayName: Publish unsigned Copilot shim + sbomEnabled: false + - script: | set -e mkdir -p $(Build.ArtifactStagingDirectory)/pkg/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli @@ -143,3 +160,6 @@ jobs: mkdir -p $(Build.ArtifactStagingDirectory)/out/vscode_cli_darwin_$(VSCODE_ARCH)_cli mv $(Build.ArtifactStagingDirectory)/pkg/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli.zip $(Build.ArtifactStagingDirectory)/out/vscode_cli_darwin_$(VSCODE_ARCH)_cli/vscode_cli_darwin_$(VSCODE_ARCH)_cli.zip displayName: Rename signed artifact + + - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + - template: ../copilot-shim/copilot-shim-quality.yml@self diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index 6681c37afcf261..9f924b959a89c8 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -22,10 +22,10 @@ steps: # The CLI is built by the parallel CLI job and mixed into the client below. - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: - - script: npx deemon --detach --wait -- node build/azure-pipelines/common/waitForArtifacts.ts "--producer=unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli=macOS (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/waitForArtifacts.ts "--producer=unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli=macOS (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" "--producer=unsigned_copilot_shim_darwin_$(VSCODE_ARCH)=macOS (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Wait for CLI artifact (background) + displayName: Wait for CLI and Copilot shim artifacts (background) # Start pulling the CG-generated NOTICE from the parallel Quality stage # in the background (overwrites repo-root ThirdPartyNotices.txt before packaging). @@ -129,13 +129,34 @@ steps: # The unsigned client is published as soon as it is built, since the macOS # (UNIVERSAL) job only waits a limited amount of time for it. - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: - - script: npx deemon --attach -- node build/azure-pipelines/common/waitForArtifacts.ts "--producer=unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli=macOS (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli + - script: npx deemon --attach -- node build/azure-pipelines/common/waitForArtifacts.ts "--producer=unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli=macOS (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" "--producer=unsigned_copilot_shim_darwin_$(VSCODE_ARCH)=macOS (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Wait for CLI artifact + displayName: Wait for CLI and Copilot shim artifacts - template: product-build-darwin-mixin-cli.yml@self + - task: DownloadPipelineArtifact@2 + inputs: + artifact: unsigned_copilot_shim_darwin_$(VSCODE_ARCH) + patterns: "**" + path: $(Build.ArtifactStagingDirectory)/copilot-shim + displayName: Download Copilot shim + + # Hardened entitlements and Codesign & Notarize sign the shim with the client, and Sign server binaries with the + # server. + - script: | + set -e + APP_ROOT="$(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH)" + APP_NAME="`ls $APP_ROOT | head -n 1`" + APP_PATH="$APP_ROOT/$APP_NAME" + unzip $(Build.ArtifactStagingDirectory)/copilot-shim/*.zip -d $(Build.ArtifactStagingDirectory)/copilot-shim + for SHIM_DIRECTORY in "$APP_PATH/Contents/Resources/app/bin/copilot-shim" "$(Agent.BuildDirectory)/vscode-server-darwin-$(VSCODE_ARCH)/bin/copilot-shim"; do + mkdir -p "$SHIM_DIRECTORY" + install -m 755 "$(Build.ArtifactStagingDirectory)/copilot-shim/copilot" "$SHIM_DIRECTORY/copilot" + done + displayName: Mix in Copilot shim + - script: | set -e APP_ROOT="$(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH)" diff --git a/build/azure-pipelines/linux/product-build-linux-cli.yml b/build/azure-pipelines/linux/product-build-linux-cli.yml index a15496187594df..9844123cf8cc3a 100644 --- a/build/azure-pipelines/linux/product-build-linux-cli.yml +++ b/build/azure-pipelines/linux/product-build-linux-cli.yml @@ -29,6 +29,12 @@ jobs: sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/cli sbomPackageName: "VS Code Linux $(VSCODE_ARCH) CLI" sbomPackageVersion: $(Build.SourceVersion) + # The Linux compile job adds the Copilot shim to the client and server builds. + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)/unsigned_copilot_shim_linux_$(VSCODE_ARCH).tar.gz + artifactName: unsigned_copilot_shim_linux_$(VSCODE_ARCH) + displayName: Publish unsigned Copilot shim + sbomEnabled: false steps: - template: ../common/checkout.yml@self @@ -147,3 +153,26 @@ jobs: OPENSSL_LIB_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm-linux/lib OPENSSL_INCLUDE_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm-linux/include SYSROOT_ARCH: armhf + + - template: ../copilot-shim/copilot-shim-compile.yml@self + parameters: + ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + COPILOT_SHIM_TARGET: x86_64-unknown-linux-gnu + ${{ if eq(parameters.VSCODE_ARCH, 'arm64') }}: + COPILOT_SHIM_TARGET: aarch64-unknown-linux-gnu + ${{ if eq(parameters.VSCODE_ARCH, 'armhf') }}: + COPILOT_SHIM_TARGET: armv7-unknown-linux-gnueabihf + COPILOT_SHIM_ARTIFACT: unsigned_copilot_shim_linux_$(VSCODE_ARCH) + COPILOT_SHIM_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + COPILOT_SHIM_ENV: + GITHUB_TOKEN: "$(github-token-code-oss)" + ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + SYSROOT_ARCH: amd64 + ${{ if eq(parameters.VSCODE_ARCH, 'arm64') }}: + SYSROOT_ARCH: arm64 + ${{ if eq(parameters.VSCODE_ARCH, 'armhf') }}: + SYSROOT_ARCH: armhf + VERIFY_GLIBC: "1" + + - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + - template: ../copilot-shim/copilot-shim-quality.yml@self diff --git a/build/azure-pipelines/linux/steps/product-build-linux-package.yml b/build/azure-pipelines/linux/steps/product-build-linux-package.yml index 03fe22081e72ff..2310ebb839c9ee 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-package.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-package.yml @@ -12,15 +12,15 @@ parameters: steps: - ${{ if eq(parameters.VSCODE_WAIT_FOR_CLI, true) }}: - - script: npx deemon --detach --wait -- node build/azure-pipelines/common/waitForArtifacts.ts "--producer=$(ARTIFACT_PREFIX)vscode_cli_linux_$(VSCODE_ARCH)_cli=Linux (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" $(ARTIFACT_PREFIX)vscode_cli_linux_$(VSCODE_ARCH)_cli + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/waitForArtifacts.ts "--producer=$(ARTIFACT_PREFIX)vscode_cli_linux_$(VSCODE_ARCH)_cli=Linux (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" "--producer=$(ARTIFACT_PREFIX)unsigned_copilot_shim_linux_$(VSCODE_ARCH)=Linux (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Wait for CLI artifact (background) + displayName: Wait for CLI and Copilot shim artifacts (background) - - script: npx deemon --attach -- node build/azure-pipelines/common/waitForArtifacts.ts "--producer=$(ARTIFACT_PREFIX)vscode_cli_linux_$(VSCODE_ARCH)_cli=Linux (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" $(ARTIFACT_PREFIX)vscode_cli_linux_$(VSCODE_ARCH)_cli + - script: npx deemon --attach -- node build/azure-pipelines/common/waitForArtifacts.ts "--producer=$(ARTIFACT_PREFIX)vscode_cli_linux_$(VSCODE_ARCH)_cli=Linux (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" "--producer=$(ARTIFACT_PREFIX)unsigned_copilot_shim_linux_$(VSCODE_ARCH)=Linux (${{ upper(parameters.VSCODE_ARCH) }}) - CLI" env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Wait for CLI artifact + displayName: Wait for CLI and Copilot shim artifacts - task: DownloadPipelineArtifact@2 inputs: @@ -37,6 +37,23 @@ steps: mv $(Build.ArtifactStagingDirectory)/cli/$APP_NAME $(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)/bin/$CLI_APP_NAME displayName: Mix in CLI + - task: DownloadPipelineArtifact@2 + inputs: + artifact: $(ARTIFACT_PREFIX)unsigned_copilot_shim_linux_$(VSCODE_ARCH) + patterns: "**" + path: $(Build.ArtifactStagingDirectory)/copilot-shim + displayName: Download Copilot shim + + # Before the client and server are archived, so the archives and the deb, rpm and snap packages include it. + - script: | + set -e + tar -xzf $(Build.ArtifactStagingDirectory)/copilot-shim/*.tar.gz -C $(Build.ArtifactStagingDirectory)/copilot-shim + install -D -m 755 $(Build.ArtifactStagingDirectory)/copilot-shim/copilot $(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)/bin/copilot-shim/copilot + if [ -d "$(agent.builddirectory)/vscode-server-linux-$(VSCODE_ARCH)" ]; then + install -D -m 755 $(Build.ArtifactStagingDirectory)/copilot-shim/copilot $(agent.builddirectory)/vscode-server-linux-$(VSCODE_ARCH)/bin/copilot-shim/copilot + fi + displayName: Mix in Copilot shim + - script: | set -e ARCHIVE_PATH=".build/linux/client/code-${{ parameters.VSCODE_QUALITY }}-$(VSCODE_ARCH)-$(date +%s).tar.gz" diff --git a/build/azure-pipelines/oss/cargo-section4.test.ts b/build/azure-pipelines/oss/cargo-section4.test.ts index cb1f19cafa4d47..ca34e33a44fb50 100644 --- a/build/azure-pipelines/oss/cargo-section4.test.ts +++ b/build/azure-pipelines/oss/cargo-section4.test.ts @@ -11,9 +11,9 @@ * Covers (no network required): * 1. isSpdxStub truth table โ€” true for all 17 known CG stub bodies, false for * real short license bodies and license prose. - * 2. parseCargoLock โ€” extracts packages from cli/Cargo.lock + build/win32, - * skips workspace crates with no source, returns correct name/version for - * clap, jiff, slog, term. + * 2. parseCargoLock โ€” extracts packages from cli/Cargo.lock, build/win32, + * and copilot_shim/Cargo.lock; preserves workspace crates with no source; + * returns correct names, versions, and registry sources. * 3. getCrateRepository โ€” override map returns mirror URLs. *--------------------------------------------------------------------------------------------*/ @@ -104,6 +104,7 @@ console.log('parseCargoLock:'); const repoRoot = path.resolve(process.cwd(), '..', '..', '..'); const cliLockPath = path.join(repoRoot, 'cli', 'Cargo.lock'); const win32LockPath = path.join(repoRoot, 'build', 'win32', 'Cargo.lock'); +const shimLockPath = path.join(repoRoot, 'copilot_shim', 'Cargo.lock'); const cliPkgs = parseCargoLock(fs.readFileSync(cliLockPath, 'utf8')); console.log(` cli/Cargo.lock: ${cliPkgs.length} packages parsed`); @@ -127,6 +128,27 @@ check('win32: slog present with version', !!slog && /^\d/.test(slog!.version)); const term = win32Pkgs.find(p => p.name === 'term'); check('win32: term present with version', !!term && /^\d/.test(term!.version)); +const shimLockContent = fs.readFileSync(shimLockPath, 'utf8'); +const shimPkgs = parseCargoLock(shimLockContent); +console.log(` copilot_shim/Cargo.lock: ${shimPkgs.length} packages parsed`); +const shimWorkspace = shimPkgs.find(p => p.name === 'copilot_shim'); +check('shim: workspace package present with no source', !!shimWorkspace && !shimWorkspace.source); + +const shimRegistryPkgs = shimPkgs.filter(p => p.source?.startsWith('registry+')); +check('shim: parsed a meaningful number of registry packages (>10)', shimRegistryPkgs.length > 10); +check('shim: all packages have valid names and versions', shimPkgs.every(p => /^[A-Za-z0-9_.-]+$/.test(p.name) && /^[0-9]/.test(p.version))); + +for (const crateName of ['tempfile', 'libc', 'windows-sys']) { + const crate = shimPkgs.find(p => p.name === crateName); + check(`shim: ${crateName} present with version + registry source`, !!crate && /^[0-9]/.test(crate.version) && !!crate.source?.startsWith('registry+')); +} + +const supportCrateNames = ['getrandom', 'rustix', 'windows-link'].filter(name => shimLockContent.includes(`name = "${name}"`)); +check( + 'shim: secure temp and platform support crates in the lock are parsed', + supportCrateNames.length > 0 && supportCrateNames.every(name => shimRegistryPkgs.some(p => p.name === name)) +); + // Sanity: no parsed package has a quote/bracket leaking into its fields. check('parse: no malformed names', cliPkgs.every(p => /^[A-Za-z0-9_.-]+$/.test(p.name))); check('parse: no malformed versions', cliPkgs.every(p => /^[0-9]/.test(p.version))); diff --git a/build/azure-pipelines/win32/product-build-win32-cli.yml b/build/azure-pipelines/win32/product-build-win32-cli.yml index 22a278519c22af..b65d32d688cf0e 100644 --- a/build/azure-pipelines/win32/product-build-win32-cli.yml +++ b/build/azure-pipelines/win32/product-build-win32-cli.yml @@ -85,3 +85,29 @@ jobs: artifactName: unsigned_vscode_cli_win32_$(VSCODE_ARCH)_cli displayName: Publish unsigned CLI sbomEnabled: false + + - template: ../copilot-shim/copilot-shim-compile.yml@self + parameters: + ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + COPILOT_SHIM_TARGET: x86_64-pc-windows-msvc + ${{ if eq(parameters.VSCODE_ARCH, 'arm64') }}: + COPILOT_SHIM_TARGET: aarch64-pc-windows-msvc + COPILOT_SHIM_ARTIFACT: unsigned_copilot_shim_win32_$(VSCODE_ARCH) + COPILOT_SHIM_CHECK_ONLY: ${{ parameters.VSCODE_CHECK_ONLY }} + COPILOT_SHIM_ENV: + ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + RUSTFLAGS: "-Ctarget-feature=+crt-static -Clink-args=/guard:cf -Clink-args=/CETCOMPAT" + ${{ if eq(parameters.VSCODE_ARCH, 'arm64') }}: + RUSTFLAGS: "-Ctarget-feature=+crt-static -Clink-args=/guard:cf -Clink-args=/CETCOMPAT:NO" + + # The Windows sign job adds the Copilot shim to the client and server builds, which signs it. + - ${{ if not(parameters.VSCODE_CHECK_ONLY) }}: + - template: ../common/publish-artifact.yml@self + parameters: + targetPath: $(Build.ArtifactStagingDirectory)/unsigned_copilot_shim_win32_$(VSCODE_ARCH).zip + artifactName: unsigned_copilot_shim_win32_$(VSCODE_ARCH) + displayName: Publish unsigned Copilot shim + sbomEnabled: false + + - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + - template: ../copilot-shim/copilot-shim-quality.yml@self diff --git a/build/azure-pipelines/win32/product-build-win32.yml b/build/azure-pipelines/win32/product-build-win32.yml index 89a011d8b4c9b2..79b9a6832560d5 100644 --- a/build/azure-pipelines/win32/product-build-win32.yml +++ b/build/azure-pipelines/win32/product-build-win32.yml @@ -16,16 +16,16 @@ parameters: # 1. Compile: compiles the client, server and web server builds and # publishes them as the compilation_win32_ artifact. # 2. Sign: code signs the builds of the compilation artifact together with -# the CLI, packages them and publishes the product artifacts, including -# the standalone CLI. +# the CLI and the Copilot shim, packages them and publishes the product +# artifacts, including the standalone CLI. # The test jobs are declared next to this template (see # product-build-win32-test.yml) and depend on the compile job. The sign job # also depends on the CLI job (see product-build-win32-cli.yml), which builds -# the unsigned CLI in parallel with the compile job, so the CLI job must be -# part of the same stage. The sign and test jobs run in parallel, but the -# Publish stage only publishes the x64 product artifacts once all -# Windows__Test_ jobs have succeeded (see artifactsByGatingJob in -# ../common/publish.ts). +# the unsigned CLI and Copilot shim in parallel with the compile job, so the +# CLI job must be part of the same stage. The sign and test jobs run in +# parallel, but the Publish stage only publishes the x64 product artifacts once +# all Windows__Test_ jobs have succeeded (see artifactsByGatingJob +# in ../common/publish.ts). jobs: - job: Windows_${{ parameters.VSCODE_ARCH }}_Compile displayName: Windows (${{ upper(parameters.VSCODE_ARCH) }}) - Compile diff --git a/build/azure-pipelines/win32/steps/product-build-win32-sign.yml b/build/azure-pipelines/win32/steps/product-build-win32-sign.yml index 44c82ed97ddeed..398ed2ebcb5be2 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-sign.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-sign.yml @@ -1,6 +1,6 @@ -# Adds the CLI to the client build, code signs the client, server and web -# server builds, and packages them together with the signed setup packages -# and the standalone CLI. +# Adds the CLI to the client build and the Copilot shim to the client and +# server builds, code signs the client, server and web server builds, and +# packages them together with the signed setup packages and the standalone CLI. steps: - task: DownloadPipelineArtifact@2 inputs: @@ -21,6 +21,25 @@ steps: Move-Item -Path "$(Build.ArtifactStagingDirectory)/cli/$AppName.exe" -Destination "$(Agent.BuildDirectory)/VSCode-win32-$(VSCODE_ARCH)/bin/$CliAppName.exe" displayName: Move VS Code CLI + - task: DownloadPipelineArtifact@2 + inputs: + artifact: unsigned_copilot_shim_win32_$(VSCODE_ARCH) + patterns: "**" + path: $(Build.ArtifactStagingDirectory)/copilot-shim + displayName: Download Copilot shim + + # Codesign signs the shim with the rest of the builds, and the setup packages include it when it exists. + - powershell: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + Expand-Archive -Path "$(Build.ArtifactStagingDirectory)/copilot-shim/unsigned_copilot_shim_win32_$(VSCODE_ARCH).zip" -DestinationPath "$(Build.ArtifactStagingDirectory)/copilot-shim" + foreach ($Build in "VSCode-win32-$(VSCODE_ARCH)", "vscode-server-win32-$(VSCODE_ARCH)") { + $ShimDirectory = "$(Agent.BuildDirectory)/$Build/bin/copilot-shim" + New-Item -ItemType Directory -Path $ShimDirectory -Force | Out-Null + Copy-Item -Path "$(Build.ArtifactStagingDirectory)/copilot-shim/copilot.exe" -Destination "$ShimDirectory/copilot.exe" + } + displayName: Mix in Copilot shim + - task: UseDotNet@2 inputs: version: 6.x diff --git a/build/gulpfile.vscode.win32.ts b/build/gulpfile.vscode.win32.ts index 1f0f2d9ea0a140..7b62a3e5ecb168 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -123,6 +123,10 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { } } + if (fs.existsSync(path.join(sourcePath, 'bin', 'copilot-shim', 'copilot.exe'))) { + definitions['CopilotShim'] = 'true'; + } + fs.writeFileSync(productJsonPath, JSON.stringify(productJson, undefined, '\t')); packageInnoSetup(issPath, { definitions }, cb as (err?: Error | null) => void); diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index b197f7da183158..3e72aeb1423fce 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -505,6 +505,24 @@ "status": "enforced" } }, + { + "key": "chat.copilotCliCommand.enabled", + "name": "CopilotCliCommand", + "category": "InteractiveSession", + "minimumVersion": "1.143", + "localization": { + "description": { + "key": "chat.copilotCliCommand.enabled.policy", + "value": "Controls whether VS Code provides the copilot command for GitHub Copilot CLI. When disabled, VS Code setup on Windows doesn't add the command to PATH and removes the entry it added, VS Code doesn't install GitHub Copilot CLI, and integrated terminals don't include the command. A GitHub Copilot CLI that was installed separately isn't affected." + } + }, + "type": "boolean", + "default": true, + "included": true, + "agentHost": { + "status": "enforced" + } + }, { "key": "chat.customizations.strictPluginOnlyCustomization", "name": "ChatStrictPluginOnlyCustomization", diff --git a/build/lib/test/dictationRuntime.test.ts b/build/lib/test/dictationRuntime.test.ts index b6ce5b5f5946a3..50ffeee7cde79b 100644 --- a/build/lib/test/dictationRuntime.test.ts +++ b/build/lib/test/dictationRuntime.test.ts @@ -17,15 +17,15 @@ import { } from '../../dictation-runtime/nuget.ts'; const dependencies: IFoundryDependencyVersions = { - onnxruntime: { version: '1.28.0' }, - 'onnxruntime-genai': { version: '0.15.2' }, + onnxruntime: { version: '1.30.0' }, + 'onnxruntime-genai': { version: '0.17.1' }, }; suite('dictation runtime', () => { test('uses the Foundry Local 2.x dependency artifact set', () => { assert.deepStrictEqual(getStandardArtifacts(dependencies), [ - { name: 'Microsoft.ML.OnnxRuntime', version: '1.28.0' }, - { name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: '0.15.2' }, + { name: 'Microsoft.ML.OnnxRuntime', version: '1.30.0' }, + { name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: '0.17.1' }, ]); }); @@ -63,7 +63,7 @@ suite('dictation runtime', () => { } await assert.rejects( fetchDependencyLibraries(target, getStandardArtifacts(dependencies), targetDirectory, { feeds: [] }), - /Failed to download Microsoft\.ML\.OnnxRuntime 1\.28\.0 from any feed/, + /Failed to download Microsoft\.ML\.OnnxRuntime 1\.30\.0 from any feed/, ); } } finally { diff --git a/build/win32/code.iss b/build/win32/code.iss index 5b4306143f6c7a..00a102f014ac9b 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -95,6 +95,10 @@ Name: "runcode"; Description: "{cm:RunAfter,{#NameShort}}"; GroupDescription: "{ Name: "{app}"; AfterInstall: DisableAppDirInheritance [Files] +#ifdef CopilotShim +; Listed first so it can be extracted quickly from the solid archive. The Copilot code publishes it to {app}\bin\copilot-shim. +Source: "bin\copilot-shim\copilot.exe"; Flags: dontcopy noencryption +#endif Source: "*"; Excludes: "\CodeSignSummary*.md,\tools,\tools\*,\policies,\policies\*,\appx,\appx\*,\{#ProductJsonRelativePath},\{#ExeBasename}.exe,\{#ExeBasename}.VisualElementsManifest.xml,\bin,\bin\*"; DestDir: "{code:GetDestDir}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#ExeBasename}.exe"; DestDir: "{code:GetDestDir}"; DestName: "{code:GetExeBasename}"; Flags: ignoreversion Source: "{#ExeBasename}.VisualElementsManifest.xml"; DestDir: "{code:GetDestDir}"; DestName: "{code:GetVisualElementsManifest}"; Flags: ignoreversion @@ -1805,6 +1809,10 @@ begin end; #endif +#ifdef CopilotShim +#include AddBackslash(SourcePath) + "copilot.iss" +#endif + procedure CurStepChanged(CurStep: TSetupStep); var UpdateResultCode: Integer; @@ -1880,6 +1888,12 @@ begin Log('Starting the tunnel service completed with result code ' + IntToStr(StartServiceResultCode)); ShouldRestartTunnelService := False end; + +#ifdef CopilotShim + // Background updates publish the shim only after inno_updater has applied the update. + if IsNotBackgroundUpdate() or (not SessionEndFileExists() and not CancelFileExists()) then + CopilotPostInstall(); +#endif end; end; @@ -1935,12 +1949,15 @@ var NewPath: string; i: Integer; begin - if not CurUninstallStep = usUninstall then begin + if CurUninstallStep <> usUninstall then begin exit; end; #ifdef AppxPackageName RemoveAppxPackage(); +#endif +#ifdef CopilotShim + CopilotUninstall(); #endif if not RegQueryStringValue({#EnvironmentRootKey}, '{#EnvironmentKey}', 'Path', Path) then begin diff --git a/build/win32/copilot.iss b/build/win32/copilot.iss new file mode 100644 index 00000000000000..863b1604e9b43a --- /dev/null +++ b/build/win32/copilot.iss @@ -0,0 +1,1163 @@ +// GitHub Copilot CLI: publishes the native `copilot` shim to {app}\bin\copilot-shim, manages its PATH entry, and optionally +// installs Copilot CLI through the shim. Design: https://github.com/microsoft/vscode-internalbacklog/issues/9765 +// +// Included from the [Code] section of code.iss when the build contains bin\copilot-shim\copilot.exe (CopilotShim). +// +// Shim contract used by setup (all files are UTF-16LE INI files written atomically by the shim): +// copilot.exe --vscode-shim probe --scope user|machine [--no-network] --timeout-ms N --result-file +// [probe] protocol=1, policy=allowed|disabled, cliFound=0|1, cliPath=, downloadAvailable=0|1, +// downloadSize=, pwshFound=0|1, reason= +// copilot.exe --vscode-shim install --non-interactive --consent=installer --progress-file +// --result-file --cancel-file --running-mutex +// [progress] phase=resolving|downloading|verifying|installing, current=, total=, heartbeat= +// [result] status=installed|alreadyInstalled|unsupported|policy|network|verification|msiexec|cancelled|error, +// exitCode=, cliPath=, cliVersion=, log=, reason= +// The install command holds the named mutex while it runs and increments heartbeat while it works. + +#ifndef CopilotCliPolicyName +#define CopilotCliPolicyName "CopilotCliCommand" +#endif +#ifndef CopilotCliLearnMoreUrl +#define CopilotCliLearnMoreUrl "https://docs.github.com/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli" +#endif + +const + CopilotChoiceInstall = 'install'; + CopilotChoiceOnFirstUse = 'onfirstuse'; + CopilotChoiceNone = 'none'; + CopilotChoicePolicy = 'policy'; + + CopilotActionKeep = 0; + CopilotActionInstall = 1; + CopilotActionAdd = 2; + CopilotActionRemove = 3; + CopilotActionPolicy = 4; + + CopilotPageNone = 0; + CopilotPageFull = 1; + CopilotPageBasic = 2; + + CopilotProbeTimeoutMs = 5000; + CopilotProbeWaitMs = 6000; + CopilotInstallStartTimeoutMs = 15000; + CopilotInstallStallTimeoutMs = 60000; + CopilotInstallTotalTimeoutMs = 900000; + CopilotInstallCancelGraceMs = 10000; + CopilotPathLengthLimit = 32767; + CopilotPathLengthMargin = 512; + + CopilotMachineEnvironmentKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; + CopilotUserEnvironmentKey = 'Environment'; + +var + CopilotInitialized: Boolean; + CopilotIsExistingInstall: Boolean; + CopilotPreviousChoice: String; + CopilotPreviousSource: String; + CopilotSwitchValue: String; + CopilotShimExtracted: Boolean; + + CopilotProbeStarted: Boolean; + CopilotProbeLaunched: Boolean; + CopilotProbeRead: Boolean; + CopilotProbeCompleted: Boolean; + CopilotProbeStartTick: Cardinal; + CopilotProbePolicyDisabled: Boolean; + CopilotProbeCliFound: Boolean; + CopilotProbeCliPath: String; + CopilotProbeDownloadAvailable: Boolean; + CopilotProbeDownloadSizeMB: Int64; + CopilotProbePwshFound: Boolean; + + CopilotPageKind: Integer; + CopilotDefaultsApplied: Boolean; + CopilotBasicPage: TWizardPage; + CopilotBasicPageBuilt: Boolean; + CopilotAddRadio: TNewRadioButton; + CopilotBasicDontAddRadio: TNewRadioButton; +#if "user" == InstallTarget + CopilotFullPage: TWizardPage; + CopilotFullPageBuilt: Boolean; + CopilotInstallNowRadio: TNewRadioButton; + CopilotFirstUseRadio: TNewRadioButton; + CopilotFullDontAddRadio: TNewRadioButton; + CopilotInstallPage: TDownloadWizardPage; +#endif + + CopilotDecided: Boolean; + CopilotAction: Integer; + CopilotChoice: String; + CopilotSource: String; + CopilotInstallCancelled: Boolean; + CopilotCommandReady: Boolean; + +function CopilotGetTickCount(): Cardinal; + external 'GetTickCount@kernel32.dll stdcall'; + +function CopilotExpandEnvironmentStrings(lpSrc: String; lpDst: String; nSize: Cardinal): Cardinal; + external 'ExpandEnvironmentStringsW@kernel32.dll stdcall'; + +function CopilotElapsedMs(const StartTick: Cardinal): Integer; +begin + Result := Integer(CopilotGetTickCount() - StartTick); +end; + +function CopilotIsUserInstaller(): Boolean; +begin +#if "user" == InstallTarget + Result := True; +#else + Result := False; +#endif +end; + +function CopilotShimDir(): String; +begin + // Under bin so that inno_updater's cleanup (--gc), which only removes top-level folders, keeps it. + Result := ExpandConstant('{app}\bin\copilot-shim'); +end; + +function CopilotTempShimPath(): String; +begin + Result := ExpandConstant('{tmp}\copilot.exe'); +end; + +// PATH helpers. Entries are compared after trimming, unquoting, expanding environment variables, normalizing +// separators, dropping trailing backslashes, and lowercasing. Other entries are always written back unchanged. + +function CopilotExpandEnvironment(const Value: String): String; +var + Size: Cardinal; + Buffer: String; + Terminator: Integer; +begin + Result := Value; + if Pos('%', Value) = 0 then + exit; + + Size := CopilotExpandEnvironmentStrings(Value, '', 0); + if Size = 0 then + exit; + + SetLength(Buffer, Size); + if CopilotExpandEnvironmentStrings(Value, Buffer, Size) = 0 then + exit; + + Terminator := Pos(#0, Buffer); + if Terminator > 0 then + Result := Copy(Buffer, 1, Terminator - 1) + else + Result := Buffer; +end; + +function CopilotNormalizePathEntry(const Entry: String): String; +begin + Result := Trim(Entry); + if (Length(Result) >= 2) and (Result[1] = '"') and (Result[Length(Result)] = '"') then + Result := Trim(Copy(Result, 2, Length(Result) - 2)); + Result := CopilotExpandEnvironment(Result); + StringChangeEx(Result, '/', '\', True); + while (Length(Result) > 3) and (Result[Length(Result)] = '\') do + SetLength(Result, Length(Result) - 1); + Result := AnsiLowercase(Result); +end; + +function CopilotPathEntryMatches(const Entry, NormalizedDir: String): Boolean; +begin + Result := (Trim(Entry) <> '') and (CopilotNormalizePathEntry(Entry) = NormalizedDir); +end; + +function CopilotPathContains(const PathValue, Dir: String): Boolean; +var + Parts: TArrayOfString; + Target: String; + I: Integer; +begin + Result := False; + Target := CopilotNormalizePathEntry(Dir); + Parts := StringSplit(PathValue, [';'], stAll); + for I := 0 to GetArrayLength(Parts) - 1 do begin + if CopilotPathEntryMatches(Parts[I], Target) then begin + Result := True; + exit; + end; + end; +end; + +// Appends Dir so that CopilotRemovePathEntry restores the original value exactly, including a trailing separator. +function CopilotAppendPathEntry(const PathValue, Dir: String): String; +begin + if PathValue = '' then + Result := Dir + else if PathValue[Length(PathValue)] = ';' then + Result := PathValue + Dir + ';' + else + Result := PathValue + ';' + Dir; +end; + +function CopilotRemovePathEntry(const PathValue, Dir: String; var Removed: Boolean): String; +var + Parts, Kept: TArrayOfString; + Target: String; + I, Count: Integer; +begin + Removed := False; + Target := CopilotNormalizePathEntry(Dir); + Parts := StringSplit(PathValue, [';'], stAll); + SetArrayLength(Kept, GetArrayLength(Parts)); + Count := 0; + for I := 0 to GetArrayLength(Parts) - 1 do begin + if CopilotPathEntryMatches(Parts[I], Target) then + Removed := True + else begin + Kept[Count] := Parts[I]; + Count := Count + 1; + end; + end; + SetArrayLength(Kept, Count); + Result := StringJoin(';', Kept); +end; + +function CopilotPathTooLong(const MachinePath, UserPath, Dir: String): Boolean; +var + Combined: Integer; +begin + Combined := Length(CopilotExpandEnvironment(MachinePath)) + 1 + Length(CopilotExpandEnvironment(UserPath)) + 1 + Length(Dir); + Result := Combined > CopilotPathLengthLimit - CopilotPathLengthMargin; +end; + +function CopilotReadPath(const RootKey: Integer; const SubKey: String): String; +begin + if not RegQueryStringValue(RootKey, SubKey, 'Path', Result) then + Result := ''; +end; + +function CopilotAddToPath(const RootKey: Integer; const SubKey, Dir: String): Boolean; +var + Current: String; +begin + Current := CopilotReadPath(RootKey, SubKey); + if CopilotPathContains(Current, Dir) then begin + Log('Copilot: PATH already contains ' + Dir); + Result := True; + exit; + end; + + if CopilotPathTooLong(CopilotReadPath(HKLM, CopilotMachineEnvironmentKey), CopilotReadPath(HKCU, CopilotUserEnvironmentKey), Dir) then begin + Log('Copilot: not adding ' + Dir + ' because PATH would become too long'); + Result := False; + exit; + end; + + Result := RegWriteExpandStringValue(RootKey, SubKey, 'Path', CopilotAppendPathEntry(Current, Dir)); + Log('Copilot: added ' + Dir + ' to PATH, success=' + BoolToStr(Result)); +end; + +function CopilotRemoveFromPath(const RootKey: Integer; const SubKey, Dir: String): Boolean; +var + Current, Updated: String; + Removed: Boolean; +begin + Result := True; + if not RegQueryStringValue(RootKey, SubKey, 'Path', Current) then + exit; + + Updated := CopilotRemovePathEntry(Current, Dir, Removed); + if not Removed then + exit; + + Result := RegWriteExpandStringValue(RootKey, SubKey, 'Path', Updated); + Log('Copilot: removed ' + Dir + ' from PATH, success=' + BoolToStr(Result)); +end; + +// Policy, switch, and previous state + +function CopilotProductPolicyDisabled(const Product: String): Boolean; +var + Value: Cardinal; +begin + // A machine policy takes precedence over a user policy, matching VS Code's policy service. + if RegQueryDWordValue(HKLM, 'SOFTWARE\Policies\Microsoft\' + Product, '{#CopilotCliPolicyName}', Value) then + Result := Value = 0 + else if RegQueryDWordValue(HKCU, 'SOFTWARE\Policies\Microsoft\' + Product, '{#CopilotCliPolicyName}', Value) then + Result := Value = 0 + else + Result := False; +end; + +function CopilotPolicyDisabled(): Boolean; +begin + // A disabled policy in any quality wins, because the shim of any installed quality can be the one on PATH. The shim + // applies the same rule. + Result := CopilotProductPolicyDisabled('{#RegValueName}') or CopilotProductPolicyDisabled('VSCode') + or CopilotProductPolicyDisabled('VSCodeInsiders') or CopilotProductPolicyDisabled('VSCodeExploration') + or CopilotProductPolicyDisabled('CodeOSS'); +end; + +function CopilotParseSwitch(const Value: String): String; +begin + Result := AnsiLowercase(Trim(Value)); + if (Result <> '') and (Result <> CopilotChoiceInstall) and (Result <> CopilotChoiceOnFirstUse) and (Result <> CopilotChoiceNone) then begin + Log('Copilot: ignoring unsupported /copilotcli value: ' + Value); + Result := ''; + end; +end; + +procedure CopilotInitialize(); +begin + if CopilotInitialized then + exit; + CopilotInitialized := True; + + // Captured before setup rewrites the uninstall key, so these describe the previous installation. + CopilotIsExistingInstall := RegKeyExists({#Uninstall64RootKey}, 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\' + Copy('{#AppId}', 2, 38) + '_is1'); + CopilotPreviousChoice := GetPreviousData('CopilotCliChoice', ''); + CopilotPreviousSource := GetPreviousData('CopilotCliSource', ''); + CopilotSwitchValue := CopilotParseSwitch(ExpandConstant('{param:copilotcli|}')); + Log('Copilot: existingInstall=' + BoolToStr(CopilotIsExistingInstall) + ', previousChoice=' + CopilotPreviousChoice + ', previousSource=' + CopilotPreviousSource + ', switch=' + CopilotSwitchValue); +end; + +// Decisions + +// Returns the preselected page option for a switch value, the previous choice, and whether the download is offered. +function CopilotSelectDefault(const Switch, PreviousChoice: String; const DownloadAvailable: Boolean): String; +begin + if Switch <> '' then + Result := Switch + else if PreviousChoice = CopilotChoiceNone then + Result := CopilotChoiceNone + else if (PreviousChoice = CopilotChoiceOnFirstUse) or (PreviousChoice = CopilotChoiceInstall) then + // Copilot CLI isn't installed now, so an earlier install was removed; don't download it again by default. + Result := CopilotChoiceOnFirstUse + else + Result := CopilotChoiceInstall; + + if (Result = CopilotChoiceInstall) and not DownloadAvailable then + Result := CopilotChoiceOnFirstUse; +end; + +// Whether a saved choice was the user's (the page, the switch) or followed `code`. A saved `policy` state isn't a choice, +// so lifting the policy brings back the default behavior. +function CopilotIsRememberedChoice(const Choice: String): Boolean; +begin + Result := (Choice = CopilotChoiceInstall) or (Choice = CopilotChoiceOnFirstUse) or (Choice = CopilotChoiceNone); +end; + +// Decides what a run without the page does: a silent install, a silent reinstall, a background update, or an +// interactive install that skipped the page. +function CopilotDecideWithoutPage(const Switch: String; const IsExistingInstall: Boolean; const PreviousChoice, PreviousSource: String; const IsUserInstaller, IsElevated, AddToPathSelected: Boolean; var Choice, Source: String): Integer; +begin + if Switch = CopilotChoiceInstall then begin + Source := 'switch'; + if IsUserInstaller and not IsElevated then begin + Result := CopilotActionInstall; + Choice := CopilotChoiceInstall; + end else begin + Result := CopilotActionAdd; + Choice := CopilotChoiceOnFirstUse; + end; + end else if Switch = CopilotChoiceOnFirstUse then begin + Result := CopilotActionAdd; + Choice := CopilotChoiceOnFirstUse; + Source := 'switch'; + end else if Switch = CopilotChoiceNone then begin + Result := CopilotActionRemove; + Choice := CopilotChoiceNone; + Source := 'switch'; + end else if IsExistingInstall and CopilotIsRememberedChoice(PreviousChoice) then begin + Result := CopilotActionKeep; + Choice := PreviousChoice; + Source := PreviousSource; + end else if AddToPathSelected then begin + // Otherwise `copilot` follows `code`: it's added when the Add to PATH task is selected, including once for existing + // installs that predate the shim. The choice is then remembered. + Result := CopilotActionAdd; + Choice := CopilotChoiceOnFirstUse; + Source := 'addtopath'; + end else begin + Result := CopilotActionKeep; + Choice := ''; + Source := ''; + end; +end; + +function CopilotChoiceFromPage(): String; +begin + Result := ''; +#if "user" == InstallTarget + if CopilotPageKind = CopilotPageFull then begin + if CopilotInstallNowRadio.Checked then + Result := CopilotChoiceInstall + else if CopilotFirstUseRadio.Checked then + Result := CopilotChoiceOnFirstUse + else + Result := CopilotChoiceNone; + exit; + end; +#endif + if CopilotPageKind = CopilotPageBasic then begin + if CopilotAddRadio.Checked then + Result := CopilotChoiceOnFirstUse + else + Result := CopilotChoiceNone; + end; +end; + +procedure CopilotEnsureDecision(); +var + PageChoice: String; +begin + if CopilotDecided then + exit; + CopilotDecided := True; + CopilotInitialize(); + + if CopilotPolicyDisabled() or (CopilotProbeCompleted and CopilotProbePolicyDisabled) then begin + CopilotAction := CopilotActionPolicy; + CopilotChoice := CopilotChoicePolicy; + CopilotSource := 'policy'; + Log('Copilot: action=policy'); + exit; + end; + + if WizardSilent() then + PageChoice := '' + else + PageChoice := CopilotChoiceFromPage(); + + if PageChoice <> '' then begin + if PageChoice = CopilotChoiceInstall then + CopilotAction := CopilotActionInstall + else if PageChoice = CopilotChoiceOnFirstUse then + CopilotAction := CopilotActionAdd + else + CopilotAction := CopilotActionRemove; + CopilotChoice := PageChoice; + CopilotSource := 'page'; + end else + CopilotAction := CopilotDecideWithoutPage(CopilotSwitchValue, CopilotIsExistingInstall, CopilotPreviousChoice, CopilotPreviousSource, + CopilotIsUserInstaller(), IsAdmin(), WizardIsTaskSelected('addtopath'), CopilotChoice, CopilotSource); + + Log(Format('Copilot: action=%d, choice=%s, source=%s', [CopilotAction, CopilotChoice, CopilotSource])); +end; + +// Shim extraction, probe, and publishing + +function CopilotExtractShim(): Boolean; +begin + if not CopilotShimExtracted then begin + try + ExtractTemporaryFile('copilot.exe'); + CopilotShimExtracted := True; + except + Log('Copilot: failed to extract the shim: ' + GetExceptionMessage); + end; + end; + Result := CopilotShimExtracted; +end; + +function CopilotProbeResultFile(): String; +begin + Result := ExpandConstant('{tmp}\copilot-probe.ini'); +end; + +procedure CopilotStartProbe(); +var + Params: String; + ResultCode: Integer; +begin + if CopilotProbeStarted then + exit; + CopilotProbeStarted := True; + CopilotProbeStartTick := CopilotGetTickCount(); + + if CopilotPolicyDisabled() then begin + Log('Copilot: not probing because Copilot CLI is disabled by policy'); + exit; + end; + if CopilotIsUserInstaller() and IsAdmin() then begin + Log('Copilot: not probing because the user installer is running elevated'); + exit; + end; + if not CopilotExtractShim() then + exit; + +#if "user" == InstallTarget + Params := '--vscode-shim probe --scope user'; +#else + // The system installer never offers the download, so it only needs local detection. + Params := '--vscode-shim probe --scope machine --no-network'; +#endif + Params := Params + ' --timeout-ms ' + IntToStr(CopilotProbeTimeoutMs) + ' --result-file ' + AddQuotes(CopilotProbeResultFile()); + + CopilotProbeLaunched := Exec(CopilotTempShimPath(), Params, '', SW_HIDE, ewNoWait, ResultCode); + if CopilotProbeLaunched then + Log('Copilot: probe started') + else + Log('Copilot: failed to start the probe: ' + SysErrorMessage(ResultCode)); +end; + +procedure CopilotReadProbeResult(); +var + ResultFile: String; +begin + ResultFile := CopilotProbeResultFile(); + CopilotProbeCompleted := FileExists(ResultFile) and (GetIniInt('probe', 'protocol', 0, 0, 1000, ResultFile) = 1); + if not CopilotProbeCompleted then begin + Log('Copilot: the probe did not complete'); + exit; + end; + + CopilotProbePolicyDisabled := CompareText(GetIniString('probe', 'policy', 'allowed', ResultFile), 'disabled') = 0; + CopilotProbeCliFound := GetIniBool('probe', 'cliFound', False, ResultFile); + CopilotProbeCliPath := GetIniString('probe', 'cliPath', '', ResultFile); + CopilotProbeDownloadAvailable := GetIniBool('probe', 'downloadAvailable', False, ResultFile); + CopilotProbeDownloadSizeMB := (StrToInt64Def(GetIniString('probe', 'downloadSize', '0', ResultFile), 0) + 524288) div 1048576; + CopilotProbePwshFound := GetIniBool('probe', 'pwshFound', True, ResultFile); + Log('Copilot: probe policyDisabled=' + BoolToStr(CopilotProbePolicyDisabled) + + ', cliFound=' + BoolToStr(CopilotProbeCliFound) + ', cliPath=' + CopilotProbeCliPath + ', downloadAvailable=' + BoolToStr(CopilotProbeDownloadAvailable) + + ', downloadSizeMB=' + IntToStr(CopilotProbeDownloadSizeMB) + ', pwshFound=' + BoolToStr(CopilotProbePwshFound) + + ', reason=' + GetIniString('probe', 'reason', '', ResultFile)); +end; + +procedure CopilotWaitForProbe(); +var + Page: TOutputMarqueeProgressWizardPage; +begin + if CopilotProbeRead then + exit; + CopilotProbeRead := True; + CopilotStartProbe(); + + if CopilotProbeLaunched and not FileExists(CopilotProbeResultFile()) and (CopilotElapsedMs(CopilotProbeStartTick) < CopilotProbeWaitMs) then begin + Page := CreateOutputMarqueeProgressPage(CustomMessage('CopilotCliPageCaption'), CustomMessage('CopilotCliChecking')); + Page.Show; + try + while not FileExists(CopilotProbeResultFile()) and (CopilotElapsedMs(CopilotProbeStartTick) < CopilotProbeWaitMs) do begin + Page.Animate; + Sleep(50); + end; + finally + Page.Hide; + end; + end; + + CopilotReadProbeResult(); +end; + +// Publishing the shim. inno_updater three-way-renames the files directly in {app}\bin but never enters its subfolders, +// so setup does the same for {app}\bin\copilot-shim (see perform_three_way_rename, find_available_old_path, util::retry, +// and remove_files in microsoft/inno-updater src/main.rs). A running shim (an open Copilot session) can't be deleted or +// overwritten but can be renamed, so it's renamed to old_copilot.exe and the session keeps running from that file. + +#ifndef CopilotRetryAttempts +#define CopilotRetryAttempts 11 +#endif + +const + CopilotShimName = 'copilot.exe'; + +// Renames Source to Target, or copies it when Copy is set. Retries like inno_updater's util::retry, which absorbs +// transient locks such as antivirus scans: up to 11 attempts, waiting attempt^2 * 50 ms after each failure. +function CopilotMoveWithRetry(const Source, Target: String; const Copy: Boolean): Boolean; +var + Attempt: Integer; +begin + Attempt := 0; + repeat + Attempt := Attempt + 1; + if Copy then + Result := CopyFile(Source, Target, False) + else + Result := RenameFile(Source, Target); + if not Result and (Attempt < {#CopilotRetryAttempts}) then + Sleep(Attempt * Attempt * 50); + until Result or (Attempt >= {#CopilotRetryAttempts}); + if not Result then + Log('Copilot: could not move ' + Source + ' to ' + Target + ' after ' + IntToStr(Attempt) + ' attempts'); +end; + +function CopilotRenameWithRetry(const Source, Target: String): Boolean; +begin + Result := CopilotMoveWithRetry(Source, Target, False); +end; + +// Returns old_copilot.exe, or old_1_copilot.exe, old_2_copilot.exe, and so on when an older session still holds it. +function CopilotAvailableOldPath(const Dir: String): String; +var + I: Integer; +begin + Result := Dir + '\old_' + CopilotShimName; + I := 1; + while FileExists(Result) do begin + Result := Dir + '\old_' + IntToStr(I) + '_' + CopilotShimName; + I := I + 1; + end; +end; + +// Deletes the old_* copies left by earlier updates and any staged new_copilot.exe. A copy that a Copilot session still +// runs is locked; it's skipped and deleted by a later install or update. +procedure CopilotDeleteOldShims(const Dir: String); +var + FindRec: TFindRec; +begin + if FindFirst(Dir + '\old_*', FindRec) then begin + try + repeat + if FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY = 0 then begin + if DeleteFile(Dir + '\' + FindRec.Name) then + Log('Copilot: deleted ' + FindRec.Name) + else + Log('Copilot: skipped ' + FindRec.Name + ', which is still in use'); + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; + DeleteFile(Dir + '\new_' + CopilotShimName); +end; + +// Renames Current to Old and New to Current. If New can't take Current's place, Old is renamed back, so a failed +// update keeps the previous shim instead of leaving none. +function CopilotThreeWayRename(const Current, Old, New: String): Boolean; +begin + Result := False; + if not FileExists(New) then begin + Result := True; + exit; + end; + if FileExists(Current) and not CopilotRenameWithRetry(Current, Old) then + exit; + if CopilotRenameWithRetry(New, Current) then + Result := True + else if FileExists(Old) then begin + Log('Copilot: restoring ' + Current); + CopilotRenameWithRetry(Old, Current); + end; +end; + +function CopilotVersionString(const FileName: String): String; +begin + if not GetVersionNumbersString(FileName, Result) then + Result := 'none'; +end; + +// Whether both files carry the same file version. Signing changes a build's bytes, so the version decides whether the +// shim changed; the shim's package version is bumped whenever it changes. +function CopilotSameShimVersion(const Source, Target: String): Boolean; +var + SourceVersion, TargetVersion: Int64; +begin + Result := FileExists(Target) and GetPackedVersion(Source, SourceVersion) and GetPackedVersion(Target, TargetVersion) + and SamePackedVersion(SourceVersion, TargetVersion); +end; + +function CopilotPublishShimFile(const Source, Dir: String): Boolean; +var + Target, Staging, PreviousVersion: String; +begin + Result := False; + Target := Dir + '\' + CopilotShimName; + Staging := Dir + '\new_' + CopilotShimName; + try + if not ForceDirectories(Dir) then begin + Log('Copilot: could not create ' + Dir); + exit; + end; + CopilotDeleteOldShims(Dir); + + if CopilotSameShimVersion(Source, Target) then begin + Log('Copilot: the published shim is up to date, version ' + CopilotVersionString(Target)); + Result := True; + exit; + end; + + PreviousVersion := CopilotVersionString(Target); + if CopilotMoveWithRetry(Source, Staging, True) then + Result := CopilotThreeWayRename(Target, CopilotAvailableOldPath(Dir), Staging); + Log('Copilot: published shim version ' + CopilotVersionString(Source) + ' over ' + PreviousVersion + + ', success=' + BoolToStr(Result)); + // Removes the previous shim unless a Copilot session still runs it, and any staged copy left by a failure. + CopilotDeleteOldShims(Dir); + except + Log('Copilot: failed to publish the shim: ' + GetExceptionMessage); + end; +end; + +function CopilotPublishShim(): Boolean; +begin + Result := CopilotExtractShim() and CopilotPublishShimFile(CopilotTempShimPath(), CopilotShimDir()); +end; + +procedure CopilotRemovePublishedShim(); +var + Dir, Target: String; +begin + Dir := CopilotShimDir(); + Target := Dir + '\' + CopilotShimName; + // A shim that a Copilot session still runs can't be deleted; renaming it removes the command right away. + if FileExists(Target) and not DeleteFile(Target) then + CopilotRenameWithRetry(Target, CopilotAvailableOldPath(Dir)); + CopilotDeleteOldShims(Dir); +end; + +// Install now + +// Returns the heartbeat, which changes whenever the shim reports progress. +function CopilotReadInstallProgress(const ProgressFile: String; var Phase: String; var Current, Total: Int64): String; +begin + Result := GetIniString('progress', 'heartbeat', '', ProgressFile); + Phase := GetIniString('progress', 'phase', '', ProgressFile); + Current := StrToInt64Def(GetIniString('progress', 'current', '0', ProgressFile), 0); + Total := StrToInt64Def(GetIniString('progress', 'total', '0', ProgressFile), 0); +end; + +#if "user" == InstallTarget +procedure CopilotShowInstallProgress(const Page: TDownloadWizardPage; const Phase: String; const Current, Total: Int64); +begin + if Phase = 'installing' then begin + // The MSI install can't be stopped safely once it has started. + Page.AbortButton.Visible := False; + Page.SetText(CustomMessage('CopilotCliInstallingPhase'), ''); + Page.SetProgress(0, 0); + end else if Phase = 'verifying' then begin + Page.SetText(CustomMessage('CopilotCliVerifyingPhase'), ''); + Page.SetProgress(0, 0); + end else if Total > 0 then begin + Page.SetText(FmtMessage(CustomMessage('CopilotCliDownloadingProgress'), [IntToStr(Current div 1048576), IntToStr(Total div 1048576)]), ''); + Page.SetProgress(Integer(Current div 1024), Integer(Total div 1024)); + end else begin + Page.SetText(CustomMessage('CopilotCliDownloadingPhase'), ''); + Page.SetProgress(0, 0); + end; +end; +#endif + +// Runs the shim's install command as the installing user. Never raises; returns True when Copilot CLI is installed. +function CopilotRunInstall(const ShowProgress: Boolean): Boolean; +var + ProgressFile, ResultFile, CancelFile, MutexName, Params, Status, Signature, LastSignature, Phase: String; + StartTick, LastChangeTick, CancelTick: Cardinal; + Current, Total: Int64; + ResultCode: Integer; + SeenRunning, CancelRequested, Visible: Boolean; +begin + Result := False; + CopilotInstallCancelled := False; + if not CopilotExtractShim() then + exit; + + ProgressFile := ExpandConstant('{tmp}\copilot-install-progress.ini'); + ResultFile := ExpandConstant('{tmp}\copilot-install-result.ini'); + CancelFile := ExpandConstant('{tmp}\copilot-install.cancel'); + MutexName := '{#AppMutex}-copilot-install'; + DeleteFile(ProgressFile); + DeleteFile(ResultFile); + DeleteFile(CancelFile); + + Params := '--vscode-shim install --non-interactive --consent=installer' + + ' --progress-file ' + AddQuotes(ProgressFile) + + ' --result-file ' + AddQuotes(ResultFile) + + ' --cancel-file ' + AddQuotes(CancelFile) + + ' --running-mutex ' + AddQuotes(MutexName); + + if not Exec(CopilotTempShimPath(), Params, '', SW_HIDE, ewNoWait, ResultCode) then begin + Log('Copilot: failed to start the install: ' + SysErrorMessage(ResultCode)); + exit; + end; + Log('Copilot: install started'); + + Visible := False; +#if "user" == InstallTarget + Visible := ShowProgress; + if Visible then begin + CopilotInstallPage.Show; + CopilotInstallPage.AbortButton.Visible := True; + CopilotShowInstallProgress(CopilotInstallPage, '', 0, 0); + end; +#endif + + StartTick := CopilotGetTickCount(); + LastChangeTick := StartTick; + CancelTick := StartTick; + LastSignature := ''; + SeenRunning := False; + CancelRequested := False; + try + while not FileExists(ResultFile) do begin + if CheckForMutexes(MutexName) then + SeenRunning := True + else if SeenRunning then begin + // The process exited; give the result file a moment to appear. + Sleep(250); + break; + end else if CopilotElapsedMs(StartTick) > CopilotInstallStartTimeoutMs then begin + Log('Copilot: the install did not start'); + break; + end; + + Signature := CopilotReadInstallProgress(ProgressFile, Phase, Current, Total); + if Signature <> LastSignature then begin + LastSignature := Signature; + LastChangeTick := CopilotGetTickCount(); + end; + + if not CancelRequested then begin +#if "user" == InstallTarget + if Visible and CopilotInstallPage.AbortedByUser then begin + Log('Copilot: the user stopped the install'); + CopilotInstallCancelled := True; + CancelRequested := True; + end; +#endif + if not CancelRequested and (CopilotElapsedMs(LastChangeTick) > CopilotInstallStallTimeoutMs) then begin + Log('Copilot: the install stopped reporting progress'); + CancelRequested := True; + end; + if not CancelRequested and (CopilotElapsedMs(StartTick) > CopilotInstallTotalTimeoutMs) then begin + Log('Copilot: the install timed out'); + CancelRequested := True; + end; + if CancelRequested then begin + SaveStringToFile(CancelFile, 'cancel', False); + CancelTick := CopilotGetTickCount(); + end; + end else if CopilotElapsedMs(CancelTick) > CopilotInstallCancelGraceMs then begin + Log('Copilot: the install did not stop after cancellation'); + break; + end; + +#if "user" == InstallTarget + if Visible then + CopilotShowInstallProgress(CopilotInstallPage, Phase, Current, Total); +#endif + Sleep(100); + end; + finally +#if "user" == InstallTarget + if Visible then + CopilotInstallPage.Hide; +#endif + end; + + if not FileExists(ResultFile) then begin + Log('Copilot: the install did not report a result'); + exit; + end; + + Status := GetIniString('result', 'status', '', ResultFile); + Log('Copilot: install status=' + Status + ', exitCode=' + GetIniString('result', 'exitCode', '', ResultFile) + + ', cliPath=' + GetIniString('result', 'cliPath', '', ResultFile) + ', cliVersion=' + GetIniString('result', 'cliVersion', '', ResultFile) + + ', log=' + GetIniString('result', 'log', '', ResultFile)); + Result := (Status = 'installed') or (Status = 'alreadyInstalled'); +end; + +// Post-install and uninstall entry points, called from code.iss + +procedure CopilotPostInstall(); +var + Installed: Boolean; +begin + CopilotEnsureDecision(); + + if CopilotAction = CopilotActionPolicy then begin + Log('Copilot: Copilot CLI is disabled by policy; removing the copilot command'); + CopilotRemoveFromPath({#EnvironmentRootKey}, '{#EnvironmentKey}', CopilotShimDir()); + CopilotRemovePublishedShim(); + exit; + end; + + // The shim is always published: VS Code terminals use it even when it isn't on PATH. + if not CopilotPublishShim() then + Log('Copilot: continuing without an updated shim'); + + if CopilotAction = CopilotActionInstall then begin + Installed := CopilotRunInstall(not WizardSilent()); + if not Installed and not WizardSilent() and not CopilotInstallCancelled then + SuppressibleMsgBox(FmtMessage(CustomMessage('CopilotCliInstallFailed'), ['{#NameLong}']), mbInformation, MB_OK, IDOK); + // The shim goes on PATH even when Copilot CLI's installer added its own folder: it runs Copilot CLI, and offers to + // install it again if Copilot CLI is uninstalled later. + CopilotCommandReady := CopilotAddToPath({#EnvironmentRootKey}, '{#EnvironmentKey}', CopilotShimDir()) or Installed; + end else if CopilotAction = CopilotActionAdd then + CopilotCommandReady := CopilotAddToPath({#EnvironmentRootKey}, '{#EnvironmentKey}', CopilotShimDir()) + else if CopilotAction = CopilotActionRemove then + CopilotRemoveFromPath({#EnvironmentRootKey}, '{#EnvironmentKey}', CopilotShimDir()); +end; + +procedure CopilotUninstall(); +begin + CopilotRemoveFromPath({#EnvironmentRootKey}, '{#EnvironmentKey}', CopilotShimDir()); +end; + +// Wizard pages + +function CopilotAddText(const Page: TWizardPage; var Top: Integer; const Left: Integer; const Caption: String; const SpaceAfter: Integer): TNewStaticText; +begin + Result := TNewStaticText.Create(Page); + Result.Parent := Page.Surface; + // Screen readers follow the window order, and each new control is placed above the ones before it. Moving it to the + // bottom keeps the window order the same as the order on the page. CopilotAddOption and CopilotAddFooter do the same. + Result.SendToBack; + Result.Left := Left; + Result.Top := Top; + Result.Width := Page.SurfaceWidth - Left; + Result.WordWrap := True; + Result.Caption := Caption; + Result.AdjustHeight; + Top := Result.Top + Result.Height + ScaleY(SpaceAfter); +end; + +function CopilotAddOption(const Page: TWizardPage; var Top: Integer; const Caption, Description: String): TNewRadioButton; +begin + Result := TNewRadioButton.Create(Page); + Result.Parent := Page.Surface; + Result.SendToBack; + Result.Left := 0; + Result.Top := Top; + Result.Width := Page.SurfaceWidth; + Result.Height := ScaleY(17); + Result.Caption := Caption; + Top := Result.Top + Result.Height + ScaleY(1); + CopilotAddText(Page, Top, ScaleX(18), Description, 8); +end; + +procedure CopilotLinkClick(Sender: TObject; const Link: String; LinkType: TSysLinkType); +var + ErrorCode: Integer; +begin + ShellExecAsOriginalUser('open', Link, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode); +end; + +procedure CopilotAddFooter(const Page: TWizardPage; var Top: Integer); +var + Note: String; + Link: TNewLinkLabel; +begin + Note := CustomMessage('CopilotCliLicenseNote'); + // Copilot CLI supports Windows PowerShell 5.1, so a missing PowerShell 7 is only a recommendation. + if CopilotProbeCompleted and not CopilotProbePwshFound then + Note := Note + ' ' + CustomMessage('CopilotCliPwshRecommended'); + + Link := TNewLinkLabel.Create(Page); + Link.Parent := Page.Surface; + Link.SendToBack; + // Link labels aren't tab stops by default, which leaves the link out of keyboard navigation. + Link.TabStop := True; + Link.AutoSize := False; + Link.Left := 0; + Link.Top := Top + ScaleY(4); + Link.Width := Page.SurfaceWidth; + Link.Caption := Note + ' ' + CustomMessage('CopilotCliLearnMore') + ''; + Link.OnLinkClick := @CopilotLinkClick; + Link.AdjustHeight; + Top := Link.Top + Link.Height; +end; + +procedure CopilotBuildBasicPage(); +var + Top: Integer; +begin + if CopilotBasicPageBuilt then + exit; + CopilotBasicPageBuilt := True; + + Top := 0; +#if "user" == InstallTarget + CopilotAddText(CopilotBasicPage, Top, 0, CustomMessage('CopilotCliPromptBasic'), 12); + CopilotAddRadio := CopilotAddOption(CopilotBasicPage, Top, CustomMessage('CopilotCliAdd'), CustomMessage('CopilotCliAddDescription')); + CopilotBasicDontAddRadio := CopilotAddOption(CopilotBasicPage, Top, CustomMessage('CopilotCliDontAdd'), FmtMessage(CustomMessage('CopilotCliDontAddDescription'), ['{#NameLong}'])); +#else + CopilotAddText(CopilotBasicPage, Top, 0, CustomMessage('CopilotCliPromptAllUsers'), 12); + CopilotAddRadio := CopilotAddOption(CopilotBasicPage, Top, CustomMessage('CopilotCliAddAllUsers'), CustomMessage('CopilotCliAddAllUsersDescription')); + CopilotBasicDontAddRadio := CopilotAddOption(CopilotBasicPage, Top, CustomMessage('CopilotCliDontAdd'), FmtMessage(CustomMessage('CopilotCliDontAddAllUsersDescription'), ['{#NameLong}'])); +#endif + CopilotAddFooter(CopilotBasicPage, Top); +end; + +#if "user" == InstallTarget +procedure CopilotBuildFullPage(); +var + Top: Integer; + Description: String; +begin + if CopilotFullPageBuilt then + exit; + CopilotFullPageBuilt := True; + + if CopilotProbeDownloadSizeMB > 0 then + Description := FmtMessage(CustomMessage('CopilotCliInstallNowDescription'), [IntToStr(CopilotProbeDownloadSizeMB), '{#NameLong}']) + else + Description := FmtMessage(CustomMessage('CopilotCliInstallNowDescriptionNoSize'), ['{#NameLong}']); + + Top := 0; + CopilotAddText(CopilotFullPage, Top, 0, CustomMessage('CopilotCliPrompt'), 12); + CopilotInstallNowRadio := CopilotAddOption(CopilotFullPage, Top, CustomMessage('CopilotCliInstallNow'), Description); + CopilotFirstUseRadio := CopilotAddOption(CopilotFullPage, Top, CustomMessage('CopilotCliFirstUse'), CustomMessage('CopilotCliFirstUseDescription')); + CopilotFullDontAddRadio := CopilotAddOption(CopilotFullPage, Top, CustomMessage('CopilotCliDontAdd'), FmtMessage(CustomMessage('CopilotCliDontAddDescription'), ['{#NameLong}'])); + CopilotAddFooter(CopilotFullPage, Top); +end; +#endif + +function CopilotDecidePageKind(): Integer; +begin + Result := CopilotPageNone; + if WizardSilent() or CopilotPolicyDisabled() then + exit; + if CopilotIsUserInstaller() and IsAdmin() then + exit; + if CopilotProbeCompleted and (CopilotProbePolicyDisabled or CopilotProbeCliFound) then + exit; + + if CopilotIsUserInstaller() and CopilotProbeCompleted and CopilotProbeDownloadAvailable then + Result := CopilotPageFull + else + Result := CopilotPageBasic; +end; + +procedure CopilotPreparePage(); +var + Choice: String; +begin + CopilotPageKind := CopilotDecidePageKind(); + Log('Copilot: page kind=' + IntToStr(CopilotPageKind)); + if CopilotPageKind = CopilotPageNone then + exit; + +#if "user" == InstallTarget + if CopilotPageKind = CopilotPageFull then + CopilotBuildFullPage() + else +#endif + CopilotBuildBasicPage(); + + if CopilotDefaultsApplied then + exit; + CopilotDefaultsApplied := True; + + Choice := CopilotSelectDefault(CopilotSwitchValue, CopilotPreviousChoice, CopilotPageKind = CopilotPageFull); +#if "user" == InstallTarget + if CopilotPageKind = CopilotPageFull then begin + CopilotInstallNowRadio.Checked := Choice = CopilotChoiceInstall; + CopilotFirstUseRadio.Checked := Choice = CopilotChoiceOnFirstUse; + CopilotFullDontAddRadio.Checked := Choice = CopilotChoiceNone; + exit; + end; +#endif + CopilotAddRadio.Checked := Choice <> CopilotChoiceNone; + CopilotBasicDontAddRadio.Checked := Choice = CopilotChoiceNone; +end; + +function CopilotReadyMemoChoice(): String; +var + Choice: String; +begin + Choice := CopilotChoiceFromPage(); + if Choice = CopilotChoiceInstall then begin + if CopilotProbeDownloadSizeMB > 0 then + Result := FmtMessage(CustomMessage('CopilotCliMemoInstallNow'), [IntToStr(CopilotProbeDownloadSizeMB)]) + else + Result := CustomMessage('CopilotCliMemoInstallNowNoSize'); + end else if Choice = CopilotChoiceOnFirstUse then + Result := CustomMessage('CopilotCliMemoAdd') + else + Result := CustomMessage('CopilotCliMemoDontAdd'); +end; + +procedure CopilotAppendMemoSection(var Memo: String; const Section, NewLine: String); +begin + if Section = '' then + exit; + if Memo <> '' then + Memo := Memo + NewLine + NewLine; + Memo := Memo + Section; +end; + +// Event functions + + +procedure CopilotInitializeWizard(); +begin + CopilotInitialize(); +#if "user" == InstallTarget + CopilotFullPage := CreateCustomPage(wpSelectTasks, CustomMessage('CopilotCliPageCaption'), CustomMessage('CopilotCliPageDescription')); + CopilotBasicPage := CreateCustomPage(CopilotFullPage.ID, CustomMessage('CopilotCliPageCaption'), CustomMessage('CopilotCliPageDescription')); + CopilotInstallPage := CreateDownloadPage(CustomMessage('CopilotCliInstallingCaption'), CustomMessage('CopilotCliInstallingDescription'), nil); +#else + CopilotBasicPage := CreateCustomPage(wpSelectTasks, CustomMessage('CopilotCliPageCaption'), CustomMessage('CopilotCliPageDescription')); +#endif +end; + + +function CopilotNextButtonClick(CurPageID: Integer): Boolean; +begin + Result := True; + if WizardSilent() then + exit; + + if CurPageID = wpLicense then + CopilotStartProbe() + else if CurPageID = wpSelectTasks then begin + CopilotWaitForProbe(); + CopilotPreparePage(); + end; +end; + + +procedure CopilotCurPageChanged(CurPageID: Integer); +var + Delta: Integer; +begin + if WizardSilent() then + exit; + + if CurPageID = wpSelectTasks then + CopilotStartProbe() + else if (CurPageID = wpFinished) and CopilotCommandReady then begin + WizardForm.FinishedLabel.Caption := WizardForm.FinishedLabel.Caption + CustomMessage('CopilotCliFinishedHint') + #13#10; + Delta := WizardForm.FinishedLabel.AdjustHeight; + WizardForm.RunList.Top := WizardForm.RunList.Top + Delta; + WizardForm.RunList.Height := WizardForm.RunList.Height - Delta; + end; +end; + + +function CopilotShouldSkipPage(PageID: Integer): Boolean; +begin + Result := False; +#if "user" == InstallTarget + if PageID = CopilotFullPage.ID then + Result := CopilotPageKind <> CopilotPageFull; +#endif + if PageID = CopilotBasicPage.ID then + Result := CopilotPageKind <> CopilotPageBasic; +end; + + +function CopilotUpdateReadyMemo(Space, NewLine, MemoUserInfoInfo, MemoDirInfo, MemoTypeInfo, MemoComponentsInfo, MemoGroupInfo, MemoTasksInfo: String): String; +begin + Result := ''; + CopilotAppendMemoSection(Result, MemoUserInfoInfo, NewLine); + CopilotAppendMemoSection(Result, MemoDirInfo, NewLine); + CopilotAppendMemoSection(Result, MemoTypeInfo, NewLine); + CopilotAppendMemoSection(Result, MemoComponentsInfo, NewLine); + CopilotAppendMemoSection(Result, MemoGroupInfo, NewLine); + CopilotAppendMemoSection(Result, MemoTasksInfo, NewLine); + if CopilotPageKind <> CopilotPageNone then + CopilotAppendMemoSection(Result, CustomMessage('CopilotCliMemoHeading') + NewLine + Space + CopilotReadyMemoChoice(), NewLine) + else if CopilotProbeCompleted and CopilotProbeCliFound and (CopilotProbeCliPath <> '') and not CopilotProbePolicyDisabled + and not CopilotPolicyDisabled() then + // Setup skips its page because Copilot CLI is already installed; say where, so the missing page isn't a surprise. + CopilotAppendMemoSection(Result, CustomMessage('CopilotCliMemoHeading') + NewLine + Space + + FmtMessage(CustomMessage('CopilotCliMemoFound'), [CopilotProbeCliPath]), NewLine); +end; + + +procedure CopilotRegisterPreviousData(PreviousDataKey: Integer); +begin + CopilotEnsureDecision(); + if CopilotChoice <> '' then begin + SetPreviousData(PreviousDataKey, 'CopilotCliChoice', CopilotChoice); + SetPreviousData(PreviousDataKey, 'CopilotCliSource', CopilotSource); + end; +end; diff --git a/build/win32/i18n/messages.en.isl b/build/win32/i18n/messages.en.isl index 6255123d34d1a5..e87e4ddeb858c5 100644 --- a/build/win32/i18n/messages.en.isl +++ b/build/win32/i18n/messages.en.isl @@ -15,3 +15,38 @@ Other=Other: SourceFile=%1 Source File OpenWithCodeContextMenu=Open w&ith %1 UpdatingVisualStudioCode=Updating Visual Studio Code... +CopilotCliPageCaption=GitHub Copilot CLI +CopilotCliPageDescription=Use GitHub Copilot from any terminal with the copilot command. +CopilotCliPrompt=GitHub Copilot CLI isn't installed on this computer. How would you like to set up the copilot command? +CopilotCliPromptBasic=How would you like to set up the copilot command? +CopilotCliPromptAllUsers=How would you like to set up the copilot command for users of this computer? +CopilotCliInstallNow=&Install GitHub Copilot CLI now +CopilotCliInstallNowDescription=Adds copilot to PATH and downloads about %1 MB from GitHub after %2 is installed. +CopilotCliInstallNowDescriptionNoSize=Adds copilot to PATH and downloads GitHub Copilot CLI from GitHub after %1 is installed. +CopilotCliFirstUse=Install GitHub Copilot CLI when I &first run copilot +CopilotCliFirstUseDescription=Adds copilot to PATH. Nothing is downloaded until you run it and confirm. +CopilotCliAdd=&Add the copilot command +CopilotCliAddDescription=Adds copilot to PATH. GitHub Copilot CLI is installed the first time you run copilot. This requires an internet connection. +CopilotCliAddAllUsers=&Add the copilot command for all users +CopilotCliAddAllUsersDescription=Adds copilot to PATH for all users. Each user is asked before GitHub Copilot CLI is downloaded the first time they run copilot. +CopilotCliDontAdd=&Don't add the copilot command +CopilotCliDontAddDescription=copilot isn't added to PATH. You can still run it in the %1 terminal. +CopilotCliDontAddAllUsersDescription=copilot isn't added to PATH. Users can still run it in the %1 terminal. +CopilotCliLicenseNote=GitHub Copilot CLI is a separate GitHub product with its own license terms, and it requires a GitHub Copilot subscription. +CopilotCliPwshRecommended=It works with Windows PowerShell, and PowerShell 7 or later is recommended. +CopilotCliLearnMore=Learn more +CopilotCliChecking=Checking for GitHub Copilot CLI... +CopilotCliMemoHeading=GitHub Copilot CLI: +CopilotCliMemoInstallNow=Add copilot to PATH and install now (about %1 MB download) +CopilotCliMemoInstallNowNoSize=Add copilot to PATH and install now +CopilotCliMemoAdd=Add copilot to PATH +CopilotCliMemoDontAdd=Don't add copilot to PATH +CopilotCliMemoFound=Already installed at %1 +CopilotCliInstallingCaption=Installing GitHub Copilot CLI +CopilotCliInstallingDescription=Setup is downloading and installing GitHub Copilot CLI. +CopilotCliDownloadingPhase=Downloading... +CopilotCliDownloadingProgress=Downloading... %1 MB of %2 MB +CopilotCliVerifyingPhase=Verifying... +CopilotCliInstallingPhase=Installing... +CopilotCliInstallFailed=%1 was installed, but GitHub Copilot CLI couldn't be installed. The copilot command will offer to install it the first time you run it. +CopilotCliFinishedHint=Open a new terminal to use the copilot command. diff --git a/copilot_shim/.cargo/config.toml b/copilot_shim/.cargo/config.toml new file mode 100644 index 00000000000000..ad9374d4a90878 --- /dev/null +++ b/copilot_shim/.cargo/config.toml @@ -0,0 +1,6 @@ +[target.'cfg(all(target_os = "windows", any(target_arch = "i686", target_arch = "x86_64", target_arch = "x86")))'] +rustflags = ["-Ctarget-feature=+crt-static", "-Clink-args=/guard:cf", "-Clink-args=/CETCOMPAT"] + +# CETCOMPAT is not supported on ARM binaries +[target.'cfg(all(target_os = "windows", not(any(target_arch = "i686", target_arch = "x86_64", target_arch = "x86"))))'] +rustflags = ["-Ctarget-feature=+crt-static", "-Clink-args=/guard:cf"] diff --git a/copilot_shim/Cargo.lock b/copilot_shim/Cargo.lock new file mode 100644 index 00000000000000..bb8ad337d9602a --- /dev/null +++ b/copilot_shim/Cargo.lock @@ -0,0 +1,116 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "copilot_shim" +version = "0.1.0" +dependencies = [ + "libc", + "tempfile", + "windows-sys", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/copilot_shim/Cargo.toml b/copilot_shim/Cargo.toml new file mode 100644 index 00000000000000..a49ac1d5b875e5 --- /dev/null +++ b/copilot_shim/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "copilot_shim" +version = "0.1.0" +edition = "2021" +autobins = false + +[lib] +name = "copilot_shim" +path = "src/lib.rs" + +[[bin]] +name = "copilot" +path = "src/main.rs" + +[profile.release] +strip = true +lto = "thin" + +[dependencies] +tempfile = "3.5.0" + +[target."cfg(unix)".dependencies] +libc = "0.2.144" + +[target."cfg(windows)".dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Networking_WinHttp", "Win32_Security", "Win32_Security_Cryptography", "Win32_Security_Cryptography_Catalog", "Win32_Security_Cryptography_Sip", "Win32_Security_WinTrust", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_Environment", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_LibraryLoader", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading"] } diff --git a/copilot_shim/REQUIREMENTS.md b/copilot_shim/REQUIREMENTS.md new file mode 100644 index 00000000000000..2bb8b689ac6a4c --- /dev/null +++ b/copilot_shim/REQUIREMENTS.md @@ -0,0 +1,1283 @@ +# Rust Copilot CLI shim requirements + +## Status + +This document defines the requirements for a standalone Rust replacement for +the existing Copilot CLI bootstrapper scripts. + +The implementation must live in a new independent Cargo package: + +```text +copilot_shim/ +``` + +This directory is a sibling of [`cli/`](../cli/). + +The executable name is: + +- `copilot` on macOS and Linux. +- `copilot.exe` on Windows. + +VS Code ships the shim in a `copilot-shim` folder inside the `bin` folder that +contains the `code` command, in both client and server builds (see +[Product integration](#product-integration)). + +## Purpose + +The shim is invoked from a terminal. It finds and launches a real +GitHub Copilot CLI already available through `PATH`, while preventing recursion +through itself or the legacy VS Code shims. + +When no usable CLI is available, the shim offers interactive installation on +supported installer targets. It then repeats discovery before launching. Other +targets receive manual-install or upstream-support guidance. The shim never +runs a candidate except to launch it: there is no minimum version, because the +Copilot CLI keeps itself up to date. + +## Scope + +The implementation includes: + +- Cross-platform Copilot CLI discovery through `PATH`. +- Self, copy, symlink, hard-link, and legacy-shim exclusion. +- Windows native and script-wrapper support. +- Interactive installation, using GitHub's per-user MSI on Windows. +- Post-install discovery. +- Argument, terminal I/O, and exit-status forwarding. +- The `--vscode-shim` option namespace, including the setup commands that the + Windows installer runs. +- Builds for the complete VS Code CLI target matrix, inside the VS Code CLI + build jobs. +- Placement in VS Code client and server builds, which sign it. + +## Product integration + +The shim lives at `bin/copilot-shim/copilot` (`copilot.exe` on Windows), +relative to the folder that contains the application executable: + +| Build | Location | +|---|---| +| Windows client | `\bin\copilot-shim\copilot.exe` | +| macOS client | `.app/Contents/Resources/app/bin/copilot-shim/copilot` | +| Linux client | `/bin/copilot-shim/copilot` | +| Servers | `/bin/copilot-shim/copilot[.exe]` | + +On Windows the folder is inside `bin` because `inno_updater --gc` removes other +top-level folders of a user installation. The folder is separate from `bin` +itself so that adding `copilot` to `PATH` never depends on adding `code`, and +the reverse. + +- The Windows installer ([`copilot.iss`](../build/win32/copilot.iss)) adds the + folder to `PATH`, and can install Copilot CLI with the setup commands. +- The Copilot extension computes the same path and adds the folder to the + `PATH` of integrated terminals, unless `chat.copilotCliCommand.enabled` is + off. It then removes `/copilotCli`, where earlier versions + wrote script shims. When the build doesn't ship the shim, as in a build from + source, it uses the development deployment described below instead. + +### Development integrated-terminal deployment + +When the build doesn't ship the shim, the extension copies a local Rust build to +`/copilotCli/copilot` (`copilot.exe` on Windows). Fill in the +absolute, extension-host-local build path in `LOCAL_SHIM_SOURCE_PATHS` in +[`copilotCLINativeShim.ts`](../extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLINativeShim.ts). +The entries are intentionally unset. WSL/SSH requires a build on the remote +host matching its OS, architecture, and libc. This development integration +does not discover or download production artifacts. + +The extension appends that storage directory to the terminal `PATH` using the +original `copilot-cli` terminal PATH contributor, like the shipped shim's +folder, so a Copilot CLI already on `PATH` is found first. Copilot-created +terminals invoke the stored binary directly; ordinary terminals can resolve it +by typing `copilot`. +The `chat.copilotCliCommand.enabled` setting and policy still control the +contribution and invocation. Without a configured/usable shim, or when +disabled, terminals use the ordinary `copilot` PATH fallback. + +Deployment replaces the old Unix launcher and removes only the obsolete +owned script files, not the storage directory or unrelated files. It compares +contents so rebuilding without changing the package version refreshes the +copy. Publication uses a same-directory staging file, executable permissions +on Unix, and rename/rollback on Windows to preserve running sessions. +An exclusive `.publish.lock` serializes extension hosts. A bounded wait +reports a stale lock rather than deleting a lock another host could own; after +verifying its recorded process has exited, the user can remove it and retry. +Locked retired Windows copies are retained for later cleanup. + +Failures are logged explicitly. A previously published native copy may remain +usable after a failed refresh, but the extension does not advertise a folder +with surviving legacy wrappers that could shadow the native executable. +The terminal link provider and Copilot session data are unchanged. + +### Ownership + +VS Code owns only the shim, the launcher that gets Copilot CLI onto the device. +Everything else belongs to Copilot CLI and the method that installed it: the +CLI's files, its `PATH` entry, its updates, its uninstallation, and its data. +The shim installs Copilot CLI only when there is none, never updates it, and +uninstalling VS Code never removes it or its data (`~/.copilot`). + +### `copilot` on PATH on Windows + +Setup decides whether the shim folder is on `PATH` (the user `PATH` for the user +installer, the machine `PATH` for the system installer), in this order: + +1. The `CopilotCliCommand` policy set to `0` disables the command: no page, no + shim, and setup removes the `PATH` entry it added. `1` means the same as not + set. +2. An explicit choice wins and is remembered for later updates: the installer + page (Install now, install on first use, or don't add) or the + `/copilotcli=install|onfirstuse|none` switch. Install now adds the shim + folder too, whether or not Copilot CLI installs: Copilot CLI's installer adds + its own folder to `PATH`, and the shim keeps `copilot` working, offering to + install Copilot CLI again, if Copilot CLI is uninstalled later. +3. Otherwise `copilot` follows `code`: when the Add to PATH task is selected, + the shim folder is added too. This applies to silent installs, silent + reinstalls, background updates, and interactive installs that skip the + page, including once for existing installs that predate the shim. The + result is remembered like a choice. + +Background updates never download Copilot CLI; only Install now on the page or +`/copilotcli=install` in the user installer does. VS Code has no commands or +prompt of its own for this; the Copilot extension adds the shim to integrated +terminals on every platform. + +### Publishing the shim on Windows + +Setup publishes the shim itself, because `inno_updater` handles only the files +directly in `bin` and never enters its subfolders. It follows `inno_updater`'s +`perform_three_way_rename`, `find_available_old_path`, `util::retry`, and +cleanup (microsoft/inno-updater `src/main.rs`), so a Copilot session running +the shim never makes an install or update fail: + +1. Delete `old_*` copies from earlier updates and any leftover + `new_copilot.exe`. A copy that a session still runs is locked and is skipped + until a later install or update. +2. Keep the published shim when its file version equals the new shim's. Signing + changes a build's bytes, so the embedded version (see + [Cargo package requirements](#cargo-package-requirements)), not a hash, + decides whether the shim changed. +3. Copy the new shim to `new_copilot.exe`. +4. Rename `copilot.exe` to `old_copilot.exe`, or to `old_1_copilot.exe`, + `old_2_copilot.exe`, and so on when an older session still holds that name. + A running shim can be renamed, and its session keeps running. +5. Rename `new_copilot.exe` to `copilot.exe`. If that fails, rename the old copy + back, so a failed update keeps the previous shim. +6. Delete the old copy unless a session still runs it. + +Each copy and rename is retried up to 11 times, waiting `attemptยฒ ร— 50` ms after +each failure, to ride out transient locks such as antivirus scans. + +Uninstalling VS Code deletes `bin`, including the shim folder. A shim that a +Copilot session is still running, and any `old_*` copies it holds, stay behind +until they're deleted manually. + +## Out of scope + +The implementation does not: + +- register a VS Code terminal profile. +- replace `CopilotCLITerminalLinkProvider`. +- modify the existing `cli/` Cargo package. +- add the shim to `PATH` outside integrated terminals on macOS and Linux. +- ship the shim in the web server builds (`vscode-server-*-web`). + +## Normative language + +`MUST`, `MUST NOT`, `SHOULD`, and `MAY` are normative. + +## Required runtime flow + +One invocation follows this sequence: + +1. Parse the leading `--vscode-shim` options. A setup command runs and exits + without the rest of this flow; `clear` and `verbose` modifiers are consumed. +2. Resolve and identify the running shim executable. +3. Discover eligible Copilot CLI candidates from the current `PATH`. +4. Reject the running shim, other Rust-shim copies, and legacy VS Code shims. +5. Select the first eligible candidate whose interpreter is available, using + the deterministic platform rules in this document. Selection MUST NOT run + any candidate. +6. On supported Unix official-script targets, if PATH has no usable candidate, + inspect the known installation location below. If no usable candidate + exists, offer installation on a supported installer target; otherwise + report the manual-install or upstream-support limitation. +7. After a successful install, discard all prior discovery results and restart + discovery from the current process environment, followed by the verified + official-script location when that script was the successful installer. +8. Launch the selected candidate, forwarding the original arguments and + terminal I/O. +9. Propagate the real CLI's exit result. + +Installation causes a logical recursive re-entry into discovery. Implementation +with a loop or state machine is preferred over unbounded call stack recursion. + +The shim adds as little startup time as possible. It doesn't check the CLI's +version, and it runs PowerShell only to find a host for a `.ps1` candidate. + +## Argument handling + +### `--vscode-shim` options + +VS Code passes options to the shim through a reserved prefix, so the shim never +consumes an argument meant for the Copilot CLI: + +```text +copilot [--vscode-shim ]... [--] [copilot arguments...] +copilot [--vscode-shim verbose]... --vscode-shim [command options...] +``` + +- Only leading `--vscode-shim ` pairs are options. Parsing stops at the + first other argument, and a later `--vscode-shim` is forwarded. +- Modifiers are removed and the remaining arguments are forwarded. A `--` + directly after one or more modifiers ends the prefix and is removed; a `--` + that is the first argument belongs to the Copilot CLI. +- Commands may be preceded only by `verbose`. They never launch the Copilot CLI + (see [Setup commands](#setup-commands)). +- A missing or unknown option name, a command combined with `clear`, or + malformed command options exit with code `2` without launching the Copilot + CLI. They print a diagnostic only in verbose mode, when `verbose` preceded + the error or `VSCODE_COPILOT_SHIM_VERBOSE` is set. + +The modifiers are: + +- `clear`: clears the current terminal before producing discovery, + installation, update, or CLI output. When stdout is not attached to a + terminal, clearing MUST be a no-op; the shim MUST NOT emit raw terminal-clear + escape sequences into redirected output. +- `verbose`: enables shim-owned diagnostics for this invocation. Without it, + `runtime.write_diagnostic` output MUST be suppressed. It does not suppress, + capture, or alter prompts, installer status, installer subprocess output, the + real Copilot CLI's stdout and stderr, or the messages described under + [Messages and diagnostics](#messages-and-diagnostics). Setting the + environment variable `VSCODE_COPILOT_SHIM_VERBOSE` to a value other than + empty or `0` has the same effect, for scripts and other places where the + arguments can't be changed. + +`--clear` has no meaning to the shim and is forwarded like any other argument. + +### Forwarding + +All remaining arguments MUST be preserved as operating-system-native argument +strings, subject to the explicitly accepted Windows PowerShell 5.1 compatibility +exception below. The implementation MUST NOT use lossy UTF-8 conversion for +forwarded arguments or paths. + +Tests must cover: + +- no arguments; +- empty arguments; +- spaces; +- single and double quotes; +- Unicode; +- trailing backslashes; +- shell metacharacters; and +- multiple arguments with the same value. + +## Terminal I/O + +The final Copilot CLI process MUST inherit the shim's stdin, stdout, and stderr. +Interactive Copilot prompts must behave as if the real CLI had been invoked +directly. + +Installer processes that may prompt the user MUST also inherit stdin, stdout, +and stderr. Output MUST remain visible in the current terminal. + +PowerShell host version probes are the exception: they capture stdout for +parsing and stderr for verbose diagnostics, within the limits specified below. +Their stdin MUST be disconnected from interactive input. + +## PATH handling + +### Native strings + +PATH entries and candidate paths MUST be handled as native OS strings: + +- `OsString`/`Path` semantics on Unix, including non-UTF-8 paths. +- Case-insensitive filename matching on Windows. + +### PATH entry rules + +The implementation MUST: + +- preserve PATH directory order; +- treat an empty PATH component as the current working directory where that is + the platform's command-resolution behavior; +- resolve relative PATH components against the current working directory, which + is looked up only when such a component exists; if it can't be, those + components are skipped; +- tolerate duplicate directories; +- skip entries that are not directories; +- skip a directory named `copilot` rather than treating it as an executable; +- skip broken symlinks and unreadable candidates; and +- treat a missing or empty `PATH` as no discovered candidate. + +Candidate deduplication MUST NOT reorder the search. The first occurrence keeps +its position. + +## Candidate discovery + +### macOS, Linux, and Alpine + +For each PATH directory in order, inspect: + +```text +copilot +``` + +An eligible candidate MUST resolve to a regular file that the current user can +execute, checked with the effective user and group IDs like a shell's `PATH` +search; an execute bit only for another user or group doesn't count. A symlink +is eligible only when its final target meets those requirements. + +On macOS x64/arm64 and GNU Linux x64/arm64, when PATH discovery finds no +usable CLI, also inspect the official script's installation location: + +1. A nonempty `PREFIX` selects `$PREFIX/bin/copilot`. +2. Otherwise effective UID zero selects `/usr/local/bin/copilot`. +3. Otherwise a nonempty `HOME` selects `$HOME/.local/bin/copilot`. + +Resolve relative prefixes against the installer's working directory. Report +missing HOME/PREFIX or an unresolvable relative location explicitly rather +than guessing a home directory. Apply the same regular-file, executable, +identity, Rust-marker, and legacy-signature checks to this candidate. +This is a discovery fallback, not an environment change or a version probe. +An eligible CLI on the original PATH always wins, and subsequent invocations +with stale PATH use this fallback before offering installation again. +Homebrew installation remains unchanged; do not infer a Homebrew prefix from +these official-script defaults. ARMhf and musl support is unchanged. + +### Windows + +Windows discovery uses a shim-owned deterministic policy rather than inheriting +the differing rules of cmd.exe, PowerShell, Git Bash, or `CreateProcess`. + +For each PATH directory in order, inspect these names in this order: + +1. `copilot.exe` +2. `copilot.cmd` +3. `copilot.bat` +4. `copilot.ps1` + +Filename matching is case-insensitive. Each name is looked up directly; PATH +directories are not listed. + +`PATHEXT` MUST NOT reorder this list. `.com` files are outside the first +implementation. A bare extensionless `copilot` is not launched on Windows, but +it MAY be inspected as a legacy-wrapper fixture. + +After the `PATH` entries, discovery searches `%LOCALAPPDATA%\GitHubCopilotCLI`, +where GitHub's per-user MSI installs `copilot.exe`. A terminal whose `PATH` +predates the installation still finds that CLI, including right after the shim +installed it. + +### Windows execution adapters + +| Candidate | Execution | +|---|---| +| `.exe` | Execute the discovered path directly. | +| `.cmd` or `.bat` | Execute through `%ComSpec% /E:ON /V:OFF /D /S /C` with unquoted switches and explicit Windows command-line quoting of the script path and arguments. `%` in the script path and arguments is escaped, because cmd expands `%VARIABLE%` even inside quotes. `/E:ON` enables that escaping, and `/V:OFF` keeps `!` literal. | +| `.ps1` | Prefer an available PowerShell 7.3+ `pwsh.exe` host with modern native argument passing; otherwise use Windows PowerShell 5.1 (`powershell.exe`) with legacy forwarding. Use `-NoLogo`, `-NoProfile`, and an execution-policy bypass where supported. Do not use `-NonInteractive` for final CLI execution. | + +If the required interpreter is unavailable, the candidate is unusable and +discovery continues with the next candidate. The shim locates PowerShell, +which means running each host to check its version, only when it reaches a +`.ps1` candidate; `.exe`, `.cmd`, and `.bat` candidates never start PowerShell. + +Except for the Windows PowerShell 5.1 compatibility exception, the interpreter +invocation MUST preserve empty arguments, quotes, Unicode, trailing +backslashes, and shell metacharacters. It MUST NOT build an unescaped command +by joining arguments with spaces. + +### PowerShell host compatibility + +Both host paths are supported: + +- PowerShell 7.3 or later uses modern native argument passing. +- When that host is unavailable, Windows PowerShell 5.1 uses the old shim's + legacy argument-forwarding approach. + +Windows PowerShell 5.1 can change empty arguments and embedded quotes when a +script forwards them to a native executable. This is an accepted compatibility +exception, not a claim of lossless forwarding on that host. Tests MUST document +the actual legacy behavior separately from modern-host argument preservation. +The shim does not guarantee that arbitrary third-party wrapper code preserves +arguments internally. + +Lack of PowerShell 7.3+ alone MUST NOT exclude a `.ps1` candidate when the 5.1 +fallback is available. If neither host is available, continue candidate +discovery. The shim does not install PowerShell automatically. + +Discovery, prompting, inherited terminal I/O, and child exit handling remain +required under both host modes. + +## Self and recursion exclusion + +### Current executable + +Resolving the current executable is mandatory. If the current executable cannot +be identified, the shim MUST print a message and stop with a nonzero exit +status. + +The implementation MUST obtain: + +- the original current-executable path; +- its canonical path; and +- its platform filesystem identity when supported. + +### Candidate identity checks + +Each candidate MUST be rejected when any of these are true: + +- its canonical path equals the current shim's canonical path; +- its available filesystem identity equals the current shim's identity; +- it contains the stable Rust-shim binary marker described below; or +- it matches a legacy-shim signature. + +Filesystem identity means: + +- device and inode on Unix; and +- volume and file ID on Windows. + +File identity is an additional check, not symlink resolution: it can identify +hard links whose canonical paths remain different. + +If the platform or filesystem explicitly does not support file IDs, the shim +MUST use canonical-path and Rust-shim marker checks instead. Canonicalization +and readable marker inspection remain required. + +An actual permission, I/O, or metadata error is not an unsupported-feature +fallback. Report the error and skip that candidate. Such an error identifying +the running executable is fatal. + +The originally discovered absolute path, not the canonical target path, is used +to launch a candidate that passes exclusion. This preserves legitimate symlink +invocation behavior. + +### Distinct copies of the Rust shim + +Path and file identity do not detect a byte-for-byte or version-different copy +of the shim. Every Rust shim build MUST therefore contain a stable binary +marker reserved for this component, for example: + +```text +VSCODE_COPILOT_RUST_SHIM_V1 +``` + +Native candidates whose size is at most 16 MiB MUST be inspected for this +marker using a bounded-memory streaming search. A candidate containing it is +another shim copy and MUST be skipped. A larger candidate is neither a shim copy +(about 1 MB) nor a legacy script shim, so it isn't read at all; this keeps +discovery from reading a large Copilot CLI executable (about 150 MB) on every +launch. The start of a candidate is read once for both checks. + +Recursion exclusion uses canonical paths, supported file identities, the binary +marker, and legacy-wrapper signatures. The shim MUST NOT set or require a +recursion-guard environment variable: legitimate child terminals and tools of +the real CLI must remain able to invoke the shim later. + +Tests MUST cover: + +- the same path; +- a symlink to the running shim; +- a hard link to the running shim; +- a copied shim; +- a different Rust-shim build containing the stable marker; +- a symlink loop; +- a broken symlink; and +- a rejected shim followed by a valid later candidate. + +## Legacy VS Code shim exclusion + +Legacy detection is content-based. File extension or parent-directory name +alone MUST NOT classify a candidate as legacy. + +Text inspection MUST: + +- inspect at most the first 128 KiB; +- accept UTF-8 with or without BOM, including a prefix that ends inside a + character; +- normalize LF and CRLF line endings; +- use exact case-sensitive markers for POSIX shell scripts; +- use ASCII-case-insensitive markers for Windows PowerShell/batch wrappers; +- treat text that isn't UTF-8, such as a `.cmd` file in the OEM code page or a + UTF-16 `.ps1` file, as not legacy, because the legacy shims were written as + UTF-8; and +- skip an unreadable candidate. + +### Required signatures + +A candidate is legacy only when every marker in one applicable row is present. + +| Legacy form | Required marker conjunction | +|---|---| +| macOS/Linux injected launcher | Starts with `#!/bin/sh`; contains `unset NODE_OPTIONS`; contains `ELECTRON_RUN_AS_NODE=1`; contains `copilotCLIShim.js`; contains `"$@"`. | +| Windows PowerShell bootstrapper | Contains `Windows GitHub Copilot CLI bootstrapper`; contains `function Find-RealCopilot`; contains `function Test-AndLaunchCopilot`; contains `$PackageName = "@github/copilot"`. | +| Windows batch/cmd wrapper | Starts with `@echo off`; contains `powershell`; contains `-ExecutionPolicy Bypass`; contains `-File`; references `copilot.ps1` or `copilotCLIShim.ps1`. | +| Windows Git Bash wrapper | Starts with `#!/bin/sh`; contains `exec`; references `copilot.bat`; contains `"$@"`. | + +The current VS Code integration generates `.ps1` and `.bat`, not `.cmd`. +A `.cmd` candidate is checked only to cover historical or renamed copies. + +Legitimate npm-generated `copilot.cmd` and `copilot.ps1` wrappers MUST remain +eligible when they do not satisfy a complete legacy signature. + +Positive fixtures for every legacy row and negative npm-wrapper fixtures are +required. + +## No Copilot CLI version check + +The shim MUST NOT run a candidate before launching it, and there is no minimum +Copilot CLI version. The Copilot CLI keeps itself up to date (`copilot update` +and automatic updates), and running `copilot --version` before every launch +cost 0.15 to 1 second, and several seconds the first time antivirus scanned a +new release of the roughly 150 MB executable. An old CLI is launched like any +other. + +## PowerShell host probes + +To choose a host for a `.ps1` candidate, the shim runs each `pwsh.exe`, and +then each `powershell.exe`, found on `PATH` with +`-NoLogo -NoProfile -NonInteractive -Command $PSVersionTable.PSVersion.ToString()` +and takes the first `major.minor.patch` in its output. It does this at most once +per invocation, and only when discovery reaches a `.ps1` candidate. + +Every PowerShell probe MUST have: + +- a 30-second timeout; +- a maximum of 256 KiB (262,144 bytes) of combined stdout and stderr; and +- null stdin, with no consumption of the user's terminal input. + +Capture must be bounded while the process runs, not truncated only after +unbounded buffering. If a probe times out or exceeds its output limit, +terminate its job, reap the child process owned by the shim, verify that no +descendant in the job remains, report the rejected host and reason, and try the +next host. + +Process creation and Job Object assignment MUST be race-free: create the probe +suspended and assign it to a kill-on-close Job Object before resuming it, or use +an equivalent mechanism that prevents descendants from escaping before +assignment. + +User cancellation stops the workflow rather than trying the next host. These +limits apply only to PowerShell probes. Interactive installers and the final CLI +have no corresponding duration or output limit. + +## Missing CLI prompt + +When discovery finds no usable CLI candidate on a supported automatic-install +target, display the installation documentation URL and prompt: + +```text +Install GitHub Copilot CLI? [y/N] +``` + +Only a response whose first non-whitespace character is `y` or `Y` is +affirmative, including the full-width `y` and `Y` that an East Asian IME types in +full-width mode; the ideographic space counts as whitespace. No response or EOF +means No. + +Declining installation exits successfully. +On ARMhf and Alpine/musl, report manual-install or upstream-support guidance and +exit nonzero instead of offering an unsupported automatic installation. + +Documentation URL: + +```text +https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli +``` + +### Prompts without a terminal + +Prompts are written to stderr, so they stay visible, and out of the output, when +stdout is redirected. The shim prompts only when stdin and stderr are both +terminals. Otherwise, as in scripts and CI, a missing CLI exits with `127`, the +code a shell uses for a command it can't find, on every target. It first prints +a one-line message with the documentation URL to stderr. + +A disabled `CopilotCliCommand` policy takes precedence (exit `10`). + +## Installation commands + +### Automatic-install support + +Shim build coverage is distinct from upstream installer coverage. Keep all nine +build targets, but use the following automatic-install policy: + +| Target | Installation | +|---|---| +| Windows x64/arm64 | GitHub's per-user MSI, downloaded and verified by the shim. | +| macOS x64/arm64 | Homebrew cask, then curl/wget official script. | +| Linux GNU x64/arm64 | curl/wget official script; do not attempt the macOS-only Homebrew cask. | +| Linux GNU armhf | Manual-install/upstream-support guidance; no automatic installer attempt. | +| Alpine/musl x64/arm64 | Manual-install guidance for an upstream musl release; no automatic installer attempt. | + +Existing CLI discovery and launching remain supported on every target. + +The current official script accepts x64/arm64 architectures and selects the +`copilot-linux-*` archives for Linux; it does not select the separately +published `copilot-linuxmusl-*` archives. ARMhf is not an accepted script +architecture. This phase does not add a custom archive installer to work around +those limitations. Musl users may obtain the appropriate executable from +. + +### Windows + +The shim installs GitHub's per-user MSI, published with `SHA256SUMS.txt` in the +github/copilot-cli releases. It installs without elevation to +`%LOCALAPPDATA%\GitHubCopilotCLI\copilot.exe` and appends that folder to the +user `PATH`. + +The shim installs only where there is no Copilot CLI and never runs the MSI to +update one. Copilot CLI updates itself (`copilot update` and automatic +updates), so the version in Installed apps can lag behind the running version. + +The shim runs ` --vscode-shim install --interactive` as a child attached +to the current terminal. That command, which also backs the installer's +install option, MUST: + +1. Report that Copilot CLI is already installed, without downloading anything, + when `%LOCALAPPDATA%\GitHubCopilotCLI\copilot.exe` exists. When Windows + Installer has Copilot CLI registered but that file is missing, report an + error that tells the user to uninstall it in Installed apps instead of + installing. `msi.dll` is loaded only for this check. +2. Resolve the latest release: send a `HEAD` request for + `https://github.com/github/copilot-cli/releases/latest` without following + redirects and take the tag from the `/releases/tag/` redirect location. +3. Download `SHA256SUMS.txt` (at most 64 KiB) and `copilot-x64.msi` or + `copilot-arm64.msi` for that tag, through WinHTTP with the system proxy + configuration. +4. Compare the MSI's SHA-256 with its entry in `SHA256SUMS.txt`. +5. Verify the MSI's Authenticode signature with `WinVerifyTrust` and require + the signer name `GitHub, Inc.`. Revocation is checked for every + certificate in the chain except the root, so a revoked certificate fails + verification. When the revocation server can't be reached, the Windows + Authenticode policy decides; by default it accepts the signature. +6. Run `%SystemRoot%\System32\msiexec.exe /i /qn /norestart /l*v + %TEMP%\vscode-copilot-cli-install.log` without elevation. Exit codes `0` and + `3010` mean success, and `1602` means the install was canceled. +7. Confirm that `%LOCALAPPDATA%\GitHubCopilotCLI\copilot.exe` exists. + +Ctrl+C stops the install at its next step: between download chunks, after +verification, or before `msiexec` starts. The temporary MSI is then removed. +A blocked network request ends when the connection fails or times out. Once +`msiexec` has started it can't be stopped safely, so it finishes and the result +is reported before the shim exits with the cancellation. The parent shim waits +for the install instead of ending it. + +WinHTTP errors are reported with WinHTTP's own message text, in the user's +display language. + +Progress goes to stderr. The MSI's `PATH` change isn't visible to processes +that are already running, so after a successful install the shim launches +`%LOCALAPPDATA%\GitHubCopilotCLI\copilot.exe` by its full path, and Windows +discovery searches that folder after the `PATH` entries. +`VSCODE_COPILOT_SHIM_RELEASES_URL` replaces the releases URL for tests; the +signer requirement still applies. Installation does not use PowerShell or +winget. + +A missing PowerShell 7 never blocks installation. Setup's page shows at most a +soft recommendation to install it. + +### macOS + +On macOS, installer attempts use this order: + +1. If `brew` exists, run: + + ```sh + brew install --cask copilot-cli + ``` + +2. If Homebrew is unavailable or fails without cancellation and `curl` exists, + download and run the official installer. +3. If the curl route is unavailable or fails without cancellation and `wget` + exists, download and run the official installer. +4. If none of Homebrew, curl, or wget exists, instruct the user to install one + of them and exit nonzero. +5. If one or more available installers fail, preserve their diagnostics, point + to the manual installation documentation, and exit nonzero. Do not tell the + user to install a tool that was already found. + +### Linux GNU x64/arm64 + +Try the curl installer route, followed by wget if curl is unavailable or the +attempt fails without cancellation. Homebrew casks are not supported on Linux, +so do not attempt `brew install --cask copilot-cli` there. + +If neither downloader exists, instruct the user to install curl or wget. If +available attempts fail, preserve their diagnostics and point to manual +installation documentation. Both outcomes exit nonzero. + +### Official installer URL + +The installer URL is: + +```text +https://gh.io/copilot-install +``` + +### Interactive curl/wget execution + +The shim MUST NOT execute `curl ... | bash` or `wget ... | bash` directly, +because that gives `bash` the script pipe as stdin instead of the user's +terminal. + +For curl or wget: + +1. Create a securely named temporary file. +2. Download the installer into that file. +3. Preserve downloader diagnostics in the current terminal. +4. Verify that `bash` is available. +5. Run `bash ` with inherited stdin, stdout, and stderr and + the installer-only PATH described below. +6. After the installer process exits and is reaped, remove the temporary file + on success, failure, and handled cancellation. + +Suggested downloader forms are semantically equivalent to: + +```sh +curl -fsSL https://gh.io/copilot-install -o +wget -O https://gh.io/copilot-install +``` + +If `bash` is unavailable on an automatic script-install target, report that +prerequisite and do not start another downloader route; it would require the +same missing interpreter. + +### Official installer PATH and shell-profile consent + +The official script tests `command -v copilot` after installation. A shim +visible to that command would suppress the missing-PATH notice and profile +prompt even when the newly installed CLI is not on PATH. + +Only the Bash command executing that script MUST receive a PATH with every +positively identified current, copied, aliased, and legacy shim directory +excluded. Preserve unrelated entries, order, and native strings; handle +relative/empty entries and aliases without substring matching. Reject an +entirely excluded PATH rather than accidentally searching the current +directory. Leave the parent environment, downloader commands, Homebrew, +Windows MSI, host probes, and final CLI environment unchanged. + +Do not add the predicted installation directory to this child PATH. The +official script must decide whether the real CLI is accessible and, when +needed, offer its own profile update. It selects the profile from SHELL and +existing configuration (for example `.profile`, `.bash_profile`, `.zprofile`, +or fish configuration); neither Rust nor VS Code duplicates this prompt or +edits/sources profiles itself. The directory persisted on consent is the real +CLI's installation directory, not extension global storage. + +Profile updates affect future shells that read that profile. They do not +change the running terminal's or shim's PATH. Declining the profile edit does +not cancel an accepted CLI installation. + +### Cancellation and temporary-file lifetime + +Handled cancellation during the bootstrap workflow MUST stop the operation. +It MUST NOT start a fallback installer, prompt again, or launch the real CLI. +In particular, Ctrl+C canceling an installer is not an ordinary failure that +permits the next installation attempt. + +Keep interactive installer children attached to the current terminal. Ctrl+C +reaches the installer too, and the installer decides how to stop; the shim +waits for it to exit rather than ending it, then cleans up its temporary script +file and reports the cancellation. Cleanup is guaranteed for normal exits, reported +failures, and handled cancellation, not SIGKILL, abrupt OS termination, +crashes, or power loss. + +## Post-install discovery + +An installer returning zero is not sufficient evidence that Copilot CLI is +ready. + +After a successful install, the shim MUST restart `PATH` discovery from the +beginning, reapply all self and legacy exclusions, and launch only a candidate +found by that second discovery. After an official-script install, the known +Unix location is inspected again after PATH, allowing immediate launch even +when PATH is unchanged and regardless of the user's profile-edit answer. +It MUST NOT launch a candidate accepted before the install without inspecting +it again. Windows retains its existing MSI-directory fallback. + +The flow MUST be bounded to one accepted install per shim invocation. If +installation returns success but neither PATH nor the applicable known +location contains an eligible CLI, explain this in normal terminal output, +instruct the user to source their profile, restart the terminal, or update +PATH, and exit nonzero without prompting again. A valid known-location +fallback also prevents reinstalling on later invocations with stale PATH. +If upstream changes its installation rules, failed verification must not be +reported as a successful launch. + +## Final launch + +The final process MUST: + +- use the exact discovered candidate and its required execution adapter; +- receive forwarded arguments according to the argument-preservation contract, + including the accepted Windows PowerShell 5.1 compatibility exception; +- inherit stdin, stdout, and stderr; +- inherit the current working directory; and +- receive the current environment without adding a recursion-guard variable. + +On Windows the shim MUST wait for the real CLI and report its result according +to the outcome table. On Unix the shim replaces itself with the real CLI +(`exec`), so the CLI's exit status, signals, and lifetime are the caller's +directly; a tool that ends `copilot` by its process ID ends the CLI. + +## Outcome and exit behavior + +| Outcome | Required shim result | +|---|---| +| Real CLI exits with a numeric code | Exit with the same code. | +| Real CLI terminates from a Unix signal | The shim was replaced by the CLI, so the caller sees the signal. | +| Real CLI or required interpreter cannot start | Print the failing program and OS error; exit `1`. | +| Current executable cannot be identified | Print a message; exit `1`. | +| Installer tools are missing | Print which tools to install and the documentation URL; exit `1`. | +| All installation attempts fail | Print that the install failed, the documentation URL, and how to turn on verbose mode; exit `1`. The Windows MSI install first prints its reason. Each attempt's result is a diagnostic. | +| Installer returns zero but re-discovery fails | Print actionable PATH/installation guidance; exit `1`. | +| The Unix installation location or installer-only PATH cannot be prepared | Print an actionable explanation; exit `1`. | +| Automatic installation is unsupported for the target | Print manual-install or upstream-support guidance; exit `1` without an installer attempt. | +| Bootstrap or installer operation is canceled | Wait for the installer to stop, without fallback, and clean up owned temporary files; exit `130`. | +| User declines installation | Exit `0` without launching. | +| Prompt receives EOF | Treat as No and exit `0`. | +| No terminal (stdin or stderr isn't a terminal) and no usable CLI | Print a message with the documentation URL; exit `127` without prompting. | +| A `--vscode-shim` option is unknown or malformed | Exit `2` without launching; print a diagnostic only in verbose mode. | +| Installation is needed, but the `CopilotCliCommand` policy is disabled | Print a message that names the policy; exit `10` without launching. | + +### Messages and diagnostics + +Shim output on stderr is either a message or a diagnostic: + +- A **message** explains why `copilot` stopped without running the Copilot + CLI, in one line, and is always printed. Every outcome above that ends the + command with a nonzero exit prints one, except a malformed `--vscode-shim` + option: only VS Code passes those options, and VS Code setup reads the + result files instead. +- A **diagnostic** is detail for troubleshooting, printed only in verbose mode: + discovery errors, skipped candidates and interpreters, PowerShell probes, + each installer attempt's result, and option errors. + +Prompts, installer progress, installer subprocess output, and the Copilot CLI's +own output are neither, and are always visible. + +## Enterprise policy + +The VS Code policy `CopilotCliCommand` controls the core setting +`chat.copilotCliCommand.enabled`. When it is disabled: + +- the shim still launches a Copilot CLI that is already installed, but never + installs one. It prints a message that names the policy and exits with `10`; +- `probe` reports `policy=disabled` and skips its network check, and `install` + reports the `policy` status; +- VS Code setup on Windows doesn't show its Copilot CLI page, doesn't publish + the shim, and removes the `PATH` entry it added; and +- the Copilot extension doesn't add the shim to integrated terminals, which run + `copilot` from `PATH` instead. + +On Windows the shim reads the policy from the registry: a `REG_DWORD` +`CopilotCliCommand` value of `0` under +`SOFTWARE\Policies\Microsoft\` disables it. Quality keys are +`VSCode`, `VSCodeInsiders`, `VSCodeExploration`, and `CodeOSS`. For each +quality, the `HKLM` value takes precedence over the `HKCU` value, as in VS Code. +A disabled policy in any quality wins, because the shim of any installed quality +can be the one on `PATH`; VS Code setup applies the same rule. + +On macOS and Linux the shim doesn't read the policy. It is reachable only from +integrated terminals there, and the Copilot extension applies the policy +through the setting. + +`CopilotCliCommand` is the only local control over the shim and installation. +It doesn't turn Copilot CLI itself on or off: that's a GitHub organization or +enterprise policy, which the CLI applies after sign-in. + +## Setup commands + +The Windows installer runs these commands; they never launch the Copilot CLI. +Result files are INI files encoded as UTF-16LE with a byte order mark, so +`GetPrivateProfileString` reads them on every Windows version. Values are +single-line, and each file is replaced atomically. + +### `probe` + +```text +copilot --vscode-shim probe --result-file [--scope user|machine] + [--no-network] [--timeout-ms ] +``` + +`probe` finds the first Copilot CLI candidate without running any candidate. +The `user` scope (default) searches the process `PATH`, the machine and user +`PATH` stored in the registry (setup may have inherited a stale `PATH`), and the +MSI folder. The `machine` scope searches only the registry's machine `PATH`. It +also reports whether PowerShell 7 is available. + +Unless `--no-network` is given or a CLI was found, it resolves the latest +release and sends a `HEAD` request for the MSI for the current architecture, +within the timeout (default 5 seconds, at most 60 seconds). The timeout covers +the whole check, including proxy discovery, so setup gets the local result even +when the network hangs. It writes a `[probe]` section with `protocol`, +`policy` (`allowed` or `disabled`), `cliFound`, `cliPath` (the first Copilot CLI +found, which setup shows when it skips its page), `pwshFound`, +`downloadAvailable`, `downloadSize`, and `reason`, and exits `0` when the file +was written. + +### `install` + +```text +copilot --vscode-shim install --interactive +copilot --vscode-shim install --non-interactive --consent=installer + --result-file [--progress-file ] [--cancel-file ] + [--running-mutex ] +``` + +`install` runs the Windows installation described under +[Installation commands](#installation-commands). Other +platforms report that it is unsupported. + +`--non-interactive` requires `--consent=installer`, the consent that setup +collected on its page or command line. In that mode the command: + +- reports `alreadyInstalled` without downloading when discovery finds a CLI, + searching the same directories as the `user` scope of `probe`; +- rewrites a `[progress]` section with `phase`, `current`, `total`, and + `heartbeat` while it works; +- stops at its next step, before `msiexec` starts, when the cancel file exists; +- holds the named mutex while it runs; and +- writes a `[result]` section with `status`, `exitCode` (the msiexec exit + code), `cliPath`, `cliVersion`, `log`, and `reason`. + +| Status | Exit code | +|---|---| +| `installed`, `alreadyInstalled` | `0` | +| `policy` | `10` | +| `network` | `20` | +| `verification` | `30` | +| `msiexec` | `40` | +| `cancelled` | `50` | +| `unsupported`, `error` | `1` | + +## Cargo package requirements + +`copilot_shim/` is an independent Cargo package with: + +- its own `Cargo.toml`; +- its own committed `Cargo.lock`; +- a single binary target named `copilot`; +- release LTO and symbol-stripping settings appropriate for a small + bootstrapper; +- the same Rust 1.88 toolchain currently installed by the CLI build jobs; and +- Microsoft copyright headers in Rust source files. + +CI builds MUST use `--locked`. + +On Windows, `build.rs` embeds a version resource whose file and product +versions are the package version. VS Code setup replaces a published shim only +when this version differs, so the package version MUST be bumped whenever the +shim changes. It MUST be `major.minor.patch` without a pre-release or build +suffix, which a file version can't carry; `build.rs` rejects one. `build.rs` +writes the resource in the `.res` format, which the +MSVC linker accepts directly, so the build needs no resource compiler or extra +dependencies. + +Dependencies must be minimal and justified. The Windows MSI installation uses +WinHTTP, CNG, and WinTrust through `windows-sys` rather than an HTTP or +cryptography crate. macOS and Linux installation rely on curl or wget. + +Third-party notice and SBOM inputs must be updated for dependencies actually +added by the package. + +## Build target matrix + +The shim must match the complete current VS Code CLI target matrix. + +| Product | Rust target | Archive | +|---|---|---| +| macOS x64 | `x86_64-apple-darwin` | zip | +| macOS arm64 | `aarch64-apple-darwin` | zip | +| Windows x64 | `x86_64-pc-windows-msvc` | zip | +| Windows arm64 | `aarch64-pc-windows-msvc` | zip | +| Linux GNU x64 | `x86_64-unknown-linux-gnu` | tar.gz | +| Linux GNU arm64 | `aarch64-unknown-linux-gnu` | tar.gz | +| Linux GNU armhf | `armv7-unknown-linux-gnueabihf` | tar.gz | +| Alpine x64 | `x86_64-unknown-linux-musl` | tar.gz | +| Alpine arm64 | `aarch64-unknown-linux-musl` | tar.gz | + +### ABI and linker requirements + +- Linux GNU artifacts MUST retain the existing CLI requirement of no GLIBC + dependency newer than 2.28. +- Alpine artifacts MUST be musl builds. +- Windows artifacts MUST use static CRT linkage and the same applicable + control-flow protection flags as the existing CLI: + - x64: `/guard:cf` and `/CETCOMPAT`; + - arm64: `/guard:cf` with the repository's current arm64 CET setting. +- Cross-target sysroots, linkers, and build hosts SHOULD follow the existing CLI + templates unless the smaller package proves they are unnecessary. + +## Build pipeline architecture + +The shim MUST NOT be added as another binary inside the existing `cli/` crate +or CLI artifact. It is built by the existing VS Code CLI jobs +(`product-build--cli.yml`), after the CLI: + +- `copilot-shim-compile.yml` builds and stages one target. It reuses the CLI + job's Rust toolchain, sccache server and cache, and Linux sysroots. +- `copilot-shim-quality.yml` runs in the Windows, macOS, and Linux x64 CLI jobs. +- Each CLI job publishes the unsigned shim as + `unsigned_copilot_shim__`. There are no separate shim jobs, + shim signing jobs, or standalone signed shim artifacts. + +The ADO CI/check-only runs of the CLI jobs build, lint, and test the shim +without publishing it. + +Before packaging, CI MUST run: + +- `cargo test --locked` on a supported host; +- `cargo clippy --all-targets --locked -- -D warnings`; and +- formatting verification. + +The product build places the unsigned shim in `bin/copilot-shim/` of the client +and server builds before it signs and packages them: + +| Platform | Where the shim is added | +|---|---| +| Windows | The sign job adds it to the client and server before `codesign.ts` signs every `.exe`. The setup packages include it when it is present (the `CopilotShim` Inno Setup definition). | +| macOS | The compile job adds it to the client app and the server after the CLI. | +| Linux GNU | The compile job adds it to the client after the CLI, so the deb, rpm, and snap packages include it, and to the server before archiving it. | +| Alpine | The Alpine job adds it to the server before archiving it. | + +## Artifact names and contents + +Shim artifacts are intermediate pipeline artifacts. Their names MUST NOT start +with `vscode_`, which the product release publisher consumes: + +```text +unsigned_copilot_shim__ +``` + +Platforms are `darwin`, `win32`, `linux`, and `alpine`. Each archive (zip on +Windows and macOS, tar.gz on Linux and Alpine) contains exactly one executable +at its root: + +- `copilot.exe` on Windows. +- `copilot` on macOS, Linux GNU, and Alpine. + +Unix archive validation MUST verify the executable bit. Windows PDBs may be +published to the symbol service but MUST NOT be included in the archive. + +## Signing and notarization + +The shim is signed with the build that contains it, like the VS Code CLI: + +- Windows: the ESRP `sign-windows` pass of `codesign.ts` over the client and + server builds; the setup packages are built from the signed client. +- macOS: hardened-runtime signing, ESRP signing, and notarization of the client + app; `sign-server.ts` for the server. The universal app merges the x64 and + arm64 shims. +- Linux GNU and Alpine: unsigned, like the CLI. The deb and rpm packages are + signed. + +## Required automated tests + +Tests must be network-free and must not invoke real package managers or modify +the developer machine. Candidate execution, prompts, installers, filesystem +identity, and process launching must have injectable seams or controlled test +fixtures. + +### Discovery and exclusion + +- PATH order across multiple directories. +- Empty, relative, duplicate, missing, and non-UTF-8 PATH entries. +- Non-directory entries and non-executable Unix files. +- Same executable path. +- Symlink, hard link, copied shim, and stable-marker shim. +- Explicitly unsupported file-ID operations use canonical paths and marker + checks; permission/I/O errors do not silently enable that fallback. +- Broken symlink and symlink loop. +- Valid later candidate after rejected candidates. +- Every positive legacy-wrapper signature, including a UTF-8 prefix that ends + inside a character; scripts that aren't UTF-8 remain candidates. +- Legitimate npm `.cmd` and `.ps1` negative fixtures. +- Windows extension ordering and case-insensitive names. + +### Candidate selection and PowerShell hosts + +- The first usable candidate is launched without being run first. +- A candidate whose interpreter is unavailable is skipped. +- PowerShell is located only for a `.ps1` candidate, at most once per + invocation. +- PowerShell host versions: the first `major.minor.patch`, with prefixes, + suffixes, fourth components, and numeric overflow. +- PowerShell probe timeout and combined-output limits, tested with injected + budgets and assertions for the production values of 30 seconds and 262,144 + bytes. +- Probe Job Object termination, owned-child reaping, and + no-surviving-descendant verification before trying the next host. +- Race-free Job Object assignment before a probe can create descendants. +- Probe cancellation stops the workflow. + +### Prompts and install flow + +- Default No for blank input. +- `y` and `Y`, including their full-width forms after an ideographic space. +- EOF. +- Without a terminal: no prompt, and exit `127` for a missing CLI. +- User declines install. +- Each automatic-install platform policy: Windows MSI, macOS + Homebrew/script, and GNU Linux x64/arm64 script. +- ARMhf and musl missing-CLI cases show guidance without running an installer; + existing CLI candidates remain launchable. +- Linux GNU x64/arm64 does not attempt a Homebrew cask. +- Fallback after an available installer fails without cancellation. +- Missing bash, brew, curl, and wget. +- Installers receive inherited terminal streams through the Windows MSI install + command, brew, curl/bash, and wget/bash. +- Successful install followed by re-discovery from the first `PATH` entry and + launch of the newly found candidate. +- Windows discovery searches `%LOCALAPPDATA%\GitHubCopilotCLI` after `PATH`, so + a CLI that was just installed is found even though the terminal's `PATH` + predates it. +- Successful install not visible in current PATH. +- Official-script default/root/custom/relative PREFIX resolution, invalid + fallback files, and original-PATH precedence. +- Installer-only exclusion of all shim entries without affecting downloader, + Homebrew, MSI, ordinary discovery, or final CLI environments. +- Isolated Unix PTY installation with both profile-consent answers, actual + `command -v` checking, immediate verified launch, and no reinstall on a + second invocation with unchanged PATH. Use temporary HOME/PREFIX and fake + installers; never modify the developer's real profile. +- No repeated install prompt in one invocation. +- Ctrl+C/handled termination during installation cancels without fallback, + another prompt, or final CLI launch; the shim waits for the installer to stop. +- A PATH entry that can't be inspected doesn't stop tool discovery. +- Temporary-script cleanup occurs after child reaping on success, failure, and + handled cancellation. + +### Options and setup commands + +- Leading `--vscode-shim clear` and `--vscode-shim verbose` modifiers are + removed, an optional `--` after them ends the prefix, and everything else, + including a later `--vscode-shim` or a bare `--clear`, is forwarded. +- Diagnostics are absent by default and present when `--vscode-shim verbose` + precedes the operation or `VSCODE_COPILOT_SHIM_VERBOSE` is set. Verbose mode + is propagated to a nested shim that runs the interactive MSI installation. +- Messages for the outcomes that end `copilot` without running the CLI are + printed without verbose mode. +- Commands parse their options, never launch the CLI, and reject unknown + options, missing values, and commands combined with `clear` with exit code + `2`. A command may be preceded by `verbose`. +- Release tags are taken only from `/releases/tag/` redirects. +- Checksums match the exact asset name, and MSI assets follow the architecture. +- Result files are UTF-16LE single-line INI files replaced atomically. +- Install statuses map to the documented exit codes. +- Unsigned files have no Authenticode signer. +- WinHTTP errors have readable messages. +- The MSI registration can be queried. +- With the `CopilotCliCommand` policy disabled, a missing CLI produces the + policy message and exits with code `10` without a prompt; an installed CLI + still launches. +- Policy registry values are read only from `REG_DWORD` values. + +### Launch behavior + +- Exact argument preservation for the Unix, native Windows, cmd/batch, and + modern PowerShell execution adapters. +- PowerShell 7.3+ selection when available and Windows PowerShell 5.1 legacy + fallback otherwise. +- Explicit legacy-host fixtures document the accepted empty-argument and + embedded-quote differences instead of claiming lossless forwarding. +- Leading `--vscode-shim clear` and `--vscode-shim verbose` modifiers consumed + and later occurrences after the prefix forwarded. +- No terminal-clear bytes in redirected output. +- Inherited stdin, stdout, stderr, environment, and working directory, + parameterized across the Unix launch path and Windows `.exe`, `.cmd`, `.bat`, + and `.ps1` adapters. +- Numeric nonzero child exit propagation, parameterized across the Unix launch + path and every Windows adapter. +- On Unix the shim replaces itself with the CLI. +- Spawn and interpreter failures print a message without verbose mode. +- Final CLI/installers receive no shim-owned recursion-guard variable, so + legitimate descendant invocations of the shim are not blocked. + +### Build and artifact behavior + +- Every target builds from the committed lockfile. +- Host tests, clippy, and formatting pass. +- No Linux GNU referenced GLIBC symbol version exceeds 2.28. +- Windows control-flow/static-CRT settings are present. +- The Windows binary's file version is the package version. +- Each archive has the expected name and one root executable. +- Unix executable mode is set. +- Shim artifact names do not reuse CLI artifact names and do not begin with + `vscode_`, so they remain outside the product release publisher. +- Client and server builds contain the shim at `bin/copilot-shim/`, signed + where the build is signed. + +## Acceptance criteria + +The work is complete only when: + +1. All target artifacts are produced under separate shim names. +2. Client and server builds contain the shim at `bin/copilot-shim/`, and the + macOS and Windows builds sign it. +3. Every archive contains the correctly named executable. +4. A manually invoked shim launches the first valid non-shim Copilot CLI in the + defined search order. +5. Self aliases, copied Rust shims, and all specified legacy shims are skipped. +6. A missing CLI follows the default-No installation flow on supported + automatic-install targets; other targets receive actionable guidance. +7. Installer prompts are usable in the current terminal. +8. A successful install is followed by fresh PATH discovery. +9. Selecting a candidate never runs it, and PowerShell runs only when a `.ps1` + candidate needs a host. +10. The real CLI's arguments follow the preservation contract and its accepted + Windows PowerShell 5.1 exception; terminal I/O and exit results are preserved. +11. The setup commands follow the documented result-file contract. +12. All required automated tests and artifact validations pass. +13. PowerShell probes enforce the approved time/output bounds and clean up + their processes on failure. +14. Handled cancellation stops bootstrap installation without fallback and + cleans temporary files after child reaping. +15. Initial and post-install discovery both respect first-usable-candidate PATH + precedence. +16. With a configured local build, integrated terminals use a safely published + global-storage native shim without deleting that directory or unrelated + content. Unconfigured paths and unavailable platforms are not reported + as validated live deployment. +17. Shim entries do not suppress the official installer's profile prompt, and + a verified official-script installation launches immediately and on later + stale-PATH invocations without another installation. + +## Source references + +- Terminal integration: + [`copilotCLITerminalIntegration.ts`](../extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLITerminalIntegration.ts) +- Windows installer integration: [`copilot.iss`](../build/win32/copilot.iss) +- Existing Rust CLI package: [`cli/`](../cli/) +- Shim compilation and quality checks: + [`copilot-shim-compile.yml`](../build/azure-pipelines/copilot-shim/copilot-shim-compile.yml), + [`copilot-shim-quality.yml`](../build/azure-pipelines/copilot-shim/copilot-shim-quality.yml) +- Shared CLI compilation: + [`cli-compile.yml`](../build/azure-pipelines/cli/cli-compile.yml) +- Windows CLI build: + [`product-build-win32-cli.yml`](../build/azure-pipelines/win32/product-build-win32-cli.yml) +- Windows signing and packaging: + [`product-build-win32-sign.yml`](../build/azure-pipelines/win32/steps/product-build-win32-sign.yml) +- macOS CLI build and signing: + [`product-build-darwin-cli.yml`](../build/azure-pipelines/darwin/product-build-darwin-cli.yml) +- Linux GNU CLI build: + [`product-build-linux-cli.yml`](../build/azure-pipelines/linux/product-build-linux-cli.yml) +- Alpine CLI build: + [`product-build-alpine-cli.yml`](../build/azure-pipelines/alpine/product-build-alpine-cli.yml) +- Product pipeline wiring: + [`product-build.yml`](../build/azure-pipelines/product-build.yml) +- Product release artifact selection: + [`publish.ts`](../build/azure-pipelines/common/publish.ts) +- GitHub Copilot CLI releases, including the MSI and `SHA256SUMS.txt`: + +- GitHub installation guidance: + +- Official installer implementation: + +- Homebrew cask platform metadata: + +- PowerShell native argument behavior: + diff --git a/copilot_shim/SHIM_POST_INSTALL.md b/copilot_shim/SHIM_POST_INSTALL.md new file mode 100644 index 00000000000000..35e7df290df656 --- /dev/null +++ b/copilot_shim/SHIM_POST_INSTALL.md @@ -0,0 +1,215 @@ +# Copilot CLI shim: behavior after installing on macOS and Linux + +## Scope + +This document explains how GitHub's official install script decides whether to +offer a `PATH` update, why the legacy extension shim and an earlier Rust shim +got different results from it, and what the Rust shim does now, during and +after an install through that script. + +It is based on: + +- a WSL terminal transcript captured on 2026-09-30; +- the last legacy TypeScript shim, which + `git show 315c844ac15^:extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIShim.ts` + shows; +- the `install.sh` in `github/copilot-cli` on `main` on 2026-09-30; and +- the current Rust shim in this directory. + +[`REQUIREMENTS.md`](./REQUIREMENTS.md) is the specification. This document +records the reasoning behind its installer rules. + +## Terminology + +- **Legacy shim**: the `copilot` launcher and `copilotCLIShim.js` that earlier + versions of the Copilot extension wrote to `/copilotCli` and + put at the front of the integrated terminal `PATH`. +- **Rust shim**: the `copilot_shim` package in this directory. +- **Official script**: `https://gh.io/copilot-install`, which resolves to + `install.sh` in `github/copilot-cli`. +- **Installed CLI**: the Copilot CLI the official script installs; for a + non-root user without `PREFIX`, `$HOME/.local/bin/copilot`. + +## What was observed + +The transcript came from a WSL terminal whose `PATH` still contained the legacy +shim's directory. The Rust shim was the build at commit `8caae699356`, before +the changes described under [Current Rust shim behavior](#current-rust-shim-behavior). + +1. Running the Rust shim with `cargo run` showed its install prompt. After `y`, + the official script installed `$HOME/.local/bin/copilot` and printed only + `Installation complete! Run 'copilot help' to get started.` A second run + installed again and printed the same line. +2. Typing `copilot` then ran the legacy shim, which showed its own prompt and + ran the same script through `curl | bash`. This time the script printed: + + ```text + Notice: $HOME/.local/bin is not in your PATH + + Would you like to add it to $HOME/.profile? [y/N] + ``` + +The transcript ends at that prompt, so the answer and what followed weren't +observed. + +## Why only the legacy shim got the profile prompt + +The official script installs the CLI to `$PREFIX/bin` (by default +`$HOME/.local/bin` for a non-root user) and then checks: + +```sh +if ! command -v copilot >/dev/null 2>&1; then + # Report the missing PATH entry and offer to update a shell profile. +else + echo "Installation complete! Run 'copilot help' to get started." +fi +``` + +The check passes for any command named `copilot`. It doesn't check that it +found the CLI it just installed. + +- The legacy shim ran the script with its own directory removed from the + child's `PATH`. Nothing else named `copilot` was on that `PATH`, so the check + failed and the script offered to update the profile. +- The Rust shim at `8caae699356` ran the script with its own `PATH` unchanged. + That `PATH` still contained the legacy shim's directory, so `command -v + copilot` found the legacy shim, and the script skipped the notice and the + prompt. The CLI was installed, but nothing put its directory on `PATH`. + +Removing only the running shim's directory would not have been enough: the +command the script found was a different shim. + +## How the official script selects a profile + +When `command -v copilot` fails, the script picks a profile from `$SHELL`: + +| Shell | Profile | +|---|---| +| zsh | `${ZDOTDIR:-$HOME}/.zprofile` | +| bash with `.bash_profile` | `$HOME/.bash_profile` | +| bash with `.bash_login` but no `.bash_profile` | `$HOME/.bash_login` | +| other bash | `$HOME/.profile` | +| fish | `${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/copilot.fish` | +| anything else | `$HOME/.profile` | + +It asks `Would you like to add it to ? [y/N]`, reading the answer from +the terminal (`/dev/tty`), and on `y` appends +`export PATH="$INSTALL_DIR:$PATH"` (fish syntax for fish). A profile change +affects shells started later; it doesn't change the `PATH` of the running +terminal or the shim. + +## Current Rust shim behavior + +### Running the official script + +On macOS (after Homebrew, when it's available) and on GNU Linux x64 and arm64, +the Rust shim downloads the official script to a temporary file with curl or +wget and runs `bash ` attached to the terminal, so the script's prompts +work. It never pipes the script into `bash`. + +The `bash` command, and only that command, gets a filtered `PATH` +([`installer_path`](./src/candidate.rs)): + +1. The shim runs discovery and collects the directory of every candidate it + rejected as the running shim (including links to it), another copy of the + Rust shim (by its marker), or a legacy shim. +2. It removes those directories from `PATH`, keeping every other entry, their + order, and their native bytes. Relative and empty entries are resolved + against the current directory before comparing. +3. If no entry is left, the install stops with an explanation instead of + running the script with an empty `PATH`. + +[`CommandSpec`](./src/model.rs) carries the override, and +[`native_command`](./src/platform/mod.rs) applies it. The downloader, Homebrew, +the Windows MSI install, PowerShell probes, and the final CLI keep the +unchanged environment, and the shim's own environment isn't modified. + +The script's `command -v copilot` now fails unless a real Copilot CLI is on +`PATH`, so it shows the missing-`PATH` notice and offers to update the profile. +Declining the profile change doesn't cancel the install. + +### Finding the CLI after the install + +The shim doesn't add the install directory to that `PATH`; the script decides +whether the CLI is reachable. Instead, after a successful install through the +script, the shim runs discovery again: + +1. `PATH`, as before; an eligible CLI there always wins. +2. Then the script's install directory + ([`official_install_directory`](./src/install.rs)): `$PREFIX/bin` when + `PREFIX` is set, `/usr/local/bin` when running as root, and otherwise + `$HOME/.local/bin`. A relative `PREFIX` is resolved against the current + directory. The candidate gets the same checks as one on `PATH`: a regular, + executable file that isn't a shim. + +So the CLI launches right away, with the original arguments, although the +terminal's `PATH` hasn't changed. Later invocations use the same fallback before +offering to install, so a terminal with a stale `PATH` doesn't install again. + +If neither finds the CLI, the shim prints, even without verbose mode: + +```text +The installation completed, but no usable GitHub Copilot CLI was found on PATH or in the expected installation location. Source your shell profile, restart the terminal, or update PATH and retry. +``` + +and exits `1` without prompting again. If the install directory can't be +determined, for example because neither `HOME` nor `PREFIX` is set, it prints +`Could not locate GitHub Copilot CLI: ` and exits `1`. + +The fallback doesn't apply to Homebrew installs, whose `bin` directory is +normally on `PATH` already, or to ARMhf and musl, which have no automatic +install. + +On Windows the shim installs GitHub's per-user MSI instead, and discovery +always searches `%LOCALAPPDATA%\GitHubCopilotCLI` after `PATH`; see +[`REQUIREMENTS.md`](./REQUIREMENTS.md). + +## Side by side + +| Stage | Legacy shim | Rust shim | +|---|---|---| +| How it tells a real CLI from a shim | Runs `copilot --version` with its own directory removed from the child's `PATH` | Inspects candidates without running them and rejects the running shim, Rust shim copies, and legacy shims | +| Linux install order | npm, Homebrew if present, curl, wget | curl, then wget | +| Running the official script | `curl ... \| bash` or `wget ... \| bash` | Download to a temporary file, then `bash ` | +| Script's stdin | The pipe; prompts read `/dev/tty` | The terminal | +| Script's `PATH` | Its own directory removed | Every rejected shim's directory removed | +| Script's `command -v copilot` | Fails unless a real CLI is on `PATH` | Fails unless a real CLI is on `PATH` | +| Missing-`PATH` notice and profile prompt | Shown | Shown | +| After the install | Checks `copilot --version` again with the same `PATH` | Searches `PATH`, then the script's install directory, and launches the CLI | + +## Legacy shims in existing terminals + +The legacy extension put its `copilotCli` directory at the front of the +integrated terminal `PATH`. The current extension replaces that contribution +(see [`REQUIREMENTS.md`](./REQUIREMENTS.md)), but a terminal restored from +before the update keeps its old `PATH` until it's restarted, which is how the +observed terminal still ran the legacy shim. The Rust shim rejects legacy shims +both as candidates and in the script's `PATH`, so they don't affect it. + +## Upstream + +The filtered `PATH` works around the script accepting any `copilot`. If the +script checked that `command -v copilot` resolves to the CLI it just installed, +the filtering would no longer be needed. + +## Not established by the transcript + +- What happened after answering the `$HOME/.profile` prompt. +- The value of `$SHELL`, and whether `.bash_profile` or `.bash_login` existed. +- The legacy shim's absolute path, and which extension build created the + terminal's `PATH`. +- The exit status of each `cargo run`. +- Whether the official script will keep this behavior. + +## Source references + +- Specification: [`REQUIREMENTS.md`](./REQUIREMENTS.md) +- Workflow and post-install discovery: [`src/app.rs`](./src/app.rs) +- Discovery and the installer `PATH`: [`src/candidate.rs`](./src/candidate.rs) +- Installer routes and the script's install directory: + [`src/install.rs`](./src/install.rs) +- Command model and process creation: [`src/model.rs`](./src/model.rs) and + [`src/platform/mod.rs`](./src/platform/mod.rs) +- Legacy shim: + `git show 315c844ac15^:extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIShim.ts` +- Official script: diff --git a/copilot_shim/build.rs b/copilot_shim/build.rs new file mode 100644 index 00000000000000..95d27df8c7f5a2 --- /dev/null +++ b/copilot_shim/build.rs @@ -0,0 +1,197 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//! Embeds a Windows version resource in `copilot.exe`. VS Code setup compares its file version with the published +//! shim's and replaces the shim only when they differ, because signing makes every build's bytes different. Bump the +//! package version whenever the shim changes. +//! +//! The resource is written directly in the `.res` format, which the MSVC linker accepts as an input, so the build +//! needs neither a resource compiler nor extra dependencies. + +use std::env; +use std::fs; +use std::path::PathBuf; + +const RT_VERSION: u16 = 16; +const LANGUAGE_EN_US: u16 = 0x0409; +const CODE_PAGE_UNICODE: u16 = 0x04b0; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=Cargo.toml"); + if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") + || env::var("CARGO_CFG_TARGET_ENV").as_deref() != Ok("msvc") + { + return; + } + + let version = env::var("CARGO_PKG_VERSION").expect("package version"); + // The file version has no place for a pre-release or build suffix, and setup compares file versions, so a suffix + // alone would never replace a published shim. + let numbers: Vec = version + .split('.') + .map(|part| { + part.parse() + .unwrap_or_else(|_| panic!("package version {version} must be major.minor.patch")) + }) + .collect(); + let [major, minor, patch] = numbers[..] else { + panic!("package version {version} must be major.minor.patch"); + }; + + let resource = version_resource(major, minor, patch, &version); + let path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")).join("version.res"); + fs::write(&path, resource).expect("write the version resource"); + println!("cargo:rustc-link-arg-bins={}", path.display()); +} + +fn version_resource(major: u16, minor: u16, patch: u16, version: &str) -> Vec { + let version_ms = (u32::from(major) << 16) | u32::from(minor); + let version_ls = u32::from(patch) << 16; + let mut fixed = Vec::new(); + for value in [ + 0xFEEF_04BD, // signature + 0x0001_0000, // structure version + version_ms, // file version + version_ls, + version_ms, // product version + version_ls, + 0x3F, // file flags mask + 0, // file flags + 0x0004_0004, // VOS_NT_WINDOWS32 + 1, // VFT_APP + 0, // subtype + 0, // date + 0, + ] { + fixed.extend_from_slice(&u32::to_le_bytes(value)); + } + + let strings = [ + ("CompanyName", "Microsoft Corporation"), + ("FileDescription", "GitHub Copilot CLI launcher"), + ("FileVersion", version), + ("InternalName", "copilot.exe"), + ( + "LegalCopyright", + "Copyright (C) Microsoft Corporation. All rights reserved.", + ), + ("OriginalFilename", "copilot.exe"), + ("ProductName", "Visual Studio Code"), + ("ProductVersion", version), + ] + .map(|(key, value)| Node::text(key, value)); + let string_table = Node::branch( + &format!("{LANGUAGE_EN_US:04x}{CODE_PAGE_UNICODE:04x}"), + strings.to_vec(), + ); + let translation = Node { + key: String::from("Translation"), + value: [LANGUAGE_EN_US, CODE_PAGE_UNICODE] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect(), + value_length: 4, + text: false, + children: Vec::new(), + }; + let root = Node { + key: String::from("VS_VERSION_INFO"), + value_length: fixed.len() as u16, + value: fixed, + text: false, + children: vec![ + Node::branch("StringFileInfo", vec![string_table]), + Node::branch("VarFileInfo", vec![translation]), + ], + }; + + let data = root.encode(); + let mut resource = resource_header(0, 0, 0, 0); + resource.extend(resource_header( + data.len() as u32, + RT_VERSION, + 1, + LANGUAGE_EN_US, + )); + resource.extend(&data); + pad(&mut resource); + resource +} + +/// A `.res` entry header with a numeric type and name. An empty first entry marks the file as a 32-bit resource file. +fn resource_header(data_size: u32, resource_type: u16, name: u16, language: u16) -> Vec { + let mut header = Vec::new(); + header.extend_from_slice(&data_size.to_le_bytes()); + header.extend_from_slice(&32_u32.to_le_bytes()); + for value in [0xFFFF, resource_type, 0xFFFF, name] { + header.extend_from_slice(&u16::to_le_bytes(value)); + } + header.extend_from_slice(&0_u32.to_le_bytes()); // data version + let memory_flags: u16 = if data_size == 0 { 0 } else { 0x0030 }; // MOVEABLE | PURE + header.extend_from_slice(&memory_flags.to_le_bytes()); + header.extend_from_slice(&language.to_le_bytes()); + header.extend_from_slice(&0_u32.to_le_bytes()); // version + header.extend_from_slice(&0_u32.to_le_bytes()); // characteristics + header +} + +/// A node of the version information tree: `wLength`, `wValueLength`, `wType`, key, value, and children, each aligned to +/// four bytes. +#[derive(Clone)] +struct Node { + key: String, + value: Vec, + value_length: u16, + text: bool, + children: Vec, +} + +impl Node { + fn text(key: &str, value: &str) -> Self { + let value: Vec = value.encode_utf16().chain([0]).collect(); + Self { + key: key.to_owned(), + value_length: value.len() as u16, + value: value.iter().flat_map(|unit| unit.to_le_bytes()).collect(), + text: true, + children: Vec::new(), + } + } + + fn branch(key: &str, children: Vec) -> Self { + Self { + key: key.to_owned(), + value: Vec::new(), + value_length: 0, + text: true, + children, + } + } + + fn encode(&self) -> Vec { + let mut bytes = vec![0; 6]; + bytes[2..4].copy_from_slice(&self.value_length.to_le_bytes()); + bytes[4..6].copy_from_slice(&u16::from(self.text).to_le_bytes()); + for unit in self.key.encode_utf16().chain([0]) { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + pad(&mut bytes); + bytes.extend_from_slice(&self.value); + for child in &self.children { + pad(&mut bytes); + bytes.extend(child.encode()); + } + let length = bytes.len() as u16; + bytes[0..2].copy_from_slice(&length.to_le_bytes()); + bytes + } +} + +fn pad(bytes: &mut Vec) { + while !bytes.len().is_multiple_of(4) { + bytes.push(0); + } +} diff --git a/copilot_shim/rustfmt.toml b/copilot_shim/rustfmt.toml new file mode 100644 index 00000000000000..218e203215e949 --- /dev/null +++ b/copilot_shim/rustfmt.toml @@ -0,0 +1 @@ +hard_tabs = true diff --git a/copilot_shim/src/app.rs b/copilot_shim/src/app.rs new file mode 100644 index 00000000000000..3ff8ec7ca8bc3b --- /dev/null +++ b/copilot_shim/src/app.rs @@ -0,0 +1,1554 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::ffi::OsString; +use std::path::Path; +#[cfg(any(windows, test))] +use std::path::PathBuf; + +#[cfg(any(windows, test))] +use crate::candidate::path_directories; +use crate::candidate::{discover, DiscoveryDiagnosticKind, DiscoveryError}; +use crate::command::{resolve_candidate, InterpreterInventory}; +use crate::install::{ + discover_tools, installer_plan, run_installer, HostTarget, InstallerAttempt, + InstallerAttemptResult, InstallerPlanError, InstallerResult, InstallerRoute, UnsupportedTarget, +}; +use crate::invocation::{self, Invocation}; +use crate::model::{ + Candidate, ProcessError, ProcessOutcome, ProcessTermination, ResolvedCandidate, + SupervisionMode, SystemError, +}; +#[cfg(any(windows, test))] +use crate::model::{CommandArguments, CommandSpec, DiscoveredFileKind, ProbeLimits}; +use crate::runtime::prompt::INSTALL_DOCUMENTATION_URL; +#[cfg(any(windows, test))] +use crate::runtime::InspectedFileType; +use crate::runtime::{PromptResponse, Runtime}; +use crate::setup; +#[cfg(any(windows, test))] +use crate::version::{first_version, PowerShellVersion}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DiscoveryCycle { + Initial, + AfterInstall(InstallerRoute), +} + +/// Exit code when Copilot CLI is missing and there is no terminal to offer an install: the code a shell uses for a +/// command it can't find. +const NOT_INSTALLED_EXIT_CODE: i32 = 127; + +/// Exit code after Ctrl+C, as a shell reports a command that SIGINT ended. +const CANCELLED_EXIT_CODE: i32 = 130; + +/// Turns on verbose diagnostics like `--vscode-shim verbose`, where arguments can't be changed, such as in scripts. +const VERBOSE_VARIABLE: &str = "VSCODE_COPILOT_SHIM_VERBOSE"; + +/// Points to the verbose diagnostics after a failure that they explain. +const VERBOSE_HINT: &str = + "For details, run copilot --vscode-shim verbose, or set VSCODE_COPILOT_SHIM_VERBOSE=1."; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ApplicationExit { + Code(i32), + InternalFailure, +} + +#[derive(Debug, Eq, PartialEq)] +enum WorkflowAction { + Launch(Candidate), + Exit(i32), +} + +impl ApplicationExit { + pub(super) fn code(self) -> i32 { + match self { + Self::Code(code) => code, + Self::InternalFailure => 1, + } + } +} + +/// Interpreters for script candidates, located only when a candidate needs them. Running PowerShell to check its +/// version takes hundreds of milliseconds. +#[derive(Debug, Default)] +struct Interpreters { + inventory: InterpreterInventory, + #[cfg(any(windows, test))] + command_shell_located: bool, + #[cfg(any(windows, test))] + power_shell_located: bool, +} + +#[cfg(any(windows, test))] +fn locate_command_shell(runtime: &R, interpreters: &mut Interpreters) { + if std::mem::replace(&mut interpreters.command_shell_located, true) { + return; + } + interpreters.inventory.command_shell = match runtime + .environment_variable("ComSpec") + .filter(|value| !value.is_empty()) + { + Some(value) => usable_program(runtime, PathBuf::from(value), "ComSpec"), + None => { + runtime.write_diagnostic("ComSpec is unavailable"); + None + } + }; +} + +#[cfg(any(windows, test))] +fn locate_power_shell( + runtime: &R, + interpreters: &mut Interpreters, +) -> Result<(), ApplicationExit> { + if std::mem::replace(&mut interpreters.power_shell_located, true) { + return Ok(()); + } + let directories = path_directories(runtime); + interpreters.inventory.powershell_7_3_or_newer = + select_power_shell(runtime, &directories, "pwsh.exe", is_modern_power_shell)?; + if interpreters.inventory.powershell_7_3_or_newer.is_none() { + interpreters.inventory.windows_powershell_5_1 = select_power_shell( + runtime, + &directories, + "powershell.exe", + is_windows_power_shell_5_1, + )?; + } + Ok(()) +} + +#[cfg(any(windows, test))] +fn usable_program(runtime: &R, path: PathBuf, role: &str) -> Option { + match runtime.inspect_path(&path) { + Ok(Some(inspection)) + if inspection.file_type == InspectedFileType::RegularFile && inspection.executable => + { + Some(path) + } + Ok(Some(_)) => { + runtime.write_diagnostic(&format!( + "{role} path {path:?} is not an executable regular file" + )); + None + } + Ok(None) => None, + // An app execution alias, such as PowerShell from the Microsoft Store, can be started but not opened + // (ERROR_CANT_ACCESS_FILE). + Err(error) if error.raw_os_error() == Some(1920) => Some(path), + Err(error) => { + runtime.write_diagnostic(&format!("failed to inspect {role} path {path:?}: {error}")); + None + } + } +} + +#[cfg(any(windows, test))] +fn select_power_shell( + runtime: &R, + directories: &[PathBuf], + name: &str, + accepts: F, +) -> Result, ApplicationExit> +where + R: Runtime, + F: Fn(PowerShellVersion) -> bool, +{ + for directory in directories { + let path = directory.join(name); + let Some(path) = usable_program(runtime, path, name) else { + continue; + }; + if probe_power_shell_version(runtime, &path)?.is_some_and(&accepts) { + return Ok(Some(path)); + } + } + Ok(None) +} + +#[cfg(any(windows, test))] +fn probe_power_shell_version( + runtime: &R, + path: &Path, +) -> Result, ApplicationExit> { + let command = CommandSpec::new( + path.as_os_str().to_os_string(), + CommandArguments::Native( + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "$PSVersionTable.PSVersion.ToString()", + ] + .into_iter() + .map(OsString::from) + .collect(), + ), + ); + let problem = match runtime.supervise( + &command, + SupervisionMode::CapturedVersionProbe(ProbeLimits::PRODUCTION), + ) { + Ok(ProcessOutcome { + termination: ProcessTermination::NumericExit(0), + captured_output: Some(output), + }) => match first_version(&output.stdout) { + Some(version) => return Ok(Some(version)), + None => String::from("returned an unparseable version"), + }, + Ok(ProcessOutcome { + termination: ProcessTermination::HandledCancellation, + .. + }) => return Err(ApplicationExit::Code(CANCELLED_EXIT_CODE)), + Ok(ProcessOutcome { + termination: ProcessTermination::NumericExit(code), + .. + }) => format!("exited with code {code}"), + #[cfg(any(unix, test))] + Ok(ProcessOutcome { + termination: ProcessTermination::UnixSignal(signal), + .. + }) => format!("ended from signal {signal}"), + Err(error) => describe_process_error(&error), + }; + runtime.write_diagnostic(&format!("PowerShell host {path:?} is unusable: {problem}")); + Ok(None) +} + +#[cfg(any(windows, test))] +fn is_modern_power_shell(version: PowerShellVersion) -> bool { + (version.major, version.minor) >= (7, 3) +} + +#[cfg(any(windows, test))] +fn is_windows_power_shell_5_1(version: PowerShellVersion) -> bool { + version.major == 5 && version.minor == 1 +} + +/// Selects the first Copilot CLI candidate that can be launched, without running any candidate. A candidate whose +/// interpreter is unavailable is skipped. The Copilot CLI keeps itself up to date, so there is no version check. +fn select_candidate( + runtime: &R, + interpreters: &mut Interpreters, + directory: Option<&Path>, +) -> Result, ApplicationExit> { + let discovery = match directory { + #[cfg(unix)] + Some(directory) => crate::candidate::discover_directory(runtime, crate::SHIM_MARKER, directory), + _ => discover(runtime, crate::SHIM_MARKER), + } + .map_err(|error| { + report_discovery_error(runtime, &error); + ApplicationExit::InternalFailure + })?; + for diagnostic in discovery.diagnostics { + if let DiscoveryDiagnosticKind::Error { operation, error } = diagnostic.kind { + runtime.write_diagnostic(&format!( + "candidate discovery {:?} failed for {:?}: {}", + operation, + diagnostic.path, + describe_system_error(&error) + )); + } + } + + // Unix candidates need no interpreter. + #[cfg(not(any(windows, test)))] + return Ok(discovery.candidates.into_iter().next().map(|discovered| { + let ResolvedCandidate::Usable(candidate) = + resolve_candidate(discovered, &interpreters.inventory); + candidate + })); + + #[cfg(any(windows, test))] + { + for discovered in discovery.candidates { + match discovered.kind { + DiscoveredFileKind::CommandScript | DiscoveredFileKind::BatchScript => { + locate_command_shell(runtime, interpreters) + } + DiscoveredFileKind::PowerShellScript => locate_power_shell(runtime, interpreters)?, + _ => {} + } + let path = discovered.path.clone(); + match resolve_candidate(discovered, &interpreters.inventory) { + ResolvedCandidate::Usable(candidate) => return Ok(Some(candidate)), + ResolvedCandidate::Unusable(reason) => runtime.write_diagnostic(&format!( + "candidate {path:?} is unusable because its interpreter is unavailable: {reason:?}" + )), + } + } + Ok(None) + } +} + +fn prepare_workflow( + runtime: &R, + target: Option, + interpreters: &mut Interpreters, +) -> Result { + let mut cycle = DiscoveryCycle::Initial; + loop { + if let Some(candidate) = select_candidate(runtime, interpreters, None)? { + return Ok(WorkflowAction::Launch(candidate)); + } + #[cfg(unix)] + if matches!( + cycle, + DiscoveryCycle::Initial + | DiscoveryCycle::AfterInstall(InstallerRoute::Curl | InstallerRoute::Wget) + ) { + let directory = + crate::install::official_install_directory(runtime, target).map_err(|error| { + runtime.write_message(&format!("Could not locate GitHub Copilot CLI: {error}")); + ApplicationExit::InternalFailure + })?; + if let Some(directory) = directory { + if let Some(candidate) = select_candidate(runtime, interpreters, Some(&directory))? + { + return Ok(WorkflowAction::Launch(candidate)); + } + } + } + match cycle { + DiscoveryCycle::Initial => { + match request_install(runtime, target)? { + None => return Ok(WorkflowAction::Exit(0)), + // Discovery searches the MSI install folder after PATH, so it finds a Copilot CLI that was just + // installed even though this terminal's PATH doesn't include it yet. + Some(route) => cycle = DiscoveryCycle::AfterInstall(route), + } + } + DiscoveryCycle::AfterInstall(_) => { + runtime.write_message( + "The installation completed, but no usable GitHub Copilot CLI was found on PATH or in the expected installation location. Source your shell profile, restart the terminal, or update PATH and retry.", + ); + return Err(ApplicationExit::InternalFailure); + } + } + } +} + +pub(crate) fn run( + runtime: &R, + arguments: Vec, + target: Option, +) -> i32 { + let invocation = invocation::parse(arguments); + runtime.set_diagnostics_enabled( + invocation.verbose + || runtime + .environment_variable(VERBOSE_VARIABLE) + .is_some_and(|value| !value.is_empty() && value != "0"), + ); + let (clear, arguments) = match invocation.result { + Ok(Invocation::Launch { clear, arguments }) => (clear, arguments), + // Commands for VS Code report and exit; they never launch the Copilot CLI. + Ok(Invocation::Probe(options)) => return setup::probe(runtime, target, &options), + Ok(Invocation::Install(options)) => return setup::install(runtime, target, &options), + Err(error) => { + runtime.write_diagnostic(&error.to_string()); + return invocation::USAGE_EXIT_CODE; + } + }; + if clear { + if let Err(error) = runtime.clear_terminal() { + runtime.write_message(&format!("Could not clear the terminal: {error}")); + return ApplicationExit::InternalFailure.code(); + } + } + + let mut interpreters = Interpreters::default(); + let result = prepare_workflow(runtime, target, &mut interpreters).map(|action| match action { + WorkflowAction::Launch(candidate) => launch_candidate(runtime, candidate, arguments), + WorkflowAction::Exit(code) => ApplicationExit::Code(code), + }); + result.map_or_else(ApplicationExit::code, ApplicationExit::code) +} + +fn launch_candidate( + runtime: &R, + candidate: Candidate, + arguments: Vec, +) -> ApplicationExit { + let command = match candidate.command(arguments) { + Ok(command) => command, + Err(error) => { + runtime.write_message(&format!( + "Could not start GitHub Copilot CLI at {}: {error:?}", + candidate.path.display() + )); + return ApplicationExit::InternalFailure; + } + }; + // On Unix the shim becomes the Copilot CLI, so this only returns if the CLI couldn't be started. + match runtime.supervise(&command, SupervisionMode::FinalInteractiveCli) { + Ok(ProcessOutcome { + termination: ProcessTermination::NumericExit(code), + .. + }) => ApplicationExit::Code(code), + #[cfg(any(unix, test))] + Ok(ProcessOutcome { + termination: ProcessTermination::UnixSignal(signal), + .. + }) => ApplicationExit::Code(128_i32.saturating_add(signal)), + Ok(ProcessOutcome { + termination: ProcessTermination::HandledCancellation, + .. + }) => ApplicationExit::Code(CANCELLED_EXIT_CODE), + Err(error) => { + runtime.write_message(&format!( + "Could not start GitHub Copilot CLI: {}", + describe_process_error(&error) + )); + ApplicationExit::InternalFailure + } + } +} +fn request_install( + runtime: &R, + target: Option, +) -> Result, ApplicationExit> { + if runtime.copilot_cli_command_disabled() { + runtime.write_message(&format!( + "GitHub Copilot CLI was not found. Installing it from VS Code is turned off by the {} policy; contact your administrator.", + setup::COPILOT_CLI_COMMAND_POLICY + )); + return Err(ApplicationExit::Code( + setup::InstallStatus::Policy.exit_code(), + )); + } + if !runtime.can_prompt() { + runtime.write_message(&format!( + "GitHub Copilot CLI was not found. Run copilot in a terminal to install it, or see {INSTALL_DOCUMENTATION_URL}" + )); + return Err(ApplicationExit::Code(NOT_INSTALLED_EXIT_CODE)); + } + let Some(target) = target else { + report_unsupported_target(runtime, None); + return Err(ApplicationExit::InternalFailure); + }; + if let Some(unsupported) = unsupported_target(target) { + report_unsupported_target(runtime, Some(unsupported)); + return Err(ApplicationExit::InternalFailure); + } + + let response = runtime.prompt().map_err(|error| { + runtime.write_message(&format!( + "Could not read the answer to the install prompt: {error}" + )); + ApplicationExit::InternalFailure + })?; + if response == PromptResponse::Declined { + return Ok(None); + } + + let tools = discover_tools(runtime, target); + let routes = installer_plan(target, &tools).map_err(|error| { + report_installer_plan_error(runtime, &error); + ApplicationExit::InternalFailure + })?; + match run_installer(runtime, &routes, &tools, runtime.diagnostics_enabled()) { + InstallerResult::Succeeded { attempts, route } => { + report_failed_installer_attempts(runtime, &attempts); + Ok(Some(route)) + } + InstallerResult::Cancelled { attempts } => { + report_installer_attempts(runtime, &attempts); + Err(ApplicationExit::Code(CANCELLED_EXIT_CODE)) + } + InstallerResult::Failed { failure, attempts } => { + report_installer_attempts(runtime, &attempts); + if let crate::install::InstallerFailure::Environment(message) = &failure { + runtime.write_message(&format!( + "Could not prepare the Copilot CLI installer: {message}" + )); + } else { + runtime.write_message(&format!( + "GitHub Copilot CLI couldn't be installed. To install it manually, see {INSTALL_DOCUMENTATION_URL}" + )); + if !runtime.diagnostics_enabled() { + runtime.write_message(VERBOSE_HINT); + } + } + runtime.write_diagnostic(&format!( + "the installation failed at installer level {failure:?}" + )); + Err(ApplicationExit::InternalFailure) + } + } +} + +fn unsupported_target(target: HostTarget) -> Option { + match target { + HostTarget::LinuxGnuArmhf => Some(UnsupportedTarget::LinuxArmhf), + HostTarget::LinuxMuslX64 | HostTarget::LinuxMuslArm64 => Some(UnsupportedTarget::LinuxMusl), + HostTarget::WindowsX64 + | HostTarget::WindowsArm64 + | HostTarget::MacosX64 + | HostTarget::MacosArm64 + | HostTarget::LinuxGnuX64 + | HostTarget::LinuxGnuArm64 => None, + } +} + +fn report_unsupported_target(runtime: &R, unsupported: Option) { + match unsupported { + Some(target) => runtime.write_message(&format!( + "GitHub Copilot CLI was not found. {}", + target.guidance() + )), + None => runtime.write_message(&format!( + "GitHub Copilot CLI was not found, and it can't be installed automatically on this system. To install it manually, see {INSTALL_DOCUMENTATION_URL}" + )), + } +} + +fn report_installer_plan_error(runtime: &R, error: &InstallerPlanError) { + match error { + InstallerPlanError::Unsupported(target) => { + report_unsupported_target(runtime, Some(*target)); + } + InstallerPlanError::MissingPrerequisites(tools) => runtime.write_message(&format!( + "GitHub Copilot CLI can't be installed because none of {} is available. Install one of them, or see {INSTALL_DOCUMENTATION_URL}", + tools + .iter() + .map(|tool| tool.command()) + .collect::>() + .join(", ") + )), + } +} + +fn report_failed_installer_attempts(runtime: &R, attempts: &[InstallerAttempt]) { + for attempt in attempts { + if !matches!(attempt.result, InstallerAttemptResult::NumericExit(0)) { + report_installer_attempt(runtime, attempt); + } + } +} + +fn report_installer_attempts(runtime: &R, attempts: &[InstallerAttempt]) { + for attempt in attempts { + report_installer_attempt(runtime, attempt); + } +} + +fn report_installer_attempt(runtime: &R, attempt: &InstallerAttempt) { + runtime.write_diagnostic(&format!( + "installer route {:?} stage {:?} returned {:?}", + attempt.route, attempt.stage, attempt.result + )); +} + +fn report_discovery_error(runtime: &R, error: &DiscoveryError) { + runtime.write_message(&format!( + "Could not start GitHub Copilot CLI: the running copilot command{} couldn't be identified: {}", + error + .path + .as_ref() + .map(|path| format!(" at {path:?}")) + .unwrap_or_default(), + describe_system_error(&error.error) + )); +} + +fn describe_process_error(error: &ProcessError) -> String { + match error { + #[cfg(windows)] + ProcessError::TimedOut(timeout) => format!("timed out after {timeout:?}"), + #[cfg(windows)] + ProcessError::OutputLimitExceeded(bytes) => { + format!("exceeded the {bytes} byte output limit") + } + ProcessError::SpawnFailed { program, error } => format!( + "{}: {}", + Path::new(program).display(), + describe_system_error(error) + ), + ProcessError::SupervisionFailed(diagnostic) => format!( + "process supervision {:?} failed for {:?}: {}", + diagnostic.operation, + diagnostic.program, + describe_system_error(&diagnostic.error) + ), + } +} + +/// The operating system's message for an error, in the user's display language. +fn describe_system_error(error: &SystemError) -> String { + match error.raw_os_error { + Some(code) => std::io::Error::from_raw_os_error(code).to_string(), + None => format!("{:?}", error.kind), + } +} +#[cfg(test)] +mod tests { + use std::cell::{Cell, RefCell}; + use std::collections::{HashMap, VecDeque}; + use std::ffi::OsString; + use std::io; + use std::path::{Path, PathBuf}; + + use super::*; + use crate::model::{CandidateKind, CapturedOutput, FileIdentityState}; + use crate::runtime::{ + EnvironmentEffects, FileSystemEffects, PathInspection, PolicyEffects, ProcessEffects, + UserInteractionEffects, + }; + + struct ProcessStep { + result: Result, + replacement_path: Option, + installed_program: Option, + } + + struct FakeRuntime { + path: RefCell>, + environment: RefCell>, + inspections: RefCell>, + inspection_errors: RefCell>, + files: RefCell>>, + process_steps: RefCell>, + commands: RefCell>, + prompt_responses: RefCell>, + prompts: RefCell>, + clears: Cell, + diagnostics: RefCell>, + messages: RefCell>, + diagnostics_enabled: Cell, + policy_disabled: Cell, + no_terminal: Cell, + } + + impl Default for FakeRuntime { + fn default() -> Self { + let mut inspections = HashMap::new(); + inspections.insert( + PathBuf::from("/shim"), + PathInspection { + canonical_path: PathBuf::from("/shim"), + file_identity: FileIdentityState::Unsupported, + file_type: InspectedFileType::RegularFile, + executable: true, + file_size: 0, + }, + ); + Self { + path: RefCell::new(None), + environment: RefCell::new(HashMap::from([( + String::from("HOME"), + OsString::from("/home/test"), + )])), + inspections: RefCell::new(inspections), + inspection_errors: RefCell::new(HashMap::new()), + files: RefCell::new(HashMap::new()), + process_steps: RefCell::new(VecDeque::new()), + commands: RefCell::new(Vec::new()), + prompt_responses: RefCell::new(VecDeque::new()), + prompts: RefCell::new(Vec::new()), + clears: Cell::new(0), + diagnostics: RefCell::new(Vec::new()), + messages: RefCell::new(Vec::new()), + diagnostics_enabled: Cell::new(true), + policy_disabled: Cell::new(false), + no_terminal: Cell::new(false), + } + } + } + + impl FakeRuntime { + fn set_path(&self, paths: &[&Path]) { + *self.path.borrow_mut() = + Some(std::env::join_paths(paths).expect("join fake PATH entries")); + } + + fn add_program(&self, path: &Path) { + self.inspections.borrow_mut().insert( + path.to_path_buf(), + PathInspection { + canonical_path: path.to_path_buf(), + file_identity: FileIdentityState::Unsupported, + file_type: InspectedFileType::RegularFile, + executable: true, + file_size: 0, + }, + ); + self.files + .borrow_mut() + .insert(path.to_path_buf(), b"real cli".to_vec()); + } + + fn push_process_result(&self, result: Result) { + self.process_steps.borrow_mut().push_back(ProcessStep { + result, + replacement_path: None, + installed_program: None, + }); + } + + #[cfg(not(windows))] + fn push_process_result_with_path( + &self, + result: Result, + paths: &[&Path], + ) { + self.process_steps.borrow_mut().push_back(ProcessStep { + result, + replacement_path: Some( + std::env::join_paths(paths).expect("join replacement PATH entries"), + ), + installed_program: None, + }); + } + + #[cfg(not(windows))] + fn push_prompt_response(&self, response: PromptResponse) { + self.prompt_responses.borrow_mut().push_back(response); + } + } + + impl EnvironmentEffects for FakeRuntime { + fn path(&self) -> Option { + self.path.borrow().clone() + } + + fn current_executable(&self) -> io::Result { + Ok(PathBuf::from("/shim")) + } + + fn current_directory(&self) -> io::Result { + Ok(PathBuf::from("/")) + } + + fn environment_variable(&self, name: &str) -> Option { + self.environment.borrow().get(name).cloned() + } + } + + impl FileSystemEffects for FakeRuntime { + fn inspect_path(&self, path: &Path) -> io::Result> { + if let Some(kind) = self.inspection_errors.borrow().get(path) { + return Err(io::Error::new(*kind, "fake inspection failure")); + } + Ok(self.inspections.borrow().get(path).cloned()) + } + + fn open_file(&self, path: &Path) -> io::Result> { + self.files + .borrow() + .get(path) + .cloned() + .map(|content| Box::new(io::Cursor::new(content)) as Box) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "fake file is missing")) + } + } + + impl UserInteractionEffects for FakeRuntime { + fn set_diagnostics_enabled(&self, enabled: bool) { + self.diagnostics_enabled.set(enabled); + } + + fn diagnostics_enabled(&self) -> bool { + self.diagnostics_enabled.get() + } + + fn clear_terminal(&self) -> io::Result<()> { + self.clears.set(self.clears.get() + 1); + Ok(()) + } + + fn can_prompt(&self) -> bool { + !self.no_terminal.get() + } + + fn prompt(&self) -> io::Result { + self.prompts.borrow_mut().push(String::from("install")); + Ok(self + .prompt_responses + .borrow_mut() + .pop_front() + .unwrap_or(PromptResponse::Declined)) + } + + fn write_diagnostic(&self, message: &str) { + if self.diagnostics_enabled.get() { + self.diagnostics.borrow_mut().push(message.to_owned()); + } + } + + fn write_message(&self, message: &str) { + self.messages.borrow_mut().push(message.to_owned()); + } + } + + impl ProcessEffects for FakeRuntime { + fn supervise( + &self, + command: &CommandSpec, + mode: SupervisionMode, + ) -> Result { + self.commands.borrow_mut().push((command.clone(), mode)); + let step = self + .process_steps + .borrow_mut() + .pop_front() + .expect("unexpected fake process invocation"); + if let Some(path) = step.replacement_path { + *self.path.borrow_mut() = Some(path); + } + if let Some(path) = step.installed_program { + self.add_program(&path); + } + step.result + } + } + + impl PolicyEffects for FakeRuntime { + fn copilot_cli_command_disabled(&self) -> bool { + self.policy_disabled.get() + } + } + + fn captured_exit(code: i32, stdout: &[u8], stderr: &[u8]) -> ProcessOutcome { + ProcessOutcome { + termination: ProcessTermination::NumericExit(code), + captured_output: Some(CapturedOutput { + stdout: stdout.to_vec(), + stderr: stderr.to_vec(), + }), + } + } + + fn interactive_exit(code: i32) -> ProcessOutcome { + ProcessOutcome { + termination: ProcessTermination::NumericExit(code), + captured_output: None, + } + } + + #[cfg(not(windows))] + fn configure_script_installer(runtime: &FakeRuntime) { + runtime.add_program(Path::new("/tools/curl")); + runtime.add_program(Path::new("/tools/bash")); + } + + /// The file name that discovery launches on this host. + const CLI: &str = if cfg!(windows) { + "copilot.exe" + } else { + "copilot" + }; + + fn cli_path(directory: &str) -> PathBuf { + Path::new(directory).join(CLI) + } + + fn programs(runtime: &FakeRuntime) -> Vec { + runtime + .commands + .borrow() + .iter() + .map(|(command, _)| PathBuf::from(command.program())) + .collect() + } + + #[test] + fn the_first_candidate_launches_without_being_run_first() { + let runtime = FakeRuntime::default(); + for directory in ["/first", "/second"] { + runtime.add_program(&cli_path(directory)); + } + runtime.set_path(&[Path::new("/first"), Path::new("/second")]); + runtime.push_process_result(Ok(interactive_exit(73))); + + let exit = run( + &runtime, + vec![ + OsString::from("--vscode-shim"), + OsString::from("clear"), + OsString::from("--clear"), + OsString::new(), + OsString::from("value"), + ], + Some(HostTarget::LinuxGnuX64), + ); + + assert_eq!( + ( + exit, + runtime.clears.get(), + runtime + .commands + .borrow() + .iter() + .map(|(command, mode)| ( + PathBuf::from(command.program()), + command.arguments().clone(), + *mode + )) + .collect::>(), + ), + ( + 73, + 1, + vec![( + cli_path("/first"), + CommandArguments::Native(vec![ + OsString::from("--clear"), + OsString::new(), + OsString::from("value"), + ]), + SupervisionMode::FinalInteractiveCli, + )], + ) + ); + } + + #[test] + fn candidate_discovery_diagnostics_require_verbose() { + let configured_runtime = || { + let runtime = FakeRuntime::default(); + runtime.add_program(&cli_path("/cli")); + runtime.set_path(&[Path::new("/denied"), Path::new("/cli")]); + runtime + .inspection_errors + .borrow_mut() + .insert(cli_path("/denied"), io::ErrorKind::PermissionDenied); + runtime.push_process_result(Ok(interactive_exit(0))); + runtime + }; + + let quiet = configured_runtime(); + let quiet_exit = run(&quiet, Vec::new(), Some(HostTarget::LinuxGnuX64)); + + let verbose = configured_runtime(); + let verbose_exit = run( + &verbose, + vec![OsString::from("--vscode-shim"), OsString::from("verbose")], + Some(HostTarget::LinuxGnuX64), + ); + + let environment = configured_runtime(); + environment + .environment + .borrow_mut() + .insert(String::from(VERBOSE_VARIABLE), OsString::from("1")); + run(&environment, Vec::new(), Some(HostTarget::LinuxGnuX64)); + + assert_eq!( + ( + quiet_exit, + quiet.diagnostics.borrow().clone(), + environment.diagnostics.borrow().len(), + verbose_exit, + verbose.diagnostics.borrow().iter().any(|message| message + .contains("candidate discovery CandidateMetadata") + && message.contains("/denied") + && message.contains("PermissionDenied")), + verbose + .commands + .borrow() + .first() + .map(|(command, _)| command.arguments().clone()), + ), + ( + 0, + Vec::::new(), + 1, + 0, + true, + Some(CommandArguments::Native(Vec::new())), + ) + ); + } + + #[test] + fn windows_interpreter_inventory_requires_version_proof() { + let runtime = FakeRuntime::default(); + let directories = [ + Path::new("/old"), + Path::new("/modern"), + Path::new("/legacy"), + ]; + runtime.set_path(&directories); + runtime + .environment + .borrow_mut() + .insert(String::from("ComSpec"), OsString::from("/cmd.exe")); + runtime.add_program(Path::new("/cmd.exe")); + runtime.add_program(Path::new("/old/pwsh.exe")); + runtime.add_program(Path::new("/modern/pwsh.exe")); + runtime.add_program(Path::new("/legacy/powershell.exe")); + runtime.push_process_result(Ok(captured_exit(0, b"7.2.9", b""))); + runtime.push_process_result(Ok(captured_exit(0, b"7.3.1", b""))); + + let mut interpreters = Interpreters::default(); + locate_command_shell(&runtime, &mut interpreters); + locate_power_shell(&runtime, &mut interpreters).expect("locate PowerShell"); + + assert_eq!( + ( + interpreters.inventory, + runtime + .commands + .borrow() + .iter() + .map(|(command, mode)| (PathBuf::from(command.program()), *mode)) + .collect::>(), + ), + ( + InterpreterInventory { + command_shell: Some(PathBuf::from("/cmd.exe")), + powershell_7_3_or_newer: Some(PathBuf::from("/modern/pwsh.exe")), + windows_powershell_5_1: None, + }, + vec![ + ( + PathBuf::from("/old/pwsh.exe"), + SupervisionMode::CapturedVersionProbe(ProbeLimits::PRODUCTION), + ), + ( + PathBuf::from("/modern/pwsh.exe"), + SupervisionMode::CapturedVersionProbe(ProbeLimits::PRODUCTION), + ), + ], + ) + ); + } + + #[test] + fn windows_interpreter_inventory_falls_back_to_verified_5_1() { + let runtime = FakeRuntime::default(); + let directories = [Path::new("/hosts")]; + runtime.set_path(&directories); + runtime.add_program(Path::new("/hosts/pwsh.exe")); + runtime.add_program(Path::new("/hosts/powershell.exe")); + runtime.push_process_result(Ok(captured_exit(0, b"7.2.0", b""))); + runtime.push_process_result(Ok(captured_exit(0, b"5.1.22621.2506", b""))); + + let mut interpreters = Interpreters::default(); + locate_command_shell(&runtime, &mut interpreters); + locate_power_shell(&runtime, &mut interpreters).expect("locate PowerShell"); + + assert_eq!( + (interpreters.inventory, runtime.diagnostics.borrow().clone()), + ( + InterpreterInventory { + command_shell: None, + powershell_7_3_or_newer: None, + windows_powershell_5_1: Some(PathBuf::from("/hosts/powershell.exe")), + }, + vec![String::from("ComSpec is unavailable")], + ) + ); + } + + #[test] + fn missing_windows_interpreters_are_explicitly_absent() { + let runtime = FakeRuntime::default(); + runtime.set_path(&[Path::new("/empty")]); + + let mut interpreters = Interpreters::default(); + locate_command_shell(&runtime, &mut interpreters); + locate_power_shell(&runtime, &mut interpreters).expect("locate PowerShell"); + + assert_eq!( + ( + interpreters.inventory, + runtime.commands.borrow().len(), + runtime.diagnostics.borrow().clone(), + ), + ( + InterpreterInventory::default(), + 0, + vec![String::from("ComSpec is unavailable")], + ) + ); + } + + #[cfg(windows)] + #[test] + fn power_shell_is_located_only_for_ps1_candidates() { + let with_hosts = |script: &str| { + let runtime = FakeRuntime::default(); + runtime.add_program(&Path::new("/cli").join(script)); + runtime.add_program(Path::new("/hosts/pwsh.exe")); + runtime.set_path(&[Path::new("/cli"), Path::new("/hosts")]); + runtime + }; + + let native = with_hosts("copilot.exe"); + native.push_process_result(Ok(interactive_exit(0))); + let native_exit = run(&native, Vec::new(), Some(HostTarget::WindowsX64)); + + let script = with_hosts("copilot.ps1"); + script.push_process_result(Ok(captured_exit(0, b"7.4.6", b""))); + script.push_process_result(Ok(interactive_exit(0))); + let script_exit = run(&script, Vec::new(), Some(HostTarget::WindowsX64)); + + assert_eq!( + ( + native_exit, + programs(&native), + script_exit, + programs(&script) + ), + ( + 0, + vec![Path::new("/cli").join("copilot.exe")], + 0, + vec![ + PathBuf::from("/hosts/pwsh.exe"), + PathBuf::from("/hosts/pwsh.exe") + ], + ) + ); + } + + #[cfg(not(windows))] + #[test] + fn install_success_rediscovers_from_the_first_path_entry() { + let install = FakeRuntime::default(); + configure_script_installer(&install); + install.add_program(Path::new("/installed/copilot")); + install.set_path(&[Path::new("/tools")]); + install.push_prompt_response(PromptResponse::Accepted); + install.push_process_result(Ok(interactive_exit(0))); + install.push_process_result_with_path( + Ok(interactive_exit(0)), + &[Path::new("/installed"), Path::new("/tools")], + ); + + let action = prepare_workflow( + &install, + Some(HostTarget::LinuxGnuX64), + &mut Interpreters::default(), + ) + .expect("prepare installed CLI"); + + assert_eq!( + ( + action_path(action), + install.prompts.borrow().clone(), + programs(&install), + ), + ( + Some(PathBuf::from("/installed/copilot")), + vec![String::from("install")], + vec![PathBuf::from("/tools/curl"), PathBuf::from("/tools/bash")], + ) + ); + } + + #[cfg(not(windows))] + #[test] + fn an_install_that_is_not_visible_in_path_does_not_prompt_twice() { + let invisible = FakeRuntime::default(); + configure_script_installer(&invisible); + invisible.set_path(&[Path::new("/tools")]); + invisible.push_prompt_response(PromptResponse::Accepted); + invisible.push_process_result(Ok(interactive_exit(0))); + invisible.push_process_result(Ok(interactive_exit(0))); + + let result = prepare_workflow( + &invisible, + Some(HostTarget::LinuxGnuX64), + &mut Interpreters::default(), + ); + + assert_eq!( + ( + result, + invisible.prompts.borrow().clone(), + invisible + .messages + .borrow() + .iter() + .any(|message| message.contains("restart the terminal")), + ), + ( + Err(ApplicationExit::InternalFailure), + vec![String::from("install")], + true, + ) + ); + } + + #[cfg(unix)] + #[test] + fn installed_cli_launches_immediately_and_on_later_stale_path_invocations() { + let runtime = FakeRuntime::default(); + configure_script_installer(&runtime); + runtime.set_path(&[Path::new("/tools")]); + runtime.push_prompt_response(PromptResponse::Accepted); + runtime.push_process_result(Ok(interactive_exit(0))); + runtime.process_steps.borrow_mut().push_back(ProcessStep { + result: Ok(interactive_exit(0)), + replacement_path: None, + installed_program: Some(PathBuf::from("/home/test/.local/bin/copilot")), + }); + runtime.push_process_result(Ok(interactive_exit(41))); + runtime.push_process_result(Ok(interactive_exit(42))); + let arguments = vec![ + OsString::from("--resume"), + OsString::from("session with spaces"), + ]; + let first = run(&runtime, arguments.clone(), Some(HostTarget::LinuxGnuX64)); + let second = run(&runtime, arguments.clone(), Some(HostTarget::LinuxGnuX64)); + let commands = runtime.commands.borrow(); + + assert_eq!( + ( + first, + second, + runtime.prompts.borrow().clone(), + programs(&runtime), + commands + .iter() + .map(|(command, _)| command.path().map(OsString::from)) + .collect::>(), + commands + .last() + .map(|(command, _)| command.arguments().clone()), + runtime.path(), + ), + ( + 41, + 42, + vec![String::from("install")], + vec![ + PathBuf::from("/tools/curl"), + PathBuf::from("/tools/bash"), + PathBuf::from("/home/test/.local/bin/copilot"), + PathBuf::from("/home/test/.local/bin/copilot") + ], + vec![None, Some(OsString::from("/tools")), None, None], + Some(CommandArguments::Native(arguments)), + Some(OsString::from("/tools")), + ) + ); + } + + #[cfg(unix)] + #[test] + fn original_path_wins_and_invalid_fallbacks_are_not_launched() { + let mut observations = Vec::new(); + for (on_path, fallback_contents, executable) in [ + (true, b"real cli".as_slice(), true), + (false, crate::SHIM_MARKER, true), + (false, b"real cli".as_slice(), false), + ] { + let runtime = FakeRuntime::default(); + runtime.set_path(&[Path::new("/tools")]); + let fallback = PathBuf::from("/home/test/.local/bin/copilot"); + runtime.add_program(&fallback); + runtime + .files + .borrow_mut() + .insert(fallback.clone(), fallback_contents.to_vec()); + runtime + .inspections + .borrow_mut() + .get_mut(&fallback) + .expect("fallback") + .executable = executable; + if on_path { + runtime.add_program(Path::new("/tools/copilot")); + } + observations.push(( + prepare_workflow( + &runtime, + Some(HostTarget::LinuxGnuX64), + &mut Interpreters::default(), + ) + .map(action_path), + runtime.prompts.borrow().len(), + )); + } + assert_eq!( + observations, + vec![ + (Ok(Some(PathBuf::from("/tools/copilot"))), 0), + (Ok(None), 1), + (Ok(None), 1), + ] + ); + } + + #[cfg(not(windows))] + #[test] + fn installer_failure_cancellation_and_unsupported_targets_are_terminal() { + let failed = FakeRuntime::default(); + configure_script_installer(&failed); + failed.set_path(&[Path::new("/tools")]); + failed.push_prompt_response(PromptResponse::Accepted); + failed.push_process_result(Ok(interactive_exit(9))); + + let failed_result = prepare_workflow( + &failed, + Some(HostTarget::LinuxGnuX64), + &mut Interpreters::default(), + ); + + let cancelled = FakeRuntime::default(); + configure_script_installer(&cancelled); + cancelled.set_path(&[Path::new("/tools")]); + cancelled.push_prompt_response(PromptResponse::Accepted); + cancelled.push_process_result(Ok(ProcessOutcome { + termination: ProcessTermination::HandledCancellation, + captured_output: None, + })); + + let cancelled_result = prepare_workflow( + &cancelled, + Some(HostTarget::LinuxGnuX64), + &mut Interpreters::default(), + ); + + let unsupported = FakeRuntime::default(); + let unsupported_result = prepare_workflow( + &unsupported, + Some(HostTarget::LinuxGnuArmhf), + &mut Interpreters::default(), + ); + + assert_eq!( + ( + failed_result, + failed.prompts.borrow().len(), + failed.commands.borrow().len(), + failed + .diagnostics + .borrow() + .iter() + .any(|message| message.contains("stage Download") + && message.contains("NumericExit(9)")), + cancelled_result, + cancelled.prompts.borrow().len(), + cancelled.commands.borrow().len(), + unsupported_result, + unsupported.prompts.borrow().len(), + unsupported + .messages + .borrow() + .iter() + .any(|message| message.contains("ARMhf")), + failed.messages.borrow().clone(), + ), + ( + Err(ApplicationExit::InternalFailure), + 1, + 1, + true, + Err(ApplicationExit::Code(130)), + 1, + 1, + Err(ApplicationExit::InternalFailure), + 0, + true, + vec![format!( + "GitHub Copilot CLI couldn't be installed. To install it manually, see {INSTALL_DOCUMENTATION_URL}" + )], + ) + ); + } + + #[cfg(not(windows))] + #[test] + fn a_declined_install_exits_without_launch() { + let install = FakeRuntime::default(); + install.set_path(&[Path::new("/empty")]); + install.push_prompt_response(PromptResponse::Declined); + + let exit = run(&install, Vec::new(), Some(HostTarget::LinuxGnuX64)); + + assert_eq!( + ( + exit, + install.prompts.borrow().clone(), + install.commands.borrow().len(), + ), + (0, vec![String::from("install")], 0) + ); + } + + #[cfg(not(windows))] + #[test] + fn tools_after_an_uninspectable_path_entry_are_found() { + let tools = FakeRuntime::default(); + tools.add_program(Path::new("/tools/bash")); + tools.add_program(Path::new("/other/curl")); + tools.set_path(&[Path::new("/tools"), Path::new("/other")]); + tools.inspection_errors.borrow_mut().insert( + PathBuf::from("/tools/curl"), + io::ErrorKind::PermissionDenied, + ); + tools.push_prompt_response(PromptResponse::Accepted); + tools.push_process_result(Ok(interactive_exit(9))); + + let result = prepare_workflow( + &tools, + Some(HostTarget::LinuxGnuX64), + &mut Interpreters::default(), + ); + + assert_eq!( + (result, programs(&tools)), + ( + Err(ApplicationExit::InternalFailure), + vec![PathBuf::from("/other/curl")] + ) + ); + } + #[test] + fn without_a_terminal_a_missing_cli_explains_exit_127_without_verbose() { + let configured_runtime = || { + let runtime = FakeRuntime::default(); + runtime.no_terminal.set(true); + runtime.set_path(&[Path::new("/empty")]); + runtime + }; + + let quiet = configured_runtime(); + let quiet_exit = run( + &quiet, + vec![OsString::from("-p")], + Some(HostTarget::LinuxGnuX64), + ); + + assert_eq!( + ( + quiet_exit, + quiet.prompts.borrow().len(), + quiet.messages.borrow().clone(), + ), + ( + 127, + 0, + vec![format!( + "GitHub Copilot CLI was not found. Run copilot in a terminal to install it, or see {INSTALL_DOCUMENTATION_URL}" + )] + ) + ); + } + + #[test] + fn policy_blocks_install_but_launches_an_installed_cli() { + let policy_runtime = |directory: &str| { + let runtime = FakeRuntime::default(); + runtime.policy_disabled.set(true); + runtime.set_path(&[Path::new(directory)]); + runtime + }; + + let missing = policy_runtime("/empty"); + let missing_exit = run(&missing, Vec::new(), Some(HostTarget::LinuxGnuX64)); + + let installed = policy_runtime("/cli"); + installed.add_program(&cli_path("/cli")); + installed.push_process_result(Ok(interactive_exit(7))); + let installed_exit = run( + &installed, + vec![OsString::from("--resume")], + Some(HostTarget::LinuxGnuX64), + ); + + assert_eq!( + ( + missing_exit, + missing.prompts.borrow().len(), + missing.messages.borrow().clone(), + installed_exit, + programs(&installed), + ), + ( + 10, + 0, + vec![String::from( + "GitHub Copilot CLI was not found. Installing it from VS Code is turned off by the CopilotCliCommand policy; contact your administrator." + )], + 7, + vec![cli_path("/cli")], + ) + ); + } + + #[cfg(not(windows))] + fn action_path(action: WorkflowAction) -> Option { + match action { + WorkflowAction::Launch(candidate) => Some(candidate.path), + WorkflowAction::Exit(_) => None, + } + } + + #[test] + fn final_exit_mapping_covers_numeric_signal_cancellation_and_internal_failure() { + let numeric = FakeRuntime::default(); + numeric.push_process_result(Ok(interactive_exit(301))); + let numeric_exit = launch_candidate( + &numeric, + direct_candidate("/numeric"), + vec![OsString::from("arg")], + ) + .code(); + + let signalled = FakeRuntime::default(); + signalled.push_process_result(Ok(ProcessOutcome { + termination: ProcessTermination::UnixSignal(9), + captured_output: None, + })); + let signal_exit = + launch_candidate(&signalled, direct_candidate("/signalled"), Vec::new()).code(); + + let cancelled = FakeRuntime::default(); + cancelled.push_process_result(Ok(ProcessOutcome { + termination: ProcessTermination::HandledCancellation, + captured_output: None, + })); + let cancellation_exit = + launch_candidate(&cancelled, direct_candidate("/cancelled"), Vec::new()).code(); + + let failed = FakeRuntime::default(); + failed.push_process_result(Err(ProcessError::SpawnFailed { + program: OsString::from("/failed"), + error: SystemError { + kind: io::ErrorKind::PermissionDenied, + raw_os_error: None, + }, + })); + let failure_exit = + launch_candidate(&failed, direct_candidate("/failed"), Vec::new()).code(); + + assert_eq!( + ( + numeric_exit, + signal_exit, + cancellation_exit, + failure_exit, + failed.messages.borrow().clone(), + ), + ( + 301, + 137, + 130, + 1, + vec![String::from( + "Could not start GitHub Copilot CLI: /failed: PermissionDenied", + )], + ) + ); + } + + fn direct_candidate(path: &str) -> Candidate { + Candidate { + path: PathBuf::from(path), + kind: CandidateKind::UnixExecutable, + } + } +} diff --git a/copilot_shim/src/candidate.rs b/copilot_shim/src/candidate.rs new file mode 100644 index 00000000000000..2d56f8dbaec1eb --- /dev/null +++ b/copilot_shim/src/candidate.rs @@ -0,0 +1,1105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::collections::HashSet; +#[cfg(unix)] +use std::ffi::OsString; +use std::io::{self, Cursor, Read}; +use std::path::{Path, PathBuf}; + +use crate::identity::{contains_marker, is_same_file}; +use crate::legacy::{is_legacy_wrapper, LEGACY_INSPECTION_LIMIT}; +use crate::model::{DiscoveredCandidate, DiscoveredFileKind, SystemError}; +use crate::runtime::{EnvironmentEffects, FileSystemEffects, InspectedFileType, PathInspection}; + +/// Only candidates up to this size are read. The shim binary is about 1 MB and a script shim is a few KB, so a larger +/// file, such as the Copilot CLI executable (about 150 MB), is neither and isn't read on every launch. +const SHIM_MARKER_SEARCH_LIMIT: u64 = 16 * 1024 * 1024; + +/// The file names looked up in each PATH directory, in order. Windows names match case-insensitively. +#[cfg(windows)] +const CANDIDATE_NAMES: [(&str, DiscoveredFileKind); 4] = [ + ("copilot.exe", DiscoveredFileKind::WindowsExecutable), + ("copilot.cmd", DiscoveredFileKind::CommandScript), + ("copilot.bat", DiscoveredFileKind::BatchScript), + ("copilot.ps1", DiscoveredFileKind::PowerShellScript), +]; +#[cfg(not(windows))] +const CANDIDATE_NAMES: [(&str, DiscoveredFileKind); 1] = + [("copilot", DiscoveredFileKind::UnixExecutable)]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DiscoveryOperation { + CandidateMetadata, + CandidateRead, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DiscoveryExclusion { + NotRegularFile, + #[cfg(any(not(windows), test))] + NotExecutable, + CurrentExecutable, + RustShimMarker, + LegacyShim, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum DiscoveryDiagnosticKind { + Error { + operation: DiscoveryOperation, + error: SystemError, + }, + Excluded(DiscoveryExclusion), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiscoveryDiagnostic { + pub(crate) path: PathBuf, + pub(crate) kind: DiscoveryDiagnosticKind, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct DiscoveryResult { + pub(crate) candidates: Vec, + pub(crate) diagnostics: Vec, +} + +/// The running shim couldn't be identified, so it couldn't be told apart from the candidates. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiscoveryError { + pub(crate) path: Option, + pub(crate) error: SystemError, +} + +pub(crate) fn discover( + runtime: &R, + shim_marker: &[u8], +) -> Result +where + R: EnvironmentEffects + FileSystemEffects, +{ + discover_directories(runtime, shim_marker, path_directories(runtime)) +} + +#[cfg(unix)] +pub(crate) fn discover_directory( + runtime: &R, + shim_marker: &[u8], + directory: &Path, +) -> Result +where + R: EnvironmentEffects + FileSystemEffects, +{ + discover_directories(runtime, shim_marker, [directory.to_path_buf()]) +} + +fn discover_directories( + runtime: &R, + shim_marker: &[u8], + directories: impl IntoIterator, +) -> Result +where + R: EnvironmentEffects + FileSystemEffects, +{ + let current_path = runtime + .current_executable() + .map_err(|error| DiscoveryError { + path: None, + error: SystemError::from(&error), + })?; + let current = match runtime.inspect_path(¤t_path) { + Ok(Some(inspection)) => inspection, + Ok(None) => { + return Err(DiscoveryError { + path: Some(current_path), + error: SystemError { + kind: io::ErrorKind::NotFound, + raw_os_error: None, + }, + }) + } + Err(error) => { + return Err(DiscoveryError { + path: Some(current_path), + error: SystemError::from(&error), + }) + } + }; + + let mut result = DiscoveryResult::default(); + let mut visited_directories = HashSet::new(); + for directory in directories { + if !visited_directories.insert(directory.clone()) { + continue; + } + for (name, kind) in CANDIDATE_NAMES { + inspect_candidate( + runtime, + ¤t, + directory.join(name), + kind, + shim_marker, + &mut result, + ); + } + } + Ok(result) +} + +/// The official installer must not mistake a shim for the CLI it just installed. Only its child PATH is filtered. +#[cfg(unix)] +pub(crate) fn installer_path(runtime: &R) -> io::Result +where + R: EnvironmentEffects + FileSystemEffects, +{ + let discovery = discover(runtime, crate::SHIM_MARKER).map_err(|error| { + io::Error::other(format!( + "could not identify the running shim at {:?}: {:?}", + error.path, error.error + )) + })?; + let shim_directories: HashSet = discovery + .diagnostics + .into_iter() + .filter_map(|diagnostic| match diagnostic.kind { + DiscoveryDiagnosticKind::Excluded( + DiscoveryExclusion::CurrentExecutable + | DiscoveryExclusion::RustShimMarker + | DiscoveryExclusion::LegacyShim, + ) => diagnostic.path.parent().map(Path::to_path_buf), + _ => None, + }) + .collect(); + let path = runtime + .path() + .filter(|path| !path.is_empty()) + .ok_or_else(|| io::Error::other("the installer requires a nonempty PATH"))?; + let mut retained = Vec::new(); + let mut current_directory = None; + for entry in std::env::split_paths(&path) { + let directory = if entry.is_absolute() { + entry.clone() + } else { + if current_directory.is_none() { + current_directory = Some(runtime.current_directory()?); + } + current_directory + .as_ref() + .expect("current directory was resolved") + .join(&entry) + }; + if !shim_directories.contains(&directory) { + retained.push(entry); + } + } + if retained.is_empty() { + return Err(io::Error::other( + "no PATH entries remain after excluding Copilot shims; add the installer tools to PATH", + )); + } + std::env::join_paths(retained).map_err(io::Error::other) +} + +/// The directories in PATH, in order. An empty or relative entry is resolved against the current directory, which is +/// looked up only when such an entry exists; if it can't be, those entries are skipped. +pub(crate) fn path_directories(runtime: &R) -> Vec { + let Some(path) = runtime.path().filter(|path| !path.is_empty()) else { + return Vec::new(); + }; + let mut current_directory = None; + std::env::split_paths(&path) + .filter_map(|entry| { + if entry.is_absolute() { + return Some(entry); + } + let base = current_directory + .get_or_insert_with(|| runtime.current_directory().ok()) + .as_ref()?; + Some(if entry.as_os_str().is_empty() { + base.clone() + } else { + base.join(entry) + }) + }) + .collect() +} + +fn inspect_candidate( + runtime: &R, + current: &PathInspection, + path: PathBuf, + kind: DiscoveredFileKind, + shim_marker: &[u8], + result: &mut DiscoveryResult, +) { + let inspection = match runtime.inspect_path(&path) { + Ok(Some(inspection)) => inspection, + Ok(None) => return, + Err(error) => { + push_error(result, path, DiscoveryOperation::CandidateMetadata, &error); + return; + } + }; + if inspection.file_type != InspectedFileType::RegularFile { + push_exclusion(result, path, DiscoveryExclusion::NotRegularFile); + return; + } + #[cfg(any(not(windows), test))] + { + if kind == DiscoveredFileKind::UnixExecutable && !inspection.executable { + push_exclusion(result, path, DiscoveryExclusion::NotExecutable); + return; + } + } + if is_same_file(current, &inspection) { + push_exclusion(result, path, DiscoveryExclusion::CurrentExecutable); + return; + } + match other_shim(runtime, &path, inspection.file_size, shim_marker) { + Ok(Some(exclusion)) => push_exclusion(result, path, exclusion), + Ok(None) => result.candidates.push(DiscoveredCandidate { path, kind }), + Err(error) => push_error(result, path, DiscoveryOperation::CandidateRead, &error), + } +} + +/// Reads the start of a candidate once to tell whether it's another copy of this shim, by its marker, or a script +/// shim that an earlier version of VS Code wrote. +fn other_shim( + runtime: &R, + path: &Path, + size: u64, + shim_marker: &[u8], +) -> io::Result> { + if size > SHIM_MARKER_SEARCH_LIMIT { + return Ok(None); + } + let mut reader = runtime.open_file(path)?; + let mut prefix = Vec::with_capacity(size.min(LEGACY_INSPECTION_LIMIT as u64) as usize); + reader + .by_ref() + .take(LEGACY_INSPECTION_LIMIT as u64) + .read_to_end(&mut prefix)?; + let rest = reader.take(SHIM_MARKER_SEARCH_LIMIT.saturating_sub(prefix.len() as u64)); + if contains_marker(Cursor::new(&prefix).chain(rest), shim_marker)? { + return Ok(Some(DiscoveryExclusion::RustShimMarker)); + } + Ok(is_legacy_wrapper(&prefix).then_some(DiscoveryExclusion::LegacyShim)) +} + +fn push_error( + result: &mut DiscoveryResult, + path: PathBuf, + operation: DiscoveryOperation, + error: &io::Error, +) { + result.diagnostics.push(DiscoveryDiagnostic { + path, + kind: DiscoveryDiagnosticKind::Error { + operation, + error: SystemError::from(error), + }, + }); +} + +fn push_exclusion(result: &mut DiscoveryResult, path: PathBuf, exclusion: DiscoveryExclusion) { + result.diagnostics.push(DiscoveryDiagnostic { + path, + kind: DiscoveryDiagnosticKind::Excluded(exclusion), + }); +} +#[cfg(test)] +mod tests { + use crate::{candidate, identity, legacy, model, runtime}; + use std::collections::HashSet; + use std::ffi::OsString; + use std::fs; + use std::io::{self, Cursor, Read}; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + use candidate::{discover, DiscoveryDiagnosticKind, DiscoveryExclusion, DiscoveryOperation}; + use legacy::is_legacy_wrapper; + #[cfg(windows)] + use model::DiscoveredFileKind; + use model::FileIdentityState; + use runtime::{EnvironmentEffects, FileSystemEffects, NativeRuntime, PathInspection}; + + static NEXT_TEMP_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory { + path: PathBuf, + } + + impl TestDirectory { + fn new(test_name: &str) -> Self { + let sequence = NEXT_TEMP_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "copilot-shim-{test_name}-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).expect("create test directory"); + Self { path } + } + + fn child(&self, name: impl AsRef) -> PathBuf { + self.path.join(name) + } + + fn directory(&self, name: impl AsRef) -> PathBuf { + let path = self.child(name); + fs::create_dir_all(&path).expect("create child directory"); + path + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + fs::remove_dir_all(&self.path).expect("remove test directory"); + } + } + + struct TestRuntime { + path: Option, + current_executable: PathBuf, + current_directory: PathBuf, + unsupported_identity: bool, + metadata_errors: HashSet, + read_errors: HashSet, + native: NativeRuntime, + } + + impl TestRuntime { + fn new( + path: Option, + current_executable: PathBuf, + current_directory: PathBuf, + ) -> Self { + Self { + path, + current_executable, + current_directory, + unsupported_identity: false, + metadata_errors: HashSet::new(), + read_errors: HashSet::new(), + native: NativeRuntime::default(), + } + } + } + + impl EnvironmentEffects for TestRuntime { + fn path(&self) -> Option { + self.path.clone() + } + + fn current_executable(&self) -> io::Result { + Ok(self.current_executable.clone()) + } + + fn current_directory(&self) -> io::Result { + Ok(self.current_directory.clone()) + } + } + + impl FileSystemEffects for TestRuntime { + fn inspect_path(&self, path: &Path) -> io::Result> { + if self.metadata_errors.contains(path) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "injected metadata error", + )); + } + let mut inspection = self.native.inspect_path(path)?; + if self.unsupported_identity { + if let Some(inspection) = &mut inspection { + inspection.file_identity = FileIdentityState::Unsupported; + } + } + Ok(inspection) + } + + fn open_file(&self, path: &Path) -> io::Result> { + if self.read_errors.contains(path) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "injected read error", + )); + } + self.native.open_file(path) + } + } + + fn shim_marker() -> Vec { + ["VSCODE_COPILOT_", "RUST_SHIM_V1"].concat().into_bytes() + } + + /// The file name discovery looks for in each PATH directory on this platform. + const CANDIDATE: &str = if cfg!(windows) { + "copilot.exe" + } else { + "copilot" + }; + + fn write_executable(path: &Path, content: &[u8]) { + fs::write(path, content).expect("write executable"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .expect("make file executable"); + } + } + + fn create_current_executable(directory: &TestDirectory, content: &[u8]) -> PathBuf { + let current_directory = directory.directory("current"); + let current = current_directory.join("copilot"); + write_executable(¤t, content); + current + } + + fn joined_path(paths: impl IntoIterator) -> OsString { + std::env::join_paths(paths).expect("join PATH entries") + } + + #[cfg(unix)] + #[test] + fn installer_path_excludes_every_shim_alias_and_preserves_other_entries() { + use std::os::unix::ffi::OsStringExt; + use std::os::unix::fs::symlink; + + let root = TestDirectory::new("installer-path"); + let current = create_current_executable(&root, crate::SHIM_MARKER); + let copied = root.directory("copy"); + write_executable(&copied.join("copilot"), crate::SHIM_MARKER); + let linked = root.directory("hardlink"); + fs::hard_link(¤t, linked.join("copilot")).expect("hardlink"); + let symlinked = root.directory("symlink"); + symlink(¤t, symlinked.join("copilot")).expect("symlink"); + let directory_alias = root.child("directory-alias"); + symlink(current.parent().expect("parent"), &directory_alias).expect("directory alias"); + let legacy = root.directory("legacy"); + write_executable( + &legacy.join("copilot"), + include_bytes!("../tests/fixtures/legacy/posix-launcher.sh"), + ); + fs::copy(¤t, root.child("copilot")).expect("shim in current directory"); + // Not created: macOS file systems reject names that aren't valid UTF-8. PATH entries are kept byte-for-byte + // whether or not they exist. + let native = root.child(PathBuf::from(OsString::from_vec(b"native-\xff".to_vec()))); + let tools = root.directory("tools"); + let real = root.directory("npm"); + write_executable( + &real.join("copilot"), + include_bytes!("../tests/fixtures/legacy/npm-wrapper.cmd"), + ); + let retained = vec![ + tools.clone(), + PathBuf::from("tools"), + native.clone(), + real.clone(), + tools.clone(), + ]; + let runtime = TestRuntime::new( + Some(joined_path([ + current.parent().expect("parent").to_path_buf(), + tools.clone(), + copied, + PathBuf::from("current/"), + linked, + symlinked, + directory_alias, + PathBuf::new(), + PathBuf::from("tools"), + legacy, + native, + real, + tools, + ])), + current, + root.path.clone(), + ); + let before = runtime.path(); + + assert_eq!( + ( + candidate::installer_path(&runtime).expect("installer PATH"), + runtime.path() + ), + (joined_path(retained), before) + ); + } + + #[cfg(unix)] + #[test] + fn installer_path_does_not_replace_excluded_entries_with_current_directory() { + let root = TestDirectory::new("empty-installer-path"); + let current = create_current_executable(&root, crate::SHIM_MARKER); + let parent = current.parent().expect("parent").to_path_buf(); + let runtime = TestRuntime::new( + Some(joined_path([parent.clone(), PathBuf::new()])), + current, + parent, + ); + assert!(candidate::installer_path(&runtime) + .expect_err("all entries excluded") + .to_string() + .contains("no PATH entries remain")); + } + + fn candidate_paths(result: &candidate::DiscoveryResult) -> Vec { + result + .candidates + .iter() + .map(|candidate| candidate.path.clone()) + .collect() + } + + // Uses Unix file names and legacy scripts; Windows discovery only considers `.exe`, `.cmd`, `.bat`, and `.ps1`. + #[cfg(unix)] + #[test] + fn self_and_legacy_matrix() { + let directory = TestDirectory::new("self-and-legacy"); + let marker = shim_marker(); + let current = create_current_executable(&directory, &marker); + let current_directory = current.parent().expect("current parent").to_path_buf(); + + let symlink_directory = directory.directory("symlink"); + let symlink = symlink_directory.join("copilot"); + #[cfg(unix)] + std::os::unix::fs::symlink(¤t, &symlink).expect("create symlink"); + #[cfg(windows)] + std::os::windows::fs::symlink_file(¤t, &symlink).expect("create symlink"); + + let hard_link_directory = directory.directory("hard-link"); + fs::hard_link(¤t, hard_link_directory.join("copilot")).expect("create hard link"); + + let copied_directory = directory.directory("copied"); + fs::copy(¤t, copied_directory.join("copilot")).expect("copy current executable"); + + let marked_directory = directory.directory("marked"); + let mut marked_content = b"prefix".to_vec(); + marked_content.extend_from_slice(&marker); + marked_content.extend_from_slice(b"suffix"); + write_executable(&marked_directory.join("copilot"), &marked_content); + + let positive_fixtures: [&[u8]; 4] = [ + include_bytes!("../tests/fixtures/legacy/posix-launcher.sh"), + include_bytes!("../tests/fixtures/legacy/windows-bootstrapper.ps1"), + include_bytes!("../tests/fixtures/legacy/windows-wrapper.cmd"), + include_bytes!("../tests/fixtures/legacy/git-bash-wrapper.sh"), + ]; + let mut path_directories = vec![ + current_directory.clone(), + symlink_directory, + hard_link_directory, + copied_directory, + marked_directory, + ]; + for (index, fixture) in positive_fixtures.into_iter().enumerate() { + let fixture_directory = directory.directory(format!("legacy-{index}")); + write_executable(&fixture_directory.join("copilot"), fixture); + path_directories.push(fixture_directory); + } + + let npm_cmd_directory = directory.directory("npm-cmd"); + write_executable( + &npm_cmd_directory.join("copilot"), + include_bytes!("../tests/fixtures/legacy/npm-wrapper.cmd"), + ); + path_directories.push(npm_cmd_directory.clone()); + let npm_ps1_directory = directory.directory("npm-ps1"); + write_executable( + &npm_ps1_directory.join("copilot"), + include_bytes!("../tests/fixtures/legacy/npm-wrapper.ps1"), + ); + path_directories.push(npm_ps1_directory.clone()); + let valid_directory = directory.directory("valid"); + write_executable(&valid_directory.join("copilot"), b"real copilot cli"); + path_directories.push(valid_directory.clone()); + + let runtime = TestRuntime::new( + Some(joined_path(path_directories)), + current, + directory.path.clone(), + ); + let result = discover(&runtime, &marker).expect("discover candidates"); + let exclusions: Vec = result + .diagnostics + .iter() + .filter_map(|diagnostic| match diagnostic.kind { + DiscoveryDiagnosticKind::Excluded(exclusion) => Some(exclusion), + DiscoveryDiagnosticKind::Error { .. } => None, + }) + .collect(); + + assert_eq!( + (candidate_paths(&result), exclusions), + ( + vec![ + npm_cmd_directory.join("copilot"), + npm_ps1_directory.join("copilot"), + valid_directory.join("copilot"), + ], + vec![ + DiscoveryExclusion::CurrentExecutable, + DiscoveryExclusion::CurrentExecutable, + DiscoveryExclusion::CurrentExecutable, + DiscoveryExclusion::RustShimMarker, + DiscoveryExclusion::RustShimMarker, + DiscoveryExclusion::LegacyShim, + DiscoveryExclusion::LegacyShim, + DiscoveryExclusion::LegacyShim, + DiscoveryExclusion::LegacyShim, + ], + ) + ); + } + + #[cfg(unix)] + #[test] + fn unix_scripts_that_are_not_utf8_are_candidates() { + let directory = TestDirectory::new("undecodable-shebang"); + let current = create_current_executable(&directory, b"current"); + let candidate_directory = directory.directory("candidate"); + let candidate = candidate_directory.join("copilot"); + write_executable(&candidate, b"#!/bin/sh\n\xFF"); + let binary_directory = directory.directory("binary"); + let binary = binary_directory.join("copilot"); + write_executable(&binary, b"\x7FELF\xFF"); + let runtime = TestRuntime::new( + Some(joined_path([candidate_directory, binary_directory])), + current, + directory.path.clone(), + ); + + let result = discover(&runtime, &shim_marker()).expect("discover candidates"); + + assert_eq!( + (candidate_paths(&result), result.diagnostics), + (vec![candidate, binary], Vec::new()) + ); + } + + #[cfg(unix)] + #[test] + fn valid_symlink_candidate_retains_discovered_path() { + let directory = TestDirectory::new("valid-symlink"); + let current = create_current_executable(&directory, b"current"); + let target_directory = directory.directory("target"); + let target = target_directory.join("copilot"); + write_executable(&target, b"real cli"); + let symlink_directory = directory.directory("symlink"); + let symlink = symlink_directory.join("copilot"); + std::os::unix::fs::symlink(&target, &symlink).expect("create candidate symlink"); + let runtime = TestRuntime::new( + Some(joined_path([symlink_directory])), + current, + directory.path.clone(), + ); + + let result = discover(&runtime, &shim_marker()).expect("discover symlink candidate"); + let candidate = result + .candidates + .first() + .expect("candidate should be eligible"); + assert_eq!( + (result.candidates.len(), candidate.path.as_path()), + (1, symlink.as_path()) + ); + } + + #[test] + fn path_entries_preserve_native_order_and_first_duplicate() { + let directory = TestDirectory::new("path-order"); + let current = create_current_executable(&directory, b"current without marker"); + let cwd_candidate = directory.child(CANDIDATE); + write_executable(&cwd_candidate, b"cwd"); + let relative_directory = directory.directory("relative"); + write_executable(&relative_directory.join(CANDIDATE), b"relative"); + let non_directory = directory.child("not-a-directory"); + fs::write(&non_directory, b"file").expect("write non-directory PATH entry"); + + let entries = vec![ + PathBuf::new(), + PathBuf::from("relative"), + relative_directory.clone(), + directory.child("missing"), + non_directory.clone(), + ]; + #[cfg(unix)] + let (entries, non_utf8_candidate) = { + use std::os::unix::ffi::OsStringExt; + + let mut entries = entries; + let non_utf8_directory = + directory.child(PathBuf::from(OsString::from_vec(b"native-\xFF".to_vec()))); + #[cfg(not(target_os = "macos"))] + let candidate = { + fs::create_dir(&non_utf8_directory).expect("create non-UTF-8 directory"); + let candidate = non_utf8_directory.join("copilot"); + write_executable(&candidate, b"native"); + Some(candidate) + }; + #[cfg(target_os = "macos")] + let candidate = None; + entries.push(non_utf8_directory); + (entries, candidate) + }; + + let runtime = TestRuntime::new(Some(joined_path(entries)), current, directory.path.clone()); + let result = discover(&runtime, &shim_marker()).expect("discover candidates"); + let expected = vec![cwd_candidate, relative_directory.join(CANDIDATE)]; + #[cfg(unix)] + let expected = { + let mut expected = expected; + if let Some(non_utf8_candidate) = non_utf8_candidate { + expected.push(non_utf8_candidate); + } + expected + }; + + // A PATH entry that is a file, like a missing one, has no candidates and isn't reported. + assert_eq!( + (candidate_paths(&result), result.diagnostics), + (expected, Vec::new()) + ); + } + + // Relies on Unix executable bits; on Windows every regular file is executable. + #[cfg(unix)] + #[test] + fn candidate_that_only_other_users_can_execute_is_skipped() { + use std::os::unix::fs::PermissionsExt; + + // Root can execute any file with an execute bit. + if unsafe { libc::geteuid() } == 0 { + return; + } + let directory = TestDirectory::new("other-users-execute"); + let current = create_current_executable(&directory, b"current"); + let others_directory = directory.directory("others"); + let others = others_directory.join("copilot"); + fs::write(&others, b"others").expect("write candidate"); + fs::set_permissions(&others, fs::Permissions::from_mode(0o611)) + .expect("let only other users execute the candidate"); + let valid_directory = directory.directory("valid"); + let valid = valid_directory.join("copilot"); + write_executable(&valid, b"valid"); + + let runtime = TestRuntime::new( + Some(joined_path([others_directory, valid_directory])), + current, + directory.path.clone(), + ); + let result = discover(&runtime, &shim_marker()).expect("discover candidates"); + + assert_eq!( + (candidate_paths(&result), result.diagnostics), + ( + vec![valid], + vec![candidate::DiscoveryDiagnostic { + path: others, + kind: DiscoveryDiagnosticKind::Excluded(DiscoveryExclusion::NotExecutable), + }] + ) + ); + } + + #[cfg(unix)] + #[test] + fn invalid_candidates_continue_to_a_valid_later_candidate() { + let directory = TestDirectory::new("invalid-candidates"); + let current = create_current_executable(&directory, b"current"); + let non_executable_directory = directory.directory("non-executable"); + fs::write(non_executable_directory.join("copilot"), b"not executable") + .expect("write non-executable"); + let directory_candidate = directory.directory("directory-candidate"); + fs::create_dir(directory_candidate.join("copilot")).expect("create directory candidate"); + let broken_directory = directory.directory("broken"); + let loop_directory = directory.directory("loop"); + #[cfg(unix)] + { + std::os::unix::fs::symlink("missing", broken_directory.join("copilot")) + .expect("create broken link"); + std::os::unix::fs::symlink("copilot", loop_directory.join("copilot")) + .expect("create link loop"); + } + #[cfg(windows)] + { + std::os::windows::fs::symlink_file("missing", broken_directory.join("copilot")) + .expect("create broken link"); + std::os::windows::fs::symlink_file("copilot", loop_directory.join("copilot")) + .expect("create link loop"); + } + let unreadable_directory = directory.directory("unreadable"); + let unreadable = unreadable_directory.join("copilot"); + write_executable(&unreadable, b"unreadable"); + let valid_directory = directory.directory("valid"); + let valid = valid_directory.join("copilot"); + write_executable(&valid, b"valid"); + + let mut runtime = TestRuntime::new( + Some(joined_path([ + non_executable_directory, + directory_candidate, + broken_directory, + loop_directory, + unreadable_directory, + valid_directory, + ])), + current, + directory.path.clone(), + ); + runtime.read_errors.insert(unreadable.clone()); + let result = discover(&runtime, &shim_marker()).expect("discover candidates"); + + assert_eq!(candidate_paths(&result), vec![valid]); + assert!(matches!( + result.diagnostics.as_slice(), + [ + candidate::DiscoveryDiagnostic { + kind: DiscoveryDiagnosticKind::Excluded(DiscoveryExclusion::NotExecutable), + .. + }, + candidate::DiscoveryDiagnostic { + kind: DiscoveryDiagnosticKind::Excluded(DiscoveryExclusion::NotRegularFile), + .. + }, + candidate::DiscoveryDiagnostic { + kind: DiscoveryDiagnosticKind::Error { + operation: DiscoveryOperation::CandidateMetadata, + error: model::SystemError { + kind: io::ErrorKind::NotFound, + .. + }, + }, + .. + }, + candidate::DiscoveryDiagnostic { + kind: DiscoveryDiagnosticKind::Error { + operation: DiscoveryOperation::CandidateMetadata, + .. + }, + .. + }, + candidate::DiscoveryDiagnostic { + kind: DiscoveryDiagnosticKind::Error { + operation: DiscoveryOperation::CandidateRead, + error: model::SystemError { + kind: io::ErrorKind::PermissionDenied, + .. + }, + }, + .. + }, + ] + )); + } + + #[test] + fn unsupported_file_ids_fall_back_but_metadata_errors_do_not() { + let directory = TestDirectory::new("identity-errors"); + let marker = shim_marker(); + let current = create_current_executable(&directory, &marker); + let hard_link_directory = directory.directory("hard-link"); + let hard_link = hard_link_directory.join(CANDIDATE); + fs::hard_link(¤t, &hard_link).expect("create hard link"); + let metadata_error_directory = directory.directory("metadata-error"); + let metadata_error = metadata_error_directory.join(CANDIDATE); + write_executable(&metadata_error, b"candidate"); + let valid_directory = directory.directory("valid"); + let valid = valid_directory.join(CANDIDATE); + write_executable(&valid, b"valid"); + + let mut runtime = TestRuntime::new( + Some(joined_path([ + hard_link_directory, + metadata_error_directory, + valid_directory, + ])), + current.clone(), + directory.path.clone(), + ); + runtime.unsupported_identity = true; + runtime.metadata_errors.insert(metadata_error.clone()); + let result = discover(&runtime, &marker).expect("discover candidates"); + + let mut fatal_runtime = TestRuntime::new(None, current.clone(), directory.path.clone()); + fatal_runtime.metadata_errors.insert(current); + let fatal = discover(&fatal_runtime, &marker).expect_err("current metadata error is fatal"); + + assert_eq!( + ( + candidate_paths(&result), + result + .diagnostics + .iter() + .map(|diagnostic| &diagnostic.kind) + .collect::>(), + fatal.error.kind, + ), + ( + vec![valid], + vec![ + &DiscoveryDiagnosticKind::Excluded(DiscoveryExclusion::RustShimMarker), + &DiscoveryDiagnosticKind::Error { + operation: DiscoveryOperation::CandidateMetadata, + error: model::SystemError { + kind: io::ErrorKind::PermissionDenied, + raw_os_error: None, + }, + }, + ], + io::ErrorKind::PermissionDenied, + ) + ); + } + + #[test] + fn missing_and_empty_path_have_no_candidates() { + let directory = TestDirectory::new("empty-path"); + let current = create_current_executable(&directory, b"current"); + let missing = TestRuntime::new(None, current.clone(), directory.path.clone()); + let empty = TestRuntime::new(Some(OsString::new()), current, directory.path.clone()); + + assert_eq!( + ( + discover(&missing, &shim_marker()) + .expect("missing PATH") + .candidates, + discover(&empty, &shim_marker()) + .expect("empty PATH") + .candidates, + ), + (Vec::new(), Vec::new()) + ); + } + + #[test] + fn legacy_signatures_are_bounded_and_exact() { + let positives: [&[u8]; 4] = [ + include_bytes!("../tests/fixtures/legacy/posix-launcher.sh"), + include_bytes!("../tests/fixtures/legacy/windows-bootstrapper.ps1"), + include_bytes!("../tests/fixtures/legacy/windows-wrapper.cmd"), + include_bytes!("../tests/fixtures/legacy/git-bash-wrapper.sh"), + ]; + let negatives: [&[u8]; 2] = [ + include_bytes!("../tests/fixtures/legacy/npm-wrapper.cmd"), + include_bytes!("../tests/fixtures/legacy/npm-wrapper.ps1"), + ]; + let mut marker_after_limit = vec![b'x'; legacy::LEGACY_INSPECTION_LIMIT]; + marker_after_limit.extend_from_slice(positives[0]); + let crlf_posix = String::from_utf8(positives[0].to_vec()) + .expect("UTF-8 fixture") + .replace('\n', "\r\n"); + let bom_posix = [b"\xEF\xBB\xBF".as_slice(), positives[0]].concat(); + let uppercase_windows = String::from_utf8(positives[2].to_vec()) + .expect("UTF-8 fixture") + .to_ascii_uppercase(); + let mut split_character = positives[0].to_vec(); + split_character.resize(legacy::LEGACY_INSPECTION_LIMIT - 1, b' '); + split_character.extend_from_slice("\u{e9}".as_bytes()); + + assert_eq!( + ( + positives.map(is_legacy_wrapper), + negatives.map(is_legacy_wrapper), + is_legacy_wrapper(crlf_posix.as_bytes()), + is_legacy_wrapper(&bom_posix), + is_legacy_wrapper(uppercase_windows.as_bytes()), + is_legacy_wrapper(&split_character), + is_legacy_wrapper(&marker_after_limit), + is_legacy_wrapper(b"\xFF#!/bin/sh"), + is_legacy_wrapper( + b"#!/bin/sh\nunset node_options\nELECTRON_RUN_AS_NODE=1 x copilotCLIShim.js \"$@\"" + ), + ), + ( + [true; 4], [false; 2], true, true, true, true, false, false, false, + ) + ); + } + struct ChunkedReader { + content: Cursor>, + maximum_chunk: usize, + } + + impl Read for ChunkedReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let length = buffer.len().min(self.maximum_chunk); + self.content.read(&mut buffer[..length]) + } + } + + #[test] + fn marker_search_streams_the_entire_file_and_handles_overlap() { + let marker = shim_marker(); + let mut content = vec![b'x'; 20_000]; + content.extend_from_slice(&marker); + content.extend_from_slice(&vec![b'y'; 20_000]); + + assert!(identity::contains_marker( + ChunkedReader { + content: Cursor::new(content), + maximum_chunk: 3, + }, + &marker, + ) + .expect("search marker")); + } + + #[test] + fn large_candidates_are_not_scanned_for_rust_shim_marker() { + let directory = TestDirectory::new("large-candidate-marker"); + let marker = shim_marker(); + let current = create_current_executable(&directory, b"current"); + let candidate_directory = directory.directory("candidate"); + let candidate = candidate_directory.join(CANDIDATE); + let mut content = marker; + content.resize(candidate::SHIM_MARKER_SEARCH_LIMIT as usize + 1, b'x'); + write_executable(&candidate, &content); + let runtime = TestRuntime::new( + Some(joined_path([candidate_directory])), + current, + directory.path.clone(), + ); + + let result = discover(&runtime, &shim_marker()).expect("discover candidates"); + + assert_eq!(candidate_paths(&result), vec![candidate]); + } + + #[cfg(windows)] + #[test] + fn windows_filesystem_discovery_uses_intrinsic_candidate_kinds() { + let directory = TestDirectory::new("windows-filesystem"); + let current = create_current_executable(&directory, b"current"); + let path_directory = directory.directory("path"); + for name in ["COPILOT.PS1", "Copilot.Bat", "Copilot.Cmd", "COPILOT.EXE"] { + write_executable(&path_directory.join(name), b"candidate"); + } + let runtime = TestRuntime::new( + Some(joined_path([path_directory])), + current, + directory.path.clone(), + ); + + assert_eq!( + discover(&runtime, &shim_marker()) + .expect("discover Windows candidates") + .candidates + .into_iter() + .map(|candidate| candidate.kind) + .collect::>(), + vec![ + DiscoveredFileKind::WindowsExecutable, + DiscoveredFileKind::CommandScript, + DiscoveredFileKind::BatchScript, + DiscoveredFileKind::PowerShellScript, + ] + ); + } +} diff --git a/copilot_shim/src/command.rs b/copilot_shim/src/command.rs new file mode 100644 index 00000000000000..d0bee4bee9dffc --- /dev/null +++ b/copilot_shim/src/command.rs @@ -0,0 +1,336 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::ffi::OsString; +use std::path::PathBuf; + +#[cfg(any(windows, test))] +use crate::model::ResolutionFailure; +use crate::model::{ + Candidate, CandidateKind, CommandArguments, CommandBuildError, CommandSpec, + DiscoveredCandidate, DiscoveredFileKind, ResolvedCandidate, +}; +#[cfg(any(windows, test))] +use crate::platform; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct InterpreterInventory { + pub(crate) command_shell: Option, + pub(crate) powershell_7_3_or_newer: Option, + pub(crate) windows_powershell_5_1: Option, +} + +pub(crate) fn resolve_candidate( + discovered: DiscoveredCandidate, + interpreters: &InterpreterInventory, +) -> ResolvedCandidate { + #[cfg(not(any(windows, test)))] + let _ = interpreters; + let kind = match discovered.kind { + #[cfg(any(not(windows), test))] + DiscoveredFileKind::UnixExecutable => CandidateKind::UnixExecutable, + #[cfg(any(windows, test))] + DiscoveredFileKind::WindowsExecutable => CandidateKind::WindowsExecutable, + #[cfg(any(windows, test))] + DiscoveredFileKind::CommandScript | DiscoveredFileKind::BatchScript => { + let Some(interpreter) = &interpreters.command_shell else { + return ResolvedCandidate::Unusable(ResolutionFailure::MissingCommandShell); + }; + CandidateKind::CommandScript { + interpreter: interpreter.clone(), + } + } + #[cfg(any(windows, test))] + DiscoveredFileKind::PowerShellScript => { + let Some(interpreter) = interpreters + .powershell_7_3_or_newer + .as_ref() + .or(interpreters.windows_powershell_5_1.as_ref()) + else { + return ResolvedCandidate::Unusable(ResolutionFailure::MissingPowerShellHost); + }; + CandidateKind::PowerShell { + interpreter: interpreter.clone(), + } + } + }; + + ResolvedCandidate::Usable(Candidate { + path: discovered.path, + kind, + }) +} + +impl Candidate { + /// The command that launches this candidate with the arguments forwarded to the Copilot CLI. + pub(crate) fn command( + &self, + forwarded_arguments: Vec, + ) -> Result { + match &self.kind { + #[cfg(any(not(windows), test))] + CandidateKind::UnixExecutable => Ok(CommandSpec::new( + self.path.clone().into_os_string(), + CommandArguments::Native(forwarded_arguments), + )), + #[cfg(any(windows, test))] + CandidateKind::WindowsExecutable => Ok(CommandSpec::new( + self.path.clone().into_os_string(), + CommandArguments::Native(forwarded_arguments), + )), + #[cfg(any(windows, test))] + CandidateKind::CommandScript { interpreter } => Ok(CommandSpec::new( + interpreter.clone().into_os_string(), + CommandArguments::WindowsCommand { + switches: platform::WINDOWS_COMMAND_SWITCHES + .into_iter() + .map(OsString::from) + .collect(), + raw_command_tail: platform::encode_windows_command_tail( + self.path.as_os_str(), + &forwarded_arguments, + )?, + }, + )), + #[cfg(any(windows, test))] + CandidateKind::PowerShell { interpreter } => { + let arguments = [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + ] + .into_iter() + .map(OsString::from) + .chain([self.path.clone().into_os_string()]) + .chain(forwarded_arguments) + .collect(); + Ok(CommandSpec::new( + interpreter.clone().into_os_string(), + CommandArguments::Native(arguments), + )) + } + } + } +} +#[cfg(test)] +mod tests { + use crate::platform; + use std::ffi::OsString; + use std::path::PathBuf; + + use super::{resolve_candidate, InterpreterInventory}; + use crate::model::{ + Candidate, CommandArguments, DiscoveredCandidate, DiscoveredFileKind, ResolutionFailure, + ResolvedCandidate, + }; + + fn discovered(path: &str, kind: DiscoveredFileKind) -> DiscoveredCandidate { + DiscoveredCandidate { + path: PathBuf::from(path), + kind, + } + } + + fn arguments() -> Vec { + [ + "", + "with spaces", + "single'quote", + "double\"quote", + "GrรผรŸe-ๆฑไบฌ", + r"trailing\\", + "&|<>()^%!;", + "duplicate", + "duplicate", + ] + .into_iter() + .map(OsString::from) + .collect() + } + + fn assert_native_round_trip(candidate: &Candidate, expected_prefix: &[&str]) { + let forwarded = arguments(); + let final_command = candidate + .command(forwarded.clone()) + .expect("build final command"); + + let CommandArguments::Native(final_arguments) = final_command.arguments() else { + panic!("expected native arguments"); + }; + let expected: Vec = expected_prefix + .iter() + .map(OsString::from) + .chain(forwarded) + .collect(); + assert_eq!(final_arguments.as_slice(), expected.as_slice()); + } + + /// cmd treats `&|<>()^` as syntax only outside quotes, so every one of them must be inside a quoted argument. + fn assert_cmd_metacharacters_are_quoted(raw_command_tail: &std::ffi::OsStr) { + let command_tail = raw_command_tail + .to_str() + .expect("test command tail should be Unicode"); + let inner = &command_tail[1..command_tail.len() - 1]; + let mut quoted = false; + for character in inner.chars() { + if character == '"' { + quoted = !quoted; + } else if !quoted && "&|<>()^".contains(character) { + panic!("unquoted cmd metacharacter in {command_tail:?}"); + } + } + assert!(!quoted, "unbalanced quotes in {command_tail:?}"); + } + + #[test] + fn argument_round_trip_matrix() { + let inventory = InterpreterInventory { + command_shell: Some(PathBuf::from(r"C:\hostile & shell\cmd.exe")), + powershell_7_3_or_newer: Some(PathBuf::from(r"C:\hostile & shell\pwsh.exe")), + windows_powershell_5_1: Some(PathBuf::from( + r"C:\hostile & shell\WindowsPowerShell\v1.0\powershell.exe", + )), + }; + + let direct = resolve_candidate( + discovered( + "/hostile & native/copilot", + DiscoveredFileKind::UnixExecutable, + ), + &inventory, + ); + let ResolvedCandidate::Usable(direct) = direct else { + panic!("direct candidate should resolve"); + }; + assert_native_round_trip(&direct, &[]); + assert_eq!( + direct + .command(Vec::new()) + .expect("build empty direct command") + .arguments(), + &CommandArguments::Native(Vec::new()) + ); + + let windows_direct = resolve_candidate( + discovered( + r"C:\hostile & native\copilot.exe", + DiscoveredFileKind::WindowsExecutable, + ), + &inventory, + ); + let ResolvedCandidate::Usable(windows_direct) = windows_direct else { + panic!("Windows direct candidate should resolve"); + }; + assert_native_round_trip(&windows_direct, &[]); + + for kind in [ + DiscoveredFileKind::CommandScript, + DiscoveredFileKind::BatchScript, + ] { + let resolved = resolve_candidate( + discovered(r"C:\hostile & script\copilot.cmd", kind), + &inventory, + ); + let ResolvedCandidate::Usable(candidate) = resolved else { + panic!("cmd candidate should resolve"); + }; + let command = candidate.command(arguments()).expect("build cmd command"); + let CommandArguments::WindowsCommand { + switches, + raw_command_tail, + } = command.arguments() + else { + panic!("expected raw Windows command tail"); + }; + assert_cmd_metacharacters_are_quoted(raw_command_tail); + assert_eq!( + ( + command.program(), + switches.as_slice(), + platform::decode_windows_command_tail(raw_command_tail) + .expect("decode reviewed command-tail encoding"), + ), + ( + inventory.command_shell.as_ref().unwrap().as_os_str(), + platform::WINDOWS_COMMAND_SWITCHES + .map(OsString::from) + .as_slice(), + std::iter::once(OsString::from(r"C:\hostile & script\copilot.cmd")) + .chain(arguments()) + .collect(), + ) + ); + } + + for inventory in [ + inventory.clone(), + InterpreterInventory { + command_shell: inventory.command_shell.clone(), + powershell_7_3_or_newer: None, + windows_powershell_5_1: inventory.windows_powershell_5_1.clone(), + }, + ] { + let resolved = resolve_candidate( + discovered( + r"C:\hostile & script\copilot.ps1", + DiscoveredFileKind::PowerShellScript, + ), + &inventory, + ); + let ResolvedCandidate::Usable(candidate) = resolved else { + panic!("PowerShell candidate should resolve"); + }; + assert_native_round_trip( + &candidate, + &[ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + r"C:\hostile & script\copilot.ps1", + ], + ); + let command = candidate + .command(Vec::new()) + .expect("build PowerShell command without forwarded arguments"); + let CommandArguments::Native(arguments) = command.arguments() else { + panic!("expected native PowerShell arguments"); + }; + assert_eq!( + ( + command.program(), + arguments.iter().any(|value| value == "-NonInteractive") + ), + ( + inventory + .powershell_7_3_or_newer + .as_ref() + .or(inventory.windows_powershell_5_1.as_ref()) + .unwrap() + .as_os_str(), + false + ) + ); + } + + assert_eq!( + resolve_candidate( + discovered("copilot.cmd", DiscoveredFileKind::CommandScript), + &InterpreterInventory::default(), + ), + ResolvedCandidate::Unusable(ResolutionFailure::MissingCommandShell) + ); + assert_eq!( + resolve_candidate( + discovered("copilot.ps1", DiscoveredFileKind::PowerShellScript), + &InterpreterInventory::default(), + ), + ResolvedCandidate::Unusable(ResolutionFailure::MissingPowerShellHost) + ); + } +} diff --git a/copilot_shim/src/identity.rs b/copilot_shim/src/identity.rs new file mode 100644 index 00000000000000..53ed3abf09ca3b --- /dev/null +++ b/copilot_shim/src/identity.rs @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::io::{self, Read}; + +use crate::model::FileIdentityState; +use crate::runtime::PathInspection; + +const READ_BUFFER_SIZE: usize = 8 * 1024; + +pub(crate) fn is_same_file(current: &PathInspection, candidate: &PathInspection) -> bool { + current.canonical_path == candidate.canonical_path + || matches!( + (¤t.file_identity, &candidate.file_identity), + ( + FileIdentityState::Supported(current), + FileIdentityState::Supported(candidate) + ) if current == candidate + ) +} + +pub(crate) fn contains_marker(mut reader: impl Read, marker: &[u8]) -> io::Result { + if marker.is_empty() { + return Ok(true); + } + + let mut buffer = [0; READ_BUFFER_SIZE]; + let mut overlap = Vec::with_capacity(marker.len().saturating_sub(1)); + loop { + let bytes_read = reader.read(&mut buffer)?; + if bytes_read == 0 { + return Ok(false); + } + + let mut searchable = Vec::with_capacity(overlap.len() + bytes_read); + searchable.extend_from_slice(&overlap); + searchable.extend_from_slice(&buffer[..bytes_read]); + if searchable + .windows(marker.len()) + .any(|window| window == marker) + { + return Ok(true); + } + + let overlap_length = searchable.len().min(marker.len().saturating_sub(1)); + overlap.clear(); + overlap.extend_from_slice(&searchable[searchable.len() - overlap_length..]); + } +} diff --git a/copilot_shim/src/install.rs b/copilot_shim/src/install.rs new file mode 100644 index 00000000000000..5cf45cc021ebfa --- /dev/null +++ b/copilot_shim/src/install.rs @@ -0,0 +1,1501 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::ffi::{OsStr, OsString}; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::candidate::path_directories; +use crate::model::{ + CommandArguments, CommandSpec, ProcessError, ProcessOutcome, ProcessTermination, + SupervisionMode, SystemError, +}; +use crate::runtime::{ + EnvironmentEffects, FileSystemEffects, InspectedFileType, ProcessEffects, Runtime, +}; + +pub(crate) const OFFICIAL_INSTALLER_URL: &str = "https://gh.io/copilot-install"; + +/// Mirrors the official script's PREFIX default, without making that directory visible to the installer via PATH. +#[cfg(unix)] +pub(crate) fn official_install_directory( + runtime: &R, + target: Option, +) -> io::Result> { + if !matches!( + target, + Some( + HostTarget::MacosX64 + | HostTarget::MacosArm64 + | HostTarget::LinuxGnuX64 + | HostTarget::LinuxGnuArm64 + ) + ) { + return Ok(None); + } + let prefix = if let Some(prefix) = runtime + .environment_variable("PREFIX") + .filter(|value| !value.is_empty()) + { + PathBuf::from(prefix) + } else if runtime.is_root() { + PathBuf::from("/usr/local") + } else { + PathBuf::from( + runtime + .environment_variable("HOME") + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + io::Error::other("HOME is not set; set HOME or PREFIX to locate the Copilot CLI installation") + })?, + ) + .join(".local") + }; + let prefix = if prefix.is_absolute() { + prefix + } else { + runtime.current_directory()?.join(prefix) + }; + Ok(Some(prefix.join("bin"))) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HostTarget { + WindowsX64, + WindowsArm64, + MacosX64, + MacosArm64, + LinuxGnuX64, + LinuxGnuArm64, + LinuxGnuArmhf, + LinuxMuslX64, + LinuxMuslArm64, +} + +impl HostTarget { + pub(crate) const fn current() -> Option { + if cfg!(all(target_os = "windows", target_arch = "x86_64")) { + Some(Self::WindowsX64) + } else if cfg!(all(target_os = "windows", target_arch = "aarch64")) { + Some(Self::WindowsArm64) + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + Some(Self::MacosX64) + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + Some(Self::MacosArm64) + } else if cfg!(all( + target_os = "linux", + target_env = "gnu", + target_arch = "x86_64" + )) { + Some(Self::LinuxGnuX64) + } else if cfg!(all( + target_os = "linux", + target_env = "gnu", + target_arch = "aarch64" + )) { + Some(Self::LinuxGnuArm64) + } else if cfg!(all( + target_os = "linux", + target_env = "gnu", + target_arch = "arm" + )) { + Some(Self::LinuxGnuArmhf) + } else if cfg!(all( + target_os = "linux", + target_env = "musl", + target_arch = "x86_64" + )) { + Some(Self::LinuxMuslX64) + } else if cfg!(all( + target_os = "linux", + target_env = "musl", + target_arch = "aarch64" + )) { + Some(Self::LinuxMuslArm64) + } else { + None + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ToolInventory { + /// The running shim, which installs GitHub's MSI on Windows through `--vscode-shim install --interactive`. + pub(crate) shim: Option, + pub(crate) brew: Option, + pub(crate) curl: Option, + pub(crate) wget: Option, + pub(crate) bash: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Tool { + Brew, + Curl, + Wget, +} + +impl Tool { + /// The command the user installs to provide this tool. + pub(crate) const fn command(self) -> &'static str { + match self { + Self::Brew => "brew", + Self::Curl => "curl", + Self::Wget => "wget", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InstallerRoute { + Msi, + Homebrew, + Curl, + Wget, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum UnsupportedTarget { + LinuxArmhf, + LinuxMusl, +} + +impl UnsupportedTarget { + pub(crate) const fn guidance(self) -> &'static str { + match self { + Self::LinuxArmhf => { + "Automatic installation is unavailable on GNU/Linux ARMhf. Install a supported GitHub Copilot CLI release manually." + } + Self::LinuxMusl => { + "Automatic installation is unavailable for musl builds. Install the matching copilot-linuxmusl release from https://github.com/github/copilot-cli/releases." + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum InstallerPlanError { + Unsupported(UnsupportedTarget), + MissingPrerequisites(Vec), +} + +pub(crate) fn installer_plan( + target: HostTarget, + tools: &ToolInventory, +) -> Result, InstallerPlanError> { + match target { + HostTarget::WindowsX64 | HostTarget::WindowsArm64 => Ok(vec![InstallerRoute::Msi]), + HostTarget::MacosX64 | HostTarget::MacosArm64 => downloader_plan(tools, true), + HostTarget::LinuxGnuX64 | HostTarget::LinuxGnuArm64 => downloader_plan(tools, false), + HostTarget::LinuxGnuArmhf => Err(InstallerPlanError::Unsupported( + UnsupportedTarget::LinuxArmhf, + )), + HostTarget::LinuxMuslX64 | HostTarget::LinuxMuslArm64 => Err( + InstallerPlanError::Unsupported(UnsupportedTarget::LinuxMusl), + ), + } +} + +fn downloader_plan( + tools: &ToolInventory, + include_homebrew: bool, +) -> Result, InstallerPlanError> { + let mut routes = Vec::new(); + if include_homebrew && tools.brew.is_some() { + routes.push(InstallerRoute::Homebrew); + } + if tools.curl.is_some() { + routes.push(InstallerRoute::Curl); + } + if tools.wget.is_some() { + routes.push(InstallerRoute::Wget); + } + if routes.is_empty() { + let mut missing = Vec::new(); + if include_homebrew { + missing.push(Tool::Brew); + } + missing.extend([Tool::Curl, Tool::Wget]); + Err(InstallerPlanError::MissingPrerequisites(missing)) + } else { + Ok(routes) + } +} + +/// Finds the tools the install routes need. A PATH entry that can't be inspected is skipped, as a shell would. +pub(crate) fn discover_tools(runtime: &R, target: HostTarget) -> ToolInventory +where + R: EnvironmentEffects + FileSystemEffects, +{ + let mut inventory = ToolInventory::default(); + if matches!(target, HostTarget::WindowsX64 | HostTarget::WindowsArm64) { + inventory.shim = runtime.current_executable().ok(); + return inventory; + } + let directories = path_directories(runtime); + inventory.bash = find_first(runtime, &directories, "bash"); + inventory.curl = find_first(runtime, &directories, "curl"); + inventory.wget = find_first(runtime, &directories, "wget"); + if matches!(target, HostTarget::MacosX64 | HostTarget::MacosArm64) { + inventory.brew = find_first(runtime, &directories, "brew"); + } + inventory +} + +fn find_first( + runtime: &R, + directories: &[PathBuf], + name: &str, +) -> Option { + directories + .iter() + .map(|directory| directory.join(name)) + .find(|path| { + matches!( + runtime.inspect_path(path), + Ok(Some(inspection)) + if inspection.file_type == InspectedFileType::RegularFile && inspection.executable + ) + }) +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InstallerStage { + Install, + Download, + RunScript, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct InstallerAttempt { + pub(crate) route: InstallerRoute, + pub(crate) stage: InstallerStage, + pub(crate) result: InstallerAttemptResult, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum InstallerAttemptResult { + NumericExit(i32), + #[cfg(any(unix, test))] + UnixSignal(i32), + Cancelled, + ProcessError(ProcessError), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum InstallerFailure { + MissingBash, + TemporaryScript(SystemError), + Environment(String), + AttemptsFailed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum InstallerResult { + Succeeded { + attempts: Vec, + route: InstallerRoute, + }, + Cancelled { + attempts: Vec, + }, + Failed { + failure: InstallerFailure, + attempts: Vec, + }, +} + +pub(crate) trait TemporaryScript { + fn path(&self) -> &Path; +} + +pub(crate) trait TemporaryScriptFactory { + fn create(&self) -> io::Result>; +} + +struct NativeTemporaryScript { + path: tempfile::TempPath, +} + +impl TemporaryScript for NativeTemporaryScript { + fn path(&self) -> &Path { + &self.path + } +} + +pub(crate) struct NativeTemporaryScriptFactory; + +impl TemporaryScriptFactory for NativeTemporaryScriptFactory { + fn create(&self) -> io::Result> { + let file = tempfile::Builder::new() + .prefix("vscode-copilot-install-") + .suffix(".sh") + .tempfile()?; + Ok(Box::new(NativeTemporaryScript { + path: file.into_temp_path(), + })) + } +} + +pub(crate) fn run_installer( + runtime: &R, + routes: &[InstallerRoute], + tools: &ToolInventory, + verbose: bool, +) -> InstallerResult { + run_installer_with( + runtime, + &NativeTemporaryScriptFactory, + routes, + tools, + verbose, + || { + #[cfg(unix)] + { + crate::candidate::installer_path(runtime).map(Some) + } + #[cfg(not(unix))] + { + Ok(None) + } + }, + ) +} + +pub(crate) fn run_installer_with( + runtime: &R, + temporary_scripts: &T, + routes: &[InstallerRoute], + tools: &ToolInventory, + verbose: bool, + mut script_path: impl FnMut() -> io::Result>, +) -> InstallerResult +where + R: ProcessEffects, + T: TemporaryScriptFactory, +{ + let mut attempts = Vec::new(); + for route in routes { + let outcome = match route { + InstallerRoute::Msi => run_command( + runtime, + *route, + InstallerStage::Install, + msi_command(tools, verbose), + &mut attempts, + ), + InstallerRoute::Homebrew => run_command( + runtime, + *route, + InstallerStage::Install, + homebrew_command(tools), + &mut attempts, + ), + InstallerRoute::Curl | InstallerRoute::Wget => { + let Some(bash) = tools.bash.as_ref() else { + return InstallerResult::Failed { + failure: InstallerFailure::MissingBash, + attempts, + }; + }; + let path = match script_path() { + Ok(path) => path, + Err(error) => { + return InstallerResult::Failed { + failure: InstallerFailure::Environment(error.to_string()), + attempts, + } + } + }; + let temporary_script = match temporary_scripts.create() { + Ok(script) => script, + Err(error) => { + return InstallerResult::Failed { + failure: InstallerFailure::TemporaryScript(SystemError::from(&error)), + attempts, + }; + } + }; + let download = downloader_command(*route, tools, temporary_script.path()); + match run_command( + runtime, + *route, + InstallerStage::Download, + download, + &mut attempts, + ) { + RouteOutcome::Succeeded => run_command( + runtime, + *route, + InstallerStage::RunScript, + Some( + native_command( + bash.as_os_str(), + vec![temporary_script.path().as_os_str().to_os_string()], + ) + .with_path(path), + ), + &mut attempts, + ), + outcome => outcome, + } + } + }; + + match outcome { + RouteOutcome::Succeeded => { + return InstallerResult::Succeeded { + attempts, + route: *route, + } + } + RouteOutcome::Cancelled => return InstallerResult::Cancelled { attempts }, + RouteOutcome::Failed => {} + } + } + InstallerResult::Failed { + failure: InstallerFailure::AttemptsFailed, + attempts, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RouteOutcome { + Succeeded, + Cancelled, + Failed, +} + +fn run_command( + runtime: &R, + route: InstallerRoute, + stage: InstallerStage, + command: Option, + attempts: &mut Vec, +) -> RouteOutcome { + let Some(command) = command else { + return RouteOutcome::Failed; + }; + match runtime.supervise(&command, SupervisionMode::InteractiveBootstrap) { + Ok(ProcessOutcome { + termination: ProcessTermination::NumericExit(code), + .. + }) => { + attempts.push(InstallerAttempt { + route, + stage, + result: InstallerAttemptResult::NumericExit(code), + }); + if code == 0 { + RouteOutcome::Succeeded + } else { + RouteOutcome::Failed + } + } + #[cfg(any(unix, test))] + Ok(ProcessOutcome { + termination: ProcessTermination::UnixSignal(signal), + .. + }) => { + attempts.push(InstallerAttempt { + route, + stage, + result: InstallerAttemptResult::UnixSignal(signal), + }); + RouteOutcome::Failed + } + Ok(ProcessOutcome { + termination: ProcessTermination::HandledCancellation, + .. + }) => { + attempts.push(InstallerAttempt { + route, + stage, + result: InstallerAttemptResult::Cancelled, + }); + RouteOutcome::Cancelled + } + Err(error) => { + attempts.push(InstallerAttempt { + route, + stage, + result: InstallerAttemptResult::ProcessError(error), + }); + RouteOutcome::Failed + } + } +} + +fn msi_command(tools: &ToolInventory, verbose: bool) -> Option { + tools.shim.as_ref().map(|shim| { + let mut arguments = vec![OsString::from("--vscode-shim")]; + if verbose { + arguments.extend([OsString::from("verbose"), OsString::from("--vscode-shim")]); + } + arguments.extend([OsString::from("install"), OsString::from("--interactive")]); + native_command(shim.as_os_str(), arguments) + }) +} + +fn homebrew_command(tools: &ToolInventory) -> Option { + tools.brew.as_ref().map(|brew| { + native_command( + brew.as_os_str(), + ["install", "--cask", "copilot-cli"] + .into_iter() + .map(OsString::from) + .collect(), + ) + }) +} + +fn downloader_command( + route: InstallerRoute, + tools: &ToolInventory, + destination: &Path, +) -> Option { + match route { + InstallerRoute::Curl => tools.curl.as_ref().map(|curl| { + native_command( + curl.as_os_str(), + [ + OsString::from("-fsSL"), + OsString::from(OFFICIAL_INSTALLER_URL), + OsString::from("-o"), + destination.as_os_str().to_os_string(), + ] + .into_iter() + .collect(), + ) + }), + InstallerRoute::Wget => tools.wget.as_ref().map(|wget| { + native_command( + wget.as_os_str(), + [ + OsString::from("-O"), + destination.as_os_str().to_os_string(), + OsString::from(OFFICIAL_INSTALLER_URL), + ] + .into_iter() + .collect(), + ) + }), + InstallerRoute::Msi | InstallerRoute::Homebrew => None, + } +} + +fn native_command(program: &OsStr, arguments: Vec) -> CommandSpec { + CommandSpec::new(program.to_os_string(), CommandArguments::Native(arguments)) +} + +#[cfg(test)] +mod tests { + use crate::{install, model, runtime}; + use std::cell::RefCell; + use std::collections::{HashMap, VecDeque}; + use std::ffi::{OsStr, OsString}; + use std::io::{self, BufRead, Cursor, Read, Write}; + use std::path::{Path, PathBuf}; + use std::rc::Rc; + + use install::{ + discover_tools, installer_plan, run_installer_with, HostTarget, InstallerPlanError, + InstallerResult, InstallerRoute, InstallerStage, TemporaryScript, TemporaryScriptFactory, + Tool, ToolInventory, UnsupportedTarget, OFFICIAL_INSTALLER_URL, + }; + use model::{ + CommandArguments, CommandSpec, FileIdentityState, ProcessError, ProcessOutcome, + ProcessTermination, SupervisionMode, + }; + use prompt::PromptResponse; + use runtime::{ + prompt, EnvironmentEffects, FileSystemEffects, InspectedFileType, PathInspection, + ProcessEffects, + }; + + fn path(name: &str) -> Option { + Some(PathBuf::from(name)) + } + + fn all_tools() -> ToolInventory { + ToolInventory { + shim: path("copilot.exe"), + brew: path("brew"), + curl: path("curl"), + wget: path("wget"), + bash: path("bash"), + } + } + + #[cfg(unix)] + #[test] + fn official_install_location_matches_prefix_home_and_effective_user_rules() { + struct Environment { + prefix: Option, + home: Option, + root: bool, + } + impl EnvironmentEffects for Environment { + fn path(&self) -> Option { + None + } + fn current_executable(&self) -> io::Result { + Err(io::ErrorKind::Unsupported.into()) + } + fn current_directory(&self) -> io::Result { + Ok(PathBuf::from("/working")) + } + fn environment_variable(&self, name: &str) -> Option { + match name { + "PREFIX" => self.prefix.clone(), + "HOME" => self.home.clone(), + _ => None, + } + } + fn is_root(&self) -> bool { + self.root + } + } + let observations: Vec<_> = [ + (None, Some("/home/user"), false), + (Some(""), Some("/home/user"), false), + (None, None, true), + (Some("/custom prefix"), None, true), + (Some("relative"), None, false), + ] + .into_iter() + .map(|(prefix, home, root)| { + install::official_install_directory( + &Environment { + prefix: prefix.map(OsString::from), + home: home.map(OsString::from), + root, + }, + Some(HostTarget::LinuxGnuX64), + ) + .expect("install directory") + }) + .collect(); + assert_eq!( + observations, + [ + "/home/user/.local/bin", + "/home/user/.local/bin", + "/usr/local/bin", + "/custom prefix/bin", + "/working/relative/bin", + ] + .into_iter() + .map(|path| Some(PathBuf::from(path))) + .collect::>() + ); + let missing = Environment { + prefix: None, + home: Some(OsString::new()), + root: false, + }; + assert!( + install::official_install_directory(&missing, Some(HostTarget::LinuxGnuX64)).is_err() + ); + assert_eq!( + install::official_install_directory(&missing, Some(HostTarget::LinuxMuslX64)) + .expect("unsupported"), + None + ); + } + + #[test] + fn installer_policy_matrix() { + let tools = all_tools(); + + assert_eq!( + [ + installer_plan(HostTarget::WindowsX64, &tools), + installer_plan(HostTarget::WindowsArm64, &tools), + installer_plan(HostTarget::MacosX64, &tools), + installer_plan(HostTarget::MacosArm64, &tools), + installer_plan(HostTarget::LinuxGnuX64, &tools), + installer_plan(HostTarget::LinuxGnuArm64, &tools), + installer_plan(HostTarget::LinuxGnuArmhf, &tools), + installer_plan(HostTarget::LinuxMuslX64, &tools), + installer_plan(HostTarget::LinuxMuslArm64, &tools), + ], + [ + Ok(vec![InstallerRoute::Msi]), + Ok(vec![InstallerRoute::Msi]), + Ok(vec![ + InstallerRoute::Homebrew, + InstallerRoute::Curl, + InstallerRoute::Wget, + ]), + Ok(vec![ + InstallerRoute::Homebrew, + InstallerRoute::Curl, + InstallerRoute::Wget, + ]), + Ok(vec![InstallerRoute::Curl, InstallerRoute::Wget]), + Ok(vec![InstallerRoute::Curl, InstallerRoute::Wget]), + Err(InstallerPlanError::Unsupported( + UnsupportedTarget::LinuxArmhf, + )), + Err(InstallerPlanError::Unsupported( + UnsupportedTarget::LinuxMusl, + )), + Err(InstallerPlanError::Unsupported( + UnsupportedTarget::LinuxMusl, + )), + ] + ); + assert_eq!( + ( + UnsupportedTarget::LinuxArmhf.guidance(), + UnsupportedTarget::LinuxMusl.guidance(), + ), + ( + "Automatic installation is unavailable on GNU/Linux ARMhf. Install a supported GitHub Copilot CLI release manually.", + "Automatic installation is unavailable for musl builds. Install the matching copilot-linuxmusl release from https://github.com/github/copilot-cli/releases.", + ) + ); + } + + #[test] + fn missing_capabilities_are_typed() { + let no_tools = ToolInventory::default(); + let tools = all_tools(); + let mut no_brew = tools.clone(); + no_brew.brew = None; + let mut no_curl = tools.clone(); + no_curl.curl = None; + let mut no_wget = tools.clone(); + no_wget.wget = None; + + assert_eq!( + ( + installer_plan(HostTarget::WindowsX64, &no_tools), + installer_plan(HostTarget::MacosX64, &no_tools), + installer_plan(HostTarget::LinuxGnuX64, &no_tools), + installer_plan(HostTarget::MacosX64, &no_brew), + installer_plan(HostTarget::MacosX64, &no_curl), + installer_plan(HostTarget::MacosX64, &no_wget), + installer_plan(HostTarget::LinuxGnuX64, &no_curl), + installer_plan(HostTarget::LinuxGnuX64, &no_wget), + ), + ( + Ok(vec![InstallerRoute::Msi]), + Err(InstallerPlanError::MissingPrerequisites(vec![ + Tool::Brew, + Tool::Curl, + Tool::Wget, + ])), + Err(InstallerPlanError::MissingPrerequisites(vec![ + Tool::Curl, + Tool::Wget, + ])), + Ok(vec![InstallerRoute::Curl, InstallerRoute::Wget]), + Ok(vec![InstallerRoute::Homebrew, InstallerRoute::Wget]), + Ok(vec![InstallerRoute::Homebrew, InstallerRoute::Curl]), + Ok(vec![InstallerRoute::Wget]), + Ok(vec![InstallerRoute::Curl]), + ) + ); + } + + #[test] + fn prompt_parser_is_default_no() { + assert_eq!( + [ + prompt::parse_response(b"y"), + prompt::parse_response(b" \tY later"), + prompt::parse_response(b""), + prompt::parse_response(b" \r\n"), + prompt::parse_response(b"yes"), + prompt::parse_response(b"no"), + prompt::parse_response("\u{3000}\u{FF59}".as_bytes()), + prompt::parse_response("\u{FF39}".as_bytes()), + ], + [ + PromptResponse::Accepted, + PromptResponse::Accepted, + PromptResponse::Declined, + PromptResponse::Declined, + PromptResponse::Accepted, + PromptResponse::Declined, + PromptResponse::Accepted, + PromptResponse::Accepted, + ] + ); + } + + #[test] + fn prompts_emit_required_text_flush_and_default_no_at_eof() { + let mut eof_input = Cursor::new(Vec::::new()); + let mut eof_output = FlushRecordingWriter::default(); + let eof = prompt::prompt_with_io(&mut eof_input, &mut eof_output).expect("prompt at EOF"); + let mut accepted_input = Cursor::new(b" Y\n"); + let mut accepted_output = FlushRecordingWriter::default(); + let accepted = prompt::prompt_with_io(&mut accepted_input, &mut accepted_output) + .expect("accepted prompt"); + + assert_eq!( + ( + eof, + String::from_utf8(eof_output.bytes).unwrap(), + eof_output.flushes, + accepted, + accepted_output.flushes, + ), + ( + PromptResponse::Declined, + format!( + "Installation instructions: {}\nInstall GitHub Copilot CLI? [y/N] ", + prompt::INSTALL_DOCUMENTATION_URL + ), + 1, + PromptResponse::Accepted, + 1, + ) + ); + } + #[test] + fn prompt_and_clear_errors_are_propagated() { + let expected = io::ErrorKind::BrokenPipe; + let prompt_error = prompt::prompt_with_io( + &mut Cursor::new(b"y"), + &mut FailingWriter { fail_flush: false }, + ) + .unwrap_err(); + let flush_error = prompt::prompt_with_io( + &mut Cursor::new(b"y"), + &mut FailingWriter { fail_flush: true }, + ) + .unwrap_err(); + let clear_error = + prompt::clear_terminal_with(&mut FailingWriter { fail_flush: false }, true) + .unwrap_err(); + let read_error = + prompt::prompt_with_io(&mut FailingReader, &mut FlushRecordingWriter::default()) + .unwrap_err(); + + assert_eq!( + ( + prompt_error.kind(), + flush_error.kind(), + clear_error.kind(), + read_error.kind(), + ), + ( + expected, + expected, + expected, + io::ErrorKind::PermissionDenied + ) + ); + } + + #[test] + fn redirected_clear_is_a_noop_and_terminal_clear_flushes() { + let mut redirected = FlushRecordingWriter::default(); + prompt::clear_terminal_with(&mut redirected, false).expect("redirected clear"); + let mut terminal = FlushRecordingWriter::default(); + prompt::clear_terminal_with(&mut terminal, true).expect("terminal clear"); + + assert_eq!( + ( + redirected.bytes, + redirected.flushes, + terminal.bytes, + terminal.flushes, + ), + (Vec::new(), 0, b"\x1b[2J\x1b[H".to_vec(), 1) + ); + } + + #[derive(Default)] + struct FlushRecordingWriter { + bytes: Vec, + flushes: usize, + } + + impl Write for FlushRecordingWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.bytes.extend_from_slice(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.flushes += 1; + Ok(()) + } + } + + struct FailingWriter { + fail_flush: bool, + } + + impl Write for FailingWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + if self.fail_flush { + Ok(buffer.len()) + } else { + Err(io::Error::from(io::ErrorKind::BrokenPipe)) + } + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::from(io::ErrorKind::BrokenPipe)) + } + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } + } + + impl BufRead for FailingReader { + fn fill_buf(&mut self) -> io::Result<&[u8]> { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } + + fn consume(&mut self, _amount: usize) {} + } + + #[derive(Clone)] + enum FakeProcessResult { + Exit(i32), + Cancelled, + Error(ProcessError), + } + + struct FakeProcesses { + results: RefCell>, + commands: RefCell>, + tracked_scripts: Rc>>, + scripts_existed_during_commands: RefCell>, + } + + impl FakeProcesses { + fn new(results: impl IntoIterator) -> Self { + Self { + results: RefCell::new(results.into_iter().collect()), + commands: RefCell::new(Vec::new()), + tracked_scripts: Rc::new(RefCell::new(Vec::new())), + scripts_existed_during_commands: RefCell::new(Vec::new()), + } + } + } + + impl ProcessEffects for FakeProcesses { + fn supervise( + &self, + command: &CommandSpec, + mode: SupervisionMode, + ) -> Result { + self.commands.borrow_mut().push((command.clone(), mode)); + self.scripts_existed_during_commands.borrow_mut().push( + self.tracked_scripts + .borrow() + .iter() + .filter(|script| script.active) + .all(|script| script.path.exists()), + ); + match self + .results + .borrow_mut() + .pop_front() + .expect("fake process result") + { + FakeProcessResult::Exit(code) => Ok(ProcessOutcome { + termination: ProcessTermination::NumericExit(code), + captured_output: None, + }), + FakeProcessResult::Cancelled => Ok(ProcessOutcome { + termination: ProcessTermination::HandledCancellation, + captured_output: None, + }), + FakeProcessResult::Error(error) => Err(error), + } + } + } + + struct TrackedPath { + path: PathBuf, + active: bool, + } + + struct TrackedTemporaryScript { + path: tempfile::TempPath, + index: usize, + tracked: Rc>>, + } + + impl TemporaryScript for TrackedTemporaryScript { + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TrackedTemporaryScript { + fn drop(&mut self) { + self.tracked.borrow_mut()[self.index].active = false; + } + } + + struct TrackedTemporaryScriptFactory { + tracked: Rc>>, + } + + impl TemporaryScriptFactory for TrackedTemporaryScriptFactory { + fn create(&self) -> io::Result> { + let path = tempfile::Builder::new() + .prefix("copilot-shim-test-") + .suffix(".sh") + .tempfile()? + .into_temp_path(); + let index = self.tracked.borrow().len(); + self.tracked.borrow_mut().push(TrackedPath { + path: path.to_path_buf(), + active: true, + }); + Ok(Box::new(TrackedTemporaryScript { + path, + index, + tracked: Rc::clone(&self.tracked), + })) + } + } + + fn run_fake( + results: impl IntoIterator, + routes: &[InstallerRoute], + tools: &ToolInventory, + ) -> (InstallerResult, FakeProcesses) { + run_fake_with_verbose(results, routes, tools, false) + } + + fn run_fake_with_verbose( + results: impl IntoIterator, + routes: &[InstallerRoute], + tools: &ToolInventory, + verbose: bool, + ) -> (InstallerResult, FakeProcesses) { + let processes = FakeProcesses::new(results); + let scripts = TrackedTemporaryScriptFactory { + tracked: Rc::clone(&processes.tracked_scripts), + }; + let result = run_installer_with(&processes, &scripts, routes, tools, verbose, || Ok(None)); + (result, processes) + } + + fn native_arguments(command: &CommandSpec) -> Vec { + match command.arguments() { + CommandArguments::Native(arguments) => arguments.clone(), + CommandArguments::WindowsCommand { .. } => panic!("installer command must be native"), + } + } + + #[test] + fn only_the_official_script_receives_the_installer_path() { + for route in [InstallerRoute::Curl, InstallerRoute::Wget] { + let processes = FakeProcesses::new([ + FakeProcessResult::Exit(1), + FakeProcessResult::Exit(0), + FakeProcessResult::Exit(0), + ]); + let scripts = TrackedTemporaryScriptFactory { + tracked: Rc::clone(&processes.tracked_scripts), + }; + let result = run_installer_with( + &processes, + &scripts, + &[InstallerRoute::Homebrew, route], + &all_tools(), + false, + || Ok(Some(OsString::from("filtered-path"))), + ); + assert!(matches!(result, InstallerResult::Succeeded { .. })); + assert_eq!( + processes + .commands + .borrow() + .iter() + .map(|(command, _)| command.path().map(OsString::from)) + .collect::>(), + vec![None, None, Some(OsString::from("filtered-path"))] + ); + } + for route in [InstallerRoute::Homebrew, InstallerRoute::Msi] { + let processes = FakeProcesses::new([FakeProcessResult::Exit(0)]); + let scripts = TrackedTemporaryScriptFactory { + tracked: Rc::clone(&processes.tracked_scripts), + }; + let result = + run_installer_with(&processes, &scripts, &[route], &all_tools(), false, || { + panic!("this route must not request filtered PATH") + }); + assert!(matches!(result, InstallerResult::Succeeded { .. })); + assert_eq!(processes.commands.borrow()[0].0.path(), None); + } + } + + #[test] + fn installer_fallback_order_and_cancellation_are_terminal() { + let tools = all_tools(); + let (fallback, fallback_processes) = run_fake( + [ + FakeProcessResult::Exit(1), + FakeProcessResult::Exit(2), + FakeProcessResult::Exit(0), + FakeProcessResult::Exit(0), + ], + &[ + InstallerRoute::Homebrew, + InstallerRoute::Curl, + InstallerRoute::Wget, + ], + &tools, + ); + let (cancelled, cancelled_processes) = run_fake( + [FakeProcessResult::Cancelled], + &[InstallerRoute::Curl, InstallerRoute::Wget], + &tools, + ); + + assert!(matches!(fallback, InstallerResult::Succeeded { .. })); + assert_eq!( + ( + fallback_processes + .commands + .borrow() + .iter() + .map(|(command, _)| command.program().to_os_string()) + .collect::>(), + fallback_processes + .commands + .borrow() + .iter() + .map(|(_, mode)| *mode) + .collect::>(), + ), + ( + ["brew", "curl", "wget", "bash"] + .into_iter() + .map(OsString::from) + .collect::>(), + vec![SupervisionMode::InteractiveBootstrap; 4], + ) + ); + assert!(matches!(&cancelled, InstallerResult::Cancelled { .. })); + assert_eq!(cancelled_processes.commands.borrow().len(), 1); + let InstallerResult::Cancelled { attempts } = cancelled else { + unreachable!(); + }; + assert_eq!( + attempts[0].result, + install::InstallerAttemptResult::Cancelled + ); + } + + #[test] + fn missing_bash_prevents_download_and_fallback() { + let mut tools = all_tools(); + tools.bash = None; + let (result, processes) = run_fake( + Vec::::new(), + &[InstallerRoute::Curl, InstallerRoute::Wget], + &tools, + ); + + assert_eq!( + result, + InstallerResult::Failed { + failure: install::InstallerFailure::MissingBash, + attempts: Vec::new(), + } + ); + assert!(processes.commands.borrow().is_empty()); + assert!(processes.tracked_scripts.borrow().is_empty()); + } + + #[test] + fn downloader_commands_use_temp_files_then_inherited_bash() { + for (route, downloader, expected_arguments) in [ + ( + InstallerRoute::Curl, + "curl", + vec![ + OsString::from("-fsSL"), + OsString::from(OFFICIAL_INSTALLER_URL), + OsString::from("-o"), + ], + ), + ( + InstallerRoute::Wget, + "wget", + vec![OsString::from("-O"), OsString::from(OFFICIAL_INSTALLER_URL)], + ), + ] { + let (result, processes) = run_fake( + [FakeProcessResult::Exit(0), FakeProcessResult::Exit(0)], + &[route], + &all_tools(), + ); + let commands = processes.commands.borrow(); + let temporary_path = processes.tracked_scripts.borrow()[0].path.clone(); + let download_arguments = native_arguments(&commands[0].0); + let bash_arguments = native_arguments(&commands[1].0); + + assert!(matches!(result, InstallerResult::Succeeded { .. })); + assert_eq!( + ( + commands[0].0.program(), + commands[0].1, + download_arguments + .iter() + .filter(|argument| *argument == temporary_path.as_os_str()) + .count(), + expected_arguments + .iter() + .all(|argument| download_arguments.contains(argument)), + commands[1].0.program(), + commands[1].1, + bash_arguments, + processes.scripts_existed_during_commands.borrow().clone(), + temporary_path.exists(), + ), + ( + OsStr::new(downloader), + SupervisionMode::InteractiveBootstrap, + 1, + true, + OsStr::new("bash"), + SupervisionMode::InteractiveBootstrap, + vec![temporary_path.as_os_str().to_os_string()], + vec![true, true], + false, + ) + ); + } + } + + #[test] + fn temporary_scripts_are_cleaned_after_failure_and_cancellation_return() { + for process_results in [ + vec![FakeProcessResult::Exit(9)], + vec![FakeProcessResult::Exit(0), FakeProcessResult::Exit(9)], + vec![FakeProcessResult::Exit(0), FakeProcessResult::Cancelled], + vec![FakeProcessResult::Error(ProcessError::SpawnFailed { + program: OsString::from("curl"), + error: model::SystemError { + kind: io::ErrorKind::NotFound, + raw_os_error: None, + }, + })], + ] { + let command_count = process_results.len(); + let (_result, processes) = + run_fake(process_results, &[InstallerRoute::Curl], &all_tools()); + let tracked = processes.tracked_scripts.borrow(); + assert_eq!( + ( + processes + .scripts_existed_during_commands + .borrow() + .as_slice(), + tracked[0].active, + tracked[0].path.exists(), + ), + (vec![true; command_count].as_slice(), false, false) + ); + } + } + + #[test] + fn windows_command_runs_the_shim_msi_install_with_matching_verbosity() { + let tools = all_tools(); + let (quiet_result, quiet_processes) = + run_fake([FakeProcessResult::Exit(0)], &[InstallerRoute::Msi], &tools); + let (verbose_result, verbose_processes) = run_fake_with_verbose( + [FakeProcessResult::Exit(0)], + &[InstallerRoute::Msi], + &tools, + true, + ); + let quiet_commands = quiet_processes.commands.borrow(); + let verbose_commands = verbose_processes.commands.borrow(); + + assert!(matches!(quiet_result, InstallerResult::Succeeded { .. })); + assert!(matches!(verbose_result, InstallerResult::Succeeded { .. })); + assert_eq!( + ( + quiet_commands[0].0.program(), + native_arguments(&quiet_commands[0].0), + quiet_commands[0].1, + verbose_commands[0].0.program(), + native_arguments(&verbose_commands[0].0), + verbose_commands[0].1, + ), + ( + OsStr::new("copilot.exe"), + ["--vscode-shim", "install", "--interactive"] + .into_iter() + .map(OsString::from) + .collect(), + SupervisionMode::InteractiveBootstrap, + OsStr::new("copilot.exe"), + [ + "--vscode-shim", + "verbose", + "--vscode-shim", + "install", + "--interactive" + ] + .into_iter() + .map(OsString::from) + .collect(), + SupervisionMode::InteractiveBootstrap, + ) + ); + } + + #[test] + fn installer_diagnostics_preserve_each_failed_stage() { + let (result, _processes) = run_fake( + [ + FakeProcessResult::Exit(3), + FakeProcessResult::Exit(0), + FakeProcessResult::Exit(4), + ], + &[InstallerRoute::Curl, InstallerRoute::Wget], + &all_tools(), + ); + let InstallerResult::Failed { attempts, .. } = result else { + panic!("expected failed installers"); + }; + + assert_eq!( + attempts + .into_iter() + .map(|attempt| (attempt.route, attempt.stage, attempt.result)) + .collect::>(), + vec![ + ( + InstallerRoute::Curl, + InstallerStage::Download, + install::InstallerAttemptResult::NumericExit(3), + ), + ( + InstallerRoute::Wget, + InstallerStage::Download, + install::InstallerAttemptResult::NumericExit(0), + ), + ( + InstallerRoute::Wget, + InstallerStage::RunScript, + install::InstallerAttemptResult::NumericExit(4), + ), + ] + ); + } + + #[derive(Default)] + struct FakeFileSystem { + path: Option, + current_directory: PathBuf, + inspections: HashMap, + } + + impl EnvironmentEffects for FakeFileSystem { + fn path(&self) -> Option { + self.path.clone() + } + + fn current_executable(&self) -> io::Result { + Err(io::Error::from(io::ErrorKind::Unsupported)) + } + + fn current_directory(&self) -> io::Result { + Ok(self.current_directory.clone()) + } + } + + impl FileSystemEffects for FakeFileSystem { + fn inspect_path(&self, path: &Path) -> io::Result> { + Ok(self.inspections.get(path).cloned()) + } + + fn open_file(&self, _path: &Path) -> io::Result> { + Err(io::Error::from(io::ErrorKind::Unsupported)) + } + } + + fn executable(path: &Path) -> PathInspection { + PathInspection { + canonical_path: path.to_path_buf(), + file_identity: FileIdentityState::Unsupported, + file_type: InspectedFileType::RegularFile, + executable: true, + file_size: 0, + } + } + + #[test] + fn tool_discovery_uses_path_order_and_executable_files() { + let first = PathBuf::from("/first"); + let second = PathBuf::from("/second"); + let mut runtime = FakeFileSystem { + path: Some(std::env::join_paths([&first, &second]).unwrap()), + current_directory: PathBuf::from("/working"), + inspections: HashMap::new(), + }; + runtime.inspections.insert( + first.join("curl"), + PathInspection { + canonical_path: first.join("curl"), + file_identity: FileIdentityState::Unsupported, + file_type: InspectedFileType::Directory, + executable: true, + file_size: 0, + }, + ); + for tool in ["bash", "curl", "wget"] { + let candidate = second.join(tool); + runtime + .inspections + .insert(candidate.clone(), executable(&candidate)); + } + + assert_eq!( + discover_tools(&runtime, HostTarget::LinuxGnuX64), + ToolInventory { + bash: Some(second.join("bash")), + curl: Some(second.join("curl")), + wget: Some(second.join("wget")), + ..ToolInventory::default() + } + ); + } + + #[cfg(unix)] + #[test] + fn tool_discovery_preserves_non_utf8_path_entries() { + use std::os::unix::ffi::OsStringExt; + + let directory = PathBuf::from(OsString::from_vec(b"/tmp/native-\xFF".to_vec())); + let bash = directory.join("bash"); + let mut runtime = FakeFileSystem { + path: Some(std::env::join_paths([&directory]).unwrap()), + current_directory: PathBuf::from("/working"), + inspections: HashMap::new(), + }; + runtime.inspections.insert(bash.clone(), executable(&bash)); + + assert_eq!( + discover_tools(&runtime, HostTarget::LinuxGnuX64).bash, + Some(bash) + ); + } +} diff --git a/copilot_shim/src/invocation.rs b/copilot_shim/src/invocation.rs new file mode 100644 index 00000000000000..a4f7c17c2c1b77 --- /dev/null +++ b/copilot_shim/src/invocation.rs @@ -0,0 +1,591 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//! Parses the `--vscode-shim` options that VS Code passes to the shim. +//! +//! The options form a prefix of the command line: +//! +//! ```text +//! copilot [--vscode-shim ]... [--] [copilot arguments...] +//! copilot [--vscode-shim verbose]... --vscode-shim [command options...] +//! ``` +//! +//! Modifiers are removed and the remaining arguments are forwarded to the Copilot CLI. Commands run and exit without +//! ever launching the Copilot CLI. `verbose` may precede a command, while `clear` may not. Parsing stops at the first +//! argument that is not a `--vscode-shim` option, so arguments meant for the Copilot CLI are never consumed. + +use std::ffi::{OsStr, OsString}; +use std::path::PathBuf; +use std::time::Duration; + +pub(crate) const OPTION: &str = "--vscode-shim"; + +/// Exit code for a malformed or unknown `--vscode-shim` option. +pub(crate) const USAGE_EXIT_CODE: i32 = 2; + +const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const MAXIMUM_PROBE_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum Invocation { + Launch { + clear: bool, + arguments: Vec, + }, + Probe(ProbeOptions), + Install(InstallOptions), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ParsedInvocation { + pub(crate) verbose: bool, + pub(crate) result: Result, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProbeScope { + User, + Machine, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProbeOptions { + pub(crate) scope: ProbeScope, + pub(crate) network: bool, + pub(crate) timeout: Duration, + pub(crate) result_file: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum InstallMode { + /// Invoked from the terminal after the user accepted the install prompt: reports progress on the console. + Interactive, + /// Invoked by VS Code setup: reports progress and the result through files. + Setup { + progress_file: Option, + result_file: PathBuf, + cancel_file: Option, + running_mutex: Option, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct InstallOptions { + pub(crate) mode: InstallMode, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum InvocationError { + MissingOptionName, + UnknownOption(OsString), + CommandAfterClear(OsString), + UnknownCommandOption { + command: &'static str, + option: OsString, + }, + MissingValue { + command: &'static str, + option: &'static str, + }, + InvalidValue { + command: &'static str, + option: &'static str, + value: OsString, + }, + MissingRequiredOption { + command: &'static str, + option: &'static str, + }, +} + +impl std::fmt::Display for InvocationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingOptionName => write!(formatter, "{OPTION} requires an option name"), + Self::UnknownOption(name) => write!(formatter, "unknown {OPTION} option {name:?}"), + Self::CommandAfterClear(name) => write!( + formatter, + "{OPTION} {name:?} cannot be combined with {OPTION} clear" + ), + Self::UnknownCommandOption { command, option } => { + write!( + formatter, + "unknown option {option:?} for {OPTION} {command}" + ) + } + Self::MissingValue { command, option } => { + write!( + formatter, + "{option} requires a value for {OPTION} {command}" + ) + } + Self::InvalidValue { + command, + option, + value, + } => write!( + formatter, + "invalid value {value:?} for {option} of {OPTION} {command}" + ), + Self::MissingRequiredOption { command, option } => { + write!(formatter, "{OPTION} {command} requires {option}") + } + } + } +} + +pub(crate) fn parse(arguments: Vec) -> ParsedInvocation { + let mut verbose = false; + let result = parse_invocation(arguments, &mut verbose); + ParsedInvocation { verbose, result } +} + +fn parse_invocation( + arguments: Vec, + verbose: &mut bool, +) -> Result { + let mut clear = false; + let mut index = 0; + while arguments + .get(index) + .is_some_and(|argument| argument == OPTION) + { + let Some(name) = arguments.get(index + 1) else { + return Err(InvocationError::MissingOptionName); + }; + match name.to_str() { + Some("clear") => { + clear = true; + index += 2; + } + Some("verbose") => { + *verbose = true; + index += 2; + } + Some("probe" | "install") if clear => { + return Err(InvocationError::CommandAfterClear(name.clone())); + } + Some("probe") => return parse_probe(&arguments[index + 2..]), + Some("install") => return parse_install(&arguments[index + 2..]), + _ => return Err(InvocationError::UnknownOption(name.clone())), + } + } + + // An explicit `--` ends the prefix, but only right after at least one option: a bare `--` belongs to the CLI. + if index > 0 + && arguments + .get(index) + .is_some_and(|argument| argument == "--") + { + index += 1; + } + + Ok(Invocation::Launch { + clear, + arguments: arguments.into_iter().skip(index).collect(), + }) +} + +fn parse_probe(options: &[OsString]) -> Result { + const COMMAND: &str = "probe"; + let mut scope = ProbeScope::User; + let mut network = true; + let mut timeout = DEFAULT_PROBE_TIMEOUT; + let mut result_file = None; + + let mut options = options.iter(); + while let Some(option) = options.next() { + match option.to_str() { + Some("--scope") => { + let value = required_value(&mut options, COMMAND, "--scope")?; + scope = match value.to_str() { + Some("user") => ProbeScope::User, + Some("machine") => ProbeScope::Machine, + _ => { + return Err(InvocationError::InvalidValue { + command: COMMAND, + option: "--scope", + value: value.clone(), + }) + } + }; + } + Some("--no-network") => network = false, + Some("--timeout-ms") => { + let value = required_value(&mut options, COMMAND, "--timeout-ms")?; + let milliseconds = value + .to_str() + .and_then(|value| value.parse::().ok()) + .filter(|milliseconds| *milliseconds > 0) + .ok_or_else(|| InvocationError::InvalidValue { + command: COMMAND, + option: "--timeout-ms", + value: value.clone(), + })?; + timeout = Duration::from_millis(milliseconds).min(MAXIMUM_PROBE_TIMEOUT); + } + Some("--result-file") => { + result_file = Some(PathBuf::from(required_value( + &mut options, + COMMAND, + "--result-file", + )?)); + } + _ => { + return Err(InvocationError::UnknownCommandOption { + command: COMMAND, + option: option.clone(), + }) + } + } + } + + Ok(Invocation::Probe(ProbeOptions { + scope, + network, + timeout, + result_file: result_file.ok_or(InvocationError::MissingRequiredOption { + command: COMMAND, + option: "--result-file", + })?, + })) +} + +fn parse_install(options: &[OsString]) -> Result { + const COMMAND: &str = "install"; + let mut interactive = false; + let mut non_interactive = false; + let mut consent = false; + let mut progress_file = None; + let mut result_file = None; + let mut cancel_file = None; + let mut running_mutex = None; + + let mut options = options.iter(); + while let Some(option) = options.next() { + match option.to_str() { + Some("--interactive") => interactive = true, + Some("--non-interactive") => non_interactive = true, + Some("--consent=installer") => consent = true, + Some("--progress-file") => { + progress_file = Some(PathBuf::from(required_value( + &mut options, + COMMAND, + "--progress-file", + )?)); + } + Some("--result-file") => { + result_file = Some(PathBuf::from(required_value( + &mut options, + COMMAND, + "--result-file", + )?)); + } + Some("--cancel-file") => { + cancel_file = Some(PathBuf::from(required_value( + &mut options, + COMMAND, + "--cancel-file", + )?)); + } + Some("--running-mutex") => { + running_mutex = + Some(required_value(&mut options, COMMAND, "--running-mutex")?.clone()); + } + _ => { + return Err(InvocationError::UnknownCommandOption { + command: COMMAND, + option: option.clone(), + }) + } + } + } + + let mode = match (interactive, non_interactive) { + (true, false) => InstallMode::Interactive, + (false, true) => { + // A silent install must carry the consent that setup collected on its page or command line. + if !consent { + return Err(InvocationError::MissingRequiredOption { + command: COMMAND, + option: "--consent=installer", + }); + } + InstallMode::Setup { + progress_file, + result_file: result_file.ok_or(InvocationError::MissingRequiredOption { + command: COMMAND, + option: "--result-file", + })?, + cancel_file, + running_mutex, + } + } + _ => { + return Err(InvocationError::MissingRequiredOption { + command: COMMAND, + option: "exactly one of --interactive or --non-interactive", + }) + } + }; + Ok(Invocation::Install(InstallOptions { mode })) +} + +fn required_value<'a>( + options: &mut std::slice::Iter<'a, OsString>, + command: &'static str, + option: &'static str, +) -> Result<&'a OsString, InvocationError> { + options + .next() + .filter(|value| !value.is_empty() && value.as_os_str() != OsStr::new(OPTION)) + .ok_or(InvocationError::MissingValue { command, option }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn arguments(values: &[&str]) -> Vec { + values.iter().map(OsString::from).collect() + } + + fn parsed(verbose: bool, result: Result) -> ParsedInvocation { + ParsedInvocation { verbose, result } + } + + fn launch(verbose: bool, clear: bool, forwarded: &[&str]) -> ParsedInvocation { + parsed( + verbose, + Ok(Invocation::Launch { + clear, + arguments: arguments(forwarded), + }), + ) + } + + #[test] + fn modifiers_are_removed_and_everything_else_is_forwarded() { + assert_eq!( + [ + parse(arguments(&[])), + parse(arguments(&["--resume", "id"])), + parse(arguments(&["--vscode-shim", "clear", "--resume", "id"])), + parse(arguments(&[ + "--vscode-shim", + "clear", + "--vscode-shim", + "verbose", + "--", + "--vscode-shim", + "clear" + ])), + parse(arguments(&[ + "--vscode-shim", + "verbose", + "--vscode-shim", + "verbose" + ])), + parse(arguments(&["--", "--vscode-shim", "clear"])), + parse(arguments(&["-p", "--vscode-shim", "clear"])), + parse(arguments(&["--clear", "x"])), + ], + [ + launch(false, false, &[]), + launch(false, false, &["--resume", "id"]), + launch(false, true, &["--resume", "id"]), + launch(true, true, &["--vscode-shim", "clear"]), + launch(true, false, &[]), + launch(false, false, &["--", "--vscode-shim", "clear"]), + launch(false, false, &["-p", "--vscode-shim", "clear"]), + launch(false, false, &["--clear", "x"]), + ] + ); + } + + #[test] + fn commands_never_launch_and_parse_their_options() { + assert_eq!( + [ + parse(arguments(&[ + "--vscode-shim", + "verbose", + "--vscode-shim", + "probe", + "--scope", + "machine", + "--no-network", + "--timeout-ms", + "250", + "--result-file", + "probe.ini", + ])), + parse(arguments(&[ + "--vscode-shim", + "install", + "--non-interactive", + "--consent=installer", + "--progress-file", + "progress.ini", + "--result-file", + "result.ini", + "--cancel-file", + "cancel", + "--running-mutex", + "mutex", + ])), + parse(arguments(&[ + "--vscode-shim", + "verbose", + "--vscode-shim", + "install", + "--interactive" + ])), + ], + [ + parsed( + true, + Ok(Invocation::Probe(ProbeOptions { + scope: ProbeScope::Machine, + network: false, + timeout: Duration::from_millis(250), + result_file: PathBuf::from("probe.ini"), + })) + ), + parsed( + false, + Ok(Invocation::Install(InstallOptions { + mode: InstallMode::Setup { + progress_file: Some(PathBuf::from("progress.ini")), + result_file: PathBuf::from("result.ini"), + cancel_file: Some(PathBuf::from("cancel")), + running_mutex: Some(OsString::from("mutex")), + }, + })) + ), + parsed( + true, + Ok(Invocation::Install(InstallOptions { + mode: InstallMode::Interactive, + })) + ), + ] + ); + } + + #[test] + fn malformed_options_are_rejected() { + assert_eq!( + [ + parse(arguments(&["--vscode-shim"])), + parse(arguments(&["--vscode-shim", "future-option", "--resume"])), + parse(arguments(&[ + "--vscode-shim", + "clear", + "--vscode-shim", + "verbose", + "--vscode-shim", + "probe" + ])), + parse(arguments(&["--vscode-shim", "probe", "extra"])), + parse(arguments(&[ + "--vscode-shim", + "verbose", + "--vscode-shim", + "probe" + ])), + parse(arguments(&[ + "--vscode-shim", + "probe", + "--scope", + "everyone", + "--result-file", + "x" + ])), + parse(arguments(&[ + "--vscode-shim", + "probe", + "--timeout-ms", + "0", + "--result-file", + "x" + ])), + parse(arguments(&["--vscode-shim", "probe", "--result-file"])), + parse(arguments(&[ + "--vscode-shim", + "install", + "--non-interactive", + "--result-file", + "x" + ])), + parse(arguments(&["--vscode-shim", "install"])), + ], + [ + parsed(false, Err(InvocationError::MissingOptionName)), + parsed( + false, + Err(InvocationError::UnknownOption(OsString::from( + "future-option" + ))) + ), + parsed( + true, + Err(InvocationError::CommandAfterClear(OsString::from("probe"))) + ), + parsed( + false, + Err(InvocationError::UnknownCommandOption { + command: "probe", + option: OsString::from("extra"), + }) + ), + parsed( + true, + Err(InvocationError::MissingRequiredOption { + command: "probe", + option: "--result-file", + }) + ), + parsed( + false, + Err(InvocationError::InvalidValue { + command: "probe", + option: "--scope", + value: OsString::from("everyone"), + }) + ), + parsed( + false, + Err(InvocationError::InvalidValue { + command: "probe", + option: "--timeout-ms", + value: OsString::from("0"), + }) + ), + parsed( + false, + Err(InvocationError::MissingValue { + command: "probe", + option: "--result-file", + }) + ), + parsed( + false, + Err(InvocationError::MissingRequiredOption { + command: "install", + option: "--consent=installer", + }) + ), + parsed( + false, + Err(InvocationError::MissingRequiredOption { + command: "install", + option: "exactly one of --interactive or --non-interactive", + }) + ), + ] + ); + } +} diff --git a/copilot_shim/src/legacy.rs b/copilot_shim/src/legacy.rs new file mode 100644 index 00000000000000..2c07452e11362d --- /dev/null +++ b/copilot_shim/src/legacy.rs @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +pub(crate) const LEGACY_INSPECTION_LIMIT: usize = 128 * 1024; + +/// Whether `bytes`, the start of a candidate file, is a script shim that an earlier version of VS Code wrote. Those +/// shims were written as UTF-8, so text in another encoding, such as a `.cmd` file in the OEM code page or a UTF-16 +/// `.ps1` file, is never one. +pub(crate) fn is_legacy_wrapper(bytes: &[u8]) -> bool { + let bytes = &bytes[..bytes.len().min(LEGACY_INSPECTION_LIMIT)]; + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + let text = match std::str::from_utf8(bytes) { + Ok(text) => text, + // The inspected prefix can end inside a character. + Err(error) if error.error_len().is_none() => { + std::str::from_utf8(&bytes[..error.valid_up_to()]).unwrap_or_default() + } + Err(_) => return false, + }; + let normalized = text.replace("\r\n", "\n"); + + is_posix_launcher(&normalized) + || is_git_bash_wrapper(&normalized) + || is_windows_bootstrapper(&normalized) + || is_windows_command_wrapper(&normalized) +} +fn is_posix_launcher(text: &str) -> bool { + text.starts_with("#!/bin/sh") + && contains_all( + text, + &[ + "unset NODE_OPTIONS", + "ELECTRON_RUN_AS_NODE=1", + "copilotCLIShim.js", + "\"$@\"", + ], + ) +} + +fn is_git_bash_wrapper(text: &str) -> bool { + text.starts_with("#!/bin/sh") && contains_all(text, &["exec", "copilot.bat", "\"$@\""]) +} + +fn is_windows_bootstrapper(text: &str) -> bool { + let text = text.to_ascii_lowercase(); + contains_all( + &text, + &[ + "windows github copilot cli bootstrapper", + "function find-realcopilot", + "function test-andlaunchcopilot", + "$packagename = \"@github/copilot\"", + ], + ) +} + +fn is_windows_command_wrapper(text: &str) -> bool { + let text = text.to_ascii_lowercase(); + text.starts_with("@echo off") + && contains_all(&text, &["powershell", "-executionpolicy bypass", "-file"]) + && (text.contains("copilot.ps1") || text.contains("copilotclishim.ps1")) +} + +fn contains_all(text: &str, markers: &[&str]) -> bool { + markers.iter().all(|marker| text.contains(marker)) +} diff --git a/copilot_shim/src/lib.rs b/copilot_shim/src/lib.rs new file mode 100644 index 00000000000000..36ea62cca42238 --- /dev/null +++ b/copilot_shim/src/lib.rs @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +mod app; +mod candidate; +mod command; +mod identity; +mod install; +mod invocation; +mod legacy; +mod model; +mod runtime; +mod setup; +#[cfg(any(windows, test))] +mod version; + +use std::ffi::OsString; + +use install::HostTarget; +#[cfg(any(windows, test))] +pub(crate) use runtime::platform; +use runtime::NativeRuntime; + +const SHIM_MARKER: &[u8] = b"VSCODE_COPILOT_RUST_SHIM_V1"; + +pub fn run(arguments: impl IntoIterator) -> i32 { + retain_binary_marker(); + + let runtime = NativeRuntime::default(); + let arguments = arguments.into_iter().collect(); + app::run(&runtime, arguments, HostTarget::current()) +} + +#[inline(never)] +fn retain_binary_marker() { + std::hint::black_box(SHIM_MARKER); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marker_is_stable() { + retain_binary_marker(); + + let expected = ["VSCODE_COPILOT_", "RUST_SHIM_V1"].concat(); + assert_eq!(SHIM_MARKER, expected.as_bytes()); + } +} diff --git a/copilot_shim/src/main.rs b/copilot_shim/src/main.rs new file mode 100644 index 00000000000000..fc2d485aa4c73b --- /dev/null +++ b/copilot_shim/src/main.rs @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +fn main() { + let exit_code = copilot_shim::run(std::env::args_os().skip(1)); + std::process::exit(exit_code); +} diff --git a/copilot_shim/src/model.rs b/copilot_shim/src/model.rs new file mode 100644 index 00000000000000..a7af5d52509277 --- /dev/null +++ b/copilot_shim/src/model.rs @@ -0,0 +1,238 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +#[cfg(any(windows, test))] +use std::time::Duration; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum FileIdentity { + #[cfg(unix)] + Unix { device: u64, inode: u64 }, + #[cfg(windows)] + Windows { volume: u64, file_id: u128 }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum FileIdentityState { + Supported(FileIdentity), + #[cfg(any(windows, test))] + Unsupported, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DiscoveredFileKind { + #[cfg(any(not(windows), test))] + UnixExecutable, + #[cfg(any(windows, test))] + WindowsExecutable, + #[cfg(any(windows, test))] + CommandScript, + #[cfg(any(windows, test))] + BatchScript, + #[cfg(any(windows, test))] + PowerShellScript, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiscoveredCandidate { + pub(crate) path: PathBuf, + pub(crate) kind: DiscoveredFileKind, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CandidateKind { + #[cfg(any(not(windows), test))] + UnixExecutable, + #[cfg(any(windows, test))] + WindowsExecutable, + /// A `.cmd` or `.bat` file, run by `cmd.exe`. + #[cfg(any(windows, test))] + CommandScript { interpreter: PathBuf }, + #[cfg(any(windows, test))] + PowerShell { interpreter: PathBuf }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Candidate { + pub(crate) path: PathBuf, + pub(crate) kind: CandidateKind, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CommandArguments { + Native(Vec), + #[cfg(any(windows, test))] + WindowsCommand { + switches: Vec, + raw_command_tail: OsString, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CommandSpec { + program: OsString, + arguments: CommandArguments, + path: Option, +} + +impl CommandSpec { + pub(crate) fn new(program: OsString, arguments: CommandArguments) -> Self { + Self { + program, + arguments, + path: None, + } + } + + pub(crate) fn program(&self) -> &OsStr { + &self.program + } + + pub(crate) fn arguments(&self) -> &CommandArguments { + &self.arguments + } + + pub(crate) fn with_path(mut self, path: Option) -> Self { + self.path = path; + self + } + + pub(crate) fn path(&self) -> Option<&std::ffi::OsStr> { + self.path.as_deref() + } +} + +#[cfg(any(windows, test))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ResolutionFailure { + MissingCommandShell, + MissingPowerShellHost, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ResolvedCandidate { + Usable(Candidate), + #[cfg(any(windows, test))] + Unusable(ResolutionFailure), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CommandBuildError { + #[cfg(any(windows, test))] + UnsupportedWindowsCommandValue, +} + +/// Limits for a PowerShell version probe. +#[cfg(any(windows, test))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ProbeLimits { + pub(crate) timeout: Duration, + pub(crate) output_bytes: usize, +} + +#[cfg(any(windows, test))] +impl ProbeLimits { + pub(crate) const PRODUCTION: Self = Self { + timeout: Duration::from_secs(30), + output_bytes: 262_144, + }; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SupervisionMode { + /// Runs a PowerShell host with captured output to read its version; only `.ps1` candidates on Windows need it. + #[cfg(any(windows, test))] + CapturedVersionProbe(ProbeLimits), + InteractiveBootstrap, + FinalInteractiveCli, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CapturedOutput { + pub(crate) stdout: Vec, + pub(crate) stderr: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProcessTermination { + NumericExit(i32), + #[cfg(any(unix, test))] + UnixSignal(i32), + /// The user pressed Ctrl+C. + HandledCancellation, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProcessOutcome { + pub(crate) termination: ProcessTermination, + pub(crate) captured_output: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct SystemError { + pub(crate) kind: std::io::ErrorKind, + pub(crate) raw_os_error: Option, +} + +impl From<&std::io::Error> for SystemError { + fn from(error: &std::io::Error) -> Self { + Self { + kind: error.kind(), + raw_os_error: error.raw_os_error(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProcessOperation { + Wait, + #[cfg(windows)] + ReadStdout, + #[cfg(windows)] + ReadStderr, + #[cfg(windows)] + Terminate, + #[cfg(windows)] + Contain, + #[cfg(windows)] + Resume, + InstallCancellationHandler, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProcessDiagnostic { + pub(crate) operation: ProcessOperation, + pub(crate) program: PathBuf, + pub(crate) error: SystemError, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ProcessError { + #[cfg(windows)] + TimedOut(Duration), + #[cfg(windows)] + OutputLimitExceeded(usize), + SpawnFailed { + program: OsString, + error: SystemError, + }, + SupervisionFailed(ProcessDiagnostic), +} + +impl ProcessError { + pub(crate) fn supervision( + operation: ProcessOperation, + program: &OsStr, + error: &std::io::Error, + ) -> Self { + Self::SupervisionFailed(ProcessDiagnostic { + operation, + program: Path::new(program).to_path_buf(), + error: SystemError::from(error), + }) + } +} diff --git a/copilot_shim/src/platform/mod.rs b/copilot_shim/src/platform/mod.rs new file mode 100644 index 00000000000000..7895e665e84f44 --- /dev/null +++ b/copilot_shim/src/platform/mod.rs @@ -0,0 +1,326 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +#[cfg(any(windows, test))] +use std::ffi::{OsStr, OsString}; +use std::io; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(any(windows, test))] +use crate::model::CommandBuildError; +use crate::model::{CommandArguments, CommandSpec, ProcessError, ProcessTermination, SystemError}; + +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; + +#[cfg(unix)] +use self::unix as native; +#[cfg(windows)] +use self::windows as native; +#[cfg(any(windows, test))] +use native::{os_string_from_wide, os_string_to_wide}; + +#[cfg(unix)] +pub(crate) use native::exec; +pub(crate) use native::install_cancellation_handler; +#[cfg(windows)] +pub(crate) use native::{spawn_captured, CapturedChild}; + +static PROCESS_CANCELLATION: AtomicBool = AtomicBool::new(false); + +/// Runs `command` attached to the terminal and waits for it to exit. +pub(crate) fn run_interactive(command: &CommandSpec) -> Result { + let mut child = native_command(command) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|error| spawn_error(command, &error))?; + child.wait().map(native::termination).map_err(|error| { + ProcessError::supervision( + crate::model::ProcessOperation::Wait, + command.program(), + &error, + ) + }) +} + +pub(crate) fn request_process_cancellation() { + PROCESS_CANCELLATION.store(true, Ordering::SeqCst); +} + +pub(crate) fn is_process_cancellation_requested() -> bool { + PROCESS_CANCELLATION.load(Ordering::SeqCst) +} + +fn native_command(command: &CommandSpec) -> Command { + let mut process = Command::new(command.program()); + if let Some(path) = command.path() { + process.env("PATH", path); + } + match command.arguments() { + CommandArguments::Native(arguments) => { + process.args(arguments); + } + #[cfg(windows)] + CommandArguments::WindowsCommand { + switches, + raw_command_tail, + } => { + use std::os::windows::process::CommandExt; + + process.args(switches); + process.raw_arg(raw_command_tail); + } + #[cfg(all(unix, test))] + CommandArguments::WindowsCommand { .. } => { + unreachable!("Windows command scripts are not executable on Unix") + } + } + process +} + +fn spawn_error(command: &CommandSpec, error: &io::Error) -> ProcessError { + ProcessError::SpawnFailed { + program: command.program().to_os_string(), + error: SystemError::from(error), + } +} +/// The `cmd.exe` switches for running a `.cmd` or `.bat` candidate: command extensions on (required by the `%` +/// neutralization below), delayed expansion off (so `!` is literal), no AutoRun, and strip only the outer quotes. +#[cfg(any(windows, test))] +pub(crate) const WINDOWS_COMMAND_SWITCHES: [&str; 5] = ["/E:ON", "/V:OFF", "/D", "/S", "/C"]; + +/// Builds the command that `cmd.exe /S /C` runs for a batch script, using the Rust standard library's batch-file +/// hardening (CVE-2024-24576) and also neutralizing `%` in the script path: +/// +/// - the script path is quoted and must not contain `"` or end with `\`; +/// - an argument is quoted when it is empty, ends with `\`, or contains anything other than letters, digits, +/// and `#$*+-./:?@\_`, so cmd treats metacharacters inside it as text; +/// - a `"` is doubled, and backslashes before a `"` or the closing quote are doubled for programs that parse their +/// command line with the Microsoft C runtime rules (for example `node.exe` behind an npm wrapper); +/// - a `%` in the script path or an argument becomes `%%cd:~,%`, an empty substring expansion that stops cmd from +/// expanding `%VARIABLE%`, which it does even inside quotes. +#[cfg(any(windows, test))] +pub(crate) fn encode_windows_command_tail( + script: &OsStr, + arguments: &[OsString], +) -> Result { + let script = os_string_to_wide(script)?; + if script.is_empty() + || script.contains(&u16::from(b'"')) + || script.last() == Some(&u16::from(b'\\')) + || contains_forbidden_unit(&script) + { + return Err(CommandBuildError::UnsupportedWindowsCommandValue); + } + + let mut command_tail = vec![u16::from(b'"'), u16::from(b'"')]; + for unit in script { + append_percent_neutralized(&mut command_tail, unit); + } + command_tail.push(u16::from(b'"')); + for argument in arguments { + let argument = os_string_to_wide(argument)?; + if contains_forbidden_unit(&argument) { + return Err(CommandBuildError::UnsupportedWindowsCommandValue); + } + command_tail.push(u16::from(b' ')); + append_batch_argument(&mut command_tail, &argument); + } + command_tail.push(u16::from(b'"')); + os_string_from_wide(&command_tail) +} + +#[cfg(any(windows, test))] +fn contains_forbidden_unit(units: &[u16]) -> bool { + units + .iter() + .any(|unit| *unit == 0 || *unit == u16::from(b'\r') || *unit == u16::from(b'\n')) +} + +#[cfg(any(windows, test))] +fn append_percent_neutralized(command_tail: &mut Vec, unit: u16) { + if unit == u16::from(b'%') { + command_tail.extend("%%cd:~,".encode_utf16()); + } + command_tail.push(unit); +} + +#[cfg(any(windows, test))] +fn append_batch_argument(command_tail: &mut Vec, argument: &[u16]) { + const UNQUOTED: &[u8] = br"#$*+-./:?@\_"; + let quote = argument.is_empty() + || argument.last() == Some(&u16::from(b'\\')) + || argument.iter().any(|unit| { + char::from_u32(u32::from(*unit)).is_none_or(|character| { + character.is_control() + || character.is_ascii() + && !(character.is_ascii_alphanumeric() + || UNQUOTED.contains(&(character as u8))) + }) + }); + + if quote { + command_tail.push(u16::from(b'"')); + } + let mut backslashes = 0; + for &unit in argument { + if unit == u16::from(b'\\') { + backslashes += 1; + command_tail.push(unit); + } else { + if unit == u16::from(b'"') { + command_tail.extend(std::iter::repeat_n(u16::from(b'\\'), backslashes)); + command_tail.push(u16::from(b'"')); + } + backslashes = 0; + append_percent_neutralized(command_tail, unit); + } + } + if quote { + command_tail.extend(std::iter::repeat_n(u16::from(b'\\'), backslashes)); + command_tail.push(u16::from(b'"')); + } +} + +/// Decodes the output of [`encode_windows_command_tail`] back into the script and its arguments, the way a program +/// behind a batch wrapper (parsing with the Microsoft C runtime rules) receives them. +#[cfg(test)] +pub(crate) fn decode_windows_command_tail( + value: &OsStr, +) -> Result, CommandBuildError> { + let quote = u16::from(b'"'); + let backslash = u16::from(b'\\'); + let space = u16::from(b' '); + let wide = os_string_to_wide(value)?; + if wide.len() < 2 || wide.first() != Some("e) || wide.last() != Some("e) { + return Err(CommandBuildError::UnsupportedWindowsCommandValue); + } + + let inner = &wide[1..wide.len() - 1]; + let mut decoded = Vec::new(); + let mut index = 0; + while index < inner.len() { + if inner[index] == space { + index += 1; + continue; + } + let mut value = Vec::new(); + if inner[index] == quote { + index += 1; + loop { + let start = index; + while inner.get(index) == Some(&backslash) { + index += 1; + } + let backslashes = index - start; + let Some(&unit) = inner.get(index) else { + return Err(CommandBuildError::UnsupportedWindowsCommandValue); + }; + if unit == quote { + value.extend(std::iter::repeat_n(backslash, backslashes / 2)); + index += 1; + if inner.get(index) == Some("e) { + value.push(quote); + index += 1; + continue; + } + break; + } + value.extend(std::iter::repeat_n(backslash, backslashes)); + value.push(unit); + index += 1; + } + } else { + while index < inner.len() && inner[index] != space { + value.push(inner[index]); + index += 1; + } + } + let text = String::from_utf16(&value) + .map_err(|_| CommandBuildError::UnsupportedWindowsCommandValue)? + .replace("%%cd:~,%", "%"); + decoded.push(OsString::from(text)); + } + Ok(decoded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_override_is_per_command_and_preserves_inheritance() { + let original = std::env::var_os("PATH"); + let command = + CommandSpec::new(OsString::from("bash"), CommandArguments::Native(Vec::new())); + let inherited = native_command(&command); + let overridden = native_command(&command.with_path(Some(OsString::from("filtered-path")))); + assert_eq!( + ( + inherited.get_envs().count(), + overridden.get_envs().collect::>(), + std::env::var_os("PATH"), + ), + ( + 0, + vec![(OsStr::new("PATH"), Some(OsStr::new("filtered-path")))], + original + ) + ); + } + + #[test] + fn windows_command_tail_decoder_round_trips_encoder_output() { + let script = OsStr::new(r"C:\Program Files\100%USERNAME%\copilot.cmd"); + let arguments = [ + OsString::from("with spaces"), + OsString::from("&|<>()^%!;"), + OsString::from(""), + OsString::from(r#"quote " and \" inside"#), + OsString::from(r"trailing\"), + OsString::from("--resume"), + ]; + let encoded = encode_windows_command_tail(script, &arguments).expect("encode command tail"); + + assert_eq!( + ( + encoded.to_string_lossy().into_owned(), + decode_windows_command_tail(&encoded).expect("decode command tail"), + ), + ( + String::from( + r#"""C:\Program Files\100%%cd:~,%USERNAME%%cd:~,%\copilot.cmd" "with spaces" "&|<>()^%%cd:~,%!;" "" "quote "" and \\"" inside" "trailing\\" --resume""# + ), + std::iter::once(script.to_os_string()) + .chain(arguments) + .collect::>(), + ) + ); + } + + #[test] + fn windows_command_tail_rejects_unsafe_scripts_and_line_breaks() { + assert_eq!( + [ + encode_windows_command_tail(OsStr::new(r#"C:\a"b.cmd"#), &[]), + encode_windows_command_tail(OsStr::new("C:\\dir\\"), &[]), + encode_windows_command_tail( + OsStr::new(r"C:\copilot.cmd"), + &[OsString::from("line\nbreak")] + ), + ], + [ + Err(CommandBuildError::UnsupportedWindowsCommandValue), + Err(CommandBuildError::UnsupportedWindowsCommandValue), + Err(CommandBuildError::UnsupportedWindowsCommandValue), + ] + ); + } +} diff --git a/copilot_shim/src/platform/unix.rs b/copilot_shim/src/platform/unix.rs new file mode 100644 index 00000000000000..9c04dd24d89f1c --- /dev/null +++ b/copilot_shim/src/platform/unix.rs @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +#[cfg(test)] +use std::ffi::{OsStr, OsString}; +use std::io; +#[cfg(test)] +use std::os::unix::ffi::OsStringExt; +use std::os::unix::process::{CommandExt, ExitStatusExt}; +use std::process::ExitStatus; + +#[cfg(test)] +use crate::model::CommandBuildError; +use crate::model::{CommandSpec, ProcessError, ProcessTermination}; + +use super::{native_command, spawn_error}; + +#[cfg(test)] +pub(super) fn os_string_to_wide(value: &OsStr) -> Result, CommandBuildError> { + value + .to_str() + .map(|value| value.encode_utf16().collect()) + .ok_or(CommandBuildError::UnsupportedWindowsCommandValue) +} + +#[cfg(test)] +pub(super) fn os_string_from_wide(value: &[u16]) -> Result { + String::from_utf16(value) + .map(|value| OsString::from_vec(value.into_bytes())) + .map_err(|_| CommandBuildError::UnsupportedWindowsCommandValue) +} + +/// Replaces the shim with `command`, so signals, the exit status, and the process's lifetime are the Copilot CLI's. +/// Returns only if the command couldn't be started. +pub(crate) fn exec(command: &CommandSpec) -> ProcessError { + let error = native_command(command).exec(); + spawn_error(command, &error) +} + +pub(crate) fn install_cancellation_handler() -> io::Result<()> { + let previous = unsafe { + libc::signal( + libc::SIGINT, + handle_interrupt as *const () as libc::sighandler_t, + ) + }; + if previous == libc::SIG_ERR { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +extern "C" fn handle_interrupt(_signal: i32) { + super::request_process_cancellation(); +} + +pub(super) fn termination(status: ExitStatus) -> ProcessTermination { + match status.code() { + Some(code) => ProcessTermination::NumericExit(code), + None => ProcessTermination::UnixSignal(status.signal().unwrap_or(0)), + } +} diff --git a/copilot_shim/src/platform/windows.rs b/copilot_shim/src/platform/windows.rs new file mode 100644 index 00000000000000..d9dcc72e1a8342 --- /dev/null +++ b/copilot_shim/src/platform/windows.rs @@ -0,0 +1,453 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::ffi::{OsStr, OsString}; +use std::fs::File; +use std::io; +use std::mem::size_of; +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +use std::os::windows::io::FromRawHandle; +use std::process::ExitStatus; +use std::ptr::{null, null_mut}; +use std::thread; +use std::time::{Duration, Instant}; + +use windows_sys::Win32::Foundation::{ + CloseHandle, SetHandleInformation, GENERIC_READ, HANDLE, HANDLE_FLAG_INHERIT, + INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WAIT_TIMEOUT, +}; +use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; +use windows_sys::Win32::Storage::FileSystem::{CreateFileW, OPEN_EXISTING}; +use windows_sys::Win32::System::Console::SetConsoleCtrlHandler; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicAccountingInformation, + JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, + TerminateJobObject, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, +}; +use windows_sys::Win32::System::Pipes::CreatePipe; +use windows_sys::Win32::System::Threading::{ + CreateProcessW, GetExitCodeProcess, ResumeThread, TerminateProcess, WaitForSingleObject, + CREATE_SUSPENDED, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOW, +}; + +use crate::model::{ + CommandArguments, CommandBuildError, CommandSpec, ProcessError, ProcessOperation, + ProcessTermination, +}; + +use super::spawn_error; + +const JOB_VERIFICATION_PERIOD: Duration = Duration::from_secs(1); +const JOB_VERIFICATION_POLL_INTERVAL: Duration = Duration::from_millis(5); + +pub(super) fn os_string_to_wide(value: &OsStr) -> Result, CommandBuildError> { + Ok(value.encode_wide().collect()) +} + +pub(super) fn os_string_from_wide(value: &[u16]) -> Result { + Ok(OsString::from_wide(value)) +} + +struct OwnedHandle(HANDLE); + +impl OwnedHandle { + fn new(handle: HANDLE) -> io::Result { + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + Err(io::Error::last_os_error()) + } else { + Ok(Self(handle)) + } + } + + fn raw(&self) -> HANDLE { + self.0 + } + + fn into_raw(mut self) -> HANDLE { + let handle = self.0; + self.0 = null_mut(); + handle + } +} + +impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE { + unsafe { + CloseHandle(self.0); + } + } + } +} + +pub(crate) struct CapturedChild { + process: OwnedHandle, + job: OwnedHandle, + stdout: Option, + stderr: Option, + reaped: bool, +} + +impl CapturedChild { + pub(crate) fn take_stdout(&mut self) -> io::Result { + self.stdout + .take() + .ok_or_else(|| io::Error::other("captured stdout is unavailable")) + } + + pub(crate) fn take_stderr(&mut self) -> io::Result { + self.stderr + .take() + .ok_or_else(|| io::Error::other("captured stderr is unavailable")) + } + + pub(crate) fn try_wait(&mut self) -> io::Result> { + match unsafe { WaitForSingleObject(self.process.raw(), 0) } { + WAIT_OBJECT_0 => { + self.reaped = true; + exit_code(self.process.raw()).map(Some) + } + WAIT_TIMEOUT => Ok(None), + _ => Err(io::Error::last_os_error()), + } + } + + pub(crate) fn terminate_and_reap(&mut self) -> io::Result<()> { + if unsafe { TerminateJobObject(self.job.raw(), 1) } == 0 { + return Err(io::Error::last_os_error()); + } + if unsafe { WaitForSingleObject(self.process.raw(), u32::MAX) } != WAIT_OBJECT_0 { + return Err(io::Error::last_os_error()); + } + self.reaped = true; + + wait_for_job_to_empty(self.job.raw()) + } +} + +fn wait_for_job_to_empty(job: HANDLE) -> io::Result<()> { + wait_for_job_to_empty_until( + || active_job_processes(job), + Instant::now() + JOB_VERIFICATION_PERIOD, + Instant::now, + thread::sleep, + ) +} + +fn wait_for_job_to_empty_until( + mut active_processes: impl FnMut() -> io::Result, + deadline: Instant, + mut now: impl FnMut() -> Instant, + mut sleep: impl FnMut(Duration), +) -> io::Result<()> { + loop { + if active_processes()? == 0 { + return Ok(()); + } + + let current = now(); + if current >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "probe Job Object still has active processes after forced termination", + )); + } + sleep(JOB_VERIFICATION_POLL_INTERVAL.min(deadline.duration_since(current))); + } +} + +fn active_job_processes(job: HANDLE) -> io::Result { + let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); + if unsafe { + QueryInformationJobObject( + job, + JobObjectBasicAccountingInformation, + &mut accounting as *mut _ as *mut _, + size_of::() as u32, + null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(accounting.ActiveProcesses) +} + +impl Drop for CapturedChild { + fn drop(&mut self) { + if !self.reaped { + unsafe { + TerminateJobObject(self.job.raw(), 1); + WaitForSingleObject(self.process.raw(), u32::MAX); + } + } + } +} + +pub(crate) fn spawn_captured(command: &CommandSpec) -> Result { + spawn_captured_inner(command).map_err(|failure| match failure { + ProbeSpawnFailure::Start(error) => spawn_error(command, &error), + ProbeSpawnFailure::Supervision(operation, error) => { + ProcessError::supervision(operation, command.program(), &error) + } + }) +} + +enum ProbeSpawnFailure { + Start(io::Error), + Supervision(ProcessOperation, io::Error), +} + +fn spawn_captured_inner(command: &CommandSpec) -> Result { + let (stdout_read, stdout_write) = create_pipe().map_err(ProbeSpawnFailure::Start)?; + let (stderr_read, stderr_write) = create_pipe().map_err(ProbeSpawnFailure::Start)?; + let null_input = open_null_input().map_err(ProbeSpawnFailure::Start)?; + let job = create_kill_on_close_job().map_err(ProbeSpawnFailure::Start)?; + let startup = STARTUPINFOW { + cb: size_of::() as u32, + dwFlags: STARTF_USESTDHANDLES, + hStdInput: null_input.raw(), + hStdOutput: stdout_write.raw(), + hStdError: stderr_write.raw(), + ..Default::default() + }; + let mut process_information = PROCESS_INFORMATION::default(); + let mut application_name: Vec = command + .program() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut command_line = create_process_command_line(command); + + let created = unsafe { + CreateProcessW( + application_name.as_mut_ptr(), + command_line.as_mut_ptr(), + null(), + null(), + 1, + CREATE_SUSPENDED, + null(), + null(), + &startup, + &mut process_information, + ) + }; + if created == 0 { + return Err(ProbeSpawnFailure::Start(io::Error::last_os_error())); + } + let process = + OwnedHandle::new(process_information.hProcess).map_err(ProbeSpawnFailure::Start)?; + let thread = OwnedHandle::new(process_information.hThread).map_err(ProbeSpawnFailure::Start)?; + drop(stdout_write); + drop(stderr_write); + + if unsafe { AssignProcessToJobObject(job.raw(), process.raw()) } == 0 { + let error = io::Error::last_os_error(); + unsafe { + TerminateProcess(process.raw(), 1); + WaitForSingleObject(process.raw(), u32::MAX); + } + return Err(ProbeSpawnFailure::Supervision( + ProcessOperation::Contain, + error, + )); + } + if unsafe { ResumeThread(thread.raw()) } == u32::MAX { + let error = io::Error::last_os_error(); + unsafe { + TerminateJobObject(job.raw(), 1); + WaitForSingleObject(process.raw(), u32::MAX); + } + return Err(ProbeSpawnFailure::Supervision( + ProcessOperation::Resume, + error, + )); + } + drop(thread); + + let stdout = unsafe { File::from_raw_handle(stdout_read.into_raw()) }; + let stderr = unsafe { File::from_raw_handle(stderr_read.into_raw()) }; + Ok(CapturedChild { + process, + job, + stdout: Some(stdout), + stderr: Some(stderr), + reaped: false, + }) +} + +pub(crate) fn install_cancellation_handler() -> io::Result<()> { + if unsafe { SetConsoleCtrlHandler(Some(handle_console_control), 1) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +unsafe extern "system" fn handle_console_control(control_type: u32) -> i32 { + if control_type == 0 { + super::request_process_cancellation(); + 1 + } else { + 0 + } +} + +fn create_pipe() -> io::Result<(OwnedHandle, OwnedHandle)> { + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: null_mut(), + bInheritHandle: 1, + }; + let mut read = null_mut(); + let mut write = null_mut(); + if unsafe { CreatePipe(&mut read, &mut write, &attributes, 0) } == 0 { + return Err(io::Error::last_os_error()); + } + let read = OwnedHandle::new(read)?; + let write = OwnedHandle::new(write)?; + if unsafe { SetHandleInformation(read.raw(), HANDLE_FLAG_INHERIT, 0) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok((read, write)) +} + +fn open_null_input() -> io::Result { + let name: Vec = OsStr::new("NUL") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: null_mut(), + bInheritHandle: 1, + }; + OwnedHandle::new(unsafe { + CreateFileW( + name.as_ptr(), + GENERIC_READ, + 0, + &attributes, + OPEN_EXISTING, + 0, + null_mut(), + ) + }) +} + +fn create_kill_on_close_job() -> io::Result { + let job = OwnedHandle::new(unsafe { CreateJobObjectW(null(), null()) })?; + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if unsafe { + SetInformationJobObject( + job.raw(), + JobObjectExtendedLimitInformation, + &limits as *const _ as *const _, + size_of::() as u32, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(job) +} + +fn create_process_command_line(command: &CommandSpec) -> Vec { + let mut line = quote_process_argument(command.program()); + match command.arguments() { + CommandArguments::Native(arguments) => { + for argument in arguments { + line.push(u16::from(b' ')); + line.extend(quote_process_argument(argument)); + } + } + CommandArguments::WindowsCommand { + switches, + raw_command_tail, + } => { + // The switches are fixed tokens; cmd.exe ignores quoted switches such as "/S". + for argument in switches { + line.push(u16::from(b' ')); + line.extend(argument.encode_wide()); + } + line.push(u16::from(b' ')); + line.extend(raw_command_tail.encode_wide()); + } + } + line.push(0); + line +} + +fn quote_process_argument(argument: &OsStr) -> Vec { + let value: Vec = argument.encode_wide().collect(); + let mut quoted = vec![u16::from(b'"')]; + let mut backslashes = 0; + for unit in value { + if unit == u16::from(b'\\') { + backslashes += 1; + } else { + if unit == u16::from(b'"') { + quoted.extend(std::iter::repeat_n(u16::from(b'\\'), backslashes * 2 + 1)); + } else { + quoted.extend(std::iter::repeat_n(u16::from(b'\\'), backslashes)); + } + backslashes = 0; + quoted.push(unit); + } + } + quoted.extend(std::iter::repeat_n(u16::from(b'\\'), backslashes * 2)); + quoted.push(u16::from(b'"')); + quoted +} + +pub(super) fn termination(status: ExitStatus) -> ProcessTermination { + // Windows processes always exit with a numeric code. + ProcessTermination::NumericExit(status.code().unwrap_or(1)) +} + +fn exit_code(process: HANDLE) -> io::Result { + let mut code = 0; + if unsafe { GetExitCodeProcess(process, &mut code) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(ProcessTermination::NumericExit(code as i32)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::time::{Duration, Instant}; + + #[test] + fn job_empty_verification_polls_until_active_processes_exit() { + let queries = Cell::new(0); + + wait_for_job_to_empty_until( + || { + queries.set(queries.get() + 1); + Ok(u32::from(queries.get() < 3)) + }, + Instant::now() + Duration::from_secs(1), + Instant::now, + |_| {}, + ) + .expect("Job Object should become empty"); + + assert_eq!(queries.get(), 3); + } + + #[test] + fn job_empty_verification_timeout_is_typed() { + let error = wait_for_job_to_empty_until(|| Ok(1), Instant::now(), Instant::now, |_| {}) + .expect_err("active Job Object should time out"); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + } +} diff --git a/copilot_shim/src/prompt.rs b/copilot_shim/src/prompt.rs new file mode 100644 index 00000000000000..7ec40595d9ca0f --- /dev/null +++ b/copilot_shim/src/prompt.rs @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::io::{self, BufRead, IsTerminal, Write}; + +pub(crate) const INSTALL_DOCUMENTATION_URL: &str = + "https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PromptResponse { + Accepted, + Declined, +} + +/// Offers to install GitHub Copilot CLI and reads the answer. The default answer is No. +pub(crate) fn prompt_with_io( + input: &mut R, + output: &mut W, +) -> io::Result { + writeln!( + output, + "Installation instructions: {INSTALL_DOCUMENTATION_URL}" + )?; + write!(output, "Install GitHub Copilot CLI? [y/N] ")?; + output.flush()?; + + let mut response = Vec::new(); + input.read_until(b'\n', &mut response)?; + Ok(parse_response(&response)) +} + +/// Accepts an answer that starts with `y`, including the full-width `y` that an East Asian IME types in full-width mode. +/// Leading whitespace, including the ideographic space, is ignored. +pub(crate) fn parse_response(response: &[u8]) -> PromptResponse { + match String::from_utf8_lossy(response) + .chars() + .find(|character| !character.is_whitespace()) + { + Some('y' | 'Y' | '\u{FF59}' | '\u{FF39}') => PromptResponse::Accepted, + _ => PromptResponse::Declined, + } +} + +pub(crate) fn native_can_prompt() -> bool { + io::stdin().is_terminal() && io::stderr().is_terminal() +} + +/// Shows the prompt on stderr, so it stays visible, and out of the output, when stdout is redirected. Callers check +/// [`native_can_prompt`] first. +pub(crate) fn native_prompt() -> io::Result { + prompt_with_io(&mut io::stdin().lock(), &mut io::stderr().lock()) +} + +#[cfg(any(unix, test))] +pub(crate) fn clear_terminal_with(output: &mut W, is_terminal: bool) -> io::Result<()> { + if !is_terminal { + return Ok(()); + } + output.write_all(b"\x1b[2J\x1b[H")?; + output.flush() +} + +#[cfg(unix)] +pub(crate) fn native_clear_terminal() -> io::Result<()> { + let stdout = io::stdout(); + let is_terminal = stdout.is_terminal(); + clear_terminal_with(&mut stdout.lock(), is_terminal) +} + +#[cfg(windows)] +pub(crate) fn native_clear_terminal() -> io::Result<()> { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::{ + FillConsoleOutputAttribute, FillConsoleOutputCharacterW, GetConsoleScreenBufferInfo, + GetStdHandle, SetConsoleCursorPosition, CONSOLE_SCREEN_BUFFER_INFO, COORD, + STD_OUTPUT_HANDLE, + }; + + let stdout = io::stdout(); + if !stdout.is_terminal() { + return Ok(()); + } + + unsafe { + let handle = GetStdHandle(STD_OUTPUT_HANDLE); + if handle == INVALID_HANDLE_VALUE || handle.is_null() { + return Err(io::Error::last_os_error()); + } + let mut information = CONSOLE_SCREEN_BUFFER_INFO::default(); + if GetConsoleScreenBufferInfo(handle, &mut information) == 0 { + return Err(io::Error::last_os_error()); + } + let origin = COORD { X: 0, Y: 0 }; + let cells = u32::from(information.dwSize.X as u16) + .saturating_mul(u32::from(information.dwSize.Y as u16)); + let mut written = 0; + if FillConsoleOutputCharacterW(handle, b' ' as u16, cells, origin, &mut written) == 0 + || FillConsoleOutputAttribute( + handle, + information.wAttributes, + cells, + origin, + &mut written, + ) == 0 || SetConsoleCursorPosition(handle, origin) == 0 + { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} diff --git a/copilot_shim/src/runtime.rs b/copilot_shim/src/runtime.rs new file mode 100644 index 00000000000000..1eea8377da7fe4 --- /dev/null +++ b/copilot_shim/src/runtime.rs @@ -0,0 +1,361 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::cell::Cell; +use std::ffi::OsString; +use std::fs::File; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +#[path = "platform/mod.rs"] +pub(crate) mod platform; +#[path = "prompt.rs"] +pub(crate) mod prompt; +#[path = "supervisor.rs"] +pub(crate) mod supervisor; + +use crate::model::{ + CommandSpec, FileIdentity, FileIdentityState, ProcessError, ProcessOperation, ProcessOutcome, + SupervisionMode, +}; +pub(crate) use prompt::PromptResponse; +use supervisor::{CancellationToken, ProcessSupervisor}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InspectedFileType { + RegularFile, + Directory, + Other, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PathInspection { + pub(crate) canonical_path: PathBuf, + pub(crate) file_identity: FileIdentityState, + pub(crate) file_type: InspectedFileType, + pub(crate) executable: bool, + pub(crate) file_size: u64, +} + +pub(crate) trait EnvironmentEffects { + fn path(&self) -> Option; + fn current_executable(&self) -> io::Result; + fn current_directory(&self) -> io::Result; + + fn environment_variable(&self, _name: &str) -> Option { + None + } + + #[cfg(unix)] + fn is_root(&self) -> bool { + false + } +} + +pub(crate) trait FileSystemEffects { + /// Inspects `path`, following links. Returns `None` when it doesn't exist. + fn inspect_path(&self, path: &Path) -> io::Result>; + fn open_file(&self, path: &Path) -> io::Result>; +} + +pub(crate) trait UserInteractionEffects { + fn set_diagnostics_enabled(&self, _enabled: bool) {} + fn diagnostics_enabled(&self) -> bool { + false + } + + fn clear_terminal(&self) -> io::Result<()>; + /// Whether a user can answer a prompt: stdin and stderr, which shows the prompt, are both terminals. + fn can_prompt(&self) -> bool; + /// Offers to install GitHub Copilot CLI. + fn prompt(&self) -> io::Result; + + fn write_diagnostic(&self, _message: &str) {} + + fn write_message(&self, message: &str) { + self.write_diagnostic(message); + } +} + +pub(crate) trait ProcessEffects { + fn supervise( + &self, + command: &CommandSpec, + mode: SupervisionMode, + ) -> Result; +} + +pub(crate) trait PolicyEffects { + /// Whether the VS Code `CopilotCliCommand` policy turns off the `copilot` command. The shim then still launches an + /// installed Copilot CLI, but never installs or updates one. + fn copilot_cli_command_disabled(&self) -> bool; +} + +pub(crate) trait Runtime: + EnvironmentEffects + FileSystemEffects + UserInteractionEffects + ProcessEffects + PolicyEffects +{ +} + +impl Runtime for T where + T: EnvironmentEffects + + FileSystemEffects + + UserInteractionEffects + + ProcessEffects + + PolicyEffects +{ +} + +#[derive(Default)] +pub(crate) struct NativeRuntime { + diagnostics_enabled: Cell, +} + +impl PolicyEffects for NativeRuntime { + fn copilot_cli_command_disabled(&self) -> bool { + // Only Windows setup puts the shim on the system PATH. Elsewhere it is reachable from integrated terminals, and + // the Copilot extension applies the policy there. + #[cfg(windows)] + { + crate::setup::windows::copilot_cli_command_disabled() + } + #[cfg(not(windows))] + { + false + } + } +} + +impl EnvironmentEffects for NativeRuntime { + fn path(&self) -> Option { + #[cfg(windows)] + { + crate::setup::windows::discovery_path(std::env::var_os("PATH")) + } + #[cfg(not(windows))] + { + std::env::var_os("PATH") + } + } + + fn current_executable(&self) -> io::Result { + std::env::current_exe() + } + + fn current_directory(&self) -> io::Result { + std::env::current_dir() + } + + fn environment_variable(&self, name: &str) -> Option { + std::env::var_os(name) + } + + #[cfg(unix)] + fn is_root(&self) -> bool { + unsafe { libc::geteuid() == 0 } + } +} + +impl FileSystemEffects for NativeRuntime { + fn inspect_path(&self, path: &Path) -> io::Result> { + match std::fs::symlink_metadata(path) { + Ok(_) => {} + // A PATH entry that is a file makes its candidates `NotADirectory`. + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + return Ok(None) + } + Err(error) => return Err(error), + } + + let canonical_path = std::fs::canonicalize(path)?; + let metadata = std::fs::metadata(path)?; + let file_type = if metadata.is_file() { + InspectedFileType::RegularFile + } else if metadata.is_dir() { + InspectedFileType::Directory + } else { + InspectedFileType::Other + }; + + #[cfg(unix)] + let executable = metadata.is_file() && executable_by_current_user(path); + #[cfg(not(unix))] + let executable = metadata.is_file(); + + Ok(Some(PathInspection { + canonical_path, + file_identity: file_identity(path, &metadata)?, + file_type, + executable, + file_size: metadata.len(), + })) + } + + fn open_file(&self, path: &Path) -> io::Result> { + Ok(Box::new(File::open(path)?)) + } +} + +/// Whether the current user can execute `path`, checked with the effective user and group IDs like a shell's `PATH` +/// search. An execute bit that applies only to another user or group doesn't count. +#[cfg(unix)] +fn executable_by_current_user(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + + let Ok(path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + unsafe { libc::faccessat(libc::AT_FDCWD, path.as_ptr(), libc::X_OK, libc::AT_EACCESS) == 0 } +} + +#[cfg(unix)] +fn file_identity(_path: &Path, metadata: &std::fs::Metadata) -> io::Result { + use std::os::unix::fs::MetadataExt; + + Ok(FileIdentityState::Supported(FileIdentity::Unix { + device: metadata.dev(), + inode: metadata.ino(), + })) +} + +#[cfg(windows)] +fn file_identity(path: &Path, _metadata: &std::fs::Metadata) -> io::Result { + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, + FILE_READ_ATTRIBUTES, + }; + + // Attribute-only access avoids sharing conflicts with running executables; FILE_FLAG_BACKUP_SEMANTICS also opens + // directories. + let file = std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path)?; + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 { + let error = io::Error::last_os_error(); + return if is_unsupported_file_identity_error(&error) { + Ok(FileIdentityState::Unsupported) + } else { + Err(error) + }; + } + Ok(FileIdentityState::Supported(FileIdentity::Windows { + volume: u64::from(information.dwVolumeSerialNumber), + file_id: (u128::from(information.nFileIndexHigh) << 32) + | u128::from(information.nFileIndexLow), + })) +} + +#[cfg(windows)] +fn is_unsupported_file_identity_error(error: &io::Error) -> bool { + use windows_sys::Win32::Foundation::{ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED}; + + matches!( + error.raw_os_error().map(|code| code as u32), + Some(ERROR_INVALID_FUNCTION) | Some(ERROR_NOT_SUPPORTED) + ) +} + +impl UserInteractionEffects for NativeRuntime { + fn set_diagnostics_enabled(&self, enabled: bool) { + self.diagnostics_enabled.set(enabled); + } + + fn diagnostics_enabled(&self) -> bool { + self.diagnostics_enabled.get() + } + + fn clear_terminal(&self) -> io::Result<()> { + prompt::native_clear_terminal() + } + + fn can_prompt(&self) -> bool { + prompt::native_can_prompt() + } + + fn prompt(&self) -> io::Result { + prompt::native_prompt() + } + + fn write_diagnostic(&self, message: &str) { + if self.diagnostics_enabled.get() { + eprintln!("{message}"); + } + } + + fn write_message(&self, message: &str) { + eprintln!("{message}"); + } +} + +impl ProcessEffects for NativeRuntime { + fn supervise( + &self, + command: &CommandSpec, + mode: SupervisionMode, + ) -> Result { + #[cfg(unix)] + if mode == SupervisionMode::FinalInteractiveCli { + return Err(platform::exec(command)); + } + let cancellation = CancellationToken::process_wide().map_err(|error| { + ProcessError::supervision( + ProcessOperation::InstallCancellationHandler, + command.program(), + &error, + ) + })?; + ProcessSupervisor::new(cancellation).supervise(command, mode) + } +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + fn windows_file_identity_reports_supported_native_file() -> io::Result<()> { + let path = std::env::current_exe()?; + let metadata = std::fs::metadata(&path)?; + + let identity = file_identity(&path, &metadata)?; + + assert!(matches!( + identity, + FileIdentityState::Supported(FileIdentity::Windows { .. }) + )); + Ok(()) + } + + #[test] + fn windows_file_identity_only_treats_explicit_unsupported_errors_as_unsupported() { + use windows_sys::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED, + }; + + assert_eq!( + [ + is_unsupported_file_identity_error(&io::Error::from_raw_os_error( + ERROR_INVALID_FUNCTION as i32, + )), + is_unsupported_file_identity_error(&io::Error::from_raw_os_error( + ERROR_NOT_SUPPORTED as i32, + )), + is_unsupported_file_identity_error(&io::Error::from_raw_os_error( + ERROR_ACCESS_DENIED as i32, + )), + ], + [true, true, false] + ); + } +} diff --git a/copilot_shim/src/setup/mod.rs b/copilot_shim/src/setup/mod.rs new file mode 100644 index 00000000000000..f26c45d534073e --- /dev/null +++ b/copilot_shim/src/setup/mod.rs @@ -0,0 +1,1080 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//! The `--vscode-shim probe|install` commands. They report to VS Code setup through INI files and never launch the +//! Copilot CLI. `install` also backs the interactive first-use install on Windows. + +use std::ffi::OsString; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::time::Duration; +use std::time::Instant; + +use crate::candidate::discover; +use crate::install::HostTarget; +use crate::invocation::{InstallMode, InstallOptions, ProbeOptions, ProbeScope}; +use crate::runtime::{ + EnvironmentEffects, FileSystemEffects, InspectedFileType, PathInspection, PolicyEffects, + UserInteractionEffects, +}; + +#[cfg(windows)] +pub(crate) mod windows; + +/// Folder under `%LOCALAPPDATA%` that GitHub's per-user Copilot CLI MSI installs `copilot.exe` into. +#[cfg(windows)] +pub(crate) const CLI_INSTALL_FOLDER: &str = "GitHubCopilotCLI"; + +/// Version of the setup contract. VS Code setup rejects probe results that report a different version. +pub(crate) const PROTOCOL_VERSION: u32 = 1; + +/// The VS Code policy that turns off the `copilot` command that VS Code provides, including installing the CLI. +pub(crate) const COPILOT_CLI_COMMAND_POLICY: &str = "CopilotCliCommand"; + +#[cfg(windows)] +const DEFAULT_RELEASES_URL: &str = "https://github.com/github/copilot-cli/releases"; +/// Replaces the GitHub releases URL, for tests and mirrors. The Authenticode signer check still applies. +#[cfg(windows)] +const RELEASES_URL_VARIABLE: &str = "VSCODE_COPILOT_SHIM_RELEASES_URL"; +#[cfg(windows)] +const EXPECTED_SIGNER: &str = "GitHub, Inc."; +#[cfg(windows)] +const INSTALL_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(windows)] +const MAXIMUM_CHECKSUMS_SIZE: u64 = 64 * 1024; + +#[cfg(windows)] +pub(crate) fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Extracts the release tag from the `Location` of a `/releases/latest` redirect. +#[cfg(any(windows, test))] +pub(crate) fn parse_release_tag(location: &str) -> Option { + let (_, rest) = location.split_once("/tag/")?; + let tag = rest.split(['/', '?', '#']).next()?; + (!tag.is_empty() + && tag.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + })) + .then(|| tag.to_owned()) +} + +/// Finds the SHA-256 of `asset` in a `sha256sum`-style checksum list. +#[cfg(any(windows, test))] +pub(crate) fn expected_sha256(checksums: &str, asset: &str) -> Option { + checksums.lines().find_map(|line| { + let (hash, name) = line.trim().split_once(char::is_whitespace)?; + let name = name.trim().trim_start_matches('*'); + (name == asset + && hash.len() == 64 + && hash.chars().all(|character| character.is_ascii_hexdigit())) + .then(|| hash.to_ascii_lowercase()) + }) +} + +#[cfg(any(windows, test))] +pub(crate) fn msi_asset(target: Option) -> Option<&'static str> { + match target? { + HostTarget::WindowsX64 => Some("copilot-x64.msi"), + HostTarget::WindowsArm64 => Some("copilot-arm64.msi"), + _ => None, + } +} + +// Result files + +/// Renders an INI section. Values are single-line so `GetPrivateProfileString` reads them back unchanged. +pub(crate) fn render_ini(section: &str, entries: &[(&str, String)]) -> String { + let mut text = format!("[{section}]\r\n"); + for (key, value) in entries { + let value: String = value + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect(); + text.push_str(&format!("{key}={value}\r\n")); + } + text +} + +/// Writes an INI file as UTF-16LE with a byte order mark, which `GetPrivateProfileString` reads on every Windows +/// version. The file is replaced atomically so a reader never sees a partial result. +pub(crate) fn write_ini(path: &Path, section: &str, entries: &[(&str, String)]) -> io::Result<()> { + let mut bytes = vec![0xFF, 0xFE]; + for unit in render_ini(section, entries).encode_utf16() { + bytes.extend(unit.to_le_bytes()); + } + let mut temporary = path.as_os_str().to_os_string(); + temporary.push(format!(".{}.tmp", std::process::id())); + let temporary = PathBuf::from(temporary); + std::fs::write(&temporary, bytes)?; + std::fs::rename(&temporary, path).inspect_err(|_| { + let _ = std::fs::remove_file(&temporary); + }) +} + +fn flag(value: bool) -> String { + String::from(if value { "1" } else { "0" }) +} + +// Discovery with an explicit search path + +struct SearchPathRuntime<'a, R> { + inner: &'a R, + path: Option, +} + +impl EnvironmentEffects for SearchPathRuntime<'_, R> { + fn path(&self) -> Option { + self.path.clone() + } + + fn current_executable(&self) -> io::Result { + self.inner.current_executable() + } + + fn current_directory(&self) -> io::Result { + self.inner.current_directory() + } + + fn environment_variable(&self, name: &str) -> Option { + self.inner.environment_variable(name) + } + + #[cfg(unix)] + fn is_root(&self) -> bool { + self.inner.is_root() + } +} + +impl FileSystemEffects for SearchPathRuntime<'_, R> { + fn inspect_path(&self, path: &Path) -> io::Result> { + self.inner.inspect_path(path) + } + + fn open_file(&self, path: &Path) -> io::Result> { + self.inner.open_file(path) + } +} + +/// Finds the first Copilot CLI candidate without running it. Other shims and the old VS Code script shims are excluded. +fn find_cli(runtime: &R, search_path: Option) -> Result, String> +where + R: EnvironmentEffects + FileSystemEffects, +{ + let runtime = SearchPathRuntime { + inner: runtime, + path: search_path, + }; + discover(&runtime, crate::SHIM_MARKER) + .map(|result| { + result + .candidates + .first() + .map(|candidate| candidate.path.clone()) + }) + .map_err(|error| { + format!( + "identifying the running shim failed: {:?}", + error.error.kind + ) + }) +} + +fn probe_search_path(runtime: &R, scope: ProbeScope) -> Option { + match scope { + #[cfg(windows)] + ProbeScope::User => windows::setup_discovery_path(runtime.environment_variable("PATH")), + #[cfg(not(windows))] + ProbeScope::User => runtime.path(), + #[cfg(windows)] + ProbeScope::Machine => windows::machine_path(), + #[cfg(not(windows))] + ProbeScope::Machine => runtime.path(), + } +} + +fn find_power_shell(runtime: &R, search_path: Option<&OsString>) -> bool +where + R: EnvironmentEffects + FileSystemEffects, +{ + let is_file = |path: PathBuf| { + matches!( + runtime.inspect_path(&path), + Ok(Some(inspection)) if inspection.file_type == InspectedFileType::RegularFile + ) + }; + search_path + .map(|path| { + std::env::split_paths(path).any(|directory| is_file(directory.join("pwsh.exe"))) + }) + .unwrap_or(false) + || runtime + .environment_variable("ProgramFiles") + .is_some_and(|program_files| { + is_file(PathBuf::from(program_files).join(r"PowerShell\7\pwsh.exe")) + }) +} + +// probe + +pub(crate) fn probe(runtime: &R, target: Option, options: &ProbeOptions) -> i32 +where + R: EnvironmentEffects + FileSystemEffects + UserInteractionEffects + PolicyEffects, +{ + let deadline = Instant::now() + options.timeout; + let policy_disabled = runtime.copilot_cli_command_disabled(); + let search_path = probe_search_path(runtime, options.scope); + let mut reasons = Vec::new(); + let cli = find_cli(runtime, search_path.clone()).unwrap_or_else(|error| { + reasons.push(format!("discovery: {error}")); + None + }); + let cli_found = cli.is_some(); + let windows_target = matches!( + target, + Some(HostTarget::WindowsX64 | HostTarget::WindowsArm64) + ); + let power_shell = !windows_target || find_power_shell(runtime, search_path.as_ref()); + + // Setup doesn't offer the download when Copilot CLI is already installed, so there's nothing to check. + let download_size = if policy_disabled { + reasons.push(format!( + "the {COPILOT_CLI_COMMAND_POLICY} policy is disabled" + )); + None + } else if cli_found { + None + } else if options.network { + probe_download_before(target, deadline) + .map_err(|error| reasons.push(format!("download: {error}"))) + .ok() + } else { + reasons.push(String::from("network checks were skipped")); + None + }; + + let entries = [ + ("protocol", PROTOCOL_VERSION.to_string()), + ( + "policy", + String::from(if policy_disabled { + "disabled" + } else { + "allowed" + }), + ), + ("cliFound", flag(cli_found)), + // Setup shows where it found Copilot CLI when it skips its page. + ( + "cliPath", + cli.map(|path| path.display().to_string()) + .unwrap_or_default(), + ), + ("pwshFound", flag(power_shell)), + ("downloadAvailable", flag(download_size.is_some())), + ("downloadSize", download_size.unwrap_or(0).to_string()), + ("reason", reasons.join("; ")), + ]; + match write_ini(&options.result_file, "probe", &entries) { + Ok(()) => 0, + Err(error) => { + runtime.write_diagnostic(&format!( + "failed to write the probe result to {:?}: {error}", + options.result_file + )); + 1 + } + } +} + +#[cfg(windows)] +fn releases_base() -> String { + std::env::var(RELEASES_URL_VARIABLE) + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| String::from(DEFAULT_RELEASES_URL)) + .trim_end_matches('/') + .to_owned() +} + +#[cfg(windows)] +fn resolve_latest_tag(client: &windows::HttpClient, base: &str) -> Result { + let response = client + .request("HEAD", &format!("{base}/latest"), false) + .map_err(|error| format!("could not reach {base}: {error}"))?; + if !(300..400).contains(&response.status) { + return Err(format!( + "{base}/latest returned HTTP {} instead of a redirect", + response.status + )); + } + response + .location() + .as_deref() + .and_then(parse_release_tag) + .ok_or_else(|| String::from("the latest release redirect has no tag")) +} + +/// Returns the size of the MSI in the latest release. +#[cfg(windows)] +fn probe_download(target: Option, deadline: Instant) -> Result { + let asset = msi_asset(target).ok_or("no MSI for this architecture")?; + let remaining = |deadline: Instant| { + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| String::from("timed out")) + }; + let base = releases_base(); + let client = + windows::HttpClient::new(remaining(deadline)?).map_err(|error| error.to_string())?; + let tag = resolve_latest_tag(&client, &base)?; + remaining(deadline)?; + let url = format!("{base}/download/{tag}/{asset}"); + let response = client + .request("HEAD", &url, true) + .map_err(|error| format!("could not reach {url}: {error}"))?; + if response.status != 200 { + return Err(format!("{url} returned HTTP {}", response.status)); + } + Ok(response.content_length().unwrap_or(0)) +} + +#[cfg(not(windows))] +fn probe_download(_target: Option, _deadline: Instant) -> Result { + Err(String::from("unsupported on this platform")) +} + +/// Runs the download check on another thread and stops waiting at the deadline. WinHTTP timeouts apply to each +/// stage separately and do not cover proxy discovery, and setup only waits a little past the probe timeout for the +/// result file. The process exits after writing the result, which ends an abandoned check. +fn probe_download_before(target: Option, deadline: Instant) -> Result { + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = sender.send(probe_download(target, deadline)); + }); + receiver + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap_or_else(|_| Err(String::from("timed out"))) +} + +// install + +// The MSI install only runs on Windows; the statuses and reporters stay shared so the contract compiles everywhere. +#[cfg_attr(not(windows), allow(dead_code))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InstallStatus { + Installed, + AlreadyInstalled, + Unsupported, + Policy, + Network, + Verification, + Msiexec, + Cancelled, + Error, +} + +impl InstallStatus { + pub(crate) fn name(self) -> &'static str { + match self { + Self::Installed => "installed", + Self::AlreadyInstalled => "alreadyInstalled", + Self::Unsupported => "unsupported", + Self::Policy => "policy", + Self::Network => "network", + Self::Verification => "verification", + Self::Msiexec => "msiexec", + Self::Cancelled => "cancelled", + Self::Error => "error", + } + } + + pub(crate) fn exit_code(self) -> i32 { + match self { + Self::Installed | Self::AlreadyInstalled => 0, + Self::Policy => 10, + Self::Network => 20, + Self::Verification => 30, + Self::Msiexec => 40, + Self::Cancelled => 50, + Self::Unsupported | Self::Error => 1, + } + } +} + +#[derive(Debug)] +pub(crate) struct InstallOutcome { + pub(crate) status: InstallStatus, + pub(crate) reason: String, + pub(crate) installer_exit_code: i32, + pub(crate) cli_path: Option, + pub(crate) release_tag: Option, + pub(crate) log: Option, +} + +impl InstallOutcome { + fn failed(status: InstallStatus, reason: impl Into) -> Self { + Self { + status, + reason: reason.into(), + installer_exit_code: 0, + cli_path: None, + release_tag: None, + log: None, + } + } +} + +#[cfg_attr(not(windows), allow(dead_code))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InstallPhase { + Resolving, + Downloading, + Verifying, + Installing, +} + +impl InstallPhase { + #[cfg_attr(not(windows), allow(dead_code))] + fn name(self) -> &'static str { + match self { + Self::Resolving => "resolving", + Self::Downloading => "downloading", + Self::Verifying => "verifying", + Self::Installing => "installing", + } + } +} + +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) trait InstallReporter { + fn report(&mut self, phase: InstallPhase, current: u64, total: u64); + fn cancelled(&self) -> bool; +} + +/// Reports progress to VS Code setup through a file that is rewritten at least every second while work continues. +#[cfg_attr(not(windows), allow(dead_code))] +struct FileReporter { + progress_file: Option, + cancel_file: Option, + heartbeat: u64, + last: Option<(InstallPhase, std::time::Instant)>, +} + +impl InstallReporter for FileReporter { + fn report(&mut self, phase: InstallPhase, current: u64, total: u64) { + let Some(path) = &self.progress_file else { + return; + }; + let now = std::time::Instant::now(); + if let Some((last_phase, last_time)) = self.last { + if last_phase == phase + && now.duration_since(last_time) < std::time::Duration::from_millis(250) + { + return; + } + } + self.heartbeat += 1; + self.last = Some((phase, now)); + let _ = write_ini( + path, + "progress", + &[ + ("phase", String::from(phase.name())), + ("current", current.to_string()), + ("total", total.to_string()), + ("heartbeat", self.heartbeat.to_string()), + ], + ); + } + + fn cancelled(&self) -> bool { + self.cancel_file.as_ref().is_some_and(|path| path.exists()) + } +} + +/// How often the first-use install may rewrite its download line. Screen readers announce every rewrite. +#[cfg_attr(not(windows), allow(dead_code))] +const CONSOLE_PROGRESS_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5); + +/// Whether the download line should be rewritten: when the download starts and finishes, and otherwise at most once per +/// [`CONSOLE_PROGRESS_INTERVAL`]. `last` is when the line was last written and the byte count it showed. +#[cfg_attr(not(windows), allow(dead_code))] +fn should_show_download_progress( + last: Option<(Instant, u64)>, + now: Instant, + current: u64, + total: u64, +) -> bool { + match last { + None => true, + Some((_, shown)) if shown == current => false, + Some(_) if current >= total => true, + Some((shown_at, _)) => now.duration_since(shown_at) >= CONSOLE_PROGRESS_INTERVAL, + } +} + +/// Reports progress in the terminal for the first-use install. +#[cfg_attr(not(windows), allow(dead_code))] +struct ConsoleReporter { + last_phase: Option, + last_progress: Option<(Instant, u64)>, +} + +impl InstallReporter for ConsoleReporter { + fn report(&mut self, phase: InstallPhase, current: u64, total: u64) { + if self.last_phase != Some(phase) { + if self.last_phase == Some(InstallPhase::Downloading) { + eprintln!(); + } + self.last_phase = Some(phase); + self.last_progress = None; + match phase { + InstallPhase::Resolving => { + eprintln!("Finding the latest GitHub Copilot CLI release...") + } + InstallPhase::Downloading => {} + InstallPhase::Verifying => eprintln!("Verifying the download..."), + InstallPhase::Installing => eprintln!("Installing GitHub Copilot CLI..."), + } + } + if phase == InstallPhase::Downloading && total > 0 { + let now = Instant::now(); + if should_show_download_progress(self.last_progress, now, current, total) { + self.last_progress = Some((now, current)); + let percent = current.saturating_mul(100) / total; + eprint!( + "\rDownloading GitHub Copilot CLI... {} MB of {} MB ({percent}%)", + current / 1_048_576, + total / 1_048_576 + ); + } + } + } + + fn cancelled(&self) -> bool { + crate::runtime::platform::is_process_cancellation_requested() + } +} + +pub(crate) fn install(runtime: &R, target: Option, options: &InstallOptions) -> i32 +where + R: EnvironmentEffects + FileSystemEffects + UserInteractionEffects + PolicyEffects, +{ + let policy_outcome = runtime.copilot_cli_command_disabled().then(|| { + InstallOutcome::failed( + InstallStatus::Policy, + format!( + "installing GitHub Copilot CLI from VS Code is turned off by the {COPILOT_CLI_COMMAND_POLICY} policy" + ), + ) + }); + match &options.mode { + InstallMode::Interactive => { + // The parent shim waits for this process, so Ctrl+C stops the install at the next step and the partial + // download is removed instead of the process ending with it. + if let Err(error) = crate::runtime::platform::install_cancellation_handler() { + runtime.write_diagnostic(&format!("failed to handle Ctrl+C: {error}")); + } + let mut outcome = policy_outcome.unwrap_or_else(|| { + let mut reporter = ConsoleReporter { + last_phase: None, + last_progress: None, + }; + install_cli(target, &mut reporter) + }); + // After Ctrl+C, a step that failed because it was interrupted is the cancellation. The parent shim reports it. + if crate::runtime::platform::is_process_cancellation_requested() + && !matches!( + outcome.status, + InstallStatus::Installed | InstallStatus::AlreadyInstalled + ) { + outcome.status = InstallStatus::Cancelled; + } + match outcome.status { + InstallStatus::Cancelled => {} + InstallStatus::Installed => eprintln!( + "Installed GitHub Copilot CLI{}.", + outcome + .release_tag + .as_deref() + .map(|tag| format!(" {tag}")) + .unwrap_or_default() + ), + InstallStatus::AlreadyInstalled => eprintln!( + "GitHub Copilot CLI is already installed{}.", + outcome + .cli_path + .as_ref() + .map(|path| format!(" at {}", path.display())) + .unwrap_or_default() + ), + // The reason is shown without verbose mode; the parent shim adds the manual install link. + _ => runtime.write_message(&format!( + "GitHub Copilot CLI couldn't be installed: {}", + outcome.reason + )), + } + outcome.status.exit_code() + } + InstallMode::Setup { + progress_file, + result_file, + cancel_file, + running_mutex, + } => { + #[cfg(windows)] + let _mutex = match running_mutex { + Some(name) => match windows::RunningMutex::acquire(name) { + Ok(mutex) => Some(mutex), + Err(error) => { + runtime.write_diagnostic(&format!( + "failed to create the running mutex: {error}" + )); + None + } + }, + None => None, + }; + #[cfg(not(windows))] + let _ = running_mutex; + + let outcome = match policy_outcome { + Some(outcome) => outcome, + None => match find_cli(runtime, probe_search_path(runtime, ProbeScope::User)) { + Ok(Some(path)) => InstallOutcome { + status: InstallStatus::AlreadyInstalled, + reason: String::new(), + installer_exit_code: 0, + cli_path: Some(path), + release_tag: None, + log: None, + }, + _ => { + let mut reporter = FileReporter { + progress_file: progress_file.clone(), + cancel_file: cancel_file.clone(), + heartbeat: 0, + last: None, + }; + install_cli(target, &mut reporter) + } + }, + }; + let entries = [ + ("status", String::from(outcome.status.name())), + ("exitCode", outcome.installer_exit_code.to_string()), + ( + "cliPath", + outcome + .cli_path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + ), + ( + "cliVersion", + outcome.release_tag.clone().unwrap_or_default(), + ), + ( + "log", + outcome + .log + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(), + ), + ("reason", outcome.reason.clone()), + ]; + if let Err(error) = write_ini(result_file, "result", &entries) { + runtime.write_diagnostic(&format!( + "failed to write the install result to {result_file:?}: {error}" + )); + } + outcome.status.exit_code() + } + } +} + +#[cfg(not(windows))] +fn install_cli(_target: Option, _reporter: &mut dyn InstallReporter) -> InstallOutcome { + InstallOutcome::failed( + InstallStatus::Unsupported, + "the MSI install is only available on Windows", + ) +} + +/// Downloads GitHub's per-user Copilot CLI MSI from the latest release, checks it against the release's checksum list +/// and its Authenticode signer, and installs it silently without elevation. +#[cfg(windows)] +fn install_cli(target: Option, reporter: &mut dyn InstallReporter) -> InstallOutcome { + use std::io::Write; + + let Some(asset) = msi_asset(target) else { + return InstallOutcome::failed(InstallStatus::Unsupported, "no MSI for this architecture"); + }; + // Copilot CLI updates itself, so this only installs where there is no Copilot CLI and never runs the MSI over one. + if let Some(path) = windows::cli_install_directory() + .map(|directory| directory.join("copilot.exe")) + .filter(|path| path.is_file()) + { + return InstallOutcome { + cli_path: Some(path), + ..InstallOutcome::failed(InstallStatus::AlreadyInstalled, "") + }; + } + if matches!(windows::cli_msi_registered(), Ok(true)) { + return InstallOutcome::failed( + InstallStatus::Error, + "GitHub Copilot CLI is registered in Installed apps, but its copilot.exe is missing; uninstall it in Settings > Apps > Installed apps and try again", + ); + } + let cancelled = + || InstallOutcome::failed(InstallStatus::Cancelled, "the install was cancelled"); + reporter.report(InstallPhase::Resolving, 0, 0); + let base = releases_base(); + let client = match windows::HttpClient::new(INSTALL_REQUEST_TIMEOUT) { + Ok(client) => client, + Err(error) => return InstallOutcome::failed(InstallStatus::Network, error.to_string()), + }; + let tag = match resolve_latest_tag(&client, &base) { + Ok(tag) => tag, + Err(error) => return InstallOutcome::failed(InstallStatus::Network, error), + }; + let with_tag = |mut outcome: InstallOutcome| { + outcome.release_tag = Some(tag.clone()); + outcome + }; + + let checksums_url = format!("{base}/download/{tag}/SHA256SUMS.txt"); + let mut checksums = String::new(); + match client.request("GET", &checksums_url, true) { + Ok(response) if response.status == 200 => { + if let Err(error) = response + .take(MAXIMUM_CHECKSUMS_SIZE) + .read_to_string(&mut checksums) + { + return with_tag(InstallOutcome::failed( + InstallStatus::Network, + error.to_string(), + )); + } + } + Ok(response) => { + return with_tag(InstallOutcome::failed( + InstallStatus::Network, + format!("{checksums_url} returned HTTP {}", response.status), + )) + } + Err(error) => { + return with_tag(InstallOutcome::failed( + InstallStatus::Network, + error.to_string(), + )) + } + } + let Some(expected) = expected_sha256(&checksums, asset) else { + return with_tag(InstallOutcome::failed( + InstallStatus::Verification, + format!("SHA256SUMS.txt has no entry for {asset}"), + )); + }; + + let directory = match tempfile::Builder::new() + .prefix("vscode-copilot-cli-") + .tempdir() + { + Ok(directory) => directory, + Err(error) => { + return with_tag(InstallOutcome::failed( + InstallStatus::Error, + error.to_string(), + )) + } + }; + let msi = directory.path().join(asset); + let url = format!("{base}/download/{tag}/{asset}"); + let download = (|| -> Result { + let mut response = client + .request("GET", &url, true) + .map_err(|error| InstallOutcome::failed(InstallStatus::Network, error.to_string()))?; + if response.status != 200 { + return Err(InstallOutcome::failed( + InstallStatus::Network, + format!("{url} returned HTTP {}", response.status), + )); + } + let total = response.content_length().unwrap_or(0); + let mut file = std::fs::File::create(&msi) + .map_err(|error| InstallOutcome::failed(InstallStatus::Error, error.to_string()))?; + let mut hash = windows::Sha256::new() + .map_err(|error| InstallOutcome::failed(InstallStatus::Error, error.to_string()))?; + let mut buffer = vec![0_u8; 64 * 1024]; + let mut current = 0_u64; + reporter.report(InstallPhase::Downloading, 0, total); + loop { + if reporter.cancelled() { + return Err(cancelled()); + } + let read = response.read(&mut buffer).map_err(|error| { + InstallOutcome::failed(InstallStatus::Network, error.to_string()) + })?; + if read == 0 { + break; + } + file.write_all(&buffer[..read]) + .and_then(|()| hash.update(&buffer[..read])) + .map_err(|error| InstallOutcome::failed(InstallStatus::Error, error.to_string()))?; + current += read as u64; + reporter.report(InstallPhase::Downloading, current, total); + } + hash.finish() + .map(|digest| hex(&digest)) + .map_err(|error| InstallOutcome::failed(InstallStatus::Error, error.to_string())) + })(); + let actual = match download { + Ok(actual) => actual, + Err(outcome) => return with_tag(outcome), + }; + + reporter.report(InstallPhase::Verifying, 0, 0); + if actual != expected { + return with_tag(InstallOutcome::failed( + InstallStatus::Verification, + format!("{asset} has SHA-256 {actual}, expected {expected}"), + )); + } + match windows::authenticode_signer(&msi) { + Ok(signer) if signer == EXPECTED_SIGNER => {} + Ok(signer) => { + return with_tag(InstallOutcome::failed( + InstallStatus::Verification, + format!("{asset} is signed by {signer:?}, expected {EXPECTED_SIGNER:?}"), + )) + } + Err(error) => { + return with_tag(InstallOutcome::failed( + InstallStatus::Verification, + error.to_string(), + )) + } + } + + // msiexec can't be stopped safely once it runs, so this is the last point where a cancel takes effect. + if reporter.cancelled() { + return with_tag(cancelled()); + } + reporter.report(InstallPhase::Installing, 0, 0); + let log = std::env::temp_dir().join("vscode-copilot-cli-install.log"); + let msiexec = std::env::var_os("SystemRoot") + .map(|root| PathBuf::from(root).join(r"System32\msiexec.exe")) + .unwrap_or_else(|| PathBuf::from("msiexec.exe")); + let mut child = match std::process::Command::new(&msiexec) + .arg("/i") + .arg(&msi) + .args(["/qn", "/norestart", "/l*v"]) + .arg(&log) + .spawn() + { + Ok(child) => child, + Err(error) => { + return with_tag(InstallOutcome::failed( + InstallStatus::Msiexec, + error.to_string(), + )) + } + }; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + reporter.report(InstallPhase::Installing, 0, 0); + std::thread::sleep(Duration::from_millis(500)); + } + Err(error) => { + return with_tag(InstallOutcome::failed( + InstallStatus::Msiexec, + error.to_string(), + )) + } + } + }; + let installer_exit_code = status.code().unwrap_or(1); + let mut outcome = match installer_exit_code { + // 3010: installed, but a restart is required to complete; the CLI is usable. + 0 | 3010 => InstallOutcome::failed(InstallStatus::Installed, ""), + 1602 => InstallOutcome::failed(InstallStatus::Cancelled, "the install was cancelled"), + code => InstallOutcome::failed( + InstallStatus::Msiexec, + format!("msiexec exited with {code}"), + ), + }; + outcome.installer_exit_code = installer_exit_code; + outcome.log = Some(log); + if outcome.status == InstallStatus::Installed { + let cli = windows::cli_install_directory().map(|directory| directory.join("copilot.exe")); + match cli { + Some(path) if path.is_file() => outcome.cli_path = Some(path), + _ => { + outcome.status = InstallStatus::Error; + outcome.reason = String::from("the MSI finished but copilot.exe was not found"); + } + } + } + with_tag(outcome) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn release_tags_are_parsed_from_redirects() { + assert_eq!( + [ + parse_release_tag("https://github.com/github/copilot-cli/releases/tag/v1.0.89"), + parse_release_tag("http://127.0.0.1:9/releases/tag/v1.0.0-1?x=y"), + parse_release_tag("https://github.com/github/copilot-cli/releases"), + parse_release_tag("https://github.com/github/copilot-cli/releases/tag/"), + parse_release_tag("https://github.com/github/copilot-cli/releases/tag/..%2F"), + ], + [ + Some(String::from("v1.0.89")), + Some(String::from("v1.0.0-1")), + None, + None, + None, + ] + ); + } + + #[test] + fn checksums_are_matched_by_exact_asset_name() { + let checksums = "c5e80e05b5f9a288dc478f1d6d4694a9466419e91bef05a6456ce0b2ccadd331 copilot-win32-arm64.zip\n\ + 8F4A8A3EB38F1033ADCBBF265DA70A6A0047FCEDC07DE259930071A9471EBD8D *copilot-x64.msi\n\ + not-a-hash copilot-arm64.msi\n"; + assert_eq!( + [ + expected_sha256(checksums, "copilot-x64.msi"), + expected_sha256(checksums, "copilot-arm64.msi"), + expected_sha256(checksums, "copilot-x64"), + ], + [ + Some(String::from( + "8f4a8a3eb38f1033adcbbf265da70a6a0047fcedc07de259930071a9471ebd8d" + )), + None, + None, + ] + ); + } + + #[test] + fn msi_assets_follow_the_windows_architecture() { + assert_eq!( + [ + msi_asset(Some(HostTarget::WindowsX64)), + msi_asset(Some(HostTarget::WindowsArm64)), + msi_asset(Some(HostTarget::MacosArm64)), + msi_asset(None), + ], + [ + Some("copilot-x64.msi"), + Some("copilot-arm64.msi"), + None, + None + ] + ); + } + + #[test] + fn ini_values_are_single_line() { + assert_eq!( + render_ini( + "probe", + &[ + ("cliFound", flag(true)), + ("reason", String::from("line one\r\nline two")), + ] + ), + "[probe]\r\ncliFound=1\r\nreason=line one line two\r\n" + ); + } + + #[test] + fn write_ini_replaces_the_file_with_utf16() { + let directory = tempfile::tempdir().expect("create directory"); + let path = directory.path().join("result.ini"); + std::fs::write(&path, b"stale").expect("write stale result"); + write_ini(&path, "result", &[("status", String::from("installed"))]).expect("write result"); + let bytes = std::fs::read(&path).expect("read result"); + let units: Vec = bytes[2..] + .chunks(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + assert_eq!( + (&bytes[..2], String::from_utf16_lossy(&units)), + ( + &[0xFF_u8, 0xFE][..], + String::from("[result]\r\nstatus=installed\r\n") + ) + ); + } + + #[test] + fn install_statuses_map_to_the_setup_exit_codes() { + assert_eq!( + [ + InstallStatus::Installed, + InstallStatus::AlreadyInstalled, + InstallStatus::Policy, + InstallStatus::Network, + InstallStatus::Verification, + InstallStatus::Msiexec, + InstallStatus::Cancelled, + InstallStatus::Unsupported, + InstallStatus::Error, + ] + .map(|status| (status.name(), status.exit_code())), + [ + ("installed", 0), + ("alreadyInstalled", 0), + ("policy", 10), + ("network", 20), + ("verification", 30), + ("msiexec", 40), + ("cancelled", 50), + ("unsupported", 1), + ("error", 1), + ] + ); + } + + #[test] + fn download_progress_is_shown_at_the_start_the_end_and_every_interval() { + let start = Instant::now(); + let soon = start + std::time::Duration::from_secs(1); + let later = start + CONSOLE_PROGRESS_INTERVAL; + assert_eq!( + [ + should_show_download_progress(None, start, 0, 100), + should_show_download_progress(Some((start, 0)), soon, 30, 100), + should_show_download_progress(Some((start, 0)), later, 60, 100), + should_show_download_progress(Some((start, 0)), soon, 100, 100), + should_show_download_progress(Some((soon, 100)), later, 100, 100), + ], + [true, false, true, true, false] + ); + } +} diff --git a/copilot_shim/src/setup/windows.rs b/copilot_shim/src/setup/windows.rs new file mode 100644 index 00000000000000..0b019e59acb1ad --- /dev/null +++ b/copilot_shim/src/setup/windows.rs @@ -0,0 +1,805 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//! Windows services for the setup commands and for finding an installed Copilot CLI: the PATH stored in the registry, +//! the VS Code `CopilotCliCommand` policy, HTTP through WinHTTP (which honors the system proxy configuration), SHA-256 +//! through CNG, Authenticode verification, and a named mutex that tells VS Code setup the install is still running. + +use std::ffi::{OsStr, OsString}; +use std::io::{self, Read}; +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +use std::path::{Path, PathBuf}; +use std::ptr::{null, null_mut}; +use std::time::Duration; + +use windows_sys::Win32::Foundation::{ + CloseHandle, FreeLibrary, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, +}; +use windows_sys::Win32::Networking::WinHttp::{ + WinHttpCloseHandle, WinHttpConnect, WinHttpCrackUrl, WinHttpOpen, WinHttpOpenRequest, + WinHttpQueryDataAvailable, WinHttpQueryHeaders, WinHttpReadData, WinHttpReceiveResponse, + WinHttpSendRequest, WinHttpSetOption, WinHttpSetTimeouts, URL_COMPONENTS, + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_DISABLE_REDIRECTS, WINHTTP_FLAG_SECURE, + WINHTTP_INTERNET_SCHEME_HTTPS, WINHTTP_OPTION_DISABLE_FEATURE, WINHTTP_QUERY_CONTENT_LENGTH, + WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_QUERY_LOCATION, WINHTTP_QUERY_STATUS_CODE, +}; +use windows_sys::Win32::Security::Cryptography::{ + BCryptCloseAlgorithmProvider, BCryptCreateHash, BCryptDestroyHash, BCryptFinishHash, + BCryptHashData, BCryptOpenAlgorithmProvider, CertGetNameStringW, BCRYPT_ALG_HANDLE, + BCRYPT_HASH_HANDLE, BCRYPT_SHA256_ALGORITHM, CERT_NAME_SIMPLE_DISPLAY_TYPE, +}; +use windows_sys::Win32::Security::WinTrust::{ + WTHelperGetProvSignerFromChain, WTHelperProvDataFromStateData, WinVerifyTrust, + WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_DATA_0, WINTRUST_FILE_INFO, + WTD_CHOICE_FILE, WTD_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT, WTD_REVOKE_WHOLECHAIN, + WTD_STATEACTION_CLOSE, WTD_STATEACTION_VERIFY, WTD_UI_NONE, +}; +use windows_sys::Win32::System::Diagnostics::Debug::{ + FormatMessageW, FORMAT_MESSAGE_FROM_HMODULE, FORMAT_MESSAGE_IGNORE_INSERTS, +}; +use windows_sys::Win32::System::Environment::ExpandEnvironmentStringsW; +use windows_sys::Win32::System::LibraryLoader::{ + GetModuleHandleW, GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32, +}; +use windows_sys::Win32::System::Registry::{ + RegGetValueW, HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, RRF_NOEXPAND, RRF_RT_REG_DWORD, + RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, +}; +use windows_sys::Win32::System::Threading::{CreateMutexW, ReleaseMutex}; + +const MACHINE_ENVIRONMENT_KEY: &str = + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; +const USER_ENVIRONMENT_KEY: &str = "Environment"; + +use super::CLI_INSTALL_FOLDER; + +/// The policy registry key names (`win32RegValueName`) of the VS Code qualities. +const PRODUCT_POLICY_KEYS: [&str; 4] = ["VSCode", "VSCodeInsiders", "VSCodeExploration", "CodeOSS"]; + +fn wide(value: &OsStr) -> Vec { + value.encode_wide().chain(std::iter::once(0)).collect() +} + +fn wide_str(value: &str) -> Vec { + wide(OsStr::new(value)) +} + +fn from_wide_until_nul(value: &[u16]) -> OsString { + let end = value + .iter() + .position(|unit| *unit == 0) + .unwrap_or(value.len()); + OsString::from_wide(&value[..end]) +} + +// Environment and PATH + +pub(crate) fn expand_environment(value: &OsStr) -> OsString { + let source = wide(value); + let required = unsafe { ExpandEnvironmentStringsW(source.as_ptr(), null_mut(), 0) }; + if required == 0 { + return value.to_os_string(); + } + let mut buffer = vec![0_u16; required as usize]; + let written = + unsafe { ExpandEnvironmentStringsW(source.as_ptr(), buffer.as_mut_ptr(), required) }; + if written == 0 || written > required { + return value.to_os_string(); + } + from_wide_until_nul(&buffer) +} + +fn registry_path(root: HKEY, subkey: &str) -> Option { + let subkey = wide_str(subkey); + let name = wide_str("Path"); + // Read the raw value and expand it ourselves; REG_EXPAND_SZ is the usual type for Path. + let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND; + let mut size = 0_u32; + let status = unsafe { + RegGetValueW( + root, + subkey.as_ptr(), + name.as_ptr(), + flags, + null_mut(), + null_mut(), + &mut size, + ) + }; + if status != 0 || size == 0 { + return None; + } + let mut buffer = vec![0_u16; (size as usize).div_ceil(2)]; + let status = unsafe { + RegGetValueW( + root, + subkey.as_ptr(), + name.as_ptr(), + flags, + null_mut(), + buffer.as_mut_ptr().cast(), + &mut size, + ) + }; + if status != 0 { + return None; + } + Some(expand_environment(&from_wide_until_nul(&buffer))) +} + +/// The machine PATH stored in the registry, with environment variables expanded. +pub(crate) fn machine_path() -> Option { + registry_path(HKEY_LOCAL_MACHINE, MACHINE_ENVIRONMENT_KEY) +} + +fn registry_dword(root: HKEY, subkey: &str, name: &str) -> Option { + let subkey = wide_str(subkey); + let name = wide_str(name); + let mut value = 0_u32; + let mut size = std::mem::size_of::() as u32; + let status = unsafe { + RegGetValueW( + root, + subkey.as_ptr(), + name.as_ptr(), + RRF_RT_REG_DWORD, + null_mut(), + (&mut value as *mut u32).cast(), + &mut size, + ) + }; + (status == 0).then_some(value) +} + +/// Whether the `CopilotCliCommand` policy of any VS Code quality turns the command off. For each quality the machine +/// policy takes precedence over the user policy, as in VS Code. A disabled policy in any quality wins, because the +/// shim of any installed quality can be the one on PATH. +pub(crate) fn copilot_cli_command_disabled() -> bool { + PRODUCT_POLICY_KEYS.iter().any(|product| { + let key = format!(r"SOFTWARE\Policies\Microsoft\{product}"); + registry_dword(HKEY_LOCAL_MACHINE, &key, super::COPILOT_CLI_COMMAND_POLICY) + .or_else(|| registry_dword(HKEY_CURRENT_USER, &key, super::COPILOT_CLI_COMMAND_POLICY)) + == Some(0) + }) +} + +fn user_path() -> Option { + registry_path(HKEY_CURRENT_USER, USER_ENVIRONMENT_KEY) +} + +/// `%LOCALAPPDATA%\GitHubCopilotCLI`, where the per-user MSI installs `copilot.exe`. +pub(crate) fn cli_install_directory() -> Option { + std::env::var_os("LOCALAPPDATA") + .filter(|value| !value.is_empty()) + .map(|local_app_data| PathBuf::from(local_app_data).join(CLI_INSTALL_FOLDER)) +} + +/// The UpgradeCode of GitHub's Copilot CLI MSI. +const CLI_MSI_UPGRADE_CODE: &str = "{E2C3A7F6-1D3A-4E8F-9E5F-8E9D4F9C1234}"; + +type MsiEnumRelatedProducts = unsafe extern "system" fn(*const u16, u32, u32, *mut u16) -> u32; + +/// Whether Windows Installer has a Copilot CLI MSI registered for this user or the machine. `msi.dll` is loaded only +/// here, so launching the CLI doesn't pay for it. +pub(crate) fn cli_msi_registered() -> io::Result { + let library = unsafe { + LoadLibraryExW( + wide_str("msi.dll").as_ptr(), + null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + }; + if library.is_null() { + return Err(io::Error::last_os_error()); + } + let result = (|| { + let procedure = + unsafe { GetProcAddress(library, c"MsiEnumRelatedProductsW".as_ptr().cast()) } + .ok_or_else(io::Error::last_os_error)?; + let enumerate: MsiEnumRelatedProducts = unsafe { std::mem::transmute(procedure) }; + let upgrade_code = wide_str(CLI_MSI_UPGRADE_CODE); + let mut product_code = [0_u16; 39]; + match unsafe { enumerate(upgrade_code.as_ptr(), 0, 0, product_code.as_mut_ptr()) } { + ERROR_SUCCESS => Ok(true), + ERROR_NO_MORE_ITEMS => Ok(false), + status => Err(io::Error::from_raw_os_error(status as i32)), + } + })(); + unsafe { FreeLibrary(library) }; + result +} + +/// The directories to search for the Copilot CLI at run time: the process PATH, then the MSI install folder. A +/// terminal started before Copilot CLI was installed has a stale PATH; the install folder lets the shim find the CLI +/// it just installed without restarting that terminal. +pub(crate) fn discovery_path(process_path: Option) -> Option { + join_search_path([process_path], cli_install_directory()) +} + +/// The directories VS Code setup should search: the process PATH, the machine and user PATH stored in the registry +/// (setup may have inherited a stale PATH), and the MSI install folder. +pub(crate) fn setup_discovery_path(process_path: Option) -> Option { + join_search_path( + [process_path, machine_path(), user_path()], + cli_install_directory(), + ) +} + +fn join_search_path( + paths: [Option; N], + extra: Option, +) -> Option { + let mut entries: Vec = Vec::new(); + for value in paths.into_iter().flatten() { + entries.extend(std::env::split_paths(&value)); + } + entries.extend(extra); + if entries.is_empty() { + return None; + } + std::env::join_paths(entries).ok() +} + +// HTTP + +/// The last WinHTTP error. WinHTTP's messages are in `winhttp.dll`, not in the system message table that +/// `io::Error` uses, so they are formatted from that module, in the user's display language. +fn last_http_error() -> io::Error { + let error = io::Error::last_os_error(); + let Some(code) = error + .raw_os_error() + .filter(|code| (12000..13000).contains(code)) + else { + return error; + }; + let mut buffer = [0_u16; 512]; + let length = unsafe { + FormatMessageW( + FORMAT_MESSAGE_FROM_HMODULE | FORMAT_MESSAGE_IGNORE_INSERTS, + GetModuleHandleW(wide_str("winhttp.dll").as_ptr()).cast_const(), + code as u32, + 0, + buffer.as_mut_ptr(), + buffer.len() as u32, + null(), + ) + }; + match String::from_utf16_lossy(&buffer[..length as usize]).trim() { + "" => error, + message => io::Error::other(format!("{message} (WinHTTP error {code})")), + } +} + +struct InternetHandle(*mut core::ffi::c_void); + +impl InternetHandle { + fn new(handle: *mut core::ffi::c_void) -> io::Result { + if handle.is_null() { + Err(last_http_error()) + } else { + Ok(Self(handle)) + } + } +} + +impl Drop for InternetHandle { + fn drop(&mut self) { + unsafe { + WinHttpCloseHandle(self.0); + } + } +} + +pub(crate) struct HttpClient { + session: InternetHandle, +} + +pub(crate) struct HttpResponse { + // Declared before the connection so the request handle closes first. + request: InternetHandle, + _connection: InternetHandle, + pub(crate) status: u32, +} + +struct CrackedUrl { + secure: bool, + host: Vec, + port: u16, + path: Vec, +} + +fn crack_url(url: &str) -> io::Result { + let url_wide = wide_str(url); + let mut components = URL_COMPONENTS { + dwStructSize: std::mem::size_of::() as u32, + dwSchemeLength: u32::MAX, + dwHostNameLength: u32::MAX, + dwUrlPathLength: u32::MAX, + dwExtraInfoLength: u32::MAX, + ..Default::default() + }; + if unsafe { WinHttpCrackUrl(url_wide.as_ptr(), 0, 0, &mut components) } == 0 { + return Err(last_http_error()); + } + let slice = |pointer: *mut u16, length: u32| -> Vec { + if pointer.is_null() || length == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(pointer, length as usize) }.to_vec() + } + }; + let mut path = slice(components.lpszUrlPath, components.dwUrlPathLength); + path.extend(slice( + components.lpszExtraInfo, + components.dwExtraInfoLength, + )); + if path.is_empty() { + path.push(u16::from(b'/')); + } + let mut host = slice(components.lpszHostName, components.dwHostNameLength); + host.push(0); + path.push(0); + Ok(CrackedUrl { + secure: components.nScheme == WINHTTP_INTERNET_SCHEME_HTTPS, + host, + port: components.nPort, + path, + }) +} + +impl HttpClient { + pub(crate) fn new(timeout: Duration) -> io::Result { + let agent = wide_str(concat!("VSCodeCopilotShim/", env!("CARGO_PKG_VERSION"))); + // Automatic proxy follows the system proxy settings, including PAC files. + let session = InternetHandle::new(unsafe { + WinHttpOpen( + agent.as_ptr(), + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + null(), + null(), + 0, + ) + })?; + let milliseconds = i32::try_from(timeout.as_millis()) + .unwrap_or(i32::MAX) + .max(1); + if unsafe { + WinHttpSetTimeouts( + session.0, + milliseconds, + milliseconds, + milliseconds, + milliseconds, + ) + } == 0 + { + return Err(last_http_error()); + } + Ok(Self { session }) + } + + /// Sends a request with `verb`. When `follow_redirects` is false, a redirect is returned as-is so its `Location` can + /// be read. + pub(crate) fn request( + &self, + verb: &str, + url: &str, + follow_redirects: bool, + ) -> io::Result { + let url = crack_url(url)?; + let connection = InternetHandle::new(unsafe { + WinHttpConnect(self.session.0, url.host.as_ptr(), url.port, 0) + })?; + let verb = wide_str(verb); + let request = InternetHandle::new(unsafe { + WinHttpOpenRequest( + connection.0, + verb.as_ptr(), + url.path.as_ptr(), + null(), + null(), + null(), + if url.secure { WINHTTP_FLAG_SECURE } else { 0 }, + ) + })?; + if !follow_redirects { + let disable = WINHTTP_DISABLE_REDIRECTS; + if unsafe { + WinHttpSetOption( + request.0, + WINHTTP_OPTION_DISABLE_FEATURE, + (&disable as *const u32).cast(), + std::mem::size_of::() as u32, + ) + } == 0 + { + return Err(last_http_error()); + } + } + if unsafe { WinHttpSendRequest(request.0, null(), 0, null(), 0, 0, 0) } == 0 + || unsafe { WinHttpReceiveResponse(request.0, null_mut()) } == 0 + { + return Err(last_http_error()); + } + let mut status = 0_u32; + let mut size = std::mem::size_of::() as u32; + if unsafe { + WinHttpQueryHeaders( + request.0, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + null(), + (&mut status as *mut u32).cast(), + &mut size, + null_mut(), + ) + } == 0 + { + return Err(last_http_error()); + } + Ok(HttpResponse { + request, + _connection: connection, + status, + }) + } +} + +impl HttpResponse { + fn header(&self, info_level: u32) -> Option { + let mut size = 0_u32; + unsafe { + WinHttpQueryHeaders( + self.request.0, + info_level, + null(), + null_mut(), + &mut size, + null_mut(), + ) + }; + if size == 0 { + return None; + } + let mut buffer = vec![0_u16; (size as usize).div_ceil(2) + 1]; + if unsafe { + WinHttpQueryHeaders( + self.request.0, + info_level, + null(), + buffer.as_mut_ptr().cast(), + &mut size, + null_mut(), + ) + } == 0 + { + return None; + } + from_wide_until_nul(&buffer).into_string().ok() + } + + pub(crate) fn location(&self) -> Option { + self.header(WINHTTP_QUERY_LOCATION) + } + + pub(crate) fn content_length(&self) -> Option { + self.header(WINHTTP_QUERY_CONTENT_LENGTH) + .and_then(|value| value.trim().parse().ok()) + } +} + +impl Read for HttpResponse { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if buffer.is_empty() { + return Ok(0); + } + let mut available = 0_u32; + if unsafe { WinHttpQueryDataAvailable(self.request.0, &mut available) } == 0 { + return Err(last_http_error()); + } + if available == 0 { + return Ok(0); + } + let length = u32::try_from(buffer.len()) + .unwrap_or(u32::MAX) + .min(available); + let mut read = 0_u32; + if unsafe { + WinHttpReadData( + self.request.0, + buffer.as_mut_ptr().cast(), + length, + &mut read, + ) + } == 0 + { + return Err(last_http_error()); + } + Ok(read as usize) + } +} + +// SHA-256 + +pub(crate) struct Sha256 { + algorithm: BCRYPT_ALG_HANDLE, + hash: BCRYPT_HASH_HANDLE, +} + +fn check_status(status: i32) -> io::Result<()> { + if status >= 0 { + Ok(()) + } else { + Err(io::Error::other(format!("CNG error {status:#x}"))) + } +} + +impl Sha256 { + pub(crate) fn new() -> io::Result { + let mut algorithm = null_mut(); + check_status(unsafe { + BCryptOpenAlgorithmProvider(&mut algorithm, BCRYPT_SHA256_ALGORITHM, null(), 0) + })?; + let mut hash = null_mut(); + if let Err(error) = check_status(unsafe { + BCryptCreateHash(algorithm, &mut hash, null_mut(), 0, null(), 0, 0) + }) { + unsafe { BCryptCloseAlgorithmProvider(algorithm, 0) }; + return Err(error); + } + Ok(Self { algorithm, hash }) + } + + pub(crate) fn update(&mut self, data: &[u8]) -> io::Result<()> { + for chunk in data.chunks(u32::MAX as usize) { + check_status(unsafe { + BCryptHashData(self.hash, chunk.as_ptr(), chunk.len() as u32, 0) + })?; + } + Ok(()) + } + + pub(crate) fn finish(self) -> io::Result<[u8; 32]> { + let mut digest = [0_u8; 32]; + check_status(unsafe { + BCryptFinishHash(self.hash, digest.as_mut_ptr(), digest.len() as u32, 0) + })?; + Ok(digest) + } +} + +impl Drop for Sha256 { + fn drop(&mut self) { + unsafe { + BCryptDestroyHash(self.hash); + BCryptCloseAlgorithmProvider(self.algorithm, 0); + } + } +} + +// Authenticode + +/// Verifies the Authenticode signature of `path` and returns the signer's display name. +/// +/// A revoked certificate anywhere in the chain below the root fails verification. When the revocation server can't be +/// reached, the Windows Authenticode policy decides; by default it accepts the signature. +pub(crate) fn authenticode_signer(path: &Path) -> io::Result { + let path_wide = wide(path.as_os_str()); + let mut file_info = WINTRUST_FILE_INFO { + cbStruct: std::mem::size_of::() as u32, + pcwszFilePath: path_wide.as_ptr(), + hFile: null_mut(), + pgKnownSubject: null_mut(), + }; + let mut data = WINTRUST_DATA { + cbStruct: std::mem::size_of::() as u32, + dwUIChoice: WTD_UI_NONE, + fdwRevocationChecks: WTD_REVOKE_WHOLECHAIN, + dwUnionChoice: WTD_CHOICE_FILE, + Anonymous: WINTRUST_DATA_0 { + pFile: &mut file_info, + }, + dwStateAction: WTD_STATEACTION_VERIFY, + dwProvFlags: WTD_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT, + ..Default::default() + }; + let mut action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + let status = unsafe { + WinVerifyTrust( + null_mut(), + &mut action, + (&mut data as *mut WINTRUST_DATA).cast(), + ) + }; + let signer = if status == 0 { + signer_name(data.hWVTStateData) + } else { + Err(io::Error::other(format!( + "Authenticode verification failed ({:#x})", + status as u32 + ))) + }; + data.dwStateAction = WTD_STATEACTION_CLOSE; + unsafe { + WinVerifyTrust( + null_mut(), + &mut action, + (&mut data as *mut WINTRUST_DATA).cast(), + ) + }; + signer +} + +fn signer_name(state: HANDLE) -> io::Result { + let missing = || io::Error::other("the signature has no signer certificate"); + let provider = unsafe { WTHelperProvDataFromStateData(state) }; + if provider.is_null() { + return Err(missing()); + } + let signer = unsafe { WTHelperGetProvSignerFromChain(provider, 0, 0, 0) }; + if signer.is_null() { + return Err(missing()); + } + let signer = unsafe { &*signer }; + if signer.csCertChain == 0 || signer.pasCertChain.is_null() { + return Err(missing()); + } + // The first certificate in the chain is the leaf that signed the file. + let certificate = unsafe { (*signer.pasCertChain).pCert }; + if certificate.is_null() { + return Err(missing()); + } + let length = unsafe { + CertGetNameStringW( + certificate, + CERT_NAME_SIMPLE_DISPLAY_TYPE, + 0, + null(), + null_mut(), + 0, + ) + }; + let mut buffer = vec![0_u16; length.max(1) as usize]; + unsafe { + CertGetNameStringW( + certificate, + CERT_NAME_SIMPLE_DISPLAY_TYPE, + 0, + null(), + buffer.as_mut_ptr(), + length, + ) + }; + Ok(from_wide_until_nul(&buffer).to_string_lossy().into_owned()) +} + +// Mutex + +/// A named mutex held while the install runs, so VS Code setup can tell the process is still alive. +pub(crate) struct RunningMutex(HANDLE); + +impl RunningMutex { + pub(crate) fn acquire(name: &OsStr) -> io::Result { + let name = wide(name); + let handle = unsafe { CreateMutexW(null(), 1, name.as_ptr()) }; + if handle.is_null() { + Err(io::Error::last_os_error()) + } else { + Ok(Self(handle)) + } + } +} + +impl Drop for RunningMutex { + fn drop(&mut self) { + unsafe { + ReleaseMutex(self.0); + CloseHandle(self.0); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_matches_known_digest() { + let mut hash = Sha256::new().expect("create SHA-256"); + hash.update(b"abc").expect("hash data"); + let digest = hash.finish().expect("finish SHA-256"); + assert_eq!( + super::super::hex(&digest), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn crack_url_splits_host_port_and_path() { + let cracked = crack_url("https://github.com/github/copilot-cli/releases/latest?x=1") + .expect("crack URL"); + let local = crack_url("http://127.0.0.1:8080").expect("crack local URL"); + assert_eq!( + ( + cracked.secure, + String::from_utf16_lossy(&cracked.host), + cracked.port, + String::from_utf16_lossy(&cracked.path), + local.secure, + local.port, + String::from_utf16_lossy(&local.path), + ), + ( + true, + String::from("github.com\0"), + 443, + String::from("/github/copilot-cli/releases/latest?x=1\0"), + false, + 8080, + String::from("/\0"), + ) + ); + } + + #[test] + fn expand_environment_expands_variables() { + let windows = std::env::var_os("SystemRoot").expect("SystemRoot"); + assert_eq!( + expand_environment(OsStr::new(r"%SystemRoot%\System32")), + PathBuf::from(windows).join("System32").into_os_string() + ); + } + + #[test] + fn discovery_searches_the_msi_install_folder_after_path() { + let path = + discovery_path(Some(OsString::from(r"C:\first;C:\second"))).expect("search path"); + assert_eq!( + std::env::split_paths(&path).collect::>(), + vec![ + PathBuf::from(r"C:\first"), + PathBuf::from(r"C:\second"), + cli_install_directory().expect("LOCALAPPDATA"), + ] + ); + } + + #[test] + fn winhttp_errors_have_readable_messages() { + // ERROR_WINHTTP_NAME_NOT_RESOLVED; the standard library alone reports no text for it. + let code = 12007; + unsafe { windows_sys::Win32::Foundation::SetLastError(code) }; + let message = last_http_error().to_string(); + assert!( + message.ends_with("(WinHTTP error 12007)") && !message.contains("FormatMessageW"), + "{message}" + ); + } + + #[test] + fn unsigned_files_have_no_authenticode_signer() { + let directory = tempfile::tempdir().expect("create temporary directory"); + let file = directory.path().join("unsigned.msi"); + std::fs::write(&file, b"not a signed package").expect("write unsigned file"); + assert!(authenticode_signer(&file).is_err()); + } + + #[test] + fn registry_dwords_are_read_only_from_dword_values() { + let key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion"; + assert_eq!( + [ + registry_dword(HKEY_LOCAL_MACHINE, key, "CurrentMajorVersionNumber"), + registry_dword(HKEY_LOCAL_MACHINE, key, "ProductName"), + registry_dword(HKEY_LOCAL_MACHINE, key, "NoSuchValue"), + ], + [Some(10), None, None] + ); + } + + #[test] + fn the_msi_registration_can_be_queried() { + assert!(cli_msi_registered().is_ok()); + } +} diff --git a/copilot_shim/src/supervisor.rs b/copilot_shim/src/supervisor.rs new file mode 100644 index 00000000000000..9f92e2e4ad2ffc --- /dev/null +++ b/copilot_shim/src/supervisor.rs @@ -0,0 +1,617 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::io; +#[cfg(windows)] +use std::io::Read; +#[cfg(test)] +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(windows)] +use std::sync::mpsc::{self, RecvTimeoutError, SyncSender}; +#[cfg(test)] +use std::sync::Arc; +use std::sync::OnceLock; +#[cfg(windows)] +use std::thread; +#[cfg(windows)] +use std::time::{Duration, Instant}; + +use super::platform; +#[cfg(windows)] +use crate::model::{CapturedOutput, ProbeLimits, ProcessOperation}; +use crate::model::{ + CommandSpec, ProcessError, ProcessOutcome, ProcessTermination, SupervisionMode, +}; + +#[cfg(windows)] +const SUPERVISION_INTERVAL: Duration = Duration::from_millis(10); +#[cfg(windows)] +const OUTPUT_CHUNK_SIZE: usize = 8 * 1024; +static HANDLER_RESULT: OnceLock> = OnceLock::new(); + +#[derive(Clone, Debug)] +pub(crate) struct CancellationToken { + /// Lets tests cancel without touching the process-wide Ctrl+C state. + #[cfg(test)] + requested: Arc, + observe_process_handler: bool, +} + +impl CancellationToken { + #[cfg(test)] + pub(crate) fn new() -> Self { + Self { + requested: Arc::new(AtomicBool::new(false)), + observe_process_handler: false, + } + } + + pub(crate) fn process_wide() -> io::Result { + match HANDLER_RESULT.get_or_init(platform::install_cancellation_handler) { + Ok(()) => Ok(Self { + #[cfg(test)] + requested: Arc::new(AtomicBool::new(false)), + observe_process_handler: true, + }), + Err(error) => Err(io::Error::new(error.kind(), error.to_string())), + } + } + + #[cfg(test)] + pub(crate) fn request(&self) { + self.requested.store(true, Ordering::SeqCst); + } + + pub(crate) fn is_requested(&self) -> bool { + #[cfg(test)] + if self.requested.load(Ordering::SeqCst) { + return true; + } + self.observe_process_handler && platform::is_process_cancellation_requested() + } +} + +pub(crate) struct ProcessSupervisor { + cancellation: CancellationToken, +} + +impl ProcessSupervisor { + pub(crate) fn new(cancellation: CancellationToken) -> Self { + Self { cancellation } + } + + pub(crate) fn supervise( + &self, + command: &CommandSpec, + mode: SupervisionMode, + ) -> Result { + let termination = match mode { + #[cfg(windows)] + SupervisionMode::CapturedVersionProbe(limits) => { + return self.supervise_captured(command, limits) + } + // Ctrl+C reaches the installer too, and the installer decides how to stop. The shim waits for it, so a + // cancelled install cleans up and an install that can't be stopped finishes, and then reports the cancel. + SupervisionMode::InteractiveBootstrap => { + let termination = platform::run_interactive(command)?; + if self.cancellation.is_requested() { + ProcessTermination::HandledCancellation + } else { + termination + } + } + #[cfg(windows)] + SupervisionMode::FinalInteractiveCli => platform::run_interactive(command)?, + #[cfg(not(windows))] + _ => unreachable!("on Unix the final CLI replaces the shim, and PowerShell probes only run on Windows"), + }; + Ok(ProcessOutcome { + termination, + captured_output: None, + }) + } + + #[cfg(windows)] + fn supervise_captured( + &self, + command: &CommandSpec, + limits: ProbeLimits, + ) -> Result { + let mut child = platform::spawn_captured(command)?; + let stdout = child.take_stdout().map_err(|error| { + ProcessError::supervision(ProcessOperation::ReadStdout, command.program(), &error) + })?; + let stderr = child.take_stderr().map_err(|error| { + ProcessError::supervision(ProcessOperation::ReadStderr, command.program(), &error) + })?; + let (sender, receiver) = mpsc::sync_channel(2); + let stdout_reader = spawn_reader(stdout, OutputStream::Stdout, sender.clone()); + let stderr_reader = spawn_reader(stderr, OutputStream::Stderr, sender); + let deadline = Instant::now() + limits.timeout; + let mut output = CapturedOutput { + stdout: Vec::new(), + stderr: Vec::new(), + }; + let mut open_streams = 2; + let mut termination = None; + let mut containment_cleaned = false; + + let result = loop { + if self.cancellation.is_requested() { + break terminate_probe( + &mut child, + command, + Ok(ProcessOutcome { + termination: ProcessTermination::HandledCancellation, + captured_output: Some(output), + }), + ); + } + if Instant::now() >= deadline { + break terminate_probe( + &mut child, + command, + Err(ProcessError::TimedOut(limits.timeout)), + ); + } + if termination.is_none() { + match child.try_wait() { + Ok(status) => termination = status, + Err(error) => { + break terminate_probe( + &mut child, + command, + Err(ProcessError::supervision( + ProcessOperation::Wait, + command.program(), + &error, + )), + ); + } + } + } + if termination.is_some() && !containment_cleaned { + if let Err(error) = cleanup_probe(&mut child, command) { + break Err(error); + } + containment_cleaned = true; + } + if let Some(termination) = termination { + if open_streams == 0 { + break Ok(ProcessOutcome { + termination, + captured_output: Some(output), + }); + } + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(remaining.min(SUPERVISION_INTERVAL)) { + Ok(ReaderEvent::Data(stream, bytes)) => { + let captured_bytes = output.stdout.len() + output.stderr.len(); + let remaining = limits.output_bytes.saturating_sub(captured_bytes); + let retained_bytes = bytes.len().min(remaining); + match stream { + OutputStream::Stdout => { + output.stdout.extend_from_slice(&bytes[..retained_bytes]) + } + OutputStream::Stderr => { + output.stderr.extend_from_slice(&bytes[..retained_bytes]) + } + } + if retained_bytes != bytes.len() { + break terminate_probe( + &mut child, + command, + Err(ProcessError::OutputLimitExceeded(limits.output_bytes)), + ); + } + } + Ok(ReaderEvent::Closed) => open_streams -= 1, + Ok(ReaderEvent::Failed(stream, error)) => { + let operation = match stream { + OutputStream::Stdout => ProcessOperation::ReadStdout, + OutputStream::Stderr => ProcessOperation::ReadStderr, + }; + break terminate_probe( + &mut child, + command, + Err(ProcessError::supervision( + operation, + command.program(), + &error, + )), + ); + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => open_streams = 0, + } + }; + + drop(receiver); + for reader in [stdout_reader, stderr_reader] { + reader.join().map_err(|_| { + let error = io::Error::other("probe output reader thread terminated unexpectedly"); + ProcessError::supervision(ProcessOperation::ReadStdout, command.program(), &error) + })?; + } + result + } +} + +#[cfg(windows)] +fn terminate_probe( + child: &mut platform::CapturedChild, + command: &CommandSpec, + result: Result, +) -> Result { + cleanup_probe(child, command)?; + result +} + +#[cfg(windows)] +fn cleanup_probe( + child: &mut platform::CapturedChild, + command: &CommandSpec, +) -> Result<(), ProcessError> { + child.terminate_and_reap().map_err(|error| { + ProcessError::supervision(ProcessOperation::Terminate, command.program(), &error) + }) +} + +#[cfg(windows)] +#[derive(Clone, Copy)] +enum OutputStream { + Stdout, + Stderr, +} + +#[cfg(windows)] +enum ReaderEvent { + Data(OutputStream, Vec), + Closed, + Failed(OutputStream, io::Error), +} + +#[cfg(windows)] +fn spawn_reader( + mut reader: impl Read + Send + 'static, + stream: OutputStream, + sender: SyncSender, +) -> thread::JoinHandle<()> { + thread::spawn(move || { + let mut buffer = [0; OUTPUT_CHUNK_SIZE]; + loop { + match reader.read(&mut buffer) { + Ok(0) => { + let _ = sender.send(ReaderEvent::Closed); + return; + } + Ok(bytes_read) => { + if sender + .send(ReaderEvent::Data(stream, buffer[..bytes_read].to_vec())) + .is_err() + { + return; + } + } + Err(error) => { + let _ = sender.send(ReaderEvent::Failed(stream, error)); + return; + } + } + } + }) +} + +#[cfg(test)] +mod tests { + use crate::{model, runtime}; + use std::ffi::OsString; + #[cfg(windows)] + use std::fs; + #[cfg(windows)] + use std::path::PathBuf; + #[cfg(windows)] + use std::thread; + use std::time::Duration; + #[cfg(windows)] + use std::time::Instant; + + use model::{CommandArguments, CommandSpec, ProcessTermination, SupervisionMode}; + #[cfg(windows)] + use model::{ProbeLimits, ProcessError}; + #[cfg(windows)] + use runtime::platform; + use runtime::supervisor::{CancellationToken, ProcessSupervisor}; + + /// Waits about `seconds` and exits with `code`. + fn wait_then_exit(seconds: u32, code: i32) -> CommandSpec { + #[cfg(unix)] + let (program, arguments) = ( + OsString::from("/bin/sh"), + [String::from("-c"), format!("sleep {seconds}; exit {code}")], + ); + #[cfg(windows)] + let (program, arguments) = ( + std::env::var_os("ComSpec").unwrap_or_else(|| OsString::from("cmd.exe")), + [ + String::from("/C"), + format!("ping -n {} 127.0.0.1 >NUL & exit {code}", seconds + 1), + ], + ); + CommandSpec::new( + program, + CommandArguments::Native(arguments.into_iter().map(OsString::from).collect()), + ) + } + + #[cfg(windows)] + fn windows_command_script(script: &std::path::Path, arguments: Vec) -> CommandSpec { + let command_shell = + std::env::var_os("ComSpec").unwrap_or_else(|| OsString::from("cmd.exe")); + let raw_command_tail = + platform::encode_windows_command_tail(script.as_os_str(), &arguments) + .expect("test command tail should encode"); + CommandSpec::new( + command_shell, + CommandArguments::WindowsCommand { + switches: platform::WINDOWS_COMMAND_SWITCHES + .into_iter() + .map(OsString::from) + .collect(), + raw_command_tail, + }, + ) + } + + #[cfg(windows)] + fn unique_windows_path(name: &str, extension: &str) -> PathBuf { + // Test thread names contain `::`, which is not valid in Windows file names. + let thread = std::thread::current().name().unwrap_or("test").replace( + |c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_', + "_", + ); + std::env::temp_dir().join(format!( + "copilot-shim-{name}-{}-{thread}.{extension}", + std::process::id(), + )) + } + + #[cfg(windows)] + fn find_windows_program(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|directory| directory.join(name)) + .find(|path| path.is_file()) + } + + #[test] + fn bootstrap_reports_its_exit_code_or_the_cancellation_after_it_exits() { + let exited = ProcessSupervisor::new(CancellationToken::new()) + .supervise(&wait_then_exit(0, 3), SupervisionMode::InteractiveBootstrap) + .expect("bootstrap should complete"); + let cancellation = CancellationToken::new(); + let request = cancellation.clone(); + let requester = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + request.request(); + }); + let cancelled = ProcessSupervisor::new(cancellation) + .supervise(&wait_then_exit(1, 0), SupervisionMode::InteractiveBootstrap) + .expect("a cancelled bootstrap should be an outcome"); + requester + .join() + .expect("cancellation requester should finish"); + + assert_eq!( + (exited.termination, cancelled.termination), + ( + ProcessTermination::NumericExit(3), + ProcessTermination::HandledCancellation + ) + ); + } + #[cfg(windows)] + #[test] + fn cmd_and_batch_raw_tail_round_trip_without_injection() { + for extension in ["cmd", "bat"] { + // cmd expands `%VARIABLE%` in the script path too, so it must stay literal there. + let script_directory = unique_windows_path("raw-tail-100%PATH%", "dir"); + fs::create_dir(&script_directory).expect("create script directory"); + let script = script_directory.join(format!("copilot.{extension}")); + let output = unique_windows_path("raw-tail-output", "txt"); + let sentinel = unique_windows_path("raw-tail-injected", "txt"); + fs::write( + &script, + format!( + "@echo off\r\nchcp 65001 >nul\r\nsetlocal DisableDelayedExpansion\r\n:next\r\nif \"%~1\"==\"\" goto done\r\n>>\"{}\" >\"{}\" echo(\r\nshift\r\ngoto next\r\n:done\r\nexit /b 0\r\n", + output.display(), + output.display() + ), + ) + .expect("write command script"); + // `%~1` shows what cmd itself passes to the script. Arguments containing `"` are covered by the decoder test, + // since cmd hands them to the script with doubled quotes for the program behind the wrapper to parse. + let argument_values = [ + String::from("with spaces"), + String::from("GrรผรŸe-ๆฑไบฌ"), + String::from("&|<>()^!;"), + String::from("100%PATH%"), + format!("safe&echo injected>{}", sentinel.display()), + String::from("--resume"), + ]; + let arguments: Vec = argument_values.iter().map(OsString::from).collect(); + let supervisor = ProcessSupervisor::new(CancellationToken::new()); + + let outcome = supervisor + .supervise( + &windows_command_script(&script, arguments.clone()), + SupervisionMode::CapturedVersionProbe(ProbeLimits { + timeout: Duration::from_secs(5), + output_bytes: 262_144, + }), + ) + .expect("command script should complete"); + + let expected = argument_values.join("\r\n") + "\r\n"; + assert_eq!( + ( + outcome.termination, + fs::read_to_string(&output).expect("read command output"), + sentinel.exists(), + ), + (ProcessTermination::NumericExit(0), expected, false) + ); + fs::remove_dir_all(script_directory).expect("remove command script"); + fs::remove_file(output).expect("remove command output"); + } + } + + #[cfg(windows)] + #[test] + fn final_interactive_cli_preserves_numeric_exit() { + let command_shell = + std::env::var_os("ComSpec").unwrap_or_else(|| OsString::from("cmd.exe")); + let command = CommandSpec::new( + command_shell, + CommandArguments::Native( + ["/D", "/S", "/C", "exit /b 23"] + .into_iter() + .map(OsString::from) + .collect(), + ), + ); + let supervisor = ProcessSupervisor::new(CancellationToken::new()); + + let outcome = supervisor + .supervise(&command, SupervisionMode::FinalInteractiveCli) + .expect("final CLI should complete"); + + assert_eq!( + (outcome.termination, outcome.captured_output), + (ProcessTermination::NumericExit(23), None) + ); + } + + #[cfg(windows)] + #[test] + fn available_powershell_hosts_preserve_arguments() { + for name in ["pwsh.exe", "powershell.exe"] { + let Some(program) = find_windows_program(name) else { + eprintln!("skipping {name}: host is unavailable"); + continue; + }; + if name == "pwsh.exe" { + let version = std::process::Command::new(&program) + .args([ + "-NoLogo", + "-NoProfile", + "-Command", + "$PSVersionTable.PSVersion.ToString()", + ]) + .output() + .expect("query PowerShell version"); + let version = String::from_utf8_lossy(&version.stdout); + let mut components = version.trim().split('.'); + let major = components + .next() + .and_then(|value| value.parse::().ok()); + let minor = components + .next() + .and_then(|value| value.parse::().ok()); + if !matches!((major, minor), (Some(major), Some(minor)) if major > 7 || (major == 7 && minor >= 3)) + { + eprintln!("skipping {name}: PowerShell 7.3+ is unavailable"); + continue; + } + } + + let script = unique_windows_path("powershell-arguments", "ps1"); + let output = unique_windows_path("powershell-output", "txt"); + fs::write( + &script, + format!( + "$args | ForEach-Object {{ [IO.File]::AppendAllText('{}', $_ + [Environment]::NewLine) }}", + output.display() + ), + ) + .expect("write PowerShell script"); + let forwarded = ["with spaces", "GrรผรŸe-ๆฑไบฌ", "trailing\\"]; + let arguments = [ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + script.to_str().expect("test path is Unicode"), + ] + .into_iter() + .chain(forwarded) + .map(OsString::from) + .collect(); + let command = CommandSpec::new(program.into(), CommandArguments::Native(arguments)); + let supervisor = ProcessSupervisor::new(CancellationToken::new()); + + let outcome = supervisor + .supervise( + &command, + SupervisionMode::CapturedVersionProbe(ProbeLimits { + timeout: Duration::from_secs(10), + output_bytes: 262_144, + }), + ) + .expect("PowerShell host should complete"); + + assert_eq!( + ( + outcome.termination, + fs::read_to_string(&output).expect("read PowerShell output"), + ), + ( + ProcessTermination::NumericExit(0), + forwarded.join("\r\n") + "\r\n", + ) + ); + fs::remove_file(script).expect("remove PowerShell script"); + fs::remove_file(output).expect("remove PowerShell output"); + } + } + + #[cfg(windows)] + #[test] + fn probe_timeout_terminates_group() { + let script = unique_windows_path("timeout-job", "cmd"); + let survivor = unique_windows_path("timeout-survivor", "txt"); + fs::write( + &script, + format!( + "@echo off\r\nstart \"\" /b cmd.exe /D /S /C \"ping 127.0.0.1 -n 3 >NUL & echo survived>\\\"{}\\\"\"\r\nping 127.0.0.1 -n 30 >NUL\r\n", + survivor.display() + ), + ) + .expect("write timeout script"); + let supervisor = ProcessSupervisor::new(CancellationToken::new()); + + let result = supervisor.supervise( + &windows_command_script(&script, Vec::new()), + SupervisionMode::CapturedVersionProbe(ProbeLimits { + timeout: Duration::from_millis(250), + output_bytes: 262_144, + }), + ); + + assert_eq!( + result, + Err(ProcessError::TimedOut(Duration::from_millis(250))) + ); + let deadline = Instant::now() + Duration::from_secs(4); + while Instant::now() < deadline && !survivor.exists() { + thread::sleep(Duration::from_millis(25)); + } + assert!(!survivor.exists(), "Job Object descendant survived timeout"); + fs::remove_file(script).expect("remove timeout script"); + } +} diff --git a/copilot_shim/src/version.rs b/copilot_shim/src/version.rs new file mode 100644 index 00000000000000..7556e9679ffbe1 --- /dev/null +++ b/copilot_shim/src/version.rs @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//! Version parsing for the PowerShell hosts that run `.ps1` Copilot CLI wrappers. + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct PowerShellVersion { + pub(crate) major: u64, + pub(crate) minor: u64, +} + +/// Finds the first `major.minor.patch` triple in the output. A triple whose component overflows makes the output +/// unparseable. +pub(crate) fn first_version(stdout: &[u8]) -> Option { + let mut index = 0; + while index < stdout.len() { + if !stdout[index].is_ascii_digit() || index > 0 && stdout[index - 1].is_ascii_digit() { + index += 1; + continue; + } + + let first_dot = digit_end(stdout, index); + if stdout.get(first_dot) != Some(&b'.') { + index = first_dot; + continue; + } + let minor_start = first_dot + 1; + if !stdout.get(minor_start).is_some_and(u8::is_ascii_digit) { + index = first_dot + 1; + continue; + } + let second_dot = digit_end(stdout, minor_start); + if stdout.get(second_dot) != Some(&b'.') { + index = second_dot; + continue; + } + let patch_start = second_dot + 1; + if !stdout.get(patch_start).is_some_and(u8::is_ascii_digit) { + index = patch_start; + continue; + } + let patch_end = digit_end(stdout, patch_start); + component(&stdout[patch_start..patch_end])?; + return Some(PowerShellVersion { + major: component(&stdout[index..first_dot])?, + minor: component(&stdout[minor_start..second_dot])?, + }); + } + None +} + +fn digit_end(bytes: &[u8], start: usize) -> usize { + let mut end = start; + while bytes.get(end).is_some_and(u8::is_ascii_digit) { + end += 1; + } + end +} + +fn component(bytes: &[u8]) -> Option { + let mut value = 0_u64; + for byte in bytes { + value = value + .checked_mul(10)? + .checked_add(u64::from(*byte - b'0'))?; + } + Some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn version(major: u64, minor: u64) -> Option { + Some(PowerShellVersion { major, minor }) + } + + #[test] + fn the_first_version_triple_is_parsed() { + assert_eq!( + [ + first_version(b"7.4.6"), + first_version(b"5.1.26100.1234"), + first_version(b"PowerShell v7.3.0-preview.1"), + first_version(b"no version"), + first_version(b"7.4"), + ], + [version(7, 4), version(5, 1), version(7, 3), None, None] + ); + } + + #[test] + fn an_overflowing_first_triple_is_unparseable() { + assert_eq!( + [ + first_version(b"18446744073709551616.1.2 then 9.9.9"), + first_version(b"prefix 18446744073709551616 then 7.4.6"), + first_version(b"1.18446744073709551616.2 then 9.9.9"), + first_version(b"1.2.18446744073709551616 then 9.9.9"), + ], + [None, version(7, 4), None, None] + ); + } +} diff --git a/copilot_shim/tests/app.rs b/copilot_shim/tests/app.rs new file mode 100644 index 00000000000000..477558dc4b8318 --- /dev/null +++ b/copilot_shim/tests/app.rs @@ -0,0 +1,259 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::fs; +#[cfg(unix)] +use std::io::Write; +use std::path::Path; +use std::process::Command; +#[cfg(unix)] +use std::process::Stdio; + +const SHIM_MARKER: &[u8] = b"VSCODE_COPILOT_RUST_SHIM_V1"; + +#[test] +fn rediscovery_and_shadowing_matrix() { + let directory = tempfile::tempdir().expect("create fake PATH"); + write_compatible_cli(directory.path()); + + let status = Command::new(env!("CARGO_BIN_EXE_copilot")) + .arg("matrix") + .env("PATH", directory.path()) + .status() + .expect("run shim"); + + assert_eq!(status.code(), Some(23)); +} + +/// VS Code setup replaces a published shim only when this version differs. +#[cfg(windows)] +#[test] +fn windows_binary_embeds_the_package_version() { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW, VS_FIXEDFILEINFO, + }; + + let path: Vec = Path::new(env!("CARGO_BIN_EXE_copilot")) + .as_os_str() + .encode_wide() + .chain([0]) + .collect(); + let size = unsafe { GetFileVersionInfoSizeW(path.as_ptr(), std::ptr::null_mut()) }; + assert_ne!(size, 0, "the shim has no version resource"); + let mut data = vec![0_u8; size as usize]; + assert_ne!( + unsafe { GetFileVersionInfoW(path.as_ptr(), 0, size, data.as_mut_ptr().cast()) }, + 0 + ); + let mut fixed: *mut std::ffi::c_void = std::ptr::null_mut(); + let mut length = 0; + let root: Vec = "\\".encode_utf16().chain([0]).collect(); + assert_ne!( + unsafe { VerQueryValueW(data.as_ptr().cast(), root.as_ptr(), &mut fixed, &mut length) }, + 0 + ); + let fixed = unsafe { &*(fixed as *const VS_FIXEDFILEINFO) }; + let expected: Vec = env!("CARGO_PKG_VERSION") + .split(['.', '-', '+']) + .take(3) + .map(|part| part.parse().expect("numeric package version")) + .collect(); + assert_eq!( + vec![ + fixed.dwFileVersionMS >> 16, + fixed.dwFileVersionMS & 0xFFFF, + fixed.dwFileVersionLS >> 16, + ], + expected, + "the Windows version resource in {} must match Cargo.toml version {}", + env!("CARGO_BIN_EXE_copilot"), + env!("CARGO_PKG_VERSION"), + ); +} + +#[test] +fn copied_shim_is_skipped_and_binary_marker_is_retained() { + let directory = tempfile::tempdir().expect("create fake PATH"); + let shim_directory = directory.path().join("shim"); + let cli_directory = directory.path().join("cli"); + fs::create_dir(&shim_directory).expect("create copied shim directory"); + fs::create_dir(&cli_directory).expect("create fake CLI directory"); + let binary = Path::new(env!("CARGO_BIN_EXE_copilot")); + let copied_shim = shim_directory.join(format!("copilot{}", std::env::consts::EXE_SUFFIX)); + fs::copy(binary, &copied_shim).expect("copy shim binary"); + write_compatible_cli(&cli_directory); + let path = std::env::join_paths([shim_directory, cli_directory]).expect("join fake PATH"); + + let status = Command::new(binary) + .arg("copied-shim") + .env("PATH", path) + .status() + .expect("run shim"); + let binary_bytes = fs::read(binary).expect("read shim binary"); + let copied_bytes = fs::read(copied_shim).expect("read copied shim binary"); + + assert_eq!( + ( + status.code(), + contains_bytes(&binary_bytes, SHIM_MARKER), + contains_bytes(&copied_bytes, SHIM_MARKER), + ), + (Some(23), true, true) + ); +} + +#[test] +fn diagnostics_require_the_verbose_modifier() { + let binary = Path::new(env!("CARGO_BIN_EXE_copilot")); + let quiet = Command::new(binary) + .arg("--vscode-shim") + .env_remove("VSCODE_COPILOT_SHIM_VERBOSE") + .output() + .expect("run quiet malformed invocation"); + let verbose = Command::new(binary) + .args(["--vscode-shim", "verbose", "--vscode-shim"]) + .env_remove("VSCODE_COPILOT_SHIM_VERBOSE") + .output() + .expect("run verbose malformed invocation"); + let environment = Command::new(binary) + .arg("--vscode-shim") + .env("VSCODE_COPILOT_SHIM_VERBOSE", "1") + .output() + .expect("run malformed invocation with verbose environment"); + + assert_eq!( + ( + quiet.status.code(), + quiet.stdout, + quiet.stderr, + verbose.status.code(), + verbose.stdout, + String::from_utf8(verbose.stderr) + .expect("UTF-8 verbose diagnostic") + .trim_end() + .to_owned(), + String::from_utf8(environment.stderr) + .expect("UTF-8 verbose diagnostic") + .trim_end() + .to_owned(), + ), + ( + Some(2), + Vec::::new(), + Vec::::new(), + Some(2), + Vec::::new(), + String::from("--vscode-shim requires an option name"), + String::from("--vscode-shim requires an option name"), + ) + ); +} + +#[cfg(unix)] +#[test] +fn unix_black_box_preserves_launch_contract() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().expect("create black-box directory"); + let cli_directory = directory.path().join("cli"); + let working_directory = directory.path().join("working"); + fs::create_dir(&cli_directory).expect("create fake CLI directory"); + fs::create_dir(&working_directory).expect("create working directory"); + let canonical_working_directory = + fs::canonicalize(&working_directory).expect("canonicalize working directory"); + let cli = cli_directory.join("copilot"); + fs::write( + &cli, + b"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n\tprintf '1.0.82\\n'\n\texit 0\nfi\nIFS= read -r input || exit 91\nprintf 'stdin=<%s>\\nenv=<%s>\\ncwd=<%s>\\nargs=' \"$input\" \"$COPILOT_SHIM_TEST_ENV\" \"$PWD\"\nfor argument do\n\tprintf '<%s>' \"$argument\"\ndone\nprintf '\\n'\nprintf 'stderr=\\n' >&2\nexit 47\n", + ) + .expect("write contract fake CLI"); + let mut permissions = fs::metadata(&cli) + .expect("read fake CLI metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&cli, permissions).expect("make fake CLI executable"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_copilot")) + .args([ + "--vscode-shim", + "clear", + "--vscode-shim", + "verbose", + "--clear", + "", + "with spaces", + "single'quote", + "double\"quote", + "GrรผรŸe-ๆฑไบฌ", + "trailing\\", + "&|<>()^%!;", + "duplicate", + "duplicate", + ]) + .current_dir(&working_directory) + .env("PATH", &cli_directory) + .env("COPILOT_SHIM_TEST_ENV", "inherited") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn shim"); + child + .stdin + .take() + .expect("shim stdin") + .write_all(b"from-stdin\n") + .expect("write shim stdin"); + let output = child.wait_with_output().expect("wait for shim"); + + assert_eq!( + ( + output.status.code(), + String::from_utf8(output.stdout).expect("UTF-8 stdout"), + String::from_utf8(output.stderr).expect("UTF-8 stderr"), + ), + ( + Some(47), + format!( + "stdin=\nenv=\ncwd=<{}>\nargs=<--clear><><&|<>()^%!;>\n", + canonical_working_directory.display() + ), + String::from("stderr=\n"), + ) + ); +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +#[cfg(unix)] +fn write_compatible_cli(directory: &Path) { + use std::os::unix::fs::PermissionsExt; + + let path = directory.join("copilot"); + fs::write( + &path, + b"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n\techo 1.0.82\n\texit 0\nfi\nexit 23\n", + ) + .expect("write fake CLI"); + let mut permissions = fs::metadata(&path) + .expect("read fake CLI metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("make fake CLI executable"); +} + +#[cfg(windows)] +fn write_compatible_cli(directory: &Path) { + fs::write( + directory.join("copilot.cmd"), + b"@echo off\r\nif \"%~1\"==\"--version\" (\r\n\techo 1.0.82\r\n\texit /b 0\r\n)\r\nexit /b 23\r\n", + ) + .expect("write fake CLI"); +} diff --git a/copilot_shim/tests/conpty.rs b/copilot_shim/tests/conpty.rs new file mode 100644 index 00000000000000..795ecd8c01ec92 --- /dev/null +++ b/copilot_shim/tests/conpty.rs @@ -0,0 +1,1176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +#![cfg(windows)] + +use std::env; +use std::ffi::{c_void, OsStr, OsString}; +use std::fs::{self, File}; +use std::io; +use std::mem::size_of; +use std::net::TcpListener; +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +use std::os::windows::io::AsRawHandle; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::ptr::{null, null_mut}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use windows_sys::Win32::Foundation::{ + CloseHandle, SetHandleInformation, HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, + WAIT_OBJECT_0, WAIT_TIMEOUT, +}; +use windows_sys::Win32::Storage::FileSystem::{ReadFile, WriteFile}; +use windows_sys::Win32::System::Console::{ + ClosePseudoConsole, CreatePseudoConsole, SetConsoleCtrlHandler, COORD, HPCON, +}; +use windows_sys::Win32::System::Pipes::CreatePipe; +use windows_sys::Win32::System::Threading::{ + CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess, + InitializeProcThreadAttributeList, TerminateProcess, UpdateProcThreadAttribute, + WaitForSingleObject, CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, + LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_INFORMATION, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + STARTF_USESTDHANDLES, STARTUPINFOEXW, +}; + +const IO_TIMEOUT: Duration = Duration::from_secs(15); +// ConPTY may render the prompt's trailing space as a cursor movement, so the marker omits it. +const INSTALL_PROMPT: &[u8] = b"Install GitHub Copilot CLI? [y/N]"; + +struct OwnedHandle(HANDLE); + +unsafe impl Send for OwnedHandle {} + +impl OwnedHandle { + fn new(handle: HANDLE) -> io::Result { + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + Err(io::Error::last_os_error()) + } else { + Ok(Self(handle)) + } + } + + fn raw(&self) -> HANDLE { + self.0 + } +} + +impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE { + unsafe { + CloseHandle(self.0); + } + } + } +} + +struct OwnedPseudoConsole(HPCON); + +impl OwnedPseudoConsole { + fn raw(&self) -> HPCON { + self.0 + } +} + +impl Drop for OwnedPseudoConsole { + fn drop(&mut self) { + unsafe { + ClosePseudoConsole(self.0); + } + } +} + +struct AttributeList { + _storage: Vec, + pointer: LPPROC_THREAD_ATTRIBUTE_LIST, +} + +impl AttributeList { + fn new(pseudo_console: HPCON) -> io::Result { + let mut bytes = 0; + unsafe { + InitializeProcThreadAttributeList(null_mut(), 1, 0, &mut bytes); + } + if bytes == 0 { + return Err(io::Error::last_os_error()); + } + let words = bytes.div_ceil(size_of::()); + let mut storage = vec![0_usize; words]; + let pointer = storage.as_mut_ptr().cast(); + if unsafe { InitializeProcThreadAttributeList(pointer, 1, 0, &mut bytes) } == 0 { + return Err(io::Error::last_os_error()); + } + let result = unsafe { + UpdateProcThreadAttribute( + pointer, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, + pseudo_console as *const c_void, + size_of::(), + null_mut(), + null(), + ) + }; + if result == 0 { + unsafe { + DeleteProcThreadAttributeList(pointer); + } + return Err(io::Error::last_os_error()); + } + Ok(Self { + _storage: storage, + pointer, + }) + } +} + +impl Drop for AttributeList { + fn drop(&mut self) { + unsafe { + DeleteProcThreadAttributeList(self.pointer); + } + } +} + +enum ReaderEvent { + Data(Vec), + Closed, + Failed(io::Error), +} + +struct ConPtyProcess { + input: Option, + process: OwnedHandle, + pseudo_console: Option, + reader: Option>, + receiver: Receiver, + transcript: Vec, + reaped: bool, +} + +impl ConPtyProcess { + fn spawn( + arguments: &[OsString], + path: &OsStr, + current_directory: &Path, + environment: &[(&str, &OsStr)], + ) -> Self { + let (input_read, input_write) = create_pipe().expect("create ConPTY input pipe"); + let (output_read, output_write) = create_pipe().expect("create ConPTY output pipe"); + let mut pseudo_console = 0; + let result = unsafe { + CreatePseudoConsole( + COORD { X: 120, Y: 40 }, + input_read.raw(), + output_write.raw(), + 0, + &mut pseudo_console, + ) + }; + assert!( + result >= 0, + "create ConPTY: {}", + io::Error::from_raw_os_error(result) + ); + let pseudo_console = OwnedPseudoConsole(pseudo_console); + drop(input_read); + drop(output_write); + + let attributes = + AttributeList::new(pseudo_console.raw()).expect("create ConPTY process attribute list"); + let mut startup = STARTUPINFOEXW::default(); + startup.StartupInfo.cb = size_of::() as u32; + // Without explicit invalid handles, a child can inherit the test runner's redirected stdio (as in CI) instead of + // attaching to the pseudoconsole. + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = INVALID_HANDLE_VALUE; + startup.StartupInfo.hStdOutput = INVALID_HANDLE_VALUE; + startup.StartupInfo.hStdError = INVALID_HANDLE_VALUE; + startup.lpAttributeList = attributes.pointer; + let executable = PathBuf::from(env!("CARGO_BIN_EXE_copilot")); + let mut application_name = wide_null(executable.as_os_str()); + let mut command_line = command_line(executable.as_os_str(), arguments); + let mut environment_block = environment_block(path, environment); + let current_directory = wide_null(current_directory.as_os_str()); + let mut process_information = PROCESS_INFORMATION::default(); + let created = unsafe { + CreateProcessW( + application_name.as_mut_ptr(), + command_line.as_mut_ptr(), + null(), + null(), + 0, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + environment_block.as_mut_ptr().cast(), + current_directory.as_ptr(), + &startup.StartupInfo, + &mut process_information, + ) + }; + assert_ne!( + created, + 0, + "spawn shim in ConPTY: {}", + io::Error::last_os_error() + ); + let process = OwnedHandle::new(process_information.hProcess).expect("own shim process"); + let thread = OwnedHandle::new(process_information.hThread).expect("own shim thread"); + drop(thread); + drop(attributes); + + let (sender, receiver) = mpsc::sync_channel(16); + let reader = thread::spawn(move || { + let mut buffer = [0_u8; 4096]; + loop { + let mut read = 0; + let result = unsafe { + ReadFile( + output_read.raw(), + buffer.as_mut_ptr(), + buffer.len() as u32, + &mut read, + null_mut(), + ) + }; + if result == 0 { + let error = io::Error::last_os_error(); + if matches!( + error.kind(), + io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof + ) { + let _ = sender.send(ReaderEvent::Closed); + } else { + let _ = sender.send(ReaderEvent::Failed(error)); + } + return; + } + if read == 0 { + let _ = sender.send(ReaderEvent::Closed); + return; + } + if sender + .send(ReaderEvent::Data(buffer[..read as usize].to_vec())) + .is_err() + { + return; + } + } + }); + + Self { + input: Some(input_write), + process, + pseudo_console: Some(pseudo_console), + reader: Some(reader), + receiver, + transcript: Vec::new(), + reaped: false, + } + } + + fn wait_for(&mut self, marker: &[u8]) { + let deadline = Instant::now() + IO_TIMEOUT; + while !contains_bytes(&self.transcript, marker) { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + self.fail(&format!( + "ConPTY deadline expired waiting for {:?}", + String::from_utf8_lossy(marker) + )); + } + match self.receiver.recv_timeout(remaining) { + Ok(ReaderEvent::Data(bytes)) => self.transcript.extend(bytes), + Ok(ReaderEvent::Closed) => self.fail("ConPTY output closed before marker"), + Ok(ReaderEvent::Failed(error)) => { + self.fail(&format!("read ConPTY output failed: {error}")) + } + Err(RecvTimeoutError::Timeout) => self.fail("ConPTY output deadline expired"), + Err(RecvTimeoutError::Disconnected) => { + self.fail("ConPTY output reader disconnected") + } + } + } + } + + fn write(&self, bytes: &[u8]) { + let input = self.input.as_ref().expect("ConPTY input is open"); + let mut offset = 0; + while offset < bytes.len() { + let mut written = 0; + let result = unsafe { + WriteFile( + input.raw(), + bytes[offset..].as_ptr(), + (bytes.len() - offset) as u32, + &mut written, + null_mut(), + ) + }; + if result == 0 { + self.fail(&format!( + "write ConPTY input failed: {}", + io::Error::last_os_error() + )); + } + if written == 0 { + self.fail("ConPTY input closed while writing"); + } + offset += written as usize; + } + } + + fn wait_for_exit(&mut self) -> i32 { + let milliseconds = IO_TIMEOUT.as_millis().min(u32::MAX as u128) as u32; + match unsafe { WaitForSingleObject(self.process.raw(), milliseconds) } { + WAIT_OBJECT_0 => {} + WAIT_TIMEOUT => self.fail("ConPTY process exit deadline expired"), + _ => self.fail(&format!( + "wait for ConPTY process failed: {}", + io::Error::last_os_error() + )), + } + let mut code = 0; + if unsafe { GetExitCodeProcess(self.process.raw(), &mut code) } == 0 { + self.fail(&format!( + "read ConPTY process exit failed: {}", + io::Error::last_os_error() + )); + } + self.reaped = true; + code as i32 + } + + fn transcript(&self) -> &[u8] { + &self.transcript + } + + fn fail(&self, message: &str) -> ! { + panic!( + "{message}\nConPTY transcript:\n{}", + String::from_utf8_lossy(&self.transcript) + ); + } +} + +impl Drop for ConPtyProcess { + fn drop(&mut self) { + if !self.reaped { + unsafe { + TerminateProcess(self.process.raw(), 1); + WaitForSingleObject(self.process.raw(), IO_TIMEOUT.as_millis() as u32); + } + self.reaped = true; + } + self.input.take(); + self.pseudo_console.take(); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} + +#[test] +fn windows_conpty_prompt_and_cli_interaction() { + let root = tempfile::tempdir().expect("create ConPTY test directory"); + let empty_path = root.path().join("empty-path"); + let cli_path = root.path().join("cli-path"); + let working_directory = root.path().join("working"); + let state = root.path().join("state"); + for directory in [&empty_path, &cli_path, &working_directory, &state] { + fs::create_dir_all(directory).expect("create ConPTY test directory"); + } + + let mut prompt = ConPtyProcess::spawn( + &[OsString::from("--vscode-shim"), OsString::from("clear")], + empty_path.as_os_str(), + &working_directory, + &[], + ); + prompt.wait_for(INSTALL_PROMPT); + let prompt_offset = find_bytes(prompt.transcript(), INSTALL_PROMPT).expect("prompt offset"); + assert!( + contains_bytes(&prompt.transcript()[..prompt_offset], b"\x1b[2J"), + "attached ConPTY was not cleared before the prompt\n{}", + String::from_utf8_lossy(prompt.transcript()) + ); + prompt.write(b"n\r"); + assert_eq!(prompt.wait_for_exit(), 0); + + let helper = cli_path.join("copilot.exe"); + build_native_helper(&helper, native_helper_source()); + let arguments = vec![ + OsString::from("--vscode-shim"), + OsString::from("clear"), + OsString::new(), + OsString::from("with spaces"), + OsString::from("double\"quote"), + OsString::from("GrรผรŸe-ๆฑไบฌ"), + OsString::from(r"trailing\"), + OsString::from("&|<>()^%!;"), + ]; + let mut cli = ConPtyProcess::spawn( + &arguments, + cli_path.as_os_str(), + &working_directory, + &[ + ("COPILOT_SHIM_TEST_STATE", state.as_os_str()), + ("COPILOT_SHIM_TEST_ENV", OsStr::new("inherited")), + ], + ); + cli.wait_for(b"CLI_READY"); + cli.write(b"synchronized-input\r"); + cli.wait_for(b"CLI_STDERR_EXACT"); + let exit_code = cli.wait_for_exit(); + let expected_arguments = encode_wide_values(&arguments[2..]); + assert_eq!( + ( + exit_code, + read_string(&state.join("stdin")), + read_string(&state.join("environment")), + observed_directory(&state), + read_string(&state.join("arguments")), + contains_bytes(cli.transcript(), b"CLI_STDOUT_EXACT"), + contains_bytes(cli.transcript(), b"CLI_STDERR_EXACT"), + ), + ( + 301, + String::from("synchronized-input"), + encode_wide_values(&[OsString::from("inherited")]), + fs::canonicalize(&working_directory).expect("canonicalize working directory"), + expected_arguments, + true, + true, + ), + "ConPTY transcript:\n{}", + String::from_utf8_lossy(cli.transcript()) + ); +} + +#[test] +fn windows_conpty_ctrl_c_cancels_without_fallback() { + let installed_cli = env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("GitHubCopilotCLI").join("copilot.exe")); + if installed_cli.as_ref().is_some_and(|path| path.is_file()) { + eprintln!( + "skipping cancellation test because GitHub Copilot CLI is installed at {}", + installed_cli.expect("installed CLI path").display() + ); + return; + } + + let root = tempfile::tempdir().expect("create cancellation test directory"); + let tools = root.path().join("tools"); + let working_directory = root.path().join("working"); + for directory in [&tools, &working_directory] { + fs::create_dir_all(directory).expect("create cancellation test directory"); + } + // A release server that accepts the connection and doesn't answer keeps the install waiting for Ctrl+C. It closes + // the connection only after Ctrl+C, and the shim waits for its install to stop instead of killing it. + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake release server"); + let releases_url = OsString::from(format!( + "http://{}/releases", + listener.local_addr().expect("fake release server address") + )); + let (connected, accepted) = mpsc::channel(); + let (close, closed) = mpsc::channel::<()>(); + let _server = thread::spawn(move || { + if let Ok((connection, _)) = listener.accept() { + let _ = connected.send(()); + let _ = closed.recv_timeout(IO_TIMEOUT); + drop(connection); + } + }); + let path = env::join_paths([tools.as_path()]).expect("join cancellation PATH"); + // A test runner started in a new process group ignores Ctrl+C, and the shim would inherit that. Restore the default + // that interactive terminals give it. + unsafe { SetConsoleCtrlHandler(None, 0) }; + + let mut process = ConPtyProcess::spawn( + &[], + &path, + &working_directory, + &[("VSCODE_COPILOT_SHIM_RELEASES_URL", releases_url.as_os_str())], + ); + process.wait_for(INSTALL_PROMPT); + process.write(b"y\r"); + process.wait_for(b"Finding the latest GitHub Copilot CLI release"); + accepted + .recv_timeout(IO_TIMEOUT) + .expect("the install should contact the release server"); + process.write(&[3]); + let _ = close.send(()); + let exit_code = process.wait_for_exit(); + + assert_eq!( + ( + exit_code, + count_bytes(process.transcript(), INSTALL_PROMPT), + contains_bytes(process.transcript(), b"couldn't be installed"), + ), + (130, 1, false), + "ConPTY transcript:\n{}", + String::from_utf8_lossy(process.transcript()) + ); +} + +#[test] +fn windows_adapters_preserve_process_contract() { + run_windows_adapter_contract_scenarios(); +} + +#[derive(Clone, Copy)] +enum AdapterKind { + NativeExecutable, + CommandScript, + BatchScript, + ModernPowerShell, + LegacyPowerShell, +} + +struct AdapterScenario { + name: &'static str, + kind: AdapterKind, + arguments: Vec, + expected_exit: i32, + host: Option, +} + +struct AdapterObservation { + state: PathBuf, + working_directory: PathBuf, +} + +fn exercise_adapter(root: &Path, scenario: &AdapterScenario) -> AdapterObservation { + let directory = root.join(scenario.name); + let state = directory.join("state"); + let working_directory = directory.join("working"); + fs::create_dir_all(&state).expect("create adapter state directory"); + fs::create_dir_all(&working_directory).expect("create adapter working directory"); + write_adapter_candidate(&directory, scenario.kind); + let path = adapter_path(&directory, scenario.host.as_deref()); + let mut process = ConPtyProcess::spawn( + &scenario.arguments, + &path, + &working_directory, + &[ + ("COPILOT_SHIM_TEST_STATE", state.as_os_str()), + ("COPILOT_SHIM_TEST_ENV", OsStr::new("adapter-environment")), + ], + ); + process.wait_for(b"CLI_READY"); + process.write(b"synchronized-input\r"); + process.wait_for(b"CLI_STDERR_EXACT"); + let exit_code = process.wait_for_exit(); + let transcript = process.transcript().to_vec(); + drop(process); + + assert_eq!( + ( + exit_code, + contains_bytes(&transcript, b"CLI_STDOUT_EXACT"), + contains_bytes(&transcript, b"CLI_STDERR_EXACT"), + ), + (scenario.expected_exit, true, true), + "adapter {} ConPTY transcript:\n{}", + scenario.name, + String::from_utf8_lossy(&transcript) + ); + AdapterObservation { + state, + working_directory, + } +} + +fn adapter_path(directory: &Path, host: Option<&Path>) -> OsString { + env::join_paths(std::iter::once(directory).chain(host.and_then(Path::parent))) + .expect("join adapter PATH") +} + +fn run_windows_adapter_contract_scenarios() { + let root = tempfile::tempdir().expect("create adapter contract directory"); + let native_arguments = full_argument_matrix(); + let native = AdapterScenario { + name: "native-exe", + kind: AdapterKind::NativeExecutable, + arguments: native_arguments.clone(), + expected_exit: 301, + host: None, + }; + let native_observation = exercise_adapter(root.path(), &native); + assert_wide_observation(&native_observation, &native_arguments); + eprintln!("ADAPTER_SCENARIO_PASSED {}", native.name); + + for (name, kind) in [ + ("command-script", AdapterKind::CommandScript), + ("batch-script", AdapterKind::BatchScript), + ] { + let sentinel = root.path().join(format!("{name}-injected")); + let arguments = command_argument_matrix(&sentinel); + let scenario = AdapterScenario { + name, + kind, + arguments: arguments.clone(), + expected_exit: 301, + host: None, + }; + let observation = exercise_adapter(root.path(), &scenario); + assert_wide_observation(&observation, &arguments); + assert!(!sentinel.exists(), "{name} argument injected a command"); + eprintln!("ADAPTER_SCENARIO_PASSED {}", scenario.name); + } + + run_power_shell_adapter_scenarios(root.path()); +} + +fn full_argument_matrix() -> Vec { + [ + "", + "with spaces", + "single'quote", + "double\"quote", + "GrรผรŸe-ๆฑไบฌ", + r"trailing\\", + "&|<>()^%!;", + "duplicate", + "duplicate", + ] + .into_iter() + .map(OsString::from) + .collect() +} + +fn command_argument_matrix(sentinel: &Path) -> Vec { + [ + String::new(), + String::from("single'quote"), + String::from("with spaces"), + String::from("GrรผรŸe-ๆฑไบฌ"), + format!("safe&echo injected>\"{}\"", sentinel.display()), + String::from(r"trailing\\"), + String::from("&|<>()^%!;"), + String::from("duplicate"), + String::from("duplicate"), + ] + .into_iter() + .map(OsString::from) + .collect() +} + +fn write_adapter_candidate(directory: &Path, kind: AdapterKind) { + let candidate = match kind { + AdapterKind::NativeExecutable => { + let path = directory.join("copilot.exe"); + build_native_helper(&path, native_helper_source()); + return; + } + AdapterKind::CommandScript => directory.join("copilot.cmd"), + AdapterKind::BatchScript => directory.join("copilot.bat"), + AdapterKind::ModernPowerShell | AdapterKind::LegacyPowerShell => { + build_native_helper( + &directory.join("adapter-helper.exe"), + native_helper_source(), + ); + let path = directory.join("copilot.ps1"); + write_power_shell_candidate(&path); + return; + } + }; + build_native_helper( + &directory.join("adapter-helper.exe"), + native_helper_source(), + ); + fs::write(candidate, command_script_source()).expect("write command adapter candidate"); +} + +fn command_script_source() -> &'static [u8] { + b"@echo off\r\nsetlocal DisableDelayedExpansion\r\nif \"%~1\"==\"--version\" (\r\n\techo 1.0.82\r\n\texit /b 0\r\n)\r\n\"%~dp0adapter-helper.exe\" %*\r\nexit /b %ERRORLEVEL%\r\n" +} + +fn assert_wide_observation(observation: &AdapterObservation, arguments: &[OsString]) { + assert_wide_io_observation(observation); + assert_eq!( + read_string(&observation.state.join("arguments")), + encode_wide_values(arguments) + ); +} + +fn assert_wide_io_observation(observation: &AdapterObservation) { + assert_eq!( + ( + read_string(&observation.state.join("stdin")), + read_string(&observation.state.join("environment")), + observed_directory(&observation.state), + ), + ( + String::from("synchronized-input"), + encode_wide_values(&[OsString::from("adapter-environment")]), + fs::canonicalize(&observation.working_directory).expect("canonicalize adapter cwd"), + ) + ); +} + +/// Canonicalizes the working directory the helper observed, because `TEMP` can use 8.3 short names. +fn observed_directory(state: &Path) -> PathBuf { + let observed = decode_wide_values(&read_string(&state.join("cwd"))); + assert_eq!( + observed.len(), + 1, + "the helper records one working directory" + ); + fs::canonicalize(&observed[0]).expect("canonicalize observed working directory") +} + +#[derive(Default)] +struct PowerShellHosts { + modern: Option, + legacy: Option, +} + +fn run_power_shell_adapter_scenarios(root: &Path) { + let hosts = power_shell_hosts(); + if let Some(host) = hosts.modern { + let arguments = full_argument_matrix(); + let scenario = AdapterScenario { + name: "modern-powershell", + kind: AdapterKind::ModernPowerShell, + arguments: arguments.clone(), + expected_exit: 301, + host: Some(host), + }; + let observation = exercise_adapter(root, &scenario); + assert_wide_observation(&observation, &arguments); + eprintln!("ADAPTER_SCENARIO_PASSED {}", scenario.name); + } else { + eprintln!("ADAPTER_SCENARIO_UNAVAILABLE modern-powershell: pwsh.exe 7.3+ was not found"); + } + + if let Some(host) = hosts.legacy { + let arguments = legacy_power_shell_arguments(); + let scenario = AdapterScenario { + name: "legacy-powershell", + kind: AdapterKind::LegacyPowerShell, + arguments, + expected_exit: 301, + host: Some(host), + }; + let observation = exercise_adapter(root, &scenario); + assert_legacy_power_shell_observation(&observation); + eprintln!("ADAPTER_SCENARIO_PASSED {}", scenario.name); + } else { + eprintln!( + "ADAPTER_SCENARIO_UNAVAILABLE legacy-powershell: Windows PowerShell 5.1 was not found" + ); + } +} + +/// Arguments after the embedded quote are the last ones, because Windows PowerShell 5.1 passes the quote through +/// unescaped and the native helper then joins everything that follows it. +fn legacy_power_shell_arguments() -> Vec { + [ + "empty-before", + "", + "empty-after", + "with spaces", + "GrรผรŸe-ๆฑไบฌ", + r"trailing\\", + "&|<>()^%!;", + "duplicate", + "duplicate", + "quote-before", + "embedded\"quote", + ] + .into_iter() + .map(OsString::from) + .collect() +} + +fn assert_legacy_power_shell_observation(observation: &AdapterObservation) { + assert_wide_io_observation(observation); + let actual = decode_wide_values(&read_string(&observation.state.join("arguments"))); + let empty_after = actual + .iter() + .position(|argument| argument == "empty-after") + .unwrap_or_else(|| panic!("legacy empty-after marker in {actual:?}")); + let quote_before = actual + .iter() + .position(|argument| argument == "quote-before") + .unwrap_or_else(|| panic!("legacy quote-before marker in {actual:?}")); + let required: Vec = [ + "with spaces", + "GrรผรŸe-ๆฑไบฌ", + r"trailing\\", + "&|<>()^%!;", + "duplicate", + "duplicate", + ] + .into_iter() + .map(OsString::from) + .collect(); + assert_eq!( + ( + actual.first().map(OsString::as_os_str), + actual.get(empty_after + 1..quote_before), + ), + (Some(OsStr::new("empty-before")), Some(required.as_slice())), + "legacy arguments {actual:?}" + ); + let empty_behavior = &actual[1..empty_after]; + let quote_behavior = &actual[quote_before + 1..]; + eprintln!( + "ADAPTER_WINDOWS_POWERSHELL_5_1_EMPTY_ARGUMENTS {}", + encode_wide_values(empty_behavior) + ); + eprintln!( + "ADAPTER_WINDOWS_POWERSHELL_5_1_EMBEDDED_QUOTES {}", + encode_wide_values(quote_behavior) + ); +} + +fn write_power_shell_candidate(path: &Path) { + fs::write(path, power_shell_script_source()).expect("write PowerShell adapter candidate"); +} + +fn power_shell_script_source() -> &'static [u8] { + b"if ($args.Count -eq 1 -and $args[0] -ceq '--version') {\r\n\t[Console]::Out.WriteLine('1.0.82')\r\n\texit 0\r\n}\r\nif ($PSVersionTable.PSVersion -ge [Version] '7.3') {\r\n\t$PSNativeCommandArgumentPassing = 'Standard'\r\n}\r\n& (Join-Path $PSScriptRoot 'adapter-helper.exe') @args\r\nexit $LASTEXITCODE\r\n" +} + +fn create_pipe() -> io::Result<(OwnedHandle, OwnedHandle)> { + let mut read = null_mut(); + let mut write = null_mut(); + if unsafe { CreatePipe(&mut read, &mut write, null(), 0) } == 0 { + return Err(io::Error::last_os_error()); + } + let read = OwnedHandle::new(read)?; + let write = OwnedHandle::new(write)?; + if unsafe { SetHandleInformation(read.raw(), HANDLE_FLAG_INHERIT, 0) } == 0 + || unsafe { SetHandleInformation(write.raw(), HANDLE_FLAG_INHERIT, 0) } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok((read, write)) +} + +fn environment_block(path: &OsStr, overrides: &[(&str, &OsStr)]) -> Vec { + let mut variables: Vec<(OsString, OsString)> = env::vars_os().collect(); + set_environment_variable(&mut variables, OsStr::new("PATH"), path); + // The shim also searches %LOCALAPPDATA%\GitHubCopilotCLI; keep a Copilot CLI installed on this machine out of tests. + let local_app_data = env::temp_dir().join("copilot-shim-test-localappdata"); + fs::create_dir_all(&local_app_data).expect("create isolated LOCALAPPDATA"); + set_environment_variable( + &mut variables, + OsStr::new("LOCALAPPDATA"), + local_app_data.as_os_str(), + ); + for (name, value) in overrides { + set_environment_variable(&mut variables, OsStr::new(name), value); + } + variables.sort_by_key(|(name, _)| name.to_string_lossy().to_ascii_lowercase()); + let mut block = Vec::new(); + for (name, value) in variables { + block.extend(name.encode_wide()); + block.push(u16::from(b'=')); + block.extend(value.encode_wide()); + block.push(0); + } + block.push(0); + block +} + +fn set_environment_variable( + variables: &mut Vec<(OsString, OsString)>, + name: &OsStr, + value: &OsStr, +) { + if let Some((_, existing)) = variables + .iter_mut() + .find(|(candidate, _)| native_ascii_eq_ignore_case(candidate, name)) + { + *existing = value.to_os_string(); + } else { + variables.push((name.to_os_string(), value.to_os_string())); + } +} + +fn native_ascii_eq_ignore_case(left: &OsStr, right: &OsStr) -> bool { + left.encode_wide() + .map(ascii_lowercase) + .eq(right.encode_wide().map(ascii_lowercase)) +} + +fn ascii_lowercase(unit: u16) -> u16 { + if (u16::from(b'A')..=u16::from(b'Z')).contains(&unit) { + unit + u16::from(b'a' - b'A') + } else { + unit + } +} + +fn command_line(program: &OsStr, arguments: &[OsString]) -> Vec { + let mut line = quote_argument(program); + for argument in arguments { + line.push(u16::from(b' ')); + line.extend(quote_argument(argument)); + } + line.push(0); + line +} + +fn quote_argument(argument: &OsStr) -> Vec { + let mut quoted = vec![u16::from(b'"')]; + let mut backslashes = 0; + for unit in argument.encode_wide() { + if unit == u16::from(b'\\') { + backslashes += 1; + } else { + let count = if unit == u16::from(b'"') { + backslashes * 2 + 1 + } else { + backslashes + }; + quoted.extend(std::iter::repeat_n(u16::from(b'\\'), count)); + backslashes = 0; + quoted.push(unit); + } + } + quoted.extend(std::iter::repeat_n(u16::from(b'\\'), backslashes * 2)); + quoted.push(u16::from(b'"')); + quoted +} + +fn wide_null(value: &OsStr) -> Vec { + value.encode_wide().chain(std::iter::once(0)).collect() +} + +fn build_native_helper(output: &Path, source: &str) { + let source_path = output.with_extension("rs"); + fs::write(&source_path, source).expect("write native helper source"); + let rustc = env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc.exe")); + let capture = output.with_extension("rustc-output"); + let stdout = File::create(&capture).expect("create rustc output"); + let stderr = stdout.try_clone().expect("clone rustc output"); + let child = Command::new(rustc) + .arg("--edition=2021") + .arg(&source_path) + .arg("-o") + .arg(output) + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + .expect("spawn rustc for native helper"); + let status = wait_for_child(child, IO_TIMEOUT).expect("wait for native helper build"); + assert!( + status.success(), + "native helper build failed with {status}:\n{}", + read_string(&capture) + ); +} + +fn power_shell_hosts() -> PowerShellHosts { + PowerShellHosts { + modern: find_power_shell_host("pwsh.exe", |version| version >= (7, 3, 0)), + legacy: find_power_shell_host("powershell.exe", |version| version.0 == 5 && version.1 == 1), + } +} + +fn find_power_shell_host(name: &str, accepts: impl Fn((u32, u32, u32)) -> bool) -> Option { + let path = env::var_os("PATH")?; + for directory in env::split_paths(&path) { + let candidate = directory.join(name); + if !candidate.is_file() { + continue; + } + let capture = tempfile::NamedTempFile::new().expect("create PowerShell probe output"); + let stdout = capture.reopen().expect("open PowerShell probe stdout"); + let stderr = stdout.try_clone().expect("open PowerShell probe stderr"); + let child = match Command::new(&candidate) + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "$PSVersionTable.PSVersion.ToString()", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + { + Ok(child) => child, + Err(error) => { + eprintln!( + "PowerShell candidate {} did not start: {error}", + candidate.display() + ); + continue; + } + }; + let status = match wait_for_child(child, IO_TIMEOUT) { + Ok(status) => status, + Err(error) => { + eprintln!( + "PowerShell candidate {} did not finish: {error}", + candidate.display() + ); + continue; + } + }; + if !status.success() { + eprintln!( + "PowerShell candidate {} exited with {status}", + candidate.display() + ); + continue; + } + let output = fs::read(capture.path()).expect("read PowerShell probe output"); + let Some(version) = first_version(&output) else { + eprintln!( + "PowerShell candidate {} returned an unparseable version", + candidate.display() + ); + continue; + }; + if accepts(version) { + return Some(candidate); + } + eprintln!( + "PowerShell candidate {} has unsupported version {}.{}.{}", + candidate.display(), + version.0, + version.1, + version.2 + ); + } + None +} + +fn wait_for_child(mut child: Child, timeout: Duration) -> io::Result { + let result = unsafe { + WaitForSingleObject( + child.as_raw_handle(), + timeout.as_millis().min(u32::MAX as u128) as u32, + ) + }; + match result { + WAIT_OBJECT_0 => child.wait(), + WAIT_TIMEOUT => { + child.kill()?; + let _ = child.wait(); + Err(io::Error::new(io::ErrorKind::TimedOut, "child timed out")) + } + _ => Err(io::Error::last_os_error()), + } +} + +fn first_version(bytes: &[u8]) -> Option<(u32, u32, u32)> { + let text = String::from_utf8_lossy(bytes); + for token in text.split(|character: char| !character.is_ascii_digit() && character != '.') { + let components: Vec = token + .split('.') + .filter_map(|component| component.parse().ok()) + .collect(); + if let [major, minor, patch, ..] = components.as_slice() { + return Some((*major, *minor, *patch)); + } + } + None +} + +fn encode_wide_values(values: &[OsString]) -> String { + let mut encoded = String::new(); + for value in values { + for unit in value.encode_wide() { + use std::fmt::Write as _; + write!(encoded, "{unit:04x}").expect("encode native value"); + } + encoded.push(';'); + } + encoded +} + +fn decode_wide_values(encoded: &str) -> Vec { + encoded + .split_terminator(';') + .map(|value| { + assert_eq!(value.len() % 4, 0, "invalid encoded native value"); + let units: Vec = value + .as_bytes() + .chunks_exact(4) + .map(|chunk| { + let chunk = std::str::from_utf8(chunk).expect("encoded value is ASCII"); + u16::from_str_radix(chunk, 16).expect("encoded value is hexadecimal") + }) + .collect(); + OsString::from_wide(&units) + }) + .collect() +} + +fn native_helper_source() -> &'static str { + r#"use std::env; +use std::ffi::OsString; +use std::fs; +use std::io::{self, Write}; +use std::os::windows::ffi::OsStrExt; +use std::path::PathBuf; + +fn encode(values: &[OsString]) -> String { + let mut encoded = String::new(); + for value in values { + for unit in value.encode_wide() { + use std::fmt::Write as _; + write!(encoded, "{unit:04x}").unwrap(); + } + encoded.push(';'); + } + encoded +} + +fn main() { + let state = PathBuf::from(env::var_os("COPILOT_SHIM_TEST_STATE").unwrap()); + let arguments: Vec = env::args_os().skip(1).collect(); + if arguments.first().is_some_and(|argument| argument == "--version") { + println!("1.0.82"); + return; + } + println!("CLI_READY"); + io::stdout().flush().unwrap(); + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + fs::write(state.join("stdin"), input.trim_end_matches(['\r', '\n'])).unwrap(); + fs::write(state.join("environment"), encode(&[env::var_os("COPILOT_SHIM_TEST_ENV").unwrap()])).unwrap(); + fs::write(state.join("cwd"), encode(&[env::current_dir().unwrap().into_os_string()])).unwrap(); + fs::write(state.join("arguments"), encode(&arguments)).unwrap(); + println!("CLI_STDOUT_EXACT"); + eprintln!("CLI_STDERR_EXACT"); + std::process::exit(301); +} +"# +} + +fn read_string(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())) +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + find_bytes(haystack, needle).is_some() +} + +fn count_bytes(haystack: &[u8], needle: &[u8]) -> usize { + haystack + .windows(needle.len()) + .filter(|window| *window == needle) + .count() +} diff --git a/copilot_shim/tests/fixtures/legacy/git-bash-wrapper.sh b/copilot_shim/tests/fixtures/legacy/git-bash-wrapper.sh new file mode 100644 index 00000000000000..febfe606d6adb9 --- /dev/null +++ b/copilot_shim/tests/fixtures/legacy/git-bash-wrapper.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exec "/storage/copilot.bat" "$@" diff --git a/copilot_shim/tests/fixtures/legacy/npm-wrapper.cmd b/copilot_shim/tests/fixtures/legacy/npm-wrapper.cmd new file mode 100644 index 00000000000000..252fa2d6d3ceeb --- /dev/null +++ b/copilot_shim/tests/fixtures/legacy/npm-wrapper.cmd @@ -0,0 +1,4 @@ +@ECHO off +GOTO start +:start +node "%~dp0\node_modules\@github\copilot\index.js" %* diff --git a/copilot_shim/tests/fixtures/legacy/npm-wrapper.ps1 b/copilot_shim/tests/fixtures/legacy/npm-wrapper.ps1 new file mode 100644 index 00000000000000..b74a82db685db0 --- /dev/null +++ b/copilot_shim/tests/fixtures/legacy/npm-wrapper.ps1 @@ -0,0 +1,3 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent +& "node.exe" "$basedir/node_modules/@github/copilot/index.js" $args diff --git a/copilot_shim/tests/fixtures/legacy/posix-launcher.sh b/copilot_shim/tests/fixtures/legacy/posix-launcher.sh new file mode 100644 index 00000000000000..4b129388fcfd2d --- /dev/null +++ b/copilot_shim/tests/fixtures/legacy/posix-launcher.sh @@ -0,0 +1,3 @@ +#!/bin/sh +unset NODE_OPTIONS +ELECTRON_RUN_AS_NODE=1 "/application/code" "/storage/copilotCLIShim.js" "$@" diff --git a/copilot_shim/tests/fixtures/legacy/windows-bootstrapper.ps1 b/copilot_shim/tests/fixtures/legacy/windows-bootstrapper.ps1 new file mode 100644 index 00000000000000..fb2971addc55bd --- /dev/null +++ b/copilot_shim/tests/fixtures/legacy/windows-bootstrapper.ps1 @@ -0,0 +1,6 @@ +๏ปฟ# Windows GitHub Copilot CLI bootstrapper +$PackageName = "@github/copilot" +function Find-RealCopilot { +} +function Test-AndLaunchCopilot { +} diff --git a/copilot_shim/tests/fixtures/legacy/windows-wrapper.cmd b/copilot_shim/tests/fixtures/legacy/windows-wrapper.cmd new file mode 100644 index 00000000000000..bb4970713b8268 --- /dev/null +++ b/copilot_shim/tests/fixtures/legacy/windows-wrapper.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -ExecutionPolicy Bypass -File "copilot.ps1" %* diff --git a/copilot_shim/tests/pty.rs b/copilot_shim/tests/pty.rs new file mode 100644 index 00000000000000..111192afb4c169 --- /dev/null +++ b/copilot_shim/tests/pty.rs @@ -0,0 +1,660 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +#![cfg(unix)] + +use std::ffi::{OsStr, OsString}; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd, RawFd}; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::ptr; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +const IO_TIMEOUT: Duration = Duration::from_secs(10); +const CLEAR_SEQUENCE: &[u8] = b"\x1b[2J\x1b[H"; +const INSTALL_PROMPT: &[u8] = b"Install GitHub Copilot CLI? [y/N] "; + +struct PtyProcess { + master: File, + child: Child, + process_group: i32, + transcript: Vec, + reaped: bool, +} + +impl PtyProcess { + fn spawn( + arguments: &[OsString], + path: &OsStr, + current_directory: &Path, + environment: &[(&str, &OsStr)], + ) -> Self { + let (master, slave) = open_pty().expect("create PTY"); + configure_terminal(slave).expect("configure PTY"); + set_nonblocking(master).expect("make PTY master nonblocking"); + + let stdin = duplicate_file(slave).expect("duplicate PTY stdin"); + let stdout = duplicate_file(slave).expect("duplicate PTY stdout"); + let stderr = duplicate_file(slave).expect("duplicate PTY stderr"); + let mut command = Command::new(env!("CARGO_BIN_EXE_copilot")); + command + .args(arguments) + .current_dir(current_directory) + .env("PATH", path) + .env("PREFIX", current_directory.join("copilot-install")) + .stdin(Stdio::from(stdin)) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + for (name, value) in environment { + command.env(name, value); + } + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + if libc::ioctl(libc::STDIN_FILENO, libc::TIOCSCTTY as _, 0) == -1 { + return Err(io::Error::last_os_error()); + } + if libc::tcsetpgrp(libc::STDIN_FILENO, libc::getpid()) == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let child = command.spawn().expect("spawn shim in PTY"); + unsafe { + libc::close(slave); + } + + Self { + master: unsafe { File::from_raw_fd(master) }, + process_group: child.id() as i32, + child, + transcript: Vec::new(), + reaped: false, + } + } + + fn wait_for(&mut self, marker: &[u8]) { + let deadline = Instant::now() + IO_TIMEOUT; + while !contains_bytes(&self.transcript, marker) { + if let Some(status) = self.try_wait().unwrap_or_else(|error| { + self.fail(&format!("wait for marker failed: {error}")); + }) { + self.fail(&format!( + "process exited with {status} before marker {:?}", + String::from_utf8_lossy(marker) + )); + } + self.read_ready(deadline); + } + } + + fn write(&mut self, bytes: &[u8]) { + let deadline = Instant::now() + IO_TIMEOUT; + let mut offset = 0; + while offset < bytes.len() { + match self.master.write(&bytes[offset..]) { + Ok(0) => self.fail("PTY input closed while writing"), + Ok(written) => offset += written, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + self.poll(libc::POLLOUT, deadline) + } + Err(error) => self.fail(&format!("write PTY input failed: {error}")), + } + } + } + + fn wait_for_exit(&mut self) -> ExitStatus { + let deadline = Instant::now() + IO_TIMEOUT; + loop { + self.drain(); + if let Some(status) = self.try_wait().unwrap_or_else(|error| { + self.fail(&format!("wait for process failed: {error}")); + }) { + self.drain(); + return status; + } + self.poll(libc::POLLIN, deadline); + } + } + + fn transcript(&self) -> &[u8] { + &self.transcript + } + + fn try_wait(&mut self) -> io::Result> { + let status = self.child.try_wait()?; + if status.is_some() { + self.reaped = true; + } + Ok(status) + } + + fn read_ready(&mut self, deadline: Instant) { + self.poll(libc::POLLIN, deadline); + self.drain(); + } + + fn poll(&self, events: i16, deadline: Instant) { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + self.fail("PTY deadline expired"); + } + let milliseconds = remaining.as_millis().min(i32::MAX as u128) as i32; + let mut descriptor = libc::pollfd { + fd: self.master.as_raw_fd(), + events, + revents: 0, + }; + let result = unsafe { libc::poll(&mut descriptor, 1, milliseconds.max(1)) }; + if result == 0 { + self.fail("PTY deadline expired"); + } + if result == -1 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + self.fail(&format!("poll PTY failed: {error}")); + } + } + } + + fn drain(&mut self) { + let mut buffer = [0_u8; 4096]; + loop { + match self.master.read(&mut buffer) { + Ok(0) => return, + Ok(read) => self.transcript.extend_from_slice(&buffer[..read]), + Err(error) + if error.kind() == io::ErrorKind::WouldBlock + || error.raw_os_error() == Some(libc::EIO) => + { + return; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => self.fail(&format!("read PTY output failed: {error}")), + } + } + } + + fn fail(&self, message: &str) -> ! { + panic!( + "{message}\nPTY transcript:\n{}", + String::from_utf8_lossy(&self.transcript) + ); + } +} + +impl Drop for PtyProcess { + fn drop(&mut self) { + if !self.reaped { + unsafe { + libc::killpg(self.process_group, libc::SIGKILL); + } + let _ = self.child.wait(); + self.reaped = true; + } + } +} + +#[test] +fn unix_pty_preserves_prompt_and_cli_interaction() { + let root = tempfile::tempdir().expect("create PTY test directory"); + let empty_path = root.path().join("empty-path"); + let cli_path = root.path().join("cli-path"); + let working_directory = root.path().join("working"); + let state = root.path().join("state"); + fs::create_dir_all(&empty_path).expect("create empty PATH"); + fs::create_dir_all(&cli_path).expect("create CLI PATH"); + fs::create_dir_all(&working_directory).expect("create working directory"); + fs::create_dir_all(&state).expect("create state directory"); + + let mut prompt = PtyProcess::spawn( + &[OsString::from("--vscode-shim"), OsString::from("clear")], + empty_path.as_os_str(), + &working_directory, + &[], + ); + prompt.wait_for(INSTALL_PROMPT); + assert!( + prompt.transcript().starts_with(CLEAR_SEQUENCE), + "attached terminal was not cleared\n{}", + String::from_utf8_lossy(prompt.transcript()) + ); + prompt.write(b"n\n"); + let prompt_status = prompt.wait_for_exit(); + assert_eq!(prompt_status.code(), Some(0), "{:?}", prompt.transcript()); + + write_executable(&cli_path.join("copilot"), compatible_cli_script()); + let redirected = run_redirected( + &[OsString::from("--vscode-shim"), OsString::from("clear")], + cli_path.as_os_str(), + &working_directory, + root.path(), + ); + assert_eq!( + ( + redirected.0.code(), + contains_bytes(&redirected.1, CLEAR_SEQUENCE), + redirected.2, + ), + (Some(0), false, Vec::::new()) + ); + + let native_argument = OsString::from_vec(b"native-\xff".to_vec()); + let arguments = vec![ + OsString::from("--vscode-shim"), + OsString::from("clear"), + OsString::new(), + OsString::from("with spaces"), + native_argument, + ]; + let mut cli = PtyProcess::spawn( + &arguments, + cli_path.as_os_str(), + &working_directory, + &[ + ("COPILOT_SHIM_TEST_STATE", state.as_os_str()), + ("COPILOT_SHIM_TEST_ENV", OsStr::new("inherited")), + ], + ); + cli.wait_for(b"CLI_READY\n"); + cli.write(b"synchronized-input\n"); + cli.wait_for(b"CLI_STDERR_EXACT\n"); + let cli_status = cli.wait_for_exit(); + + let mut expected_arguments = Vec::new(); + for argument in &arguments[2..] { + expected_arguments.extend_from_slice(argument.as_bytes()); + expected_arguments.push(0); + } + let expected_cwd = fs::canonicalize(&working_directory) + .expect("canonicalize working directory") + .into_os_string() + .into_vec(); + assert_eq!( + ( + cli_status.code(), + read(&state.join("stdin")), + read(&state.join("environment")), + read(&state.join("arguments")), + trim_newline(read(&state.join("cwd"))), + contains_bytes( + cli.transcript(), + b"CLI_READY\nCLI_STDOUT_EXACT\nCLI_STDERR_EXACT\n" + ), + ), + ( + Some(47), + b"synchronized-input".to_vec(), + b"inherited".to_vec(), + expected_arguments, + expected_cwd, + true, + ), + "PTY transcript:\n{}", + String::from_utf8_lossy(cli.transcript()) + ); +} + +#[test] +fn unix_pty_install_offers_profile_update_and_launches_without_refreshing_path() { + for accept_profile in [false, true] { + let root = tempfile::tempdir().expect("profile test directory"); + let home = root.path().join("home"); + let working = root.path().join("working"); + let state = root.path().join("state"); + let tools = root.path().join("tools"); + let shim = root.path().join("copilotCli"); + let legacy = root.path().join("legacy"); + let prefix = root.path().join("install prefix"); + for directory in [ + &home, + &working, + &state, + &tools, + &shim, + &legacy, + &prefix.join("bin"), + ] { + fs::create_dir_all(directory).expect("fixture directory"); + } + fs::copy(env!("CARGO_BIN_EXE_copilot"), shim.join("copilot")).expect("copied Rust shim"); + write_executable( + &legacy.join("copilot"), + include_bytes!("fixtures/legacy/posix-launcher.sh"), + ); + std::os::unix::fs::symlink("/bin/bash", tools.join("bash")).expect("Bash"); + write_executable( + &tools.join("curl"), + br#"#!/bin/sh +printf 'download\n' >> "$COPILOT_SHIM_TEST_STATE/downloads" +printf '%s' "$PATH" > "$COPILOT_SHIM_TEST_STATE/downloader-path" +/bin/cp "$COPILOT_SHIM_TEST_INSTALLER" "$4" +"#, + ); + let cli = root.path().join("cli"); + write_executable( + &cli, + br#"#!/bin/sh +printf '%s' "$PATH" > "$COPILOT_SHIM_TEST_STATE/cli-path" +printf '%s' "$PWD" > "$COPILOT_SHIM_TEST_STATE/cwd" +printf '%s' "$COPILOT_SHIM_TEST_ENV" > "$COPILOT_SHIM_TEST_STATE/environment" +printf '%s\0' "$@" > "$COPILOT_SHIM_TEST_STATE/arguments" +printf 'CLI_READY\n' +IFS= read -r input +printf '%s' "$input" > "$COPILOT_SHIM_TEST_STATE/stdin" +exit 47 +"#, + ); + let installer = root.path().join("installer.sh"); + fs::write( + &installer, + r#"#!/bin/bash +set -e +/bin/cp "$COPILOT_SHIM_TEST_CLI" "$PREFIX/bin/copilot" +printf '%s' "$PATH" > "$COPILOT_SHIM_TEST_STATE/installer-path" +if command -v copilot >/dev/null 2>&1; then + printf 'Unexpected shim visible to installer\n' >&2 + exit 91 +fi +printf 'Would you like to add it to %s/.profile? [y/N] ' "$HOME" +read -r answer > "$HOME/.profile" +fi +printf '\nInstallation complete\n' +"#, + ) + .expect("fake installer"); + let path = std::env::join_paths([&shim, &legacy, &tools]).expect("PATH"); + let environment = [ + ("HOME", home.as_os_str()), + ("PREFIX", prefix.as_os_str()), + ("SHELL", OsStr::new("/bin/bash")), + ("COPILOT_SHIM_TEST_STATE", state.as_os_str()), + ("COPILOT_SHIM_TEST_ENV", OsStr::new("inherited")), + ("COPILOT_SHIM_TEST_INSTALLER", installer.as_os_str()), + ("COPILOT_SHIM_TEST_CLI", cli.as_os_str()), + ]; + let arguments: Vec = ["--resume", "session with spaces", "", "quote'\""] + .into_iter() + .map(OsString::from) + .collect(); + let mut first = PtyProcess::spawn(&arguments, &path, &working, &environment); + first.wait_for(INSTALL_PROMPT); + first.write(b"y\n"); + first.wait_for(b".profile? [y/N] "); + first.write(if accept_profile { b"y\n" } else { b"n\n" }); + first.wait_for(b"CLI_READY\n"); + first.write(b"first-input\n"); + let first_status = first.wait_for_exit(); + + let mut second = PtyProcess::spawn(&arguments, &path, &working, &environment); + second.wait_for(b"CLI_READY\n"); + second.write(b"second-input\n"); + let second_status = second.wait_for_exit(); + let mut expected_arguments = Vec::new(); + for argument in &arguments { + expected_arguments.extend_from_slice(argument.as_bytes()); + expected_arguments.push(0); + } + assert_eq!( + ( + first_status.code(), + second_status.code(), + home.join(".profile").exists(), + read(&state.join("downloads")), + read(&state.join("installer-path")), + read(&state.join("downloader-path")), + read(&state.join("cli-path")), + read(&state.join("arguments")), + read(&state.join("environment")), + read(&state.join("stdin")), + read(&state.join("cwd")), + count_bytes(second.transcript(), INSTALL_PROMPT), + ), + ( + Some(47), + Some(47), + accept_profile, + b"download\n".to_vec(), + tools.as_os_str().as_bytes().to_vec(), + path.as_bytes().to_vec(), + path.as_bytes().to_vec(), + expected_arguments, + b"inherited".to_vec(), + b"second-input".to_vec(), + fs::canonicalize(&working) + .expect("cwd") + .as_os_str() + .as_bytes() + .to_vec(), + 0, + ) + ); + if accept_profile { + assert_eq!( + fs::read_to_string(home.join(".profile")).expect("profile"), + format!("export PATH=\"{}/bin:$PATH\"\n", prefix.display()) + ); + } + } +} + +#[test] +fn unix_pty_ctrl_c_cancels_without_fallback() { + let root = tempfile::tempdir().expect("create cancellation test directory"); + let tools = root.path().join("tools"); + let working_directory = root.path().join("working"); + let state = root.path().join("state"); + let temporary = root.path().join("temporary"); + for directory in [&tools, &working_directory, &state, &temporary] { + fs::create_dir_all(directory).expect("create cancellation test directory"); + } + write_executable(&tools.join("curl"), blocking_curl_script()); + write_executable(&tools.join("wget"), &marker_script("wget-started")); + write_executable(&tools.join("bash"), &marker_script("bash-started")); + + let mut process = PtyProcess::spawn( + &[], + tools.as_os_str(), + &working_directory, + &[ + ("COPILOT_SHIM_TEST_STATE", state.as_os_str()), + ("TMPDIR", temporary.as_os_str()), + ], + ); + process.wait_for(INSTALL_PROMPT); + process.write(b"y\n"); + process.wait_for(b"CURL_READY\n"); + process.write(&[3]); + let status = process.wait_for_exit(); + + let curl_pid = String::from_utf8(read(&state.join("curl-pid"))) + .expect("UTF-8 curl PID") + .parse::() + .expect("numeric curl PID"); + let temporary_script = PathBuf::from(OsString::from_vec(read(&state.join("temporary-script")))); + assert_eq!( + ( + status.code(), + process_exists(curl_pid), + temporary_script.exists(), + state.join("curl-completed").exists(), + state.join("wget-started").exists(), + state.join("bash-started").exists(), + count_bytes(process.transcript(), INSTALL_PROMPT), + ), + (Some(130), false, false, false, false, false, 1), + "PTY transcript:\n{}", + String::from_utf8_lossy(process.transcript()) + ); +} + +fn open_pty() -> io::Result<(RawFd, RawFd)> { + let mut master = -1; + let mut slave = -1; + let mut window = libc::winsize { + ws_row: 40, + ws_col: 120, + ws_xpixel: 0, + ws_ypixel: 0, + }; + if unsafe { + libc::openpty( + &mut master, + &mut slave, + ptr::null_mut(), + ptr::null_mut(), + // Linux takes a const pointer and macOS a mutable one; a raw mutable pointer works for both. + ptr::addr_of_mut!(window), + ) + } == -1 + { + Err(io::Error::last_os_error()) + } else { + Ok((master, slave)) + } +} + +fn configure_terminal(slave: RawFd) -> io::Result<()> { + let mut attributes = unsafe { std::mem::zeroed::() }; + if unsafe { libc::tcgetattr(slave, &mut attributes) } == -1 { + return Err(io::Error::last_os_error()); + } + attributes.c_lflag |= libc::ICANON | libc::ISIG; + attributes.c_lflag &= !(libc::ECHO | libc::ECHONL); + attributes.c_oflag &= !libc::OPOST; + if unsafe { libc::tcsetattr(slave, libc::TCSANOW, &attributes) } == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +fn set_nonblocking(file: RawFd) -> io::Result<()> { + let flags = unsafe { libc::fcntl(file, libc::F_GETFL) }; + if flags == -1 || unsafe { libc::fcntl(file, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +fn duplicate_file(file: RawFd) -> io::Result { + let duplicate = unsafe { libc::dup(file) }; + if duplicate == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(unsafe { File::from_raw_fd(duplicate) }) + } +} + +fn run_redirected( + arguments: &[OsString], + path: &OsStr, + current_directory: &Path, + output_directory: &Path, +) -> (ExitStatus, Vec, Vec) { + let stdout_path = output_directory.join("redirected-stdout"); + let stderr_path = output_directory.join("redirected-stderr"); + let stdout = File::create(&stdout_path).expect("create redirected stdout"); + let stderr = File::create(&stderr_path).expect("create redirected stderr"); + let mut child = Command::new(env!("CARGO_BIN_EXE_copilot")) + .args(arguments) + .current_dir(current_directory) + .env("PATH", path) + .env("PREFIX", current_directory.join("copilot-install")) + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + .expect("spawn redirected shim"); + let process = child.id() as i32; + let (sender, receiver) = mpsc::sync_channel(1); + let waiter = thread::spawn(move || { + let _ = sender.send(child.wait()); + }); + let status = match receiver.recv_timeout(IO_TIMEOUT) { + Ok(result) => result.expect("wait for redirected shim"), + Err(error) => { + unsafe { + libc::kill(process, libc::SIGKILL); + } + let _ = waiter.join(); + panic!( + "redirected shim timed out ({error})\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&read(&stdout_path)), + String::from_utf8_lossy(&read(&stderr_path)) + ); + } + }; + waiter.join().expect("join redirected shim waiter"); + (status, read(&stdout_path), read(&stderr_path)) +} + +fn write_executable(path: &Path, contents: &[u8]) { + fs::write(path, contents).expect("write executable"); + let mut permissions = fs::metadata(path) + .expect("read executable metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("make executable"); +} + +fn compatible_cli_script() -> &'static [u8] { + b"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n\tprintf '1.0.82\\n'\n\texit 0\nfi\nif [ -z \"${COPILOT_SHIM_TEST_STATE:-}\" ]; then\n\texit 0\nfi\nprintf 'CLI_READY\\n'\nIFS= read -r input || exit 91\nprintf '%s' \"$input\" > \"$COPILOT_SHIM_TEST_STATE/stdin\"\nprintf '%s' \"$COPILOT_SHIM_TEST_ENV\" > \"$COPILOT_SHIM_TEST_STATE/environment\"\npwd -P > \"$COPILOT_SHIM_TEST_STATE/cwd\"\n: > \"$COPILOT_SHIM_TEST_STATE/arguments\"\nfor argument do\n\tprintf '%s\\0' \"$argument\" >> \"$COPILOT_SHIM_TEST_STATE/arguments\"\ndone\nprintf 'CLI_STDOUT_EXACT\\n'\nprintf 'CLI_STDERR_EXACT\\n' >&2\nexit 47\n" +} + +fn blocking_curl_script() -> &'static [u8] { + b"#!/bin/sh\ndestination=\nwhile [ \"$#\" -gt 0 ]; do\n\tif [ \"$1\" = \"-o\" ]; then\n\t\tshift\n\t\tdestination=$1\n\tfi\n\tshift\ndone\nprintf '%s' \"$$\" > \"$COPILOT_SHIM_TEST_STATE/curl-pid\"\nprintf '%s' \"$destination\" > \"$COPILOT_SHIM_TEST_STATE/temporary-script\"\nprintf 'CURL_READY\\n'\ntrap 'exit 130' INT TERM HUP\nwhile :; do\n\tIFS= read -r ignored || :\ndone\n: > \"$COPILOT_SHIM_TEST_STATE/curl-completed\"\n" +} + +fn marker_script(marker: &str) -> Vec { + format!("#!/bin/sh\n: > \"$COPILOT_SHIM_TEST_STATE/{marker}\"\nexit 1\n").into_bytes() +} + +fn read(path: &Path) -> Vec { + fs::read(path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())) +} + +fn trim_newline(mut bytes: Vec) -> Vec { + while bytes + .last() + .is_some_and(|byte| matches!(byte, b'\r' | b'\n')) + { + bytes.pop(); + } + bytes +} + +fn process_exists(process: i32) -> bool { + if unsafe { libc::kill(process, 0) } == 0 { + return true; + } + io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn count_bytes(haystack: &[u8], needle: &[u8]) -> usize { + haystack + .windows(needle.len()) + .filter(|window| *window == needle) + .count() +} diff --git a/extensions/copilot/.esbuild.mts b/extensions/copilot/.esbuild.mts index 30f54405e76ebe..527e893399c9c1 100644 --- a/extensions/copilot/.esbuild.mts +++ b/extensions/copilot/.esbuild.mts @@ -168,7 +168,6 @@ const nodeExtHostBuildOptions = { { in: './src/platform/diff/node/diffWorkerMain.ts', out: 'diffWorker' }, { in: './src/extension/chatSessions/copilotcli/node/copilotCLITodoWorker.ts', out: 'copilotCLITodoWorker' }, { in: './src/extension/onboardDebug/node/copilotDebugWorker/index.ts', out: 'copilotDebugCommand' }, - { in: './src/extension/chatSessions/vscode-node/copilotCLIShim.ts', out: 'copilotCLIShim' }, { in: './src/test-extension.ts', out: 'test-extension' }, { in: './src/sanity-test-extension.ts', out: 'sanity-test-extension' }, ], diff --git a/extensions/copilot/.vscodeignore b/extensions/copilot/.vscodeignore index 148e96090e3684..18e572809d5678 100644 --- a/extensions/copilot/.vscodeignore +++ b/extensions/copilot/.vscodeignore @@ -15,7 +15,6 @@ assets/walkthroughs/** !dist/copilotCLITodoWorker.js !dist/webview.js !dist/copilotDebugCommand.js -!dist/copilotCLIShim.js !dist/suggestionsPanelWebview.js !node_modules/@vscode/copilot-typescript-server-plugin/package.json !node_modules/@vscode/copilot-typescript-server-plugin/dist/*.js diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLINativeShim.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLINativeShim.ts new file mode 100644 index 00000000000000..5f1ea1b22ff297 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLINativeShim.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from 'crypto'; +import { constants, promises as fs } from 'fs'; +import { ILogService } from '../../../platform/log/common/logService'; +import { timeout } from '../../../util/vs/base/common/async'; +import * as path from '../../../util/vs/base/common/path'; +import { isUUID } from '../../../util/vs/base/common/uuid'; + +// Fill in absolute local build paths on the extension-host machine (Linux paths for WSL/SSH). +const LOCAL_SHIM_SOURCE_PATHS: Partial> = { + win32: 'C:\\development\\vsc\\vscode\\copilot_shim\\target\\debug\\copilot.exe', + darwin: '', + linux: '', +}; + +const SHIM_MARKER = Buffer.from('VSCODE_COPILOT_RUST_SHIM_V1'); +const MAX_SHIM_SIZE = 16 * 1024 * 1024; +type ShimLogger = Pick; + +/** + * Publishes a local development build without truncating a running executable. The stable storage directory also + * serves ordinary integrated terminals, whose PATH contribution can outlive the extension host. + */ +export async function prepareCopilotCLINativeShim( + globalStoragePath: string, + logService: ShimLogger, + sourcePath: string | undefined = LOCAL_SHIM_SOURCE_PATHS[process.platform], + platform: NodeJS.Platform = process.platform, +): Promise { + if (!sourcePath) { + logService.info('[CopilotCLITerminalIntegration] No local Rust shim path is configured in copilotCLINativeShim.ts; terminals run copilot from PATH.'); + return undefined; + } + + const storagePath = path.join(globalStoragePath, 'copilotCli'); + const filename = platform === 'win32' ? 'copilot.exe' : 'copilot'; + const destination = path.join(storagePath, filename); + const lockPath = path.join(storagePath, '.publish.lock'); + try { + if (!path.isAbsolute(sourcePath)) { + throw new Error('The local Rust shim source path must be absolute.'); + } + const source = await readFile(sourcePath); + if (!source?.includes(SHIM_MARKER)) { + throw new Error(`The local build at ${sourcePath} is not a Rust Copilot shim.`); + } + await fs.mkdir(storagePath, { recursive: true }); + const lock = await acquireLock(lockPath); + try { + await lock.writeFile(`${process.pid}\n`); + const current = await readFile(destination); + if (!current?.equals(source)) { + await publish(storagePath, filename, source, platform, logService); + } else if (platform !== 'win32') { + await fs.chmod(destination, 0o750); + } + await removeLegacyFiles(storagePath, platform); + await removeRetiredCopies(storagePath, filename, logService); + } finally { + try { + await lock.close(); + } finally { + await fs.unlink(lockPath); + } + } + return destination; + } catch (error) { + logService.warn(`[CopilotCLITerminalIntegration] Failed to prepare the local Rust shim: ${error}`); + try { + const current = await readFile(destination); + if (current?.includes(SHIM_MARKER)) { + if (platform !== 'win32') { + await fs.access(destination, constants.X_OK); + } + // A surviving PowerShell/Git Bash wrapper could still win shell command resolution. + await checkLegacyFilesRemoved(storagePath, platform); + logService.warn(`[CopilotCLITerminalIntegration] Keeping the previously published Rust shim at ${destination}.`); + return destination; + } + } catch (error) { + logService.warn(`[CopilotCLITerminalIntegration] The stored shim is not ready: ${error}`); + } + logService.warn('[CopilotCLITerminalIntegration] No usable stored Rust shim is available; terminals run copilot from PATH.'); + return undefined; + } +} + +async function readFile(filename: string): Promise { + try { + const stat = await fs.stat(filename); + if (!stat.isFile() || stat.size > MAX_SHIM_SIZE) { + throw new Error(`Expected a shim file no larger than 16 MiB: ${filename}`); + } + return await fs.readFile(filename); + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return undefined; + } + throw error; + } +} + +async function acquireLock(lockPath: string) { + for (let attempt = 0; ; attempt++) { + try { + return await fs.open(lockPath, 'wx'); + } catch (error) { + if (!hasCode(error, 'EEXIST')) { + throw error; + } + if (attempt >= 40) { + throw new Error(`Timed out waiting for ${lockPath}. If its recorded extension-host process has exited, remove the stale lock and retry.`); + } + await timeout(50); + } + } +} + +async function renameWithRetry(source: string, destination: string): Promise { + for (let attempt = 0; ; attempt++) { + try { + await fs.rename(source, destination); + return; + } catch (error) { + if (attempt >= 4 || !['EACCES', 'EPERM', 'EBUSY'].some(code => hasCode(error, code))) { + throw error; + } + await timeout(50 * (attempt + 1) ** 2); + } + } +} + +async function publish(storagePath: string, filename: string, source: Buffer, platform: NodeJS.Platform, logService: ShimLogger): Promise { + const destination = path.join(storagePath, filename); + const staging = path.join(storagePath, `.${filename}.new-${randomUUID()}`); + const previous = path.join(storagePath, `.${filename}.previous-${randomUUID()}`); + let movedPrevious = false; + try { + await fs.writeFile(staging, source, { flag: 'wx', mode: 0o750 }); + if (platform !== 'win32') { + await fs.chmod(staging, 0o750); + } else { + try { + await renameWithRetry(destination, previous); + movedPrevious = true; + } catch (error) { + if (!hasCode(error, 'ENOENT')) { + throw error; + } + } + } + try { + await renameWithRetry(staging, destination); + } catch (error) { + if (movedPrevious) { + try { + await renameWithRetry(previous, destination); + } catch (restoreError) { + logService.warn(`[CopilotCLITerminalIntegration] Could not restore ${previous} to ${destination}: ${restoreError}`); + } + } + throw error; + } + } finally { + try { + await fs.rm(staging, { force: true }); + } catch (error) { + logService.warn(`[CopilotCLITerminalIntegration] Could not remove staging file ${staging}: ${error}`); + } + } +} + +function legacyFiles(platform: NodeJS.Platform): string[] { + return ['copilot.ps1', 'copilot.bat', 'copilot.cmd', 'copilotCLIShim.js', 'copilotCLIShim.ps1', + ...(platform === 'win32' ? ['copilot'] : [])]; +} + +async function removeLegacyFiles(storagePath: string, platform: NodeJS.Platform): Promise { + for (const filename of legacyFiles(platform)) { + await fs.rm(path.join(storagePath, filename), { force: true }); + } +} + +async function checkLegacyFilesRemoved(storagePath: string, platform: NodeJS.Platform): Promise { + for (const filename of legacyFiles(platform)) { + try { + await fs.lstat(path.join(storagePath, filename)); + } catch (error) { + if (hasCode(error, 'ENOENT')) { + continue; + } + throw error; + } + throw new Error(`The legacy shim ${filename} still exists in ${storagePath}.`); + } +} + +async function removeRetiredCopies(storagePath: string, filename: string, logService: ShimLogger): Promise { + const prefixes = [`.${filename}.previous-`, `.${filename}.new-`]; + for (const entry of await fs.readdir(storagePath, { withFileTypes: true })) { + if (entry.isFile() && prefixes.some(prefix => entry.name.startsWith(prefix) && isUUID(entry.name.substring(prefix.length)))) { + try { + await fs.unlink(path.join(storagePath, entry.name)); + } catch (error) { + logService.warn(`[CopilotCLITerminalIntegration] Could not remove retired shim ${entry.name}; it may still be running: ${error}`); + } + } + } +} + +function hasCode(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIShim.ps1 b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIShim.ps1 deleted file mode 100644 index 418849d775a906..00000000000000 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIShim.ps1 +++ /dev/null @@ -1,236 +0,0 @@ -#--------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -#--------------------------------------------------------------------------------------------- - -# Windows GitHub Copilot CLI bootstrapper -# -# Responsibilities: -# 1. Locate the real Copilot CLI binary (avoid recursion if this file shadows it). -# 2. Offer to install if missing (npm -g @github/copilot). -# 3. Enforce minimum version (>= REQUIRED_VERSION) with interactive update. -# 4. Execute the real binary with original arguments and exit with its status. -# -# NOTE: This file intentionally keeps logic selfโ€‘contained (no external deps) so it can be dropped into PATH directly. - -# Minimum required Copilot CLI version -$RequiredVersion = "0.0.394" -$PackageName = "@github/copilot" - -function Invoke-NpmGlobalCommand { - param( - [Parameter(Mandatory = $true)][ValidateSet('install', 'update')][string]$Command, - [Parameter(Mandatory = $true)][string]$Package - ) - - $npmArgs = @($Command, '-g', $Package) - - $npmCmd = Get-Command npm.cmd -ErrorAction SilentlyContinue - if ($npmCmd) { - & npm.cmd @npmArgs - } else { - & npm @npmArgs - } -} - -function Invoke-WingetInstall { - $wingetCmd = Get-Command winget -ErrorAction SilentlyContinue - if (-not $wingetCmd) { - return $false - } - & winget install GitHub.Copilot - return ($LASTEXITCODE -eq 0) -} - -function Install-CopilotCLI { - # Try npm first - $npmCmd = Get-Command npm -ErrorAction SilentlyContinue - if (-not $npmCmd) { $npmCmd = Get-Command npm.cmd -ErrorAction SilentlyContinue } - if ($npmCmd) { - try { - Invoke-NpmGlobalCommand -Command 'install' -Package $PackageName - if ($LASTEXITCODE -eq 0) { return $true } - } catch { } - } - # Fall back to winget - Write-Host "npm is not available or installation failed. Trying winget..." - if (Invoke-WingetInstall) { return $true } - return $false -} - -function Update-CopilotCLI { - # Try npm first - $npmCmd = Get-Command npm -ErrorAction SilentlyContinue - if (-not $npmCmd) { $npmCmd = Get-Command npm.cmd -ErrorAction SilentlyContinue } - if ($npmCmd) { - try { - Invoke-NpmGlobalCommand -Command 'update' -Package $PackageName - if ($LASTEXITCODE -eq 0) { return $true } - } catch { } - } - # Fall back to winget - Write-Host "npm is not available or update failed. Trying winget..." - if (Invoke-WingetInstall) { return $true } - return $false -} - -function Find-RealCopilot { - # Find the real copilot binary, avoiding this script if it's in PATH - $CurrentScript = $MyInvocation.PSCommandPath - if (-not $CurrentScript) { $CurrentScript = $PSCommandPath } - $CopilotPath = (Get-Command copilot -ErrorAction SilentlyContinue).Source - - # Check if the copilot command would point to this script - $CurrentScriptResolved = if ($CurrentScript) { (Resolve-Path $CurrentScript -ErrorAction SilentlyContinue).Path } else { $null } - $CopilotPathResolved = if ($CopilotPath) { (Resolve-Path $CopilotPath -ErrorAction SilentlyContinue).Path } else { $null } - - if ($CurrentScript -eq $CopilotPath -or (Split-Path $CurrentScript -Parent) -eq (Split-Path $CopilotPath -Parent) -or ($CurrentScriptResolved -and $CopilotPathResolved -and $CurrentScriptResolved -eq $CopilotPathResolved)) { - # The copilot in PATH is this script, find the real one by temporarily removing this script's directory from PATH - $ScriptDir = Split-Path $CurrentScript -Parent - $OldPath = $env:PATH - # Use appropriate path delimiter based on OS - $PathDelimiter = if ($IsWindows -or $env:OS -eq "Windows_NT") { ';' } else { ':' } - $env:PATH = ($env:PATH -split $PathDelimiter | Where-Object { $_ -ne $ScriptDir }) -join $PathDelimiter - $RealCopilot = (Get-Command copilot -ErrorAction SilentlyContinue).Source - $env:PATH = $OldPath - - if ($RealCopilot -and (Test-Path $RealCopilot)) { - return $RealCopilot - } else { - return $null - } - } else { - # The copilot in PATH is different from this script, use it - if ($CopilotPath -and (Test-Path $CopilotPath)) { - return $CopilotPath - } else { - return $null - } - } -} - -function Test-VersionCompatibility { - param([string]$Version) - $cleanInstalled = $Version -replace '^v','' - $cleanRequired = $RequiredVersion -replace '^v','' - try { - $installedVer = [version]$cleanInstalled - $requiredVer = [version]$cleanRequired - } catch { - return $false - } - return ($installedVer -ge $requiredVer) -} - -function Test-AndLaunchCopilot { - param([string[]]$Arguments) - - # Check if real copilot command exists - $realCopilot = Find-RealCopilot - if (-not $realCopilot) { - Write-Host "Cannot find GitHub Copilot CLI (https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)" - $answer = Read-Host "Install GitHub Copilot CLI? (y/N)" - if ($answer -eq "y" -or $answer -eq "Y") { - if (Install-CopilotCLI) { - Test-AndLaunchCopilot $Arguments - return - } else { - Read-Host "Installation failed. Please install manually (https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)." - return - } - } else { - exit 0 - } - } - - # Check version compatibility - $realCopilot = Find-RealCopilot - if (-not $realCopilot) { - Write-Host "Error: Unable to find copilot binary." - $answer = Read-Host "Would you like to reinstall GitHub Copilot CLI? (y/N)" - if ($answer -eq "y" -or $answer -eq "Y") { - Write-Host "Reinstalling GitHub Copilot CLI..." - if (Install-CopilotCLI) { - Test-AndLaunchCopilot $Arguments - return - } else { - Read-Host "Reinstallation failed. Please install manually (https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)." - return - } - } else { - exit 0 - } - } - - try { - $versionOutput = & $realCopilot --version 2>$null - if ($LASTEXITCODE -ne 0) { - throw "Command failed" - } - } catch { - # Write-Host "Error: Unable to check copilot version." - $answer = Read-Host "Would you like to reinstall GitHub Copilot CLI? (y/N)" - if ($answer -eq "y" -or $answer -eq "Y") { - if (Install-CopilotCLI) { - Test-AndLaunchCopilot $Arguments - return - } else { - Read-Host "Reinstallation failed. Please install manually (https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)." - return - } - } else { - exit 0 - } - } - - # Extract version number from output (search through all lines) - $version = $null - if ($versionOutput) { - foreach ($line in ($versionOutput -split "`n")) { - $trimmedLine = $line.Trim() - if ($trimmedLine -match '[0-9]+\.[0-9]+\.[0-9]+') { - $version = $matches[0] - break - } - } - } - - # Command succeeded - assume CLI is installed even if we can't parse the version - - # Only check version compatibility if we have a valid version - if ($version -and -not (Test-VersionCompatibility $version)) { - Write-Host "GitHub Copilot CLI version $version is not compatible." - Write-Host "Version $RequiredVersion or later is required." - $answer = Read-Host "Update GitHub Copilot CLI? (y/N)" - if ($answer -eq "y" -or $answer -eq "Y") { - if (Update-CopilotCLI) { - Test-AndLaunchCopilot $Arguments - return - } else { - Read-Host "Update failed. Please update manually (https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)." - return - } - } else { - exit 0 - } - } - - # All checks passed, execute the real copilot binary - $realCopilot = Find-RealCopilot - if ($realCopilot -and (Test-Path $realCopilot)) { - & $realCopilot @Arguments - } else { - Write-Host "Error: Could not find the real GitHub Copilot CLI binary" - Read-Host "Please ensure it's properly installed (https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)" - return - } -} - -# Start the check and launch process -$finalArgs = $args -# Handle --clear argument -if ($args.Length -gt 0 -and $args[0] -eq '--clear') { - Clear-Host - $finalArgs = $args[1..($args.Length - 1)] -} - -Test-AndLaunchCopilot $finalArgs \ No newline at end of file diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIShim.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIShim.ts deleted file mode 100644 index a0211f4a48276c..00000000000000 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIShim.ts +++ /dev/null @@ -1,223 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { spawnSync } from 'child_process'; -import * as readline from 'readline'; -import * as path from '../../../util/vs/base/common/path'; - -// โš ๏ธโš ๏ธโš ๏ธ -// This file is built into a standalone bundle, executed from the terminal. -// Avoid including unnecessary dependencies! -// -// This is used on macOS and Linux. On Windows, you'll need to make changes -// in copilotCLITerminalIntegration.ps1 instead. This is because Electron on Windows -// is not built with support for console stdin. -// โš ๏ธโš ๏ธโš ๏ธ - -/* - * Universal GitHub Copilot CLI bootstrapper - * - * Works from any interactive shell (bash, zsh, sh, PowerShell Core (pwsh), Nushell, csh/tcsh) via shebang. - * Responsibilities: - * 1. Locate the real Copilot CLI binary (avoid recursion if this file shadows it). - * 2. Offer to install if missing (npm -g @github/copilot). - * 3. Enforce minimum version (>= REQUIRED_VERSION) with interactive update. - * 4. Execute the real binary with original arguments and exit with its status. - * - * NOTE: This file intentionally keeps logic selfโ€‘contained (no external deps) so it can be dropped into PATH directly. - */ - -const REQUIRED_VERSION = '0.0.394'; -const PACKAGE_NAME = '@github/copilot'; -const env = { ...process.env, PATH: (process.env.PATH || '').replaceAll(`${__dirname}${path.delimiter}`, '').replaceAll(`${path.delimiter}${__dirname}`, '') }; - -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, -}); - -function log(msg: string) { process.stdout.write(msg + '\n'); } - -function warn(msg: string) { process.stderr.write(msg + '\n'); } - -function promptYes(question: string): Promise { - return new Promise((resolve) => { - rl.question(`${question} ['y/N'] `, (answer) => { - resolve(answer.toLowerCase()[0] === 'y'); - }); - }); -} - -function semverParts(v: string) { - const cleaned = v.replace(/^v/, '').split('.'); - return [0, 1, 2].map(i => parseInt((cleaned[i] || '0').replace(/[^0-9].*$/, ''), 10) || 0); -} - -function versionGte(versionA: string, versionB: string) { - const aa = semverParts(versionA), bb = semverParts(versionB); - for (let i = 0; i < 3; i++) { - if (aa[i] > bb[i]) { return true; } - if (aa[i] < bb[i]) { return false; } - } - return true; -} - -/** - * Returns the version of Copilot CLI installed. - * If not installed, then returns `undefined`, else returns an object with the version. - * Version can be undefined if it cannot be determined. - */ -function getCopilotInfo(): { installed: true; version?: string } | undefined { - const result = spawnSync('copilot --version', { env, shell: true, encoding: 'utf8' }); - if (result.error || result.status !== 0) { - return undefined; - } - const m = result.stdout.match(/[0-9]+\.[0-9]+\.[0-9]+/); - return m ? { version: m[0], installed: true } : { installed: true }; - -} - -function runNpm(args: string[], label: string) { - const result = spawnSync('npm', args, { stdio: 'inherit', env }); - if (result.error) { - warn(`${label} failed: ${result.error.message}`); - return false; - } - if (result.status !== 0) { - warn(`${label} failed with exit code ${result.status}`); - return false; - } - return true; -} - -function runBrew(label: string) { - const result = spawnSync('brew', ['install', 'copilot-cli'], { stdio: 'inherit', env }); - if (result.error) { - warn(`${label} via brew failed: ${result.error.message}`); - return false; - } - if (result.status !== 0) { - warn(`${label} via brew failed with exit code ${result.status}`); - return false; - } - return true; -} - -function runCurl(label: string) { - const result = spawnSync('bash', ['-c', 'curl -fsSL https://gh.io/copilot-install | bash'], { stdio: 'inherit', env }); - if (result.error) { - warn(`${label} via curl failed: ${result.error.message}`); - return false; - } - if (result.status !== 0) { - warn(`${label} via curl failed with exit code ${result.status}`); - return false; - } - return true; -} - -function runWget(label: string) { - const result = spawnSync('bash', ['-c', 'wget -qO- https://gh.io/copilot-install | bash'], { stdio: 'inherit', env }); - if (result.error) { - warn(`${label} via wget failed: ${result.error.message}`); - return false; - } - if (result.status !== 0) { - warn(`${label} via wget failed with exit code ${result.status}`); - return false; - } - return true; -} - -function hasCommand(cmd: string) { - const result = spawnSync('sh', ['-c', `command -v ${cmd}`], { env, encoding: 'utf8' }); - return !result.error && result.status === 0; -} - -function installCopilotCLI(label: string, update = false): boolean { - // Try npm first - if (hasCommand('npm') && runNpm([update ? 'update' : 'install', '-g', PACKAGE_NAME], label)) { - return true; - } - // Try brew - if (hasCommand('brew')) { - log(`npm is not available or ${update ? 'update' : 'installation'} failed. Trying brew...`); - if (runBrew(label)) { return true; } - } - // Try curl - if (hasCommand('curl')) { - log('Trying install script via curl...'); - if (runCurl(label)) { return true; } - } - // Try wget - if (hasCommand('wget')) { - log('Trying install script via wget...'); - if (runWget(label)) { return true; } - } - return false; -} - -async function ensureInstalled() { - const version = getCopilotInfo(); - if (!version) { - warn('Cannot find GitHub Copilot CLI (https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)'); - if (await promptYes('Install GitHub Copilot CLI?')) { - if (installCopilotCLI('Installing')) { - return ensureInstalled(); - } - await pressKeyToExit(); - } else { - process.exit(0); - } - } - return version; -} - -async function validateVersion(version: string) { - if (!versionGte(version, REQUIRED_VERSION)) { - warn(`GitHub Copilot CLI version ${version} is not compatible.`); - log(`Version ${REQUIRED_VERSION} or later is required.`); - if (await promptYes('Update GitHub Copilot CLI?')) { - if (installCopilotCLI('Update', true)) { - return true; - } - await pressKeyToExit(); - } else { - process.exit(0); - } - } -} - -async function pressKeyToExit(message: string = 'Press Enter to exit...'): Promise { - await new Promise((resolve) => { - rl.question(`${message}`, () => { - resolve(); - }); - }); - process.exit(0); - -} - -(async function main() { - const info = await ensureInstalled(); - if (info?.version) { - await validateVersion(info.version); - } - if (!info) { - warn('Error: Could not locate Copilot CLI after update.'); - await pressKeyToExit('Try manually reinstalling (https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)'); - } - const args = process.argv.slice(2); - - // In vscode we use `--clear` to indicate that the terminal should be cleared before running the command - // Used when launching terminal in editor view (for best possible UX, so it doesn't look like a terminal) - if (args[0] === '--clear') { - console.clear(); - args.shift(); - } - - spawnSync('copilot', args, { stdio: 'inherit', env }); - process.exit(0); -})(); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLITerminalIntegration.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLITerminalIntegration.ts index 50ddf89ef0d9af..e2bff5e31bc674 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLITerminalIntegration.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLITerminalIntegration.ts @@ -21,15 +21,38 @@ import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecy import * as path from '../../../util/vs/base/common/path'; import { windowsToGitBashPath } from '../../../util/vs/workbench/contrib/terminalContrib/suggest/browser/terminalGitBashHelpers'; import { PythonTerminalService } from './copilotCLIPythonTerminalService'; +import { prepareCopilotCLINativeShim } from './copilotCLINativeShim'; import { CopilotCLITerminalLinkProvider, SessionDirResolver } from './copilotCLITerminalLinkProvider'; -//@ts-ignore -import powershellScript from './copilotCLIShim.ps1'; - -const COPILOT_CLI_SHIM_JS = 'copilotCLIShim.js'; const COPILOT_CLI_COMMAND = 'copilot'; +const COPILOT_SHIM_DIRECTORY = 'copilot-shim'; const COPILOT_ICON = new ThemeIcon('copilot'); +/** + * Core setting, controlled by the `CopilotCliCommand` policy, that turns off the native shim in terminals. + */ +const COPILOT_CLI_COMMAND_ENABLED_SETTING = 'chat.copilotCliCommand.enabled'; + +/** + * Directory in global storage where earlier versions wrote script shims, and where a local development build of the + * shim is published when this build doesn't ship one. + */ +const STORED_SHIM_DIRECTORY = 'copilotCli'; + +/** + * Returns where the native `copilot` shim ships: a `copilot-shim` folder in the `bin` folder that contains the `code` + * command. On macOS the `bin` folder is under the app root; elsewhere it is next to the application executable. + */ +export function getNativeCopilotShimPath(platform: NodeJS.Platform, execPath: string, appRoot: string): string { + if (platform === 'win32') { + return path.win32.join(path.win32.dirname(execPath), 'bin', COPILOT_SHIM_DIRECTORY, `${COPILOT_CLI_COMMAND}.exe`); + } + if (platform === 'darwin') { + return path.posix.join(appRoot, 'bin', COPILOT_SHIM_DIRECTORY, COPILOT_CLI_COMMAND); + } + return path.posix.join(path.posix.dirname(execPath), 'bin', COPILOT_SHIM_DIRECTORY, COPILOT_CLI_COMMAND); +} + export type TerminalOpenLocation = 'panel' | 'editor' | 'editorBeside'; export interface ICopilotCLITerminalIntegration extends Disposable { @@ -51,6 +74,10 @@ type IShellInfo = { shellArgs: string[]; iconPath?: ThemeIcon; copilotCommand: string; + /** + * Clears the screen before `copilotCommand` when the command is typed into an interactive shell. + */ + clearCommand: string; exitCommand: string | undefined; }; @@ -59,14 +86,14 @@ export const ICopilotCLITerminalIntegration = createServiceIdentifier; - private shellScriptPath: string | undefined; /** - * On Windows only: a POSIX shell script (no extension) that Git Bash / MSYS bash - * can execute. Used when the user's default shell is `bash.exe`, since bash cannot - * run the `copilot.bat` shim. + * The native shim when available and enabled; otherwise `copilot`, resolved from PATH. */ - private posixShellScriptPath: string | undefined; - private powershellScriptPath: string | undefined; + private copilotCommand: string = COPILOT_CLI_COMMAND; + /** + * The native shim that ships with this build, or a local development build published in global storage. + */ + private nativeShimPath: string | undefined; private readonly pythonTerminalService: PythonTerminalService; private readonly _linkProvider: CopilotCLITerminalLinkProvider | undefined; constructor( @@ -74,9 +101,9 @@ export class CopilotCLITerminalIntegration extends Disposable implements ICopilo @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @ITerminalService private readonly terminalService: ITerminalService, @IEnvService private readonly envService: IEnvService, - @ILogService logService: ILogService, + @ILogService private readonly logService: ILogService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IConfigurationService configurationService: IConfigurationService, + @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkspaceService workspaceService: IWorkspaceService, @IOTelService private readonly _otelService: IOTelService, ) { @@ -90,47 +117,13 @@ export class CopilotCLITerminalIntegration extends Disposable implements ICopilo } private async initialize(): Promise { - const globalStorageUri = this.context.globalStorageUri; - if (!globalStorageUri) { - // globalStorageUri is not available in extension tests - return; - } - - const storageLocation = path.join(globalStorageUri.fsPath, 'copilotCli'); - this.terminalService.contributePath('copilot-cli', storageLocation, { command: COPILOT_CLI_COMMAND }, true); - - await fs.mkdir(storageLocation, { recursive: true }); - - if (process.platform === 'win32') { - this.powershellScriptPath = path.join(storageLocation, `${COPILOT_CLI_COMMAND}.ps1`); - await fs.writeFile(this.powershellScriptPath, powershellScript); - const copilotPowershellScript = `@echo off -powershell -ExecutionPolicy Bypass -File "${this.powershellScriptPath}" %* -`; - this.shellScriptPath = path.join(storageLocation, `${COPILOT_CLI_COMMAND}.bat`); - await fs.writeFile(this.shellScriptPath, copilotPowershellScript); - - // Also create a POSIX shell script for Git Bash on Windows. Bash cannot - // execute the .bat shim directly inside a `bash -c` string, and we cannot run - // the JS shim via Electron-as-node here because Electron on Windows does not - // support console stdin (see copilotCLIShim.ts header). Instead, delegate to - // the existing .bat shim, which routes through cmd.exe -> PowerShell where - // console stdin works correctly. - const posixBatPath = windowsToGitBashPath(this.shellScriptPath); - const copilotBashScript = `#!/bin/sh\nexec "${posixBatPath}" "$@"\n`; - this.posixShellScriptPath = path.join(storageLocation, COPILOT_CLI_COMMAND); - await fs.writeFile(this.posixShellScriptPath, copilotBashScript); - } else { - const copilotShellScript = `#!/bin/sh -unset NODE_OPTIONS -ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${path.join(storageLocation, COPILOT_CLI_SHIM_JS)}" "$@"`; - await fs.copyFile(path.join(__dirname, COPILOT_CLI_SHIM_JS), path.join(storageLocation, COPILOT_CLI_SHIM_JS)); - this.shellScriptPath = path.join(storageLocation, COPILOT_CLI_COMMAND); - this.powershellScriptPath = path.join(storageLocation, `copilotCLIShim.ps1`); - await fs.writeFile(this.shellScriptPath, copilotShellScript); - await fs.writeFile(this.powershellScriptPath, powershellScript); - await fs.chmod(this.shellScriptPath, 0o750); - } + this.nativeShimPath = await this.findNativeShim(); + this.updateCopilotCommand(); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(COPILOT_CLI_COMMAND_ENABLED_SETTING)) { + this.updateCopilotCommand(); + } + })); const provideTerminalProfile = async () => { const shellInfo = await this.getShellInfo([]); @@ -153,7 +146,64 @@ ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${path.join(storageLocation, COPIL }); }; this._register(window.registerTerminalProfileProvider('copilot-cli', { provideTerminalProfile })); + } + + /** + * Uses the native shim, and adds its directory to the end of the terminal PATH, unless it is missing or + * `chat.copilotCliCommand.enabled` (or its `CopilotCliCommand` policy) turns it off. Like the entry Windows setup + * adds, it comes after the user's PATH, so a Copilot CLI on PATH is found first and keeps working either way. + */ + private updateCopilotCommand(): void { + const enabled = this.configurationService.getNonExtensionConfig(COPILOT_CLI_COMMAND_ENABLED_SETTING) !== false; + if (enabled && this.nativeShimPath) { + this.copilotCommand = this.nativeShimPath; + this.terminalService.contributePath('copilot-cli', path.dirname(this.nativeShimPath), { command: COPILOT_CLI_COMMAND }); + return; + } + this.copilotCommand = COPILOT_CLI_COMMAND; + // Also drops a persisted contribution when no usable native shim is available. + this.terminalService.removePathContribution('copilot-cli'); + if (!enabled && this.nativeShimPath) { + this.logService.info(`[CopilotCLITerminalIntegration] ${COPILOT_CLI_COMMAND_ENABLED_SETTING} is off; terminals run copilot from PATH.`); + } + } + + /** + * Returns the shim that ships next to the `code` command. A build from source doesn't ship one; it can use a local + * development build instead (see `copilotCLINativeShim.ts`). + */ + private async findNativeShim(): Promise { + const shippedPath = getNativeCopilotShimPath(process.platform, process.execPath, this.envService.appRoot); + if (await isFile(shippedPath)) { + await this.removeStoredShims(); + return shippedPath; + } + + const globalStorageUri = this.context.globalStorageUri; + if (!globalStorageUri) { + // globalStorageUri is not available in extension tests + this.logService.info(`[CopilotCLITerminalIntegration] The native copilot shim was not found at ${shippedPath}; terminals run copilot from PATH.`); + return undefined; + } + return prepareCopilotCLINativeShim(globalStorageUri.fsPath, this.logService); + } + + /** + * Removes the script shims that earlier versions wrote to global storage, which terminals restored with their old + * PATH could still run, and any local development build published there. + */ + private async removeStoredShims(): Promise { + const globalStorageUri = this.context.globalStorageUri; + if (!globalStorageUri) { + return; + } + + try { + await fs.rm(path.join(globalStorageUri.fsPath, STORED_SHIM_DIRECTORY), { recursive: true, force: true }); + } catch (error) { + this.logService.warn(`[CopilotCLITerminalIntegration] Failed to remove the stored copilot shims: ${error}`); + } } public setTerminalSessionDir(terminal: Terminal, sessionDir: Uri): void { @@ -165,18 +215,11 @@ ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${path.join(storageLocation, COPIL } public async openTerminal(name: string, cliArgs: string[] = [], cwd?: string, location: TerminalOpenLocation = 'editor'): Promise { - // Capture session type before mutating cliArgs. // If cliArgs are provided (e.g. --resume), we are resuming a session; otherwise it's a new session. const sessionType = cliArgs.length > 0 ? 'resume' : 'new'; - // Generate another set of shell args, but with --clear to clear the terminal before running the command. - // We'd like to hide all of the custom shell commands we send to the terminal from the user. - cliArgs.unshift('--clear'); - - let [shellPathAndArgs] = await Promise.all([ - this.getShellInfo(cliArgs), - this.initialization - ]); + await this.initialization; + const shellPathAndArgs = await this.getShellInfo(cliArgs); const options = await getCommonTerminalOptions(name, this._authenticationService, this._otelService, location); options.cwd = cwd; @@ -189,7 +232,7 @@ ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${path.join(storageLocation, COPIL if (terminal) { this._register(terminal); this._linkProvider?.registerTerminal(terminal); - const command = this.buildCommandForPythonTerminal(shellPathAndArgs?.copilotCommand, cliArgs, shellPathAndArgs); + const command = this.buildCommandForPythonTerminal(shellPathAndArgs.copilotCommand, cliArgs, shellPathAndArgs); await this.sendCommandToTerminal(terminal, command, true, shellPathAndArgs); this.sendTerminalOpenTelemetry(sessionType, shellPathAndArgs.shell, 'pythonTerminal', location); return terminal; @@ -199,26 +242,19 @@ ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${path.join(storageLocation, COPIL if (!shellPathAndArgs) { const terminal = this._register(this.terminalService.createTerminal(options)); this._linkProvider?.registerTerminal(terminal); - cliArgs.shift(); // Remove --clear as we can't run it without a shell integration - const command = this.buildCommandForTerminal(terminal, COPILOT_CLI_COMMAND, cliArgs); + const command = this.buildCommandForTerminal(terminal, this.copilotCommand, cliArgs); await this.sendCommandToTerminal(terminal, command, false, shellPathAndArgs); this.sendTerminalOpenTelemetry(sessionType, 'unknown', 'fallbackTerminal', location); return terminal; } - cliArgs.shift(); // Remove --clear as we are creating a new terminal with our own args. - shellPathAndArgs = await this.getShellInfo(cliArgs); - if (shellPathAndArgs) { - options.shellPath = shellPathAndArgs.shellPath; - options.shellArgs = shellPathAndArgs.shellArgs; - const terminal = this._register(this.terminalService.createTerminal(options)); - this._linkProvider?.registerTerminal(terminal); - terminal.show(); - this.sendTerminalOpenTelemetry(sessionType, shellPathAndArgs.shell, 'shellArgsTerminal', location); - return terminal; - } - - return undefined; + options.shellPath = shellPathAndArgs.shellPath; + options.shellArgs = shellPathAndArgs.shellArgs; + const terminal = this._register(this.terminalService.createTerminal(options)); + this._linkProvider?.registerTerminal(terminal); + terminal.show(); + this.sendTerminalOpenTelemetry(sessionType, shellPathAndArgs.shell, 'shellArgsTerminal', location); + return terminal; } private sendTerminalOpenTelemetry(sessionType: string, shell: string, terminalCreationMethod: string, location: TerminalOpenLocation): void { @@ -229,14 +265,16 @@ ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${path.join(storageLocation, COPIL "sessionType" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the terminal is for a new session or resuming an existing one." }, "shell" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The shell type used for the terminal." }, "terminalCreationMethod" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "How the terminal was created." }, - "location" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Where the terminal was opened - panel, editor area (active), or editor area (beside)." } + "location" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Where the terminal was opened - panel, editor area (active), or editor area (beside)." }, + "shim" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the terminal runs the native copilot shim that ships with VS Code or resolves copilot from PATH." } } */ this.telemetryService.sendMSFTTelemetryEvent('copilotcli.terminal.open', { sessionType, shell, terminalCreationMethod, - location + location, + shim: this.copilotCommand === COPILOT_CLI_COMMAND ? 'path' : 'native' }); } @@ -246,14 +284,15 @@ ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${path.join(storageLocation, COPIL // Starting with empty space to hide from terminal history commandPrefix = ' '; } + let invocationPrefix = ''; if (shellInfo.shell === 'powershell' || shellInfo.shell === 'pwsh') { - // Run powershell script - commandPrefix = '& '; + invocationPrefix = '& '; } const exitCommand = shellInfo.exitCommand || ''; - return `${commandPrefix}${quoteArgsForShell(copilotCommand, [])} ${cliArgs.join(' ')} ${exitCommand}`; + // Clear the screen first to hide the environment activation commands sent to the terminal. + return `${commandPrefix}${shellInfo.clearCommand}${invocationPrefix}${quoteArgsForShell(copilotCommand, [])} ${cliArgs.join(' ')} ${exitCommand}`; } private buildCommandForTerminal(terminal: Terminal, copilotCommand: string, cliArgs: string[]) { @@ -322,68 +361,73 @@ ELECTRON_RUN_AS_NODE=1 "${process.execPath}" "${path.join(storageLocation, COPIL ? path.basename(shellPath).toLowerCase() : path.basename(shellPath); const iconPath = COPILOT_ICON; + const copilotCommand = this.copilotCommand; - if (shellBasename === 'zsh' && this.shellScriptPath) { + if (shellBasename === 'zsh') { return { shell: 'zsh', shellPath, - shellArgs: [`-ci${shellArgs.includes('-l') ? 'l' : ''}`, quoteArgsForShell(this.shellScriptPath, cliArgs)], + shellArgs: [`-ci${shellArgs.includes('-l') ? 'l' : ''}`, quoteArgsForShell(copilotCommand, cliArgs)], iconPath, - copilotCommand: this.shellScriptPath, + copilotCommand, + clearCommand: 'clear && ', exitCommand: `&& exit` }; - } else if ((shellBasename === 'bash' || shellBasename === 'bash.exe') && (configPlatform === 'windows' ? this.posixShellScriptPath : this.shellScriptPath)) { - // On Windows (Git Bash), use the POSIX shim and reference it by its MSYS path, - // since the path lives inside the `-ic` shell-string and is not translated by MSYS. - const scriptPath = configPlatform === 'windows' ? this.posixShellScriptPath! : this.shellScriptPath!; - const bashScriptPath = configPlatform === 'windows' ? windowsToGitBashPath(scriptPath) : scriptPath; + } else if (shellBasename === 'bash' || shellBasename === 'bash.exe') { + // Git Bash on Windows doesn't translate a Windows path inside the `-ic` shell string, so use its MSYS form. + const bashCommand = configPlatform === 'windows' ? windowsToGitBashPath(copilotCommand) : copilotCommand; return { shell: 'bash', shellPath, - shellArgs: [`-${shellArgs.includes('-l') ? 'l' : ''}ic`, quoteArgsForShell(bashScriptPath, cliArgs)], + shellArgs: [`-${shellArgs.includes('-l') ? 'l' : ''}ic`, quoteArgsForShell(bashCommand, cliArgs)], iconPath, - copilotCommand: bashScriptPath, + copilotCommand: bashCommand, + clearCommand: 'clear && ', exitCommand: `&& exit` }; - } else if (shellBasename === 'fish' && this.shellScriptPath) { + } else if (shellBasename === 'fish') { const fishArgs: string[] = []; if (shellArgs.includes('-l')) { fishArgs.push('-l'); } - fishArgs.push('-c', quoteArgsForShell(this.shellScriptPath, cliArgs)); + fishArgs.push('-c', quoteArgsForShell(copilotCommand, cliArgs)); return { shell: 'fish', shellPath, shellArgs: fishArgs, iconPath, - copilotCommand: this.shellScriptPath, + copilotCommand, + clearCommand: 'clear; ', exitCommand: `; and exit` }; - } else if ((shellBasename === 'pwsh' || shellBasename === 'pwsh.exe') && this.powershellScriptPath) { + } else if (shellBasename === 'pwsh' || shellBasename === 'pwsh.exe') { return { shell: 'pwsh', shellPath, - shellArgs: ['-File', this.powershellScriptPath, ...cliArgs], + shellArgs: ['-Command', quoteArgsForPowerShell(copilotCommand, cliArgs)], iconPath, - copilotCommand: this.powershellScriptPath, + copilotCommand, + clearCommand: 'Clear-Host; ', exitCommand: `&& exit` }; - } else if ((shellBasename === 'powershell' || shellBasename === 'powershell.exe') && this.powershellScriptPath && configPlatform === 'windows') { + } else if ((shellBasename === 'powershell' || shellBasename === 'powershell.exe') && configPlatform === 'windows') { return { shell: 'powershell', shellPath, - shellArgs: ['-File', this.powershellScriptPath, ...cliArgs], + shellArgs: ['-Command', quoteArgsForPowerShell(copilotCommand, cliArgs)], iconPath, - copilotCommand: this.powershellScriptPath, + copilotCommand, + clearCommand: 'Clear-Host; ', exitCommand: `&& exit` }; - } else if ((shellBasename === 'cmd' || shellBasename === 'cmd.exe') && this.shellScriptPath && configPlatform === 'windows') { + } else if ((shellBasename === 'cmd' || shellBasename === 'cmd.exe') && configPlatform === 'windows') { return { shell: 'cmd', shellPath, - shellArgs: ['/c', this.shellScriptPath, ...cliArgs], + shellArgs: ['/c', copilotCommand, ...cliArgs], iconPath, - copilotCommand: this.shellScriptPath, + copilotCommand, + clearCommand: 'cls && ', exitCommand: '&& exit' }; } @@ -406,6 +450,23 @@ function quoteArgsForShell(shellScript: string, args: string[]): string { return args.length ? `${escapeArg(shellScript)} ${escapedArgs.join(' ')}` : escapeArg(shellScript); } +/** + * Builds a PowerShell command that runs `command` with `args`. Single-quoted strings are literal in PowerShell, so + * only embedded single quotes need escaping. + */ +function quoteArgsForPowerShell(command: string, args: string[]): string { + const quote = (value: string) => `'${value.replace(/'/g, `''`)}'`; + return ['&', quote(command), ...args.map(quote)].join(' '); +} + +async function isFile(filePath: string): Promise { + try { + return (await fs.stat(filePath)).isFile(); + } catch { + return false; + } +} + async function getCommonTerminalOptions(name: string, authenticationService: IAuthenticationService, otelService: IOTelService, location: TerminalOpenLocation = 'editor'): Promise { const options: TerminalOptions = { name, diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts index c8a8eaba8d4e0b..643623964f8964 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts @@ -38,7 +38,6 @@ import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionParticipant, import { PullRequestDetectionService } from '../pullRequestDetectionService'; import { ISessionOptionGroupBuilder } from '../sessionOptionGroupBuilder'; import { ISessionRequestLifecycle } from '../sessionRequestLifecycle'; -vi.mock('../copilotCLIShim.ps1', () => ({ default: '# mock powershell script' })); beforeAll(() => { (vscodeShim as Record).chat = { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLINativeShim.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLINativeShim.spec.ts new file mode 100644 index 00000000000000..6a14a43d94852e --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLINativeShim.spec.ts @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TestLogService } from '../../../../platform/testing/common/testLogService'; +import { prepareCopilotCLINativeShim } from '../copilotCLINativeShim'; + +const firstBuild = Buffer.from('VSCODE_COPILOT_RUST_SHIM_V1:first-local-build'); +const secondBuild = Buffer.from('VSCODE_COPILOT_RUST_SHIM_V1:second-local-build'); + +class ShimLogService extends TestLogService { + override info = vi.fn<(message: string) => void>(); + override warn = vi.fn<(message: string) => void>(); +} + +describe('Copilot CLI native shim deployment', () => { + let root: string; + let globalStorage: string; + let storage: string; + let source: string; + let logger: ShimLogService; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(tmpdir(), 'copilot-native-shim-')); + globalStorage = path.join(root, 'global storage'); + storage = path.join(globalStorage, 'copilotCli'); + source = path.join(root, 'local build'); + logger = new ShimLogService(); + await fs.writeFile(source, firstBuild); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(root, { recursive: true, force: true }); + }); + + for (const platform of ['win32', 'linux', 'darwin'] as const) { + it(`publishes ${platform} shims without deleting global storage or unrelated files`, async () => { + await fs.mkdir(storage, { recursive: true }); + for (const filename of ['copilot', 'copilot.ps1', 'copilot.bat', 'copilot.cmd', 'copilotCLIShim.js', 'copilotCLIShim.ps1']) { + await fs.writeFile(path.join(storage, filename), 'legacy script'); + } + await fs.writeFile(path.join(storage, 'unrelated.txt'), 'keep'); + const filename = platform === 'win32' ? 'copilot.exe' : 'copilot'; + const unrelatedCopy = `.${filename}.previous-not-owned`; + await fs.writeFile(path.join(storage, unrelatedCopy), 'keep this too'); + const destination = path.join(storage, filename); + const result = await prepareCopilotCLINativeShim(globalStorage, logger, source, platform); + expect({ + result, + entries: (await fs.readdir(storage)).sort(), + binary: await fs.readFile(destination), + unrelated: await fs.readFile(path.join(storage, 'unrelated.txt'), 'utf8'), + }).toEqual({ + result: destination, + entries: [unrelatedCopy, filename, 'unrelated.txt'], + binary: firstBuild, + unrelated: 'keep', + }); + if (process.platform !== 'win32' && platform !== 'win32') { + expect((await fs.stat(destination)).mode & 0o777).toBe(0o750); + } + }); + } + + it('refreshes changed contents and does not republish identical builds', async () => { + const destination = await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'); + const rename = vi.spyOn(fs, 'rename'); + await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'); + expect(rename).not.toHaveBeenCalled(); + await fs.writeFile(source, secondBuild); + await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'); + expect({ + binary: await fs.readFile(destination!), + entries: await fs.readdir(storage), + }).toEqual({ binary: secondBuild, entries: ['copilot.exe'] }); + }); + + it('serializes concurrent publication in the same storage directory', async () => { + const results = await Promise.all(Array.from({ length: 3 }, () => + prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'))); + expect({ + results, + binary: await fs.readFile(path.join(storage, 'copilot.exe')), + entries: await fs.readdir(storage), + warnings: logger.warn.mock.calls, + }).toEqual({ + results: Array(3).fill(path.join(storage, 'copilot.exe')), + binary: firstBuild, + entries: ['copilot.exe'], + warnings: [], + }); + }); + + it('does not advertise an unset or invalid source', async () => { + await fs.writeFile(source, 'not the Rust shim'); + const results = [ + await prepareCopilotCLINativeShim(globalStorage, logger, '', 'win32'), + await prepareCopilotCLINativeShim(globalStorage, logger, 'relative-path', 'win32'), + await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'), + await prepareCopilotCLINativeShim(globalStorage, logger, path.join(root, 'missing'), 'win32'), + ]; + expect({ + results, + loggedUnconfigured: logger.info.mock.calls.some(([message]) => message.includes('No local Rust shim path')), + loggedFailure: logger.warn.mock.calls.some(([message]) => message.includes('Failed to prepare')), + }).toEqual({ results: [undefined, undefined, undefined, undefined], loggedUnconfigured: true, loggedFailure: true }); + }); + + it('keeps the previously published native copy if the source becomes unavailable', async () => { + const destination = await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'); + await fs.unlink(source); + expect({ + result: await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'), + binary: await fs.readFile(destination!), + loggedRetention: logger.warn.mock.calls.some(([message]) => message.includes('Keeping the previously published')), + }).toEqual({ result: destination, binary: firstBuild, loggedRetention: true }); + }); + + it('rolls back a failed Windows publication instead of losing the working copy', async () => { + const destination = await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'); + await fs.writeFile(source, secondBuild); + const rename = fs.rename; + vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => { + if (from.toString().includes('.new-')) { + throw Object.assign(new Error('injected publication failure'), { code: 'EIO' }); + } + return rename(from, to); + }); + expect({ + result: await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'), + binary: await fs.readFile(destination!), + entries: await fs.readdir(storage), + warned: logger.warn.mock.calls.length > 0, + }).toEqual({ result: destination, binary: firstBuild, entries: ['copilot.exe'], warned: true }); + }); + + it('retains locked old binaries and cleans them up on a later activation', async () => { + await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'); + await fs.writeFile(source, secondBuild); + const unlink = fs.unlink; + const lock = vi.spyOn(fs, 'unlink').mockImplementation(async filename => { + if (filename.toString().includes('.previous-')) { + throw Object.assign(new Error('running binary'), { code: 'EPERM' }); + } + return unlink(filename); + }); + const result = await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'); + const retained = (await fs.readdir(storage)).filter(name => name.includes('.previous-')); + lock.mockRestore(); + await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'); + expect({ + result, + retained: retained.length, + entries: await fs.readdir(storage), + binary: await fs.readFile(path.join(storage, 'copilot.exe')), + }).toEqual({ result: path.join(storage, 'copilot.exe'), retained: 1, entries: ['copilot.exe'], binary: secondBuild }); + }); + + it('does not advertise a directory with a surviving legacy wrapper', async () => { + await fs.mkdir(storage, { recursive: true }); + const legacy = path.join(storage, 'copilot.ps1'); + await fs.writeFile(legacy, 'legacy script'); + const rm = fs.rm; + vi.spyOn(fs, 'rm').mockImplementation(async (filename, options) => { + if (filename === legacy) { + throw Object.assign(new Error('locked legacy script'), { code: 'EACCES' }); + } + return rm(filename, options); + }); + expect({ + result: await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'), + legacy: await fs.readFile(legacy, 'utf8'), + binary: await fs.readFile(path.join(storage, 'copilot.exe')), + warned: logger.warn.mock.calls.some(([message]) => message.includes('legacy shim')), + }).toEqual({ result: undefined, legacy: 'legacy script', binary: firstBuild, warned: true }); + }); + + it('reports a stale lock without deleting a lock another host may own', async () => { + await fs.mkdir(storage, { recursive: true }); + await fs.writeFile(path.join(storage, '.publish.lock'), '1234\n'); + expect({ + result: await prepareCopilotCLINativeShim(globalStorage, logger, source, 'win32'), + lock: await fs.readFile(path.join(storage, '.publish.lock'), 'utf8'), + warned: logger.warn.mock.calls.some(([message]) => message.includes('stale lock')), + }).toEqual({ result: undefined, lock: '1234\n', warned: true }); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLITerminalIntegration.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLITerminalIntegration.spec.ts index 14787ac6857617..43697ba959eef2 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLITerminalIntegration.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLITerminalIntegration.spec.ts @@ -15,31 +15,28 @@ import { NullTelemetryService } from '../../../../platform/telemetry/common/null import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; import { ITerminalService, NullTerminalService } from '../../../../platform/terminal/common/terminalService'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; +import { Emitter } from '../../../../util/vs/base/common/event'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; - -// The .ps1 asset cannot be parsed by Vite's transform pipeline, -// so we need to tell Vite to treat .ps1 files as raw text via a mock -vi.mock('../copilotCLIShim.ps1', () => ({ default: '# mock powershell script' })); +import * as path from '../../../../util/vs/base/common/path'; // Mock fs operations to avoid real filesystem access during tests -const { mockMkdir, mockWriteFile, mockCopyFile, mockChmod, mockStat } = vi.hoisted(() => ({ - mockMkdir: vi.fn(async () => { }), - mockWriteFile: vi.fn(async () => { }), - mockCopyFile: vi.fn(async () => { }), - mockChmod: vi.fn(async () => { }), +const { mockRm, mockStat, mockPrepareShim } = vi.hoisted(() => ({ + mockRm: vi.fn(async () => { }), mockStat: vi.fn(async () => ({ isFile: () => true })), + mockPrepareShim: vi.fn<() => Promise>(), })); vi.mock('fs', () => ({ promises: { - mkdir: mockMkdir, - writeFile: mockWriteFile, - copyFile: mockCopyFile, - chmod: mockChmod, + rm: mockRm, stat: mockStat, } })); +vi.mock('../copilotCLINativeShim', () => ({ + prepareCopilotCLINativeShim: mockPrepareShim, +})); + // Mock Python terminal service to avoid extension dependency vi.mock('../copilotCLIPythonTerminalService', () => ({ PythonTerminalService: class { @@ -65,7 +62,17 @@ vi.mock('../../../../platform/workspace/common/workspaceService', () => ({ import type { IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { PythonTerminalService } from '../copilotCLIPythonTerminalService'; -import { CopilotCLITerminalIntegration } from '../copilotCLITerminalIntegration'; +import { CopilotCLITerminalIntegration, getNativeCopilotShimPath } from '../copilotCLITerminalIntegration'; + +const expectedShimPath = getNativeCopilotShimPath(process.platform, process.execPath, ''); +const storedShimPath = path.join('/tmp/test-global-storage', 'copilotCli', process.platform === 'win32' ? 'copilot.exe' : 'copilot'); + +/** + * Mirrors how the integration quotes a command for POSIX shells. + */ +function escapeForPosixShell(value: string): string { + return /[\s"'$`\\|&;()<>]/.test(value) ? `"${value.replace(/["\\]/g, '\\$&')}"` : value; +} interface MockTerminal extends Pick { show: Mock; @@ -78,6 +85,7 @@ class TestTerminalService extends NullTerminalService { public mockTerminal: MockTerminal; public createTerminalSpy: Mock; public contributePathSpy: Mock; + public removePathContributionSpy: Mock; constructor() { super(); @@ -89,6 +97,7 @@ class TestTerminalService extends NullTerminalService { }; this.createTerminalSpy = vi.fn().mockReturnValue(this.mockTerminal); this.contributePathSpy = vi.fn(); + this.removePathContributionSpy = vi.fn(); } override createTerminal(): Terminal { @@ -98,6 +107,36 @@ class TestTerminalService extends NullTerminalService { override contributePath(contributor: unknown, pathLocation: unknown, description?: unknown, prepend?: unknown): void { this.contributePathSpy(contributor, pathLocation, description, prepend); } + + override removePathContribution(contributor: string): void { + this.removePathContributionSpy(contributor); + } +} + +/** + * Provides `chat.copilotCliCommand.enabled`, which is unset unless a test sets it. + */ +class TestConfigurationService { + private readonly changeEmitter = new Emitter<{ affectsConfiguration(section: string): boolean }>(); + readonly onDidChangeConfiguration = this.changeEmitter.event; + private commandEnabled: boolean | undefined; + + getConfig() { + return true; + } + + getNonExtensionConfig(key: string): T | undefined { + return (key === 'chat.copilotCliCommand.enabled' ? this.commandEnabled : undefined) as T | undefined; + } + + setCommandEnabled(value: boolean | undefined): void { + this.commandEnabled = value; + this.changeEmitter.fire({ affectsConfiguration: section => section === 'chat.copilotCliCommand.enabled' }); + } + + dispose(): void { + this.changeEmitter.dispose(); + } } class TestEnvService { @@ -182,38 +221,42 @@ describe('CopilotCLITerminalIntegration', () => { let terminalService: TestTerminalService; let telemetryService: TestTelemetryService; let envService: TestEnvService; + let configurationService: TestConfigurationService; let integration: CopilotCLITerminalIntegration; let authService: MockAuthenticationService; + async function createIntegration(): Promise { + const result = new CopilotCLITerminalIntegration( + new TestExtensionContext() as unknown as IVSCodeExtensionContext, + authService as unknown as IAuthenticationService, + terminalService as unknown as ITerminalService, + envService as unknown as IEnvService, + { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService, + telemetryService as unknown as ITelemetryService, + configurationService as unknown as IConfigurationService, + { requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService, + new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), + ); + disposables.add(result); + await (result as any).initialization; + return result; + } + beforeEach(async () => { vi.clearAllMocks(); + mockPrepareShim.mockResolvedValue(storedShimPath); terminalService = disposables.add(new TestTerminalService()); telemetryService = new TestTelemetryService(); envService = new TestEnvService(); + configurationService = disposables.add(new TestConfigurationService()); authService = new MockAuthenticationService(); setupTerminalConfig('zsh', { zsh: { path: 'zsh' }, }); - integration = new CopilotCLITerminalIntegration( - new TestExtensionContext() as unknown as IVSCodeExtensionContext, - authService as unknown as IAuthenticationService, - terminalService as unknown as ITerminalService, - envService as unknown as IEnvService, - { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService, - telemetryService as unknown as ITelemetryService, - { getConfig: () => true } as unknown as IConfigurationService, - - { requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService, - - new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), - ); - disposables.add(integration); - - // Wait for initialization to complete - await (integration as any).initialization; + integration = await createIntegration(); }); afterEach(() => { @@ -280,7 +323,7 @@ describe('CopilotCLITerminalIntegration', () => { envService as unknown as IEnvService, { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService, telemetryService as unknown as ITelemetryService, - { getConfig: () => true } as unknown as IConfigurationService, + configurationService as unknown as IConfigurationService, { requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService, @@ -315,6 +358,7 @@ describe('CopilotCLITerminalIntegration', () => { expect(event).toBeDefined(); expect(event!.properties.terminalCreationMethod).toBe('pythonTerminal'); expect(event!.properties.shell).toBe('zsh'); + expect(mockPythonTerminal.sendText).toHaveBeenCalledWith(` clear && ${escapeForPosixShell(expectedShimPath)} && exit`); }); it('should use shellArgsTerminal method when python terminal is not available', async () => { @@ -325,17 +369,11 @@ describe('CopilotCLITerminalIntegration', () => { expect(event!.properties.terminalCreationMethod).toBe('shellArgsTerminal'); }); - it('should prepend --clear to cliArgs', async () => { + it('should pass the CLI args to the native shim without --clear', async () => { await integration.openTerminal('Test Terminal', ['--resume', 'sess-1']); - // For shellArgs terminal path, --clear gets removed before getShellInfo, - // but the final shell args should contain the original CLI args const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions; - const shellArgs = callArgs.shellArgs as string[]; - // Shell args should contain the cli args (--resume, sess-1) but not --clear - const joinedArgs = shellArgs.join(' '); - expect(joinedArgs).toContain('--resume'); - expect(joinedArgs).toContain('sess-1'); + expect(callArgs.shellArgs).toEqual(['-ci', `${escapeForPosixShell(expectedShimPath)} --resume sess-1`]); }); it('should use editor location by default', async () => { @@ -360,7 +398,7 @@ describe('CopilotCLITerminalIntegration', () => { { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService, telemetryService as unknown as ITelemetryService, - { getConfig: () => true } as unknown as IConfigurationService, + configurationService as unknown as IConfigurationService, { requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), ); @@ -373,18 +411,133 @@ describe('CopilotCLITerminalIntegration', () => { expect(event).toBeDefined(); expect(event!.properties.shell).toBe('bash'); }); + + it('should run the native shim through PowerShell with literal arguments', async () => { + setupTerminalConfig('PowerShell', { PowerShell: { path: 'pwsh' } }); + envService.shell = 'pwsh'; + const pwshIntegration = await createIntegration(); + + await pwshIntegration.openTerminal('PowerShell Terminal', ['--resume', 'it\'s 1']); + + const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions; + expect(callArgs.shellArgs).toEqual(['-Command', `& '${expectedShimPath}' '--resume' 'it''s 1'`]); + }); + + it.runIf(process.platform === 'win32')('should run the native shim through cmd', async () => { + setupTerminalConfig('Command Prompt', { 'Command Prompt': { path: 'cmd.exe' } }); + envService.shell = 'C:\\Windows\\System32\\cmd.exe'; + const cmdIntegration = await createIntegration(); + + await cmdIntegration.openTerminal('Cmd Terminal', ['--resume', 'sess-1']); + + const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions; + expect(callArgs.shellArgs).toEqual(['/c', expectedShimPath, '--resume', 'sess-1']); + }); + + it.runIf(process.platform === 'win32')('should launch a stored native shim with spaces through Git Bash', async () => { + envService.shell = 'C:\\Program Files\\Git\\bin\\bash.exe'; + mockStat.mockRejectedValueOnce(new Error('ENOENT')); + mockPrepareShim.mockResolvedValueOnce('C:\\global storage\\copilotCli\\copilot.exe'); + const bashIntegration = await createIntegration(); + await bashIntegration.openTerminal('Git Bash', ['--resume', 'session-1']); + const options = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions; + expect(options.shellArgs).toEqual(['-ic', '"/c/global storage/copilotCli/copilot.exe" --resume session-1']); + }); + + it('should launch a stored native shim with spaces through PowerShell', async () => { + envService.shell = 'pwsh'; + const storedPath = '/global storage/copilotCli/copilot'; + mockStat.mockRejectedValueOnce(new Error('ENOENT')); + mockPrepareShim.mockResolvedValueOnce(storedPath); + const powershellIntegration = await createIntegration(); + await powershellIntegration.openTerminal('PowerShell', ['--resume', 'session 1']); + const options = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions; + expect(options.shellArgs).toEqual(['-Command', `& '${storedPath}' '--resume' 'session 1'`]); + }); + + it('should run copilot from PATH when the native shim is missing', async () => { + mockStat.mockRejectedValueOnce(new Error('ENOENT')); + mockPrepareShim.mockResolvedValueOnce(undefined); + const pathIntegration = await createIntegration(); + + await pathIntegration.openTerminal('Path Terminal', ['--resume', 'sess-1']); + + const callArgs = terminalService.createTerminalSpy.mock.calls[0][0] as TerminalOptions; + expect(callArgs.shellArgs).toEqual(['-ci', 'copilot --resume sess-1']); + const event = telemetryService.events.find(e => e.name === 'copilotcli.terminal.open'); + expect(event!.properties.shim).toBe('path'); + }); }); describe('initialize', () => { - it('should contribute path to terminal service', async () => { + it('should append the native shim directory to the terminal PATH', async () => { expect(terminalService.contributePathSpy).toHaveBeenCalledWith( 'copilot-cli', - expect.stringContaining('copilotCli'), - expect.objectContaining({ command: 'copilot' }), - true, + path.dirname(expectedShimPath), + { command: 'copilot' }, + undefined, ); }); + it('should not contribute to the terminal PATH when the native shim is missing', async () => { + terminalService.contributePathSpy.mockClear(); + mockStat.mockRejectedValueOnce(new Error('ENOENT')); + mockPrepareShim.mockResolvedValueOnce(undefined); + await createIntegration(); + + expect(terminalService.contributePathSpy).not.toHaveBeenCalled(); + }); + + it('should follow chat.copilotCliCommand.enabled, which the CopilotCliCommand policy controls', async () => { + terminalService.contributePathSpy.mockClear(); + configurationService.setCommandEnabled(false); + await integration.openTerminal('Disabled Terminal', ['--resume', 'sess-1']); + configurationService.setCommandEnabled(true); + await integration.openTerminal('Enabled Terminal', ['--resume', 'sess-1']); + + expect({ + shellArgs: terminalService.createTerminalSpy.mock.calls.map(call => (call[0] as TerminalOptions).shellArgs), + removed: terminalService.removePathContributionSpy.mock.calls, + contributed: terminalService.contributePathSpy.mock.calls, + }).toEqual({ + shellArgs: [ + ['-ci', 'copilot --resume sess-1'], + ['-ci', `${escapeForPosixShell(expectedShimPath)} --resume sess-1`], + ], + removed: [['copilot-cli']], + contributed: [['copilot-cli', path.dirname(expectedShimPath), { command: 'copilot' }, undefined]], + }); + }); + + it('should use the shipped shim and remove the shims stored in global storage', async () => { + expect({ + checked: mockStat.mock.calls, + removed: mockRm.mock.calls, + prepared: mockPrepareShim.mock.calls.length, + }).toEqual({ + checked: [[expectedShimPath]], + removed: [[path.join('/tmp/test-global-storage', 'copilotCli'), { recursive: true, force: true }]], + prepared: 0, + }); + }); + + it('should use a local development build when this build does not ship the shim', async () => { + terminalService.contributePathSpy.mockClear(); + mockRm.mockClear(); + mockStat.mockRejectedValueOnce(new Error('ENOENT')); + await createIntegration(); + + expect({ + prepared: mockPrepareShim.mock.calls, + removed: mockRm.mock.calls, + contributed: terminalService.contributePathSpy.mock.calls.map(call => call.slice(0, 3)), + }).toEqual({ + prepared: [['/tmp/test-global-storage', expect.objectContaining({ warn: expect.any(Function) })]], + removed: [], + contributed: [['copilot-cli', path.dirname(storedShimPath), { command: 'copilot' }]], + }); + }); + it('should register a terminal profile provider', async () => { expect(mockRegisterTerminalProfileProvider).toHaveBeenCalledWith( 'copilot-cli', @@ -393,6 +546,20 @@ describe('CopilotCLITerminalIntegration', () => { }); }); + describe('getNativeCopilotShimPath', () => { + it('should resolve the shim next to the code command on each platform', () => { + expect({ + win32: getNativeCopilotShimPath('win32', 'C:\\Program Files\\Microsoft VS Code\\Code.exe', 'C:\\Program Files\\Microsoft VS Code\\1a2b3c4d5e\\resources\\app'), + darwin: getNativeCopilotShimPath('darwin', '/Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper (Plugin).app/Contents/MacOS/Code Helper (Plugin)', '/Applications/Visual Studio Code.app/Contents/Resources/app'), + linux: getNativeCopilotShimPath('linux', '/usr/share/code/code', '/usr/share/code/resources/app'), + }).toEqual({ + win32: 'C:\\Program Files\\Microsoft VS Code\\bin\\copilot-shim\\copilot.exe', + darwin: '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/copilot-shim/copilot', + linux: '/usr/share/code/bin/copilot-shim/copilot', + }); + }); + }); + describe('telemetry', () => { it('should include location in telemetry', async () => { await integration.openTerminal('Test', [], undefined, 'panel'); @@ -440,7 +607,7 @@ describe('CopilotCLITerminalIntegration', () => { { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), createSubLogger: () => ({}) } as unknown as ILogService, telemetryService as unknown as ITelemetryService, - { getConfig: () => true } as unknown as IConfigurationService, + configurationService as unknown as IConfigurationService, { requestResourceTrust: vi.fn().mockResolvedValue(true) } as unknown as IWorkspaceService, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), ); diff --git a/extensions/markdown-language-features/CONTRIBUTING.md b/extensions/markdown-language-features/CONTRIBUTING.md index 1b62674b50af1f..b144ff6cf80a3f 100644 --- a/extensions/markdown-language-features/CONTRIBUTING.md +++ b/extensions/markdown-language-features/CONTRIBUTING.md @@ -80,6 +80,22 @@ Build outputs are written to `out/` (desktop), `dist/` (web), and `notebook-out/ Markdown editor commands and their default keybindings are defined in `vscode-packages/vscode-team-tools/packages/markdown-editor/src/editorCommands.ts`. Do not manually edit entries marked with `"$generated": true` in this extension's `package.json` or their titles in `package.nls.json`: `npm run build-markdown-editor` and `npm run watch-markdown-editor` regenerate them while preserving manual entries. Run `npm run check-markdown-editor-package-json` to verify that the checked-in manifests are current without modifying them. +### Embedded code-block editor presentation + +`markdown.codeBlockEditorProviders` contributions and export-API resolve results +accept optional `presentation: { resizable, fullWidth, fullView, minHeight }`. +The Markdown editor owns these controls, including for static/uncooperative +pages. Resolve-result properties override contribution defaults; explicit +provider properties override guest-advertised preferences. Keep the contribution +schema, descriptor validation, RPC schema, equality checks, and static/export-API +paths in sync when extending this contract. + +Undefined resolve-result properties inherit the contribution defaults; explicit `false` still overrides them. The extension consumes the published `@vscode/markdown-editor` package, which implements the controls and mode notifications. + +The implementation and interaction fixtures live in the local Markdown editor +package. See its README's **Embedded editor presentation** section for the +optional mode-notification messages and sizing precedence. + ### Active block highlighting `markdown.editor.highlightActiveBlock` controls the rich editor's active-block diff --git a/extensions/markdown-language-features/README.md b/extensions/markdown-language-features/README.md index 531fc922202039..2052521da84b88 100644 --- a/extensions/markdown-language-features/README.md +++ b/extensions/markdown-language-features/README.md @@ -5,18 +5,3 @@ ## Features See [Markdown in Visual Studio Code](https://code.visualstudio.com/docs/languages/markdown) to learn about the features of this extension. - -### Markdown editor commit links - -Commit links such as `[commit](commit:1234567)` render the commit subject and -addition/deletion counts. Opening one shows the commit's changes in the multi-diff -editor. Without a repository hint, the Git extension searches open repositories. - -Use `[commit](commit:1234567?path=../../my-repo)` to select a repository relative to -the Markdown file, including repositories that are not open yet. Absolute file -URIs are also supported. URL-encode paths containing `?`, `#`, or `&`. A hinted -repository must contain the commit; an invalid hint does not fall back to another -repository. Opening a new repository requires a trusted workspace. - -Forge links such as `https://github.com/owner/repo/commit/1234567?path=../repo` -also accept this hint, but the local repository's remote must match the URL. diff --git a/extensions/markdown-language-features/markdown-editor-src/clipboardMedia.ts b/extensions/markdown-language-features/markdown-editor-src/clipboardMedia.ts new file mode 100644 index 00000000000000..04e6acbc040c85 --- /dev/null +++ b/extensions/markdown-language-features/markdown-editor-src/clipboardMedia.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { NativeClipboardData } from '@vscode/markdown-editor'; + +export const clipboardFormatsUnavailableMessage = 'The clipboard formats are not available to this editor. Try copying the media itself instead of its file.'; + +export function getNativeClipboardMedia(data: NativeClipboardData, plainText: boolean): { readonly text: string; readonly images: readonly File[] } { + const images = plainText ? [] : data.files.filter(file => /^(image|video|audio)\//.test(file.type)); + if (!plainText && data.files.length && !images.length && !data.text) { + throw new Error(clipboardFormatsUnavailableMessage); + } + return { text: data.text, images }; +} diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index d576b42dbda780..9efe3f744dee09 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -123,6 +123,7 @@ class Editor extends Disposable { #rename: RenameController | undefined; #completion: CompletionController | undefined; #diagnostics: DiagnosticsController | undefined; + #imagePaste: ImagePasteController | undefined; #view: EditorView | undefined; #embeddedCodeEditorFactory: LazyCodeBlockEditorFactory | undefined; /** Identifies the authoritative text baseline against which local edits are computed. */ @@ -182,6 +183,10 @@ class Editor extends Disposable { this.model.readonlyMode.set(initialState.readonly, undefined); this._register(this.#connection.register(markdownEditorRenderer, { + readClipboardMedia: (request, _context, { signal }) => { + if (!this.#imagePaste) { throw new Error('The Markdown editor is not ready to paste media.'); } + return this.#imagePaste.readClipboardMedia(request, signal); + }, diagnosticsChanged: () => this.#diagnostics?.refresh(), update: ({ content, editEpoch }) => { // Applying authoritative text maps selection and clears stale pending-paragraph state. @@ -447,7 +452,7 @@ class Editor extends Disposable { // the TextDocument owns the history, and a second local stack would drift // from the Edit menu, dirty state and hot exit. this.#controller = this._register(new EditorController(model, view, { - clipboardStrategy: this._register(new ImagePasteController(model, view, this.#host, () => this.#editEpoch)), + clipboardStrategy: this.#imagePaste = this._register(new ImagePasteController(model, view, this.#host, () => this.#editEpoch)), keyboardProfile: vscodeLocalKeyboardProfile, forwardedKeyboardProfile: vscodeHostKeyboardProfile, historyStrategy: { diff --git a/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts b/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts index 9cad5a79864e1d..36efb569e82335 100644 --- a/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts +++ b/extensions/markdown-language-features/markdown-editor-src/imagePasteController.ts @@ -3,9 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AsyncClipboardStrategy, EditorModel, EditorView, Selection, readNativeClipboard, type IClipboardContext, type IClipboardStrategy } from '@vscode/markdown-editor'; +import { AsyncClipboardStrategy, EditorModel, EditorView, Selection, readNativeClipboard, type IClipboardContext, type IClipboardStrategy, type NativeClipboardData } from '@vscode/markdown-editor'; import { Disposable } from '@vscode/observables'; -import type { MarkdownEditorHost } from '../src/preview/markdownEditorProtocol'; +import type { MarkdownEditorHost, MarkdownEditorRenderer } from '../src/preview/markdownEditorProtocol'; +import { ClipboardMediaStore } from '../src/preview/markdownEditorMediaTransfer'; +import { clipboardFormatsUnavailableMessage, getNativeClipboardMedia } from './clipboardMedia'; export class ImagePasteController extends Disposable implements IClipboardStrategy { readonly #strategy: AsyncClipboardStrategy; @@ -13,6 +15,7 @@ export class ImagePasteController extends Disposable implements IClipboardStrate #disposed = false; #request = 0; #interaction = 0; + #media: ClipboardMediaStore | undefined; constructor( readonly editor: EditorModel, @@ -22,27 +25,28 @@ export class ImagePasteController extends Disposable implements IClipboardStrate readonly clipboard: Clipboard = navigator.clipboard, ) { super(); - this.#strategy = new AsyncClipboardStrategy(clipboard, (context, plainText) => this.#paste(context, async () => { - const native = readNativeClipboard(context.element); + this.#strategy = new AsyncClipboardStrategy(clipboard, (context, plainText, nativeData?: NativeClipboardData) => this.#paste(context, async () => { + const native = nativeData ?? readNativeClipboard(context.element); if (native) { - return { text: native.text, images: plainText ? [] : native.files.filter(file => file.type.startsWith('image/')) }; + return getNativeClipboardMedia(native, plainText); } if (plainText || !clipboard.read) { return { text: await clipboard.readText(), images: [] }; } const items = await clipboard.read(); const images: File[] = []; let text = ''; for (const item of items) { - const type = item.types.find(type => type === 'image/png') ?? item.types.find(type => type.startsWith('image/')); + const type = item.types.find(type => type === 'image/png') ?? item.types.find(type => /^(image|video|audio)\//.test(type)); if (type) { const blob = await item.getType(type); - const extension = type.slice('image/'.length).split('+')[0]; - images.push(new File([blob], `image.${extension}`, { type })); + const [kind, subtype] = type.split('/'); + const extension = subtype.split('+')[0]; + images.push(new File([blob], `${kind}.${extension}`, { type })); } else if (item.types.includes('text/plain')) { text += await (await item.getType('text/plain')).text(); } } if (items.length && !images.length && !text && items.every(item => !item.types.includes('text/plain'))) { - throw new Error('The clipboard formats are not available to this editor. Try copying the image itself instead of its file.'); + throw new Error(clipboardFormatsUnavailableMessage); } return { text, images }; }, plainText)); @@ -54,32 +58,31 @@ export class ImagePasteController extends Disposable implements IClipboardStrate writeText(text: string): Promise { return this.#strategy.writeText(text); } + readClipboardMedia(request: Parameters[0], signal: AbortSignal): ReturnType { + if (!this.#media || this.#disposed) { + throw new Error('The clipboard media paste has expired.'); + } + return this.#media.read(request, signal); + } + connect(context: IClipboardContext): { dispose(): void } { const interact = (): void => { this.#interaction++; }; context.element.addEventListener('pointerdown', interact); context.element.addEventListener('keydown', interact); const connection = this.#strategy.connect(context); - const paste = (event: ClipboardEvent): void => { - if (event.target !== context.element || !event.clipboardData) { return; } - const images = [...event.clipboardData.files].filter(file => file.type.startsWith('image/')); - const text = event.clipboardData.getData('text/plain'); - event.preventDefault(); - event.stopPropagation(); - void this.#paste(context, async () => ({ images, text })); - }; - context.element.addEventListener('paste', paste); return { dispose: () => { connection.dispose(); context.element.removeEventListener('pointerdown', interact); context.element.removeEventListener('keydown', interact); - context.element.removeEventListener('paste', paste); } }; } async #paste(context: IClipboardContext, read: () => Promise<{ readonly images: readonly File[]; readonly text: string }>, plainText = false): Promise { const request = ++this.#request; + this.#media?.dispose(); + this.#media = undefined; const source = this.editor.sourceText.get(); const selection = this.editor.selection.get(); const epoch = this.epoch(); @@ -88,33 +91,24 @@ export class ImagePasteController extends Disposable implements IClipboardStrate this.#showError('Switch to editing mode and place the caret before pasting.'); return; } + let media: ClipboardMediaStore | undefined; try { const { images, text } = await read(); - const payload = await Promise.all(images.map(async image => ({ - name: image.name, - mime: image.type, - base64: await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result).split(',')[1]); - reader.onerror = () => reject(reader.error ?? new Error('Could not read clipboard image.')); - reader.onabort = () => reject(new Error('Clipboard image reading was cancelled.')); - reader.readAsDataURL(image); - }), - }))); if (this.#disposed || request !== this.#request) { return; } if (source !== this.editor.sourceText.get() || selection !== this.editor.selection.get() || epoch !== this.epoch() || this.editor.readonlyMode.get() || document.activeElement !== context.element) { throw new Error('The editor changed while reading the clipboard. Paste again.'); } this.#status.hidden = true; - if (!payload.length) { + if (!images.length) { if (text) { context.insertText(text, plainText); } return; } - this.#status.textContent = 'Pasting image...'; + this.#status.textContent = 'Pasting media...'; this.#status.hidden = false; + media = this.#media = new ClipboardMediaStore(images); const result = await this.host.pasteImages({ - start: selection.range.start, endExclusive: selection.range.endExclusive, editEpoch: epoch, images: payload, + start: selection.range.start, endExclusive: selection.range.endExclusive, editEpoch: epoch, images: [...media.images], }); if (this.#disposed || request !== this.#request) { return; } this.#status.hidden = true; @@ -126,6 +120,9 @@ export class ImagePasteController extends Disposable implements IClipboardStrate if (!this.#disposed && request === this.#request) { this.#showError(`Could not paste: ${error instanceof Error ? error.message : String(error)}`); } + } finally { + media?.dispose(); + if (this.#media === media) { this.#media = undefined; } } } @@ -136,6 +133,8 @@ export class ImagePasteController extends Disposable implements IClipboardStrate override dispose(): void { this.#disposed = true; + this.#media?.dispose(); + this.#media = undefined; super.dispose(); } } diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index f4afd9cec5e07f..4928ea624983e4 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "0.0.2-121", + "@vscode/markdown-editor": "0.0.2-130", "@vscode/observables": "^0.1.1-0", "@vscode/web-editors": "^0.0.2-41", "dompurify": "^3.4.10", @@ -670,9 +670,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-121", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-121.tgz", - "integrity": "sha512-PkBZf2cGqutKE+t1UThpoRSomyvqHwsiXiCtoG+0/YDTbH6n4WulkIq8fE8jNmyJQtSHJihwJaIwVaeMBOmMXQ==", + "version": "0.0.2-130", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-130.tgz", + "integrity": "sha512-pqDLMtjxLSfT38Olu0UDnMPMTTF8ZSotjIMXjj8ESMJ0qmc5I/QGvbcjwMEwA4afJP1TG0gfBqo8DNUuKNgBlw==", "license": "MIT", "dependencies": { "@vscode/codicons": "0.0.46-36", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 78b09f67720b97..19312715806cd2 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -1982,6 +1982,7 @@ "update-markdown-editor-package-json": "node ./scripts/updateMarkdownEditorPackageJson.mts --write", "check-markdown-editor-package-json": "node ./scripts/updateMarkdownEditorPackageJson.mts --check", "test-markdown-editor-package-json": "node --test ./scripts/updateMarkdownEditorPackageJson.test.mts", + "test-markdown-editor-clipboard": "node --test ./scripts/clipboardMedia.test.mts ./scripts/markdownEditorMediaTransfer.test.mts", "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", "bundle-web": "node ./esbuild.browser.mts", "typecheck-web": "node ../../node_modules/@typescript/native/lib/tsc.js --project ./tsconfig.browser.json --noEmit", @@ -1991,7 +1992,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "0.0.2-121", + "@vscode/markdown-editor": "0.0.2-130", "@vscode/observables": "^0.1.1-0", "@vscode/web-editors": "^0.0.2-41", "dompurify": "^3.4.10", diff --git a/extensions/markdown-language-features/schemas/package.schema.json b/extensions/markdown-language-features/schemas/package.schema.json index dc8f89cb2bb577..a3f10c689d40f5 100644 --- a/extensions/markdown-language-features/schemas/package.schema.json +++ b/extensions/markdown-language-features/schemas/package.schema.json @@ -178,6 +178,17 @@ "exclusiveMinimum": 0, "description": "Initial placeholder height before the editor reports its measured height" }, + "presentation": { + "type": "object", + "additionalProperties": false, + "description": "Host-owned Markdown editor presentation controls. Supported even when the embedded page does not implement the editor protocol.", + "properties": { + "resizable": { "type": "boolean", "description": "Allow users to resize the editor height." }, + "fullWidth": { "type": "boolean", "description": "Offer a toggle to use the available document width." }, + "fullView": { "type": "boolean", "description": "Offer a toggle to fill the Markdown editor viewport." }, + "minHeight": { "type": "number", "exclusiveMinimum": 0, "maximum": 10000, "description": "Minimum inline content height in CSS pixels." } + } + }, "sandbox": { "type": "object", "additionalProperties": false, diff --git a/extensions/markdown-language-features/scripts/clipboardMedia.test.mts b/extensions/markdown-language-features/scripts/clipboardMedia.test.mts new file mode 100644 index 00000000000000..20ba25d95a2c4c --- /dev/null +++ b/extensions/markdown-language-features/scripts/clipboardMedia.test.mts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getNativeClipboardMedia } from '../markdown-editor-src/clipboardMedia.ts'; + +test('preserves native image, video, and audio file identity and bytes', async () => { + const files = [ + new File([new Uint8Array([1, 2, 3])], 'image.png', { type: 'image/png' }), + new File([new Uint8Array([4, 5, 6])], 'clip.mp4', { type: 'video/mp4' }), + new File([new Uint8Array([7, 8, 9])], 'sound.mp3', { type: 'audio/mpeg' }), + ]; + const result = getNativeClipboardMedia({ text: 'fallback', files }, false); + assert.deepStrictEqual({ + text: result.text, + files: await Promise.all(result.images.map(async file => ({ name: file.name, type: file.type, bytes: [...new Uint8Array(await file.arrayBuffer())] }))), + }, { + text: 'fallback', + files: [ + { name: 'image.png', type: 'image/png', bytes: [1, 2, 3] }, + { name: 'clip.mp4', type: 'video/mp4', bytes: [4, 5, 6] }, + { name: 'sound.mp3', type: 'audio/mpeg', bytes: [7, 8, 9] }, + ], + }); + assert.ok(result.images.every((file, index) => file === files[index])); +}); + +test('reports unsupported and untyped native file-only payloads explicitly', () => { + for (const type of ['application/octet-stream', '']) { + assert.throws(() => getNativeClipboardMedia({ text: '', files: [new File(['data'], 'file.bin', { type })] }, false), /clipboard formats are not available/); + } +}); + +test('preserves plain-text fallback, empty clipboard, and explicit plain-text paste', () => { + const files = [new File(['data'], 'file.bin')]; + assert.deepStrictEqual([ + getNativeClipboardMedia({ text: 'fallback', files }, false), + getNativeClipboardMedia({ text: '', files }, true), + getNativeClipboardMedia({ text: '', files: [] }, false), + ], [ + { text: 'fallback', images: [] }, + { text: '', images: [] }, + { text: '', images: [] }, + ]); +}); diff --git a/extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts b/extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts new file mode 100644 index 00000000000000..a86e5b363765b3 --- /dev/null +++ b/extensions/markdown-language-features/scripts/codeBlockEditorPresentation.test.mts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { codeBlockEditorPresentation, mergeCodeBlockEditorPresentation } from '../src/preview/markdownEditorProtocol.ts'; + +test('presentation RPC schema preserves explicit false and optional preferences', () => { + const options = { resizable: true, fullWidth: false, fullView: true, minHeight: 120 }; + assert.deepEqual(codeBlockEditorPresentation.parse(options), options); + assert.deepEqual(codeBlockEditorPresentation.parse({}), {}); +}); + +test('presentation RPC schema rejects invalid preferences and heights', () => { + for (const value of [null, { fullView: 'true' }, { minHeight: 0 }, { minHeight: -1 }, { minHeight: Infinity }, { minHeight: NaN }, { minHeight: 10_001 }]) { + assert.equal(codeBlockEditorPresentation.safeParse(value).success, false); + } +}); + +test('undefined resolved preferences inherit defaults while explicit false overrides them', () => { + const contributed = { resizable: true, fullWidth: false, fullView: true, minHeight: 160 }; + assert.deepEqual(mergeCodeBlockEditorPresentation(contributed, { + resizable: false, + fullWidth: undefined, + fullView: false, + minHeight: undefined, + }), { resizable: false, fullWidth: false, fullView: false, minHeight: 160 }); + assert.deepEqual(mergeCodeBlockEditorPresentation(contributed, { + resizable: undefined, + fullWidth: true, + fullView: undefined, + minHeight: 240, + }), { resizable: true, fullWidth: true, fullView: true, minHeight: 240 }); +}); + +test('missing presentation preference objects preserve the available preferences', () => { + const preferences = { fullView: false, minHeight: 160 }; + assert.deepEqual({ + neither: mergeCodeBlockEditorPresentation(undefined, undefined), + contributed: mergeCodeBlockEditorPresentation(preferences, undefined), + resolved: mergeCodeBlockEditorPresentation(undefined, preferences), + empty: mergeCodeBlockEditorPresentation({}, {}), + }, { + neither: undefined, + contributed: preferences, + resolved: preferences, + empty: { resizable: undefined, fullWidth: undefined, fullView: undefined, minHeight: undefined }, + }); +}); diff --git a/extensions/markdown-language-features/scripts/markdownEditorMediaTransfer.test.mts b/extensions/markdown-language-features/scripts/markdownEditorMediaTransfer.test.mts new file mode 100644 index 00000000000000..2715fd802880bd --- /dev/null +++ b/extensions/markdown-language-features/scripts/markdownEditorMediaTransfer.test.mts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { ClipboardMediaStore, clipboardMediaChunkSize, readClipboardMediaContents } from '../src/preview/markdownEditorMediaTransfer.ts'; + +test('retains metadata without reading files and transfers exact bytes in bounded sequential chunks', async () => { + const bytes = Uint8Array.from({ length: clipboardMediaChunkSize * 2 + 37 }, (_, index) => index % 256); + const slices: [number | undefined, number | undefined][] = []; + class TrackedFile extends File { + override slice(start?: number, end?: number): Blob { + slices.push([start, end]); + return super.slice(start, end); + } + override arrayBuffer(): Promise { + throw new Error('Must not read the whole clipboard file'); + } + } + const store = new ClipboardMediaStore([new TrackedFile([bytes], 'clip.mp4', { type: 'video/mp4' })]); + const controller = new AbortController(); + try { + assert.deepStrictEqual({ images: store.images.map(({ id, ...image }) => ({ ...image, hasId: !!id })), slices }, { + images: [{ name: 'clip.mp4', mime: 'video/mp4', size: bytes.length, hasId: true }], slices: [], + }); + let active = 0; + let peak = 0; + const result = await readClipboardMediaContents(store.images[0], async request => { + peak = Math.max(peak, ++active); + const chunk = await store.read(request, controller.signal); + assert.ok(chunk.base64.length <= 4 * Math.ceil(clipboardMediaChunkSize / 3)); + active--; + return chunk; + }, () => { }); + assert.deepStrictEqual({ bytes: result, slices, peak }, { + bytes, + slices: [[0, clipboardMediaChunkSize], [clipboardMediaChunkSize, clipboardMediaChunkSize * 2], [clipboardMediaChunkSize * 2, bytes.length]], + peak: 1, + }); + } finally { store.dispose(); } +}); + +test('rejects oversized, invalid, unknown and expired chunk requests', async () => { + const store = new ClipboardMediaStore([new File(['abcd'], 'image.png', { type: 'image/png' })]); + const signal = new AbortController().signal; + const request = { id: store.images[0].id, offset: 0, length: 4 }; + try { + for (const invalid of [ + { length: clipboardMediaChunkSize + 1 }, { offset: -1 }, { offset: 0.5 }, + { length: 0 }, { offset: 1 }, { id: 'unknown' }, + ]) { + await assert.rejects(store.read({ ...request, ...invalid }, signal), /Invalid or expired/); + } + store.dispose(); + await assert.rejects(store.read(request, signal), /Invalid or expired/); + } finally { store.dispose(); } +}); + +test('transfers a file larger than 25 MiB without a whole-file base64 payload or a file-size cap', async () => { + const bytes = new Uint8Array(25 * 1024 * 1024 + 1); + bytes[0] = 19; + bytes[bytes.length - 1] = 71; + const store = new ClipboardMediaStore([new File([bytes], 'large.mp4', { type: 'video/mp4' })]); + const signal = new AbortController().signal; + let calls = 0; + try { + const actual = await readClipboardMediaContents(store.images[0], request => { + calls++; + assert.ok(request.length <= clipboardMediaChunkSize); + return store.read(request, signal); + }, () => { }); + assert.deepStrictEqual(actual, bytes); + assert.strictEqual(calls, 101); + } finally { store.dispose(); } +}); + +test('stops after cancellation or a stale edit without requesting the next chunk', async () => { + for (const cancel of [true, false]) { + const store = new ClipboardMediaStore([new File([new Uint8Array(clipboardMediaChunkSize + 1)], 'clip.mp4', { type: 'video/mp4' })]); + const controller = new AbortController(); + let calls = 0; + let stale = false; + try { + await assert.rejects(readClipboardMediaContents(store.images[0], async request => { + calls++; + const result = await store.read(request, controller.signal); + if (cancel) { controller.abort(); } else { stale = true; } + return result; + }, () => { + controller.signal.throwIfAborted(); + if (stale) { throw new Error('stale'); } + })); + assert.strictEqual(calls, 1); + } finally { store.dispose(); } + } +}); + +test('propagates read errors and rejects malformed or truncated responses', async () => { + const file = { id: 'file', name: 'image.png', mime: 'image/png', size: 4 }; + await assert.rejects(readClipboardMediaContents(file, async () => { throw new Error('read failed'); }, () => { }), /read failed/); + for (const base64 of ['!', 'AQID', 'AQIDBAU=', '!!!!!!!!']) { + await assert.rejects(readClipboardMediaContents(file, async () => ({ base64 }), () => { }), /clipboard media chunk/); + } +}); + +test('rejects disposal and cancellation while a chunk read is pending', async () => { + for (const cancel of [true, false]) { + const entered = Promise.withResolvers(); + const finish = Promise.withResolvers(); + class DelayedFile extends File { + override slice(): Blob { + const blob = super.slice(); + blob.arrayBuffer = () => { entered.resolve(); return finish.promise; }; + return blob; + } + } + const store = new ClipboardMediaStore([new DelayedFile(['abcd'], 'image.png', { type: 'image/png' })]); + const controller = new AbortController(); + try { + const result = store.read({ id: store.images[0].id, offset: 0, length: 4 }, controller.signal); + await entered.promise; + if (cancel) { controller.abort(); } else { store.dispose(); } + finish.resolve(new ArrayBuffer(4)); + await assert.rejects(result); + } finally { store.dispose(); } + } +}); diff --git a/extensions/markdown-language-features/src/markdownExtensions.ts b/extensions/markdown-language-features/src/markdownExtensions.ts index d24b9108a042fe..a4f74719b22301 100644 --- a/extensions/markdown-language-features/src/markdownExtensions.ts +++ b/extensions/markdown-language-features/src/markdownExtensions.ts @@ -23,6 +23,13 @@ function* resolveExtensionResources(extension: vscode.Extension, resourcePa } } +import { codeBlockEditorPresentation, type ResolvedCodeBlockEditor } from './preview/markdownEditorProtocol'; + +export function codeBlockEditorPresentationEqual(a: ResolvedCodeBlockEditor['presentation'], b: ResolvedCodeBlockEditor['presentation']): boolean { + return a?.resizable === b?.resizable && a?.fullWidth === b?.fullWidth + && a?.fullView === b?.fullView && a?.minHeight === b?.minHeight; +} + export interface MarkdownPreviewScript { readonly resource: vscode.Uri; readonly type?: 'module'; @@ -53,6 +60,7 @@ export interface MarkdownCodeBlockEditorProvider { readonly source: MarkdownCodeBlockEditorSource; readonly contentType: 'text' | 'json'; readonly initialHeight?: number; + readonly presentation?: ResolvedCodeBlockEditor['presentation']; readonly sandbox?: MarkdownCodeBlockEditorSandbox; } @@ -106,6 +114,7 @@ export namespace MarkdownContributions { && sourceEqual(x.source, y.source) && x.contentType === y.contentType && x.initialHeight === y.initialHeight + && codeBlockEditorPresentationEqual(x.presentation, y.presentation) && sandboxEqual(x.sandbox, y.sandbox)); } @@ -184,6 +193,7 @@ export namespace MarkdownContributions { || (provider.runtimeKey !== undefined && (typeof provider.runtimeKey !== 'string' || provider.runtimeKey.length === 0 || provider.runtimeKey.length > 256)) || (provider.contentType !== undefined && provider.contentType !== 'text' && provider.contentType !== 'json') || (provider.initialHeight !== undefined && !isPositiveNumber(provider.initialHeight)) + || (provider.presentation !== undefined && !codeBlockEditorPresentation.safeParse(provider.presentation).success) ) { continue; } @@ -197,6 +207,7 @@ export namespace MarkdownContributions { source, contentType: provider.contentType ?? 'text', initialHeight: provider.initialHeight as number | undefined, + presentation: provider.presentation === undefined ? undefined : codeBlockEditorPresentation.parse(provider.presentation), sandbox: readSandbox(provider.sandbox), }; } diff --git a/extensions/markdown-language-features/src/preview/markdownEditorImagePaste.ts b/extensions/markdown-language-features/src/preview/markdownEditorImagePaste.ts index 59a07d101588fa..f68bc814ba91aa 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorImagePaste.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorImagePaste.ts @@ -6,13 +6,15 @@ import * as vscode from 'vscode'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { CopyFilesSettings, ResourcePasteOrDropProvider } from '../languageFeatures/copyFiles/dropOrPasteResource'; -import type { MarkdownEditorHost } from './markdownEditorProtocol'; +import type { MarkdownEditorHost, MarkdownEditorRenderer } from './markdownEditorProtocol'; +import { readClipboardMediaContents } from './markdownEditorMediaTransfer'; export class MarkdownEditorImagePaste implements vscode.Disposable { #pending: vscode.CancellationTokenSource | undefined; readonly #drain: () => Promise; readonly #snapshot: () => { readonly text: string; readonly editEpoch: number }; readonly #isEditable: () => boolean; + readonly #readClipboardMedia: MarkdownEditorRenderer['readClipboardMedia']; readonly #apply: typeof vscode.workspace.applyEdit; constructor( @@ -20,11 +22,13 @@ export class MarkdownEditorImagePaste implements vscode.Disposable { drain: () => Promise, snapshot: () => { readonly text: string; readonly editEpoch: number }, isEditable: () => boolean, + readClipboardMedia: MarkdownEditorRenderer['readClipboardMedia'], apply: typeof vscode.workspace.applyEdit = edit => vscode.workspace.applyEdit(edit), ) { this.#drain = drain; this.#snapshot = snapshot; this.#isEditable = isEditable; + this.#readClipboardMedia = readClipboardMedia; this.#apply = apply; } @@ -38,34 +42,38 @@ export class MarkdownEditorImagePaste implements vscode.Disposable { const checkCurrent = (): void => { if (pending.token.isCancellationRequested) { throw new vscode.CancellationError(); } if (!this.#isEditable() || this.document.isClosed || version !== this.document.version || request.editEpoch !== this.#snapshot().editEpoch) { - throw new Error(vscode.l10n.t('The editor changed. Paste the image again.')); + throw new Error(vscode.l10n.t('The editor changed. Paste the media again.')); } }; checkCurrent(); if (!Number.isSafeInteger(request.start) || !Number.isSafeInteger(request.endExclusive) || request.start < 0 || request.start > request.endExclusive || request.endExclusive > snapshot.text.length || !request.images.length) { - throw new Error(vscode.l10n.t('Invalid image paste range or empty clipboard image list.')); + throw new Error(vscode.l10n.t('Invalid media paste range or empty clipboard media list.')); } if (this.document.isUntitled) { - throw new Error(vscode.l10n.t('Save the Markdown document before pasting an image.')); + throw new Error(vscode.l10n.t('Save the Markdown document before pasting media.')); } const config = vscode.workspace.getConfiguration('markdown', this.document); if (vscode.workspace.getConfiguration('editor', this.document).get('pasteAs.enabled') === false) { - throw new Error(vscode.l10n.t('Image paste is disabled by editor.pasteAs.enabled.')); + throw new Error(vscode.l10n.t('Media paste is disabled by editor.pasteAs.enabled.')); } const enabled = config.get('editor.paste.enabled', true); if (enabled === false || enabled === 'never') { - throw new Error(vscode.l10n.t('Markdown image paste is disabled by markdown.editor.paste.enabled.')); + throw new Error(vscode.l10n.t('Markdown media paste is disabled by markdown.editor.paste.enabled.')); } const transfer = new vscode.DataTransfer(); + const ids = new Set(); request.images.forEach((image, index) => { - if (!/^image\/[\w.+-]+$/.test(image.mime) || !image.name || /[\\/:]/.test(image.name) || image.name === '.' || image.name === '..' - || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(image.base64) || !image.base64) { - throw new Error(vscode.l10n.t('Invalid clipboard image.')); + if (!/^(image|video|audio)\/[\w.+-]+$/.test(image.mime) || !image.name || /[\\/:]/.test(image.name) || image.name === '.' || image.name === '..' + || !image.id || ids.has(image.id) || !Number.isSafeInteger(image.size) || image.size <= 0) { + throw new Error(vscode.l10n.t('Invalid clipboard media.')); } - const contents = Uint8Array.from(atob(image.base64), char => char.charCodeAt(0)); + ids.add(image.id); const item = new vscode.DataTransferItem(''); - item.asFile = () => ({ name: image.name, uri: undefined, data: async () => contents }); + item.asFile = () => ({ + name: image.name, uri: undefined, + data: () => readClipboardMediaContents(image, chunk => this.#readClipboardMedia(chunk), checkCurrent), + }); transfer.set(`${image.mime};index=${index}`, item); }); const edit = await ResourcePasteOrDropProvider.createEditForMediaFiles(this.document, transfer, @@ -73,7 +81,7 @@ export class MarkdownEditorImagePaste implements vscode.Disposable { await this.#drain(); checkCurrent(); if (!edit?.additionalEdits) { - throw new Error(vscode.l10n.t('Image paste requires markdown.editor.paste.copyIntoWorkspace to be mediaFiles.')); + throw new Error(vscode.l10n.t('Media paste requires markdown.editor.paste.copyIntoWorkspace to be mediaFiles.')); } const mirror = TextDocument.create(this.document.uri.toString(), 'markdown', 0, snapshot.text); const start = mirror.positionAt(request.start); @@ -84,7 +92,7 @@ export class MarkdownEditorImagePaste implements vscode.Disposable { // Workspace edits resolve snippet defaults without starting a snippet session in a custom editor. edit.additionalEdits.set(this.document.uri, [vscode.SnippetTextEdit.replace(range, edit.snippet)]); if (!await this.#apply(edit.additionalEdits)) { - throw new Error(vscode.l10n.t('The workspace rejected the image paste.')); + throw new Error(vscode.l10n.t('The workspace rejected the media paste.')); } const current = this.#snapshot(); const newMirror = TextDocument.create(this.document.uri.toString(), 'markdown', 0, current.text); diff --git a/extensions/markdown-language-features/src/preview/markdownEditorMediaTransfer.ts b/extensions/markdown-language-features/src/preview/markdownEditorMediaTransfer.ts new file mode 100644 index 00000000000000..f70b022503463e --- /dev/null +++ b/extensions/markdown-language-features/src/preview/markdownEditorMediaTransfer.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { MarkdownEditorHost, MarkdownEditorRenderer } from './markdownEditorProtocol'; + +export const clipboardMediaChunkSize = 256 * 1024; + +type ClipboardMedia = Parameters[0]['images'][number]; +type ChunkRequest = Parameters[0]; +type ChunkResult = Awaited>; + +export class ClipboardMediaStore { + readonly #files = new Map(); + readonly images: readonly ClipboardMedia[]; + + constructor(files: readonly File[]) { + this.images = files.map(file => { + const id = crypto.randomUUID(); + this.#files.set(id, file); + return { id, name: file.name, mime: file.type, size: file.size }; + }); + } + + async read(request: ChunkRequest, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const file = this.#files.get(request.id); + if (!file || !Number.isSafeInteger(request.offset) || !Number.isSafeInteger(request.length) + || request.offset < 0 || request.length <= 0 || request.length > clipboardMediaChunkSize + || request.offset > file.size - request.length) { + throw new Error('Invalid or expired clipboard media chunk request.'); + } + const bytes = new Uint8Array(await file.slice(request.offset, request.offset + request.length).arrayBuffer()); + signal.throwIfAborted(); + if (this.#files.get(request.id) !== file) { + throw new Error('The clipboard media paste has expired.'); + } + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += 8192) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + } + return { base64: btoa(binary) }; + } + + dispose(): void { + this.#files.clear(); + } +} + +export async function readClipboardMediaContents( + file: ClipboardMedia, + read: (request: ChunkRequest) => Promise, + checkCurrent: () => void, +): Promise { + checkCurrent(); + const contents = new Uint8Array(file.size); + for (let offset = 0; offset < contents.length; offset += clipboardMediaChunkSize) { + checkCurrent(); + const length = Math.min(clipboardMediaChunkSize, contents.length - offset); + const { base64 } = await read({ id: file.id, offset, length }); + checkCurrent(); + if (base64.length !== 4 * Math.ceil(length / 3) + || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) { + throw new Error('Invalid clipboard media chunk.'); + } + const binary = atob(base64); + if (binary.length !== length) { + throw new Error('Unexpected clipboard media chunk length.'); + } + for (let index = 0; index < binary.length; index++) { + contents[offset + index] = binary.charCodeAt(index); + } + } + return contents; +} diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts index 1700c2b0ff24b1..38e1767b1c8390 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProtocol.ts @@ -37,6 +37,31 @@ const sandbox = z.object({ pointerLock: z.optional(z.boolean()), clipboardWrite: z.optional(z.boolean()), }); +export const codeBlockEditorPresentation = z.object({ + resizable: z.optional(z.boolean()), + fullWidth: z.optional(z.boolean()), + fullView: z.optional(z.boolean()), + minHeight: z.optional(z.number().check(z.positive(), z.maximum(10_000))), +}); + +export function mergeCodeBlockEditorPresentation( + contributed: ResolvedCodeBlockEditor['presentation'], + resolved: ResolvedCodeBlockEditor['presentation'], +): ResolvedCodeBlockEditor['presentation'] { + if (!contributed) { + return resolved; + } + if (!resolved) { + return contributed; + } + return { + resizable: resolved.resizable ?? contributed.resizable, + fullWidth: resolved.fullWidth ?? contributed.fullWidth, + fullView: resolved.fullView ?? contributed.fullView, + minHeight: resolved.minHeight ?? contributed.minHeight, + }; +} + const resolvedCodeBlockEditor = z.object({ cacheKey: z.optional(z.string()), html: z.string(), @@ -45,6 +70,7 @@ const resolvedCodeBlockEditor = z.object({ hostTransport: z.optional(z.boolean()), contentType: z.enum(['text', 'json']), initialHeight: z.optional(z.number().check(z.positive())), + presentation: z.optional(codeBlockEditorPresentation), sandbox: z.optional(sandbox), }); const codeBlockEditorProvider = z.object({ @@ -115,11 +141,12 @@ export const markdownEditorHost = defineInterface({ id: 'markdown.editor.host' } cancelCompletions: requestType(z.object({ requestId: offset }), z.void()), pasteImages: requestType(z.extend(range, { editEpoch: offset, - images: z.array(z.object({ name: z.string(), mime: z.string(), base64: z.string() })), + images: z.array(z.object({ id: z.string(), name: z.string(), mime: z.string(), size: offset })), }), z.object({ offset: z.optional(offset), editEpoch: offset })), }); export const markdownEditorRenderer = defineInterface({ id: 'markdown.editor.renderer' }, { + readClipboardMedia: requestType(z.object({ id: z.string(), offset, length: offset }), z.object({ base64: z.string() })), configurationChanged: notificationType(editorConfiguration), diagnosticsChanged: requestType(z.object({}), z.void()), update: requestType(z.object({ content: z.string(), editEpoch: offset }), z.void()), diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index d63134b49dbd3b..b91552f27868ec 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -14,14 +14,15 @@ import { RecoveringTaskQueue } from './recoveringTaskQueue'; import { encodeWebviewInitialState } from './webviewInitialState'; import type { ILogger } from '../logging'; import type { ResolvedDocumentLinkTarget } from '../client/protocol'; -import type { - MarkdownCodeBlockEditorProvider, - MarkdownCodeBlockEditorSandbox, - MarkdownContributionProvider, +import { + codeBlockEditorPresentationEqual, + type MarkdownCodeBlockEditorProvider, + type MarkdownCodeBlockEditorSandbox, + type MarkdownContributionProvider, } from '../markdownExtensions'; import { generateUuid } from '../util/uuid'; import { MarkdownEditorRichLinkController } from './markdownEditorRichLinks'; -import { markdownEditorHost, markdownEditorRenderer, type CodeBlockEditorProviderDefinition, type MarkdownEditorConfiguration, type ResolvedCodeBlockEditor } from './markdownEditorProtocol'; +import { codeBlockEditorPresentation, markdownEditorHost, markdownEditorRenderer, mergeCodeBlockEditorPresentation, type CodeBlockEditorProviderDefinition, type MarkdownEditorConfiguration, type ResolvedCodeBlockEditor } from './markdownEditorProtocol'; import { createMarkdownEditorRpcConnection, MarkdownEditorRpcTransport } from './markdownEditorRpc'; import { MarkdownEditorRename } from './markdownEditorRename'; import { MarkdownEditorLanguageFeatures } from './markdownEditorLanguageFeatures'; @@ -59,6 +60,7 @@ interface ProviderResolvedCodeBlockEditor { readonly cacheKey?: string; readonly runtimeKey?: string; readonly initialHeight?: number; + readonly presentation?: ResolvedCodeBlockEditor['presentation']; readonly sandbox?: MarkdownCodeBlockEditorSandbox; } @@ -475,7 +477,8 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT () => webviewPanel.active && !resolveCancellation.token.isCancellationRequested); const imagePaste = new MarkdownEditorImagePaste(document, () => editQueue.drain(), () => ({ text: webviewText, editEpoch: editQueue.epoch }), - () => !readonly && webviewPanel.active && !resolveCancellation.token.isCancellationRequested); + () => !readonly && webviewPanel.active && !resolveCancellation.token.isCancellationRequested, + request => editorWebview.renderer.readClipboardMedia(request)); const navigation = new MarkdownEditorNavigation(document, () => editQueue.drain(), () => ({ text: webviewText, editEpoch: editQueue.epoch }), () => editorWebview.renderer, token => editorWebview.whenReady(token)); @@ -801,6 +804,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT resourceBaseUrl: getCodeBlockEditorResourceBaseUrl(webview, provider.source.resource), contentType: provider.contentType, initialHeight: provider.initialHeight, + presentation: provider.presentation, sandbox: provider.sandbox, }, }, @@ -963,6 +967,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT hostTransport: hasHostTransport && contribution.source.kind === 'exportApi' && contribution.source.apiVersion >= 2, contentType: value.contentType ?? contribution.contentType, initialHeight: value.initialHeight ?? contribution.initialHeight, + presentation: mergeCodeBlockEditorPresentation(contribution.presentation, value.presentation), sandbox: intersectSandbox(contribution.sandbox, value.sandbox), }; } @@ -1199,6 +1204,7 @@ function resolvedCodeBlockEditorsEqual(a: ResolvedCodeBlockEditor, b: ResolvedCo && a.hostTransport === b.hostTransport && a.contentType === b.contentType && a.initialHeight === b.initialHeight + && codeBlockEditorPresentationEqual(a.presentation, b.presentation) && a.sandbox?.forms === b.sandbox?.forms && a.sandbox?.downloads === b.sandbox?.downloads && a.sandbox?.pointerLock === b.sandbox?.pointerLock @@ -1254,6 +1260,7 @@ function isProviderResolvedCodeBlockEditor(value: unknown): value is ProviderRes || (descriptor.cacheKey !== undefined && typeof descriptor.cacheKey !== 'string') || (descriptor.runtimeKey !== undefined && (typeof descriptor.runtimeKey !== 'string' || descriptor.runtimeKey.length === 0 || descriptor.runtimeKey.length > 256)) || (descriptor.initialHeight !== undefined && (!Number.isFinite(descriptor.initialHeight) || (descriptor.initialHeight as number) <= 0)) + || (descriptor.presentation !== undefined && !codeBlockEditorPresentation.safeParse(descriptor.presentation).success) || !isSandbox(descriptor.sandbox) || !descriptor.content || typeof descriptor.content !== 'object' diff --git a/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts b/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts index 9cb378681fb5d8..0b89a44efd1eb4 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorBridge.test.ts @@ -39,6 +39,7 @@ suite('Markdown editor bridge', () => { const reveals: Parameters[0][] = []; let updated: (() => void) | undefined; renderer.register(markdownEditorRenderer, { + readClipboardMedia: () => ({ base64: '' }), diagnosticsChanged: () => { }, update: update => { updates.push(update); updated?.(); }, codeBlockEditorProviders: () => { }, diff --git a/extensions/markdown-language-features/src/test/markdownEditorImagePaste.test.ts b/extensions/markdown-language-features/src/test/markdownEditorImagePaste.test.ts index e193042d6d51b9..82a61d2f2845b5 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorImagePaste.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorImagePaste.test.ts @@ -4,15 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import * as os from 'os'; import 'mocha'; import * as vscode from 'vscode'; import { MarkdownEditorImagePaste } from '../preview/markdownEditorImagePaste'; import { CopyFilesSettings, ResourcePasteOrDropProvider } from '../languageFeatures/copyFiles/dropOrPasteResource'; +import type { MarkdownEditorRenderer } from '../preview/markdownEditorProtocol'; +import { clipboardMediaChunkSize } from '../preview/markdownEditorMediaTransfer'; suite('Markdown editor image paste', () => { const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jD1sAAAAASUVORK5CYII='; - const image = { name: 'image.png', mime: 'image/png', base64 }; + const image = { id: 'image', name: 'image.png', mime: 'image/png', size: atob(base64).length }; + let files: Map; + let chunks: Parameters[0][]; + const read: MarkdownEditorRenderer['readClipboardMedia'] = async request => { + chunks.push(request); + const contents = files.get(request.id); + assert.ok(contents, `Missing test clipboard file ${request.id}`); + return { base64: Buffer.from(contents.subarray(request.offset, request.offset + request.length)).toString('base64') }; + }; let root: vscode.Uri; let document: vscode.TextDocument; let state: { text: string; editEpoch: number; editable: boolean }; @@ -26,13 +35,15 @@ suite('Markdown editor image paste', () => { }; setup(async () => { - root = vscode.Uri.joinPath(vscode.Uri.file(os.tmpdir()), `markdown-image-paste-${crypto.randomUUID()}`); + root = vscode.Uri.joinPath(vscode.Uri.file(__dirname), `markdown-image-paste-${crypto.randomUUID()}`); await vscode.workspace.fs.createDirectory(root); const uri = vscode.Uri.joinPath(root, 'document.md'); await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode('# Title\r\n\r\nHERE')); document = await vscode.workspace.openTextDocument(uri); state = { text: '# Title\n\nHERE', editEpoch: 0, editable: true }; - paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => state.editable, apply); + files = new Map([[image.id, Uint8Array.from(atob(base64), c => c.charCodeAt(0))]]); + chunks = []; + paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => state.editable, read, apply); }); teardown(async () => { @@ -62,9 +73,71 @@ suite('Markdown editor image paste', () => { } finally { token.dispose(); } }); + for (const kind of ['video', 'audio']) { + test(`creates exact ${kind} bytes and the configured media embed`, async () => { + const media = { id: kind, name: kind === 'video' ? 'clip.mp4' : 'sound.mp3', mime: kind === 'video' ? 'video/mp4' : 'audio/mpeg', size: 4 }; + files.set(media.id, new Uint8Array([1, 2, 3, 4])); + const result = await paste.paste({ ...request(), images: [media] }); + assert.deepStrictEqual({ + text: document.getText(), + bytes: [...await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, media.name))], + result, + }, { + text: `# Title\r\n\r\n<${kind} controls src="${media.name}" title="Title">`, + bytes: [1, 2, 3, 4], + result: { offset: state.text.length, editEpoch: 1 }, + }); + }); + } + + test('reads files lazily, sequentially and in bounded chunks before applying a single workspace edit', async () => { + const bytes = Uint8Array.from({ length: clipboardMediaChunkSize * 2 + 5 }, (_, index) => index % 256); + files.set('video', bytes); + await paste.paste({ ...request(), images: [ + { id: 'video', name: 'clip.mp4', mime: 'video/mp4', size: bytes.length }, image, + ] }); + assert.deepStrictEqual({ + chunks, + bytes: Uint8Array.from(await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, 'clip.mp4'))), + editEpoch: state.editEpoch, + }, { + chunks: [ + { id: 'video', offset: 0, length: clipboardMediaChunkSize }, + { id: 'video', offset: clipboardMediaChunkSize, length: clipboardMediaChunkSize }, + { id: 'video', offset: clipboardMediaChunkSize * 2, length: 5 }, + { id: 'image', offset: 0, length: image.size }, + ], + bytes, + editEpoch: 1, + }); + }); + + test('stops requesting chunks after stale edits, cancellation or read errors without writing files', async () => { + for (const reason of ['stale', 'cancelled', 'read error']) { + paste.dispose(); + chunks = []; + paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, async request => { + chunks.push(request); + if (reason === 'stale') { state.editEpoch++; } + if (reason === 'cancelled') { paste.dispose(); } + if (reason === 'read error') { throw new Error('read failed'); } + return { base64: Buffer.alloc(request.length).toString('base64') }; + }, apply); + await assert.rejects(paste.paste({ ...request(), images: [ + { id: 'video', name: 'clip.mp4', mime: 'video/mp4', size: clipboardMediaChunkSize + 1 }, + ] }), reason === 'stale' ? /editor changed/ : reason === 'read error' ? /read failed/ : vscode.CancellationError); + assert.deepStrictEqual({ + chunks: chunks.length, + files: (await vscode.workspace.fs.readDirectory(root)).map(([name]) => name), + text: document.getText(), + }, { chunks: 1, files: ['document.md'], text: '# Title\r\n\r\nHERE' }); + } + }); + test('increments collisions without overwriting and supports multiple images of one MIME type', async () => { await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(root, 'image.png'), new Uint8Array([1, 2, 3])); - await paste.paste({ ...request(), images: [image, { ...image, base64: 'BAUG' }] }); + files.set('second', new Uint8Array([4, 5, 6])); + await paste.paste({ ...request(), images: [image, { ...image, id: 'second', size: 3 }] }); const links = Array.from(document.getText().matchAll(/!\[alt text\]\(([^)]+)\)/g), match => match[1]); assert.deepStrictEqual([...links].sort(), ['image-1.png', 'image-2.png']); assert.strictEqual(document.getText(), `# Title\r\n\r\n![alt text](${links[0]}) ![alt text](${links[1]})`); @@ -95,13 +168,16 @@ suite('Markdown editor image paste', () => { }); test('rejects invalid input, stale epochs, and locked editors before creating files', async () => { - await assert.rejects(paste.paste({ ...request(), images: [{ ...image, name: '../escape.png' }] }), /Invalid clipboard image/); - await assert.rejects(paste.paste({ ...request(), images: [{ ...image, base64: 'invalid!' }] }), /Invalid clipboard image/); - await assert.rejects(paste.paste({ ...request(), endExclusive: 999 }), /Invalid image paste range/); - await assert.rejects(paste.paste({ ...request(), start: -1 }), /Invalid image paste range/); + await assert.rejects(paste.paste({ ...request(), images: [{ ...image, name: '../escape.png' }] }), /Invalid clipboard media/); + await assert.rejects(paste.paste({ ...request(), images: [{ ...image, size: -1 }] }), /Invalid clipboard media/); + await assert.rejects(paste.paste({ ...request(), images: [image, image] }), /Invalid clipboard media/); + await assert.rejects(paste.paste({ ...request(), images: [{ ...image, mime: 'application/octet-stream' }] }), /Invalid clipboard media/); + await assert.rejects(paste.paste({ ...request(), endExclusive: 999 }), /Invalid media paste range/); + await assert.rejects(paste.paste({ ...request(), start: -1 }), /Invalid media paste range/); await assert.rejects(paste.paste({ ...request(), editEpoch: 9 }), /editor changed/); state.editable = false; await assert.rejects(paste.paste(request()), /editor changed/); + assert.deepStrictEqual(chunks, []); assert.deepStrictEqual((await vscode.workspace.fs.readDirectory(root)).map(([name]) => name), ['document.md']); }); @@ -110,12 +186,12 @@ suite('Markdown editor image paste', () => { paste.dispose(); paste = new MarkdownEditorImagePaste(document, async () => { if (++drains === 2) { state.editEpoch++; } - }, () => state, () => true, apply); + }, () => state, () => true, read, apply); await assert.rejects(paste.paste(request()), /editor changed/); assert.deepStrictEqual((await vscode.workspace.fs.readDirectory(root)).map(([name]) => name), ['document.md']); const entered = Promise.withResolvers(); const finish = Promise.withResolvers(); - paste = new MarkdownEditorImagePaste(document, async () => { entered.resolve(); await finish.promise; }, () => state, () => true, apply); + paste = new MarkdownEditorImagePaste(document, async () => { entered.resolve(); await finish.promise; }, () => state, () => true, read, apply); const result = paste.paste(request()); await entered.promise; paste.dispose(); @@ -131,18 +207,18 @@ suite('Markdown editor image paste', () => { await assert.rejects(paste.paste(request()), /disabled/); } finally { await config.update('pasteAs.enabled', previous, vscode.ConfigurationTarget.Global); } const untitled = await vscode.workspace.openTextDocument({ language: 'markdown', content: 'HERE' }); - const unsaved = new MarkdownEditorImagePaste(untitled, async () => { }, () => ({ text: 'HERE', editEpoch: 0 }), () => true); + const unsaved = new MarkdownEditorImagePaste(untitled, async () => { }, () => ({ text: 'HERE', editEpoch: 0 }), () => true, read); try { await assert.rejects(unsaved.paste({ ...request(), start: 0, endExclusive: 4, editEpoch: 0 }), /Save the Markdown/); } finally { unsaved.dispose(); } - paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, async () => false); + paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, read, async () => false); await assert.rejects(paste.paste(request()), /workspace rejected/); assert.deepStrictEqual((await vscode.workspace.fs.readDirectory(root)).map(([name]) => name), ['document.md']); }); test('does not return a stale caret when another edit follows application', async () => { paste.dispose(); - paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, async edit => { + paste = new MarkdownEditorImagePaste(document, async () => { }, () => state, () => true, read, async edit => { await apply(edit); const followup = new vscode.WorkspaceEdit(); followup.insert(document.uri, new vscode.Position(0, 0), 'Other edit\n'); diff --git a/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts index fd4a2ea248aa45..c8a7cc8135b5ce 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts @@ -314,9 +314,18 @@ suite('Markdown code block editor API versioning', () => { 'shared-runtime', )[0]?.runtimeKey, 'shared-runtime'); }); + + test('reads and validates presentation preferences', () => { + const source = { kind: 'exportApi', apiVersion: 2 }; + const presentation = { resizable: true, fullWidth: false, fullView: true, minHeight: 160 }; + assert.deepStrictEqual(readCodeBlockEditorProviders(source, undefined, presentation)[0]?.presentation, presentation); + for (const invalid of [{ resizable: 'yes' }, { minHeight: 0 }, { minHeight: Infinity }, { minHeight: 10_001 }]) { + assert.strictEqual(readCodeBlockEditorProviders(source, undefined, invalid).length, 0); + } + }); }); -function readCodeBlockEditorProviders(source: unknown, runtimeKey?: string) { +function readCodeBlockEditorProviders(source: unknown, runtimeKey?: string, presentation?: unknown) { const extension = { id: 'test.markdown-code-block-editor', extensionUri: vscode.Uri.file('/test/markdown-code-block-editor'), @@ -328,6 +337,7 @@ function readCodeBlockEditorProviders(source: unknown, runtimeKey?: string) { selector: { language: 'test' }, source, runtimeKey, + presentation, }], }, }, diff --git a/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts b/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts index 24d67b59d6e116..34440ae65bb1fa 100644 --- a/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts +++ b/extensions/markdown-language-features/src/test/markdownEditorRpc.test.ts @@ -6,9 +6,10 @@ import * as assert from 'assert'; import 'mocha'; import { ErrorCode, type InterfaceHandlers, type JsonRpcMessage } from '@vscode/hubrpc'; -import { markdownEditorHost, markdownEditorRenderer } from '../preview/markdownEditorProtocol'; +import { markdownEditorHost, markdownEditorRenderer, type MarkdownEditorRenderer } from '../preview/markdownEditorProtocol'; import { createMarkdownEditorRpcConnection, MarkdownEditorRpcTransport } from '../preview/markdownEditorRpc'; import { RecoveringTaskQueue } from '../preview/recoveringTaskQueue'; +import { clipboardMediaChunkSize, readClipboardMediaContents } from '../preview/markdownEditorMediaTransfer'; suite('Markdown editor RPC', () => { const disposables: { dispose(): void }[] = []; @@ -56,6 +57,7 @@ suite('Markdown editor RPC', () => { }); host.get(markdownEditorRenderer); renderer.register(markdownEditorRenderer, { + readClipboardMedia: () => ({ base64: 'AQ==' }), configurationChanged: () => { }, diagnosticsChanged: () => { }, update: () => { }, @@ -77,6 +79,36 @@ suite('Markdown editor RPC', () => { return { host, renderer, hostInbox, rendererInbox, errors }; } + test('resolves lazy media chunks while the paste request is awaiting its result', async () => { + const bytes = Buffer.alloc(clipboardMediaChunkSize + 3, 71); + const image = { id: 'video', name: 'clip.mp4', mime: 'video/mp4', size: bytes.length }; + const chunks: Parameters[0][] = []; + const { host, renderer, hostInbox, errors } = pair({ + pasteImages: async request => { + assert.deepStrictEqual(request.images, [image]); + const contents = await readClipboardMediaContents(request.images[0], request => remote.readClipboardMedia(request), () => { }); + assert.deepStrictEqual(contents, Uint8Array.from(bytes)); + return { offset: 4, editEpoch: 1 }; + }, + }, { + readClipboardMedia: request => { + chunks.push(request); + return { base64: bytes.subarray(request.offset, request.offset + request.length).toString('base64') }; + }, + }); + const remote = host.get(markdownEditorRenderer); + const request = { start: 0, endExclusive: 0, editEpoch: 0, images: [image] }; + assert.deepStrictEqual(await renderer.get(markdownEditorHost).pasteImages(request), { offset: 4, editEpoch: 1 }); + assert.deepStrictEqual(hostInbox.messages[0], { + channel: 'markdownEditor', messageSecret: 'secret', + message: { jsonrpc: '2.0', id: 1, method: 'markdown.editor.host::pasteImages', params: request }, + }); + assert.deepStrictEqual({ chunks, errors }, { chunks: [ + { id: 'video', offset: 0, length: clipboardMediaChunkSize }, + { id: 'video', offset: clipboardMediaChunkSize, length: 3 }, + ], errors: [] }); + }); + test('sends presentation notifications without request IDs or acknowledgement messages', async () => { const seen: unknown[] = []; const { host, renderer, hostInbox, rendererInbox, errors } = pair({ diff --git a/extensions/media-preview/media/imagePreview.js b/extensions/media-preview/media/imagePreview.js index 17b9fb22818257..8a53cfe224d206 100644 --- a/extensions/media-preview/media/imagePreview.js +++ b/extensions/media-preview/media/imagePreview.js @@ -454,19 +454,105 @@ try { await navigator.clipboard.write([new ClipboardItem({ - 'image/png': new Promise((resolve, reject) => { - const canvas = document.createElement('canvas'); - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - canvas.getContext('2d').drawImage(image, 0, 0); - canvas.toBlob((blob) => { - resolve(blob); - canvas.remove(); - }, 'image/png'); - }) + 'image/png': encodeImageAsPng() })]); } catch (e) { console.error(e); } } + + /** + * Encodes the preview image as a PNG. + * + * On the main thread, Chromium encodes canvas PNGs using idle tasks. These can be starved for + * seconds when the webview doesn't get idle time, delaying the copy. Workers encode directly, + * so prefer encoding there and only fall back to the main thread if that fails. + * + * @return {Promise} + */ + async function encodeImageAsPng() { + if (typeof OffscreenCanvas !== 'undefined' && typeof Worker !== 'undefined') { + try { + // Draw on the main thread so EXIF orientation, sRGB conversion and the copied animation + // frame match what was previously copied. `createImageBitmap(image)` differs on these. + const canvas = new OffscreenCanvas(image.naturalWidth, image.naturalHeight); + /** @type {OffscreenCanvasRenderingContext2D} */ (canvas.getContext('2d')).drawImage(image, 0, 0); + return await encodePngInWorker(canvas.transferToImageBitmap()); + } catch (e) { + console.warn('Failed to encode image in worker, falling back to main thread', e); + } + } + + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d')).drawImage(image, 0, 0); + canvas.toBlob((blob) => { + canvas.width = canvas.height = 0; + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to encode image')); + } + }, 'image/png'); + }); + } + + // Avoid a 'bitmaprenderer' context here: in Firefox, convertToBlob() on one can fail to settle. + const pngEncoderWorkerSource = ` +self.onmessage = async (e) => { + const bitmap = e.data; + try { + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + canvas.getContext('2d').drawImage(bitmap, 0, 0); + // Free the source pixels before encoding so only one full-size copy is alive during the encode. + bitmap.close(); + self.postMessage({ blob: await canvas.convertToBlob({ type: 'image/png' }) }); + } catch (err) { + bitmap.close(); + self.postMessage({ error: String(err) }); + } +};`; + + /** + * @param {ImageBitmap} bitmap Transferred to the worker. + * @return {Promise} + */ + function encodePngInWorker(bitmap) { + return new Promise((resolve, reject) => { + const workerUrl = URL.createObjectURL(new Blob([pngEncoderWorkerSource], { type: 'text/javascript' })); + /** @type {Worker | undefined} */ + let worker; + + const dispose = () => { + worker?.terminate(); + URL.revokeObjectURL(workerUrl); + }; + + try { + worker = new Worker(workerUrl); + worker.onmessage = (e) => { + dispose(); + if (e.data?.blob) { + resolve(e.data.blob); + } else { + reject(new Error(e.data?.error ?? 'Failed to encode image')); + } + }; + worker.onerror = (e) => { + dispose(); + reject(new Error(e.message || 'Image encoder worker failed')); + }; + // Throws synchronously if the bitmap can't be transferred, e.g. a DataCloneError + // for a bitmap that isn't origin-clean. Neither worker handler runs in that case. + worker.postMessage(bitmap, [bitmap]); + } catch (e) { + dispose(); + // No-op if the bitmap was already transferred. + bitmap.close(); + reject(e); + } + }); + } }()); diff --git a/extensions/media-preview/src/imagePreview/index.ts b/extensions/media-preview/src/imagePreview/index.ts index 79cb70c9fa72a9..80eb07988df7de 100644 --- a/extensions/media-preview/src/imagePreview/index.ts +++ b/extensions/media-preview/src/imagePreview/index.ts @@ -201,7 +201,7 @@ class ImagePreview extends MediaPreview { - + diff --git a/package-lock.json b/package-lock.json index 19875c2cea286a..2655426ea2662e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "@xterm/xterm": "^6.1.0-beta.304", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", - "foundry-local-sdk": "2.0.1", + "foundry-local-sdk": "2.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -10168,9 +10168,9 @@ } }, "node_modules/foundry-local-sdk": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/foundry-local-sdk/-/foundry-local-sdk-2.0.1.tgz", - "integrity": "sha512-5TwBhKypAGEeezfvluiNlWPVIBX82MW9riqerlROKEzODRYxmzo13fKQ2qEDkVBbioV2PWP4LFFzUqk/NrKjcw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/foundry-local-sdk/-/foundry-local-sdk-2.1.0.tgz", + "integrity": "sha512-sUgF+O7uHGVj3bInufe9nmj2SDry06dKGfVLpg9Y1TPPk/hxZEDAk2QPtU13eGZ0k7SIP3OOZFTkmvbtT+TPrw==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 076e2bd08f6f1d..17367f81726f04 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "@xterm/xterm": "^6.1.0-beta.304", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", - "foundry-local-sdk": "2.0.1", + "foundry-local-sdk": "2.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -298,7 +298,7 @@ "allowScripts": { "bufferutil@4.1.0": true, "cpu-features": false, - "foundry-local-sdk@2.0.1": true, + "foundry-local-sdk@2.1.0": true, "kerberos@2.1.1": true, "koffi@3.2.1": false, "native-keymap@3.3.9": true, diff --git a/src/vs/base/common/jsonEdit.ts b/src/vs/base/common/jsonEdit.ts index ea4646cffc4233..0f7003c84ccba5 100644 --- a/src/vs/base/common/jsonEdit.ts +++ b/src/vs/base/common/jsonEdit.ts @@ -115,7 +115,7 @@ export function setProperty(text: string, originalPath: JSONPath, value: unknown const previous = parent.children[removalIndex - 1]; const offset = previous.offset + previous.length; const parentEndOffset = parent.offset + parent.length; - edit = { offset, length: parentEndOffset - 2 - offset, content: '' }; + edit = { offset, length: parentEndOffset - 1 - offset, content: '' }; } else { edit = { offset: toRemove.offset, length: parent.children[removalIndex + 1].offset - toRemove.offset, content: '' }; } diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 0767bb78c5affd..2c732eba205ac4 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -177,6 +177,11 @@ export interface IProductConfiguration { readonly trustedExtensionPublishers?: readonly string[]; readonly extensionRecommendations?: IStringDictionary; + readonly connectorRecommendations?: IStringDictionary<{ + readonly name: string; + readonly description: string; + readonly iconUrl: string; + }>; readonly configBasedExtensionTips?: IStringDictionary; readonly exeBasedExtensionTips?: IStringDictionary; readonly remoteExtensionTips?: IStringDictionary; diff --git a/src/vs/base/common/uri.ts b/src/vs/base/common/uri.ts index f49b4318a91c62..b84b6f5142c30a 100644 --- a/src/vs/base/common/uri.ts +++ b/src/vs/base/common/uri.ts @@ -685,14 +685,14 @@ function _asFormatted(uri: URI, skipEncoding: boolean): string { // @ const userinfo = authority.substr(0, idx); authority = authority.substr(idx + 1); - idx = userinfo.lastIndexOf(':'); + idx = userinfo.indexOf(':'); if (idx === -1) { res += encoder(userinfo, false, false); } else { // :@ res += encoder(userinfo.substr(0, idx), false, false); res += ':'; - res += encoder(userinfo.substr(idx + 1), false, true); + res += encoder(userinfo.substr(idx + 1), false, false); } res += '@'; } diff --git a/src/vs/base/test/common/jsonEdit.test.ts b/src/vs/base/test/common/jsonEdit.test.ts index fa09d6cb5b563c..a903df71da2d01 100644 --- a/src/vs/base/test/common/jsonEdit.test.ts +++ b/src/vs/base/test/common/jsonEdit.test.ts @@ -207,4 +207,10 @@ suite('JSON - edits', () => { assertEdit(content, edits, '// This is a comment\n[\n 1,\n "foo"\n]'); }); + test('remove last item in compact array without whitespace', () => { + const content = '[1,2]'; + const edits = setProperty(content, [1], undefined, formatterOptions); + assertEdit(content, edits, '[\n 1\n]'); + }); + }); diff --git a/src/vs/base/test/common/uri.test.ts b/src/vs/base/test/common/uri.test.ts index 5fff51ab10b073..edc945a9e9a1de 100644 --- a/src/vs/base/test/common/uri.test.ts +++ b/src/vs/base/test/common/uri.test.ts @@ -430,6 +430,12 @@ suite('URI', () => { assert.strictEqual(value.toString(), 'http://f%C3%B6%C3%B6:b%C3%B6r@l%C3%B6calhost:8080/far'); }); + test('URI#toString, colon in password splits userinfo at the first colon', () => { + const value = URI.parse('http://foo:pa:ss@localhost/far'); + assert.strictEqual(value.toString(), 'http://foo:pa%3Ass@localhost/far'); + assert.strictEqual(URI.parse(value.toString()).authority, 'foo:pa:ss@localhost'); + }); + test('correctFileUriToFilePath2', () => { const test = (input: string, expected: string) => { diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index fd5c9d7d4ed547..199772593eb03d 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -55,6 +55,7 @@ export const enum AccessibleViewProviderId { SessionsChat = 'sessionsChat', SessionsStorageCleanup = 'sessionsStorageCleanup', SessionsChanges = 'sessionsChanges', + EmptyFileEditorSearch = 'emptyFileEditorSearch', SessionsListNotification = 'sessionsListNotification', SessionCanvas = 'sessionCanvas', Survey = 'survey', diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 4c4a92b3fbd874..52ea97a38bfe53 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -528,6 +528,8 @@ Copilot also has no AH-session container: No `CopilotSessionEntry`, `AgentSessionEntry`, default-chat URI helper, or sibling cascade remains. Send/history/model/agent/abort/tool/config/dispose/release operations resolve one leaf. Active-client state remains keyed by the owning SDK session where it is genuinely shared, while each live leaf owns its own SDK and MCP lifecycle. Capabilities remain `multipleChats: { fork: true }`. +Customization discovery (agents, skills, instructions and workspace MCP configuration) is per chat. A chat anchors to its own effective working directories: its first, plus its others when multi-root is enabled. It never anchors to the session's other folders, so a peer working in a second folder gets that folder's customizations. A fork anchors to its own folder, and a worktree-isolated chat to its worktree. The chats of a session that share an anchor set share one refcounted `CustomizationAnchor` in the session's `SessionPluginController`, so discovery never runs twice for the same set; it is released with the last chat using it, and on session finalize. The session's `ActiveClient` keeps the client tool set and client-synced plugins, so client updates still reach every chat. A chat's snapshot is its anchor set's discoveries plus the session's client contributions. Agent pickers list the session's published (default chat's) agents. So when a chat's selected custom agent isn't discovered by its own anchor set, that one agent is taken from the published anchor set. Its instructions and skills stay its own folder's. `SessionState.customizations` remains the default chat's view, and the published anchor follows the default chat, including after a chat-only move. `copilot.sharedCustomizationDirectories` is still written for older builds but is not read. MCP runtime state is still published to the session by every live chat (last writer wins), so a peer's folder-only MCP servers also appear in the session list. + Steering received during turn preparation or configuration refresh is buffered for the addressed chat. A dispatched turn confirmed running by root SDK events accepts and flushes steering immediately, even if its admission RPC has not returned; successful admission also flushes it. Preparation or send failure retains buffered steering for a successful retry, rather than dispatching against an unready runtime. Pending updates replace or remove buffered steering; abort clears it and emits `steering_consumed` to clear the pending UI. The live SDK-session wrapper takes ownership synchronously before MCP reconciliation, so abort and disposal can clear steering throughout delivery. Chat disposal and host shutdown discard buffered delivery, and pending-message updates are ignored once host shutdown starts. `CopilotSessionLauncher` sets `mcpOAuthTokenStorage: 'in-memory'` for created, resumed, and ephemeral SDK sessions. VS Code owns durable MCP credentials through `onMcpAuthRequest`; the runtime must not consult its persistent MCP OAuth keychain store. diff --git a/src/vs/platform/agentHost/common/agentHostChangesetService.ts b/src/vs/platform/agentHost/common/agentHostChangesetService.ts index 52109d74f55dd4..c030ebd417af0b 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetService.ts @@ -123,7 +123,7 @@ export interface IAgentHostChangesetService { /** * Refreshes the session changeset once its working directory is available. - * The strategy defaults to `auto`; overrides apply only to this computation. + * The strategy defaults to `fileEditTracker`; overrides apply only to this computation. */ refreshSessionChangeset(session: ProtocolURI, strategy?: ChangesetDiffStrategy): void; diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemService.ts b/src/vs/platform/agentHost/common/agentHostFileSystemService.ts index 62fe746e657e8e..e6789a8598e83f 100644 --- a/src/vs/platform/agentHost/common/agentHostFileSystemService.ts +++ b/src/vs/platform/agentHost/common/agentHostFileSystemService.ts @@ -22,6 +22,7 @@ export type { IRemoteFilesystemConnection } from './agentHostFileSystemProvider. * and are reachable by the agent host via `fetchContent`. */ export const SYNCED_CUSTOMIZATION_SCHEME = 'vscode-synced-customization'; +export const SYNCED_CUSTOMIZATION_DISPLAY_NAME = 'VS Code Synced Data'; export const IAgentHostFileSystemService = createDecorator('agentHostFileSystemService'); diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 19dd632865aca8..8bf1bcb35db315 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -229,6 +229,7 @@ export interface IGitRemote { export interface IAgentHostGitService { readonly _serviceBrand: undefined; getCurrentBranch(workingDirectory: URI): Promise; + getHEAD(workingDirectory: URI): Promise; /** With `throwOnError`, only a successful detached-HEAD lookup returns `undefined`. */ getCurrentBranchName?(workingDirectory: URI, options?: { readonly throwOnError?: boolean }): Promise; getDefaultBranch(workingDirectory: URI): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostPolicySupport.ts b/src/vs/platform/agentHost/common/agentHostPolicySupport.ts index 2c872b81498169..69079246b5653e 100644 --- a/src/vs/platform/agentHost/common/agentHostPolicySupport.ts +++ b/src/vs/platform/agentHost/common/agentHostPolicySupport.ts @@ -176,6 +176,9 @@ export const agentHostPolicySupport: Readonly typeof entry === 'string' && entry.length > 0).map(entry => URI.parse(entry)) : undefined; - } catch { - return undefined; - } -} - /** The session-wide settings of the session's central state, `undefined` while that is not loaded. */ function createAgentChatSessionSettings(snapshot: ISessionStateSnapshot | undefined): IAgentChatSessionSettings | undefined { if (!snapshot) { @@ -58,14 +29,15 @@ function createAgentChatSessionSettings(snapshot: ISessionStateSnapshot | undefi } const defaultEntry = [...snapshot.chats.values()].find(entry => entry.isDefault); const defaultDirectories = defaultEntry?.launchWorkingDirectories?.map(entry => URI.parse(entry)); - const shared = parseDirectories(snapshot.values.get(COPILOT_SHARED_CUSTOMIZATION_DIRECTORIES_KEY)); const customizationDirectory = snapshot.values.get(COPILOT_CUSTOMIZATION_DIRECTORY_KEY); const projectUri = snapshot.values.get('copilot.project.uri'); const projectName = snapshot.values.get('copilot.project.displayName'); - const anchors = shared ?? defaultDirectories; + // The session's customizations follow its default chat's own folders, also + // after a chat-only move (`copilot.sharedCustomizationDirectories` is kept + // only for older builds). return { - customizationDirectory: shared?.[0] ?? defaultDirectories?.[0] ?? (customizationDirectory ? URI.parse(customizationDirectory) : undefined), - additionalCustomizationDirectories: anchors?.slice(1) ?? [], + customizationDirectory: defaultDirectories?.[0] ?? (customizationDirectory ? URI.parse(customizationDirectory) : undefined), + additionalCustomizationDirectories: defaultDirectories?.slice(1) ?? [], project: projectUri && projectName !== undefined ? { uri: URI.parse(projectUri), displayName: projectName } : undefined, workspaceless: snapshot.values.get(AH_META_WORKSPACELESS_DB_KEY) === 'true', defaultChatWorkingDirectory: defaultDirectories?.[0] ?? (defaultEntry?.workingDirectories?.[0] !== undefined ? URI.parse(defaultEntry.workingDirectories[0]) : undefined), @@ -101,6 +73,23 @@ export function createChatOwnerRef(stateManager: AgentHostStateManager, chat: UR }; } +/** + * Builds the host-owned {@link IAgentChatContext} for an addressed chat + * operation โ€” the single place Agent Host derives the transient context handed + * to a provider, so every boundary (create, materialize, send, truncate, + * dispose, release, model/agent change, history read) carries the same facts: + * + * - `resource` โ€” provider-owned persistence scope for the exact chat, its + * recorded `storageResource`; + * - `owner` โ€” the session that owns the chat, read at each use; + * - `origin` โ€” how the chat came into existence, read from its `ChatSummary`. + * Absent only in the narrow restore window before a chat is registered, + * where the restoring caller supplies the origin itself; + * - `customizations` โ€” the owning session's effective host customizations. + * + * Accepts either resource form so callers already holding protocol URI strings + * (the action pipeline) need not round-trip through {@link URI}. + */ export function createAgentChatContext(stateManager: AgentHostStateManager, session: URI | ProtocolURI, chat: URI | ProtocolURI, storageResource: ProtocolURI, sessionState: ISessionStateSnapshot | undefined): IAgentChatContext { const sessionKey = toKey(session); const chatKey = toKey(chat); diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index 36b40855085d6a..fdd9302f212b8b 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -205,7 +205,6 @@ export class AgentHostChangesetCoordinator extends Disposable { } else { this._clearBranchSummaryResources(session); this._changesets.recomputeSubscribedChangesets(session); - this._changesetFileMonitor.trackSessionChanges(session, session); } } } @@ -261,10 +260,6 @@ export class AgentHostChangesetCoordinator extends Disposable { if (parsed?.kind === ChangesetKind.Session) { this._addSubscription(parsed.ownerUri, resourceStr); this._changesets.refreshSessionChangeset(parsed.ownerUri); - // Chat-scoped Session Changes come only from tracked edits, which external file changes do not affect. - if (!isAhpChatChannel(parsed.ownerUri)) { - this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.ownerUri); - } return; } @@ -292,7 +287,6 @@ export class AgentHostChangesetCoordinator extends Disposable { this._reconcileBranchSummaryResources(session); } else { this._changesets.refreshSessionChangeset(session); - this._changesetFileMonitor.trackSessionChanges(session, session); } } @@ -440,7 +434,6 @@ export class AgentHostChangesetCoordinator extends Disposable { } if (parsed?.kind === ChangesetKind.Session) { this._removeSubscription(parsed.ownerUri, resourceStr); - this._changesetFileMonitor.untrackSessionChanges(resourceStr); return; } if (parsed?.kind === ChangesetKind.Turn && parsed.turnId !== undefined) { @@ -450,7 +443,6 @@ export class AgentHostChangesetCoordinator extends Disposable { if (!parsed) { this._removeSubscription(resourceStr, resourceStr); this._clearBranchSummaryResources(resourceStr); - this._changesetFileMonitor.untrackSessionChanges(resourceStr); } } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts index 4b4281ecb65cec..500dffa14feaf9 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts @@ -6,7 +6,7 @@ import { SequencerByKey } from '../../../base/common/async.js'; import { Disposable, DisposableMap, IReference, ReferenceCollection } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; -import { buildSessionChangesetUri, buildUncommittedChangesetUri, changesetSession, parseChangesetUri } from '../common/changesetUri.js'; +import { buildUncommittedChangesetUri, changesetSession, parseChangesetUri } from '../common/changesetUri.js'; import { isAhpChatChannel, parseSubagentSessionUri } from '../common/state/sessionState.js'; import { DEFAULT_AGENT_HOST_WATCH_EXCLUDES, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; @@ -152,8 +152,6 @@ export class ChangesetFileMonitorCoordinator extends Disposable { onSessionDisposed(sessionStr: string): void { this.untrackSessionChanges(buildUncommittedChangesetUri(sessionStr)); - this.untrackSessionChanges(buildSessionChangesetUri(sessionStr)); - this.untrackSessionChanges(sessionStr); for (const [subscription, owner] of [...this._watchOwners]) { const source = this._watchSources.get(owner) ?? owner; if (source === sessionStr || this._watchSessions.get(subscription) === sessionStr) { diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index e824dbc11fc109..b38acb0e766727 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -492,7 +492,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC this._scheduleStaticRecompute(branchChangesetOwner, 'branch'); } - refreshSessionChangeset(session: ProtocolURI, strategy: ChangesetDiffStrategy = 'auto'): void { + refreshSessionChangeset(session: ProtocolURI, strategy: ChangesetDiffStrategy = 'fileEditTracker'): void { if (isAhpChatChannel(session)) { this._refreshChatSessionChangeset(session); return; @@ -543,8 +543,6 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC this.refreshBranchChangeset(parsed.ownerUri); break; case ChangesetKind.Session: - // Keep checkpoint-backed shell edits when a Git state refresh - // arrives after the end-of-turn checkpoint computation. this.refreshSessionChangeset(session); break; case ChangesetKind.Uncommitted: diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 709cfb60e92f02..6ac52380c5942e 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -17,7 +17,7 @@ import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js'; import { buildGitBlobUri } from './gitDiffContent.js'; -import { CheckoutBlockedByLocalChangesError, EMPTY_TREE_OBJECT, IAddWorktreeOptions, IAgentHostGitService, IBranch, IBranchDiffSafetyInfo, IRefQuery, IComputeSessionFileDiffsOptions, IDefaultBranch, IGitRemote, IPullOptions, IPushOptions, GitRefType, IRemoteBranch, GitRef, ITag, Branch, IWorktreeFileProgress } from '../common/agentHostGitService.js'; +import { CheckoutBlockedByLocalChangesError, EMPTY_TREE_OBJECT, IAddWorktreeOptions, IAgentHostGitService, IBranch, IBranchDiffSafetyInfo, IRefQuery, IComputeSessionFileDiffsOptions, IDefaultBranch, IGitRemote, IPullOptions, IPushOptions, GitRefType, IRemoteBranch, GitRef, ITag, Branch, IWorktreeFileProgress, IDetachedHead } from '../common/agentHostGitService.js'; import { LRUCache } from '../../../base/common/map.js'; import { firstParallel, Limiter, SequencerByKey, timeout } from '../../../base/common/async.js'; import { derived, observableValueOpts, type IObservable } from '../../../base/common/observable.js'; @@ -66,6 +66,19 @@ export class AgentHostGitService implements IAgentHostGitService { || undefined; } + async getHEAD(workingDirectory: URI): Promise { + const headRef = await this._runGit(workingDirectory, ['symbolic-ref', 'HEAD']); + if (headRef) { + // Branch + const branch = await this.getBranch(workingDirectory, headRef.trim()); + return branch?.kind === GitRefType.Head ? branch : undefined; + } + + // Detached HEAD + const result = await this._runGit(workingDirectory, ['rev-parse', 'HEAD']); + return result ? { kind: GitRefType.DetachedHead, name: result.trim() } : undefined; + } + async getCurrentBranchName(workingDirectory: URI, options?: { readonly throwOnError?: boolean }): Promise { return (await this._runGit(workingDirectory, ['branch', '--show-current'], options))?.trim() || undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts index 498264f79acafa..dc99255b2b234c 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts @@ -5,6 +5,7 @@ import { hostname, release } from 'os'; import { Disposable, isDisposable, toDisposable, type DisposableStore, type IDisposable } from '../../../base/common/lifecycle.js'; +import { Emitter, type Event } from '../../../base/common/event.js'; import { joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { getDevDeviceId, getMachineId, getSqmMachineId } from '../../../base/node/id.js'; @@ -40,6 +41,7 @@ export interface IAgentHostTelemetryServiceOptions { } export interface IAgentHostTelemetryService extends ITelemetryService, IAgentHostRestrictedTelemetry { + readonly onDidChangeTelemetryLevel: Event; updateTelemetryLevel(telemetryLevel: TelemetryLevel): void; /** Register an account reader with provider-owned lifetime, never process-wide account properties. */ registerCopilotTelemetryProvider(provider: string, getContext: () => IAgentTelemetryContext): IDisposable; @@ -51,6 +53,8 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT declare readonly _serviceBrand: undefined; private _telemetryLevel: TelemetryLevel; + private readonly _onDidChangeTelemetryLevel = this._register(new Emitter()); + readonly onDidChangeTelemetryLevel: Event = this._onDidChangeTelemetryLevel.event; /** * Whether `/copilot_internal/user` enables enhanced/restricted telemetry. Defaults to `false` @@ -229,7 +233,12 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT } updateTelemetryLevel(telemetryLevel: TelemetryLevel): void { + const previous = this.telemetryLevel; this._telemetryLevel = Math.min(this._telemetryLevel, telemetryLevel); + const next = this.telemetryLevel; + if (next !== previous) { + this._onDidChangeTelemetryLevel.fire(next); + } } } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 2d5de500956626..73b6ba701b882e 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -585,7 +585,8 @@ function withChatWorkingDirectories(sessionWorkingDirectories: readonly string[] } const result = [...sessionWorkingDirectories]; const keys = new Set(result.map(getWorkingDirectoryKey)); - for (const directory of chats?.flatMap(chat => chat.workingDirectories ?? []) ?? []) { + // Archived chats are detached from the session workspace until they are unarchived. + for (const directory of chats?.flatMap(chat => chat.archived ? [] : chat.workingDirectories ?? []) ?? []) { const key = getWorkingDirectoryKey(directory); if (!keys.has(key)) { keys.add(key); @@ -8668,6 +8669,77 @@ export class AgentService extends Disposable implements IAgentService { }); } + /** + * Detaches an archived chat's folders from the aggregate session workspace, + * or attaches them back when the chat is unarchived. The chat keeps its own + * folders either way. A folder stays attached while it is the primary folder + * or another unarchived chat uses it, including a chat that inherits the + * complete session workspace. Worktrees are never deleted. + */ + setArchivedChatWorkspaceAttached(session: URI, chat: URI, attached: boolean): Promise { + const sessionKey = session.toString(); + return this._additionalWorktreeSequencer.queue(sessionKey, async () => { + const state = this._stateManager.getSessionState(sessionKey); + const workingDirectories = this._stateManager.getSessionSummary(sessionKey)?.workingDirectories; + const chatSummary = state?.chats.find(candidate => candidate.resource === chat.toString()); + const chatDirectories = chatSummary?.workingDirectories; + if (!state || state.lifecycle !== SessionLifecycle.Ready || !workingDirectories?.length || !chatSummary || !chatDirectories?.length + || isSessionChatArchived(chatSummary) === attached) { + return; + } + const provider = this._providerService.getProviderForSession(session); + if (!provider?.getDescriptor().capabilities?.multipleWorkingDirectories) { + return; + } + const isAttached = (directory: string) => workingDirectories.some(candidate => isEqual(URI.parse(candidate), URI.parse(directory))); + if (attached) { + const missing = chatDirectories.filter(directory => !isAttached(directory)); + for (const directory of missing) { + await this._attachSessionWorkingDirectoryForChat(session, URI.parse(directory)); + } + if (missing.length) { + this._publishWorkingDirectoryIdentities(sessionKey); + await this._persistSessionWorkingDirectories(session); + } + return; + } + + const { capability } = this._getChatWorkingDirectoryContext(session); + const otherChats = state.chats.filter(candidate => candidate.resource !== chatSummary.resource && !isSessionChatArchived(candidate)); + if (otherChats.some(candidate => candidate.workingDirectories === undefined)) { + return; + } + const primary = URI.parse(workingDirectories[0]); + let removed = false; + for (const directory of chatDirectories) { + const uri = URI.parse(directory); + const current = this._stateManager.getSessionSummary(sessionKey)?.workingDirectories ?? []; + if (isEqual(uri, primary) || !current.some(candidate => isEqual(URI.parse(candidate), uri)) + || otherChats.some(candidate => candidate.workingDirectories?.some(other => isEqual(URI.parse(other), uri)))) { + continue; + } + this._stateManager.dispatchServerAction(sessionKey, resolveSessionWorkingDirectoryAction({ + type: ActionType.SessionWorkingDirectoryRemoved, + directory, + }, current, capability)); + removed = true; + } + if (removed) { + this._publishWorkingDirectoryIdentities(sessionKey); + await this._persistSessionWorkingDirectories(session); + } + }); + } + + private async _persistSessionWorkingDirectories(session: URI): Promise { + if (this._stateManager.isEphemeralSession(session.toString())) { + return; + } + await this._persistListVisibleSessionState(session, { + [SESSION_WORKING_DIRECTORIES_KEY]: JSON.stringify(this._stateManager.getSessionSummary(session.toString())?.workingDirectories ?? []), + }); + } + private async _attachSessionWorkingDirectoryForChat(session: URI, directory: URI): Promise { const sessionKey = session.toString(); const { workingDirectories, capability } = this._getChatWorkingDirectoryContext(session); diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index 66e0d72c0ef08a..e861f66943dd54 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -46,6 +46,7 @@ import type { ISessionServerToolAccessor } from './shared/sessionServerTools.js' import { type IAgentServiceFoundation } from './agentServiceFoundation.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; import { ISessionWorkspaceConversionService, SessionWorkspaceConversionService } from './chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostArchivedChatWorkspaceService } from './chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { IAgentHostTurnTracker } from './agentHostTurnTracker.js'; import { AgentHostSessionLifecycle, type IAgentHostSessionLifecycleAccessor } from './agentHostSessionLifecycle.js'; import { persistSessionMetadataValues } from './shared/persistSessionMetadata.js'; @@ -231,6 +232,10 @@ export function createAgentServiceComposition( setChatWorkingDirectory: (session, chat, directory, replaceSessionWorkspace, expectedSessionDirectories) => agentService!.setChatWorkingDirectory(session, chat, directory, replaceSessionWorkspace, expectedSessionDirectories), })); services.set(ISessionWorkspaceConversionService, workspaceConversionService.value); + services.set(IAgentHostArchivedChatWorkspaceService, { + _serviceBrand: undefined, + setChatWorkspaceAttached: (session, chat, attached) => agentService!.setArchivedChatWorkspaceAttached(session, chat, attached), + }); const collaborators: IAgentServiceCollaborators = { gitHubEndpointService, diff --git a/src/vs/platform/agentHost/node/chatContributions/chatArchive/archivedChatWorkspaceService.ts b/src/vs/platform/agentHost/node/chatContributions/chatArchive/archivedChatWorkspaceService.ts new file mode 100644 index 00000000000000..2a8971e17e5a40 --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/chatArchive/archivedChatWorkspaceService.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../instantiation/common/instantiation.js'; + +export const IAgentHostArchivedChatWorkspaceService = createDecorator('agentHostArchivedChatWorkspaceService'); + +/** + * Keeps an archived chat's folders out of the aggregate session workspace. + */ +export interface IAgentHostArchivedChatWorkspaceService { + readonly _serviceBrand: undefined; + + /** + * Detaches the chat's folders from its session workspace when `attached` is + * `false`, and attaches them back when `true`. Folders still used by the + * session or another unarchived chat stay attached. + */ + setChatWorkspaceAttached(session: URI, chat: URI, attached: boolean): Promise; +} diff --git a/src/vs/platform/agentHost/node/chatContributions/chatArchive/chatArchiveContribution.ts b/src/vs/platform/agentHost/node/chatContributions/chatArchive/chatArchiveContribution.ts index c5993adcf66d24..2800b1bb275daa 100644 --- a/src/vs/platform/agentHost/node/chatContributions/chatArchive/chatArchiveContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/chatArchive/chatArchiveContribution.ts @@ -12,10 +12,12 @@ import { isAhpChatChannel, isSessionStatusRead } from '../../../common/state/ses import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { IAgentHostChatOwnershipService } from '../../agentHostChatOwnershipService.js'; import { IAgentHostPeerChatPersistenceService } from '../../agentHostPeerChatStore.js'; +import { IAgentHostArchivedChatWorkspaceService } from './archivedChatWorkspaceService.js'; /** * Persists independently archived peer-chat state after the protocol action has - * been accepted and reduced. + * been accepted and reduced, and detaches an archived chat's folders from the + * session workspace until it is unarchived. */ export class ChatArchiveContribution extends Disposable implements IAgentHostChatContribution { @@ -27,6 +29,7 @@ export class ChatArchiveContribution extends Disposable implements IAgentHostCha @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @IAgentHostChatOwnershipService private readonly _chatOwnership: IAgentHostChatOwnershipService, @IAgentHostPeerChatPersistenceService private readonly _peerChatPersistenceService: IAgentHostPeerChatPersistenceService, + @IAgentHostArchivedChatWorkspaceService private readonly _archivedChatWorkspace: IAgentHostArchivedChatWorkspaceService, @ILogService private readonly _logService: ILogService, ) { super(); @@ -47,5 +50,8 @@ export class ChatArchiveContribution extends Disposable implements IAgentHostCha void this._peerChatPersistenceService.setArchived(session, chat, dispatched.action.isArchived).catch(error => { this._logService.error(error, `[ChatArchiveContribution] Failed to persist archived state for ${dispatched.channel}`); }); + void this._archivedChatWorkspace.setChatWorkspaceAttached(session, chat, !dispatched.action.isArchived).catch(error => { + this._logService.error(error, `[ChatArchiveContribution] Failed to update the session workspace for ${dispatched.channel}`); + }); } } diff --git a/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts b/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts index 4b6a33ce8f41f3..e050e8c0343864 100644 --- a/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts @@ -11,7 +11,7 @@ import type { IAgentHostChatContribution, IAgentHostChatContributionContext, ITu import { IAgentConfigurationService } from '../../agentConfigurationService.js'; import { URI } from '../../../../../base/common/uri.js'; -/** Starts end-of-turn checkpoint capture and schedules tracked then Git-backed changeset recomputation. */ +/** Starts end-of-turn checkpoint capture and schedules tracked changeset recomputation. */ export class CheckpointAndChangesetContribution extends Disposable implements IAgentHostChatContribution { static readonly id = 'checkpointAndChangeset'; @@ -45,9 +45,6 @@ export class CheckpointAndChangesetContribution extends Disposable implements IA this._onTurnComplete(turn); void checkpoint.catch(err => { this._logService.warn(`[AgentSideEffects] Turn checkpoint capture failed for ${turn.session}/${turn.turnId}: ${err instanceof Error ? err.message : String(err)}`); - }).then(() => { - // Recover terminal-tool edits that providers cannot report through the file-edit tracker. - this._changesets.refreshSessionChangeset(turn.session, 'auto'); }); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 116be58ae834c0..8da8f5d41cea2f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -13,7 +13,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { structuralEquals } from '../../../../base/common/equals.js'; import { CancellationError, getErrorCode, getErrorMessage } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableMap, DisposableResourceMap, DisposableSet, DisposableStore, type IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableResourceMap, DisposableSet, type IDisposable, type IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { equals } from '../../../../base/common/objects.js'; @@ -106,7 +106,7 @@ import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConver import { CopilotGitHubTelemetryForwarder, type ICopilotModelCallCorrelationTelemetry } from './copilotGitHubTelemetryForwarder.js'; import { CopilotGitHubCredentials, CopilotGitHubSessionCredentials } from './copilotGitHubCredentials.js'; import { CopilotSecondaryAssignmentContext } from './copilotSecondaryAssignmentContext.js'; -import { AGENT_HOST_COPILOT_CLIENT_NAME, CopilotSessionLauncher, AutoTierConfigKey, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getLocalModelSelectionId, isCopilotReasoningEffort, resolveCopilotAutoTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { AGENT_HOST_COPILOT_CLIENT_NAME, CopilotSessionLauncher, AutoTierConfigKey, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getLocalModelSelectionId, isCopilotReasoningEffort, isCopilotSessionTelemetryEnabled, resolveCopilotAutoTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { createLocalModelRowMeta } from '../../common/meta/localModelStatus.js'; import { errorCodeForLog } from '../../common/logRedaction.js'; import { withSessionInitiator } from '../../common/meta/agentSessionInitiatorMeta.js'; @@ -357,6 +357,11 @@ interface IWorkingDirectoryChangeTransactionOptions { readonly previousCustomizationAdditionalDirectories: readonly URI[]; readonly previousMetadata: IWorkingDirectoryMetadataSnapshot; readonly updateCustomizationAnchor?: boolean; + /** + * Moves the published customization view with the default chat on a + * chat-only move, without rebasing enablement or changing the project. + */ + readonly followCustomizationAnchor?: boolean; readonly sharedCustomizationDirectories?: readonly URI[]; } @@ -594,10 +599,15 @@ class CopilotChatEntry extends Disposable { constructor( readonly chatSession: CopilotAgentSession, activeClient: ActiveClient, + /** The chat's own customization anchor set, held so its discovery stays live as long as the chat is. */ + anchor: IReference | undefined, onMcpNotification: Emitter, onDidRequireAuth: () => void, ) { super(); + if (anchor) { + this._register(anchor); + } this._register(chatSession); this._register(chatSession.onMcpNotification(notification => onMcpNotification.fire(notification))); this._register(chatSession.onDidRequireAuth(onDidRequireAuth)); @@ -1076,12 +1086,18 @@ export class CopilotAgent extends Disposable implements IAgent { this._register(this._githubCredentials.onDidRequestRefresh(() => this._handleCopilotSessionAuthRequired(false))); if (isAgentHostTelemetryService(this._telemetryService)) { this._register(this._telemetryService.registerCopilotTelemetryProvider(this.id, () => this.getTelemetryContext())); + this._register(this._telemetryService.onDidChangeTelemetryLevel(() => { + this._revokeSessionTelemetryIfDisabled().catch(err => + this._logService.error('[Copilot] Failed to revoke session telemetry', err) + ); + })); } this._worktree = worktree; this._configurationService.publishRootTransientValues?.({ [CopilotCliVSCodeAssignmentContextKey]: undefined, }); this._lastStartupConfig = this._readClientStartupConfig(); + this._sessionTelemetryRevoked = !isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService); this._plugins = this._register(this._instantiationService.createInstance(PluginController, () => this._ensureClient())); this._sessionLauncher = this._instantiationService.createInstance(CopilotSessionLauncher); this._customizationInstallations = this._register(new CopilotCustomizationInstallations( @@ -1089,13 +1105,23 @@ export class CopilotAgent extends Disposable implements IAgent { session => this._findSessionChat(session)?.sessionId, async (client, sessionId) => { const copilotClient = client as CopilotClient; - const session = await this._sessionLauncher.createCustomizationPolicySession( + const createSession = () => this._sessionLauncher.createCustomizationPolicySession( copilotClient, sessionId, getCopilotHomePath(this._environmentService.userHome.fsPath, process.env), this._getGitHubSessionCredentials(), this._getGitHubHostUri(), ); + const telemetryEnabledAtStart = isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService); + let session = await createSession(); + if (telemetryEnabledAtStart && !isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService)) { + try { + await session.disconnect(); + } catch { + // best-effort teardown before re-creating with telemetry disabled + } + session = await createSession(); + } return { client, sessionId: session.sessionId, @@ -1170,6 +1196,9 @@ export class CopilotAgent extends Disposable implements IAgent { this._restartClientIfStartupConfigChanged().catch(err => this._logService.error('[Copilot] Failed to apply root config change', err) ); + this._revokeSessionTelemetryIfDisabled().catch(err => + this._logService.error('[Copilot] Failed to revoke session telemetry', err) + ); })); this._register(this._managedSettingsService.onDidChange(() => { this._restartClientIfStartupConfigChanged().catch(err => @@ -1238,6 +1267,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private _lastStartupConfig: CopilotAgentStartupConfig; + private _sessionTelemetryRevoked: boolean; private _migrateLegacyEnabledSnapshot: boolean | undefined; private _isSessionSyncEnabled(): boolean { @@ -1640,6 +1670,13 @@ export class CopilotAgent extends Disposable implements IAgent { for (const session of this._allLiveSessions()) { void this._scheduleConnectorRefresh(session); } + if (this._sessionTelemetryRevoked) { + queueMicrotask(() => { + this._disposeIdleTelemetryRevokedSessions().catch(err => + this._logService.error('[Copilot] Failed to dispose telemetry-revoked session', err) + ); + }); + } if (this._pendingClientRestartReasons.size === 0) { return; } @@ -1650,6 +1687,58 @@ export class CopilotAgent extends Disposable implements IAgent { }); } + /** + * Revokes session telemetry on existing runtime sessions when the effective + * telemetry level drops below `USAGE`, without waiting for every chat across + * the host to become idle. Idle sessions and the hidden customization policy + * session are torn down immediately so they cannot be reused with telemetry + * enabled; sessions with in-flight work are marked so they tear down as soon + * as their own turn/subagents finish (or before their next turn in + * {@link _refreshSessionConfiguration}). + */ + private async _revokeSessionTelemetryIfDisabled(): Promise { + if (this._sessionTelemetryRevoked || isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService)) { + return; + } + this._sessionTelemetryRevoked = true; + for (const session of [...this._allLiveSessions(), ...this._sessionsPendingRegistration]) { + session.markTelemetryRevocationRequiresRestart?.(); + } + const policyRelease = this._customizationInstallations.releasePolicySession(); + await Promise.all([ + policyRelease, + this._disposeIdleTelemetryRevokedSessions(), + ]); + } + + /** + * Tears down a telemetry-revoked session through its chat sequencer once it + * has no active turn or subagent, so disposal never races a concurrently + * queued operation on that chat. + */ + private _scheduleTelemetryRevokedSessionDisposal(session: CopilotAgentSession): Promise { + if (this._shutdownPromise || !session.requiresRestartAfterTelemetryRevocation || session.isDisposed || session.hasActiveTurn || session.hasActiveSubagents) { + return Promise.resolve(); + } + return this._queueChat(session.sessionId, session.sessionId, 'disposeTelemetryRevokedSession', async () => { + if (this._shutdownPromise || session.isDisposed || session.hasActiveTurn || session.hasActiveSubagents || this._findSessionBySdkId(session.sessionId) !== session) { + return; + } + const canvasHandoff = session.takeCanvasProjection(); + if (canvasHandoff) { + this._canvasHandoffs.set(session.chatChannelUri, canvasHandoff); + } + await this._destroyLiveSession(session, true); + }).catch(error => this._logService.warn(`[Copilot:${session.sessionId}] Failed to dispose telemetry-revoked session`, error)); + } + + private async _disposeIdleTelemetryRevokedSessions(): Promise { + if (this._shutdownPromise) { + return; + } + await Promise.all(this._allLiveSessions().map(session => this._scheduleTelemetryRevokedSessionDisposal(session))); + } + private async _handleClientOperationFailure(error: unknown, operation: CopilotClientOperation, correlation?: ICopilotFailureCorrelation): Promise { const failureKind = classifyCopilotClientOperationFailure(error); if (!failureKind) { @@ -2033,6 +2122,9 @@ export class CopilotAgent extends Disposable implements IAgent { const previousCustomizationDirectory = activeClient.pluginController.directory ?? previousWorkingDirectory; const previousCustomizationAdditionalDirectories = [...activeClient.pluginController.additionalDirectories]; + const defaultChatOnlyMove = chatOnly && !replaceSessionWorkspace && isEqual(resource, ownerSession); + // The roots older builds keep discovering from: those saved by the first chat-only move, else the folders it moves away from. + const savedSharedRoots = defaultChatOnlyMove ? this._parseWorkingDirectories(storedMetadata.snapshot.sharedCustomizationDirectories, undefined) : undefined; const transaction = this._createWorkingDirectoryChangeTransaction({ resource, activeClient, @@ -2042,8 +2134,10 @@ export class CopilotAgent extends Disposable implements IAgent { previousCustomizationAdditionalDirectories, previousMetadata: storedMetadata.snapshot, updateCustomizationAnchor: !chatOnly || replaceSessionWorkspace, - sharedCustomizationDirectories: chatOnly && !replaceSessionWorkspace && isEqual(resource, ownerSession) - ? [previousCustomizationDirectory, ...previousCustomizationAdditionalDirectories] + followCustomizationAnchor: defaultChatOnlyMove, + // Still written for older builds, which keep discovery at the original folders; this build does not read it. + sharedCustomizationDirectories: defaultChatOnlyMove + ? savedSharedRoots?.length ? savedSharedRoots : [previousCustomizationDirectory, ...previousCustomizationAdditionalDirectories] : undefined, }); await entry.setWorkingDirectory(workingDirectory, transaction); @@ -2120,9 +2214,9 @@ export class CopilotAgent extends Disposable implements IAgent { }; const applyProviderState = async (directory: URI, additionalDirectories: readonly URI[], metadata: IWorkingDirectoryMetadataSnapshot): Promise => { const errors: string[] = []; - if (options.updateCustomizationAnchor !== false) { + if (options.updateCustomizationAnchor !== false || options.followCustomizationAnchor) { try { - activeClient.pluginController.reanchor(directory); + activeClient.pluginController.reanchor(directory, options.updateCustomizationAnchor !== false); } catch (error) { errors.push(`customization anchor: ${getErrorMessage(error)}`); } @@ -2131,6 +2225,9 @@ export class CopilotAgent extends Disposable implements IAgent { } catch (error) { errors.push(`customization additional roots: ${getErrorMessage(error)}`); } + if (options.followCustomizationAnchor) { + activeClient.pluginController.publishWhenSettled(); + } } try { await this._storeWorkingDirectoryMetadataSnapshot(resource, metadata); @@ -2471,12 +2568,16 @@ export class CopilotAgent extends Disposable implements IAgent { if (token.isCancellationRequested) { throw new CancellationError(); } - const current = this._findSessionChat(session); + const context = this._resolveMcpRequestChatContext(controlChat.chat); + if (!context) { + throw new Error('Cannot start an MCP server because the session runtime is no longer available.'); + } + const current = context.target ?? await this._ensureResolvedChatSession(context); if (!current) { throw new Error('Cannot start an MCP server because the session runtime is no longer available.'); } return this._refreshSessionConfiguration( - this._resolveSendChatContext(controlChat.chat), + context, current, undefined, { operation: 'startMcpServer', allowRestart: 'whenIdle', token }, @@ -2533,7 +2634,9 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Resolves the customization anchor(s) for a session. `directory` is the + * Resolves the customization anchor(s) of a session's published view, which + * follows its default chat (each chat's own snapshot anchors to that chat's + * folders, see {@link _acquireChatCustomizationAnchor}). `directory` is the * primary (index 0) anchor โ€” the worktree for worktree-isolated sessions. * `additionalDirectories` are the non-primary roots to attach to discovery, * and are applied only when `applyAdditional` is true: @@ -4468,12 +4571,18 @@ export class CopilotAgent extends Disposable implements IAgent { } private async _listInstalledPlugins(sessionId: string) { + const client = await this._ensureClient(); + const installedPlugins = (await client.rpc.plugins.list()).plugins; const session = this._findSessionChat(this._sessionResourceForCompletions(sessionId)); - if (session) { - return session.listInstalledPlugins(); + if (!session) { + return installedPlugins; } - const client = await this._ensureClient(); - return (await client.rpc.plugins.list()).plugins; + const sessionPlugins = await session.listInstalledPlugins(); + const sessionPluginIds = new Set(sessionPlugins.map(plugin => JSON.stringify([plugin.name, plugin.marketplace, plugin.directSourceId]))); + return [ + ...sessionPlugins, + ...installedPlugins.filter(plugin => !sessionPluginIds.has(JSON.stringify([plugin.name, plugin.marketplace, plugin.directSourceId]))), + ]; } private async _listPluginMarketplacePlugins(sessionId: string): Promise { @@ -5438,14 +5547,17 @@ export class CopilotAgent extends Disposable implements IAgent { activeClient.pluginController.reanchor(customizationDirectory); // Multi-root: anchor customization discovery to the additional workspace // roots (index 1..N of the resolved set). Empty when single-root / gated off. - activeClient.pluginController.setAdditionalDirectories(this._additionalCustomizationDirectories(resolvedWorkingDirectories)); - // Advertise exactly the clients Agent Host fanned this chat out to. - const snapshot = await activeClient.snapshot(chat.toString()); - const shellManager = this._instantiationService.createInstance(ShellManager, owner, chat, storage, workingDirectory); - + const additionalDirectories = this._additionalCustomizationDirectories(resolvedWorkingDirectories); + activeClient.pluginController.setAdditionalDirectories(additionalDirectories); + // The chat's own anchor set (the published view follows the default chat's); + // held until the chat's entry holds it so it is discovered only once. + const anchor = await this._acquireChatCustomizationAnchor(activeClient, customizationDirectory, additionalDirectories); let agentSession: CopilotAgentSession | undefined; let agent: AgentSelection | undefined; try { + // Advertise exactly the clients Agent Host fanned this chat out to. + const snapshot = await activeClient.snapshot(chat.toString(), anchor?.object, provisional.agent?.uri); + const shellManager = this._instantiationService.createInstance(ShellManager, owner, chat, storage, workingDirectory); const resolvedAgent = provisional.isEphemeral ? undefined : await this._resolveAgentWhenMaterializing(provisional, snapshot, workingDirectory); agent = resolvedAgent?.agent; const launchPlan: CopilotSessionLaunchPlan = { @@ -5483,6 +5595,8 @@ export class CopilotAgent extends Disposable implements IAgent { } catch (error) { agentSession?.dispose(); throw error; + } finally { + anchor?.dispose(); } stageRecorder?.mark('persist'); @@ -5707,6 +5821,27 @@ export class CopilotAgent extends Disposable implements IAgent { options: { readonly operation: 'sendMessage' | 'resumeTurn' | 'startMcpServer'; readonly allowRestart: 'whenIdle' | 'always'; readonly turnId?: string; readonly token?: CancellationToken; readonly launchedForTurn?: boolean }, ): Promise { const activeClient = this._activeClients.get(context.ownerSession); + // The chat's own anchor set as a relaunch would apply it; held across a + // restart so the replacement reuses its discovery. + const anchor = activeClient + ? await this._acquireChatCustomizationAnchor(activeClient, entry.customizationDirectory ?? entry.workingDirectory, workingDirectories !== undefined ? workingDirectories.slice(1) : entry.appliedAdditionalDirectories) + : undefined; + try { + return await this._refreshSessionConfigurationWithAnchor(context, entry, workingDirectories, options, activeClient, anchor?.object); + } finally { + anchor?.dispose(); + } + } + + private async _refreshSessionConfigurationWithAnchor( + context: IResolvedCopilotChatContext, + entry: CopilotAgentSession, + workingDirectories: readonly URI[] | undefined, + options: { readonly operation: 'sendMessage' | 'resumeTurn' | 'startMcpServer'; readonly allowRestart: 'whenIdle' | 'always'; readonly turnId?: string; readonly token?: CancellationToken; readonly launchedForTurn?: boolean }, + activeClient: ActiveClient | undefined, + anchor: CustomizationAnchor | undefined, + ): Promise { + const selectedAgentUri = this._chatBackings.get(context.chatKey)?.agent?.uri; // MCP Stop still needs the queued sync to finish before it can resolve the server to stop. const waitToken = options.operation !== 'startMcpServer' ? options.token ?? CancellationToken.None : CancellationToken.None; const { operation, allowRestart, turnId, launchedForTurn } = options; @@ -5716,7 +5851,7 @@ export class CopilotAgent extends Disposable implements IAgent { // without re-awaiting the syncs and workspace scans the launch just completed. // A sync or discovery refresh that started since then is not yet reflected // in that state, so the turn then awaits it like any other send. - const useObservedState = launchedForTurn === true && !!activeClient?.pluginController.isSettled(); + const useObservedState = launchedForTurn === true && !!activeClient?.pluginController.isSettled(anchor); if (!useObservedState) { await activeClient?.pluginController.retryFailedClientSyncIfNeeded(waitToken); } @@ -5725,12 +5860,12 @@ export class CopilotAgent extends Disposable implements IAgent { const [storedWorkingDirectory, currentSnapshot] = await Promise.all([ this._readStoredWorkingDirectory(context.resource), !activeClient ? undefined - : useObservedState ? activeClient.currentSnapshot(context.chatKey) - : raceCancellationError(activeClient.snapshot(context.chatKey), waitToken), + : useObservedState ? activeClient.currentSnapshot(context.chatKey, anchor, selectedAgentUri) + : raceCancellationError(activeClient.snapshot(context.chatKey, anchor, selectedAgentUri), waitToken), ]); const primaryDirectoryChanged = !!storedWorkingDirectory && !isEqual(entry.workingDirectory, storedWorkingDirectory); - const structuralRestartReason = !entry.requiresRestartAfterWorkingDirectoryChange && !entry.requiresRestartAfterModelChange && activeClient && currentSnapshot - ? await raceCancellationError(activeClient.getRestartReason(entry.appliedSnapshot, context.chatKey, currentSnapshot), waitToken) + const structuralRestartReason = !entry.requiresRestartAfterWorkingDirectoryChange && !entry.requiresRestartAfterModelChange && !entry.requiresRestartAfterTelemetryRevocation && activeClient && currentSnapshot + ? await raceCancellationError(activeClient.getRestartReason(entry.appliedSnapshot, context.chatKey, currentSnapshot, anchor), waitToken) : undefined; const currentDisabledRootMcpServers = currentSnapshot ? await raceCancellationError(this._disabledRootMcpServers(context.ownerSession, currentSnapshot), waitToken) @@ -5742,6 +5877,7 @@ export class CopilotAgent extends Disposable implements IAgent { const refreshReason = (entry.requiresRestartAfterWorkingDirectoryChange ? 'workingDirectoryChanged' : undefined) ?? (primaryDirectoryChanged ? 'primaryDirectoryChanged' : undefined) ?? (entry.requiresRestartAfterModelChange ? 'hydraFusionModelChanged' : undefined) + ?? (entry.requiresRestartAfterTelemetryRevocation ? 'telemetryRevoked' : undefined) ?? (rootsChanged ? 'additionalDirectoriesChanged' : undefined) ?? structuralRestartReason ?? (disabledRootMcpServersChanged ? 'disabledRootMcpServersChanged' : undefined) @@ -5762,7 +5898,7 @@ export class CopilotAgent extends Disposable implements IAgent { throw new CopilotSessionConfigurationBusyError(entry); } this._logService.info(`[Copilot:${context.ownerSessionId}] Session configuration changed, refreshing session: operation=${operation}, sdkSessionId=${entry.sessionId}, chat=${context.chatKey}, turnId=${turnId ?? '(none)'}, reason=${refreshReason}, clients=[${activeClient ? [...activeClient.toolSet.clientIds()].join(', ') || '(none)' : '(none)'}]`); - const canvasHandoff = entry.takeCanvasProjection(); + const canvasHandoff = entry.takeCanvasProjection() ?? this._canvasHandoffs.get(entry.chatChannelUri); if (canvasHandoff) { this._canvasHandoffs.set(entry.chatChannelUri, canvasHandoff); } @@ -5788,9 +5924,10 @@ export class CopilotAgent extends Disposable implements IAgent { /** Commits handed-over canvases to the replacement the refresh kept, or closes them when there is none. */ private _settleCanvasHandoff(chat: URI, handoff: ICopilotCanvasHandoff, replacement: CopilotAgentSession | undefined): void { - if (this._canvasHandoffs.get(chat) === handoff) { - this._canvasHandoffs.delete(chat); + if (this._canvasHandoffs.get(chat) !== handoff) { + return; } + this._canvasHandoffs.delete(chat); if (!replacement?.adoptCanvasHandoff(handoff)) { this._onDidChatProgress.fire({ kind: 'action', resource: chat, action: { type: ActionType.ChatCanvasesChanged, canvases: undefined } }); } @@ -6091,6 +6228,7 @@ export class CopilotAgent extends Disposable implements IAgent { const queue = (task: () => Promise) => fork?.independentQueue ? this._queueChat(sessionId, chatKey, 'createChat', task) : this._queueSession(forkSourceSessionId ?? sessionId, task); + let anchor: IReference | undefined; await queue(async () => { const existing = this._chatBackings.get(chatKey); if (existing) { @@ -6117,12 +6255,16 @@ export class CopilotAgent extends Disposable implements IAgent { // SDK chat. Keying it by the chat URI instead would // snapshot empty/stale tools and never see subsequent updates, and // would also leak (nothing disposes a chat-keyed ActiveClient). + // Discovery, though, is the chat's own: it anchors to the chat's + // working directory, not to the session's folders. const anchors = await this._getSessionCustomizationAnchors(session, context.sessionSettings); const activeClient = this._getOrCreateActiveClient(session, anchors.directory ?? workingDirectory); if (anchors.applyAdditional) { activeClient.pluginController.setAdditionalDirectories(anchors.additionalDirectories); } - const snapshot = await activeClient.snapshot(chatKey); + // Held until the chat's entry holds it so the set is discovered only once. + anchor = await this._acquireChatCustomizationAnchor(activeClient, workingDirectory, undefined); + const snapshot = await activeClient.snapshot(chatKey, anchor?.object, agent?.uri); const shellManager = this._instantiationService.createInstance(ShellManager, session, chat, context.resource, workingDirectory); // The database copy lands in the storage scope Agent Host chose for // this chat, which is also the scope its runtime reads and writes. @@ -6235,7 +6377,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.warn(`[Copilot] Failed to copy reviewed ref for fork: ${err instanceof Error ? err.message : String(err)}`); } } - }); + }).finally(() => anchor?.dispose()); if (!result) { throw new Error(`[Copilot] createChat: no backing was recorded for ${chatKey}`); } @@ -6374,6 +6516,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (isProvisional) { this._provisionalSessions.delete(provisionalKey); } + this._canvasHandoffs.delete(chat); this._chatBackings.delete(chatKey); this._chatOwners.delete(chatKey); this._chatSubagentFormats.delete(chatKey); @@ -6664,9 +6807,15 @@ export class CopilotAgent extends Disposable implements IAgent { if (!lease) { return undefined; } + const canvasHandoff = this._canvasHandoffs.get(chat); + let replacement: CopilotAgentSession | undefined; try { - return await this._retryRestoreAfterClientReplacement(acquireClient => this._resumeChatSessionOnce(context, acquireClient, workingDirectories, stageRecorder)); + replacement = await this._retryRestoreAfterClientReplacement(acquireClient => this._resumeChatSessionOnce(context, acquireClient, workingDirectories, stageRecorder)); + return replacement; } finally { + if (canvasHandoff) { + this._settleCanvasHandoff(chat, canvasHandoff, replacement); + } lease.dispose(); } }); @@ -6675,6 +6824,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _resumeChatSessionOnce(context: IResolvedCopilotChatContext, acquireClient: () => Promise, workingDirectories?: readonly URI[], stageRecorder?: IAgentProviderSendStageRecorder): Promise { const { ownerSession, ownerSessionId, chat, chatKey } = context; let agentSession: CopilotAgentSession | undefined; + let anchor: IReference | undefined; try { this._throwIfWorkingDirectoryMutationBlocksChat(ownerSession, chat); const again = this._findChatByUri(chat); @@ -6720,10 +6870,17 @@ export class CopilotAgent extends Disposable implements IAgent { stageRecorder?.mark('snapshot'); const anchors = await this._getSessionCustomizationAnchors(ownerSession, context.sessionSettings); const activeClient = this._getOrCreateActiveClient(ownerSession, anchors.directory); - if (anchors.applyAdditional) { + const additionalDirectories = launchWorkingDirectories?.slice(1); + if (isEqual(context.resource, ownerSession)) { + // The default chat: the published view follows its own folders. + activeClient.pluginController.reanchor(workingDirectory, false); + activeClient.pluginController.setAdditionalDirectories(this._isMultiRootEnabled() ? additionalDirectories ?? [] : []); + } else if (anchors.applyAdditional) { activeClient.pluginController.setAdditionalDirectories(anchors.additionalDirectories); } - const snapshot = await activeClient.snapshot(chatKey); + // The chat's own anchor set, held until its entry holds it so the set is discovered only once. + anchor = await this._acquireChatCustomizationAnchor(activeClient, workingDirectory, additionalDirectories); + const snapshot = await activeClient.snapshot(chatKey, anchor?.object, info.agent?.uri); const disabledRootMcpServers = await this._disabledRootMcpServers(ownerSession, snapshot); const shellManager = this._instantiationService.createInstance(ShellManager, ownerSession, chat, context.resource, workingDirectory); const launchPlan: CopilotSessionLaunchPlan = { @@ -6732,7 +6889,7 @@ export class CopilotAgent extends Disposable implements IAgent { extensionSdkPath: this._extensionSdkPathByClient.get(client), sessionId: info.sdkSessionId, workingDirectory, - additionalDirectories: launchWorkingDirectories?.slice(1), + additionalDirectories, resolvedAgentName: info.agent ? this._resolveAgentName(snapshot, info.agent) : undefined, snapshot, disabledRootMcpServers, @@ -6760,6 +6917,8 @@ export class CopilotAgent extends Disposable implements IAgent { agentSession?.dispose(); this._logService.warn(`[Copilot] Failed to resume chat backing ${chatKey}: ${error instanceof Error ? error.message : String(error)}`); throw error; + } finally { + anchor?.dispose(); } } @@ -7208,6 +7367,10 @@ export class CopilotAgent extends Disposable implements IAgent { const client = await this._ensureClient(); return new Set(Object.keys((await client.rpc.mcp.config.list()).servers)); }, + getInstalledPlugins: async () => { + const client = await this._ensureClient(); + return (await client.rpc.plugins.list()).plugins; + }, serverToolHost: this._serverToolHost, onTurnEnded: () => this._onChatTurnEnded(), onConnectorRefreshRetryable: session => void this._scheduleConnectorRefresh(session), @@ -7268,13 +7431,36 @@ export class CopilotAgent extends Disposable implements IAgent { } private _createChatEntry(session: CopilotAgentSession, activeClient: ActiveClient): CopilotChatEntry { - return new CopilotChatEntry(session, activeClient, this._onMcpNotification, () => this._handleCopilotSessionAuthRequired()); + const anchor = this._acquireLiveChatCustomizationAnchor(activeClient, session.customizationDirectory ?? session.workingDirectory, session.appliedAdditionalDirectories); + return new CopilotChatEntry(session, activeClient, anchor, this._onMcpNotification, () => this._handleCopilotSessionAuthRequired()); + } + + /** + * Acquires a chat's customization anchor set: its own first working + * directory plus, when multi-root is enabled, its other working directories. + * Never the session's folders: a peer working elsewhere discovers its own + * folder's customizations. + */ + private async _acquireChatCustomizationAnchor(activeClient: ActiveClient, directory: URI | undefined, additionalDirectories: readonly URI[] | undefined): Promise | undefined> { + if (!directory) { + return undefined; + } + const additional = this._isMultiRootEnabled() ? additionalDirectories ?? [] : []; + await activeClient.pluginController.resolveAnchorPaths([directory, ...additional]); + return activeClient.pluginController.acquireAnchor(directory, additional); + } + + /** {@link _acquireChatCustomizationAnchor} for a chat's entry, whose launch already resolved the real paths. */ + private _acquireLiveChatCustomizationAnchor(activeClient: ActiveClient, directory: URI | undefined, additionalDirectories: readonly URI[]): IReference | undefined { + return directory ? activeClient.pluginController.acquireAnchor(directory, this._isMultiRootEnabled() ? additionalDirectories : []) : undefined; } private _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: ActiveClient): void { const current = this._chatBackings.get(chat.toString()); + // Created before the previous entry is released so a shared anchor set is not torn down and rediscovered. + const entry = this._createChatEntry(session, activeClient); this._chatEntriesBySdkId.deleteAndDispose(session.sessionId); - this._chatEntriesBySdkId.set(session.sessionId, this._createChatEntry(session, activeClient)); + this._chatEntriesBySdkId.set(session.sessionId, entry); this._chatBackings.set(chat.toString(), { ...current, sdkSessionId: session.sessionId }); this._backgroundWorkWatches.get(chat)?.attach(session); } @@ -7294,8 +7480,9 @@ export class CopilotAgent extends Disposable implements IAgent { } private _registerUnboundSession(session: CopilotAgentSession, activeClient: ActiveClient): void { + const entry = this._createChatEntry(session, activeClient); this._chatEntriesBySdkId.deleteAndDispose(session.sessionId); - this._chatEntriesBySdkId.set(session.sessionId, this._createChatEntry(session, activeClient)); + this._chatEntriesBySdkId.set(session.sessionId, entry); } /** Rejects a session initialized by a client that was stopped or replaced during launch. */ @@ -7354,6 +7541,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (session.hasPendingConnectorRefresh) { await session.refreshConnectors(); } + await session.revalidateTelemetryBeforeRegistration?.(); if (!this._sessionsPendingRegistration.deleteAndLeak(session)) { throw new CancellationError(); } @@ -7384,7 +7572,16 @@ export class CopilotAgent extends Disposable implements IAgent { } private _doResumeSession(sessionId: string, chatChannelUri: URI, workingDirectories?: readonly URI[], stageRecorder?: IAgentProviderSendStageRecorder): Promise { - return this._retryRestoreAfterClientReplacement(acquireClient => this._resumeSessionOnce(sessionId, chatChannelUri, acquireClient, workingDirectories, stageRecorder)); + const canvasHandoff = this._canvasHandoffs.get(chatChannelUri); + let replacement: CopilotAgentSession | undefined; + return this._retryRestoreAfterClientReplacement(acquireClient => this._resumeSessionOnce(sessionId, chatChannelUri, acquireClient, workingDirectories, stageRecorder)).then(session => { + replacement = session; + return session; + }).finally(() => { + if (canvasHandoff) { + this._settleCanvasHandoff(chatChannelUri, canvasHandoff, replacement); + } + }); } private async _resumeSessionOnce(sessionId: string, chatChannelUri: URI, acquireClient: () => Promise, workingDirectories?: readonly URI[], stageRecorder?: IAgentProviderSendStageRecorder): Promise { @@ -7419,8 +7616,10 @@ export class CopilotAgent extends Disposable implements IAgent { } else { resolvedWorkingDirectory = await this._resolveOwnedWorkingDirectoryForResume(this._sessionSettings.get(sessionUri), sessionUri, sessionId, workingDirectory); } - // Only an explicit chat-only move decouples shared discovery from the main workspace. - const customizationDirectory = storedMetadata.sharedCustomizationDirectories?.[0] ?? resolvedWorkingDirectory; + // The default chat anchors to its own folders, including after a + // chat-only move (`copilot.sharedCustomizationDirectories` is kept only + // for older builds). + const customizationDirectory = resolvedWorkingDirectory; // Always create an ActiveClient so the snapshot includes host + // session-discovered customizations, even when no client has // registered an active-client handle yet. @@ -7430,52 +7629,59 @@ export class CopilotAgent extends Disposable implements IAgent { // root on resume. Empty when single-root / gated off. A send-time // snapshot supersedes the persisted restoration seed. const launchWorkingDirectories = this._workingDirectoriesForResume(storedMetadata, workingDirectories) ?? storedMetadata.workingDirectories; - activeClient.pluginController.setAdditionalDirectories(this._additionalCustomizationDirectories(storedMetadata.sharedCustomizationDirectories ?? launchWorkingDirectories)); - // Prefer chat-scoped membership when this SDK session is already bound to a chat. - const snapshot = await activeClient.snapshot(this._findBoundSessionChatUri(sessionId)?.toString()); - - const disabledRootMcpServers = await this._disabledRootMcpServers(owner.get(), snapshot); - const shellManager = this._instantiationService.createInstance(ShellManager, owner.get(), this._findBoundSessionChatUri(sessionId) ?? sessionUri, sessionUri, resolvedWorkingDirectory); - const resolvedAgentName = storedMetadata.agent ? this._resolveAgentName(snapshot, storedMetadata.agent) : undefined; - if (storedMetadata.agent && !resolvedAgentName) { - this._logService.info(`[Copilot:${sessionId}] Stored custom agent is not available in the current plugin snapshot; resuming without a custom agent`); - } - const launchPlan: CopilotSessionLaunchPlan = { - kind: 'resume', - client, - extensionSdkPath: this._extensionSdkPathByClient.get(client), - sessionId, - workingDirectory: resolvedWorkingDirectory, - additionalDirectories: this._additionalCustomizationDirectories(launchWorkingDirectories), - resolvedAgentName, - snapshot, - disabledRootMcpServers, - activeClientToolSet: activeClient.toolSet, - shellManager, - githubCredentials: this._getGitHubSessionCredentials(), - workspaceless: storedMetadata.workspaceless, - stageRecorder, - fallback: { - model: storedMetadata.model, - longContextWindow: this._longContextWindowFor(storedMetadata.model?.id), - freeLongContext: this._isFreeLongContext(storedMetadata.model?.id), - }, - }; - - const agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient, { owner, chatChannelUri, resource: sessionUri, subagentChatUriFormat: this._subagentFormatFor(chatChannelUri) }); + const additionalDirectories = this._additionalCustomizationDirectories(launchWorkingDirectories); + activeClient.pluginController.setAdditionalDirectories(additionalDirectories); + // The chat's own anchor set (the published view's), held until its entry holds it. + const anchor = await this._acquireChatCustomizationAnchor(activeClient, customizationDirectory, additionalDirectories); try { - await this._initializeAndRegisterSession(agentSession, () => { - this._registerInitializedSession(sessionId, agentSession, activeClient, launchPlan.client); - }, async () => { - stageRecorder?.mark('persist'); - await this._storeSessionMetadata(sessionUri, undefined, undefined, launchWorkingDirectories, undefined, undefined); - }); - } catch (err) { - agentSession.dispose(); - throw err; - } + // Prefer chat-scoped membership when this SDK session is already bound to a chat. + const snapshot = await activeClient.snapshot(this._findBoundSessionChatUri(sessionId)?.toString(), anchor?.object, storedMetadata.agent?.uri); - return agentSession; + const disabledRootMcpServers = await this._disabledRootMcpServers(owner.get(), snapshot); + const shellManager = this._instantiationService.createInstance(ShellManager, owner.get(), this._findBoundSessionChatUri(sessionId) ?? sessionUri, sessionUri, resolvedWorkingDirectory); + const resolvedAgentName = storedMetadata.agent ? this._resolveAgentName(snapshot, storedMetadata.agent) : undefined; + if (storedMetadata.agent && !resolvedAgentName) { + this._logService.info(`[Copilot:${sessionId}] Stored custom agent is not available in the current plugin snapshot; resuming without a custom agent`); + } + const launchPlan: CopilotSessionLaunchPlan = { + kind: 'resume', + client, + extensionSdkPath: this._extensionSdkPathByClient.get(client), + sessionId, + workingDirectory: resolvedWorkingDirectory, + additionalDirectories, + resolvedAgentName, + snapshot, + disabledRootMcpServers, + activeClientToolSet: activeClient.toolSet, + shellManager, + githubCredentials: this._getGitHubSessionCredentials(), + workspaceless: storedMetadata.workspaceless, + stageRecorder, + fallback: { + model: storedMetadata.model, + longContextWindow: this._longContextWindowFor(storedMetadata.model?.id), + freeLongContext: this._isFreeLongContext(storedMetadata.model?.id), + }, + }; + + const agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient, { owner, chatChannelUri, resource: sessionUri, subagentChatUriFormat: this._subagentFormatFor(chatChannelUri) }); + try { + await this._initializeAndRegisterSession(agentSession, () => { + this._registerInitializedSession(sessionId, agentSession, activeClient, launchPlan.client); + }, async () => { + stageRecorder?.mark('persist'); + await this._storeSessionMetadata(sessionUri, undefined, undefined, launchWorkingDirectories, undefined, undefined); + }); + } catch (err) { + agentSession.dispose(); + throw err; + } + + return agentSession; + } finally { + anchor?.dispose(); + } } private _workingDirectoriesForResume( @@ -7735,7 +7941,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; workingDirectories?: readonly URI[]; customizationDirectory?: URI; sharedCustomizationDirectories?: readonly URI[]; workspaceless?: boolean }> { + private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; workingDirectories?: readonly URI[]; customizationDirectory?: URI; workspaceless?: boolean }> { const ref = await this._openMetadata(session); if (!ref) { return {}; @@ -7747,7 +7953,6 @@ export class CopilotAgent extends Disposable implements IAgent { [CopilotAgent._META_CWD]: true, [CopilotAgent._META_CWDS]: true, [CopilotAgent._META_CUSTOMIZATION_DIRECTORY]: true, - [CopilotAgent._META_SHARED_CUSTOMIZATION_DIRECTORIES]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, }); const cwd = m[CopilotAgent._META_CWD]; @@ -7759,7 +7964,6 @@ export class CopilotAgent extends Disposable implements IAgent { workingDirectory, workingDirectories: this._parseWorkingDirectories(m[CopilotAgent._META_CWDS], workingDirectory), customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, - sharedCustomizationDirectories: this._parseWorkingDirectories(m[CopilotAgent._META_SHARED_CUSTOMIZATION_DIRECTORIES], undefined), workspaceless: m[AH_META_WORKSPACELESS_DB_KEY] === 'true', }; } finally { @@ -8109,9 +8313,9 @@ export function mapToParsedPlugin(customizations: readonly DirectoryCustomizatio * - parsing + resolution helpers used by both host- and client-side * customizations * - * Per-session state (client-published customizations and on-disk - * customization discovery for the session's working directory) lives on {@link SessionPluginController}, - * one per {@link CopilotAgentSession}. Each session controller holds + * Per-session state (client-published customizations, and the on-disk + * customization discovery of each anchor set its chats use) lives on + * {@link SessionPluginController}, one per session. Each session controller holds * a reference back to this shared controller for the resolve/sync * helpers it needs. */ @@ -8293,7 +8497,71 @@ interface ITopLevelMcpServers { readonly owners?: ReadonlyMap; } -/** Per-session plugin/customization view that publishes session-scoped actions. */ +/** + * On-disk customization discovery (workspace and user-home conventions, and + * workspace MCP configuration) for one ordered anchor set. The chats of a + * session that anchor to the same set share one instance through + * {@link SessionPluginController.acquireAnchor}, so discovery never runs twice + * for the same set. + */ +class CustomizationAnchor extends Disposable { + private readonly _discovered = this._register(new MutableDisposable()); + private readonly _mcpDiscovery = this._register(new MutableDisposable()); + + /** Identifies the ordered anchor set; updated when its folders' real paths resolve, see {@link SessionPluginController.acquireAnchor}. */ + key: string; + + constructor( + key: string, + readonly directory: URI, + readonly additionalDirectories: readonly URI[], + /** The roots discovered as workspace folders; empty for a workspace-less scratch directory. */ + private readonly _workspaceDirectories: readonly URI[], + private readonly _userHome: URI, + private readonly _getClient: () => Promise, + private readonly _onDidChange: () => void, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IFileService private readonly _fileService: IFileService, + ) { + super(); + this.key = key; + } + + /** `undefined` once disposed (with the last chat using it, or the session), so a late read discovers nothing. */ + discoveredEntry(): SessionDiscoveredEntry | undefined { + if (this._store.isDisposed) { + return undefined; + } + if (!this._discovered.value) { + this._discovered.value = this._instantiationService.createInstance(SessionDiscoveredEntry, this._workspaceDirectories, this._userHome, this._getClient, this._onDidChange); + } + return this._discovered.value; + } + + mcpDiscoveryEntry(): SessionMcpDiscovery | undefined { + if (this._workspaceDirectories.length === 0 || this._store.isDisposed) { + return undefined; + } + if (!this._mcpDiscovery.value) { + const discovery = new SessionMcpDiscovery(this._workspaceDirectories, this._fileService); + this._mcpDiscovery.value = discovery; + this._register(discovery.onDidChange(() => this._onDidChange())); + } + return this._mcpDiscovery.value; + } + + /** Whether no discovery refresh is queued or running for this anchor set. */ + get isSettled(): boolean { + return (this._discovered.value?.isSettled ?? true) && (this._mcpDiscovery.value?.isSettled ?? true); + } +} + +/** + * Per-session plugin/customization view that publishes session-scoped actions. + * The published view is anchored to the session's default chat; every chat's + * SDK snapshot is anchored to that chat's own folders through + * {@link acquireAnchor}. + */ class SessionPluginController extends Disposable { private readonly _onDidPublish = this._register(new Emitter()); /** Per-session action stream (reset + per-item updates). */ @@ -8309,8 +8577,13 @@ class SessionPluginController extends Disposable { /** Per-client customization state; published customizations are the stable first-wins union of these entries. */ private readonly _clients = new Map(); - private readonly _sessionDiscovered: MutableDisposable = this._register(new MutableDisposable()); - private readonly _sessionMcpDiscovery = this._register(new MutableDisposable<{ readonly discovery: SessionMcpDiscovery; dispose(): void }>()); + /** Symlink-resolved paths of anchor directories, by their spelling; filled by {@link resolveAnchorPaths}. */ + private readonly _realPaths = new Map(); + + /** Live anchor sets of this session, refcounted by the chats (and the published view) that use them. */ + private readonly _anchors = new Map(); + /** The published view's reference to its anchor set, acquired lazily from {@link _directory} and {@link _additionalDirectories}. */ + private readonly _published = this._register(new MutableDisposable>()); /** Additional multi-root workspace folders (roots 1..N); the primary root is tracked separately. */ private _additionalDirectories: readonly URI[] = []; @@ -8325,13 +8598,150 @@ class SessionPluginController extends Disposable { private readonly _topLevelMcpServers: () => ITopLevelMcpServers, @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instantiationService: IInstantiationService, - @IFileService private readonly _fileService: IFileService, @IAgentHostCustomizationEnablementService private readonly _customizationEnablementService: IAgentHostCustomizationEnablementService, ) { super(); this._enablementReady = this._customizationEnablementService.initializeSession(this._session.toString()).then(() => { this._isEnablementReady = true; }); + this._register(toDisposable(() => { + for (const { anchor } of this._anchors.values()) { + anchor.dispose(); + } + this._anchors.clear(); + })); + } + + /** + * Acquires the shared discovery for an ordered anchor set. Chats of this + * session that anchor to the same set share one {@link CustomizationAnchor}; + * it is disposed when its last reference is released, or with this controller. + */ + public acquireAnchor(directory: URI, additionalDirectories: readonly URI[]): IReference { + const key = this._anchorKey(directory, additionalDirectories); + let entry = this._anchors.get(key); + if (!entry) { + const workspaceDirectories = isEqual(directory, workspacelessScratchDir(this._parent.getUserHome(), AgentSession.id(this._session))) ? [] : [directory, ...additionalDirectories]; + const anchor: CustomizationAnchor = this._instantiationService.createInstance(CustomizationAnchor, key, directory, additionalDirectories, workspaceDirectories, this._parent.getUserHome(), () => this._parent.getClient(), () => this._onDidChangeAnchor(anchor)); + entry = { anchor, refs: 0 }; + if (this._store.isDisposed) { + // A late acquisition after session finalize owns its anchor outright. + return { object: anchor, dispose: () => anchor.dispose() }; + } + this._anchors.set(key, entry); + } + entry.refs++; + const held = entry; + let released = false; + return { + object: held.anchor, + dispose: () => { + if (released) { + return; + } + released = true; + if (--held.refs === 0 && this._anchors.get(held.anchor.key) === held) { + this._anchors.delete(held.anchor.key); + held.anchor.dispose(); + } + }, + }; + } + + /** + * Resolves the real paths of `directories` so that {@link acquireAnchor} + * keys a folder reached through different spellings (a symlink such as + * `/tmp` and `/private/tmp`, or a different case where paths ignore case) + * as one anchor set. Only the key is affected: an anchor set discovers the + * directories it was first acquired with. A folder whose real path is not + * yet resolved, or cannot be, is keyed by its own spelling; live anchor sets + * acquired before their folders resolved are re-keyed once they do. + */ + public async resolveAnchorPaths(directories: readonly URI[]): Promise { + let resolved = false; + await Promise.all(directories.map(async directory => { + if (directory.scheme !== Schemas.file || this._realPaths.has(directory.fsPath)) { + return; + } + try { + const realPath = await fs.realpath(directory.fsPath); + if (!this._realPaths.has(directory.fsPath)) { + this._realPaths.set(directory.fsPath, realPath); + resolved = true; + } + } catch { + // A missing folder keeps its own spelling. + } + })); + if (resolved) { + this._rekeyAnchors(); + } + } + + /** + * Moves live anchor sets to the keys of their now-resolved folders, so a set + * acquired under its spelling (e.g. by an early publish) is shared rather than + * discovered again. When another live set already holds the resolved key, + * both stay live until released; only new acquisitions share the holder. + */ + private _rekeyAnchors(): void { + for (const [key, entry] of [...this._anchors]) { + const resolvedKey = this._anchorKey(entry.anchor.directory, entry.anchor.additionalDirectories); + if (resolvedKey === key || this._anchors.has(resolvedKey)) { + continue; + } + this._anchors.delete(key); + entry.anchor.key = resolvedKey; + this._anchors.set(resolvedKey, entry); + } + } + + /** The key of an ordered anchor set: the comparison keys of its directories' real paths. */ + private _anchorKey(directory: URI, additionalDirectories: readonly URI[]): string { + return [directory, ...additionalDirectories].map(candidate => { + const realPath = candidate.scheme === Schemas.file ? this._realPaths.get(candidate.fsPath) : undefined; + return extUriBiasedIgnorePathCase.getComparisonKey(realPath !== undefined ? URI.file(realPath) : candidate); + }).join('\n'); + } + + /** The anchor sets currently alive for this session, each as the `[primary, ...additional]` directories it discovers. */ + public liveAnchorKeys(): readonly string[] { + return [...this._anchors.values()].map(({ anchor }) => [anchor.directory, ...anchor.additionalDirectories].map(directory => directory.toString()).join('\n')); + } + + private _onDidChangeAnchor(anchor: CustomizationAnchor): void { + // Only the published view's anchor set feeds session state; a chat on + // another set picks up its changes from its next snapshot. Compared by + // key: the published view may not have re-read its set since it moved. + if (this._published.value?.object === anchor || (this._directory && anchor.key === this._anchorKey(this._directory, this._additionalDirectories))) { + this._publish(() => ({ + type: ActionType.SessionCustomizationsChanged, + customizations: [...this.getCustomizations()], + })); + } + } + + /** + * Publishes the whole view once its anchor set's discovery has settled, for + * a move of the published view that no host operation republishes (a + * chat-only move of the default chat): a set's initial scan does not notify. + */ + public publishWhenSettled(): void { + this.getCustomizationsSettled().then(() => this._publish(() => ({ + type: ActionType.SessionCustomizationsChanged, + customizations: [...this.getCustomizations()], + })), error => this._logService.warn('[Copilot:SessionPluginController] Failed to publish customizations after the anchor moved', error)); + } + + /** The anchor set of the published view, or `undefined` before the session has a customization directory or once it is disposed. */ + private _publishedAnchor(): CustomizationAnchor | undefined { + if (!this._directory || this._store.isDisposed) { + return undefined; + } + if (!this._published.value) { + this._published.value = this.acquireAnchor(this._directory, this._additionalDirectories); + } + return this._published.value.object; } public get directory(): URI | undefined { @@ -8357,10 +8767,11 @@ class SessionPluginController extends Disposable { } /** - * Set the additional (non-primary) workspace roots. Recreates the discovered - * entry when the set actually changes so discovery re-scans every root โ€” - * important when this is set after a primary-only entry was already created - * (e.g. on resume). A no-op for the single-root case (empty tail). + * Set the published view's additional (non-primary) workspace roots. Moves + * the published view to the matching anchor set when the set actually + * changes so discovery covers every root โ€” important when this is set after + * a primary-only set was already in use (e.g. on resume). A no-op for the + * single-root case (empty tail). */ public setAdditionalDirectories(directories: readonly URI[]): void { if (this._additionalDirectories.length === directories.length @@ -8368,30 +8779,31 @@ class SessionPluginController extends Disposable { return; } this._additionalDirectories = directories; - this._sessionDiscovered.clear(); - this._sessionMcpDiscovery.clear(); + this._published.clear(); } /** * Move the session's customization anchor to a new directory (e.g. from the - * user-picked folder to the worktree at materialization). Recreates the - * discovered entry so discovery/watchers re-scan the new directory. + * user-picked folder to the worktree at materialization). The published view + * moves to the new directory's anchor set so discovery/watchers cover it. + * + * @param rebaseEnablement whether enablement recorded for customizations under + * the previous directory carries over to the same paths under the new one. */ - public reanchor(directory: URI): void { + public reanchor(directory: URI, rebaseEnablement = true): void { if (this._directory && isEqual(this._directory, directory)) { return; } const previous = this._directory; this._directory = directory; - this._sessionDiscovered.clear(); - this._sessionMcpDiscovery.clear(); - if (previous && !this._previousDirectories.some(candidate => isEqual(candidate, previous))) { + this._published.clear(); + if (rebaseEnablement && previous && !this._previousDirectories.some(candidate => isEqual(candidate, previous))) { this._previousDirectories.push(previous); } } public getCustomizations(): readonly Customization[] { - return this._resolveCustomizationEnablement().customizations; + return this._resolveCustomizationEnablement(this._publishedAnchor()).customizations; } /** The session's runtime-owned top-level MCP servers, with enablement resolved for publication. */ @@ -8400,7 +8812,8 @@ class SessionPluginController extends Disposable { return resolveCustomizationEnablement(this._customizationEnablementService, this._session, customizations, this._clientChildEnablement(), undefined, owners).customizations; } - private _resolveCustomizationEnablement() { + /** Host, client and the given anchor set's discovered customizations, with enablement resolved. */ + private _resolveCustomizationEnablement(anchor: CustomizationAnchor | undefined) { const hostCustomizations = this._parent.hostCustomizations(); const clientCustomizations = this._flattenClientCustomizations(); const explicitPluginIds = new Set([ @@ -8411,15 +8824,14 @@ class SessionPluginController extends Disposable { ...hostCustomizations.map(item => this._projectForPublish(item.customization)), ...clientCustomizations.map(item => this._projectForPublish(item.customization)), ]; - const entry = this._discoveredEntry(); - const discovered = entry?.currentCustomizations() ?? []; + const discovered = anchor?.discoveredEntry()?.currentCustomizations() ?? []; for (const customization of discovered) { if (customization.type === CustomizationType.Plugin && explicitPluginIds.has(customization.id)) { continue; } result.push(this._projectForPublish(customization)); } - for (const definition of this._mcpDiscoveryEntry()?.definitions ?? []) { + for (const definition of anchor?.mcpDiscoveryEntry()?.definitions ?? []) { result.push(this._projectForPublish(definition.customization)); } result.push(buildCopilotBuiltinAgentsContainer()); @@ -8457,18 +8869,24 @@ class SessionPluginController extends Disposable { */ public async getCustomizationsSettled(): Promise { await this._enablementReady; - const entry = this._discoveredEntry(); + if (this._directory) { + await this.resolveAnchorPaths([this._directory, ...this._additionalDirectories]); + } + const anchor = this._publishedAnchor(); await Promise.all([ this._parent.hostSync().catch(err => this._logService.warn('[Copilot:SessionPluginController] Host customization update failed', err)), ...[...this._clients.values()].map(client => client.sync.catch(err => this._logService.warn('[Copilot:SessionPluginController] Client customization sync failed', err))), - entry?.whenSettled(), - this._mcpDiscoveryEntry()?.refresh(), + anchor?.discoveredEntry()?.whenSettled(), + anchor?.mcpDiscoveryEntry()?.refresh(), ]); return this.getCustomizations(); } - /** Returns the parsed plugins currently enabled for this session, awaiting any pending sync. */ - public async getAppliedPlugins(): Promise { + /** + * Returns the parsed plugins enabled for a chat on `anchor` (the published + * view's anchor set when omitted), awaiting any pending sync. + */ + public async getAppliedPlugins(anchor: CustomizationAnchor | undefined = this._publishedAnchor()): Promise { await this._customizationEnablementService.initializeSession(this._session.toString()); const [host] = await Promise.all([ this._parent.hostSync().catch(err => { @@ -8479,18 +8897,56 @@ class SessionPluginController extends Disposable { this._logService.warn('[Copilot:SessionPluginController] Client customization sync failed', err); return client.customizations; })), - this._discoveredEntry()?.whenSettled(), - this._mcpDiscoveryEntry()?.refresh(), + anchor?.discoveredEntry()?.whenSettled(), + anchor?.mcpDiscoveryEntry()?.refresh(), ]); - return this._appliedPlugins(host); + return this._appliedPlugins(host, anchor); } /** * The plugins enabled from the state already observed in memory, without * awaiting pending syncs or re-scanning workspace MCP configuration. */ - public currentAppliedPlugins(): readonly ICopilotPluginInfo[] { - return this._appliedPlugins(this._parent.hostCustomizations()); + public currentAppliedPlugins(anchor: CustomizationAnchor | undefined = this._publishedAnchor()): readonly ICopilotPluginInfo[] { + return this._appliedPlugins(this._parent.hostCustomizations(), anchor); + } + + /** + * A chat's agent picker lists the published view's agents (the default + * chat's), so a chat's selected custom agent that its own `anchor` set does + * not discover is taken from the published view's anchor set, so that an + * agent the picker shows always resolves. Only the agent carries over; the + * chat's instructions and skills stay its own folder's. + */ + public async withSelectedAgent(plugins: readonly ICopilotPluginInfo[], anchor: CustomizationAnchor | undefined, selectedAgentUri: string | undefined): Promise { + const published = this._selectedAgentFallbackAnchor(plugins, anchor, selectedAgentUri); + return published ? this._withAgentFrom(plugins, await this.getAppliedPlugins(published), selectedAgentUri!) : plugins; + } + + /** {@link withSelectedAgent} from the state already observed in memory. */ + public currentWithSelectedAgent(plugins: readonly ICopilotPluginInfo[], anchor: CustomizationAnchor | undefined, selectedAgentUri: string | undefined): readonly ICopilotPluginInfo[] { + const published = this._selectedAgentFallbackAnchor(plugins, anchor, selectedAgentUri); + return published ? this._withAgentFrom(plugins, this.currentAppliedPlugins(published), selectedAgentUri!) : plugins; + } + + private _selectedAgentFallbackAnchor(plugins: readonly ICopilotPluginInfo[], anchor: CustomizationAnchor | undefined, selectedAgentUri: string | undefined): CustomizationAnchor | undefined { + if (!selectedAgentUri || !anchor || plugins.some(plugin => plugin.agents.some(agent => agent.uri.toString() === selectedAgentUri))) { + return undefined; + } + const published = this._publishedAnchor(); + return published !== anchor ? published : undefined; + } + + private _withAgentFrom(plugins: readonly ICopilotPluginInfo[], source: readonly ICopilotPluginInfo[], selectedAgentUri: string): readonly ICopilotPluginInfo[] { + const agent = source.flatMap(plugin => plugin.agents).find(candidate => candidate.uri.toString() === selectedAgentUri); + return agent ? [...plugins, { + format: PluginFormat.Copilot, + hooks: [], + mcpServers: [], + skills: [], + agents: [agent], + instructions: [], + } satisfies ICopilotPluginInfo] : plugins; } /** @@ -8499,19 +8955,17 @@ class SessionPluginController extends Disposable { * customizations `Loading` as soon as it starts and those are filtered out * of the applied plugins until it completes. */ - public isSettled(): boolean { + public isSettled(anchor: CustomizationAnchor | undefined = this._publishedAnchor()): boolean { const isLoading = (items: readonly IResolvedCustomization[]) => items.some(item => item.customization.load?.kind === CustomizationLoadStatus.Loading); return this._isEnablementReady && !isLoading(this._parent.hostCustomizations()) && ![...this._clients.values()].some(client => isLoading(client.customizations)) - && (this._sessionDiscovered.value?.isSettled ?? true) - && (this._sessionMcpDiscovery.value?.discovery.isSettled ?? true); + && (anchor?.isSettled ?? true); } - private _appliedPlugins(host: readonly IResolvedCustomization[]): readonly ICopilotPluginInfo[] { - const entry = this._discoveredEntry(); - const mcpDiscovery = this._mcpDiscoveryEntry(); - const resolved = this._resolveCustomizationEnablement(); + private _appliedPlugins(host: readonly IResolvedCustomization[], anchor: CustomizationAnchor | undefined): readonly ICopilotPluginInfo[] { + const mcpDiscovery = anchor?.mcpDiscoveryEntry(); + const resolved = this._resolveCustomizationEnablement(anchor); const desiredByUri = new Map(resolved.customizations.map(customization => [customization.uri, customization])); const desiredById = new Map(resolved.customizations.map(customization => [customization.id, customization])); const mcpEnablement = getSdkMcpServerEnablement(resolved); @@ -8526,7 +8980,7 @@ class SessionPluginController extends Disposable { : undefined; return children?.length ? children : undefined; }; - const discovered = entry?.currentCustomizations() ?? []; + const discovered = anchor?.discoveredEntry()?.currentCustomizations() ?? []; const discoveredDirectories = discovered.filter((customization): customization is DirectoryCustomization => customization.type === CustomizationType.Directory); const sessionPlugin = discoveredDirectories.some(isEnabledForSdk) ? mapToParsedPlugin(discoveredDirectories) : undefined; const withSdkRegistration = (plugin: IParsedPlugin, pluginDir: URI | undefined): ICopilotPluginInfo => ({ @@ -8536,7 +8990,7 @@ class SessionPluginController extends Disposable { }); const sessionPlugins: ICopilotPluginInfo[] = sessionPlugin ? [withSdkRegistration(sessionPlugin, undefined)] : []; - const primaryCwd = this._directory; + const primaryCwd = anchor?.directory ?? this._directory; const withClientDefaults = (item: IResolvedCustomization): ICopilotPluginInfo => { const plugin = item.plugin!; // Standalone customizations must not reach the SDK as plugin content: without a @@ -8723,49 +9177,6 @@ class SessionPluginController extends Disposable { } } - private _discoveredEntry(): SessionDiscoveredEntry | undefined { - if (!this._directory) { - return undefined; - } - - if (!this._sessionDiscovered.value) { - this._sessionDiscovered.value = this._instantiationService.createInstance(SessionDiscoveredEntry, - this._workspaceCustomizationDirectories(), - this._parent.getUserHome(), - () => this._parent.getClient(), - () => this._publish(() => ({ - type: ActionType.SessionCustomizationsChanged, - customizations: [...this.getCustomizations()], - })) - ); - } - return this._sessionDiscovered.value; - } - - private _mcpDiscoveryEntry(): SessionMcpDiscovery | undefined { - const workingDirectories = this._workspaceCustomizationDirectories(); - if (workingDirectories.length === 0) { - return undefined; - } - if (!this._sessionMcpDiscovery.value) { - const store = new DisposableStore(); - const discovery = store.add(new SessionMcpDiscovery(workingDirectories, this._fileService)); - store.add(discovery.onDidChange(() => this._publish(() => ({ - type: ActionType.SessionCustomizationsChanged, - customizations: [...this.getCustomizations()], - })))); - this._sessionMcpDiscovery.value = { discovery, dispose: () => store.dispose() }; - } - return this._sessionMcpDiscovery.value.discovery; - } - - private _workspaceCustomizationDirectories(): readonly URI[] { - if (!this._directory || isEqual(this._directory, workspacelessScratchDir(this._parent.getUserHome(), AgentSession.id(this._session)))) { - return []; - } - return [this._directory, ...this._additionalDirectories]; - } - private _publish(action: () => SessionAction): void { const publish = () => { if (!this._store.isDisposed) { @@ -9100,11 +9511,16 @@ class ActiveClient extends Disposable { this.pluginController.removeClient(clientId); } - /** Builds the client/plugin/MCP snapshot a chat should advertise to its SDK session. */ - async snapshot(chatKey?: string): Promise { + /** + * Builds the client/plugin/MCP snapshot a chat should advertise to its SDK + * session: the session's client tools and plugins, plus the customizations + * discovered for the chat's own `anchor` set (the published view's when omitted), + * and the chat's selected custom agent (see {@link SessionPluginController.withSelectedAgent}). + */ + async snapshot(chatKey?: string, anchor?: CustomizationAnchor, selectedAgentUri?: string): Promise { return { tools: chatKey === undefined ? this.toolSet.merged() : this.toolsForChat(chatKey), - plugins: await this.pluginController.getAppliedPlugins(), + plugins: await this.pluginController.withSelectedAgent(await this.pluginController.getAppliedPlugins(anchor), anchor, selectedAgentUri), mcpServers: await this._getMcpServers(), }; } @@ -9113,10 +9529,10 @@ class ActiveClient extends Disposable { * The snapshot from the state already observed in memory, without awaiting * pending customization syncs or re-scanning workspace MCP configuration. */ - currentSnapshot(chatKey: string): IActiveClientSnapshot { + currentSnapshot(chatKey: string, anchor?: CustomizationAnchor, selectedAgentUri?: string): IActiveClientSnapshot { return { tools: this.toolsForChat(chatKey), - plugins: this.pluginController.currentAppliedPlugins(), + plugins: this.pluginController.currentWithSelectedAgent(this.pluginController.currentAppliedPlugins(anchor), anchor, selectedAgentUri), mcpServers: this._currentMcpServers(), }; } @@ -9135,8 +9551,8 @@ class ActiveClient extends Disposable { } /** Reports the first changed structural contribution without including its contents. */ - async getRestartReason(snap: IActiveClientSnapshot, chatKey?: string, current?: IActiveClientSnapshot): Promise { - current ??= await this.snapshot(chatKey); + async getRestartReason(snap: IActiveClientSnapshot, chatKey?: string, current?: IActiveClientSnapshot, anchor?: CustomizationAnchor): Promise { + current ??= await this.snapshot(chatKey, anchor); if (!parsedPluginsEqual(snap.plugins, current.plugins)) { return 'clientPluginsChanged'; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 1bc37ecc5574d8..3aa7fd3a2e9771 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -17,7 +17,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { Emitter } from '../../../../base/common/event.js'; import { CancellationError, getErrorMessage, isCancellationError } from '../../../../base/common/errors.js'; import { escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, IReference, type IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IReference, type IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { isAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; @@ -120,7 +120,7 @@ import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, import type { ErrorInfo, ProtectedResourceMetadata } from '../../common/state/protocol/common/state.js'; import { CopilotSlashCommandProvider } from './copilotSlashCommandProvider.js'; import { CopilotMcpToolRoutingCache, getMcpRoutingCacheKey, getMcpRoutingProxyName, type ICopilotMcpRoutingServer, type ICopilotMcpRoutingTool } from './copilotMcpToolRoutingCache.js'; -import { getCopilotCustomizationCommandHandler, installCopilotPlugin, removeCopilotPluginMarketplace, uninstallCopilotPlugin } from './copilotCustomizationCommandDisplay.js'; +import { getCopilotCustomizationCommandHandler, installCopilotPlugin, type ICopilotPluginCommandApi, removeCopilotPluginMarketplace, uninstallCopilotPlugin } from './copilotCustomizationCommandDisplay.js'; import { renderCopilotSlashCommandOutput, type RuntimeSlashCommandInfo } from './copilotSlashCommand.js'; import { CopilotSandboxPolicyDisplay } from './copilotSandboxPolicyDisplay.js'; import { toSlashCommandResourceMeta } from '../../common/meta/agentSlashCommandOutputMeta.js'; @@ -647,6 +647,8 @@ export interface ICopilotAgentSessionOptions { readonly hostCustomizations?: () => readonly Customization[]; /** Reads configured user servers before SDK launch, without waiting for their connection inventory. */ readonly getUserMcpServerNames?: () => Promise>; + /** Lists globally installed plugins, including disabled entries omitted from the live session inventory. */ + readonly getInstalledPlugins?: () => Promise>['plugins']>; /** * Live registry of every active client's tool contributions, shared by * reference with the agent's per-session {@link ActiveClient}. Read at @@ -1542,6 +1544,7 @@ export class CopilotAgentSession extends Disposable { private readonly _sessionUsageMetricsRefreshThrottler = this._register(new Throttler()); /** SDK session wrapper, set by {@link initializeSession}. */ private _wrapper!: CopilotSessionWrapper; + private readonly _wrapperDisposables = this._register(new DisposableStore()); /** BYOK providers and models registered on {@link _wrapper}; seeded at launch and grown by {@link syncByokModels}. */ private _registeredByokConfig: ICopilotByokSessionConfig = {}; private readonly _byokSync = new Sequencer(); @@ -1554,6 +1557,7 @@ export class CopilotAgentSession extends Disposable { private _workingDirectoryMutationInProgress = false; private _requiresRestartAfterWorkingDirectoryChange = false; private _requiresRestartAfterModelChange = false; + private _requiresRestartAfterTelemetryRevocation = false; private readonly _slashCommandProvider: CopilotSlashCommandProvider; /** Last agent mode pushed to the SDK via {@link applyMode}, to elide redundant `rpc.mode.set` calls. */ private _lastAppliedMode: CopilotSdkMode | undefined; @@ -1796,7 +1800,7 @@ export class CopilotAgentSession extends Disposable { const sandboxPolicyDisplay = this._instantiationService.createInstance(CopilotSandboxPolicyDisplay, this.sessionId, this._storageUri); this._slashCommandProvider = new CopilotSlashCommandProvider( () => this._wrapper.session.rpc.commands.list({ includeBuiltins: true, includeSkills: true, includeClientCommands: true }).then(c => c.commands), - { getCommandHandler: command => sandboxPolicyDisplay.getHandler(command) ?? getCopilotCustomizationCommandHandler(command, this._wrapper.session.rpc.plugins) }, + { getCommandHandler: command => sandboxPolicyDisplay.getHandler(command) ?? getCopilotCustomizationCommandHandler(command, this._wrapper.session.rpc.plugins, options.getInstalledPlugins) }, this._logService, ); this._onDidSessionProgress = options.onDidSessionProgress; @@ -3206,6 +3210,14 @@ export class CopilotAgentSession extends Disposable { this._requiresRestartAfterModelChange = true; } + get requiresRestartAfterTelemetryRevocation(): boolean { + return this._requiresRestartAfterTelemetryRevocation; + } + + markTelemetryRevocationRequiresRestart(): void { + this._requiresRestartAfterTelemetryRevocation = true; + } + get requiresMcpLaunchConfigurationRefresh(): boolean { this._markMcpLaunchConfigurationDirty(); return this._mcpLaunchConfigurationDirty; @@ -3632,41 +3644,6 @@ export class CopilotAgentSession extends Disposable { if (this._store.isDisposed) { throw new CancellationError(); } - const wrapper = await this._sessionLauncher.launch(this._launchPlan, this._createRuntimeAdapter()); - // The session may have been disposed while we were awaiting the - // launcher. If so, dispose the freshly-created wrapper and - // skip subscribing โ€” registering on a disposed store would leak. - if (this._store.isDisposed) { - wrapper.dispose(); - throw new CancellationError(); - } - this._wrapper = this._register(wrapper); - this._registeredByokConfig = wrapper.launchByokConfig; - this._reportLocalModelSelection(this._launchPlan.kind === 'create' ? this._launchPlan.model?.id : this._launchPlan.fallback.model?.id); - this._canvasExtensions = undefined; - this._ignoredRestoredCanvasInstanceIds.clear(); - if (this._launchPlan.kind === 'resume') { - for (const canvas of wrapper.session.openCanvases) { - if (!this._canvasByInstanceId.has(canvas.instanceId)) { - this._ignoredRestoredCanvasInstanceIds.add(canvas.instanceId); - } - } - } - if (!wrapper.canvasRuntimeEnabled) { - this._canvasByInstanceId.clear(); - } - const samplingInterest = await wrapper.session.rpc.eventLog.registerInterest({ eventType: 'sampling.requested' }); - if (this._store.isDisposed) { - throw new CancellationError(); - } - wrapper.setBeforeDisconnect(async () => { - await Promise.allSettled(this._pendingOutputCaptures); - try { - await this._awaitControlPlaneRpc('rpc.eventLog.releaseInterest', wrapper.session.rpc.eventLog.releaseInterest({ handle: samplingInterest.handle })); - } catch (error) { - this._logService.error(error, `[Copilot:${this.sessionId}] Failed to release sampling event interest`); - } - }); this._register(this._customizationEnablementService.onDidChange(event => { if (!event.sessions.includes(this.launchOwner.toString())) { return; @@ -3674,25 +3651,8 @@ export class CopilotAgentSession extends Disposable { this._markMcpLaunchConfigurationDirty(); this._reconcileMcpServerEnablement().catch(error => this._logService.error(error, `[Copilot:${this.sessionId}] Failed to reconcile MCP enablement after customizations changed`)); })); - this._subscribeToEvents(); - this._subscribeToSdkEvents(); - this._readCommittedAutoTier(wrapper); - this._subscribeForMemoInvalidation(); - this._subscribeForInstructionsCollectedTelemetry(); this._subscribeToPermissionConfigChanges(); - await this._sandbox.updateDiagnostics(); - await this._waitForCanvasExtensions(wrapper); - await this._reconcileCanvasHandoff(wrapper); - await this._syncShellInitScript(); - this._promptCacheState = this._promptCache.read(this.resourceUri); - if (this._launchPlan.kind === 'resume') { - await this._refreshSessionUsageMetrics(); - if (this._store.isDisposed) { - throw new CancellationError(); - } - // Warm the persisted-event reconstruction so canonical subagent names are known before the next turn labels agent reads. - this._getMappedEvents().catch(() => { /* surfaced to whichever caller reads the messages */ }); - } + await this._initializeSdkWrapper(); // Advertise the agent host's server tools for this session so clients // see them as server-provided. Execution happens in-process via the SDK @@ -3700,6 +3660,104 @@ export class CopilotAgentSession extends Disposable { this._serverToolHost?.advertise(this._storageUri.toString()); } + /** + * Re-launches the underlying SDK session if telemetry was revoked after + * {@link initializeSession} finished its launch loop (e.g. while + * `beforeRegistration` or `refreshConnectors` was running). + */ + async revalidateTelemetryBeforeRegistration(): Promise { + while (!this._store.isDisposed && this._requiresRestartAfterTelemetryRevocation) { + await this._relaunchSdkWrapperForTelemetryRevocation(); + } + } + + private async _initializeSdkWrapper(): Promise { + while (true) { + this._requiresRestartAfterTelemetryRevocation = false; + const wrapper = await this._sessionLauncher.launch(this._launchPlan, this._createRuntimeAdapter()); + if (this._store.isDisposed) { + wrapper.dispose(); + throw new CancellationError(); + } + if (this._requiresRestartAfterTelemetryRevocation) { + await wrapper.disconnect().catch(() => { /* best-effort teardown */ }); + wrapper.dispose(); + if (this._store.isDisposed) { + throw new CancellationError(); + } + continue; + } + this._wrapperDisposables.clear(); + this._wrapper = this._wrapperDisposables.add(wrapper); + this._registeredByokConfig = wrapper.launchByokConfig; + this._reportLocalModelSelection(this._launchPlan.kind === 'create' ? this._launchPlan.model?.id : this._launchPlan.fallback.model?.id); + this._canvasExtensions = undefined; + this._ignoredRestoredCanvasInstanceIds.clear(); + if (this._launchPlan.kind === 'resume') { + for (const canvas of wrapper.session.openCanvases) { + if (!this._canvasByInstanceId.has(canvas.instanceId)) { + this._ignoredRestoredCanvasInstanceIds.add(canvas.instanceId); + } + } + } + if (!wrapper.canvasRuntimeEnabled) { + this._canvasByInstanceId.clear(); + } + const samplingInterest = await wrapper.session.rpc.eventLog.registerInterest({ eventType: 'sampling.requested' }); + if (this._store.isDisposed) { + throw new CancellationError(); + } + wrapper.setBeforeDisconnect(async () => { + await Promise.allSettled(this._pendingOutputCaptures); + try { + await this._awaitControlPlaneRpc('rpc.eventLog.releaseInterest', wrapper.session.rpc.eventLog.releaseInterest({ handle: samplingInterest.handle })); + } catch (error) { + this._logService.error(error, `[Copilot:${this.sessionId}] Failed to release sampling event interest`); + } + }); + this._subscribeToEvents(this._wrapperDisposables); + this._subscribeToSdkEvents(this._wrapperDisposables); + this._readCommittedAutoTier(wrapper); + this._subscribeForMemoInvalidation(this._wrapperDisposables); + this._subscribeForInstructionsCollectedTelemetry(this._wrapperDisposables); + await this._sandbox.updateDiagnostics(); + await this._waitForCanvasExtensions(wrapper); + await this._reconcileCanvasHandoff(wrapper); + await this._syncShellInitScript(); + this._promptCacheState = this._promptCache.read(this.resourceUri); + if (this._launchPlan.kind === 'resume') { + await this._refreshSessionUsageMetrics(); + if (this._store.isDisposed) { + throw new CancellationError(); + } + // Warm the persisted-event reconstruction so canonical subagent names are known before the next turn labels agent reads. + this._getMappedEvents().catch(() => { /* surfaced to whichever caller reads the messages */ }); + } + if (!this._requiresRestartAfterTelemetryRevocation) { + return; + } + await this._disconnectAndClearWrapper(); + } + } + + private async _relaunchSdkWrapperForTelemetryRevocation(): Promise { + await this._disconnectAndClearWrapper(); + if (this._store.isDisposed) { + throw new CancellationError(); + } + await this._initializeSdkWrapper(); + } + + private async _disconnectAndClearWrapper(): Promise { + const wrapper = this._wrapper; + if (wrapper) { + await wrapper.disconnect().catch(() => { /* best-effort teardown */ }); + } + this._wrapperDisposables.clear(); + this._lastAppliedShellInitScripts = undefined; + this._registeredShellInitScriptPath = undefined; + } + /** Persists the host-owned catalog title through the runtime's naming API. */ async synchronizeTitle(title: string): Promise { const name = Array.from(title.trim()).slice(0, 100).join(''); @@ -7347,11 +7405,11 @@ export class CopilotAgentSession extends Disposable { // ---- event wiring ------------------------------------------------------- - private _subscribeToEvents(): void { + private _subscribeToEvents(store: DisposableStore = this._store): void { const wrapper = this._wrapper; const sessionId = this.sessionId; - this._register(wrapper.onSystemNotification(e => { + store.add(wrapper.onSystemNotification(e => { if (e.data.kind.type === 'shell_completed') { this._nonPtyShellTerminals.completeBackgroundShell(e.data.kind.shellId, e.data.kind.exitCode); } @@ -7415,7 +7473,7 @@ export class CopilotAgentSession extends Disposable { // right boundary. The DB only sets `event_id` when it's NULL, // so doing this for synthetic injections would permanently // pin the wrong event to the turn. - this._register(wrapper.onUserMessage(e => { + store.add(wrapper.onUserMessage(e => { if (e.agentId) { this._resumeSubagentForEvent(e, { text: e.data.content, origin: { kind: MessageKind.User } }); return; @@ -7458,7 +7516,7 @@ export class CopilotAgentSession extends Disposable { this._onSteeringReady?.(this); })); - this._register(wrapper.onMessageDelta(e => { + store.add(wrapper.onMessageDelta(e => { this._logService.trace(`[Copilot:${sessionId}] delta: ${e.data.deltaContent}`); this._resumeSubagentForEvent(e); if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.message_delta')) { @@ -7525,7 +7583,7 @@ export class CopilotAgentSession extends Disposable { } } }; - this._register(wrapper.onMessage(e => { + store.add(wrapper.onMessage(e => { this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`); this._resumeSubagentForEvent(e); if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.message')) { @@ -7593,15 +7651,15 @@ export class CopilotAgentSession extends Disposable { renderMessage(e, parentToolCallId, isLastMessageChunk); })); - this._register(wrapper.onSamplingRequested(e => { + store.add(wrapper.onSamplingRequested(e => { void this._handleSamplingRequest(e.data); })); - this._register(wrapper.onSamplingCompleted(e => { + store.add(wrapper.onSamplingCompleted(e => { this._completeSamplingRequest(e.data.requestId); })); // TODO@connor4312: Remove this correlation once the SDK permission callback includes auto-approval data. - this._register(wrapper.onPermissionRequested(e => { + store.add(wrapper.onPermissionRequested(e => { const toolCallId = e.data.permissionRequest.toolCallId; if (!toolCallId) { return; @@ -7624,7 +7682,7 @@ export class CopilotAgentSession extends Disposable { }); })); - this._register(wrapper.onPermissionCompleted(e => { + store.add(wrapper.onPermissionCompleted(e => { const toolCallId = e.data.toolCallId; if (!toolCallId) { return; @@ -7648,7 +7706,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onToolCallDelta(e => { + store.add(wrapper.onToolCallDelta(e => { this._logService.trace(`[Copilot:${sessionId}] Tool call delta: ${e.data.toolName ?? ''} (${e.data.toolCallId})`); this._resumeSubagentForEvent(e); if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.tool_call_delta')) { @@ -7903,7 +7961,7 @@ export class CopilotAgentSession extends Disposable { } }; this._surfaceProvisionalFusionToolStart = handleToolStart; - this._register(wrapper.onToolStart(e => { + store.add(wrapper.onToolStart(e => { if (this._surfacedProvisionalFusionToolCallIds.has(e.data.toolCallId)) { // The committed re-emission of a tool call already shown from its provisional phase. this._logService.trace(`[Copilot:${sessionId}] Ignoring committed Fusion tool start already surfaced: ${e.data.toolCallId}`); @@ -8128,7 +8186,7 @@ export class CopilotAgentSession extends Disposable { } turn?.trackToolCompletion(completion); }; - this._register(wrapper.onToolComplete(e => { + store.add(wrapper.onToolComplete(e => { if (this._surfacedProvisionalFusionToolCallIds.delete(e.data.toolCallId) && !this._activeToolCalls.has(e.data.toolCallId)) { this._logService.trace(`[Copilot:${sessionId}] Ignoring committed Fusion tool completion already surfaced: ${e.data.toolCallId}`); return; @@ -8136,7 +8194,7 @@ export class CopilotAgentSession extends Disposable { handleToolComplete(e); })); - this._register(wrapper.onProvisionalFusionEvent(e => { + store.add(wrapper.onProvisionalFusionEvent(e => { if (e.type === 'assistant.message') { // Review phases show their critique when they complete. Everything else a phase says belongs to its chat; only the committed answer becomes the response. if (e.agentId || e.data.fusion?.conversationScope === 'review') { @@ -8170,7 +8228,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onIdle(async e => { + store.add(wrapper.onIdle(async e => { this._logService.info(`[Copilot:${sessionId}] Session idle`); const idleFromFailedExecution = !e.agentId && !e.data.aborted && this._failedExecutionIdlePending; if (!e.agentId) { @@ -8257,7 +8315,7 @@ export class CopilotAgentSession extends Disposable { // `skill.invoked` event with the resolved SKILL.md path. Synthesize a // tool-start/complete pair from the latter so the UI can render a // clickable file link, matching the `view`-tool display style. - this._register(wrapper.onSkillInvoked(e => { + store.add(wrapper.onSkillInvoked(e => { this._logService.info(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); this._resumeSubagentForEvent(e); if (this._shouldDropUnmappedSubagentEvent(e, 'skill.invoked')) { @@ -8303,7 +8361,7 @@ export class CopilotAgentSession extends Disposable { }, parentToolCallId); })); - this._register(wrapper.onSubagentStarted(e => { + store.add(wrapper.onSubagentStarted(e => { if (this._dropLateRootTurnEvents) { this._logService.error(`[Copilot:${sessionId}] subagent.started emitted after cancellation; dropping`); return; @@ -8339,15 +8397,15 @@ export class CopilotAgentSession extends Disposable { this._updateSubagentModel(e.data.toolCallId, e.data.model); })); - this._register(wrapper.onSubagentCompleted(e => { + store.add(wrapper.onSubagentCompleted(e => { this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, e.data.cancelled ? { kind: 'cancelled', durationMs: e.data.durationMs } : undefined); })); - this._register(wrapper.onSubagentFailed(e => { + store.add(wrapper.onSubagentFailed(e => { this._scheduleSubagentLifecycleCompletion(e.agentId, e.data.toolCallId, { kind: 'failed', message: e.data.error, durationMs: e.data.durationMs }); })); - this._register(wrapper.onSubagentConfigured(e => { + store.add(wrapper.onSubagentConfigured(e => { const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); if (!parentToolCallId) { this._logService.warn(`[Copilot:${sessionId}] Unable to attribute model configuration for unknown subagent agentId=${e.agentId}`); @@ -8359,7 +8417,7 @@ export class CopilotAgentSession extends Disposable { }); })); - this._register(wrapper.onTurnStart(e => { + store.add(wrapper.onTurnStart(e => { if (!e.agentId || !e.data.model) { return; } @@ -8370,7 +8428,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onSessionError(e => { + store.add(wrapper.onSessionError(e => { if (!e.agentId && this._shouldDropLateRootTurnEvent('session.error')) { return; } @@ -8406,7 +8464,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onModelCallFailure(e => { + store.add(wrapper.onModelCallFailure(e => { reportCopilotModelCallFailure(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId, this._currentTurn.value?.clientContext)); })); @@ -8416,7 +8474,7 @@ export class CopilotAgentSession extends Disposable { let lastParentUsageTurnId: string | undefined; let autoModeResolved: { readonly turnId: string; readonly data: NonNullable } | undefined; - this._register(wrapper.onAutoModeResolved(e => { + store.add(wrapper.onAutoModeResolved(e => { if (!e.agentId && this._shouldDropLateRootTurnEvent('session.auto_mode_resolved')) { return; } @@ -8480,7 +8538,7 @@ export class CopilotAgentSession extends Disposable { }); })); - this._register(wrapper.onUsage(e => { + store.add(wrapper.onUsage(e => { this._resumeSubagentForEvent(e); if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.usage')) { return; @@ -8675,7 +8733,7 @@ export class CopilotAgentSession extends Disposable { // // Losing this re-emit to a turn that ended mid-flight costs only the session // total's freshness; the turn's own cost was already reported synchronously. - this._register(wrapper.onUsage(async e => { + store.add(wrapper.onUsage(async e => { if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.usage')) { return; } @@ -8729,7 +8787,7 @@ export class CopilotAgentSession extends Disposable { // call. One that runs between turns belongs to no turn: it is reflected in the session total // only, rather than being carried onto whatever runs next and inflating an unrelated // response footer by what is often the session's single most expensive call. - this._register(wrapper.onSessionCompactionComplete(async e => { + store.add(wrapper.onSessionCompactionComplete(async e => { const observeUsage = () => { const observedParentToolCallId = this._parentToolCallIdForSubagentEvent(e); if ((!e.agentId || observedParentToolCallId) && !this._dropLateRootTurnEvents) { @@ -8843,7 +8901,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onReasoningDelta(e => { + store.add(wrapper.onReasoningDelta(e => { this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`); this._resumeSubagentForEvent(e); if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.reasoning_delta')) { @@ -8856,7 +8914,7 @@ export class CopilotAgentSession extends Disposable { // (e.g. after the model approves a plan, or after we set the mode // before sending). The SDK and AHP share the same three modes // (`interactive` / `plan` / `autopilot`), so we map directly. - this._register(wrapper.onSessionModeChanged(e => { + store.add(wrapper.onSessionModeChanged(e => { // Sub-agents (e.g. a `task` tool sub-agent running in plan mode) // emit their own `session.mode_changed` events carrying an // `agentId`. @@ -8879,14 +8937,14 @@ export class CopilotAgentSession extends Disposable { // bare top-level entry (`SessionCustomizationUpdated`). Each state // change is also logged (with structured metadata) so it flows to the // agent host's OTLP log stream and the per-server Output channels. - this._register(wrapper.onMcpServersLoaded(() => { + store.add(wrapper.onMcpServersLoaded(() => { // The SDK re-emits a cached loaded snapshot on later turns, not live connection state. this._logService.trace(`[Copilot:${sessionId}] MCP server inventory invalidated by session.mcp_servers_loaded; refreshing rpc.mcp.listConfigured`); void this._refreshMcpServersFromRpc().catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to refresh MCP server inventory after session.mcp_servers_loaded`, err); }); })); - this._register(wrapper.onMcpServerStatusChanged(e => { + store.add(wrapper.onMcpServerStatusChanged(e => { this._mcpLifecycleVersion++; const requirement = this._lastMcpAuthRequirements.get(e.data.serverName); if (requirement && e.data.status !== 'pending') { @@ -8916,16 +8974,16 @@ export class CopilotAgentSession extends Disposable { }); } })); - this._register(wrapper.onMcpOAuthCompleted(e => { + store.add(wrapper.onMcpOAuthCompleted(e => { this._handleMcpOAuthCompleted(e.data.requestId, e.data.outcome); })); - this._register(wrapper.onToolsUpdated(() => { + store.add(wrapper.onToolsUpdated(() => { this._slashCommandProvider.clearCache(); this._refreshReadyMcpToolRoutingCaches(); this._fireMcpToolsListChanged(); })); - this._register(wrapper.onCommandsChanged(() => { + store.add(wrapper.onCommandsChanged(() => { this._slashCommandProvider.clearCache(); })); @@ -9349,24 +9407,24 @@ export class CopilotAgentSession extends Disposable { } /** Invalidates both history scopes when persisted events change, while coalescing idle reads within each scope. */ - private _subscribeForMemoInvalidation(): void { + private _subscribeForMemoInvalidation(store: DisposableStore = this._store): void { const wrapper = this._wrapper; const invalidate = () => this._invalidateMappedEvents(); // New content appended to the log. - this._register(wrapper.onUserMessage(invalidate)); - this._register(wrapper.onTurnStart(invalidate)); - this._register(wrapper.onMessage(invalidate)); - this._register(wrapper.onToolStart(invalidate)); - this._register(wrapper.onToolComplete(invalidate)); - this._register(wrapper.onSubagentStarted(invalidate)); - this._register(wrapper.onSubagentCompleted(invalidate)); - this._register(wrapper.onSubagentFailed(invalidate)); - this._register(wrapper.onTurnEnd(invalidate)); - this._register(wrapper.onSessionError(invalidate)); + store.add(wrapper.onUserMessage(invalidate)); + store.add(wrapper.onTurnStart(invalidate)); + store.add(wrapper.onMessage(invalidate)); + store.add(wrapper.onToolStart(invalidate)); + store.add(wrapper.onToolComplete(invalidate)); + store.add(wrapper.onSubagentStarted(invalidate)); + store.add(wrapper.onSubagentCompleted(invalidate)); + store.add(wrapper.onSubagentFailed(invalidate)); + store.add(wrapper.onTurnEnd(invalidate)); + store.add(wrapper.onSessionError(invalidate)); // In-place rewrites of the persisted log. - this._register(wrapper.onSessionCompactionComplete(invalidate)); - this._register(wrapper.onSessionTruncation(invalidate)); - this._register(wrapper.onSessionSnapshotRewind(invalidate)); + store.add(wrapper.onSessionCompactionComplete(invalidate)); + store.add(wrapper.onSessionTruncation(invalidate)); + store.add(wrapper.onSessionSnapshotRewind(invalidate)); } /** @@ -9374,11 +9432,11 @@ export class CopilotAgentSession extends Disposable { * Attempts to match local chat's `ComputeAutomaticInstructions` * emitter (`src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts`) */ - private _subscribeForInstructionsCollectedTelemetry(): void { + private _subscribeForInstructionsCollectedTelemetry(store: DisposableStore = this._store): void { const wrapper = this._wrapper; const sessionId = this.sessionId; - this._register(wrapper.onUserMessage(e => { + store.add(wrapper.onUserMessage(e => { // Skip subagent and SDK-injected messages (matches guard on this event above). if (e.agentId || (e.data.source && e.data.source.toLowerCase() !== 'user')) { return; @@ -9860,13 +9918,13 @@ export class CopilotAgentSession extends Disposable { })(); } - private _subscribeToSdkEvents(): void { + private _subscribeToSdkEvents(store: DisposableStore = this._store): void { const wrapper = this._wrapper; const sessionId = this.sessionId; - this._register(wrapper.onFusionEvent(event => this._acceptFusionEvent(event))); + store.add(wrapper.onFusionEvent(event => this._acceptFusionEvent(event))); - this._register(wrapper.onUnhandledEvent(e => { + store.add(wrapper.onUnhandledEvent(e => { if (e.type === 'session.binary_asset') { this._logService.trace(`[Copilot:${sessionId}] Binary asset received: ${e.data.mimeType}, ${e.data.byteLength} bytes`); return; @@ -9879,35 +9937,35 @@ export class CopilotAgentSession extends Disposable { this._logService.trace(`[Copilot:${sessionId}] Unhandled SDK event: ${safeStringify(loggedEvent)}`); })); - this._register(wrapper.onIndexedSearch(e => { + store.add(wrapper.onIndexedSearch(e => { const data = e.data.kind === 'startup' || e.data.kind === 'server_error' ? (({ errorMessage: _, ...data }) => data)(e.data) : e.data; this._logService.trace(`[Copilot:${sessionId}] session.indexed_search: ${safeStringify(data)}`); })); - this._register(wrapper.onExtensionsLoaded(e => { + store.add(wrapper.onExtensionsLoaded(e => { if (wrapper.canvasRuntimeEnabled) { this._canvasExtensions = e.data.extensions; this._markCanvasProjectionLive(); } })); - this._register(wrapper.onCanvasRegistryChanged(() => { + store.add(wrapper.onCanvasRegistryChanged(() => { if (wrapper.canvasRuntimeEnabled && this._canvasPhase !== 'released') { this._markCanvasProjectionLive(); this._publishCanvases(); } })); - this._register(wrapper.onUserMessage(e => { + store.add(wrapper.onUserMessage(e => { if (!e.agentId) { this._ignoredRestoredCanvasInstanceIds.clear(); } })); // Only first opens emit recorded; opened also includes provider rehydration. - this._register(wrapper.onCanvasRecorded(e => { + store.add(wrapper.onCanvasRecorded(e => { if (!wrapper.canvasRuntimeEnabled || e.agentId) { return; } @@ -9919,7 +9977,7 @@ export class CopilotAgentSession extends Disposable { ); })); - this._register(wrapper.onCanvasOpened(e => { + store.add(wrapper.onCanvasOpened(e => { if (!this._projectsCanvasEvent(wrapper, e.agentId)) { return; } @@ -9930,14 +9988,14 @@ export class CopilotAgentSession extends Disposable { this._projectOpenedCanvas(e.data); })); - this._register(wrapper.onCanvasClosed(e => { + store.add(wrapper.onCanvasClosed(e => { if (!this._projectsCanvasEvent(wrapper, e.agentId) || !this._canvasByInstanceId.delete(e.data.instanceId)) { return; } this._publishCanvases(); })); - this._register(wrapper.onCanvasUnavailable(e => { + store.add(wrapper.onCanvasUnavailable(e => { if (!this._projectsCanvasEvent(wrapper, e.agentId)) { return; } @@ -9953,15 +10011,15 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onSessionStart(e => { + store.add(wrapper.onSessionStart(e => { this._logService.trace(`[Copilot:${sessionId}] Session started: model=${e.data.selectedModel ?? 'default'}, producer=${e.data.producer}`); })); - this._register(wrapper.onSessionResume(e => { + store.add(wrapper.onSessionResume(e => { this._logService.trace(`[Copilot:${sessionId}] Session resumed: eventCount=${e.data.eventCount}`); })); - this._register(wrapper.onSessionInfo(e => { + store.add(wrapper.onSessionInfo(e => { const attributes: Record = { infoType: e.data.infoType }; if (e.data.tip) { attributes.tip = e.data.tip; @@ -9981,14 +10039,14 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onSessionWarning(e => { + store.add(wrapper.onSessionWarning(e => { this._logService.warn(`[Copilot:${sessionId}] ${e.data.message}`, new OtelData({ warningType: e.data.warningType })); if (e.data.warningType === MANAGED_PLUGINS_EVENT_TYPE && !e.agentId) { this._reportManagedPluginFailure(e.data.message); } })); - this._register(wrapper.onSessionModelChange(e => { + store.add(wrapper.onSessionModelChange(e => { this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`); const committedAutoTier = getCommittedAutoTier(e.data.newModel, e.data.autoTier); // The runtime omits `autoTier` on Auto-to-Auto changes that keep its committed preference. @@ -10022,46 +10080,46 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onManagedSettingsResolved(e => { + store.add(wrapper.onManagedSettingsResolved(e => { if (!e.agentId) { void this._managedSettingsResolved.complete(); } this._logService.info(`[Copilot:${sessionId}] Managed settings resolved: source=${e.data.source}, managedKeys=${e.data.managedKeys.join(',') || '(none)'}, bypassPermissionsDisabled=${e.data.bypassPermissionsDisabled}, failClosed=${e.data.failClosed}`); })); - this._register(wrapper.onManagedSettingsEnforced(e => { + store.add(wrapper.onManagedSettingsEnforced(e => { this._logService.warn(`[Copilot:${sessionId}] Managed settings enforced: action=${e.data.action}, setting=${e.data.setting}, escalation=${e.data.escalation ?? '(none)'}, failClosed=${e.data.failClosed}, message=${e.data.message}`); })); - this._register(wrapper.onSessionHandoff(e => { + store.add(wrapper.onSessionHandoff(e => { this._logService.trace(`[Copilot:${sessionId}] Session handoff: sourceType=${e.data.sourceType}, remoteSessionId=${e.data.remoteSessionId ?? '(none)'}`); })); - this._register(wrapper.onSessionTruncation(e => { + store.add(wrapper.onSessionTruncation(e => { this._logService.trace(`[Copilot:${sessionId}] Session truncation: removed ${e.data.tokensRemovedDuringTruncation} tokens, ${e.data.messagesRemovedDuringTruncation} messages`); })); - this._register(wrapper.onSessionSnapshotRewind(e => { + store.add(wrapper.onSessionSnapshotRewind(e => { this._logService.trace(`[Copilot:${sessionId}] Snapshot rewind: upTo=${e.data.upToEventId}, eventsRemoved=${e.data.eventsRemoved}`); })); - this._register(wrapper.onSessionShutdown(e => { + store.add(wrapper.onSessionShutdown(e => { this._logService.trace(`[Copilot:${sessionId}] Session shutdown: type=${e.data.shutdownType}, apiDuration=${e.data.totalApiDurationMs}ms`); })); - this._register(wrapper.onSessionUsageInfo(e => { + store.add(wrapper.onSessionUsageInfo(e => { this._logService.trace(`[Copilot:${sessionId}] Usage info: ${e.data.currentTokens}/${e.data.tokenLimit} tokens, ${e.data.messagesLength} messages`); })); - this._register(wrapper.onSessionCompactionStart(() => { + store.add(wrapper.onSessionCompactionStart(() => { this._logService.trace(`[Copilot:${sessionId}] Compaction started`); })); - this._register(wrapper.onSessionCompactionComplete(e => { + store.add(wrapper.onSessionCompactionComplete(e => { this._logService.trace(`[Copilot:${sessionId}] Compaction complete: success=${e.data.success}, tokensRemoved=${e.data.tokensRemoved ?? '?'}`); })); - this._register(wrapper.onUserMessage(e => { + store.add(wrapper.onUserMessage(e => { this._logService.trace(`[Copilot:${sessionId}] User message: ${e.data.content.length} chars, ${e.data.attachments?.length ?? 0} attachments`); // Restricted `conversation.messageText` (source=user): the raw user prompt text. Emit only // for genuine human prompts on the main agent โ€” skip subagent turns (driven by the parent) @@ -10073,15 +10131,15 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onPendingMessagesModified(() => { + store.add(wrapper.onPendingMessagesModified(() => { this._logService.trace(`[Copilot:${sessionId}] Pending messages modified`); })); - this._register(wrapper.onBackgroundTasksChanged(() => { + store.add(wrapper.onBackgroundTasksChanged(() => { this._refreshBackgroundTasks(); })); - this._register(wrapper.onTurnStart(e => { + store.add(wrapper.onTurnStart(e => { if (e.agentId) { this._subagentActivityRevisions.set(e.agentId, (this._subagentActivityRevisions.get(e.agentId) ?? 0) + 1); this._pendingSubagentLifecycleCompletions.delete(e.agentId); @@ -10131,20 +10189,20 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onModelCallFinished(e => { + store.add(wrapper.onModelCallFinished(e => { this._emitModelCallFinished(e); })); - this._register(wrapper.onIntent(e => { + store.add(wrapper.onIntent(e => { this._logService.trace(`[Copilot:${sessionId}] Intent: ${e.data.intent}`); this._publishActivity('intent', e.data.intent || undefined); })); - this._register(wrapper.onReasoning(e => { + store.add(wrapper.onReasoning(e => { this._logService.trace(`[Copilot:${sessionId}] Reasoning: ${e.data.content.length} chars`); })); - this._register(wrapper.onTurnEnd(e => { + store.add(wrapper.onTurnEnd(e => { this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`); if (e.agentId) { this._refreshBackgroundTasks(); @@ -10155,7 +10213,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onAbort(e => { + store.add(wrapper.onAbort(e => { this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`); if (!e.agentId) { this._cancelFusionEvents(); @@ -10176,7 +10234,7 @@ export class CopilotAgentSession extends Disposable { } })); - this._register(wrapper.onToolUserRequested(e => { + store.add(wrapper.onToolUserRequested(e => { this._logService.trace(`[Copilot:${sessionId}] Tool user-requested: ${e.data.toolName} (${e.data.toolCallId})`); })); @@ -10214,17 +10272,17 @@ export class CopilotAgentSession extends Disposable { } }; - this._register(wrapper.onToolPartialResult(e => { + store.add(wrapper.onToolPartialResult(e => { this._logService.trace(`[Copilot:${sessionId}] Tool partial result: ${e.data.toolCallId} (${e.data.partialOutput.length} chars)`); appendShellOutput(e.data.toolCallId, () => this._nonPtyShellTerminals.append(e.data.toolCallId, e.data.partialOutput)); })); - this._register(wrapper.onToolShellOutput(e => { + store.add(wrapper.onToolShellOutput(e => { this._logService.trace(`[Copilot:${sessionId}] Tool shell output: ${e.data.toolCallId} #${e.data.sequence} (${e.data.text.length} chars)`); appendShellOutput(e.data.toolCallId, () => this._nonPtyShellTerminals.appendChunk(e.data.toolCallId, e.data)); })); - this._register(wrapper.onToolProgress(e => { + store.add(wrapper.onToolProgress(e => { this._logService.trace(`[Copilot:${sessionId}] Tool progress: ${e.data.toolCallId} - ${e.data.progressMessage}`); const tracked = this._activeToolCalls.get(e.data.toolCallId); if (tracked?.toolName !== CopilotToolName.ImageGeneration) { @@ -10250,37 +10308,37 @@ export class CopilotAgentSession extends Disposable { }, parentToolCallId); })); - this._register(wrapper.onSkillInvoked(e => { + store.add(wrapper.onSkillInvoked(e => { this._logService.trace(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); })); - this._register(wrapper.onSubagentStarted(e => { + store.add(wrapper.onSubagentStarted(e => { this._logService.trace(`[Copilot:${sessionId}] Subagent started: ${e.data.agentName} (${e.data.agentDisplayName})`); })); - this._register(wrapper.onSubagentCompleted(e => { + store.add(wrapper.onSubagentCompleted(e => { this._seedSubagentDisplayNames([e]); this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`); })); - this._register(wrapper.onSubagentFailed(e => { + store.add(wrapper.onSubagentFailed(e => { this._seedSubagentDisplayNames([e]); this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`); })); - this._register(wrapper.onSubagentSelected(e => { + store.add(wrapper.onSubagentSelected(e => { this._logService.trace(`[Copilot:${sessionId}] Subagent selected: ${e.data.agentName}`); })); - this._register(wrapper.onHookStart(e => { + store.add(wrapper.onHookStart(e => { this._logService.trace(`[Copilot:${sessionId}] Hook started: ${e.data.hookType} (${e.data.hookInvocationId})`); })); - this._register(wrapper.onHookEnd(e => { + store.add(wrapper.onHookEnd(e => { this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`); })); - this._register(wrapper.onSystemMessage(e => { + store.add(wrapper.onSystemMessage(e => { this._logService.trace(`[Copilot:${sessionId}] System message [${e.data.role}]: ${e.data.content.length} chars`); })); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotCustomizationCommandDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotCustomizationCommandDisplay.ts index c96abb941679ff..1938581b62aec2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotCustomizationCommandDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotCustomizationCommandDisplay.ts @@ -6,6 +6,7 @@ import { appendEscapedMarkdownCodeBlockFence, appendEscapedMarkdownInlineCode, escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; import type { CopilotSession } from '@github/copilot-sdk'; import { localize } from '../../../../nls.js'; +import { SYNCED_CUSTOMIZATION_DISPLAY_NAME } from '../../common/agentHostFileSystemService.js'; import type { CopilotSlashCommandOutput, CopilotSlashCommandResult, ICopilotSlashCommandHandler, RuntimeSlashCommandInfo } from './copilotSlashCommand.js'; type CopilotPluginsApi = CopilotSession['rpc']['plugins']; @@ -33,7 +34,11 @@ export async function removeCopilotPluginMarketplace(plugins: ICopilotPluginComm return result; } -export function getCopilotCustomizationCommandHandler(command: RuntimeSlashCommandInfo, plugins?: ICopilotPluginCommandApi): ICopilotSlashCommandHandler | undefined { +export function getCopilotCustomizationCommandHandler( + command: RuntimeSlashCommandInfo, + plugins?: ICopilotPluginCommandApi, + getInstalledPlugins?: () => Promise>['plugins']>, +): ICopilotSlashCommandHandler | undefined { if (command.kind !== 'builtin' || !['mcp', 'plugin', 'skills'].includes(command.name)) { return undefined; } @@ -41,12 +46,12 @@ export function getCopilotCustomizationCommandHandler(command: RuntimeSlashComma invoke: command.name === 'plugin' && plugins ? input => invokeCopilotPluginCommand(input, plugins) : undefined, getProgressMessage: command.name === 'plugin' && plugins ? getCopilotPluginProgressMessage : undefined, showProgressImmediately: command.name === 'plugin' && plugins ? input => splitCommand(input).command === 'install' : undefined, - getOutput: (input, result) => { + getOutput: async (input, result) => { switch (command.name) { case 'mcp': return formatCopilotMcpOutput(input, result); case 'plugin': - return formatCopilotPluginOutput(input, result); + return formatCopilotPluginOutput(input, result, getInstalledPlugins ? await getInstalledPlugins() : undefined); case 'skills': return formatCopilotSkillsOutput(input, result); } @@ -135,10 +140,18 @@ export async function invokeCopilotPluginCommand(input: string, plugins: ICopilo return pluginUsage(localize('copilotPlugin.enablementUsage', "Usage: /plugin {0} ", command)); } const plugin = await findPlugin(rest, plugins); + if (!plugin) { + if (command === 'disable') { + return pluginUsage(localize('copilotPlugin.notFound', "Plugin {0} is not installed.", rest)); + } + await plugins.enable({ names: [rest] }); + await plugins.reload(); + return pluginResult(localize('copilotPlugin.enabled', "Enabled plugin {0}.", rest)); + } if (typeof plugin === 'string') { return pluginUsage(plugin); } - if (!plugin.marketplace) { + if (plugin.directSourceId !== undefined) { return pluginUsage(localize('copilotPlugin.directEnablementUnsupported', "Plugin {0} is installed directly and cannot be enabled or disabled. Uninstall it instead.", rest)); } const enabled = command === 'enable'; @@ -148,9 +161,9 @@ export async function invokeCopilotPluginCommand(input: string, plugins: ICopilo : localize('copilotPlugin.alreadyDisabled', "Plugin {0} is already disabled.", rest)); } if (command === 'enable') { - await plugins.enable({ names: [rest] }); + await plugins.enable({ names: [plugin.name] }); } else { - await plugins.disable({ names: [rest] }); + await plugins.disable({ names: [plugin.name] }); } await plugins.reload(); return pluginResult(command === 'enable' @@ -250,10 +263,10 @@ function splitCommand(input: string): { command: string; rest: string } { }; } -async function findPlugin(target: string, plugins: ICopilotPluginCommandApi): Promise>['plugins'][number] | string> { +async function findPlugin(target: string, plugins: ICopilotPluginCommandApi): Promise>['plugins'][number] | string | undefined> { const matches = (await plugins.list()).plugins.filter(plugin => pluginDisplayName(plugin.name, plugin.marketplace) === target || plugin.name === target); if (!matches.length) { - return localize('copilotPlugin.notFound', "Plugin {0} is not installed.", target); + return undefined; } if (matches.length > 1) { return localize('copilotPlugin.ambiguous', "Multiple plugins are named {0}. Use the full plugin@marketplace name.", target); @@ -511,7 +524,11 @@ function formatCopilotMcpServer(line: string, details: readonly string[]): strin ]; } -function formatCopilotPluginOutput(input: string, result: CopilotSlashCommandResult): CopilotSlashCommandOutput | undefined { +function formatCopilotPluginOutput( + input: string, + result: CopilotSlashCommandResult, + installedPlugins?: Awaited>['plugins'], +): CopilotSlashCommandOutput | undefined { const subcommand = input.trim().toLowerCase(); if (!isPlainTextResult(result)) { return undefined; @@ -525,11 +542,12 @@ function formatCopilotPluginOutput(input: string, result: CopilotSlashCommandRes if (!['', 'list', 'ls'].includes(subcommand)) { return undefined; } - if (lines.length < 3 || lines[0] !== 'Installed Plugins:' || lines[1] !== '') { + if (lines[0] !== 'Installed Plugins:' || (lines[1] !== undefined && lines[1] !== '')) { return undefined; } const output = [`# ${localize('copilotPlugins.installed', "Installed plugins")}`, '']; + const listedPluginNames = new Set(); for (const line of lines.slice(2)) { const match = /^ โ€ข (?.+?)(? \(disabled\))?$/.exec(line); if (!match?.groups?.plugin) { @@ -541,11 +559,29 @@ function formatCopilotPluginOutput(input: string, result: CopilotSlashCommandRes if (versionMatch) { plugin = plugin.slice(0, versionMatch.index); } - const disabled = match.groups.disabled + if (plugin === SYNCED_CUSTOMIZATION_DISPLAY_NAME) { + continue; + } + const installedPluginMatches = installedPlugins?.filter(installedPlugin => pluginDisplayName(installedPlugin.name, installedPlugin.marketplace) === plugin || installedPlugin.name === plugin); + const installedPlugin = installedPluginMatches?.length === 1 ? installedPluginMatches[0] : undefined; + if (installedPlugin) { + plugin = pluginDisplayName(installedPlugin.name, installedPlugin.marketplace); + } + listedPluginNames.add(plugin); + const disabled = match.groups.disabled || installedPlugin?.enabled === false ? ` (${localize('copilotPlugins.disabled', "disabled")})` : ''; output.push(`- ${appendEscapedMarkdownInlineCode(plugin)}${version ? ` โ€” v${escapeCustomizationMarkdownText(version)}` : ''}${disabled}`); } + for (const installedPlugin of installedPlugins ?? []) { + const qualifiedName = pluginDisplayName(installedPlugin.name, installedPlugin.marketplace); + if (installedPlugin.enabled || installedPlugin.installed === false || installedPlugin.name === SYNCED_CUSTOMIZATION_DISPLAY_NAME + || listedPluginNames.has(qualifiedName)) { + continue; + } + const version = installedPlugin.version ? ` โ€” v${escapeCustomizationMarkdownText(installedPlugin.version)}` : ''; + output.push(`- ${appendEscapedMarkdownInlineCode(qualifiedName)}${version} (${localize('copilotPlugins.disabled', "disabled")})`); + } return { kind: 'text', text: output.join('\n'), markdown: true }; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 8d9324f4f9a3d1..958a5cc34a3171 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -14,11 +14,12 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { IFileService } from '../../../files/common/files.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; +import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agent.js'; import type { IAgentProviderSendStageRecorder } from '../../common/agentHostTelemetry.js'; import { getByokLmSelectionModelId, resolveByokLmEnablement, type IByokLmModelInfo } from '../../common/agentHostByokLm.js'; import { LOCAL_MODEL_SENTINEL_PROVIDER_NAME } from '../../common/localModel.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTelemetryLevelConfigKey, agentHostConfigValueToTelemetryLevel, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { AGENT_HOST_COPILOT_CLIENT_NAME, getCopilotApprovalPolicy, resolveCopilotManagedSettings } from './copilotApprovalPolicy.js'; import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliConfigSchema, normalizeModelFamilyAlias, normalizeToolSearchDeferThreshold, resolveModelCapabilityOverrideField } from '../../common/copilotCliConfig.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; @@ -771,6 +772,30 @@ export async function applySandboxConfig(session: CopilotSessionWrapper['session } } +/** + * Whether internal Copilot runtime session telemetry is enabled for a session. + * + * Evaluates the most restrictive level between `ITelemetryService` (which + * captures process launch flags, environment variables, product telemetry + * support, and monotonic ratcheting) and the root configuration's + * `telemetryLevel` (which reflects connected clients before `AgentSideEffects` + * updates `ITelemetryService`). When `telemetryService` is omitted (e.g. in + * lightweight unit-test stubs), falls back to the root configuration alone. + */ +export function isCopilotSessionTelemetryEnabled( + configurationService: Pick, + telemetryService?: Pick, +): boolean { + const configuredLevel = agentHostConfigValueToTelemetryLevel( + configurationService.getRootValue(platformRootSchema, AgentHostTelemetryLevelConfigKey), + ); + const serviceLevel = telemetryService?.telemetryLevel ?? TelemetryLevel.USAGE; + const effectiveLevel = configuredLevel !== undefined + ? Math.min(serviceLevel, configuredLevel) + : serviceLevel; + return effectiveLevel >= TelemetryLevel.USAGE; +} + export class CopilotSessionLauncher implements ICopilotSessionLauncher { /** @@ -793,6 +818,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { @IByokLmBridgeRegistry private readonly _byokLmBridgeRegistry: IByokLmBridgeRegistry, @IAgentHostOTelService private readonly _otelService: IAgentHostOTelService, @IAgentHostSessionOpenTelemetry private readonly _sessionOpenTelemetry: IAgentHostSessionOpenTelemetry, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { } async createCustomizationPolicySession(client: CopilotClient, sessionId: string, workingDirectory: string, githubCredentials: CopilotGitHubSessionCredentials, githubHostUri: string): Promise { @@ -805,9 +831,11 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { 'github-mcp-server', ])].sort(); const copilotConnectorsEnabled = this._configurationService.getRootValue(platformRootSchema, AgentHostMcpConnectorsEnabledConfigKey) === true; + const enableSessionTelemetry = isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService); const session = await client.createSession({ sessionId, clientName: AGENT_HOST_COPILOT_CLIENT_NAME, + ...(enableSessionTelemetry ? {} : { enableSessionTelemetry: false }), workingDirectory, enableConfigDiscovery: true, enableSkills: true, @@ -1271,9 +1299,11 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { finalSessionConfig: Object.keys(mcpServers).sort(), })}`); } + const enableSessionTelemetry = isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService); return { ...byok, ...disabledMcpServers, + ...(enableSessionTelemetry ? {} : { enableSessionTelemetry: false }), onEvent: event => { runtime.onSessionEvent?.(event); const owner = runtime.launchOwner.toString(); diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index 5875eb5676e366..64065cca5672e2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -12,7 +12,7 @@ import { getCompletionAction, toCommandCompletionAttachmentMeta } from '../../co import { getCopilotConfigSlashCommandItems, ICopilotConfigSlashCommandState, isCopilotConfigSlashCommand } from '../../common/copilotConfigSlashCommands.js'; import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider, type IAgentHostCompletionContext } from '../agentHostCompletions.js'; import { extractLeadingSlashToken, extractWhitespaceDelimitedSlashToken, matchesSlashCompletion } from '../agentHostSlashCompletion.js'; -import { SYNCED_CUSTOMIZATION_SCHEME } from '../../common/agentHostFileSystemService.js'; +import { SYNCED_CUSTOMIZATION_DISPLAY_NAME, SYNCED_CUSTOMIZATION_SCHEME } from '../../common/agentHostFileSystemService.js'; import { isCustomizationEnabled, isSkillEligibleForUserInvocation } from '../../common/customizationEnablement.js'; import type { RuntimeSlashCommandInfo } from './copilotSlashCommand.js'; import { copilotSandboxPolicyCommand } from './copilotSandboxPolicyDisplay.js'; @@ -42,7 +42,7 @@ export interface ICopilotSlashCommandSessionInfo { getSessionCustomizations: (session: string) => Promise; getPluginMarketplaces?(sessionId: string): Promise; getPluginMarketplacePlugins?(sessionId: string): Promise; - getInstalledPlugins?(sessionId: string): Promise; + getInstalledPlugins?(sessionId: string): Promise; /** * The completing chat's current config state (`mode` / `autoApprove` axes, * which a chat may set for itself), used to filter config-action slash @@ -184,20 +184,32 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti if (commandName.toLowerCase() === 'plugin') { const plugins = await this._sessionInfo.getInstalledPlugins?.(sessionId) ?? []; + const syncedPluginNames = new Set((await this._sessionInfo.getSessionCustomizations(sessionId)) + .filter((customization): customization is PluginCustomization => customization.type === CustomizationType.Plugin && isSyncedCustomization(customization)) + .map(customization => customization.name)); const directPluginNameCounts = new Map(); for (const plugin of plugins) { if (!plugin.marketplace) { directPluginNameCounts.set(plugin.name, (directPluginNameCounts.get(plugin.name) ?? 0) + 1); } } - const candidates = plugins + const availablePlugins = plugins .filter(plugin => plugin.installed !== false && !plugin.managed) + .filter(plugin => plugin.name !== SYNCED_CUSTOMIZATION_DISPLAY_NAME) + .filter(plugin => plugin.marketplace || plugin.source !== 'builtin' || !syncedPluginNames.has(plugin.name)); + const togglePluginNameCounts = new Map(); + for (const plugin of availablePlugins) { + if (plugin.directSourceId === undefined) { + togglePluginNameCounts.set(plugin.name, (togglePluginNameCounts.get(plugin.name) ?? 0) + 1); + } + } + const candidates = availablePlugins .filter(plugin => { switch (subcommand.toLowerCase()) { case 'enable': - return !!plugin.marketplace && !plugin.enabled; + return plugin.directSourceId === undefined && !plugin.enabled; case 'disable': - return !!plugin.marketplace && plugin.enabled; + return plugin.directSourceId === undefined && plugin.enabled; case 'uninstall': case 'remove': case 'update': @@ -205,7 +217,15 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti } return false; }) - .map(plugin => plugin.marketplace ? `${plugin.name}@${plugin.marketplace}` : plugin.name); + .flatMap(plugin => { + if (subcommand.toLowerCase() !== 'enable' && subcommand.toLowerCase() !== 'disable') { + return [plugin.marketplace ? `${plugin.name}@${plugin.marketplace}` : plugin.name]; + } + if ((togglePluginNameCounts.get(plugin.name) ?? 0) <= 1) { + return [plugin.name]; + } + return plugin.marketplace ? [`${plugin.name}@${plugin.marketplace}`] : []; + }); return Array.from(new Set(candidates)) .filter(name => matchesSlashCompletion(text.slice(range.start, offset), name)) .map((name): CompletionItem => ({ diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index d7d7ef85d1aae9..e9854effda2404 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -17,7 +17,7 @@ import { localize } from '../../../../nls.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { AgentSession, IAgentSessionProjectInfo } from '../../common/agent.js'; -import { getBranchCompletions, GitRefType, IAgentHostGitService, IDefaultBranch, IGitRemote, IRemoteBranch, IWorktreeFileProgress, META_DIFF_BASE_BRANCH, tryResolvePrimaryWorktreeRoot } from '../../common/agentHostGitService.js'; +import { Branch, getBranchCompletions, GitRefType, IAgentHostGitService, IBranch, IDetachedHead, IGitRemote, IRemoteBranch, IWorktreeFileProgress, META_DIFF_BASE_BRANCH, tryResolvePrimaryWorktreeRoot } from '../../common/agentHostGitService.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; import { ISchemaProperty, schemaProperty } from '../../common/agentHostSchema.js'; import { ISessionDataService, type ISessionDatabase } from '../../common/sessionDataService.js'; @@ -441,8 +441,8 @@ export interface IResolveIsolationConfigRequest { } interface IIsolationGitInfo { - readonly currentBranch: string; - readonly defaultBranch: IDefaultBranch; + readonly headRef: IBranch | IDetachedHead; + readonly defaultBranch: Branch | undefined; } /** @@ -905,36 +905,41 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI * `folder` and no branch property is offered. */ async resolveIsolationConfig(request: IResolveIsolationConfigRequest): Promise { - const gitInfo = request.workingDirectory ? await this._getGitInfo(request.workingDirectory) : undefined; - const branchName = gitInfo?.currentBranch; - const pullRequestUrl = getSessionPullRequestUrl(request.config); + const gitInfo = request.workingDirectory + ? await this._getGitInfo(request.workingDirectory) + : undefined; const isolationProperty = schemaProperty<'folder' | 'worktree'>({ type: 'string', title: localize('agentHost.sessionConfig.isolation', "Isolation"), description: localize('agentHost.sessionConfig.isolationDescription', "Where the agent should make changes"), - enum: branchName ? ['folder', 'worktree'] : ['folder'], - enumLabels: branchName + enum: gitInfo ? ['folder', 'worktree'] : ['folder'], + enumLabels: gitInfo ? [ localize('agentHost.sessionConfig.isolation.folder', "Folder"), localize('agentHost.sessionConfig.isolation.worktree', "Worktree") ] : [localize('agentHost.sessionConfig.isolation.folder', "Folder")], - enumDescriptions: branchName + enumDescriptions: gitInfo ? [ localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder"), localize('agentHost.sessionConfig.isolation.worktreeDescription', "Create a Git worktree for isolation") ] : [localize('agentHost.sessionConfig.isolation.folderDescription', "Work directly in the folder")], - default: branchName ? 'worktree' : 'folder', - readOnly: !branchName, + default: gitInfo ? 'worktree' : 'folder', + readOnly: !gitInfo, sessionMutable: false, }); - // Resolve isolation first because the branch default depends on the effective value. - // A pull request is always checked out into its own worktree. - const isolationDefault: 'folder' | 'worktree' = branchName ? 'worktree' : 'folder'; - const isolationValue = branchName && pullRequestUrl !== undefined + // Resolve isolation first because the branch default depends + // on the effective value. A pull request is always checked out + // into its own worktree. + const isolationDefault = gitInfo + ? 'worktree' + : 'folder'; + + const pullRequestUrl = getSessionPullRequestUrl(request.config); + const isolationValue = gitInfo && pullRequestUrl !== undefined ? 'worktree' : isolationProperty.validate(request.config?.[SessionConfigKey.Isolation]) ? request.config![SessionConfigKey.Isolation] as 'folder' | 'worktree' @@ -948,16 +953,28 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI sessionMutable: false, }); - let branchProperty: ISchemaProperty | undefined; - let branchDefault: string | undefined; let branchValue: string | undefined; + let branchDefault: string | undefined; + let branchProperty: ISchemaProperty | undefined; let worktreeBranchPrefixProperty: ISchemaProperty | undefined; let worktreeIncludeFilesProperty: ISchemaProperty | undefined; let worktreeSymlinkFoldersProperty: ISchemaProperty | undefined; + if (gitInfo) { - branchDefault = isolationValue === 'worktree' - ? gitInfo.defaultBranch.startPoint - : gitInfo.currentBranch; + if (isolationValue === 'worktree') { + // Worktree + if (gitInfo.defaultBranch?.kind === GitRefType.Head) { + branchDefault = gitInfo.defaultBranch.upstream?.name ?? gitInfo.defaultBranch.name; + } else if (gitInfo.defaultBranch?.kind === GitRefType.RemoteHead) { + branchDefault = gitInfo.defaultBranch.name; + } else { + branchDefault = gitInfo.headRef.name; + } + } else { + // Folder + branchDefault = gitInfo.headRef.name; + } + branchValue = typeof request.config?.[SessionConfigKey.Branch] === 'string' ? request.config[SessionConfigKey.Branch] as string : branchDefault; @@ -1843,13 +1860,17 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI return undefined; } - const [branch, remoteDefaultBranch] = await Promise.all([ - this._gitService.getCurrentBranch(repositoryRoot), - this._gitService.getDefaultBranch(repositoryRoot), + // Get HEAD and default branch in parallel + const [headRef, defaultBranch] = await Promise.all([ + this._gitService.getHEAD(repositoryRoot), + this._gitService.getDefaultBranch2(repositoryRoot), ]); - const currentBranch = branch ?? headCommit; - const defaultBranch = remoteDefaultBranch ?? { name: currentBranch, startPoint: currentBranch }; - return { currentBranch, defaultBranch }; + + if (!headRef) { + return undefined; + } + + return { headRef, defaultBranch }; } /** diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 01bcbef1f6b550..c51349ff96a3a4 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -732,6 +732,7 @@ export function createNoopGitService(): import('../../common/agentHostGitService return { _serviceBrand: undefined, getCurrentBranch: async () => undefined, + getHEAD: async () => undefined, getDefaultBranch: async () => undefined, getDefaultBranch2: async () => undefined, getBranch: async () => undefined, diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index cf00123205e2fe..2af8620d0f2883 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -330,8 +330,8 @@ suite('ChangesetSessionCoordinator', () => { } } - test('post-restore session subscription setup is idempotent and installs monitoring', async () => { - const { coordinator, stateManager, changesets, subscriptions, monitor } = createEnvironment(); + test('post-restore session subscription setup is idempotent and does not watch files', async () => { + const { coordinator, stateManager, changesets, subscriptions, monitor, gitService } = createEnvironment(); const session = AgentSession.uri('mock', 'concurrent-restore').toString(); coordinator.onFirstSubscriber(URI.parse(session)); createSession(stateManager, session, 'file:///repo'); @@ -339,20 +339,23 @@ suite('ChangesetSessionCoordinator', () => { coordinator.ensureSessionSubscription(session); coordinator.ensureSessionSubscription(session); - await monitor.waitForAcquisitions(1); + await tick(); coordinator.onLastSubscriber(URI.parse(session)); + await tick(); assert.deepStrictEqual({ branch: changesets.branchRefreshes, session: changesets.sessionRefreshes, + rootLookups: gitService.rootLookupCalls, acquisitions: monitor.acquisitions, disposals: monitor.disposals, subscriptions: [...subscriptions.getSessionSubscriptions(session)], }, { branch: [], session: [session], - acquisitions: ['file:///repo'], - disposals: ['file:///repo'], + rootLookups: [], + acquisitions: [], + disposals: [], subscriptions: [], }); }); @@ -742,7 +745,7 @@ suite('ChangesetSessionCoordinator', () => { createSession(environment.stateManager, firstSession, 'file:///repo/worktree-a'); createSession(environment.stateManager, secondSession, 'file:///repo/worktree-b'); - environment.coordinator.onFirstSubscriber(URI.parse(firstSession)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(firstSession))); await environment.monitor.waitForAcquisitions(1); environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(secondSession))); await environment.gitService.waitForRootLookups(2); @@ -761,8 +764,8 @@ suite('ChangesetSessionCoordinator', () => { }, { acquisitions: ['file:///repo'], branchRefreshes: [], - sessionRefreshes: [firstSession], - uncommittedRefreshes: [secondSession], + sessionRefreshes: [], + uncommittedRefreshes: [firstSession, secondSession], gitStateRefreshes: [firstSession, secondSession], }); }); @@ -774,13 +777,13 @@ suite('ChangesetSessionCoordinator', () => { createSession(environment.stateManager, firstSession, 'file:///repo/worktree-a'); createSession(environment.stateManager, secondSession, 'file:///repo/worktree-b'); - environment.coordinator.onFirstSubscriber(URI.parse(firstSession)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(firstSession))); await environment.monitor.waitForAcquisitions(1); environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(secondSession))); await environment.gitService.waitForRootLookups(2); await tick(); - environment.coordinator.onLastSubscriber(URI.parse(firstSession)); + environment.coordinator.onLastSubscriber(URI.parse(buildUncommittedChangesetUri(firstSession))); assert.deepStrictEqual(environment.monitor.disposals, []); environment.coordinator.onLastSubscriber(URI.parse(buildUncommittedChangesetUri(secondSession))); assert.deepStrictEqual(environment.monitor.disposals, ['file:///repo']); @@ -889,15 +892,39 @@ suite('ChangesetSessionCoordinator', () => { }); }); + test('subscribes session-owned Session Changes and refreshes them without watching files', async () => { + const session = AgentSession.uri('mock', 'session-owned-session-changes').toString(); + const environment = createEnvironment(); + createSession(environment.stateManager, session, 'file:///repo/worktree'); + environment.stateManager.setSessionConfig(session, { schema: { type: 'object', properties: {} }, values: { [SessionConfigKey.Isolation]: 'folder' } }); + const changeset = buildSessionChangesetUri(session); + + environment.coordinator.onFirstSubscriber(URI.parse(changeset)); + await tick(); + + assert.deepStrictEqual({ + subscriptions: [...environment.subscriptions.getSessionSubscriptions(session)], + sessionRefreshes: environment.changesets.sessionRefreshes, + rootLookups: environment.gitService.rootLookupCalls, + acquisitions: environment.monitor.acquisitions, + }, { + subscriptions: [changeset], + sessionRefreshes: [session], + rootLookups: [], + acquisitions: [], + }); + }); + test('does not attach root state when watcher acquisition fails', async () => { const session = AgentSession.uri('mock', 'session-1').toString(); const environment = createEnvironment(); createSession(environment.stateManager, session, 'file:///repo/worktree'); environment.monitor.failAcquire = true; - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.gitService.waitForRootLookups(1); await tick(); + environment.changesets.clearRefreshes(); environment.monitor.fire(URI.file('/repo')); await tick(); @@ -913,7 +940,7 @@ suite('ChangesetSessionCoordinator', () => { const environment = createEnvironment(root); createSession(environment.stateManager, session, 'file:///repo/worktree'); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.monitor.waitForAcquisitions(1); const operationBaseline = environment.updateOperationsCalls.length; environment.coordinator.onSessionTurnActiveChanged(session, true); @@ -931,7 +958,7 @@ suite('ChangesetSessionCoordinator', () => { assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, disposals: environment.monitor.disposals, refreshes: environment.changesets.uncommittedRefreshes, operationRefreshes: environment.updateOperationsCalls.slice(operationBaseline) }, { acquisitions: ['file:///repo', 'file:///repo'], disposals: ['file:///repo'], - refreshes: [], + refreshes: [session], operationRefreshes: [session, session], }); }); @@ -944,9 +971,9 @@ suite('ChangesetSessionCoordinator', () => { createSession(environment.stateManager, firstSession, 'file:///repo/worktree-a'); createSession(environment.stateManager, secondSession, 'file:///repo/worktree-b'); - environment.coordinator.onFirstSubscriber(URI.parse(firstSession)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(firstSession))); await environment.monitor.waitForAcquisitions(1); - environment.coordinator.onFirstSubscriber(URI.parse(secondSession)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(secondSession))); await environment.gitService.waitForRootLookups(2); await tick(); environment.coordinator.onSessionTurnActiveChanged(secondSession, true); @@ -958,13 +985,14 @@ suite('ChangesetSessionCoordinator', () => { environment.coordinator.onSessionTurnActiveChanged(secondSession, false); await environment.monitor.waitForAcquisitions(2); + await tick(); environment.monitor.fire(root); await tick(); assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, disposals: environment.monitor.disposals, uncommittedRefreshes: environment.changesets.uncommittedRefreshes }, { acquisitions: ['file:///repo', 'file:///repo'], disposals: ['file:///repo'], - uncommittedRefreshes: [], + uncommittedRefreshes: [firstSession, secondSession], }); }); @@ -976,7 +1004,7 @@ suite('ChangesetSessionCoordinator', () => { createSession(environment.stateManager, parentSession, 'file:///repo/worktree'); createSession(environment.stateManager, subagentSession, undefined); - environment.coordinator.onFirstSubscriber(URI.parse(parentSession)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(parentSession))); await environment.monitor.waitForAcquisitions(1); environment.coordinator.onSessionTurnActiveChanged(subagentSession, true); await environment.gitService.waitForRootLookups(2); @@ -993,7 +1021,7 @@ suite('ChangesetSessionCoordinator', () => { assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, disposals: environment.monitor.disposals, refreshes: environment.changesets.uncommittedRefreshes }, { acquisitions: ['file:///repo', 'file:///repo'], disposals: ['file:///repo'], - refreshes: [], + refreshes: [parentSession], }); }); @@ -1002,12 +1030,12 @@ suite('ChangesetSessionCoordinator', () => { const environment = createEnvironment(); createSession(environment.stateManager, session, 'file:///repo/worktree'); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.monitor.waitForAcquisitions(1); environment.coordinator.onSessionTurnActiveChanged(session, true); await environment.gitService.waitForRootLookups(2); await tick(); - environment.coordinator.onLastSubscriber(URI.parse(session)); + environment.coordinator.onLastSubscriber(URI.parse(buildUncommittedChangesetUri(session))); environment.coordinator.onSessionDisposed(session); environment.coordinator.onSessionTurnActiveChanged(session, false); await tick(); @@ -1028,13 +1056,13 @@ suite('ChangesetSessionCoordinator', () => { ]))); createMultiRootSession(environment.stateManager, session, [rootA.toString(), rootB.toString()]); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.monitor.waitForAcquisitions(2); assert.deepStrictEqual([...environment.monitor.acquisitions].sort(), [rootA.toString(), rootB.toString()].sort()); }); - test('a secondary-root external edit refreshes the summary using the primary working directory', async () => { + test('a secondary-root external edit refreshes subscribed changesets using the primary working directory', async () => { const session = AgentSession.uri('mock', 'session-1').toString(); const primaryRoot = URI.file('/projects/repoA'); const secondaryRoot = URI.file('/projects/repoB'); @@ -1044,7 +1072,7 @@ suite('ChangesetSessionCoordinator', () => { ]))); createMultiRootSession(environment.stateManager, session, [primaryRoot.toString(), secondaryRoot.toString()]); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.monitor.waitForAcquisitions(2); environment.changesets.clearRefreshes(); environment.gitStateService.clearRefreshes(); @@ -1057,12 +1085,12 @@ suite('ChangesetSessionCoordinator', () => { refreshedWith: environment.gitStateService.refreshedWith, recomputed: environment.changesets.recomputed, branchRefreshes: environment.changesets.branchRefreshes, - sessionRefreshes: environment.changesets.sessionRefreshes, + uncommittedRefreshes: environment.changesets.uncommittedRefreshes, }, { refreshedWith: [{ sessionKey: session, workingDirectory: primaryRoot.toString() }], recomputed: [session], branchRefreshes: [], - sessionRefreshes: [session], + uncommittedRefreshes: [session], }); }); @@ -1076,7 +1104,7 @@ suite('ChangesetSessionCoordinator', () => { ]))); createMultiRootSession(environment.stateManager, session, [rootA.toString(), rootB.toString()]); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.monitor.waitForAcquisitions(2); environment.coordinator.onSessionTurnActiveChanged(session, true); await environment.gitService.waitForRootLookups(3); @@ -1104,7 +1132,7 @@ suite('ChangesetSessionCoordinator', () => { ]))); createMultiRootSession(environment.stateManager, session, [dirA, dirB]); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.gitService.waitForRootLookups(2); await tick(); @@ -1121,9 +1149,9 @@ suite('ChangesetSessionCoordinator', () => { ]))); createMultiRootSession(environment.stateManager, session, [rootA.toString(), rootB.toString()]); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.monitor.waitForAcquisitions(2); - environment.coordinator.onLastSubscriber(URI.parse(session)); + environment.coordinator.onLastSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await tick(); assert.deepStrictEqual({ @@ -1145,7 +1173,7 @@ suite('ChangesetSessionCoordinator', () => { ]))); createMultiRootSession(environment.stateManager, session, [nonGitPrimary, secondaryRoot.toString()]); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.monitor.waitForAcquisitions(1); environment.changesets.clearRefreshes(); environment.gitStateService.clearRefreshes(); @@ -1173,7 +1201,7 @@ suite('ChangesetSessionCoordinator', () => { ]))); createMultiRootSession(environment.stateManager, session, [rootA.toString(), rootB.toString()]); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.monitor.waitForAcquisitions(2); environment.coordinator.onSessionTurnActiveChanged(session, true); await environment.gitService.waitForRootLookups(3); @@ -1210,8 +1238,8 @@ suite('ChangesetSessionCoordinator', () => { createMultiRootSession(environment.stateManager, sessionA, [sharedRoot.toString()]); createMultiRootSession(environment.stateManager, sessionB, [rootB.toString(), sharedRoot.toString()]); - environment.coordinator.onFirstSubscriber(URI.parse(sessionA)); - environment.coordinator.onFirstSubscriber(URI.parse(sessionB)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(sessionA))); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(sessionB))); await environment.monitor.waitForAcquisitions(2); environment.coordinator.onSessionTurnActiveChanged(sessionA, true); await environment.gitService.waitForRootLookups(4); @@ -1311,7 +1339,7 @@ suite('ChangesetSessionCoordinator', () => { ]))); createMultiRootSession(environment.stateManager, session, [rootA.toString(), rootB.toString()]); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.monitor.waitForAcquisitions(2); // repoB drops out of the session's working directories; the next @@ -1345,8 +1373,8 @@ suite('ChangesetSessionCoordinator', () => { createMultiRootSession(environment.stateManager, sessionA, [rootA.toString(), sharedRoot.toString()]); createMultiRootSession(environment.stateManager, sessionB, [rootB.toString(), sharedRoot.toString()]); - environment.coordinator.onFirstSubscriber(URI.parse(sessionA)); - environment.coordinator.onFirstSubscriber(URI.parse(sessionB)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(sessionA))); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(sessionB))); await environment.monitor.waitForAcquisitions(3); environment.coordinator.onSessionTurnActiveChanged(sessionA, true); await environment.gitService.waitForRootLookups(5); @@ -1372,12 +1400,12 @@ suite('ChangesetSessionCoordinator', () => { // repoB's watcher acquisition fails on the first attempt. environment.monitor.failAcquireFor.add(rootB.toString()); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.monitor.waitForAcquisitions(2); await tick(); // The failure clears; a re-attach must retry repoB (not short-circuit on - // a cached signature) so an edit there then refreshes the summary. + // a cached signature) so an edit there then refreshes the subscribed changesets. environment.monitor.failAcquireFor.delete(rootB.toString()); environment.coordinator.onSessionMaterialized(session); await environment.monitor.waitForAcquisitions(3); @@ -1404,11 +1432,11 @@ suite('ChangesetSessionCoordinator', () => { ]))); createSession(environment.stateManager, session, rootA.toString()); - environment.coordinator.onFirstSubscriber(URI.parse(session)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); await environment.monitor.waitForAcquisitions(1); // Adding a second root mid-session must start watching it (no lifecycle - // event required) so external edits there refresh the summary. + // event required) so external edits there refresh the subscribed changesets. environment.stateManager.dispatchServerAction(session, { type: ActionType.SessionWorkingDirectorySet, directory: rootB.toString() }); await environment.monitor.waitForAcquisitions(2); @@ -1438,7 +1466,7 @@ suite('ChangesetSessionCoordinator', () => { // The subagent has NO own working directories, so it inherits the parent's set. createSession(environment.stateManager, subagentSession); - environment.coordinator.onFirstSubscriber(URI.parse(subagentSession)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(subagentSession))); await environment.monitor.waitForAcquisitions(2); environment.changesets.clearRefreshes(); environment.gitStateService.clearRefreshes(); @@ -1472,7 +1500,7 @@ suite('ChangesetSessionCoordinator', () => { createSession(environment.stateManager, parentSession, rootA.toString()); createSession(environment.stateManager, subagentSession); - environment.coordinator.onFirstSubscriber(URI.parse(subagentSession)); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(subagentSession))); await environment.monitor.waitForAcquisitions(1); // The PARENT gains a second root mid-session: the inheriting subagent must @@ -1671,7 +1699,7 @@ class TestChangesetService implements IAgentHostChangesetService { this.branchRefreshes.push(session); } refreshSessionChangeset(session: string, strategy?: ChangesetDiffStrategy): void { - assert.strictEqual(strategy ?? 'auto', 'auto'); + assert.strictEqual(strategy ?? 'fileEditTracker', 'fileEditTracker'); this.sessionRefreshes.push(session); } onWorkingDirectoryAvailable(session: string): void { diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 0d3cd544b3441b..5f8d3da07bb983 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -2546,7 +2546,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { stateManager.dispatchServerAction(chat, { type: ActionType.ChatTurnComplete, turnId: turn.id, duration: 1 }); } - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await waitForChangesetReady(stateManager, buildSessionChangesetUri(sessionStr)); assert.deepStrictEqual({ @@ -2601,7 +2601,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { } const changesetUri = buildSessionChangesetUri(sessionStr); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await waitForChangesetReady(stateManager, changesetUri); stateManager.dispatchServerAction(mainChat, { @@ -2614,7 +2614,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { }, hidden), }); stateManager.dispatchServerAction(mainChat, { type: ActionType.ChatTurnComplete, turnId: hidden ? 'notice' : 'repair', duration: 1000 }); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await waitForChangesetReady(stateManager, changesetUri); assert.deepStrictEqual({ @@ -2680,7 +2680,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { stateManager.setSessionSummaryChanges(sessionStr, oldSummary); svc.refreshBranchChangeset(sessionStr); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await Promise.all([branchStarted.p, sessionStarted.p]); const branchFirst = selectedFirst === (isolation === 'worktree'); if (branchFirst) { @@ -2725,7 +2725,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { stateManager.setSessionSummaryChanges(sessionStr, oldSummary); svc.restoreStaticChangeset(sessionStr, 'session', [gitDiff('/wd/legacy.ts', 100, 20)]); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await waitForChangesetReady(stateManager, sessionChangeset); const live = stateManager.getSessionSummary(sessionStr)?.changes; await svc.whenSummaryCommitted(); @@ -3019,7 +3019,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { workingDirectories: ['file:///repoB'], }, }); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await waitForChangesetReady(stateManager, sessionChangeset); assert.deepStrictEqual({ @@ -3247,7 +3247,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { workingDirectories: ['file:///repoA', 'file:///repoA/sub', 'file:///repoB'], isolation, git, checkpoint: summaryCheckpoint(), db, }); completeTurn(stateManager); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await waitForChangesetReady(stateManager, sessionChangeset); const beforeBranch = stateManager.getSessionSummary(sessionStr)?.changes; const branchCallsBeforeRefresh = [...branchCalls]; @@ -3294,7 +3294,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { }); completeTurn(stateManager); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await waitForChangesetReady(stateManager, sessionChangeset); assert.deepStrictEqual({ @@ -3327,7 +3327,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { workingDirectories: ['file:///repoA', 'file:///repoB'], git, checkpoint, peer: { resource: buildChatUri(sessionStr, 'peer'), db: new TestSessionDatabase(), turnId: 'peer-turn' }, }); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await waitForChangesetReady(stateManager, sessionChangeset); assert.deepStrictEqual({ calls, turnCalls, summary: stateManager.getSessionSummary(sessionStr)?.changes }, { calls: [ @@ -3383,7 +3383,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { }); stateManager.dispatchServerAction(peer, { type: ActionType.ChatTurnComplete, turnId: 'turn-b', duration: 1 }); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await waitForChangesetReady(stateManager, sessionChangeset); assert.deepStrictEqual(calls.sort((a, b) => a.root.localeCompare(b.root)), [ @@ -3413,7 +3413,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { workingDirectories: ['file:///workspace', 'file:///workspace/repoA', 'file:///repoB'], git, checkpoint, db, peer: { resource: buildChatUri(sessionStr, 'peer'), db: peerDb, turnId: 'peer-turn', onDispose: () => peerDisposed++ }, }); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await waitForChangesetReady(stateManager, sessionChangeset); assert.deepStrictEqual({ files: stateManager.getChangesetState(sessionChangeset)?.files.map(file => file.id).sort(), @@ -3465,7 +3465,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { svc.restoreStaticChangeset(sessionStr, 'session', previous); stateManager.setSessionSummaryChanges(sessionStr, oldSummary); await db.setMetadata(META_CHANGES_SUMMARY, JSON.stringify(oldSummary)); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); for (let i = 0; i < 500 && stateManager.getChangesetState(sessionChangeset)?.status !== ChangesetStatus.Error; i++) { await timeout(1); } @@ -3500,7 +3500,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { const { svc, stateManager } = build({ workingDirectories, git, checkpoint: summaryCheckpoint() }); completeTurn(stateManager); stateManager.setSessionSummaryChanges(sessionStr, oldSummary); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); for (let i = 0; i < 500 && stateManager.getChangesetState(sessionChangeset)?.status !== ChangesetStatus.Ready; i++) { await timeout(1); releases.shift()?.(); @@ -3532,7 +3532,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { completeTurn(stateManager); svc.recomputeSubscribedChangesets(sessionStr); await waitForChangesetReady(stateManager, selected); - assert.deepStrictEqual({ git: calls, tracked: db.getAllFileEditsCalls }, { git: 1, tracked: 0 }); + assert.deepStrictEqual({ git: calls, tracked: db.getAllFileEditsCalls }, isolation === 'folder' ? { git: 0, tracked: 1 } : { git: 1, tracked: 0 }); }); } @@ -3658,7 +3658,7 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { const db = new TestSessionDatabase(); const { svc, stateManager } = build({ workingDirectories: ['file:///wd'], isolation: 'folder', git, checkpoint: summaryCheckpoint(), db }); completeTurn(stateManager); - svc.refreshSessionChangeset(sessionStr); + svc.refreshSessionChangeset(sessionStr, 'auto'); await started.p; if (transition === 'restored') { stateManager.setSessionConfig(sessionStr, { schema: { type: 'object', properties: {} }, values: { [SessionConfigKey.Isolation]: 'worktree' } }); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts index 446c59e0e4ada2..56ca93cc6e5f5b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts @@ -257,7 +257,9 @@ suite('AgentHostChangesetStrategy', () => { test(`${isolation ?? 'legacy'} isolation with ${strategy ?? 'omitted'} strategy selects the requested session and turn sources`, async () => { const fixture = createFixture({ isolation }); addEdit(fixture.db); - const tracker = strategy === 'fileEditTracker'; + // Session Changes default to tracked edits; turn changesets default to auto. + const sessionTracker = strategy === 'fileEditTracker' || strategy === undefined; + const turnTracker = strategy === 'fileEditTracker'; await refresh(fixture, strategy); await fixture.service.computeTurnChangeset(session, turnId, strategy); @@ -270,15 +272,15 @@ suite('AgentHostChangesetStrategy', () => { repositories: fixture.repositoryCalls, tracked: [fixture.db.getAllFileEditsCalls, fixture.db.getFileEditsByTurnCalls, fixture.diff.callCount], }, { - session: ready(tracker ? [trackedDiff()] : [gitOnlyDiff]), - turn: ready(tracker ? [trackedDiff()] : [gitOnlyDiff]), - git: tracker ? [] : [ - { directory: 'file:///repo', fromRef: 'baseline', toRef: 'current' }, - { directory: 'file:///repo', fromRef: 'parent', toRef: 'current' }, + session: ready(sessionTracker ? [trackedDiff()] : [gitOnlyDiff]), + turn: ready(turnTracker ? [trackedDiff()] : [gitOnlyDiff]), + git: [ + ...(sessionTracker ? [] : [{ directory: 'file:///repo', fromRef: 'baseline', toRef: 'current' }]), + ...(turnTracker ? [] : [{ directory: 'file:///repo', fromRef: 'parent', toRef: 'current' }]), ], - checkpoints: tracker ? [] : ['baseline', turnId, turnId], + checkpoints: [...(sessionTracker ? [] : ['baseline', turnId]), ...(turnTracker ? [] : [turnId])].sort(), repositories: [], - tracked: tracker ? [1, 1, 2] : [0, 0, 0], + tracked: [sessionTracker ? 1 : 0, turnTracker ? 1 : 0, (sessionTracker ? 1 : 0) + (turnTracker ? 1 : 0)], }); }); } @@ -287,7 +289,7 @@ suite('AgentHostChangesetStrategy', () => { test('explicit overrides are one-shot rather than a session preference', async () => { const fixture = createFixture({ isolation: 'folder' }); addEdit(fixture.db); - await refresh(fixture, 'fileEditTracker'); + await refresh(fixture, 'git'); await fixture.service.computeTurnChangeset(session, turnId, 'fileEditTracker'); await refresh(fixture); await fixture.service.computeTurnChangeset(session, turnId); @@ -297,25 +299,25 @@ suite('AgentHostChangesetStrategy', () => { turn: snapshot(fixture.state, turnChangeset), gitCalls: fixture.gitCalls.length, isolation: fixture.state.getSessionState(session)?.config?.values[SessionConfigKey.Isolation], - }, { session: ready([gitOnlyDiff]), turn: ready([gitOnlyDiff]), gitCalls: 2, isolation: 'folder' }); + }, { session: ready([trackedDiff()]), turn: ready([gitOnlyDiff]), gitCalls: 2, isolation: 'folder' }); }); - test('subscribed session refreshes preserve checkpoint edits from default and peer chats', async () => { - const fixture = createFixture({ - peer: { resource: buildChatUri(session, 'peer'), db: new TestSessionDatabase(), turnId: 'peer-turn' }, - }); - const edits = ['default-provider.txt', 'peer-provider.txt'].map(name => ({ - after: { uri: URI.file(`/repo/${name}`).toString(), content: { uri: `git:/${name}` } }, - diff: { added: 1, removed: 0 }, - })); - fixture.results.git = edits; - await refresh(fixture); + test('subscribed session refreshes use only tracked edits from default and peer chats', async () => { + const peer = buildChatUri(session, 'peer'); + const peerDb = new TestSessionDatabase(); + const fixture = createFixture({ peer: { resource: peer, db: peerDb, turnId: 'peer-turn' } }); + fixture.results.git = [gitOnlyDiff]; + addEdit(fixture.db); + addEdit(peerDb, '/repo/peer.txt', 'peer-turn', 'peer-edit'); const { publications, done } = recordSessionPublications(fixture, 1); fixture.service.recomputeSubscribedChangesets(session); await done; - assert.deepStrictEqual(publications, [ready(edits)]); + assert.deepStrictEqual({ publications, gitCalls: fixture.gitCalls.length }, { + publications: [ready([trackedDiff('/repo/peer.txt', peer, 'peer-edit'), trackedDiff()])], + gitCalls: 0, + }); }); test('subscribed session refreshes fall back to tracked edits without checkpoints', async () => { @@ -354,7 +356,7 @@ suite('AgentHostChangesetStrategy', () => { await published; const beforeRefresh = snapshot(fixture.state, sessionChangeset); - // First subscription and background Git-state refreshes both select auto. + // First subscription and background Git-state refreshes both use tracked edits. await refresh(fixture); const afterSubscribe = snapshot(fixture.state, sessionChangeset); const backgroundPublished = nextPublication(fixture.state, sessionChangeset); @@ -367,11 +369,7 @@ suite('AgentHostChangesetStrategy', () => { }; fixture.state.dispatchServerAction(chat, { type: ActionType.ChatTurnComplete, turnId: activeTurnId, duration: 1 }); - const completedDiff: ISessionFileDiff = { - after: { uri: URI.file('/repo/active.txt').toString(), content: { uri: 'git:/active.txt' } }, - diff: { added: 1, removed: 0 }, - }; - fixture.results.git = [completedDiff, gitOnlyDiff]; + fixture.results.git = [gitOnlyDiff]; await refresh(fixture); const expectedActive = ready([trackedDiff('/repo/active.txt', owner === 'default' ? session : peer, 'active-edit')]); assert.deepStrictEqual({ beforeRefresh, afterSubscribe, afterBackgroundRefresh, activeState, afterCompletion: snapshot(fixture.state, sessionChangeset) }, { @@ -379,7 +377,7 @@ suite('AgentHostChangesetStrategy', () => { afterSubscribe: expectedActive, afterBackgroundRefresh: expectedActive, activeState: { id: activeTurnId, completed: [owner === 'default' ? turnId : 'peer-turn'] }, - afterCompletion: ready([completedDiff, gitOnlyDiff]), + afterCompletion: expectedActive, }); })); } @@ -554,7 +552,7 @@ suite('AgentHostChangesetStrategy', () => { gitCalls: fixture.gitCalls.length, }, { session: ready([trackedDiff()]), turn: ready([trackedDiff()]), - reads: [1, 1], gitCalls: failure === 'missing checkpoints' ? 0 : 2, + reads: [1, 1], gitCalls: failure === 'missing checkpoints' ? 0 : strategy === undefined ? 1 : 2, }); }); } @@ -1022,7 +1020,7 @@ suite('AgentHostChangesetStrategy', () => { }, { state: ready([trackedDiff()]), reads: [2, 0], git: [] }); }); - test('restore and subscription refreshes preserve session checkpoints after isolation changes', async () => { + test('restore and subscription refreshes keep tracked session changes after isolation changes', async () => { const fixture = createFixture(); addEdit(fixture.db); await refresh(fixture); @@ -1036,7 +1034,7 @@ suite('AgentHostChangesetStrategy', () => { turn: snapshot(fixture.state, turnChangeset), gitCalls: fixture.gitCalls.length, reads: [fixture.db.getAllFileEditsCalls, fixture.db.getFileEditsByTurnCalls], - }, { session: ready([gitOnlyDiff]), turn: ready([trackedDiff()]), gitCalls: 3, reads: [0, 1] }); + }, { session: ready([trackedDiff()]), turn: ready([trackedDiff()]), gitCalls: 1, reads: [2, 1] }); }); test('main branch changes retain the persisted baseline while using the session folder', async () => { diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index dea1cc0c3a8212..2f6b461d850a09 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -40,6 +40,7 @@ class TestGitService implements IAgentHostGitService { }]; async getCurrentBranch(): Promise { return this.currentBranch; } + async getHEAD(): Promise { return undefined; } async getCurrentBranchName(): Promise { return this.currentBranchName; } async getDefaultBranch(): Promise { return { name: 'main', startPoint: 'main' }; } async getDefaultBranch2(): Promise { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index 347c70a2c01b3e..4d411a5dadc87c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -33,6 +33,7 @@ class TestGitService implements IAgentHostGitService { untrackedPaths: readonly string[] = []; async getCurrentBranch(): Promise { return undefined; } + async getHEAD(): Promise { return undefined; } async getDefaultBranch(): Promise { return undefined; } async getDefaultBranch2(): Promise { return undefined; } async getBranch(): Promise { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index 738d889140d908..d8627b824816a6 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -460,6 +460,30 @@ suite('AgentHostGitService - getSessionGitState (real git)', () => { }, { branch: 'main', detached: undefined }); }); + (hasGit ? test : test.skip)('getHEAD resolves attached and detached HEAD', async () => { + const dir = initRepo(); + const directory = URI.file(dir); + const attached = await svc!.getHEAD(directory); + const commit = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(); + cp.execFileSync('git', ['checkout', '--detach', '-q'], { cwd: dir, stdio: 'pipe' }); + + assert.deepStrictEqual({ + attached, + detached: await svc!.getHEAD(directory), + }, { + attached: { + ref: 'refs/heads/main', + name: 'main', + upstream: undefined, + kind: GitRefType.Head, + }, + detached: { + name: commit, + kind: GitRefType.DetachedHead, + }, + }); + }); + (hasGit ? test : test.skip)('getCurrentBranchName throws on failed strict lookup without changing best-effort callers', async () => { tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-nongit-')); const directory = URI.file(tmpRoot); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index ab72cdd5121065..ad50c3a72c6e30 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -104,6 +104,7 @@ class TestGitService implements IAgentHostGitService { branchChanges: readonly ISessionFileDiff[] | undefined = [{ after: { uri: 'file:///repo/file.ts', content: { uri: 'file:///repo/file.ts' } } }]; async getCurrentBranch(): Promise { return 'feature/test'; } + async getHEAD(): Promise { return undefined; } async getDefaultBranch(): Promise { return { name: 'main', startPoint: 'main' }; } async getDefaultBranch2(): Promise { return undefined; } async getBranch(): Promise { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/agentHostSeams.test.ts b/src/vs/platform/agentHost/test/node/agentHostSeams.test.ts index 20ebc8c4f06017..501b2c119bc253 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSeams.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSeams.test.ts @@ -83,6 +83,29 @@ suite('Agent Host provider seams', () => { suite('createAgentChatContext', () => { + test('anchors session customizations to the default chat\'s own folders, not the shared folders older builds read', () => { + manager.createSession(summary()); + const chatEntry = (isDefault: boolean, launchWorkingDirectories: readonly string[]) => ({ + isDefault, storage: undefined, title: undefined, titleSource: undefined, isRead: undefined, workingDirectories: undefined, launchWorkingDirectories, changes: undefined, + }); + const context = createAgentChatContext(manager, session, defaultChat, manager.resolveChatStorageResource(session.toString(), defaultChat.toString()), { + session: sessionKey, + authority: 2, + revision: 1, + // What a chat-only move of the default chat from `f1` writes for older builds. + values: new Map([['copilot.sharedCustomizationDirectories', JSON.stringify(['file:///f1'])], ['copilot.customizationDirectory', 'file:///f1']]), + folders: new Map(), + chats: new Map([[defaultChat.toString(), chatEntry(true, ['file:///f2', 'file:///f3'])], [peerChat.toString(), chatEntry(false, ['file:///f1'])]]), + }); + assert.deepStrictEqual({ + customizationDirectory: context.sessionSettings?.customizationDirectory?.toString(), + additionalCustomizationDirectories: context.sessionSettings?.additionalCustomizationDirectories.map(directory => directory.toString()), + }, { + customizationDirectory: 'file:///f2', + additionalCustomizationDirectories: ['file:///f3'], + }); + }); + test('stamps resource and origin for the session-backed default chat', () => { manager.createSession(summary()); const context = createAgentChatContext(manager, session, defaultChat, manager.resolveChatStorageResource(session.toString(), defaultChat.toString()), undefined); diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionStateOwnershipGuard.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionStateOwnershipGuard.test.ts index 8f8cc5170ededc..fc003c40d0a0c8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionStateOwnershipGuard.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionStateOwnershipGuard.test.ts @@ -124,7 +124,7 @@ const ALLOWLIST: readonly IAllowance[] = [ { file: 'copilot/copilotAgent.ts', key: 'AH_META_IS_ARCHIVED_DB_KEY', count: 1, removedBy: 'permanent', reason: 'resume reads the session\'s archived state through _openMetadata: the store, never importing' }, { file: 'copilot/copilotAgent.ts', key: 'AH_META_IS_DONE_DB_KEY', count: 1, removedBy: 'permanent', reason: 'resume reads the session\'s legacy archived state through _openMetadata: the store, never importing' }, { file: 'copilot/copilotAgent.ts', key: '_META_CUSTOMIZATION_DIRECTORY', count: 3, removedBy: 'permanent', reason: 'working-directory and stored-metadata reads through _openMetadata: the store for a session, a peer backing\'s own db' }, - { file: 'copilot/copilotAgent.ts', key: '_META_SHARED_CUSTOMIZATION_DIRECTORIES', count: 2, removedBy: 'permanent', reason: 'reads through _openMetadata: the store for a session, a peer backing\'s own db' }, + { file: 'copilot/copilotAgent.ts', key: '_META_SHARED_CUSTOMIZATION_DIRECTORIES', count: 1, removedBy: 'permanent', reason: 'working-directory snapshot read through _openMetadata (rollback, and older builds still read the value): the store for a session, a peer backing\'s own db' }, { file: 'copilot/copilotAgent.ts', key: '_META_PROJECT_URI', count: 2, removedBy: 'permanent', reason: 'reads through _openMetadata: the store for a session, a peer backing\'s own db' }, { file: 'copilot/copilotAgent.ts', key: '_META_PROJECT_DISPLAY_NAME', count: 2, removedBy: 'permanent', reason: 'reads through _openMetadata: the store for a session, a peer backing\'s own db' }, { file: 'copilot/copilotAgent.ts', key: '_META_PROJECT_RESOLVED', count: 2, removedBy: 'permanent', reason: 'reads through _openMetadata: the store for a session, a peer backing\'s own db' }, diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts index ad267d8e2438de..dfe056e0b15a50 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts @@ -224,9 +224,12 @@ suite('AgentHostTelemetryService', () => { test('uses the launch telemetry level before a client connects and only becomes more restrictive', () => { const delegate = new TestTelemetryService(); const service = disposables.add(new AgentHostTelemetryService(delegate, undefined, undefined, undefined, TelemetryLevel.USAGE)); + const levelChanges: TelemetryLevel[] = []; + disposables.add(service.onDidChangeTelemetryLevel(level => levelChanges.push(level))); service.publicLog('beforeClientLevel', { count: 1 }); service.updateTelemetryLevel(TelemetryLevel.ERROR); + service.updateTelemetryLevel(TelemetryLevel.ERROR); service.publicLog('afterClientLevel', { count: 2 }); service.publicLogError('afterClientLevelError', { count: 3 }); service.updateTelemetryLevel(TelemetryLevel.NONE); @@ -237,11 +240,13 @@ suite('AgentHostTelemetryService', () => { assert.deepStrictEqual({ telemetryLevel: service.telemetryLevel, sendErrorTelemetry: service.sendErrorTelemetry, + levelChanges, events: delegate.events, errorEvents: delegate.errorEvents, }, { telemetryLevel: TelemetryLevel.NONE, sendErrorTelemetry: false, + levelChanges: [TelemetryLevel.ERROR, TelemetryLevel.NONE], events: [{ eventName: 'beforeClientLevel', data: { count: 1 } }], errorEvents: [{ eventName: 'afterClientLevelError', data: { count: 3 } }], }); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index bc63aea455a5ab..e03e69a74acdd5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -40,6 +40,7 @@ import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry, IAgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AdditionalWorktreeLifecycleService, IAdditionalWorktreeLifecycleService } from '../../node/chatContributions/additionalWorktreeLifecycle/additionalWorktreeLifecycleService.js'; import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostArchivedChatWorkspaceService } from '../../node/chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; @@ -321,6 +322,7 @@ suite('AgentSideEffects โ€” tool call telemetry', () => { cancel: () => { }, updateSessionWorkspace: async () => { }, } satisfies ISessionWorkspaceConversionService], + [IAgentHostArchivedChatWorkspaceService, { _serviceBrand: undefined, setChatWorkspaceAttached: async () => { } } satisfies IAgentHostArchivedChatWorkspaceService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index 1ab95b793a1941..fad4ef3c3d9419 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -40,6 +40,7 @@ import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry, IAgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AdditionalWorktreeLifecycleService, IAdditionalWorktreeLifecycleService } from '../../node/chatContributions/additionalWorktreeLifecycle/additionalWorktreeLifecycleService.js'; import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostArchivedChatWorkspaceService } from '../../node/chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; @@ -278,6 +279,7 @@ suite('AgentSideEffects โ€” turn hang telemetry', () => { cancel: () => { }, updateSessionWorkspace: async () => { }, } satisfies ISessionWorkspaceConversionService], + [IAgentHostArchivedChatWorkspaceService, { _serviceBrand: undefined, setChatWorkspaceAttached: async () => { } } satisfies IAgentHostArchivedChatWorkspaceService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 3bdd7d266fb7c9..2ce665f74162b3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -47,6 +47,7 @@ import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry, IAgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AdditionalWorktreeLifecycleService, IAdditionalWorktreeLifecycleService } from '../../node/chatContributions/additionalWorktreeLifecycle/additionalWorktreeLifecycleService.js'; import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostArchivedChatWorkspaceService } from '../../node/chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; @@ -315,6 +316,7 @@ suite('AgentSideEffects โ€” turn tracker telemetry', () => { cancel: () => { }, updateSessionWorkspace: async () => { }, } satisfies ISessionWorkspaceConversionService], + [IAgentHostArchivedChatWorkspaceService, { _serviceBrand: undefined, setChatWorkspaceAttached: async () => { } } satisfies IAgentHostArchivedChatWorkspaceService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 03e6eff5c668b5..b6b3186addebfc 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -3206,7 +3206,14 @@ suite('AgentService (node dispatcher)', () => { gitService.getRepositoryRoot = async () => workingDirectory; gitService.revParse = async () => 'head'; gitService.getCurrentBranch = async () => 'feature'; + gitService.getHEAD = async () => ({ ref: 'refs/heads/feature', name: 'feature', kind: GitRefType.Head }); gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' }); + gitService.getDefaultBranch2 = async () => ({ + ref: 'refs/heads/main', + name: 'main', + upstream: { ref: 'refs/remotes/origin/main', name: 'origin/main', remote: 'origin' }, + kind: GitRefType.Head, + }); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); setTestAgentHostWorktreeIsolation(localService, disposables.add(new WorktreeIsolation( { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, @@ -3271,7 +3278,14 @@ suite('AgentService (node dispatcher)', () => { gitService.getRepositoryRoot = async () => workingDirectory; gitService.revParse = async () => 'head'; gitService.getCurrentBranch = async () => 'feature'; + gitService.getHEAD = async () => ({ ref: 'refs/heads/feature', name: 'feature', kind: GitRefType.Head }); gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' }); + gitService.getDefaultBranch2 = async () => ({ + ref: 'refs/heads/main', + name: 'main', + upstream: { ref: 'refs/remotes/origin/main', name: 'origin/main', remote: 'origin' }, + kind: GitRefType.Head, + }); gitService.getBranches = async () => [ { ref: 'refs/heads/main', name: 'main', kind: GitRefType.Head }, { ref: 'refs/heads/feature', name: 'feature', kind: GitRefType.Head }, @@ -3474,7 +3488,9 @@ suite('AgentService (node dispatcher)', () => { gitService.getRepositoryRoot = async () => workingDirectory; gitService.revParse = async () => 'head'; gitService.getCurrentBranch = async () => 'feature'; + gitService.getHEAD = async () => ({ ref: 'refs/heads/feature', name: 'feature', kind: GitRefType.Head }); gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' }); + gitService.getDefaultBranch2 = async () => ({ ref: 'refs/heads/main', name: 'main', kind: GitRefType.Head }); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); const isolation = disposables.add(new WorktreeIsolation( { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, @@ -8653,6 +8669,116 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('archiving a peer chat detaches its folder from the session and unarchiving attaches it back', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { + return { providerData: 'peer-backing' }; + } + } + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createPerSessionDataService().service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot', { + multipleChats: { fork: true }, + multipleWorkingDirectories: { immutablePrimary: true }, + })); + registerTestAgentProvider(svc, agent); + const primary = URI.file('/workspace/primary'); + const other = URI.file('/workspace/other'); + const session = await svc.createSession({ legacyChatUris: true, provider: agent.id, workingDirectories: [primary] }); + const peer = URI.parse(buildChatUri(session, 'other-folder-peer')); + const primaryPeer = URI.parse(buildChatUri(session, 'primary-folder-peer')); + await svc.createChat(session, peer, { title: 'Other Folder', workingDirectories: [await svc.addSessionWorkingDirectoryForChat(session, other, { isolation: 'folder' })] }); + await svc.createChat(session, primaryPeer, { title: 'Primary Folder', workingDirectories: [primary] }); + const stateManager = getStateManager(svc); + const sessionFolders = async () => { + await timeout(10); + return stateManager.getSessionSummary(session.toString())?.workingDirectories; + }; + + svc.dispatchAction(peer.toString(), { type: ActionType.ChatIsArchivedChanged, isArchived: true }, 'client', 1); + const afterArchive = await sessionFolders(); + svc.dispatchAction(primaryPeer.toString(), { type: ActionType.ChatIsArchivedChanged, isArchived: true }, 'client', 2); + const afterPrimaryArchive = await sessionFolders(); + svc.dispatchAction(peer.toString(), { type: ActionType.ChatIsArchivedChanged, isArchived: false }, 'client', 3); + const afterUnarchive = await sessionFolders(); + // An archived chat whose folder is still attached, as before archiving detached folders, is repaired by archiving it again. + svc.dispatchAction(peer.toString(), { type: ActionType.ChatIsArchivedChanged, isArchived: true }, 'client', 4); + await sessionFolders(); + await svc.addSessionWorkingDirectoryForChat(session, other, { isolation: 'folder' }); + svc.dispatchAction(peer.toString(), { type: ActionType.ChatIsArchivedChanged, isArchived: true }, 'client', 5); + const afterRepair = await sessionFolders(); + + assert.deepStrictEqual({ + afterArchive, + // The primary folder is never detached. + afterPrimaryArchive, + afterUnarchive, + afterRepair, + peerFolders: stateManager.getSessionState(session.toString())?.chats.find(chat => chat.resource === peer.toString())?.workingDirectories, + }, { + afterArchive: [primary.toString()], + afterPrimaryArchive: [primary.toString()], + afterUnarchive: [primary.toString(), other.toString()], + afterRepair: [primary.toString()], + peerFolders: [other.toString()], + }); + }); + + test('archiving a peer chat in its own worktree detaches the worktree without deleting it', async () => { + const perSession = createPerSessionDataService(); + const repository = URI.file('/workspace/repository'); + const worktree = URI.file('/workspace/repository.worktrees/task'); + const handle = generateUuid(); + const gitService: IAgentHostGitService = { + ...createNoopGitService(), + getRepositoryRoot: async () => repository, + getWorktreeRoots: async () => [repository, worktree], + getDefaultBranch: async () => ({ name: 'main', startPoint: 'origin/main' }), + }; + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, gitService)); + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { + return { providerData: 'peer-backing' }; + } + } + const agent = disposables.add(new MultiChatAgent('copilot', { + multipleChats: { fork: true }, + multipleWorkingDirectories: { immutablePrimary: true }, + })); + registerTestAgentProvider(svc, agent); + const primary = URI.file('/workspace/primary'); + const session = await svc.createSession({ legacyChatUris: true, provider: agent.id, workingDirectories: [primary] }); + const deleted: string[] = []; + setTestAgentHostWorktreeIsolation(svc, createTestAgentHostWorktreeIsolation({ + createDetachedWorktree: async () => ({ handle, worktree }), + claimDetachedWorktree: async () => { }, + deleteDetachedWorktreeRecord: async deletedHandle => { deleted.push(deletedHandle); }, + })); + const peer = URI.parse(buildChatUri(session, 'worktree-peer')); + const prepared = await svc.prepareChatWorkingDirectory(session, repository, { isolation: 'worktree', prompt: 'task' }); + await prepared.associateWithChat?.(peer); + await svc.createChat(session, peer, { title: 'Worktree', workingDirectories: [prepared.directory] }); + const stateManager = getStateManager(svc); + const snapshot = async () => { + await timeout(10); + return { + session: stateManager.getSessionSummary(session.toString())?.workingDirectories, + peer: stateManager.getSessionState(session.toString())?.chats.find(chat => chat.resource === peer.toString())?.workingDirectories, + records: (await readSessionAdditionalWorktrees(getTestSessionStateStore(svc), session)).map(record => record.handle), + }; + }; + + svc.dispatchAction(peer.toString(), { type: ActionType.ChatIsArchivedChanged, isArchived: true }, 'client', 1); + const afterArchive = await snapshot(); + svc.dispatchAction(peer.toString(), { type: ActionType.ChatIsArchivedChanged, isArchived: false }, 'client', 2); + const afterUnarchive = await snapshot(); + + assert.deepStrictEqual({ afterArchive, afterUnarchive, deleted }, { + afterArchive: { session: [primary.toString()], peer: [worktree.toString()], records: [handle] }, + afterUnarchive: { session: [primary.toString(), worktree.toString()], peer: [worktree.toString()], records: [handle] }, + deleted: [], + }); + }); + test('restores a folder added for a peer chat as a session folder', async () => { class MultiChatAgent extends MockAgent { override async createChat(): Promise { @@ -19310,6 +19436,7 @@ suite('AgentService (node dispatcher)', () => { _serviceBrand: undefined, hasGitRoot: createNoopGitService().hasGitRoot, getCurrentBranch: async () => undefined, + getHEAD: async () => undefined, getDefaultBranch: async () => undefined, getDefaultBranch2: async () => undefined, getBranch: async () => undefined, @@ -19425,6 +19552,7 @@ suite('AgentService (node dispatcher)', () => { _serviceBrand: undefined, hasGitRoot: createNoopGitService().hasGitRoot, getCurrentBranch: async () => undefined, + getHEAD: async () => undefined, getDefaultBranch: async () => undefined, getDefaultBranch2: async () => undefined, getBranch: async () => undefined, @@ -22435,6 +22563,13 @@ suite('AgentService (node dispatcher)', () => { const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); registerTestAgentProvider(localService, agent); + // The toggle publishes only after loading the session's central state; slow that, as a loaded runner does. + const central = getTestAgentHostDatabase(localService); + const readSessionState = central.readSessionState.bind(central); + central.readSessionState = async (...args) => { + await timeout(25); + return readSessionState(...args); + }; const session = AgentSession.uri('copilot', `passive-${action.type}`); const sessionStr = session.toString(); @@ -22449,16 +22584,12 @@ suite('AgentService (node dispatcher)', () => { getStateManager(localService).announceSurfacedSession(summary); getStateManager(localService).prepareSessionSummariesForListing([summary]); - const notifications: INotification[] = []; - const listener = localService.onDidNotification(n => notifications.push(n)); - + const published = Event.toPromise(Event.filter(localService.onDidNotification, n => n.type === 'root/sessionSummaryChanged')); localService.dispatchAction(sessionStr, action, 'test-client', 1, AgentHostClientType.EditorWindow); - for (let attempt = 0; attempt < 20 && !notifications.some(notification => notification.type === 'root/sessionSummaryChanged'); attempt++) { - await timeout(0); - } - listener.dispose(); + const summaryChanged = await raceTimeout(published, 5000); + await getTestSessionStateStore(localService).whenIdle(undefined); + await localService.whenCatalogReconciliationIdle(); - const summaryChanged = notifications.find(n => n.type === 'root/sessionSummaryChanged'); assert.deepStrictEqual({ action: action.type, persisted: await db.getMetadata(key), @@ -23543,6 +23674,11 @@ suite('AgentService (node dispatcher)', () => { log, fileService, backing.service, { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, database, )); + // Disposing does not drain session state like shutdown does; a mirror flush still running would hit the closed database. + const disposeService = async (service: AgentService) => { + await getTestSessionStateStore(service).whenIdle(undefined); + service.dispose(); + }; class HistoricalSubagentAgent extends MockAgent { readonly reads: string[] = []; override async getSessionMessages(session: URI): Promise { @@ -23568,15 +23704,30 @@ suite('AgentService (node dispatcher)', () => { { type: 'subagent_started', session, toolCallId: 'historical-task', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explore' }, { type: 'tool_complete', session, toolCallId: 'historical-task', result: { success: true, pastTenseMessage: 'Delegated', content: [] } }, ]; - first.dispose(); - await database.close(); + // Hold the timer-driven mirror flush in flight across the dispose, as a slow machine does. + const mirrored = database.markSessionStateLegacyMirrored.bind(database); + const flushStarted = new DeferredPromise(); + const releaseFlush = new DeferredPromise(); + database.markSessionStateLegacyMirrored = async (...args) => { + flushStarted.complete(); + await releaseFlush.p; + return mirrored(...args); + }; + await raceTimeout(flushStarted.p, 1000); + const disposed = (async () => { + await disposeService(first); + await database.close(); + })(); + await timeout(0); + releaseFlush.complete(); + await disposed; database = disposables.add(new AgentHostDatabase(path)); const restored = createService(); registerTestAgentProvider(restored, agent); await restored.restoreSession(session); const row = (await database.readChatV2(session.toString(), child)).chat!; await database.updateChatV2Metadata(child, row, { metadata: { ...row.metadata, summary: 'Normalized Worker', titleSource: 'user' } }); - restored.dispose(); + await disposeService(restored); await database.close(); database = disposables.add(new AgentHostDatabase(path)); const restarted = createService(); @@ -23605,7 +23756,7 @@ suite('AgentService (node dispatcher)', () => { publicChats: [defaultChat], errors: [], }); - restarted.dispose(); + await disposeService(restarted); } finally { await database.close(); rmSync(directory, { recursive: true, force: true }); @@ -28785,7 +28936,9 @@ suite('AgentService (node dispatcher)', () => { gitService.getRepositoryRoot = async candidate => candidate.toString() === workingDirectory.toString() ? workingDirectory : undefined; gitService.revParse = async () => 'head'; gitService.getCurrentBranch = async () => 'feature'; + gitService.getHEAD = async () => ({ ref: 'refs/heads/feature', name: 'feature', kind: GitRefType.Head }); gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' }); + gitService.getDefaultBranch2 = async () => ({ ref: 'refs/heads/main', name: 'main', kind: GitRefType.Head }); const sessionDataService = createSessionDataService(new TestSessionDatabase()); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); setTestAgentHostWorktreeIsolation(localService, disposables.add(new WorktreeIsolation( @@ -32541,7 +32694,9 @@ suite('AgentService (node dispatcher)', () => { firstGitService.getRepositoryRoot = async () => sourceDir; firstGitService.revParse = async () => 'head'; firstGitService.getCurrentBranch = async () => 'main'; + firstGitService.getHEAD = async () => ({ ref: 'refs/heads/main', name: 'main', kind: GitRefType.Head }); firstGitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' }); + firstGitService.getDefaultBranch2 = async () => ({ ref: 'refs/heads/main', name: 'main', kind: GitRefType.Head }); firstGitService.addWorktree = async () => { throw new Error('git worktree exited with code 128: restart regression diagnostic'); }; diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index d5a559efe239c0..d9e90f18df699f 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -69,6 +69,7 @@ import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry, IAgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AdditionalWorktreeLifecycleService, IAdditionalWorktreeLifecycleService } from '../../node/chatContributions/additionalWorktreeLifecycle/additionalWorktreeLifecycleService.js'; import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostArchivedChatWorkspaceService } from '../../node/chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter, type IAgentHostAskQuestionsToolInvokedEvent, type IAgentHostTurnCompletedEvent } from '../../node/agentHostTelemetryReporter.js'; import { IAgentHostSessionPromptService } from '../../node/agentHostSessionPromptService.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from '../../node/agentHostToolCallTracker.js'; @@ -254,6 +255,7 @@ function createTestSideEffects( setArchived: async () => { }, }], ); + services.set(IAgentHostArchivedChatWorkspaceService, { _serviceBrand: undefined, setChatWorkspaceAttached: async () => { } } satisfies IAgentHostArchivedChatWorkspaceService); services.set(ISessionWorkspaceConversionService, { _serviceBrand: undefined, supportsChatIsolation: () => false, diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 72e182ca4016b9..ee0eb847abed43 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -67,6 +67,7 @@ import { LocalCommandContribution } from '../../node/chatContributions/localComm import { MarkUnreadContribution } from '../../node/chatContributions/markUnread/markUnreadContribution.js'; import { QueueDrainContribution } from '../../node/chatContributions/queueDrain/queueDrainContribution.js'; import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostArchivedChatWorkspaceService } from '../../node/chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { SessionWorkspaceConversionContribution } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.js'; import { SessionTitleContribution } from '../../node/chatContributions/sessionTitle/sessionTitleContribution.js'; import { SideChatContribution } from '../../node/chatContributions/sideChat/sideChatContribution.js'; @@ -956,6 +957,7 @@ function createBuiltInContributions(disposables: ReturnType { }, }], ); + services.set(IAgentHostArchivedChatWorkspaceService, { _serviceBrand: undefined, setChatWorkspaceAttached: async () => { } } satisfies IAgentHostArchivedChatWorkspaceService); services.set(ISessionWorkspaceConversionService, { _serviceBrand: undefined, supportsChatIsolation: () => false, @@ -1041,6 +1043,7 @@ function createQueueDrainContributions(disposables: ReturnType { } } satisfies IAgentHostArchivedChatWorkspaceService); services.set(ISessionWorkspaceConversionService, { _serviceBrand: undefined, supportsChatIsolation: () => false, @@ -1182,6 +1185,7 @@ suite('AgentHostChatContributions', () => { const peerChat = buildChatUri(session, 'peer'); const failingChat = buildChatUri(session, 'failing-peer'); const persisted: { session: string; chat: string; archived: boolean }[] = []; + const workspace: { session: string; chat: string; attached: boolean }[] = []; const errors: string[] = []; const logService = new class extends NullLogService { override error(message: string | Error, ...args: unknown[]): void { @@ -1218,6 +1222,10 @@ suite('AgentHostChatContributions', () => { [IAgentHostStateManager, stateManager], [IAgentHostChatOwnershipService, createTestChatOwnershipService(stateManager)], [IAgentHostPeerChatPersistenceService, peerChatPersistenceService], + [IAgentHostArchivedChatWorkspaceService, { + _serviceBrand: undefined, + setChatWorkspaceAttached: async (session: URI, chat: URI, attached: boolean) => { workspace.push({ session: session.toString(), chat: chat.toString(), attached }); }, + } satisfies IAgentHostArchivedChatWorkspaceService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const contributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); @@ -1232,6 +1240,7 @@ suite('AgentHostChatContributions', () => { assert.deepStrictEqual({ persisted, + workspace, errors, peerRead: !!((stateManager.getChatState(peerChat)?.status ?? 0) & SessionStatus.IsRead), failingRead: !!((stateManager.getChatState(failingChat)?.status ?? 0) & SessionStatus.IsRead), @@ -1240,6 +1249,11 @@ suite('AgentHostChatContributions', () => { { session, chat: peerChat, archived: true }, { session, chat: peerChat, archived: false }, ], + workspace: [ + { session, chat: peerChat, attached: false }, + { session, chat: peerChat, attached: true }, + { session, chat: failingChat, attached: false }, + ], errors: [`Error: write failed [ChatArchiveContribution] Failed to persist archived state for ${failingChat}`], peerRead: true, failingRead: true, @@ -1265,6 +1279,7 @@ suite('AgentHostChatContributions', () => { setRead: async () => { }, setArchived: async (_session: URI, chat: URI, archived: boolean) => { persisted.push({ chat: chat.toString(), archived }); }, } satisfies IAgentHostPeerChatPersistenceService], + [IAgentHostArchivedChatWorkspaceService, { _serviceBrand: undefined, setChatWorkspaceAttached: async () => { } } satisfies IAgentHostArchivedChatWorkspaceService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const contributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); @@ -2050,7 +2065,7 @@ suite('AgentHostChatContributions', () => { ] satisfies ITurnEnd['reason'][]) { for (const rejected of [false, true]) { for (const owner of ['session', 'defaultChat', 'peerChat'] as const) { - test(`${owner} ${reason.kind} schedules tracked changes before checkpoint capture ${rejected ? 'rejects' : 'resolves'} and auto refresh after`, async () => { + test(`${owner} ${reason.kind} schedules tracked changes without waiting for checkpoint capture that ${rejected ? 'rejects' : 'resolves'}`, async () => { const contributions = createBuiltInContributions(disposables); const checkpoint = new DeferredPromise(); const captures: { session: string; chat: string; turnId: string }[] = []; @@ -2090,7 +2105,7 @@ suite('AgentHostChatContributions', () => { captures: [{ session: turn.session, chat: channel, turnId: turn.turnId }], whilePending: { recomputes: expected, refreshes: [] }, recomputes: expected, - refreshes: [[turn.session, 'auto']], + refreshes: [], warnings: rejected ? [`[AgentSideEffects] Turn checkpoint capture failed for ${turn.session}/${turn.turnId}: checkpoint failed`] : [], }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 6a6b4561daac4a..3238faa21b7297 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -19,6 +19,7 @@ import { DeferredPromise, raceTimeout, timeout } from '../../../../base/common/a import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; import { Disposable, DisposableStore, toDisposable, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; +import { equals } from '../../../../base/common/arrays.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Schemas } from '../../../../base/common/network.js'; import { isEqual, isEqualOrParent } from '../../../../base/common/resources.js'; @@ -58,7 +59,7 @@ import { AgentHostEnsembleEnabledSettingId, CopilotCliConfigKey, CopilotCliVSCod import { readAgentModelEnsembleMeta } from '../../common/meta/agentModelMeta.js'; import { TestExperimentTriggerTelemetryService } from '../../../telemetry/test/common/experimentTriggerTestUtils.js'; import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey, platformSessionSchema } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTelemetryLevelConfigKey, platformSessionSchema, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { toClientPluginMcpDefaultCwdsMeta, toClientPluginStandaloneMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; @@ -198,12 +199,10 @@ function hostSessionSettings(session: URI): IAgentChatSessionSettings | undefine return []; } }; - const shared = m['copilot.sharedCustomizationDirectories'] ? parseDirs(m['copilot.sharedCustomizationDirectories']) : undefined; const defaults = m['copilot.workingDirectories'] ? parseDirs(m['copilot.workingDirectories']) : m['copilot.workingDirectory'] ? [URI.parse(m['copilot.workingDirectory'])] : undefined; - const anchors = shared ?? defaults; return { - customizationDirectory: shared?.[0] ?? defaults?.[0] ?? (m['copilot.customizationDirectory'] ? URI.parse(m['copilot.customizationDirectory']) : undefined), - additionalCustomizationDirectories: anchors?.slice(1) ?? [], + customizationDirectory: defaults?.[0] ?? (m['copilot.customizationDirectory'] ? URI.parse(m['copilot.customizationDirectory']) : undefined), + additionalCustomizationDirectories: defaults?.slice(1) ?? [], project: undefined, workspaceless: m[AH_META_WORKSPACELESS_DB_KEY] === 'true', defaultChatWorkingDirectory: defaults?.[0], @@ -390,6 +389,7 @@ class TestAgentHostGitService implements IAgentHostGitService { dirtyWorkingDirectories = new Set(); async getCurrentBranch(): Promise { return undefined; } + async getHEAD(): Promise { return undefined; } async getDefaultBranch(): Promise { return undefined; } async getDefaultBranch2(): Promise { return undefined; } async getBranch(): Promise { return undefined; } @@ -2277,6 +2277,491 @@ suite('CopilotAgent', () => { } }); + suite('per-chat customization anchors', () => { + interface IAnchorInternalsAnchor { readonly directory: URI; readonly _discovered: { readonly value: { readonly _discovery: { readonly _onDidChange: Emitter } } | undefined } } + interface IAnchorInternalsController { + liveAnchorKeys(): readonly string[]; + acquireAnchor(directory: URI, additionalDirectories: readonly URI[]): IReference; + getCustomizationsSettled(): Promise; + readonly _anchors: ReadonlyMap; + } + type AnchorInternals = { _activeClients: { get(session: URI): { pluginController: IAnchorInternalsController; snapshot(chatKey: string, anchor: IAnchorInternalsAnchor): Promise } | undefined } }; + /** The custom agents (excluding built-ins) of a customization list. */ + const listedAgents = (customizations: readonly Customization[]) => customizations.flatMap(item => item.type === CustomizationType.Directory && item.uri !== buildCopilotBuiltinAgentsContainer().uri + ? (item.children ?? []).filter(child => child.type === CustomizationType.Agent).map(child => child.name) : []).sort(); + /** Makes discovery report one more agent, `-extra-agent`, and raises a discovery change in `folder`'s anchor set. */ + function addAgentToFolder(agent: CopilotAgent, client: TestCopilotClient, session: URI, folder: URI): void { + const discoverAgents = client.discoverAgents; + client.discoverAgents = async params => { + const discovered = await discoverAgents(params); + return { ...discovered, agents: [...discovered.agents, { ...discovered.agents[0], id: `${discovered.agents[0].id}-extra`, name: `${basename(folder.fsPath)}-extra-agent`, displayName: `${basename(folder.fsPath)}-extra-agent` }] }; + }; + const controller = (agent as unknown as AnchorInternals)._activeClients.get(session)!.pluginController; + [...controller._anchors.values()].find(({ anchor }) => isEqual(anchor.directory, folder))!.anchor._discovered.value!._discovery._onDidChange.fire(); + } + function collectPublishedAgents(agent: CopilotAgent, session: URI): (readonly string[])[] { + const published: (readonly string[])[] = []; + disposables.add(agent.onDidChatProgress(signal => { + if (signal.kind === 'action' && isEqual(signal.resource, session) && signal.action.type === ActionType.SessionCustomizationsChanged) { + published.push(listedAgents(signal.action.customizations)); + } + })); + return published; + } + async function waitUntil(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 300 && !condition(); attempt++) { + await timeout(10); + } + return condition(); + } + + /** + * A session whose two folders each carry their own custom agent, + * instructions file and workspace MCP server (`-agent`, + * `.instructions.md`, `-server`). The default chat runs + * in the session's folders; peers are created in a chosen folder. + */ + async function createAnchorFixture(multiRoot: boolean) { + const root = await fs.mkdtemp(`${os.tmpdir()}/agent-chat-anchors-`); + const f1 = URI.file(join(root, 'f1')); + const f2 = URI.file(join(root, 'f2')); + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new TestDiskFileSystemProvider(new NullLogService())))); + for (const folder of [f1, f2]) { + const name = basename(folder.fsPath); + await fileService.writeFile(URI.joinPath(folder, '.github', 'agents', `${name}.agent.md`), VSBuffer.fromString(`${name} agent`)); + await fileService.writeFile(URI.joinPath(folder, '.github', 'instructions', `${name}.instructions.md`), VSBuffer.fromString(`${name} instructions`)); + await fileService.writeFile(URI.joinPath(folder, '.mcp.json'), VSBuffer.fromString(JSON.stringify({ mcpServers: { [`${name}-server`]: { command: `${name}-server` } } }))); + } + const client = new TestCopilotClient([]); + const agentFile = (projectPath: string) => URI.file(join(projectPath, '.github', 'agents', `${basename(projectPath)}.agent.md`)); + const instructionsFile = (projectPath: string) => URI.file(join(projectPath, '.github', 'instructions', `${basename(projectPath)}.instructions.md`)); + client.getAgentDiscoveryPaths = async params => ({ + paths: (params.projectPaths ?? []).map(projectPath => ({ path: dirname(agentFile(projectPath).fsPath), scope: 'project', preferredForCreation: true, projectPath })), + }); + client.discoverAgents = async params => ({ + agents: (params.projectPaths ?? []).map(projectPath => ({ id: customizationId(agentFile(projectPath).toString()), name: `${basename(projectPath)}-agent`, displayName: `${basename(projectPath)}-agent`, description: '', path: agentFile(projectPath).fsPath })), + }); + client.getInstructionDiscoveryPaths = async params => ({ + paths: (params.projectPaths ?? []).map(projectPath => ({ path: dirname(instructionsFile(projectPath).fsPath), location: 'repository', kind: 'directory', preferredForCreation: true, projectPath })), + }); + client.discoverInstructions = async params => ({ + sources: (params.projectPaths ?? []).map(projectPath => ({ id: customizationId(instructionsFile(projectPath).toString()), label: basename(instructionsFile(projectPath).fsPath), sourcePath: instructionsFile(projectPath).fsPath, content: '', type: 'vscode', location: 'repository', applyTo: ['**'], description: '', projectPath })), + }); + const launches: { readonly sdkSessionId: string; readonly tools: readonly string[]; readonly agent: string | undefined; readonly customAgents: readonly string[] }[] = []; + const sdkSessions = new Map(); + client.createSession = async config => { + const sdk = new MockCopilotSession(config.sessionId); + Object.assign(sdk.rpc, { agent: { select: async () => ({}), deselect: async () => ({}) } }); + sdkSessions.set(sdk.sessionId, sdk); + launches.push({ sdkSessionId: sdk.sessionId, tools: (config.tools ?? []).map(tool => tool.name), agent: config.agent, customAgents: (config.customAgents ?? []).map(agent => agent.name).sort() }); + return sdk as unknown as CopilotSession; + }; + client.resumeSession = async (id, config) => { + launches.push({ sdkSessionId: id, tools: (config?.tools ?? []).map(tool => tool.name), agent: config?.agent, customAgents: (config?.customAgents ?? []).map(agent => agent.name).sort() }); + return (sdkSessions.get(id) ?? new MockCopilotSession(id)) as unknown as CopilotSession; + }; + const sessionDataService = disposables.add(new TestSessionDataService()); + const createAgent = async () => { + const context = createTestAgentContext(disposables, { + sessionDataService, copilotClient: client, fileService, userHome: URI.file(join(root, 'home')), + rootConfig: { [AgentHostCopilotMultiRootEnabledConfigKey]: multiRoot, [AgentHostGitHubMcpServerEnabledConfigKey]: false }, + }); + await context.agent.authenticate('https://api.github.com', 'token'); + return context; + }; + let { agent, stateManager } = await createAgent(); + const session = AgentSession.uri('copilotcli', `chat-anchors-${multiRoot}`); + const main = defaultChatUri(session); + const sessionDirectories = multiRoot ? [f1, f2] : [f1]; + await provisionSession(agent, { session, workingDirectories: sessionDirectories }); + await agent.chats.sendMessage(main, 'Work in the session folders', sessionDirectories, undefined, undefined, undefined, exactChatContext(session, main, session)); + const summarize = (plugins: CopilotAgentSession['appliedSnapshot']['plugins']) => ({ + agents: plugins.flatMap(plugin => plugin.agents.map(item => item.name)).sort(), + instructions: plugins.flatMap(plugin => plugin.instructions.map(item => item.name)).sort(), + mcpServers: plugins.flatMap(plugin => plugin.mcpServers.map(item => item.name)).sort(), + }); + const mainSdkSessionId = launches[0].sdkSessionId; + return { + get agent() { return agent; }, + get stateManager() { return stateManager; }, + client, session, main, f1, f2, sessionDataService, launches, + /** Creates a peer chat working in `folders`, optionally before the host has loaded the session's state. */ + createPeer: async (name: string, folder: URI, options?: { readonly sessionSettingsUnloaded?: boolean }) => { + const peer = URI.parse(buildChatUri(session, name)); + const context = exactChatContext(session, peer, peer); + await agent.chats.createChat(peer, options?.sessionSettingsUnloaded ? { ...context, sessionSettings: undefined } : context, { workingDirectories: [folder] }); + return peer; + }, + /** Replaces the agent with a fresh one over the same storage, the default chat restored but not live. */ + restart: async () => { + await disposeAgent(agent); + ({ agent, stateManager } = await createAgent()); + await agent.materializeChat(main, exactChatContext(session, main, session), JSON.stringify({ sdkSessionId: mainSdkSessionId })); + }, + context: (chat: URI) => exactChatContext(session, chat, isEqual(chat, main) ? session : chat), + /** What the chat's SDK session was launched with. */ + applied: (chat: URI) => summarize(getPeerChatStub(agent, chat)!.appliedSnapshot.plugins), + /** The session's published customizations (`SessionState.customizations`). */ + published: async () => { + const customizations = await getDefaultChatCustomizations(agent, session); + return { + agents: listedAgents(customizations), + mcpServers: customizations.filter(item => item.type === CustomizationType.McpServer).map(item => item.name).sort(), + }; + }, + discoveryRuns: (folders: readonly URI[]) => client.skillDiscoveryRequests.filter(request => equals(request.projectPaths, folders.map(folder => folder.fsPath))).length, + liveAnchorKeys: () => (agent as unknown as AnchorInternals)._activeClients.get(session)?.pluginController.liveAnchorKeys() ?? [], + dispose: async () => { + await disposeAgent(agent); + await fs.rm(root, { recursive: true, force: true }); + }, + }; + } + + for (const multiRoot of [false, true]) { + test(`a peer in the session's second folder gets that folder's agents, instructions and MCP servers, and the default chat is unchanged (multi-root: ${multiRoot})`, async () => { + const fixture = await createAnchorFixture(multiRoot); + try { + const { agent, f1, f2 } = fixture; + const peer = await fixture.createPeer('peer-f2', f2); + // A peer whose own folders are `f2` then `f1` gets its other folder only when multi-root is on. + const multiFolderPeer = await fixture.createPeer('peer-f2-f1', f2); + await agent.chats.sendMessage(multiFolderPeer, 'Work in both folders', [f2, f1], undefined, undefined, undefined, fixture.context(multiFolderPeer)); + const name = (folder: URI) => basename(folder.fsPath); + const folderView = (folders: readonly URI[]) => ({ + agents: folders.map(folder => `${name(folder)}-agent`).sort(), + instructions: folders.map(folder => `${name(folder)}.instructions.md`).sort(), + mcpServers: folders.map(folder => `${name(folder)}-server`).sort(), + }); + const sessionFolders = multiRoot ? [f1, f2] : [f1]; + assert.deepStrictEqual({ + peer: fixture.applied(peer), + multiFolderPeer: fixture.applied(multiFolderPeer), + main: fixture.applied(fixture.main), + published: await fixture.published(), + }, { + // Neither the first folder's (multi-root off) nor the union (multi-root on). + peer: folderView([f2]), + multiFolderPeer: folderView(multiRoot ? [f2, f1] : [f2]), + main: folderView(sessionFolders), + published: { agents: folderView(sessionFolders).agents, mcpServers: folderView(sessionFolders).mcpServers }, + }); + } finally { + await fixture.dispose(); + } + }); + } + + test('the default chat follows a chat-only move to another folder, sharing a peer\'s discovery there, while older builds still get the shared folders', async () => { + const fixture = await createAnchorFixture(false); + try { + const { agent, main, f1, f2 } = fixture; + const peer = await fixture.createPeer('peer-f2', f2); + const before = { main: fixture.applied(main), published: await fixture.published() }; + await agent.setChatWorkingDirectory(main, fixture.context(main), f2); + const publishedAfterMove = await fixture.published(); + await agent.chats.sendMessage(main, 'Continue in the second folder', [f2], undefined, undefined, undefined, fixture.context(main)); + const db = fixture.sessionDataService.openDatabase(fixture.session); + let sharedRoots: string | undefined; + try { + sharedRoots = await db.object.getMetadata('copilot.sharedCustomizationDirectories'); + } finally { + db.dispose(); + } + assert.deepStrictEqual({ + before, + publishedAfterMove, + main: fixture.applied(main), + peer: fixture.applied(peer), + published: await fixture.published(), + f2DiscoveryRuns: fixture.discoveryRuns([f2]), + sharedRoots, + }, { + before: { + main: { agents: ['f1-agent'], instructions: ['f1.instructions.md'], mcpServers: ['f1-server'] }, + published: { agents: ['f1-agent'], mcpServers: ['f1-server'] }, + }, + publishedAfterMove: { agents: ['f2-agent'], mcpServers: ['f2-server'] }, + main: { agents: ['f2-agent'], instructions: ['f2.instructions.md'], mcpServers: ['f2-server'] }, + peer: { agents: ['f2-agent'], instructions: ['f2.instructions.md'], mcpServers: ['f2-server'] }, + published: { agents: ['f2-agent'], mcpServers: ['f2-server'] }, + f2DiscoveryRuns: 1, + sharedRoots: JSON.stringify([f1.toString()]), + }); + } finally { + await fixture.dispose(); + } + }); + + test('the published view stays the default chat\'s when a peer in another folder starts first after a restart', async () => { + const fixture = await createAnchorFixture(false); + try { + const { main, f1, f2 } = fixture; + await fixture.restart(); + const peer = await fixture.createPeer('peer-f2', f2, { sessionSettingsUnloaded: true }); + await fixture.agent.chats.sendMessage(main, 'Continue in the first folder', [f1], undefined, undefined, undefined, fixture.context(main)); + assert.deepStrictEqual({ peer: fixture.applied(peer).agents, main: fixture.applied(main).agents, published: await fixture.published() }, { + peer: ['f2-agent'], + main: ['f1-agent'], + published: { agents: ['f1-agent'], mcpServers: ['f1-server'] }, + }); + } finally { + await fixture.dispose(); + } + }); + + test('a peer selecting an agent the session\'s picker lists but its own folder lacks resolves and runs with it, keeping its own folder\'s instructions', async () => { + const fixture = await createAnchorFixture(false); + try { + const { agent, f1, f2 } = fixture; + const peer = await fixture.createPeer('peer-f2', f2); + // The picker lists the session's (default chat's) agents: here `f1-agent`. + const listed = (await fixture.published()).agents; + await agent.chats.changeAgent(peer, { uri: URI.joinPath(f1, '.github', 'agents', 'f1.agent.md').toString() }, fixture.context(peer)); + await agent.chats.sendMessage(peer, 'Use the first folder\'s agent', [f2], undefined, undefined, undefined, fixture.context(peer)); + const peerSdkSessionId = getPeerChatStub(agent, peer)!.sessionId; + const launch = fixture.launches.filter(candidate => candidate.sdkSessionId === peerSdkSessionId).at(-1); + const customAgents = launch?.customAgents.filter(name => !COPILOT_BUILTIN_AGENT_NAMES.has(name)); + assert.deepStrictEqual({ listed, launch: { agent: launch?.agent, customAgents }, applied: fixture.applied(peer) }, { + listed: ['f1-agent'], + launch: { agent: 'f1-agent', customAgents: ['f1-agent', 'f2-agent'] }, + applied: { agents: ['f1-agent', 'f2-agent'], instructions: ['f2.instructions.md'], mcpServers: ['f2-server'] }, + }); + } finally { + await fixture.dispose(); + } + }); + + test('the session list follows the default chat\'s chat-only move, and its new folder\'s changes publish without re-reading it', async () => { + const fixture = await createAnchorFixture(false); + try { + const { agent, session, main, f2, client } = fixture; + const published = collectPublishedAgents(agent, session); + await agent.setChatWorkingDirectory(main, fixture.context(main), f2); + const afterMove = await waitUntil(() => equals(published.at(-1), ['f2-agent'])); + // A new agent appears in the new folder; nothing reads the session's customizations meanwhile. + addAgentToFolder(agent, client, session, f2); + const afterChange = await waitUntil(() => equals(published.at(-1), ['f2-agent', 'f2-extra-agent'])); + assert.deepStrictEqual({ afterMove, afterChange, last: published.at(-1) }, { afterMove: true, afterChange: true, last: ['f2-agent', 'f2-extra-agent'] }); + } finally { + await fixture.dispose(); + } + }); + + test('after a restart, changes in the resumed default chat\'s folder publish without the session list being re-read', async () => { + const fixture = await createAnchorFixture(false); + try { + const { main, f1, client, session } = fixture; + await fixture.restart(); + const agent = fixture.agent; + await agent.chats.sendMessage(main, 'Continue after the restart', [f1], undefined, undefined, undefined, fixture.context(main)); + const published = collectPublishedAgents(agent, session); + addAgentToFolder(agent, client, session, f1); + assert.deepStrictEqual(await waitUntil(() => equals(published.at(-1), ['f1-agent', 'f1-extra-agent'])), true); + } finally { + await fixture.dispose(); + } + }); + + test('a read overlapping session teardown resolves without discovering', async () => { + const fixture = await createAnchorFixture(false); + try { + const { agent, session, main, f2 } = fixture; + const activeClient = (agent as unknown as AnchorInternals)._activeClients.get(session)!; + // A snapshot in flight holds its chat's anchor set while the last chat goes and the session finalizes. + const anchor = activeClient.pluginController.acquireAnchor(f2, []); + try { + await agent.chats.disposeChat(main, fixture.context(main)); + const snapshot = await activeClient.snapshot(main.toString(), anchor.object); + const published = await activeClient.pluginController.getCustomizationsSettled(); + assert.deepStrictEqual({ snapshotAgents: snapshot.plugins.flatMap(plugin => plugin.agents.map(item => item.name)), publishedAgents: listedAgents(published) }, { snapshotAgents: [], publishedAgents: [] }); + } finally { + anchor.dispose(); + } + } finally { + await fixture.dispose(); + } + }); + + test('chats reaching the same folder through different spellings share one discovery of it', async () => { + const fixture = await createAnchorFixture(false); + try { + const { f1, f2 } = fixture; + // The same folder through a symlink, as `/tmp` and `/private/tmp` on macOS. + const alias = URI.file(join(dirname(f2.fsPath), 'f2-alias')); + await fs.symlink(f2.fsPath, alias.fsPath, process.platform === 'win32' ? 'junction' : 'dir'); + const viaFolder = await fixture.createPeer('via-folder', f2); + const viaAlias = await fixture.createPeer('via-alias', alias); + assert.deepStrictEqual({ + viaFolder: fixture.applied(viaFolder).agents, + viaAlias: fixture.applied(viaAlias).agents, + launchedIn: getPeerChatStub(fixture.agent, viaAlias)?.workingDirectory?.fsPath, + discoveryRuns: fixture.discoveryRuns([f2]) + fixture.discoveryRuns([alias]), + liveAnchors: fixture.liveAnchorKeys().length, + }, { + viaFolder: ['f2-agent'], + viaAlias: ['f2-agent'], + // Only the key is normalized; the chat still runs in the folder it was given. + launchedIn: alias.fsPath, + discoveryRuns: 1, + liveAnchors: [f1, f2].length, + }); + } finally { + await fixture.dispose(); + } + }); + + test('a folder published before its identity resolves shares its discovery with the chat that launches there through a symlink', async () => { + const fixture = await createAnchorFixture(false); + try { + const { agent, f2 } = fixture; + const alias = URI.file(join(dirname(f2.fsPath), 'f2-early-alias')); + await fs.symlink(f2.fsPath, alias.fsPath, process.platform === 'win32' ? 'junction' : 'dir'); + const session = AgentSession.uri('copilotcli', 'anchors-early-publish'); + const chat = defaultChatUri(session); + await provisionSession(agent, { session, workingDirectories: [alias] }); + // A client customization sync publishes the session's list before any chat launches. + const activeClient = (agent as unknown as { _getOrCreateActiveClient(session: URI, directory: URI): { pluginController: IAnchorInternalsController & { getCustomizations(): readonly Customization[] } } })._getOrCreateActiveClient(session, alias); + activeClient.pluginController.getCustomizations(); + await agent.chats.sendMessage(chat, 'Launch through the symlink', [alias], undefined, undefined, undefined, exactChatContext(session, chat, session)); + // The test discovery names an agent after the folder spelling it scanned: the published (alias) one. + assert.deepStrictEqual({ + applied: fixture.applied(chat).agents, + discoveryRuns: fixture.discoveryRuns([alias]) + fixture.discoveryRuns([f2]), + liveAnchors: activeClient.pluginController.liveAnchorKeys().length, + }, { applied: ['f2-early-alias-agent'], discoveryRuns: 1, liveAnchors: 1 }); + } finally { + await fixture.dispose(); + } + }); + + test('repeated chat-only moves of the default chat keep the first move\'s roots for older builds', async () => { + const fixture = await createAnchorFixture(false); + try { + const { agent, main, f1, f2 } = fixture; + const f3 = URI.file(join(dirname(f2.fsPath), 'f3')); + await fs.mkdir(f3.fsPath); + const sharedRoots = async () => { + const db = fixture.sessionDataService.openDatabase(fixture.session); + try { + return await db.object.getMetadata('copilot.sharedCustomizationDirectories'); + } finally { + db.dispose(); + } + }; + await agent.setChatWorkingDirectory(main, fixture.context(main), f2); + const afterFirstMove = await sharedRoots(); + await agent.chats.sendMessage(main, 'Continue in the second folder', [f2], undefined, undefined, undefined, fixture.context(main)); + await agent.setChatWorkingDirectory(main, fixture.context(main), f3); + assert.deepStrictEqual({ afterFirstMove, afterSecondMove: await sharedRoots() }, { + afterFirstMove: JSON.stringify([f1.toString()]), + afterSecondMove: JSON.stringify([f1.toString()]), + }); + } finally { + await fixture.dispose(); + } + }); + + test('chats working in the same folder share one discovery of it', async () => { + const fixture = await createAnchorFixture(false); + try { + const { f1, f2 } = fixture; + const first = await fixture.createPeer('first-f2', f2); + const second = await fixture.createPeer('second-f2', f2); + assert.deepStrictEqual({ + first: fixture.applied(first).agents, + second: fixture.applied(second).agents, + f2DiscoveryRuns: fixture.discoveryRuns([f2]), + liveAnchors: [...fixture.liveAnchorKeys()].sort(), + }, { + first: ['f2-agent'], + second: ['f2-agent'], + f2DiscoveryRuns: 1, + liveAnchors: [f1.toString(), f2.toString()].sort(), + }); + } finally { + await fixture.dispose(); + } + }); + + test('client tool updates reach every chat of the session, whichever folder it works in', async () => { + const fixture = await createAnchorFixture(false); + try { + const { agent, main, f2 } = fixture; + const peer = await fixture.createPeer('peer-f2', f2); + for (const chat of [main, peer]) { + agent.getOrCreateActiveClient(chat, fixture.context(chat), { clientId: 'window' }).tools = [{ + name: 'window_tool', + description: 'A client tool', + inputSchema: { type: 'object', properties: {} }, + }]; + } + const mark = fixture.launches.length; + await agent.chats.sendMessage(peer, 'Use the tool', [f2], undefined, undefined, undefined, fixture.context(peer)); + await agent.chats.sendMessage(main, 'Use the tool', [fixture.f1], undefined, undefined, undefined, fixture.context(main)); + assert.deepStrictEqual({ + relaunches: fixture.launches.slice(mark).map(launch => launch.tools.includes('window_tool')), + peer: getPeerChatStub(agent, peer)?.appliedSnapshot.tools.map(tool => tool.name), + main: getPeerChatStub(agent, main)?.appliedSnapshot.tools.map(tool => tool.name), + }, { + relaunches: [true, true], + peer: ['window_tool'], + main: ['window_tool'], + }); + } finally { + await fixture.dispose(); + } + }); + + test('a folder\'s discovery is released with the last chat using it, and every discovery with the session', async () => { + const fixture = await createAnchorFixture(false); + try { + const { agent, session, main, f1, f2 } = fixture; + const peer = await fixture.createPeer('peer-f2', f2); + await fixture.published(); + const controller = (agent as unknown as AnchorInternals)._activeClients.get(session)!.pluginController; + const withPeer = [...controller.liveAnchorKeys()].sort(); + await agent.chats.disposeChat(peer, fixture.context(peer)); + const afterPeer = [...controller.liveAnchorKeys()]; + await agent.chats.disposeChat(main, fixture.context(main)); + assert.deepStrictEqual({ withPeer, afterPeer, afterSession: controller.liveAnchorKeys(), finalized: (agent as unknown as AnchorInternals)._activeClients.get(session) }, { + withPeer: [f1.toString(), f2.toString()].sort(), + afterPeer: [f1.toString()], + afterSession: [], + finalized: undefined, + }); + } finally { + await fixture.dispose(); + } + }); + + test('a peer\'s folder-only MCP server still appears in the session\'s list (MCP runtime state is published by every chat)', async () => { + const fixture = await createAnchorFixture(false); + try { + const { agent, stateManager, session, f1, f2 } = fixture; + const peer = await fixture.createPeer('peer-f2', f2); + const now = new Date().toISOString(); + stateManager.createSession({ + resource: session.toString(), provider: 'copilotcli', title: 'Anchors', status: SessionStatus.Idle, + createdAt: now, modifiedAt: now, workingDirectories: [f1.toString()], defaultChat: fixture.main.toString(), + }); + disposables.add(agent.onDidChatProgress(signal => { + if (signal.kind === 'action' && signal.resource.toString() === session.toString()) { + stateManager.dispatchServerAction(session.toString(), signal.action as SessionAction); + } + })); + stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...await getDefaultChatCustomizations(agent, session)] }); + const listed = () => (stateManager.getSessionState(session.toString())?.customizations ?? []).filter(item => item.type === CustomizationType.McpServer).map(item => item.name).sort(); + const before = listed(); + getPeerChatStub(agent, peer)!['_mcpCustomizations'].applyOne({ name: 'f2-server', state: { kind: McpServerStatus.Ready } }); + assert.deepStrictEqual({ before, after: listed() }, { before: ['f1-server'], after: ['f1-server', 'f2-server'] }); + } finally { + await fixture.dispose(); + } + }); + }); + suite('MCP authentication during session initialization', () => { test('standard host sessions reserve independent Copilot backing IDs without changing their address', async () => { const client = new McpAuthChallengeCopilotClient([]); @@ -7029,6 +7514,9 @@ suite('CopilotAgent', () => { return { agent, fileService, session, chats, resources, sdkSessions, client, worktreeIsolation, sessionDataService, previous, next, secondary, sessionDirectories, resumed, continuations, reanchors, metadata, activeClient, sends: () => sends, + /** Marks the discovery runs made so far; {@link discoveriesSince} returns the folders of each later run. */ + discoveryMark: () => client.skillDiscoveryRequests.length, + discoveriesSince: (mark: number) => client.skillDiscoveryRequests.slice(mark).map(request => request.projectPaths), pluginDirectory: () => activeClient.pluginController.directory?.toString(), restore: async () => { await disposeAgent(agent); @@ -7437,7 +7925,7 @@ suite('CopilotAgent', () => { }); } - test('creating a peer first after a cold main-only move preserves persisted shared discovery roots', async () => { + test('creating a peer first after a cold main-only move anchors every chat to its own folders and shares a folder\'s discovery', async () => { const fixture = await createFixture(true); let restored: CopilotAgent | undefined; try { @@ -7454,22 +7942,24 @@ suite('CopilotAgent', () => { createdDirectory = options.workingDirectory; return sdk as unknown as CopilotSession; }; + const mark = fixture.discoveryMark(); await restored.chats.createChat(peer, exactChatContext(session, peer, peer), { workingDirectories: [next] }); - const discoveryAfterCreate = client.skillDiscoveryRequests.at(-1)?.projectPaths; + const discoveriesAfterCreate = fixture.discoveriesSince(mark); await restored.chats.sendMessage(chats[1], 'Continue in the original workspace', [previous], undefined, undefined, undefined, exactChatContext(session, chats[1], resources[1])); - const discoveryAfterSibling = client.skillDiscoveryRequests.at(-1)?.projectPaths; + const discoveriesAfterSibling = fixture.discoveriesSince(mark); await restored.chats.sendMessage(chats[0], 'Continue in the main workspace', [next], undefined, undefined, undefined, exactChatContext(session, chats[0], resources[0])); assert.deepStrictEqual({ createdDirectory, - discoveryAfterCreate, - discoveryAfterSibling, - discoveryAfterMain: client.skillDiscoveryRequests.at(-1)?.projectPaths, + discoveriesAfterCreate, + discoveriesAfterSibling, + discoveriesAfterMain: fixture.discoveriesSince(mark), directories: [peer, chats[0], chats[1]].map(chat => getPeerChatStub(restored!, chat)?.workingDirectory?.toString()), }, { createdDirectory: next.fsPath, - discoveryAfterCreate: [previous.fsPath, secondary.fsPath], - discoveryAfterSibling: [previous.fsPath, secondary.fsPath], - discoveryAfterMain: [previous.fsPath, secondary.fsPath], + discoveriesAfterCreate: [[next.fsPath]], + discoveriesAfterSibling: [[next.fsPath], [previous.fsPath]], + // The main chat followed its move to `next`, whose discovery the peer already runs. + discoveriesAfterMain: [[next.fsPath], [previous.fsPath]], directories: [next.toString(), next.toString(), previous.toString()], }); } finally { @@ -7825,14 +8315,15 @@ suite('CopilotAgent', () => { ['next send', false], ['release and reopen', false], ['cold restart', false], ['next send', true], ['release and reopen', true], ['cold restart', true], ] as const) { - test(`preserves an isolated peer workspace and shared discovery through ${resume} and sibling sends (multi-root: ${multiRoot})`, async () => { + test(`anchors an isolated peer's discovery to its own workspace through ${resume} and sibling sends (multi-root: ${multiRoot})`, async () => { const fixture = await createFixture(multiRoot); let agent = fixture.agent; try { - const { session, chats, resources, previous, next, sessionDirectories, client } = fixture; + const { session, chats, resources, previous, next, sessionDirectories } = fixture; const context = exactChatContext(session, chats[1], resources[1]); const before = await fixture.metadata(); await agent.setChatWorkingDirectory(chats[1], context, next); + const peerMark = fixture.discoveryMark(); if (resume === 'cold restart') { agent = await fixture.restore(); } else if (resume === 'release and reopen') { @@ -7841,25 +8332,32 @@ suite('CopilotAgent', () => { await agent.chats.getMessages(chats[1], context); const reopenedDirectory = getPeerChatStub(agent, chats[1])?.workingDirectory?.toString(); await agent.chats.sendMessage(chats[1], 'Continue in the isolated workspace', [next], undefined, undefined, undefined, context); - const discoveryAfterPeerSend = client.skillDiscoveryRequests.at(-1)?.projectPaths; + const discoveriesForPeer = fixture.discoveriesSince(peerMark); + const siblingMark = fixture.discoveryMark(); for (const index of [0, 2]) { await agent.chats.sendMessage(chats[index], 'Continue in the original workspace', index === 0 ? sessionDirectories : [previous], undefined, undefined, undefined, exactChatContext(session, chats[index], resources[index])); } + const discoveriesForSiblings = fixture.discoveriesSince(siblingMark); + const finalMark = fixture.discoveryMark(); await agent.chats.sendMessage(chats[1], 'Continue after the siblings', [next], undefined, undefined, undefined, context); assert.deepStrictEqual({ reopenedDirectory, directories: chats.map(chat => getPeerChatStub(agent, chat)?.workingDirectory?.toString()), additionalDirectories: chats.map(chat => getPeerChatStub(agent, chat)?.appliedAdditionalDirectories.map(directory => directory.toString())), - discoveryAfterPeerSend, - discoveryAfterSiblingSends: client.skillDiscoveryRequests.at(-1)?.projectPaths, + discoveriesForPeer, + discoveriesForSiblings, + discoveriesAfterFinalPeerSend: fixture.discoveriesSince(finalMark), metadata: await fixture.metadata(), sends: fixture.sends(), }, { reopenedDirectory: next.toString(), directories: [previous.toString(), next.toString(), previous.toString()], additionalDirectories: [sessionDirectories.slice(1).map(directory => directory.toString()), [], []], - discoveryAfterPeerSend: sessionDirectories.map(directory => directory.fsPath), - discoveryAfterSiblingSends: sessionDirectories.map(directory => directory.fsPath), + // The peer discovers only its own workspace; its previous anchor is released with its previous runtime. + discoveriesForPeer: [[next.fsPath]], + // The main chat discovers its own folders; the cold peer shares that discovery only when they are the same set. + discoveriesForSiblings: multiRoot ? [sessionDirectories.map(directory => directory.fsPath), [previous.fsPath]] : [[previous.fsPath]], + discoveriesAfterFinalPeerSend: [], metadata: [before[0], { 'copilot.workingDirectory': next.toString(), 'copilot.workingDirectories': JSON.stringify([next.toString()]), @@ -7877,13 +8375,14 @@ suite('CopilotAgent', () => { } for (const resume of ['next send', 'release and reopen', 'cold restart', 'peer-first cold restart'] as const) { - test(`preserves the shared discovery anchor after a main-only workspace change through ${resume}`, async () => { + test(`the default chat's discovery follows its chat-only workspace change through ${resume}`, async () => { const fixture = await createFixture(); let agent = fixture.agent; try { - const { session, chats, resources, previous, next, client } = fixture; + const { session, chats, resources, previous, next } = fixture; const context = exactChatContext(session, chats[0], resources[0]); await agent.chats.getMessages(chats[0], context); + const mainMark = fixture.discoveryMark(); await agent.setChatWorkingDirectory(chats[0], context, next); const resumeStart = fixture.resumed.length; if (resume === 'cold restart' || resume === 'peer-first cold restart') { @@ -7898,10 +8397,13 @@ suite('CopilotAgent', () => { await agent.chats.getMessages(chats[0], context); } await agent.chats.sendMessage(chats[0], 'Continue in the new main workspace', [next], undefined, undefined, undefined, context); - const discoveryAfterMainSend = client.skillDiscoveryRequests.at(-1)?.projectPaths; + const discoveriesThroughMainSend = fixture.discoveriesSince(mainMark); + const siblingMark = fixture.discoveryMark(); for (const index of [1, 2]) { await agent.chats.sendMessage(chats[index], 'Continue in the original workspace', [previous], undefined, undefined, undefined, exactChatContext(session, chats[index], resources[index])); } + const discoveriesForSiblings = fixture.discoveriesSince(siblingMark); + const finalMark = fixture.discoveryMark(); await agent.chats.sendMessage(chats[0], 'Continue after the siblings', [next], undefined, undefined, undefined, context); const db = fixture.sessionDataService.openDatabase(session); let sharedRoots: string | undefined; @@ -7913,15 +8415,25 @@ suite('CopilotAgent', () => { assert.deepStrictEqual({ directories: chats.map(chat => getPeerChatStub(agent, chat)?.workingDirectory?.toString()), mainResumes: fixture.resumed.slice(resumeStart).filter(call => call.id === 'session-wide'), - discoveryAfterMainSend, - discoveryAfterSiblingSends: client.skillDiscoveryRequests.at(-1)?.projectPaths, + discoveriesThroughMainSend, + discoveriesForSiblings, + discoveriesAfterFinalMainSend: fixture.discoveriesSince(finalMark), + publishedAnchor: fixture.pluginDirectory(), sharedRoots, sends: fixture.sends(), }, { directories: [next.toString(), previous.toString(), previous.toString()], mainResumes: [{ id: 'session-wide', directory: next.fsPath }], - discoveryAfterMainSend: [previous.fsPath], - discoveryAfterSiblingSends: [previous.fsPath], + // The move publishes the main chat's new folder, which its relaunch then shares; + // a restarted host discovers it again, after any peer reopened first. + discoveriesThroughMainSend: resume === 'cold restart' ? [[next.fsPath], [next.fsPath]] + : resume === 'peer-first cold restart' ? [[next.fsPath], [previous.fsPath], [next.fsPath]] + : [[next.fsPath]], + // The peers share one discovery of their folder, which a live peer already holds unless the host restarted. + discoveriesForSiblings: resume === 'cold restart' ? [[previous.fsPath]] : [], + discoveriesAfterFinalMainSend: [], + publishedAnchor: next.toString(), + // Still written for older builds, which keep discovery at the previous folders. sharedRoots: JSON.stringify([previous.toString()]), sends: 4, }); @@ -7934,7 +8446,7 @@ suite('CopilotAgent', () => { }); } - test('retains shared roots on a main-only move and clears obsolete roots when replacing the workspace after removing peers', async () => { + test('writes shared roots for older builds on a main-only move that discovery follows, and clears them when replacing the workspace after removing peers', async () => { const fixture = await createFixture(true); let restored: CopilotAgent | undefined; try { @@ -7945,7 +8457,14 @@ suite('CopilotAgent', () => { activeClient.pluginController.setAdditionalDirectories([secondary]); await agent.setChatWorkingDirectory(chats[0], context, next); await agent.chats.sendMessage(chats[0], 'Continue in the new main workspace', [next], undefined, undefined, undefined, context); - const sharedDiscovery = client.skillDiscoveryRequests.at(-1)?.projectPaths; + const movedDiscovery = client.skillDiscoveryRequests.at(-1)?.projectPaths; + const movedDb = fixture.sessionDataService.openDatabase(session); + let movedSharedRoots: string | undefined; + try { + movedSharedRoots = await movedDb.object.getMetadata('copilot.sharedCustomizationDirectories'); + } finally { + movedDb.dispose(); + } for (const index of [1, 2]) { await agent.chats.disposeChat(chats[index], exactChatContext(session, chats[index], resources[index])); } @@ -7972,14 +8491,16 @@ suite('CopilotAgent', () => { await restored.materializeChat(chats[0], context, JSON.stringify({ sdkSessionId: 'session-wide' })); await restored.chats.sendMessage(chats[0], 'Continue after reopening', [secondary], undefined, undefined, undefined, context); assert.deepStrictEqual({ - sharedDiscovery, + movedDiscovery, + movedSharedRoots, replacementDiscovery, reopenedDiscovery: client.skillDiscoveryRequests.at(-1)?.projectPaths, sharedRoots, directory: getPeerChatStub(restored, chats[0])?.workingDirectory?.toString(), additionalDirectories, }, { - sharedDiscovery: [previous.fsPath, secondary.fsPath], + movedDiscovery: [next.fsPath], + movedSharedRoots: JSON.stringify([previous.toString(), secondary.toString()]), replacementDiscovery: [secondary.fsPath], reopenedDiscovery: [secondary.fsPath], sharedRoots: '', @@ -11071,6 +11592,407 @@ suite('CopilotAgent', () => { }); } + test('revokes session telemetry on idle and busy sessions without waiting for global idleness', async () => { + const client = new StopCountingClient([]); + const resumeTelemetryFlags: (boolean | undefined)[] = []; + client.resumeSession = async (_id, config) => { + reportManagedSettings(config); + resumeTelemetryFlags.push(config?.enableSessionTelemetry); + return new MockCopilotSession() as unknown as CopilotSession; + }; + const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ + telemetryLevel: TelemetryLevel.USAGE, + appenders: [], + }, TEST_PRODUCT_SERVICE))); + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent, configurationService } = createTestAgentContext(disposables, { + copilotClient: client, + telemetryService, + sessionDataService, + useRealResumePath: true, + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await agent.listChatsToMigrate(); + + const idleSessionUri = AgentSession.uri('copilotcli', 'idle'); + const idleChatUri = defaultChatUri(idleSessionUri); + const workingDirectory = URI.file('/workspace'); + const dbRef = sessionDataService.openDatabase(idleSessionUri); + try { + await dbRef.object.setMetadata('copilot.workingDirectory', workingDirectory.toString()); + await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([workingDirectory.toString()])); + } finally { + dbRef.dispose(); + } + + let idleDisposed = false; + let idleDestroyed = false; + const idle = { + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { idle.requiresRestartAfterTelemetryRevocation = true; }, + dispose() { idleDisposed = true; }, + async destroySession() { idleDestroyed = true; }, + }; + setDefaultSessionStub(agent, 'idle', idle, idleChatUri); + + let busyDisposed = false; + let busyDestroyed = false; + const busy = { + hasActiveTurn: true, + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { busy.requiresRestartAfterTelemetryRevocation = true; }, + dispose() { busyDisposed = true; }, + async destroySession() { busyDestroyed = true; }, + }; + setDefaultSessionStub(agent, 'busy', busy); + + configurationService.updateRootConfig({ + [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(TelemetryLevel.NONE), + }); + await timeout(0); + + const whileBusy = { + stopCount: client.stopCount, + idleDisposed, + idleDestroyed, + busyDisposed, + busyDestroyed, + busyMarked: busy.requiresRestartAfterTelemetryRevocation, + }; + + // Reusing the previously idle chat while `busy` is still running must relaunch + // it through resumeSession with `enableSessionTelemetry: false` rather than + // reusing the telemetry-enabled session. + await agent.chats.sendMessage(idleChatUri, 'hello after opt-out', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(idleSessionUri, idleChatUri, idleSessionUri)); + + // Once `busy` finishes its turn, its telemetry-enabled session is torn down too. + busy.hasActiveTurn = false; + reportChatTurnEnded(agent); + await timeout(0); + + assert.deepStrictEqual({ + whileBusy, + resumeTelemetryFlags, + afterBusyTurn: { + stopCount: client.stopCount, + busyDisposed, + busyDestroyed, + }, + }, { + whileBusy: { + stopCount: 0, + idleDisposed: true, + idleDestroyed: true, + busyDisposed: false, + busyDestroyed: false, + busyMarked: true, + }, + resumeTelemetryFlags: [false], + afterBusyTurn: { + stopCount: 0, + busyDisposed: true, + busyDestroyed: true, + }, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('revokes session telemetry and releases policy sessions when handshake lowers telemetry level', async () => { + const client = new StopCountingClient([]); + const policyCreates: (boolean | undefined)[] = []; + const policyDisconnects: string[] = []; + const policyDeletes: string[] = []; + client.createSession = async config => { + reportManagedSettings(config); + policyCreates.push(config?.enableSessionTelemetry); + return { + sessionId: config?.sessionId ?? 'policy-session', + disconnect: async () => { policyDisconnects.push(config?.sessionId ?? 'policy-session'); }, + rpc: { + gitHubAuth: { + setCredentials: async () => ({ success: true, copilotUserResolved: true }), + }, + }, + } as unknown as CopilotSession; + }; + client.deleteSession = async sessionId => { policyDeletes.push(sessionId); }; + (client.rpc.mcp as unknown as { discover: () => Promise<{ servers: never[]; errors: never[] }> }).discover = async () => ({ servers: [], errors: [] }); + (client.rpc as unknown as { catalog: { search: () => Promise<{ kind: 'succeeded'; searchId: string; candidates: never[]; hasMore: boolean }> } }).catalog = { + search: async () => ({ kind: 'succeeded', searchId: 's1', candidates: [], hasMore: false }), + }; + const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ + telemetryLevel: TelemetryLevel.USAGE, + appenders: [], + }, TEST_PRODUCT_SERVICE))); + const { agent } = createTestAgentContext(disposables, { + copilotClient: client, + telemetryService, + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await agent.searchCustomizationMarketplace(AgentSession.uri('copilotcli', 's1'), { query: 'test', limit: 10 }); + + let idleDisposed = false; + let idleDestroyed = false; + const idle = { + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { idle.requiresRestartAfterTelemetryRevocation = true; }, + dispose() { idleDisposed = true; }, + async destroySession() { idleDestroyed = true; }, + }; + setDefaultSessionStub(agent, 'idle', idle); + + // Simulates ProtocolServerHandler._applyClientTelemetryLevel on initialize/reconnect. + telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); + await timeout(0); + + // A subsequent marketplace search after telemetry is already disabled must create + // the policy session once with `enableSessionTelemetry: false` (no duplicate retry). + await agent.searchCustomizationMarketplace(AgentSession.uri('copilotcli', 's1'), { query: 'after-disable', limit: 10 }); + + assert.deepStrictEqual({ + idleDisposed, + idleDestroyed, + policyCreates, + policyDisconnects: policyDisconnects.length, + policyDeletes: policyDeletes.length, + }, { + idleDisposed: true, + idleDestroyed: true, + policyCreates: [undefined, false], + policyDisconnects: 1, + policyDeletes: 1, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('revalidates in-flight session launches when telemetry is revoked during SDK launch or post-launch setup', async () => { + for (const stage of ['sdkLaunch', 'postLaunch'] as const) { + for (const pathKind of ['send', 'history'] as const) { + const client = new StopCountingClient([]); + const launchTelemetryFlags: (boolean | undefined)[] = []; + const disconnectedSessions: number[] = []; + const shellInitUpdatesByAttempt = new Map(); + const pauseReached = new DeferredPromise(); + const allowContinue = new DeferredPromise(); + let launchAttempt = 0; + + const handleLaunch = async (config: Parameters[1]) => { + const attempt = ++launchAttempt; + reportManagedSettings(config); + launchTelemetryFlags.push(config?.enableSessionTelemetry); + if (stage === 'sdkLaunch' && attempt === 1) { + pauseReached.complete(); + await allowContinue.p; + } + const mockSession = new MockCopilotSession(`sess-${stage}-${pathKind}`); + const origOptionsUpdate = mockSession.rpc.options.update; + mockSession.rpc.options.update = (async (params?: { shell?: { initScripts?: unknown[] } }) => { + if (params?.shell?.initScripts !== undefined) { + shellInitUpdatesByAttempt.set(attempt, params.shell.initScripts); + if (stage === 'postLaunch' && attempt === 1) { + pauseReached.complete(); + await allowContinue.p; + } + } + return origOptionsUpdate(); + }) as typeof mockSession.rpc.options.update; + const origDisconnect = mockSession.disconnect.bind(mockSession); + mockSession.disconnect = async () => { + disconnectedSessions.push(attempt); + return origDisconnect(); + }; + return mockSession as unknown as CopilotSession; + }; + client.createSession = config => handleLaunch(config); + client.resumeSession = (_id, config) => handleLaunch(config); + + const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ + telemetryLevel: TelemetryLevel.USAGE, + appenders: [], + }, TEST_PRODUCT_SERVICE))); + const sessionDataService = disposables.add(new TestSessionDataService()); + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const { agent, stateManager } = createTestAgentContext(disposables, { + copilotClient: client, + telemetryService, + sessionDataService, + fileService, + useRealResumePath: true, + rootConfig: { [CopilotCliConfigKey.EnableShellInitScript]: true }, + }); + const sessionUri = AgentSession.uri('copilotcli', `sess-${stage}-${pathKind}`); + const chatUri = defaultChatUri(sessionUri); + const workingDirectory = URI.file('/workspace'); + const now = new Date().toISOString(); + stateManager.createSession({ + resource: sessionUri.toString(), + provider: 'copilotcli', + title: 'Test', + status: SessionStatus.Idle, + createdAt: now, + modifiedAt: now, + workingDirectories: [workingDirectory.toString()], + defaultChat: chatUri.toString(), + }); + stateManager.setSessionConfig(sessionUri.toString(), { + schema: platformSessionSchema.toProtocol(), + values: { [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'export TEST_INIT=1\n' }] }, + }); + + try { + await agent.authenticate('https://api.github.com', 'token'); + if (pathKind === 'send') { + await provisionSession(agent, { session: sessionUri, workingDirectories: [workingDirectory] }); + const op = agent.chats.sendMessage(chatUri, 'hello', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(sessionUri, chatUri, sessionUri)); + await pauseReached.p; + telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); + allowContinue.complete(); + await op; + } else { + const dbRef = sessionDataService.openDatabase(sessionUri); + try { + await dbRef.object.setMetadata('copilot.workingDirectory', workingDirectory.toString()); + await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([workingDirectory.toString()])); + } finally { + dbRef.dispose(); + } + await agent.materializeChat(chatUri, exactChatContext(sessionUri, chatUri, sessionUri), undefined); + const op = agent.chats.getMessages(chatUri, exactChatContext(sessionUri, chatUri, sessionUri)); + await pauseReached.p; + telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); + allowContinue.complete(); + await op; + } + + assert.deepStrictEqual({ + stage, + pathKind, + launchTelemetryFlags, + disconnectedSessions, + shellInitRegisteredOnAttempts: [...shellInitUpdatesByAttempt.entries()].map(([attempt, scripts]) => ({ attempt, count: scripts.length })), + }, { + stage, + pathKind, + launchTelemetryFlags: [undefined, false], + disconnectedSessions: [1], + shellInitRegisteredOnAttempts: stage === 'sdkLaunch' + ? [{ attempt: 2, count: 1 }] + : [{ attempt: 1, count: 1 }, { attempt: 2, count: 1 }], + }); + } finally { + allowContinue.complete(); + await disposeAgent(agent); + } + } + } + }); + + test('preserves open canvases across telemetry-revoked session teardown and closes them if resume fails', async () => { + for (const resumeSucceeds of [true, false]) { + const client = new StopCountingClient([]); + const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ + telemetryLevel: TelemetryLevel.USAGE, + appenders: [], + }, TEST_PRODUCT_SERVICE))); + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent } = createTestAgentContext(disposables, { + copilotClient: client, + telemetryService, + sessionDataService, + useRealResumePath: true, + }); + const sessionId = `canvas-revoke-${resumeSucceeds}`; + const sessionUri = AgentSession.uri('copilotcli', sessionId); + const chatUri = defaultChatUri(sessionUri); + const workingDirectory = URI.file('/workspace'); + const handoff: ICopilotCanvasHandoff = { + projections: [{ + resource: URI.parse(`ahp-canvas:/${sessionId}/preview`), + canvas: { instanceId: 'preview', extensionId: 'project:preview', canvasId: 'preview' }, + }], + }; + const membership: { resource: string; action: unknown }[] = []; + disposables.add(agent.onDidChatProgress(signal => { + if (signal.kind === 'action' && signal.action.type === ActionType.ChatCanvasesChanged) { + membership.push({ resource: signal.resource.toString(), action: signal.action }); + } + })); + const internals = agent as unknown as { + _resumeSessionOnce: (id: string, chatChannelUri: URI, acquireClient: () => Promise) => Promise; + _canvasHandoffs: { get(chat: URI): ICopilotCanvasHandoff | undefined; readonly size: number }; + }; + + try { + await agent.authenticate('https://api.github.com', 'token'); + const initialSession = { + requiresRestartAfterTelemetryRevocation: false, + markTelemetryRevocationRequiresRestart() { initialSession.requiresRestartAfterTelemetryRevocation = true; }, + takeCanvasProjection: () => handoff, + }; + setDefaultSessionStub(agent, sessionId, initialSession, chatUri); + + telemetryService.updateTelemetryLevel(TelemetryLevel.NONE); + await timeout(0); + + const handoffWhileDisconnected = internals._canvasHandoffs.get(chatUri); + + let adopted = false; + internals._resumeSessionOnce = async (_id, _chatChannelUri, acquireClient) => { + await acquireClient(); + if (!resumeSucceeds) { + throw new Error('resume failed after telemetry revocation'); + } + const receivedHandoff = internals._canvasHandoffs.get(chatUri); + const replacement = { + send: async () => { }, + dispose: () => { }, + resetTurnState: () => { }, + adoptCanvasHandoff: (candidate: ICopilotCanvasHandoff) => { + adopted = candidate === receivedHandoff && candidate === handoff; + return adopted; + }, + }; + setDefaultSessionStub(agent, sessionId, replacement, chatUri); + return replacement as unknown as CopilotAgentSession; + }; + + if (resumeSucceeds) { + await agent.chats.sendMessage(chatUri, 'hello', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(sessionUri, chatUri, sessionUri)); + } else { + await assert.rejects( + () => agent.chats.sendMessage(chatUri, 'hello', [workingDirectory], undefined, 'turn-1', undefined, exactChatContext(sessionUri, chatUri, sessionUri)), + /resume failed after telemetry revocation/, + ); + } + + assert.deepStrictEqual({ + resumeSucceeds, + handoffWhileDisconnected, + adopted, + pendingHandoffsAfter: internals._canvasHandoffs.size, + membership, + }, { + resumeSucceeds, + handoffWhileDisconnected: handoff, + adopted: resumeSucceeds, + pendingHandoffsAfter: 0, + membership: resumeSucceeds + ? [] + : [{ resource: chatUri.toString(), action: { type: ActionType.ChatCanvasesChanged, canvases: undefined } }], + }); + } finally { + await disposeAgent(agent); + } + } + }); + test('does not restart when an unrelated config key changes', async () => { const client = new StopCountingClient([]); const logService = new MutableLogService(); @@ -14711,9 +15633,10 @@ suite('CopilotAgent', () => { await agent.chats.prepareTurn!(chat, 'turn-1', [workingDirectory], context); const pluginController = (agent as unknown as { _activeClients: { get(session: URI): { pluginController: { isSettled(): boolean; getCustomizationsSettled(): Promise } } | undefined } })._activeClients.get(result.session)!.pluginController; // The discovery's change event is what a watched workspace file change raises. - const discovery = (pluginController as unknown as { _sessionDiscovered: { value: { _discovery: { _onDidChange: Emitter } } } })._sessionDiscovered.value._discovery; - await pluginController.getCustomizationsSettled(); + // The published view shares the default chat's anchor set, so this is the discovery the turn waits for. + const discovery = (pluginController as unknown as { _published: { value: { object: { _discovered: { value: { _discovery: { _onDidChange: Emitter } } } } } } })._published.value.object._discovered.value._discovery; + const beforeChange = pluginController.isSettled(); discovery._onDidChange.fire(); const whileRefreshing = pluginController.isSettled(); @@ -16969,7 +17892,7 @@ suite('CopilotAgent', () => { }); suite('MCP configuration preparation', () => { - async function createHarness(options: { cold?: boolean; failSync?: boolean; syncGate?: Promise } = {}) { + async function createHarness(options: { cold?: boolean; failSync?: boolean; syncGate?: Promise; telemetryService?: ITelemetryService; sdkSessionId?: string } = {}) { const fileService = disposables.add(new FileService(new NullLogService())); disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); const pluginDirectory = URI.file('/late-plugin'); @@ -16989,7 +17912,7 @@ suite('CopilotAgent', () => { } } const sessionDataService = disposables.add(new TestSessionDataService()); - const sdkSessionId = 'restored-sdk-session'; + const sdkSessionId = options.sdkSessionId ?? 'restored-sdk-session'; const client = new TestCopilotClient([sdkSession(sdkSessionId, '/workspace')]); const oldRuntime = new MockCopilotSession(sdkSessionId); const newRuntime = new MockCopilotSession(sdkSessionId); @@ -17022,8 +17945,8 @@ suite('CopilotAgent', () => { await resumeGate; return newRuntime as unknown as CopilotSession; }; - const { agent, stateManager, worktreeIsolation } = createTestAgentContext(disposables, { - copilotClient: client, useRealResumePath: true, sessionDataService, fileService, pluginManager: new PluginManager(), + const { agent, stateManager, worktreeIsolation, configurationService } = createTestAgentContext(disposables, { + copilotClient: client, useRealResumePath: true, sessionDataService, fileService, pluginManager: new PluginManager(), telemetryService: options.telemetryService, }); disposables.add(toDisposable(() => agent.dispose())); await agent.authenticate('https://api.github.com', 'token'); @@ -17055,7 +17978,7 @@ suite('CopilotAgent', () => { })); await syncStarted.p; return { - agent, session, chat, context, oldRuntime, newRuntime, resumeConfigs, resumedIds, syncCalls, pluginDirectory, healthyDirectory, refreshStarted, disconnectStarted, sessionDataService, worktreeIsolation, + agent, session, chat, context, oldRuntime, newRuntime, resumeConfigs, resumedIds, syncCalls, pluginDirectory, healthyDirectory, refreshStarted, disconnectStarted, sessionDataService, worktreeIsolation, configurationService, serverId: `${URI.joinPath(pluginDirectory, '.mcp.json')}#mcp=late-server`, otherId: `${URI.joinPath(pluginDirectory, '.mcp.json')}#mcp=other-server`, live: () => chatEntriesBySdkId(agent).get(sdkSessionId)!.chatSession, @@ -17090,6 +18013,65 @@ suite('CopilotAgent', () => { } }); + for (const sdkSessionId of ['mcp-refresh-session', 'restored-sdk-session']) { + for (const cancelled of [false, true]) { + test(`Start resumes an idle chat after telemetry opt-out (${sdkSessionId}, ${cancelled ? 'cancelled' : 'completed'})`, async () => { + const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ + telemetryLevel: TelemetryLevel.USAGE, + appenders: [], + }, TEST_PRODUCT_SERVICE))); + const h = await createHarness({ telemetryService, sdkSessionId }); + const gate = new DeferredPromise(); + try { + h.configurationService.updateRootConfig({ + [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(TelemetryLevel.NONE), + }); + await h.disconnectStarted.p; + await h.agent.stopMcpServer(h.session, h.serverId); + assert.strictEqual(chatEntriesBySdkId(h.agent).has(sdkSessionId), false); + + const source = disposables.add(new CancellationTokenSource()); + if (cancelled) { + h.blockResume(gate.p); + } + const start = h.agent.startMcpServer(h.session, h.serverId, source.token); + const outcome = start.then(() => undefined, error => error); + if (cancelled) { + await Promise.race([h.refreshStarted.p, start]); + source.cancel(); + assert.ok(isCancellationError(await outcome)); + const stop = h.agent.stopMcpServer(h.session, h.serverId); + gate.complete(); + await stop; + } else { + await start; + await h.agent.startMcpServer(h.session, h.serverId); + } + assert.deepStrictEqual({ + ids: h.resumedIds, + telemetry: h.resumeConfigs.map(config => config?.enableSessionTelemetry), + plugins: h.resumeConfigs[1]?.pluginDirectories, + oldStarts: h.oldRuntime.mcpStartCalls, + starts: h.newRuntime.mcpStartCalls, + stops: h.newRuntime.mcpStopCalls, + turns: h.oldRuntime.sendCalls + h.newRuntime.sendCalls, + }, { + ids: [sdkSessionId, sdkSessionId], + telemetry: [undefined, false], + plugins: [h.pluginDirectory.fsPath, h.healthyDirectory.fsPath], + oldStarts: [], + starts: cancelled ? [] : ['late-server', 'late-server'], + stops: cancelled ? ['late-server'] : [], + turns: 0, + }); + } finally { + gate.complete(); + await disposeAgent(h.agent); + } + }); + } + } + for (const ending of ['idle', 'stop', 'dispose'] as const) { test(`defers refresh during an active turn and handles ${ending}`, async () => { const h = await createHarness({ failSync: true }); diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index 40473d54cab15a..06d9268da51dd2 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -18,6 +18,7 @@ class TestAgentHostGitService implements IAgentHostGitService { worktreeRootCalls = 0; async getCurrentBranch(): Promise { return undefined; } + async getHEAD(): Promise { return undefined; } async getDefaultBranch(): Promise { return undefined; } async getDefaultBranch2(): Promise { return undefined; } async getBranch(): Promise { return undefined; } diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 088cffcb44e760..3794ded80a4601 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -17,8 +17,9 @@ import { ServiceCollection } from '../../../instantiation/common/serviceCollecti import { ILogService, LogLevel, NullLogService } from '../../../log/common/log.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; import type { TerminalSandboxEngine } from '../../../sandbox/common/terminalSandboxEngine.js'; +import { type ITelemetryService, TelemetryConfiguration, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import type { IByokLmBridgeConnection, IByokLmChatRequest, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, platformSessionSchema, type SchemaValues } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostTelemetryLevelConfigKey, platformSessionSchema, type SchemaValues } from '../../common/agentHostSchema.js'; import type { IAgentHostManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; import { toClientPluginMcpDefaultCwdsMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; import { readSessionSandboxState } from '../../common/meta/agentSandboxStateMeta.js'; @@ -127,7 +128,7 @@ const noopSessionOpenTelemetry: IAgentHostSessionOpenTelemetry = { sdkResumeFallbackCreated: () => { }, }; -function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettingsPermissions, rootValues: Record = {}, logService: ILogService = new NullLogService(), sessionOpenTelemetry: IAgentHostSessionOpenTelemetry = noopSessionOpenTelemetry, configuration?: IAgentConfigurationService): CopilotSessionLauncher { +function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettingsPermissions, rootValues: Record = {}, logService: ILogService = new NullLogService(), sessionOpenTelemetry: IAgentHostSessionOpenTelemetry = noopSessionOpenTelemetry, configuration?: IAgentConfigurationService, telemetryService?: ITelemetryService): CopilotSessionLauncher { const configurationService = configuration ?? { getRootValue: (_schema: unknown, key: string) => rootValues[key], getRootConfigValues: () => rootValues, @@ -156,6 +157,7 @@ function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettin withTraceContext: (_context: undefined, fn: () => T): T => fn(), } as unknown as IAgentHostOTelService, sessionOpenTelemetry, + telemetryService ?? ({ telemetryLevel: TelemetryLevel.USAGE } as ITelemetryService), ); } @@ -1597,6 +1599,94 @@ suite('CopilotSessionLauncher shared session config', () => { await launcher.disposeByokProxyHandle(); } }); + + test('passes enableSessionTelemetry: false on create, resume, and policy sessions when telemetry is below usage', async () => { + const session = { + sessionId: 'session-1', + on: () => () => { }, + disconnect: async () => { }, + rpc: { + options: { update: async () => ({ success: true }) }, + gitHubAuth: { + setCredentials: async () => ({ success: true, copilotUserResolved: true }), + }, + }, + } as unknown as CopilotSession; + const captured: { create?: boolean; resume?: boolean; policy?: boolean }[] = []; + + for (const scenario of [ + { rootValues: {}, telemetryLevel: TelemetryLevel.USAGE }, + { rootValues: { [AgentHostTelemetryLevelConfigKey]: TelemetryConfiguration.ON }, telemetryLevel: TelemetryLevel.USAGE }, + { rootValues: { [AgentHostTelemetryLevelConfigKey]: TelemetryConfiguration.OFF }, telemetryLevel: TelemetryLevel.USAGE }, + { rootValues: { [AgentHostTelemetryLevelConfigKey]: TelemetryConfiguration.ERROR }, telemetryLevel: TelemetryLevel.USAGE }, + { rootValues: { [AgentHostTelemetryLevelConfigKey]: TelemetryConfiguration.CRASH }, telemetryLevel: TelemetryLevel.USAGE }, + { rootValues: { [AgentHostTelemetryLevelConfigKey]: TelemetryConfiguration.ON }, telemetryLevel: TelemetryLevel.NONE }, + { rootValues: {}, telemetryLevel: TelemetryLevel.ERROR }, + ]) { + const entry: { create?: boolean; resume?: boolean; policy?: boolean } = {}; + const client = { + createSession: async (config: Parameters[0]) => { + reportManagedSettings(config); + if (config?.sessionId === 'hidden-policy') { + entry.policy = config.enableSessionTelemetry; + } else { + entry.create = config?.enableSessionTelemetry; + } + return session; + }, + resumeSession: async (_sessionId: string, config: Parameters[1]) => { + reportManagedSettings(config); + entry.resume = config?.enableSessionTelemetry; + return session; + }, + rpc: { + mcp: { discover: async () => ({ servers: [], errors: [] }) }, + managedSettings: createUnmanagedCopilotSettings(), + account: new class extends mock() { }, + sandbox: { proxyCa: new class extends mock() { }, getHostSupport: async () => ({ supported: true, capabilities: [] }) }, + }, + }; + const launcher = createTestLauncher( + undefined, + scenario.rootValues, + new NullLogService(), + noopSessionOpenTelemetry, + undefined, + { telemetryLevel: scenario.telemetryLevel } as ITelemetryService, + ); + const basePlan = { + client: client as unknown as CopilotSessionLaunchPlan['client'], + extensionSdkPath: '/copilot-sdk', + sessionId: 'session-1', + workingDirectory: testWorkingDirectory, + resolvedAgentName: undefined, + snapshot: { tools: [], plugins: [], mcpServers: {} }, + activeClientToolSet: new ActiveClientToolSet(), + shellManager: undefined, + githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined), + }; + const sessions = new DisposableStore(); + try { + sessions.add(await launcher.launch({ ...basePlan, kind: 'create', model: undefined }, testRuntime)); + sessions.add(await launcher.launch({ ...basePlan, kind: 'resume', fallback: { model: undefined } }, testRuntime)); + await launcher.createCustomizationPolicySession(client as unknown as CopilotClient, 'hidden-policy', '/copilot-home', CopilotGitHubSessionCredentials.fromToken('token'), 'https://github.com'); + captured.push(entry); + } finally { + sessions.dispose(); + await launcher.disposeByokProxyHandle(); + } + } + + assert.deepStrictEqual(captured, [ + { create: undefined, resume: undefined, policy: undefined }, + { create: undefined, resume: undefined, policy: undefined }, + { create: false, resume: false, policy: false }, + { create: false, resume: false, policy: false }, + { create: false, resume: false, policy: false }, + { create: false, resume: false, policy: false }, + { create: false, resume: false, policy: false }, + ]); + }); }); suite('CopilotSessionLauncher local index', () => { diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommand.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommand.test.ts index b7e53eeed96230..9928019c3bce2d 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommand.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommand.test.ts @@ -356,6 +356,81 @@ suite('Copilot slash command handlers', () => { }); }); + test('qualifies runtime list entries using the installed plugin inventory', async () => { + const handler = getCopilotCustomizationCommandHandler(command, undefined, async () => [ + { name: 'azure', marketplace: 'awesome-copilot', enabled: false }, + ]); + const output = await handler?.getOutput?.('list', { + kind: 'text', + text: [ + 'Installed Plugins:', + '', + ' โ€ข azure v1.2.94', + ].join('\n'), + }); + + assert.deepStrictEqual(output, { + kind: 'text', + text: [ + '# Installed plugins', + '', + '- `azure@awesome-copilot` โ€” v1.2.94 (disabled)', + ].join('\n'), + markdown: true, + }); + }); + + test('adds disabled plugins omitted from the runtime list', async () => { + const handler = getCopilotCustomizationCommandHandler(command, undefined, async () => [ + { name: 'azure', marketplace: 'awesome-copilot', version: '1.2.94', enabled: false }, + { name: 'VS Code Synced Data', marketplace: '', enabled: true }, + ]); + const output = await handler?.getOutput?.('list', { + kind: 'text', + text: [ + 'Installed Plugins:', + '', + ' โ€ข VS Code Synced Data', + ].join('\n'), + }); + + assert.deepStrictEqual(output, { + kind: 'text', + text: [ + '# Installed plugins', + '', + '- `azure@awesome-copilot` โ€” v1.2.94 (disabled)', + ].join('\n'), + markdown: true, + }); + }); + + test('keeps disabled plugins with the same name from other marketplaces', async () => { + const handler = getCopilotCustomizationCommandHandler(command, undefined, async () => [ + { name: 'azure', marketplace: 'marketplace-one', enabled: true }, + { name: 'azure', marketplace: 'marketplace-two', version: '2.0.0', enabled: false }, + ]); + const output = await handler?.getOutput?.('list', { + kind: 'text', + text: [ + 'Installed Plugins:', + '', + ' โ€ข azure@marketplace-one v1.0.0', + ].join('\n'), + }); + + assert.deepStrictEqual(output, { + kind: 'text', + text: [ + '# Installed plugins', + '', + '- `azure@marketplace-one` โ€” v1.0.0', + '- `azure@marketplace-two` โ€” v2.0.0 (disabled)', + ].join('\n'), + markdown: true, + }); + }); + test('defers to the runtime for unsupported and changed responses', async () => { const handler = getCopilotCustomizationCommandHandler(command); assert.deepStrictEqual([ @@ -497,12 +572,62 @@ suite('Copilot slash command handlers', () => { calls: [ 'install:document-skills@anthropic', 'reload', 'update:document-skills@anthropic', 'reload', - 'list', 'disable:document-skills@anthropic', 'reload', + 'list', 'disable:document-skills', 'reload', 'uninstall:document-skills@anthropic', 'reload', ], }); }); + test('disables built-in plugins but rejects direct-source plugins', async () => { + const calls: string[] = []; + const plugins: ICopilotPluginCommandApi = { + ...pluginApi(calls), + list: async () => ({ + plugins: [ + { name: 'builtin-plugin', marketplace: '', enabled: true, source: 'builtin' }, + { name: 'direct-plugin', marketplace: '', enabled: true, directSourceId: 'direct-plugin-source' }, + ], + }), + }; + + assert.deepStrictEqual({ + builtin: await invokeCopilotPluginCommand('disable builtin-plugin', plugins), + direct: await invokeCopilotPluginCommand('disable direct-plugin', plugins), + calls, + }, { + builtin: { + kind: 'text', + text: 'Disabled plugin builtin-plugin.', + runtimeSettingsChanged: true, + }, + direct: { + kind: 'text', + text: 'Plugin direct-plugin is installed directly and cannot be enabled or disabled. Uninstall it instead.', + }, + calls: ['disable:builtin-plugin', 'reload'], + }); + }); + + test('enables a globally installed plugin missing from the session inventory', async () => { + const calls: string[] = []; + const plugins: ICopilotPluginCommandApi = { + ...pluginApi(calls), + list: async () => ({ plugins: [] }), + }; + + assert.deepStrictEqual({ + result: await invokeCopilotPluginCommand('enable azure', plugins), + calls, + }, { + result: { + kind: 'text', + text: 'Enabled plugin azure.', + runtimeSettingsChanged: true, + }, + calls: ['enable:azure', 'reload'], + }); + }); + test('invokes plugin marketplace commands', async () => { const calls: string[] = []; const plugins = pluginApi(calls); diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index 4cc57d805e154d..145c01b6091fcc 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -556,18 +556,28 @@ suite('CopilotSlashCommandCompletionProvider', () => { uri: 'file:///plugins/elastic-elasticsearch', name: 'elastic-elasticsearch', load: { kind: CustomizationLoadStatus.Loaded }, + }, { + type: CustomizationType.Plugin, + id: `${SYNCED_CUSTOMIZATION_SCHEME}:/plugins/vscode-synced`, + uri: `${SYNCED_CUSTOMIZATION_SCHEME}:/plugins/vscode-synced`, + name: 'VS Code Synced Data', + load: { kind: CustomizationLoadStatus.Loaded }, }], getInstalledPlugins: async sessionId => { requestedSessionIds.push(sessionId); return [ { name: 'elasticsearch', marketplace: 'awesome-copilot', enabled: true }, - { name: 'dotnet', marketplace: 'awesome-copilot', enabled: false }, + { name: 'azure', marketplace: 'awesome-copilot', enabled: false }, { name: 'builtin-plugin', marketplace: '', enabled: true, source: 'builtin' }, + { name: 'VS Code Synced Data', marketplace: '', enabled: true, source: 'builtin' }, + { name: 'VS Code Synced Data', marketplace: '', enabled: true }, { name: 'managed-plugin', marketplace: 'enterprise', enabled: true, managed: true }, { name: 'missing-plugin', marketplace: 'enterprise', enabled: false, installed: false }, - { name: 'direct-plugin', marketplace: '', enabled: true }, - { name: 'ambiguous-direct', marketplace: '', enabled: true }, - { name: 'ambiguous-direct', marketplace: '', enabled: true }, + { name: 'direct-plugin', marketplace: '', enabled: true, directSourceId: 'direct-plugin-source' }, + { name: 'ambiguous-direct', marketplace: '', enabled: true, directSourceId: 'ambiguous-direct-source-1' }, + { name: 'ambiguous-direct', marketplace: '', enabled: true, directSourceId: 'ambiguous-direct-source-2' }, + { name: 'duplicate', marketplace: 'marketplace-one', enabled: true }, + { name: 'duplicate', marketplace: 'marketplace-two', enabled: false }, ]; }, }); @@ -586,10 +596,10 @@ suite('CopilotSlashCommandCompletionProvider', () => { disable: await complete('/plugin disable '), requestedSessionIds, }, { - uninstall: ['direct-plugin', 'dotnet@awesome-copilot', 'elasticsearch@awesome-copilot'], + uninstall: ['azure@awesome-copilot', 'direct-plugin', 'duplicate@marketplace-one', 'duplicate@marketplace-two', 'elasticsearch@awesome-copilot'], update: ['elasticsearch@awesome-copilot'], - enable: ['dotnet@awesome-copilot'], - disable: ['elasticsearch@awesome-copilot'], + enable: ['azure', 'duplicate@marketplace-two'], + disable: ['builtin-plugin', 'duplicate@marketplace-one', 'elasticsearch'], requestedSessionIds: ['abc', 'abc', 'abc', 'abc'], }); }); diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-session-changeset-aggregates-provider-edits-from-default-and-peer-chats.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-session-changeset-aggregates-provider-edits-from-default-and-peer-chats.yaml deleted file mode 100644 index a681a13a24fcd9..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/captures/codex-session-changeset-aggregates-provider-edits-from-default-and-peer-chats.yaml +++ /dev/null @@ -1,89 +0,0 @@ -version: 1 -dialect: responses -exchanges: - - request: - model: gpt-5.3-codex - system: ${system} - messages: - - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''default-provider.txt'',''DEFAULT_PROVIDER'')"`. Then reply exactly "created".' - response: - content: - - type: text - text: Running that exact command now. - - type: tool_use - id: toolcall_0 - name: exec_command - input: - cmd: node -e "require('fs').writeFileSync('default-provider.txt','DEFAULT_PROVIDER')" - stopReason: tool_use - - request: - model: gpt-5.3-codex - system: ${system} - messages: - - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''default-provider.txt'',''DEFAULT_PROVIDER'')"`. Then reply exactly "created".' - - role: assistant - content: Running that exact command now. - - role: assistant - content: - - type: tool_use - name: exec_command - input: - cmd: node -e "require('fs').writeFileSync('default-provider.txt','DEFAULT_PROVIDER')" - - role: user - content: - - type: tool_result - tool_use_id: toolcall_0 - content: |- - Chunk ID: 4706e0 - Wall time: 0.0000 seconds - Process exited with code 0 - Original token count: 0 - Output: - response: - content: created - stopReason: end_turn - - request: - model: gpt-5.3-codex - system: ${system} - messages: - - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''peer-provider.txt'',''PEER_PROVIDER'')"`. Then reply exactly "created".' - response: - content: - - type: text - text: Got it โ€” running that exact command now. - - type: tool_use - id: toolcall_1 - name: exec_command - input: - cmd: node -e "require('fs').writeFileSync('peer-provider.txt','PEER_PROVIDER')" - stopReason: tool_use - - request: - model: gpt-5.3-codex - system: ${system} - messages: - - role: user - content: 'Run exactly this shell command, with no modifications: `node -e "require(''fs'').writeFileSync(''peer-provider.txt'',''PEER_PROVIDER'')"`. Then reply exactly "created".' - - role: assistant - content: Got it โ€” running that exact command now. - - role: assistant - content: - - type: tool_use - name: exec_command - input: - cmd: node -e "require('fs').writeFileSync('peer-provider.txt','PEER_PROVIDER')" - - role: user - content: - - type: tool_result - tool_use_id: toolcall_1 - content: |- - Chunk ID: b8d2ea - Wall time: 0.0000 seconds - Process exited with code 0 - Original token count: 0 - Output: - response: - content: created - stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts index 360e008628ad07..8c9cb9b94535b1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -1817,9 +1817,9 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { }, false); if (context.tier === 'parity') { - const supportsProviderFileEdits = config.streamingFileCreateToolName !== undefined || config.fileOperationStrategy === 'shell'; - const providerFileEditsEnabled = config.fileOperationStrategy !== 'shell' || context.portableShellToolReplayEnabled; - (config.supportsMultipleChats && supportsProviderFileEdits && providerFileEditsEnabled ? test : test.skip)('session changeset aggregates provider edits from default and peer chats', async function () { + // Session Changes come only from tracked file-tool edits; shell-command edits are intentionally excluded. + const tracksProviderFileEdits = config.fileOperationStrategy === 'fileTools' && config.streamingFileCreateToolName !== undefined; + (config.supportsMultipleChats && tracksProviderFileEdits ? test : test.skip)('session changeset aggregates provider edits from default and peer chats', async function () { this.timeout(240_000); const workspace = createGitWorkspace(`ahp-provider-session-changeset-${config.provider}-`); const sessionUri = await createSessionIn(workspace, 'provider-session-changeset'); @@ -1829,13 +1829,8 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { const sessionChangeset = buildSessionChangesetUri(sessionUri); await context.client.call('subscribe', { channel: sessionChangeset }); - const createPrompt = (file: string, contents: string): string => { - if (config.fileOperationStrategy !== 'shell') { - return `Create ${file} containing exactly ${contents} using your file creation tool; do not run a shell command. Then reply exactly "created".`; - } - const command = `node -e "require('fs').writeFileSync('${file}','${contents}')"`; - return `Run exactly this shell command, with no modifications: \`${command}\`. Then reply exactly "created".`; - }; + const createPrompt = (file: string, contents: string): string => + `Create ${file} containing exactly ${contents} using your file creation tool; do not run a shell command. Then reply exactly "created".`; await driveTurnToCompletion( context.client, sessionUri, @@ -1870,8 +1865,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { 'peer-provider.txt', ]); - // A refresh after checkpoint capture must still include shell edits that - // the provider's file-edit tracker cannot report. + // A fresh subscription recomputes the same tracked edits. await context.client.call('unsubscribe', { channel: sessionChangeset }); const refreshed = await changesetState(sessionChangeset); assert.deepStrictEqual(refreshed.files.map(fileUri).sort(), files.map(file => fileUri(file!)).sort()); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts index 4dd37d94d32988..bbad39653322a5 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import * as cp from 'child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -14,7 +14,7 @@ import { buildBranchChangesetUri, buildFolderChangesetOwnerUri, buildSessionChan import { ListSessionsResult, SubscribeResult } from '../../../common/state/protocol/commands.js'; import type { ChangesetContentChangedAction, SessionAddedParams, SessionSummaryChangedParams } from '../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; -import { ROOT_STATE_URI, type SessionState } from '../../../common/state/sessionState.js'; +import { ChangesetStatus, ROOT_STATE_URI, type ChangesetState, type SessionState } from '../../../common/state/sessionState.js'; import { defaultChatChannel } from '../hostDefaultChats.js'; import { dispatchTurnStarted, @@ -86,7 +86,38 @@ const hasGit = (() => { } }); - test('terminal-driven file edit lands in Branch and Session Changes', async function () { + /** Reads a changeset once any in-flight computation has settled. */ + async function settledChangeset(channel: string): Promise { + const isPending = (status: ChangesetStatus) => status === ChangesetStatus.Computing || status === ChangesetStatus.Recomputing; + let snapshot = (await client.call('subscribe', { channel })).snapshot!; + while (isPending((snapshot.state as ChangesetState).status)) { + const fromSeq = snapshot.fromSeq; + await client.waitForNotification(n => + isActionNotification(n, 'changeset/statusChanged') + && getActionEnvelope(n).channel === channel + && getActionEnvelope(n).serverSeq > fromSeq + && !isPending((getActionEnvelope(n).action as { readonly status: ChangesetStatus }).status), + getAgentHostE2ETestTimeout(10_000, 30_000), + ); + snapshot = (await client.call('subscribe', { channel })).snapshot!; + } + return snapshot.state as ChangesetState; + } + + /** Waits until the chat that runs `turnId` completes it. */ + async function waitForTurnComplete(turnId: string): Promise { + await client.waitForNotification(n => + isActionNotification(n, 'chat/turnComplete') + && (getActionEnvelope(n).action as { readonly turnId?: string }).turnId === turnId, + getAgentHostE2ETestTimeout(10_000, 30_000), + ); + } + + const changesetFileNames = (state: ChangesetState) => state.files.map(file => (file.edit.after?.uri ?? file.edit.before?.uri)?.split('/').pop()); + + // Session Changes come only from tracked file-tool edits, so a terminal-driven + // edit reaches Branch Changes through Git but never Session Changes. + test('terminal-driven file edit lands in Branch Changes but not Session Changes', async function () { this.timeout(getAgentHostE2ETestTimeout(15_000, 90_000)); // Create a session whose working directory is the tmp git repo. @@ -112,9 +143,10 @@ const hasGit = (() => { // Fire a turn that runs the `terminal-edit:` mock prompt. The mock // agent writes the file via fs.writeFile (no ToolResultFileEditContent), - // so the diff must come from the git-driven path. + // so only the git-driven Branch Changes can see it. const editedFile = join(tmpRoot, 'from-terminal.txt'); dispatchTurnStarted(client, sessionUri, 'turn-1', `terminal-edit:${editedFile}`, 1); + await waitForTurnComplete('turn-1'); // Wait for a `changeset/contentChanged` action whose file list // includes the edited file. On macOS, git's `--show-toplevel` @@ -122,38 +154,27 @@ const hasGit = (() => { // prefix; match by basename. const fileUri = (edit: ChangesetContentChangedAction['files'][number]['edit']) => edit.after?.uri ?? edit.before?.uri; - const matchesEditedFile = (action: ChangesetContentChangedAction) => - action.files.some(f => { - const u = fileUri(f.edit); - return typeof u === 'string' && u.endsWith('/from-terminal.txt'); - }); - const waitForEditedFile = async (changeset: string) => { - const notification = await client.waitForNotification(n => { - if (!isActionNotification(n, 'changeset/contentChanged') || getActionEnvelope(n).channel !== changeset) { - return false; - } - return matchesEditedFile(getActionEnvelope(n).action as ChangesetContentChangedAction); - }, getAgentHostE2ETestTimeout(10_000, 30_000)); - const action = getActionEnvelope(notification).action as ChangesetContentChangedAction; - return action.files.find(f => { + const notification = await client.waitForNotification(n => { + if (!isActionNotification(n, 'changeset/contentChanged') || getActionEnvelope(n).channel !== branchChangesetUri) { + return false; + } + return (getActionEnvelope(n).action as ChangesetContentChangedAction).files.some(f => { const u = fileUri(f.edit); return typeof u === 'string' && u.endsWith('/from-terminal.txt'); }); - }; - const [branchFile, sessionFile] = await Promise.all([ - waitForEditedFile(branchChangesetUri), - waitForEditedFile(sessionChangesetUri), - ]); + }, getAgentHostE2ETestTimeout(10_000, 30_000)); + const branchFile = (getActionEnvelope(notification).action as ChangesetContentChangedAction).files.find(f => fileUri(f.edit)?.endsWith('/from-terminal.txt')); + const sessionChangeset = await settledChangeset(sessionChangesetUri); assert.deepStrictEqual({ branch: { before: branchFile?.edit.before, hasAfter: !!branchFile?.edit.after }, - session: { before: sessionFile?.edit.before, hasAfter: !!sessionFile?.edit.after }, + session: { status: sessionChangeset.status, files: changesetFileNames(sessionChangeset) }, }, { branch: { before: undefined, hasAfter: true }, - session: { before: undefined, hasAfter: true }, + session: { status: ChangesetStatus.Ready, files: [] }, }); }); - test('folder summary matches Session Changes and excludes pre-existing working-tree changes', async function () { + test('folder summary follows Session Changes and excludes terminal edits and pre-existing working-tree changes', async function () { this.timeout(getAgentHostE2ETestTimeout(15_000, 90_000)); writeFileSync(join(tmpRoot, 'pre-existing.txt'), 'pre-existing change\n'); await client.call('initialize', { protocolVersions: [PROTOCOL_VERSION], clientId: 'test-folder-summary' }); @@ -164,37 +185,24 @@ const hasGit = (() => { }); const changeset = `${session}/changeset/session`; await client.call('subscribe', { channel: session }); + await client.call('subscribe', { channel: defaultChatChannel(session) }); await client.call('subscribe', { channel: changeset }); client.clearReceived(); - dispatchTurnStarted(client, session, 'turn-folder-summary', `terminal-edit:${join(tmpRoot, 'session-edit.txt')}`, 1); - const content = await client.waitForNotification(n => - isActionNotification(n, 'changeset/contentChanged') - && getActionEnvelope(n).channel === changeset - && (getActionEnvelope(n).action as ChangesetContentChangedAction).files.some(file => file.edit.after?.uri.endsWith('/session-edit.txt')), - getAgentHostE2ETestTimeout(10_000, 30_000), - ); - const files = (getActionEnvelope(content).action as ChangesetContentChangedAction).files; - const summary = await client.waitForNotification(n => - n.method === 'root/sessionSummaryChanged' - && (n.params as SessionSummaryChangedParams).session === session - && (n.params as SessionSummaryChangedParams).changes.changes?.files === 1, - ); + const editedFile = join(tmpRoot, 'session-edit.txt'); + dispatchTurnStarted(client, session, 'turn-folder-summary', `terminal-edit:${editedFile}`, 1); + await waitForTurnComplete('turn-folder-summary'); + const sessionChangeset = await settledChangeset(changeset); const listed = await client.call('listSessions', { channel: ROOT_STATE_URI }); - const changes = { - additions: files[0].edit.diff?.added ?? 0, - deletions: files[0].edit.diff?.removed ?? 0, - files: 1, - }; assert.deepStrictEqual({ - files: files.map(file => file.edit.after?.uri.split('/').pop()), - changes: (summary.params as SessionSummaryChangedParams).changes.changes, - listedChanges: listed.items.find(item => item.resource === session)?.changes, + edited: readFileSync(editedFile, 'utf8'), + session: { status: sessionChangeset.status, files: changesetFileNames(sessionChangeset) }, + listedFiles: listed.items.find(item => item.resource === session)?.changes?.files ?? 0, }, { - files: ['session-edit.txt'], - changes, - listedChanges: changes, + edited: 'edited-from-terminal\n', + session: { status: ChangesetStatus.Ready, files: [] }, + listedFiles: 0, }); }); @@ -234,19 +242,14 @@ const hasGit = (() => { client.clearReceived(); dispatchTurnStarted(client, session, 'turn-worktree-summary', `terminal-edit:${join(worktree, 'session-edit.txt')}`, 1); - const waitForEditedFiles = async (changeset: string) => { - const notification = await client.waitForNotification(n => - isActionNotification(n, 'changeset/contentChanged') - && getActionEnvelope(n).channel === changeset - && (getActionEnvelope(n).action as ChangesetContentChangedAction).files.some(file => file.edit.after?.uri.endsWith('/session-edit.txt')), - getAgentHostE2ETestTimeout(10_000, 30_000), - ); - return (getActionEnvelope(notification).action as ChangesetContentChangedAction).files; - }; - const [branchFiles, sessionFiles] = await Promise.all([ - waitForEditedFiles(branchChangeset), - waitForEditedFiles(sessionChangeset), - ]); + const branchNotification = await client.waitForNotification(n => + isActionNotification(n, 'changeset/contentChanged') + && getActionEnvelope(n).channel === branchChangeset + && (getActionEnvelope(n).action as ChangesetContentChangedAction).files.some(file => file.edit.after?.uri.endsWith('/session-edit.txt')), + getAgentHostE2ETestTimeout(10_000, 30_000), + ); + const branchFiles = (getActionEnvelope(branchNotification).action as ChangesetContentChangedAction).files; + const sessionChangesetState = await settledChangeset(sessionChangeset); const fileStats = (files: ChangesetContentChangedAction['files']) => files.map(file => ({ name: (file.edit.after?.uri ?? file.edit.before?.uri)?.split('/').pop(), additions: file.edit.diff?.added, @@ -254,14 +257,14 @@ const hasGit = (() => { })).sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); assert.deepStrictEqual({ branch: fileStats(branchFiles), - session: fileStats(sessionFiles), + session: changesetFileNames(sessionChangesetState), }, { branch: [ { name: 'committed.txt', additions: 2, deletions: 0 }, { name: 'seed.txt', additions: 3, deletions: 1 }, { name: 'session-edit.txt', additions: 1, deletions: 0 }, ], - session: [{ name: 'session-edit.txt', additions: 1, deletions: 0 }], + session: [], }); const changes = branchFiles.reduce((total, file) => ({ diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index 6c988f50fa5b5e..e9d0b41c2eefd4 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -13,7 +13,7 @@ import { basename, getComparisonKey } from '../../../../../base/common/resources import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../log/common/log.js'; -import { GitRefType, IAgentHostGitService, META_DIFF_BASE_BRANCH, type IAddWorktreeOptions } from '../../../common/agentHostGitService.js'; +import { GitRefType, IAgentHostGitService, META_DIFF_BASE_BRANCH, type IAddWorktreeOptions, type IBranch } from '../../../common/agentHostGitService.js'; import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, MessageKind, ResponsePartKind, TurnState, type ISessionGitState, type Turn } from '../../../common/state/sessionState.js'; import { AgentBranchNameGenerator, IAgentBranchNameGenerator } from '../../../node/shared/agentBranchNameGenerator.js'; @@ -114,13 +114,28 @@ suite('WorktreeIsolation', () => { const sessionUri = URI.parse('agent-session://test/s1'); const sessionId = 's1'; + function localBranch(name: string, upstreamName?: string): IBranch { + return { + ref: `refs/heads/${name}`, + name, + kind: GitRefType.Head, + upstream: upstreamName ? { + ref: `refs/remotes/${upstreamName}`, + name: upstreamName, + remote: upstreamName.split('/')[0], + } : undefined, + }; + } + function createGitService(): IAgentHostGitService { return { ...createNoopGitService(), getRepositoryRoot: async () => repoRoot, revParse: async (_root, expr) => expr === 'HEAD' ? headCommit : undefined, getCurrentBranch: async () => 'feature', + getHEAD: async () => localBranch('feature'), getDefaultBranch: async () => ({ name: 'main', startPoint: 'main' }), + getDefaultBranch2: async () => localBranch('main'), getBranches: async () => [ { ref: 'refs/heads/main', name: 'main', kind: GitRefType.Head }, { ref: 'refs/heads/feature', name: 'feature', kind: GitRefType.Head }, @@ -520,7 +535,7 @@ suite('WorktreeIsolation', () => { gitService: { ...createGitService(), getRepositoryRoot: async () => { rootReads++; return repoRoot; }, - getCurrentBranch: async () => currentBranch, + getHEAD: async () => localBranch(currentBranch), } }); const request = { workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'folder' } }; @@ -532,28 +547,28 @@ suite('WorktreeIsolation', () => { assert.strictEqual(rootReads, 2); }); - test('reads current and default branches concurrently after validating HEAD', async () => { - const finishCurrentBranch = new DeferredPromise(); + test('reads HEAD and the default branch concurrently after validating the commit', async () => { + const finishHEAD = new DeferredPromise(); const calls: string[] = []; const isolation = createIsolation(disposables, { gitService: { ...createGitService(), revParse: async () => { calls.push('HEAD'); return headCommit; }, - getCurrentBranch: () => { calls.push('current'); return finishCurrentBranch.p; }, - getDefaultBranch: async () => { calls.push('default'); return undefined; }, + getHEAD: () => { calls.push('head'); return finishHEAD.p; }, + getDefaultBranch2: async () => { calls.push('default'); return undefined; }, } }); const resolution = isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'folder' } }); - let callsBeforeCompletingCurrentBranch: string[]; + let callsBeforeCompletingHEAD: string[]; try { await timeout(0); - callsBeforeCompletingCurrentBranch = [...calls]; + callsBeforeCompletingHEAD = [...calls]; } finally { - finishCurrentBranch.complete('feature'); + finishHEAD.complete(localBranch('feature')); } const result = await resolution; - assert.deepStrictEqual({ callsBeforeCompletingCurrentBranch, branch: result.branchValue }, { - callsBeforeCompletingCurrentBranch: ['HEAD', 'current', 'default'], + assert.deepStrictEqual({ callsBeforeCompletingHEAD, branch: result.branchValue }, { + callsBeforeCompletingHEAD: ['HEAD', 'head', 'default'], branch: 'feature', }); }); @@ -564,8 +579,8 @@ suite('WorktreeIsolation', () => { gitService: { ...createGitService(), revParse: async () => undefined, - getCurrentBranch: async () => { calls.push('current'); return 'feature'; }, - getDefaultBranch: async () => { calls.push('default'); return undefined; }, + getHEAD: async () => { calls.push('head'); return localBranch('feature'); }, + getDefaultBranch2: async () => { calls.push('default'); return undefined; }, } }); const result = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: undefined }); @@ -574,12 +589,12 @@ suite('WorktreeIsolation', () => { }); }); - test('detached checkout uses the validated HEAD commit as its branch fallback', async () => { + test('detached checkout offers worktree isolation from HEAD without a default branch', async () => { const isolation = createIsolation(disposables, { gitService: { ...createGitService(), - getCurrentBranch: async () => undefined, - getDefaultBranch: async () => undefined, + getHEAD: async () => ({ name: headCommit!, kind: GitRefType.DetachedHead }), + getDefaultBranch2: async () => undefined, } }); const result = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: undefined }); @@ -605,21 +620,21 @@ suite('WorktreeIsolation', () => { assert.strictEqual(rootReads, 2); }); - for (const failingRead of ['current', 'default']) { - test(`a failed ${failingRead} branch read is surfaced and retried`, async () => { + for (const failingRead of ['HEAD', 'default branch'] as const) { + test(`a failed ${failingRead} read is surfaced and retried`, async () => { const failure = new Error('Git unavailable'); let fail = true; const isolation = createIsolation(disposables, { gitService: { ...createGitService(), - getCurrentBranch: async () => { - if (fail && failingRead === 'current') { + getHEAD: async () => { + if (fail && failingRead === 'HEAD') { throw failure; } - return 'feature'; + return localBranch('feature'); }, - getDefaultBranch: async () => { - if (fail && failingRead === 'default') { + getDefaultBranch2: async () => { + if (fail && failingRead === 'default branch') { throw failure; } return undefined; @@ -644,8 +659,8 @@ suite('WorktreeIsolation', () => { const isolation = createIsolation(disposables, { gitService: { ...createGitService(), - getCurrentBranch: async () => { branchRead.complete(); return currentBranch; }, - getDefaultBranch: async () => { await finishRead.p; return { name: 'main', startPoint: 'main' }; }, + getHEAD: async () => { branchRead.complete(); return localBranch(currentBranch); }, + getDefaultBranch2: async () => { await finishRead.p; return localBranch('main'); }, } }); const request = { workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'folder' } }; @@ -734,13 +749,13 @@ suite('WorktreeIsolation', () => { test('defaults worktree isolation to the default branch start point', async () => { const gitService = createGitService(); - let defaultBranchStartPoint = 'origin/main'; - gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: defaultBranchStartPoint }); + let defaultBranch = localBranch('main', 'origin/main'); + gitService.getDefaultBranch2 = async () => defaultBranch; const isolation = createIsolation(disposables, { gitService }); const checked = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'worktree' } }); const folder = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'folder' } }); const explicit = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'feature' } }); - defaultBranchStartPoint = 'main'; + defaultBranch = localBranch('main'); const localFallback = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'worktree' } }); assert.deepStrictEqual({ @@ -776,8 +791,7 @@ suite('WorktreeIsolation', () => { test('uses the selected local default branch as the worktree start point', async () => { const gitService = createGitService(); - gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' }); - gitService.getDefaultBranch2 = async () => ({ ref: 'refs/heads/main', name: 'main', kind: GitRefType.Head }); + gitService.getDefaultBranch2 = async () => localBranch('main'); const isolation = createIsolation(disposables, { gitService }); const config = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: undefined }); @@ -798,8 +812,8 @@ suite('WorktreeIsolation', () => { startPoint: addWorktreeCalls[0]?.commitish, diffBaseBranch: await db.getMetadata('agentHost.diffBaseBranch'), }, { - branchDefault: 'origin/main', - branchEnum: ['origin/main'], + branchDefault: 'main', + branchEnum: ['main'], startPoint: 'main', diffBaseBranch: 'main', }); @@ -843,7 +857,7 @@ suite('WorktreeIsolation', () => { operations, }, { startPoint: 'origin/main', - diffBaseBranch: 'origin/main', + diffBaseBranch: 'main', operations: ['resolve:origin/main', 'fetch:origin:refs/remotes/origin/main', 'add:origin/main'], }); }); diff --git a/src/vs/platform/quickinput/browser/quickAccess.ts b/src/vs/platform/quickinput/browser/quickAccess.ts index 54dfc47e29099c..71abebd6f860c7 100644 --- a/src/vs/platform/quickinput/browser/quickAccess.ts +++ b/src/vs/platform/quickinput/browser/quickAccess.ts @@ -56,7 +56,7 @@ export class QuickAccessController extends Disposable implements IQuickAccessCon private doShowOrPick(value: string, pick: boolean, options?: IQuickAccessOptions): Promise | void { // Find provider for the value to show - const [provider, descriptor] = this.getOrInstantiateProvider(value, options?.enabledProviderPrefixes); + const [provider, descriptor] = this.getOrInstantiateProvider(value, options?.enabledProviderPrefixes, options?.providerPrefix); // Return early if quick access is already showing on that same prefix const visibleQuickAccess = this.visibleQuickAccess; @@ -212,10 +212,11 @@ export class QuickAccessController extends Disposable implements IQuickAccessCon // Whenever the value changes, check if the provider has // changed and if so - re-create the picker from the beginning disposables.add(picker.onDidChangeValue(value => { - const [providerForValue] = this.getOrInstantiateProvider(value, options?.enabledProviderPrefixes); + const [providerForValue] = this.getOrInstantiateProvider(value, options?.enabledProviderPrefixes, options?.providerPrefix); if (providerForValue !== provider) { this.show(value, { enabledProviderPrefixes: options?.enabledProviderPrefixes, + providerPrefix: options?.providerPrefix, // do not rewrite value from user typing! preserveValue: true, // persist the value of the providerOptions from the original showing @@ -236,8 +237,8 @@ export class QuickAccessController extends Disposable implements IQuickAccessCon return disposables; } - private getOrInstantiateProvider(value: string, enabledProviderPrefixes?: string[]): [IQuickAccessProvider | undefined, IQuickAccessProviderDescriptor | undefined] { - const providerDescriptor = this.registry.getQuickAccessProvider(value, this.contextKeyService); + private getOrInstantiateProvider(value: string, enabledProviderPrefixes?: string[], providerPrefix?: string): [IQuickAccessProvider | undefined, IQuickAccessProviderDescriptor | undefined] { + const providerDescriptor = this.registry.getQuickAccessProvider(providerPrefix ?? value, this.contextKeyService); if (!providerDescriptor || enabledProviderPrefixes && !enabledProviderPrefixes?.includes(providerDescriptor.prefix)) { return [undefined, undefined]; } diff --git a/src/vs/platform/quickinput/common/quickAccess.ts b/src/vs/platform/quickinput/common/quickAccess.ts index a9fbfbde96e907..e4465c558db657 100644 --- a/src/vs/platform/quickinput/common/quickAccess.ts +++ b/src/vs/platform/quickinput/common/quickAccess.ts @@ -39,6 +39,11 @@ export interface AnythingQuickAccessProviderRunOptions extends IQuickAccessProvi } export interface IQuickAccessOptions { + /** + * The prefix of the provider to use regardless of the input value. + */ + readonly providerPrefix?: string; + /** * An optional anchor for the picker. */ diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts index a9a3f6a90d71e9..8088e874324b87 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/promptInputModel.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import type { IBuffer, IBufferCell, IBufferLine, IMarker, Terminal } from '@xterm/headless'; -import { throttle } from '../../../../../base/common/decorators.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ILogService, LogLevel } from '../../../../log/common/log.js'; @@ -270,7 +269,6 @@ export class PromptInputModel extends Disposable implements IPromptInputModel { this._onDidChangeInput.fire(this._createStateObject()); } - @throttle(0) private _sync() { try { this._doSync(); diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 7503771ae8a109..b9f01ec5836e78 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -331,7 +331,11 @@ export interface IPtyService { workspaceId: string, workspaceName: string ): Promise; - attachToProcess(id: number): Promise; + /** + * Attaches to a persistent process, failing if `pid` is provided and differs from its process ID. + * Persistent process IDs restart with the pty host, so a saved ID can later belong to another process. + */ + attachToProcess(id: number, pid?: number): Promise; detachFromProcess(id: number, forcePersist?: boolean): Promise; shutdownAll(): Promise; @@ -1166,7 +1170,11 @@ export interface ITerminalBackend extends ITerminalBackendPtyServiceContribution readonly onDidRequestDetach: Event<{ requestId: number; workspaceId: string; instanceId: number }>; attachToProcess(id: number): Promise; - attachToRevivedProcess(id: number): Promise; + /** + * Attaches to the process revived for a restored terminal, otherwise to its original process. The + * local backend refuses an original process whose pid differs from `pid`, unless `pid` is 0 (unknown). + */ + attachToRevivedProcess(id: number, pid: number): Promise; listProcesses(): Promise; getLatency(): Promise; getDefaultSystemShell(osOverride?: OperatingSystem): Promise; diff --git a/src/vs/platform/terminal/node/ptyHostService.ts b/src/vs/platform/terminal/node/ptyHostService.ts index 3ac9cdb8e9a149..0a32e0c49860e0 100644 --- a/src/vs/platform/terminal/node/ptyHostService.ts +++ b/src/vs/platform/terminal/node/ptyHostService.ts @@ -220,8 +220,8 @@ export class PtyHostService extends Disposable implements IPtyHostService { updateIcon(id: number, userInitiated: boolean, icon: TerminalIcon, color?: string): Promise { return this._proxy.updateIcon(id, userInitiated, icon, color); } - attachToProcess(id: number): Promise { - return this._proxy.attachToProcess(id); + attachToProcess(id: number, pid?: number): Promise { + return this._proxy.attachToProcess(id, pid); } detachFromProcess(id: number, forcePersist?: boolean): Promise { return this._proxy.detachFromProcess(id, forcePersist); diff --git a/src/vs/platform/terminal/node/ptyService.ts b/src/vs/platform/terminal/node/ptyService.ts index 476664d1dd0b24..ac7cc764daf09f 100644 --- a/src/vs/platform/terminal/node/ptyService.ts +++ b/src/vs/platform/terminal/node/ptyService.ts @@ -139,11 +139,11 @@ export class PtyService extends Disposable implements IPtyService { readonly onDidChangeProperty = this._traceEvent('_onDidChangeProperty', this._onDidChangeProperty.event); private _traceEvent(name: string, event: Event): Event { - event(e => { + this._register(event(e => { if (this._logService.getLevel() === LogLevel.Trace) { this._logService.trace(`[RPC Event] PtyService#${name}.fire(${JSON.stringify(e)})`); } - }); + })); return event; } @@ -364,9 +364,13 @@ export class PtyService extends Disposable implements IPtyService { } @traceRpc - async attachToProcess(id: number): Promise { + async attachToProcess(id: number, pid?: number): Promise { try { - await this._throwIfNoPty(id).attach(); + const persistentProcess = this._throwIfNoPty(id); + if (pid !== undefined && persistentProcess.pid !== pid) { + throw new ErrorNoTelemetry(`Pty ${id} has pid ${persistentProcess.pid}, expected ${pid}`); + } + await persistentProcess.attach(); this._logService.info(`Persistent process reconnection "${id}"`); } catch (e) { this._logService.warn(`Persistent process reconnection "${id}" failed`, e.message); diff --git a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts index e625ae66a9b868..636005c7db4bee 100644 --- a/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts +++ b/src/vs/platform/terminal/test/common/capabilities/commandDetection/promptInputModel.test.ts @@ -10,6 +10,7 @@ import { PromptInputModel, type IPromptInputModelState } from '../../../../commo import { Emitter } from '../../../../../../base/common/event.js'; import type { ITerminalCommand } from '../../../../common/capabilities/capabilities.js'; import { ok, notDeepStrictEqual, strictEqual } from 'assert'; +import { useFakeTimers } from 'sinon'; import { timeout } from '../../../../../../base/common/async.js'; import { importAMDNodeModule } from '../../../../../../amdX.js'; import { GeneralShellType, PosixShellType } from '../../../../common/terminal.js'; @@ -159,6 +160,23 @@ suite('PromptInputModel', () => { strictEqual(promptInputModel.value, ''); }); + test('should update input when the system clock moves backwards', async () => { + await writePromise('$ '); + fireCommandStart(); + await assertPromptInput('|'); + + await writePromise('foo'); + await assertPromptInput('foo|'); + + const clock = useFakeTimers({ now: Date.now() - 10_000, toFake: ['Date'] }); + try { + await writePromise(' bar'); + await assertPromptInput('foo bar|'); + } finally { + clock.restore(); + } + }); + test('cursor navigation', async () => { await writePromise('$ '); fireCommandStart(); diff --git a/src/vs/platform/terminal/test/node/ptyService.test.ts b/src/vs/platform/terminal/test/node/ptyService.test.ts index 0dcd94448f1626..5fc850b3bc256b 100644 --- a/src/vs/platform/terminal/test/node/ptyService.test.ts +++ b/src/vs/platform/terminal/test/node/ptyService.test.ts @@ -8,9 +8,11 @@ import { SerializeAddon } from '@xterm/addon-serialize'; import pkg from '@xterm/headless'; import { createSandbox } from 'sinon'; import { toDisposable } from '../../../../base/common/lifecycle.js'; +import { upcastPartial } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { XtermSerializer } from '../../node/ptyService.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { PtyService, XtermSerializer } from '../../node/ptyService.js'; suite('XtermSerializer', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -63,3 +65,27 @@ suite('XtermSerializer', () => { deepStrictEqual({ disposed: dispose.callCount, serialized: serialize.callCount, screen: replay.events[0].data }, { disposed: 1, serialized: 0, screen: 'saved screen' }); }); }); + +suite('PtyService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('attachToProcess only attaches when the expected pid matches', async () => { + const service = store.add(new PtyService(new NullLogService(), upcastPartial({}), { graceTime: 60000, shortGraceTime: 6000, scrollback: 100 }, 0)); + let attachCount = 0; + // Stand in for a running persistent process to avoid spawning a pty + const ptys: Map; shutdown(): void }> = Reflect.get(service, '_ptys'); + ptys.set(1, { pid: 1234, attach: async () => { attachCount++; }, shutdown: () => { } }); + + async function attach(pid?: number): Promise { + try { + await service.attachToProcess(1, pid); + return 'attached'; + } catch { + return 'rejected'; + } + } + const results = { otherPid: await attach(4321), samePid: await attach(1234), noPid: await attach() }; + + deepStrictEqual({ ...results, attachCount }, { otherPid: 'rejected', samePid: 'attached', noPid: 'attached', attachCount: 2 }); + }); +}); diff --git a/src/vs/platform/tunnel/node/tunnelService.ts b/src/vs/platform/tunnel/node/tunnelService.ts index 3554ea785d7740..fcaef2c9715fc9 100644 --- a/src/vs/platform/tunnel/node/tunnelService.ts +++ b/src/vs/platform/tunnel/node/tunnelService.ts @@ -11,7 +11,7 @@ import { NodeSocket } from '../../../base/parts/ipc/node/ipc.net.js'; import { Barrier } from '../../../base/common/async.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { OS } from '../../../base/common/platform.js'; -import { ISocket } from '../../../base/parts/ipc/common/ipc.net.js'; +import { ISocket, SocketCloseEventType } from '../../../base/parts/ipc/common/ipc.net.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; @@ -157,7 +157,14 @@ export class NodeRemoteTunnel extends Disposable implements RemoteTunnel { } private _mirrorGenericSocket(localSocket: net.Socket, remoteSocket: ISocket) { - remoteSocket.onClose(() => localSocket.destroy()); + remoteSocket.onClose(e => { + // Abort on error so the local client can't mistake a truncated stream for a complete one; otherwise flush buffered data. + if (e?.type === SocketCloseEventType.NodeSocketCloseEvent && e.hadError) { + localSocket.destroy(); + } else { + localSocket.end(); + } + }); remoteSocket.onEnd(() => localSocket.end()); remoteSocket.onData(d => localSocket.write(d.buffer)); localSocket.on('data', d => remoteSocket.write(VSBuffer.wrap(d))); diff --git a/src/vs/platform/tunnel/test/node/tunnelService.test.ts b/src/vs/platform/tunnel/test/node/tunnelService.test.ts new file mode 100644 index 00000000000000..f499977094a139 --- /dev/null +++ b/src/vs/platform/tunnel/test/node/tunnelService.test.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { once } from 'events'; +import * as net from 'net'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { toDisposable } from '../../../../base/common/lifecycle.js'; +import { ISocket, SocketCloseEvent, SocketCloseEventType } from '../../../../base/parts/ipc/common/ipc.net.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NodeRemoteTunnel } from '../../node/tunnelService.js'; + +suite('NodeRemoteTunnel', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + async function mirrorGenericSocketWithBufferedResponse() { + const onData = store.add(new Emitter()); + const onEnd = store.add(new Emitter()); + const onClose = store.add(new Emitter()); + const remoteSocket: ISocket = { + onData: listener => store.add(onData.event(listener)), + onEnd: listener => store.add(onEnd.event(listener)), + onClose: listener => store.add(onClose.event(listener)), + write: () => { }, + end: () => { }, + drain: async () => { }, + traceSocketEvent: () => { }, + dispose: () => { } + }; + + const server = net.createServer(); + store.add(toDisposable(() => server.close())); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + + const connection = new Promise(resolve => server.once('connection', socket => { + store.add(toDisposable(() => socket.destroy())); + resolve(socket); + })); + const client = net.createConnection({ host: '127.0.0.1', port: (server.address() as net.AddressInfo).port }); + store.add(toDisposable(() => client.destroy())); + const received: Buffer[] = []; + client.on('data', data => received.push(data)); + client.pause(); + const localSocket = await connection; + // eslint-disable-next-line local/code-no-bracket-notation-for-identifiers -- Isolate the private socket bridge from the remote agent handshake. + NodeRemoteTunnel.prototype['_mirrorGenericSocket'](localSocket, remoteSocket); + + const body = Buffer.alloc(16 * 1024 * 1024, 'response'); + const response = Buffer.concat([ + Buffer.from(`HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`), + body + ]); + // Deliver in message-sized chunks; some platforms (Windows) hand a single large write straight to the kernel without buffering. + const chunkSize = 1024 * 1024; + for (let offset = 0; offset < response.length; offset += chunkSize) { + onData.fire(VSBuffer.wrap(response.subarray(offset, offset + chunkSize))); + } + assert.ok(localSocket.writableLength > 0, 'The response must still have buffered data when the remote socket terminates'); + + return { onEnd, onClose, client, received, localSocket, response }; + } + + for (const termination of ['end', 'close', 'end then close']) { + test(`generic socket flushes a large response on ${termination}`, async () => { + const { onEnd, onClose, client, received, response } = await mirrorGenericSocketWithBufferedResponse(); + const ended = once(client, 'end'); + + if (termination !== 'close') { + onEnd.fire(); + } + if (termination !== 'end') { + onClose.fire(undefined); + } + + client.resume(); + await ended; + const actual = Buffer.concat(received); + assert.ok(actual.equals(response), `Received ${actual.length} of ${response.length} response bytes`); + }); + } + + test('generic socket aborts the local socket when the remote socket closes with an error', async () => { + const { onClose, localSocket } = await mirrorGenericSocketWithBufferedResponse(); + + onClose.fire({ type: SocketCloseEventType.NodeSocketCloseEvent, hadError: true, error: new Error('remote failure') }); + + assert.strictEqual(localSocket.destroyed, true); + }); +}); diff --git a/src/vs/sessions/browser/media/workbench.css b/src/vs/sessions/browser/media/workbench.css index d0aee02aedab1f..642ad7083aea8a 100644 --- a/src/vs/sessions/browser/media/workbench.css +++ b/src/vs/sessions/browser/media/workbench.css @@ -444,7 +444,8 @@ } .agent-sessions-workbench .explorer-folders-view .monaco-list-row, -.agent-sessions-workbench .changes-file-list .monaco-list-row { +.agent-sessions-workbench .changes-file-list .monaco-list-row, +.agent-sessions-workbench .pane-body.integrated-terminal .tabs-list .monaco-list-row { left: var(--vscode-spacing-size40); right: var(--vscode-spacing-size40); width: auto; @@ -452,7 +453,8 @@ } .agent-sessions-workbench.modern-ui-compact .explorer-folders-view .monaco-list-row, -.agent-sessions-workbench.modern-ui-compact .changes-file-list .monaco-list-row { +.agent-sessions-workbench.modern-ui-compact .changes-file-list .monaco-list-row, +.agent-sessions-workbench.modern-ui-compact .pane-body.integrated-terminal .tabs-list .monaco-list-row { left: var(--vscode-spacing-size20); right: var(--vscode-spacing-size20); } diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 29e990ff6abede..5af3ed45a86bf5 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -93,6 +93,7 @@ export const Menus = { SessionsEditorTabsBarContext: new MenuId('SessionsEditorTabsBarContext'), SessionsEditorTabsBarAddTab: new MenuId('SessionsEditorTabsBarAddTab'), SessionsEditorTabsBarAddTabCanvas: new MenuId('SessionsEditorTabsBarAddTabCanvas'), + EmptyFileEditorActions: new MenuId('SessionsEmptyFileEditorActions'), /** * Entries merged into the dropdown of the changes button bar's primary * button. A submenu contributed to its `primary` group names a group of diff --git a/src/vs/sessions/browser/parts/media/panelPart.css b/src/vs/sessions/browser/parts/media/panelPart.css index 26a69e40771440..e00f2730773dbb 100644 --- a/src/vs/sessions/browser/parts/media/panelPart.css +++ b/src/vs/sessions/browser/parts/media/panelPart.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /* Hide the top title border because we draw our own */ -.monaco-workbench .part.panel.bottom .composite.title { +.monaco-workbench.agent-sessions-workbench .part.panel.bottom .composite.title { border-top-width: 0; } @@ -14,6 +14,134 @@ background-color: var(--vscode-agentsPanel-background); } +.agent-sessions-workbench .part.panel.bottom.panel-labels > .title > .title-actions { + height: 100%; +} + +.agent-sessions-workbench.modern-ui-tabs:not(.phone-layout) .part.panel.bottom.panel-labels { + --modern-ui-tab-active-background: var(--modern-ui-editor-tab-active-background); + --modern-ui-tab-hover-background: var(--modern-ui-editor-tab-hover-background); + --vscode-modernTab-activeForeground: var(--modern-ui-editor-tab-active-foreground); + --vscode-modernTab-hoverForeground: var(--modern-ui-editor-tab-hover-foreground); + --vscode-panelTitle-activeForeground: var(--modern-ui-editor-tab-hover-foreground); +} + +.agent-sessions-workbench.modern-ui-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) { + padding-inline: var(--vscode-spacing-size80); + border-block: 0; +} + +.agent-sessions-workbench.modern-ui-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) > .active-item-indicator { + inset: 0 var(--vscode-spacing-size20); + width: auto; + height: auto; + border: var(--vscode-strokeThickness) solid var(--modern-ui-editor-tab-border); + border-radius: var(--vscode-cornerRadius-small); + box-sizing: border-box; + transform: none; +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels { + --modern-ui-tab-active-background: var(--modern-ui-connected-tab-surface); + --modern-ui-connected-tab-surface: var(--vscode-agentsPanel-background); + --modern-ui-connected-tab-border: var(--part-border-color, var(--vscode-agentsBottomPanel-border, var(--vscode-agentsPanel-border))); + --modern-ui-connected-tab-radius: var(--vscode-cornerRadius-medium, var(--vscode-cornerRadius-small)); + --modern-ui-connected-tab-cap-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-shoulder-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)); +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title { + position: relative; + padding-inline-start: 0; + background-color: var(--modern-ui-connected-tab-strip-background, var(--vscode-editorGroupHeader-connectedTabsBackground, var(--vscode-editorGroupHeader-tabsBackground))); +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title::after { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 0; + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + pointer-events: none; +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) > .active-item-indicator { + top: calc(-1 * var(--vscode-strokeThickness)); + right: 0; + bottom: calc(-1 * var(--vscode-strokeThickness)); + left: 0; + height: auto; + border-radius: var(--vscode-cornerRadius-small); +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon).checked > .active-item-indicator { + z-index: 1; + overflow: visible; + background-color: var(--modern-ui-connected-tab-surface); + border: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom: 0; + border-radius: var(--modern-ui-connected-tab-cap-radius) var(--modern-ui-connected-tab-cap-radius) 0 0; +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) > :is(.action-label, .badge) { + z-index: 2; +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon).checked > .active-item-indicator::before, +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon).checked > .active-item-indicator::after { + content: ''; + display: block !important; + position: absolute; + bottom: 0; + width: var(--modern-ui-connected-tab-shoulder-radius); + height: var(--modern-ui-connected-tab-shoulder-radius); + box-sizing: border-box; + border-top: 0; + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + pointer-events: none; +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon).checked > .active-item-indicator::before { + right: 100%; + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border) !important; + border-bottom-right-radius: var(--modern-ui-connected-tab-shoulder-radius); + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon).checked > .active-item-indicator::after { + left: 100%; + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom-left-radius: var(--modern-ui-connected-tab-shoulder-radius); + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / -2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:first-child:not(.icon).checked > .active-item-indicator::before { + content: none; +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar:dir(rtl) .action-item:not(.icon).checked > .active-item-indicator::before { + right: auto; + left: 100%; + border-right: 0; + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom-right-radius: 0; + border-bottom-left-radius: var(--modern-ui-connected-tab-shoulder-radius); + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / -2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); +} + +.agent-sessions-workbench.modern-ui-tabs.modern-ui-connected-editor-tabs:not(.phone-layout) .part.panel.bottom.panel-labels > .title > .composite-bar-container > .composite-bar > .monaco-action-bar:dir(rtl) .action-item:not(.icon).checked > .active-item-indicator::after { + right: 100%; + left: auto; + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-left: 0; + border-bottom-right-radius: var(--modern-ui-connected-tab-shoulder-radius); + border-bottom-left-radius: 0; + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); +} + /* Override base workbench panel content background for terminal */ .agent-sessions-workbench .part.panel > .content .monaco-editor, .agent-sessions-workbench .part.panel > .content .monaco-editor .margin, @@ -42,3 +170,7 @@ .agent-sessions-workbench .part.panel .terminal-wrapper { background-color: var(--vscode-agentsPanel-background); } + +.monaco-workbench.agent-sessions-workbench .part.panel .pane-body.integrated-terminal .tabs-list-container:not(.drop-target) { + background-color: var(--vscode-agentsPanel-background); +} diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts index 3246c9591dd864..09751b35131818 100644 --- a/src/vs/sessions/browser/parts/panelPart.ts +++ b/src/vs/sessions/browser/parts/panelPart.ts @@ -36,6 +36,15 @@ import { Extensions } from '../../../workbench/browser/panecomposite.js'; import { isPhoneLayout } from './mobile/mobileLayout.js'; import { mainWindow } from '../../../base/browser/window.js'; import { getSessionsPartFrameInset } from './agentsPartCard.js'; +import { EditorHeaderControl } from '../../../workbench/browser/parts/editor/editorHeaderControl.js'; + +function getPanelTabBarHeight(showLabels: boolean, compact: boolean): number | undefined { + if (!showLabels) { + return undefined; + } + + return compact ? EditorHeaderControl.COMPACT_HEIGHT : EditorHeaderControl.DEFAULT_HEIGHT; +} /** * Panel part specifically for agent sessions workbench. @@ -93,7 +102,14 @@ export class PanelPart extends AbstractPaneCompositePart { ) { super( Parts.PANEL_PART, - { hasTitle: true, trailingSeparator: true }, + { + hasTitle: true, + trailingSeparator: true, + titleHeight: () => getPanelTabBarHeight( + configurationService.getValue('workbench.panel.showLabels') !== false, + configurationService.getValue<'default' | 'compact'>('window.density.editorTabHeight') === 'compact' + ) + }, PanelPart.activePanelSettingsKey, ActivePanelContext.bindTo(contextKeyService), PanelFocusContext.bindTo(contextKeyService), @@ -120,8 +136,14 @@ export class PanelPart extends AbstractPaneCompositePart { ); this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('workbench.panel.showLabels')) { - this.updateCompositeBar(true); + const panelLabelsChanged = e.affectsConfiguration('workbench.panel.showLabels'); + const editorTabHeightChanged = e.affectsConfiguration('window.density.editorTabHeight'); + if (panelLabelsChanged || editorTabHeightChanged) { + this.updatePanelTabBarStyles(); + if (panelLabelsChanged) { + this.updateCompositeBar(true); + } + this.relayoutForInsets(); } })); this._register(this.layoutService.onDidChangePanelAlignment(() => this.relayoutForInsets())); @@ -136,6 +158,7 @@ export class PanelPart extends AbstractPaneCompositePart { super.updateStyles(); const container = assertReturnsDefined(this.getContainer()); + this.updatePanelTabBarStyles(); // Store background and border as CSS variables for the card styling on .part container.style.setProperty('--part-background', this.getColor(agentsPanelBackground) || ''); @@ -149,6 +172,19 @@ export class PanelPart extends AbstractPaneCompositePart { container.style.borderTopWidth = ''; } + private updatePanelTabBarStyles(): void { + const container = this.getContainer(); + const showLabels = this.configurationService.getValue('workbench.panel.showLabels') !== false; + container?.classList.toggle('panel-labels', showLabels); + + const tabBarHeight = getPanelTabBarHeight(showLabels, this.configurationService.getValue<'default' | 'compact'>('window.density.editorTabHeight') === 'compact'); + if (tabBarHeight === undefined) { + this.titleArea?.style.removeProperty('height'); + } else { + this.titleArea?.style.setProperty('height', `${tabBarHeight}px`); + } + } + protected getCompositeBarOptions(): IPaneCompositeBarOptions { return { partContainerClass: 'panel', diff --git a/src/vs/sessions/common/agentHostSessionWorkspace.ts b/src/vs/sessions/common/agentHostSessionWorkspace.ts index 7a4ca2f7ef839f..5929570815d4fd 100644 --- a/src/vs/sessions/common/agentHostSessionWorkspace.ts +++ b/src/vs/sessions/common/agentHostSessionWorkspace.ts @@ -105,19 +105,22 @@ export type IFolderGitHubInfoResolver = (workingDirectory: URI) => IObservable folder.workingDirectory)) { - const folder = sessionWorkspace.folders.find(candidate => extUri.isEqual(candidate.workingDirectory, workingDirectory)); + const folder = sessionWorkspace.folders.find(candidate => extUri.isEqual(candidate.workingDirectory, workingDirectory)) + ?? (resolveDetachedFolders ? createAdditionalFolder(workingDirectory, sessionWorkspace.description) : undefined); if (!folder) { return undefined; } @@ -143,6 +146,21 @@ export function buildAgentHostChatWorkspace(sessionWorkspace: ISessionWorkspace }; } +/** A non-primary folder; a VS Code-created worktree reports its repository as the folder's root. */ +function createAdditionalFolder(workingDirectory: URI, description: string | undefined): ISessionFolder { + const repositoryRoot = getRepositoryRootFromWorktree(workingDirectory); + const root = repositoryRoot ?? workingDirectory; + return { + root, + workingDirectory, + name: basename(root) || root.path, + description, + ...(repositoryRoot ? { + gitRepository: { uri: repositoryRoot, workTreeUri: workingDirectory, baseBranchName: undefined, isRepository: constObservable(true), gitHubInfo: constObservable(undefined) }, + } : {}), + }; +} + function withFolderGitState(folder: ISessionFolder, gitState: ISessionGitState): ISessionFolder { const repository = folder.gitRepository ?? { uri: folder.root, @@ -200,19 +218,7 @@ export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProject // git state; a VS Code-created worktree reports its repository as the // folder's project, so a chat working in it shows that project. const primary = workingDirectories?.[0]; - const additionalFolders: ISessionFolder[] = (workingDirectories ?? []).slice(1).map(dir => { - const repositoryRoot = getRepositoryRootFromWorktree(dir); - const root = repositoryRoot ?? dir; - return { - root, - workingDirectory: dir, - name: basename(root) || root.path, - description: options.description, - ...(repositoryRoot ? { - gitRepository: { uri: repositoryRoot, workTreeUri: dir, baseBranchName: undefined, isRepository: constObservable(true), gitHubInfo: constObservable(undefined) }, - } : {}), - }; - }); + const additionalFolders = (workingDirectories ?? []).slice(1).map(dir => createAdditionalFolder(dir, options.description)); if (project) { const workTreeUri = extUri.isEqual(primary, project.uri) ? undefined : primary; diff --git a/src/vs/sessions/common/sessionConfig.ts b/src/vs/sessions/common/sessionConfig.ts index 90ca70879c1097..cba5c77ae2f6a5 100644 --- a/src/vs/sessions/common/sessionConfig.ts +++ b/src/vs/sessions/common/sessionConfig.ts @@ -9,6 +9,8 @@ export const USE_WORKTREE_SETTING = 'sessions.useWorktree'; export const SESSIONS_CHAT_TABS_SETTING = 'sessions.showChatTabs'; +export const SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING = 'sessions.experimental.emptyFileEditorActions'; + export const SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING = 'sessions.list.groupExternalSessions'; export const SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING = 'sessions.list.externalApplicationBadge'; diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts index 4925abbbc4c044..829515d6703321 100644 --- a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts @@ -3,20 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ThrottlerByKey } from '../../../../base/common/async.js'; -import { structuralEquals } from '../../../../base/common/equals.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, constObservable, derived, IReader, observableValueOpts } from '../../../../base/common/observable.js'; +import { constObservable, derived, IObservable, IReader } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { CanvasInput, canvasOwnerKey, compareCanvasDefinitions, ICanvasContext, ICanvasContextService, ICanvasOwner, ICanvasService, ICanvasWorkingSets, isOpenableExtensionCanvasDefinition } from '../../../../workbench/contrib/canvases/common/canvas.js'; +import { CanvasInput, canvasOwnerKey, ICanvasContext, ICanvasContextService, ICanvasOwner, ICanvasService, ICanvasWorkingSets } from '../../../../workbench/contrib/canvases/common/canvas.js'; +import { CanvasDefinitionStore } from '../../../../workbench/contrib/canvases/common/canvasDefinitionStore.js'; import { IEditorGroup, IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IChat, ISession, ISessionCanvasDefinition } from '../../../services/sessions/common/session.js'; -import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionEditorWorkingSetService } from '../../layout/common/sessionEditorWorkingSet.js'; import { createSessionCanvasReference, getSessionCanvasDefinitionInstanceId, ISessionCanvasRegistryService } from '../common/sessionCanvas.js'; @@ -109,37 +108,28 @@ export class SessionCanvasContextService extends Disposable implements ICanvasCo export class SessionCanvasRegistryService extends Disposable implements ISessionCanvasRegistryService { declare readonly _serviceBrand: undefined; - readonly availableCanvases = observableValueOpts({ owner: this, equalsFn: structuralEquals }, []); - /** Owner key of the chat whose canvases {@link availableCanvases} lists. */ - private availableCanvasesOwner: string | undefined; - private readonly availableCanvasesRefreshes = this._register(new ThrottlerByKey()); + readonly availableCanvases: IObservable; constructor( @ISessionsService private readonly sessionsService: ISessionsService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ICanvasService private readonly canvasService: ICanvasService, - @ILogService private readonly logService: ILogService, + @ILogService logService: ILogService, ) { super(); - this._register(autorun(reader => { + this.availableCanvases = this._register(new CanvasDefinitionStore(reader => { const session = sessionsService.activeSession.read(reader); const chat = session?.activeChat.read(reader); // Hosts republish canvas membership when their registry changes, so refresh on every emission. chat?.canvases?.read(reader); - const supported = canvasService.enabled.read(reader) && session?.capabilities.read(reader).supportsCanvases === true; - if (!session || !chat || !supported) { - this.availableCanvasesOwner = undefined; - this.availableCanvases.set([], undefined); - return; + if (!session || !chat || !canvasService.enabled.read(reader) || session.capabilities.read(reader).supportsCanvases !== true) { + return undefined; } - const owner = canvasOwnerKey({ providerId: session.providerId, session: session.resource, chat: chat.resource }); - // Clear only when the chat changes: an open Add Tab menu runs the commands registered for the current list. - if (owner !== this.availableCanvasesOwner) { - this.availableCanvasesOwner = owner; - this.availableCanvases.set([], undefined); - } - void this.availableCanvasesRefreshes.queue(owner, () => this.loadAvailableCanvases(session, chat, owner)); - })); + return { + owner: { providerId: session.providerId, session: session.resource, chat: chat.resource }, + listCanvases: () => sessionsManagementService.listCanvases(session, chat), + }; + }, '[SessionCanvasRegistryService]', logService)).definitions; } async openCanvas(canvas: ISessionCanvasDefinition): Promise { @@ -156,25 +146,6 @@ export class SessionCanvasRegistryService extends Disposable implements ISession } await this.sessionsManagementService.openCanvas(session, chat, canvas, instanceId); } - - private async loadAvailableCanvases(session: IActiveSession, chat: IChat, owner: string): Promise { - try { - const canvases = await this.sessionsManagementService.listCanvases(session, chat); - if (this._store.isDisposed || owner !== this.availableCanvasesOwner) { - return; - } - this.availableCanvases.set( - canvases.filter(isOpenableExtensionCanvasDefinition).sort(compareCanvasDefinitions), - undefined, - ); - } catch (error) { - if (this._store.isDisposed || owner !== this.availableCanvasesOwner) { - return; - } - this.availableCanvases.set([], undefined); - this.logService.error('[SessionCanvasRegistryService] Failed to list registered canvases', error); - } - } } function ownsSession(owner: ICanvasOwner, session: ISession): boolean { diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index b0fe46c4ea4fe1..1b96e9c73f10a3 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -58,7 +58,7 @@ import { WorktreeCreatedTaskDispatcher, AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SE import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import '../../sessions/browser/mobile/mobileOverlayContribution.js'; import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, COMPARE_AGENTS_OPEN_IN_GRID_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_COMPOSER_OPTIONS_EXPANDED_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING } from '../common/constants.js'; +import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, COMPARE_AGENTS_OPEN_IN_GRID_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_COMPOSER_OPTIONS_EXPANDED_SETTING, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING } from '../common/constants.js'; import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext } from '../../../common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { ISessionsChatViewStateService, SessionsChatViewStateService } from './chatViewStateService.js'; @@ -645,6 +645,14 @@ Registry.as(ConfigurationExtensions.Configuration).regis tags: ['experimental'], experiment: { mode: 'auto' }, }, + [NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING]: { + type: 'boolean', + default: false, + scope: ConfigurationScope.APPLICATION, + description: localize('sessions.chat.experimental.connectorRecommendations', "Controls whether the new-session composer recommends connectors that can add work context to chats."), + tags: ['experimental'], + experiment: { mode: 'auto' }, + }, [NEW_SESSION_WELCOME_PHRASES_SETTING]: { type: 'boolean', default: false, diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 2ef03a52546d61..c1f8a7dc54606f 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -38,6 +38,7 @@ import { ChatContentMarkdownRenderer } from '../../../../workbench/contrib/chat/ import { chatPersistentContentVisibleClass, ChatWidget } from '../../../../workbench/contrib/chat/browser/widget/chatWidget.js'; import { SESSIONS_CHAT_CONTENT_HORIZONTAL_PADDING } from '../../../../workbench/contrib/chat/browser/widget/chatOptions.js'; import { setModelPreservingInputTypedWhileLoading } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatOpen, IChatOpenDiagnosticsService } from '../../../../workbench/contrib/chat/common/chatOpenDiagnosticsService.js'; import { IChatModelReference, IChatService, ResponseModelState } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { isChatTranscriptContextVariableEntry, IChatRequestTranscriptContextVariableEntry, IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatModel, IChatModel, IInputModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; @@ -278,6 +279,7 @@ export class ChatView extends AbstractChatView { /** Tracks the currently loaded chat resource to avoid redundant reloads. */ private _currentChatResource: URI | undefined; + private readonly _openDiagnostics = this._register(new MutableDisposable()); /** * The bound chat whose content provider was unregistered. Its model stays wired to the * provider that went away, so a replacement registering for the same session type never @@ -330,6 +332,7 @@ export class ChatView extends AbstractChatView { @INewChatVoiceTargetService private readonly newChatVoiceTargetService: INewChatVoiceTargetService, @ISessionsChatViewStateService private readonly viewStateService: ISessionsChatViewStateService, @ISessionOpenTelemetryService private readonly sessionOpenTelemetryService: ISessionOpenTelemetryService, + @IChatOpenDiagnosticsService private readonly chatOpenDiagnosticsService: IChatOpenDiagnosticsService, @ISessionsChatBackgroundService private readonly chatBackgroundService: ISessionsChatBackgroundService, @INotificationService private readonly notificationService: INotificationService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @@ -828,13 +831,50 @@ export class ChatView extends AbstractChatView { const cts = new CancellationTokenSource(); this._loadCts.value = cts; const token = cts.token; + const open = this._openDiagnostics.value = this.chatOpenDiagnosticsService.startOpen('agentsWindow', resource, token); + open.mark('selectionHandled'); this._setLoading(true); // Capture the input draft before the load window opens so text typed // during loading is preserved when the model binds. See #325323. const inputBeforeLoad = this._widget.getInput(); - const loadPromise = this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, token, 'ChatView').then(ref => { + const loadPromise = (async (): Promise => { + let ref: IChatModelReference | undefined; + try { + ref = await this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, token, 'ChatView'); + } catch (err) { + if (!isEqual(this._currentChatResource, resource) || this._loadCts.value !== cts) { + return false; + } + if (token.isCancellationRequested) { + this.logService.trace(`[ChatView] setChat cancelled uri=${resource.toString()}`); + this._setLoading(false); + return false; + } + this.logService.error(`[ChatView] Failed to load chat model for chat uri=${resource.toString()}`, err); + this._loadFailure.set({ + resource, + status: { + kind: 'history', + message: localize('chatView.historyLoadFailed', "Couldn't load this conversation."), + action: { + label: localize('chatView.retryHistoryLoad', "Retry"), + run: async () => { + if (!token.isCancellationRequested && this._loadCts.value === cts && isEqual(this._currentChatResource, resource)) { + await this._loadChat(resource, this._currentSessionObs.get(), { inputModelToPreserve: options?.inputModelToPreserve }); + } + }, + }, + }, + }, undefined); + this._setLoading(false); + open.fail(err); + if (session) { + this.sessionOpenTelemetryService.modelBindFailed(session.resource, resource); + } + return false; + } const isCurrentChat = isEqual(this._currentChatResource, resource); const isCurrentLoad = this._loadCts.value === cts; if (token.isCancellationRequested || !ref || !isCurrentChat || !isCurrentLoad) { @@ -844,9 +884,12 @@ export class ChatView extends AbstractChatView { } if (isCurrentChat && isCurrentLoad) { this._setLoading(false); + if (!token.isCancellationRequested && !ref) { + open.fail(new Error('The chat model could not be acquired')); + } } this.logService.trace(`[ChatView] setChat abandoned uri=${resource.toString()}`); - return; + return false; } this.logService.trace(`[ChatView] setChat model loaded uri=${resource.toString()}`); const inputState = options?.inputModelToPreserve ? this._widget.getInputState() : undefined; @@ -890,43 +933,29 @@ export class ChatView extends AbstractChatView { // inner widget is fully attached to the loaded model. this.element.dataset.boundChatResource = resource.toString(); this.logService.trace(`[ChatView] setChat done uri=${resource.toString()}`); - }, err => { - if (!isEqual(this._currentChatResource, resource) || this._loadCts.value !== cts) { - return; - } - if (token.isCancellationRequested) { - this.logService.trace(`[ChatView] setChat cancelled uri=${resource.toString()}`); - this._setLoading(false); - return; - } - this.logService.error(`[ChatView] Failed to load chat model for chat uri=${resource.toString()}`, err); - this._loadFailure.set({ - resource, - status: { - kind: 'history', - message: localize('chatView.historyLoadFailed', "Couldn't load this conversation."), - action: { - label: localize('chatView.retryHistoryLoad', "Retry"), - run: async () => { - if (!token.isCancellationRequested && this._loadCts.value === cts && isEqual(this._currentChatResource, resource)) { - await this._loadChat(resource, this._currentSessionObs.get(), { inputModelToPreserve: options?.inputModelToPreserve }); - } - }, - }, - }, - }, undefined); - this._setLoading(false); - if (session) { - this.sessionOpenTelemetryService.modelBindFailed(session.resource, resource); + return true; + })(); + + const loaded = (async () => { + try { + const bound = await loadPromise; + if (bound && !token.isCancellationRequested) { + open.complete(this._widget.visible && !this.element.ownerDocument.hidden); + } + } catch (err) { + open.fail(err); + throw err; + } finally { + open.end(); } - }); + })(); // Surface progress on this leaf's own bar while the chat model loads, // matching how each editor group shows progress independently. The short // delay avoids flashing the bar for fast cached loads. - this.showProgressWhile(loadPromise, ChatView.LOADING_FEEDBACK_DELAY_MS); + this.showProgressWhile(loaded, ChatView.LOADING_FEEDBACK_DELAY_MS); this._layoutChatWidget(); - return loadPromise; + return loaded; } private _saveCurrentViewState(): void { diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 64cbc1583a72cb..45f5674ed9f4ee 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -116,21 +116,42 @@ border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; } -/* Getting-started tip demoted below the input in the experimental composer, matching - the placement of the new-session migration notice. Hidden by the presenter until a - tip is showing, so it reserves no space otherwise. */ +.new-session-below-input-surfaces { + position: absolute; + top: 100%; + left: 0; + right: 0; + width: 100%; + margin-top: var(--vscode-spacing-size200); + overflow-y: auto; +} + +/* Getting-started tip demoted below the input in the experimental composer. */ .experimental-new-session-composer .new-session-getting-started-tip-below { width: 100%; - max-width: 800px; /* Extra top gap so the tip's hover actions, which float above its top edge, clear the input. */ - margin: var(--vscode-spacing-size280) auto 0; + margin-top: var(--vscode-spacing-size160); } /* As a standalone card (not attached above the input) the tip takes a full border and rounds every corner, unlike the input-attached notice that squares its joined bottom. */ .new-session-getting-started-tip-below .chat-input-notice { - border-bottom: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); - border-radius: var(--vscode-cornerRadius-large); + border: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder, color-mix(in srgb, var(--vscode-foreground) 10%, transparent)); + border-radius: var(--vscode-cornerRadius-medium); + background-color: color-mix(in srgb, var(--vscode-foreground) 3%, var(--session-view-background, var(--vscode-editor-background))); +} + +:is(.hc-black, .hc-light) .new-session-getting-started-tip-below .chat-input-notice { + border-color: var(--vscode-contrastBorder); + background-color: var(--vscode-editor-background); +} + +.agent-sessions-workbench.phone-layout .new-session-below-input-surfaces { + position: static; + order: 1; + flex: 0 1 auto; + min-height: 0; + margin: 0 0 var(--vscode-spacing-size200); } .new-chat-session-options .new-chat-session-options-details { diff --git a/src/vs/sessions/contrib/chat/browser/media/newChatConnectorRecommendations.css b/src/vs/sessions/contrib/chat/browser/media/newChatConnectorRecommendations.css new file mode 100644 index 00000000000000..24f7c344f8852c --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/newChatConnectorRecommendations.css @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.new-session-connector-recommendations { + width: 100%; + color: var(--vscode-foreground); +} + +.new-session-connector-recommendations[hidden], +.new-chat-widget-content.no-agent-host .new-session-connector-recommendations { + display: none; +} + +.new-session-connector-recommendation-cards { + display: flex; + gap: var(--vscode-spacing-size80); + width: 100%; + padding: var(--vscode-spacing-size20); + box-sizing: border-box; + overflow-x: auto; + scrollbar-width: thin; +} + +.new-session-connector-recommendation-card { + position: relative; + flex: 1 0 0; + min-width: 180px; +} + +.monaco-button.new-session-connector-recommendation-card-primary { + display: grid; + grid-template-columns: var(--vscode-spacing-size320) minmax(0, 1fr); + column-gap: var(--vscode-spacing-size100); + align-items: start; + width: 100%; + min-height: 100%; + padding: var(--vscode-spacing-size60); + padding-right: var(--vscode-spacing-size320); + box-sizing: border-box; + border: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder, color-mix(in srgb, var(--vscode-foreground) 10%, transparent)); + border-radius: var(--vscode-cornerRadius-medium); + background-color: color-mix(in srgb, var(--vscode-foreground) 3%, var(--session-view-background, var(--vscode-editor-background))); + color: var(--vscode-foreground); + text-align: left; +} + +.monaco-button.new-session-connector-recommendation-card-primary:hover { + border-color: var(--vscode-focusBorder); + background-color: color-mix(in srgb, var(--vscode-focusBorder) 8%, var(--vscode-editorWidget-background)); +} + +.monaco-button.new-session-connector-recommendation-card-primary:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.new-session-connector-recommendation-icon { + display: flex; + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size320); + height: var(--vscode-spacing-size320); + border-radius: var(--vscode-cornerRadius-medium); + overflow: hidden; + background-color: var(--vscode-editor-background); +} + +.new-session-connector-recommendation-icon img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.new-session-connector-recommendation-icon > .codicon { + font-size: var(--vscode-codiconFontSize); + color: var(--vscode-descriptionForeground); +} + +.new-session-connector-recommendation-content { + min-width: 0; +} + +.new-session-connector-recommendation-name { + overflow: hidden; + color: var(--vscode-foreground); + font-size: var(--vscode-fontSize-label1); + font-weight: var(--vscode-fontWeight-semiBold); + text-overflow: ellipsis; + white-space: nowrap; +} + +.new-session-connector-recommendation-description { + margin-top: var(--vscode-spacing-size20); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label2); + line-height: 1.2; +} + +.monaco-button.new-session-connector-recommendation-dismiss { + position: absolute; + top: var(--vscode-spacing-size60); + right: var(--vscode-spacing-size60); + display: flex; + align-items: center; + justify-content: center; + width: var(--vscode-spacing-size240); + height: var(--vscode-spacing-size240); + padding: 0; + border: 0; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + color: var(--vscode-icon-foreground); +} + +.monaco-button.new-session-connector-recommendation-dismiss:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.monaco-button.new-session-connector-recommendation-dismiss:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +:is(.hc-black, .hc-light) .monaco-button.new-session-connector-recommendation-card-primary { + border-color: var(--vscode-contrastBorder); + background-color: var(--vscode-editor-background); +} diff --git a/src/vs/sessions/contrib/chat/browser/media/newChatMigrationNotice.css b/src/vs/sessions/contrib/chat/browser/media/newChatMigrationNotice.css index 636b2ba79dc361..d113a0dd3cb79d 100644 --- a/src/vs/sessions/contrib/chat/browser/media/newChatMigrationNotice.css +++ b/src/vs/sessions/contrib/chat/browser/media/newChatMigrationNotice.css @@ -4,21 +4,17 @@ *--------------------------------------------------------------------------------------------*/ .chat-customization-migration-notice.new-chat-migration-notice { - /* Keep the composer centered independently of the notice's height. */ - position: absolute; - top: 100%; - left: 0; - right: 0; - width: auto; - margin-top: var(--vscode-spacing-size160); - /* The composer floats over the chat background wallpaper, which the shared - notice's translucent fill lets read straight through. Composite the same - tint over the opaque session background so the notice stays a legible chip. */ + width: 100%; + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); + border-color: var(--vscode-contrastBorder, color-mix(in srgb, var(--vscode-foreground) 10%, transparent)); + border-radius: var(--vscode-cornerRadius-medium); background-color: color-mix(in srgb, var(--vscode-foreground) 3%, var(--session-view-background, var(--vscode-editor-background))); + line-height: normal; } .hc-black .chat-customization-migration-notice.new-chat-migration-notice, .hc-light .chat-customization-migration-notice.new-chat-migration-notice { + border-color: var(--vscode-contrastBorder); background-color: var(--vscode-editor-background); } diff --git a/src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts b/src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts new file mode 100644 index 00000000000000..310b18d7ef38e1 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newChatConnectorRecommendations.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/newChatConnectorRecommendations.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { Button, IButtonOptions } from '../../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources } from '../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { logSettingExperimentTrigger } from '../../../../platform/telemetry/common/experimentTrigger.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { mcpServerIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { renderCustomizationMarketplaceIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationPresentation.js'; +import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING } from '../common/constants.js'; + +interface IConnectorRecommendation { + readonly id: string; + readonly name: string; + readonly description: string; + readonly icon: URI | undefined; +} + +interface IConnectorRecommendationCard { + readonly definition: IConnectorRecommendation; + readonly element: HTMLElement; + readonly primary: Button; + readonly icon: HTMLElement; + readonly iconDisposables: DisposableStore; +} + +const quietButtonStyles: IButtonOptions = { + ...defaultButtonStyles, + buttonBackground: undefined, + buttonHoverBackground: undefined, + buttonForeground: undefined, + buttonBorder: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + secondary: true, +}; + +export class NewChatConnectorRecommendations extends Disposable { + + readonly element = dom.$('.new-session-connector-recommendations'); + private readonly cards = new Map(); + private readonly dismissed = new Set(); + + constructor( + private readonly focusInput: () => void, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ICommandService private readonly commandService: ICommandService, + @ILogService private readonly logService: ILogService, + @INotificationService private readonly notificationService: INotificationService, + @IProductService productService: IProductService, + @IThemeService private readonly themeService: IThemeService, + @ITelemetryService telemetryService: ITelemetryService, + ) { + super(); + this._register(toDisposable(() => this.element.remove())); + if (isConfigurationValueFromDefault(this.configurationService, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING)) { + logSettingExperimentTrigger(telemetryService, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING); + } + + this.element.setAttribute('role', 'group'); + this.element.setAttribute('aria-label', localize('newSession.connectorRecommendations.ariaLabel', "Recommended connectors")); + const cards = dom.append(this.element, dom.$('.new-session-connector-recommendation-cards')); + for (const [id, recommendation] of Object.entries(productService.connectorRecommendations ?? {})) { + this.renderCard(cards, { + id, + name: recommendation.name, + description: recommendation.description, + icon: parseIconUri(recommendation.iconUrl), + }); + } + + this.updateVisibility(); + this._register(this.themeService.onDidColorThemeChange(() => this.updateCardIcons())); + this._register(this.configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(CustomizationMarketplaceConfiguration.MarketplaceEnabled) + || event.affectsConfiguration(CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled) + || event.affectsConfiguration(NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING)) { + this.updateVisibility(); + } + })); + } + + private renderCard(container: HTMLElement, definition: IConnectorRecommendation): void { + const ariaLabel = localize('newSession.connectorRecommendations.open', "Review the {0} connector. {1}", definition.name, definition.description); + const card = dom.append(container, dom.$('.new-session-connector-recommendation-card')); + card.dataset.connectorId = definition.id; + const primary = this._register(new Button(card, { ...quietButtonStyles, ariaLabel })); + primary.element.classList.add('new-session-connector-recommendation-card-primary'); + const icon = dom.append(primary.element, dom.$('.new-session-connector-recommendation-icon')); + const content = dom.append(primary.element, dom.$('.new-session-connector-recommendation-content')); + dom.append(content, dom.$('.new-session-connector-recommendation-name', undefined, definition.name)); + dom.append(content, dom.$('.new-session-connector-recommendation-description', undefined, definition.description)); + this._register(primary.onDidClick(() => this.openConnector(definition))); + const dismiss = this._register(new Button(card, { + ...quietButtonStyles, + ariaLabel: localize('newSession.connectorRecommendations.dismissOne', "Dismiss {0} Connector Recommendation for This Session", definition.name), + })); + dismiss.element.classList.add('new-session-connector-recommendation-dismiss'); + dismiss.icon = Codicon.close; + this._register(dismiss.onDidClick(() => this.dismissCard(definition.id))); + const iconDisposables = this._register(new DisposableStore()); + const recommendationCard = { definition, element: card, primary, icon, iconDisposables }; + this.cards.set(definition.id, recommendationCard); + this.renderCardIcon(recommendationCard); + } + + private dismissCard(id: string): void { + const cards = [...this.cards.values()]; + const dismissedIndex = cards.findIndex(card => card.definition.id === id); + this.dismissed.add(id); + this.updateVisibility(); + const nextCard = cards.slice(dismissedIndex + 1).find(card => !this.dismissed.has(card.definition.id)); + const previousCard = cards.slice(0, dismissedIndex).reverse().find(card => !this.dismissed.has(card.definition.id)); + const focusTarget = nextCard ?? previousCard; + if (focusTarget) { + focusTarget.primary.focus(); + } else { + this.focusInput(); + } + } + + private async openConnector(definition: IConnectorRecommendation): Promise { + try { + await this.commandService.executeCommand(AICustomizationManagementCommands.OpenMarketplace, { + section: AICustomizationManagementSection.McpServers, + sourceId: CustomizationMarketplaceSources.CopilotConnectors.id, + connectorName: definition.id, + }); + } catch (error) { + this.logService.error(`Failed to open the ${definition.name} Copilot connector`, error); + this.notificationService.error(localize('newSession.connectorRecommendations.openError', "Unable to open the {0} connector.", definition.name)); + } + } + + private updateVisibility(): void { + for (const card of this.cards.values()) { + card.element.hidden = this.dismissed.has(card.definition.id); + } + this.element.hidden = this.cards.size === 0 + || this.dismissed.size === this.cards.size + || this.configurationService.getValue(NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING) !== true + || this.configurationService.getValue(CustomizationMarketplaceConfiguration.MarketplaceEnabled) !== true + || this.configurationService.getValue(CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled) !== true; + } + + private updateCardIcons(): void { + for (const card of this.cards.values()) { + this.renderCardIcon(card); + } + } + + private renderCardIcon(card: IConnectorRecommendationCard): void { + card.iconDisposables.clear(); + renderCustomizationMarketplaceIcon( + card.icon, + mcpServerIcon, + card.definition.icon, + this.themeService.getColorTheme().type, + card.iconDisposables, + ); + } +} + +function parseIconUri(iconUrl: string): URI | undefined { + try { + const icon = URI.parse(iconUrl); + return icon.scheme === 'https' || icon.scheme === 'data' ? icon : undefined; + } catch { + return undefined; + } +} + +function isConfigurationValueFromDefault(configurationService: IConfigurationService, setting: string): boolean { + const inspected = configurationService.inspect(setting); + return inspected.applicationValue === undefined + && inspected.userValue === undefined + && inspected.userLocalValue === undefined + && inspected.userRemoteValue === undefined + && inspected.workspaceValue === undefined + && inspected.workspaceFolderValue === undefined + && inspected.memoryValue === undefined + && inspected.policyValue === undefined; +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 39948755ba5e13..088e0418272559 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -59,7 +59,7 @@ import { buildNewSessionPrompt } from '../../agentFeedback/browser/agentFeedback import { SessionInputBannerWidget } from '../../sessionInputBanners/browser/sessionInputBannerWidget.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { ChatInputTipPresenter } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputTipPresenter.js'; -import { chatInputStackClass, ChatInputStackSlot, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; +import { chatInputStackClass, chatInputStackSlotClass, chatInputSurfaceStackClass, ChatInputStackSlot, setChatInputStackSlot } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; import { IChatPetService } from '../../../../workbench/contrib/chat/browser/chatPetService.js'; import { IChatTipService } from '../../../../workbench/contrib/chat/browser/chatTipService.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; @@ -82,6 +82,7 @@ import { isAutoModel, isHydraFusionModel } from '../../../../workbench/contrib/c import { IAgentsWindowDraft } from '../../../../platform/window/common/window.js'; import { reviveChatDraft } from '../../../../workbench/contrib/chat/common/attachments/chatDraft.js'; import { NewChatMigrationNotice } from './newChatMigrationNotice.js'; +import { NewChatConnectorRecommendations } from './newChatConnectorRecommendations.js'; import { FOCUS_NEW_SESSION_HARNESS_PICKER_WHEN, FOCUS_NEW_SESSION_WORKSPACE_PICKER_WHEN } from './newChatPickerKeybinding.js'; import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; @@ -768,7 +769,25 @@ export class NewChatWidget extends Disposable { : undefined ); })); - this._register(this.instantiationService.createInstance(NewChatMigrationNotice, chatWidgetContent, this._session, () => this.focusInput())); + const belowInputSurfaces = dom.append(chatWidgetContent, dom.$(`.new-session-below-input-surfaces.${chatInputSurfaceStackClass}`)); + const updateBelowInputSurfacesHeight = () => { + if (chatWidgetContainer.closest('.phone-layout')) { + belowInputSurfaces.style.removeProperty('max-height'); + return; + } + const availableHeight = chatWidgetContainer.getBoundingClientRect().bottom - belowInputSurfaces.getBoundingClientRect().top; + belowInputSurfaces.style.maxHeight = `${Math.max(0, availableHeight)}px`; + }; + const belowInputSurfacesResizeObserver = this._register(new dom.DisposableResizeObserver( + 'NewChatWidget.belowInputSurfaces', + updateBelowInputSurfacesHeight, + dom.getWindow(belowInputSurfaces), + )); + this._register(belowInputSurfacesResizeObserver.observe(chatWidgetContainer)); + this._register(belowInputSurfacesResizeObserver.observe(chatWidgetContent)); + this._register(toDisposable(() => belowInputSurfaces.style.removeProperty('max-height'))); + const connectorRecommendations = this._register(this.instantiationService.createInstance(NewChatConnectorRecommendations, () => this.focusInput())); + belowInputSurfaces.appendChild(connectorRecommendations.element); // In the experimental composer the getting-started tip is demoted to a standalone // notice below the input (like the migration notice) so it never sits between the @@ -777,13 +796,17 @@ export class NewChatWidget extends Disposable { // experimental layout (which requires the unified workspace picker) moves the tip without // recreating the presenter. const inputTipSlot = this._newChatInput.gettingStartedTipContainerElement; - // The below host carries the tip container class so the shared tip styling applies in the - // demoted position exactly as it does in the input's own slot. - const tipBelowHost = dom.append(chatWidgetContent, dom.$('.new-session-getting-started-tip-below.chat-getting-started-tip-container')); - const chatTipContainer = dom.$('.new-session-getting-started-tip'); + const chatTipContainer = dom.$(`.new-session-getting-started-tip.chat-getting-started-tip-container.${chatInputStackSlotClass}`); this._register(autorun(reader => { - (this._useExperimentalComposerLayout.read(reader) ? tipBelowHost : inputTipSlot)?.appendChild(chatTipContainer); + const showBelowInput = this._useExperimentalComposerLayout.read(reader); + chatTipContainer.classList.toggle('new-session-getting-started-tip-below', showBelowInput); + if (showBelowInput) { + connectorRecommendations.element.insertAdjacentElement('afterend', chatTipContainer); + } else { + inputTipSlot?.appendChild(chatTipContainer); + } })); + this._register(this.instantiationService.createInstance(NewChatMigrationNotice, belowInputSurfaces, this._session, () => this.focusInput())); this._chatTipPresenter.value = this.instantiationService.createInstance( ChatInputTipPresenter, { diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index c721c207c11526..e379b972852c92 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -31,13 +31,14 @@ import { isPhoneLayout } from '../../../browser/parts/mobile/mobileLayout.js'; import { ISessionComparisonService } from '../../../services/sessions/common/sessionComparison.js'; import { buildSessionComparisonAccessibleContent, isJudgeSession, SessionComparisonResultFocused } from './sessionComparisonResult.js'; import { SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SessionsChatTabsMode } from '../../../common/sessionConfig.js'; -import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; +import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; import { IAgentHostFilterService } from '../../../services/agentHostFilter/common/agentHostFilter.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { RemoteAgentHostAutoConnectSettingId, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { AGENT_SESSIONS_RESPONSE_SELECTION_MENU_SETTING } from './responseSelectionSideChatController.js'; import { areDevContainerSamplesEnabled } from '../../../common/devContainerAgentHostService.js'; +import { CustomizationMarketplaceConfiguration } from '../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementation { readonly priority = 120; readonly name = 'sessionsChat'; @@ -137,6 +138,11 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.compareAgentsInactivePaneNotification', "When a question tool needs attention in an inactive visible pane, the confirmation notification setting can show an operating system notification even while another pane in the Agents window is active.")); } content.push(localize('sessionsChat.migrations', "When agent customizations need an update, a notice below the new-session input shows how many need attention. Use Tab to reach Review Migrations and open the Migrations page. Dismiss Migration Notice for This Workspace hides the notice for that workspace, including after restarting, and returns focus to the input.")); + if (configurationService.getValue(NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING) === true + && configurationService.getValue(CustomizationMarketplaceConfiguration.MarketplaceEnabled) === true + && configurationService.getValue(CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled) === true) { + content.push(localize('sessionsChat.connectorRecommendations', "Near the new-session input, three connector cards offer Linear, Notion, and WorkIQ. On phones the cards appear above the input; on wider layouts they appear below it. Use Tab and Shift+Tab to move between each card and its dismiss button. Press Enter or Space on a card to review that connector in the Copilot Connectors marketplace. Dismissing a recommendation moves focus to the next card; dismissing the last card returns focus to the input.")); + } if (configurationService.getValue(UNIFIED_WORKSPACE_PICKER_SETTING)) { content.push(localize('sessionsChat.newSessionPickers', "In a new-session composer, open and focus the workspace picker{0} or the harness picker{1}. Focus either picker control and open its context menu{2} to configure its keybinding.", ``, ``, '')); } diff --git a/src/vs/sessions/contrib/chat/common/constants.ts b/src/vs/sessions/contrib/chat/common/constants.ts index 0a047ff0a74b2a..7adb9959170577 100644 --- a/src/vs/sessions/contrib/chat/common/constants.ts +++ b/src/vs/sessions/contrib/chat/common/constants.ts @@ -17,3 +17,4 @@ export const COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING = 'sessions.chat.exper export const NEW_SESSION_WELCOME_PHRASES_SETTING = 'sessions.chat.experimental.welcomePhrases'; export const NEW_SESSION_WELCOME_NAME_SETTING = 'sessions.chat.experimental.welcomeName'; export const NEW_SESSION_WELCOME_MESSAGES_SETTING = 'sessions.chat.experimental.welcomeMessages'; +export const NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING = 'sessions.chat.experimental.connectorRecommendations'; diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index e2bae31f45cf44..6a15cfac5ff8b6 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -34,6 +34,7 @@ import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../../../../workben import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.js'; import { renderChatInputPickerSplit } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { isChatInputStackSlotShowing } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputStack.js'; +import { ChatOpenDiagnosticsService } from '../../../../../workbench/contrib/chat/common/chatOpenDiagnosticsService.js'; import { IChatModelReference, ResponseModelState } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { ChatModel, ChatResponseModel, IChatModel, IChatModelInputState, IChatRequestModel, IInputModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; @@ -732,6 +733,8 @@ suite('Sessions - Chat View', () => { _modelRef: modelRef, _preparationModel: { value: undefined }, _loadCts: loadCts, + _openDiagnostics: disposables.add(new MutableDisposable()), + chatOpenDiagnosticsService: disposables.add(new ChatOpenDiagnosticsService(new NullLogService())), chatService: { onDidDisposeSession: onDidDisposeSession.event }, logService: new class extends NullLogService { override warn(message: string): void { warnings.push(message); } @@ -1015,6 +1018,8 @@ suite('Sessions - Chat View', () => { _preparationModel: { value: undefined }, _modelRef: modelRef, _loadCts: loadCts, + _openDiagnostics: disposables.add(new MutableDisposable()), + chatOpenDiagnosticsService: disposables.add(new ChatOpenDiagnosticsService(new NullLogService())), element: dom.$('div'), chatService: { acquireOrLoadSession: () => { @@ -1258,6 +1263,8 @@ suite('Sessions - Chat View', () => { const viewStates = new Map(); const boundModels: (URI | undefined)[] = []; const widgetState: { input: string; attachments: IChatModelInputState['attachments']; scroll: number; model: IChatModel | undefined } = { input: '', attachments: [], scroll: 0, model: undefined }; + const openOutcomes: string[] = []; + const widgetVisibility = { visible: true }; const modelRef = disposables.add(new MutableDisposable()); const loadCts = disposables.add(new MutableDisposable()); const view: { @@ -1284,6 +1291,7 @@ suite('Sessions - Chat View', () => { hasVisibleTranscriptContent: observableValue('transcript', false), _modelRef: modelRef, _loadCts: loadCts, + _openDiagnostics: disposables.add(new MutableDisposable()), element: dom.$('div'), configurationService: { getValue: () => undefined }, chatService: { @@ -1294,6 +1302,11 @@ suite('Sessions - Chat View', () => { }, }, logService: new NullLogService(), + chatOpenDiagnosticsService: disposables.add(new ChatOpenDiagnosticsService(new class extends NullLogService { + override info(message: string): void { + openOutcomes.push(message.split(' ')[2]); + } + }())), viewStateService: { get: (resource: URI) => viewStates.get(resource.toString()), set: (resource: URI, state: { scroll: number }) => viewStates.set(resource.toString(), state), @@ -1303,6 +1316,7 @@ suite('Sessions - Chat View', () => { _layoutChatWidget: () => { }, showProgressWhile: (promise: Promise) => completions.push(promise), _widget: { + get visible() { return widgetVisibility.visible; }, get viewModel() { return widgetState.model && { sessionResource: widgetState.model.sessionResource }; }, getInput: () => widgetState.input, setInput: (input: string) => { widgetState.input = input; }, @@ -1343,7 +1357,11 @@ suite('Sessions - Chat View', () => { widgetState.scroll = scroll; }; const snapshot = () => ({ input: widgetState.input, attachments: widgetState.attachments.map(a => a.name), scroll: widgetState.scroll }); - return { view, createChat, complete, compose, snapshot, historyKeys, disposedReferences, boundModels }; + const fail = async (index: number, error: Error) => { + await requests[index].error(error); + await completions[index]; + }; + return { view, createChat, complete, fail, compose, snapshot, historyKeys, disposedReferences, boundModels, openOutcomes, widgetVisibility }; } suite('switching chats on one view', () => { @@ -1375,6 +1393,58 @@ suite('Sessions - Chat View', () => { }); }); + suite('chat open diagnostics', () => { + test('still completes when the same chat is set again while it loads and after it is bound', async () => { + const { view, createChat, complete, openOutcomes } = createSwitchingView(); + const chatA = createChat(resourceA); + + view.setChat(chatA, 'key-a'); + view.setChat(chatA, 'key-a'); + const duringLoad = [...openOutcomes]; + await complete(0, resourceA); + const afterLoad = [...openOutcomes]; + view.setChat(chatA, 'key-a'); + + assert.deepStrictEqual({ duringLoad, afterLoad, final: openOutcomes }, { duringLoad: [], afterLoad: ['completed'], final: ['completed'] }); + }); + + test('cancels diagnostics when another chat replaces the one being opened', async () => { + const { view, createChat, complete, openOutcomes } = createSwitchingView(); + view.setChat(createChat(resourceA), 'key-a'); + view.setChat(createChat(resourceB), 'key-b'); + await complete(0, resourceA); + await complete(1, resourceB); + + assert.deepStrictEqual(openOutcomes, ['cancelled', 'completed']); + }); + + test('fails when the chat model cannot be loaded', async () => { + const { view, createChat, fail, openOutcomes } = createSwitchingView(); + view.setChat(createChat(resourceA), 'key-a'); + await fail(0, new Error('no model')); + + assert.deepStrictEqual(openOutcomes, ['failed']); + }); + + test('a chat loaded while the view is hidden is logged as hidden', async () => { + const { view, createChat, complete, openOutcomes, widgetVisibility } = createSwitchingView(); + widgetVisibility.visible = false; + view.setChat(createChat(resourceA), 'key-a'); + await complete(0, resourceA); + + assert.deepStrictEqual(openOutcomes, ['hidden']); + }); + + test('an open ends as cancelled when the view is disposed while it loads', async () => { + const { view, createChat, complete, openOutcomes } = createSwitchingView(); + view.setChat(createChat(resourceA), 'key-a'); + view.dispose(); + await complete(0, resourceA); + + assert.deepStrictEqual(openOutcomes, ['cancelled']); + }); + }); + test('does not bind a superseded chat when the view is disposed mid-switch', async () => { const { view, createChat, complete, disposedReferences, boundModels } = createSwitchingView(); view.setChat(createChat(resourceA), 'key-a'); @@ -3750,6 +3820,8 @@ suite('Sessions - Chat View', () => { _loadedChatResource: observableValue('loaded', undefined), _loadFailure: observableValue('loadFailure', undefined), _loadCts: viewStore.add(new MutableDisposable()), + _openDiagnostics: viewStore.add(new MutableDisposable()), + chatOpenDiagnosticsService: viewStore.add(new ChatOpenDiagnosticsService(new NullLogService())), _modelRef: viewStore.add(new MutableDisposable()), _preparationModel: { value: undefined }, isLoadingTranscript: observableValue('loading', false), diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts new file mode 100644 index 00000000000000..e3e7d33b952881 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/newChatConnectorRecommendations.test.ts @@ -0,0 +1,213 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { type IConfigurationChangeEvent, type IConfigurationOverrides, IConfigurationService, type IConfigurationValue } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { TestExperimentTriggerTelemetryService } from '../../../../../platform/telemetry/test/common/experimentTriggerTestUtils.js'; +import { mcpServerIcon } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { NewChatConnectorRecommendations } from '../../browser/newChatConnectorRecommendations.js'; +import { NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING } from '../../common/constants.js'; + +const connectorRecommendations: NonNullable = { + linear: { name: 'Linear', description: 'Plan and track product work.', iconUrl: 'https://example.com/linear.png' }, + notion: { name: 'Notion', description: 'Search team knowledge and documentation.', iconUrl: 'https://example.com/notion.png' }, + 'workiq-mail': { name: 'WorkIQ', description: 'Bring Microsoft 365 work context into chats.', iconUrl: 'https://example.com/workiq.png' }, +}; + +suite('NewChatConnectorRecommendations', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function setup(marketplaceEnabled: boolean, connectorsEnabled: boolean, fromDefault = true, productRecommendations: IProductService['connectorRecommendations'] | null = connectorRecommendations, recommendationsEnabled = connectorsEnabled) { + const instantiationService = store.add(new TestInstantiationService()); + const configurationService = new class extends TestConfigurationService { + override inspect(key: string, overrides?: IConfigurationOverrides): IConfigurationValue { + if (!fromDefault) { + return super.inspect(key, overrides); + } + const value = this.getValue(key, overrides); + return { value, defaultValue: value, overrideIdentifiers: [] }; + } + }({ + [NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING]: recommendationsEnabled, + [CustomizationMarketplaceConfiguration.MarketplaceEnabled]: marketplaceEnabled, + [CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled]: connectorsEnabled, + }); + store.add(configurationService.onDidChangeConfigurationEmitter); + const commands: { id: string; args: readonly unknown[] }[] = []; + const errors: unknown[][] = []; + const notifications: string[] = []; + const telemetryService = new TestExperimentTriggerTelemetryService(); + let focusCount = 0; + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ICommandService, { + executeCommand: async (id: string, ...args: unknown[]) => { + commands.push({ id, args }); + }, + }); + instantiationService.stub(ILogService, { + error: (...args: unknown[]) => errors.push(args), + warn: (...args: unknown[]) => errors.push(args), + }); + instantiationService.stub(INotificationService, new class extends mock() { + override error(message: string) { notifications.push(message); return undefined; } + }()); + instantiationService.stub(IProductService, { _serviceBrand: undefined, connectorRecommendations: productRecommendations ?? undefined }); + instantiationService.stub(IThemeService, new TestThemeService()); + instantiationService.stub(ITelemetryService, telemetryService); + const recommendations = store.add(instantiationService.createInstance(NewChatConnectorRecommendations, () => focusCount++)); + document.body.appendChild(recommendations.element); + return { recommendations, configurationService, commands, errors, notifications, telemetryService, get focusCount() { return focusCount; } }; + } + + test('triggers the recommendation experiment before deciding visibility', () => { + const triggers = [ + setup(false, false).telemetryService.triggers, + setup(true, true).telemetryService.triggers, + setup(true, true, false).telemetryService.triggers, + ]; + + assert.deepStrictEqual(triggers, [ + ['config.sessions.chat.experimental.connectorRecommendations'], + ['config.sessions.chat.experimental.connectorRecommendations'], + [], + ]); + }); + + test('requires the dedicated recommendation setting in addition to connector capability', () => { + const fixture = setup(true, true, true, connectorRecommendations, false); + + assert.deepStrictEqual({ + hidden: fixture.recommendations.element.hidden, + triggers: fixture.telemetryService.triggers, + }, { + hidden: true, + triggers: ['config.sessions.chat.experimental.connectorRecommendations'], + }); + }); + + test('renders product recommendations and opens the selected connector detail', async () => { + const fixture = setup(true, true); + await Promise.resolve(); + await Promise.resolve(); + const cards = [...fixture.recommendations.element.querySelectorAll('.new-session-connector-recommendation-card')]; + cards[2].querySelector('.new-session-connector-recommendation-card-primary')!.click(); + await Promise.resolve(); + + assert.deepStrictEqual({ + hidden: fixture.recommendations.element.hidden, + role: fixture.recommendations.element.getAttribute('role'), + names: cards.map(card => card.querySelector('.new-session-connector-recommendation-name')?.textContent), + descriptions: cards.map(card => card.querySelector('.new-session-connector-recommendation-description')?.textContent), + icons: cards.map(card => card.querySelector('img')?.src), + commands: fixture.commands, + errors: fixture.errors, + triggers: fixture.telemetryService.triggers, + }, { + hidden: false, + role: 'group', + names: ['Linear', 'Notion', 'WorkIQ'], + descriptions: [ + 'Plan and track product work.', + 'Search team knowledge and documentation.', + 'Bring Microsoft 365 work context into chats.', + ], + icons: ['https://example.com/linear.png', 'https://example.com/notion.png', 'https://example.com/workiq.png'], + commands: [{ + id: AICustomizationManagementCommands.OpenMarketplace, + args: [{ + section: AICustomizationManagementSection.McpServers, + sourceId: CustomizationMarketplaceSources.CopilotConnectors.id, + connectorName: 'workiq-mail', + }], + }], + errors: [], + triggers: ['config.sessions.chat.experimental.connectorRecommendations'], + }); + }); + + test('tracks enablement and dismisses individual cards for the widget lifetime', async () => { + const fixture = setup(true, false, true, connectorRecommendations, true); + assert.strictEqual(fixture.recommendations.element.hidden, true); + + await fixture.configurationService.setUserConfiguration(CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled, true); + fixture.configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(section: string): boolean { + return section === CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled; + } + }()); + assert.strictEqual(fixture.recommendations.element.hidden, false); + + const cards = [...fixture.recommendations.element.querySelectorAll('.new-session-connector-recommendation-card')]; + const dismissButtons = cards.map(card => card.querySelector('.new-session-connector-recommendation-dismiss')!); + dismissButtons[1].click(); + const focusAfterMiddleDismiss = document.activeElement === cards[2].querySelector('.new-session-connector-recommendation-card-primary'); + dismissButtons[2].click(); + const focusAfterLastDismiss = document.activeElement === cards[0].querySelector('.new-session-connector-recommendation-card-primary'); + dismissButtons[0].click(); + await fixture.configurationService.setUserConfiguration(CustomizationMarketplaceConfiguration.MarketplaceEnabled, false); + await fixture.configurationService.setUserConfiguration(CustomizationMarketplaceConfiguration.MarketplaceEnabled, true); + fixture.configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(section: string): boolean { + return section === CustomizationMarketplaceConfiguration.MarketplaceEnabled; + } + }()); + + assert.deepStrictEqual({ + hidden: fixture.recommendations.element.hidden, + dismissedCards: cards.map(card => card.hidden), + focusAfterMiddleDismiss, + focusAfterLastDismiss, + focusCount: fixture.focusCount, + }, { + hidden: true, + dismissedCards: [true, true, true], + focusAfterMiddleDismiss: true, + focusAfterLastDismiss: true, + focusCount: 1, + }); + }); + + test('uses the MCP server icon when a product image URL is invalid', () => { + const fixture = setup(true, true, true, { + linear: { name: 'Linear', description: 'Plan and track product work.', iconUrl: 'data:image/png;base64,invalid' }, + notion: { name: 'Notion', description: 'Search team knowledge and documentation.', iconUrl: '' }, + 'workiq-mail': { name: 'WorkIQ', description: 'Bring Microsoft 365 work context into chats.', iconUrl: 'file:///tmp/workiq.png' }, + }); + const fallbackClasses = [...fixture.recommendations.element.querySelectorAll('.new-session-connector-recommendation-icon > .codicon')] + .map(icon => [...icon.classList]); + + assert.deepStrictEqual(fallbackClasses, [ + ['codicon', 'codicon-ai-customization-mcp-server'], + ['codicon', 'codicon-ai-customization-mcp-server'], + ['codicon', 'codicon-ai-customization-mcp-server'], + ]); + assert.strictEqual(mcpServerIcon.id, 'ai-customization-mcp-server'); + }); + + test('hides the surface when the product has no recommendations', () => { + const fixture = setup(true, true, true, null); + + assert.deepStrictEqual({ + hidden: fixture.recommendations.element.hidden, + cardCount: fixture.recommendations.element.querySelectorAll('.new-session-connector-recommendation-card').length, + }, { + hidden: true, + cardCount: 0, + }); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts index 922f25184a140a..cf8f5b99485d34 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -26,24 +26,28 @@ import { ActionWidgetService, IActionWidgetService } from '../../../../../platfo import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { type IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { ContextViewService } from '../../../../../platform/contextview/browser/contextViewService.js'; +import { ICustomizationMarketplaceResource } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; +import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { asCssVariable } from '../../../../../platform/theme/common/colorUtils.js'; import { isHighContrast } from '../../../../../platform/theme/common/theme.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IRequestService } from '../../../../../platform/request/common/request.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IChatTipService } from '../../../../../workbench/contrib/chat/browser/chatTipService.js'; import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../../../workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.js'; import { IMicCaptureService } from '../../../../../workbench/contrib/chat/browser/voiceClient/micCaptureService.js'; import { ITtsPlaybackService } from '../../../../../workbench/contrib/chat/browser/voiceClient/ttsPlaybackService.js'; import { IVoiceSessionController } from '../../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IVoiceInputModeService, VoiceInputMode } from '../../../../../workbench/contrib/chat/browser/voiceInputMode/voiceInputMode.js'; import { IAICustomizationWorkspaceService } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; @@ -66,7 +70,7 @@ import { IsPhoneLayoutContext } from '../../../../common/contextkeys.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; import { ISearchService } from '../../../../../workbench/services/search/common/search.js'; import { FixtureMenuService, registerChatFixtureServices } from '../../../../../workbench/test/browser/componentFixtures/chat/chatFixtureUtils.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, waitForFixtureCondition } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { activeSessionViewBackground } from '../../../../common/theme.js'; import { IAgentHostSessionsProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; @@ -85,11 +89,12 @@ import { AGENT_FEEDBACK_NEW_SESSION_RESOURCE, AgentFeedbackKind, AgentFeedbackSt import { IAquariumService } from '../../../aquarium/browser/aquariumOverlay.js'; import { computeIssueIcon, computePullRequestIcon, GitHubIssueState, GitHubPullRequestState } from '../../../github/common/types.js'; import { NewChatView } from '../../browser/chatView.js'; -import { COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../../common/constants.js'; +import { COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../../common/constants.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../../common/newChatContextIds.js'; import { INewSessionComposerService, INewSessionPromptOption, NewSessionComposerService, NewSessionPromptOptionsState } from '../../browser/newSessionComposerService.js'; import { INewChatVoiceTargetService, NewChatVoiceTargetService } from '../../browser/newChatVoice.js'; import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js'; +import { AGENTS_WINDOW_TOTAL_SESSIONS_STORAGE_KEY } from '../../../../../platform/chat/common/agentsWindowInvitation.js'; import '../../../../browser/media/style.css'; import '../../../../browser/parts/media/sessionView.css'; @@ -101,6 +106,58 @@ const ATTACHED_FOLDER_URI = URI.file('/Code/docs'); const ATTACHED_REPOSITORY_URI = URI.parse('https://github.com/microsoft/typescript'); const ATTACHED_REPOSITORY_ROOT = URI.parse('vscode-vfs://github/microsoft/typescript/HEAD'); +function connectorIcon(base64: string): URI { + return URI.parse(`data:image/png;base64,${base64}`); +} + +const linearConnectorIcon = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAGZElEQVR42s1Xa2xTZRh+vnPabmNd1250bQcDnGHohDElkYFx84ew8EcuXrYxLoNtDMQ5f6iJwMxiooiXjUswKhEYWzfQhIuCEn7IEKJm3EIykIRsiAlYoS3burU9pz3f64+26x0xRvEkb07Od77vfZ/v/Z738rHpM4oJD/BRET1Q+2EAjAGxWCLHYv8zBgAMAEW84+ckWx8aU4UWch5SGp5IFB4L/GdjiwNzQhpjvUggSrQecSCjjiDRaYTHGIgoCILAOSHsPQZBEILzKQ5IMv1Ef4MDgsCgKBycc2i1WmRnZ0On0wEAXC4XHA4HXC4XBIFBFFXgnCfwTEIOIIEbWcRYYHeSJCM/Px/l5fMxd+4cTJk8GTpdxhiAGzd+w48//YwTJ07g2rV+qNWqiGNiSfWzgmmFkU6KM845hyAIWFa9FA0N9cjJybnnju7cuYNdu75AR6cVnPMg+SiGsGH9bGrBIxQejGepKIpobt6EqsoKAIDP5wNjbExC5x4StVoNADh0+Ag2bNgERfEjmX4AEAJs5UEFfEyRIAhwOp0oLS1FVWUF3G43ZFlOaDgMmEGWZXi9XixetBDvvNMCWfZFzOVRYIkIoiEruyU2hBgL7DQvbxIuXLiI9PRxKCkpgSRJCXcdAhH5liQJxcUzcfPWTZw9ew5paangPJ6UosEQDYAxBp/PB71ej2NHj0AQBLz11kZYLGbMnv0kPB5vRDxTUuE8sNvHHivE198cxeioG4IgIDbqRL0hK84DAIMkSZg16wmsXlUDSZKwYWMzLGYLSkpmw+PxRMRy2Fjs2+v1wmw2o79/AL29Z5GWlhYMz0gA+mgARIGYV6vVsFq7UFhYiLVr18Dj9mDjpmaYzSbMmTMHo6OjUV7gnEcB4JxDURSIoghJknDs2LdI0WjAYz2QqTe0RLJeUThUKhW6uzths/2Bd9/bjJlFRVi3bi2GXcNofrsFZpMZTz01FyMjI8E0G288coyB4fCRr+H3++NqQlwmZIxBkrzwuN3Y174Hi5e8gMqqanR3deLDD7bA71ewbv16cOKor6uF3W6HKIpJOSHLMjIytNBq02G3O6BWq6N4EFcLQim3tq4B+9p346sv9+OliipUVC5Fl7UDba0fgXMFL69/BVxRUFu7GvY7dgiiEBeaodoR8kRs1CT0gKJwpKRo4HDY8WJFFb460I3urg5UVi3D0url6Oxsx9a2VigKxyuNTeBEWLlieZQnIgmqUqkwODgYrBNCMBdEJSIKMp+COYBheNiFN994HQ/n5+O5hYtx+fIVdFk7UFZWiurqFTh48BC2tn2MtQ1r0NjYhD1722EwGCDLctT5+/1+iKKI/oHrGBoahigKMV4gCIGSGZYQB+wOB7qsHTCbTVi06Hn09V0eA7Fs+QocOnQY27a1or6+Dk2vvoa97fuQlZUFWZahKAoURYHf74cgMJw5cwY+ny9xAsudkEfRMolM5gk05aGHqa+vj3755SrNLH6CjDlm+uH0abLb7TS/fAGlpI6j/fsPkMfjprq6NQQI1NraRrdv36YrV67Q1atXqb+/ny5cvEhTCx6lHFMu5U6YRLH2YMmdSLEyMW8yaTMyqaZmNd29e5fOnTtHxY/PovFGE/X0nCKbzUbzyxeQRpNGXd3d5HK5qLaungBGH33cSjbb73Tp0iWy2WxUv6aB0rWZNDFvMiWyBbNlAiUSS24e6TKzqLVtKzmdDurt7aUZRcVkNJro+5Mn6datmzRvXjlpUtKos9NKQ0ODtGp1LQGg9za/T0ODg7R9+w7SZWaRJTePktkR07UZLYnKJBGgUqlw8mQPdDodykqfxrxnn8V3x49j9+49eKasDPX1dTh//jy2bd+BgoJpaHq1EQ6HE59+9jkGBq6j02oNEE1gSNZ5sRyTJWnfFIrhQHldhKamRmRkZGBlzSpcH7iOvXt3Y/r06WhoWIeeUz34ZOdOFBXNwEsVS3Hjxq/Q6w0RDUkSG8Yc8z0bN8YYGBiGhodhNI7HkiWLYTQasWXLFuj1BuzYvhU6nQ41q2rhdruh1+vhdDqh0WigKH/dF7LxRtN9daWiKEKWZbhGRpCakoL09HR4vRLUahVSU1MhyTJ4MPxi0+09AWSPz7nvq1Go/Q605ByMBTKbonCIohDVrPwrV7NQtQt/K2CMRWW4//xu+E/X/38upw/q+RNjBjB4UKkl3AAAAABJRU5ErkJggg=='; +const notionConnectorIcon = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAGA0lEQVR42rVX2WsTXxg9M3emk8k0K0kwbg+CdUVBKT6IqBH0RcFSnxRfalsffRHcELGCIFaILeZFKiJUFMHgPyAWjIgoIhQKARGxpULI0iQzk2SW7/cgM7+0Tbq4HBgG7ty599zvfsv5OCIiLIAz1O7dDI7jwHEceJ6fN25ZVsvxhRCaN7VtGzzPg+M4d/HVgIhQq9XAGENHR4dLhDHW9h+ulQUAQNM0aJoGVVVRKBRQKBRQKpVQKBTw8+dP5HI55PN55PN5zM7OolgsolwuQ1VVSJKEnp4e3L17F2vWrIFpmmCMtTwQZ9s2AYCu67h69SoymQy+f/+OQqEAy7KWPLEsywgGg4hEIgiFQgiFQohGo8jn80in0wgGg3jy5AlOnDjR1hqcYRgkCAKePn2KM2fOAAB6e3sRCAQQDocRjUYRj8cRDocRCATcjbxeL7xer2vqhZiamsLZs2fx6dMnnD9/Hvfu3YOiKDBNE4Ig/D/RMAwiIkomk8QYI1mW6eHDh/S3MDw8TDzP065duyiTyRARkW3b7neXCmMMlmXBMAwMDAzgzZs3SKVS8Pv9yzqdpmmo1WrQdR2qqqJYLGJubg6qqiIej+PAgQOYmJjAoUOHMDQ0hIsXL4LnefA8Pz8KmsmMj4/jy5cv6OnpgW3bKJfLKBaLyOfzqFQqqFQqqFarmJubQ7lcRqPRWDZKDMPAlStXsHPnThw/fvzXdbSa6DjL5OQkJicn2y4oiiJ8Ph/Wr18PRVHg8/ng8/kQCoUQDofh9/vh8/kgyzICgQAePXqE9+/fo1QqLc4DrczL8zwGBwdx8OBBKIoCv9+PQCAARVHg8Xjg8XigKApkWV5Rzsjn88hkMpAkaWkCHMfBtm1wHIfr169j7dq1K0pETiJbCF3XIYoi6vX6ousWllqQ4zhUKhWYpgkigiiKrtPpuo5qtYrNmze783mex+vXr/H27VtYloWZmRlEo1Hcvn3bTdkLwS93Ko7jIAgCGGM4ffo0NmzYgK1bt2Lbtm3Yvn077ty5A57nYVkWiAjpdBrJZBJDQ0P4/Pkz9u3bt2RCWxEBx4MTiQS6urqQy+VQrVZBRLh58yampqYgiiIajQZGR0cxMjICQRDQ39+PkydPwjCM3yfQTKS/vx9Hjx51qxxjDPV6HQMDAyAi13c0TYNpmiiVSm5V/GMCTng6J5dl2S0ymUwGIyMj6OjomFdNHZJLYVUEGGMQRREAcO7cOezevRu2bUMQBFy7dg3ZbBZEtGwR+20CzT6xceNGPHjwwM0XqqpicHCwrbf/NQIOcrkc9u/fj97eXjQaDUiShImJCbx8+RKxWOzfE+B5HkSE+/fvIxQKwTRN8DyPCxcu4OPHj25i+qcEOI7DunXrMDw87Hr79PQ0ksnk6gk4YbQa/WfbNur1Ovr6+pBIJGBZFgRBcFPuqq5AlmW0kYfzwtCBJEmuFYgIqVQKHo/HjRan3q/Akr8mHTt2DIqiuEVouTD8+vUrvn37Btu2YVkWtmzZghs3brh1w0lIKzElmaZJRERjY2MEgERRJADE8zxls1kiImo0GnTp0iWKRCIEgADQjh07aHZ2lmzbJsMwyDRNOnXqFEmSRJs2baJXr16RZVmkaRqZpkm3bt0iAPT8+XMiIjIM45ckY4zBNE309fXh3bt3GBsbA2PMTSrOs2fPHhw+fBixWAxer9dVw81NyIsXL/Djxw/E43FXfMqyDACugJ1nYUcc2rZNpmlSrVajvXv3EgASBIFmZmZ+S4xqmka5XI6mp6cpm83Shw8fKJFIEGOMxsfH51ugucWSJAnPnj1Dd3c3yuUyHj9+jCNHjkBVVVQqFaiqilKphGKxCFVVUa1WUalUUCgUUC6X5+lEXdeh6/qiahgMBhdbwIEj09PptHvXv/swxqizs5NisRh1dXVRd3c3Xb58mer1uivNW7ZmjigdHR1FKpVCo9GA1+uFoihuY+IIUL/fj0gk4grQzs5OVzvKsgxZluHxeCDLcsvK2LY3dPSdE2ZO+P0JnPBsJtKWQKtezlmg+ZeFGXRhZ93qWzP+A41xBscn4a3rAAAAAElFTkSuQmCC'; +const workIqConnectorIcon = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAH8klEQVR42uVXa3CcVRl+3vPd9vJlk90k3V7ShqQrqRubFoK1VDAIljIQ5TZbFBAGfoh2YEBxhh/88QcOVkRUEPUHCALaaaHDAB0BHcY4vYRKsAW6DW0oTSihuW2zm+zudznnvP5o6hRMaEH94fjOvHPm++Y753vO896B/3ehT7mHOju/ZRQbHQFkUDsW0X19tygAPKP/NbAik7nESac74gAcANEZddIdHfFsNmcDEJ/kYqf7IXV1dRnAvEhPzxaKRJDKXrRxXaxh2edhGfCn39v95qbvvOwBE9muHPIY9dDTo06HDTodunOrv+tsOfC4hULBuvziuy4o1LZ/36jPrLZjKYiIBThAEI73jr/1/H1vPHtPTyqVCs+86Mawd8sD/qnM8nEAKJvNWXV1vrNz53OUyZyz9M7LH7x19/jYDW95vhlzU1rEXBYxB+QYZNclhRKe9IpDvzu46e6HhvfveqdtzdfYmHT8fH5LOBcImtvJuiN9fS8YABJ3Xf/Y+rNau+6wzNiSjTuehHbn6XhykTBqEjDiDhAxAEdoEbWFnUwh8AtDxYM7f9Z77/WbAZQ6u7tV3wsveLOxYcwCQHR0rI3u2fOi3f3l21bflvv1/cuazvl2worWDk4Mqt7ht0XErScz4sKIRkARC+QYEI5JZBMU+cpM1CTdpdl1Z1z6zZWW7Rx+c+vjI+mOtaI8ckieCgB1dd3o7N691b4599CVF3Te9FiyZn6b55W0gMa0XxavHX0HdiwJM+ZCxCJMERPkmES2ADkmhGMKNhQzKY4saGqtW3nulbVntB06sPmBt5u7bkRxcK/60G1PfsghJ/r7XzTOXn5ZpvUzX/kJ21G3UDkmA5DwISiYsQ8zawgBw4mREYsRmQIwockSgCVAtknkmCL0JqWRcN3GrsvuX3rhlRmv/0UDuZyYEwByWWNkZIQ/d9Y1F9uxhvpJr6QCIcwqGFUwPGaAmU0nLmAIBN7EaFCdGCVLwEokBQwwGQDZ4rhGbVOGZRVZMK9+4VU3XTwyMqKz2dyHWDdPpn90FAKAaUSSTZ4gViAwMywwBDR8IggzQl7pvZ1Th599cmrwLwMAULeyO7Pgwmuvj2eya7SlAQoBU4AscfwPEbBZn1oMwGrEPj5B5EcBwPdLBMDwtaIqM2kwNBghM4MJIahSGnn1ocLA5k1BBeNuY6MEgMPPP7h/4PkHd63Y8NOvz+u64lajdVEMygcMzPgGiA06fnYpQXMx8E/xWFGVAc0MSQwTzCQMIQWPHs1vfrq+ZcVYwqor5qPTPqpVyjZm7WJQwL6Hv7d13uvD62nd2hb91TXaSLnEXhVkASRmzzmzAvAFUAVDA5DMMBgAGFUitutqta2mpvMH91YBaADIA6q5udmsbW3VCSfB2LYdU6/vhbrhUhhfagdF5k55YraXgQbKM45XAVCBpmmtUYWIRowac0JKAeRwckwzM2nfN91YIpZIL4Q7XiZxz28R/vgpgBlwrNMH4AOoMKPMjAozqkQ0JavM0br0mSu/0VEcGqJsV2N0Zr/IdW2IDg0NiY6mC5fX16TTtmFwTV0D1TSmYT/3KtQDLwFRB6c0wZETPgBGZSZrSjBMBgQzm6Yp4u25O+bvfWbP+z0PD7R25moWy4C29DyM+fObWr+w9NLbHdMlpaQmEBEAsXgx1I48UDp8/PBdvXMDMJwEA2AfhAozA5gJQUCBRVA5xhxNts+/+tEnKgf++MOhP2386yGArlt71/kdzeffbZvussnpAtuWLRgMzQzWDBImR3cfBAB2PojxnACsaIoBqGpYnrDABK1BRFCsobSEVCGpalGz6Sw71vTFJ9LdGzfflm5BMtm6XkmNyemCNg1TSCVBJGAIE4YwYBg2GVKPA5BRKzF3LSgsqlJqasom1lI1nbM+MGzTk772VSiC0EcYVDFZKVKxeFSfG3NpQ9v57U11i9urfhle4GnNWkitIJWCVBJhGCqQEIH0gv6Rnh8Fujiceh/BIAb17MVocBBuyypT7tk2GSzqCMPaJV1lGYhS+ZgqV4s0OTVOzYbA7c3tlJvfwpYKdbE6zVJLUkqJUIWQUiKUIUsZaIZhhErSkcK+e7fv3bytpWWV/9pYT3By9PxLOa6MDeqG5ecZ+pVf7ZPxxOEFdYvaWtxkqlEFdEXDAn1n5mxqidag4FfIC0OhdChCGVKoQoQyRBAGWmktSNiiWBl/t394xw9eyT/6hyULl1cGBnZXTuSOUzUkZlN2dY3I9zptrStar1v/i5tXLOm8NuHEI0PHRvW0VyEpJXmBDy/w4AUeKn6Vfd9nhiEmpwve0Hj/71/Z98gjY2PD72abVvv5I71TAOTpNCQAwKWxI6HZtobfKg1NPfvSL3eOTI++nnAXLYg7ySVSSpr2ppUf+vADn70w0KxJaA06Onl4547+p+9++W+PPOW6DaNNTdly/7u7ygBmbVLpVA1pNps1U6mW+Pbt2yIA6u+85udXLV+85paoU7uw4pfhBwFCKTFW+mB4/5FXf7Nlx31bYWFi+dLzPKUK5Xw+Lz+uMaXTnQc6O7sdVZyM7xnYbi9tXnHm1Ws2XO069WeHoeTR0nt///Mbjz3zzuC+A5nMyqC2bJT7PujzZ+zN/7GhBOi0Vq26JOG6bgOANIDWGU27rtuwKrMqAcD6JMMJfbqxrNNIhZ+1y/4hAQBxp1UXrP1BX1/fJx7P6N+cKU+s/JH1f0f+Ab8jv+QQL9KTAAAAAElFTkSuQmCC'; + +const fixtureConnectors: readonly ICustomizationMarketplaceResource[] = [ + { + sourceId: CustomizationMarketplaceSources.CopilotConnectors.id, + identifier: 'linear', + displayName: 'Linear', + description: 'Plan and track product work.', + mediaType: 'application/mcp-server+json', + tags: [], + capabilities: [], + representativeQueries: [], + icon: connectorIcon(linearConnectorIcon), + }, + { + sourceId: CustomizationMarketplaceSources.CopilotConnectors.id, + identifier: 'notion', + displayName: 'Notion', + description: 'Search team knowledge and documentation.', + mediaType: 'application/mcp-server+json', + tags: [], + capabilities: [], + representativeQueries: [], + icon: connectorIcon(notionConnectorIcon), + }, + { + sourceId: CustomizationMarketplaceSources.CopilotConnectors.id, + identifier: 'workiq-mail', + displayName: 'Work IQ', + description: 'Bring Microsoft 365 work context into chats.', + mediaType: 'application/mcp-server+json', + tags: [], + capabilities: [], + representativeQueries: [], + icon: connectorIcon(workIqConnectorIcon), + }, +]; + +const fixtureConnectorRecommendations: NonNullable = Object.fromEntries( + fixtureConnectors.map(connector => [connector.identifier, { + name: connector.identifier === 'workiq-mail' ? 'WorkIQ' : connector.displayName, + description: connector.description, + iconUrl: URI.isUri(connector.icon) ? connector.icon.toString(true) : '', + }]), +); + interface INewChatWidgetFixtureOptions { readonly width?: number; readonly height?: number; @@ -127,9 +184,15 @@ interface INewChatWidgetFixtureOptions { readonly chatBackground?: 'codicons' | 'loud'; readonly migrationCount?: number; readonly experimentalComposerLayout?: boolean; + readonly toggleExperimentalComposerLayout?: boolean; readonly unifiedWorkspacePicker?: boolean; readonly collapsedSessionOptionsShowIcons?: boolean; readonly welcomePhrases?: boolean; + readonly connectorRecommendations?: boolean; + readonly connectorProductIcons?: boolean; + readonly dismissedConnectorCount?: number; + readonly focusConnectorRecommendations?: boolean; + readonly expectBelowInputSurfacesScrollable?: boolean; } class FixturePickerActionViewItem extends BaseActionViewItem implements IChatInputPickerResponsiveState { @@ -277,9 +340,15 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN chatBackground, migrationCount = 0, experimentalComposerLayout = false, + toggleExperimentalComposerLayout = false, unifiedWorkspacePicker = experimentalComposerLayout, collapsedSessionOptionsShowIcons = false, welcomePhrases = false, + connectorRecommendations = false, + connectorProductIcons = true, + dismissedConnectorCount = 0, + focusConnectorRecommendations = false, + expectBelowInputSurfacesScrollable = false, } = options; const hasChatBackground = chatBackground !== undefined; const feedbackItems: readonly IAgentFeedback[] = Array.from({ length: commentCount }, (_, index) => ({ @@ -310,23 +379,35 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN [UNIFIED_WORKSPACE_PICKER_SETTING]: unifiedWorkspacePicker, [EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING]: experimentalComposerLayout, [COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING]: collapsedSessionOptionsShowIcons, + [CustomizationMarketplaceConfiguration.MarketplaceEnabled]: connectorRecommendations, + [CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled]: connectorRecommendations, + [NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING]: connectorRecommendations, ...(chatBackground === 'codicons' ? { [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING]: AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, [AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING]: AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, } : {}), }); disposableStore.add(configurationService.onDidChangeConfigurationEmitter); + const storageService = disposableStore.add(new InMemoryStorageService()); + if (showTip) { + storageService.store(AGENTS_WINDOW_TOTAL_SESSIONS_STORAGE_KEY, 2, StorageScope.APPLICATION, StorageTarget.MACHINE); + } const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: reg => { registerChatFixtureServices(reg); reg.defineInstance(IConfigurationService, configurationService); + reg.defineInstance(IProductService, new class extends mock() { + override readonly connectorRecommendations = connectorRecommendations + ? connectorProductIcons + ? fixtureConnectorRecommendations + : Object.fromEntries(Object.entries(fixtureConnectorRecommendations).map(([id, recommendation]) => [id, { ...recommendation, iconUrl: '' }])) + : undefined; + }()); + reg.defineInstance(IStorageService, storageService); reg.defineInstance(IAuthenticationService, new class extends mock() { }()); reg.defineInstance(IRequestService, new class extends mock() { }()); - if (migrationCount > 0) { - reg.defineInstance(IStorageService, disposableStore.add(new InMemoryStorageService())); - } if (withAutoModel || withConfiguredModel) { reg.define(IMenuService, AutoModelFixtureMenuService); reg.defineInstance(ILanguageModelsService, new class extends NullLanguageModelsService { @@ -518,6 +599,9 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN reg.define(ISessionsChatBackgroundService, SessionsChatBackgroundService); }, }); + if (showTip) { + ChatContextKeys.foregroundSessionCount.bindTo(instantiationService.get(IContextKeyService)).set(0); + } await instantiationService.get(IConfigurationService).updateValue(COMPARE_AGENTS_ENABLED_SETTING, true); container.style.width = `${width}px`; @@ -629,6 +713,17 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN } } await nextFrame(); + if (toggleExperimentalComposerLayout) { + for (const enabled of [true, false, true]) { + await configurationService.setUserConfiguration(EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, enabled); + configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(section: string): boolean { + return section === EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING; + } + }()); + await nextFrame(); + } + } const promptBox = view.element.querySelector('.new-chat-input-container'); assert(!!promptBox); const widgetContent = view.element.querySelector('.new-chat-widget-content'); @@ -824,8 +919,75 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN notice.style.display = ''; assert(before.x === withoutNotice.x && before.y === withoutNotice.y && before.height === withoutNotice.height, 'The migration notice must not move or resize the centered input.'); - assert(notice.getBoundingClientRect().bottom <= container.getBoundingClientRect().bottom, - 'The migration notice must fit below the input.'); + if (!expectBelowInputSurfacesScrollable) { + assert(notice.getBoundingClientRect().bottom <= container.getBoundingClientRect().bottom, + 'The migration notice must fit below the input.'); + } + } + if (connectorRecommendations) { + const recommendations = view.element.querySelector('.new-session-connector-recommendations'); + const cards = recommendations?.querySelectorAll('.new-session-connector-recommendation-card'); + assert(!!recommendations && cards?.length === 3 && targetWindow.getComputedStyle(recommendations).display !== 'none'); + if (connectorProductIcons) { + await waitForFixtureCondition(() => recommendations.querySelectorAll('img').length === 3, 'Connector recommendation icons did not load.'); + await Promise.all([...recommendations.querySelectorAll('img')].map(image => image.decode())); + } else { + assert(recommendations.querySelectorAll('.codicon-ai-customization-mcp-server').length === 3, + 'Every recommendation without a product image must use the MCP server fallback icon.'); + } + const dismissButtons = [...recommendations.querySelectorAll('.new-session-connector-recommendation-dismiss')]; + for (let index = 0; index < dismissedConnectorCount; index++) { + dismissButtons[index]?.click(); + } + assert([...cards].filter(card => !card.hidden).length === 3 - dismissedConnectorCount, + 'Dismissed connector recommendations must collapse while the remaining cards stay visible.'); + assert(recommendations.getBoundingClientRect().bottom <= container.getBoundingClientRect().bottom, + 'Connector recommendations must fit below the input.'); + const input = view.element.querySelector('.new-chat-input-container'); + const composerGap = phoneLayout + ? (input?.getBoundingClientRect().top ?? 0) - recommendations.getBoundingClientRect().bottom + : recommendations.getBoundingClientRect().top - (input?.getBoundingClientRect().bottom ?? 0); + assert(!!input && composerGap >= 20, + 'Connector recommendations must have at least 20px of breathing room next to the composer.'); + if (width >= DEFAULT_WIDTH) { + assert([...cards].every(card => card.getBoundingClientRect().height <= 80), + 'Wide connector cards must stay compact at no more than 80px tall.'); + } + if (focusConnectorRecommendations) { + context.focus({ focus: () => cards[0].querySelector('.new-session-connector-recommendation-card-primary')?.focus() }); + } + } + if (showTip) { + const tip = view.element.querySelector('.chat-tip-widget'); + assert(!!tip && targetWindow.getComputedStyle(tip).display !== 'none', 'The getting-started tip must be visible.'); + } + if (connectorRecommendations && showTip && migrationCount > 0) { + const recommendations = view.element.querySelector('.new-session-connector-recommendations'); + const tip = view.element.querySelector('.chat-tip-widget'); + const migration = view.element.querySelector('.new-chat-migration-notice'); + const belowInputSurfaces = view.element.querySelector('.new-session-below-input-surfaces'); + assert(!!recommendations && !!tip && !!migration + && recommendations.getBoundingClientRect().top < tip.getBoundingClientRect().top + && tip.getBoundingClientRect().top < migration.getBoundingClientRect().top, + 'Connector cards, the getting-started tip, and migration notice must appear in that order.'); + if (!phoneLayout) { + assert(Math.abs(tip.getBoundingClientRect().height - migration.getBoundingClientRect().height) <= 1, + 'The getting-started tip and migration notice must have matching heights.'); + } + if (expectBelowInputSurfacesScrollable) { + assert(!!belowInputSurfaces + && belowInputSurfaces.scrollHeight > belowInputSurfaces.clientHeight + && belowInputSurfaces.getBoundingClientRect().bottom <= container.getBoundingClientRect().bottom + && promptBox.getBoundingClientRect().bottom <= container.getBoundingClientRect().bottom, + 'Constrained desktop support surfaces must scroll without clipping the composer.'); + } + } + if (phoneLayout) { + const input = view.element.querySelector('.new-chat-input-container'); + const containerRect = container.getBoundingClientRect(); + const inputRect = input?.getBoundingClientRect(); + assert(!!input && input.checkVisibility() && !!inputRect && inputRect.height > 0 && inputRect.top >= containerRect.top && inputRect.bottom <= containerRect.bottom, + 'The phone composer must remain visible within the fixture viewport.'); } const repositoryConfigContainer = view.element.querySelector('.new-chat-repo-config-container'); if (withControlPickers) { @@ -1077,6 +1239,78 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newWidget/' }, { expectedVisualDescriptions: ['Over a loud repeating magenta, cyan, yellow, and blue striped background, the new-session composer shows the customization migration notice below the input on an opaque bordered surface. None of the stripes show through the notice.'], render: context => renderNewChatWidget(context, { withWorkspace: true, migrationCount: 4, chatBackground: 'loud' }), }), + ConnectorRecommendationsBefore: defineComponentFixture({ + labels: { kind: 'screenshot' }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['The control state shows the centered new-session composer with no connector recommendation below it.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, experimentalComposerLayout: true }), + }), + ConnectorRecommendations: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['Below the centered new-session composer, three equal cards for Linear, Notion, and WorkIQ each show a product image, name, short benefit, and independent dismiss control. No section heading, subtitle, or arrow icon appears.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsNarrow: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['In a narrow Agents window, the three connector cards retain readable widths in one horizontally scrollable row instead of compressing their product images and copy.'], + render: context => renderNewChatWidget(context, { width: 420, height: 760, withWorkspace: true, connectorRecommendations: true, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsOneDismissed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['After dismissing Linear, the Notion and WorkIQ recommendation cards remain and expand evenly across the available row without leaving an empty slot.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, dismissedConnectorCount: 1, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsTwoDismissed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['After dismissing Linear and Notion, the WorkIQ recommendation card remains as one full-width card without leaving empty slots.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, dismissedConnectorCount: 2, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsFallbackIcons: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['When connector catalog images are unavailable, all three recommendation cards use the same standard MCP server fallback icon while retaining their names, benefits, and actions.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, connectorProductIcons: false, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsBackground: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['Over a loud repeating background, all three connector cards remain legible on opaque neutral card surfaces, and the section heading remains visually subordinate to the composer.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, experimentalComposerLayout: true, chatBackground: 'loud' }), + }), + ConnectorRecommendationsWithNotices: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['The compact three-card connector recommendation section appears first, followed by the getting-started tip and then the migration notice in a consistently spaced stack below the composer.'], + render: context => renderNewChatWidget(context, { height: 820, withWorkspace: true, migrationCount: 4, showTip: true, connectorRecommendations: true, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsShortAfterLayoutToggle: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['After toggling the experimental composer off and on in a short desktop pane, connector cards, the getting-started tip, and migration notice retain their order in a scrollable support region while the composer remains visible.'], + render: context => renderNewChatWidget(context, { + height: 400, + withWorkspace: true, + migrationCount: 4, + showTip: true, + connectorRecommendations: true, + unifiedWorkspacePicker: true, + toggleExperimentalComposerLayout: true, + expectBelowInputSurfacesScrollable: true, + }), + }), + ConnectorRecommendationsFocused: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['Keyboard focus is visible around the first connector card primary action with the standard focus treatment while each card retains a separate reachable dismiss control.'], + render: context => renderNewChatWidget(context, { height: 700, withWorkspace: true, connectorRecommendations: true, focusConnectorRecommendations: true, experimentalComposerLayout: true }), + }), + ConnectorRecommendationsPhone: defineComponentFixture({ + labels: { kind: 'screenshot' }, + expectedVisualDescriptions: ['On a phone-sized Agents window, the connector section sits immediately above the bottom-pinned composer and preserves readable cards in a horizontally scrollable row within the viewport.'], + render: context => renderNewChatWidget(context, { width: 390, height: 760, withWorkspace: true, withControlPickers: true, connectorRecommendations: true, experimentalComposerLayout: true, phoneLayout: true }), + }), + ConnectorRecommendationsPhoneWithNotices: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + expectedVisualDescriptions: ['On a phone-sized Agents window, the connector cards, getting-started tip, and migration notice form a compact stack immediately above the bottom-pinned composer, without covering the workspace controls or overflowing the viewport.'], + render: context => renderNewChatWidget(context, { width: 390, height: 760, withWorkspace: true, withControlPickers: true, migrationCount: 4, showTip: true, connectorRecommendations: true, experimentalComposerLayout: true, phoneLayout: true }), + }), NewSessionDefault: defineComponentFixture({ labels: { kind: 'screenshot' }, render: context => renderNewChatWidget(context, { withWorkspace: true }), diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts index 236f5262358387..ba246f56f53b98 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts @@ -28,10 +28,11 @@ import { ISessionsPartService } from '../../../../services/sessions/browser/sess import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionComparison, ISessionComparisonService, SessionComparisonParticipantRole } from '../../../../services/sessions/common/sessionComparison.js'; -import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../../common/constants.js'; +import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COMPARE_AGENTS_ENABLED_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../../common/constants.js'; import { AGENT_SESSIONS_RESPONSE_SELECTION_MENU_SETTING } from '../../browser/responseSelectionSideChatController.js'; import { SESSION_ARCHIVE_NUDGE_SETTING } from '../../browser/sessionArchiveNudge.js'; import { SessionComparisonAccessibleView, SessionsChatAccessibilityHelp } from '../../browser/sessionsChatAccessibilityHelp.js'; +import { CustomizationMarketplaceConfiguration } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; import { SessionsListPromoteNewChatActionContext } from '../../../../common/contextkeys.js'; import { SESSIONS_CHAT_TABS_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SessionsChatTabsMode } from '../../../../common/sessionConfig.js'; import { RemoteSessionToolsEnabledSettingId } from '../../../remoteSessions/common/remoteSessions.js'; @@ -58,6 +59,28 @@ suite('SessionsChatAccessibilityHelp', () => { }, { back: true, forward: true, singletonViews: true }); }); + test('documents connector recommendations when the marketplace is available', () => { + const enabled = [false, true].map(value => { + const instantiationService = store.add(new TestInstantiationService()); + const configuration = new TestConfigurationService({ + [NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING]: value, + [CustomizationMarketplaceConfiguration.MarketplaceEnabled]: value, + [CustomizationMarketplaceConfiguration.CopilotConnectorsEnabled]: value, + }); + store.add(configuration.onDidChangeConfigurationEmitter); + instantiationService.stub(IConfigurationService, configuration); + stubContextKeyService(instantiationService, configuration); + instantiationService.stub(ISessionsPartService, new class extends mock() { }()); + instantiationService.stub(ISessionsService, new class extends mock() { }()); + instantiationService.stub(IAgentHostFilterService, { selectedHost: undefined }); + instantiationService.stub(IWorkbenchLayoutService, { mainContainer: mainWindow.document.createElement('div') }); + return store.add(new SessionsChatAccessibilityHelp().getProvider(instantiationService)).provideContent() + .includes('three connector cards offer Linear, Notion, and WorkIQ'); + }); + + assert.deepStrictEqual(enabled, [false, true]); + }); + test('documents repo-less Cloud chats in the harness picker', () => { const instantiationService = store.add(new TestInstantiationService()); const configuration = new TestConfigurationService(); diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditor.contribution.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.contribution.ts index b6e28752a7bb86..37102d6071b387 100644 --- a/src/vs/sessions/contrib/editor/browser/emptyFileEditor.contribution.ts +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.contribution.ts @@ -3,15 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../nls.js'; +import { localize, localize2 } from '../../../../nls.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationNode, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../workbench/browser/editor.js'; import { EditorExtensions, IEditorFactoryRegistry } from '../../../../workbench/common/editor.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { NEW_UNTITLED_FILE_COMMAND_ID } from '../../../../workbench/contrib/files/browser/fileConstants.js'; +import { Menus } from '../../../browser/menus.js'; import { AgentWorkbenchLayout, IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; +import { SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING } from '../../../common/sessionConfig.js'; import { EmptyFileEditor } from './emptyFileEditor.js'; +import { EmptyFileEditorAccessibilityHelp } from './emptyFileEditorAccessibility.js'; import { EmptyFileEditorInput, EmptyFileEditorSerializer } from './emptyFileEditorInput.js'; /** @@ -50,3 +59,29 @@ class DesktopEmptyFileEditorContribution extends Disposable implements IWorkbenc } registerWorkbenchContribution2(DesktopEmptyFileEditorContribution.ID, DesktopEmptyFileEditorContribution, WorkbenchPhase.BlockStartup); +AccessibleViewRegistry.register(new EmptyFileEditorAccessibilityHelp()); +MenuRegistry.appendMenuItem(Menus.EmptyFileEditorActions, { + command: { + id: NEW_UNTITLED_FILE_COMMAND_ID, + title: localize2('emptyFileEditor.newFile', "New File"), + icon: Codicon.newFile, + }, + group: 'navigation', + order: 1, +}); + +export const emptyFileEditorConfiguration = { + id: 'sessions', + properties: { + [SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING]: { + type: 'boolean', + default: product.quality === 'insider', + scope: ConfigurationScope.WINDOW, + tags: ['experimental'], + experiment: { mode: 'auto' }, + description: localize('sessions.experimental.emptyFileEditorActions', "Enables file search and a New File action in the empty Files editor."), + }, + }, +} satisfies IConfigurationNode; + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration(emptyFileEditorConfiguration); diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts index 668838a1b004db..ec54e2023de39e 100644 --- a/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts @@ -4,83 +4,164 @@ *--------------------------------------------------------------------------------------------*/ import './media/emptyFileEditor.css'; -import { $, append, Dimension } from '../../../../base/browser/dom.js'; -import { Action } from '../../../../base/common/actions.js'; +import { $, append, clearNode, Dimension } from '../../../../base/browser/dom.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { logSettingExperimentTrigger } from '../../../../platform/telemetry/common/experimentTrigger.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { EditorPane } from '../../../../workbench/browser/parts/editor/editorPane.js'; +import { IEditorOpenContext } from '../../../../workbench/common/editor.js'; +import { NEW_UNTITLED_FILE_COMMAND_ID } from '../../../../workbench/contrib/files/browser/fileConstants.js'; +import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { Menus } from '../../../browser/menus.js'; import { renderSessionsEmptyState } from '../../../browser/parts/sessionsEmptyState.js'; +import { SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING } from '../../../common/sessionConfig.js'; +import { ISessionWorkspace } from '../../../services/sessions/common/session.js'; import { CompactButtonActionViewItem } from '../../sessions/browser/sessionsActions.js'; +import { EmptyFileEditorSearch } from './emptyFileEditorSearch.js'; import { EmptyFileEditorInput } from './emptyFileEditorInput.js'; -const QUICK_OPEN_COMMAND_ID = 'workbench.action.quickOpen'; +class EmptyFileEditorActionViewItem extends CompactButtonActionViewItem { -class SearchFilesActionViewItem extends CompactButtonActionViewItem { + constructor( + action: IAction, + private readonly actionCommandId: string, + @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(action, keybindingService, hoverService, contextKeyService); + } protected override get commandId(): string { - return QUICK_OPEN_COMMAND_ID; + return this.actionCommandId; } protected override get label(): string { - return localize('emptyFileEditor.search', "Search Files"); + return this.action.label; + } + + protected override get icon() { + return Codicon.newFile; } protected override getHoverContent(keybindingLabel: string | undefined): string { return keybindingLabel - ? localize('emptyFileEditor.searchTooltip', "Search Files ({0})", keybindingLabel) - : localize('emptyFileEditor.searchTooltipNoKeybinding', "Search Files"); + ? localize('emptyFileEditor.actionTooltip', "{0} ({1})", this.action.label, keybindingLabel) + : this.action.label; } protected override getAriaLabel(keybindingAriaLabel: string | undefined): string { return keybindingAriaLabel - ? localize('emptyFileEditor.searchAria', "Search Files ({0})", keybindingAriaLabel) - : localize('emptyFileEditor.searchAriaNoKeybinding', "Search Files"); + ? localize('emptyFileEditor.actionAria', "{0} ({1})", this.action.label, keybindingAriaLabel) + : this.action.label; } + } export class EmptyFileEditor extends EditorPane { static readonly ID = EmptyFileEditorInput.EDITOR_ID; + private readonly inputDisposables = this._register(new MutableDisposable()); + private readonly featureDisposables = this._register(new MutableDisposable()); + private content: HTMLElement | undefined; + private search: EmptyFileEditorSearch | undefined; + private workspace: ISessionWorkspace | undefined; constructor( group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, @IStorageService storageService: IStorageService, - @ICommandService private readonly commandService: ICommandService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(EmptyFileEditor.ID, group, telemetryService, themeService, storageService); + this._register(this.configurationService.onDidChangeConfiguration(event => { + if (event.affectsConfiguration(SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING)) { + this.renderContent(); + } + })); } protected override createEditor(parent: HTMLElement): void { const container = append(parent, $('.empty-file-editor')); - const content = append(container, $('.empty-file-editor-content')); + this.content = append(container, $('.empty-file-editor-content')); + this.renderContent(); + } + private renderContent(): void { + if (!this.content) { + return; + } + + this.featureDisposables.clear(); + this.search = undefined; + clearNode(this.content); renderSessionsEmptyState( - content, + this.content, localize('emptyFileEditor.title', "Files"), localize('emptyFileEditor.description', "Select a file from the Files view"), ); - const actions = append(content, $('.empty-file-editor-actions')); - const action = this._register(this.createSearchAction()); - const actionViewItem = this._register(this.instantiationService.createInstance(SearchFilesActionViewItem, action)); - actionViewItem.render(actions); + logSettingExperimentTrigger(this.telemetryService, SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING); + if (!this.configurationService.getValue(SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING)) { + return; + } + + const featureDisposables = new DisposableStore(); + this.featureDisposables.value = featureDisposables; + const searchContainer = append(this.content, $('.empty-file-editor-search')); + this.search = featureDisposables.add(this.instantiationService.createInstance(EmptyFileEditorSearch, searchContainer)); + this.search.setWorkspace(this.workspace); + + const actions = append(this.content, $('.empty-file-editor-actions')); + const scopedContextKeyService = featureDisposables.add(this.contextKeyService.createScoped(actions)); + const scopedInstantiationService = featureDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService]))); + featureDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actions, Menus.EmptyFileEditorActions, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + telemetrySource: 'emptyFileEditor.actions', + toolbarOptions: { primaryGroup: group => group === 'navigation' }, + actionViewItemProvider: action => action.id === NEW_UNTITLED_FILE_COMMAND_ID + ? scopedInstantiationService.createInstance(EmptyFileEditorActionViewItem, action, NEW_UNTITLED_FILE_COMMAND_ID) + : undefined, + })); } - private createSearchAction(): Action { - return new Action(QUICK_OPEN_COMMAND_ID, localize('emptyFileEditor.search', "Search Files"), undefined, true, () => this.commandService.executeCommand(QUICK_OPEN_COMMAND_ID, '')); + override async setInput(input: EmptyFileEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + await super.setInput(input, options, context, token); + if (token.isCancellationRequested) { + return; + } + + const store = new DisposableStore(); + this.inputDisposables.value = store; + const updateWorkspace = () => { + this.workspace = input.workspace; + this.search?.setWorkspace(this.workspace); + }; + store.add(input.onDidChangeLabel(updateWorkspace)); + updateWorkspace(); } override focus(): void { // Do not steal focus to the search button when the editor opens. } - override layout(_dimension: Dimension): void { } + override layout(dimension: Dimension): void { + } } diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditorAccessibility.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditorAccessibility.ts new file mode 100644 index 00000000000000..8d8f2770a10a99 --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditorAccessibility.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getActiveElement, isHTMLElement } from '../../../../base/browser/dom.js'; +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { localize } from '../../../../nls.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { AccessibilityVerbositySettingId } from '../../../../workbench/contrib/accessibility/browser/accessibilityConfiguration.js'; +import { EmptyFileEditorSearchFocusedContext, EmptyFileEditorSearchPickerFocusedContext } from './emptyFileEditorSearch.js'; + +export class EmptyFileEditorAccessibilityHelp implements IAccessibleViewImplementation { + readonly priority = 115; + readonly name = 'emptyFileEditorSearch'; + readonly type = AccessibleViewType.Help; + readonly when = ContextKeyExpr.or(EmptyFileEditorSearchFocusedContext, EmptyFileEditorSearchPickerFocusedContext); + + getProvider(_accessor: ServicesAccessor): AccessibleContentProvider { + const focusedElement = getActiveElement(); + return new AccessibleContentProvider( + AccessibleViewProviderId.EmptyFileEditorSearch, + { type: AccessibleViewType.Help }, + () => [ + localize('emptyFileEditor.searchHelp.overview', "You are in the Files editor search. Type part of a file name to search the active session workspace."), + localize('emptyFileEditor.searchHelp.navigation', "Use Up Arrow and Down Arrow to move the highlighted result while focus remains in the search input. Press Enter to open the highlighted file."), + localize('emptyFileEditor.searchHelp.dismiss', "Press Escape to clear the search text and close the search results."), + ].join('\n'), + () => { + if (isHTMLElement(focusedElement)) { + focusedElement.focus(); + } + }, + AccessibilityVerbositySettingId.EmptyFileEditorSearch, + ); + } +} diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditorSearch.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditorSearch.ts new file mode 100644 index 00000000000000..ce92beb79a09af --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditorSearch.ts @@ -0,0 +1,399 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import * as glob from '../../../../base/common/glob.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; +import { prepareQuery } from '../../../../base/common/fuzzyScorer.js'; +import { defaultGenerator } from '../../../../base/common/idGenerator.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { dirname, relativePath } from '../../../../base/common/resources.js'; +import { fuzzyContains } from '../../../../base/common/strings.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { IAccessibleViewService } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { FileOperationResult, IFileService, IFileStat, toFileOperationResult } from '../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IQuickInputService, IQuickPick, IQuickPickItem, QuickInputType } from '../../../../platform/quickinput/common/quickInput.js'; +import { defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { AccessibilityVerbositySettingId } from '../../../../workbench/contrib/accessibility/browser/accessibilityConfiguration.js'; +import { IAnythingQuickAccessFileSearchDelegate, IAnythingQuickAccessProviderRunOptions } from '../../../../workbench/contrib/search/common/search.js'; +import { QueryBuilder } from '../../../../workbench/services/search/common/queryBuilder.js'; +import { getExcludes, hasSiblingFn, ISearchComplete, ISearchConfiguration, ISearchService } from '../../../../workbench/services/search/common/search.js'; +import { ISessionFolder, ISessionWorkspace } from '../../../services/sessions/common/session.js'; + +export const EmptyFileEditorSearchFocusedContext = new RawContextKey('emptyFileEditorSearchFocused', false, localize('emptyFileEditorSearchFocused', "Whether focus is in the empty Files editor search.")); +export const EmptyFileEditorSearchPickerFocusedContext = new RawContextKey('emptyFileEditorSearchPickerFocused', false, localize('emptyFileEditorSearchPickerFocused', "Whether the empty Files editor search picker is active.")); + +const MAX_SEARCH_RESULTS = 512; +const MAX_VIRTUAL_WORKSPACE_ENTRIES = 10_000; + +interface IVirtualFileSearchEntry { + readonly resource: URI; + readonly searchPath: string; +} + +interface IVirtualFileSearchIndex { + readonly entries: readonly IVirtualFileSearchEntry[]; + readonly limitHit: boolean; +} + +export class EmptyFileEditorSearch extends Disposable { + + private readonly input: InputBox; + private readonly inputContainer: HTMLElement; + private readonly pickerDisposables = this._register(new MutableDisposable()); + private picker: IQuickPick | undefined; + private restoreInputFocusOnHide = false; + private syncingPickerValue = false; + private workspace: ISessionWorkspace | undefined; + private accessibilityHelpHintAnnounced = false; + private readonly fileQueryBuilder: QueryBuilder; + private readonly pickerFocusedContext: IContextKey; + + constructor( + container: HTMLElement, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @IAccessibleViewService private readonly accessibleViewService: IAccessibleViewService, + @IContextKeyService contextKeyService: IContextKeyService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IFileService private readonly fileService: IFileService, + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly logService: ILogService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @ISearchService private readonly searchService: ISearchService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + ) { + super(); + this.fileQueryBuilder = instantiationService.createInstance(QueryBuilder); + this.pickerFocusedContext = EmptyFileEditorSearchPickerFocusedContext.bindTo(contextKeyService); + this._register(toDisposable(() => this.pickerFocusedContext.reset())); + + const scopedContextKeyService = this._register(contextKeyService.createScoped(container)); + const focusedContext = EmptyFileEditorSearchFocusedContext.bindTo(scopedContextKeyService); + const focusTracker = this._register(dom.trackFocus(container)); + this._register(focusTracker.onDidFocus(() => focusedContext.set(true))); + this._register(focusTracker.onDidBlur(() => focusedContext.set(false))); + this._register(toDisposable(() => focusedContext.reset())); + + this.inputContainer = dom.append(container, dom.$('.empty-file-editor-search-input')); + this.input = this._register(new InputBox(this.inputContainer, undefined, { + ariaLabel: localize('emptyFileEditor.searchAriaLabel', "Search files in the session workspace"), + placeholder: localize('emptyFileEditor.searchPlaceholder', "Search files by name"), + inputBoxStyles: defaultInputBoxStyles, + })); + + this._register(this.input.onDidChange(value => this.onDidChangeValue(value))); + this._register(dom.addDisposableListener(this.input.inputElement, dom.EventType.FOCUS, () => { + this.showPicker(); + this.announceAccessibilityHelpHint(); + })); + this._register(dom.addDisposableListener(dom.getWindow(this.inputContainer), dom.EventType.KEY_DOWN, event => { + if (this.picker && new StandardKeyboardEvent(event).equals(KeyCode.Escape)) { + this.restoreInputFocusOnHide = true; + this.picker.value = ''; + } + }, true)); + this._register(dom.addDisposableListener(dom.getWindow(this.inputContainer), dom.EventType.POINTER_DOWN, event => { + if (this.picker && dom.isHTMLElement(event.target) && event.target.closest('.monaco-sash')) { + this.picker.hide(); + } + }, true)); + this._register(dom.addDisposableListener(this.input.inputElement, dom.EventType.KEY_DOWN, event => { + if (!this.picker && this.input.value && new StandardKeyboardEvent(event).equals(KeyCode.Escape)) { + event.preventDefault(); + event.stopPropagation(); + this.input.value = ''; + } + })); + } + + setWorkspace(workspace: ISessionWorkspace | undefined): void { + this.workspace = workspace; + if (this.picker?.value) { + this.restoreInputFocusOnHide = true; + this.picker.hide(); + } + } + + private onDidChangeValue(value: string): void { + if (this.syncingPickerValue) { + return; + } + if (!value.trim()) { + this.restoreInputFocusOnHide = !!this.picker; + this.picker?.hide(); + return; + } + + this.showPicker(); + } + + private showPicker(): void { + if (this.picker || !this.input.value.trim()) { + return; + } + + const workspace = this.workspace; + const pickerLifetime = new CancellationTokenSource(); + const cacheKey = `emptyFileEditorSearch-${defaultGenerator.nextId()}`; + const providerOptions: IAnythingQuickAccessProviderRunOptions = { + filesOnly: true, + includeOpenToSideButton: false, + fileSearchDelegate: this.createFileSearchDelegate(workspace, cacheKey, pickerLifetime.token), + }; + const disposables = new DisposableStore(); + this.pickerDisposables.value = disposables; + disposables.add(toDisposable(() => pickerLifetime.dispose(true))); + disposables.add(toDisposable(() => { + void this.searchService.clearCache(cacheKey); + })); + this.quickInputService.quickAccess.show(this.input.value, { + providerPrefix: '', + anchor: this.input.element, + anchorPosition: 'overlay', + preserveValue: true, + placeholder: localize('emptyFileEditor.searchPlaceholder', "Search files by name"), + providerOptions, + }); + const currentQuickInput = this.quickInputService.currentQuickInput; + if (currentQuickInput?.type !== QuickInputType.QuickPick) { + this.pickerDisposables.clear(); + return; + } + const picker = currentQuickInput as IQuickPick; + this.picker = picker; + this.pickerFocusedContext.set(true); + disposables.add(toDisposable(() => picker.hide())); + disposables.add(Event.once(this.quickInputService.onHide)(() => { + if (this.picker !== picker) { + return; + } + const restoreInputFocus = this.restoreInputFocusOnHide; + this.restoreInputFocusOnHide = false; + this.setInputValue(picker.value); + this.inputContainer.style.visibility = ''; + this.picker = undefined; + this.pickerFocusedContext.reset(); + this.pickerDisposables.clear(); + if (restoreInputFocus) { + this.input.focus(); + } + })); + + picker.ariaLabel = localize('emptyFileEditor.searchAriaLabel', "Search files in the session workspace"); + picker.ignoreFocusOut = false; + picker.valueSelection = [picker.value.length, picker.value.length]; + + disposables.add(picker.onDidChangeValue(value => { + this.setInputValue(value); + if (!value.trim()) { + this.restoreInputFocusOnHide = true; + picker.hide(); + } + })); + this.inputContainer.style.visibility = 'hidden'; + } + + private announceAccessibilityHelpHint(): void { + if (this.accessibilityHelpHintAnnounced || !this.accessibilityService.isScreenReaderOptimized()) { + return; + } + + const hint = this.accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.EmptyFileEditorSearch); + if (hint) { + this.accessibilityHelpHintAnnounced = true; + this.accessibilityService.status(hint); + } + } + + private setInputValue(value: string): void { + this.syncingPickerValue = true; + try { + this.input.value = value; + } finally { + this.syncingPickerValue = false; + } + } + + private createFileSearchDelegate(workspace: ISessionWorkspace | undefined, cacheKey: string, lifetimeToken: CancellationToken): IAnythingQuickAccessFileSearchDelegate { + const folders = workspace?.folders ?? []; + const isSearchable = (folder: ISessionFolder) => folder.workingDirectory.scheme === Schemas.file + || folder.workingDirectory.scheme === Schemas.vscodeRemote + || this.searchService.schemeHasFileSearchProvider(folder.workingDirectory.scheme); + const searchableFolders = folders.filter(isSearchable); + const virtualFolders = folders.filter(folder => !isSearchable(folder)); + let virtualFileIndex: Promise | undefined; + const getVirtualFileIndex = () => { + if (!virtualFileIndex) { + const index = this.createVirtualFileSearchIndex(workspace, virtualFolders, lifetimeToken); + virtualFileIndex = index; + void index.catch(() => { + if (virtualFileIndex === index) { + virtualFileIndex = undefined; + } + }); + } + return virtualFileIndex; + }; + + return { + search: (filePattern, token) => { + return this.searchFiles(searchableFolders, getVirtualFileIndex(), filePattern, cacheKey, token); + }, + getItemDescription: resource => this.getItemDescription(workspace, resource), + }; + } + + private async searchFiles(searchableFolders: readonly ISessionFolder[], virtualFileIndex: Promise, filePattern: string, cacheKey: string, token: CancellationToken): Promise { + const [searchResult, virtualIndex] = await Promise.all([ + searchableFolders.length + ? this.searchService.fileSearch(this.fileQueryBuilder.file(searchableFolders.map((folder, index) => ({ + uri: folder.workingDirectory, + name: folder.name, + index, + })), { + _reason: 'openFileHandler', + filePattern, + cacheKey, + sortByScore: true, + maxResults: MAX_SEARCH_RESULTS, + }), token) + : Promise.resolve({ results: [], messages: [], limitHit: false } satisfies ISearchComplete), + virtualFileIndex.catch(error => { + if (!token.isCancellationRequested) { + this.logService.error('[EmptyFileEditorSearch] Failed to index a virtual workspace.', error); + } + return { entries: [], limitHit: false }; + }), + ]); + if (token.isCancellationRequested) { + return { results: [], messages: [], limitHit: false }; + } + + const results: ISearchComplete['results'] = []; + const seen = new Set(); + const normalizedPattern = prepareQuery(filePattern).normalizedLowercase.replaceAll('\\', '/'); + const virtualResults = virtualIndex.entries + .filter(entry => !normalizedPattern || fuzzyContains(entry.searchPath, normalizedPattern)) + .map(entry => ({ resource: entry.resource })); + for (const result of [...searchResult.results, ...virtualResults]) { + const key = this.uriIdentityService.extUri.getComparisonKey(result.resource); + if (seen.has(key)) { + continue; + } + seen.add(key); + results.push(result); + } + + return { + results, + limitHit: searchResult.limitHit || virtualIndex.limitHit, + messages: searchResult.messages, + }; + } + + private async createVirtualFileSearchIndex(workspace: ISessionWorkspace | undefined, folders: readonly ISessionFolder[], token: CancellationToken): Promise { + const entries: IVirtualFileSearchEntry[] = []; + let limitHit = false; + let visitedEntries = 0; + const visitedDirectories = new Set(); + const queue = folders.map(folder => ({ + folder, + resource: folder.workingDirectory, + excludes: glob.parse(getExcludes(this.configurationService.getValue({ resource: folder.workingDirectory })) ?? {}, { trimForExclusions: true }), + })); + + for (let queueIndex = 0; queueIndex < queue.length && !token.isCancellationRequested; queueIndex++) { + const current = queue[queueIndex]; + const directoryKey = this.uriIdentityService.extUri.getComparisonKey(current.resource); + if (visitedDirectories.has(directoryKey)) { + continue; + } + visitedDirectories.add(directoryKey); + + let stat: IFileStat; + try { + stat = await this.fileService.resolve(current.resource); + } catch (error) { + if (toFileOperationResult(error as Error) === FileOperationResult.FILE_NOT_FOUND) { + continue; + } + throw error; + } + const children = stat.children?.slice().sort((first, second) => { + if (first.isDirectory !== second.isDirectory) { + return first.isDirectory ? -1 : 1; + } + return first.name.localeCompare(second.name); + }) ?? []; + const hasSibling = hasSiblingFn(() => children.map(child => child.name)); + + for (const child of children) { + if (token.isCancellationRequested) { + break; + } + const path = relativePath(current.folder.workingDirectory, child.resource); + const excludeMatch = path && current.excludes(path, child.name, hasSibling); + if (!path || typeof excludeMatch === 'string') { + continue; + } + if (visitedEntries >= MAX_VIRTUAL_WORKSPACE_ENTRIES) { + limitHit = true; + break; + } + visitedEntries++; + + if (child.isDirectory) { + queue.push({ ...current, resource: child.resource }); + } else { + const folderName = this.getFolderName(workspace, current.folder); + entries.push({ + resource: child.resource, + searchPath: folders.length > 1 ? `${folderName}/${path}` : path, + }); + } + } + if (limitHit) { + break; + } + } + + return { entries, limitHit }; + } + + private getItemDescription(workspace: ISessionWorkspace | undefined, resource: URI): string | undefined { + const folders = workspace?.folders ?? []; + let folder: ISessionFolder | undefined; + for (const candidate of folders) { + if (this.uriIdentityService.extUri.isEqualOrParent(resource, candidate.workingDirectory) + && (!folder || candidate.workingDirectory.path.length > folder.workingDirectory.path.length)) { + folder = candidate; + } + } + if (!folder) { + return undefined; + } + + const directory = relativePath(folder.workingDirectory, dirname(resource)); + return folders.length > 1 + ? directory ? `${this.getFolderName(workspace, folder)}/${directory}` : this.getFolderName(workspace, folder) + : directory; + } + + private getFolderName(workspace: ISessionWorkspace | undefined, folder: ISessionFolder): string { + return workspace?.folders[0] === folder ? workspace.label : folder.name; + } +} diff --git a/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css b/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css index 64ade699ef37ee..532bc676b182a2 100644 --- a/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css +++ b/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .empty-file-editor { + position: relative; height: 100%; display: flex; align-items: center; @@ -13,18 +14,46 @@ } .empty-file-editor-content { + position: absolute; + top: 40%; + transform: translateY(-50%); display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--vscode-spacing-size160); - padding: 20px; - max-width: 280px; + padding: var(--vscode-spacing-size200); + width: min(440px, calc(100% - var(--vscode-spacing-size400))); +} + +.empty-file-editor-search { + position: relative; + z-index: 1; + width: 100%; + height: var(--vscode-spacing-size400); +} + +.empty-file-editor-search-input { + position: relative; + width: 100%; + box-sizing: border-box; + padding: var(--vscode-spacing-size60) var(--vscode-spacing-size60) var(--vscode-spacing-size40); +} + +.empty-file-editor-search-input .monaco-inputbox { + border-radius: var(--vscode-cornerRadius-medium); } .empty-file-editor-actions { display: flex; align-items: center; justify-content: center; - margin-top: 2px; + gap: var(--vscode-spacing-size60); + margin-top: var(--vscode-spacing-size20); +} + +.empty-file-editor-actions .new-session-button-icon { + flex-shrink: 0; + margin-right: var(--vscode-spacing-size20); + font-size: var(--vscode-codiconFontSize-compact); } diff --git a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts index 8420432673e176..8c54eaf0f547ce 100644 --- a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts +++ b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts @@ -5,23 +5,36 @@ import assert from 'assert'; import { mainWindow } from '../../../../../base/browser/window.js'; +import { timeout } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; -import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { IAccessibleViewService } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { IMenuService, isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { MenuService } from '../../../../../platform/actions/common/menuService.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { ContextKeyExpression, ContextKeyValue, IContext } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpression, ContextKeyValue, IContext, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; +import { FileOperationError, FileOperationResult, IFileService } from '../../../../../platform/files/common/files.js'; +import product from '../../../../../platform/product/common/product.js'; +import { IQuickInputService, IQuickPick, IQuickPickItem, QuickInputType } from '../../../../../platform/quickinput/common/quickInput.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { TestExperimentTriggerTelemetryService } from '../../../../../platform/telemetry/test/common/experimentTriggerTestUtils.js'; import { editorBackground } from '../../../../../platform/theme/common/colorRegistry.js'; import { Extensions as ThemeServiceExtensions, IThemingRegistry } from '../../../../../platform/theme/common/themeService.js'; import { EditorInputCapabilities } from '../../../../../workbench/common/editor.js'; @@ -36,22 +49,31 @@ import { generateColorThemeCSS } from '../../../../../workbench/services/themes/ import { ColorThemeData } from '../../../../../workbench/services/themes/common/colorThemeData.js'; import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { IBrowserViewWorkbenchService } from '../../../../../workbench/contrib/browserView/common/browserView.js'; +import { NEW_UNTITLED_FILE_COMMAND_ID } from '../../../../../workbench/contrib/files/browser/fileConstants.js'; import { TERMINAL_VIEW_ID } from '../../../../../workbench/contrib/terminal/common/terminal.js'; import { openNewSearchEditor } from '../../../../../workbench/contrib/searchEditor/browser/searchEditorActions.js'; +import { IFileQuery, ISearchComplete, ISearchService } from '../../../../../workbench/services/search/common/search.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { Menus } from '../../../../browser/menus.js'; +import { SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING } from '../../../../common/sessionConfig.js'; import { ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js'; import { SessionChangesEditorInput } from '../../../changes/browser/sessionChangesEditorInput.js'; import { NewBrowserTabAction, NewChangesTabAction, NewFileTabAction, NewSearchTabAction } from '../../browser/addTabActions.js'; +import { EmptyFileEditor } from '../../browser/emptyFileEditor.js'; +import { emptyFileEditorConfiguration } from '../../browser/emptyFileEditor.contribution.js'; +import { EmptyFileEditorSearch, EmptyFileEditorSearchPickerFocusedContext } from '../../browser/emptyFileEditorSearch.js'; import { EmptyFileEditorInput, EmptyFileEditorSerializer } from '../../browser/emptyFileEditorInput.js'; import { EditorTabsVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext } from '../../../../../workbench/common/contextkeys.js'; -import { TestEnvironmentService, TestLayoutService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { TestEnvironmentService, TestLayoutService, workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { createFileStat } from '../../../../../workbench/test/common/workbenchTestServices.js'; import { IsQuickChatSessionContext, DesktopChangesTabAvailableContext, DesktopChangesTabMissingContext, DesktopFilesTabAvailableContext, DesktopFilesTabMissingContext } from '../../../../common/contextkeys.js'; // Import editor contribution to trigger action registration. import { SessionsTabStyleContribution } from '../../browser/editor.contribution.js'; +import '../../../../../workbench/contrib/search/browser/searchQuickAccess.contribution.js'; import '../../../../browser/media/workbench.css'; import '../../../../browser/parts/media/chatCompositeBar.css'; import '../../../../browser/parts/media/editorPart.css'; @@ -63,12 +85,41 @@ function appendElement(parent: HTMLElement, className: string): HTMLElement { return element; } +function registerEmptyFileEditorActions() { + if (MenuRegistry.getMenuItems(Menus.EmptyFileEditorActions).some(item => isIMenuItem(item) && item.command.id === NEW_UNTITLED_FILE_COMMAND_ID)) { + return Disposable.None; + } + return MenuRegistry.appendMenuItem(Menus.EmptyFileEditorActions, { + command: { + id: NEW_UNTITLED_FILE_COMMAND_ID, + title: 'New File', + icon: Codicon.newFile, + }, + group: 'navigation', + order: 1, + }); +} + +async function waitForCondition(condition: () => boolean, message: string): Promise { + const deadline = Date.now() + 2000; + while (!condition()) { + if (Date.now() >= deadline) { + throw new Error(message); + } + await timeout(10); + } +} + class TestResourceEditorInput extends EditorInput { override readonly typeId = 'test.sessions.resourceEditor'; constructor(private readonly _resource: URI) { super(); } override get resource(): URI { return this._resource; } } +function hasResource(value: unknown): value is { resource: URI } { + return typeof value === 'object' && value !== null && URI.isUri(Reflect.get(value, 'resource')); +} + suite('Sessions - Editor Contribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -448,16 +499,20 @@ suite('Sessions - Editor Contribution', () => { } function createWorkspace(...workingDirectories: URI[]): ISessionWorkspace { + return createWorkspaceWithFolders(workingDirectories.map(workingDirectory => ({ + root: workingDirectory, + workingDirectory, + name: workingDirectory.path, + description: undefined, + }))); + } + + function createWorkspaceWithFolders(folders: ISessionWorkspace['folders']): ISessionWorkspace { return { uri: URI.file('/repo/workspace.code-workspace'), label: 'workspace', icon: Codicon.rootFolder, - folders: workingDirectories.map(workingDirectory => ({ - root: workingDirectory, - workingDirectory, - name: workingDirectory.path, - description: undefined, - })), + folders, requiresWorkspaceTrust: false, isVirtualWorkspace: false, }; @@ -527,6 +582,552 @@ suite('Sessions - Editor Contribution', () => { }]); }); + test('registers empty file editor actions as an Insiders-default experiment-controlled setting', () => { + const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + if (!configurationRegistry.getConfigurationProperties()[SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING]) { + configurationRegistry.registerConfiguration(emptyFileEditorConfiguration); + store.add(toDisposable(() => configurationRegistry.deregisterConfigurations([emptyFileEditorConfiguration]))); + } + const property = configurationRegistry.getConfigurationProperties()[SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING]; + assert.deepStrictEqual({ + id: SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING, + type: property.type, + default: property.default, + scope: property.scope, + tags: property.tags, + experiment: property.experiment, + }, { + id: 'sessions.experimental.emptyFileEditorActions', + type: 'boolean', + default: product.quality === 'insider', + scope: ConfigurationScope.WINDOW, + tags: ['experimental', 'onExP'], + experiment: { mode: 'auto' }, + }); + }); + + test('empty file editor gates experimental actions and responds to setting changes', async () => { + const instantiationService = workbenchInstantiationService(undefined, store); + store.add(registerEmptyFileEditorActions()); + instantiationService.stub(IContextKeyService, new class extends MockContextKeyService { + override contextMatchesRules(): boolean { + return true; + } + }); + instantiationService.stub(IMenuService, store.add(instantiationService.createInstance(MenuService))); + const configurationService = new TestConfigurationService({ + [SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING]: false, + }); + store.add(configurationService.onDidChangeConfigurationEmitter); + instantiationService.stub(IConfigurationService, configurationService); + const telemetryService = new TestExperimentTriggerTelemetryService(); + instantiationService.stub(ITelemetryService, telemetryService); + const editor = store.add(instantiationService.createInstance(EmptyFileEditor, new class extends mock() { + override windowId = mainWindow.vscodeWindowId; + }())); + const container = appendElement(mainWindow.document.body, 'empty-file-editor-test'); + store.add({ dispose: () => container.remove() }); + + editor.create(container); + const disabledContent = Array.from(container.querySelector('.empty-file-editor-content')?.children ?? []).map(element => element.className); + await configurationService.setUserConfiguration(SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING, true); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: setting => setting === SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING, + source: ConfigurationTarget.USER, + affectedKeys: new Set([SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING]), + change: { keys: [SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING], overrides: [] }, + }); + + assert.deepStrictEqual({ + disabledContent, + enabledContent: Array.from(container.querySelector('.empty-file-editor-content')?.children ?? []).map(element => element.className), + description: container.querySelector('.sessions-empty-state-description')?.textContent, + triggers: telemetryService.triggers, + }, { + disabledContent: ['sessions-empty-state'], + enabledContent: ['sessions-empty-state', 'empty-file-editor-search', 'empty-file-editor-actions'], + description: 'Select a file from the Files view', + triggers: [`config.${SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING}`], + }); + }); + + test('empty file editor shows file search and new file action', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + store.add(registerEmptyFileEditorActions()); + instantiationService.stub(IContextKeyService, new class extends MockContextKeyService { + override contextMatchesRules(): boolean { + return true; + } + }); + const configurationService = new TestConfigurationService({ + [SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING]: true, + }); + store.add(configurationService.onDidChangeConfigurationEmitter); + instantiationService.stub(IConfigurationService, configurationService); + const telemetryService = new TestExperimentTriggerTelemetryService(); + instantiationService.stub(ITelemetryService, telemetryService); + const commands: { id: string; args: unknown[] }[] = []; + instantiationService.stub(ICommandService, new class extends mock() { + override executeCommand(id: string, ...args: unknown[]): Promise { + commands.push({ id, args }); + return Promise.resolve(undefined); + } + }); + instantiationService.stub(IMenuService, store.add(instantiationService.createInstance(MenuService))); + const editor = store.add(instantiationService.createInstance(EmptyFileEditor, new class extends mock() { + override windowId = mainWindow.vscodeWindowId; + }())); + const container = appendElement(mainWindow.document.body, 'empty-file-editor-test'); + store.add({ dispose: () => container.remove() }); + + editor.create(container); + const buttons = Array.from(container.querySelectorAll('.empty-file-editor-actions .monaco-button')); + buttons.forEach(button => button.click()); + + assert.deepStrictEqual({ + searchPlaceholder: container.querySelector('.empty-file-editor-search input')?.placeholder, + content: Array.from(container.querySelector('.empty-file-editor-content')?.children ?? []).map(element => element.className), + description: container.querySelector('.sessions-empty-state-description')?.textContent, + buttons: buttons.map(button => ({ + text: button.textContent, + ariaLabel: button.getAttribute('aria-label'), + icon: button.querySelector('.new-session-button-icon.codicon-new-file')?.getAttribute('aria-hidden'), + })), + commands, + triggers: telemetryService.triggers, + }, { + searchPlaceholder: 'Search files by name', + content: ['sessions-empty-state', 'empty-file-editor-search', 'empty-file-editor-actions'], + description: 'Select a file from the Files view', + buttons: [ + { text: 'New File', ariaLabel: 'New File', icon: 'true' }, + ], + commands: [ + { id: 'workbench.action.files.newUntitledFile', args: [] }, + ], + triggers: [`config.${SESSIONS_EMPTY_FILE_EDITOR_ACTIONS_SETTING}`], + }); + }); + + test('empty file editor search shows and opens matching files', async () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const queries: IFileQuery[] = []; + const clearedCacheKeys: string[] = []; + const opened: { resource: URI; preserveFocus: boolean | undefined; pinned: boolean | undefined }[] = []; + instantiationService.stub(ISearchService, new class extends mock() { + override async fileSearch(query: IFileQuery): Promise { + queries.push(query); + return { + results: query.filePattern?.startsWith('#') ? [ + { resource: URI.file('/repo/.worktrees/session/src/#app.ts') }, + ] : [ + { resource: URI.file('/repo/.worktrees/session/src/app.ts') }, + { resource: URI.file('/repo/.worktrees/session/test/app.test.ts') }, + ], + messages: [], + limitHit: false, + }; + } + override clearCache(cacheKey: string): Promise { + clearedCacheKeys.push(cacheKey); + return Promise.resolve(); + } + }); + instantiationService.stub(IEditorService, new class extends mock() { + override async openEditor(...args: unknown[]): Promise { + const editor = args[0]; + if (hasResource(editor)) { + const options = Reflect.get(editor, 'options') as IEditorOptions | undefined; + opened.push({ resource: editor.resource, preserveFocus: options?.preserveFocus, pinned: options?.pinned }); + } + return undefined; + } + }); + const container = appendElement(mainWindow.document.body, 'empty-file-editor-search empty-file-editor-search-test'); + container.style.width = '420px'; + store.add({ dispose: () => container.remove() }); + const search = store.add(instantiationService.createInstance(EmptyFileEditorSearch, container)); + search.setWorkspace(createWorkspaceWithFolders([{ + root: URI.file('/repo'), + workingDirectory: URI.file('/repo/.worktrees/session'), + name: 'session', + description: undefined, + }])); + + const input = container.querySelector('input'); + assert.ok(input); + const inputBox = container.querySelector('.monaco-inputbox'); + assert.ok(inputBox); + const getInputBounds = () => { + const { left, top, width, height } = inputBox.getBoundingClientRect(); + return { left, top, width, height }; + }; + const inputBoundsBeforeSearch = getInputBounds(); + input.value = 'a'; + input.dispatchEvent(new mainWindow.Event('input', { bubbles: true })); + const pickerTextInput = mainWindow.document.querySelector('.quick-input-widget.quick-input-widget-overlay .quick-input-filter input'); + assert.ok(pickerTextInput); + pickerTextInput.setRangeText('p', pickerTextInput.selectionStart ?? 0, pickerTextInput.selectionEnd ?? 0, 'end'); + pickerTextInput.dispatchEvent(new mainWindow.Event('input', { bubbles: true })); + pickerTextInput.setRangeText('p', pickerTextInput.selectionStart ?? 0, pickerTextInput.selectionEnd ?? 0, 'end'); + pickerTextInput.dispatchEvent(new mainWindow.Event('input', { bubbles: true })); + await waitForCondition(() => queries.length >= 1 + && mainWindow.document.querySelectorAll('.quick-input-widget.quick-input-widget-overlay .quick-input-list .monaco-list-row').length === 2, + 'Expected file search results to appear'); + const valueAfterPickerOpened = pickerTextInput.value; + + const picker = mainWindow.document.querySelector('.quick-input-widget.quick-input-widget-overlay'); + const pickerInput = picker?.querySelector('.quick-input-filter'); + const rows = Array.from(picker?.querySelectorAll('.quick-input-list .monaco-list-row') ?? []); + const pickerInputBounds = pickerInput?.getBoundingClientRect(); + const pickerHelpContext = instantiationService.get(IContextKeyService).getContextKeyValue(EmptyFileEditorSearchPickerFocusedContext.key); + const activePicker = instantiationService.get(IQuickInputService).currentQuickInput; + assert.strictEqual(activePicker?.type, QuickInputType.QuickPick); + (activePicker as IQuickPick).accept(true); + await waitForCondition(() => opened.length === 1, 'Expected the background acceptance to open the active file'); + const backgroundAcceptState = { + pickerVisible: picker?.style.display !== 'none', + preserveFocus: opened[0].preserveFocus, + pinned: opened[0].pinned, + }; + (activePicker as IQuickPick).value = '#app'; + await waitForCondition(() => queries.some(query => query.filePattern === '#app') + && mainWindow.document.querySelectorAll('.quick-input-widget.quick-input-widget-overlay .quick-input-list .monaco-list-row').length === 1, + 'Expected the literal prefixed filename query to stay with file search'); + const prefixedRows = Array.from( + mainWindow.document.querySelectorAll('.quick-input-widget.quick-input-widget-overlay .quick-input-list .monaco-list-row') + ).map(row => row.textContent); + const queryCountBeforeMultiTerm = queries.length; + (activePicker as IQuickPick).value = 'app test'; + await waitForCondition(() => queries.length > queryCountBeforeMultiTerm + && mainWindow.document.querySelectorAll('.quick-input-widget.quick-input-widget-overlay .quick-input-list .monaco-list-row').length === 1, + 'Expected multi-term filtering to settle'); + const multiTermRows = Array.from( + mainWindow.document.querySelectorAll('.quick-input-widget.quick-input-widget-overlay .quick-input-list .monaco-list-row') + ).map(row => row.textContent); + (activePicker as IQuickPick).accept(); + await waitForCondition(() => opened.length === 2, 'Expected the foreground acceptance to open the active file'); + await waitForCondition(() => picker?.style.display === 'none', 'Expected the picker to close after foreground acceptance'); + await waitForCondition(() => queries.every(query => typeof query.cacheKey === 'string' && clearedCacheKeys.includes(query.cacheKey)), + 'Expected the picker-scoped search cache to be cleared'); + input.blur(); + await waitForCondition(() => instantiationService.get(IContextKeyService).getContextKeyValue(EmptyFileEditorSearchPickerFocusedContext.key) === false, + 'Expected the search help context to clear after focus leaves'); + + assert.deepStrictEqual({ + query: queries[0] && { + folders: queries[0].folderQueries.map(folder => folder.folder.toString()), + filePattern: queries[0].filePattern, + maxResults: queries[0].maxResults, + hasCacheKey: typeof queries[0].cacheKey === 'string', + }, + queryPatterns: Array.from(new Set(queries.map(query => query.filePattern))), + cacheKeyIsPickerScoped: new Set(queries.map(query => query.cacheKey)).size === 1, + searchedCacheKeysCleared: queries.every(query => typeof query.cacheKey === 'string' && clearedCacheKeys.includes(query.cacheKey)), + rows: rows.map(row => row.textContent), + prefixedRows, + multiTermRows, + usesFileIcon: rows[0]?.querySelector('.monaco-icon-label')?.classList.contains('file-icon'), + usesRealQuickPick: picker?.classList.contains('quick-input-widget-overlay'), + hasItemButtons: rows.some(row => !!row.querySelector('.quick-input-list-entry.has-actions')), + valueAfterPickerOpened, + inputBoundsStable: pickerInputBounds?.left === inputBoundsBeforeSearch.left + && pickerInputBounds.top === inputBoundsBeforeSearch.top + && pickerInputBounds.width === inputBoundsBeforeSearch.width + && pickerInputBounds.height === inputBoundsBeforeSearch.height, + pickerHelpContext, + backgroundAcceptState, + opened: opened.map(({ resource, preserveFocus, pinned }) => ({ resource: resource.toString(), preserveFocus, pinned })), + pickerHelpContextAfterClose: instantiationService.get(IContextKeyService).getContextKeyValue(EmptyFileEditorSearchPickerFocusedContext.key), + }, { + query: { + folders: [URI.file('/repo/.worktrees/session').toString()], + filePattern: 'app', + maxResults: 512, + hasCacheKey: true, + }, + queryPatterns: ['app', '#app'], + cacheKeyIsPickerScoped: true, + searchedCacheKeysCleared: true, + rows: ['app.tssrc', 'app.test.tstest'], + prefixedRows: ['#app.tssrc'], + multiTermRows: ['app.test.tstest'], + usesFileIcon: true, + usesRealQuickPick: true, + hasItemButtons: false, + valueAfterPickerOpened: 'app', + inputBoundsStable: true, + pickerHelpContext: true, + backgroundAcceptState: { + pickerVisible: true, + preserveFocus: true, + pinned: true, + }, + opened: [ + { resource: URI.file('/repo/.worktrees/session/src/app.ts').toString(), preserveFocus: true, pinned: true }, + { resource: URI.file('/repo/.worktrees/session/test/app.test.ts').toString(), preserveFocus: false, pinned: true }, + ], + pickerHelpContextAfterClose: false, + }); + }); + + test('empty file editor search combines session working directories before native ranking', async () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const queries: IFileQuery[] = []; + instantiationService.stub(ISearchService, new class extends mock() { + override async fileSearch(query: IFileQuery): Promise { + queries.push(query); + return { results: [], messages: [], limitHit: false }; + } + override clearCache(): Promise { + return Promise.resolve(); + } + }); + const container = appendElement(mainWindow.document.body, 'empty-file-editor-search'); + store.add({ dispose: () => container.remove() }); + const search = store.add(instantiationService.createInstance(EmptyFileEditorSearch, container)); + search.setWorkspace(createWorkspaceWithFolders([ + { + root: URI.file('/repo'), + workingDirectory: URI.file('/repo/.worktrees/session'), + name: 'session', + description: undefined, + }, + { + root: URI.file('/other'), + workingDirectory: URI.file('/other/.worktrees/session'), + name: 'other', + description: undefined, + }, + ])); + + const input = container.querySelector('input'); + assert.ok(input); + input.value = 'file'; + input.dispatchEvent(new mainWindow.Event('input', { bubbles: true })); + await waitForCondition(() => queries.length === 1, 'Expected a combined workspace search'); + + assert.deepStrictEqual({ + folders: queries[0].folderQueries.map(folderQuery => folderQuery.folder.toString()), + maxResults: queries[0].maxResults, + }, { + folders: [ + 'file:///repo/.worktrees/session', + 'file:///other/.worktrees/session', + ], + maxResults: 512, + }); + }); + + test('empty file editor search announces its accessibility help hint once', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const announcements: string[] = []; + let hint: string | null = null; + instantiationService.stub(IAccessibilityService, new class extends mock() { + override isScreenReaderOptimized(): boolean { + return true; + } + override status(message: string): void { + announcements.push(message); + } + }); + instantiationService.stub(IAccessibleViewService, new class extends mock() { + override getOpenAriaHint(): string | null { + return hint; + } + }); + const container = appendElement(mainWindow.document.body, 'empty-file-editor-search'); + store.add({ dispose: () => container.remove() }); + store.add(instantiationService.createInstance(EmptyFileEditorSearch, container)); + const input = container.querySelector('input'); + assert.ok(input); + + input.focus(); + input.blur(); + hint = 'Inspect accessibility help'; + input.dispatchEvent(new mainWindow.FocusEvent('focus')); + input.dispatchEvent(new mainWindow.FocusEvent('focus')); + + assert.deepStrictEqual(announcements, ['Inspect accessibility help']); + }); + + test('empty file editor search traverses virtual session workspaces', async () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const root = URI.parse('test://workspace/root'); + const source = root.with({ path: '/root/src' }); + const excluded = root.with({ path: '/root/ignored' }); + const removed = root.with({ path: '/root/removed' }); + const resource = root.with({ path: '/root/src/app.test.ts' }); + const generatedResource = root.with({ path: '/root/src/app.test.js' }); + const excludedResource = root.with({ path: '/root/ignored/secret.ts' }); + const searchQueries: IFileQuery[] = []; + const opened: URI[] = []; + let excludedResolved = false; + const configurationService = new TestConfigurationService({ + files: { + exclude: { + '**/ignored': true, + '**/*.js': { when: '$(basename).ts' }, + }, + }, + }); + store.add(configurationService.onDidChangeConfigurationEmitter); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IFileService, new class extends mock() { + override async resolve(candidate: URI) { + if (candidate.toString() === root.toString()) { + return createFileStat(root, false, false, true, false, [ + { resource: source, isFile: false, isDirectory: true }, + { resource: excluded, isFile: false, isDirectory: true }, + { resource: removed, isFile: false, isDirectory: true }, + ]); + } + if (candidate.toString() === source.toString()) { + return createFileStat(source, false, false, true, false, [ + { resource, isFile: true, isDirectory: false }, + { resource: generatedResource, isFile: true, isDirectory: false }, + { resource: root, isFile: false, isDirectory: true }, + ]); + } + if (candidate.toString() === removed.toString()) { + throw new FileOperationError('Removed while indexing', FileOperationResult.FILE_NOT_FOUND); + } + if (candidate.toString() === excluded.toString()) { + excludedResolved = true; + return createFileStat(excluded, false, false, true, false, [{ resource: excludedResource, isFile: true, isDirectory: false }]); + } + return createFileStat(candidate); + } + }); + instantiationService.stub(ISearchService, new class extends mock() { + override schemeHasFileSearchProvider(): boolean { + return false; + } + override clearCache(): Promise { + return Promise.resolve(); + } + override async fileSearch(query: IFileQuery): Promise { + searchQueries.push(query); + return { results: [], messages: [], limitHit: false }; + } + }); + instantiationService.stub(IEditorService, new class extends mock() { + override async openEditor(...args: unknown[]): Promise { + const editor = args[0]; + if (hasResource(editor)) { + opened.push(editor.resource); + } + return undefined; + } + }); + const container = appendElement(mainWindow.document.body, 'empty-file-editor-search'); + store.add({ dispose: () => container.remove() }); + const search = store.add(instantiationService.createInstance(EmptyFileEditorSearch, container)); + search.setWorkspace(createWorkspace(root)); + + const input = container.querySelector('input'); + assert.ok(input); + input.value = 'app'; + input.dispatchEvent(new mainWindow.Event('input', { bubbles: true })); + await waitForCondition(() => mainWindow.document.querySelectorAll('.quick-input-widget.quick-input-widget-overlay .quick-input-list .monaco-list-row').length === 1, + 'Expected sibling-dependent excludes to remove the generated file'); + const pickerInput = mainWindow.document.querySelector('.quick-input-widget.quick-input-widget-overlay .quick-input-filter input'); + assert.ok(pickerInput); + pickerInput.value = '"app*.ts"'; + pickerInput.dispatchEvent(new mainWindow.Event('input', { bubbles: true })); + await waitForCondition(() => mainWindow.document.querySelectorAll('.quick-input-widget.quick-input-widget-overlay .quick-input-list .monaco-list-row').length === 1, + 'Expected quoted wildcard syntax to match the virtual file'); + const rows = Array.from(mainWindow.document.querySelectorAll('.quick-input-widget.quick-input-widget-overlay .quick-input-list .monaco-list-row')); + rows[0]?.dispatchEvent(new mainWindow.MouseEvent('dblclick', { bubbles: true })); + await timeout(0); + + assert.deepStrictEqual({ + rows: rows.map(row => row.textContent), + opened: opened.map(candidate => candidate.toString()), + searchQueries: searchQueries.length, + excludedResolved, + query: pickerInput.value, + }, { + rows: ['app.test.tssrc'], + opened: [resource.toString()], + searchQueries: 0, + excludedResolved: false, + query: '"app*.ts"', + }); + }); + + test('empty file editor search preserves provider results and retries failed virtual indexes', async () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const nativeRoot = URI.file('/repo'); + const virtualRoot = URI.parse('test://workspace/root'); + let virtualResolveAttempts = 0; + const loggedErrors: unknown[] = []; + const fileSearchPatterns: string[] = []; + instantiationService.stub(IFileService, new class extends mock() { + override async resolve(): Promise { + virtualResolveAttempts++; + throw new Error('Virtual provider unavailable'); + } + }); + instantiationService.stub(ILogService, new class extends mock() { + override error(error: unknown): void { + loggedErrors.push(error); + } + }); + instantiationService.stub(ISearchService, new class extends mock() { + override schemeHasFileSearchProvider(): boolean { + return false; + } + override clearCache(): Promise { + return Promise.resolve(); + } + override async fileSearch(query: IFileQuery): Promise { + fileSearchPatterns.push(query.filePattern ?? ''); + return { + results: [{ resource: URI.joinPath(nativeRoot, `${query.filePattern}.ts`) }], + messages: [], + limitHit: false, + }; + } + }); + const container = appendElement(mainWindow.document.body, 'empty-file-editor-search'); + store.add({ dispose: () => container.remove() }); + const search = store.add(instantiationService.createInstance(EmptyFileEditorSearch, container)); + search.setWorkspace(createWorkspaceWithFolders([ + { root: nativeRoot, workingDirectory: nativeRoot, name: 'native', description: undefined }, + { root: virtualRoot, workingDirectory: virtualRoot, name: 'virtual', description: undefined }, + ])); + + const input = container.querySelector('input'); + assert.ok(input); + input.value = 'app'; + input.dispatchEvent(new mainWindow.Event('input', { bubbles: true })); + await waitForCondition(() => virtualResolveAttempts >= 1 + && mainWindow.document.querySelectorAll('.quick-input-widget.quick-input-widget-overlay .quick-input-list .monaco-list-row').length === 1, + 'Expected native results despite the virtual provider failure'); + const firstRows = Array.from(mainWindow.document.querySelectorAll('.quick-input-widget.quick-input-widget-overlay .quick-input-list .monaco-list-row')).map(row => row.textContent); + const picker = instantiationService.get(IQuickInputService).currentQuickInput; + assert.strictEqual(picker?.type, QuickInputType.QuickPick); + (picker as IQuickPick).value = 'other'; + await waitForCondition(() => fileSearchPatterns.includes('other'), + 'Expected a failed virtual index to be retried'); + const secondRows = Array.from(mainWindow.document.querySelectorAll('.quick-input-widget.quick-input-widget-overlay .quick-input-list .monaco-list-row')).map(row => row.textContent); + + assert.deepStrictEqual({ + firstRows, + secondRows, + virtualResolveAttempts, + loggedErrors: loggedErrors.length, + fileSearchPatterns, + }, { + firstRows: ['app.tsworkspace'], + secondRows: ['other.tsworkspace'], + virtualResolveAttempts: 2, + loggedErrors: 2, + fileSearchPatterns: ['app', 'other'], + }); + }); + test('new browser tab action opens a pinned browser editor', async () => { const instantiationService = store.add(new TestInstantiationService()); const browserInput = new class extends mock() { }; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index e987cd279c3926..e4554f29fe8af7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -1087,6 +1087,7 @@ class AdditionalChat extends Disposable { workingDirectories?.map(directory => mapWorkingDirectoryUri(URI.parse(directory))), output?.getFolderGitHubInfo(reader), output?.getScopeGitState(reader, workingDirectories), + this._isArchived.read(reader), ); }); const interactivity = derived(reader => effectiveChatInteractivity( diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index c3e41d93fd00c3..c85238d318b703 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -10339,6 +10339,54 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('an archived chat keeps its folder, branch, and repository after its folder is detached from the session', () => { + const provider = createProvider(disposables, agentHost); + const primaryDirectory = URI.file('/work/MyRepo'); + const peerDirectory = URI.file('/work/api.worktrees/task'); + const session = setupMultiChatSession(provider, 'multi-archived-detached', [primaryDirectory]); + const sessionUri = AgentSession.uri('copilotcli', 'multi-archived-detached').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer-1'); + const peerScopeId = 'host-scope-api'; + let meta: SessionState['_meta'] = { + githubData: { [primaryDirectory.toString()]: { owner: 'octo', repo: 'MyRepo' }, [peerDirectory.toString()]: { owner: 'octo', repo: 'api' } }, + gitData: { [peerScopeId]: { branchName: 'feature/api', hasGitRemote: true } }, + }; + meta = withWorkingDirectoryKey(meta, primaryDirectory.toString(), primaryDirectory.toString()); + meta = withWorkingDirectoryKey(meta, peerDirectory.toString(), peerDirectory.toString()); + meta = withWorkingDirectoryScopeId(meta, [peerDirectory.toString()], peerScopeId); + const setPeerArchived = (archived: boolean) => agentHost.setSessionState('multi-archived-detached', 'copilotcli', makeState([ + makeChatSummary(defaultChat, '', ProtocolSessionStatus.Idle, [primaryDirectory.toString()]), + makeChatSummary(peerChat, 'Peer', archived ? ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsArchived : ProtocolSessionStatus.Idle, [peerDirectory.toString()]), + ], { defaultChat, meta, workingDirectories: [primaryDirectory.toString()] })); + const peerWorkspace = () => { + const workspace = session.chats.get().find(chat => chat.resource.fragment === 'peer-1')?.workspace.get(); + return workspace && { + label: workspace.label, + folders: workspace.folders.map(folder => ({ + root: folder.root.toString(), + workingDirectory: folder.workingDirectory.toString(), + branch: folder.gitRepository?.branchName, + repository: folder.gitRepository?.gitHubInfo.get()?.repo, + })), + }; + }; + + setPeerArchived(true); + const archived = peerWorkspace(); + setPeerArchived(false); + const unarchivedBeforeAttach = peerWorkspace(); + + assert.deepStrictEqual({ archived, unarchivedBeforeAttach }, { + archived: { + label: 'api', + folders: [{ root: URI.file('/work/api').toString(), workingDirectory: peerDirectory.toString(), branch: 'feature/api', repository: 'api' }], + }, + // Until the host attaches the folder again, an unarchived chat's folder is still loading. + unarchivedBeforeAttach: undefined, + }); + }); + test('does not match another folder by path case once the host published the folder key', () => { const provider = createProvider(disposables, agentHost); const upperDirectory = URI.file('/work/API'); diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionWorktreeCleanupService.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionWorktreeCleanupService.ts index 385496d5cc2645..cbb2e16167c82b 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionWorktreeCleanupService.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionWorktreeCleanupService.ts @@ -86,6 +86,7 @@ export class SessionWorktreeCleanupService extends Disposable implements ISessio readonly progressListeners: Set<(progress: IWorktreeMeasurementProgress) => void>; readonly getProgress: () => IWorktreeMeasurementProgress | undefined; } | undefined; + private _foregroundMeasurement: Promise | undefined; private _refreshPromise: Promise | undefined; private _activated = false; private _dismissed = false; @@ -118,7 +119,6 @@ export class SessionWorktreeCleanupService extends Disposable implements ISessio })); this._register(this.sessionsManagementService.onDidArchiveSession(() => { this._lastScanAt = 0; - this._lastMeasurementAt = 0; })); const refreshScheduler = this._register(new RunOnceScheduler(() => { if (this._activated) { @@ -386,21 +386,37 @@ export class SessionWorktreeCleanupService extends Disposable implements ISessio ) { return this._getWorktrees(minimumAgeDays); } - const cancellation = new CancellationTokenSource(); - try { - return await this.progressService.withProgress({ + + if (!this._foregroundMeasurement) { + const cancellation = new CancellationTokenSource(); + const foregroundMeasurement = this.progressService.withProgress({ location: ProgressLocation.Notification, title: localize('worktreeCleanup.measuringProgress', "Measuring agent session worktrees..."), delay: 300, cancellable: true, - }, progress => this._getWorktrees(minimumAgeDays, cancellation.token, measurementProgress => { - progress.report({ - message: localize('worktreeCleanup.measuringProgressCount', "Scanned {0} of {1} worktrees", measurementProgress.scannedWorktrees, measurementProgress.totalWorktrees), + }, async progress => { + await this._getMeasuredWorktrees(minimumAgeDays, cancellation.token, measurementProgress => { + progress.report({ + message: localize('worktreeCleanup.measuringProgressCount', "Scanned {0} of {1} worktrees", measurementProgress.scannedWorktrees, measurementProgress.totalWorktrees), + }); }); - }), () => cancellation.cancel()); - } finally { - cancellation.dispose(); + }, () => cancellation.cancel()).finally(() => cancellation.dispose()); + this._foregroundMeasurement = foregroundMeasurement; + foregroundMeasurement.then( + () => { + if (this._foregroundMeasurement === foregroundMeasurement) { + this._foregroundMeasurement = undefined; + } + }, + () => { + if (this._foregroundMeasurement === foregroundMeasurement) { + this._foregroundMeasurement = undefined; + } + }, + ); } + await this._foregroundMeasurement; + return this._getWorktrees(minimumAgeDays); } /** @@ -452,7 +468,6 @@ export class SessionWorktreeCleanupService extends Disposable implements ISessio : localize('worktreeCleanup.archived.many', "Started cleanup for {0} worktrees.", worktreeCount); status(message); this._lastScanAt = 0; - this._lastMeasurementAt = 0; return true; } diff --git a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionWorktreeCleanupService.test.ts b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionWorktreeCleanupService.test.ts index 09c41b708b5a74..dff645e94a0a4b 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionWorktreeCleanupService.test.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionWorktreeCleanupService.test.ts @@ -9,7 +9,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { isCancellationError } from '../../../../../base/common/errors.js'; -import { constObservable } from '../../../../../base/common/observable.js'; +import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -322,7 +322,7 @@ suite('SessionWorktreeCleanupService', () => { }); }); - test('coalesces concurrent measurements while giving each caller cancellable progress', async () => { + test('coalesces concurrent editor loads into one foreground measurement', async () => { const diskUsage = new DeferredPromise(); const progressService = new TestProgressService(); let scanCount = 0; @@ -361,10 +361,6 @@ suite('SessionWorktreeCleanupService', () => { title: 'Measuring agent session worktrees...', cancellable: true, }, - { - title: 'Measuring agent session worktrees...', - cancellable: true, - }, ], states: ['eligible', 'eligible'], }); @@ -453,6 +449,57 @@ suite('SessionWorktreeCleanupService', () => { }); }); + test('reuses measured remaining worktrees after cleanup', async () => { + const archived = observableValue('archived', false); + const onDidArchiveSession = disposables.add(new Emitter()); + const selected = createSession('selected', oldDate(), SessionStatus.Completed, false, true, 1, archived); + const remaining = createSession('remaining', oldDate()); + let scanCount = 0; + const progressService = new TestProgressService(); + const service = disposables.add(createService( + [selected, remaining], + true, + () => { + scanCount++; + return ByteSize.GB; + }, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + Event.None, + { + progressService, + dialogService: upcastPartial({ confirm: async () => ({ confirmed: true }) }), + onArchive: async session => { + archived.set(true, undefined); + onDidArchiveSession.fire(session); + }, + onDidArchiveSession: onDidArchiveSession.event, + }, + )); + + const worktrees = await service.getWorktrees(14); + await service.cleanupWorktrees([{ + session: selected, + sizeBytes: worktrees[0].sizeBytes!, + worktreeCount: worktrees[0].worktreeCount, + }]); + const reloaded = await service.getWorktrees(14); + + assert.deepStrictEqual({ + scanCount, + measurementProgressCount: progressService.options.filter(option => option.title === 'Measuring agent session worktrees...').length, + sessionIds: reloaded.map(worktree => worktree.session.sessionId), + }, { + scanCount: 2, + measurementProgressCount: 1, + sessionIds: ['remaining'], + }); + }); + test('cancels a foreground wait while disk measurement remains unresolved', async () => { const diskUsage = new DeferredPromise(); const progressService = new TestProgressService(); @@ -597,6 +644,7 @@ function createService( readonly progressService?: IProgressService; readonly dialogService?: IDialogService; readonly onArchive?: (session: ISession) => Promise; + readonly onDidArchiveSession?: Event; } = {}, ): SessionWorktreeCleanupService { return new SessionWorktreeCleanupService( @@ -604,7 +652,7 @@ function createService( getSessions: () => sessions, getSessionWorktreeDiskUsage: async (session, token) => sizeForSession(session, token), archiveSession: async session => overrides.onArchive?.(session), - onDidArchiveSession: Event.None, + onDidArchiveSession: overrides.onDidArchiveSession ?? Event.None, onDidChangeSessions, }), upcastPartial({ activeSession: constObservable(activeSession) }), @@ -655,7 +703,7 @@ function oldDate(): Date { return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); } -function createSession(id: string, updatedAt: Date, status = SessionStatus.Completed, archived = false, hasWorktree = true, worktreeCount = 1): ISession { +function createSession(id: string, updatedAt: Date, status = SessionStatus.Completed, archived = false, hasWorktree = true, worktreeCount = 1, archivedState?: IObservable): ISession { const resource = URI.parse(`test:/${id}`); const chat = upcastPartial({ resource, @@ -682,7 +730,7 @@ function createSession(id: string, updatedAt: Date, status = SessionStatus.Compl updatedAt: constObservable(updatedAt), title: constObservable(id), status: constObservable(status), - isArchived: constObservable(archived), + isArchived: archivedState ?? constObservable(archived), isRead: constObservable(true), workspace: constObservable({ uri: URI.file(`/repo/${id}`), diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 631e9da792e91a..88d1d0136b5b29 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -23,6 +23,7 @@ import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurati import { SESSIONS_LIST_SHOW_ARCHIVED_BY_DEFAULT_SETTING, SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING, SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING } from './views/sessionsList.js'; import { SessionsMouseNavigationContribution } from './sessionsMouseNavigation.js'; import './sessionDetailsAction.js'; +import './updateSessionFoldersAction.js'; import { SESSIONS_MARK_AS_DONE_CONFETTI_SETTING } from '../../../../platform/chat/common/sessionArchiveActions.js'; import { SessionsWindowNotifier } from './sessionsWindowNotifier.js'; import { ExternalSessionApplicationBadgeMode, SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING, SESSIONS_SIDEBAR_SEPARATE_NAVIGATION_SETTING, SESSIONS_LIST_REARRANGE_TREATMENT, SessionsChatTabsMode, USE_WORKTREE_SETTING } from '../../../common/sessionConfig.js'; diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 6947b3580a51c5..6340b0a5f43b94 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -1311,8 +1311,8 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ /** * Base class for the compact pill button rendered in the sessions UI (e.g. the "New" session/chat - * buttons, the empty file editor's "Search Files" button). Subclasses provide the command id, - * label and hover/aria text. + * buttons and the empty file editor actions). Subclasses provide the command id, + * label, optional icon and hover/aria text. */ export abstract class CompactButtonActionViewItem extends BaseActionViewItem { @@ -1331,6 +1331,11 @@ export abstract class CompactButtonActionViewItem extends BaseActionViewItem { /** Visible pill label (e.g. "New", "New Chat"). */ protected abstract get label(): string; + /** Optional leading icon for the pill. */ + protected get icon(): ThemeIcon | undefined { + return undefined; + } + /** Hover text; receives the resolved keybinding label, if any. */ protected abstract getHoverContent(keybindingLabel: string | undefined): string; @@ -1391,6 +1396,13 @@ export abstract class CompactButtonActionViewItem extends BaseActionViewItem { })); const buttonLabel = $('span.new-session-button-label', undefined, this.label); + const icon = this.icon; + if (icon) { + const iconElement = $('span.new-session-button-icon'); + iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); + iconElement.setAttribute('aria-hidden', 'true'); + buttonLabel.prepend(iconElement); + } const keybindingHint = $('span.new-session-keybinding-hint'); const keybindingHintLabel = this.showKeybindingHint ? this._register(new KeybindingLabel(keybindingHint, OS, { diff --git a/src/vs/sessions/contrib/sessions/browser/updateSessionFoldersAction.ts b/src/vs/sessions/contrib/sessions/browser/updateSessionFoldersAction.ts new file mode 100644 index 00000000000000..a18f7f09912e50 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/updateSessionFoldersAction.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toErrorMessage } from '../../../../base/common/errorMessage.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { getChatCapabilities } from '../../../services/sessions/common/session.js'; + +/** + * Re-applies the archived state of every archived chat in the unarchived + * sessions, so the provider detaches those chats' folders from their session + * workspace. Repairs sessions whose chats were archived before archiving + * detached their folders. + */ +class UpdateSessionFoldersWithChatsAction extends Action2 { + + static readonly ID = 'sessions.updateSessionFoldersWithChats'; + + constructor() { + super({ + id: UpdateSessionFoldersWithChatsAction.ID, + title: localize2('sessions.updateSessionFoldersWithChats', "Update Session Folders with Chats"), + category: Categories.Developer, + f1: true, + precondition: ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.enabled), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const notificationService = accessor.get(INotificationService); + const logService = accessor.get(ILogService); + + let updated = 0; + let failed = 0; + for (const session of sessionsManagementService.getSessions()) { + if (session.isArchived.get()) { + continue; + } + for (const chat of session.chats.get()) { + if (!chat.isArchived.get() || !getChatCapabilities(chat, session, undefined).canArchive) { + continue; + } + try { + await sessionsManagementService.archiveChat(session, chat); + updated++; + } catch (error) { + failed++; + logService.error(`[UpdateSessionFoldersWithChats] Failed to update chat ${chat.resource.toString()}: ${toErrorMessage(error)}`); + } + } + } + + if (failed) { + notificationService.info(localize('sessions.updateSessionFoldersWithChats.partial', "Updated folders for {0} archived chat(s). {1} chat(s) could not be updated; see the log for details.", updated, failed)); + } else if (updated === 1) { + notificationService.info(localize('sessions.updateSessionFoldersWithChats.doneOne', "Updated folders for 1 archived chat.")); + } else { + notificationService.info(localize('sessions.updateSessionFoldersWithChats.done', "Updated folders for {0} archived chats.", updated)); + } + } +} + +registerAction2(UpdateSessionFoldersWithChatsAction); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index db46207ae4e833..3eb352fa255f6f 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -3615,6 +3615,7 @@ export class SessionsList extends Disposable implements ISessionsList { private navigationSections: readonly ISessionSection[] = []; private sessions: ISession[] = []; private readonly sessionChatsObserver = this._register(new MutableDisposable()); + private readonly sessionChatsUpdate = this._register(new MutableDisposable()); private readonly activeSessionUpdate = this._register(new MutableDisposable()); private readonly nestedSessionOrderUpdate = this._register(new MutableDisposable()); private readonly collapsedSessionResources: Set; @@ -4431,6 +4432,7 @@ export class SessionsList extends Disposable implements ISessionsList { refresh(): void { this.sessions = this._sessionsManagementService.getSessions(); + this.sessionChatsUpdate.clear(); let initialized = false; this.sessionChatsObserver.value = autorun(reader => { for (const session of this.sessions) { @@ -4439,7 +4441,11 @@ export class SessionsList extends Disposable implements ISessionsList { session.application.read(reader); } if (initialized && this.visible) { - this.update(); + this.sessionChatsUpdate.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.listContainer), () => { + if (this.visible) { + this.update(); + } + }); } initialized = true; }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 9205c91387132e..e5f85e0b950d0e 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -2917,11 +2917,12 @@ suite('Sessions - SessionsList', () => { }); } - test('reacts to external identity and hides the section when its sessions are filtered out', () => { + test('reacts to external identity and hides the section when its sessions are filtered out', async () => { const session = createTestSession('Session', { workspaceLabel: 'Alpha' }); const { list, container } = renderList([session.session], SessionsGrouping.Workspace, { groupExternalSessions: true }); const before = sectionLabels(container); session.isExternal.set(true, undefined); + await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); const external = sectionLabels(container); list.filters.setExcluded({ kind: 'harness', id: 'copilot' }, true); const filtered = sectionLabels(container); @@ -4582,7 +4583,7 @@ suite('Sessions - SessionsList', () => { compact = false, configure?: (instantiationService: TestInstantiationService) => void, sorting = SessionsSorting.Created, - ): { readonly container: HTMLElement; readonly list: SessionsList; readonly managementService: TestSessionsManagementService } { + ): { readonly container: HTMLElement; readonly list: TestSessionsList; readonly managementService: TestSessionsManagementService } { const harness = createListHarness(disposables, [session], instantiationService => { if (enableMotion) { instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { @@ -4592,7 +4593,7 @@ suite('Sessions - SessionsList', () => { configure?.(instantiationService); }); const container = harness.createContainer(); - const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + const list = harness.store.add(harness.instantiationService.createInstance(TestSessionsList, container, { grouping: () => SessionsGrouping.Date, sorting: () => sorting, compact: () => compact, @@ -5615,7 +5616,7 @@ suite('Sessions - SessionsList', () => { ); }); - test('updates nested chat rows when the session chat catalog changes', () => { + test('updates nested chat rows when the session chat catalog changes', async () => { const main = createChat('Main chat'); const peer = createChat('Peer chat', ChatOriginKind.User); const chats = observableValue('session-chats', [main]); @@ -5630,6 +5631,7 @@ suite('Sessions - SessionsList', () => { const before = chatRowTitles(container); chats.set([main, peer], undefined); + await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); const twistie = container.querySelector('.session-chat-twistie.collapsible'); assert.ok(twistie); @@ -5644,7 +5646,52 @@ suite('Sessions - SessionsList', () => { }); }); - test('shows archived nested chats when archived items are included', () => { + test('does not leave overlapping rows when rendering a chat updates the catalog', async () => { + const main = createChat('Main chat'); + const appended = createChat('Appended chat', ChatOriginKind.User); + const chats = observableValue('session-chats', [main]); + let didUpdateCatalog = false; + const target = { + ...createChat('Target chat', ChatOriginKind.User), + title: derived(() => { + if (!didUpdateCatalog) { + didUpdateCatalog = true; + chats.set([...chats.get(), appended], undefined); + } + return 'Target chat'; + }), + }; + const peers = Array.from({ length: 30 }, (_, index) => createChat(`Peer ${index}`, ChatOriginKind.User)); + chats.set([main, ...peers, target], undefined); + const base = createTestSession('Session').session; + const session: ISession = { + ...base, + chats, + mainChat: constObservable(main), + capabilities: constObservable({ supportsMultipleChats: true }), + }; + const { container, list } = renderSessionChatsList(session); + + assert.strictEqual(list.revealItem({ session: session.resource, chat: target.resource }), true); + await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); + const hasAppendedChat = list.revealItem({ session: session.resource, chat: appended.resource }); + + const rows = [...container.querySelectorAll('.monaco-list[aria-label="Sessions"] .monaco-list-rows > .monaco-list-row')]; + const indices = rows.map(row => row.dataset.index); + assert.deepStrictEqual({ + didUpdateCatalog, + hasAppendedChat, + renderedRowCount: rows.length, + uniqueRenderedRowCount: new Set(indices).size, + }, { + didUpdateCatalog: true, + hasAppendedChat: true, + renderedRowCount: rows.length, + uniqueRenderedRowCount: rows.length, + }); + }); + + test('shows archived nested chats when archived items are included', async () => { const main = createChat('Main chat'); const isArchived = observableValue('peer-is-archived', false); const peer: IChat = { ...createChat('Peer chat', ChatOriginKind.User), isArchived }; @@ -5659,6 +5706,7 @@ suite('Sessions - SessionsList', () => { const before = chatRowTitles(container); isArchived.set(true, undefined); + await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); const hidden = chatRowTitles(container); list.setExcludeArchived(false); const archivedRow = container.querySelector('.session-chat-item'); diff --git a/src/vs/sessions/contrib/terminal/test/browser/terminalTabsStyles.test.ts b/src/vs/sessions/contrib/terminal/test/browser/terminalTabsStyles.test.ts new file mode 100644 index 00000000000000..59db68f64a7b22 --- /dev/null +++ b/src/vs/sessions/contrib/terminal/test/browser/terminalTabsStyles.test.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../../browser/media/workbench.css'; +import '../../../../browser/parts/media/panelPart.css'; +import '../../../../../base/browser/ui/list/list.css'; +import '../../../../../workbench/contrib/terminal/browser/media/terminal.css'; +import '../../../../../workbench/contrib/modernUI/browser/media/padding.css'; +import '../../../../../workbench/contrib/modernUI/browser/media/roundedCorners.css'; +import '../../../../../workbench/contrib/modernUI/browser/media/tabs.css'; +import assert from 'assert'; +import { $, append } from '../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; + +suite('Sessions - Terminal Tabs Styles', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createTerminalList(agents: boolean, compact: boolean, theme: string) { + const root = append(mainWindow.document.body, $('.monaco-workbench.modern-ui-tabs')); + store.add(toDisposable(() => root.remove())); + root.classList.add(theme, agents ? 'agent-sessions-workbench' : 'modern-ui'); + root.classList.toggle('modern-ui-compact', compact); + root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-cornerRadius-small: 4px; --vscode-panel-background: #112233; --vscode-agentsPanel-background: #223344; --vscode-list-dropBackground: #334455;'; + const panel = append(root, $('.part.panel')); + const paneView = append(panel, $('.monaco-pane-view')); + const body = append(paneView, $('.pane-body.integrated-terminal')); + const tabs = append(body, $('.tabs-container.has-text')); + const listContainer = append(tabs, $('.tabs-list-container')); + const list = append(listContainer, $('.tabs-list.monaco-list')); + const rows = append(list, $('.monaco-list-rows')); + rows.style.width = '200px'; + const row = append(rows, $('.monaco-list-row.selected')); + const entry = append(row, $('.terminal-tabs-entry.is-active')); + append(entry, $('.monaco-icon-label')); + return { root, listContainer, row, entry }; + } + + for (const compact of [false, true]) { + for (const theme of ['vs', 'vs-dark', 'hc-black', 'hc-light']) { + test(`matches editor terminal row styling in ${theme}, ${compact ? 'compact' : 'default'} density`, () => { + const agents = createTerminalList(true, compact, theme); + const editor = createTerminalList(false, compact, theme); + const getRowStyles = (row: HTMLElement) => { + const style = mainWindow.getComputedStyle(row); + return { + left: style.left, + right: style.right, + width: style.width, + borderRadius: style.borderRadius, + }; + }; + const inset = compact ? 2 : 4; + assert.deepStrictEqual({ + agents: getRowStyles(agents.row), + editor: getRowStyles(editor.row), + entryPadding: mainWindow.getComputedStyle(agents.entry).paddingInline, + background: mainWindow.getComputedStyle(agents.listContainer).backgroundColor, + }, { + agents: { left: `${inset}px`, right: `${inset}px`, width: `${200 - 2 * inset}px`, borderRadius: '4px' }, + editor: { left: `${inset}px`, right: `${inset}px`, width: `${200 - 2 * inset}px`, borderRadius: '4px' }, + entryPadding: '8px', + background: 'rgb(34, 51, 68)', + }); + }); + } + } + + test('preserves the drop target background and icon-only row styling', () => { + const { root, listContainer, row } = createTerminalList(true, false, 'vs-dark'); + root.querySelector('.tabs-container')!.classList.remove('has-text'); + listContainer.classList.add('drop-target'); + assert.deepStrictEqual({ + background: mainWindow.getComputedStyle(listContainer).backgroundColor, + borderRadius: mainWindow.getComputedStyle(row).borderRadius, + left: mainWindow.getComputedStyle(row).left, + right: mainWindow.getComputedStyle(row).right, + }, { + background: 'rgb(51, 68, 85)', + borderRadius: '4px', + left: '4px', + right: '4px', + }); + }); +}); diff --git a/src/vs/sessions/electron-browser/actions/vscodeActions.ts b/src/vs/sessions/electron-browser/actions/vscodeActions.ts index 54e5d14c3bac39..8222650f1ae382 100644 --- a/src/vs/sessions/electron-browser/actions/vscodeActions.ts +++ b/src/vs/sessions/electron-browser/actions/vscodeActions.ts @@ -7,6 +7,7 @@ import { Codicon } from '../../../base/common/codicons.js'; import { getWindowId } from '../../../base/browser/dom.js'; import { mainWindow } from '../../../base/browser/window.js'; import { URI } from '../../../base/common/uri.js'; +import { Schemas } from '../../../base/common/network.js'; import { ServicesAccessor } from '../../../editor/browser/editorExtensions.js'; import { EditorContextKeys } from '../../../editor/common/editorContextKeys.js'; import { localize2 } from '../../../nls.js'; @@ -30,7 +31,8 @@ import { IInstantiationService } from '../../../platform/instantiation/common/in import { Disposable } from '../../../base/common/lifecycle.js'; import { resolveRemoteFolderUri } from '../../browser/openInVSCodeUtils.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; -import { IOpenedMainWindow } from '../../../platform/window/common/window.js'; +import { IFolderToOpen, IOpenedMainWindow } from '../../../platform/window/common/window.js'; +import { ILabelService, Verbosity } from '../../../platform/label/common/label.js'; import { OPEN_VSCODE_WINDOW_COMMAND_ID, RETURN_TO_VSCODE_EDITOR_COMMAND_ID, SHOULD_SHOW_RETURN_TO_VSCODE_EDITOR_COMMAND_ID } from '../../common/sessionCommands.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; @@ -57,36 +59,37 @@ export class OpenSessionInVSCodeAction extends Action2 { logSessionsInteraction(telemetryService, 'openInVSCode'); const sessionsService = accessor.get(ISessionsService); - const sessionsProvidersService = accessor.get(ISessionsProvidersService); - const remoteAgentHostService = accessor.get(IRemoteAgentHostService); - const nativeHostService = accessor.get(INativeHostService); + const instantiationService = accessor.get(IInstantiationService); - return openSessionInVSCode(nativeHostService, sessionsService.activeSession.get(), sessionsProvidersService, remoteAgentHostService); + return instantiationService.invokeFunction(openSessionInVSCode, sessionsService.activeSession.get()); } } -export async function openSessionInVSCode( - nativeHostService: INativeHostService, - session: IActiveSession | undefined, - sessionsProvidersService: ISessionsProvidersService, - remoteAgentHostService: IRemoteAgentHostService, -): Promise { +export async function openSessionInVSCode(accessor: ServicesAccessor, session: IActiveSession | undefined): Promise { + const nativeHostService = accessor.get(INativeHostService); + const sessionsProvidersService = accessor.get(ISessionsProvidersService); + const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + const labelService = accessor.get(ILabelService); + const folders = session?.activeChat.get().workspace.get()?.folders; if (!session || !folders?.length) { return nativeHostService.openWindow(); } - const folderUris: URI[] = []; + const foldersToOpen: IFolderToOpen[] = []; for (const folder of folders) { const folderUri = resolveRemoteFolderUri(folder.workingDirectory, session.providerId, sessionsProvidersService, remoteAgentHostService); if (!folderUri) { return nativeHostService.openWindow({ remoteAuthority: null }); } - folderUris.push(folderUri); + foldersToOpen.push({ + folderUri, + label: folderUri.scheme === Schemas.vscodeRemote ? labelService.getWorkspaceLabel(folder.workingDirectory, { verbose: Verbosity.LONG }) : undefined, + }); } const chatSessionToOpen = getChatSessionToOpenInEditor(session); - return nativeHostService.openWindow(folderUris.map(folderUri => ({ folderUri })), { forceNewWindow: true, chatSessionToOpen }); + return nativeHostService.openWindow(foldersToOpen, { forceNewWindow: true, chatSessionToOpen }); } /** diff --git a/src/vs/sessions/test/browser/parts/panelPart.test.ts b/src/vs/sessions/test/browser/parts/panelPart.test.ts index d8254253f4aaac..54fa5002a778b5 100644 --- a/src/vs/sessions/test/browser/parts/panelPart.test.ts +++ b/src/vs/sessions/test/browser/parts/panelPart.test.ts @@ -15,11 +15,14 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; -import { Part } from '../../../../workbench/browser/part.js'; +import { IPartOptions, Part } from '../../../../workbench/browser/part.js'; import { IPaneCompositeBarOptions } from '../../../../workbench/browser/parts/paneCompositeBar.js'; import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts, Position } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ViewDescriptorService } from '../../../../workbench/services/views/browser/viewDescriptorService.js'; import { TestLayoutService, workbenchInstantiationService } from '../../../../workbench/test/browser/workbenchTestServices.js'; +import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js'; +// eslint-disable-next-line local/code-import-patterns +import '../../../../workbench/contrib/modernUI/browser/media/tabs.css'; import { PanelPart } from '../../../browser/parts/panelPart.js'; interface IPanelPartTestHarness { @@ -80,6 +83,101 @@ function getAlignmentSubmenu(actions: readonly IAction[]): SubmenuAction { return submenu; } +function createPanelTab(root: HTMLElement): { + panel: HTMLElement; + title: HTMLElement; + actionItem: HTMLElement; + actionLabel: HTMLElement; + badge: HTMLElement; + indicator: HTMLElement; + secondActionItem: HTMLElement; + secondIndicator: HTMLElement; + titleActions: HTMLElement; + titleActionItem: HTMLElement; +} { + const panel = mainWindow.document.createElement('div'); + panel.className = 'part panel bottom pane-composite-part panel-labels'; + panel.style.setProperty('--part-border-color', '#345678'); + root.appendChild(panel); + + const title = mainWindow.document.createElement('div'); + title.className = 'composite title has-composite-bar'; + title.style.height = '32px'; + panel.appendChild(title); + + const compositeBarContainer = mainWindow.document.createElement('div'); + compositeBarContainer.className = 'composite-bar-container'; + title.appendChild(compositeBarContainer); + + const compositeBar = mainWindow.document.createElement('div'); + compositeBar.className = 'composite-bar'; + compositeBarContainer.appendChild(compositeBar); + + const actionBar = mainWindow.document.createElement('div'); + actionBar.className = 'monaco-action-bar'; + compositeBar.appendChild(actionBar); + + const actionsContainer = mainWindow.document.createElement('div'); + actionsContainer.className = 'actions-container'; + actionBar.appendChild(actionsContainer); + + const actionItem = mainWindow.document.createElement('div'); + actionItem.className = 'action-item checked'; + actionsContainer.appendChild(actionItem); + + const actionLabel = mainWindow.document.createElement('a'); + actionLabel.className = 'action-label'; + actionItem.appendChild(actionLabel); + + const badge = mainWindow.document.createElement('div'); + badge.className = 'badge'; + actionItem.appendChild(badge); + + const badgeContent = mainWindow.document.createElement('div'); + badgeContent.className = 'badge-content'; + badgeContent.textContent = '1'; + badge.appendChild(badgeContent); + + const indicator = mainWindow.document.createElement('div'); + indicator.className = 'active-item-indicator'; + actionItem.appendChild(indicator); + + const secondActionItem = mainWindow.document.createElement('div'); + secondActionItem.className = 'action-item'; + actionsContainer.appendChild(secondActionItem); + + const secondActionLabel = mainWindow.document.createElement('a'); + secondActionLabel.className = 'action-label'; + secondActionItem.appendChild(secondActionLabel); + + const secondIndicator = mainWindow.document.createElement('div'); + secondIndicator.className = 'active-item-indicator'; + secondActionItem.appendChild(secondIndicator); + + const titleActions = mainWindow.document.createElement('div'); + titleActions.className = 'title-actions'; + title.appendChild(titleActions); + + const titleToolbar = mainWindow.document.createElement('div'); + titleToolbar.className = 'monaco-toolbar'; + titleActions.appendChild(titleToolbar); + + const titleActionBar = mainWindow.document.createElement('div'); + titleActionBar.className = 'monaco-action-bar'; + titleToolbar.appendChild(titleActionBar); + + const titleActionsContainer = mainWindow.document.createElement('div'); + titleActionsContainer.className = 'actions-container'; + titleActionBar.appendChild(titleActionsContainer); + + const titleActionItem = mainWindow.document.createElement('div'); + titleActionItem.className = 'action-item'; + titleActionItem.style.height = '22px'; + titleActionsContainer.appendChild(titleActionItem); + + return { panel, title, actionItem, actionLabel, badge, indicator, secondActionItem, secondIndicator, titleActions, titleActionItem }; +} + suite('Sessions - Panel Part', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -106,6 +204,32 @@ suite('Sessions - Panel Part', () => { ]); }); + test('matches labeled panel height to editor tab density', () => { + const configurationService = new TestConfigurationService({ + 'window.density.editorTabHeight': 'default', + 'workbench.panel.showLabels': true, + }); + const instantiationService = workbenchInstantiationService({ + configurationService: () => configurationService + }, disposables); + const viewDescriptorService = disposables.add(instantiationService.createInstance(ViewDescriptorService)); + instantiationService.stub(IViewDescriptorService, viewDescriptorService); + const panelPart = disposables.add(instantiationService.createInstance(PanelPart)); + const titleHeight = (Reflect.get(panelPart, 'options') as IPartOptions).titleHeight; + + const defaultHeight = titleHeight?.(); + configurationService.setUserConfiguration('window.density.editorTabHeight', 'compact'); + const compactHeight = titleHeight?.(); + configurationService.setUserConfiguration('workbench.panel.showLabels', false); + const iconHeight = titleHeight?.(); + + assert.deepStrictEqual({ defaultHeight, compactHeight, iconHeight }, { + defaultHeight: 32, + compactHeight: 28, + iconHeight: undefined, + }); + }); + test('replaces only view container movement with panel alignment actions', async () => { let alignment: 'center' | 'justify' = 'justify'; const commands: string[] = []; @@ -208,4 +332,169 @@ suite('Sessions - Panel Part', () => { phoneActionsPreserved: true, }); }); + + test('uses the editor tab presentation for bottom panel tabs', () => { + const root = mainWindow.document.createElement('div'); + root.className = 'monaco-workbench agent-sessions-workbench modern-ui-tabs'; + root.style.cssText = [ + '--vscode-agentsPanel-background: #112233', + '--vscode-cornerRadius-small: 4px', + '--vscode-cornerRadius-medium: 6px', + '--vscode-fontSize-body1: 13px', + '--vscode-fontWeight-regular: 400', + '--vscode-modernEditorTab-activeBackground: #203040', + '--vscode-spacing-size20: 2px', + '--vscode-spacing-size40: 4px', + '--vscode-spacing-size60: 6px', + '--vscode-spacing-size80: 8px', + '--vscode-spacing-size100: 10px', + '--vscode-spacing-size240: 24px', + '--vscode-spacing-size280: 28px', + '--vscode-strokeThickness: 1px', + '--modern-ui-editor-tab-border: transparent', + '--modern-ui-connected-tab-strip-background: #445566', + '--modern-ui-editor-tab-active-background: #203040', + '--modern-ui-editor-tab-active-foreground: #abcdef', + ].join(';'); + mainWindow.document.body.appendChild(root); + disposables.add({ dispose: () => root.remove() }); + const { panel, title, actionItem, actionLabel, badge, indicator, secondActionItem, secondIndicator, titleActions, titleActionItem } = createPanelTab(root); + const targetWindow = mainWindow; + const getTitleActionLayout = () => { + const titleRect = title.getBoundingClientRect(); + const actionItemRect = titleActionItem.getBoundingClientRect(); + return { + height: titleActions.getBoundingClientRect().height, + centerOffset: actionItemRect.top + actionItemRect.height / 2 - (titleRect.top + titleRect.height / 2), + }; + }; + + const pill = { + titleHeight: title.getBoundingClientRect().height, + titleActions: getTitleActionLayout(), + background: targetWindow.getComputedStyle(indicator).backgroundColor, + foreground: targetWindow.getComputedStyle(actionLabel).color, + itemPadding: targetWindow.getComputedStyle(actionItem).padding, + top: targetWindow.getComputedStyle(indicator).top, + right: targetWindow.getComputedStyle(indicator).right, + bottom: targetWindow.getComputedStyle(indicator).bottom, + left: targetWindow.getComputedStyle(indicator).left, + boxHeight: indicator.getBoundingClientRect().height, + radius: targetWindow.getComputedStyle(indicator).borderRadius, + }; + + root.classList.add('modern-ui-connected-editor-tabs'); + const connected = { + titleHeight: title.getBoundingClientRect().height, + titleBackground: targetWindow.getComputedStyle(title).backgroundColor, + background: targetWindow.getComputedStyle(indicator).backgroundColor, + foreground: targetWindow.getComputedStyle(actionLabel).color, + labelZIndex: targetWindow.getComputedStyle(actionLabel).zIndex, + badgeDisplay: targetWindow.getComputedStyle(badge).display, + badgeZIndex: targetWindow.getComputedStyle(badge).zIndex, + badgeText: badge.textContent, + top: targetWindow.getComputedStyle(indicator).top, + bottom: targetWindow.getComputedStyle(indicator).bottom, + borderTopColor: targetWindow.getComputedStyle(indicator).borderTopColor, + borderBottomWidth: targetWindow.getComputedStyle(indicator).borderBottomWidth, + radius: targetWindow.getComputedStyle(indicator).borderRadius, + leftShoulder: targetWindow.getComputedStyle(indicator, '::before').content, + rightShoulder: targetWindow.getComputedStyle(indicator, '::after').content, + firstTabOmitsLeftShoulder: actionItem === actionItem.parentElement?.firstElementChild, + }; + + actionItem.classList.remove('checked'); + secondActionItem.classList.add('checked'); + const nonFirstConnected = { + leftShoulder: targetWindow.getComputedStyle(secondIndicator, '::before').content, + leftShoulderBorderBottomColor: targetWindow.getComputedStyle(secondIndicator, '::before').borderBottomColor, + leftShoulderBorderBottomWidth: targetWindow.getComputedStyle(secondIndicator, '::before').borderBottomWidth, + }; + secondActionItem.classList.remove('checked'); + actionItem.classList.add('checked'); + + title.style.height = '28px'; + const compact = { + titleHeight: title.getBoundingClientRect().height, + itemHeight: actionItem.getBoundingClientRect().height, + titleActions: getTitleActionLayout(), + }; + + panel.classList.remove('panel-labels'); + title.style.removeProperty('height'); + actionItem.classList.add('icon'); + const iconMode = { + titleHeight: title.getBoundingClientRect().height, + titleActions: getTitleActionLayout(), + titleBackground: targetWindow.getComputedStyle(title).backgroundColor, + itemPadding: targetWindow.getComputedStyle(actionItem).padding, + indicatorTop: targetWindow.getComputedStyle(indicator).top, + indicatorBottom: targetWindow.getComputedStyle(indicator).bottom, + indicatorHeight: targetWindow.getComputedStyle(indicator).height, + indicatorRadius: targetWindow.getComputedStyle(indicator).borderRadius, + }; + + assert.deepStrictEqual({ pill, connected, nonFirstConnected, compact, iconMode }, { + pill: { + titleHeight: 32, + titleActions: { + height: 32, + centerOffset: 0, + }, + background: 'rgb(32, 48, 64)', + foreground: 'rgb(171, 205, 239)', + itemPadding: '0px 8px', + top: '0px', + right: '2px', + bottom: '0px', + left: '2px', + boxHeight: 32, + radius: '4px', + }, + connected: { + titleHeight: 32, + titleBackground: 'rgb(68, 85, 102)', + background: 'rgb(17, 34, 51)', + foreground: 'rgb(171, 205, 239)', + labelZIndex: '2', + badgeDisplay: 'flex', + badgeZIndex: '2', + badgeText: '1', + top: '-1px', + bottom: '-1px', + borderTopColor: 'rgb(52, 86, 120)', + borderBottomWidth: '0px', + radius: '7px 7px 0px 0px', + leftShoulder: 'none', + rightShoulder: '""', + firstTabOmitsLeftShoulder: true, + }, + nonFirstConnected: { + leftShoulder: '""', + leftShoulderBorderBottomColor: 'rgb(52, 86, 120)', + leftShoulderBorderBottomWidth: '1px', + }, + compact: { + titleHeight: 28, + itemHeight: 28, + titleActions: { + height: 28, + centerOffset: 0, + }, + }, + iconMode: { + titleHeight: 35, + titleActions: { + height: 35, + centerOffset: 0, + }, + titleBackground: 'rgb(17, 34, 51)', + itemPadding: '0px 6px', + indicatorTop: '13.5px', + indicatorBottom: '-10.5px', + indicatorHeight: '24px', + indicatorRadius: '4px', + }, + }); + }); }); diff --git a/src/vs/sessions/test/common/agentHostSessionWorkspace.test.ts b/src/vs/sessions/test/common/agentHostSessionWorkspace.test.ts index b88a1ccbf390a2..9bffb1b8468b31 100644 --- a/src/vs/sessions/test/common/agentHostSessionWorkspace.test.ts +++ b/src/vs/sessions/test/common/agentHostSessionWorkspace.test.ts @@ -93,6 +93,36 @@ suite('Agent Host Session Workspace', () => { }); }); + test('resolves detached folders on their own only when asked', () => { + const worktree = URI.file('/src/tools.worktrees/task'); + const plainFolder = URI.file('/src/notes'); + const gitState: ISessionGitState = { branchName: 'agents/task', baseBranchName: 'main' }; + const summarize = (workspace: ISessionWorkspace | undefined) => workspace && { + uri: workspace.uri.toString(), + label: workspace.label, + folders: workspace.folders.map(folder => ({ + root: folder.root.toString(), + workingDirectory: folder.workingDirectory.toString(), + repository: folder.gitRepository && { uri: folder.gitRepository.uri.toString(), workTreeUri: folder.gitRepository.workTreeUri?.toString(), branchName: folder.gitRepository.branchName }, + })), + }; + + assert.deepStrictEqual({ + attachedOnly: buildAgentHostChatWorkspace(sessionWorkspace, [worktree], undefined, gitState), + detached: summarize(buildAgentHostChatWorkspace(sessionWorkspace, [worktree, plainFolder], undefined, gitState, true)), + }, { + attachedOnly: undefined, + detached: { + uri: URI.file('/src/tools').toString(), + label: 'tools', + folders: [ + { root: URI.file('/src/tools').toString(), workingDirectory: worktree.toString(), repository: { uri: URI.file('/src/tools').toString(), workTreeUri: worktree.toString(), branchName: 'agents/task' } }, + { root: plainFolder.toString(), workingDirectory: plainFolder.toString(), repository: undefined }, + ], + }, + }); + }); + test('projects chat-scoped Git state onto the chat workspace', () => { const gitState: ISessionGitState = { branchName: 'peer-feature', diff --git a/src/vs/sessions/test/electron-browser/vscodeActions.test.ts b/src/vs/sessions/test/electron-browser/vscodeActions.test.ts index 557423316e9857..ff2c80f3fc39b8 100644 --- a/src/vs/sessions/test/electron-browser/vscodeActions.test.ts +++ b/src/vs/sessions/test/electron-browser/vscodeActions.test.ts @@ -16,12 +16,29 @@ import { IChat } from '../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; import { IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostEntryType } from '../../../platform/agentHost/common/remoteAgentHostService.js'; import { Codicon } from '../../../base/common/codicons.js'; -import { agentHostAuthority, toAgentHostUri } from '../../../platform/agentHost/common/agentHostUri.js'; +import { agentHostAuthority, agentHostLabelFormatter, toAgentHostUri } from '../../../platform/agentHost/common/agentHostUri.js'; import { IAgentHostSessionsProvider } from '../../common/agentHostSessionsProvider.js'; import { ISessionsProvider } from '../../services/sessions/common/sessionsProvider.js'; +import { LabelService } from '../../../workbench/services/label/common/labelService.js'; +import { workbenchInstantiationService } from '../../../workbench/test/browser/workbenchTestServices.js'; +import { OperatingSystem } from '../../../base/common/platform.js'; +import { splitRecentLabel } from '../../../base/common/labels.js'; +import { ILabelService } from '../../../platform/label/common/label.js'; +import { TestInstantiationService } from '../../../platform/instantiation/test/common/instantiationServiceMock.js'; suite('VS Code Actions', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let labelService: LabelService; + + setup(() => { + instantiationService = workbenchInstantiationService(undefined, disposables); + labelService = disposables.add(instantiationService.createInstance(LabelService)); + instantiationService.stub(ILabelService, labelService); + instantiationService.stub(ISessionsProvidersService, new class extends mock() { }()); + instantiationService.stub(IRemoteAgentHostService, new class extends mock() { }()); + }); test('shows return action only when there is no other main window', () => { const currentWindow = createWindow(1); @@ -72,11 +89,11 @@ suite('VS Code Actions', () => { }); test('opens every active chat workspace folder in one editor window', async () => { - const calls: { folders?: string[]; forceNewWindow?: boolean; chatSessionToOpen?: string }[] = []; + const calls: { folders?: { uri: string; label: string | undefined }[]; forceNewWindow?: boolean; chatSessionToOpen?: string }[] = []; const nativeHostService = new class extends mock() { override async openWindow(toOpen?: IOpenEmptyWindowOptions | IWindowOpenable[], options?: IOpenWindowOptions): Promise { calls.push({ - folders: Array.isArray(toOpen) ? toOpen.filter(isFolderToOpen).map(openable => openable.folderUri.toString()) : undefined, + folders: Array.isArray(toOpen) ? toOpen.filter(isFolderToOpen).map(openable => ({ uri: openable.folderUri.toString(), label: openable.label })) : undefined, forceNewWindow: options?.forceNewWindow, chatSessionToOpen: options?.chatSessionToOpen?.toString(), }); @@ -84,15 +101,11 @@ suite('VS Code Actions', () => { }(); const session = createSession('multi-folder', true, [URI.file('/repo-b'), URI.file('/repo-c')]); - await openSessionInVSCode( - nativeHostService, - session, - new class extends mock() { }(), - new class extends mock() { }(), - ); + instantiationService.stub(INativeHostService, nativeHostService); + await instantiationService.invokeFunction(openSessionInVSCode, session); assert.deepStrictEqual(calls, [{ - folders: ['file:///repo-b', 'file:///repo-c'], + folders: [{ uri: 'file:///repo-b', label: undefined }, { uri: 'file:///repo-c', label: undefined }], forceNewWindow: true, chatSessionToOpen: 'test:/multi-folder', }]); @@ -106,12 +119,8 @@ suite('VS Code Actions', () => { } }(); - await openSessionInVSCode( - nativeHostService, - createSession('no-workspace', true), - new class extends mock() { }(), - new class extends mock() { }(), - ); + instantiationService.stub(INativeHostService, nativeHostService); + await instantiationService.invokeFunction(openSessionInVSCode, createSession('no-workspace', true)); assert.deepStrictEqual(calls, [{ empty: true }]); }); @@ -124,21 +133,19 @@ suite('VS Code Actions', () => { const calls: { toOpen?: IOpenEmptyWindowOptions | IWindowOpenable[]; options?: IOpenWindowOptions }[] = []; const session = createSession('unsupported', true, folders); const provider: ISessionsProvider = upcastPartial({ id: 'agenthost-test', remoteAddress: 'cloudsandbox:environment' }); - await openSessionInVSCode( - new class extends mock() { - override async openWindow(toOpen?: IOpenEmptyWindowOptions | IWindowOpenable[], options?: IOpenWindowOptions): Promise { - calls.push({ toOpen, options }); - } - }(), - session, - upcastPartial({ getProvider: () => provider as T }), - upcastPartial({ - getEntryByAddress: () => ({ - name: 'Windows dev box', - connection: { type: RemoteAgentHostEntryType.CloudSandbox, address: 'cloudsandbox:environment', environmentId: 'environment', environmentKind: 'user-local' }, - }), + instantiationService.stub(INativeHostService, new class extends mock() { + override async openWindow(toOpen?: IOpenEmptyWindowOptions | IWindowOpenable[], options?: IOpenWindowOptions): Promise { + calls.push({ toOpen, options }); + } + }()); + instantiationService.stub(ISessionsProvidersService, { getProvider: () => provider as T }); + instantiationService.stub(IRemoteAgentHostService, { + getEntryByAddress: () => ({ + name: 'Windows dev box', + connection: { type: RemoteAgentHostEntryType.CloudSandbox, address: 'cloudsandbox:environment', environmentId: 'environment', environmentKind: 'user-local' }, }), - ); + }); + await instantiationService.invokeFunction(openSessionInVSCode, session); assert.deepStrictEqual({ calls, session: session.resource.toString(), @@ -158,57 +165,93 @@ suite('VS Code Actions', () => { { type: RemoteAgentHostEntryType.DevContainer, address: 'devcontainer:host', hostPath: '/source/project' }, ] satisfies IRemoteAgentHostEntry['connection'][]) { test(`preserves supported ${connection.type} workspace and chat handoff`, async () => { - const calls: { folders?: URI[]; options?: IOpenWindowOptions }[] = []; + const calls: { folders?: { uri: URI; label: string | undefined }[]; options?: IOpenWindowOptions }[] = []; const folder = toAgentHostUri(URI.file('/remote/project'), agentHostAuthority('test:host')); const entry: IRemoteAgentHostEntry = { name: 'Remote host', connection }; const provider: ISessionsProvider = upcastPartial({ id: 'agenthost-test', remoteAddress: 'test:host' }); - await openSessionInVSCode( - new class extends mock() { - override async openWindow(toOpen?: IOpenEmptyWindowOptions | IWindowOpenable[], options?: IOpenWindowOptions): Promise { - calls.push({ folders: Array.isArray(toOpen) ? toOpen.filter(isFolderToOpen).map(openable => openable.folderUri) : undefined, options }); - } - }(), - createSession('remote', true, [folder]), - upcastPartial({ - getProvider: () => provider as T, - }), - upcastPartial({ getEntryByAddress: () => entry }), - ); + const formatter = agentHostLabelFormatter(folder.authority, OperatingSystem.Linux); + disposables.add(labelService.registerFormatter({ + ...formatter, + formatting: { ...formatter.formatting, workspaceSuffix: entry.name }, + })); + instantiationService.stub(INativeHostService, new class extends mock() { + override async openWindow(toOpen?: IOpenEmptyWindowOptions | IWindowOpenable[], options?: IOpenWindowOptions): Promise { + calls.push({ folders: Array.isArray(toOpen) ? toOpen.filter(isFolderToOpen).map(openable => ({ uri: openable.folderUri, label: openable.label })) : undefined, options }); + } + }()); + instantiationService.stub(ISessionsProvidersService, { getProvider: () => provider as T }); + instantiationService.stub(IRemoteAgentHostService, { getEntryByAddress: () => entry }); + await instantiationService.invokeFunction(openSessionInVSCode, createSession('remote', true, [folder])); assert.deepStrictEqual({ - folders: calls[0].folders?.map(folder => ({ scheme: folder.scheme, path: folder.path })), + folders: calls[0].folders?.map(folder => ({ scheme: folder.uri.scheme, path: folder.uri.path, label: folder.label, title: folder.label && splitRecentLabel(folder.label).name })), options: calls[0].options, }, { - folders: [{ scheme: 'vscode-remote', path: '/remote/project' }], + folders: [{ scheme: 'vscode-remote', path: '/remote/project', label: '/remote/project [Remote host]', title: 'project [Remote host]' }], options: { forceNewWindow: true, chatSessionToOpen: URI.from({ scheme: 'test', path: '/remote' }) }, }); }); } + test('preserves Windows remote labels for every folder opened through a tunnel', async () => { + const calls: { uri: URI; label: string | undefined; title: string | undefined }[][] = []; + const authority = agentHostAuthority('test:host'); + const folders = ['/c:/remote/project', '/d:/other/repository'].map(path => toAgentHostUri(URI.from({ scheme: 'file', path }), authority)); + const formatter = agentHostLabelFormatter(authority, OperatingSystem.Windows); + disposables.add(labelService.registerFormatter({ + ...formatter, + formatting: { ...formatter.formatting, workspaceSuffix: 'Windows dev box' }, + })); + const provider: ISessionsProvider = upcastPartial({ id: 'agenthost-test', remoteAddress: 'test:host' }); + instantiationService.stub(INativeHostService, new class extends mock() { + override async openWindow(toOpen?: IOpenEmptyWindowOptions | IWindowOpenable[]): Promise { + calls.push(Array.isArray(toOpen) ? toOpen.filter(isFolderToOpen).map(openable => ({ + uri: openable.folderUri, + label: openable.label, + title: openable.label && splitRecentLabel(openable.label).name, + })) : []); + } + }()); + instantiationService.stub(ISessionsProvidersService, { getProvider: () => provider as T }); + instantiationService.stub(IRemoteAgentHostService, { + getEntryByAddress: () => ({ name: 'Windows dev box', connection: { type: RemoteAgentHostEntryType.Tunnel, tunnelId: 'host', clusterId: 'region', label: 'my-tunnel' } }), + }); + await instantiationService.invokeFunction(openSessionInVSCode, createSession('remote', true, folders)); + + assert.deepStrictEqual(calls, [[{ + uri: URI.from({ scheme: 'vscode-remote', authority: 'tunnel+my-tunnel', path: '/c:/remote/project' }), + label: 'C:\\remote\\project [Windows dev box]', + title: 'project [Windows dev box]', + }, { + uri: URI.from({ scheme: 'vscode-remote', authority: 'tunnel+my-tunnel', path: '/d:/other/repository' }), + label: 'D:\\other\\repository [Windows dev box]', + title: 'repository [Windows dev box]', + }]]); + }); + test('propagates a failure to open the fallback Editor window', async () => { const error = new Error('Editor window could not be opened'); - await assert.rejects(openSessionInVSCode( - new class extends mock() { - override async openWindow(): Promise { throw error; } - }(), + instantiationService.stub(INativeHostService, new class extends mock() { + override async openWindow(): Promise { throw error; } + }()); + instantiationService.stub(ISessionsProvidersService, { getProvider: () => undefined }); + await assert.rejects(instantiationService.invokeFunction( + openSessionInVSCode, createSession('unsupported', true, [toAgentHostUri(URI.file('/remote/project'), agentHostAuthority('ws:host'))]), - upcastPartial({ getProvider: () => undefined }), - new class extends mock() { }(), ), error); }); test('does not replace an unexpected supported handoff failure with an empty window', async () => { const calls: string[] = []; const error = new Error('Remote resolver failed'); - await assert.rejects(openSessionInVSCode( - new class extends mock() { - override async openWindow(toOpen?: IOpenEmptyWindowOptions | IWindowOpenable[]): Promise { - calls.push(Array.isArray(toOpen) ? 'workspace' : 'empty'); - throw error; - } - }(), + instantiationService.stub(INativeHostService, new class extends mock() { + override async openWindow(toOpen?: IOpenEmptyWindowOptions | IWindowOpenable[]): Promise { + calls.push(Array.isArray(toOpen) ? 'workspace' : 'empty'); + throw error; + } + }()); + await assert.rejects(instantiationService.invokeFunction( + openSessionInVSCode, createSession('supported', true, [URI.file('/local/project')]), - new class extends mock() { }(), - new class extends mock() { }(), ), error); assert.deepStrictEqual(calls, ['workspace']); }); diff --git a/src/vs/workbench/browser/part.ts b/src/vs/workbench/browser/part.ts index 059221692f5d6e..dc347513bf2a3c 100644 --- a/src/vs/workbench/browser/part.ts +++ b/src/vs/workbench/browser/part.ts @@ -18,6 +18,7 @@ import { IDisposable, toDisposable } from '../../base/common/lifecycle.js'; export interface IPartOptions { readonly hasTitle?: boolean; readonly borderWidth?: () => number; + readonly titleHeight?: () => number | undefined; } export interface ILayoutContentResult { @@ -234,7 +235,7 @@ class PartLayout { // Title Size: Width (Fill), Height (Variable). let titleSize: Dimension; if (this.options.hasTitle) { - const titleHeight = this.layoutService.isFloatingPanelsEnabled() ? PartLayout.AREA_HEIGHT_MODERN_UI : PartLayout.TITLE_HEIGHT; + const titleHeight = this.options.titleHeight?.() ?? (this.layoutService.isFloatingPanelsEnabled() ? PartLayout.AREA_HEIGHT_MODERN_UI : PartLayout.TITLE_HEIGHT); titleSize = new Dimension(width, Math.min(height, titleHeight)); } else { titleSize = Dimension.None; diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 403ef7f7c55247..d7f01137d74049 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -76,6 +76,7 @@ export const enum AccessibilityVerbositySettingId { SessionWorktreeCleanup = 'accessibility.verbosity.sessionWorktreeCleanup', NewSessionWelcome = 'accessibility.verbosity.newSessionWelcome', SessionsChanges = 'accessibility.verbosity.sessionsChanges', + EmptyFileEditorSearch = 'accessibility.verbosity.emptyFileEditorSearch', SessionsListNotification = 'accessibility.verbosity.sessionsListNotification', SessionCanvas = 'accessibility.verbosity.sessionCanvas', ChatQuestionCarousel = 'accessibility.verbosity.chatQuestionCarousel', @@ -200,6 +201,10 @@ const configuration: IConfigurationNode = { description: localize('verbosity.emptyEditorHint', 'Provide information about relevant actions in an empty text editor.'), ...baseVerbosityProperty }, + [AccessibilityVerbositySettingId.EmptyFileEditorSearch]: { + description: localize('verbosity.emptyFileEditorSearch', 'Provide information about how to use file search in the empty Files editor.'), + ...baseVerbosityProperty + }, [AccessibilityVerbositySettingId.ReplEditor]: { description: localize('verbosity.replEditor.description', 'Provide information about how to access the REPL editor accessibility help menu when the REPL editor is focused.'), ...baseVerbosityProperty diff --git a/src/vs/workbench/contrib/bulkEdit/browser/bulkEditService.ts b/src/vs/workbench/contrib/bulkEdit/browser/bulkEditService.ts index a4ba533e180c0b..b982296acd38fd 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/bulkEditService.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/bulkEditService.ts @@ -18,7 +18,7 @@ import { Extensions, IConfigurationRegistry } from '../../../../platform/configu import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; +import { canLog, ILogService, LogLevel } from '../../../../platform/log/common/log.js'; import { IProgress, IProgressStep, Progress } from '../../../../platform/progress/common/progress.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { UndoRedoGroup, UndoRedoSource } from '../../../../platform/undoRedo/common/undoRedo.js'; @@ -141,26 +141,33 @@ class BulkEdit { return resources.flat(); } + private _logEdits(operation: string, edits: ResourceEdit[]): void { + // avoid serializing (potentially very many) edits when debug logging is off + if (canLog(this._logService.getLevel(), LogLevel.Debug)) { + this._logService.debug(operation, JSON.stringify(edits)); + } + } + private async _performFileEdits(edits: ResourceFileEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, confirmBeforeUndo: boolean, progress: IProgress): Promise { - this._logService.debug('_performFileEdits', JSON.stringify(edits)); + this._logEdits('_performFileEdits', edits); const model = this._instaService.createInstance(BulkFileEdits, this._label || localize('workspaceEdit', "Workspace Edit"), this._code || 'undoredo.workspaceEdit', undoRedoGroup, undoRedoSource, confirmBeforeUndo, progress, this._token, edits); return await model.apply(); } private async _performTextEdits(edits: ResourceTextEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, progress: IProgress, reason: TextModelEditSource | undefined): Promise { - this._logService.debug('_performTextEdits', JSON.stringify(edits)); + this._logEdits('_performTextEdits', edits); const model = this._instaService.createInstance(BulkTextEdits, this._label || localize('workspaceEdit', "Workspace Edit"), this._code || 'undoredo.workspaceEdit', this._editor, undoRedoGroup, undoRedoSource, progress, this._token, edits); return await model.apply(reason); } private async _performCellEdits(edits: ResourceNotebookCellEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, progress: IProgress): Promise { - this._logService.debug('_performCellEdits', JSON.stringify(edits)); + this._logEdits('_performCellEdits', edits); const model = this._instaService.createInstance(BulkCellEdits, undoRedoGroup, undoRedoSource, progress, this._token, edits); return await model.apply(); } private async _performOpaqueEdits(edits: ResourceAttachmentEdit[], undoRedoGroup: UndoRedoGroup, undoRedoSource: UndoRedoSource | undefined, progress: IProgress): Promise { - this._logService.debug('_performOpaqueEdits', JSON.stringify(edits)); + this._logEdits('_performOpaqueEdits', edits); const model = this._instaService.createInstance(OpaqueEdits, undoRedoGroup, undoRedoSource, progress, this._token, edits); return await model.apply(); } diff --git a/src/vs/workbench/contrib/canvases/browser/editorCanvasActions.ts b/src/vs/workbench/contrib/canvases/browser/editorCanvasActions.ts index 2ac8399e41e45a..69da71bafdbeef 100644 --- a/src/vs/workbench/contrib/canvases/browser/editorCanvasActions.ts +++ b/src/vs/workbench/contrib/canvases/browser/editorCanvasActions.ts @@ -4,13 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; -import { ThrottledDelayer } from '../../../../base/common/async.js'; -import { structuralEquals } from '../../../../base/common/equals.js'; import { toErrorMessage } from '../../../../base/common/errorMessage.js'; -import { isCancellationError } from '../../../../base/common/errors.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IReader, observableFromEvent, observableSignal, observableSignalFromEvent, observableValue, observableValueOpts, transaction } from '../../../../base/common/observable.js'; +import { autorun, derived, IReader, observableFromEvent, observableSignal, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; @@ -18,7 +15,8 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { ChatViewId, IChatWidget, IChatWidgetService, isIChatViewViewContext } from '../../chat/browser/chat.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; -import { canvasOwnerKey, compareCanvasDefinitions, getCanvasDefinitionInstanceId, getCanvasDefinitionLabels, getCanvasInstanceLabels, getCanvasReferenceKey, ICanvasContext, ICanvasDefinition, ICanvasOwner, ICanvasReference, ICanvasService, isCanvasOwner, isOpenableExtensionCanvasDefinition } from '../common/canvas.js'; +import { canvasOwnerKey, getCanvasDefinitionInstanceId, getCanvasDefinitionLabels, getCanvasInstanceLabels, getCanvasReferenceKey, ICanvasContext, ICanvasDefinition, ICanvasOwner, ICanvasReference, ICanvasService, isCanvasOwner } from '../common/canvas.js'; +import { CanvasDefinitionStore } from '../common/canvasDefinitionStore.js'; export const OPEN_EDITOR_CANVAS_COMMAND_ID = 'workbench.action.chat.openCanvas'; export const REOPEN_EDITOR_CANVAS_COMMAND_ID = 'workbench.action.chat.reopenCanvas'; @@ -73,19 +71,13 @@ export function registerEditorCanvasActions( const store = new DisposableStore(); const registrations = store.add(new DisposableMap()); const submenuRegistration = store.add(new MutableDisposable()); - const currentContext = observableValue(store, undefined); - const availableDefinitions = observableValueOpts({ owner: store, equalsFn: structuralEquals }, []); const widgetStateChanged = observableSignal(store); - const definitionRefreshDelayer = store.add(new ThrottledDelayer(0)); const widgets = observableFromEvent( store, Event.any(widgetService.onDidAddWidget, widgetService.onDidRemoveWidget), () => widgetService.getAllWidgets().slice(), ); - let requestSequence = 0; - let commandSequence = 0; - - store.add(autorun(reader => { + const currentContext = derived(store, reader => { const currentWidgets = widgets.read(reader); for (const widget of currentWidgets) { if (isChatViewWidget(widget)) { @@ -93,31 +85,18 @@ export function registerEditorCanvasActions( } } widgetStateChanged.read(reader); - const context = canvasService.enabled.read(reader) ? getChatViewCanvasContext(currentWidgets, reader) : undefined; + return canvasService.enabled.read(reader) ? getChatViewCanvasContext(currentWidgets, reader) : undefined; + }); + const availableDefinitions = store.add(new CanvasDefinitionStore(reader => { + const context = currentContext.read(reader); const definitionProvider = context?.definitionProvider; - if (definitionProvider) { - observableSignalFromEvent(reader.store, definitionProvider.onDidChangeDefinitions).read(reader); - } - const previousContext = currentContext.read(undefined); - const request = ++requestSequence; - if (context !== previousContext) { - definitionRefreshDelayer.cancel(); + if (!context || !definitionProvider) { + return undefined; } - transaction(tx => { - currentContext.set(context, tx); - if (context !== previousContext) { - availableDefinitions.set([], tx); - } - }); - if (definitionProvider) { - void definitionRefreshDelayer.trigger(() => loadAvailableDefinitions(context, definitionProvider, request)) - .catch(error => { - if (!isCancellationError(error)) { - logService.error('[EditorCanvasActions] Failed to schedule registered canvas refresh', error); - } - }); - } - })); + observableSignalFromEvent(reader.store, definitionProvider.onDidChangeDefinitions).read(reader); + return { owner: context.owner, listCanvases: () => definitionProvider.listCanvases() }; + }, '[EditorCanvasActions]', logService)).definitions; + let commandSequence = 0; store.add(autorun(reader => { const context = currentContext.read(reader); @@ -187,35 +166,6 @@ export function registerEditorCanvasActions( : undefined; })); - async function loadAvailableDefinitions(context: ICanvasContext, definitionProvider: NonNullable, request: number): Promise { - if (!isCurrentDefinitionsRequest(context, definitionProvider, request)) { - return; - } - try { - const definitions = await definitionProvider.listCanvases(); - if (!isCurrentDefinitionsRequest(context, definitionProvider, request)) { - return; - } - availableDefinitions.set( - definitions.filter(isOpenableExtensionCanvasDefinition).sort(compareCanvasDefinitions), - undefined, - ); - } catch (error) { - if (!isCurrentDefinitionsRequest(context, definitionProvider, request)) { - return; - } - logService.error('[EditorCanvasActions] Failed to list registered canvases', error); - } - } - - function isCurrentDefinitionsRequest(context: ICanvasContext, definitionProvider: NonNullable, request: number): boolean { - return !store.isDisposed - && request === requestSequence - && currentContext.get() === context - && context.definitionProvider === definitionProvider - && canvasService.enabled.get(); - } - async function openCanvasDefinition(owner: ICanvasOwner, canvas: ICanvasDefinition, label: string): Promise { const context = currentContext.get(); const definitionAvailable = availableDefinitions.get().some(candidate => candidate.extensionId === canvas.extensionId && candidate.canvasId === canvas.canvasId); diff --git a/src/vs/workbench/contrib/canvases/common/canvasDefinitionStore.ts b/src/vs/workbench/contrib/canvases/common/canvasDefinitionStore.ts new file mode 100644 index 00000000000000..3e104692c2d5f9 --- /dev/null +++ b/src/vs/workbench/contrib/canvases/common/canvasDefinitionStore.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Delayer, ThrottlerByKey } from '../../../../base/common/async.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, derivedOpts, IObservable, IReaderWithStore, observableValue } from '../../../../base/common/observable.js'; +import { MicrotaskDelay } from '../../../../base/common/symbols.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { canvasOwnerKey, compareCanvasDefinitions, ICanvasDefinition, ICanvasOwner, isOpenableExtensionCanvasDefinition } from './canvas.js'; + +/** The chat whose registered canvas types a {@link CanvasDefinitionStore} lists. */ +export interface ICanvasDefinitionSource { + readonly owner: ICanvasOwner; + listCanvases(): Promise; +} + +interface ICanvasDefinitionRequest { + readonly ownerKey: string; + readonly source: ICanvasDefinitionSource; +} + +interface ILoadedCanvasDefinitions { + readonly ownerKey: string; + readonly definitions: readonly ICanvasDefinition[]; +} + +/** + * Lists the openable canvas types registered for one chat at a time, refreshing whenever `source` re-evaluates. + * Refreshes keep the current list so commands built from it stay registered; only an owner change or an unavailable source clears it. + */ +export class CanvasDefinitionStore extends Disposable { + + /** The current owner's openable definitions in menu order, empty until a listing for that owner succeeds. */ + readonly definitions: IObservable; + + private readonly loaded = observableValue(this, undefined); + /** Coalesces synchronous bursts of invalidations into one refresh. */ + private readonly refreshDelayer = this._register(new Delayer(MicrotaskDelay)); + private readonly refreshes = this._register(new ThrottlerByKey()); + private ownerKey: string | undefined; + + constructor( + source: (reader: IReaderWithStore) => ICanvasDefinitionSource | undefined, + private readonly logPrefix: string, + @ILogService private readonly logService: ILogService, + ) { + super(); + const request = derived(this, reader => { + const current = source(reader); + return current && { ownerKey: canvasOwnerKey(current.owner), source: current }; + }); + this.definitions = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { + const ownerKey = request.read(reader)?.ownerKey; + const loaded = this.loaded.read(reader); + // Derived from the requested owner so readers never pair it with another owner's definitions. + return ownerKey !== undefined && loaded?.ownerKey === ownerKey ? loaded.definitions : []; + }); + this._register(autorun(reader => { + const next = request.read(reader); + if (next?.ownerKey !== this.ownerKey) { + this.ownerKey = next?.ownerKey; + this.loaded.set(undefined, undefined); + } + if (next) { + this.refreshDelayer.trigger(() => this.refreshes.queue(next.ownerKey, () => this.refresh(next))).catch(error => { + if (!isCancellationError(error)) { + this.logService.error(`${this.logPrefix} Failed to schedule registered canvas refresh`, error); + } + }); + } + })); + } + + private async refresh(request: ICanvasDefinitionRequest): Promise { + if (!this.isCurrent(request)) { + return; + } + try { + const definitions = await request.source.listCanvases(); + if (this.isCurrent(request)) { + this.loaded.set({ ownerKey: request.ownerKey, definitions: definitions.filter(isOpenableExtensionCanvasDefinition).sort(compareCanvasDefinitions) }, undefined); + } + } catch (error) { + if (this.isCurrent(request)) { + this.logService.error(`${this.logPrefix} Failed to list registered canvases`, error); + } + } + } + + private isCurrent(request: ICanvasDefinitionRequest): boolean { + return !this._store.isDisposed && request.ownerKey === this.ownerKey; + } +} diff --git a/src/vs/workbench/contrib/canvases/test/common/canvasDefinitionStore.test.ts b/src/vs/workbench/contrib/canvases/test/common/canvasDefinitionStore.test.ts new file mode 100644 index 00000000000000..37add55c2c55e8 --- /dev/null +++ b/src/vs/workbench/contrib/canvases/test/common/canvasDefinitionStore.test.ts @@ -0,0 +1,233 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { autorun, observableSignal, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { ICanvasDefinition } from '../../common/canvas.js'; +import { CanvasDefinitionStore } from '../../common/canvasDefinitionStore.js'; + +suite('CanvasDefinitionStore', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function definition(canvasId: string, overrides: Partial = {}): ICanvasDefinition { + return { canvasId, extensionId: `user:${canvasId}`, extensionSource: 'user', displayName: canvasId, description: canvasId, requiresInput: false, ...overrides }; + } + + function createHarness() { + const chat = observableValue('chat', 'main'); + const connection = observableValue('connection', 'first'); + const invalidated = observableSignal('invalidated'); + const listings: { chat: string; connection: string; result: DeferredPromise }[] = []; + const errors: string[] = []; + const logService = new class extends NullLogService { + override error(message: string | Error): void { + errors.push(String(message)); + } + }; + const definitionStore = store.add(new CanvasDefinitionStore(reader => { + const name = chat.read(reader); + const connectionName = connection.read(reader); + invalidated.read(reader); + return name === undefined ? undefined : { + owner: { providerId: 'local', session: URI.parse('agent-host-session:/session'), chat: URI.parse(`agent-host-chat:/session/${name}`) }, + listCanvases: () => { + const result = new DeferredPromise(); + listings.push({ chat: name, connection: connectionName, result }); + return result.p; + }, + }; + }, '[Test]', logService)); + return { + chat, + connection, + invalidated, + listings, + errors, + definitionStore, + canvasIds: () => definitionStore.definitions.get().map(canvas => canvas.canvasId), + pendingChats: () => listings.filter(listing => !listing.result.isSettled).map(listing => listing.chat), + }; + } + + test('lists openable definitions in menu order and keeps them through pending and failed refreshes', async () => { + const harness = createHarness(); + await timeout(0); + harness.listings[0].result.complete([ + definition('zeta'), + definition('form', { requiresInput: true }), + definition('browser', { extensionId: 'github-app', extensionSource: 'unknown' }), + definition('alpha', { extensionId: 'project:alpha', extensionSource: 'project' }), + ]); + await timeout(0); + const listed = harness.canvasIds(); + harness.invalidated.trigger(undefined); + await timeout(0); + const whileRefreshing = harness.canvasIds(); + harness.listings[1].result.error(new Error('Host unavailable')); + await timeout(0); + + assert.deepStrictEqual({ listed, whileRefreshing, afterFailure: harness.canvasIds(), errors: harness.errors }, { + listed: ['alpha', 'zeta'], + whileRefreshing: ['alpha', 'zeta'], + afterFailure: ['alpha', 'zeta'], + errors: ['[Test] Failed to list registered canvases'], + }); + }); + + test('coalesces invalidations per owner and applies each result in order', async () => { + const harness = createHarness(); + await timeout(0); + harness.invalidated.trigger(undefined); + harness.invalidated.trigger(undefined); + await timeout(0); + harness.invalidated.trigger(undefined); + await timeout(0); + const pendingWhileInvalidating = harness.pendingChats(); + harness.listings[0].result.complete([definition('plan')]); + await timeout(0); + const afterFirstResult = { canvasIds: harness.canvasIds(), pendingChats: harness.pendingChats() }; + harness.listings[1].result.complete([definition('plan'), definition('board')]); + await timeout(0); + const afterFollowUp = harness.canvasIds(); + harness.invalidated.trigger(undefined); + harness.invalidated.trigger(undefined); + harness.invalidated.trigger(undefined); + await timeout(0); + harness.listings[2].result.complete([definition('plan'), definition('board')]); + await timeout(0); + + assert.deepStrictEqual({ pendingWhileInvalidating, afterFirstResult, afterFollowUp, listings: harness.listings.length }, { + pendingWhileInvalidating: ['main'], + afterFirstResult: { canvasIds: ['plan'], pendingChats: ['main'] }, + afterFollowUp: ['board', 'plan'], + listings: 3, + }); + }); + + test('keeps the list when the same owner gets a new source and refreshes with the newest source', async () => { + const harness = createHarness(); + await timeout(0); + harness.listings[0].result.complete([definition('plan')]); + await timeout(0); + harness.connection.set('second', undefined); + await timeout(0); + harness.connection.set('third', undefined); + await timeout(0); + harness.connection.set('fourth', undefined); + await timeout(0); + const whileReplacing = harness.canvasIds(); + harness.listings[1].result.complete([definition('plan'), definition('board')]); + await timeout(0); + const afterReplacedListing = harness.canvasIds(); + harness.listings[2].result.complete([definition('board')]); + await timeout(0); + + assert.deepStrictEqual({ + whileReplacing, + afterReplacedListing, + canvasIds: harness.canvasIds(), + listings: harness.listings.map(listing => `${listing.chat}/${listing.connection}`), + }, { + whileReplacing: ['plan'], + afterReplacedListing: ['board', 'plan'], + canvasIds: ['board'], + listings: ['main/first', 'main/second', 'main/fourth'], + }); + }); + + test('lists a new owner immediately and never lists or applies the previous owner', async () => { + const harness = createHarness(); + await timeout(0); + harness.invalidated.trigger(undefined); + await timeout(0); + harness.chat.set('peer', undefined); + await timeout(0); + const pendingAfterSwitch = harness.pendingChats(); + harness.listings[0].result.complete([definition('plan')]); + await timeout(0); + + assert.deepStrictEqual({ pendingAfterSwitch, listedChats: harness.listings.map(listing => listing.chat), canvasIds: harness.canvasIds() }, { + pendingAfterSwitch: ['main', 'peer'], + listedChats: ['main', 'peer'], + canvasIds: [], + }); + }); + + test('clears on owner changes without pairing an owner with another owner\'s definitions', async () => { + const harness = createHarness(); + const observed: string[] = []; + store.add(autorun(reader => { + observed.push(`${harness.chat.read(reader)}: ${harness.definitionStore.definitions.read(reader).map(canvas => canvas.canvasId).join(', ')}`); + })); + await timeout(0); + harness.listings[0].result.complete([definition('plan')]); + await timeout(0); + let readInSwitchTransaction: string[] | undefined; + transaction(tx => { + harness.chat.set('peer', tx); + readInSwitchTransaction = harness.canvasIds(); + }); + await timeout(0); + harness.listings[1].result.complete([definition('board')]); + await timeout(0); + harness.chat.set('main', undefined); + await timeout(0); + + assert.deepStrictEqual({ observed, readInSwitchTransaction }, { + observed: ['main: ', 'main: plan', 'peer: ', 'peer: board', 'main: '], + readInSwitchTransaction: [], + }); + }); + + test('does not notify observers when a refresh returns structurally identical definitions', async () => { + const harness = createHarness(); + let notifications = 0; + store.add(autorun(reader => { + harness.definitionStore.definitions.read(reader); + notifications++; + })); + await timeout(0); + harness.listings[0].result.complete([definition('plan')]); + await timeout(0); + harness.invalidated.trigger(undefined); + await timeout(0); + harness.listings[1].result.complete([definition('plan')]); + await timeout(0); + + assert.deepStrictEqual({ notifications, canvasIds: harness.canvasIds() }, { notifications: 2, canvasIds: ['plan'] }); + }); + + test('clears while unavailable and ignores results that arrive late or after disposal', async () => { + const harness = createHarness(); + await timeout(0); + harness.listings[0].result.complete([definition('plan')]); + await timeout(0); + harness.invalidated.trigger(undefined); + await timeout(0); + harness.chat.set(undefined, undefined); + const whileUnavailable = harness.canvasIds(); + harness.listings[1].result.complete([definition('late')]); + await timeout(0); + const afterLateResult = harness.canvasIds(); + harness.chat.set('main', undefined); + await timeout(0); + harness.invalidated.trigger(undefined); + harness.definitionStore.dispose(); + harness.listings[2].result.complete([definition('afterDispose')]); + await timeout(0); + + assert.deepStrictEqual({ whileUnavailable, afterLateResult, afterDispose: harness.canvasIds(), listings: harness.listings.length, errors: harness.errors }, { + whileUnavailable: [], + afterLateResult: [], + afterDispose: [], + listings: 3, + errors: [], + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts index 7041dbef78c78d..fd1d3351801d77 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts @@ -21,19 +21,18 @@ import { toClientPluginMcpDefaultCwdsMeta, toClientPluginStandaloneMeta, type Cl import { withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { customizationId, type ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { IAgentHostFileSystemService, SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; +import { IAgentHostFileSystemService, SYNCED_CUSTOMIZATION_DISPLAY_NAME, SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; import { IgnoreFile } from '../../../../../../workbench/services/search/common/ignoreFile.js'; // Re-export so existing consumers don't need to change their import source. export { SYNCED_CUSTOMIZATION_SCHEME }; -const DISPLAY_NAME = 'VS Code Synced Data'; const FILE_OPERATION_CONCURRENCY = 10; const SKILL_DIRECTORY_IGNORE = new IgnoreFile('.git\nnode_modules\n', '/', undefined, true); const bundleSequencer = new SequencerByKey(); const MANIFEST_CONTENT = JSON.stringify({ - name: DISPLAY_NAME, + name: SYNCED_CUSTOMIZATION_DISPLAY_NAME, description: 'Customization data synced from VS Code', }, null, '\t'); @@ -412,7 +411,7 @@ export class SyncedCustomizationBundler extends Disposable { type: CustomizationType.Plugin, id: customizationId(rootUriString), uri: rootUriString, - name: this._standalone ? STANDALONE_DISPLAY_NAME : DISPLAY_NAME, + name: this._standalone ? STANDALONE_DISPLAY_NAME : SYNCED_CUSTOMIZATION_DISPLAY_NAME, nonce, ...(meta ? { _meta: meta } : {}), enablement: withCustomizationEnablement(undefined, CustomizationEnablementKind.Global, { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/repositoryPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/repositoryPicker.ts index aef5f9aab1c95a..ca9d79632c6587 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/repositoryPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/repositoryPicker.ts @@ -17,6 +17,8 @@ export const PICK_REPOSITORY_COMMAND_ID = '_chat.pickRepository'; export interface IRepositoryPickerOptions { readonly allowRepositoryUrl?: boolean; readonly placeholder?: string; + /** Maps the typed value to what items are filtered by locally, so a search term that is rewritten for the API still matches its results. */ + readonly filterValue?: (value: string) => string; } export interface IRepositoryPickResult { @@ -58,6 +60,9 @@ export class RepositoryPicker extends Disposable { quickPick.ariaLabel = quickPick.placeholder; quickPick.matchOnDescription = true; quickPick.matchOnDetail = true; + if (options.filterValue) { + quickPick.filterValue = options.filterValue; + } const updateItems = async () => { requests.clear(); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index fe8ba38a432720..1bf6b41a892d66 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -65,7 +65,7 @@ import { DELETE_AI_CUSTOMIZATION_ID, resolveAICustomizationManagementOpenEditorTarget, } from './aiCustomizationManagement.js'; -import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditor, CopilotConnectorDetailOpenResult } from './aiCustomizationManagementEditor.js'; import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; import './customizationMarketplace.contribution.js'; import './customizationMigrationAccessibility.js'; @@ -880,8 +880,18 @@ class AICustomizationManagementActionsContribution extends Disposable implements const pane = await editorService.openEditor(input, { pinned: true }); if (pane instanceof AICustomizationManagementEditor) { const section = typeof target === 'string' ? target : target?.section ?? AICustomizationManagementSection.McpServers; - const sourceId = typeof target === 'string' ? undefined : target?.sourceId; - pane.selectSectionById(section, { showMarketplace: true, marketplaceSourceId: sourceId }); + if (typeof target !== 'string' && target?.connectorName) { + const result = await pane.showCopilotConnectorDetail(target.connectorName); + if (result === CopilotConnectorDetailOpenResult.NotFound) { + throw new Error(`Copilot connector '${target.connectorName}' is not available`); + } + } else { + const sourceId = typeof target === 'string' ? undefined : target?.sourceId; + pane.selectSectionById(section, { showMarketplace: true, marketplaceSourceId: sourceId }); + if (typeof target !== 'string' && target?.searchQuery !== undefined) { + pane.setSearchQuery(target.searchQuery); + } + } } } })); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 4283715706470d..1b16eb024bf811 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -114,7 +114,7 @@ import { IAgentPluginService } from '../../common/plugins/agentPluginService.js' import { createWorkbenchMcpServerDetailInput, EmbeddedMcpServerDetail, IMcpServerDetailInput, isMcpServerDetailMigratable } from './embeddedMcpServerDetail.js'; import { EmbeddedAgentPluginDetail } from './embeddedAgentPluginDetail.js'; import { EmbeddedConnectorDetail } from './embeddedConnectorDetail.js'; -import { ICopilotConnector } from './copilotConnectorsService.js'; +import { ICopilotConnector, ICopilotConnectorsService } from './copilotConnectorsService.js'; import { EmbeddedMarketplaceDetail } from './embeddedMarketplaceDetail.js'; import { IMcpService, IMcpWorkbenchService, type IWorkbenchMcpServer, McpServerInstallState } from '../../../mcp/common/mcpTypes.js'; import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; @@ -176,6 +176,21 @@ export function isCurrentPluginContributionNavigation( return requestGeneration === currentGeneration && requestedSection === selectedSection && isListView; } +export function isCurrentConnectorDetailNavigation( + requestGeneration: number, + currentGeneration: number, + requestInput: object | undefined, + currentInput: object | undefined, +): boolean { + return requestGeneration === currentGeneration && requestInput !== undefined && requestInput === currentInput; +} + +export const enum CopilotConnectorDetailOpenResult { + Opened = 'opened', + NotFound = 'notFound', + Superseded = 'superseded', +} + type CustomizationEditorItemSelectedEvent = { section: string; promptType: string; @@ -530,6 +545,7 @@ export class AICustomizationManagementEditor extends EditorPane { @IEditorService private readonly editorService: IEditorService, @ICustomizationMarketplaceService private readonly marketplaceService: ICustomizationMarketplaceService, @ICustomizationMarketplaceInstallService private readonly marketplaceInstallService: ICustomizationMarketplaceInstallService, + @ICopilotConnectorsService private readonly copilotConnectorsService: ICopilotConnectorsService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IEnvironmentService private readonly environmentService: IEnvironmentService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @@ -3872,6 +3888,7 @@ export class AICustomizationManagementEditor extends EditorPane { await this.revealCustomizationByUri(target.uri); return; } + for (let attempt = 0; attempt < 10; attempt++) { const revealed = target.section === AICustomizationManagementSection.Plugins && target.uri ? await this.pluginListWidget?.revealAndSelectItemByUri(target.uri) @@ -3885,6 +3902,22 @@ export class AICustomizationManagementEditor extends EditorPane { } } + public async showCopilotConnectorDetail(connectorName: string): Promise { + const navigationGeneration = this.contentNavigationGeneration; + const input = this.input; + const connectors = await this.copilotConnectorsService.getConnectors(CancellationToken.None); + if (!isCurrentConnectorDetailNavigation(navigationGeneration, this.contentNavigationGeneration, input, this.input)) { + return CopilotConnectorDetailOpenResult.Superseded; + } + const connector = connectors.find(candidate => candidate.name === connectorName); + if (!connector) { + return CopilotConnectorDetailOpenResult.NotFound; + } + this.selectSectionById(AICustomizationManagementSection.McpServers); + this.showEmbeddedConnectorDetail(connector); + return CopilotConnectorDetailOpenResult.Opened; + } + /** * Scrolls the active list widget so the last item is visible. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts index bbb487d71de72d..da67b6121d848d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts @@ -81,7 +81,6 @@ type CopilotConnectorsCatalogSnapshotClassification = { type CopilotConnectorConnectionActionEvent = { action: 'connect' | 'disconnect'; outcome: 'success' | 'error' | 'cancelled'; - connectorName: string; connectionStatusBefore: CopilotConnectorConnectionStatus | 'not_loaded'; connectionStatusAfter: CopilotConnectorConnectionStatus | 'not_loaded'; durationMs: number; @@ -94,7 +93,6 @@ type CopilotConnectorConnectionActionClassification = { comment: 'Tracks explicit Copilot connector connection actions and outcomes for rollout and comparison with MCP server installation and usage.'; action: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user attempted to connect or disconnect a connector.' }; outcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the connection action succeeded, failed, or was cancelled.' }; - connectorName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Public catalog identifier of the connector acted on.' }; connectionStatusBefore: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Last known connector connection status before the action.' }; connectionStatusAfter: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Last known connector connection status after the action.' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Duration of the connection action in milliseconds, including authorization and polling.' }; @@ -484,7 +482,6 @@ export class CopilotConnectorsService extends Disposable implements ICopilotConn this.telemetryService.publicLog2('copilotConnectors.connectionAction', { action, outcome, - connectorName: name, connectionStatusBefore, connectionStatusAfter: connector?.connectionStatus ?? 'not_loaded', durationMs, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts index 87afcf1e2d1f7e..4f0c6a4cd9d4fc 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationDashboard.ts @@ -525,6 +525,8 @@ export class CustomizationMigrationDashboard extends Disposable { overrideStyles: customizationTreeStyles, multipleSelectionSupport: false, horizontalScrolling: false, + // Rows with migration warnings grow to fit all of them instead of clipping at a fixed height. + supportDynamicHeights: true, openOnSingleClick: true, identityProvider: { getId: entry => entry.id }, accessibilityProvider: { @@ -628,7 +630,8 @@ export class CustomizationMigrationDashboard extends Disposable { treeContainer.style.height = ''; const height = treeContainer.clientHeight || getCustomizationTreeContentHeight(this.element, treeContainer, this.availableHeight); treeContainer.style.height = `${height}px`; - tree.layout(height); + // Passing the width lets the tree re-measure wrapped warning rows when the dashboard is resized. + tree.layout(height, treeContainer.clientWidth); this.callbacks.onDidChangeContent?.(); }; this.layoutTree = layoutTree; diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 037880e47255d3..f0c3f43b877f42 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -294,7 +294,7 @@ CommandsRegistry.registerCommand(PICK_REPOSITORY_COMMAND_ID, async (accessor: Se throw new Error('The repository search command did not return repository names'); } return repositories; - }, options); + }, options ?? undefined); // `undefined` arrives as `null` over the extension host RPC } finally { store.dispose(); } @@ -2391,6 +2391,23 @@ configurationRegistry.registerConfiguration({ default: false, scope: ConfigurationScope.WINDOW, }, + [ChatConfiguration.CopilotCliCommandEnabled]: { + type: 'boolean', + markdownDescription: nls.localize('chat.copilotCliCommand.enabled', "Controls whether integrated terminals include the `copilot` command that ships with VS Code. The command runs GitHub Copilot CLI and offers to install it when it's missing. A GitHub Copilot CLI that you installed yourself isn't affected."), + default: true, + scope: ConfigurationScope.APPLICATION, + policy: { + name: 'CopilotCliCommand', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.143', + localization: { + description: { + key: 'chat.copilotCliCommand.enabled.policy', + value: nls.localize('chat.copilotCliCommand.enabled.policy', "Controls whether VS Code provides the copilot command for GitHub Copilot CLI. When disabled, VS Code setup on Windows doesn't add the command to PATH and removes the entry it added, VS Code doesn't install GitHub Copilot CLI, and integrated terminals don't include the command. A GitHub Copilot CLI that was installed separately isn't affected."), + } + }, + } + }, [ChatConfiguration.TitleBarSignInEnabled]: { type: 'boolean', description: nls.localize('chat.titleBar.signIn.enabled', "Controls whether the Copilot Sign In button is shown in the title bar when signed out. When disabled, the Sign In affordance falls back to the status bar."), diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts index 0f373d9791fa98..11e10e3e8f7cc3 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts @@ -58,7 +58,8 @@ interface ITaskSummary { readonly updated_at?: string; readonly state?: string; readonly agent_collaborators?: readonly { readonly slug?: string }[]; - readonly compute?: { readonly provider?: string }; + readonly compute?: { readonly provider?: string } | null; + readonly current_environment?: { readonly kind?: string }; /** * The owning repository, identified by numeric id only โ€” the payload carries no name. See * {@link CloudSandboxApiService._resolveRepositoryName}. @@ -163,9 +164,6 @@ const DIAGNOSTIC_RESPONSE_HEADERS = ['x-github-request-id', 'x-request-id', 'x-s /** How many recent tasks to scan for sandbox sessions during discovery, per page. */ const DISCOVERY_TASK_SCAN_LIMIT = 100; -/** Bounds sequential page fetches. Hitting it leaves tasks unscanned, so the result is `partial`. */ -const DISCOVERY_TASK_PAGE_LIMIT = 10; - /** * Max concurrent task-detail fetches during discovery. * @@ -376,7 +374,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA { withRepository: true, archived: false }, { withRepository: false, archived: false }, { withRepository: true, archived: true }, { withRepository: false, archived: true }, ]) { - for (let page = 1; page <= DISCOVERY_TASK_PAGE_LIMIT; page++) { + for (let page = 1; ; page++) { if (token.isCancellationRequested) { throw new CancellationError(); } @@ -410,9 +408,6 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA if (!hasNextLink(context.res.headers?.link)) { break; } - if (page === DISCOVERY_TASK_PAGE_LIMIT) { - truncated = true; - } } catch (error) { if (isCancellationError(error) || token.isCancellationRequested) { throw new CancellationError(); @@ -438,11 +433,22 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA const discoveryTime = checkpoint ?? Date.now(); const removedTaskIds: string[] = []; const sandboxTasks: IScannedTask[] = []; + const exclusions = { notSandboxCompute: 0, missingSessionBinding: 0, stalledQueuedSession: 0, detailFetchFailed: 0 }; + const logExcludedTask = (taskId: string, reason: keyof typeof exclusions) => { + exclusions[reason]++; + this._logService.debug(`${LOG_PREFIX} Excluded task ${JSON.stringify({ taskId, reason })}`); + }; + let acceptedWithOtherAgent = 0; for (const task of tasks.values()) { if (isCloudSandboxTask(task)) { sandboxTasks.push(task); + if (!task.agent_collaborators?.some(c => c.slug === CLOUD_SANDBOX_AGENT_SLUG)) { + acceptedWithOtherAgent++; + this._logService.debug(`${LOG_PREFIX} Accepted task previously excluded by agent slug ${JSON.stringify({ taskId: task.id })}`); + } } else { - if (cache.has(task.id) || isCloudSandboxTask(task)) { + logExcludedTask(task.id, 'notSandboxCompute'); + if (cache.has(task.id)) { removedTaskIds.push(task.id); } cache.delete(task.id); @@ -489,6 +495,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA } if (cached.unstartedSince !== undefined && discoveryTime - cached.unstartedSince >= STALLED_SESSION_GRACE_MS) { cache.set(task.id, cached); + logExcludedTask(task.id, 'stalledQueuedSession'); // An unread page may contain this candidate's recovery update. if (!truncated || scannedTaskIds.has(task.id)) { removedTaskIds.push(task.id); @@ -501,12 +508,16 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA cached = { ...cached, session: { ...cached.session, repoName } }; } cache.set(task.id, cached); + if (!cached.session) { + logExcludedTask(task.id, 'missingSessionBinding'); + } return cached.session; } catch (error) { if (isCancellationError(error) || token.isCancellationRequested) { throw new CancellationError(); } this._logService.warn(`${LOG_PREFIX} Discovery getTask ${task.id} failed: ${toErrorMessage(error)}`); + logExcludedTask(task.id, 'detailFetchFailed'); cache.set(task.id, { ...cache.get(task.id), summary: task, needsRefresh: true }); unresolved++; return undefined; @@ -544,6 +555,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA const sessions = discovered.filter((session): session is ICloudSandboxDiscoveredSession => session !== undefined); const unnamed = sessions.filter(session => !session.repoName).length; this._logService.info(`${LOG_PREFIX} ${since ? 'Incremental discovery' : 'Discovery'} found ${sessions.length} sandbox session(s) from ${sandboxTasks.length} sandbox task(s) out of ${scannedTaskIds.size} scanned${truncated ? ' (scan truncated)' : ''}${unresolved > 0 ? `; ${unresolved} unresolved` : ''}${unnamed > 0 ? `; ${unnamed} without a repository name (they group under "Unknown")` : ''}.`); + this._logService.info(`${LOG_PREFIX} Discovery filtering ${JSON.stringify({ ...exclusions, acceptedWithOtherAgent })}`); for (const session of sessions) { this._logService.debug(`${LOG_PREFIX} Discovered sandbox session ${JSON.stringify({ taskId: session.taskId, sessionId: session.sessionId, environmentId: session.environmentId, @@ -1183,17 +1195,10 @@ function rateLimitDelay(retryAfter: string | string[] | undefined, attempt: numb : Math.min(RATE_LIMIT_BASE_DELAY_MS * Math.pow(2, attempt), RATE_LIMIT_MAX_BACKOFF_MS); } -/** - * Whether a task is a cloud sandbox task: owned by {@link CLOUD_SANDBOX_AGENT_SLUG} and running on - * the `sandboxes` compute provider. Reads list-level fields only. - * - * The slug half must be settled before `chat.agentHost.cloudSandbox.enabled` is turned on: sandbox - * tasks are expected to move to a different slug, which would silently make discovery return - * nothing. `compute.provider` is the durable test. - */ +/** Use a managed sandbox environment when the task has no compute-provider metadata. */ function isCloudSandboxTask(task: ITaskSummary): boolean { - const isCloudCodingAgent = task.agent_collaborators?.some(c => c.slug === CLOUD_SANDBOX_AGENT_SLUG) ?? false; - return isCloudCodingAgent && task.compute?.provider === 'sandboxes'; + const provider = task.compute?.provider; + return provider === 'sandboxes' || (provider === undefined && task.current_environment?.kind === 'managed-sandbox'); } /** Whether a `Link` header advertises another page (`rel="next"`). */ diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts new file mode 100644 index 00000000000000..928e9ecb811a8b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxRepositoryPicker.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceCancellationError } from '../../../../../base/common/async.js'; +import { CancellationToken, cancelOnDispose } from '../../../../../base/common/cancellation.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { IGitHubClient } from '../../../../../platform/github/common/githubService.js'; +import { GitHubRequestError } from '../../../../../platform/github/common/githubTypes.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchGitHubService } from '../../../../services/github/common/githubService.js'; +import { getGitHubRepositoryFromRemoteUrl } from '../../../git/common/utils.js'; +import { RepositoryPicker } from '../agentSessions/repositoryPicker.js'; + +const REQUEST_CALLER = 'cloudSandbox.repositoryPicker'; +const PAGE_SIZE = 100; + +interface IGitHubLoginResponse { + readonly login?: string; +} + +/** A pasted GitHub URL stands for its `owner/repo`, both for the search and for filtering its results. */ +function toRepositorySearchTerm(value: string): string { + const trimmed = value.trim(); + const remote = getGitHubRepositoryFromRemoteUrl(trimmed); + return remote ? `${remote.owner}/${remote.repo}` : trimmed; +} + +/** Searches the signed-in account's GitHub repositories in the renderer, so no extension host is required. */ +export class CloudSandboxRepositoryPicker extends Disposable { + + private readonly _currentPick = this._register(new MutableDisposable()); + + constructor( + @IWorkbenchGitHubService private readonly _gitHubService: IWorkbenchGitHubService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + } + + /** Resolves to the picked `owner/repo`, or `undefined` when dismissed. Rejects when no repository-scoped GitHub session exists. */ + async pick(token: CancellationToken = CancellationToken.None): Promise { + const store = new DisposableStore(); + this._currentPick.value = store; + const pickToken = cancelOnDispose(store); + store.add(token.onCancellationRequested(() => store.dispose())); + const controller = new AbortController(); + store.add(toDisposable(() => controller.abort())); + try { + const client = store.add(await raceCancellationError(this._gitHubService.acquireDefaultAccountClient(controller.signal), pickToken)).object; + // The viewer and their organizations scope every search, so resolve them once per pick. + let scope: Promise | undefined; + const resolveScope = (signal: AbortSignal) => scope ??= this._resolveSearchScope(client, signal).catch(error => { scope = undefined; throw error; }); + const picker = store.add(this._instantiationService.createInstance(RepositoryPicker)); + const selection = await picker.pickRepository(async (query, requestToken) => { + const requestController = new AbortController(); + const listener = requestToken.onCancellationRequested(() => requestController.abort()); + try { + return await this._searchRepositories(client, query, resolveScope, AbortSignal.any([controller.signal, requestController.signal])); + } finally { + listener.dispose(); + } + }, { filterValue: toRepositorySearchTerm }, pickToken); + return selection?.repository; + } finally { + store.dispose(); + if (this._currentPick.value === store) { + this._currentPick.clear(); + } + } + } + + private async _searchRepositories(client: IGitHubClient, query: string, resolveScope: (signal: AbortSignal) => Promise, signal: AbortSignal): Promise { + const { account } = await client.credentials.getCredential(signal); + const term = toRepositorySearchTerm(query); + if (!term) { + const page = await client.query.listRepositories(account, signal, { + affiliation: ['owner', 'collaborator', 'organization_member'], + sort: 'updated', + perPage: PAGE_SIZE, + }); + return page.repositories.map(repository => repository.nameWithOwner); + } + const page = await client.query.searchRepositories(account, `${term} in:name fork:true ${await resolveScope(signal)}`, signal, { sort: 'updated', perPage: PAGE_SIZE }); + return page.repositories.map(repository => repository.nameWithOwner); + } + + /** `user: org:...` qualifiers restricting search to repositories the account can reach. */ + private async _resolveSearchScope(client: IGitHubClient, signal: AbortSignal): Promise { + const { account, token } = await client.credentials.getCredential(signal); + const apiBaseUri = client.endpoint.getApiBaseUri(); + const [user, organizations] = await Promise.all([ + client.transport.rest(account, token, { caller: REQUEST_CALLER, method: 'GET', url: `${apiBaseUri}/user`, etag: true, priority: 'interactive' }, signal), + client.transport.rest(account, token, { caller: REQUEST_CALLER, method: 'GET', url: `${apiBaseUri}/user/orgs?per_page=${PAGE_SIZE}`, etag: true, priority: 'interactive' }, signal), + ]); + if (!user.data?.login || !Array.isArray(organizations.data)) { + throw new GitHubRequestError('GitHub did not return an account and organizations for repository search', 'malformedResponse'); + } + return [`user:${user.data.login}`, ...organizations.data.flatMap(organization => organization.login ? [`org:${organization.login}`] : [])].join(' '); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts index 1f6801375f0221..592be988b4473e 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts @@ -22,7 +22,7 @@ import { ChangesetKind } from '../../../../../platform/agentHost/common/changese import { CLOUD_SANDBOX_AGENT_PROVIDER, CLOUD_SANDBOX_SESSION_SCHEME, cloudSandboxAddress, ICloudSandboxAgentHostService, ICloudSandboxApiService } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -44,6 +44,7 @@ import { ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionIt import { isUntitledChatSession } from '../../common/model/chatUri.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { CloudSandboxModels } from './cloudSandboxModels.js'; +import { CloudSandboxRepositoryPicker } from './cloudSandboxRepositoryPicker.js'; import { createCloudSandboxDraftOptionGroups, createCloudSandboxSessionConfig, readCloudSandboxDraftConfig, resolveCloudSandboxSessionConfig } from './cloudSandboxSessionConfig.js'; import { CloudSandboxSessionContribution, ICloudSandboxSessionEnvironment } from './cloudSandboxSessionContribution.js'; import { CloudSandboxSessionListController } from './cloudSandboxSessionListController.js'; @@ -55,8 +56,6 @@ export const CLOUD_SANDBOX_SESSION_TYPE = SessionType.CloudSandbox; export const CLOUD_SANDBOX_REPOSITORY_OPTION_GROUP = 'repository'; /** Opens the GitHub repository picker and records the pick as the draft's repository option. */ export const CLOUD_SANDBOX_SELECT_REPOSITORY_COMMAND_ID = 'workbench.action.chat.cloudSandbox.selectRepository'; -/** The Copilot Chat extension's repository search, shared with the Agents window on desktop. */ -const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.chat.cloudSessions.openRepository'; /** Pre-selected while no repository is known; a slash-free id so it can never be mistaken for a pick. */ const SELECT_REPOSITORY_PLACEHOLDER_ID = 'select-repository'; const REPOSITORY_NWO_PATTERN = /^[\w.-]+\/[\w.-]+$/; @@ -90,6 +89,7 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo private readonly _draftOptionGroups: IObservable; /** Models offered on a draft, from the shared cloud catalog; alive while the creation type is registered. */ private readonly _draftModels = this._register(new MutableDisposable()); + private readonly _repositoryPicker = this._register(new MutableDisposable()); constructor( @ICloudSandboxAgentHostService cloudSandboxService: ICloudSandboxAgentHostService, @@ -192,7 +192,7 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo return [{ id: CLOUD_SANDBOX_REPOSITORY_OPTION_GROUP, name: localize('cloudSandbox.repositoryGroup', "Repository"), - description: localize('cloudSandbox.repositoryGroupDescription', "The GitHub repository to clone into the sandbox"), + description: localize('cloudSandbox.repositoryGroupDescription', "Select repository..."), icon: Codicon.repo, items, }]; @@ -202,7 +202,7 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo return [{ id: CLOUD_SANDBOX_REPOSITORY_OPTION_GROUP, name: localize('cloudSandbox.repositoryGroup', "Repository"), - description: localize('cloudSandbox.repositoryGroupDescription', "The GitHub repository to clone into the sandbox"), + description: localize('cloudSandbox.repositoryGroupDescription', "Select repository..."), icon: Codicon.repo, selected: { id: SELECT_REPOSITORY_PLACEHOLDER_ID, @@ -286,7 +286,7 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo // Also keeps refresh available before discovery has found the first environment. store.add(this._editorChatSessionsService.registerChatSessionItemController(CLOUD_SANDBOX_SESSION_TYPE, this)); store.add(CommandsRegistry.registerCommand(CLOUD_SANDBOX_SELECT_REPOSITORY_COMMAND_ID, (accessor, sessionResource?: URI) => - this._selectRepository(accessor.get(ICommandService), accessor.get(INotificationService), sessionResource))); + this._selectRepository(accessor.get(INotificationService), sessionResource))); this._editorChatSessionsService.setOptionGroupsForSessionType(CLOUD_SANDBOX_SESSION_TYPE, 0, this._draftOptionGroups.get()); store.add(toDisposable(() => this._editorChatSessionsService.setOptionGroupsForSessionType(CLOUD_SANDBOX_SESSION_TYPE, 0, undefined))); } else { @@ -294,11 +294,10 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo } } - /** Runs the extension's GitHub repository search and records the pick on the draft that invoked it. */ - private async _selectRepository(commandService: ICommandService, notificationService: INotificationService, sessionResource: URI | undefined): Promise { + /** Records the picked repository on the draft that invoked the picker. */ + private async _selectRepository(notificationService: INotificationService, sessionResource: URI | undefined): Promise { try { - // Without a resource the extension only returns the pick instead of also writing its own option. - const repoNwo = await commandService.executeCommand(OPEN_REPOSITORY_COMMAND_ID); + const repoNwo = await this._pickRepository(); if (repoNwo && URI.isUri(sessionResource)) { this._editorChatSessionsService.setSessionOption(sessionResource, CLOUD_SANDBOX_REPOSITORY_OPTION_GROUP, { id: repoNwo, name: repoNwo, icon: Codicon.repo }); } @@ -309,6 +308,12 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo } } + /** Searches the signed-in account's GitHub repositories in the renderer; no extension host is involved. */ + protected _pickRepository(): Promise { + const picker = this._repositoryPicker.value ??= this._instantiationService.createInstance(CloudSandboxRepositoryPicker); + return picker.pick(); + } + protected override _createProvider(env: ICloudSandboxSessionEnvironment, store: DisposableStore): CloudSandboxSessionListController { const address = cloudSandboxAddress(env.environmentId); const provider = store.add(this._instantiationService.createInstance(CloudSandboxSessionListController, address, this._workspaceRepositories, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index dab893bedc5c69..8c8828873325e2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -27,6 +27,7 @@ import { autorun, constObservable, derived, IObservable, IReader, observableFrom import { extUri, isEqual } from '../../../../../base/common/resources.js'; import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; +import { IChatOpenDiagnosticsService } from '../../common/chatOpenDiagnosticsService.js'; import { ChatPerfMark, clearChatMarks, markChat } from '../../common/chatPerf.js'; import { CloudSandboxSessionTrace } from '../../common/cloudSandboxSessionTrace.js'; import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; @@ -645,6 +646,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @IManagedSettingsService managedSettingsService: IManagedSettingsService, @ICommandService private readonly commandService: ICommandService, @IAgentHostPolicyContinuationService private readonly policyContinuationService: IAgentHostPolicyContinuationService, + @IChatOpenDiagnosticsService private readonly chatOpenDiagnosticsService: IChatOpenDiagnosticsService, ) { super(); this._persistentContentHeight = viewOptions.persistentContentHeight ?? 0; @@ -1510,6 +1512,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this._pendingFirstRenderSessionResource = undefined; this.logService.trace(`ChatWidget#firstRender: session=${this.viewModel.sessionResource.toString()} items=${items.length}`); CloudSandboxSessionTrace.get(this.viewModel.model)?.record('firstRender', { items: items.length }); + this.chatOpenDiagnosticsService.mark(this.viewModel.sessionResource, 'firstRender'); } if (!skipDynamicLayout && this._dynamicMessageLayoutData) { @@ -3051,6 +3054,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.listWidget.setViewModel(this.viewModel); CloudSandboxSessionTrace.get(model)?.record('widgetBound'); + this.chatOpenDiagnosticsService.mark(model.sessionResource, 'modelAttached'); // Armed only once the list is bound, so a render triggered while the // outgoing model was torn down cannot consume it. this._pendingFirstRenderSessionResource = model.sessionResource; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts index c6c19dd369ff50..05c8024a48344c 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts @@ -33,6 +33,7 @@ import { EditorInput } from '../../../../../common/editor/editorInput.js'; import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../../../common/theme.js'; import { IEditorGroup, IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { IWorkbenchLayoutService } from '../../../../../services/layout/browser/layoutService.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatModel, IChatModelInputState, IExportableChatData, ISerializableChatData } from '../../../common/model/chatModel.js'; import { IChatService } from '../../../common/chatService/chatService.js'; @@ -43,7 +44,8 @@ import { AgentHostSessionInputPills } from '../../agentSessions/agentHost/agentH import { ChatEditorInput } from './chatEditorInput.js'; import { ChatWidget } from '../../widget/chatWidget.js'; import { IChatWidgetViewState, setModelPreservingInputTypedWhileLoading } from '../../chat.js'; -import { getChatSessionType } from '../../../common/model/chatUri.js'; +import { getChatSessionType, isUntitledChatSession } from '../../../common/model/chatUri.js'; +import { IChatOpen, IChatOpenDiagnosticsService } from '../../../common/chatOpenDiagnosticsService.js'; export interface IChatEditorOptions extends IEditorOptions { /** @@ -84,6 +86,7 @@ export class ChatEditor extends AbstractEditorWithViewState()); + private readonly _openDiagnostics = this._register(new MutableDisposable()); private _providerReloadState: { readonly input: ChatEditorInput; readonly viewState: IChatWidgetViewState; @@ -105,6 +108,8 @@ export class ChatEditor extends AbstractEditorWithViewState