Merge pull request #308249 from microsoft/connor4312/reapply-tunnels

Reapply tunnels work
This commit is contained in:
Connor Peet
2026-04-07 12:58:34 -04:00
committed by GitHub
54 changed files with 3654 additions and 1134 deletions
+14
View File
@@ -99,6 +99,20 @@ kerberos/node_modules/**
cpu-features/**
# utf-8-validate and bufferutil are native modules pulled in by the websocket
# package. They include prebuilds for multiple platforms which break cross-platform
# bundling (rcedit fails on non-PE .node files). Both modules have pure JS fallbacks
# that are sufficient for Node.js >= 18.14.0, so we strip all native artifacts.
utf-8-validate/binding.gyp
utf-8-validate/build/**
utf-8-validate/prebuilds/**
utf-8-validate/src/**
bufferutil/binding.gyp
bufferutil/build/**
bufferutil/prebuilds/**
bufferutil/src/**
ssh2/lib/protocol/crypto/binding.gyp
ssh2/lib/protocol/crypto/build/**
ssh2/lib/protocol/crypto/src/**
+19 -563
View File
@@ -9,42 +9,22 @@ use std::io::{Read, Write};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Request, Response, Server};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::Mutex;
use hyper::Server;
use crate::async_pipe::{get_socket_name, get_socket_rw_stream, AsyncPipe};
use crate::constants::VSCODE_CLI_QUALITY;
use crate::download_cache::DownloadCache;
use crate::log;
use crate::options::Quality;
use crate::tunnels::paths::{get_server_folder_name, SERVER_FOLDER_NAME};
use crate::tunnels::agent_host::{handle_request, AgentHostConfig, AgentHostManager};
use crate::tunnels::legal;
use crate::tunnels::shutdown_signal::ShutdownRequest;
use crate::update_service::{
unzip_downloaded_release, Platform, Release, TargetKind, UpdateService,
};
use crate::util::command::new_script_command;
use crate::update_service::Platform;
use crate::util::errors::AnyError;
use crate::util::http::{self, ReqwestSimpleHttp};
use crate::util::io::SilentCopyProgress;
use crate::util::sync::{new_barrier, Barrier, BarrierOpener};
use crate::{
tunnels::legal,
util::{errors::CodeError, prereqs::PreReqChecker},
};
use crate::util::errors::CodeError;
use crate::util::http::ReqwestSimpleHttp;
use crate::util::prereqs::PreReqChecker;
use super::{args::AgentHostArgs, CommandContext};
/// How often to check for server updates.
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
/// How often to re-check whether the server has exited when an update is pending.
const UPDATE_POLL_INTERVAL: Duration = Duration::from_secs(10 * 60);
/// How long to wait for the server to signal readiness.
const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
/// Runs a local agent host server. Downloads the latest VS Code server on
/// demand, starts it with `--enable-remote-auto-shutdown`, and proxies
/// WebSocket connections from a local TCP port to the server's agent host
@@ -69,7 +49,18 @@ pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result<
}
}
let manager = AgentHostManager::new(&ctx, platform, args.clone())?;
let manager = AgentHostManager::new(
ctx.log.clone(),
platform,
ctx.paths.server_cache.clone(),
Arc::new(ReqwestSimpleHttp::with_client(ctx.http.clone())),
AgentHostConfig {
server_data_dir: args.server_data_dir.clone(),
without_connection_token: args.without_connection_token,
connection_token: args.connection_token.clone(),
connection_token_file: args.connection_token_file.clone(),
},
);
// Eagerly resolve the latest version so the first connection is fast.
// Skip when using a dev override since updates don't apply.
@@ -130,541 +121,6 @@ pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result<
Ok(0)
}
// ---- AgentHostManager -------------------------------------------------------
/// State of the running VS Code server process.
struct RunningServer {
child: tokio::process::Child,
commit: String,
}
/// Manages the VS Code server lifecycle: on-demand start, auto-restart
/// after idle shutdown, and background update checking.
struct AgentHostManager {
log: log::Logger,
args: AgentHostArgs,
platform: Platform,
cache: DownloadCache,
update_service: UpdateService,
/// The latest known release, with the time it was checked.
latest_release: Mutex<Option<(Instant, Release)>>,
/// The currently running server, if any.
running: Mutex<Option<RunningServer>>,
/// Barrier that opens when a server is ready (socket path available).
/// Reset each time a new server is started.
ready: Mutex<Option<Barrier<Result<PathBuf, String>>>>,
}
impl AgentHostManager {
fn new(
ctx: &CommandContext,
platform: Platform,
args: AgentHostArgs,
) -> Result<Arc<Self>, CodeError> {
// Seed latest_release from cache if available
let cache = ctx.paths.server_cache.clone();
Ok(Arc::new(Self {
log: ctx.log.clone(),
args,
platform,
cache,
update_service: UpdateService::new(
ctx.log.clone(),
Arc::new(ReqwestSimpleHttp::with_client(ctx.http.clone())),
),
latest_release: Mutex::new(None),
running: Mutex::new(None),
ready: Mutex::new(None),
}))
}
/// Returns the socket path to a running server, starting one if needed.
async fn ensure_server(self: &Arc<Self>) -> Result<PathBuf, CodeError> {
// Fast path: if we already have a barrier, wait on it
{
let ready = self.ready.lock().await;
if let Some(barrier) = &*ready {
if barrier.is_open() {
// Check if the process is still running
let running = self.running.lock().await;
if running.is_some() {
return barrier
.clone()
.wait()
.await
.unwrap()
.map_err(CodeError::ServerDownloadError);
}
} else {
// Still starting up, wait for it
let mut barrier = barrier.clone();
drop(ready);
return barrier
.wait()
.await
.unwrap()
.map_err(CodeError::ServerDownloadError);
}
}
}
// Need to start a new server
self.start_server().await
}
/// Starts the server with the latest already-downloaded version.
/// Only blocks on a network fetch if no version has been downloaded yet.
async fn start_server(self: &Arc<Self>) -> Result<PathBuf, CodeError> {
let (release, server_dir) = self.get_cached_or_download().await?;
let (mut barrier, opener) = new_barrier::<Result<PathBuf, String>>();
{
let mut ready = self.ready.lock().await;
*ready = Some(barrier.clone());
}
let self_clone = self.clone();
let release_clone = release.clone();
tokio::spawn(async move {
self_clone
.run_server(release_clone, server_dir, opener)
.await;
});
barrier
.wait()
.await
.unwrap()
.map_err(CodeError::ServerDownloadError)
}
/// Runs the server process to completion, handling readiness signaling.
async fn run_server(
self: &Arc<Self>,
release: Release,
server_dir: PathBuf,
opener: BarrierOpener<Result<PathBuf, String>>,
) {
let executable = if let Some(p) = option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH") {
PathBuf::from(p)
} else {
server_dir
.join(SERVER_FOLDER_NAME)
.join("bin")
.join(release.quality.server_entrypoint())
};
let agent_host_socket = get_socket_name();
let mut cmd = new_script_command(&executable);
cmd.stdin(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.arg("--socket-path");
cmd.arg(get_socket_name());
cmd.arg("--agent-host-path");
cmd.arg(&agent_host_socket);
cmd.args([
"--start-server",
"--accept-server-license-terms",
"--enable-remote-auto-shutdown",
]);
if let Some(a) = &self.args.server_data_dir {
cmd.arg("--server-data-dir");
cmd.arg(a);
}
if self.args.without_connection_token {
cmd.arg("--without-connection-token");
}
if let Some(ct) = &self.args.connection_token_file {
cmd.arg("--connection-token-file");
cmd.arg(ct);
}
cmd.env_remove("VSCODE_DEV");
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
opener.open(Err(e.to_string()));
return;
}
};
let commit_prefix = &release.commit[..release.commit.len().min(7)];
let (mut stdout, mut stderr) = (
BufReader::new(child.stdout.take().unwrap()).lines(),
BufReader::new(child.stderr.take().unwrap()).lines(),
);
// Wait for readiness with a timeout
let mut opener = Some(opener);
let socket_path = agent_host_socket.clone();
let startup_deadline = tokio::time::sleep(STARTUP_TIMEOUT);
tokio::pin!(startup_deadline);
let mut ready = false;
loop {
tokio::select! {
Ok(Some(l)) = stdout.next_line() => {
debug!(self.log, "[{} stdout]: {}", commit_prefix, l);
if !ready && l.contains("Agent host server listening on") {
ready = true;
if let Some(o) = opener.take() {
o.open(Ok(socket_path.clone()));
}
}
}
Ok(Some(l)) = stderr.next_line() => {
debug!(self.log, "[{} stderr]: {}", commit_prefix, l);
}
_ = &mut startup_deadline, if !ready => {
warning!(self.log, "[{}]: Server did not become ready within {}s", commit_prefix, STARTUP_TIMEOUT.as_secs());
// Don't fail — the server may still start up, just slowly
if let Some(o) = opener.take() {
o.open(Ok(socket_path.clone()));
}
ready = true;
}
e = child.wait() => {
info!(self.log, "[{} process]: exited: {:?}", commit_prefix, e);
if let Some(o) = opener.take() {
o.open(Err(format!("Server exited before ready: {e:?}")));
}
break;
}
}
if ready {
break;
}
}
// Store the running server state
{
let mut running = self.running.lock().await;
*running = Some(RunningServer {
child,
commit: release.commit.clone(),
});
}
if !ready {
return;
}
info!(self.log, "[{}]: Server ready", commit_prefix);
// Continue reading output until the process exits
let log = self.log.clone();
let commit_prefix = commit_prefix.to_string();
let self_clone = self.clone();
tokio::spawn(async move {
loop {
tokio::select! {
Ok(Some(l)) = stdout.next_line() => {
debug!(log, "[{} stdout]: {}", commit_prefix, l);
}
Ok(Some(l)) = stderr.next_line() => {
debug!(log, "[{} stderr]: {}", commit_prefix, l);
}
else => break,
}
}
// Server process has exited (auto-shutdown or crash)
info!(log, "[{}]: Server process ended", commit_prefix);
let mut running = self_clone.running.lock().await;
if let Some(r) = &*running {
if r.commit == commit_prefix || r.commit.starts_with(&commit_prefix) {
// Only clear if it's still our server
}
}
*running = None;
});
}
/// Returns a release and its local directory. Prefers the latest known
/// release if it has already been downloaded; otherwise falls back to any
/// cached version. Only fetches from the network and downloads if
/// nothing is cached at all.
async fn get_cached_or_download(&self) -> Result<(Release, PathBuf), CodeError> {
// When using a dev override, skip the update service entirely -
// the override path is used directly by run_server().
if option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH").is_some() {
let release = Release {
name: String::new(),
commit: String::from("dev"),
platform: self.platform,
target: TargetKind::Server,
quality: Quality::Insiders,
};
return Ok((release, PathBuf::new()));
}
// Best case: the latest known release is already downloaded
if let Some((_, release)) = &*self.latest_release.lock().await {
let name = get_server_folder_name(release.quality, &release.commit);
if let Some(dir) = self.cache.exists(&name) {
return Ok((release.clone(), dir));
}
}
let quality = VSCODE_CLI_QUALITY
.ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality"))
.and_then(|q| {
Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality"))
})?;
// Fall back to any cached version (still instant, just not the newest).
// Cache entries are named "<quality>-<commit>" via get_server_folder_name.
for entry in self.cache.get() {
if let Some(dir) = self.cache.exists(&entry) {
let (entry_quality, commit) = match entry.split_once('-') {
Some((q, c)) => match Quality::try_from(q.to_lowercase().as_str()) {
Ok(parsed) => (parsed, c.to_string()),
Err(_) => (quality, entry.clone()),
},
None => (quality, entry.clone()),
};
let release = Release {
name: String::new(),
commit,
platform: self.platform,
target: TargetKind::Server,
quality: entry_quality,
};
return Ok((release, dir));
}
}
// Nothing cached — must fetch and download (blocks the first connection)
info!(self.log, "No cached server version, downloading latest...");
let release = self.get_latest_release().await?;
let dir = self.ensure_downloaded(&release).await?;
Ok((release, dir))
}
/// Ensures the release is downloaded, returning the server directory.
async fn ensure_downloaded(&self, release: &Release) -> Result<PathBuf, CodeError> {
let cache_name = get_server_folder_name(release.quality, &release.commit);
if let Some(dir) = self.cache.exists(&cache_name) {
return Ok(dir);
}
info!(self.log, "Downloading server {}", release.commit);
let release = release.clone();
let log = self.log.clone();
let update_service = self.update_service.clone();
self.cache
.create(&cache_name, |target_dir| async move {
let tmpdir = tempfile::tempdir().unwrap();
let response = update_service.get_download_stream(&release).await?;
let name = response.url_path_basename().unwrap();
let archive_path = tmpdir.path().join(name);
http::download_into_file(
&archive_path,
log.get_download_logger("Downloading server:"),
response,
)
.await?;
let server_dir = target_dir.join(SERVER_FOLDER_NAME);
unzip_downloaded_release(&archive_path, &server_dir, SilentCopyProgress())?;
Ok(())
})
.await
.map_err(|e| CodeError::ServerDownloadError(e.to_string()))
}
/// Gets the latest release, caching the result.
async fn get_latest_release(&self) -> Result<Release, CodeError> {
let mut latest = self.latest_release.lock().await;
let now = Instant::now();
let quality = VSCODE_CLI_QUALITY
.ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality"))
.and_then(|q| {
Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality"))
})?;
let result = self
.update_service
.get_latest_commit(self.platform, TargetKind::Server, quality)
.await
.map_err(|e| CodeError::UpdateCheckFailed(e.to_string()));
// If the update service is unavailable, fall back to the cached version
if let (Err(e), Some((_, previous))) = (&result, latest.clone()) {
warning!(self.log, "Error checking for updates, using cached: {}", e);
*latest = Some((now, previous.clone()));
return Ok(previous);
}
let release = result?;
debug!(self.log, "Resolved server version: {}", release);
*latest = Some((now, release.clone()));
Ok(release)
}
/// Background loop: checks for updates periodically and pre-downloads
/// new versions when the server is idle.
async fn run_update_loop(self: Arc<Self>) {
let mut interval = tokio::time::interval(UPDATE_CHECK_INTERVAL);
interval.tick().await; // skip the immediate first tick
loop {
interval.tick().await;
let new_release = match self.get_latest_release().await {
Ok(r) => r,
Err(e) => {
warning!(self.log, "Update check failed: {}", e);
continue;
}
};
// Check if we already have this version
let name = get_server_folder_name(new_release.quality, &new_release.commit);
if self.cache.exists(&name).is_some() {
continue;
}
info!(self.log, "New server version available: {}", new_release);
// Wait until the server is not running before downloading
loop {
{
let running = self.running.lock().await;
if running.is_none() {
break;
}
}
debug!(self.log, "Server still running, waiting before updating...");
tokio::time::sleep(UPDATE_POLL_INTERVAL).await;
}
// Download the new version
match self.ensure_downloaded(&new_release).await {
Ok(_) => info!(self.log, "Updated server to {}", new_release),
Err(e) => warning!(self.log, "Failed to download update: {}", e),
}
}
}
/// Kills the currently running server, if any.
async fn kill_running_server(&self) {
let mut running = self.running.lock().await;
if let Some(mut server) = running.take() {
let _ = server.child.kill().await;
}
}
}
// ---- HTTP/WebSocket proxy ---------------------------------------------------
/// Proxies an incoming HTTP/WebSocket request to the agent host's Unix socket.
async fn handle_request(
manager: Arc<AgentHostManager>,
req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
let socket_path = match manager.ensure_server().await {
Ok(p) => p,
Err(e) => {
error!(manager.log, "Error starting agent host: {:?}", e);
return Ok(Response::builder()
.status(503)
.body(Body::from(format!("Error starting agent host: {e:?}")))
.unwrap());
}
};
let is_upgrade = req.headers().contains_key(hyper::header::UPGRADE);
let rw = match get_socket_rw_stream(&socket_path).await {
Ok(rw) => rw,
Err(e) => {
error!(
manager.log,
"Error connecting to agent host socket: {:?}", e
);
return Ok(Response::builder()
.status(503)
.body(Body::from(format!("Error connecting to agent host: {e:?}")))
.unwrap());
}
};
if is_upgrade {
Ok(forward_ws_to_server(rw, req).await)
} else {
Ok(forward_http_to_server(rw, req).await)
}
}
/// Proxies a standard HTTP request through the socket.
async fn forward_http_to_server(rw: AsyncPipe, req: Request<Body>) -> Response<Body> {
let (mut request_sender, connection) =
match hyper::client::conn::Builder::new().handshake(rw).await {
Ok(r) => r,
Err(e) => return connection_err(e),
};
tokio::spawn(connection);
request_sender
.send_request(req)
.await
.unwrap_or_else(connection_err)
}
/// Proxies a WebSocket upgrade request through the socket.
async fn forward_ws_to_server(rw: AsyncPipe, mut req: Request<Body>) -> Response<Body> {
let (mut request_sender, connection) =
match hyper::client::conn::Builder::new().handshake(rw).await {
Ok(r) => r,
Err(e) => return connection_err(e),
};
tokio::spawn(connection);
let mut proxied_req = Request::builder().uri(req.uri());
for (k, v) in req.headers() {
proxied_req = proxied_req.header(k, v);
}
let mut res = request_sender
.send_request(proxied_req.body(Body::empty()).unwrap())
.await
.unwrap_or_else(connection_err);
let mut proxied_res = Response::new(Body::empty());
*proxied_res.status_mut() = res.status();
for (k, v) in res.headers() {
proxied_res.headers_mut().insert(k, v.clone());
}
if res.status() == hyper::StatusCode::SWITCHING_PROTOCOLS {
tokio::spawn(async move {
let (s_req, s_res) =
tokio::join!(hyper::upgrade::on(&mut req), hyper::upgrade::on(&mut res));
if let (Ok(mut s_req), Ok(mut s_res)) = (s_req, s_res) {
let _ = tokio::io::copy_bidirectional(&mut s_req, &mut s_res).await;
}
});
}
proxied_res
}
fn connection_err(err: hyper::Error) -> Response<Body> {
Response::builder()
.status(503)
.body(Body::from(format!(
"Error connecting to agent host: {err:?}"
)))
.unwrap()
}
fn mint_connection_token(path: &Path, prefer_token: Option<String>) -> std::io::Result<String> {
#[cfg(not(windows))]
use std::os::unix::fs::OpenOptionsExt;
+5 -3
View File
@@ -31,7 +31,8 @@ use crate::{
async_pipe::{get_socket_name, listen_socket_rw_stream, AsyncRWAccepter},
auth::Auth,
constants::{
APPLICATION_NAME, CONTROL_PORT, IS_A_TTY, TUNNEL_CLI_LOCK_NAME, TUNNEL_SERVICE_LOCK_NAME,
AGENT_HOST_PORT, APPLICATION_NAME, CONTROL_PORT, IS_A_TTY, TUNNEL_CLI_LOCK_NAME,
TUNNEL_SERVICE_LOCK_NAME,
},
log,
state::LauncherPaths,
@@ -330,7 +331,8 @@ pub async fn user(ctx: CommandContext, user_args: TunnelUserSubCommands) -> Resu
}
TunnelUserSubCommands::Show => {
if let Ok(Some(sc)) = auth.get_current_credential() {
ctx.log.result(format!("logged in with provider {}", sc.provider));
ctx.log
.result(format!("logged in with provider {}", sc.provider));
} else {
ctx.log.result("not logged in");
return Ok(1);
@@ -649,7 +651,7 @@ async fn serve_with_csa(
dt.start_existing_tunnel(t).await
} else {
tokio::select! {
t = dt.start_new_launcher_tunnel(gateway_args.name.as_deref(), gateway_args.random_name, &[CONTROL_PORT]) => t,
t = dt.start_new_launcher_tunnel(gateway_args.name.as_deref(), gateway_args.random_name, &[CONTROL_PORT, AGENT_HOST_PORT]) => t,
_ = shutdown.wait() => return Ok(1),
}
}?;
+3 -1
View File
@@ -12,6 +12,7 @@ use lazy_static::lazy_static;
use crate::options::Quality;
pub const CONTROL_PORT: u16 = 31545;
pub const AGENT_HOST_PORT: u16 = 31546;
/// Protocol version sent to clients. This can be used to indicate new or
/// changed capabilities that clients may wish to leverage.
@@ -20,7 +21,8 @@ pub const CONTROL_PORT: u16 = 31545;
/// are compressed bidirectionally.
/// 3 - The server's connection token is set to a SHA256 hash of the tunnel ID
/// 4 - The server's msgpack messages are no longer length-prefixed
pub const PROTOCOL_VERSION: u32 = 4;
/// 5 - The server now exposes an agent host connection
pub const PROTOCOL_VERSION: u32 = 5;
/// Prefix for the tunnel tag that includes the version.
pub const PROTOCOL_VERSION_TAG_PREFIX: &str = "protocolv";
+1
View File
@@ -13,6 +13,7 @@ pub mod shutdown_signal;
pub mod singleton_client;
pub mod singleton_server;
pub mod agent_host;
mod challenge;
mod control_server;
mod nosleep;
+574
View File
@@ -0,0 +1,574 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use hyper::{Body, Request, Response};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::Mutex;
use crate::async_pipe::{get_socket_name, get_socket_rw_stream, AsyncPipe};
use crate::constants::VSCODE_CLI_QUALITY;
use crate::download_cache::DownloadCache;
use crate::log;
use crate::options::Quality;
use crate::update_service::{
unzip_downloaded_release, Platform, Release, TargetKind, UpdateService,
};
use crate::util::command::new_script_command;
use crate::util::errors::CodeError;
use crate::util::http::{self, BoxedHttp};
use crate::util::io::SilentCopyProgress;
use crate::util::sync::{new_barrier, Barrier, BarrierOpener};
use super::paths::{get_server_folder_name, SERVER_FOLDER_NAME};
/// How often to check for server updates.
pub const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
/// How often to re-check whether the server has exited when an update is pending.
pub const UPDATE_POLL_INTERVAL: Duration = Duration::from_secs(10 * 60);
/// How long to wait for the server to signal readiness.
pub const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
/// Configuration for the agent host server process.
#[derive(Clone, Debug)]
pub struct AgentHostConfig {
pub server_data_dir: Option<String>,
pub without_connection_token: bool,
pub connection_token: Option<String>,
pub connection_token_file: Option<String>,
}
/// State of the running VS Code server process.
struct RunningServer {
child: tokio::process::Child,
commit: String,
}
/// Manages the VS Code server lifecycle: on-demand start, auto-restart
/// after idle shutdown, and background update checking.
pub struct AgentHostManager {
log: log::Logger,
config: AgentHostConfig,
platform: Platform,
cache: DownloadCache,
update_service: UpdateService,
/// The latest known release, with the time it was checked.
latest_release: Mutex<Option<(Instant, Release)>>,
/// The currently running server, if any.
running: Mutex<Option<RunningServer>>,
/// Barrier that opens when a server is ready (socket path available).
/// Reset each time a new server is started.
ready: Mutex<Option<Barrier<Result<PathBuf, String>>>>,
}
impl AgentHostManager {
pub fn new(
log: log::Logger,
platform: Platform,
cache: DownloadCache,
http: BoxedHttp,
config: AgentHostConfig,
) -> Arc<Self> {
Arc::new(Self {
update_service: UpdateService::new(log.clone(), http),
log,
config,
platform,
cache,
latest_release: Mutex::new(None),
running: Mutex::new(None),
ready: Mutex::new(None),
})
}
/// Returns the socket path to a running server, starting one if needed.
pub async fn ensure_server(self: &Arc<Self>) -> Result<PathBuf, CodeError> {
// Fast path: if we already have a barrier, wait on it
{
let ready = self.ready.lock().await;
if let Some(barrier) = &*ready {
if barrier.is_open() {
// Check if the process is still running
let running = self.running.lock().await;
if running.is_some() {
return barrier
.clone()
.wait()
.await
.unwrap()
.map_err(CodeError::ServerDownloadError);
}
} else {
// Still starting up, wait for it
let mut barrier = barrier.clone();
drop(ready);
return barrier
.wait()
.await
.unwrap()
.map_err(CodeError::ServerDownloadError);
}
}
}
// Need to start a new server
self.start_server().await
}
/// Starts the server with the latest already-downloaded version.
/// Only blocks on a network fetch if no version has been downloaded yet.
async fn start_server(self: &Arc<Self>) -> Result<PathBuf, CodeError> {
let (release, server_dir) = self.get_cached_or_download().await?;
let (mut barrier, opener) = new_barrier::<Result<PathBuf, String>>();
{
let mut ready = self.ready.lock().await;
*ready = Some(barrier.clone());
}
let self_clone = self.clone();
let release_clone = release.clone();
tokio::spawn(async move {
self_clone
.run_server(release_clone, server_dir, opener)
.await;
});
barrier
.wait()
.await
.unwrap()
.map_err(CodeError::ServerDownloadError)
}
/// Runs the server process to completion, handling readiness signaling.
async fn run_server(
self: &Arc<Self>,
release: Release,
server_dir: PathBuf,
opener: BarrierOpener<Result<PathBuf, String>>,
) {
let executable = if let Some(p) = option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH") {
PathBuf::from(p)
} else {
server_dir
.join(SERVER_FOLDER_NAME)
.join("bin")
.join(release.quality.server_entrypoint())
};
let agent_host_socket = get_socket_name();
let mut cmd = new_script_command(&executable);
cmd.stdin(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.arg("--socket-path");
cmd.arg(get_socket_name());
cmd.arg("--agent-host-path");
cmd.arg(&agent_host_socket);
cmd.args([
"--start-server",
"--accept-server-license-terms",
"--enable-remote-auto-shutdown",
]);
if let Some(a) = &self.config.server_data_dir {
cmd.arg("--server-data-dir");
cmd.arg(a);
}
if self.config.without_connection_token {
cmd.arg("--without-connection-token");
}
if let Some(ct) = &self.config.connection_token_file {
cmd.arg("--connection-token-file");
cmd.arg(ct);
}
cmd.env_remove("VSCODE_DEV");
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
opener.open(Err(e.to_string()));
return;
}
};
let commit_prefix = &release.commit[..release.commit.len().min(7)];
let (mut stdout, mut stderr) = (
BufReader::new(child.stdout.take().unwrap()).lines(),
BufReader::new(child.stderr.take().unwrap()).lines(),
);
// Wait for readiness with a timeout
let mut opener = Some(opener);
let socket_path = agent_host_socket.clone();
let startup_deadline = tokio::time::sleep(STARTUP_TIMEOUT);
tokio::pin!(startup_deadline);
let mut ready = false;
loop {
tokio::select! {
Ok(Some(l)) = stdout.next_line() => {
debug!(self.log, "[{} stdout]: {}", commit_prefix, l);
if !ready && l.contains("Agent host server listening on") {
ready = true;
if let Some(o) = opener.take() {
o.open(Ok(socket_path.clone()));
}
}
}
Ok(Some(l)) = stderr.next_line() => {
debug!(self.log, "[{} stderr]: {}", commit_prefix, l);
}
_ = &mut startup_deadline, if !ready => {
warning!(self.log, "[{}]: Server did not become ready within {}s", commit_prefix, STARTUP_TIMEOUT.as_secs());
// Don't fail — the server may still start up, just slowly
if let Some(o) = opener.take() {
o.open(Ok(socket_path.clone()));
}
ready = true;
}
e = child.wait() => {
info!(self.log, "[{} process]: exited: {:?}", commit_prefix, e);
if let Some(o) = opener.take() {
o.open(Err(format!("Server exited before ready: {e:?}")));
}
break;
}
}
if ready {
break;
}
}
// Store the running server state
{
let mut running = self.running.lock().await;
*running = Some(RunningServer {
child,
commit: release.commit.clone(),
});
}
if !ready {
return;
}
info!(self.log, "[{}]: Server ready", commit_prefix);
// Continue reading output until the process exits
let log = self.log.clone();
let commit_prefix = commit_prefix.to_string();
let self_clone = self.clone();
tokio::spawn(async move {
loop {
tokio::select! {
Ok(Some(l)) = stdout.next_line() => {
debug!(log, "[{} stdout]: {}", commit_prefix, l);
}
Ok(Some(l)) = stderr.next_line() => {
debug!(log, "[{} stderr]: {}", commit_prefix, l);
}
else => break,
}
}
// Server process has exited (auto-shutdown or crash)
info!(log, "[{}]: Server process ended", commit_prefix);
let mut running = self_clone.running.lock().await;
if let Some(r) = &*running {
if r.commit == commit_prefix || r.commit.starts_with(&commit_prefix) {
*running = None;
}
}
});
}
/// Returns a release and its local directory. Prefers the latest known
/// release if it has already been downloaded; otherwise falls back to any
/// cached version. Only fetches from the network and downloads if
/// nothing is cached at all.
async fn get_cached_or_download(&self) -> Result<(Release, PathBuf), CodeError> {
// When using a dev override, skip the update service entirely -
// the override path is used directly by run_server().
if option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH").is_some() {
let release = Release {
name: String::new(),
commit: String::from("dev"),
platform: self.platform,
target: TargetKind::Server,
quality: Quality::Insiders,
};
return Ok((release, PathBuf::new()));
}
// Best case: the latest known release is already downloaded
if let Some((_, release)) = &*self.latest_release.lock().await {
let name = get_server_folder_name(release.quality, &release.commit);
if let Some(dir) = self.cache.exists(&name) {
return Ok((release.clone(), dir));
}
}
let quality = VSCODE_CLI_QUALITY
.ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality"))
.and_then(|q| {
Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality"))
})?;
// Fall back to any cached version (still instant, just not the newest).
// Cache entries are named "<quality>-<commit>" via get_server_folder_name.
for entry in self.cache.get() {
if let Some(dir) = self.cache.exists(&entry) {
let (entry_quality, commit) = match entry.split_once('-') {
Some((q, c)) => match Quality::try_from(q.to_lowercase().as_str()) {
Ok(parsed) => (parsed, c.to_string()),
Err(_) => (quality, entry.clone()),
},
None => (quality, entry.clone()),
};
let release = Release {
name: String::new(),
commit,
platform: self.platform,
target: TargetKind::Server,
quality: entry_quality,
};
return Ok((release, dir));
}
}
// Nothing cached — must fetch and download (blocks the first connection)
info!(self.log, "No cached server version, downloading latest...");
let release = self.get_latest_release().await?;
let dir = self.ensure_downloaded(&release).await?;
Ok((release, dir))
}
/// Ensures the release is downloaded, returning the server directory.
pub async fn ensure_downloaded(&self, release: &Release) -> Result<PathBuf, CodeError> {
let cache_name = get_server_folder_name(release.quality, &release.commit);
if let Some(dir) = self.cache.exists(&cache_name) {
return Ok(dir);
}
info!(self.log, "Downloading server {}", release.commit);
let release = release.clone();
let log = self.log.clone();
let update_service = self.update_service.clone();
self.cache
.create(&cache_name, |target_dir| async move {
let tmpdir = tempfile::tempdir().unwrap();
let response = update_service.get_download_stream(&release).await?;
let name = response.url_path_basename().unwrap();
let archive_path = tmpdir.path().join(name);
http::download_into_file(
&archive_path,
log.get_download_logger("Downloading server:"),
response,
)
.await?;
let server_dir = target_dir.join(SERVER_FOLDER_NAME);
unzip_downloaded_release(&archive_path, &server_dir, SilentCopyProgress())?;
Ok(())
})
.await
.map_err(|e| CodeError::ServerDownloadError(e.to_string()))
}
/// Gets the latest release, caching the result.
pub async fn get_latest_release(&self) -> Result<Release, CodeError> {
let mut latest = self.latest_release.lock().await;
let now = Instant::now();
let quality = VSCODE_CLI_QUALITY
.ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality"))
.and_then(|q| {
Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality"))
})?;
let result = self
.update_service
.get_latest_commit(self.platform, TargetKind::Server, quality)
.await
.map_err(|e| CodeError::UpdateCheckFailed(e.to_string()));
// If the update service is unavailable, fall back to the cached version
if let (Err(e), Some((_, previous))) = (&result, latest.clone()) {
warning!(self.log, "Error checking for updates, using cached: {}", e);
*latest = Some((now, previous.clone()));
return Ok(previous);
}
let release = result?;
debug!(self.log, "Resolved server version: {}", release);
*latest = Some((now, release.clone()));
Ok(release)
}
/// Background loop: checks for updates periodically and pre-downloads
/// new versions when the server is idle.
pub async fn run_update_loop(self: Arc<Self>) {
let mut interval = tokio::time::interval(UPDATE_CHECK_INTERVAL);
interval.tick().await; // skip the immediate first tick
loop {
interval.tick().await;
let new_release = match self.get_latest_release().await {
Ok(r) => r,
Err(e) => {
warning!(self.log, "Update check failed: {}", e);
continue;
}
};
// Check if we already have this version
let name = get_server_folder_name(new_release.quality, &new_release.commit);
if self.cache.exists(&name).is_some() {
continue;
}
info!(self.log, "New server version available: {}", new_release);
// Wait until the server is not running before downloading
loop {
{
let running = self.running.lock().await;
if running.is_none() {
break;
}
}
debug!(self.log, "Server still running, waiting before updating...");
tokio::time::sleep(UPDATE_POLL_INTERVAL).await;
}
// Download the new version
match self.ensure_downloaded(&new_release).await {
Ok(_) => info!(self.log, "Updated server to {}", new_release),
Err(e) => warning!(self.log, "Failed to download update: {}", e),
}
}
}
/// Kills the currently running server, if any.
pub async fn kill_running_server(&self) {
let mut running = self.running.lock().await;
if let Some(mut server) = running.take() {
let _ = server.child.kill().await;
}
}
}
// ---- HTTP/WebSocket proxy ---------------------------------------------------
/// Proxies an incoming HTTP/WebSocket request to the agent host's Unix socket.
pub async fn handle_request(
manager: Arc<AgentHostManager>,
req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
let socket_path = match manager.ensure_server().await {
Ok(p) => p,
Err(e) => {
error!(manager.log, "Error starting agent host: {:?}", e);
return Ok(Response::builder()
.status(503)
.body(Body::from(format!("Error starting agent host: {e:?}")))
.unwrap());
}
};
let is_upgrade = req.headers().contains_key(hyper::header::UPGRADE);
let rw = match get_socket_rw_stream(&socket_path).await {
Ok(rw) => rw,
Err(e) => {
error!(
manager.log,
"Error connecting to agent host socket: {:?}", e
);
return Ok(Response::builder()
.status(503)
.body(Body::from(format!("Error connecting to agent host: {e:?}")))
.unwrap());
}
};
if is_upgrade {
Ok(forward_ws_to_server(rw, req).await)
} else {
Ok(forward_http_to_server(rw, req).await)
}
}
/// Proxies a standard HTTP request through the socket.
async fn forward_http_to_server(rw: AsyncPipe, req: Request<Body>) -> Response<Body> {
let (mut request_sender, connection) =
match hyper::client::conn::Builder::new().handshake(rw).await {
Ok(r) => r,
Err(e) => return connection_err(e),
};
tokio::spawn(connection);
request_sender
.send_request(req)
.await
.unwrap_or_else(connection_err)
}
/// Proxies a WebSocket upgrade request through the socket.
async fn forward_ws_to_server(rw: AsyncPipe, mut req: Request<Body>) -> Response<Body> {
let (mut request_sender, connection) =
match hyper::client::conn::Builder::new().handshake(rw).await {
Ok(r) => r,
Err(e) => return connection_err(e),
};
tokio::spawn(connection);
let mut proxied_req = Request::builder().uri(req.uri());
for (k, v) in req.headers() {
proxied_req = proxied_req.header(k, v);
}
let mut res = request_sender
.send_request(proxied_req.body(Body::empty()).unwrap())
.await
.unwrap_or_else(connection_err);
let mut proxied_res = Response::new(Body::empty());
*proxied_res.status_mut() = res.status();
for (k, v) in res.headers() {
proxied_res.headers_mut().insert(k, v.clone());
}
if res.status() == hyper::StatusCode::SWITCHING_PROTOCOLS {
tokio::spawn(async move {
let (s_req, s_res) =
tokio::join!(hyper::upgrade::on(&mut req), hyper::upgrade::on(&mut res));
if let (Ok(mut s_req), Ok(mut s_res)) = (s_req, s_res) {
let _ = tokio::io::copy_bidirectional(&mut s_req, &mut s_res).await;
}
});
}
proxied_res
}
fn connection_err(err: hyper::Error) -> Response<Body> {
Response::builder()
.status(503)
.body(Body::from(format!(
"Error connecting to agent host: {err:?}"
)))
.unwrap()
}
+61 -1
View File
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
use crate::async_pipe::get_socket_rw_stream;
use crate::constants::{CONTROL_PORT, PRODUCT_NAME_LONG};
use crate::constants::{AGENT_HOST_PORT, CONTROL_PORT, PRODUCT_NAME_LONG};
use crate::log;
use crate::msgpack_rpc::{new_msgpack_rpc, start_msgpack_rpc, MsgPackCodec, MsgPackSerializer};
use crate::options::Quality;
@@ -44,6 +44,9 @@ use std::time::Instant;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, DuplexStream};
use tokio::sync::{mpsc, Mutex};
use super::agent_host::{
handle_request as handle_agent_host_request, AgentHostConfig, AgentHostManager,
};
use super::challenge::{create_challenge, sign_challenge, verify_challenge};
use super::code_server::{
download_cli_into_cache, AnyCodeServer, CodeServerArgs, ServerBuilder, ServerParamsRaw,
@@ -182,10 +185,46 @@ pub async fn serve(
mut shutdown_rx: Barrier<ShutdownSignal>,
) -> Result<ServerTermination, AnyError> {
let mut port = tunnel.add_port_direct(CONTROL_PORT).await?;
let mut agent_host_port = tunnel.add_port_direct(AGENT_HOST_PORT).await?;
let mut forwarding = PortForwardingProcessor::new();
let (tx, mut rx) = mpsc::channel::<ServerSignal>(4);
let (exit_barrier, signal_exit) = new_barrier();
// Set up the agent host manager for on-demand server start on AGENT_HOST_PORT
let agent_host_manager = AgentHostManager::new(
log.clone(),
platform,
launcher_paths.server_cache.clone(),
Arc::new(ReqwestSimpleHttp::new()),
AgentHostConfig {
server_data_dir: code_server_args.server_data_dir.clone(),
without_connection_token: true,
connection_token: None,
connection_token_file: None,
},
);
// Eagerly resolve the latest version and start background updates
if option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH").is_none() {
let mgr = agent_host_manager.clone();
let log_for_init = log.clone();
tokio::spawn(async move {
match mgr.get_latest_release().await {
Ok(release) => {
if let Err(e) = mgr.ensure_downloaded(&release).await {
warning!(log_for_init, "Error downloading latest server: {}", e);
}
}
Err(e) => warning!(log_for_init, "Error resolving latest version: {}", e),
}
});
let mgr = agent_host_manager.clone();
tokio::spawn(async move {
mgr.run_update_loop().await;
});
}
if !code_server_args.install_extensions.is_empty() {
info!(
log,
@@ -210,6 +249,7 @@ pub async fn serve(
tokio::select! {
Ok(reason) = shutdown_rx.wait() => {
info!(log, "Shutting down: {}", reason);
agent_host_manager.kill_running_server().await;
drop(signal_exit);
return Ok(ServerTermination {
next: match reason {
@@ -221,6 +261,7 @@ pub async fn serve(
},
c = rx.recv() => {
if let Some(ServerSignal::Respawn) = c {
agent_host_manager.kill_running_server().await;
drop(signal_exit);
return Ok(ServerTermination {
next: Next::Respawn,
@@ -231,6 +272,25 @@ pub async fn serve(
Some(w) = forwarding.recv() => {
forwarding.process(w, &mut tunnel).await;
},
Some(socket) = agent_host_port.recv() => {
let mgr = agent_host_manager.clone();
let ah_log = log.clone();
tokio::spawn(async move {
debug!(ah_log, "Serving new agent host connection");
let rw = socket.into_rw();
let svc = hyper::service::service_fn(move |req| {
let mgr = mgr.clone();
async move { handle_agent_host_request(mgr, req).await }
});
if let Err(e) = hyper::server::conn::Http::new()
.serve_connection(rw, svc)
.with_upgrades()
.await
{
debug!(ah_log, "Agent host connection ended: {:?}", e);
}
});
},
l = port.recv() => {
let socket = match l {
Some(p) => p,
+3 -3
View File
@@ -8,7 +8,7 @@ use std::collections::HashSet;
use tokio::sync::{mpsc, oneshot};
use crate::{
constants::CONTROL_PORT,
constants::{AGENT_HOST_PORT, CONTROL_PORT},
util::errors::{AnyError, CannotForwardControlPort, ServerHasClosed},
};
@@ -72,7 +72,7 @@ impl PortForwardingProcessor {
port: u16,
tunnel: &mut ActiveTunnel,
) -> Result<(), AnyError> {
if port == CONTROL_PORT {
if port == CONTROL_PORT || port == AGENT_HOST_PORT {
return Err(CannotForwardControlPort().into());
}
@@ -87,7 +87,7 @@ impl PortForwardingProcessor {
privacy: PortPrivacy,
tunnel: &mut ActiveTunnel,
) -> Result<String, AnyError> {
if port == CONTROL_PORT {
if port == CONTROL_PORT || port == AGENT_HOST_PORT {
return Err(CannotForwardControlPort().into());
}
+2
View File
@@ -1499,6 +1499,8 @@ export default tseslint.config(
'when': 'hasNode',
'allow': [
'@github/copilot-sdk',
'@microsoft/dev-tunnels-contracts',
'@microsoft/dev-tunnels-management',
'@parcel/watcher',
'@vscode/sqlite3',
'@vscode/vscode-languagedetection',
+314 -42
View File
@@ -15,6 +15,11 @@
"@github/copilot-sdk": "^0.2.0",
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@microsoft/dev-tunnels-connections": "^1.3.41",
"@microsoft/dev-tunnels-contracts": "^1.3.41",
"@microsoft/dev-tunnels-management": "^1.3.41",
"@microsoft/dev-tunnels-ssh": "^3.12.22",
"@microsoft/dev-tunnels-ssh-tcp": "^3.12.22",
"@parcel/watcher": "^2.5.6",
"@types/semver": "^7.5.8",
"@vscode/codicons": "^0.0.46-1",
@@ -1611,6 +1616,118 @@
"resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-shims/-/applicationinsights-shims-2.0.2.tgz",
"integrity": "sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg=="
},
"node_modules/@microsoft/dev-tunnels-connections": {
"version": "1.3.41",
"resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.41.tgz",
"integrity": "sha512-6TcFQ0BE+lFYRFHJcAEkxyiQ7Y4rXH6jjGGYSjPNEkyiyC6r503m5gukHZGHJE9GOpbH3eVrSZYJ+7/gNULvzA==",
"license": "MIT",
"dependencies": {
"@microsoft/dev-tunnels-contracts": "1.3.41",
"@microsoft/dev-tunnels-management": "1.3.41",
"await-semaphore": "^0.1.3",
"buffer": "^5.2.1",
"debug": "^4.1.1",
"es5-ext": "0.10.64",
"uuid": "^3.3.3",
"vscode-jsonrpc": "^4.0.0",
"websocket": "^1.0.28"
},
"peerDependencies": {
"@microsoft/dev-tunnels-ssh": "^3.12.22",
"@microsoft/dev-tunnels-ssh-tcp": "^3.12.22"
}
},
"node_modules/@microsoft/dev-tunnels-connections/node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"license": "MIT",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/@microsoft/dev-tunnels-connections/node_modules/vscode-jsonrpc": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz",
"integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==",
"license": "MIT",
"engines": {
"node": ">=8.0.0 || >=10.0.0"
}
},
"node_modules/@microsoft/dev-tunnels-contracts": {
"version": "1.3.41",
"resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.41.tgz",
"integrity": "sha512-TpaIbXVLMS2kX6XmtLpGisy6om4lzI3c6uRsJDV2PrCeCy/unk2H4+cC9Yb2y50iQRTpAiviYoEiT594BOyyYA==",
"license": "MIT",
"dependencies": {
"buffer": "^5.2.1",
"debug": "^4.1.1",
"vscode-jsonrpc": "^4.0.0"
}
},
"node_modules/@microsoft/dev-tunnels-contracts/node_modules/vscode-jsonrpc": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz",
"integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==",
"license": "MIT",
"engines": {
"node": ">=8.0.0 || >=10.0.0"
}
},
"node_modules/@microsoft/dev-tunnels-management": {
"version": "1.3.41",
"resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.41.tgz",
"integrity": "sha512-Xaj9l4ccUOLVcO4MBAC0L1bz8dHKXMqZ471jgKKQvX0n3cTvLEGVab3rS5qYMraNxUTSUTeDiGCh3ZsKZ23RsQ==",
"license": "MIT",
"dependencies": {
"@microsoft/dev-tunnels-contracts": "1.3.41",
"axios": "^1.8.4",
"buffer": "^5.2.1",
"debug": "^4.1.1",
"vscode-jsonrpc": "^4.0.0"
}
},
"node_modules/@microsoft/dev-tunnels-management/node_modules/vscode-jsonrpc": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz",
"integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==",
"license": "MIT",
"engines": {
"node": ">=8.0.0 || >=10.0.0"
}
},
"node_modules/@microsoft/dev-tunnels-ssh": {
"version": "3.12.22",
"resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh/-/dev-tunnels-ssh-3.12.22.tgz",
"integrity": "sha512-BiYjRiBAbvo306zeZmRaLgY5f3JuN8x0F108ZcssVwL11CUP5zvYwjyOUysMOSH42qfQG/zCG+1b56muLGW+5A==",
"license": "MIT",
"dependencies": {
"buffer": "^5.2.1",
"debug": "^4.1.1",
"diffie-hellman": "^5.0.3",
"vscode-jsonrpc": "^4.0.0"
}
},
"node_modules/@microsoft/dev-tunnels-ssh-tcp": {
"version": "3.12.22",
"resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh-tcp/-/dev-tunnels-ssh-tcp-3.12.22.tgz",
"integrity": "sha512-oZs7CLRv5V6Lo0+oFGsW2EwKFAzAdQ+sa+06yc9O4CyV0HAywP/E6o52vhjVJD4RpsZzCTsUf0ApoYYr05hDpw==",
"license": "MIT",
"dependencies": {
"@microsoft/dev-tunnels-ssh": "~3.12"
}
},
"node_modules/@microsoft/dev-tunnels-ssh/node_modules/vscode-jsonrpc": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz",
"integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==",
"license": "MIT",
"engines": {
"node": ">=8.0.0 || >=10.0.0"
}
},
"node_modules/@microsoft/dynamicproto-js": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.9.tgz",
@@ -5231,8 +5348,7 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k= sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k= sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/atob": {
"version": "2.1.2",
@@ -5258,6 +5374,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/await-semaphore": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/await-semaphore/-/await-semaphore-0.1.3.tgz",
"integrity": "sha512-d1W2aNSYcz/sxYO4pMGX9vq65qOTu0P800epMud+6cYYX0QcT7zyqcxec3VWzpgvdXo57UWmVbZpLMjX2m1I7Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/b4a": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz",
@@ -5504,6 +5646,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -5565,6 +5713,12 @@
"node": ">=8"
}
},
"node_modules/brorand": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
"integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
"license": "MIT"
},
"node_modules/browser-stdout": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
@@ -5660,6 +5814,19 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true
},
"node_modules/bufferutil": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz",
"integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -5792,7 +5959,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -6329,7 +6495,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -6341,7 +6506,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk= sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -6917,7 +7081,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
"integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
"dev": true,
"dependencies": {
"es5-ext": "^0.10.50",
"type": "^1.0.1"
@@ -7304,6 +7467,17 @@
"node": ">=0.3.1"
}
},
"node_modules/diffie-hellman": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
"integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.1.0",
"miller-rabin": "^4.0.0",
"randombytes": "^2.0.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -7364,7 +7538,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -7692,7 +7865,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -7702,7 +7874,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@@ -7717,7 +7888,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -7730,7 +7900,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -7743,11 +7912,11 @@
}
},
"node_modules/es5-ext": {
"version": "0.10.63",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.63.tgz",
"integrity": "sha512-hUCZd2Byj/mNKjfP9jXrdVZ62B8KuA/VoK7X8nUh5qT+AxDmcbvZz041oDVZdbIN1qW6XY9VDNwzkvKnZvK2TQ==",
"dev": true,
"version": "0.10.64",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.3",
@@ -7769,7 +7938,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c= sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
"dev": true,
"dependencies": {
"d": "1",
"es5-ext": "^0.10.35",
@@ -7780,7 +7948,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
"integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
"dev": true,
"dependencies": {
"d": "^1.0.1",
"ext": "^1.1.2"
@@ -8021,7 +8188,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
"dev": true,
"dependencies": {
"d": "^1.0.1",
"es5-ext": "^0.10.62",
@@ -8035,8 +8201,7 @@
"node_modules/esniff/node_modules/type": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz",
"integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==",
"dev": true
"integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw=="
},
"node_modules/espree": {
"version": "10.4.0",
@@ -8139,7 +8304,6 @@
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
"dev": true,
"dependencies": {
"d": "1",
"es5-ext": "~0.10.14"
@@ -8491,7 +8655,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz",
"integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==",
"dev": true,
"dependencies": {
"type": "^2.0.0"
}
@@ -8499,8 +8662,7 @@
"node_modules/ext/node_modules/type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type/-/type-2.1.0.tgz",
"integrity": "sha512-G9absDWvhAWCV2gmF1zKud3OyC61nZDwWvBL2DApaVFogI07CprggiQAOOjvp2NRjYWFzPyu7vwtDrQFq8jeSA==",
"dev": true
"integrity": "sha512-G9absDWvhAWCV2gmF1zKud3OyC61nZDwWvBL2DApaVFogI07CprggiQAOOjvp2NRjYWFzPyu7vwtDrQFq8jeSA=="
},
"node_modules/extend": {
"version": "3.0.2",
@@ -9100,6 +9262,26 @@
"util-deprecate": "~1.0.1"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -9147,10 +9329,9 @@
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -9291,7 +9472,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -9353,7 +9533,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -9391,7 +9570,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -9970,7 +10148,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -11357,7 +11534,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -11370,7 +11546,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -11449,7 +11624,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -12262,6 +12436,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"license": "MIT"
},
"node_modules/is-unc-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz",
@@ -13499,7 +13679,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -13611,6 +13790,19 @@
"node": ">=8.6"
}
},
"node_modules/miller-rabin": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
"integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"brorand": "^1.0.1"
},
"bin": {
"miller-rabin": "bin/miller-rabin"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@@ -13627,7 +13819,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -13637,7 +13828,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@@ -14135,8 +14325,7 @@
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"dev": true
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
},
"node_modules/nise": {
"version": "5.1.0",
@@ -14206,6 +14395,17 @@
}
}
},
"node_modules/node-gyp-build": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz",
"integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-html-markdown": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/node-html-markdown/-/node-html-markdown-1.3.0.tgz",
@@ -15895,6 +16095,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -18720,8 +18929,7 @@
"node_modules/type": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==",
"dev": true
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg=="
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -18805,6 +19013,15 @@
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"dev": true
},
"node_modules/typedarray-to-buffer": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
"integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
"license": "MIT",
"dependencies": {
"is-typedarray": "^1.0.0"
}
},
"node_modules/typescript": {
"version": "6.0.0-dev.20260306",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.0-dev.20260306.tgz",
@@ -19129,6 +19346,19 @@
"node": ">= 0.8.0"
}
},
"node_modules/utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
"integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-gyp-build": "^4.3.0"
},
"engines": {
"node": ">=6.14.2"
}
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
@@ -19573,6 +19803,38 @@
"integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"dev": true
},
"node_modules/websocket": {
"version": "1.0.35",
"resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz",
"integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==",
"license": "Apache-2.0",
"dependencies": {
"bufferutil": "^4.0.1",
"debug": "^2.2.0",
"es5-ext": "^0.10.63",
"typedarray-to-buffer": "^3.1.5",
"utf-8-validate": "^5.0.2",
"yaeti": "^0.0.6"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/websocket/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/websocket/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@@ -19844,6 +20106,16 @@
"node": ">=10"
}
},
"node_modules/yaeti": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz",
"integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"engines": {
"node": ">=0.10.32"
}
},
"node_modules/yallist": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+5
View File
@@ -92,6 +92,11 @@
"@github/copilot-sdk": "^0.2.0",
"@microsoft/1ds-core-js": "^3.2.13",
"@microsoft/1ds-post-js": "^3.2.13",
"@microsoft/dev-tunnels-connections": "^1.3.41",
"@microsoft/dev-tunnels-contracts": "^1.3.41",
"@microsoft/dev-tunnels-management": "^1.3.41",
"@microsoft/dev-tunnels-ssh": "^3.12.22",
"@microsoft/dev-tunnels-ssh-tcp": "^3.12.22",
"@parcel/watcher": "^2.5.6",
"@types/semver": "^7.5.8",
"@vscode/codicons": "^0.0.46-1",
@@ -89,6 +89,8 @@ import { IExtensionsScannerService } from '../../../platform/extensionManagement
import { ExtensionsScannerService } from '../../../platform/extensionManagement/node/extensionsScannerService.js';
import { ISSHRemoteAgentHostMainService, SSH_REMOTE_AGENT_HOST_CHANNEL } from '../../../platform/agentHost/common/sshRemoteAgentHost.js';
import { SSHRemoteAgentHostMainService } from '../../../platform/agentHost/node/sshRemoteAgentHostService.js';
import { ITunnelAgentHostMainService, TUNNEL_AGENT_HOST_CHANNEL } from '../../../platform/agentHost/common/tunnelAgentHost.js';
import { TunnelAgentHostMainService } from '../../../platform/agentHost/node/tunnelAgentHostService.js';
import { IUserDataProfilesService } from '../../../platform/userDataProfile/common/userDataProfile.js';
import { IExtensionsProfileScannerService } from '../../../platform/extensionManagement/common/extensionsProfileScannerService.js';
import { PolicyChannelClient } from '../../../platform/policy/common/policyIpc.js';
@@ -412,6 +414,9 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
// SSH Remote Agent Host
services.set(ISSHRemoteAgentHostMainService, new SyncDescriptor(SSHRemoteAgentHostMainService, undefined, true));
// Tunnel Agent Host
services.set(ITunnelAgentHostMainService, new SyncDescriptor(TunnelAgentHostMainService, undefined, true));
return new InstantiationService(services);
}
@@ -490,6 +495,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
// SSH Remote Agent Host
const sshRemoteAgentHostChannel = ProxyChannel.fromService(accessor.get(ISSHRemoteAgentHostMainService), this._store);
this.server.registerChannel(SSH_REMOTE_AGENT_HOST_CHANNEL, sshRemoteAgentHostChannel);
// Tunnel Agent Host
const tunnelAgentHostChannel = ProxyChannel.fromService(accessor.get(ITunnelAgentHostMainService), this._store);
this.server.registerChannel(TUNNEL_AGENT_HOST_CHANNEL, tunnelAgentHostChannel);
}
private registerErrorHandler(logService: ILogService): void {
@@ -8,6 +8,7 @@ import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oa
import { URI } from '../../../base/common/uri.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import type { ISyncedCustomization } from './agentPluginManager.js';
import { IProtectedResourceMetadata } from './state/protocol/state.js';
import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js';
import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js';
import { AttachmentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult, type PolicyState, type StringOrMarkdown } from './state/sessionState.js';
@@ -39,6 +40,8 @@ export interface IAgentSessionMetadata {
readonly modifiedTime: number;
readonly summary?: string;
readonly workingDirectory?: URI;
readonly isRead?: boolean;
readonly isDone?: boolean;
}
export type AgentProvider = string;
@@ -48,32 +51,10 @@ export interface IAgentDescriptor {
readonly provider: AgentProvider;
readonly displayName: string;
readonly description: string;
/**
* Whether the renderer should push a GitHub auth token for this agent.
* @deprecated Use {@link IResourceMetadata.resources} from {@link IAgentService.getResourceMetadata} instead.
*/
readonly requiresAuth: boolean;
}
// ---- Auth types (RFC 9728 / RFC 6750 inspired) -----------------------------
/**
* Describes the agent host as an OAuth 2.0 protected resource.
* Uses {@link IAuthorizationProtectedResourceMetadata} from RFC 9728
* to describe auth requirements, enabling clients to resolve tokens
* using the standard VS Code authentication service.
*
* Returned from the server via {@link IAgentService.getResourceMetadata}.
*/
export interface IResourceMetadata {
/**
* Protected resources the agent host requires authentication for.
* Each entry uses the standard RFC 9728 shape so clients can resolve
* tokens via {@link IAuthenticationService.getOrActivateProviderIdForServer}.
*/
readonly resources: readonly IAuthorizationProtectedResourceMetadata[];
}
/**
* Parameters for the `authenticate` command.
* Analogous to sending `Authorization: Bearer <token>` (RFC 6750 section 2.1).
@@ -359,7 +340,7 @@ export interface IAgent {
listSessions(): Promise<IAgentSessionMetadata[]>;
/** Declare protected resources this agent requires auth for (RFC 9728). */
getProtectedResources(): IAuthorizationProtectedResourceMetadata[];
getProtectedResources(): IProtectedResourceMetadata[];
/**
* Authenticate for a specific resource. Returns true if accepted.
@@ -421,28 +402,14 @@ export const IAgentService = createDecorator<IAgentService>('agentService');
export interface IAgentService {
readonly _serviceBrand: undefined;
/** Discover available agent backends from the agent host. */
listAgents(): Promise<IAgentDescriptor[]>;
/**
* Retrieve the resource metadata describing auth requirements.
* Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata).
*/
getResourceMetadata(): Promise<IResourceMetadata>;
/**
* Authenticate for a protected resource on the server.
* The {@link IAuthenticateParams.resource} must match a resource from
* {@link getResourceMetadata}. Analogous to RFC 6750 bearer token delivery.
* the agent's protectedResources in root state. Analogous to RFC 6750
* bearer token delivery.
*/
authenticate(params: IAuthenticateParams): Promise<IAuthenticateResult>;
/**
* Refresh the model list from all providers, publishing updated
* agents (with models) to root state via `root/agentsChanged`.
*/
refreshModels(): Promise<void>;
/** List all available sessions from the Copilot CLI. */
listSessions(): Promise<IAgentSessionMetadata[]>;
@@ -7,6 +7,7 @@ import { Event } from '../../../base/common/event.js';
import { connectionTokenQueryName } from '../../../base/common/network.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import type { IAgentConnection } from './agentService.js';
import { TUNNEL_ADDRESS_PREFIX } from './tunnelAgentHost.js';
/** Connection status for a remote agent host. */
export const enum RemoteAgentHostConnectionStatus {
@@ -21,15 +22,53 @@ export const RemoteAgentHostsSettingId = 'chat.remoteAgentHosts';
/** Configuration key to enable remote agent host connections. */
export const RemoteAgentHostsEnabledSettingId = 'chat.remoteAgentHostsEnabled';
/** An entry in the {@link RemoteAgentHostsSettingId} setting. */
export interface IRemoteAgentHostEntry {
export const enum RemoteAgentHostEntryType {
WebSocket = 'websocket',
SSH = 'ssh',
Tunnel = 'tunnel',
}
export interface IRemoteAgentHostWebSocketConnection {
readonly type: RemoteAgentHostEntryType.WebSocket;
readonly address: string;
}
export interface IRemoteAgentHostSSHConnection {
readonly type: RemoteAgentHostEntryType.SSH;
readonly address: string;
readonly name: string;
readonly connectionToken?: string;
/** SSH config host alias — if set, the tunnel is re-established on startup. */
readonly sshConfigHost?: string;
}
export interface IRemoteAgentHostTunnelConnection {
readonly type: RemoteAgentHostEntryType.Tunnel;
/** Dev tunnel ID. */
readonly tunnelId: string;
/** Dev tunnel cluster region. */
readonly clusterId: string;
/** Auth provider used to connect to this tunnel. */
readonly authProvider?: 'github' | 'microsoft';
}
export type RemoteAgentHostConnection = IRemoteAgentHostWebSocketConnection | IRemoteAgentHostSSHConnection | IRemoteAgentHostTunnelConnection;
/** An entry in the {@link RemoteAgentHostsSettingId} setting. */
export interface IRemoteAgentHostEntry {
readonly name: string;
readonly connectionToken?: string;
readonly connection: RemoteAgentHostConnection;
}
export function getEntryAddress(entry: IRemoteAgentHostEntry): string {
switch (entry.connection.type) {
case RemoteAgentHostEntryType.WebSocket:
case RemoteAgentHostEntryType.SSH:
return entry.connection.address;
case RemoteAgentHostEntryType.Tunnel:
return `${TUNNEL_ADDRESS_PREFIX}${entry.connection.tunnelId}`;
}
}
export const enum RemoteAgentHostInputValidationError {
Empty = 'empty',
Invalid = 'invalid',
@@ -93,8 +132,8 @@ export interface IRemoteAgentHostService {
reconnect(address: string): void;
/**
* Register a pre-connected SSH agent connection.
* Used by the SSH service to inject relay-backed connections
* Register a pre-connected agent connection.
* Used by the SSH and tunnel services to inject relay-backed connections
* without going through the WebSocket connect flow.
*/
addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise<IRemoteAgentHostConnectionInfo>;
@@ -195,3 +234,53 @@ function formatRemoteAgentHostAddress(url: URL, protocol: 'ws:' | 'wss:' | undef
const base = protocol ? `${protocol}//${url.host}` : url.host;
return `${base}${path}${query}`;
}
/** Raw shape of entries persisted in the {@link RemoteAgentHostsSettingId} setting. */
export interface IRawRemoteAgentHostEntry {
readonly address: string;
readonly name: string;
readonly connectionToken?: string;
readonly sshConfigHost?: string;
}
export function rawEntryToEntry(raw: IRawRemoteAgentHostEntry): IRemoteAgentHostEntry | undefined {
if (raw.sshConfigHost) {
return {
name: raw.name,
connectionToken: raw.connectionToken,
connection: {
type: RemoteAgentHostEntryType.SSH,
address: raw.address,
sshConfigHost: raw.sshConfigHost,
},
};
}
return {
name: raw.name,
connectionToken: raw.connectionToken,
connection: {
type: RemoteAgentHostEntryType.WebSocket,
address: raw.address,
},
};
}
export function entryToRawEntry(entry: IRemoteAgentHostEntry): IRawRemoteAgentHostEntry | undefined {
switch (entry.connection.type) {
case RemoteAgentHostEntryType.SSH:
return {
address: entry.connection.address,
name: entry.name,
connectionToken: entry.connectionToken,
sshConfigHost: entry.connection.sshConfigHost,
};
case RemoteAgentHostEntryType.WebSocket:
return {
address: entry.connection.address,
name: entry.name,
connectionToken: entry.connectionToken,
};
case RemoteAgentHostEntryType.Tunnel:
return undefined;
}
}
@@ -1 +1 @@
b13578c
27a63cf
@@ -9,15 +9,16 @@
// Generated from types/actions.ts — do not edit
// Run `npm run generate` to regenerate.
import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction } from './actions.js';
import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type IRootTerminalsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionToolCallContentChangedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction, type ISessionIsReadChangedAction, type ISessionIsDoneChangedAction, type ITerminalDataAction, type ITerminalInputAction, type ITerminalResizedAction, type ITerminalClaimedAction, type ITerminalTitleChangedAction, type ITerminalCwdChangedAction, type ITerminalExitedAction, type ITerminalClearedAction } from './actions.js';
// ─── Root vs Session Action Unions ───────────────────────────────────────────
// ─── Root vs Session vs Terminal Action Unions ───────────────────────────────
/** Union of all root-scoped actions. */
export type IRootAction =
| IRootAgentsChangedAction
| IRootActiveSessionsChangedAction
| IRootTerminalsChangedAction
;
/** Union of all session-scoped actions. */
@@ -33,6 +34,7 @@ export type ISessionAction =
| ISessionToolCallConfirmedAction
| ISessionToolCallCompleteAction
| ISessionToolCallResultConfirmedAction
| ISessionToolCallContentChangedAction
| ISessionTurnCompleteAction
| ISessionTurnCancelledAction
| ISessionErrorAction
@@ -49,6 +51,8 @@ export type ISessionAction =
| ISessionCustomizationsChangedAction
| ISessionCustomizationToggledAction
| ISessionTruncatedAction
| ISessionIsReadChangedAction
| ISessionIsDoneChangedAction
;
/** Union of session actions that clients may dispatch. */
@@ -67,6 +71,8 @@ export type IClientSessionAction =
| ISessionQueuedMessagesReorderedAction
| ISessionCustomizationToggledAction
| ISessionTruncatedAction
| ISessionIsReadChangedAction
| ISessionIsDoneChangedAction
;
/** Union of session actions that only the server may produce. */
@@ -78,6 +84,7 @@ export type IServerSessionAction =
| ISessionToolCallStartAction
| ISessionToolCallDeltaAction
| ISessionToolCallReadyAction
| ISessionToolCallContentChangedAction
| ISessionTurnCompleteAction
| ISessionErrorAction
| ISessionUsageAction
@@ -86,6 +93,34 @@ export type IServerSessionAction =
| ISessionCustomizationsChangedAction
;
/** Union of all terminal-scoped actions. */
export type ITerminalAction =
| ITerminalDataAction
| ITerminalInputAction
| ITerminalResizedAction
| ITerminalClaimedAction
| ITerminalTitleChangedAction
| ITerminalCwdChangedAction
| ITerminalExitedAction
| ITerminalClearedAction
;
/** Union of terminal actions that clients may dispatch. */
export type IClientTerminalAction =
| ITerminalInputAction
| ITerminalResizedAction
| ITerminalClaimedAction
| ITerminalTitleChangedAction
| ITerminalClearedAction
;
/** Union of terminal actions that only the server may produce. */
export type IServerTerminalAction =
| ITerminalDataAction
| ITerminalCwdChangedAction
| ITerminalExitedAction
;
// ─── Client-Dispatchable Map ─────────────────────────────────────────────────
/**
@@ -95,6 +130,7 @@ export type IServerSessionAction =
export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boolean } = {
[ActionType.RootAgentsChanged]: false,
[ActionType.RootActiveSessionsChanged]: false,
[ActionType.RootTerminalsChanged]: false,
[ActionType.SessionReady]: false,
[ActionType.SessionCreationFailed]: false,
[ActionType.SessionTurnStarted]: true,
@@ -106,6 +142,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo
[ActionType.SessionToolCallConfirmed]: true,
[ActionType.SessionToolCallComplete]: true,
[ActionType.SessionToolCallResultConfirmed]: true,
[ActionType.SessionToolCallContentChanged]: false,
[ActionType.SessionTurnComplete]: false,
[ActionType.SessionTurnCancelled]: true,
[ActionType.SessionError]: false,
@@ -122,4 +159,14 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo
[ActionType.SessionCustomizationsChanged]: false,
[ActionType.SessionCustomizationToggled]: true,
[ActionType.SessionTruncated]: true,
[ActionType.SessionIsReadChanged]: true,
[ActionType.SessionIsDoneChanged]: true,
[ActionType.TerminalData]: false,
[ActionType.TerminalInput]: true,
[ActionType.TerminalResized]: true,
[ActionType.TerminalClaimed]: true,
[ActionType.TerminalTitleChanged]: true,
[ActionType.TerminalCwdChanged]: false,
[ActionType.TerminalExited]: false,
[ActionType.TerminalCleared]: true,
};
@@ -6,7 +6,7 @@
// allow-any-unicode-comment-file
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts
import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type ISessionCustomization } from './state.js';
import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolResultContent, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type ISessionCustomization, type ITerminalInfo, type ITerminalClaim } from './state.js';
// ─── Action Type Enum ────────────────────────────────────────────────────────
@@ -30,6 +30,7 @@ export const enum ActionType {
SessionToolCallConfirmed = 'session/toolCallConfirmed',
SessionToolCallComplete = 'session/toolCallComplete',
SessionToolCallResultConfirmed = 'session/toolCallResultConfirmed',
SessionToolCallContentChanged = 'session/toolCallContentChanged',
SessionTurnComplete = 'session/turnComplete',
SessionTurnCancelled = 'session/turnCancelled',
SessionError = 'session/error',
@@ -46,6 +47,17 @@ export const enum ActionType {
SessionCustomizationsChanged = 'session/customizationsChanged',
SessionCustomizationToggled = 'session/customizationToggled',
SessionTruncated = 'session/truncated',
SessionIsReadChanged = 'session/isReadChanged',
SessionIsDoneChanged = 'session/isDoneChanged',
RootTerminalsChanged = 'root/terminalsChanged',
TerminalData = 'terminal/data',
TerminalInput = 'terminal/input',
TerminalResized = 'terminal/resized',
TerminalClaimed = 'terminal/claimed',
TerminalTitleChanged = 'terminal/titleChanged',
TerminalCwdChanged = 'terminal/cwdChanged',
TerminalExited = 'terminal/exited',
TerminalCleared = 'terminal/cleared',
}
// ─── Action Envelope ─────────────────────────────────────────────────────────
@@ -118,6 +130,21 @@ export interface IRootActiveSessionsChangedAction {
activeSessions: number;
}
/**
* Fired when the list of known terminals changes.
*
* Full-replacement semantics: the `terminals` array replaces the previous
* `terminals` entirely.
*
* @category Root Actions
* @version 1
*/
export interface IRootTerminalsChangedAction {
type: ActionType.RootTerminalsChanged;
/** Updated terminal list (full replacement) */
terminals: ITerminalInfo[];
}
// ─── Session Actions ─────────────────────────────────────────────────────────
/**
@@ -356,6 +383,22 @@ export interface ISessionToolCallResultConfirmedAction extends IToolCallActionBa
approved: boolean;
}
/**
* Partial content produced while a tool is still executing.
*
* Replaces the `content` array on the running tool call state. Clients can
* use this to display live feedback (e.g. a terminal reference) before the
* tool completes.
*
* @category Session Actions
* @version 1
*/
export interface ISessionToolCallContentChangedAction extends IToolCallActionBase {
type: ActionType.SessionToolCallContentChanged;
/** The current partial content for the running tool call */
content: IToolResultContent[];
}
/**
* Turn finished the assistant is idle.
*
@@ -469,6 +512,42 @@ export interface ISessionModelChangedAction {
model: string;
}
/**
* The read state of the session changed.
*
* Dispatched by a client to mark a session as read (e.g. after viewing it)
* or unread (e.g. after new activity since the client last looked at it).
*
* @category Session Actions
* @version 1
* @clientDispatchable
*/
export interface ISessionIsReadChangedAction {
type: ActionType.SessionIsReadChanged;
/** Session URI */
session: URI;
/** Whether the session has been read */
isRead: boolean;
}
/**
* The done state of the session changed.
*
* Dispatched by a client to mark a session as done (e.g. the task is
* complete) or to reopen it.
*
* @category Session Actions
* @version 1
* @clientDispatchable
*/
export interface ISessionIsDoneChangedAction {
type: ActionType.SessionIsDoneChanged;
/** Session URI */
session: URI;
/** Whether the session is done */
isDone: boolean;
}
/**
* Server tools for this session have changed.
*
@@ -657,6 +736,149 @@ export interface ISessionQueuedMessagesReorderedAction {
order: string[];
}
// ─── Terminal Actions ────────────────────────────────────────────────────────
/**
* Terminal output data (pty client direction).
*
* Appends `data` to the terminal's `content` in the reducer.
*
* `terminal/data` and `terminal/input` are intentionally separate actions
* because standard write-ahead reconciliation is not safe for terminal I/O.
* A pty is a stateful, mutable process optimistically applying input or
* predicting output would produce incorrect state. Instead, `terminal/input`
* is a side-effect-only action (client server pty), and `terminal/data`
* is server-authoritative output (pty server client).
*
* @category Terminal Actions
* @version 1
*/
export interface ITerminalDataAction {
type: ActionType.TerminalData;
/** Terminal URI */
terminal: URI;
/** Output data (may contain ANSI escape sequences) */
data: string;
}
/**
* Keyboard input sent to the terminal process (client pty direction).
*
* This is a side-effect-only action: the server forwards the data to the
* terminal's pty. The reducer treats this as a no-op since `terminal/data`
* actions will reflect any resulting output.
*
* See `terminal/data` for why these two actions are kept separate.
*
* @category Terminal Actions
* @version 1
* @clientDispatchable
*/
export interface ITerminalInputAction {
type: ActionType.TerminalInput;
/** Terminal URI */
terminal: URI;
/** Input data to send to the pty */
data: string;
}
/**
* Terminal dimensions changed.
*
* Dispatchable by clients to request a resize, or by the server to inform
* clients of the actual terminal dimensions.
*
* @category Terminal Actions
* @version 1
* @clientDispatchable
*/
export interface ITerminalResizedAction {
type: ActionType.TerminalResized;
/** Terminal URI */
terminal: URI;
/** Terminal width in columns */
cols: number;
/** Terminal height in rows */
rows: number;
}
/**
* Terminal claim changed. A client or session transfers ownership of the terminal.
*
* The server SHOULD reject if the dispatching client does not currently hold
* the claim.
*
* @category Terminal Actions
* @version 1
* @clientDispatchable
*/
export interface ITerminalClaimedAction {
type: ActionType.TerminalClaimed;
/** Terminal URI */
terminal: URI;
/** The new claim */
claim: ITerminalClaim;
}
/**
* Terminal title changed.
*
* Fired by the server when the terminal process updates its title (e.g. via
* escape sequences), or dispatched by a client to rename a terminal.
*
* @category Terminal Actions
* @version 1
* @clientDispatchable
*/
export interface ITerminalTitleChangedAction {
type: ActionType.TerminalTitleChanged;
/** Terminal URI */
terminal: URI;
/** New terminal title */
title: string;
}
/**
* Terminal working directory changed.
*
* @category Terminal Actions
* @version 1
*/
export interface ITerminalCwdChangedAction {
type: ActionType.TerminalCwdChanged;
/** Terminal URI */
terminal: URI;
/** New working directory */
cwd: URI;
}
/**
* Terminal process exited.
*
* @category Terminal Actions
* @version 1
*/
export interface ITerminalExitedAction {
type: ActionType.TerminalExited;
/** Terminal URI */
terminal: URI;
/** Process exit code. `undefined` if the process was killed without an exit code. */
exitCode?: number;
}
/**
* Terminal scrollback buffer cleared.
*
* @category Terminal Actions
* @version 1
* @clientDispatchable
*/
export interface ITerminalClearedAction {
type: ActionType.TerminalCleared;
/** Terminal URI */
terminal: URI;
}
// ─── Discriminated Union ─────────────────────────────────────────────────────
/**
@@ -665,6 +887,7 @@ export interface ISessionQueuedMessagesReorderedAction {
export type IStateAction =
| IRootAgentsChangedAction
| IRootActiveSessionsChangedAction
| IRootTerminalsChangedAction
| ISessionReadyAction
| ISessionCreationFailedAction
| ISessionTurnStartedAction
@@ -676,6 +899,7 @@ export type IStateAction =
| ISessionToolCallConfirmedAction
| ISessionToolCallCompleteAction
| ISessionToolCallResultConfirmedAction
| ISessionToolCallContentChangedAction
| ISessionTurnCompleteAction
| ISessionTurnCancelledAction
| ISessionErrorAction
@@ -691,4 +915,14 @@ export type IStateAction =
| ISessionQueuedMessagesReorderedAction
| ISessionCustomizationsChangedAction
| ISessionCustomizationToggledAction
| ISessionTruncatedAction;
| ISessionTruncatedAction
| ISessionIsReadChangedAction
| ISessionIsDoneChangedAction
| ITerminalDataAction
| ITerminalInputAction
| ITerminalResizedAction
| ITerminalClaimedAction
| ITerminalTitleChangedAction
| ITerminalCwdChangedAction
| ITerminalExitedAction
| ITerminalClearedAction;
@@ -6,7 +6,7 @@
// allow-any-unicode-comment-file
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts
import type { URI, ISnapshot, ISessionSummary, ITurn } from './state.js';
import type { URI, ISnapshot, ISessionSummary, ITurn, ITerminalClaim } from './state.js';
import type { IActionEnvelope, IStateAction } from './actions.js';
// ─── initialize ──────────────────────────────────────────────────────────────
@@ -211,6 +211,55 @@ export interface IDisposeSessionParams {
session: URI;
}
// ─── createTerminal ──────────────────────────────────────────────────────────
/**
* Creates a new terminal on the server.
*
* After creation, the client should subscribe to the terminal URI to receive
* state updates. The server dispatches `root/terminalsChanged` to update the
* root terminal list.
*
* @category Commands
* @method createTerminal
* @direction Client Server
* @messageType Request
* @version 1
*/
export interface ICreateTerminalParams {
/** Terminal URI (client-chosen) */
terminal: URI;
/** Initial owner of the terminal */
claim: ITerminalClaim;
/** Human-readable terminal name */
name?: string;
/** Initial working directory URI */
cwd?: URI;
/** Initial terminal width in columns */
cols?: number;
/** Initial terminal height in rows */
rows?: number;
}
// ─── disposeTerminal ─────────────────────────────────────────────────────────
/**
* Disposes a terminal and kills its process if still running.
*
* The server dispatches `root/terminalsChanged` to remove the terminal from
* the root terminal list.
*
* @category Commands
* @method disposeTerminal
* @direction Client Server
* @messageType Request
* @version 1
*/
export interface IDisposeTerminalParams {
/** Terminal URI to dispose */
terminal: URI;
}
// ─── listSessions ────────────────────────────────────────────────────────────
/**
@@ -6,7 +6,7 @@
// allow-any-unicode-comment-file
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts
import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IResourceReadParams, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IResourceListParams, IResourceListResult, IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceMoveParams, IResourceMoveResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js';
import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, ICreateTerminalParams, IDisposeTerminalParams, IListSessionsParams, IListSessionsResult, IResourceReadParams, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IResourceListParams, IResourceListResult, IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceMoveParams, IResourceMoveResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js';
import type { IActionEnvelope } from './actions.js';
import type { IProtocolNotification } from './notifications.js';
@@ -62,6 +62,8 @@ export interface ICommandMap {
'subscribe': { params: ISubscribeParams; result: ISubscribeResult };
'createSession': { params: ICreateSessionParams; result: null };
'disposeSession': { params: IDisposeSessionParams; result: null };
'createTerminal': { params: ICreateTerminalParams; result: null };
'disposeTerminal': { params: IDisposeTerminalParams; result: null };
'listSessions': { params: IListSessionsParams; result: IListSessionsResult };
'resourceRead': { params: IResourceReadParams; result: IResourceReadResult };
'resourceWrite': { params: IResourceWriteParams; result: IResourceWriteResult };
@@ -7,8 +7,8 @@
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts
import { ActionType } from './actions.js';
import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type IRootState, type ISessionState, type IToolCallState, type IResponsePart, type IToolCallResponsePart, type ITurn, type IPendingMessage } from './state.js';
import { IS_CLIENT_DISPATCHABLE, type IRootAction, type ISessionAction, type IClientSessionAction } from './action-origin.generated.js';
import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type IRootState, type ISessionState, type ITerminalState, type IToolCallState, type IResponsePart, type IToolCallResponsePart, type ITurn, type IPendingMessage } from './state.js';
import { IS_CLIENT_DISPATCHABLE, type IRootAction, type ISessionAction, type IClientSessionAction, type ITerminalAction, type IClientTerminalAction } from './action-origin.generated.js';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -184,6 +184,9 @@ export function rootReducer(state: IRootState, action: IRootAction, log?: (msg:
case ActionType.RootActiveSessionsChanged:
return { ...state, activeSessions: action.activeSessions };
case ActionType.RootTerminalsChanged:
return { ...state, terminals: action.terminals };
default:
softAssertNever(action, log);
return state;
@@ -218,7 +221,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log
case ActionType.SessionTurnStarted: {
let next: ISessionState = {
...state,
summary: { ...state.summary, status: SessionStatus.InProgress, modifiedAt: Date.now() },
summary: { ...state.summary, status: SessionStatus.InProgress, modifiedAt: Date.now(), isRead: false },
activeTurn: {
id: action.turnId,
userMessage: action.userMessage,
@@ -417,6 +420,17 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log
};
});
case ActionType.SessionToolCallContentChanged:
return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => {
if (tc.status !== ToolCallStatus.Running) {
return tc;
}
return {
...tc,
content: action.content,
};
});
// ── Metadata ──────────────────────────────────────────────────────────
case ActionType.SessionTitleChanged:
@@ -448,6 +462,18 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log
summary: { ...state.summary, model: action.model, modifiedAt: Date.now() },
};
case ActionType.SessionIsReadChanged:
return {
...state,
summary: { ...state.summary, isRead: action.isRead },
};
case ActionType.SessionIsDoneChanged:
return {
...state,
summary: { ...state.summary, isDone: action.isDone },
};
case ActionType.SessionServerToolsChanged:
return { ...state, serverTools: action.tools };
@@ -571,14 +597,53 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log
}
}
// ─── Terminal Reducer ────────────────────────────────────────────────────────
/**
* Pure reducer for terminal state. Handles all {@link ITerminalAction} variants.
*/
export function terminalReducer(state: ITerminalState, action: ITerminalAction, log?: (msg: string) => void): ITerminalState {
switch (action.type) {
case ActionType.TerminalData:
return { ...state, content: state.content + action.data };
case ActionType.TerminalInput:
// Side-effect-only: the server forwards to the pty.
// No state change in the reducer.
return state;
case ActionType.TerminalResized:
return { ...state, cols: action.cols, rows: action.rows };
case ActionType.TerminalClaimed:
return { ...state, claim: action.claim };
case ActionType.TerminalTitleChanged:
return { ...state, title: action.title };
case ActionType.TerminalCwdChanged:
return { ...state, cwd: action.cwd };
case ActionType.TerminalExited:
return { ...state, exitCode: action.exitCode };
case ActionType.TerminalCleared:
return { ...state, content: '' };
default:
softAssertNever(action, log);
return state;
}
}
// ─── Dispatch Validation ─────────────────────────────────────────────────────
/**
* Type guard that checks whether an action may be dispatched by a client.
* Type guard that checks whether a session action may be dispatched by a client.
*
* Servers SHOULD call this to validate incoming `dispatchAction` requests
* and reject any action the client is not allowed to originate.
*/
export function isClientDispatchable(action: ISessionAction): action is IClientSessionAction {
export function isClientDispatchable(action: ISessionAction | ITerminalAction): action is IClientSessionAction | IClientTerminalAction {
return IS_CLIENT_DISPATCHABLE[action.type];
}
@@ -152,6 +152,8 @@ export interface IRootState {
agents: IAgentInfo[];
/** Number of active (non-disposed) sessions on the server */
activeSessions?: number;
/** Known terminals on the server. Subscribe to individual terminal URIs for full state. */
terminals?: ITerminalInfo[];
}
/**
@@ -313,6 +315,20 @@ export interface ISessionActiveClient {
customizations?: ICustomizationRef[];
}
/**
* A summary of changes to a single file within a session.
*
* @category Session State
*/
export interface ISessionFileDiff {
/** URI of the affected file */
uri: URI;
/** Number of items added (e.g., lines for text files, cells for notebooks) */
added?: number;
/** Number of items removed (e.g., lines for text files, cells for notebooks) */
removed?: number;
}
/**
* @category Session State
*/
@@ -333,6 +349,12 @@ export interface ISessionSummary {
model?: string;
/** The working directory URI for this session */
workingDirectory?: URI;
/** Whether the client has viewed this session since its last modification */
isRead?: boolean;
/** Whether the session has been marked as done by the client */
isDone?: boolean;
/** Files changed during this session with diff statistics */
diffs?: ISessionFileDiff[];
}
// ─── Turn Types ──────────────────────────────────────────────────────────────
@@ -659,6 +681,13 @@ export interface IToolCallRunningState extends IToolCallBase, IToolCallParameter
status: ToolCallStatus.Running;
/** How the tool was confirmed for execution */
confirmed: ToolCallConfirmationReason;
/**
* Partial content produced while the tool is still executing.
*
* For example, a terminal content block lets clients subscribe to live
* output before the tool completes.
*/
content?: IToolResultContent[];
}
/**
@@ -795,6 +824,7 @@ export const enum ToolResultContentType {
EmbeddedResource = 'embeddedResource',
Resource = 'resource',
FileEdit = 'fileEdit',
Terminal = 'terminal',
}
/**
@@ -869,12 +899,29 @@ export interface IToolResultFileEditContent {
};
}
/**
* A reference to a terminal whose output is relevant to this tool result.
*
* Clients can subscribe to the terminal's URI to stream its output in real
* time, providing live feedback while a tool is executing.
*
* @category Tool Result Content
*/
export interface IToolResultTerminalContent {
type: ToolResultContentType.Terminal;
/** Terminal URI (subscribable for full terminal state) */
resource: URI;
/** Display title for the terminal content */
title: string;
}
/**
* Content block in a tool result.
*
* Mirrors the content blocks in MCP `CallToolResult.content`, plus
* `IToolResultResourceContent` for lazy-loading large results and
* `IToolResultFileEditContent` for file edit diffs (AHP extensions).
* `IToolResultResourceContent` for lazy-loading large results,
* `IToolResultFileEditContent` for file edit diffs, and
* `IToolResultTerminalContent` for live terminal output (AHP extensions).
*
* @category Tool Result Content
*/
@@ -882,7 +929,8 @@ export type IToolResultContent =
| IToolResultTextContent
| IToolResultEmbeddedResourceContent
| IToolResultResourceContent
| IToolResultFileEditContent;
| IToolResultFileEditContent
| IToolResultTerminalContent;
// ─── Customization Types ─────────────────────────────────────────────────────
@@ -950,6 +998,95 @@ export interface ISessionCustomization {
statusMessage?: string;
}
// ─── Terminal Types ──────────────────────────────────────────────────────────
/**
* Lightweight terminal metadata exposed on the root state.
*
* @category Terminal Types
*/
export interface ITerminalInfo {
/** Terminal URI (subscribable for full terminal state) */
resource: URI;
/** Human-readable terminal title */
title: string;
/** Who currently holds this terminal */
claim: ITerminalClaim;
/** Process exit code, if the terminal process has exited */
exitCode?: number;
}
/**
* Discriminant for terminal claim kinds.
*
* @category Terminal Types
*/
export const enum TerminalClaimKind {
Client = 'client',
Session = 'session',
}
/**
* A terminal claimed by a connected client.
*
* @category Terminal Types
*/
export interface ITerminalClientClaim {
/** Discriminant */
kind: TerminalClaimKind.Client;
/** The `clientId` of the claiming client */
clientId: string;
}
/**
* A terminal claimed by a session, optionally scoped to a specific turn or tool call.
*
* @category Terminal Types
*/
export interface ITerminalSessionClaim {
/** Discriminant */
kind: TerminalClaimKind.Session;
/** Session URI that claimed the terminal */
session: URI;
/** Optional turn identifier within the session */
turnId?: string;
/** Optional tool call identifier within the turn */
toolCallId?: string;
}
/**
* Describes who currently holds a terminal. A terminal may be claimed by
* either a connected client or a session (e.g. during a tool call).
*
* @category Terminal Types
*/
export type ITerminalClaim = ITerminalClientClaim | ITerminalSessionClaim;
/**
* Full state for a single terminal, loaded when a client subscribes to the terminal's URI.
*
* @category Terminal Types
*/
export interface ITerminalState {
/** Human-readable terminal title */
title: string;
/** Current working directory of the terminal process */
cwd?: URI;
/** Terminal width in columns */
cols?: number;
/** Terminal height in rows */
rows?: number;
/**
* Accumulated terminal output. May contain ANSI escape sequences.
* The scrollback length is implementation-defined.
*/
content: string;
/** Process exit code, set when the terminal process exits */
exitCode?: number;
/** Who currently holds this terminal */
claim: ITerminalClaim;
}
// ─── Common Types ────────────────────────────────────────────────────────────
/**
@@ -988,7 +1125,7 @@ export interface ISnapshot {
/** The subscribed resource URI (e.g. `agenthost:/root` or `copilot:/<uuid>`) */
resource: URI;
/** The current state of the resource */
state: IRootState | ISessionState;
state: IRootState | ISessionState | ITerminalState;
/** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */
fromSeq: number;
}
@@ -37,6 +37,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe
[ActionType.SessionToolCallConfirmed]: 1,
[ActionType.SessionToolCallComplete]: 1,
[ActionType.SessionToolCallResultConfirmed]: 1,
[ActionType.SessionToolCallContentChanged]: 1,
[ActionType.SessionTurnComplete]: 1,
[ActionType.SessionTurnCancelled]: 1,
[ActionType.SessionError]: 1,
@@ -53,6 +54,17 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe
[ActionType.SessionCustomizationsChanged]: 1,
[ActionType.SessionCustomizationToggled]: 1,
[ActionType.SessionTruncated]: 1,
[ActionType.SessionIsReadChanged]: 1,
[ActionType.SessionIsDoneChanged]: 1,
[ActionType.RootTerminalsChanged]: 1,
[ActionType.TerminalData]: 1,
[ActionType.TerminalInput]: 1,
[ActionType.TerminalResized]: 1,
[ActionType.TerminalClaimed]: 1,
[ActionType.TerminalTitleChanged]: 1,
[ActionType.TerminalCwdChanged]: 1,
[ActionType.TerminalExited]: 1,
[ActionType.TerminalCleared]: 1,
};
/**
@@ -47,6 +47,8 @@ export {
type ISessionPendingMessageSetAction,
type ISessionPendingMessageRemovedAction,
type ISessionQueuedMessagesReorderedAction,
type ISessionIsReadChangedAction,
type ISessionIsDoneChangedAction,
type IStateAction,
} from './protocol/actions.js';
@@ -85,6 +87,8 @@ import type {
ISessionPendingMessageSetAction,
ISessionPendingMessageRemovedAction,
ISessionQueuedMessagesReorderedAction,
ISessionIsReadChangedAction,
ISessionIsDoneChangedAction,
} from './protocol/actions.js';
import type { IProtocolNotification } from './protocol/notifications.js';
@@ -123,6 +127,8 @@ export type ICustomizationToggledAction = import('./protocol/actions.js').ISessi
export type IPendingMessageSetAction = ISessionPendingMessageSetAction;
export type IPendingMessageRemovedAction = ISessionPendingMessageRemovedAction;
export type IQueuedMessagesReorderedAction = ISessionQueuedMessagesReorderedAction;
export type IIsReadChangedAction = ISessionIsReadChangedAction;
export type IIsDoneChangedAction = ISessionIsDoneChangedAction;
// Notifications
export type INotification = IProtocolNotification;
@@ -20,7 +20,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { IActionEnvelope, INotification, ISessionAction, isRootAction, isSessionAction, IStateAction } from './sessionActions.js';
import { rootReducer, sessionReducer } from './sessionReducers.js';
import { IRootState, ISessionState, ROOT_STATE_URI } from './sessionState.js';
import { IRootState, ISessionState, ITerminalState, ROOT_STATE_URI } from './sessionState.js';
import { ILogService } from '../../../log/common/log.js';
// ---- Pending action tracking ------------------------------------------------
@@ -110,7 +110,7 @@ export class SessionClientState extends Disposable {
* Apply a state snapshot received from the server (from handshake,
* subscribe response, or reconnection).
*/
handleSnapshot(resource: string, state: IRootState | ISessionState, fromSeq: number): void {
handleSnapshot(resource: string, state: IRootState | ISessionState | ITerminalState, fromSeq: number): void {
this._lastSeenServerSeq = Math.max(this._lastSeenServerSeq, fromSeq);
if (resource === ROOT_STATE_URI) {
@@ -43,6 +43,7 @@ export {
type ISessionState,
type ISessionSummary,
type ISnapshot,
type ITerminalState,
type IToolAnnotations,
type IToolCallCancelledState,
type IToolCallCompletedState,
@@ -0,0 +1,215 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Event } from '../../../base/common/event.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
export const ITunnelAgentHostService = createDecorator<ITunnelAgentHostService>('tunnelAgentHostService');
/**
* IPC channel name for the shared-process tunnel service.
*/
export const TUNNEL_AGENT_HOST_CHANNEL = 'tunnelAgentHost';
/** Configuration key for the list of manually configured tunnel names. */
export const TunnelAgentHostsSettingId = 'chat.remoteAgentTunnels';
/** Minimum protocol version required for agent host connections. */
export const TUNNEL_MIN_PROTOCOL_VERSION = 5;
/** Well-known port for the agent host on tunnel machines. */
export const TUNNEL_AGENT_HOST_PORT = 31546;
/** Label used to identify VS Code server launcher tunnels. */
export const TUNNEL_LAUNCHER_LABEL = 'vscode-server-launcher';
/** Address prefix for tunnel-backed connections (e.g. `tunnel:myTunnelId`). */
export const TUNNEL_ADDRESS_PREFIX = 'tunnel:';
/** Prefix for protocol version tags. */
export const PROTOCOL_VERSION_TAG_PREFIX = 'protocolv';
/**
* Parse tunnel tags to extract display name and protocol version.
* Follows the convention from the vscode-remote-tunnels SDK: the
* first label that is not `vscode-server-launcher`, does not start
* with `_`, and is not a `protocolvN` tag is the display name.
*/
export class TunnelTags {
public readonly protocolVersion: number = 2;
public readonly name: string | undefined;
constructor(readonly value: readonly string[] | undefined) {
if (value) {
let protocolVersion: number | undefined;
let name: string | undefined;
for (const tag of value) {
if (tag.startsWith(PROTOCOL_VERSION_TAG_PREFIX)) {
const parsed = Number(tag.slice(PROTOCOL_VERSION_TAG_PREFIX.length));
if (!isNaN(parsed)) {
protocolVersion = parsed;
}
} else if (!tag.startsWith('_') && tag !== TUNNEL_LAUNCHER_LABEL && !name) {
name = tag;
}
}
if (protocolVersion !== undefined) {
this.protocolVersion = protocolVersion;
}
if (name !== undefined) {
this.name = name;
}
}
}
}
/** A recently used tunnel cached in storage. */
export interface ICachedTunnel {
readonly tunnelId: string;
readonly clusterId: string;
readonly name: string;
readonly authProvider?: 'github' | 'microsoft';
}
/** Information about a discovered dev tunnel with an agent host. */
export interface ITunnelInfo {
/** The tunnel's unique identifier. */
readonly tunnelId: string;
/** The cluster region where the tunnel is hosted. */
readonly clusterId: string;
/** Display name derived from tunnel tags or tunnel name. */
readonly name: string;
/** All tags/labels on the tunnel. */
readonly tags: readonly string[];
/** Parsed protocol version from tags. */
readonly protocolVersion: number;
/** Number of hosts currently accepting connections (0 = offline). */
readonly hostConnectionCount: number;
}
/**
* Serializable result from a successful tunnel connect operation.
* Returned over IPC from the shared process.
*/
export interface ITunnelConnectResult {
/** Unique identifier for this connection's relay channel. */
readonly connectionId: string;
/** Display-friendly address (e.g. "tunnel:myTunnel"). */
readonly address: string;
/** Display name for the tunnel. */
readonly name: string;
/** Connection token derived from the tunnel ID. */
readonly connectionToken: string;
}
/**
* A message relayed from a remote agent host through the tunnel.
* The shared process acts as a WebSocket proxy, forwarding JSON
* messages bidirectionally between the tunnel and the renderer via IPC.
*/
export interface ITunnelRelayMessage {
readonly connectionId: string;
readonly data: string;
}
/**
* Main-process (shared process) service that manages dev tunnel
* connections. The renderer calls this over IPC and handles registration
* with {@link IRemoteAgentHostService} locally.
*/
export const ITunnelAgentHostMainService = createDecorator<ITunnelAgentHostMainService>('tunnelAgentHostMainService');
export interface ITunnelAgentHostMainService {
readonly _serviceBrand: undefined;
/** Fires when a message is received from a remote agent host via the tunnel relay. */
readonly onDidRelayMessage: Event<ITunnelRelayMessage>;
/** Fires when a relay connection to a remote agent host closes. */
readonly onDidRelayClose: Event<string /* connectionId */>;
/**
* List dev tunnels associated with the user's account that have
* the `vscode-server-launcher` label and a protocol version tag
* of at least {@link TUNNEL_MIN_PROTOCOL_VERSION}.
*
* @param token The user's access token (GitHub or Microsoft).
* @param authProvider The auth provider that issued the token.
* @param additionalTunnelNames Optional tunnel names to look up
* in addition to the account-wide enumeration.
*/
listTunnels(token: string, authProvider: 'github' | 'microsoft', additionalTunnelNames?: string[]): Promise<ITunnelInfo[]>;
/**
* Connect to a tunnel's agent host via the dev tunnels relay and
* begin relaying WebSocket messages through IPC.
*
* @param token The user's access token (GitHub or Microsoft).
* @param authProvider The auth provider that issued the token.
* @param tunnelId The tunnel ID to connect to.
* @param clusterId The cluster region of the tunnel.
*/
connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise<ITunnelConnectResult>;
/**
* Send a message to a remote agent host through the tunnel relay.
*/
relaySend(connectionId: string, message: string): Promise<void>;
/**
* Disconnect a tunnel relay connection.
*/
disconnect(connectionId: string): Promise<void>;
}
/**
* Renderer-side service that manages dev tunnel agent host connections.
* Uses the shared-process {@link ITunnelAgentHostMainService} for
* actual tunnel SDK operations and registers connections with
* {@link IRemoteAgentHostService}.
*/
export interface ITunnelAgentHostService {
readonly _serviceBrand: undefined;
/** Fires when the set of available tunnels changes. */
readonly onDidChangeTunnels: Event<void>;
/**
* Enumerate available dev tunnels with agent host support.
* When {@link options.silent} is `true`, uses cached tokens without
* prompting the user. Returns an empty array if no cached token.
*/
listTunnels(options?: { silent?: boolean }): Promise<ITunnelInfo[]>;
/**
* Connect to a tunnel's agent host and register the connection
* with {@link IRemoteAgentHostService}.
*
* @param tunnel The tunnel to connect to.
* @param authProvider Optional auth provider to use. If omitted, uses cached/last known.
*/
connect(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): Promise<void>;
/**
* Disconnect from a tunnel agent host.
*/
disconnect(address: string): Promise<void>;
/** Get the list of recently used (cached) tunnels. */
getCachedTunnels(): ICachedTunnel[];
/** Cache a tunnel as recently used. */
cacheTunnel(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): void;
/** Remove a tunnel from the cache. */
removeCachedTunnel(tunnelId: string): void;
/**
* Determine which auth provider has an existing cached session.
* When {@link silent} is true, does not prompt the user.
* Returns `undefined` if no cached session is available.
*/
getAuthProvider(options?: { silent?: boolean }): Promise<'github' | 'microsoft' | undefined>;
}
@@ -13,7 +13,7 @@ import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'
import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { ILogService } from '../../log/common/log.js';
import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js';
import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js';
import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js';
import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js';
import { revive } from '../../../base/common/marshalling.js';
@@ -83,18 +83,9 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService {
// ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ----
getResourceMetadata(): Promise<IResourceMetadata> {
return this._proxy.getResourceMetadata();
}
authenticate(params: IAuthenticateParams): Promise<IAuthenticateResult> {
return this._proxy.authenticate(params);
}
listAgents(): Promise<IAgentDescriptor[]> {
return this._proxy.listAgents();
}
refreshModels(): Promise<void> {
return this._proxy.refreshModels();
}
listSessions(): Promise<IAgentSessionMetadata[]> {
return this._proxy.listSessions();
}
@@ -15,7 +15,7 @@ import { URI } from '../../../base/common/uri.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { ILogService } from '../../log/common/log.js';
import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js';
import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js';
import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js';
import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js';
import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js';
import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js';
@@ -150,32 +150,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
return session;
}
/**
* Retrieve the server's resource metadata describing auth requirements.
*/
async getResourceMetadata(): Promise<IResourceMetadata> {
return await this._sendExtensionRequest('getResourceMetadata') as IResourceMetadata;
}
/**
* Authenticate with the remote agent host using a specific scheme.
*/
async authenticate(params: IAuthenticateParams): Promise<IAuthenticateResult> {
return await this._sendExtensionRequest('authenticate', params) as IAuthenticateResult;
}
/**
* Refresh the model list from all providers on the remote host.
*/
async refreshModels(): Promise<void> {
await this._sendExtensionRequest('refreshModels');
}
/**
* Discover available agent backends from the remote host.
*/
async listAgents(): Promise<IAgentDescriptor[]> {
return await this._sendExtensionRequest('listAgents') as IAgentDescriptor[];
await this._sendRequest('authenticate', params);
return { authenticated: true };
}
/**
@@ -203,6 +183,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
modifiedTime: s.modifiedAt,
summary: s.title,
workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined,
isRead: s.isRead,
isDone: s.isDone,
}));
}
@@ -18,14 +18,20 @@ import type { IAgentConnection } from '../common/agentService.js';
import {
IRemoteAgentHostService,
RemoteAgentHostConnectionStatus,
RemoteAgentHostEntryType,
RemoteAgentHostsEnabledSettingId,
RemoteAgentHostsSettingId,
entryToRawEntry,
getEntryAddress,
rawEntryToEntry,
type IRawRemoteAgentHostEntry,
type IRemoteAgentHostConnectionInfo,
type IRemoteAgentHostEntry,
} from '../common/remoteAgentHostService.js';
import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js';
import { WebSocketClientTransport } from './webSocketClientTransport.js';
import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js';
import { isDefined } from '../../../base/common/types.js';
/** Tracks a single remote connection through its lifecycle. */
interface IConnectionEntry {
@@ -90,7 +96,12 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
}
get configuredEntries(): readonly IRemoteAgentHostEntry[] {
return this._getConfiguredEntries().map(e => ({ ...e, address: normalizeRemoteAgentHostAddress(e.address) }));
return this._getConfiguredEntries().map(e => {
if (e.connection.type === RemoteAgentHostEntryType.Tunnel) {
return e;
}
return { ...e, connection: { ...e.connection, address: normalizeRemoteAgentHostAddress(e.connection.address) } };
});
}
getConnection(address: string): IAgentConnection | undefined {
@@ -102,11 +113,11 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
reconnect(address: string): void {
const normalized = normalizeRemoteAgentHostAddress(address);
// SSH entries are reconnected by the SSH service, not via WebSocket
// SSH/tunnel entries are reconnected by their respective services
const configuredEntry = this._getConfiguredEntries().find(
e => normalizeRemoteAgentHostAddress(e.address) === normalized
e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized
);
if (configuredEntry?.sshConfigHost) {
if (configuredEntry && configuredEntry.connection.type !== RemoteAgentHostEntryType.WebSocket) {
return;
}
@@ -132,8 +143,11 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
throw new Error('Remote agent host connections are not enabled.');
}
const entry: IRemoteAgentHostEntry = { ...input, address: normalizeRemoteAgentHostAddress(input.address) };
const existingConnection = this._getConnectionInfo(entry.address);
const entry: IRemoteAgentHostEntry = input.connection.type === RemoteAgentHostEntryType.Tunnel
? input
: { ...input, connection: { ...input.connection, address: normalizeRemoteAgentHostAddress(input.connection.address) } };
const address = getEntryAddress(entry);
const existingConnection = this._getConnectionInfo(address);
await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry));
if (existingConnection) {
@@ -143,24 +157,36 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
};
}
const connectedConnection = this._getConnectionInfo(entry.address);
// SSH entries are connected externally — just persist
// the entry and return a disconnected placeholder. The connection
// will be established by the SSH contribution.
if (entry.connection.type === RemoteAgentHostEntryType.SSH) {
return {
address,
name: entry.name,
clientId: '',
status: RemoteAgentHostConnectionStatus.Disconnected,
};
}
const connectedConnection = this._getConnectionInfo(address);
if (connectedConnection) {
return connectedConnection;
}
const wait = this._getOrCreateConnectionWait(entry.address);
const wait = this._getOrCreateConnectionWait(address);
const connection = await raceTimeout(wait.p, RemoteAgentHostService.ConnectionWaitTimeout, () => {
this._pendingConnectionWaits.delete(entry.address);
this._pendingConnectionWaits.delete(address);
});
if (!connection) {
throw new Error(`Timed out connecting to ${entry.address}`);
throw new Error(`Timed out connecting to ${address}`);
}
return connection;
}
async addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise<IRemoteAgentHostConnectionInfo> {
const address = entry.address;
const address = getEntryAddress(entry);
// Dispose any existing entry for this address to avoid leaking
// old protocol clients and relay transports on reconnect.
@@ -190,8 +216,9 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
}
}));
// Persist SSH entries — await so that the config is written before
// Persist entries — await so that the config is written before
// onDidChangeConnections fires, ensuring _reconcile creates the provider.
// Tunnel entries are filtered out by _storeConfiguredEntries automatically.
await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry));
this._onDidChangeConnections.fire();
@@ -210,7 +237,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
// This setting is only used in the sessions app (user scope), so we
// don't need to inspect per-scope values like _upsertConfiguredEntry does.
const entries = this._getConfiguredEntries().filter(
e => normalizeRemoteAgentHostAddress(e.address) !== normalized
e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) !== normalized
);
await this._storeConfiguredEntries(entries);
@@ -246,9 +273,9 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
return;
}
const rawEntries: IRemoteAgentHostEntry[] = this._configurationService.getValue<IRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId) ?? [];
const entries = rawEntries.map(e => ({ ...e, address: normalizeRemoteAgentHostAddress(e.address) }));
const desired = new Set(entries.map(e => e.address));
const rawEntries = (this._configurationService.getValue<IRawRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined);
const entriesWithAddress = rawEntries.map(e => ({ entry: e, address: normalizeRemoteAgentHostAddress(getEntryAddress(e)) }));
const desired = new Set(entriesWithAddress.map(e => e.address));
this._logService.info(`[RemoteAgentHost] Reconciling: desired=[${[...desired].join(', ')}], current=[${[...this._entries.keys()].map(a => `${a}(${this._entries.get(a)!.connected ? 'connected' : 'pending'})`).join(', ')}]`);
@@ -257,10 +284,10 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
const oldNames = new Map(this._names);
this._names.clear();
this._tokens.clear();
for (const entry of entries) {
this._names.set(entry.address, entry.name);
this._tokens.set(entry.address, entry.connectionToken);
if (this._entries.has(entry.address) && oldNames.get(entry.address) !== entry.name) {
for (const { entry, address } of entriesWithAddress) {
this._names.set(address, entry.name);
this._tokens.set(address, entry.connectionToken);
if (this._entries.has(address) && oldNames.get(address) !== entry.name) {
namesChanged = true;
}
}
@@ -275,10 +302,11 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
}
}
// Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService)
for (const entry of entries) {
if (!this._entries.has(entry.address) && !entry.sshConfigHost) {
this._connectTo(entry.address, entry.connectionToken);
// Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService,
// and skip tunnel entries — those are handled by ITunnelAgentHostService)
for (const { entry, address } of entriesWithAddress) {
if (!this._entries.has(address) && entry.connection.type === RemoteAgentHostEntryType.WebSocket) {
this._connectTo(address, entry.connectionToken);
}
}
@@ -389,7 +417,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
/** Check whether the given normalized address is still in the configured entries. */
private _isAddressConfigured(address: string): boolean {
const entries = this._getConfiguredEntries();
return entries.some(e => normalizeRemoteAgentHostAddress(e.address) === address);
return entries.some(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === address);
}
private _getConnectionInfo(address: string): IRemoteAgentHostConnectionInfo | undefined {
@@ -397,7 +425,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
}
private _getConfiguredEntries(): IRemoteAgentHostEntry[] {
return this._configurationService.getValue<IRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId) ?? [];
return (this._configurationService.getValue<IRawRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined);
}
private _upsertConfiguredEntry(entry: IRemoteAgentHostEntry): IRemoteAgentHostEntry[] {
@@ -405,27 +433,28 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
// merge entries from an overriding scope (e.g. workspace) into the
// user scope and then lose them on the next read.
const target = this._getConfigurationTarget();
const inspected = this._configurationService.inspect<IRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId);
let configuredEntries: readonly IRemoteAgentHostEntry[];
const inspected = this._configurationService.inspect<IRawRemoteAgentHostEntry[]>(RemoteAgentHostsSettingId);
let configuredRaw: readonly IRawRemoteAgentHostEntry[];
switch (target) {
case ConfigurationTarget.USER_LOCAL:
configuredEntries = inspected.userLocalValue ?? [];
configuredRaw = inspected.userLocalValue ?? [];
break;
case ConfigurationTarget.USER_REMOTE:
configuredEntries = inspected.userRemoteValue ?? [];
configuredRaw = inspected.userRemoteValue ?? [];
break;
default:
configuredEntries = inspected.userValue ?? [];
configuredRaw = inspected.userValue ?? [];
break;
}
const normalizedAddress = normalizeRemoteAgentHostAddress(entry.address);
const existingIndex = configuredEntries.findIndex(configuredEntry => normalizeRemoteAgentHostAddress(configuredEntry.address) === normalizedAddress);
const configuredEntries = configuredRaw.map(rawEntryToEntry).filter((e): e is IRemoteAgentHostEntry => e !== undefined);
const normalizedAddress = normalizeRemoteAgentHostAddress(getEntryAddress(entry));
const existingIndex = configuredEntries.findIndex(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalizedAddress);
if (existingIndex === -1) {
return [...configuredEntries, entry];
}
return configuredEntries.map((configuredEntry, index) => index === existingIndex ? entry : configuredEntry);
return configuredEntries.map((e, index) => index === existingIndex ? entry : e);
}
private _getConfigurationTarget(): ConfigurationTarget {
@@ -443,7 +472,8 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo
}
private async _storeConfiguredEntries(entries: IRemoteAgentHostEntry[]): Promise<void> {
await this._configurationService.updateValue(RemoteAgentHostsSettingId, entries, this._getConfigurationTarget());
const raw = entries.map(entryToRawEntry).filter(isDefined);
await this._configurationService.updateValue(RemoteAgentHostsSettingId, raw, this._getConfigurationTarget());
}
private _getOrCreateConnectionWait(address: string): DeferredPromise<IRemoteAgentHostConnectionInfo> {
@@ -9,7 +9,7 @@ import { ILogService } from '../../log/common/log.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { ISharedProcessService } from '../../ipc/electron-browser/services.js';
import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js';
import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js';
import { IRemoteAgentHostService, RemoteAgentHostEntryType } from '../common/remoteAgentHostService.js';
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
import { SSHRelayTransport } from './sshRelayTransport.js';
import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js';
@@ -92,10 +92,13 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
this._logService.trace('[SSHRemoteAgentHost] Protocol handshake completed');
await this._remoteAgentHostService.addSSHConnection({
address: result.address,
name: result.name,
connectionToken: result.connectionToken,
sshConfigHost: result.sshConfigHost,
connection: {
type: RemoteAgentHostEntryType.SSH,
address: result.address,
sshConfigHost: result.sshConfigHost,
},
}, protocolClient);
} catch (err) {
this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err);
@@ -141,10 +144,13 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA
await protocolClient.connect();
await this._remoteAgentHostService.addSSHConnection({
address: result.address,
name: result.name,
connectionToken: result.connectionToken,
sshConfigHost: result.sshConfigHost,
connection: {
type: RemoteAgentHostEntryType.SSH,
address: result.address,
sshConfigHost: result.sshConfigHost,
},
}, protocolClient);
const handle = new SSHAgentHostConnectionHandle(
@@ -0,0 +1,64 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IProtocolTransport } from '../common/state/sessionTransport.js';
import type { ITunnelAgentHostMainService, ITunnelRelayMessage } from '../common/tunnelAgentHost.js';
/**
* A protocol transport that relays messages through the shared process
* tunnel relay via IPC, instead of using a direct WebSocket connection.
*
* The shared process manages the actual dev tunnel relay connection
* and forwards messages bidirectionally through this IPC channel.
*/
export class TunnelRelayTransport extends Disposable implements IProtocolTransport {
private readonly _onMessage = this._register(new Emitter<IProtocolMessage>());
readonly onMessage = this._onMessage.event;
private readonly _onClose = this._register(new Emitter<void>());
readonly onClose = this._onClose.event;
constructor(
private readonly _connectionId: string,
private readonly _tunnelService: ITunnelAgentHostMainService,
) {
super();
// Listen for relay messages from the shared process
this._register(this._tunnelService.onDidRelayMessage((msg: ITunnelRelayMessage) => {
if (msg.connectionId === this._connectionId) {
try {
const parsed = JSON.parse(msg.data) as IProtocolMessage;
this._onMessage.fire(parsed);
} catch {
// Malformed message — drop
}
}
}));
// Listen for relay close
this._register(this._tunnelService.onDidRelayClose((closedId: string) => {
if (closedId === this._connectionId) {
this._onClose.fire();
}
}));
}
override dispose(): void {
// Tear down the shared-process relay connection
this._tunnelService.disconnect(this._connectionId).catch(() => { /* best effort */ });
super.dispose();
}
send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void {
this._tunnelService.relaySend(this._connectionId, JSON.stringify(message)).catch(() => {
// Send failed — connection probably closed
});
}
}
+34 -31
View File
@@ -11,7 +11,7 @@ import { URI } from '../../../base/common/uri.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js';
import { ILogService } from '../../log/common/log.js';
import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentService, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js';
import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentService, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js';
import { ISessionDataService } from '../common/sessionDataService.js';
import { ActionType, IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js';
import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IDirectoryEntry, type IResourceCopyParams, type IResourceCopyResult, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js';
@@ -91,20 +91,6 @@ export class AgentService extends Disposable implements IAgentService {
// ---- auth ---------------------------------------------------------------
async listAgents(): Promise<IAgentDescriptor[]> {
return [...this._providers.values()].map(p => p.getDescriptor());
}
async getResourceMetadata(): Promise<IResourceMetadata> {
const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources());
return { resources };
}
getResourceMetadataSync(): IResourceMetadata {
const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources());
return { resources };
}
async authenticate(params: IAuthenticateParams): Promise<IAuthenticateResult> {
this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`);
for (const provider of this._providers.values()) {
@@ -136,15 +122,27 @@ export class AgentService extends Disposable implements IAgentService {
return s;
}
try {
const customTitle = await ref.object.getMetadata('customTitle');
const [customTitle, isReadRaw, isDoneRaw] = await Promise.all([
ref.object.getMetadata('customTitle'),
ref.object.getMetadata('isRead'),
ref.object.getMetadata('isDone'),
]);
let updated = s;
if (customTitle) {
return { ...s, summary: customTitle };
updated = { ...updated, summary: customTitle };
}
if (isReadRaw !== undefined) {
updated = { ...updated, isRead: isReadRaw === 'true' };
}
if (isDoneRaw !== undefined) {
updated = { ...updated, isDone: isDoneRaw === 'true' };
}
return updated;
} finally {
ref.dispose();
}
} catch {
// ignore — title overlay is best-effort
} catch (e) {
this._logService.warn(`[AgentService] Failed to read session metadata overlay for ${s.session}`, e);
}
return s;
}));
@@ -153,15 +151,6 @@ export class AgentService extends Disposable implements IAgentService {
return result;
}
/**
* Refreshes the model list from all providers and publishes the updated
* agents (with their models) to root state via `root/agentsChanged`.
*/
async refreshModels(): Promise<void> {
this._logService.trace('[AgentService] refreshModels called');
this._updateAgents();
}
async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
const providerId = config?.provider ?? this._defaultProvider;
const provider = providerId ? this._providers.get(providerId) : undefined;
@@ -325,24 +314,36 @@ export class AgentService extends Disposable implements IAgentService {
}
const turns = this._buildTurnsFromMessages(messages);
// Check for a persisted custom title in the session database
// Check for persisted metadata in the session database
let title = meta.summary ?? 'Session';
let isRead: boolean | undefined;
let isDone: boolean | undefined;
const ref = this._sessionDataService.tryOpenDatabase?.(session);
if (ref) {
try {
const db = await ref;
if (db) {
try {
const customTitle = await db.object.getMetadata('customTitle');
const [customTitle, isReadRaw, isDoneRaw] = await Promise.all([
db.object.getMetadata('customTitle'),
db.object.getMetadata('isRead'),
db.object.getMetadata('isDone'),
]);
if (customTitle) {
title = customTitle;
}
if (isReadRaw !== undefined) {
isRead = isReadRaw === 'true';
}
if (isDoneRaw !== undefined) {
isDone = isDoneRaw === 'true';
}
} finally {
db.dispose();
}
}
} catch {
// Best-effort: fall back to agent-provided title
// Best-effort: fall back to agent-provided metadata
}
}
@@ -354,6 +355,8 @@ export class AgentService extends Disposable implements IAgentService {
createdAt: meta.startTime,
modifiedAt: meta.modifiedTime,
workingDirectory: meta.workingDirectory?.toString(),
isRead,
isDone,
};
this._stateManager.restoreSession(summary, turns);
@@ -87,7 +87,11 @@ export class AgentSideEffects extends Disposable {
} catch {
models = [];
}
return { provider: d.provider, displayName: d.displayName, description: d.description, models };
const protectedResources = a.getProtectedResources();
return {
provider: d.provider, displayName: d.displayName, description: d.description, models,
protectedResources: protectedResources.length > 0 ? protectedResources : undefined,
};
}));
this._stateManager.dispatchServerAction({ type: ActionType.RootAgentsChanged, agents: infos });
}
@@ -380,6 +384,14 @@ export class AgentSideEffects extends Disposable {
agent?.setCustomizationEnabled?.(action.uri, action.enabled);
break;
}
case ActionType.SessionIsReadChanged: {
this._persistSessionFlag(action.session, 'isRead', action.isRead ? 'true' : '');
break;
}
case ActionType.SessionIsDoneChanged: {
this._persistSessionFlag(action.session, 'isDone', action.isDone ? 'true' : '');
break;
}
}
}
@@ -392,6 +404,15 @@ export class AgentSideEffects extends Disposable {
});
}
private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void {
const ref = this._options.sessionDataService.openDatabase(URI.parse(session));
ref.object.setMetadata(key, value).catch(err => {
this._logService.warn(`[AgentSideEffects] Failed to persist ${key}`, err);
}).finally(() => {
ref.dispose();
});
}
/**
* Pushes the current pending message state from the session to the agent.
* The server controls queued message consumption; only steering messages
@@ -9,7 +9,6 @@ import { SequencerByKey } from '../../../../base/common/async.js';
import { Emitter } from '../../../../base/common/event.js';
import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js';
import { FileAccess } from '../../../../base/common/network.js';
import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js';
import { delimiter, dirname } from '../../../../base/common/path.js';
import { URI } from '../../../../base/common/uri.js';
import { generateUuid } from '../../../../base/common/uuid.js';
@@ -25,6 +24,7 @@ import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSessio
import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js';
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
import { forkCopilotSessionOnDisk, getCopilotDataDir, truncateCopilotSessionOnDisk } from './copilotAgentForking.js';
import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js';
/**
* Agent provider backed by the Copilot SDK {@link CopilotClient}.
@@ -59,16 +59,16 @@ export class CopilotAgent extends Disposable implements IAgent {
provider: 'copilot',
displayName: 'Agent Host - Copilot',
description: 'Copilot SDK agent running in a dedicated process',
requiresAuth: true,
};
}
getProtectedResources(): IAuthorizationProtectedResourceMetadata[] {
getProtectedResources(): IProtectedResourceMetadata[] {
return [{
resource: 'https://api.github.com',
resource_name: 'GitHub Copilot',
authorization_servers: ['https://github.com/login/oauth'],
scopes_supported: ['read:user', 'user:email'],
required: true,
}];
}
@@ -10,11 +10,12 @@ import { hasKey } from '../../../base/common/types.js';
import { URI } from '../../../base/common/uri.js';
import { ILogService } from '../../log/common/log.js';
import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js';
import { AgentSession, type IAgentService, type IAuthenticateParams } from '../common/agentService.js';
import { AgentSession, type IAgentService } from '../common/agentService.js';
import type { ICommandMap } from '../common/state/protocol/messages.js';
import { IActionEnvelope, INotification, isSessionAction, type ISessionAction } from '../common/state/sessionActions.js';
import { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js';
import {
AHP_AUTH_REQUIRED,
AHP_PROVIDER_NOT_FOUND,
AHP_SESSION_NOT_FOUND,
AHP_UNSUPPORTED_PROTOCOL_VERSION,
@@ -59,7 +60,7 @@ function jsonRpcErrorFrom(id: number, err: unknown): IJsonRpcResponse {
* Methods handled by the request dispatcher. Excludes `initialize` and
* `reconnect` which are handled during the handshake phase.
*/
type RequestMethod = Exclude<keyof ICommandMap, 'initialize' | 'reconnect' | 'authenticate'>;
type RequestMethod = Exclude<keyof ICommandMap, 'initialize' | 'reconnect'>;
/**
* Typed handler map: each key is a request method, each value is a handler
@@ -385,6 +386,8 @@ export class ProtocolServerHandler extends Disposable {
createdAt: s.startTime,
modifiedAt: s.modifiedTime,
workingDirectory: s.workingDirectory?.toString(),
isRead: s.isRead,
isDone: s.isDone,
}));
return { items };
},
@@ -425,6 +428,19 @@ export class ProtocolServerHandler extends Disposable {
resourceMove: async (_client, params) => {
return this._agentService.resourceMove(params);
},
createTerminal: async () => {
return null;
},
disposeTerminal: async () => {
return null;
},
authenticate: async (_client, params) => {
const result = await this._agentService.authenticate(params);
if (!result.authenticated) {
throw new ProtocolError(AHP_AUTH_REQUIRED, 'Authentication failed for resource: ' + params.resource);
}
return {};
},
};
@@ -496,21 +512,8 @@ export class ProtocolServerHandler extends Disposable {
* protocol. Returns a Promise if the method was recognized, undefined
* otherwise.
*/
private _handleExtensionRequest(method: string, params: unknown): Promise<unknown> | undefined {
private _handleExtensionRequest(method: string, _params: unknown): Promise<unknown> | undefined {
switch (method) {
case 'getResourceMetadata':
return this._agentService.getResourceMetadata();
case 'authenticate': {
const authParams = params as IAuthenticateParams;
if (!authParams || typeof authParams.resource !== 'string' || typeof authParams.token !== 'string') {
return Promise.reject(new ProtocolError(-32602, 'Invalid authenticate params'));
}
return this._agentService.authenticate(authParams);
}
case 'refreshModels':
return this._agentService.refreshModels();
case 'listAgents':
return this._agentService.listAgents();
case 'shutdown':
return this._agentService.shutdown();
default:
@@ -0,0 +1,333 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { Tunnel } from '@microsoft/dev-tunnels-contracts';
import type { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management';
import { createHash } from 'crypto';
import type WebSocket from 'ws';
import { Emitter, Event } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { ILogService } from '../../log/common/log.js';
import {
ITunnelAgentHostMainService,
TUNNEL_ADDRESS_PREFIX,
TUNNEL_AGENT_HOST_PORT,
TUNNEL_LAUNCHER_LABEL,
TUNNEL_MIN_PROTOCOL_VERSION,
TunnelTags,
type ITunnelConnectResult,
type ITunnelInfo,
type ITunnelRelayMessage,
} from '../common/tunnelAgentHost.js';
const LOG_PREFIX = '[TunnelAgentHost]';
/**
* Derive a connection token from a tunnel ID using the same convention
* as the VS Code CLI (see `get_connection_token` in cli/src/commands/tunnels.rs).
*/
function deriveConnectionToken(tunnelId: string): string {
const hash = createHash('sha256');
hash.update(tunnelId);
let result = hash.digest('base64url');
if (result.startsWith('-')) {
result = 'a' + result;
}
return result;
}
/** State for a single active tunnel relay connection. */
class TunnelConnection extends Disposable {
private readonly _onDidClose = this._register(new Emitter<void>());
readonly onDidClose = this._onDidClose.event;
private _closed = false;
constructor(
readonly connectionId: string,
readonly address: string,
readonly name: string,
readonly connectionToken: string,
private readonly _relay: { send: (data: string) => void; close: () => void },
private readonly _relayClient: { dispose(): void },
) {
super();
}
override dispose(): void {
if (!this._closed) {
this._closed = true;
this._relay.close();
this._relayClient.dispose();
this._onDidClose.fire();
}
super.dispose();
}
relaySend(data: string): void {
this._relay.send(data);
}
}
export class TunnelAgentHostMainService extends Disposable implements ITunnelAgentHostMainService {
declare readonly _serviceBrand: undefined;
private readonly _onDidRelayMessage = this._register(new Emitter<ITunnelRelayMessage>());
readonly onDidRelayMessage: Event<ITunnelRelayMessage> = this._onDidRelayMessage.event;
private readonly _onDidRelayClose = this._register(new Emitter<string>());
readonly onDidRelayClose: Event<string> = this._onDidRelayClose.event;
private readonly _connections = new Map<string, TunnelConnection>();
constructor(
@ILogService private readonly _logService: ILogService,
) {
super();
}
async listTunnels(token: string, authProvider: 'github' | 'microsoft', additionalTunnelNames?: string[]): Promise<ITunnelInfo[]> {
const client = await this._createManagementClient(token, authProvider);
const results: ITunnelInfo[] = [];
const seen = new Set<string>();
try {
// Enumerate all tunnels with the vscode-server-launcher label
const tunnels = await client.listTunnels(undefined, undefined, {
labels: [TUNNEL_LAUNCHER_LABEL],
requireAllLabels: true,
includePorts: true,
tokenScopes: ['connect'],
});
for (const tunnel of tunnels) {
const info = this._parseTunnelInfo(tunnel);
if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION) {
results.push(info);
seen.add(info.tunnelId);
}
}
} catch (err) {
this._logService.error(`${LOG_PREFIX} Failed to enumerate tunnels`, err);
}
// Look up additional tunnels by name
if (additionalTunnelNames) {
for (const tunnelName of additionalTunnelNames) {
try {
const [tunnel] = await client.listTunnels(undefined, undefined, {
labels: [tunnelName, TUNNEL_LAUNCHER_LABEL],
requireAllLabels: true,
includePorts: true,
tokenScopes: ['connect'],
limit: 1,
});
if (tunnel) {
const info = this._parseTunnelInfo(tunnel);
if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION && !seen.has(info.tunnelId)) {
results.push(info);
seen.add(info.tunnelId);
}
}
} catch (err) {
this._logService.warn(`${LOG_PREFIX} Failed to look up tunnel '${tunnelName}'`, err);
}
}
}
this._logService.info(`${LOG_PREFIX} Found ${results.length} tunnel(s) with agent host support`);
return results;
}
async connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise<ITunnelConnectResult> {
// Tear down any existing connection to this tunnel first.
// Each connect() call creates a fresh relay with its own protocol
// session, so the old one must be closed to avoid conflicts.
for (const [id, conn] of this._connections) {
if (conn.address === `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`) {
this._logService.info(`${LOG_PREFIX} Closing existing relay for tunnel ${tunnelId} before reconnecting`);
this._connections.delete(id);
conn.dispose();
break;
}
}
const client = await this._createManagementClient(token, authProvider);
const connectionId = generateUuid();
const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`;
this._logService.info(`${LOG_PREFIX} Connecting to tunnel ${tunnelId} in cluster ${clusterId}...`);
// Get the full tunnel with endpoints and access tokens
const tunnel: Tunnel = { tunnelId, clusterId };
const resolved = await client.getTunnel(tunnel, {
includePorts: true,
tokenScopes: ['connect'],
});
if (!resolved) {
throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`);
}
// Connect to the tunnel relay
const { TunnelRelayTunnelClient } = await import('@microsoft/dev-tunnels-connections');
const relayClient = new TunnelRelayTunnelClient(client);
relayClient.acceptLocalConnectionsForForwardedPorts = false;
if (resolved.endpoints) {
relayClient.endpoints = resolved.endpoints;
}
await relayClient.connect(resolved);
this._logService.info(`${LOG_PREFIX} Tunnel relay connected, waiting for port ${TUNNEL_AGENT_HOST_PORT}...`);
// Wait for the agent host port to become available
await relayClient.waitForForwardedPort(TUNNEL_AGENT_HOST_PORT);
// Connect to the forwarded port — returns a Duplex stream
const portStream = await relayClient.connectToForwardedPort(TUNNEL_AGENT_HOST_PORT);
this._logService.info(`${LOG_PREFIX} Connected to forwarded port ${TUNNEL_AGENT_HOST_PORT}`);
// Derive connection token from tunnel ID (matches CLI convention)
const connectionToken = deriveConnectionToken(tunnelId);
// Parse display name from tags
const tags = new TunnelTags(resolved.labels);
const name = tags.name || resolved.name || tunnelId;
// Create WebSocket over the port stream
const relay = await this._createWebSocketRelay(
portStream,
connectionToken,
connectionId,
);
const conn = new TunnelConnection(
connectionId,
address,
name,
connectionToken,
relay,
relayClient,
);
conn.onDidClose(() => {
this._connections.delete(connectionId);
this._onDidRelayClose.fire(connectionId);
});
this._connections.set(connectionId, conn);
return { connectionId, address, name, connectionToken };
}
async relaySend(connectionId: string, message: string): Promise<void> {
const conn = this._connections.get(connectionId);
if (conn) {
conn.relaySend(message);
}
}
async disconnect(connectionId: string): Promise<void> {
const conn = this._connections.get(connectionId);
if (conn) {
conn.dispose();
}
}
private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft'): Promise<TunnelManagementHttpClient> {
const mgmt = await import('@microsoft/dev-tunnels-management');
const authHeader = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`;
return new mgmt.TunnelManagementHttpClient(
'vscode-sessions',
mgmt.ManagementApiVersions.Version20230927preview,
async () => authHeader,
);
}
private _parseTunnelInfo(tunnel: Tunnel): ITunnelInfo | undefined {
const labels = tunnel.labels ?? [];
const tags = new TunnelTags(labels);
if (tags.protocolVersion < TUNNEL_MIN_PROTOCOL_VERSION) {
return undefined;
}
const tunnelId = tunnel.tunnelId;
const clusterId = tunnel.clusterId;
if (!tunnelId || !clusterId) {
return undefined;
}
const name = tags.name || tunnel.name || tunnelId;
const rawCount = tunnel.status?.hostConnectionCount;
const hostConnectionCount = typeof rawCount === 'number' ? rawCount : (rawCount?.current ?? 0);
return {
tunnelId,
clusterId,
name,
tags: labels,
protocolVersion: tags.protocolVersion,
hostConnectionCount,
};
}
private async _createWebSocketRelay(
portStream: NodeJS.ReadWriteStream,
connectionToken: string,
connectionId: string,
): Promise<{ send: (data: string) => void; close: () => void }> {
const WS = await import('ws');
return new Promise((resolve, reject) => {
// Construct WebSocket URL — the stream is already connected to the right port
let url = `ws://localhost:${TUNNEL_AGENT_HOST_PORT}`;
if (connectionToken) {
url += `?tkn=${encodeURIComponent(connectionToken)}`;
}
// Create WebSocket over the existing stream from the tunnel relay
const ws = new WS.WebSocket(url, {
createConnection: (() => portStream) as unknown as WebSocket.ClientOptions['createConnection'],
});
ws.on('open', () => {
this._logService.info(`${LOG_PREFIX} WebSocket relay connected to agent host via tunnel`);
resolve({
send: (data: string) => {
if (ws.readyState === ws.OPEN) {
ws.send(data);
}
},
close: () => ws.close(),
});
});
ws.on('message', (data: WebSocket.RawData) => {
let text: string;
if (Array.isArray(data)) {
text = Buffer.concat(data).toString();
} else if (data instanceof ArrayBuffer) {
text = Buffer.from(new Uint8Array(data)).toString();
} else {
text = data.toString();
}
this._onDidRelayMessage.fire({ connectionId, data: text });
});
ws.on('close', () => {
const conn = this._connections.get(connectionId);
if (conn) {
conn.dispose();
}
});
ws.on('error', (wsErr: unknown) => {
this._logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`);
reject(wsErr);
});
});
}
}
@@ -12,7 +12,7 @@ import { TestInstantiationService } from '../../../instantiation/test/common/ins
import { IConfigurationService, type IConfigurationChangeEvent } from '../../../configuration/common/configuration.js';
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
import { RemoteAgentHostService } from '../../electron-browser/remoteAgentHostServiceImpl.js';
import { parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js';
import { parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, entryToRawEntry, type IRawRemoteAgentHostEntry, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js';
import { DeferredPromise } from '../../../../base/common/async.js';
// ---- Mock protocol client ---------------------------------------------------
@@ -47,7 +47,7 @@ class TestConfigurationService {
private readonly _onDidChangeConfiguration = new Emitter<Partial<IConfigurationChangeEvent>>();
readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event;
private _entries: IRemoteAgentHostEntry[] = [];
private _entries: IRawRemoteAgentHostEntry[] = [];
private _enabled = true;
getValue(key?: string): unknown {
@@ -64,15 +64,18 @@ class TestConfigurationService {
}
async updateValue(_key: string, value: unknown): Promise<void> {
this.setEntries((value as IRemoteAgentHostEntry[] | undefined) ?? []);
this._entries = (value as IRawRemoteAgentHostEntry[] | undefined) ?? [];
this._onDidChangeConfiguration.fire({
affectsConfiguration: (key: string) => key === RemoteAgentHostsSettingId || key === RemoteAgentHostsEnabledSettingId,
});
}
get entries(): readonly IRemoteAgentHostEntry[] {
get entries(): readonly IRawRemoteAgentHostEntry[] {
return this._entries;
}
setEntries(entries: IRemoteAgentHostEntry[]): void {
this._entries = entries;
this._entries = entries.map(entryToRawEntry).filter((e): e is IRawRemoteAgentHostEntry => e !== undefined);
this._onDidChangeConfiguration.fire({
affectsConfiguration: (key: string) => key === RemoteAgentHostsSettingId || key === RemoteAgentHostsEnabledSettingId,
});
@@ -158,7 +161,7 @@ suite('RemoteAgentHostService', () => {
});
test('creates connection when setting is updated', async () => {
configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]);
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
// Resolve the connect promise
assert.strictEqual(createdClients.length, 1);
@@ -172,7 +175,7 @@ suite('RemoteAgentHostService', () => {
});
test('getConnection returns client after successful connect', async () => {
configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]);
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
createdClients[0].connectDeferred.complete();
await waitForConnected();
@@ -183,7 +186,7 @@ suite('RemoteAgentHostService', () => {
test('removes connection when setting entry is removed', async () => {
// Add a connection
configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]);
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
createdClients[0].connectDeferred.complete();
await waitForConnected();
@@ -197,7 +200,7 @@ suite('RemoteAgentHostService', () => {
});
test('fires onDidChangeConnections when connection closes', async () => {
configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]);
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
createdClients[0].connectDeferred.complete();
await waitForConnected();
@@ -214,7 +217,7 @@ suite('RemoteAgentHostService', () => {
});
test('removes connection on connect failure', async () => {
configService.setEntries([{ address: 'ws://bad:9999', name: 'Bad' }]);
configService.setEntries([{ name: 'Bad', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://bad:9999' } }]);
assert.strictEqual(createdClients.length, 1);
// Fail the connection and wait for the service to react
@@ -228,8 +231,8 @@ suite('RemoteAgentHostService', () => {
test('manages multiple connections independently', async () => {
configService.setEntries([
{ address: 'ws://host1:8080', name: 'Host 1' },
{ address: 'ws://host2:8080', name: 'Host 2' },
{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } },
{ name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:8080' } },
]);
assert.strictEqual(createdClients.length, 2);
@@ -247,14 +250,14 @@ suite('RemoteAgentHostService', () => {
});
test('does not re-create existing connections on setting update', async () => {
configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]);
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
createdClients[0].connectDeferred.complete();
await waitForConnected();
const firstClientId = createdClients[0].clientId;
// Update setting with same address (but different name)
configService.setEntries([{ address: 'ws://host1:8080', name: 'Renamed' }]);
configService.setEntries([{ name: 'Renamed', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
// Should NOT have created a second client
assert.strictEqual(createdClients.length, 1);
@@ -271,9 +274,9 @@ suite('RemoteAgentHostService', () => {
test('addRemoteAgentHost stores the entry and waits for connection', async () => {
const connectionPromise = service.addRemoteAgentHost({
address: 'ws://host1:8080',
name: 'Host 1',
connectionToken: 'secret-token',
connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' },
});
assert.deepStrictEqual(configService.entries, [{
@@ -296,14 +299,14 @@ suite('RemoteAgentHostService', () => {
});
test('addRemoteAgentHost updates existing configured entries without reconnecting', async () => {
configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]);
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]);
createdClients[0].connectDeferred.complete();
await waitForConnected();
const connection = await service.addRemoteAgentHost({
address: 'ws://host1:8080',
name: 'Updated Host',
connectionToken: 'new-token',
connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' },
});
assert.strictEqual(createdClients.length, 1);
@@ -324,24 +327,24 @@ suite('RemoteAgentHostService', () => {
test('addRemoteAgentHost appends when adding a second host', async () => {
// Add first host
const firstPromise = service.addRemoteAgentHost({
address: 'host1:8080',
name: 'Host 1',
connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' },
});
createdClients[0].connectDeferred.complete();
await firstPromise;
// Add second host
const secondPromise = service.addRemoteAgentHost({
address: 'host2:9090',
name: 'Host 2',
connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host2:9090' },
});
createdClients[1].connectDeferred.complete();
await secondPromise;
assert.strictEqual(createdClients.length, 2);
assert.deepStrictEqual(configService.entries, [
{ address: 'host1:8080', name: 'Host 1' },
{ address: 'host2:9090', name: 'Host 2' },
{ address: 'host1:8080', name: 'Host 1', connectionToken: undefined },
{ address: 'host2:9090', name: 'Host 2', connectionToken: undefined },
]);
assert.strictEqual(service.connections.length, 2);
});
@@ -350,9 +353,9 @@ suite('RemoteAgentHostService', () => {
// Simulate a fast connect: the mock client resolves synchronously
// during the config change handler, before addRemoteAgentHost has a
// chance to create its DeferredPromise wait.
const originalSetEntries = configService.setEntries.bind(configService);
configService.setEntries = (entries: IRemoteAgentHostEntry[]) => {
originalSetEntries(entries);
const originalUpdateValue = configService.updateValue.bind(configService);
configService.updateValue = async (key: string, value: unknown) => {
await originalUpdateValue(key, value);
// Complete the connection synchronously inside the config change callback
if (createdClients.length > 0) {
createdClients[createdClients.length - 1].connectDeferred.complete();
@@ -360,8 +363,8 @@ suite('RemoteAgentHostService', () => {
};
const connection = await service.addRemoteAgentHost({
address: 'fast-host:1234',
name: 'Fast Host',
connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'fast-host:1234' },
});
assert.strictEqual(connection.address, 'fast-host:1234');
@@ -369,7 +372,7 @@ suite('RemoteAgentHostService', () => {
});
test('disabling the enabled setting disconnects all remotes', async () => {
configService.setEntries([{ address: 'host1:8080', name: 'Host 1' }]);
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]);
createdClients[0].connectDeferred.complete();
await waitForConnected();
assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1);
@@ -383,13 +386,13 @@ suite('RemoteAgentHostService', () => {
configService.setEnabled(false);
await assert.rejects(
() => service.addRemoteAgentHost({ address: 'host1:8080', name: 'Host 1' }),
() => service.addRemoteAgentHost({ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }),
/not enabled/,
);
});
test('re-enabling reconnects configured remotes', async () => {
configService.setEntries([{ address: 'host1:8080', name: 'Host 1' }]);
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]);
createdClients[0].connectDeferred.complete();
await waitForConnected();
assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1);
@@ -406,8 +409,8 @@ suite('RemoteAgentHostService', () => {
test('removeRemoteAgentHost removes entry and disconnects', async () => {
configService.setEntries([
{ address: 'ws://host1:8080', name: 'Host 1' },
{ address: 'ws://host2:9090', name: 'Host 2' },
{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } },
{ name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:9090' } },
]);
createdClients[0].connectDeferred.complete();
createdClients[1].connectDeferred.complete();
@@ -417,7 +420,7 @@ suite('RemoteAgentHostService', () => {
await service.removeRemoteAgentHost('ws://host1:8080');
assert.deepStrictEqual(configService.entries, [
{ address: 'ws://host2:9090', name: 'Host 2' },
{ address: 'ws://host2:9090', name: 'Host 2', connectionToken: undefined },
]);
assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1);
assert.strictEqual(service.getConnection('ws://host1:8080'), undefined);
@@ -425,7 +428,7 @@ suite('RemoteAgentHostService', () => {
});
test('removeRemoteAgentHost normalizes address before removing', async () => {
configService.setEntries([{ address: 'host1:8080', name: 'Host 1' }]);
configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]);
createdClients[0].connectDeferred.complete();
await waitForConnected();
@@ -86,24 +86,6 @@ suite('AgentService (node dispatcher)', () => {
});
});
// ---- listAgents -----------------------------------------------------
suite('listAgents', () => {
test('returns descriptors from all registered providers', async () => {
service.registerProvider(copilotAgent);
const agents = await service.listAgents();
assert.strictEqual(agents.length, 1);
assert.ok(agents.some(a => a.provider === 'copilot'));
});
test('returns empty array when no providers are registered', async () => {
const agents = await service.listAgents();
assert.strictEqual(agents.length, 0);
});
});
// ---- createSession --------------------------------------------------
suite('createSession', () => {
@@ -211,46 +193,6 @@ suite('AgentService (node dispatcher)', () => {
assert.strictEqual(sessions.length, 1);
assert.strictEqual(sessions[0].summary, 'Auto-generated Title');
});
test('refreshModels publishes models in root state via agentsChanged', async () => {
service.registerProvider(copilotAgent);
const envelopes: IActionEnvelope[] = [];
disposables.add(service.onDidAction(e => envelopes.push(e)));
service.refreshModels();
// Model fetch is async inside AgentSideEffects — wait for it
await new Promise(r => setTimeout(r, 50));
const agentsChanged = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged);
assert.ok(agentsChanged);
});
});
// ---- getResourceMetadata --------------------------------------------
suite('getResourceMetadata', () => {
test('aggregates protected resources from all providers', async () => {
service.registerProvider(copilotAgent);
const mockAgent = new MockAgent('other');
disposables.add(toDisposable(() => mockAgent.dispose()));
service.registerProvider(mockAgent);
const metadata = await service.getResourceMetadata();
// copilot agent returns one resource (https://api.github.com),
// generic MockAgent('other') returns empty
assert.deepStrictEqual(metadata, {
resources: [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }],
});
});
test('returns empty resources when no providers registered', async () => {
const metadata = await service.getResourceMetadata();
assert.deepStrictEqual(metadata, { resources: [] });
});
});
// ---- authenticate ---------------------------------------------------
@@ -9,6 +9,7 @@ import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/c
import { URI } from '../../../../base/common/uri.js';
import { type ISyncedCustomization } from '../../common/agentPluginManager.js';
import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js';
import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js';
import { CustomizationStatus, ToolResultContentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult } from '../../common/state/sessionState.js';
/** Well-known auto-generated title used by the 'with-title' prompt. */
@@ -48,12 +49,12 @@ export class MockAgent implements IAgent {
constructor(readonly id: AgentProvider = 'mock') { }
getDescriptor(): IAgentDescriptor {
return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, requiresAuth: this.id === 'copilot' };
return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent` };
}
getProtectedResources(): IAuthorizationProtectedResourceMetadata[] {
getProtectedResources(): IProtectedResourceMetadata[] {
if (this.id === 'copilot') {
return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }];
return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }];
}
return [];
}
@@ -179,7 +180,7 @@ export class ScriptedMockAgent implements IAgent {
}
getDescriptor(): IAgentDescriptor {
return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', requiresAuth: false };
return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent' };
}
getProtectedResources(): IAuthorizationProtectedResourceMetadata[] {
@@ -9,7 +9,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { NullLogService } from '../../../log/common/log.js';
import type { IAgentCreateSessionConfig, IAgentDescriptor, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../../common/agentService.js';
import type { IAgentCreateSessionConfig, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../../common/agentService.js';
import { IResourceReadResult } from '../../common/state/protocol/commands.js';
import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js';
import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js';
@@ -101,10 +101,7 @@ class MockAgentService implements IAgentService {
}
unsubscribe(_resource: URI): void { }
async shutdown(): Promise<void> { }
async getResourceMetadata(): Promise<IResourceMetadata> { return { resources: [] }; }
async authenticate(_params: IAuthenticateParams): Promise<IAuthenticateResult> { return { authenticated: true }; }
async refreshModels(): Promise<void> { }
async listAgents(): Promise<IAgentDescriptor[]> { return []; }
async resourceWrite(_params: IResourceWriteParams): Promise<IResourceWriteResult> { return {}; }
async resourceList(uri: URI): Promise<IResourceListResult> {
this.browsedUris.push(uri);
@@ -427,28 +424,16 @@ suite('ProtocolServerHandler', () => {
// ---- Extension methods: auth ----------------------------------------
test('getResourceMetadata returns resource metadata via extension request', async () => {
const transport = connectClient('client-metadata');
transport.sent.length = 0;
const responsePromise = waitForResponse(transport, 2);
transport.simulateMessage(request(2, 'getResourceMetadata'));
const resp = await responsePromise as { result?: { resources: unknown[] } };
assert.ok(resp?.result);
assert.ok(Array.isArray(resp.result!.resources));
});
test('authenticate returns result via extension request', async () => {
test('authenticate returns result via typed request', async () => {
const transport = connectClient('client-auth');
transport.sent.length = 0;
const responsePromise = waitForResponse(transport, 2);
transport.simulateMessage(request(2, 'authenticate', { resource: 'https://api.github.com', token: 'test-token' }));
const resp = await responsePromise as { result?: { authenticated: boolean } };
const resp = await responsePromise as { result?: Record<string, unknown>; error?: { code: number; message: string } };
assert.ok(resp?.result);
assert.strictEqual(resp.result!.authenticated, true);
assert.ok(!resp.error, `unexpected error: ${resp.error?.message}`);
assert.deepStrictEqual(resp.result, {});
});
test('extension request preserves ProtocolError code and data', async () => {
@@ -1441,4 +1441,100 @@ suite('Protocol WebSocket E2E', function () {
}
assert.ok(gotError, 'should get error for invalid fork source session');
});
test('isRead and isDone flags survive in listSessions after dispatch', async function () {
this.timeout(15_000);
const sessionUri = await createAndSubscribeSession(client, 'test-read-done-flags');
// Dispatch isDone=true
client.notify('dispatchAction', {
clientSeq: 1,
action: {
type: 'session/isDoneChanged',
session: sessionUri,
isDone: true,
},
});
await client.waitForNotification(n => isActionNotification(n, 'session/isDoneChanged'));
// Dispatch isRead=true
client.notify('dispatchAction', {
clientSeq: 2,
action: {
type: 'session/isReadChanged',
session: sessionUri,
isRead: true,
},
});
await client.waitForNotification(n => isActionNotification(n, 'session/isReadChanged'));
// Verify the flags are reflected in the subscribed session state
const snapshot = await client.call<ISubscribeResult>('subscribe', { resource: sessionUri });
const state = snapshot.snapshot.state as ISessionState;
assert.strictEqual(state.summary.isDone, true, 'isDone should be true in snapshot');
assert.strictEqual(state.summary.isRead, true, 'isRead should be true in snapshot');
// Poll listSessions until the persisted flags appear (async DB write)
client.close();
const client2 = new TestProtocolClient(server.port);
await client2.connect();
await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-read-done-flags-2' });
let session: IListSessionsResult['items'][0] | undefined;
for (let i = 0; i < 20; i++) {
const result = await client2.call<IListSessionsResult>('listSessions');
session = result.items.find(s => s.resource === sessionUri);
if (session?.isDone === true && session?.isRead === true) {
break;
}
await timeout(100);
}
assert.ok(session, 'session should appear in listSessions');
assert.strictEqual(session.isDone, true, 'isDone should be persisted in listSessions');
assert.strictEqual(session.isRead, true, 'isRead should be persisted in listSessions');
client2.close();
});
test('dispatching isRead=false explicitly persists as false', async function () {
this.timeout(15_000);
const sessionUri = await createAndSubscribeSession(client, 'test-isread-false');
// On a fresh session, isRead is undefined in the DB. Dispatching
// isRead=false should persist the value so that listSessions
// returns an explicit `false` rather than omitting the field.
client.notify('dispatchAction', {
clientSeq: 1,
action: {
type: 'session/isReadChanged',
session: sessionUri,
isRead: false,
},
});
await client.waitForNotification(n => isActionNotification(n, 'session/isReadChanged'));
client.close();
const client2 = new TestProtocolClient(server.port);
await client2.connect();
await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-isread-false-2' });
let session: IListSessionsResult['items'][0] | undefined;
for (let i = 0; i < 20; i++) {
const result = await client2.call<IListSessionsResult>('listSessions');
session = result.items.find(s => s.resource === sessionUri);
if (session && session.isRead === false) {
break;
}
await timeout(100);
}
assert.ok(session, 'session should appear in listSessions');
assert.strictEqual(session.isRead, false, 'isRead=false should be explicitly persisted');
client2.close();
});
});
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import * as dom from '../../../../base/browser/dom.js';
import { SubmenuAction, toAction } from '../../../../base/common/actions.js';
import { IAction, SubmenuAction, toAction } from '../../../../base/common/actions.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { MarkdownString } from '../../../../base/common/htmlContent.js';
@@ -14,8 +14,11 @@ import { Schemas } from '../../../../base/common/network.js';
import { localize } from '../../../../nls.js';
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js';
import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { TUNNEL_ADDRESS_PREFIX } from '../../../../platform/agentHost/common/tunnelAgentHost.js';
import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js';
import { IOutputService } from '../../../../workbench/services/output/common/output.js';
import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
@@ -62,6 +65,8 @@ interface IWorkspacePickerItem {
readonly checked?: boolean;
/** Remote provider reference for gear menu actions. */
readonly remoteProvider?: ISessionsProvider;
/** When true, clicking this item triggers the tunnel connection command. */
readonly tunnelAction?: boolean;
}
/**
@@ -98,6 +103,8 @@ export class WorkspacePicker extends Disposable {
@IClipboardService private readonly clipboardService: IClipboardService,
@IPreferencesService private readonly preferencesService: IPreferencesService,
@IOutputService private readonly outputService: IOutputService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@ICommandService private readonly commandService: ICommandService,
) {
super();
@@ -182,13 +189,20 @@ export class WorkspacePicker extends Disposable {
const delegate: IActionListDelegate<IWorkspacePickerItem> = {
onSelect: (item) => {
this.actionWidgetService.hide();
if (item.selection && this._isProviderUnavailable(item.selection.providerId)) {
if (item.tunnelAction) {
this.commandService.executeCommand('workbench.action.sessions.connectViaTunnel');
} else if (item.selection && this._isProviderUnavailable(item.selection.providerId)) {
// Workspace belongs to an unavailable remote — ignore selection
return;
}
if (item.remoteProvider && item.browseActionIndex === undefined) {
// Disconnected remote host — show options menu after widget hides
this._showRemoteHostOptionsDelayed(item.remoteProvider);
if (item.remoteProvider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) {
// Disconnected tunnel — trigger connection flow
this.commandService.executeCommand('workbench.action.sessions.connectViaTunnel');
} else {
// Disconnected SSH host — show options menu after widget hides
this._showRemoteHostOptionsDelayed(item.remoteProvider);
}
} else if (item.browseActionIndex !== undefined) {
this._executeBrowseAction(item.browseActionIndex);
} else if (item.selection) {
@@ -414,37 +428,57 @@ export class WorkspacePicker extends Disposable {
}
}
if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator && remoteProviders.length) {
items.push({ kind: ActionListItemKind.Separator, label: '' });
}
for (const provider of remoteProviders) {
const status = provider.connectionStatus!.get();
const isConnected = status === RemoteAgentHostConnectionStatus.Connected;
const providerBrowseIndex = allBrowseActions.findIndex(a => a.providerId === provider.id);
if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) {
items.push({ kind: ActionListItemKind.Separator, label: '' });
const toolbarActions: IAction[] = [];
// Gear menu only for SSH hosts, not tunnel providers
if (!provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) {
toolbarActions.push(toAction({
id: `workspacePicker.remote.gear.${provider.id}`,
label: localize('workspacePicker.remoteOptions', "Options"),
class: ThemeIcon.asClassName(Codicon.gear),
run: () => {
this.actionWidgetService.hide();
this._showRemoteHostOptionsDelayed(provider);
},
}));
}
const isTunnel = provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX);
items.push({
kind: ActionListItemKind.Action,
label: provider.label,
description: this._getStatusDescription(status),
hover: { content: this._getStatusHover(status, provider.remoteAddress) },
group: { title: '', icon: Codicon.remote },
disabled: !isConnected,
group: { title: '', icon: isTunnel ? Codicon.cloud : Codicon.remote },
disabled: isTunnel ? false : !isConnected,
item: {
browseActionIndex: isConnected && providerBrowseIndex >= 0 ? providerBrowseIndex : undefined,
remoteProvider: provider,
},
toolbarActions: [
toAction({
id: `workspacePicker.remote.gear.${provider.id}`,
label: localize('workspacePicker.remoteOptions', "Options"),
class: ThemeIcon.asClassName(Codicon.gear),
run: () => {
this.actionWidgetService.hide();
this._showRemoteHostOptionsDelayed(provider);
},
}),
],
toolbarActions,
});
}
// "Tunnels..." entry — shown when remote agent hosts are enabled
if (this.configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {
if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) {
items.push({ kind: ActionListItemKind.Separator, label: '' });
}
items.push({
kind: ActionListItemKind.Action,
label: localize('workspacePicker.tunnels', "Tunnels..."),
group: { title: '', icon: Codicon.cloud },
item: { tunnelAction: true },
});
}
@@ -100,6 +100,13 @@ class LocalSessionAdapter implements ISession {
? LocalAgentHostSessionsProvider.buildWorkspace(metadata.workingDirectory)
: undefined);
if (metadata.isRead === false) {
this.isRead.set(false, undefined);
}
if (metadata.isDone) {
this.isArchived.set(true, undefined);
}
this.mainChat = {
resource: this.resource,
createdAt: this.createdAt,
@@ -139,6 +146,16 @@ class LocalSessionAdapter implements ISession {
didChange = true;
}
if (metadata.isRead !== undefined && metadata.isRead !== this.isRead.get()) {
this.isRead.set(metadata.isRead, undefined);
didChange = true;
}
if (metadata.isDone !== undefined && metadata.isDone !== this.isArchived.get()) {
this.isArchived.set(metadata.isDone, undefined);
didChange = true;
}
return didChange;
}
}
@@ -222,6 +239,10 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
this._refreshSessions();
} else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) {
this._handleTitleChanged(e.action.session, e.action.title);
} else if (e.action.type === ActionType.SessionIsReadChanged && isSessionAction(e.action)) {
this._handleIsReadChanged(e.action.session, e.action.isRead);
} else if (e.action.type === ActionType.SessionIsDoneChanged && isSessionAction(e.action)) {
this._handleIsDoneChanged(e.action.session, e.action.isDone);
}
}));
}
@@ -332,12 +353,26 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
// -- Session Actions --
async archiveSession(_sessionId: string): Promise<void> {
// Agent host sessions don't support archiving
async archiveSession(sessionId: string): Promise<void> {
const rawId = this._rawIdFromChatId(sessionId);
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
if (cached && rawId) {
cached.isArchived.set(true, undefined);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: true };
this._agentHostService.dispatchAction(action, this._agentHostService.clientId, this._agentHostService.nextClientSeq());
}
}
async unarchiveSession(_sessionId: string): Promise<void> {
// Agent host sessions don't support unarchiving
async unarchiveSession(sessionId: string): Promise<void> {
const rawId = this._rawIdFromChatId(sessionId);
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
if (cached && rawId) {
cached.isArchived.set(false, undefined);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: false };
this._agentHostService.dispatchAction(action, this._agentHostService.clientId, this._agentHostService.nextClientSeq());
}
}
async deleteSession(sessionId: string): Promise<void> {
@@ -368,8 +403,10 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
setRead(sessionId: string, read: boolean): void {
const rawId = this._rawIdFromChatId(sessionId);
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
if (cached) {
if (cached && rawId) {
cached.isRead.set(read, undefined);
const action = { type: ActionType.SessionIsReadChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isRead: read };
this._agentHostService.dispatchAction(action, this._agentHostService.clientId, this._agentHostService.nextClientSeq());
}
}
@@ -529,7 +566,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
}
}
private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string }): void {
private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string; isRead?: boolean; isDone?: boolean }): void {
const sessionUri = URI.parse(summary.resource);
const rawId = AgentSession.id(sessionUri);
if (this._sessionCache.has(rawId)) {
@@ -545,6 +582,8 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
modifiedTime: summary.modifiedAt,
summary: summary.title,
workingDirectory: workingDir,
isRead: summary.isRead,
isDone: summary.isDone,
};
const provider = AgentSession.provider(sessionUri) ?? DEFAULT_AGENT_PROVIDER;
const cached = new LocalSessionAdapter(meta, this.id, sessionTypeForProvider(provider), this.sessionTypes[0].id);
@@ -570,6 +609,24 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi
}
}
private _handleIsReadChanged(session: string, isRead: boolean): void {
const rawId = AgentSession.id(session);
const cached = this._sessionCache.get(rawId);
if (cached) {
cached.isRead.set(isRead, undefined);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
}
}
private _handleIsDoneChanged(session: string, isDone: boolean): void {
const rawId = AgentSession.id(session);
const cached = this._sessionCache.get(rawId);
if (cached) {
cached.isArchived.set(isDone, undefined);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
}
}
private _rawIdFromChatId(chatId: string): string | undefined {
const prefix = `${this.id}:`;
const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId;
@@ -10,9 +10,9 @@ import { URI } from '../../../../base/common/uri.js';
import * as nls from '../../../../nls.js';
import { agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js';
import { type AgentProvider, type IAgentConnection } from '../../../../platform/agentHost/common/agentService.js';
import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { type URI as ProtocolURI } from '../../../../platform/agentHost/common/state/protocol/state.js';
import { isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js';
import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { TunnelAgentHostsSettingId } from '../../../../platform/agentHost/common/tunnelAgentHost.js';
import { type IProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../platform/agentHost/common/state/protocol/state.js';
import { SessionClientState } from '../../../../platform/agentHost/common/state/sessionClientState.js';
import { ROOT_STATE_URI, type IAgentInfo, type ICustomizationRef, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
@@ -153,7 +153,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
private _reconcileProviders(): void {
const enabled = this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId);
const entries = enabled ? this._remoteAgentHostService.configuredEntries : [];
const desiredAddresses = new Set(entries.map(e => e.address));
const desiredAddresses = new Set(entries.map(e => getEntryAddress(e)));
// Remove providers no longer configured
for (const [address] of this._providerStores) {
@@ -164,26 +164,28 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
// Add or recreate providers for configured entries
for (const entry of entries) {
const existing = this._providerInstances.get(entry.address);
if (existing && existing.label !== (entry.name || entry.address)) {
const address = getEntryAddress(entry);
const existing = this._providerInstances.get(address);
if (existing && existing.label !== (entry.name || address)) {
// Name changed — recreate since ISessionsProvider.label is readonly
this._providerStores.deleteAndDispose(entry.address);
this._providerStores.deleteAndDispose(address);
}
if (!this._providerStores.has(entry.address)) {
if (!this._providerStores.has(address)) {
this._createProvider(entry);
}
}
}
private _createProvider(entry: IRemoteAgentHostEntry): void {
const address = getEntryAddress(entry);
const store = new DisposableStore();
const provider = this._instantiationService.createInstance(
RemoteAgentHostSessionsProvider, { address: entry.address, name: entry.name });
RemoteAgentHostSessionsProvider, { address, name: entry.name });
store.add(provider);
store.add(this._sessionsProvidersService.registerProvider(provider));
this._providerInstances.set(entry.address, provider);
store.add(toDisposable(() => this._providerInstances.delete(entry.address)));
this._providerStores.set(entry.address, store);
this._providerInstances.set(address, provider);
store.add(toDisposable(() => this._providerInstances.delete(address)));
this._providerStores.set(address, store);
}
/**
@@ -193,24 +195,26 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
private _reconnectSSHEntries(): void {
const entries = this._remoteAgentHostService.configuredEntries;
for (const entry of entries) {
if (!entry.sshConfigHost) {
if (entry.connection.type !== RemoteAgentHostEntryType.SSH || !entry.connection.sshConfigHost) {
continue;
}
const address = getEntryAddress(entry);
const sshConfigHost = entry.connection.sshConfigHost;
// Skip if already connected or reconnecting
const hasConnection = this._remoteAgentHostService.connections.some(
c => c.address === entry.address && c.status === RemoteAgentHostConnectionStatus.Connected
c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected
);
if (hasConnection || this._pendingSSHReconnects.has(entry.sshConfigHost)) {
if (hasConnection || this._pendingSSHReconnects.has(sshConfigHost)) {
continue;
}
this._pendingSSHReconnects.add(entry.sshConfigHost);
this._logService.info(`[RemoteAgentHost] Re-establishing SSH tunnel for ${entry.sshConfigHost}`);
this._sshService.reconnect(entry.sshConfigHost, entry.name).then(() => {
this._pendingSSHReconnects.delete(entry.sshConfigHost!);
this._logService.info(`[RemoteAgentHost] SSH tunnel re-established for ${entry.sshConfigHost}`);
this._pendingSSHReconnects.add(sshConfigHost);
this._logService.info(`[RemoteAgentHost] Re-establishing SSH tunnel for ${sshConfigHost}`);
this._sshService.reconnect(sshConfigHost, entry.name).then(() => {
this._pendingSSHReconnects.delete(sshConfigHost);
this._logService.info(`[RemoteAgentHost] SSH tunnel re-established for ${sshConfigHost}`);
}).catch(err => {
this._pendingSSHReconnects.delete(entry.sshConfigHost!);
this._logService.error(`[RemoteAgentHost] SSH reconnect failed for ${entry.sshConfigHost}`, err);
this._pendingSSHReconnects.delete(sshConfigHost);
this._logService.error(`[RemoteAgentHost] SSH reconnect failed for ${sshConfigHost}`, err);
});
}
}
@@ -276,11 +280,9 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
const authority = agentHostAuthority(address);
store.add(this._agentHostFileSystemService.registerAuthority(authority, connection));
// Forward non-session actions to client state
// Forward action envelopes to client state (both root and session)
store.add(loggedConnection.onDidAction(envelope => {
if (!isSessionAction(envelope.action)) {
connState.clientState.receiveEnvelope(envelope);
}
connState.clientState.receiveEnvelope(envelope);
}));
// Forward notifications to client state
@@ -304,9 +306,6 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
loggedConnection.logError('subscribe(root)', err);
});
// Authenticate with this new connection and refresh models afterward
this._authenticateWithConnection(loggedConnection).then(() => loggedConnection.refreshModels()).catch(() => { /* best-effort */ });
// Wire connection to existing sessions provider
this._providerInstances.get(address)?.setConnection(loggedConnection, connectionInfo.defaultDirectory);
@@ -330,6 +329,10 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
}
}
// Authenticate using protectedResources from agent info
this._authenticateWithConnection(loggedConnection, rootState.agents)
.catch(() => { /* best-effort */ });
// Register new agents, push model updates to existing ones
for (const agent of rootState.agents) {
if (!connState.agents.has(agent.provider)) {
@@ -425,10 +428,11 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
description: agent.description,
connection: loggedConnection,
connectionAuthority: sanitized,
clientState: connState.clientState,
extensionId: 'vscode.remote-agent-host',
extensionDisplayName: 'Remote Agent Host',
resolveWorkingDirectory,
resolveAuthentication: () => this._resolveAuthenticationInteractively(loggedConnection),
resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(loggedConnection, resources),
customizations,
}));
agentStore.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler));
@@ -491,26 +495,29 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
private _authenticateAllConnections(): void {
for (const [, connState] of this._connections) {
this._authenticateWithConnection(connState.loggedConnection).then(() => connState.loggedConnection.refreshModels()).catch(() => { /* best-effort */ });
const rootState = connState.clientState.rootState;
if (rootState) {
this._authenticateWithConnection(connState.loggedConnection, rootState.agents).catch(() => { /* best-effort */ });
}
}
}
/**
* Discover auth requirements from the connection's resource metadata
* and authenticate using matching tokens resolved via the standard
* VS Code authentication service (same flow as MCP auth).
* Authenticate using protectedResources from agent info in root state.
* Resolves tokens via the standard VS Code authentication service.
*/
private async _authenticateWithConnection(loggedConnection: LoggingAgentConnection): Promise<void> {
private async _authenticateWithConnection(loggedConnection: LoggingAgentConnection, agents: readonly IAgentInfo[]): Promise<void> {
try {
const metadata = await loggedConnection.getResourceMetadata();
for (const resource of metadata.resources) {
const resourceUri = URI.parse(resource.resource);
const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []);
if (token) {
this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`);
await loggedConnection.authenticate({ resource: resource.resource, token });
} else {
this._logService.info(`[RemoteAgentHost] No token resolved for resource: ${resource.resource}`);
for (const agent of agents) {
for (const resource of agent.protectedResources ?? []) {
const resourceUri = URI.parse(resource.resource);
const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []);
if (token) {
this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`);
await loggedConnection.authenticate({ resource: resource.resource, token });
} else {
this._logService.info(`[RemoteAgentHost] No token resolved for resource: ${resource.resource}`);
}
}
}
} catch (err) {
@@ -531,28 +538,36 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
* Interactively prompt the user to authenticate when the server requires it.
* Returns true if authentication succeeded.
*/
private async _resolveAuthenticationInteractively(loggedConnection: LoggingAgentConnection): Promise<boolean> {
private async _resolveAuthenticationInteractively(loggedConnection: LoggingAgentConnection, protectedResources: readonly IProtectedResourceMetadata[]): Promise<boolean> {
try {
const metadata = await loggedConnection.getResourceMetadata();
for (const resource of metadata.resources) {
for (const resource of protectedResources) {
for (const server of resource.authorization_servers ?? []) {
const serverUri = URI.parse(server);
const resourceUri = URI.parse(resource.resource);
const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri);
if (!providerId) {
continue;
const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []);
if (token) {
await loggedConnection.authenticate({
resource: resource.resource,
token,
});
} else {
const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri);
if (!providerId) {
continue;
}
const scopes = [...(resource.scopes_supported ?? [])];
const session = await this._authenticationService.createSession(providerId, scopes, {
activateImmediate: true,
authorizationServer: serverUri,
});
await loggedConnection.authenticate({
resource: resource.resource,
token: session.accessToken,
});
}
const scopes = [...(resource.scopes_supported ?? [])];
const session = await this._authenticationService.createSession(providerId, scopes, {
activateImmediate: true,
authorizationServer: serverUri,
});
await loggedConnection.authenticate({
resource: resource.resource,
token: session.accessToken,
});
this._logService.info(`[RemoteAgentHost] Interactive authentication succeeded for ${resource.resource}`);
return true;
}
@@ -600,5 +615,13 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis
scope: ConfigurationScope.APPLICATION,
tags: ['experimental', 'advanced'],
},
[TunnelAgentHostsSettingId]: {
type: 'array',
items: { type: 'string' },
description: nls.localize('chat.remoteAgentTunnels', "Additional dev tunnel names to look for when connecting to remote agent hosts. These are looked up in addition to tunnels automatically enumerated from your account."),
default: [],
scope: ConfigurationScope.APPLICATION,
tags: ['experimental', 'advanced'],
},
},
});
@@ -5,13 +5,16 @@
import { localize, localize2 } from '../../../../nls.js';
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostEntryType, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { ISSHRemoteAgentHostService, SSHAuthMethod, type ISSHAgentHostConfig, type ISSHAgentHostConnection, type ISSHResolvedConfig } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js';
import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { SessionsCategories } from '../../../common/categories.js';
import { NewChatViewPane, SessionsViewId } from '../../chat/browser/newChatViewPane.js';
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
@@ -76,9 +79,12 @@ registerAction2(class extends Action2 {
// Connect
try {
await remoteAgentHostService.addRemoteAgentHost({
address: parsed.parsed.address,
name: name.trim(),
connectionToken: parsed.parsed.connectionToken,
connection: {
type: RemoteAgentHostEntryType.WebSocket,
address: parsed.parsed.address,
},
});
} catch {
notificationService.error(localize('addRemoteFailed', "Failed to connect to remote agent host {0}.", parsed.parsed.address));
@@ -468,3 +474,180 @@ registerAction2(class extends Action2 {
await promptToConnectViaSSH(accessor);
}
});
// ---- Connect via Dev Tunnel -------------------------------------------------
interface ITunnelPickItem extends IQuickPickItem {
readonly tunnel: ITunnelInfo;
}
interface IAuthProviderPickItem extends IQuickPickItem {
readonly provider: 'github' | 'microsoft';
}
async function promptToConnectViaTunnel(
accessor: ServicesAccessor,
): Promise<void> {
const tunnelService = accessor.get(ITunnelAgentHostService);
const quickInputService = accessor.get(IQuickInputService);
const notificationService = accessor.get(INotificationService);
const authenticationService = accessor.get(IAuthenticationService);
const instantiationService = accessor.get(IInstantiationService);
const productService = accessor.get(IProductService);
// Step 1: Determine auth provider — try cached sessions first, then prompt
let authProvider = await tunnelService.getAuthProvider({ silent: true });
if (!authProvider) {
// No cached session — prompt user to choose auth provider
const authPicks: IAuthProviderPickItem[] = [
{
provider: 'github',
label: localize('tunnelAuthGitHub', "GitHub"),
description: localize('tunnelAuthGitHubDesc', "Sign in with your GitHub account"),
},
{
provider: 'microsoft',
label: localize('tunnelAuthMicrosoft', "Microsoft Account"),
description: localize('tunnelAuthMicrosoftDesc', "Sign in with your Microsoft account"),
},
];
const authPicked = await quickInputService.pick(authPicks, {
title: localize('tunnelAuthTitle', "Sign In for Dev Tunnels"),
placeHolder: localize('tunnelAuthPlaceholder', "Choose an authentication provider"),
});
if (!authPicked) {
return;
}
authProvider = authPicked.provider;
// Trigger interactive auth for the chosen provider
const scopes = productService.tunnelApplicationConfig?.authenticationProviders?.[authProvider]?.scopes ?? [];
try {
await authenticationService.createSession(authProvider, scopes, { activateImmediate: true });
} catch {
notificationService.error(localize('tunnelAuthFailed', "Authentication failed. Please try again."));
return;
}
}
// Step 2: Show tunnel picker immediately in busy state while enumerating
const tunnelPicker = quickInputService.createQuickPick<ITunnelPickItem>();
tunnelPicker.title = localize('tunnelPickTitle', "Connect via Dev Tunnel");
tunnelPicker.placeholder = localize('tunnelPickPlaceholder', "Select a dev tunnel to connect to");
tunnelPicker.busy = true;
tunnelPicker.show();
let tunnels: ITunnelInfo[];
try {
tunnels = await tunnelService.listTunnels();
} catch (err) {
tunnelPicker.dispose();
notificationService.error(localize('tunnelListFailed', "Failed to list dev tunnels: {0}", err instanceof Error ? err.message : String(err)));
return;
}
if (tunnels.length === 0) {
tunnelPicker.dispose();
notificationService.info(localize('tunnelNoneFound', "No dev tunnels with agent host support were found. Start a tunnel with 'code tunnel' on another machine."));
return;
}
tunnelPicker.items = tunnels.map(t => ({
label: t.name,
description: `${t.tunnelId} · protocol v${t.protocolVersion}`,
tunnel: t,
}));
tunnelPicker.busy = false;
// Step 3: Wait for user selection
const picked = await new Promise<ITunnelPickItem | undefined>(resolve => {
tunnelPicker.onDidAccept(() => {
resolve(tunnelPicker.selectedItems[0]);
tunnelPicker.dispose();
});
tunnelPicker.onDidHide(() => {
resolve(undefined);
tunnelPicker.dispose();
});
});
if (!picked) {
return;
}
// Step 4: Connect to the tunnel with progress notification
const handle = notificationService.notify({
severity: Severity.Info,
message: localize('tunnelConnecting', "Connecting to tunnel '{0}'...", picked.tunnel.name),
progress: { infinite: true },
});
try {
await tunnelService.connect(picked.tunnel, authProvider);
handle.close();
} catch (err) {
handle.close();
notificationService.error(localize('tunnelConnectFailed', "Failed to connect to tunnel '{0}': {1}", picked.tunnel.name, err instanceof Error ? err.message : String(err)));
return;
}
// Cache the tunnel for future reconnections
tunnelService.cacheTunnel(picked.tunnel, authProvider);
// Step 5: Open folder picker (same pattern as SSH)
await instantiationService.invokeFunction(accessor => promptForTunnelFolder(accessor, picked.tunnel));
}
/**
* After a successful tunnel connection, show the remote folder picker and
* pre-select the chosen folder in the workspace picker.
*/
async function promptForTunnelFolder(
accessor: ServicesAccessor,
tunnel: ITunnelInfo,
): Promise<void> {
const viewsService = accessor.get(IViewsService);
const sessionsProvidersService = accessor.get(ISessionsProvidersService);
const sessionsManagementService = accessor.get(ISessionsManagementService);
const tunnelAddress = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;
// The provider is created by TunnelAgentHostContribution when the
// tunnel is cached (via onDidChangeTunnels / _reconcileProviders).
const provider = sessionsProvidersService.getProviders().find(p => p.remoteAddress === tunnelAddress);
if (!provider) {
return;
}
// Use the provider's existing browse action to show the folder picker
const browseAction = provider.browseActions[0];
if (!browseAction) {
return;
}
const workspace = await browseAction.run();
if (!workspace) {
return;
}
sessionsManagementService.openNewSessionView();
const view = await viewsService.openView<NewChatViewPane>(SessionsViewId, true);
view?.selectWorkspace({ providerId: provider.id, workspace });
}
registerAction2(class extends Action2 {
constructor() {
super({
id: 'workbench.action.sessions.connectViaTunnel',
title: localize2('connectViaTunnel', "Connect to Remote Agent Host via Dev Tunnel"),
category: SessionsCategories.Sessions,
f1: true,
precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true),
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
await promptToConnectViaTunnel(accessor);
}
});
@@ -22,7 +22,6 @@ import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/
import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js';
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { INotificationService } from '../../../../platform/notification/common/notification.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
import { IChatSendRequestOptions, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js';
import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
@@ -78,6 +77,8 @@ interface IChatData {
export interface IRemoteAgentHostSessionsProviderConfig {
readonly address: string;
readonly name: string;
/** Optional hook to establish a connection on demand (e.g. tunnel relay). */
readonly connectOnDemand?: () => Promise<void>;
}
/**
@@ -130,12 +131,25 @@ class RemoteSessionAdapter implements IChatData {
this.workspace = observableValue('workspace', metadata.workingDirectory
? RemoteAgentHostSessionsProvider.buildWorkspace(metadata.workingDirectory, providerLabel, connectionAuthority)
: undefined);
if (metadata.isRead === false) {
this.isRead.set(false, undefined);
}
if (metadata.isDone) {
this.isArchived.set(true, undefined);
}
}
update(metadata: IAgentSessionMetadata): void {
this.title.set(metadata.summary ?? this.title.get(), undefined);
this.updatedAt.set(new Date(metadata.modifiedTime), undefined);
this.lastTurnEnd.set(metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined, undefined);
if (metadata.isRead !== undefined) {
this.isRead.set(metadata.isRead, undefined);
}
if (metadata.isDone !== undefined) {
this.isArchived.set(metadata.isDone, undefined);
}
}
}
@@ -200,6 +214,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
private readonly _connectionListeners = this._register(new DisposableStore());
private readonly _onDidDisconnect = this._register(new Emitter<void>());
private readonly _connectionAuthority: string;
private readonly _connectOnDemand: (() => Promise<void>) | undefined;
constructor(
config: IRemoteAgentHostSessionsProviderConfig,
@@ -209,11 +224,11 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
@IChatWidgetService private readonly _chatWidgetService: IChatWidgetService,
@ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService,
@INotificationService private readonly _notificationService: INotificationService,
@IStorageService private readonly _storageService: IStorageService,
) {
super();
this._connectionAuthority = agentHostAuthority(config.address);
this._connectOnDemand = config.connectOnDemand;
const displayName = config.name || config.address;
this.id = `agenthost-${this._connectionAuthority}`;
@@ -275,6 +290,10 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
this._refreshSessions(cts.token).finally(() => cts.dispose());
} else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) {
this._handleTitleChanged(e.action.session, e.action.title);
} else if (e.action.type === ActionType.SessionIsReadChanged && isSessionAction(e.action)) {
this._handleIsReadChanged(e.action.session, e.action.isRead);
} else if (e.action.type === ActionType.SessionIsDoneChanged && isSessionAction(e.action)) {
this._handleIsDoneChanged(e.action.session, e.action.isDone);
}
}));
@@ -413,8 +432,11 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
if (cached && rawId) {
cached.isArchived.set(true, undefined);
this._storeArchivedState(rawId, true);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] });
if (this._connection) {
const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: true };
this._connection.dispatchAction(action, this._connection.clientId, this._connection.nextClientSeq());
}
}
}
@@ -423,8 +445,11 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
if (cached && rawId) {
cached.isArchived.set(false, undefined);
this._storeArchivedState(rawId, false);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] });
if (this._connection) {
const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: false };
this._connection.dispatchAction(action, this._connection.clientId, this._connection.nextClientSeq());
}
}
}
@@ -434,7 +459,6 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
if (cached && rawId && this._connection) {
await this._connection.disposeSession(AgentSession.uri(cached.agentProvider, rawId));
this._sessionCache.delete(rawId);
this._storeArchivedState(rawId, false);
this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(cached)], changed: [] });
}
}
@@ -459,6 +483,10 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
const cached = rawId ? this._sessionCache.get(rawId) : undefined;
if (cached) {
cached.isRead.set(read, undefined);
if (this._connection && rawId) {
const action = { type: ActionType.SessionIsReadChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isRead: read };
this._connection.dispatchAction(action, this._connection.clientId, this._connection.nextClientSeq());
}
}
}
@@ -588,7 +616,6 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
changed.push(this._chatToSession(existing));
} else {
const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label, this._connectionAuthority);
this._restoreArchivedState(rawId, cached);
this._sessionCache.set(rawId, cached);
added.push(this._chatToSession(cached));
}
@@ -602,9 +629,6 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
}
}
// Prune archived IDs that no longer exist on the server
this._pruneArchivedIds(currentKeys);
if (added.length > 0 || removed.length > 0 || changed.length > 0) {
this._onDidChangeSessions.fire({ added, removed, changed });
}
@@ -649,7 +673,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
}
}
private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string }): void {
private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string; isRead?: boolean; isDone?: boolean }): void {
const sessionUri = URI.parse(summary.resource);
const rawId = AgentSession.id(sessionUri);
if (this._sessionCache.has(rawId)) {
@@ -666,9 +690,10 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
modifiedTime: summary.modifiedAt,
summary: summary.title,
workingDirectory: workingDir,
isRead: summary.isRead,
isDone: summary.isDone,
};
const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label, this._connectionAuthority);
this._restoreArchivedState(rawId, cached);
this._sessionCache.set(rawId, cached);
this._onDidChangeSessions.fire({ added: [this._chatToSession(cached)], removed: [], changed: [] });
}
@@ -678,7 +703,6 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
const cached = this._sessionCache.get(rawId);
if (cached) {
this._sessionCache.delete(rawId);
this._storeArchivedState(rawId, false);
this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(cached)], changed: [] });
}
}
@@ -692,60 +716,21 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
}
}
// -- Private: Archived State Persistence --
private get _archivedStorageKey(): string {
return `remoteAgentHost.archivedSessions.${this.id}`;
}
private _loadArchivedIds(): Set<string> {
const raw = this._storageService.get(this._archivedStorageKey, StorageScope.PROFILE);
if (!raw) {
return new Set();
}
try {
const parsed = JSON.parse(raw);
return new Set(Array.isArray(parsed) ? parsed : []);
} catch {
return new Set();
private _handleIsReadChanged(session: string, isRead: boolean): void {
const rawId = AgentSession.id(session);
const cached = this._sessionCache.get(rawId);
if (cached) {
cached.isRead.set(isRead, undefined);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] });
}
}
private _storeArchivedState(rawId: string, archived: boolean): void {
const ids = this._loadArchivedIds();
if (archived) {
ids.add(rawId);
} else {
ids.delete(rawId);
}
this._storageService.store(this._archivedStorageKey, JSON.stringify([...ids]), StorageScope.PROFILE, StorageTarget.USER);
}
private _restoreArchivedState(rawId: string, session: RemoteSessionAdapter): void {
if (this._loadArchivedIds().has(rawId)) {
session.isArchived.set(true, undefined);
}
}
/**
* Remove archived IDs that are no longer present on the server.
* Called after a full refresh to prevent unbounded growth of stored IDs.
*/
private _pruneArchivedIds(validIds: Set<string>): void {
const archivedIds = this._loadArchivedIds();
let changed = false;
for (const id of archivedIds) {
if (!validIds.has(id)) {
archivedIds.delete(id);
changed = true;
}
}
if (changed) {
if (archivedIds.size === 0) {
this._storageService.remove(this._archivedStorageKey, StorageScope.PROFILE);
} else {
this._storageService.store(this._archivedStorageKey, JSON.stringify([...archivedIds]), StorageScope.PROFILE, StorageTarget.USER);
}
private _handleIsDoneChanged(session: string, isDone: boolean): void {
const rawId = AgentSession.id(session);
const cached = this._sessionCache.get(rawId);
if (cached) {
cached.isArchived.set(isDone, undefined);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] });
}
}
@@ -766,6 +751,11 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess
// -- Private: Browse --
private async _browseForFolder(): Promise<ISessionWorkspace | undefined> {
// Establish connection on demand if a hook is provided (e.g. tunnel relay)
if (!this._connection && this._connectOnDemand) {
await this._connectOnDemand();
}
if (!this._connection) {
this._notificationService.error(localize('notConnected', "Unable to connect to remote agent host '{0}'.", this.label));
return undefined;
@@ -0,0 +1,248 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
import * as nls from '../../../../nls.js';
import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js';
/** Minimum interval between silent status checks (5 minutes). */
const STATUS_CHECK_INTERVAL = 5 * 60 * 1000;
export class TunnelAgentHostContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'sessions.contrib.tunnelAgentHostContribution';
private readonly _providerStores = this._register(new DisposableMap<string /* address */, DisposableStore>());
private readonly _providerInstances = new Map<string, RemoteAgentHostSessionsProvider>();
private readonly _pendingConnects = new Map<string, Promise<void>>();
private _lastStatusCheck = 0;
constructor(
@ITunnelAgentHostService private readonly _tunnelService: ITunnelAgentHostService,
@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@INotificationService private readonly _notificationService: INotificationService,
) {
super();
// Create providers for cached tunnels
this._reconcileProviders();
// Update connection statuses when connections change
this._register(this._remoteAgentHostService.onDidChangeConnections(() => {
this._updateConnectionStatuses();
this._wireConnections();
}));
// Reconcile providers when the tunnel cache changes
this._register(this._tunnelService.onDidChangeTunnels(() => {
this._reconcileProviders();
}));
// Silently check status of cached tunnels on startup
this._silentStatusCheck();
}
/**
* Called by the workspace picker when it opens. Silently re-checks
* tunnel statuses if more than 5 minutes have elapsed since the last check.
*/
async checkTunnelStatuses(): Promise<void> {
if (Date.now() - this._lastStatusCheck < STATUS_CHECK_INTERVAL) {
return;
}
await this._silentStatusCheck();
}
// -- Provider management --
private _reconcileProviders(): void {
const enabled = this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId);
const cached = enabled ? this._tunnelService.getCachedTunnels() : [];
const desiredAddresses = new Set(cached.map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`));
// Remove providers no longer cached
for (const [address] of this._providerStores) {
if (!desiredAddresses.has(address)) {
this._providerStores.deleteAndDispose(address);
this._providerInstances.delete(address);
}
}
// Add providers for cached tunnels
for (const tunnel of cached) {
const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`;
if (!this._providerStores.has(address)) {
this._createProvider(address, tunnel.name);
}
}
}
private _createProvider(address: string, name: string): void {
const store = new DisposableStore();
const provider = this._instantiationService.createInstance(
RemoteAgentHostSessionsProvider, {
address,
name,
connectOnDemand: () => this._connectTunnel(address),
},
);
store.add(provider);
store.add(this._sessionsProvidersService.registerProvider(provider));
this._providerInstances.set(address, provider);
store.add(toDisposable(() => this._providerInstances.delete(address)));
this._providerStores.set(address, store);
}
// -- Connection status --
private _updateConnectionStatuses(): void {
for (const [address, provider] of this._providerInstances) {
const connectionInfo = this._remoteAgentHostService.connections.find(c => c.address === address);
if (connectionInfo) {
provider.setConnectionStatus(connectionInfo.status);
} else if (this._pendingConnects.has(address)) {
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting);
} else {
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected);
}
}
}
/**
* Wire live connections to their providers so session operations work.
*/
private _wireConnections(): void {
for (const [address, provider] of this._providerInstances) {
const connectionInfo = this._remoteAgentHostService.connections.find(
c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected
);
if (connectionInfo) {
const connection = this._remoteAgentHostService.getConnection(address);
if (connection) {
provider.setConnection(connection, connectionInfo.defaultDirectory);
}
}
}
}
// -- On-demand connection --
/**
* Establish a relay connection to a cached tunnel. Called on demand
* when the user invokes the browse action on an online-but-not-connected tunnel.
*/
private _connectTunnel(address: string): Promise<void> {
const existing = this._pendingConnects.get(address);
if (existing) {
return existing;
}
const tunnelId = address.slice(TUNNEL_ADDRESS_PREFIX.length);
const cached = this._tunnelService.getCachedTunnels().find(t => t.tunnelId === tunnelId);
if (!cached) {
return Promise.resolve();
}
const promise = (async () => {
// Show a progress notification after a short delay so quick
// connects don't flash a notification.
let handle: { close(): void } | undefined;
const timer = setTimeout(() => {
handle = this._notificationService.notify({
severity: Severity.Info,
message: nls.localize('tunnelConnecting', "Connecting to tunnel '{0}'...", cached.name),
progress: { infinite: true },
});
}, 1000);
this._updateConnectionStatuses();
try {
const tunnelInfo: ITunnelInfo = {
tunnelId: cached.tunnelId,
clusterId: cached.clusterId,
name: cached.name,
tags: [],
protocolVersion: 5,
hostConnectionCount: 0,
};
await this._tunnelService.connect(tunnelInfo, cached.authProvider);
} finally {
clearTimeout(timer);
handle?.close();
this._pendingConnects.delete(address);
this._updateConnectionStatuses();
}
})();
this._pendingConnects.set(address, promise);
return promise;
}
// -- Silent status check --
private async _silentStatusCheck(): Promise<void> {
const enabled = this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId);
if (!enabled) {
return;
}
this._lastStatusCheck = Date.now();
// Fetch tunnel list silently to check online status
let onlineTunnels: ITunnelInfo[] | undefined;
try {
onlineTunnels = await this._tunnelService.listTunnels({ silent: true });
} catch {
// No cached token or network error — leave statuses as-is
return;
}
const cached = this._tunnelService.getCachedTunnels();
if (onlineTunnels) {
const onlineIds = new Set(onlineTunnels.map(t => t.tunnelId));
// Remove cached tunnels that no longer exist on the account
for (const tunnel of cached) {
if (!onlineIds.has(tunnel.tunnelId)) {
this._tunnelService.removeCachedTunnel(tunnel.tunnelId);
}
}
// Update online/offline status based on hostConnectionCount.
// For tunnels, Connected means "host is online" (clickable to connect),
// Disconnected means "host is offline". Actual relay connection
// establishment happens when the user clicks the tunnel.
const onlineTunnelMap = new Map(onlineTunnels.map(t => [t.tunnelId, t]));
for (const [address, provider] of this._providerInstances) {
// Skip tunnels that already have an active relay connection
const hasConnection = this._remoteAgentHostService.connections.some(
c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected
);
if (hasConnection) {
continue;
}
const tunnelId = address.slice(TUNNEL_ADDRESS_PREFIX.length);
const info = onlineTunnelMap.get(tunnelId);
if (info && info.hostConnectionCount > 0) {
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connected);
} else {
provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected);
}
}
}
}
}
registerWorkbenchContribution2(TunnelAgentHostContribution.ID, TunnelAgentHostContribution, WorkbenchPhase.AfterRestored);
@@ -0,0 +1,10 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { ITunnelAgentHostService } from '../../../../platform/agentHost/common/tunnelAgentHost.js';
import { TunnelAgentHostService } from './tunnelAgentHostServiceImpl.js';
registerSingleton(ITunnelAgentHostService, TunnelAgentHostService, InstantiationType.Delayed);
@@ -0,0 +1,275 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js';
import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { ISharedProcessService } from '../../../../platform/ipc/electron-browser/services.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IRemoteAgentHostService, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
import {
ITunnelAgentHostService,
TUNNEL_AGENT_HOST_CHANNEL,
TunnelAgentHostsSettingId,
type ICachedTunnel,
type ITunnelAgentHostMainService,
type ITunnelInfo,
} from '../../../../platform/agentHost/common/tunnelAgentHost.js';
import { RemoteAgentHostProtocolClient } from '../../../../platform/agentHost/electron-browser/remoteAgentHostProtocolClient.js';
import { TunnelRelayTransport } from '../../../../platform/agentHost/electron-browser/tunnelRelayTransport.js';
const LOG_PREFIX = '[TunnelAgentHost]';
/** Storage key for recently used tunnel cache. */
const CACHED_TUNNELS_KEY = 'tunnelAgentHost.recentTunnels';
/**
* Renderer-side implementation of {@link ITunnelAgentHostService} that
* delegates tunnel SDK operations to the shared process via IPC, then
* registers connections with the renderer-local {@link IRemoteAgentHostService}.
*/
export class TunnelAgentHostService extends Disposable implements ITunnelAgentHostService {
declare readonly _serviceBrand: undefined;
private readonly _mainService: ITunnelAgentHostMainService;
private readonly _onDidChangeTunnels = this._register(new Emitter<void>());
readonly onDidChangeTunnels: Event<void> = this._onDidChangeTunnels.event;
/** Tracks which auth provider was last used successfully. */
private _lastAuthProvider: 'github' | 'microsoft' | undefined;
constructor(
@ISharedProcessService sharedProcessService: ISharedProcessService,
@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,
@ILogService private readonly _logService: ILogService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
@IProductService private readonly _productService: IProductService,
@IStorageService private readonly _storageService: IStorageService,
) {
super();
this._mainService = ProxyChannel.toService<ITunnelAgentHostMainService>(
sharedProcessService.getChannel(TUNNEL_AGENT_HOST_CHANNEL),
);
}
async listTunnels(options?: { silent?: boolean }): Promise<ITunnelInfo[]> {
if (!this._configurationService.getValue<boolean>(RemoteAgentHostsEnabledSettingId)) {
return [];
}
const silent = options?.silent ?? false;
const auth = await this._getToken(silent);
if (!auth) {
if (silent) {
this._logService.debug(`${LOG_PREFIX} No cached token available for silent tunnel enumeration`);
} else {
this._logService.warn(`${LOG_PREFIX} No auth token available for tunnel enumeration`);
}
return [];
}
const additionalNames = this._configurationService.getValue<string[]>(TunnelAgentHostsSettingId) ?? [];
return this._mainService.listTunnels(auth.token, auth.provider, additionalNames.length > 0 ? additionalNames : undefined);
}
async connect(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): Promise<void> {
const auth = authProvider
? await this._getTokenForProvider(authProvider, false)
: await this._getToken(false);
if (!auth) {
throw new Error('No authentication available');
}
this._logService.info(`${LOG_PREFIX} Connecting to tunnel '${tunnel.name}' (${tunnel.tunnelId})`);
const result = await this._mainService.connect(auth.token, auth.provider, tunnel.tunnelId, tunnel.clusterId);
this._logService.info(`${LOG_PREFIX} Tunnel relay connected, connectionId=${result.connectionId}`);
// Create relay transport + protocol client, then register with RemoteAgentHostService
try {
const transport = new TunnelRelayTransport(result.connectionId, this._mainService);
const protocolClient = this._instantiationService.createInstance(
RemoteAgentHostProtocolClient, result.address, transport,
);
await protocolClient.connect();
this._logService.info(`${LOG_PREFIX} Protocol handshake completed with ${result.address}`);
await this._remoteAgentHostService.addSSHConnection({
name: result.name,
connectionToken: result.connectionToken,
connection: {
type: RemoteAgentHostEntryType.Tunnel,
tunnelId: tunnel.tunnelId,
clusterId: tunnel.clusterId,
authProvider: auth.provider,
},
}, protocolClient);
this._onDidChangeTunnels.fire();
} catch (err) {
this._logService.error(`${LOG_PREFIX} Connection setup failed`, err);
this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ });
throw err;
}
}
async disconnect(address: string): Promise<void> {
await this._remoteAgentHostService.removeRemoteAgentHost(address);
this._onDidChangeTunnels.fire();
}
/**
* Get an auth token, trying cached sessions first (silent),
* then prompting interactively if `silent` is false.
*/
private async _getToken(silent: boolean): Promise<{ token: string; provider: 'github' | 'microsoft' } | undefined> {
// Try the last known provider first
if (this._lastAuthProvider) {
const result = await this._getTokenForProvider(this._lastAuthProvider, silent);
if (result) {
return result;
}
}
// Try both providers silently
for (const provider of ['github', 'microsoft'] as const) {
if (provider === this._lastAuthProvider) {
continue; // Already tried above
}
const result = await this._getTokenForProvider(provider, true);
if (result) {
return result;
}
}
// If not silent, we would need the caller to prompt for provider selection.
// Return undefined — the caller (promptToConnectViaTunnel) handles the interactive flow.
return undefined;
}
/**
* Get a token for a specific auth provider.
* @param provider The auth provider to use.
* @param silent If true, only try cached sessions. If false, prompt the user.
*/
private _getScopesForProvider(provider: 'github' | 'microsoft'): string[] {
const config = this._productService.tunnelApplicationConfig?.authenticationProviders;
return config?.[provider]?.scopes ?? [];
}
private async _getTokenForProvider(
provider: 'github' | 'microsoft',
silent: boolean,
): Promise<{ token: string; provider: 'github' | 'microsoft' } | undefined> {
const providerId = provider;
const scopes = this._getScopesForProvider(provider);
if (scopes.length === 0) {
return undefined;
}
try {
// Try exact scope match first
let sessions = await this._authenticationService.getSessions(providerId, scopes, {}, true);
// Fall back: find any session whose scopes are a superset
if (sessions.length === 0) {
const allSessions = await this._authenticationService.getSessions(providerId, undefined, {}, true);
const requestedSet = new Set(scopes);
let bestSession: typeof allSessions[number] | undefined;
let bestExtra = Infinity;
for (const session of allSessions) {
const sessionScopes = new Set(session.scopes);
let isSuperset = true;
for (const scope of requestedSet) {
if (!sessionScopes.has(scope)) {
isSuperset = false;
break;
}
}
if (isSuperset) {
const extra = sessionScopes.size - requestedSet.size;
if (extra < bestExtra) {
bestExtra = extra;
bestSession = session;
}
}
}
if (bestSession) {
sessions = [bestSession];
}
}
// Interactive fallback: create a new session
if (sessions.length === 0 && !silent) {
const session = await this._authenticationService.createSession(providerId, scopes, { activateImmediate: true });
sessions = [session];
}
if (sessions.length > 0) {
const token = sessions[0].accessToken;
if (token) {
this._lastAuthProvider = provider;
return { token, provider };
}
}
} catch (err) {
this._logService.debug(`${LOG_PREFIX} Failed to get ${provider} token: ${err}`);
}
return undefined;
}
async getAuthProvider(options?: { silent?: boolean }): Promise<'github' | 'microsoft' | undefined> {
const result = await this._getToken(options?.silent ?? true);
return result?.provider;
}
getCachedTunnels(): ICachedTunnel[] {
const raw = this._storageService.get(CACHED_TUNNELS_KEY, StorageScope.APPLICATION);
if (!raw) {
return [];
}
try {
return JSON.parse(raw);
} catch {
return [];
}
}
cacheTunnel(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): void {
const cached = this.getCachedTunnels();
const filtered = cached.filter(t => t.tunnelId !== tunnel.tunnelId);
filtered.unshift({
tunnelId: tunnel.tunnelId,
clusterId: tunnel.clusterId,
name: tunnel.name,
authProvider,
});
this._storeCachedTunnels(filtered.slice(0, 20));
this._onDidChangeTunnels.fire();
}
removeCachedTunnel(tunnelId: string): void {
const cached = this.getCachedTunnels();
this._storeCachedTunnels(cached.filter(t => t.tunnelId !== tunnelId));
this._onDidChangeTunnels.fire();
}
private _storeCachedTunnels(tunnels: ICachedTunnel[]): void {
if (tunnels.length === 0) {
this._storageService.remove(CACHED_TUNNELS_KEY, StorageScope.APPLICATION);
} else {
this._storageService.store(CACHED_TUNNELS_KEY, JSON.stringify(tunnels), StorageScope.APPLICATION, StorageTarget.USER);
}
}
}
+2
View File
@@ -201,8 +201,10 @@ import '../workbench/contrib/policyExport/electron-browser/policyExport.contribu
import '../platform/agentHost/electron-browser/agentHostService.js';
import '../platform/agentHost/electron-browser/remoteAgentHostService.js';
import '../platform/agentHost/electron-browser/sshRemoteAgentHostService.js';
import './contrib/remoteAgentHost/electron-browser/tunnelAgentHostService.js';
import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js';
import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js';
import './contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.js';
// Local Agent Host
import './contrib/localAgentHost/browser/localAgentHost.contribution.js';
@@ -10,8 +10,7 @@ import { isEqualOrParent } from '../../../../../../base/common/resources.js';
import { ThemeIcon } from '../../../../../../base/common/themables.js';
import { URI } from '../../../../../../base/common/uri.js';
import { AgentHostEnabledSettingId, IAgentHostService, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js';
import { type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { type IProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js';
import { ROOT_STATE_URI, type IAgentInfo, type ICustomizationRef, type IRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
@@ -90,11 +89,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
// Forward action envelopes from the host to client state
this._register(this._loggedConnection.onDidAction(envelope => {
// Only root actions are relevant here; session actions are
// handled by individual session handlers.
if (!isSessionAction(envelope.action)) {
this._clientState!.receiveEnvelope(envelope);
}
this._clientState!.receiveEnvelope(envelope);
}));
// Forward notifications to client state
@@ -135,6 +130,10 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
}
}
// Authenticate using protectedResources from agent info
this._authenticateWithServer(rootState.agents)
.catch(() => { /* best-effort */ });
// Register new agents and push model updates to existing ones
for (const agent of rootState.agents) {
if (!this._agentRegistrations.has(agent.provider)) {
@@ -201,7 +200,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
description: agent.description,
connection: this._loggedConnection!,
connectionAuthority: 'local',
resolveAuthentication: () => this._resolveAuthenticationInteractively(),
clientState: this._clientState!,
resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(resources),
customizations,
}));
store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler));
@@ -216,12 +216,15 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
store.add(toDisposable(() => this._modelProviders.delete(agent.provider)));
store.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider));
// Push auth token and refresh models from server
this._authenticateWithServer().then(() => this._loggedConnection!.refreshModels()).catch(() => { /* best-effort */ });
store.add(this._defaultAccountService.onDidChangeDefaultAccount(() =>
this._authenticateWithServer().then(() => this._loggedConnection!.refreshModels()).catch(() => { /* best-effort */ })));
store.add(this._authenticationService.onDidChangeSessions(() =>
this._authenticateWithServer().then(() => this._loggedConnection!.refreshModels()).catch(() => { /* best-effort */ })));
// Re-authenticate when credentials change
store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => {
const agents = this._clientState?.rootState?.agents ?? [];
this._authenticateWithServer(agents).catch(() => { /* best-effort */ });
}));
store.add(this._authenticationService.onDidChangeSessions(() => {
const agents = this._clientState?.rootState?.agents ?? [];
this._authenticateWithServer(agents).catch(() => { /* best-effort */ });
}));
}
/**
@@ -267,22 +270,21 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
}
/**
* Discover auth requirements from the server's resource metadata
* and authenticate using matching tokens resolved via the standard
* VS Code authentication service (same flow as MCP auth).
* Authenticate using protectedResources from agent info in root state.
* Resolves tokens via the standard VS Code authentication service.
*/
private async _authenticateWithServer(): Promise<void> {
private async _authenticateWithServer(agents: readonly IAgentInfo[]): Promise<void> {
try {
const metadata = await this._loggedConnection!.getResourceMetadata();
this._logService.trace(`[AgentHost] Resource metadata: ${metadata.resources.length} resource(s)`);
for (const resource of metadata.resources) {
const resourceUri = URI.parse(resource.resource);
const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []);
if (token) {
this._logService.info(`[AgentHost] Authenticating for resource: ${resource.resource}`);
await this._loggedConnection!.authenticate({ resource: resource.resource, token });
} else {
this._logService.info(`[AgentHost] No token resolved for resource: ${resource.resource}`);
for (const agent of agents) {
for (const resource of agent.protectedResources ?? []) {
const resourceUri = URI.parse(resource.resource);
const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []);
if (token) {
this._logService.info(`[AgentHost] Authenticating for resource: ${resource.resource}`);
await this._loggedConnection!.authenticate({ resource: resource.resource, token });
} else {
this._logService.info(`[AgentHost] No token resolved for resource: ${resource.resource}`);
}
}
}
} catch (err) {
@@ -301,14 +303,13 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
/**
* Interactively prompt the user to authenticate when the server requires it.
* Fetches resource metadata, resolves the auth provider, creates a session
* (which triggers the login UI), and pushes the token to the server.
* Returns true if authentication succeeded.
* Uses protectedResources from root state, resolves the auth provider,
* creates a session (which triggers the login UI), and pushes the token
* to the server. Returns true if authentication succeeded.
*/
private async _resolveAuthenticationInteractively(): Promise<boolean> {
private async _resolveAuthenticationInteractively(protectedResources: IProtectedResourceMetadata[]): Promise<boolean> {
try {
const metadata = await this._loggedConnection!.getResourceMetadata();
for (const resource of metadata.resources) {
for (const resource of protectedResources) {
for (const server of resource.authorization_servers ?? []) {
const serverUri = URI.parse(server);
const resourceUri = URI.parse(resource.resource);
@@ -16,8 +16,8 @@ import { URI } from '../../../../../../base/common/uri.js';
import { localize } from '../../../../../../nls.js';
import { AgentProvider, AgentSession, IAgentAttachment, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js';
import { ISessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js';
import { ICustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { ActionType, isSessionAction, type ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { ICustomizationRef, type IProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
import { ActionType, type ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js';
import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js';
import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js';
@@ -149,6 +149,8 @@ export interface IAgentHostSessionHandlerConfig {
readonly connection: IAgentConnection;
/** Sanitized connection authority for constructing vscode-agent-host:// URIs. */
readonly connectionAuthority: string;
/** Shared client state manager that tracks both root and session state. */
readonly clientState: SessionClientState;
/** Extension identifier for the registered agent. Defaults to 'vscode.agent-host'. */
readonly extensionId?: string;
/** Extension display name for the registered agent. Defaults to 'Agent Host'. */
@@ -162,8 +164,11 @@ export interface IAgentHostSessionHandlerConfig {
* Optional callback invoked when the server rejects an operation because
* authentication is required. Should trigger interactive authentication
* and return true if the user authenticated successfully.
*
* @param protectedResources The protected resources from the agent's root
* state that require authentication.
*/
readonly resolveAuthentication?: () => Promise<boolean>;
readonly resolveAuthentication?: (protectedResources: IProtectedResourceMetadata[]) => Promise<boolean>;
/**
* Observable set of agent-level customizations to include in the active
@@ -187,7 +192,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
private readonly _clientDispatchedTurnIds = new Set<string>();
private readonly _config: IAgentHostSessionHandlerConfig;
/** Client state manager shared across all sessions for this handler. */
/** Client state manager shared with the parent contribution. */
private readonly _clientState: SessionClientState;
constructor(
@@ -202,9 +207,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
) {
super();
this._config = config;
// Create shared client state manager for this handler instance
this._clientState = this._register(new SessionClientState(config.connection.clientId, this._logService, () => config.connection.nextClientSeq()));
this._clientState = config.clientState;
// Register an editing session provider for this handler's session type
this._register(this._chatEditingService.registerEditingSessionProvider(
@@ -220,13 +223,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
},
));
// Forward action envelopes from IPC to client state
this._register(config.connection.onDidAction(envelope => {
if (isSessionAction(envelope.action)) {
this._clientState.receiveEnvelope(envelope);
}
}));
// When the customizations observable changes, re-dispatch
// activeClientChanged for sessions where this client is already
// the active client. This avoids overwriting another client's
@@ -1363,6 +1359,19 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
this._logService.trace(`[AgentHost] Creating new session, model=${rawModelId ?? '(default)'}, provider=${this._config.provider}${fork ? `, fork from ${fork.session.toString()} at index ${fork.turnIndex}` : ''}`);
// Eagerly authenticate before creating the session if the agent
// declares required protected resources. This avoids a wasted
// round-trip where createSession fails with AuthRequired.
const agentInfo = this._clientState.rootState?.agents.find(a => a.provider === this._config.provider);
const protectedResources = agentInfo?.protectedResources ?? [];
const hasRequiredAuth = protectedResources.some(r => r.required !== false);
if (hasRequiredAuth && this._config.resolveAuthentication) {
const authenticated = await this._config.resolveAuthentication(protectedResources);
if (!authenticated) {
throw new Error(localize('agentHost.authRequired', "Authentication is required to start a session. Please sign in and try again."));
}
}
let session: URI;
try {
session = await this._config.connection.createSession({
@@ -1372,10 +1381,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
fork,
});
} catch (err) {
// If authentication is required, try to resolve it and retry once
// If authentication is required (e.g. token expired), try interactive auth and retry once
if (this._isAuthRequiredError(err) && this._config.resolveAuthentication) {
this._logService.info('[AgentHost] Authentication required, prompting user...');
const authenticated = await this._config.resolveAuthentication();
const authenticated = await this._config.resolveAuthentication(protectedResources);
if (authenticated) {
session = await this._config.connection.createSession({
model: rawModelId,
@@ -7,7 +7,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js';
import { Disposable } from '../../../../../../base/common/lifecycle.js';
import { URI, UriComponents } from '../../../../../../base/common/uri.js';
import { Registry } from '../../../../../../platform/registry/common/platform.js';
import { IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js';
import { IAgentConnection, IAgentCreateSessionConfig, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js';
import type { IActionEnvelope, INotification, ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js';
import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js';
@@ -101,22 +101,10 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti
// ---- IAgentConnection method proxies with logging -----------------------
async listAgents(): Promise<IAgentDescriptor[]> {
return this._logCall('listAgents', undefined, () => this._inner.listAgents());
}
async getResourceMetadata(): Promise<IResourceMetadata> {
return this._logCall('getResourceMetadata', undefined, () => this._inner.getResourceMetadata());
}
async authenticate(params: IAuthenticateParams): Promise<IAuthenticateResult> {
return this._logCall('authenticate', params, () => this._inner.authenticate(params));
}
async refreshModels(): Promise<void> {
return this._logCall('refreshModels', undefined, () => this._inner.refreshModels());
}
async listSessions(): Promise<IAgentSessionMetadata[]> {
return this._logCall('listSessions', undefined, () => this._inner.listSessions());
}
@@ -16,6 +16,7 @@ import { timeout } from '../../../../../../base/common/async.js';
import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js';
import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js';
import type { IActionEnvelope, INotification, ISessionAction, IToolCallConfirmedAction, ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js';
import type { ICustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
@@ -66,12 +67,6 @@ class MockAgentHostService extends mock<IAgentHostService>() {
return [...this._sessions.values()];
}
override async listAgents() {
return this.agents;
}
override async refreshModels(): Promise<void> { }
override async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
if (config) {
this.createSessionCalls.push(config);
@@ -220,6 +215,8 @@ function createTestServices(disposables: DisposableStore) {
function createContribution(disposables: DisposableStore) {
const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables);
const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq()));
disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e)));
const listController = disposables.add(instantiationService.createInstance(AgentHostSessionListController, 'agent-host-copilot', 'copilot', agentHostService, undefined));
const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {
provider: 'copilot' as const,
@@ -229,6 +226,7 @@ function createContribution(disposables: DisposableStore) {
description: 'Copilot SDK agent running in a dedicated process',
connection: agentHostService,
connectionAuthority: 'local',
clientState,
}));
const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution));
@@ -1446,6 +1444,8 @@ suite('AgentHostChatContribution', () => {
test('handler uses custom extensionId from config', async () => {
const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables);
const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq()));
disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e)));
disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {
provider: 'copilot' as const,
agentId: 'remote-test-copilot',
@@ -1454,6 +1454,7 @@ suite('AgentHostChatContribution', () => {
description: 'Remote agent',
connection: agentHostService,
connectionAuthority: 'local',
clientState,
extensionId: 'vscode.remote-agent-host',
extensionDisplayName: 'Remote Agent Host',
}));
@@ -1467,6 +1468,8 @@ suite('AgentHostChatContribution', () => {
test('handler defaults extensionId when not provided', async () => {
const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables);
const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq()));
disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e)));
disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {
provider: 'copilot' as const,
agentId: 'default-ext-test',
@@ -1475,6 +1478,7 @@ suite('AgentHostChatContribution', () => {
description: 'test',
connection: agentHostService,
connectionAuthority: 'local',
clientState,
}));
const registered = chatAgentService.registeredAgents.get('default-ext-test');
@@ -1486,6 +1490,8 @@ suite('AgentHostChatContribution', () => {
test('handler uses resolveWorkingDirectory callback', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const { instantiationService, agentHostService } = createTestServices(disposables);
const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq()));
disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e)));
const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {
provider: 'copilot' as const,
agentId: 'workdir-test',
@@ -1494,6 +1500,7 @@ suite('AgentHostChatContribution', () => {
description: 'test',
connection: agentHostService,
connectionAuthority: 'local',
clientState,
resolveWorkingDirectory: () => URI.file('/custom/working/dir'),
}));
@@ -1518,6 +1525,8 @@ suite('AgentHostChatContribution', () => {
path: '/file/-/home/user/project',
});
const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq()));
disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e)));
const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {
provider: 'copilot' as const,
agentId: 'workdir-agenthost-test',
@@ -1526,6 +1535,7 @@ suite('AgentHostChatContribution', () => {
description: 'test',
connection: agentHostService,
connectionAuthority: 'my-server',
clientState,
resolveWorkingDirectory: () => agentHostUri,
}));
@@ -1567,6 +1577,8 @@ suite('AgentHostChatContribution', () => {
const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables);
// Create handler with agentHostService as IAgentConnection (not IAgentHostService)
const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq()));
disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e)));
const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {
provider: 'copilot' as const,
agentId: 'connection-test',
@@ -1575,6 +1587,7 @@ suite('AgentHostChatContribution', () => {
description: 'test',
connection: agentHostService,
connectionAuthority: 'local',
clientState,
}));
// Verify it registered an agent
@@ -2163,6 +2176,8 @@ suite('AgentHostChatContribution', () => {
{ uri: 'file:///plugin-a', displayName: 'Plugin A' },
]);
const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq()));
disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e)));
const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {
provider: 'copilot' as const,
agentId: 'agent-host-copilot',
@@ -2171,6 +2186,7 @@ suite('AgentHostChatContribution', () => {
description: 'test',
connection: agentHostService,
connectionAuthority: 'local',
clientState,
customizations,
}));
@@ -2192,6 +2208,8 @@ suite('AgentHostChatContribution', () => {
const customizations = observableValue<ICustomizationRef[]>('customizations', []);
const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq()));
disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e)));
const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, {
provider: 'copilot' as const,
agentId: 'agent-host-copilot',
@@ -2200,6 +2218,7 @@ suite('AgentHostChatContribution', () => {
description: 'test',
connection: agentHostService,
connectionAuthority: 'local',
clientState,
customizations,
}));