diff --git a/build/azure-pipelines/common/downloadCopilotVsix.ts b/build/azure-pipelines/common/downloadCopilotVsix.ts index e29e41bbb282f2..d43e4113d90b9e 100644 --- a/build/azure-pipelines/common/downloadCopilotVsix.ts +++ b/build/azure-pipelines/common/downloadCopilotVsix.ts @@ -99,7 +99,10 @@ async function unzip(zipPath: string, outputPath: string): Promise { const filePath = path.join(outputPath, entry.fileName); fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const ostream = fs.createWriteStream(filePath); + // Unix file mode is stored in the upper 16 bits of externalFileAttributes. + // Preserve it so executables like node-pty's spawn-helper stay executable. + const mode = (entry.externalFileAttributes >>> 16) & 0o777; + const ostream = fs.createWriteStream(filePath, mode ? { mode } : undefined); ostream.on('finish', () => { result.push(filePath); zipfile!.readEntry(); diff --git a/build/azure-pipelines/product-copilot-recovery.yml b/build/azure-pipelines/product-copilot-recovery.yml index edc4e64f6fa096..a0c52e68302470 100644 --- a/build/azure-pipelines/product-copilot-recovery.yml +++ b/build/azure-pipelines/product-copilot-recovery.yml @@ -48,6 +48,19 @@ extends: - template: copilot/setup-steps.yml - template: copilot/build-steps.yml + - script: | + set -e + + # The GLIBC check (from the shared extension template) scans every .node + # under $PWD. @github/copilot vendors @picovoice/pvrecorder-node, whose + # linux/x86_64 prebuild depends on GLIBC > 2.28. The native is not + # shipped in the VSIX, so just remove the pvrecorder prebuilds before + # the check runs. + find "$(Build.SourcesDirectory)" \ + -type d -name pvrecorder-node \ + -path "*@picovoice/pvrecorder-node" \ + -print -exec rm -rf {} + + displayName: Remove pvrecorder native binaries before GLIBC check uploadSourceMaps: enabled: true diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts index 7208837f4acb19..beb7ba848fd31b 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -163,6 +163,48 @@ function extractAlpinefromDocker(nodeVersion: string, platform: string, arch: st return es.readArray([new File({ path: 'node', contents, stat: { mode: parseInt('755', 8) } as fs.Stats })]); } +// WSL1 binfmt_elf rejects PT_LOAD segments with p_align > PAGE_SIZE (0x1000). +// Node 24 linux-x64 ships an `lpstub` LOAD segment aligned to 2 MiB for hugepage +// remapping; clamp it so the binary still loads under WSL1. +function patchElfLoadAlign(): NodeJS.ReadWriteStream { + return es.mapSync(file => { + if (!file.contents || !Buffer.isBuffer(file.contents)) { + return file; + } + const buf = file.contents; + if (buf.length < 64) { + return file; + } + if (buf[0] !== 0x7f || buf[1] !== 0x45 || buf[2] !== 0x4c || buf[3] !== 0x46) { + return file; + } + if (buf[4] !== 2 /* ELFCLASS64 */ || buf[5] !== 1 /* ELFDATA2LSB */) { + return file; + } + const e_phoff = Number(buf.readBigUInt64LE(0x20)); + const e_phentsize = buf.readUInt16LE(0x36); + const e_phnum = buf.readUInt16LE(0x38); + if (e_phentsize !== 56) { + return file; + } + const PT_LOAD = 1; + const MAX_ALIGN = 0x1000n; + for (let i = 0; i < e_phnum; i++) { + const off = e_phoff + i * e_phentsize; + if (off + e_phentsize > buf.length) { + break; + } + if (buf.readUInt32LE(off) !== PT_LOAD) { + continue; + } + if (buf.readBigUInt64LE(off + 48) > MAX_ALIGN) { + buf.writeBigUInt64LE(MAX_ALIGN, off + 48); + } + } + return file; + }); +} + const { nodeVersion, internalNodeVersion } = getNodeVersion(); BUILD_TARGETS.forEach(({ platform, arch }) => { @@ -229,14 +271,16 @@ function nodejs(platform: string, arch: string): NodeJS.ReadWriteStream | undefi fetchUrls(`/dist/v${nodeVersion}/win-${arch}/node.exe`, { base: 'https://nodejs.org', checksumSha256 })) .pipe(rename('node.exe')); case 'darwin': - case 'linux': - return (product.nodejsRepository !== 'https://nodejs.org' ? + case 'linux': { + const downloaded = (product.nodejsRepository !== 'https://nodejs.org' ? fetchGithub(product.nodejsRepository, { version: `${nodeVersion}-${internalNodeVersion}`, name: expectedName!, checksumSha256 }) : fetchUrls(`/dist/v${nodeVersion}/node-v${nodeVersion}-${platform}-${arch}.tar.gz`, { base: 'https://nodejs.org', checksumSha256 }) ).pipe(flatmap(stream => stream.pipe(gunzip()).pipe(untar()))) .pipe(filter('**/node')) .pipe(util.setExecutableBit('**')) .pipe(rename('node')); + return platform === 'linux' && arch === 'x64' ? downloaded.pipe(patchElfLoadAlign()) : downloaded; + } case 'alpine': return product.nodejsRepository !== 'https://nodejs.org' ? fetchGithub(product.nodejsRepository, { version: `${nodeVersion}-${internalNodeVersion}`, name: expectedName!, checksumSha256 }) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 2bf6b039ba62e6..11ee3a804be975 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -19,9 +19,9 @@ dependencies = [ [[package]] name = "ahp" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c974ae2da70e455e9e72de59ccc5279afc660ce267ab994474734d3310401f0" +checksum = "95f2c1c1d2a8428afa4b009abad4801f3b4d559950fc3041b764dc345ec0a855" dependencies = [ "ahp-types", "serde", @@ -33,30 +33,15 @@ dependencies = [ [[package]] name = "ahp-types" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299233ec34afadd8dd8c24fc63c50e24eb376c9588dd5b48804e33f60b1ecaf5" +checksum = "f33e8d9b2ed87ce94cb6339928b126f6942ebb168e532d47b81ad04dab8e71f3" dependencies = [ "serde", "serde_json", "serde_repr", ] -[[package]] -name = "ahp-ws" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516267592f4f77df2cecb21b4945ae6eb5a7cf2b2d07062e6423fa2e56a1caa6" -dependencies = [ - "ahp", - "futures-util", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite", - "url", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -427,7 +412,6 @@ version = "0.1.0" dependencies = [ "ahp", "ahp-types", - "ahp-ws", "base64", "bytes", "cfg-if", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 6670a2ed84d7ac..5df78ffb6d3d0d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -56,9 +56,8 @@ console = "0.15.7" bytes = "1.11.1" tar = "0.4.46" local-ip-address = "0.6" -ahp = "0.1" -ahp-types = "0.1" -ahp-ws = "0.1" +ahp = "0.2" +ahp-types = "0.2" [build-dependencies] serde = { version="1.0.163", features = ["derive"] } diff --git a/cli/src/commands/agent.rs b/cli/src/commands/agent.rs index 71abc5eaa33a8e..701c56b3f569d9 100644 --- a/cli/src/commands/agent.rs +++ b/cli/src/commands/agent.rs @@ -9,9 +9,11 @@ use ahp::{Client, Transport, TransportError, TransportMessage}; use ahp_types::commands::{AuthenticateParams, AuthenticateResult}; use ahp_types::errors::ahp_error_codes; use ahp_types::state::ProtectedResourceMetadata; -use ahp_types::PROTOCOL_VERSION; +use ahp_types::{ROOT_RESOURCE_URI, PROTOCOL_VERSION}; use futures::{SinkExt, StreamExt}; +use tokio::io::{AsyncRead, AsyncWrite}; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{connect_async, WebSocketStream}; use crate::auth::{Auth, AuthProvider}; use crate::constants::AGENT_HOST_PORT; @@ -46,7 +48,7 @@ pub async fn connect( }; client - .initialize("code-cli".into(), PROTOCOL_VERSION as i64, vec![]) + .initialize("code-cli".into(), vec![PROTOCOL_VERSION.to_string()], vec![]) .await .map_err(|e| wrap(e, "AHP initialize failed"))?; @@ -55,50 +57,39 @@ pub async fn connect( /// Opens a WebSocket connection and creates an AHP client. async fn connect_ws(address: &str) -> Result { - let transport = ahp_ws::WebSocketTransport::connect(address) + let (ws_stream, _) = connect_async(address) .await .map_err(|e| wrap(e, format!("Failed to connect to agent host at {address}")))?; + let transport = WsTransport::new(ws_stream, ()); + Client::connect(transport, ahp::ClientConfig::default()) .await .map_err(|e| wrap(e, "Failed to establish AHP session").into()) } -/// Connects to an agent host over a dev tunnel relay. Looks up the tunnel -/// by name, opens a direct-tcpip channel to the agent host port, performs -/// a WebSocket handshake over the raw stream, then creates an AHP client. -async fn connect_via_tunnel(ctx: &CommandContext, name: &str) -> Result { - let auth = Auth::new(&ctx.paths, ctx.log.clone()); - let mut dt = DevTunnels::new_remote_tunnel(&ctx.log, auth, &ctx.paths); - - let (port_conn, _relay_handle) = dt.connect_to_tunnel_port(name, AGENT_HOST_PORT).await?; - - let rw = port_conn.into_rw(); - let (ws_stream, _) = tokio_tungstenite::client_async("ws://localhost/", rw) - .await - .map_err(|e| wrap(e, "WebSocket handshake over tunnel failed"))?; - - let transport = TunnelWsTransport { - inner: ws_stream, - // Keep the relay handle alive so the SSH session isn't dropped. - _relay_handle, - }; - - Client::connect(transport, ahp::ClientConfig::default()) - .await - .map_err(|e| wrap(e, "Failed to establish AHP session over tunnel").into()) +/// A [`Transport`] backed by a `tokio-tungstenite` WebSocket stream. +/// +/// `_guard` keeps an auxiliary resource alive for the lifetime of the +/// transport; the tunnel connection uses it to retain the relay handle so +/// the underlying SSH session isn't dropped. Use `()` when no such +/// resource is needed. +struct WsTransport { + inner: WebSocketStream, + _guard: G, } -/// A [`Transport`] backed by a WebSocket stream running over a tunnel -/// relay channel (via `PortConnectionRW`). -struct TunnelWsTransport { - inner: tokio_tungstenite::WebSocketStream, - /// Prevent the relay handle from being dropped, which would close the - /// underlying SSH session. - _relay_handle: tunnels::connections::ClientRelayHandle, +impl WsTransport { + fn new(inner: WebSocketStream, guard: G) -> Self { + Self { inner, _guard: guard } + } } -impl Transport for TunnelWsTransport { +impl Transport for WsTransport +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + G: Send + 'static, +{ async fn send(&mut self, msg: TransportMessage) -> Result<(), TransportError> { let frame = match msg { TransportMessage::Parsed(m) => { @@ -140,6 +131,28 @@ impl Transport for TunnelWsTransport { } } +/// Connects to an agent host over a dev tunnel relay. Looks up the tunnel +/// by name, opens a direct-tcpip channel to the agent host port, performs +/// a WebSocket handshake over the raw stream, then creates an AHP client. +async fn connect_via_tunnel(ctx: &CommandContext, name: &str) -> Result { + let auth = Auth::new(&ctx.paths, ctx.log.clone()); + let mut dt = DevTunnels::new_remote_tunnel(&ctx.log, auth, &ctx.paths); + + let (port_conn, relay_handle) = dt.connect_to_tunnel_port(name, AGENT_HOST_PORT).await?; + + let rw = port_conn.into_rw(); + let (ws_stream, _) = tokio_tungstenite::client_async("ws://localhost/", rw) + .await + .map_err(|e| wrap(e, "WebSocket handshake over tunnel failed"))?; + + // Keep the relay handle alive so the SSH session isn't dropped. + let transport = WsTransport::new(ws_stream, relay_handle); + + Client::connect(transport, ahp::ClientConfig::default()) + .await + .map_err(|e| wrap(e, "Failed to establish AHP session over tunnel").into()) +} + /// Issues a JSON-RPC request, automatically handling `-32007` auth errors /// by running the device-flow login and retrying once. pub async fn request_with_auth( @@ -236,6 +249,7 @@ async fn authenticate_from_error( .request( "authenticate", AuthenticateParams { + channel: ROOT_RESOURCE_URI.to_string(), resource: resource.resource.clone(), token: credential.access_token().to_string(), }, diff --git a/cli/src/commands/agent_logs.rs b/cli/src/commands/agent_logs.rs index c52a08a81b50d9..3af3a71431e837 100644 --- a/cli/src/commands/agent_logs.rs +++ b/cli/src/commands/agent_logs.rs @@ -27,7 +27,7 @@ pub async fn agent_logs(ctx: CommandContext, args: AgentLogsArgs) -> Result Result { print_action(envelope.server_seq, &envelope.action); } - Some(SubscriptionEvent::Notification(notif)) => { + Some(other) => { let notif_style = Style::new().magenta(); - println!("{}", notif_style.apply_to(format!("notification: {notif:?}"))); + println!("{}", notif_style.apply_to(format!("notification: {other:?}"))); } None => { println!("{}", Styles::muted().apply_to("Subscription closed.")); @@ -85,7 +85,11 @@ fn print_initial_state(uri: &str, result: &SubscribeResult) { uri_style.apply_to(uri) ); - if let SnapshotState::Session(ref session) = result.snapshot.state { + let Some(ref snapshot) = result.snapshot else { + return; + }; + + if let SnapshotState::Session(ref session) = snapshot.state { let s = &session.summary; if !s.title.is_empty() { println!(" {} {}", label.apply_to("title:"), s.title); @@ -116,7 +120,7 @@ fn print_initial_state(uri: &str, result: &SubscribeResult) { } } - println!(" {} {}", label.apply_to("seq:"), result.snapshot.from_seq); + println!(" {} {}", label.apply_to("seq:"), snapshot.from_seq); } fn print_action(seq: u64, action: &StateAction) { diff --git a/cli/src/commands/agent_ps.rs b/cli/src/commands/agent_ps.rs index c8e0b8c2be7c89..95a0e3157e6e0a 100644 --- a/cli/src/commands/agent_ps.rs +++ b/cli/src/commands/agent_ps.rs @@ -5,6 +5,7 @@ use ahp_types::commands::{ListSessionsParams, ListSessionsResult}; use ahp_types::state::{SessionStatus, SessionSummary}; +use ahp_types::ROOT_RESOURCE_URI; use crate::util::errors::AnyError; @@ -17,9 +18,16 @@ use super::CommandContext; pub async fn agent_ps(ctx: CommandContext, args: AgentPsArgs) -> Result { let client = agent::connect(&ctx, args.address.as_deref(), args.tunnel.as_deref()).await?; - let result: ListSessionsResult = - agent::request_with_auth(&ctx, &client, "listSessions", ListSessionsParams::default()) - .await?; + let result: ListSessionsResult = agent::request_with_auth( + &ctx, + &client, + "listSessions", + ListSessionsParams { + channel: ROOT_RESOURCE_URI.to_string(), + filter: None, + }, + ) + .await?; client.shutdown().await; diff --git a/cli/src/commands/agent_stop.rs b/cli/src/commands/agent_stop.rs index 1e3fbcdcc97336..c18c31232695d5 100644 --- a/cli/src/commands/agent_stop.rs +++ b/cli/src/commands/agent_stop.rs @@ -24,13 +24,13 @@ pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result session.active_turn.map(|t| t.id), + let turn_id = match result.snapshot.map(|s| s.state) { + Some(SnapshotState::Session(session)) => session.active_turn.map(|t| t.id), _ => None, }; @@ -46,12 +46,12 @@ pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result=16.20.0" + }, + "optionalDependencies": { + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260527.2", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260527.2", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260527.2", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260527.2", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260527.2", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260527.2", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260527.2" + } + }, + "node_modules/@typescript/native-preview-darwin-arm64": { + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-3LqSu4DlxkEfeC/Z/29QMCJn5jjkDtXI7LYuxfmjdmAatS6umDKqm8J17fnP/7fyrZUMBTIYRwSDpChGV3G1ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-darwin-x64": { + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-H4+sxE9qaBbLF83wMdWE0FsgfK0Pom+/O+/oxqyGzhVkDJlNt3vfpgQZMit48/Gm44AacGfBggJ9Dhbi3aeSFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-arm": { + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-6I9Cv9ozwfS9zB9vRQDPIYseLX3artEO9jl3yVgLj4ishwlSF4cWAbIsjl5IztPaEgHv8coej/6tX1D0uaBzXg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-arm64": { + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-BGUDMjC2Z3TTdZRkGGwhBLelkP5UYgO2rbep8aF4dS3fu7T5lFPPrnfS6EgqJgie+cF5Fsev7xEq8wWyBDM+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-x64": { + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-vpazOu+ozlxBo8U57YJMzsOPuxAV8H7fu36KJ8ea8At/D8pdGmOAy5TuB+9OBQV9JDe0OXJMy2kmbhOpmkTAmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-win32-arm64": { + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-DBFnFE3V6AITkPO1K1VxXf3yEZKjU2FwtXlNwRqhzDu0rrL2SsJHOSrBDX+OacTxQFzZMxFcpiuhV8jHZALPEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-win32-x64": { + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-1tBlErMvQgcMqqYwsx4tytupcjCJcOUXD3vBn1Wb/kAvus1FzWQAFE0fcKBvLfcqLQfTiiEwKKEtbLjGmakqqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@typespec/ts-http-runtime": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.2.3.tgz", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index b7845156548ef6..3887586e96d3fc 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6903,6 +6903,7 @@ "@typescript-eslint/eslint-plugin": "^8.35.0", "@typescript-eslint/parser": "^8.32.0", "@typescript-eslint/typescript-estree": "^8.26.1", + "@typescript/native-preview": "^7.0.0-dev.20260429", "@vitest/coverage-v8": "^3.2.4", "@vitest/snapshot": "^1.5.0", "@vscode/debugadapter": "^1.68.0", diff --git a/extensions/copilot/script/postinstall.ts b/extensions/copilot/script/postinstall.ts index c4327e1288b5b6..ddcae4a3a33038 100644 --- a/extensions/copilot/script/postinstall.ts +++ b/extensions/copilot/script/postinstall.ts @@ -118,7 +118,17 @@ async function copyCopilotCliPrebuildFiles() { if (normalizedSrc.includes('/prebuilds/linuxmusl-')) { return false; } - return src.endsWith('computer.node') || src.endsWith('runtime.node'); + return src.endsWith('computer.node') + || src.endsWith('runtime.node') + // node-pty natives: pty.node (+ spawn-helper) on Unix, + // conpty.node and its companions on Windows. `endsWith('pty.node')` + // also matches `conpty.node`. The conpty native additionally needs + // conpty_console_list.node and the conpty/ helpers (OpenConsole.exe, + // conpty.dll) to actually spawn. The *.pdb debug symbols are skipped. + || src.endsWith('pty.node') + || src.endsWith('conpty_console_list.node') + || src.endsWith('spawn-helper') + || normalizedSrc.includes('/conpty/'); } return true; } catch { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index c9da169ba194a2..6bc0bd34d0ed73 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -19,6 +19,7 @@ import { ILogService } from '../../../../platform/log/common/logService'; import { deriveCopilotCliOTelEnv } from '../../../../platform/otel/common/agentOTelEnv'; import { IOTelService } from '../../../../platform/otel/common/otelService'; import { IPromptsService } from '../../../../platform/promptFiles/common/promptsService'; +import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { createServiceIdentifier } from '../../../../util/common/services'; import { coalesce } from '../../../../util/vs/base/common/arrays'; @@ -362,6 +363,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS @IChatDebugFileLoggerService private readonly _debugFileLogger: IChatDebugFileLoggerService, @IPromptsService private readonly _promptsService: IPromptsService, @ICopilotCLIModels private readonly _copilotCLIModels: ICopilotCLIModels, + @IExperimentationService private readonly _experimentationService: IExperimentationService, ) { super(); this.showExternalSessions = this.configurationService.getConfig(ConfigKey.Advanced.CLIShowExternalSessions); @@ -961,6 +963,10 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS } private getSandboxConfig(): SessionOptions['sandboxConfig'] { + // Team-internal ExP gate: when disabled, sandbox is force-off regardless of user setting. + if (!this.configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AgentSandboxEnabled, this._experimentationService)) { + return undefined; + } const sandboxSettingId = process.platform === 'win32' ? 'chat.agent.sandbox.enabledWindows' : 'chat.agent.sandbox.enabled'; const rawSandboxSetting = this.configurationService.getNonExtensionConfig(sandboxSettingId); const sandboxSetting = typeof rawSandboxSetting === 'string' diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index 75a31ffd9eaa91..f942ebbb431ccf 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -22,6 +22,7 @@ import { NoopOTelService, resolveOTelConfig } from '../../../../../platform/otel import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService'; import { NullRequestLogger } from '../../../../../platform/requestLogger/node/nullRequestLogger'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/nullTelemetryService'; +import { NullExperimentationService } from '../../../../../platform/telemetry/common/nullExperimentationService'; import { NullWorkspaceService } from '../../../../../platform/workspace/common/workspaceService'; import { mock } from '../../../../../util/common/test/simpleMock'; import { DisposableStore, IReference, toDisposable } from '../../../../../util/vs/base/common/lifecycle'; @@ -184,7 +185,7 @@ describe('CopilotCLISessionService', () => { const titleService = new NullCustomSessionTitleService(); metadataStore = new MockChatSessionMetadataStore(); promptsService = disposables.add(new MockPromptsService()); - service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), promptsService, new NullCopilotCLIModels())); + service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), promptsService, new NullCopilotCLIModels(), new NullExperimentationService())); manager = await service.getSessionManager() as unknown as MockCliSdkSessionManager; }); @@ -227,7 +228,7 @@ describe('CopilotCLISessionService', () => { override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; } override async summarize(): Promise { return undefined; } }(); - const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels())); + const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels(), new NullExperimentationService())); const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager & { opts: { autoModeManager: Record } }; @@ -623,7 +624,7 @@ describe('CopilotCLISessionService', () => { return undefined; } }(); - const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels())); + const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels(), new NullExperimentationService())); await mkdir(sessionDir.fsPath, { recursive: true }); await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [ @@ -658,7 +659,7 @@ describe('CopilotCLISessionService', () => { const delegationService = new class extends mock() { override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; } }(); - const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels())); + const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels(), new NullExperimentationService())); await mkdir(sessionDir.fsPath, { recursive: true }); const eventsFilePath = join(sessionDir.fsPath, 'events.jsonl'); @@ -727,7 +728,7 @@ describe('CopilotCLISessionService', () => { return undefined; } }(); - const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels())); + const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels(), new NullExperimentationService())); const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager; const session = new MockCliSdkSession(sessionId, new Date('2024-01-01T00:00:00.000Z')); @@ -769,7 +770,7 @@ describe('CopilotCLISessionService', () => { const delegationService = new class extends mock() { override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; } }(); - const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels())); + const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels(), new NullExperimentationService())); const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager; // Session has a summary with '<' (which forces the session-load fallback path) @@ -1032,7 +1033,7 @@ describe('CopilotCLISessionService', () => { const delegationService = new class extends mock() { override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; } }(); - const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels())); + const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels(), new NullExperimentationService())); const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager; localManager.sessions.set(sourceId, new MockCliSdkSession(sourceId, new Date())); @@ -1076,7 +1077,7 @@ describe('CopilotCLISessionService', () => { }(); const metadataStore = new MockChatSessionMetadataStore(); await metadataStore.updateRequestDetails(sourceId, [{ vscodeRequestId: 'vsc-req-1', copilotRequestId: 'sdk-event-1', toolIdEditMap: {} }]); - const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels())); + const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels(), new NullExperimentationService())); const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager; localManager.sessions.set(sourceId, sdkSession); const forkSpy = vi.spyOn(localManager, 'forkSession'); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts index 9279e7106c70ac..379f000ed4cf8e 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLISDKUpgrade.spec.ts @@ -90,6 +90,22 @@ describe('CopilotCLI SDK Upgrade', function () { path.join('sdk', 'prebuilds', 'linux-x64', 'runtime.node'), path.join('sdk', 'prebuilds', 'win32-arm64', 'runtime.node'), path.join('sdk', 'prebuilds', 'win32-x64', 'runtime.node'), + // node-pty natives re-shipped into the @github/copilot/sdk subpackage by our + // postinstall so built-in installs can spawn through node-pty (used by mxc). + path.join('sdk', 'prebuilds', 'darwin-arm64', 'pty.node'), + path.join('sdk', 'prebuilds', 'darwin-x64', 'pty.node'), + path.join('sdk', 'prebuilds', 'linux-arm64', 'pty.node'), + path.join('sdk', 'prebuilds', 'linux-x64', 'pty.node'), + path.join('sdk', 'prebuilds', 'darwin-arm64', 'spawn-helper'), + path.join('sdk', 'prebuilds', 'darwin-x64', 'spawn-helper'), + path.join('sdk', 'prebuilds', 'win32-arm64', 'conpty.node'), + path.join('sdk', 'prebuilds', 'win32-x64', 'conpty.node'), + path.join('sdk', 'prebuilds', 'win32-arm64', 'conpty_console_list.node'), + path.join('sdk', 'prebuilds', 'win32-x64', 'conpty_console_list.node'), + path.join('sdk', 'prebuilds', 'win32-arm64', 'conpty', 'OpenConsole.exe'), + path.join('sdk', 'prebuilds', 'win32-arm64', 'conpty', 'conpty.dll'), + path.join('sdk', 'prebuilds', 'win32-x64', 'conpty', 'OpenConsole.exe'), + path.join('sdk', 'prebuilds', 'win32-x64', 'conpty', 'conpty.dll'), path.join('ripgrep', 'bin', 'darwin-arm64', 'rg'), path.join('ripgrep', 'bin', 'darwin-x64', 'rg'), path.join('ripgrep', 'bin', 'linux-x64', 'rg'), diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index 72dafbcd034e32..c4571b16eac742 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -19,6 +19,7 @@ import { ILogService } from '../../../../platform/log/common/logService'; import { NoopOTelService, resolveOTelConfig } from '../../../../platform/otel/common/index'; import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger'; import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService'; +import { NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; import type { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; import { MockExtensionContext } from '../../../../platform/test/node/extensionContext'; import { IWorkspaceService, NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; @@ -417,7 +418,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { } } as unknown as IInstantiationService; customSessionTitleService = new CustomSessionTitleService(new MockExtensionContext() as unknown as IVSCodeExtensionContext, accessor.get(IInstantiationService), logService, new MockChatSessionMetadataStore()); - sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace, workspaceFolderService, worktree, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), models as unknown as ICopilotCLIModels)); + sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace, workspaceFolderService, worktree, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), models as unknown as ICopilotCLIModels, new NullExperimentationService())); manager = await sessionService.getSessionManager() as unknown as MockCliSdkSessionManager; contentProvider = new class extends mock() { diff --git a/extensions/copilot/src/extension/prompt/node/intentDetector.tsx b/extensions/copilot/src/extension/prompt/node/intentDetector.tsx index b58fb34c66b3e2..f8c3c23325d28b 100644 --- a/extensions/copilot/src/extension/prompt/node/intentDetector.tsx +++ b/extensions/copilot/src/extension/prompt/node/intentDetector.tsx @@ -260,27 +260,32 @@ export class IntentDetector implements ChatParticipantDetectionProvider { history: Turn[] = [], document?: TextDocumentSnapshot ) { - const endpoint = await this.endpointProvider.getChatEndpoint('copilot-utility-small'); - - const { messages: currentSelection } = await renderPromptElement(this.instantiationService, endpoint, CurrentSelection, { document }); - const { messages: conversationHistory } = await renderPromptElement(this.instantiationService, endpoint, ConversationHistory, { history, priority: 1000 }, undefined, undefined).catch(() => ({ messages: [] })); - - const { history: historyMessages, fileExcerpt, attachedContext, fileExcerptExceedsBudget } = this.prepareInternalTelemetryContext(getTextPart(currentSelection?.[0]?.content), conversationHistory, chatVariables); - - this.telemetryService.sendInternalMSFTTelemetryEvent( - 'participantDetectionContext', - { - chatLocation: ChatLocation.toString(location), - userQuery, - history: historyMessages.join(''), - assignedIntent: typeof assignedIntent === 'string' ? assignedIntent : undefined, - assignedThirdPartyChatParticipant: typeof assignedIntent !== 'string' ? assignedIntent.participant : undefined, - assignedThirdPartyChatCommand: typeof assignedIntent !== 'string' ? assignedIntent.command : undefined, - fileExcerpt: fileExcerpt ?? (fileExcerptExceedsBudget ? '' : ''), - attachedContext: attachedContext.join(';') - }, - {} - ); + try { + const endpoint = await this.endpointProvider.getChatEndpoint('copilot-utility-small'); + + const { messages: currentSelection } = await renderPromptElement(this.instantiationService, endpoint, CurrentSelection, { document }); + const { messages: conversationHistory } = await renderPromptElement(this.instantiationService, endpoint, ConversationHistory, { history, priority: 1000 }, undefined, undefined).catch(() => ({ messages: [] })); + + const { history: historyMessages, fileExcerpt, attachedContext, fileExcerptExceedsBudget } = this.prepareInternalTelemetryContext(getTextPart(currentSelection?.[0]?.content), conversationHistory, chatVariables); + + this.telemetryService.sendInternalMSFTTelemetryEvent( + 'participantDetectionContext', + { + chatLocation: ChatLocation.toString(location), + userQuery, + history: historyMessages.join(''), + assignedIntent: typeof assignedIntent === 'string' ? assignedIntent : undefined, + assignedThirdPartyChatParticipant: typeof assignedIntent !== 'string' ? assignedIntent.participant : undefined, + assignedThirdPartyChatCommand: typeof assignedIntent !== 'string' ? assignedIntent.command : undefined, + fileExcerpt: fileExcerpt ?? (fileExcerptExceedsBudget ? '' : ''), + attachedContext: attachedContext.join(';') + }, + {} + ); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + this.logService.warn(`[IntentDetector] Skipping participant detection context telemetry: ${message}`); + } } private validateResult( diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 8ea68920c4f3eb..e15641ae1d21a1 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -802,6 +802,7 @@ export namespace ConfigKey { export const InlineChatUseCodeMapper = defineTeamInternalSetting('chat.advanced.inlineChat.useCodeMapper', ConfigType.Simple, false); export const EnablePromptRendererTracing = defineTeamInternalSetting('chat.advanced.promptRenderer.trace', ConfigType.Simple, false); // Backed by Experiments + export const AgentSandboxEnabled = defineTeamInternalSetting('chat.advanced.agent.sandbox.enabled', ConfigType.ExperimentBased, false); export const DebugCollectFetcherTelemetry = defineTeamInternalSetting('chat.advanced.debug.collectFetcherTelemetry', ConfigType.ExperimentBased, true); export const DebugShowNetworkStatus = defineTeamInternalSetting('chat.advanced.debug.showNetworkStatus', ConfigType.ExperimentBased, false); export const GeminiFunctionCallingMode = defineTeamInternalSetting<'auto' | 'none' | 'required' | 'validated' | undefined>('chat.advanced.gemini.functionCallingMode', ConfigType.ExperimentBased, 'validated'); diff --git a/extensions/html-language-features/server/src/modes/javascriptMode.ts b/extensions/html-language-features/server/src/modes/javascriptMode.ts index ba26f7a4e348d8..50c641f238edb9 100644 --- a/extensions/html-language-features/server/src/modes/javascriptMode.ts +++ b/extensions/html-language-features/server/src/modes/javascriptMode.ts @@ -10,6 +10,7 @@ import { DocumentHighlight, DocumentHighlightKind, CompletionList, Position, FormattingOptions, FoldingRange, FoldingRangeKind, SelectionRange, LanguageMode, Settings, SemanticTokenData, Workspace, DocumentContext, CompletionItemData, isCompletionItemData, FILE_PROTOCOL, DocumentUri } from './languageModes.js'; +import { MarkupKind } from 'vscode-languageserver'; import { getWordAtText, isWhitespaceOnly, repeat } from '../utils/strings.js'; import { HTMLDocumentRegions } from './embeddedSupport.js'; @@ -185,10 +186,26 @@ export function getJavaScriptMode(documentRegions: LanguageModelCache { + const offset = value.indexOf('|'); + value = value.substr(0, offset) + value.substr(offset + 1); + + const workspace = { + settings: {}, + folders: [{ name: 'x', uri: testUri.substr(0, testUri.lastIndexOf('/')) }] + }; + + const document = TextDocument.create(testUri, 'html', 0, value); + const position = document.positionAt(offset); + + const languageModes = getLanguageModes({ css: true, javascript: true }, workspace, ClientCapabilities.LATEST, getNodeFileFS()); + const mode = languageModes.getModeAtPosition(document, position)!; + + try { + const hover = await mode.doHover!(document, position); + assert.ok(hover, 'expected a hover result'); + const contents = hover.contents; + if (typeof contents === 'string') { + return contents; + } + if (Array.isArray(contents)) { + return contents.map(c => typeof c === 'string' ? c : c.value).join('\n'); + } + return (contents as { value: string }).value; + } finally { + languageModes.dispose(); + } +} + +suite('HTML Hover', () => { + test('JavaScript hover in ' + ].join('\n'); + const value = await getHoverValue(html); + + // The signature should still be present in a typescript code block + assert.match(value, /```typescript[\s\S]*Greeter[\s\S]*```/, `signature missing in hover: ${value}`); + // The JSDoc summary should also be present (this is the bug fix) + assert.ok(value.includes('A greeter that says hello.'), `JSDoc description missing in hover: ${value}`); + }); + + test('JavaScript hover in ' + ].join('\n'); + const value = await getHoverValue(html); + + assert.match(value, /```typescript[\s\S]*sayHello[\s\S]*```/, `signature missing in hover: ${value}`); + assert.ok(value.includes('Returns a friendly greeting.'), `JSDoc description missing in hover: ${value}`); + }); +}); diff --git a/extensions/npm/src/features/packageJSONContribution.ts b/extensions/npm/src/features/packageJSONContribution.ts index 03aca24ad406a3..6589059a7cb4d4 100644 --- a/extensions/npm/src/features/packageJSONContribution.ts +++ b/extensions/npm/src/features/packageJSONContribution.ts @@ -323,18 +323,16 @@ export class PackageJSONContribution implements IJSONContribution { } private async npmView(npmCommandPath: string, pack: string, resource: Uri | undefined): Promise { - // Request @latest to avoid fetching publish timestamps for all versions in the time field. - const args = ['view', '--json', '--', `${pack}@latest`, 'description', 'homepage', 'version', 'time']; - + const args = ['view', '--json', '--', pack, 'description', 'dist-tags.latest', 'homepage', 'version', 'time']; const stdout = await this.runNpmCommand(npmCommandPath, args, resource); if (stdout) { try { const content = JSON.parse(stdout); - const version = content['version']; + const version = content['dist-tags.latest'] || content['version']; return { description: content['description'], - version: content['version'], - time: version ? content['time']?.[version] : undefined, + version, + time: content.time?.[version], homepage: content['homepage'] }; } catch (e) { diff --git a/extensions/vscode-test-resolver/src/extension.ts b/extensions/vscode-test-resolver/src/extension.ts index 373c8809341970..5c9b4785d02826 100644 --- a/extensions/vscode-test-resolver/src/extension.ts +++ b/extensions/vscode-test-resolver/src/extension.ts @@ -24,6 +24,10 @@ let outputChannel: vscode.OutputChannel; const SLOWED_DOWN_CONNECTION_DELAY = 800; +function isExpectedSocketCloseError(error: NodeJS.ErrnoException): boolean { + return error.code === 'ECONNRESET' || error.code === 'EPIPE' || error.code === 'ECONNABORTED'; +} + export function activate(context: vscode.ExtensionContext) { let connectionPaused = false; @@ -270,6 +274,11 @@ export function activate(context: vscode.ExtensionContext) { outputChannel.appendLine(`Proxy connection accepted`); let remoteReady = true, localReady = true; const remoteSocket = net.createConnection({ port: serverAddr.port }); + const onSocketError = (error: NodeJS.ErrnoException) => { + if (!isExpectedSocketCloseError(error)) { + outputChannel.appendLine(`Socket error: ${error.message}`); + } + }; let isDisconnected = false; const handleConnectionPause = () => { @@ -326,6 +335,8 @@ export function activate(context: vscode.ExtensionContext) { proxySocket.resume(); } }); + proxySocket.on('error', onSocketError); + remoteSocket.on('error', onSocketError); proxySocket.on('close', () => { outputChannel.appendLine(`Proxy socket closed, closing remote socket.`); remoteSocket.end(); diff --git a/package-lock.json b/package-lock.json index 33fd5916531140..576b059016eb66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,7 +103,7 @@ "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", - "@typescript/native-preview": "^7.0.0-dev.20260429", + "@typescript/native-preview": "^7.0.0-dev.20260527", "@vscode/component-explorer": "^0.2.1-27", "@vscode/component-explorer-cli": "^0.2.1-27", "@vscode/gulp-electron": "1.41.3", @@ -3621,9 +3621,9 @@ } }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20260506.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260506.1.tgz", - "integrity": "sha512-UcEslgHBaHYPAisVQcyARDfps7nKyugmUyXcsfE1HiHcVuvZ4tBJ5C93sG1FDeHWJ9skGQ68ec+Xsx086geAfg==", + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-piqkDwikVeizCFqA1lcwI5F4wOAtBdxuliWe77ApBNRyBPPvfCJB+u/HYi9/8t5nd0sWvFs6/qt/AzJ1CCoykQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3633,19 +3633,19 @@ "node": ">=16.20.0" }, "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260506.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260506.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20260506.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260506.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20260506.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260506.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20260506.1" + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260527.2", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260527.2", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260527.2", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260527.2", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260527.2", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260527.2", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260527.2" } }, "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20260506.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260506.1.tgz", - "integrity": "sha512-dAd7qG2J508+4CRSuoEA0EUxViIedQ0D+8xKoZiM0EQHCwww8glWYCo72UTjcRZctS3QbJY3PtGSvo3nzL4oVw==", + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-3LqSu4DlxkEfeC/Z/29QMCJn5jjkDtXI7LYuxfmjdmAatS6umDKqm8J17fnP/7fyrZUMBTIYRwSDpChGV3G1ew==", "cpu": [ "arm64" ], @@ -3660,9 +3660,9 @@ } }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20260506.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260506.1.tgz", - "integrity": "sha512-1Q7Elncpuiozvx3HCTgFbSxNz2m2FIkO1QW5f15igcZDG3vMW4QglNflmXosc69bzYI7KfYZuaGX3yGzJkGbfg==", + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-H4+sxE9qaBbLF83wMdWE0FsgfK0Pom+/O+/oxqyGzhVkDJlNt3vfpgQZMit48/Gm44AacGfBggJ9Dhbi3aeSFw==", "cpu": [ "x64" ], @@ -3677,9 +3677,9 @@ } }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20260506.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260506.1.tgz", - "integrity": "sha512-MfYn1p+aOorZ2Y+7sqLvSoAXPEz/RfKgHfeYO240Udco30B4oapm7Hsq2PsS9Z2Oth/RorGjY0jLP2OhnkY2Ig==", + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-6I9Cv9ozwfS9zB9vRQDPIYseLX3artEO9jl3yVgLj4ishwlSF4cWAbIsjl5IztPaEgHv8coej/6tX1D0uaBzXg==", "cpu": [ "arm" ], @@ -3694,9 +3694,9 @@ } }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20260506.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260506.1.tgz", - "integrity": "sha512-Q1W4DHplR2urmtPwoz9tw6XUGWRNXF+CIXJQ8ZpIZFj/OHgvTw8vkYkKFuaEao3lSjTsR4lQe/wL2Xr5K0hxuA==", + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-BGUDMjC2Z3TTdZRkGGwhBLelkP5UYgO2rbep8aF4dS3fu7T5lFPPrnfS6EgqJgie+cF5Fsev7xEq8wWyBDM+lg==", "cpu": [ "arm64" ], @@ -3711,9 +3711,9 @@ } }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20260506.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260506.1.tgz", - "integrity": "sha512-b+sbLBCIchbrGQNbjIvVN2qd+ieqqp/nghi0n2zOAKGPsfd5wG6ceqxWJKADdBDCohsCCGt//rZccUwFugIsyA==", + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-vpazOu+ozlxBo8U57YJMzsOPuxAV8H7fu36KJ8ea8At/D8pdGmOAy5TuB+9OBQV9JDe0OXJMy2kmbhOpmkTAmA==", "cpu": [ "x64" ], @@ -3728,9 +3728,9 @@ } }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20260506.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260506.1.tgz", - "integrity": "sha512-l59d8pZjFT7GoWpgCOy6aBcxLSALphA91X4Z/2XHo5HnM0bQ/yJjB7XMeUQZBdk5DZCdZL+sWTfmXLRggm7sFg==", + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-DBFnFE3V6AITkPO1K1VxXf3yEZKjU2FwtXlNwRqhzDu0rrL2SsJHOSrBDX+OacTxQFzZMxFcpiuhV8jHZALPEg==", "cpu": [ "arm64" ], @@ -3745,9 +3745,9 @@ } }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20260506.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260506.1.tgz", - "integrity": "sha512-dJDLSzaz2xjRYYmTSfcCepZUi3ITjQSJ6Gk5YGplMF57UmZCAGI+ns4Te/V74IJiQigXqTnyEIGorwsOqhW8gQ==", + "version": "7.0.0-dev.20260527.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260527.2.tgz", + "integrity": "sha512-1tBlErMvQgcMqqYwsx4tytupcjCJcOUXD3vBn1Wb/kAvus1FzWQAFE0fcKBvLfcqLQfTiiEwKKEtbLjGmakqqg==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index d8a32c66590dcc..996a6b37c7a5b4 100644 --- a/package.json +++ b/package.json @@ -183,7 +183,7 @@ "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", - "@typescript/native-preview": "^7.0.0-dev.20260429", + "@typescript/native-preview": "^7.0.0-dev.20260527", "@vscode/component-explorer": "^0.2.1-27", "@vscode/component-explorer-cli": "^0.2.1-27", "@vscode/gulp-electron": "1.41.3", diff --git a/src/vs/editor/common/languages/languageConfiguration.ts b/src/vs/editor/common/languages/languageConfiguration.ts index c569efd68c1609..c812db7b62b09f 100644 --- a/src/vs/editor/common/languages/languageConfiguration.ts +++ b/src/vs/editor/common/languages/languageConfiguration.ts @@ -142,7 +142,6 @@ export interface IndentationRule { * Describes language specific folding markers such as '#region' and '#endregion'. * The start and end regexes will be tested against the contents of all lines and must be designed efficiently: * - the regex should start with '^' - * - regexp flags (i, g) are ignored */ export interface FoldingMarkers { start: RegExp; diff --git a/src/vs/editor/contrib/folding/browser/indentRangeProvider.ts b/src/vs/editor/contrib/folding/browser/indentRangeProvider.ts index e1bbe8a949c911..041b00b5f680f2 100644 --- a/src/vs/editor/contrib/folding/browser/indentRangeProvider.ts +++ b/src/vs/editor/contrib/folding/browser/indentRangeProvider.ts @@ -128,8 +128,15 @@ export function computeRanges(model: ITextModel, offSide: boolean, markers?: Fol const result = new RangesCollector(foldingRangesLimit); let pattern: RegExp | undefined = undefined; + let startPattern: RegExp | undefined = undefined; + let endPattern: RegExp | undefined = undefined; if (markers) { - pattern = new RegExp(`(${markers.start.source})|(?:${markers.end.source})`); + if (markers.start.flags === markers.end.flags) { + pattern = new RegExp(`(${markers.start.source})|(?:${markers.end.source})`, markers.start.flags); + } else { + startPattern = markers.start; + endPattern = markers.end; + } } const previousRegions: PreviousRegion[] = []; @@ -149,10 +156,28 @@ export function computeRanges(model: ITextModel, offSide: boolean, markers?: Fol } continue; // only whitespace } + let isStartMatch = false; + let isEndMatch = false; let m; - if (pattern && (m = lineContent.match(pattern))) { + if (pattern) { + pattern.lastIndex = 0; + if ((m = pattern.exec(lineContent))) { + isStartMatch = !!m[1]; + isEndMatch = !isStartMatch; + } + } else { + if (startPattern) { + startPattern.lastIndex = 0; + isStartMatch = startPattern.test(lineContent); + } + if (!isStartMatch && endPattern) { + endPattern.lastIndex = 0; + isEndMatch = endPattern.test(lineContent); + } + } + if (isStartMatch || isEndMatch) { // folding pattern match - if (m[1]) { // start pattern match + if (isStartMatch) { // start pattern match // discard all regions until the folding pattern let i = previousRegions.length - 1; while (i > 0 && previousRegions[i].indent !== -2) { diff --git a/src/vs/editor/contrib/folding/test/browser/indentRangeProvider.test.ts b/src/vs/editor/contrib/folding/test/browser/indentRangeProvider.test.ts index 70d3c5897412f3..b4a2b32511564b 100644 --- a/src/vs/editor/contrib/folding/test/browser/indentRangeProvider.test.ts +++ b/src/vs/editor/contrib/folding/test/browser/indentRangeProvider.test.ts @@ -332,4 +332,41 @@ suite('Folding with regions', () => { /* 9*/ '#endregion', ], [r(1, 9, -1, true), r(3, 4, 0), r(6, 7, 0)], true, markers); }); + test('Markers with stateful flags', () => { + assertRanges([ + /* 1*/ '#region', + /* 2*/ 'content', + /* 3*/ '#endregion', + ], [r(1, 3, -1, true)], false, { + start: /^\s*#region\b/g, + end: /^\s*#endregion\b/g + }); + + assertRanges([ + /* 1*/ '#REGION', + /* 2*/ 'content', + /* 3*/ '#endregion', + ], [r(1, 3, -1, true)], false, { + start: /^\s*#region\b/gi, + end: /^\s*#endregion\b/g + }); + + assertRanges([ + /* 1*/ '#REGION', + /* 2*/ 'content', + /* 3*/ '#ENDREGION', + ], [], false, { + start: /^\s*#region\b/gi, + end: /^\s*#endregion\b/g + }); + + assertRanges([ + /* 1*/ '#REGION', + /* 2*/ 'content', + /* 3*/ '#ENDREGION', + ], [], false, { + start: /^\s*#region\b/g, + end: /^\s*#endregion\b/gi + }); + }); }); diff --git a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts index c185d3c41be551..3841b40148d8d0 100644 --- a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts +++ b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts @@ -22,6 +22,8 @@ export const enum AgentHostConfigKey { DefaultShell = 'defaultShell', /** When true, Copilot SDK sessions use the SDK's default terminal behavior instead of Agent Host's terminal tool override. */ DisableCustomTerminalTool = 'disableCustomTerminalTool', + /** When true, Copilot SDK sessions enable the rubber duck critic subagent. */ + RubberDuck = 'rubberDuck', } /** @@ -74,6 +76,12 @@ export const agentHostCustomizationConfigSchema = createSchema({ description: localize('agentHost.config.disableCustomTerminalTool.description', "When enabled, Copilot SDK sessions use the SDK's default terminal behavior instead of Agent Host's terminal tool override."), default: false, }), + [AgentHostConfigKey.RubberDuck]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.rubberDuck.title', "Rubber Duck Agent"), + description: localize('agentHost.config.rubberDuck.description', "When enabled, the coding agent uses a rubber duck critic subagent to review code changes using a complementary model."), + default: false, + }), }); export const defaultAgentHostCustomizationConfigValues = { diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 65f117b6d322e3..511b5fccc83ecc 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -15,7 +15,6 @@ import { AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, - AgentHostRubberDuckEnabledSettingId, } from './agentService.js'; // Settings consumed by the agent host starter (`electronAgentHostStarter.ts` @@ -40,13 +39,6 @@ configurationRegistry.registerConfiguration({ title: nls.localize('chatAgentHostStarterConfigurationTitle', "Chat Agent Host Starter"), type: 'object', properties: { - [AgentHostRubberDuckEnabledSettingId]: { - type: 'boolean', - description: nls.localize('chat.agentHost.rubberDuck.enabled', "When enabled, the coding agent uses a rubber duck critic subagent to review code changes using a complementary model. Requires `#chat.agentHost.enabled#`."), - default: false, - tags: ['experimental', 'advanced'], - included: product.quality !== 'stable', - }, [AgentHostClaudeAgentSdkPathSettingId]: { type: 'string', description: nls.localize('chat.agentHost.claudeAgent.path', "Experimental, for local testing only. Absolute path to a locally-installed `@anthropic-ai/claude-agent-sdk` package. When set, the Claude agent provider is registered inside the agent host and the SDK is loaded from this path. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect. This setting will be removed once the SDK is delivered through the Extension Marketplace."), diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index e8111ebbc90094..c46a188fe9922f 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -57,9 +57,6 @@ export const AgentHostAhpJsonlLoggingSettingId = 'chat.agentHost.ahpJsonlLogging /** Configuration key that controls whether Agent Host uses its terminal tool override for Copilot SDK sessions. */ export const AgentHostCustomTerminalToolEnabledSettingId = 'chat.agentHost.customTerminalTool.enabled'; -/** Configuration key that controls whether the rubber duck critic subagent is enabled for Copilot SDK sessions. */ -export const AgentHostRubberDuckEnabledSettingId = 'chat.agentHost.rubberDuck.enabled'; - /** * Configuration key that holds the absolute path to a locally-installed * `@anthropic-ai/claude-agent-sdk` package. When non-empty, the Claude agent diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index a82e4d2cbe5d05..3532cdb6aede13 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -19,7 +19,7 @@ import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentSdkPathSettingId, AgentHostClaudeSdkPathEnvVar, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, AgentHostRubberDuckEnabledSettingId, buildAgentHostOTelEnv } from '../common/agentService.js'; +import { AgentHostClaudeAgentSdkPathSettingId, AgentHostClaudeSdkPathEnvVar, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv } from '../common/agentService.js'; import { deepClone } from '../../../base/common/objects.js'; import '../common/agentHost.config.contribution.js'; import '../common/agentHostStarter.config.contribution.js'; @@ -87,9 +87,6 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt dbSpanExporterEnabled: this._configurationService.getValue(AgentHostOTelDbSpanExporterEnabledSettingId), }, process.env); - // Enable rubber duck critic subagent when the setting is on. - const rubberDuckEnabled = this._configurationService.getValue(AgentHostRubberDuckEnabledSettingId); - const args = [ '--logsPath', this._environmentMainService.logsHome.with({ scheme: Schemas.file }).fsPath, '--user-data-dir', this._environmentMainService.userDataPath, @@ -112,7 +109,6 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt VSCODE_VERBOSE_LOGGING: 'true', ...(claudeSdkPath ? { [AgentHostClaudeSdkPathEnvVar]: claudeSdkPath } : {}), ...otelEnv, - ...(rubberDuckEnabled ? { RUBBER_DUCK_AGENT: 'true' } : {}), } }); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 848019842dc29b..fde6f8325947d7 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -10,6 +10,7 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { getExtensionForMimeType, getMediaMime } from '../../../base/common/mime.js'; +import { Schemas } from '../../../base/common/network.js'; import { equals as objectEquals } from '../../../base/common/objects.js'; import { observableValue } from '../../../base/common/observable.js'; import { extname as resourcesExtname, isEqual, isEqualOrParent, joinPath } from '../../../base/common/resources.js'; @@ -1194,6 +1195,12 @@ export class AgentService extends Disposable implements IAgentService { } if (attachment.type === MessageAttachmentKind.Resource && this._isRewritableAttachment(attachment, attachmentsRootStr)) { const originalUri = URI.parse(attachment.uri); + // If the attachment references a file that already exists on the agent + // host side, leave it untouched rather than snapshotting a client copy (#319314). + if (originalUri.scheme === Schemas.file && await this._fileExistsSafe(originalUri)) { + return attachment; + } + const bytes = await this._readClientResource(originalUri, clientId); const basename = this._attachmentBasename(attachment.label, getMediaMime(originalUri.path)); return this._writeAndRewrite(attachment, bytes, basename, attachmentsRoot); @@ -1204,6 +1211,18 @@ export class AgentService extends Disposable implements IAgentService { return attachment; } + /** + * Like {@link IFileService.exists} but never throws (e.g. when no provider + * is registered for the URI scheme), returning `false` in that case. + */ + private async _fileExistsSafe(uri: URI): Promise { + try { + return await this._fileService.exists(uri); + } catch { + return false; + } + } + /** * Reads `originalUri` through the `vscode-agent-client` filesystem * provider so it is fetched from the originating client. Falls back to diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 6426974fc5aa59..ff2b3b62abdf12 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -299,30 +299,55 @@ export class CopilotAgent extends Disposable implements IAgent { this.onDidCustomizationsChange = this._plugins.onDidChange; this._register(completions.registerProvider(new CopilotSlashCommandCompletionProvider(this.id, { hasHistory: (sessionId) => !this._provisionalSessions.has(sessionId) && this._sessions.has(sessionId), + isRubberDuckEnabled: () => this._isRubberDuckEnabled(), }))); - // Restart the CLI client when session sync setting changes (only if idle) + // Restart the CLI client when a setting baked into the client/subprocess at + // startup changes, disposing any active sessions. Both session sync (a client + // option) and the rubber duck flag (a subprocess env var) are applied in + // `_ensureClient`, so they only take effect on the next client start. this._register(this._configurationService.onDidRootConfigChange(() => { - this._restartClientIfSessionSyncChanged().catch(err => - this._logService.error('[Copilot] Failed to restart client after session sync change', err) + this._restartClientIfStartupConfigChanged().catch(err => + this._logService.error('[Copilot] Failed to restart client after config change', err) ); })); } private _lastSessionSyncEnabled: boolean = this._isSessionSyncEnabled(); + private _lastRubberDuckEnabled: boolean = this._isRubberDuckEnabled(); private _isSessionSyncEnabled(): boolean { return this._configurationService.getRootValue(platformRootSchema, AgentHostSessionSyncEnabledConfigKey) === true; } - private async _restartClientIfSessionSyncChanged(): Promise { - const current = this._isSessionSyncEnabled(); - if (this._lastSessionSyncEnabled === current) { + private _isRubberDuckEnabled(): boolean { + return this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.RubberDuck) === true; + } + + /** + * Restarts the CLI client when a config value that is only read at client + * startup ({@link _isSessionSyncEnabled} client option, {@link _isRubberDuckEnabled} + * subprocess env var) has changed. Any active sessions are disposed before + * the client is stopped; the latest values are picked up the next time + * {@link _ensureClient} runs. If the client is still starting up, the + * in-flight start detects the change against {@link _lastSessionSyncEnabled} / + * {@link _lastRubberDuckEnabled} and aborts so it never comes up stale. + */ + private async _restartClientIfStartupConfigChanged(): Promise { + const sessionSync = this._isSessionSyncEnabled(); + const rubberDuck = this._isRubberDuckEnabled(); + if (this._lastSessionSyncEnabled === sessionSync && this._lastRubberDuckEnabled === rubberDuck) { return; } - this._lastSessionSyncEnabled = current; - if (this._client && this._sessions.size === 0) { - this._logService.info(`[Copilot] Session sync changed to ${current}, restarting CopilotClient`); + const changed = [ + this._lastSessionSyncEnabled !== sessionSync ? `sessionSync=${sessionSync}` : undefined, + this._lastRubberDuckEnabled !== rubberDuck ? `rubberDuck=${rubberDuck}` : undefined, + ].filter((v): v is string => v !== undefined).join(', '); + this._lastSessionSyncEnabled = sessionSync; + this._lastRubberDuckEnabled = rubberDuck; + if (this._client) { + this._logService.info(`[Copilot] Startup config changed (${changed}), restarting CopilotClient`); + this._sessions.clearAndDisposeAll(); await this._stopClient(); } } @@ -473,6 +498,11 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._clientStarting) { return this._clientStarting; } + // Snapshot the startup config so we can detect a change that lands while the + // client is still starting and abort the stale start (the values are baked + // into the client options / subprocess env below). + const sessionSyncAtStartup = this._isSessionSyncEnabled(); + const rubberDuckAtStartup = this._isRubberDuckEnabled(); const clientStarting = (async () => { this._logService.info('[Copilot] Starting CopilotClient... (with token)'); @@ -494,6 +524,15 @@ export class CopilotAgent extends Disposable implements IAgent { env['COPILOT_CLI_RUN_AS_NODE'] = '1'; env['USE_BUILTIN_RIPGREP'] = 'false'; + // Enable the rubber duck critic subagent in the CLI when the agent host + // config opts in. `RUBBER_DUCK_AGENT` is the SDK's required interface for + // gating this experimental feature + if (this._isRubberDuckEnabled()) { + env['RUBBER_DUCK_AGENT'] = 'true'; + } else { + delete env['RUBBER_DUCK_AGENT']; + } + // Resolve the CLI entry point from node_modules. We can't use require.resolve() // because @github/copilot's exports map blocks direct subpath access. // FileAccess.asFileUri('') points to the `out/` directory; node_modules is one level up. @@ -525,6 +564,10 @@ export class CopilotAgent extends Disposable implements IAgent { await client.stop(); throw new Error('Copilot authentication changed while the client was starting'); } + if (this._isSessionSyncEnabled() !== sessionSyncAtStartup || this._isRubberDuckEnabled() !== rubberDuckAtStartup) { + await client.stop(); + throw new Error('Copilot startup config changed while the client was starting'); + } this._logService.info('[Copilot] CopilotClient started successfully'); this._enablePlanModeOnClient(client); this._client = client; @@ -1560,6 +1603,11 @@ export class CopilotAgent extends Disposable implements IAgent { // it, `rpc.plan.read()` returns `path: null` and the SDK // never emits `exit_plan_mode.requested`. infiniteSessions: { enabled: true }, + // Per-session remote export: the client-level `--remote` flag + // (enableRemoteSessions) enables the CLI *capability*, but each + // session must opt in via `remoteSession` to actually export + // events. Without this, sessions default to "off". + remoteSession: this._isSessionSyncEnabled() ? 'export' : undefined, }; }; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index f04a7cd116642a..8df0039262a255 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -819,7 +819,7 @@ export class CopilotAgentSession extends Disposable { prompt = slashCommand.rest; } if (slashCommand?.command === 'rubber-duck') { - if (!process.env['RUBBER_DUCK_AGENT']) { + if (this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.RubberDuck) !== true) { // Feature not enabled — pass the remaining text through as a plain // message rather than injecting agent instructions for an unavailable agent. prompt = slashCommand.rest; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index 5e0c5bafa886af..4943986a0e128c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -35,6 +35,11 @@ function getCommandDescription(command: CopilotSlashCommandName): string { export interface ICopilotSlashCommandSessionInfo { /** `sessionId` is the raw id (URI path without the leading slash). */ hasHistory(sessionId: string): boolean; + /** + * Whether the experimental rubber duck critic subagent is enabled via + * the agent host config. When absent or `false`, `/rubber-duck` is hidden. + */ + isRubberDuckEnabled?(): boolean; } /** @@ -97,6 +102,7 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti // `/abc` → typed = 'abc'; empty after just '/' → typed = ''. const typed = leading.typed; + const rubberDuckEnabled = this._sessionInfo?.isRubberDuckEnabled?.() ?? false; const items: CompletionItem[] = []; for (const command of COMMANDS) { if (typed.length > 0 && !command.startsWith(typed)) { @@ -107,7 +113,7 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti continue; } // `/rubber-duck` is only available when the feature is enabled. - if (command === 'rubber-duck' && !process.env['RUBBER_DUCK_AGENT']) { + if (command === 'rubber-duck' && !rubberDuckEnabled) { continue; } items.push({ diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts index 5ed225d381d819..733472086387ee 100644 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -13,7 +13,7 @@ import { parseAgentHostDebugPort } from '../../environment/node/environmentServi import { ILogService } from '../../log/common/log.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentSdkPathSettingId, AgentHostClaudeSdkPathEnvVar, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, AgentHostRubberDuckEnabledSettingId, buildAgentHostOTelEnv } from '../common/agentService.js'; +import { AgentHostClaudeAgentSdkPathSettingId, AgentHostClaudeSdkPathEnvVar, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv } from '../common/agentService.js'; import '../common/agentHostStarter.config.contribution.js'; /** @@ -99,11 +99,6 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte }, process.env); Object.assign(env, otelEnv); - // Enable rubber duck critic subagent when the setting is on. - if (this._configurationService.getValue(AgentHostRubberDuckEnabledSettingId)) { - env['RUBBER_DUCK_AGENT'] = 'true'; - } - // Forward WebSocket server configuration to the child process via env vars if (this._wsConfig) { if (this._wsConfig.port) { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 74139329deacc7..9671b3f4e6178d 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -304,6 +304,29 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(snapshot.value.toString(), 'hello world'); }); + test('passes through existing file:// Resource attachments unchanged (#319314)', async () => { + const { svc, agent, session } = await setup(); + // Register a file-scheme provider so the attachment URI resolves to + // an existing file on the agent host side. + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const fileUri = URI.from({ scheme: Schemas.file, path: '/host/source.txt' }); + await fileService.writeFile(fileUri, VSBuffer.fromString('on host')); + + await dispatchTurnAndWait(svc, agent, session, [{ + type: MessageAttachmentKind.Resource, + uri: fileUri.toString(), + label: 'source.txt', + displayKind: 'document', + }]); + + assert.deepStrictEqual(agent.sendMessageCalls[0].attachments, [{ + type: MessageAttachmentKind.Resource, + uri: fileUri.toString(), + label: 'source.txt', + displayKind: 'document', + }]); + }); + test('preserves selection range on Resource rewrite', async () => { const { svc, agent, session, attachmentsRoot } = await setup(); const sourceUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/sel.txt' }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 0d0e1d2233f161..bb5dcb7d60f42b 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -453,6 +453,71 @@ suite('CopilotAgent', () => { } }); + suite('restart on startup config change', () => { + + class StopCountingClient extends TestCopilotClient { + stopCount = 0; + override async stop(): ReturnType { + this.stopCount++; + return super.stop(); + } + } + + test('restarts the idle client when the rubber duck config changes', async () => { + const client = new StopCountingClient([]); + const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + // Force the client to start so a subsequent config change has something to restart. + await agent.listSessions(); + + configurationService.updateRootConfig({ [AgentHostConfigKey.RubberDuck]: true }); + await Promise.resolve(); + + assert.strictEqual(client.stopCount, 1); + } finally { + await disposeAgent(agent); + } + }); + + test('restarts and disposes active sessions when the config changes', async () => { + const client = new StopCountingClient([]); + const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await agent.listSessions(); + + let disposed = false; + const sessions = (agent as unknown as { _sessions: { set(k: string, v: { dispose(): void }): void } })._sessions; + sessions.set('active', { dispose() { disposed = true; } }); + + configurationService.updateRootConfig({ [AgentHostConfigKey.RubberDuck]: true }); + await Promise.resolve(); + + assert.strictEqual(client.stopCount, 1); + assert.strictEqual(disposed, true); + } finally { + await disposeAgent(agent); + } + }); + + test('does not restart when an unrelated config key changes', async () => { + const client = new StopCountingClient([]); + const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await agent.listSessions(); + + configurationService.updateRootConfig({ [AgentHostConfigKey.DisableCustomTerminalTool]: true }); + await Promise.resolve(); + + assert.strictEqual(client.stopCount, 0); + } finally { + await disposeAgent(agent); + } + }); + }); + test('models include billing multiplier metadata when SDK provides it', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [{ diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index 61f7e809b5f518..7578ef4d294c23 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -12,20 +12,6 @@ import { CopilotSlashCommandCompletionProvider, parseLeadingSlashCommand } from suite('CopilotSlashCommandCompletionProvider', () => { - let _savedRubberDuckEnv: string | undefined; - suiteSetup(() => { - _savedRubberDuckEnv = process.env['RUBBER_DUCK_AGENT']; - process.env['RUBBER_DUCK_AGENT'] = 'true'; - }); - - suiteTeardown(() => { - if (_savedRubberDuckEnv === undefined) { - delete process.env['RUBBER_DUCK_AGENT']; - } else { - process.env['RUBBER_DUCK_AGENT'] = _savedRubberDuckEnv; - } - }); - ensureNoDisposablesAreLeakedInTestSuite(); suite('parseLeadingSlashCommand', () => { @@ -87,7 +73,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { }); suite('provideCompletionItems', () => { - const provider = new CopilotSlashCommandCompletionProvider('copilotcli'); + const provider = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => true }); const session = 'copilotcli:/abc'; async function run(text: string, offset = text.length) { @@ -182,30 +168,26 @@ suite('CopilotSlashCommandCompletionProvider', () => { }); test('omits /compact when session has no history', async () => { - const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => false }); + const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => false, isRubberDuckEnabled: () => true }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, }, CancellationToken.None); assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/research ', '/rubber-duck ']); }); - test('omits /rubber-duck when env var is unset', async () => { - const saved = process.env['RUBBER_DUCK_AGENT']; - delete process.env['RUBBER_DUCK_AGENT']; - try { - const items = await run('/'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ']); - } finally { - if (saved !== undefined) { - process.env['RUBBER_DUCK_AGENT'] = saved; - } - } + test('omits /rubber-duck when not enabled', async () => { + const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: () => true, isRubberDuckEnabled: () => false }); + const items = await gated.provideCompletionItems({ + kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, + }, CancellationToken.None); + assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact', '/research ']); }); test('passes raw session id (no scheme/slash) to hasHistory', async () => { let seen: string | undefined; const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { hasHistory: (id: string) => { seen = id; return true; }, + isRubberDuckEnabled: () => true, }); await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: 'copilotcli:/abc', text: '/', offset: 1, diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index 5d21a9e42f2ae7..db73d3cabc5fe7 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -276,22 +276,20 @@ export class TerminalSandboxEngine extends Disposable { // Quote shell arguments so the wrapped command cannot break out of the outer shell. const commandToRunInSandbox = this._getSandboxCommandWithPreservedCwd(command, cwd); const sandboxRuntimeCommand = `PATH="$PATH:${this._pathDirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" CLAUDE_TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(commandToRunInSandbox)}`; - const wrappedCommand = this._os === OperatingSystem.Linux && cwd?.path && cwd.path !== this._tempDir.path - ? `cd ${this._quoteShellArgument(this._tempDir.path)}; ${sandboxRuntimeCommand}` - : sandboxRuntimeCommand; // On workbench Electron builds the exec path points at the Electron binary, so we // prefix `ELECTRON_RUN_AS_NODE=1` to make it behave as Node.js. Remote workbench and // the agent host already resolve a real `node` binary and the host clears the flag. if (this._runAsNode) { + const nodeSandboxRuntimeCommand = `ELECTRON_RUN_AS_NODE=1 ${sandboxRuntimeCommand}`; return { - command: `ELECTRON_RUN_AS_NODE=1 ${wrappedCommand}`, + command: this._wrapSandboxRuntimeCommandForLaunch(nodeSandboxRuntimeCommand, cwd), isSandboxWrapped: true, requiresAllowNetworkConfirmation: allowNetworkForCommand && !this._isSandboxAllowNetworkConfigured() ? true : undefined, ...allowNetworkConfirmationMetadata, }; } return { - command: wrappedCommand, + command: this._wrapSandboxRuntimeCommandForLaunch(sandboxRuntimeCommand, cwd), isSandboxWrapped: true, requiresAllowNetworkConfirmation: allowNetworkForCommand && !this._isSandboxAllowNetworkConfigured() ? true : undefined, ...allowNetworkConfirmationMetadata, @@ -435,6 +433,13 @@ export class TerminalSandboxEngine extends Disposable { return `cd ${this._quoteShellArgument(cwd.path)} && ${command}`; } + private _wrapSandboxRuntimeCommandForLaunch(sandboxRuntimeCommand: string, cwd: URI | undefined): string { + const tempDirPath = this._tempDir?.path; + return this._os === OperatingSystem.Linux && cwd?.path && tempDirPath && cwd.path !== tempDirPath + ? `cd ${this._quoteShellArgument(tempDirPath)}; ${sandboxRuntimeCommand}` + : sandboxRuntimeCommand; + } + private _wrapUnsandboxedCommand(command: string, shell?: string): string { if (this._os === OperatingSystem.Windows) { return this._windowsMxcRuntime.wrapUnsandboxedCommand(command); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index bbbc3bde21da44..1911ac0a6bcd1f 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -60,12 +60,21 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun private availableUpdate: IAvailableUpdate | undefined; private updateCancellationTokenSource: CancellationTokenSource | undefined; + private readonly readyMutexName: string; + private readonly updatingMutexName: string; + private readonly setupMutexName: string; + @memoize get cachePath(): Promise { const result = path.join(tmpdir(), `vscode-${this.productService.quality}-${this.productService.target}-${process.arch}`); return mkdir(result, { recursive: true }).then(() => result); } + @memoize + private get mutex(): Promise { + return import('@vscode/windows-mutex'); + } + constructor( @ILifecycleMainService lifecycleMainService: ILifecycleMainService, @IConfigurationService configurationService: IConfigurationService, @@ -81,6 +90,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun ) { super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, telemetryService, applicationStorageMainService, meteredConnectionService, true); + this.readyMutexName = `${productService.win32MutexName}-ready`; + this.updatingMutexName = `${productService.win32MutexName}-updating`; + this.setupMutexName = `${productService.win32MutexName}setup`; + lifecycleMainService.setRelaunchHandler(this); } @@ -344,56 +357,75 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const cachePath = await this.cachePath; const sessionEndFlagPath = path.join(cachePath, 'session-ending.flag'); const cancelFilePath = path.join(cachePath, `cancel.flag`); - await this.unlink(cancelFilePath); - const progressFilePath = path.join(cachePath, `update-progress`); - await this.unlink(progressFilePath); - this.availableUpdate.updateFilePath = path.join(cachePath, `CodeSetup-${this.productService.quality}-${update.version}.flag`); this.availableUpdate.cancelFilePath = cancelFilePath; - await pfs.Promises.writeFile(this.availableUpdate.updateFilePath, 'flag'); - const child = spawn(this.availableUpdate.packagePath, - [ - '/verysilent', - '/log', - `/update="${this.availableUpdate.updateFilePath}"`, - `/progress="${progressFilePath}"`, - `/sessionend="${sessionEndFlagPath}"`, - `/cancel="${cancelFilePath}"`, - '/nocloseapplications', - '/mergetasks=runcode,!desktopicon,!quicklaunchicon' - ], - { - detached: true, - stdio: ['ignore', 'ignore', 'ignore'], - windowsVerbatimArguments: true, - env: { ...process.env, __COMPAT_LAYER: 'RunAsInvoker' } - } - ); + const mutex = await this.mutex; + const skippedSpawn = this.isInstallerActive(mutex); - // Track the process so we can cancel it if needed - this.availableUpdate.updateProcess = child; + // Skip the spawn if another Inno Setup is already running for this product (background update or a manual installer); + // otherwise Inno's "Setup is already running" modal pops up. The `-ready` mutex poll below still advances our state when it finishes. + if (skippedSpawn) { + this.logService.info('update#doApplyUpdate: another instance is already running setup, waiting for it to finish'); + } else { + await this.unlink(cancelFilePath); + await this.unlink(progressFilePath); + await pfs.Promises.writeFile(this.availableUpdate.updateFilePath, 'flag'); + + const child = spawn(this.availableUpdate.packagePath, + [ + '/verysilent', + '/log', + `/update="${this.availableUpdate.updateFilePath}"`, + `/progress="${progressFilePath}"`, + `/sessionend="${sessionEndFlagPath}"`, + `/cancel="${cancelFilePath}"`, + '/nocloseapplications', + '/mergetasks=runcode,!desktopicon,!quicklaunchicon' + ], + { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + windowsVerbatimArguments: true, + env: { ...process.env, __COMPAT_LAYER: 'RunAsInvoker' } + } + ); - child.once('exit', () => { - this.availableUpdate = undefined; - this.setState(State.Idle(getUpdateType())); - }); + // Track the process so we can cancel it if needed + this.availableUpdate.updateProcess = child; - const readyMutexName = `${this.productService.win32MutexName}-ready`; - const mutex = await import('@vscode/windows-mutex'); + child.once('exit', () => { + this.availableUpdate = undefined; + this.setState(State.Idle(getUpdateType())); + }); + } this.updateCancellationTokenSource?.dispose(true); const cts = this.updateCancellationTokenSource = new CancellationTokenSource(); const token = cts.token; const poll = async () => { + // If we skipped the spawn, the foreign installer was active when we started; treat that as having seen it run + // so a quick exit (cancel/fail) before the first poll iteration still drops us to Idle. + let seenRunning = skippedSpawn; while (this.state.type === StateType.Updating && !token.isCancellationRequested) { - if (mutex.isActive(readyMutexName)) { + if (mutex.isActive(this.readyMutexName)) { this.setState(State.Ready(update, explicit, this._overwrite)); return; } + // Inno gone without `-ready` => install cancelled/failed; drop to Idle. + if (this.isInstallerActive(mutex)) { + seenRunning = true; + } else if (seenRunning) { + if (!this.availableUpdate?.updateProcess) { + this.availableUpdate = undefined; + this.setState(State.Idle(getUpdateType())); + } + return; + } + try { const progressContent = await readFile(progressFilePath, 'utf8'); if (!token.isCancellationRequested) { @@ -435,14 +467,21 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return; } + const { updateProcess, updateFilePath, cancelFilePath } = this.availableUpdate; + + // Another instance owns the installer: abort if it's still running so we don't start a new + // update cycle on top of it; keep `availableUpdate` so quit-and-install can still complete. + if (!updateProcess && this.isInstallerActive(await this.mutex)) { + throw new Error('Cannot cancel pending update: another instance is still running setup'); + } + // Cancel the polling loop this.updateCancellationTokenSource?.dispose(true); this.updateCancellationTokenSource = undefined; - this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); - const { updateProcess, updateFilePath, cancelFilePath } = this.availableUpdate; - if (updateProcess && updateProcess.exitCode === null) { + this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); + // Remove all listeners to prevent the exit handler from changing state updateProcess.removeAllListeners(); const exitPromise = new Promise(resolve => updateProcess.once('exit', () => resolve(true))); @@ -543,6 +582,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } } + private isInstallerActive(mutex: typeof import('@vscode/windows-mutex')): boolean { + return mutex.isActive(this.updatingMutexName) || mutex.isActive(this.setupMutexName); + } + private async unlink(path: string | undefined): Promise { if (path) { try { diff --git a/src/vs/server/node/extensionHostConnection.ts b/src/vs/server/node/extensionHostConnection.ts index e093c3f9b04eb6..812909ff7f4fe3 100644 --- a/src/vs/server/node/extensionHostConnection.ts +++ b/src/vs/server/node/extensionHostConnection.ts @@ -163,7 +163,9 @@ export class ExtensionHostConnection extends Disposable { const disposables = new DisposableStore(); disposables.add(connectionData.socket); disposables.add(toDisposable(() => { - extHostSocket.destroy(); + if (!extHostSocket.destroyed && !extHostSocket.writableEnded) { + extHostSocket.end(); + } })); const stopAndCleanup = () => { diff --git a/src/vs/sessions/browser/parts/sessionsPart.ts b/src/vs/sessions/browser/parts/sessionsPart.ts index ff021e987b3b28..65c5c9cca0e150 100644 --- a/src/vs/sessions/browser/parts/sessionsPart.ts +++ b/src/vs/sessions/browser/parts/sessionsPart.ts @@ -15,7 +15,7 @@ import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js' import { Direction, SerializableGrid, Sizing } from '../../../base/browser/ui/grid/grid.js'; import { Part } from '../../../workbench/browser/part.js'; import { ActiveSessionsContext, MultipleSessionsVisibleContext, SessionsFocusContext } from '../../common/contextkeys.js'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, isAncestor } from '../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, isAncestor, trackFocus } from '../../../base/browser/dom.js'; import { IActiveSession } from '../../services/sessions/common/sessionsManagement.js'; import { SessionView } from './sessionView.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; @@ -76,6 +76,7 @@ export class SessionsPart extends Part { protected _lastLayout: { readonly width: number; readonly height: number; readonly top: number; readonly left: number } | undefined; private readonly _multipleSessionsVisibleKey: IContextKey; + private readonly _sessionsFocusKey: IContextKey; get preferredHeight(): number | undefined { return this.layoutService.mainContainerDimension.height * 0.4; @@ -100,7 +101,7 @@ export class SessionsPart extends Part { // Bind context keys for compatibility with existing when-clauses ActiveSessionsContext.bindTo(contextKeyService); - SessionsFocusContext.bindTo(contextKeyService); + this._sessionsFocusKey = SessionsFocusContext.bindTo(contextKeyService); this._multipleSessionsVisibleKey = MultipleSessionsVisibleContext.bindTo(contextKeyService); } @@ -115,6 +116,12 @@ export class SessionsPart extends Part { const contentArea = $('.content'); parent.appendChild(contentArea); + // Track keyboard focus within the sessions content so the `sessionsFocus` + // context key reflects whether a session (its chat view) currently has focus. + const focusTracker = this._register(trackFocus(contentArea)); + this._register(focusTracker.onDidFocus(() => this._sessionsFocusKey.set(true))); + this._register(focusTracker.onDidBlur(() => this._sessionsFocusKey.set(false))); + // Progress bar pinned to the top of the content area (see sessionsPart.css // rule `.part.sessionspart > .content > .monaco-progress-container`). this._progressBar = this._register(new ProgressBar(contentArea, defaultProgressBarStyles)); @@ -264,6 +271,39 @@ export class SessionsPart extends Part { return this._slots.find(s => s.boundSessionId === sessionId)?.view; } + /** + * Moves keyboard focus into the session view hosting the given session id (or + * the placeholder view when `sessionId` is `undefined`), first revealing it in + * the grid when it is only partially visible. No-op if no matching slot exists. + */ + focusSession(sessionId: string | undefined): void { + const slot = this._slots.find(s => s.boundSessionId === sessionId); + if (!slot) { + return; + } + this._revealView(slot.view); + slot.view.focus(); + } + + /** + * Ensures the given view is fully visible within the grid. The grid clips its + * leaves (`overflow: hidden`) and lays them out side by side; when there are + * more sessions than fit, the grid's split view overflows horizontally and + * becomes scrollable, leaving views near the edges partially hidden. When the + * target view is not fully visible, scroll it into view. + */ + private _revealView(view: SessionView): void { + if (!this._gridWidget) { + return; + } + const containerRect = this._gridWidget.element.getBoundingClientRect(); + const viewRect = view.element.getBoundingClientRect(); + const isFullyVisible = viewRect.left >= containerRect.left - 1 && viewRect.right <= containerRect.right + 1; + if (!isFullyVisible) { + view.element.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + } + /** * Returns the progress indicator for the part. Drives the progress bar shown * at the top of the content area. Indicator state is scoped to the part's diff --git a/src/vs/sessions/browser/parts/sessionsPartService.ts b/src/vs/sessions/browser/parts/sessionsPartService.ts index 86efe596a69e47..60a4bbfa34316c 100644 --- a/src/vs/sessions/browser/parts/sessionsPartService.ts +++ b/src/vs/sessions/browser/parts/sessionsPartService.ts @@ -131,7 +131,7 @@ export class SessionsParts extends Disposable implements ISessionsPartService { } focusSession(session: IActiveSession | undefined): void { - this._mainPart.getSessionView(session?.sessionId)?.focus(); + this._mainPart.focusSession(session?.sessionId); } getSessionView(sessionId: string | undefined): SessionView | undefined { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index e229bd9688f9fb..cd673d5bb424e1 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -5,7 +5,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { fromNow } from '../../../../base/common/date.js'; -import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -18,7 +18,7 @@ import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.j import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, MultipleSessionsVisibleContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, MultipleSessionsVisibleContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../browser/parts/sessionsPartService.js'; @@ -219,6 +219,65 @@ registerAction2(class FocusActiveSessionAction extends Action2 { } }); +// -- Focus Nth Session in the Grid (Cmd/Ctrl+1..9) -- + +for (let index = 0; index < 9; index++) { + const position = index + 1; + registerAction2(class FocusSessionByPositionAction extends Action2 { + constructor() { + super({ + id: `sessions.focusSessionInGrid${position}`, + title: localize2('focusSessionInGrid', "Focus Session {0} in Grid", position), + f1: true, + category: SessionsCategories.Sessions, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib + 1, + primary: KeyMod.CtrlCmd | (KeyCode.Digit1 + index), + when: IsSessionsWindowContext, + }, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessionsPartService = accessor.get(ISessionsPartService); + + const visible = sessionsManagementService.visibleSessions.get(); + if (index >= visible.length) { + return; + } + + const session = visible[index]; + sessionsManagementService.setActive(session); + sessionsPartService.focusSession(session); + } + }); +} + +// -- Close All Sessions -- + +registerAction2(class CloseAllSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessions.closeAllSessions', + title: localize2('closeAllSessions', "Close All Sessions"), + f1: true, + category: SessionsCategories.Sessions, + precondition: IsSessionsWindowContext, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib + 1, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.CtrlCmd | KeyCode.KeyW), + // Only fire from the keyboard while a session (its chat view) has focus. + when: ContextKeyExpr.and(IsSessionsWindowContext, SessionsFocusContext), + }, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + accessor.get(ISessionsManagementService).closeAllSessions(); + } +}); + registerAction2(class AddChatToSessionBarAction extends Action2 { constructor() { super({ diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index f4960aa359515d..4cc1918a3bfbff 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -654,6 +654,22 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } + closeAllSessions(): void { + const ids = this._visibility.visibleSessions.get() + .filter((s): s is IActiveSession => !!s) + .map(s => s.sessionId); + if (ids.length === 0) { + return; + } + + this._pendingNewSession = undefined; + + // Remove every visible session in a single pass; the visibility model + // clears the active session, which drives the grid back to the + // new-session view via the part's reconciliation. + this._visibility.removeMany(ids); + } + private _restoreInitialChat(session: ISession): IChat { const chats = session.chats.get(); let initialChat = chats[0]; diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 7d537c23948b68..83f32cb2a76779 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -211,6 +211,13 @@ export interface ISessionsManagementService { */ closeSession(session: ISession | undefined): void; + /** + * Close all sessions currently shown in the grid. Removes every visible + * session in a single pass and lands on the new-session view. No-op when no + * session is currently visible. + */ + closeAllSessions(): void; + setActive(session: IActiveSession | undefined): void; /** diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 3dba91ec80ba5b..d2c1767fdb79a9 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -179,6 +179,7 @@ class MockSessionStore implements ISessionsManagementService { toggleSessionStickiness(_session: ISession): void { throw new Error('not implemented'); } insertAt(_session: ISession, _targetSessionId: string, _side: 'left' | 'right', _activate?: boolean): void { throw new Error('not implemented'); } closeSession(_session: ISession | undefined): void { throw new Error('not implemented'); } + closeAllSessions(): void { throw new Error('not implemented'); } setActive(_session: IActiveSession): void { throw new Error('not implemented'); } archiveSession(_session: ISession): Promise { throw new Error('not implemented'); } unarchiveSession(_session: ISession): Promise { throw new Error('not implemented'); } diff --git a/src/vs/workbench/contrib/chat/browser/hasByokModelsContribution.ts b/src/vs/workbench/contrib/chat/browser/hasByokModelsContribution.ts index 90da60c9baee68..ca5c7c1d662c31 100644 --- a/src/vs/workbench/contrib/chat/browser/hasByokModelsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/hasByokModelsContribution.ts @@ -20,16 +20,16 @@ import { ILanguageModelsConfigurationService } from '../common/languageModelsCon * Owns the `github.copilot.hasByokModels` context key. The key is true iff: * - `github.copilot.clientByokEnabled` is true (set by `ChatEntitlementService` + Copilot extension), * - `chat.aiDisabled` is off, and - * - the language-models configuration has at least one non-Copilot vendor group (post extension scan), - * or — pre-scan — the `chatNonCopilotModelsAreUserSelectable` signal is on. + * - the language-models configuration has at least one non-Copilot vendor group (at any time), + * or — pre extension scan — the `chatNonCopilotModelsAreUserSelectable` signal is on. * * Strategy (avoids activating BYOK extensions just to gate UI): - * 1. Restore the last persisted answer so UI surfaces are correct on warm reload. - * 2. Before extensions register, treat the signal as optimistic — flip true when it does. - * 3. After extensions register, configured non-Copilot vendor groups are the source of truth. - * The signal is ignored here because the model cache can lag behind group removal (e.g. the - * Copilot extension's BYOK secret storage still has the API key, so re-resolving returns - * stale models), which would otherwise keep the sign-in UI hidden after removal. + * 1. Restore the last persisted answer for correct warm-reload UI. + * 2. Configured non-Copilot vendor groups are a positive signal at any time. + * 3. Pre-registration only, also trust the `chatNonCopilotModelsAreUserSelectable` signal + * (post-registration it can be stale — model cache lags behind group removal). + * 4. Only persist `false` after both extension scan and first config load complete, so startup + * latency doesn't clobber a previously-true answer. * * Eager so the key is bound before any sign-in UI renders. */ @@ -46,6 +46,7 @@ export class HasByokModelsContribution extends Disposable implements IWorkbenchC private readonly _hasByokModels: IContextKey; private _extensionsRegistered = false; + private _configurationLoaded = false; constructor( @ILanguageModelsConfigurationService private readonly _languageModelsConfigurationService: ILanguageModelsConfigurationService, @@ -68,6 +69,13 @@ export class HasByokModelsContribution extends Disposable implements IWorkbenchC } }); + this._languageModelsConfigurationService.whenReady.then(() => { + if (!this._store.isDisposed) { + this._configurationLoaded = true; + this._update(); + } + }); + this._register(Event.any( Event.filter(this._configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.AIDisabled)), Event.filter(this._contextKeyService.onDidChangeContext, e => e.affectsSome(HasByokModelsContribution.TRACKED_KEYS)), @@ -99,17 +107,22 @@ export class HasByokModelsContribution extends Disposable implements IWorkbenchC return; } - if (!this._extensionsRegistered) { - // Optimistic flip on the signal; otherwise leave the restored value alone. - if (this._contextKeyService.getContextKeyValue(ChatContextKeys.nonCopilotLanguageModelsAreUserSelectable.key)) { - this._setResult(true); - } + const hasByokVendor = this._languageModelsConfigurationService.getLanguageModelsProviderGroups().some(g => g.vendor !== COPILOT_VENDOR_ID); + if (hasByokVendor) { + this._setResult(true); return; } - // Post-registration: configured non-Copilot vendor groups are authoritative; the signal - // can be stale (model cache may lag behind group removal). - const hasByokVendor = this._languageModelsConfigurationService.getLanguageModelsProviderGroups().some(g => g.vendor !== COPILOT_VENDOR_ID); - this._setResult(hasByokVendor); + // Pre-registration only: trust the user-selectable signal as an optimistic positive. + // Post-registration it can be stale (model cache lags behind group removal), so ignore. + if (!this._extensionsRegistered && this._contextKeyService.getContextKeyValue(ChatContextKeys.nonCopilotLanguageModelsAreUserSelectable.key)) { + this._setResult(true); + return; + } + + // Defer negative result until startup signals have settled. + if (this._extensionsRegistered && this._configurationLoaded) { + this._setResult(false); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts index 05f03bb64aa536..dad19aa1361c9d 100644 --- a/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts +++ b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts @@ -44,6 +44,10 @@ export class LanguageModelsConfigurationService extends Disposable implements IL private languageModelsProviderGroups: LanguageModelsProviderGroups = []; + /** Resolved once the first config-file load attempt completes; assigned exactly once in the ctor. Rejections are swallowed so consumers can treat readiness as "first load attempted". */ + private readonly _whenReady: Promise; + get whenReady(): Promise { return this._whenReady; } + constructor( @IFileService private readonly fileService: IFileService, @ITextFileService private readonly textFileService: ITextFileService, @@ -55,7 +59,7 @@ export class LanguageModelsConfigurationService extends Disposable implements IL ) { super(); this.modelsConfigurationFile = userDataProfileService.currentProfile.languageModelsResource; - this.updateLanguageModelsConfiguration(); + this._whenReady = this.updateLanguageModelsConfiguration().catch(() => { /* swallow: readiness signals "attempted", not "succeeded" */ }); // Watch the parent folder for reliable change detection across platforms (especially Windows // where `fs.watch` on individual files can miss in-place writes). this._register(fileService.watch(uriIdentityService.extUri.dirname(this.modelsConfigurationFile))); diff --git a/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts index ef645487a79b6e..faae159570b89a 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts @@ -24,6 +24,9 @@ export interface ILanguageModelsConfigurationService { readonly onDidChangeLanguageModelGroups: Event; + /** Resolves after the first config-file load attempt (success or failure), so callers can distinguish empty from not-yet-loaded. Never rejects. */ + readonly whenReady: Promise; + getLanguageModelsProviderGroups(): readonly ILanguageModelsProviderGroup[]; addLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise; diff --git a/src/vs/workbench/contrib/chat/test/browser/hasByokModelsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/hasByokModelsContribution.test.ts index c36a623d2e3acc..60fb67bbbec0da 100644 --- a/src/vs/workbench/contrib/chat/test/browser/hasByokModelsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/hasByokModelsContribution.test.ts @@ -40,6 +40,7 @@ suite('HasByokModelsContribution', () => { readonly storage?: { readonly lastKnown?: boolean; }; + readonly deferConfigReady?: boolean; } class FakeLanguageModelsConfigurationService { @@ -48,6 +49,19 @@ suite('HasByokModelsContribution', () => { private readonly _onDidChangeLanguageModelGroups = new Emitter(); readonly onDidChangeLanguageModelGroups = this._onDidChangeLanguageModelGroups.event; private _groups: readonly FakeProviderGroup[] = []; + private _resolveReady!: () => void; + readonly whenReady: Promise; + + constructor(defer: boolean) { + this.whenReady = new Promise(resolve => { this._resolveReady = resolve; }); + if (!defer) { + this._resolveReady(); + } + } + + resolveReady(): void { + this._resolveReady(); + } setGroups(groups: readonly FakeProviderGroup[]): void { this._groups = groups; @@ -92,7 +106,7 @@ suite('HasByokModelsContribution', () => { storage.store('chat.hasByokModels.lastKnown', options.storage.lastKnown, StorageScope.APPLICATION, StorageTarget.MACHINE); } - const configService = new FakeLanguageModelsConfigurationService(); + const configService = new FakeLanguageModelsConfigurationService(options.deferConfigReady ?? false); store.add({ dispose: () => configService.dispose() }); if (options.groups) { (configService as unknown as { _groups: readonly FakeProviderGroup[] })._groups = options.groups; @@ -260,4 +274,41 @@ suite('HasByokModelsContribution', () => { await flush(); assert.deepStrictEqual(snapshot(scenario), { hasByokModels: true, persistedLastKnown: true }); }); + + // Regression for #319121: during cold start, the language-models configuration file load is + // async. A previously-persisted `true` must survive until that load completes; otherwise + // BYOK-gated UI (e.g. the Copilot signed-out placeholder) flickers on every restart. + test('persisted true survives while config load is pending (#319121)', async () => { + const store = disposables.add(new DisposableStore()); + const scenario = createScenario(store, { + storage: { lastKnown: true }, + deferConfigReady: true, + }); + await flush(); + + // Extensions have registered but configuration has not loaded yet — keep restored value. + assert.deepStrictEqual(snapshot(scenario, true), { hasByokModels: true, persistedLastKnown: true }); + + // Once the config loads and reports a BYOK group, the value stays true. + (scenario.configService as unknown as { _groups: readonly FakeProviderGroup[] })._groups = [{ vendor: 'ollama', name: 'Ollama' }]; + scenario.configService.resolveReady(); + await flush(); + + assert.deepStrictEqual(snapshot(scenario), { hasByokModels: true, persistedLastKnown: true }); + }); + + test('persisted true cleared once config load completes with no BYOK groups', async () => { + const store = disposables.add(new DisposableStore()); + const scenario = createScenario(store, { + storage: { lastKnown: true }, + deferConfigReady: true, + }); + await flush(); + assert.strictEqual(scenario.hasByokModels.get(), true); + + scenario.configService.resolveReady(); + await flush(); + + assert.deepStrictEqual(snapshot(scenario, true), { hasByokModels: false, persistedLastKnown: false }); + }); }); diff --git a/src/vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint.ts b/src/vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint.ts index 9b12b00eb96b86..a91f1a28aaec3f 100644 --- a/src/vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint.ts +++ b/src/vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint.ts @@ -747,12 +747,40 @@ const schema: IJSONSchema = { description: nls.localize('schema.folding.markers', 'Language specific folding markers such as \'#region\' and \'#endregion\'. The start and end regexes will be tested against the contents of all lines and must be designed efficiently'), properties: { start: { - type: 'string', - description: nls.localize('schema.folding.markers.start', 'The RegExp pattern for the start marker. The regexp must start with \'^\'.') + type: ['string', 'object'], + description: nls.localize('schema.folding.markers.start', 'The RegExp pattern for the start marker. The regexp must start with \'^\'.'), + properties: { + pattern: { + type: 'string', + description: nls.localize('schema.folding.markers.start.pattern', 'The RegExp pattern for the start marker.'), + default: '', + }, + flags: { + type: 'string', + description: nls.localize('schema.folding.markers.start.flags', 'The RegExp flags for the start marker.'), + default: '', + pattern: '^([gimuy]+)$', + patternErrorMessage: nls.localize('schema.folding.markers.start.errorMessage', 'Must match the pattern `/^([gimuy]+)$/`.') + } + } }, end: { - type: 'string', - description: nls.localize('schema.folding.markers.end', 'The RegExp pattern for the end marker. The regexp must start with \'^\'.') + type: ['string', 'object'], + description: nls.localize('schema.folding.markers.end', 'The RegExp pattern for the end marker. The regexp must start with \'^\'.'), + properties: { + pattern: { + type: 'string', + description: nls.localize('schema.folding.markers.end.pattern', 'The RegExp pattern for the end marker.'), + default: '', + }, + flags: { + type: 'string', + description: nls.localize('schema.folding.markers.end.flags', 'The RegExp flags for the end marker.'), + default: '', + pattern: '^([gimuy]+)$', + patternErrorMessage: nls.localize('schema.folding.markers.end.errorMessage', 'Must match the pattern `/^([gimuy]+)$/`.') + } + } }, } } diff --git a/src/vs/workbench/contrib/codeEditor/test/node/autoindent.test.ts b/src/vs/workbench/contrib/codeEditor/test/node/autoindent.test.ts index 7bfd3ded3e3d24..a617b1f16d0ffa 100644 --- a/src/vs/workbench/contrib/codeEditor/test/node/autoindent.test.ts +++ b/src/vs/workbench/contrib/codeEditor/test/node/autoindent.test.ts @@ -95,6 +95,26 @@ function registerTokenizationSupport(instantiationService: TestInstantiationServ return TokenizationRegistry.register(languageId, tokenizationSupport); } +suite('Language Configuration Parsing', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + test('Folding markers support object regex syntax with flags', () => { + const parsed = LanguageConfigurationFileHandler.extractValidConfig('testLang', { + folding: { + markers: { + start: { pattern: '^\\s*#region\\b', flags: 'i' }, + end: { pattern: '^\\s*#endregion\\b', flags: 'i' } + } + } + }); + + assert.ok(parsed.folding?.markers); + assert.strictEqual(parsed.folding?.markers?.start.flags, 'i'); + assert.strictEqual(parsed.folding?.markers?.end.flags, 'i'); + assert.ok(parsed.folding?.markers?.start.test('#REGION')); + assert.ok(parsed.folding?.markers?.end.test('#ENDREGION')); + }); +}); + suite('Auto-Reindentation - TypeScript/JavaScript', () => { const languageId = LanguageId.TypeScript; diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 9e45dc8d6ec505..43d2b947aecb4b 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -1221,6 +1221,10 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } getAutoUpdateDelayRemaining(extension: IExtension): number { + // Extensions from publishers trusted by the product are auto updated without delay. + if (this.isFromTrustedPublisher(extension)) { + return 0; + } const lastUpdated = extension.gallery?.lastUpdated; if (!Number.isFinite(lastUpdated) || !lastUpdated) { return 0; @@ -1233,6 +1237,16 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension return Math.max(0, DELAYED_AUTO_UPDATE_PERIOD - elapsed); } + private isFromTrustedPublisher(extension: IExtension): boolean { + const trustedPublishers = this.productService.trustedExtensionPublishers; + if (!trustedPublishers?.length) { + return false; + } + const publisher = extension.publisher.toLowerCase(); + return trustedPublishers.includes(publisher) + || trustedPublishers.includes(extension.publisherDisplayName.toLowerCase()); + } + async updateAutoUpdateForAllExtensions(isAutoUpdateEnabled: boolean): Promise { const wasAutoUpdateEnabled = this.isAutoUpdateEnabled(); if (wasAutoUpdateEnabled === isAutoUpdateEnabled) { diff --git a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts index a8f4b0cf84a933..8ac18ba3384940 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts @@ -481,6 +481,14 @@ suite('ExtensionsWorkbenchServiceTest', () => { assert.strictEqual(testObject.getAutoUpdateDelayRemaining(testObject.local[0]), 0); }); + test('test getAutoUpdateDelayRemaining returns 0 for a trusted publisher', async () => { + instantiationService.stub(IProductService, { ...TestProductService, trustedExtensionPublishers: ['pub'] }); + testObject = await anOutdatedExtensionWorkbenchService(Date.now() - (1000 * 60 * 60) /* 1 hour ago */); + + assert.strictEqual(testObject.getAutoUpdateDelayRemaining(testObject.local[0]), 0); + assert.strictEqual(testObject.isAutoUpdateDelayed(testObject.local[0]), false); + }); + test('test getAutoUpdateValue normalizes legacy insiders values', async () => { const expected = new Map([ ['on', true], diff --git a/src/vs/workbench/contrib/terminal/browser/terminalResizeDebouncer.ts b/src/vs/workbench/contrib/terminal/browser/terminalResizeDebouncer.ts index 88062ff24fb7f9..f4911a2dc40766 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalResizeDebouncer.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalResizeDebouncer.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { getWindow, runWhenWindowIdle } from '../../../../base/browser/dom.js'; -import { debounce } from '../../../../base/common/decorators.js'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import type { XtermTerminal } from './xterm/xtermTerminal.js'; @@ -13,6 +13,7 @@ const enum Constants { * The _normal_ buffer length threshold at which point resizing starts being debounced. */ StartDebouncingThreshold = 200, + DebounceResizeXDelay = 100, } export class TerminalResizeDebouncer extends Disposable { @@ -22,6 +23,13 @@ export class TerminalResizeDebouncer extends Disposable { private readonly _resizeXJob = this._register(new MutableDisposable()); private readonly _resizeYJob = this._register(new MutableDisposable()); + // Owned by the disposable store so the pending timer is cancelled on dispose, + // avoiding callbacks that fire against a torn-down xterm renderer. + private readonly _debounceResizeXScheduler = this._register(new RunOnceScheduler( + () => this._resizeXCallback(this._latestX), + Constants.DebounceResizeXDelay, + )); + constructor( private readonly _isVisible: () => boolean, private readonly _getXterm: () => XtermTerminal | undefined, @@ -43,6 +51,7 @@ export class TerminalResizeDebouncer extends Disposable { if (immediate || this._getXterm()!.raw.buffer.normal.length < Constants.StartDebouncingThreshold) { this._resizeXJob.clear(); this._resizeYJob.clear(); + this._debounceResizeXScheduler.cancel(); this._resizeBothCallback(cols, rows); return; } @@ -75,28 +84,18 @@ export class TerminalResizeDebouncer extends Disposable { // expensive due to reflow. this._resizeYCallback(rows); this._latestX = cols; - this._debounceResizeX(cols); + this._debounceResizeXScheduler.schedule(); } flush(): void { if (this._store.isDisposed) { return; } - if (this._resizeXJob.value || this._resizeYJob.value) { + if (this._resizeXJob.value || this._resizeYJob.value || this._debounceResizeXScheduler.isScheduled()) { this._resizeXJob.clear(); this._resizeYJob.clear(); + this._debounceResizeXScheduler.cancel(); this._resizeBothCallback(this._latestX, this._latestY); } } - - @debounce(100) - private _debounceResizeX(cols: number) { - // The @debounce decorator schedules a setTimeout that is not tied to the - // disposable store, so this can fire after the terminal/xterm renderer is - // disposed. Bail out to avoid throwing from xterm.js dimension getters. - if (this._store.isDisposed) { - return; - } - this._resizeXCallback(cols); - } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index e6948b4388338c..37c6cef2ecde46 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -23,7 +23,7 @@ import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../ import { Event, Emitter } from '../../../../../../base/common/event.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; -import { isWindows, OperatingSystem } from '../../../../../../base/common/platform.js'; +import { isWindows, OperatingSystem, OS } from '../../../../../../base/common/platform.js'; import { IRemoteAgentEnvironment } from '../../../../../../platform/remote/common/remoteAgentEnvironment.js'; import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; import { testWorkspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; @@ -939,6 +939,22 @@ suite('TerminalSandboxService - network domains', () => { strictEqual(wrapResult.isSandboxWrapped, true, 'Command should remain sandbox wrapped'); }); + if (OS === OperatingSystem.Linux) { + test('should apply ELECTRON_RUN_AS_NODE to the sandbox runtime after the Linux temp-dir cd', async () => { + remoteAgentService.remoteEnvironment = null; + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = await sandboxService.wrapCommand('head -1 /etc/shells', false, 'bash', URI.file('/workspace-one')); + const expectedWrappedCwd = String.raw`-c 'cd '\''/workspace-one'\'' && head -1 /etc/shells'`; + + ok(wrapResult.command.startsWith(`cd '${sandboxService.getTempDir()?.path}'; ELECTRON_RUN_AS_NODE=1 PATH="$PATH:`), 'ELECTRON_RUN_AS_NODE should apply to the sandbox runtime, not the temp-dir cd'); + ok(!wrapResult.command.startsWith('ELECTRON_RUN_AS_NODE=1 cd '), 'ELECTRON_RUN_AS_NODE should not apply to the temp-dir cd command'); + ok(wrapResult.command.includes(expectedWrappedCwd), `Sandboxed command should restore the original cwd before running the user command. Actual: ${wrapResult.command}`); + strictEqual(wrapResult.isSandboxWrapped, true, 'Command should remain sandbox wrapped'); + }); + } + test('should preserve TMPDIR when unsandboxed execution is requested', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 990252bc48bd4b..3fc08f3c3796a8 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -42,7 +42,6 @@ export class TestContext { private readonly tempDirs = new Set(); private readonly wslTempDirs = new Set(); - private readonly patchedWslNodePaths = new Set(); private nextPort = 3010; private currentTestName: string | undefined; private screenshotCounter = 0; @@ -257,38 +256,6 @@ export class TestContext { return undefined; } - /** - * On WSL1, patches the Node.js binary used by the server to remove ELF note sections - * that cause Node 24 to fail to start. No-op on WSL2. - * @param wslEntryPoint The WSL path to the server entry point script. - */ - public applyWsl1Node24Workaround(wslEntryPoint: string): void { - if (this.getUbuntuWslVersion() !== 1) { - return; - } - - const wslNodePath = wslEntryPoint.replace(/\/bin\/[^/]+$/, '/node'); - if (this.patchedWslNodePaths.has(wslNodePath)) { - return; - } - - this.patchedWslNodePaths.add(wslNodePath); - this.warn(`Applying WSL1 Node 24 workaround for ${wslNodePath}`); - - const shellScript = [ - 'set -e', - `node_path='${wslNodePath}'`, - 'backup_path="${node_path}.orig"', - 'if [ -f "${backup_path}" ]; then exit 0; fi', - 'if ! command -v objcopy >/dev/null 2>&1; then apt-get update && apt-get install -y binutils; fi', - 'cp "${node_path}" "${backup_path}"', - 'objcopy --remove-section .note.ABI-tag --remove-section .note.gnu.build-id --remove-section .note.gnu.property "${backup_path}" "${node_path}"', - 'chmod +x "${node_path}"', - ].join('; '); - - this.runNoErrors('wsl', '-d', 'Ubuntu', 'sh', '-lc', shellScript); - } - /** * Ensures that the directory for the specified file path exists. */ diff --git a/test/sanity/src/wsl.test.ts b/test/sanity/src/wsl.test.ts index 0e6b56d23b8b90..65a6455fee2d8d 100644 --- a/test/sanity/src/wsl.test.ts +++ b/test/sanity/src/wsl.test.ts @@ -61,7 +61,6 @@ export function setup(context: TestContext) { } const wslEntryPoint = context.toWslPath(entryPoint); - context.applyWsl1Node24Workaround(wslEntryPoint); await context.runCliApp('WSL Server', 'wsl', [ @@ -102,7 +101,6 @@ export function setup(context: TestContext) { const test = new WslUITest(context, undefined, wslWorkspaceDir, wslExtensionsDir); const wslEntryPoint = context.toWslPath(entryPoint); - context.applyWsl1Node24Workaround(wslEntryPoint); await context.runCliApp('WSL Server', 'wsl', [