agentHost: unify local endpoint discovery

Publish editor and standalone agent hosts through a shared user-data registry with cross-process locking. Update CLI discovery and management commands to work across all registered hosts without the legacy lockfile.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Connor Peet
2026-07-30 14:37:59 -07:00
co-authored by Copilot
parent a01c032942
commit ab22c19a1f
28 changed files with 4412 additions and 691 deletions
+3 -1
View File
@@ -114,7 +114,9 @@ async fn main() -> Result<(), std::convert::Infallible> {
Some(args::AgentSubcommand::Stop(stop_args)) => {
agent_stop::agent_stop(context!(), stop_args).await
}
Some(args::AgentSubcommand::Kill) => agent_kill::agent_kill(context!()).await,
Some(args::AgentSubcommand::Kill(kill_args)) => {
agent_kill::agent_kill(context!(), kill_args).await
}
Some(args::AgentSubcommand::Logs(logs_args)) => {
agent_logs::agent_logs(context!(), logs_args).await
}
+1
View File
@@ -6,6 +6,7 @@
mod context;
pub mod agent;
pub mod agent_discovery;
pub mod agent_host;
pub mod agent_kill;
pub mod agent_logs;
+197 -40
View File
@@ -3,37 +3,46 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
use std::fs;
use std::sync::LazyLock;
use ahp::{Client, Transport, TransportError, TransportMessage};
use ahp_types::commands::{AuthenticateParams, AuthenticateResult};
use ahp_types::errors::ahp_error_codes;
use ahp_types::state::ProtectedResourceMetadata;
use ahp_types::{ROOT_RESOURCE_URI, PROTOCOL_VERSION};
use ahp_types::{PROTOCOL_VERSION, ROOT_RESOURCE_URI};
use futures::{SinkExt, StreamExt};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::sync::Mutex as AsyncMutex;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{connect_async, WebSocketStream};
use crate::async_pipe::get_socket_rw_stream;
use crate::auth::{Auth, AuthProvider};
use crate::constants::AGENT_HOST_PORT;
use crate::log;
use crate::tunnels::agent_host_registry::{AgentHostEndpointAddress, AgentHostEndpointMetadata};
use crate::tunnels::dev_tunnels::DevTunnels;
use crate::util::errors::{wrap, AnyError, CodeError};
use crate::util::machine::process_exists;
use crate::util::errors::{wrap, AnyError};
use super::CommandContext;
use crate::tunnels::agent_host_metadata::AgentHostMetadata;
/// Connects to an agent host, initializes the AHP session, and returns
/// the ready-to-use client. If an explicit `address` is given it is used
/// directly; if `tunnel_name` is given, the tunnel is looked up via the
/// dev tunnels API; otherwise the lockfile written by `code agent host`
/// is read to discover the local instance.
/// Connects to an agent host at an explicit `--address` or `--tunnel`
/// target, initializes the AHP session, and returns the ready-to-use
/// client. If `address` is given it is used directly; otherwise
/// `tunnel_name` is looked up via the dev tunnels API.
///
/// This is deliberately explicit-target-only: automatic discovery of a
/// local standalone/editor instance lives in
/// [`super::agent_discovery`] instead (see
/// [`super::agent_discovery::discover_live_endpoints`] and
/// [`super::agent_discovery::connect_to_session_host`]). Every call site
/// already branches to one of those before falling through here, so
/// passing neither `address` nor `tunnel_name` is a caller bug, not a
/// "no target given" case to recover from silently.
///
/// The returned client has been initialized but **not** authenticated.
/// Use [`request_with_auth`] to issue commands that may require auth.
pub async fn connect(
pub async fn connect_explicit(
ctx: &CommandContext,
address: Option<&str>,
tunnel_name: Option<&str>,
@@ -41,18 +50,57 @@ pub async fn connect(
let client = match (address, tunnel_name) {
(Some(addr), _) => connect_ws(addr).await?,
(None, Some(name)) => connect_via_tunnel(ctx, name).await?,
(None, None) => {
let addr = resolve_address_from_lockfile(ctx)?;
connect_ws(&addr).await?
(None, None) => unreachable!(
"connect_explicit requires --address or --tunnel; callers must route \
the no-target case through agent_discovery instead"
),
};
initialize_client(&client).await?;
Ok(client)
}
/// Connects to a specific registry-discovered endpoint (used by
/// multi-host auto-discovery in `ps`/`logs`/`stop`/`kill`). Dispatches to
/// a `tcp` WebSocket connection or a `socket`/named-pipe raw-stream
/// WebSocket handshake depending on the endpoint's address kind, and
/// always includes the endpoint's connection token as the `tkn` query
/// parameter, matching the wire convention documented in
/// `LOCAL_ENDPOINT.md`.
///
/// Like [`connect_explicit`], the returned client is initialized but not yet
/// authenticated; use [`request_with_auth`] for calls that may need it.
pub async fn connect_to_endpoint(endpoint: &AgentHostEndpointMetadata) -> Result<Client, AnyError> {
let client = match &endpoint.endpoint {
AgentHostEndpointAddress::Tcp { host, port } => {
let dial_host = crate::commands::agent_host::dial_host(Some(host));
let mut url = format!("ws://{dial_host}:{port}/");
if !endpoint.connection_token.is_empty() {
url.push_str(&format!("?tkn={}", endpoint.connection_token));
}
connect_ws(&url).await?
}
AgentHostEndpointAddress::Socket { path } => {
connect_ws_over_socket(path, &endpoint.connection_token).await?
}
};
initialize_client(&client).await?;
Ok(client)
}
/// Shared final step of establishing an AHP session: performs the
/// protocol `initialize` handshake common to every connection kind.
async fn initialize_client(client: &Client) -> Result<(), AnyError> {
client
.initialize("code-cli".into(), vec![PROTOCOL_VERSION.to_string()], vec![])
.initialize(
"code-cli".into(),
vec![PROTOCOL_VERSION.to_string()],
vec![],
)
.await
.map_err(|e| wrap(e, "AHP initialize failed"))?;
Ok(client)
Ok(())
}
/// Opens a WebSocket connection and creates an AHP client.
@@ -68,6 +116,37 @@ async fn connect_ws(address: &str) -> Result<Client, AnyError> {
.map_err(|e| wrap(e, "Failed to establish AHP session").into())
}
/// Opens a raw-stream WebSocket connection over a Unix domain socket
/// (Unix) or named pipe (Windows) using the existing [`async_pipe`]
/// abstraction, mirroring the tunnel raw-stream handshake in
/// [`connect_via_tunnel`]. This is how the CLI reaches `editor`-owned
/// registry endpoints, which are never TCP.
async fn connect_ws_over_socket(path: &str, connection_token: &str) -> Result<Client, AnyError> {
let pipe = get_socket_rw_stream(std::path::Path::new(path))
.await
.map_err(|e| {
wrap(
e,
format!("Failed to connect to agent host socket at {path}"),
)
})?;
let mut url = "ws://localhost/".to_string();
if !connection_token.is_empty() {
url.push_str(&format!("?tkn={connection_token}"));
}
let (ws_stream, _) = tokio_tungstenite::client_async(url, pipe)
.await
.map_err(|e| wrap(e, format!("WebSocket handshake over socket {path} failed")))?;
let transport = WsTransport::new(ws_stream, ());
Client::connect(transport, ahp::ClientConfig::default())
.await
.map_err(|e| wrap(e, "Failed to establish AHP session over socket").into())
}
/// A [`Transport`] backed by a `tokio-tungstenite` WebSocket stream.
///
/// `_guard` keeps an auxiliary resource alive for the lifetime of the
@@ -81,7 +160,10 @@ struct WsTransport<S, G = ()> {
impl<S, G> WsTransport<S, G> {
fn new(inner: WebSocketStream<S>, guard: G) -> Self {
Self { inner, _guard: guard }
Self {
inner,
_guard: guard,
}
}
}
@@ -153,6 +235,34 @@ async fn connect_via_tunnel(ctx: &CommandContext, name: &str) -> Result<Client,
.map_err(|e| wrap(e, "Failed to establish AHP session over tunnel").into())
}
/// Serializes the auth-retry branch of [`request_with_auth`] across
/// concurrently-queried hosts (see multi-host `ps`/`logs`/`stop`
/// discovery). Without this, two hosts hitting `AUTH_REQUIRED` at the
/// same time could each kick off a competing device-flow login. Callers
/// past the first still pay the lock wait, but
/// [`authenticate_from_error`] checks for a cached credential before
/// starting a new login, so only the first caller actually performs one;
/// the rest observe the cache and proceed immediately.
static AUTH_SERIALIZE: LazyLock<AsyncMutex<()>> = LazyLock::new(|| AsyncMutex::new(()));
/// Runs `authenticate` while holding [`AUTH_SERIALIZE`], releasing the
/// guard as soon as `authenticate` completes — *before* returning to the
/// caller. This is split out from [`request_with_auth`] specifically so
/// the guard is never accidentally held across the retried RPC that
/// follows: that RPC can be slow (or the host can be hung/unreachable),
/// and holding the lock across it would stall unrelated hosts' own
/// authentication attempts for no reason. Also split out so this
/// locking behavior can be exercised directly in tests without needing
/// a live AHP client.
async fn serialize_auth<T, F, Fut>(authenticate: F) -> T
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
let _guard = AUTH_SERIALIZE.lock().await;
authenticate().await
}
/// Issues a JSON-RPC request, automatically handling `-32007` auth errors
/// by running the device-flow login and retrying once.
pub async fn request_with_auth<P, R>(
@@ -172,7 +282,10 @@ where
ctx.log,
"Server requires authentication, starting login flow..."
);
authenticate_from_error(ctx, client, e).await?;
serialize_auth(|| authenticate_from_error(ctx, client, e)).await?;
// Deliberately outside `serialize_auth`'s guard: this retry
// can be slow or hang if the host is unreachable, and must
// not block other hosts' concurrent authentication.
client
.request::<P, R>(method, params)
.await
@@ -266,31 +379,75 @@ async fn authenticate_from_error(
Ok(())
}
fn resolve_address_from_lockfile(ctx: &CommandContext) -> Result<String, AnyError> {
let lockfile_path = ctx.paths.agent_host_lockfile();
#[cfg(test)]
mod tests {
use super::*;
use crate::async_pipe::{get_socket_name, listen_socket_rw_stream};
let data = fs::read_to_string(&lockfile_path).map_err(|e| {
wrap(
e,
"No running agent host found. Start one with `code agent host` or specify --address",
)
})?;
/// Exercises the real Unix-socket/named-pipe raw-stream WebSocket
/// handshake used to reach `editor`-owned (and socket-based
/// standalone) registry endpoints, using the same cross-platform
/// [`async_pipe`] listener/accept pattern already established for
/// this abstraction elsewhere in the crate (see
/// `tunnels::agent_host` tests), rather than mocking the transport.
#[tokio::test]
async fn connect_ws_over_socket_completes_handshake_over_a_real_socket() {
let path = get_socket_name();
let mut listener = listen_socket_rw_stream(&path).await.unwrap();
let metadata: AgentHostMetadata = serde_json::from_str(&data).map_err(|e| {
wrap(
e,
format!("Corrupt agent host lockfile at {}", lockfile_path.display()),
)
})?;
let server = tokio::spawn(async move {
let pipe = listener.accept().await.unwrap();
// Accepting the WS handshake server-side is enough to prove
// the client's raw-stream `client_async` handshake (with the
// `?tkn=` connection token in the URL) completes correctly
// against a real listener; `Client::connect` itself never
// blocks on server behavior past that.
let _ws = tokio_tungstenite::accept_async(pipe).await.unwrap();
});
if !process_exists(metadata.pid) {
let _ = fs::remove_file(&lockfile_path);
return Err(CodeError::NoRunningAgentHost.into());
let client = connect_ws_over_socket(path.to_str().unwrap(), "test-token").await;
assert!(client.is_ok(), "expected Ok, got {:?}", client.err());
server.await.unwrap();
#[cfg(unix)]
let _ = std::fs::remove_file(&path);
}
let mut url = format!("ws://127.0.0.1:{}/", metadata.port);
if let Some(token) = &metadata.connection_token {
url.push_str(&format!("?tkn={token}"));
/// Regression test for the `AUTH_SERIALIZE` scoping fix: the guard
/// must be released as soon as `authenticate` finishes, *not* held
/// across whatever the caller does afterwards (in production, the
/// retried RPC). Otherwise a slow/hung host's post-auth retry could
/// stall an unrelated host's own authentication attempt. We can't
/// easily stand up two real AHP `Client`s here, so this exercises
/// `serialize_auth` directly: task A's simulated "retry" (a long
/// sleep performed *after* `serialize_auth` returns) must not block
/// task B's concurrent `serialize_auth` call.
#[tokio::test]
async fn serialize_auth_releases_guard_before_caller_retries() {
let host_a = tokio::spawn(async {
serialize_auth(|| async {}).await;
// Simulates a slow/hung host's retried RPC, which happens
// *after* the guard should already have been released.
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
});
// Give host A a head start so it acquires the guard first.
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
// If the guard were (incorrectly) held across host A's simulated
// retry, this would time out; with the fix it resolves almost
// immediately since the guard was already released.
let host_b = tokio::time::timeout(std::time::Duration::from_millis(150), async {
serialize_auth(|| async {}).await;
})
.await;
assert!(
host_b.is_ok(),
"host B's authenticate must not wait for host A's retry"
);
host_a.await.unwrap();
}
Ok(url)
}
+315
View File
@@ -0,0 +1,315 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//! Shared multi-host discovery helpers for `code agent ps|logs|stop`.
//!
//! `code agent host` supervisors (standalone) and running VS Code windows
//! (editor) each publish a live entry to the shared local agent-host
//! endpoint registry (schema v2; see
//! [`crate::tunnels::agent_host_registry`]). When the user doesn't pin a
//! specific `--address`/`--tunnel`, these commands should consider *every*
//! live entry a candidate host, not just one arbitrarily selected one —
//! that's what this module coordinates.
use ahp::Client;
use ahp_types::commands::{ListSessionsParams, ListSessionsResult};
use ahp_types::ROOT_RESOURCE_URI;
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use crate::log;
use crate::tunnels::agent_host_registry::{self, AgentHostEndpointMetadata};
use crate::tunnels::user_data_path::resolve_user_data_path;
use crate::util::errors::{AnyError, CodeError};
use super::agent;
use super::CommandContext;
/// Enumerates every live schema-v2 registry endpoint (both `editor` and
/// `standalone`, both socket/pipe and TCP addresses) as auto-discovery
/// candidates. The shared registry is the sole source of truth for
/// automatic discovery; callers should treat an empty `Vec` as "no agent
/// host is currently running" ([`CodeError::NoRunningAgentHost`]) rather
/// than falling back to any other discovery mechanism.
pub fn discover_live_endpoints(
ctx: &CommandContext,
user_data_dir: Option<&str>,
) -> Vec<AgentHostEndpointMetadata> {
let user_data_path = resolve_user_data_path(user_data_dir);
trace!(
ctx.log,
"discovering live agent hosts in {}",
user_data_path.display()
);
agent_host_registry::list_live_endpoints(&ctx.log, &user_data_path)
}
/// Outcome of probing a single host for a given session, as consumed by
/// [`resolve_first_match`]. Generic over the "found" payload `T` (rather
/// than hard-coding a live [`Client`]) purely so the early-return/
/// cancellation control flow in [`resolve_first_match`] can be exercised
/// directly in tests with cheap, deterministic fake futures instead of a
/// real AHP connection.
enum ProbeResult<T> {
/// Connected and queried successfully; the session was present. The
/// payload is whatever the caller wants back for the matching host
/// (a ready-to-use [`Client`] in production).
Found(T),
/// Connected and queried successfully; the session was not present.
NotFound,
/// The host could not be connected to or queried at all (network
/// error, auth failure, protocol error, ...). Nothing can be
/// concluded about whether it owns the session; its label is kept
/// for diagnostics.
Unreachable(String),
}
/// Failure produced by [`resolve_first_match`] when no host matched.
#[derive(Debug, Clone, PartialEq, Eq)]
enum SearchFailure {
/// Every host was searched successfully and none owns the session:
/// safe to report "session not found".
NotFound,
/// At least one host could not be searched (its label recorded
/// here), and no host that *could* be searched owned the session. We
/// must not claim the session doesn't exist — it might live on the
/// host(s) we couldn't reach.
Incomplete(Vec<String>),
}
/// Consumes host-probe results as they complete, returning the payload
/// from the first match *immediately*: dropping `probes` (which happens
/// as soon as this function returns) cancels any probes still in flight,
/// so a hung or unreachable host can never delay `logs`/`stop` once a
/// match is found on a faster host. A `Found` anywhere takes precedence
/// over `Unreachable` elsewhere: once we have a definitive match we
/// don't need to care that some other host was unreachable. When
/// nothing matches, returns [`SearchFailure::NotFound`] if every host
/// was searched successfully, or [`SearchFailure::Incomplete`] (carrying
/// the unreachable hosts' labels) if at least one host could not be
/// searched.
async fn resolve_first_match<T, F>(mut probes: FuturesUnordered<F>) -> Result<T, SearchFailure>
where
F: std::future::Future<Output = ProbeResult<T>>,
{
let mut unreachable_labels = Vec::new();
while let Some(probe) = probes.next().await {
match probe {
ProbeResult::Found(payload) => return Ok(payload),
ProbeResult::NotFound => {}
ProbeResult::Unreachable(label) => unreachable_labels.push(label),
}
}
if unreachable_labels.is_empty() {
Err(SearchFailure::NotFound)
} else {
Err(SearchFailure::Incomplete(unreachable_labels))
}
}
/// Connects to `endpoint` and checks whether `session` appears in its
/// `listSessions` catalog — membership, not error-text sniffing, is the
/// source of truth per requirement. On success but no match, or on any
/// failure, the client (if any) is shut down before returning so callers
/// never need to remember to clean up non-matching connections.
async fn probe_host(
ctx: &CommandContext,
endpoint: AgentHostEndpointMetadata,
session: &str,
) -> ProbeResult<Client> {
let label = endpoint.label();
let client = match agent::connect_to_endpoint(&endpoint).await {
Ok(client) => client,
Err(e) => {
warning!(ctx.log, "Could not connect to agent host {}: {}", label, e);
return ProbeResult::Unreachable(label);
}
};
let listed: Result<ListSessionsResult, AnyError> = agent::request_with_auth(
ctx,
&client,
"listSessions",
ListSessionsParams {
channel: ROOT_RESOURCE_URI.to_string(),
filter: None,
},
)
.await;
match listed {
Ok(r) if r.items.iter().any(|s| s.resource == session) => ProbeResult::Found(client),
Ok(_) => {
client.shutdown().await;
ProbeResult::NotFound
}
Err(e) => {
warning!(
ctx.log,
"Could not search agent host {} for session {}: {}",
label,
session,
e
);
client.shutdown().await;
ProbeResult::Unreachable(label)
}
}
}
/// Shared auto-discovery entry point for session-targeted commands
/// (`logs`, `stop`). Searches every live registry endpoint concurrently
/// for `session`, preferring `listSessions` catalog membership over
/// error-text sniffing, and returns a ready-to-use client for whichever
/// host owns it as soon as that host responds — it does not wait for
/// slower or hung hosts once a match is found. Unused/non-matching
/// clients are shut down before this returns; any probes still in
/// flight when a match is found are cancelled (never leaked) by simply
/// dropping them.
///
/// Returns [`CodeError::NoRunningAgentHost`] when the shared registry has
/// no live endpoints at all — the sole source of truth for automatic
/// discovery.
///
/// Callers with an explicit `--address`/`--tunnel` should call
/// [`agent::connect_explicit`] directly instead of this helper; this
/// path is only for auto-discovery.
pub async fn connect_to_session_host(
ctx: &CommandContext,
session: &str,
user_data_dir: Option<&str>,
) -> Result<Client, AnyError> {
let endpoints = discover_live_endpoints(ctx, user_data_dir);
if endpoints.is_empty() {
return Err(CodeError::NoRunningAgentHost.into());
}
let probes = FuturesUnordered::new();
for endpoint in endpoints {
probes.push(probe_host(ctx, endpoint, session));
}
match resolve_first_match(probes).await {
Ok(client) => Ok(client),
Err(SearchFailure::NotFound) => {
Err(CodeError::SessionNotFoundOnAnyHost(session.to_string()).into())
}
Err(SearchFailure::Incomplete(unreachable_labels)) => Err(
CodeError::IncompleteSessionSearch(session.to_string(), unreachable_labels.join(", "))
.into(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A fake host probe used by [`resolve_first_match`] tests: same
/// concrete future type regardless of arguments (required to share a
/// single [`FuturesUnordered`]), optionally sleeping first to
/// simulate a slow/hung host, and optionally flipping `completed`
/// so a test can assert it was never driven to completion.
async fn fake_probe(
delay: Option<std::time::Duration>,
result: ProbeResult<u32>,
completed: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> ProbeResult<u32> {
if let Some(delay) = delay {
tokio::time::sleep(delay).await;
}
if let Some(completed) = completed {
completed.store(true, std::sync::atomic::Ordering::SeqCst);
}
result
}
#[tokio::test]
async fn resolve_first_match_returns_immediately_and_cancels_remaining_probes() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
let hung_probe_completed = Arc::new(AtomicBool::new(false));
let probes = FuturesUnordered::new();
probes.push(fake_probe(None, ProbeResult::Found(42), None));
probes.push(fake_probe(
// Long enough that if it were ever awaited to completion the
// test would time out; the fix must never wait on it.
Some(Duration::from_secs(3600)),
ProbeResult::Unreachable("slow-host".to_string()),
Some(hung_probe_completed.clone()),
));
let outcome = tokio::time::timeout(Duration::from_millis(200), resolve_first_match(probes))
.await
.expect("resolve_first_match must not wait on the hung probe")
.expect("expected the fast host's match");
assert_eq!(outcome, 42);
assert!(
!hung_probe_completed.load(Ordering::SeqCst),
"the hung probe's future must be dropped/cancelled, not run to completion"
);
}
#[tokio::test]
async fn resolve_first_match_prefers_found_even_when_seen_after_unreachable() {
use std::time::Duration;
// The first probe to complete is Unreachable; a slightly slower
// probe later resolves with a match. A `Found` anywhere must
// still win over an already-observed `Unreachable`.
let probes = FuturesUnordered::new();
probes.push(fake_probe(
None,
ProbeResult::Unreachable("host-a".to_string()),
None,
));
probes.push(fake_probe(
Some(Duration::from_millis(10)),
ProbeResult::Found(7),
None,
));
let outcome = resolve_first_match(probes)
.await
.expect("expected the delayed match to win");
assert_eq!(outcome, 7);
}
#[tokio::test]
async fn resolve_first_match_is_not_found_when_every_host_was_searched() {
let probes = FuturesUnordered::new();
probes.push(fake_probe(None, ProbeResult::NotFound, None));
probes.push(fake_probe(None, ProbeResult::NotFound, None));
let err = resolve_first_match::<u32, _>(probes)
.await
.expect_err("no host matched, so this should be an Err");
assert_eq!(err, SearchFailure::NotFound);
}
#[tokio::test]
async fn resolve_first_match_is_incomplete_when_a_host_could_not_be_searched() {
let probes = FuturesUnordered::new();
probes.push(fake_probe(None, ProbeResult::NotFound, None));
probes.push(fake_probe(
None,
ProbeResult::Unreachable("host-b".to_string()),
None,
));
let err = resolve_first_match::<u32, _>(probes)
.await
.expect_err("no host matched, so this should be an Err");
assert_eq!(err, SearchFailure::Incomplete(vec!["host-b".to_string()]));
}
}
+85 -62
View File
@@ -17,13 +17,14 @@ use crate::constants::{self, AGENT_HOST_PORT};
use crate::log;
use crate::state::LauncherPaths;
use crate::tunnels::agent_host::{
classify_agent_host_lockfile, AgentHostConfig, AgentHostLockfileDecision, AgentHostManager,
classify_agent_host, AgentHostConfig, AgentHostManager, AgentHostReuseDecision,
AgentHostSidecar, LoopbackAuth,
};
use crate::tunnels::agent_host_metadata::remove_agent_host_metadata;
use crate::tunnels::agent_host_registry::{self, AgentHostEndpointIdentity, AgentHostServerType};
use crate::tunnels::code_server::CodeServerArgs;
use crate::tunnels::dev_tunnels::DevTunnels;
use crate::tunnels::shutdown_signal::ShutdownRequest;
use crate::tunnels::user_data_path::resolve_user_data_path;
use crate::update_service::Platform;
use crate::util::command::{kill_tree, DetachFromParent};
use crate::util::errors::{wrap, AnyError, CodeError};
@@ -36,13 +37,14 @@ use super::tunnels::fulfill_existing_tunnel_args;
use super::CommandContext;
/// Internal env var that flips `code agent host` into supervisor mode:
/// the body that actually binds the TCP listener, writes the lockfile,
/// owns the proxy sidecar, and manages the AH backend's lifecycle. The
/// foreground `code agent host` invocation re-execs itself detached with
/// this variable set so the supervisor outlives the user's terminal.
/// the body that actually binds the TCP listener, publishes the
/// supervisor's registry entry, owns the proxy sidecar, and manages the AH
/// backend's lifecycle. The foreground `code agent host` invocation
/// re-execs itself detached with this variable set so the supervisor
/// outlives the user's terminal.
const SUPERVISOR_ENV: &str = "VSCODE_AGENT_HOST_SUPERVISOR";
/// Single-line sentinel the supervisor prints once the listener is bound,
/// the lockfile is written, and the banner has been flushed. The
/// its registry entry is published, and the banner has been flushed. The
/// foreground process watches for this on the supervisor's stdout, then
/// either exits (`--detach`) or starts forwarding output.
const SUPERVISOR_READY_LINE: &str = "__VSCODE_AGENT_HOST_READY__";
@@ -52,16 +54,17 @@ const SUPERVISOR_READY_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// Runs the `code agent host` command. Acts in one of two modes:
///
/// * **Foreground** (the default): classifies the canonical lockfile and
/// either prints info about the live supervisor (`Reuse`), or daemonizes
/// a new supervisor child (`SpawnFresh`) and either exits (`--detach`)
/// or follows the supervisor's stdout until Ctrl-C.
/// * **Foreground** (the default): consults the shared local agent-host
/// endpoint registry and either prints info about the live supervisor
/// (`Reuse`), or daemonizes a new supervisor child (`SpawnFresh`) and
/// either exits (`--detach`) or follows the supervisor's stdout until
/// Ctrl-C.
///
/// * **Supervisor** (when [`SUPERVISOR_ENV`] is set): binds the public TCP
/// listener, writes the lockfile recording this process's PID + port,
/// runs the proxy accept loop, and manages the underlying VS Code server
/// as a regular child process so the supervisor can kill+respawn it on
/// update.
/// listener, publishes a registry entry recording this process's PID +
/// port, runs the proxy accept loop, and manages the underlying VS Code
/// server as a regular child process so the supervisor can kill+respawn
/// it on update.
pub async fn agent_host(ctx: CommandContext, args: AgentHostArgs) -> Result<i32, AnyError> {
if std::env::var_os(SUPERVISOR_ENV).is_some() {
return run_supervisor(ctx, args).await;
@@ -74,16 +77,17 @@ pub async fn agent_host(ctx: CommandContext, args: AgentHostArgs) -> Result<i32,
/// the user hits Ctrl-C.
async fn run_foreground(ctx: CommandContext, args: AgentHostArgs) -> Result<i32, AnyError> {
let started = Instant::now();
let lockfile_path = ctx.paths.agent_host_lockfile();
let user_data_path = resolve_user_data_path(args.user_data_dir.as_deref());
let decision = classify_agent_host_lockfile(&ctx.log, &lockfile_path);
let decision = classify_agent_host(&ctx.log, &user_data_path);
if let AgentHostLockfileDecision::Reuse {
if let AgentHostReuseDecision::Reuse {
pid,
host,
port,
token,
tunnel_name,
instance_id,
} = &decision
{
// User asked to replace explicitly: kill + spawn fresh, regardless
@@ -95,7 +99,7 @@ async fn run_foreground(ctx: CommandContext, args: AgentHostArgs) -> Result<i32,
pid,
port
);
replace_existing(&ctx.log, &lockfile_path, *pid).await?;
replace_existing(&ctx.log, &user_data_path, *pid, instance_id.clone()).await?;
return daemonize_supervisor(&args).await;
}
@@ -140,6 +144,8 @@ async fn run_foreground(ctx: CommandContext, args: AgentHostArgs) -> Result<i32,
/// until killed.
async fn run_supervisor(mut ctx: CommandContext, mut args: AgentHostArgs) -> Result<i32, AnyError> {
let started = Instant::now();
let user_data_path = resolve_user_data_path(args.user_data_dir.as_deref());
let instance_id = uuid::Uuid::new_v4().to_string();
// Attach a file log sink before anything else, so download progress,
// AH child crashes, update-loop errors, and post-handoff diagnostics
@@ -257,7 +263,8 @@ async fn run_supervisor(mut ctx: CommandContext, mut args: AgentHostArgs) -> Res
args.host.clone(),
loopback_auth,
tunnel_name.clone(),
ctx.paths.agent_host_lockfile(),
user_data_path.clone(),
instance_id.clone(),
)
.await?;
let bound_port = sidecar.bound_addr().port();
@@ -359,10 +366,10 @@ fn print_reuse_banner(
if let (Some(base), Some(name)) = (constants::EDITOR_WEB_URL, tunnel_name) {
output::print_banner_line("Tunnel", &format!("{base}/agents/tunnel/{name}"));
}
// Surface the host the supervisor was actually bound to (older
// lockfiles omit it; fall back to loopback). This lets the network
// hint correctly say "use --host to expose" only when the supervisor
// really is loopback-only.
// Surface the host the supervisor was actually bound to (falling back
// to loopback if unknown). This lets the network hint correctly say
// "use --host to expose" only when the supervisor really is
// loopback-only.
let banner_listen_ip = host
.and_then(|h| h.parse::<std::net::IpAddr>().ok())
.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
@@ -377,43 +384,44 @@ fn print_reuse_banner(
}
/// Compare the user's requested supervisor configuration with what's
/// recorded in the lockfile. Returns a short human description of the
/// first conflict found (e.g. `"--host 0.0.0.0 conflicts with the
/// running supervisor (bound to 127.0.0.1)"`), or `None` when the
/// requested config is compatible with sharing the existing supervisor.
/// recorded for the running supervisor's registry entry. Returns a short
/// human description of the first conflict found (e.g. `"--host 0.0.0.0
/// conflicts with the running supervisor (bound to 127.0.0.1)"`), or
/// `None` when the requested config is compatible with sharing the
/// existing supervisor.
///
/// `lockfile_host` may be `None` when the lockfile was written by an
/// older CLI; in that case we conservatively skip the host comparison
/// (the supervisor is most likely loopback, which is the default).
/// `running_host` may be `None` when the registry entry doesn't record a
/// host; in that case we conservatively skip the host comparison (the
/// supervisor is most likely loopback, which is the default).
fn detect_config_conflict(
args: &AgentHostArgs,
lockfile_host: Option<&str>,
lockfile_port: u16,
lockfile_token: Option<&str>,
lockfile_tunnel: Option<&str>,
running_host: Option<&str>,
running_port: u16,
running_token: Option<&str>,
running_tunnel: Option<&str>,
) -> Option<String> {
if let (Some(requested), Some(running)) = (args.host.as_deref(), lockfile_host) {
if let (Some(requested), Some(running)) = (args.host.as_deref(), running_host) {
if requested != running {
return Some(format!(
"--host {requested} conflicts with the running supervisor (bound to {running})"
));
}
}
if args.port != 0 && args.port != lockfile_port {
if args.port != 0 && args.port != running_port {
return Some(format!(
"--port {requested} conflicts with the running supervisor (bound to {running})",
requested = args.port,
running = lockfile_port,
running = running_port,
));
}
if args.without_connection_token && lockfile_token.is_some() {
if args.without_connection_token && running_token.is_some() {
return Some(
"--without-connection-token conflicts with the running supervisor (uses a token)"
.to_string(),
);
}
if let Some(requested) = args.connection_token.as_deref() {
match lockfile_token {
match running_token {
None => {
return Some(
"--connection-token conflicts with the running supervisor (no token configured)"
@@ -428,7 +436,7 @@ fn detect_config_conflict(
Some(_) => {}
}
}
if args.tunnel && lockfile_tunnel.is_none() {
if args.tunnel && running_tunnel.is_none() {
return Some(
"--tunnel conflicts with the running supervisor (not exposed via a tunnel)".to_string(),
);
@@ -436,9 +444,16 @@ fn detect_config_conflict(
None
}
/// Kill the existing supervisor process tree and drop the lockfile so the
/// subsequent supervisor start writes a clean record.
async fn replace_existing(log: &log::Logger, lockfile: &Path, pid: u32) -> Result<(), AnyError> {
/// Kill the existing supervisor process tree and remove its exact
/// `(standalone, pid, instanceId)` entry from the shared local agent-host
/// endpoint registry, so the subsequent supervisor start publishes a
/// clean one.
async fn replace_existing(
log: &log::Logger,
user_data_path: &Path,
pid: u32,
instance_id: String,
) -> Result<(), AnyError> {
if let Err(e) = kill_tree(pid).await {
warning!(
log,
@@ -447,7 +462,12 @@ async fn replace_existing(log: &log::Logger, lockfile: &Path, pid: u32) -> Resul
e
);
}
let _ = remove_agent_host_metadata(lockfile);
let identity = AgentHostEndpointIdentity {
server_type: AgentHostServerType::Standalone,
pid,
instance_id,
};
agent_host_registry::remove_agent_host_endpoint(log, user_data_path, &identity);
Ok(())
}
@@ -522,14 +542,14 @@ pub async fn ensure_supervisor_running(
launcher_paths: &LauncherPaths,
log: &log::Logger,
) -> Result<ActiveAgentHost, AnyError> {
let lockfile_path = launcher_paths.agent_host_lockfile();
if let AgentHostLockfileDecision::Reuse {
let user_data_path = resolve_user_data_path(None);
if let AgentHostReuseDecision::Reuse {
pid,
host,
port,
token,
..
} = classify_agent_host_lockfile(log, &lockfile_path)
} = classify_agent_host(log, &user_data_path)
{
return Ok(ActiveAgentHost {
pid,
@@ -594,8 +614,8 @@ pub async fn ensure_supervisor_running(
}
}
match classify_agent_host_lockfile(log, &lockfile_path) {
AgentHostLockfileDecision::Reuse {
match classify_agent_host(log, &user_data_path) {
AgentHostReuseDecision::Reuse {
pid,
host,
port,
@@ -607,22 +627,25 @@ pub async fn ensure_supervisor_running(
port,
token,
}),
AgentHostLockfileDecision::SpawnFresh => Err(CodeError::CouldNotListenOnInterface(
std::io::Error::other("agent host supervisor signalled ready but lockfile is missing"),
)
.into()),
AgentHostReuseDecision::SpawnFresh => {
Err(CodeError::CouldNotListenOnInterface(std::io::Error::other(
"agent host supervisor signalled ready but its registry entry is missing",
))
.into())
}
}
}
/// Endpoint of a running agent host supervisor, as recorded in the
/// lockfile and consumed by tunnel + bridge callers.
/// Endpoint of a running agent host supervisor, as recorded in the shared
/// local agent-host endpoint registry and consumed by tunnel + bridge
/// callers.
pub struct ActiveAgentHost {
pub pid: u32,
/// Host the supervisor was bound to (e.g. `"0.0.0.0"`, `"::1"`,
/// `"localhost"`, a specific IP). `None` for lockfiles written by
/// older CLIs. Consumers should pair this with [`dial_host`] to
/// pick the right loopback target when the supervisor was bound to
/// a wildcard.
/// `"localhost"`, a specific IP). `None` when the registry entry
/// doesn't record a host. Consumers should pair this with
/// [`dial_host`] to pick the right loopback target when the
/// supervisor was bound to a wildcard.
pub host: Option<String>,
pub port: u16,
pub token: Option<String>,
@@ -632,8 +655,8 @@ impl ActiveAgentHost {
/// Loopback address callers should dial to reach this supervisor.
/// Maps IPv4/IPv6 wildcards (`0.0.0.0` / `::`) to the corresponding
/// loopback; passes specific hosts (e.g. `::1`, `localhost`,
/// `10.0.0.5`) through unchanged. Missing host (older lockfile)
/// falls back to IPv4 loopback to preserve the prior behaviour.
/// `10.0.0.5`) through unchanged. A missing host falls back to IPv4
/// loopback to preserve the prior behaviour.
pub fn dial_host(&self) -> &str {
dial_host(self.host.as_deref())
}
+143 -32
View File
@@ -3,54 +3,165 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
use std::fs;
use crate::constants::IS_INTERACTIVE_CLI;
use crate::log;
use crate::tunnels::agent_host_metadata::AgentHostMetadata;
use crate::tunnels::agent_host_registry::{self, AgentHostEndpointMetadata};
use crate::tunnels::user_data_path::resolve_user_data_path;
use crate::util::command::kill_tree;
use crate::util::errors::{wrap, AnyError};
use crate::util::machine::process_exists;
use crate::util::errors::{wrap, AnyError, CodeError};
use crate::util::input::prompt_index;
use super::args::AgentKillArgs;
use super::CommandContext;
/// Forcefully kills the running agent host process tree and cleans up.
pub async fn agent_kill(ctx: CommandContext) -> Result<i32, AnyError> {
let lockfile_path = ctx.paths.agent_host_lockfile();
/// Forcefully kills a running **standalone** agent host process tree and
/// cleans up its registry entry.
///
/// Only ever considers `standalone` entries (see
/// [`agent_host_registry::list_live_standalone_endpoints`]): `editor`
/// entries are owned by running VS Code windows and must never be killed
/// by this command. If exactly one live standalone host is registered it
/// is killed directly, matching prior single-instance behavior. If more
/// than one exists, `--instance-id` selects one non-interactively;
/// otherwise, in an interactive terminal, the user is prompted to choose
/// by pid/instance/address/quality. In a non-interactive context with
/// more than one candidate and no `--instance-id`, this fails rather than
/// guessing which host to kill. Returns
/// [`CodeError::NoRunningAgentHost`] when the shared registry has no live
/// standalone entry at all.
pub async fn agent_kill(ctx: CommandContext, args: AgentKillArgs) -> Result<i32, AnyError> {
let user_data_path = resolve_user_data_path(args.user_data_dir.as_deref());
let candidates = agent_host_registry::list_live_standalone_endpoints(&ctx.log, &user_data_path);
let data = fs::read_to_string(&lockfile_path).map_err(|e| {
wrap(
e,
"No running agent host found. Start one with `code agent host`",
)
})?;
let metadata: AgentHostMetadata = serde_json::from_str(&data).map_err(|e| {
wrap(
e,
format!("Corrupt agent host lockfile at {}", lockfile_path.display()),
)
})?;
if !process_exists(metadata.pid) {
let _ = fs::remove_file(&lockfile_path);
ctx.log
.result("Agent host is not running (stale lockfile cleaned up).");
return Ok(0);
if candidates.is_empty() {
return Err(CodeError::NoRunningAgentHost.into());
}
let selected = select_candidate(&candidates, args.instance_id.as_deref())?;
kill_standalone_endpoint(&ctx, &user_data_path, selected).await
}
/// Picks the standalone endpoint to kill out of `candidates`, which must
/// be non-empty. Behavior:
/// - `instance_id` given: the matching candidate, or an error if none
/// matches (even when there is only one candidate — an explicit
/// mismatch should not silently kill the wrong host).
/// - Exactly one candidate, no `instance_id`: that one, directly.
/// - Multiple candidates, no `instance_id`, interactive terminal: prompt.
/// - Multiple candidates, no `instance_id`, non-interactive: error asking
/// for `--instance-id`.
fn select_candidate<'a>(
candidates: &'a [AgentHostEndpointMetadata],
instance_id: Option<&str>,
) -> Result<&'a AgentHostEndpointMetadata, AnyError> {
if let Some(instance_id) = instance_id {
return candidates
.iter()
.find(|e| e.instance_id == instance_id)
.ok_or_else(|| CodeError::UnknownAgentHostInstance(instance_id.to_string()).into());
}
if candidates.len() == 1 {
return Ok(&candidates[0]);
}
if !*IS_INTERACTIVE_CLI {
let ids = candidates
.iter()
.map(|e| e.instance_id.clone())
.collect::<Vec<_>>()
.join(", ");
return Err(CodeError::AmbiguousAgentHostInstance(ids).into());
}
let labels: Vec<String> = candidates.iter().map(|e| e.label()).collect();
let chosen = prompt_index(
"Multiple standalone agent hosts are running; pick one to kill",
&labels,
)
.map_err(|e| wrap(e, "Failed to read agent host selection"))?;
Ok(&candidates[chosen])
}
async fn kill_standalone_endpoint(
ctx: &CommandContext,
user_data_path: &std::path::Path,
selected: &AgentHostEndpointMetadata,
) -> Result<i32, AnyError> {
debug!(
ctx.log,
"Killing agent host process tree (pid {})", metadata.pid
"Killing agent host process tree (pid {})", selected.pid
);
kill_tree(metadata.pid)
kill_tree(selected.pid)
.await
.map_err(|e| wrap(e, "Failed to kill agent host process tree"))?;
let _ = fs::remove_file(&lockfile_path);
agent_host_registry::remove_agent_host_endpoint(&ctx.log, user_data_path, &selected.identity());
ctx.log
.result(format!("Killed agent host (pid {}).", metadata.pid));
.result(format!("Killed agent host (pid {}).", selected.pid));
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn standalone(pid: u32, instance_id: &str) -> AgentHostEndpointMetadata {
AgentHostEndpointMetadata::new_standalone(
pid,
instance_id.to_string(),
"127.0.0.1".to_string(),
9000,
"tok".to_string(),
"1".to_string(),
None,
None,
)
}
#[test]
fn select_candidate_returns_single_candidate_directly() {
let candidates = vec![standalone(1, "only")];
let selected = select_candidate(&candidates, None).unwrap();
assert_eq!(selected.instance_id, "only");
}
#[test]
fn select_candidate_with_instance_id_matches_even_when_only_one_candidate() {
let candidates = vec![standalone(1, "only")];
let selected = select_candidate(&candidates, Some("only")).unwrap();
assert_eq!(selected.instance_id, "only");
}
#[test]
fn select_candidate_with_mismatched_instance_id_errors() {
let candidates = vec![standalone(1, "only")];
let err = select_candidate(&candidates, Some("nope")).unwrap_err();
assert!(matches!(
err,
AnyError::CodeError(CodeError::UnknownAgentHostInstance(ref id)) if id == "nope"
));
}
#[test]
fn select_candidate_with_instance_id_picks_matching_one_of_several() {
let candidates = vec![standalone(1, "a"), standalone(2, "b")];
let selected = select_candidate(&candidates, Some("b")).unwrap();
assert_eq!(selected.pid, 2);
}
#[test]
fn select_candidate_multiple_without_instance_id_in_non_interactive_test_env_errors() {
// `cargo test` runs with no attached TTY, so `IS_INTERACTIVE_CLI` is
// false here; this exercises the non-interactive-disambiguation
// error path without needing to fake a terminal/prompt.
let candidates = vec![standalone(1, "a"), standalone(2, "b")];
let err = select_candidate(&candidates, None).unwrap_err();
assert!(matches!(
err,
AnyError::CodeError(CodeError::AmbiguousAgentHostInstance(_))
));
}
}
+15 -1
View File
@@ -13,13 +13,27 @@ use crate::tunnels::shutdown_signal::ShutdownRequest;
use crate::util::errors::AnyError;
use super::agent;
use super::agent_discovery;
use super::args::AgentLogsArgs;
use super::output::Styles;
use super::CommandContext;
/// Subscribes to a session and streams actions/notifications in real time.
pub async fn agent_logs(ctx: CommandContext, args: AgentLogsArgs) -> Result<i32, AnyError> {
let client = agent::connect(&ctx, args.address.as_deref(), args.tunnel.as_deref()).await?;
let client = match (
args.discovery.address.as_deref(),
args.discovery.tunnel.as_deref(),
) {
(None, None) => {
agent_discovery::connect_to_session_host(
&ctx,
&args.session,
args.discovery.user_data_dir.as_deref(),
)
.await?
}
(address, tunnel) => agent::connect_explicit(&ctx, address, tunnel).await?,
};
let (result, mut sub): (SubscribeResult, _) = {
let r: SubscribeResult = agent::request_with_auth(
+309 -16
View File
@@ -6,20 +6,63 @@
use ahp_types::commands::{ListSessionsParams, ListSessionsResult};
use ahp_types::state::{SessionStatus, SessionSummary};
use ahp_types::ROOT_RESOURCE_URI;
use futures::stream::FuturesUnordered;
use futures::StreamExt;
use serde::Serialize;
use crate::util::errors::AnyError;
use crate::log;
use crate::tunnels::agent_host_registry::AgentHostEndpointMetadata;
use crate::util::errors::{wrap, AnyError, CodeError};
use super::agent;
use super::agent_discovery;
use super::args::AgentPsArgs;
use super::output::{self, Styles};
use super::CommandContext;
/// Lists active sessions on a running agent host.
///
/// With an explicit `--address`/`--tunnel`, this queries exactly one host
/// and prints its sessions with no host-provenance header, same as before
/// multi-host discovery existed. Otherwise every live registry endpoint
/// (both `editor` and `standalone`, both socket/pipe and TCP) is queried
/// concurrently; human output is printed as each host completes rather
/// than waiting for the slowest one, and `--json` collects every host's
/// outcome into a single valid JSON document tagged with host
/// provenance. Returns [`CodeError::NoRunningAgentHost`] when the shared
/// registry has no live entries at all — the sole source of truth for
/// automatic discovery.
pub async fn agent_ps(ctx: CommandContext, args: AgentPsArgs) -> Result<i32, AnyError> {
let client = agent::connect(&ctx, args.address.as_deref(), args.tunnel.as_deref()).await?;
if args.discovery.address.is_some() || args.discovery.tunnel.is_some() {
return agent_ps_single(
&ctx,
&args,
args.discovery.address.as_deref(),
args.discovery.tunnel.as_deref(),
)
.await;
}
let endpoints =
agent_discovery::discover_live_endpoints(&ctx, args.discovery.user_data_dir.as_deref());
if endpoints.is_empty() {
return Err(CodeError::NoRunningAgentHost.into());
}
agent_ps_multi(&ctx, &args, endpoints).await
}
/// Single-host path used for an explicit `--address`/`--tunnel`.
async fn agent_ps_single(
ctx: &CommandContext,
args: &AgentPsArgs,
address: Option<&str>,
tunnel: Option<&str>,
) -> Result<i32, AnyError> {
let client = agent::connect_explicit(ctx, address, tunnel).await?;
let result: ListSessionsResult = agent::request_with_auth(
&ctx,
ctx,
&client,
"listSessions",
ListSessionsParams {
@@ -31,22 +74,11 @@ pub async fn agent_ps(ctx: CommandContext, args: AgentPsArgs) -> Result<i32, Any
client.shutdown().await;
let mut items: Vec<&SessionSummary> = if args.all {
result.items.iter().collect()
} else {
result
.items
.iter()
.filter(|s| is_active(s.status))
.collect()
};
// Most-recently-modified first.
items.sort_by_key(|b| std::cmp::Reverse(b.modified_at));
let items = select_and_sort(&result.items, args.all);
if args.json {
let json = serde_json::to_string_pretty(&items)
.map_err(|e| crate::util::errors::wrap(e, "Failed to serialize sessions"))?;
.map_err(|e| wrap(e, "Failed to serialize sessions"))?;
output::print_paged(&json);
} else if items.is_empty() {
ctx.log.result("No active sessions.");
@@ -58,6 +90,198 @@ pub async fn agent_ps(ctx: CommandContext, args: AgentPsArgs) -> Result<i32, Any
Ok(0)
}
/// Outcome of querying one discovered host for its session list.
struct HostSessions {
endpoint: AgentHostEndpointMetadata,
sessions: Result<Vec<SessionSummary>, AnyError>,
}
async fn query_host(ctx: &CommandContext, endpoint: AgentHostEndpointMetadata) -> HostSessions {
let sessions = query_host_sessions(ctx, &endpoint).await;
HostSessions { endpoint, sessions }
}
async fn query_host_sessions(
ctx: &CommandContext,
endpoint: &AgentHostEndpointMetadata,
) -> Result<Vec<SessionSummary>, AnyError> {
let client = agent::connect_to_endpoint(endpoint).await?;
let result: ListSessionsResult = agent::request_with_auth(
ctx,
&client,
"listSessions",
ListSessionsParams {
channel: ROOT_RESOURCE_URI.to_string(),
filter: None,
},
)
.await?;
client.shutdown().await;
Ok(result.items)
}
/// Multi-host auto-discovery path (requirement 3): connects to and
/// queries every discovered host concurrently via `FuturesUnordered`,
/// printing each host's results (human mode) as soon as that host
/// completes rather than waiting for the slowest one. Per-host failures
/// are reported as warnings and don't stop other hosts; the command only
/// returns an error if *no* host could be queried at all. `--json`
/// collects every host's outcome (success or error) before printing
/// once, so the emitted JSON is always a single valid document — never
/// corrupted by interleaving multiple hosts' output — tagged with host
/// provenance.
async fn agent_ps_multi(
ctx: &CommandContext,
args: &AgentPsArgs,
endpoints: Vec<AgentHostEndpointMetadata>,
) -> Result<i32, AnyError> {
let mut tasks = FuturesUnordered::new();
for endpoint in endpoints {
tasks.push(query_host(ctx, endpoint));
}
let mut any_succeeded = false;
let mut collected: Vec<HostSessions> = Vec::new();
while let Some(outcome) = tasks.next().await {
match &outcome.sessions {
Ok(_) => any_succeeded = true,
Err(e) => {
warning!(
ctx.log,
"Could not query agent host {}: {}",
outcome.endpoint.label(),
e
);
}
}
if args.json {
collected.push(outcome);
} else {
print_host_outcome_human(&outcome, args.all);
}
}
if !any_succeeded {
return Err(CodeError::NoAgentHostReachable(
"no discovered agent host could be queried".to_string(),
)
.into());
}
if args.json {
let json = build_json_output(&collected, args.all)?;
output::print_paged(&json);
}
Ok(0)
}
/// Prints one host's outcome immediately (bypasses the pager used by the
/// single-host path: paging would buffer output until the whole command
/// finishes, defeating the point of streaming results as hosts
/// complete).
fn print_host_outcome_human(outcome: &HostSessions, all: bool) {
let header = Styles::title();
println!(
"\n{}",
header.apply_to(format!("── {} ──", outcome.endpoint.label()))
);
match &outcome.sessions {
Ok(sessions) => {
let items = select_and_sort(sessions, all);
if items.is_empty() {
println!(" {}", Styles::muted().apply_to("No active sessions."));
} else {
print!("{}", format_sessions_list(&items));
}
}
Err(e) => {
println!(" {}", Styles::error().apply_to(format!("{e}")));
}
}
}
/// JSON-serializable host provenance tag, kept intentionally small and
/// stable (not just a re-export of [`AgentHostEndpointMetadata`], whose
/// shape is an internal implementation detail of the registry file).
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct HostJson {
#[serde(rename = "type")]
server_type: &'static str,
pid: u32,
instance_id: String,
address: String,
#[serde(skip_serializing_if = "Option::is_none")]
quality: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tunnel_name: Option<String>,
}
impl From<&AgentHostEndpointMetadata> for HostJson {
fn from(e: &AgentHostEndpointMetadata) -> Self {
HostJson {
server_type: match e.server_type {
crate::tunnels::agent_host_registry::AgentHostServerType::Editor => "editor",
crate::tunnels::agent_host_registry::AgentHostServerType::Standalone => {
"standalone"
}
},
pid: e.pid,
instance_id: e.instance_id.clone(),
address: e.address_label(),
quality: e.quality.clone(),
tunnel_name: e.tunnel_name.clone(),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct HostSessionsJson<'a> {
host: HostJson,
#[serde(skip_serializing_if = "Option::is_none")]
sessions: Option<Vec<&'a SessionSummary>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
fn build_json_output(collected: &[HostSessions], all: bool) -> Result<String, AnyError> {
let hosts: Vec<HostSessionsJson> = collected
.iter()
.map(|outcome| HostSessionsJson {
host: HostJson::from(&outcome.endpoint),
sessions: outcome
.sessions
.as_ref()
.ok()
.map(|s| select_and_sort(s, all)),
error: outcome.sessions.as_ref().err().map(|e| e.to_string()),
})
.collect();
serde_json::to_string_pretty(&hosts).map_err(|e| wrap(e, "Failed to serialize sessions").into())
}
/// Applies the `--all` filter (active-only unless set) and sorts
/// most-recently-modified first, shared by every output path so human
/// and JSON output (single- or multi-host) always agree on ordering.
fn select_and_sort(sessions: &[SessionSummary], all: bool) -> Vec<&SessionSummary> {
let mut items: Vec<&SessionSummary> = if all {
sessions.iter().collect()
} else {
sessions.iter().filter(|s| is_active(s.status)).collect()
};
items.sort_by_key(|b| std::cmp::Reverse(b.modified_at));
items
}
/// A session is "active" if it is in-progress, needs input, or errored
/// (i.e. not just idle/archived).
fn is_active(status: u32) -> bool {
@@ -131,3 +355,72 @@ fn status_styled(status: u32) -> console::StyledObject<String> {
Styles::muted().apply_to(format!("? unknown ({})", status.bits()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn session(resource: &str, status: u32, modified_at: i64) -> SessionSummary {
SessionSummary {
resource: resource.to_string(),
provider: "test".to_string(),
title: String::new(),
status,
activity: None,
created_at: 0,
modified_at,
project: None,
model: None,
agent: None,
working_directory: None,
changes: None,
annotations: None,
}
}
#[test]
fn select_and_sort_filters_idle_unless_all() {
let idle = session("a", SessionStatus::Idle.bits(), 1);
let active = session("b", SessionStatus::InProgress.bits(), 2);
let sessions = vec![idle.clone(), active.clone()];
let filtered = select_and_sort(&sessions, false);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].resource, "b");
let all = select_and_sort(&sessions, true);
assert_eq!(all.len(), 2);
}
#[test]
fn select_and_sort_orders_most_recently_modified_first() {
let older = session("a", SessionStatus::InProgress.bits(), 1);
let newer = session("b", SessionStatus::InProgress.bits(), 2);
let sessions = vec![older, newer];
let sorted = select_and_sort(&sessions, true);
assert_eq!(sorted[0].resource, "b");
assert_eq!(sorted[1].resource, "a");
}
#[test]
fn host_json_carries_provenance_fields() {
let entry = AgentHostEndpointMetadata::new_standalone(
42,
"instance-a".to_string(),
"127.0.0.1".to_string(),
8080,
"tok".to_string(),
"0.1.0".to_string(),
Some("insider".to_string()),
None,
);
let json = HostJson::from(&entry);
assert_eq!(json.server_type, "standalone");
assert_eq!(json.pid, 42);
assert_eq!(json.instance_id, "instance-a");
assert_eq!(json.address, "127.0.0.1:8080");
assert_eq!(json.quality.as_deref(), Some("insider"));
}
}
+15 -1
View File
@@ -11,13 +11,27 @@ use crate::log;
use crate::util::errors::{wrap, AnyError};
use super::agent;
use super::agent_discovery;
use super::args::AgentStopArgs;
use super::CommandContext;
/// Cancels the active turn of every in-progress chat in a session on a running
/// agent host.
pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result<i32, AnyError> {
let client = agent::connect(&ctx, args.address.as_deref(), args.tunnel.as_deref()).await?;
let client = match (
args.discovery.address.as_deref(),
args.discovery.tunnel.as_deref(),
) {
(None, None) => {
agent_discovery::connect_to_session_host(
&ctx,
&args.session,
args.discovery.user_data_dir.as_deref(),
)
.await?
}
(address, tunnel) => agent::connect_explicit(&ctx, address, tunnel).await?,
};
// Subscribe to the session to get its catalog of chats.
let result: SubscribeResult = agent::request_with_auth(
+51 -17
View File
@@ -243,7 +243,7 @@ pub struct AgentHostArgs {
pub host: Option<String>,
/// Port the agent host should bind on. If 0 (the default) the OS
/// picks a free ephemeral port; the chosen port is recorded in the
/// agent host lockfile.
/// shared agent-host endpoint registry.
#[clap(long, default_value_t = 0)]
pub port: u16,
/// A secret that must be included with all requests.
@@ -259,6 +259,15 @@ pub struct AgentHostArgs {
#[clap(long)]
pub server_data_dir: Option<String>,
/// Overrides the resolved user data directory used to home the shared
/// local agent-host endpoint registry
/// (`<user-data-dir>/agent-host/local-endpoint/metadata.json`), the same
/// file editor windows publish to. Defaults to the platform user data
/// directory (honoring `VSCODE_PORTABLE` / `VSCODE_APPDATA` when set),
/// matching the editor's own resolution rules.
#[clap(long)]
pub user_data_dir: Option<String>,
/// Stop any agent host already running on this machine and start a
/// fresh one. Without this flag, the command reuses an existing live
/// supervisor when its configuration is compatible, and errors out
@@ -304,14 +313,29 @@ pub enum AgentSubcommand {
Stop(AgentStopArgs),
/// Forcefully kill the running agent host process tree.
Kill,
Kill(AgentKillArgs),
/// Stream live session events.
Logs(AgentLogsArgs),
}
/// Discovery/connection target shared by every agent-host command that
/// can either auto-discover a local instance or target one explicitly:
/// `code agent ps|stop|logs`. `--user-data-dir` scopes automatic
/// discovery to a specific registry (see
/// [`crate::commands::agent_discovery::discover_live_endpoints`]);
/// `--address`/`--tunnel` bypass discovery entirely and connect to
/// exactly one explicit target (see
/// [`crate::commands::agent::connect_explicit`]). Passing neither
/// `--address` nor `--tunnel` means "discover automatically", not
/// "connect nowhere" — every consumer of this struct must branch on that
/// itself.
#[derive(Args, Debug, Clone)]
pub struct AgentPsArgs {
pub struct AgentDiscoveryArgs {
/// Directory containing the shared agent host registry used for automatic discovery.
#[clap(long)]
pub user_data_dir: Option<String>,
/// WebSocket address of a running agent host (e.g. ws://127.0.0.1:1234?tkn=secret).
/// If omitted, the CLI discovers a locally running agent host automatically.
#[clap(long)]
@@ -320,6 +344,13 @@ pub struct AgentPsArgs {
/// Connect via a named dev tunnel instead of the local address.
#[clap(long)]
pub tunnel: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub struct AgentPsArgs {
/// Discovery/connection target; see [`AgentDiscoveryArgs`].
#[clap(flatten)]
pub discovery: AgentDiscoveryArgs,
/// Output results as JSON instead of a human-readable table.
#[clap(long)]
@@ -335,14 +366,9 @@ pub struct AgentStopArgs {
/// Session URI to cancel the active turn of (e.g. copilot:/<uuid>).
pub session: String,
/// WebSocket address of a running agent host.
/// If omitted, the CLI discovers a locally running agent host automatically.
#[clap(long)]
pub address: Option<String>,
/// Connect via a named dev tunnel instead of the local address.
#[clap(long)]
pub tunnel: Option<String>,
/// Discovery/connection target; see [`AgentDiscoveryArgs`].
#[clap(flatten)]
pub discovery: AgentDiscoveryArgs,
}
#[derive(Args, Debug, Clone)]
@@ -350,14 +376,22 @@ pub struct AgentLogsArgs {
/// Session URI to stream events for (e.g. copilot:/<uuid>).
pub session: String,
/// WebSocket address of a running agent host.
/// If omitted, the CLI discovers a locally running agent host automatically.
#[clap(long)]
pub address: Option<String>,
/// Discovery/connection target; see [`AgentDiscoveryArgs`].
#[clap(flatten)]
pub discovery: AgentDiscoveryArgs,
}
/// Connect via a named dev tunnel instead of the local address.
#[derive(Args, Debug, Clone)]
pub struct AgentKillArgs {
/// Directory containing the shared agent host registry used for automatic discovery.
#[clap(long)]
pub tunnel: Option<String>,
pub user_data_dir: Option<String>,
/// Instance ID of the standalone agent host to kill, as shown when
/// multiple are running. Required to select non-interactively when
/// more than one live standalone agent host is registered.
#[clap(long)]
pub instance_id: Option<String>,
}
#[derive(Args, Debug, Clone)]
+14 -3
View File
@@ -67,6 +67,16 @@ pub const QUALITYLESS_PRODUCT_NAME: &str = match option_env!("VSCODE_CLI_QUALITY
None => "Code",
};
/// Short product name, mirroring `product.json`'s `nameShort` (e.g. `Code -
/// OSS`, `Visual Studio Code`). Used as the leaf directory name when
/// resolving the platform user data directory, matching the TypeScript
/// resolver in `src/vs/platform/environment/node/userDataPath.ts` (which is
/// passed `product.nameShort`).
pub const PRODUCT_NAME_SHORT: &str = match option_env!("VSCODE_CLI_NAME_SHORT") {
Some(n) => n,
None => "Code - OSS",
};
/// Name of the application without quality information.
pub const QUALITYLESS_SERVER_NAME: &str = concatcp!(QUALITYLESS_PRODUCT_NAME, " Server");
@@ -94,9 +104,10 @@ pub const DEFAULT_DATA_PARENT_DIR: &str = match option_env!("VSCODE_CLI_DATA_FOL
/// Canonical, machine-wide parent directory used to coordinate the agent
/// host across CLI invocations. Mirrors the `serverDataFolderName` in
/// `product.json` so the lockfile/log written by `code agent host` lines
/// up with the directory the SSH `command-shell` entry point already uses
/// (otherwise local + remote would race on different lockfiles).
/// `product.json` so the supervisor log written by `code agent host`
/// lines up with the directory the SSH `command-shell` entry point
/// already uses (otherwise local + remote would race on different
/// directories).
pub const SERVER_DATA_PARENT_DIR: &str = match option_env!("VSCODE_CLI_SERVER_DATA_FOLDER_NAME") {
Some(n) => n,
None => ".vscode-server-oss",
+3 -15
View File
@@ -237,18 +237,6 @@ impl LauncherPaths {
})
}
/// Lockfile for the running agent host. Pinned to the canonical
/// server data dir (see [`agent_host_root`]) so a `code agent host`
/// invoked locally and the supervisor spawned by the SSH
/// `command-shell` path agree on the same lockfile regardless of
/// `--cli-data-dir`.
pub fn agent_host_lockfile(&self) -> PathBuf {
self.agent_host_root().join(format!(
"agent-host-{}.lock",
VSCODE_CLI_QUALITY.unwrap_or("oss")
))
}
/// Suggested path for the detached `code agent host` supervisor's log
/// file. The supervisor severs its inherited stdio after signalling
/// readiness, so a file log is the only way to debug post-handoff
@@ -260,10 +248,10 @@ impl LauncherPaths {
))
}
/// Canonical machine-wide directory holding agent host coordination
/// files (lockfile + supervisor log). Anchored on `serverDataFolderName`
/// Canonical machine-wide directory holding the detached `code agent
/// host` supervisor's log file. Anchored on `serverDataFolderName`
/// rather than `self.root` so different `--cli-data-dir` values still
/// land on the same lockfile. Falls back to `self.root` when no home
/// land on the same log file. Falls back to `self.root` when no home
/// directory is available.
fn agent_host_root(&self) -> PathBuf {
match dirs::home_dir() {
+4 -1
View File
@@ -14,7 +14,9 @@ pub mod singleton_client;
pub mod singleton_server;
pub mod agent_host;
pub mod agent_host_metadata;
pub mod agent_host_registry;
#[cfg(windows)]
mod agent_host_registry_acl_windows;
mod challenge;
mod control_server;
mod nosleep;
@@ -35,6 +37,7 @@ mod service_macos;
#[cfg(target_os = "windows")]
mod service_windows;
mod socket_signal;
pub mod user_data_path;
mod wsl_detect;
pub use control_server::{
+391 -198
View File
@@ -21,7 +21,9 @@ use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use crate::async_pipe::{get_socket_name, get_socket_rw_stream, listen_socket_rw_stream, AsyncPipeListener};
use crate::async_pipe::{
get_socket_name, get_socket_rw_stream, listen_socket_rw_stream, AsyncPipeListener,
};
use crate::constants::VSCODE_CLI_QUALITY;
use crate::download_cache::DownloadCache;
use crate::log;
@@ -36,8 +38,9 @@ use crate::util::http::{empty_body, full_body, HyperBody};
use crate::util::io::SilentCopyProgress;
use crate::util::sync::{new_barrier, Barrier, BarrierOpener};
use super::agent_host_metadata::{
remove_agent_host_metadata_for_pid, write_agent_host_metadata, AgentHostMetadata,
use super::agent_host_registry::{
self, AgentHostEndpointIdentity, AgentHostEndpointMetadata, AgentHostServerType,
AGENT_HOST_PROTOCOL_VERSION,
};
use super::paths::{get_server_folder_name, SERVER_FOLDER_NAME};
use super::shutdown_signal::ShutdownSignal;
@@ -634,13 +637,21 @@ impl AgentHostManager {
let mut listener = match listen_socket_rw_stream(path).await {
Ok(l) => l,
Err(e) => {
warning!(self.log, "Failed to bind management socket {:?}: {}", path, e);
warning!(
self.log,
"Failed to bind management socket {:?}: {}",
path,
e
);
self.management_listener_started
.store(false, Ordering::SeqCst);
return;
}
};
debug!(self.log, "Listening for agent host management requests on {:?}", path);
debug!(
self.log,
"Listening for agent host management requests on {:?}", path
);
self.run_management_accept_loop(&mut listener).await;
}
@@ -704,7 +715,11 @@ impl AgentHostManager {
let new_release = match self.get_latest_release().await {
Ok(r) => r,
Err(e) => {
warning!(self.log, "Upgrade request: latest release lookup failed: {}", e);
warning!(
self.log,
"Upgrade request: latest release lookup failed: {}",
e
);
return json_response(
503,
&UpgradeResponse {
@@ -762,7 +777,12 @@ impl AgentHostManager {
// `upgradeStarted`. The background update loop usually pre-fetches
// this, so the common path is a no-op.
if let Err(e) = self.ensure_downloaded(&new_release).await {
warning!(self.log, "Failed to download upgrade {}: {}", new_release, e);
warning!(
self.log,
"Failed to download upgrade {}: {}",
new_release,
e
);
self.upgrade_in_progress.store(false, Ordering::SeqCst);
return json_response(
503,
@@ -791,9 +811,15 @@ impl AgentHostManager {
// ready endpoint instead of paying for startup again.
match self_clone.start_server().await {
Ok(_) => info!(self_clone.log, "Restarted agent host on {}", release_commit),
Err(e) => warning!(self_clone.log, "Failed to restart agent host after upgrade: {}", e),
Err(e) => warning!(
self_clone.log,
"Failed to restart agent host after upgrade: {}",
e
),
}
self_clone.upgrade_in_progress.store(false, Ordering::SeqCst);
self_clone
.upgrade_in_progress
.store(false, Ordering::SeqCst);
});
json_response(
@@ -1000,21 +1026,32 @@ pub struct AgentHostSidecar {
listener: TcpListener,
bound_addr: SocketAddr,
public_token: Option<String>,
lockfile_path: PathBuf,
user_data_path: PathBuf,
instance_id: String,
pid: u32,
/// Set once registry cleanup for this instance's identity has been
/// performed (successfully or not — a best-effort attempt counts), so
/// `Drop` never redundantly repeats it after an explicit [`Self::shutdown`].
registry_cleaned_up: AtomicBool,
}
impl AgentHostSidecar {
/// Binds a TCP listener at `addr`, writes the canonical agent host
/// lockfile pointing at the bound port, and returns a sidecar ready to
/// [`serve`](Self::serve) connections. The agent host backend is *not*
/// started here — the wrapped [`AgentHostManager`] starts it on demand
/// when the first request arrives.
/// Binds a TCP listener at `addr`, publishes a `standalone` entry to the
/// shared local agent-host endpoint registry (schema v2, see
/// [`agent_host_registry`]) pointing at the bound port, and returns a
/// sidecar ready to [`serve`](Self::serve) connections. The agent host
/// backend is *not* started here — the wrapped [`AgentHostManager`]
/// starts it on demand when the first request arrives.
///
/// `loopback_auth` decides whether the local TCP accept loop enforces a
/// connection token. The caller MUST make this choice deliberately:
/// loopback is reachable from any local process, so binding without a
/// token must be a conscious user opt-in (e.g. `--without-connection-token`).
///
/// `user_data_path` is the resolved user data directory that homes the
/// registry (see [`super::user_data_path`]); `instance_id` is this
/// process's stable identity within the registry, used to disambiguate
/// PID reuse and to scope `--replace`/removal to exactly this entry.
pub async fn bind_tcp(
log: log::Logger,
manager: Arc<AgentHostManager>,
@@ -1022,7 +1059,8 @@ impl AgentHostSidecar {
host_label: Option<String>,
loopback_auth: LoopbackAuth,
tunnel_name: Option<String>,
lockfile_path: PathBuf,
user_data_path: PathBuf,
instance_id: String,
) -> Result<Arc<Self>, AnyError> {
let public_token = loopback_auth.into_token();
let listener = TcpListener::bind(addr)
@@ -1039,15 +1077,44 @@ impl AgentHostSidecar {
// character-equal without spuriously flagging hostname-vs-IP
// equivalents as a config conflict.
let host = host_label.unwrap_or_else(|| bound_addr.ip().to_string());
let metadata = build_metadata(
let entry = AgentHostEndpointMetadata::new_standalone(
pid,
instance_id.clone(),
host,
bound_addr.port(),
public_token.clone(),
tunnel_name.as_deref(),
public_token.clone().unwrap_or_default(),
AGENT_HOST_PROTOCOL_VERSION.to_string(),
VSCODE_CLI_QUALITY.map(str::to_string),
tunnel_name,
);
if let Err(e) = write_agent_host_metadata(&lockfile_path, &metadata) {
warning!(log, "Failed to write agent host lockfile: {}", e);
// Registry publish does blocking filesystem I/O and may briefly
// spin-retry on a contended lock; run it on a blocking-safe thread
// so it never stalls the tokio runtime.
{
let publish_log = log.clone();
let publish_path = user_data_path.clone();
match tokio::task::spawn_blocking(move || {
agent_host_registry::publish_agent_host_endpoint(
&publish_log,
&publish_path,
&entry,
)
})
.await
{
Ok(Ok(())) => {}
Ok(Err(e)) => warning!(
log,
"Failed to publish agent host endpoint registry entry: {}",
e
),
Err(e) => warning!(
log,
"Agent host endpoint registry publish task failed: {}",
e
),
}
}
Ok(Arc::new(Self {
@@ -1056,8 +1123,10 @@ impl AgentHostSidecar {
listener,
bound_addr,
public_token,
lockfile_path,
user_data_path,
instance_id,
pid,
registry_cleaned_up: AtomicBool::new(false),
}))
}
@@ -1131,37 +1200,78 @@ impl AgentHostSidecar {
}
}
/// Stops the agent host backend and removes the lockfile if it still
/// belongs to this process. Safe to call multiple times.
/// Stops the agent host backend and removes this instance's entry from
/// the shared local agent-host endpoint registry. Safe to call multiple
/// times: only the first call performs registry cleanup, and `Drop`
/// will not repeat it afterwards (see `registry_cleaned_up`).
pub async fn shutdown(&self) {
self.manager.kill_running_server().await;
if let Err(e) = remove_agent_host_metadata_for_pid(&self.lockfile_path, self.pid) {
warning!(self.log, "Failed to clean up agent host lockfile: {}", e);
if self.registry_cleaned_up.swap(true, Ordering::SeqCst) {
return;
}
let identity = AgentHostEndpointIdentity {
server_type: AgentHostServerType::Standalone,
pid: self.pid,
instance_id: self.instance_id.clone(),
};
let log = self.log.clone();
let user_data_path = self.user_data_path.clone();
if let Err(e) = tokio::task::spawn_blocking(move || {
agent_host_registry::remove_agent_host_endpoint(&log, &user_data_path, &identity);
})
.await
{
warning!(
self.log,
"Agent host endpoint registry cleanup task failed: {}",
e
);
}
}
}
impl Drop for AgentHostSidecar {
fn drop(&mut self) {
// Best-effort cleanup if the caller forgot to call `shutdown`. Only
// removes the lockfile when the recorded PID still matches us.
let _ = remove_agent_host_metadata_for_pid(&self.lockfile_path, self.pid);
}
}
// If `shutdown` already performed (or is performing) registry
// cleanup, don't repeat it here.
if self.registry_cleaned_up.swap(true, Ordering::SeqCst) {
return;
}
fn build_metadata(
pid: u32,
host: String,
port: u16,
connection_token: Option<String>,
tunnel_name: Option<&str>,
) -> AgentHostMetadata {
let mut metadata = AgentHostMetadata::new(pid, port);
metadata.host = Some(host);
metadata.connection_token = connection_token;
metadata.quality = VSCODE_CLI_QUALITY.map(str::to_string);
metadata.tunnel_name = tunnel_name.map(str::to_string);
metadata
// Best-effort cleanup for the case where the caller forgot to call
// `shutdown`. `remove_agent_host_endpoint` only removes the entry
// that exactly matches our own `(type, pid, instanceId)` identity.
let identity = AgentHostEndpointIdentity {
server_type: AgentHostServerType::Standalone,
pid: self.pid,
instance_id: self.instance_id.clone(),
};
let log = self.log.clone();
let user_data_path = self.user_data_path.clone();
// `drop` is synchronous and must not block a Tokio worker thread
// with this call's blocking filesystem I/O (lock acquisition,
// reads/writes). If a runtime is reachable from here, hand the
// cleanup off to a blocking-safe thread and don't wait for it —
// this is already a best-effort fallback, so fire-and-forget is
// acceptable. If no runtime is available (e.g. this sidecar
// outlived it), there is no worker thread left to protect, so it's
// safe to just do the blocking removal inline.
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
handle.spawn_blocking(move || {
agent_host_registry::remove_agent_host_endpoint(
&log,
&user_data_path,
&identity,
);
});
}
Err(_) => {
agent_host_registry::remove_agent_host_endpoint(&log, &user_data_path, &identity);
}
}
}
}
/// How the loopback TCP accept loop authenticates incoming connections.
@@ -1210,76 +1320,68 @@ async fn handle_request_with_auth(
handle_request(manager, req).await
}
// ---- Lockfile-aware reuse ---------------------------------------------------
// ---- Registry-based reuse ---------------------------------------------------
/// Decision derived from inspecting `agent-host-<quality>.lock`. Used by
/// CLI entry points (e.g. `code tunnel`, `code agent host`) to decide
/// whether they may safely own the agent host lockfile or should forward
/// to / share the existing one.
/// Decision derived from consulting the shared local agent-host endpoint
/// registry (schema v2; see [`agent_host_registry`]). Used by CLI entry
/// points (e.g. `code tunnel`, `code agent host`) to decide whether they
/// may safely start their own supervisor or should forward to / share an
/// existing one.
///
/// The agent host server is downloaded on demand and may speak a newer
/// protocol than the CLI itself is built with, so we deliberately do NOT
/// check the protocol version: any live registered supervisor is always
/// considered reusable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentHostLockfileDecision {
/// No live agent host registered; the caller may start its own sidecar.
pub enum AgentHostReuseDecision {
/// No live standalone agent host registered; the caller may start its
/// own sidecar.
SpawnFresh,
/// A live agent host supervisor owns the lockfile. Tunnel callers
/// should forward to `127.0.0.1:port` instead of binding a second
/// listener / clobbering the lockfile. `host` and `tunnel_name`
/// expose the supervisor's effective config so foreground callers
/// can detect a configuration conflict and refuse to silently reuse.
/// A live standalone agent host supervisor owns a registry entry.
/// Tunnel callers should forward to `127.0.0.1:port` instead of
/// binding a second listener / publishing a conflicting entry. `host`
/// and `tunnel_name` expose the supervisor's effective config so
/// foreground callers can detect a configuration conflict and refuse
/// to silently reuse.
Reuse {
pid: u32,
host: Option<String>,
port: u16,
token: Option<String>,
tunnel_name: Option<String>,
/// This entry's stable identity within the registry, used by
/// `--replace` to scope removal to exactly this instance.
instance_id: String,
},
}
/// Inspect the agent host lockfile at `path` and decide whether the caller
/// should spawn a fresh sidecar or reuse an existing live one. Missing /
/// unreadable / stale (dead-PID) lockfiles all map to
/// [`AgentHostLockfileDecision::SpawnFresh`].
pub fn classify_agent_host_lockfile(
/// Preferred entry point for CLI commands that need to discover a live
/// standalone agent host: consults the shared local agent-host endpoint
/// registry (schema v2), the sole source of truth for automatic
/// discovery.
///
/// `editor` entries are never selected here — they are owned by running VS
/// Code windows and must remain invisible to (and unkillable by) the
/// standalone CLI's discovery/`--replace` path. See
/// [`agent_host_registry::select_live_standalone_endpoint`].
pub fn classify_agent_host(
log: &log::Logger,
path: &std::path::Path,
) -> AgentHostLockfileDecision {
use super::agent_host_metadata::read_agent_host_metadata;
use crate::util::machine::process_exists;
let metadata = match read_agent_host_metadata(path) {
Ok(Some(m)) => m,
Ok(None) => return AgentHostLockfileDecision::SpawnFresh,
Err(e) => {
debug!(
log,
"Could not read agent host lockfile {}: {}",
path.display(),
e
);
return AgentHostLockfileDecision::SpawnFresh;
}
};
if !process_exists(metadata.pid) {
debug!(
log,
"Agent host lockfile {} references dead PID {}; treating as stale",
path.display(),
metadata.pid
);
return AgentHostLockfileDecision::SpawnFresh;
}
AgentHostLockfileDecision::Reuse {
pid: metadata.pid,
host: metadata.host,
port: metadata.port,
token: metadata.connection_token,
tunnel_name: metadata.tunnel_name,
user_data_path: &std::path::Path,
) -> AgentHostReuseDecision {
match agent_host_registry::select_live_standalone_endpoint(log, user_data_path) {
Some(selected) => AgentHostReuseDecision::Reuse {
pid: selected.pid,
host: Some(selected.host),
port: selected.port,
token: if selected.connection_token.is_empty() {
None
} else {
Some(selected.connection_token)
},
tunnel_name: selected.tunnel_name,
instance_id: selected.instance_id,
},
None => AgentHostReuseDecision::SpawnFresh,
}
}
@@ -1382,9 +1484,7 @@ fn inject_connection_token(uri: &::http::Uri, token: &str) -> ::http::Uri {
#[cfg(test)]
mod tests {
use super::*;
use crate::tunnels::agent_host_metadata::{
read_agent_host_metadata, AGENT_HOST_PROTOCOL_VERSION,
};
use crate::tunnels::agent_host_registry::AgentHostEndpointAddress;
use crate::util::http::ReqwestSimpleHttp;
use std::path::Path;
@@ -1492,36 +1592,10 @@ mod tests {
assert!(json.contains(r#""restartDelayMs":3000"#), "got: {}", json);
}
#[test]
fn metadata_includes_quality_and_no_tunnel() {
let metadata = build_metadata(
42,
"127.0.0.1".to_string(),
8080,
Some("tok".to_string()),
None,
);
assert_eq!(metadata.pid, 42);
assert_eq!(metadata.host.as_deref(), Some("127.0.0.1"));
assert_eq!(metadata.port, 8080);
assert_eq!(metadata.connection_token.as_deref(), Some("tok"));
assert_eq!(metadata.protocol_version, AGENT_HOST_PROTOCOL_VERSION);
assert_eq!(metadata.tunnel_name, None);
}
#[test]
fn metadata_records_tunnel_name() {
let metadata = build_metadata(42, "0.0.0.0".to_string(), 8080, None, Some("my-tunnel"));
assert_eq!(metadata.host.as_deref(), Some("0.0.0.0"));
assert_eq!(metadata.tunnel_name.as_deref(), Some("my-tunnel"));
}
#[tokio::test]
async fn bind_tcp_writes_lockfile_with_bound_port_and_pid() {
async fn bind_tcp_publishes_registry_entry_with_bound_port_and_pid() {
let dir = tempfile::tempdir().unwrap();
let lockfile = dir.path().join("agent-host.lock");
let user_data_path = dir.path().join("user-data");
let manager = make_test_manager(dir.path());
let sidecar = AgentHostSidecar::bind_tcp(
@@ -1531,24 +1605,36 @@ mod tests {
Some("localhost".to_string()),
LoopbackAuth::Token("tok".to_string()),
Some("my-tunnel".to_string()),
lockfile.clone(),
user_data_path.clone(),
"instance-a".to_string(),
)
.await
.unwrap();
let metadata = read_agent_host_metadata(&lockfile).unwrap().unwrap();
assert_eq!(metadata.pid, std::process::id());
assert_eq!(metadata.host.as_deref(), Some("localhost"));
assert_eq!(metadata.port, sidecar.bound_addr().port());
assert_ne!(metadata.port, 0);
assert_eq!(metadata.connection_token.as_deref(), Some("tok"));
assert_eq!(metadata.tunnel_name.as_deref(), Some("my-tunnel"));
let entries =
agent_host_registry::read_registry(&log::Logger::test(), &user_data_path).unwrap();
assert_eq!(entries.len(), 1);
let entry = &entries[0];
assert_eq!(entry.server_type, AgentHostServerType::Standalone);
assert_eq!(entry.pid, std::process::id());
assert_eq!(entry.instance_id, "instance-a");
assert_eq!(entry.connection_token, "tok");
assert_eq!(entry.tunnel_name.as_deref(), Some("my-tunnel"));
assert_eq!(entry.protocol_version, AGENT_HOST_PROTOCOL_VERSION);
match &entry.endpoint {
AgentHostEndpointAddress::Tcp { host, port } => {
assert_eq!(host, "localhost");
assert_eq!(*port, sidecar.bound_addr().port());
assert_ne!(*port, 0);
}
other => panic!("expected a tcp endpoint, got {:?}", other),
}
}
#[tokio::test]
async fn drop_removes_lockfile_when_pid_matches() {
async fn drop_removes_registry_entry_matching_our_identity_without_blocking_worker() {
let dir = tempfile::tempdir().unwrap();
let lockfile = dir.path().join("agent-host.lock");
let user_data_path = dir.path().join("user-data");
let manager = make_test_manager(dir.path());
{
@@ -1559,20 +1645,43 @@ mod tests {
None,
LoopbackAuth::Disabled,
None,
lockfile.clone(),
user_data_path.clone(),
"instance-fallback".to_string(),
)
.await
.unwrap();
assert!(lockfile.exists());
assert_eq!(
agent_host_registry::read_registry(&log::Logger::test(), &user_data_path)
.unwrap()
.len(),
1
);
}
assert!(!lockfile.exists());
// `shutdown` was never called, so `Drop`'s fallback cleanup is
// responsible here. It dispatches the blocking removal to a
// separate blocking-safe thread rather than doing it inline on
// this async task's worker, so poll briefly for it to land instead
// of asserting immediately.
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if agent_host_registry::read_registry(&log::Logger::test(), &user_data_path)
.unwrap()
.is_empty()
{
break;
}
if Instant::now() >= deadline {
panic!("drop's fallback registry cleanup did not complete in time");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn shutdown_leaves_lockfile_when_pid_was_overwritten() {
async fn shutdown_leaves_registry_entry_owned_by_a_different_instance() {
let dir = tempfile::tempdir().unwrap();
let lockfile = dir.path().join("agent-host.lock");
let user_data_path = dir.path().join("user-data");
let manager = make_test_manager(dir.path());
let sidecar = AgentHostSidecar::bind_tcp(
@@ -1582,96 +1691,180 @@ mod tests {
None,
LoopbackAuth::Disabled,
None,
lockfile.clone(),
user_data_path.clone(),
"instance-c".to_string(),
)
.await
.unwrap();
// Simulate another process taking over the same lockfile path.
let foreign_pid = std::process::id().wrapping_add(1);
write_agent_host_metadata(&lockfile, &AgentHostMetadata::new(foreign_pid, 9999)).unwrap();
// Simulate another live process taking over with a distinct
// instance ID; `shutdown`/`Drop` must only ever remove the entry
// exactly matching our own `(type, pid, instanceId)` identity.
let foreign = AgentHostEndpointMetadata::new_standalone(
std::process::id(),
"instance-foreign".to_string(),
"127.0.0.1".to_string(),
9999,
String::new(),
AGENT_HOST_PROTOCOL_VERSION.to_string(),
None,
None,
);
agent_host_registry::publish_agent_host_endpoint(
&log::Logger::test(),
&user_data_path,
&foreign,
)
.unwrap();
sidecar.shutdown().await;
let preserved = read_agent_host_metadata(&lockfile).unwrap().unwrap();
assert_eq!(preserved.pid, foreign_pid);
assert_eq!(preserved.port, 9999);
let entries =
agent_host_registry::read_registry(&log::Logger::test(), &user_data_path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].instance_id, "instance-foreign");
}
#[tokio::test]
async fn drop_does_not_redundantly_clean_up_after_shutdown() {
let dir = tempfile::tempdir().unwrap();
let user_data_path = dir.path().join("user-data");
let manager = make_test_manager(dir.path());
let sidecar = AgentHostSidecar::bind_tcp(
log::Logger::test(),
manager,
SocketAddr::from(([127, 0, 0, 1], 0)),
None,
LoopbackAuth::Disabled,
None,
user_data_path.clone(),
"instance-shutdown-then-drop".to_string(),
)
.await
.unwrap();
sidecar.shutdown().await;
assert!(
agent_host_registry::read_registry(&log::Logger::test(), &user_data_path)
.unwrap()
.is_empty()
);
// Republish an entry reusing our own identity, simulating a case
// where some other writer took over that exact (type, pid,
// instanceId) slot right after `shutdown` removed it. If `Drop`
// were to redundantly repeat cleanup after `shutdown` already
// claimed it, it would incorrectly remove this entry too.
let republished = AgentHostEndpointMetadata::new_standalone(
std::process::id(),
"instance-shutdown-then-drop".to_string(),
"127.0.0.1".to_string(),
9999,
String::new(),
AGENT_HOST_PROTOCOL_VERSION.to_string(),
None,
None,
);
agent_host_registry::publish_agent_host_endpoint(
&log::Logger::test(),
&user_data_path,
&republished,
)
.unwrap();
drop(sidecar);
// Give any (incorrectly) dispatched fallback cleanup a moment to
// run before asserting it left the republished entry untouched.
tokio::time::sleep(Duration::from_millis(100)).await;
let entries =
agent_host_registry::read_registry(&log::Logger::test(), &user_data_path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].instance_id, "instance-shutdown-then-drop");
}
#[test]
fn classify_returns_spawn_fresh_when_lockfile_missing() {
fn classify_agent_host_returns_spawn_fresh_when_registry_empty() {
let dir = tempfile::tempdir().unwrap();
let lockfile = dir.path().join("missing.lock");
let user_data_path = dir.path().join("user-data");
let decision = classify_agent_host_lockfile(&log::Logger::test(), &lockfile);
let decision = classify_agent_host(&log::Logger::test(), &user_data_path);
assert_eq!(decision, AgentHostLockfileDecision::SpawnFresh);
assert_eq!(decision, AgentHostReuseDecision::SpawnFresh);
}
#[test]
fn classify_returns_spawn_fresh_for_stale_pid() {
fn classify_agent_host_prefers_live_registry_standalone_entry() {
let dir = tempfile::tempdir().unwrap();
let lockfile = dir.path().join("agent-host.lock");
// Use a PID that is extremely unlikely to exist (max u32 - 1).
// `process_exists` returns false for unknown PIDs.
let mut metadata = AgentHostMetadata::new(u32::MAX - 1, 1234);
metadata.connection_token = Some("ignored".to_string());
write_agent_host_metadata(&lockfile, &metadata).unwrap();
let decision = classify_agent_host_lockfile(&log::Logger::test(), &lockfile);
assert_eq!(decision, AgentHostLockfileDecision::SpawnFresh);
}
#[test]
fn classify_returns_reuse_for_live_compatible_lockfile() {
let dir = tempfile::tempdir().unwrap();
let lockfile = dir.path().join("agent-host.lock");
let user_data_path = dir.path().join("user-data");
let pid = std::process::id();
let mut metadata = AgentHostMetadata::new(pid, 4321);
metadata.host = Some("127.0.0.1".to_string());
metadata.connection_token = Some("tok".to_string());
write_agent_host_metadata(&lockfile, &metadata).unwrap();
let decision = classify_agent_host_lockfile(&log::Logger::test(), &lockfile);
let entry = AgentHostEndpointMetadata::new_standalone(
pid,
"instance-registry".to_string(),
"127.0.0.1".to_string(),
4321,
"registry-tok".to_string(),
AGENT_HOST_PROTOCOL_VERSION.to_string(),
None,
None,
);
agent_host_registry::publish_agent_host_endpoint(
&log::Logger::test(),
&user_data_path,
&entry,
)
.unwrap();
let decision = classify_agent_host(&log::Logger::test(), &user_data_path);
assert_eq!(
decision,
AgentHostLockfileDecision::Reuse {
AgentHostReuseDecision::Reuse {
pid,
host: Some("127.0.0.1".to_string()),
port: 4321,
token: Some("tok".to_string()),
token: Some("registry-tok".to_string()),
tunnel_name: None,
instance_id: "instance-registry".to_string(),
}
);
}
#[test]
fn classify_returns_reuse_for_live_newer_protocol() {
// The agent host server is downloaded on demand and may speak a
// newer protocol than the CLI is built with; we must still treat
// it as reusable rather than refusing to share it.
fn classify_agent_host_never_selects_an_editor_registry_entry() {
let dir = tempfile::tempdir().unwrap();
let lockfile = dir.path().join("agent-host.lock");
let pid = std::process::id();
let mut metadata = AgentHostMetadata::new(pid, 4321);
metadata.protocol_version = "9.9.9".to_string();
metadata.connection_token = Some("tok".to_string());
write_agent_host_metadata(&lockfile, &metadata).unwrap();
let user_data_path = dir.path().join("user-data");
let decision = classify_agent_host_lockfile(&log::Logger::test(), &lockfile);
let editor = AgentHostEndpointMetadata {
schema_version:
crate::tunnels::agent_host_registry::AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION,
server_type: AgentHostServerType::Editor,
pid: std::process::id(),
instance_id: "editor-instance".to_string(),
protocol_version: AGENT_HOST_PROTOCOL_VERSION.to_string(),
connection_token: "editor-tok".to_string(),
endpoint: AgentHostEndpointAddress::Socket {
path: "/tmp/editor.sock".to_string(),
},
quality: None,
tunnel_name: None,
};
agent_host_registry::publish_agent_host_endpoint(
&log::Logger::test(),
&user_data_path,
&editor,
)
.unwrap();
assert_eq!(
decision,
AgentHostLockfileDecision::Reuse {
pid,
host: None,
port: 4321,
token: Some("tok".to_string()),
tunnel_name: None,
}
);
// With only an (ignored) editor entry present, the caller must be
// told to spawn a fresh standalone supervisor rather than ever
// touching the editor entry.
let decision = classify_agent_host(&log::Logger::test(), &user_data_path);
assert_eq!(decision, AgentHostReuseDecision::SpawnFresh);
}
#[test]
-215
View File
@@ -1,215 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
use std::fs;
use std::io::{self, Write};
use std::path::Path;
use serde::{Deserialize, Serialize};
pub const AGENT_HOST_METADATA_SCHEMA_VERSION: u32 = 1;
pub const AGENT_HOST_PROTOCOL_VERSION: &str = "0.1.0";
/// Persisted record describing a running `code agent host` proxy, written to
/// the per-quality lockfile (`<launcher-root>/agent-host-<quality>.lock`).
///
/// This schema is shared with the TypeScript SSH client in
/// `src/vs/platform/agentHost/common/remoteAgentHostMetadata.ts`; field
/// renames or removals MUST be coordinated across both languages.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentHostMetadata {
pub schema_version: u32,
pub pid: u32,
pub port: u16,
/// Host the supervisor's TCP listener was bound to (e.g. `127.0.0.1`,
/// `0.0.0.0`). Optional so older lockfiles still parse; consumers
/// fall back to loopback when absent. Used by the foreground
/// `code agent host` command to detect when a caller's requested
/// `--host` differs from what's already running.
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub connection_token: Option<String>,
pub protocol_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_name: Option<String>,
}
impl AgentHostMetadata {
pub fn new(pid: u32, port: u16) -> Self {
Self {
schema_version: AGENT_HOST_METADATA_SCHEMA_VERSION,
pid,
port,
host: None,
connection_token: None,
protocol_version: AGENT_HOST_PROTOCOL_VERSION.to_string(),
quality: None,
tunnel_name: None,
}
}
}
pub fn read_agent_host_metadata(path: &Path) -> io::Result<Option<AgentHostMetadata>> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
serde_json::from_str(&text)
.map(Some)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
}
pub fn write_agent_host_metadata(path: &Path, metadata: &AgentHostMetadata) -> io::Result<()> {
#[cfg(not(windows))]
use std::os::unix::fs::PermissionsExt;
let parent = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)?;
#[cfg(not(windows))]
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
let mut temp = tempfile::NamedTempFile::new_in(parent)?;
#[cfg(not(windows))]
temp.as_file()
.set_permissions(fs::Permissions::from_mode(0o600))?;
temp.write_all(serde_json::to_string(metadata)?.as_bytes())?;
temp.flush()?;
temp.persist(path).map_err(|err| err.error)?;
#[cfg(not(windows))]
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
Ok(())
}
pub fn remove_agent_host_metadata(path: &Path) -> io::Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
pub fn remove_agent_host_metadata_for_pid(path: &Path, pid: u32) -> io::Result<()> {
match read_agent_host_metadata(path)? {
Some(metadata) if metadata.pid == pid => remove_agent_host_metadata(path),
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn metadata_path(dir: &Path) -> PathBuf {
dir.join("agent-host.lock")
}
#[test]
fn serializes_with_camel_case_fields() {
let mut metadata = AgentHostMetadata::new(1234, 8080);
metadata.host = Some("0.0.0.0".to_string());
metadata.connection_token = Some("tok".to_string());
metadata.quality = Some("insider".to_string());
metadata.tunnel_name = Some("my-tunnel".to_string());
let value = serde_json::to_value(&metadata).unwrap();
assert_eq!(value["schemaVersion"], 1);
assert_eq!(value["pid"], 1234);
assert_eq!(value["port"], 8080);
assert_eq!(value["host"], "0.0.0.0");
assert_eq!(value["connectionToken"], "tok");
assert_eq!(value["protocolVersion"], "0.1.0");
assert_eq!(value["quality"], "insider");
assert_eq!(value["tunnelName"], "my-tunnel");
}
#[test]
fn omits_optional_fields_when_unset() {
let metadata = AgentHostMetadata::new(1234, 8080);
let value = serde_json::to_value(&metadata).unwrap();
assert!(value.get("host").is_none());
assert!(value.get("connectionToken").is_none());
assert!(value.get("quality").is_none());
assert!(value.get("tunnelName").is_none());
}
#[test]
fn round_trips_metadata() {
let dir = tempfile::tempdir().unwrap();
let path = metadata_path(dir.path());
let mut metadata = AgentHostMetadata::new(1234, 8080);
metadata.connection_token = Some("tok".to_string());
write_agent_host_metadata(&path, &metadata).unwrap();
assert_eq!(read_agent_host_metadata(&path).unwrap(), Some(metadata));
}
#[test]
fn missing_metadata_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = metadata_path(dir.path());
assert_eq!(read_agent_host_metadata(&path).unwrap(), None);
}
#[test]
fn invalid_metadata_returns_invalid_data() {
let dir = tempfile::tempdir().unwrap();
let path = metadata_path(dir.path());
fs::write(&path, "not json").unwrap();
let err = read_agent_host_metadata(&path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn remove_metadata_ignores_missing_file() {
let dir = tempfile::tempdir().unwrap();
let path = metadata_path(dir.path());
remove_agent_host_metadata(&path).unwrap();
}
#[test]
fn remove_metadata_for_pid_only_removes_matching_process() {
let dir = tempfile::tempdir().unwrap();
let path = metadata_path(dir.path());
let metadata = AgentHostMetadata::new(1234, 8080);
write_agent_host_metadata(&path, &metadata).unwrap();
remove_agent_host_metadata_for_pid(&path, 4321).unwrap();
assert_eq!(read_agent_host_metadata(&path).unwrap(), Some(metadata));
remove_agent_host_metadata_for_pid(&path, 1234).unwrap();
assert_eq!(read_agent_host_metadata(&path).unwrap(), None);
}
#[cfg(not(windows))]
#[test]
fn writes_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = metadata_path(dir.path());
write_agent_host_metadata(&path, &AgentHostMetadata::new(1234, 8080)).unwrap();
assert_eq!(
fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777,
0o700
);
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,350 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//! Windows-only owner-only ACL for the shared agent host endpoint registry
//! directory, via native Win32 security APIs (no `whoami.exe`/`icacls.exe`
//! subprocesses). The current user is identified by SID rather than by a
//! locale-sensitive account name.
use std::ffi::OsStr;
use std::io;
use std::iter::once;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use std::ptr;
use windows_sys::core::PWSTR;
use windows_sys::Win32::Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL};
use windows_sys::Win32::Security::Authorization::{
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
SetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT,
};
use windows_sys::Win32::Security::{
GetSecurityDescriptorDacl, GetTokenInformation, TokenUser, ACL, DACL_SECURITY_INFORMATION,
PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, TOKEN_USER,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
/// Applies a protected DACL to `path` granting full control, with
/// object/container inheritance (so children inherit the same grants), to
/// only the current user, `LOCAL SYSTEM`, and `BUILTIN\Administrators`.
/// Mirrors `prepareLocalAgentHostEndpointMetadataDirectory`'s Windows branch
/// in `node/localAgentHostMetadata.ts`.
pub(super) fn apply_owner_only_acl(path: &Path) -> io::Result<()> {
let sid = current_user_sid_string()?;
// "D:P" = protected DACL, i.e. no inherited ACEs from the parent
// (matches `icacls /inheritance:r`). Each `(A;OICI;FA;;;<sid>)` grants
// Full-Access to <sid>, with Object-Inherit + Container-Inherit so
// files/subdirectories created underneath inherit the same grant. `SY`
// and `BA` are the SDDL well-known aliases for LOCAL SYSTEM and
// BUILTIN\Administrators.
let sddl = format!("D:P(A;OICI;FA;;;{sid})(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)");
let descriptor = SecurityDescriptor::from_sddl(&sddl)?;
let dacl = descriptor.dacl()?;
let path_wide = to_wide_null(path.as_os_str());
// SAFETY: `path_wide` is a valid null-terminated wide string live for
// the call; `dacl` points into `descriptor`, which outlives this call.
// Null owner/group/sacl leave those unchanged.
let result = unsafe {
SetNamedSecurityInfoW(
path_wide.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
ptr::null_mut(),
ptr::null_mut(),
dacl,
ptr::null(),
)
};
if result != 0 {
return Err(io::Error::from_raw_os_error(result as i32));
}
Ok(())
}
/// Returns the exact SID (never a locale-sensitive account name) of the
/// current process's user, in `S-1-5-...` string form.
fn current_user_sid_string() -> io::Result<String> {
// SAFETY: `GetCurrentProcess` returns a pseudo-handle that must not be
// closed. `OpenProcessToken` with `TOKEN_QUERY` only opens a read
// handle to the process's own token.
let mut token: HANDLE = ptr::null_mut();
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
return Err(io::Error::last_os_error());
}
let token = OwnedHandle(token);
let mut needed: u32 = 0;
// SAFETY: passing a null/zero-length buffer is the documented way to
// query the required size; it is written to `needed` regardless of
// the (expected) failure return.
unsafe { GetTokenInformation(token.0, TokenUser, ptr::null_mut(), 0, &mut needed) };
if needed == 0 {
return Err(io::Error::last_os_error());
}
let mut buffer = vec![0u8; needed as usize];
// SAFETY: `buffer` is exactly `needed` bytes, the size this same call
// reported above, and is only read as a `TOKEN_USER` once this
// succeeds.
let ok = unsafe {
GetTokenInformation(
token.0,
TokenUser,
buffer.as_mut_ptr() as *mut _,
needed,
&mut needed,
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: `buffer` was just filled with a valid `TOKEN_USER` above;
// `User.Sid` points inside `buffer` and stays valid for `buffer`'s
// lifetime, which outlives this call to `sid_to_string`.
let sid = unsafe { (*(buffer.as_ptr() as *const TOKEN_USER)).User.Sid };
sid_to_string(sid)
}
/// Converts a SID to its `S-1-5-...` string form.
fn sid_to_string(sid: PSID) -> io::Result<String> {
let mut string_sid: PWSTR = ptr::null_mut();
// SAFETY: `sid` is a valid SID for the duration of this call (see
// callers). `ConvertSidToStringSidW` allocates `string_sid` via
// `LocalAlloc`; ownership is transferred to `LocalWideString`, which
// frees it via `LocalFree` on drop.
if unsafe { ConvertSidToStringSidW(sid, &mut string_sid) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(LocalWideString(string_sid).to_string_lossy())
}
fn to_wide_null(value: &OsStr) -> Vec<u16> {
value.encode_wide().chain(once(0)).collect()
}
/// An owned process token handle, closed on drop.
struct OwnedHandle(HANDLE);
impl Drop for OwnedHandle {
fn drop(&mut self) {
// SAFETY: `self.0` is a valid handle opened by `OpenProcessToken`
// and not otherwise closed.
unsafe {
CloseHandle(self.0);
}
}
}
/// An owned `LocalAlloc`-backed wide string, e.g. as returned by
/// `ConvertSidToStringSidW`, freed via `LocalFree` on drop.
struct LocalWideString(PWSTR);
impl LocalWideString {
fn to_string_lossy(&self) -> String {
// SAFETY: `self.0` is a valid null-terminated wide string for the
// lifetime of `self`.
let len = unsafe { (0..).take_while(|&i| *self.0.add(i) != 0).count() };
// SAFETY: `[self.0, self.0 + len)` was just measured as the
// null-terminated extent of a valid wide string above.
let slice = unsafe { std::slice::from_raw_parts(self.0, len) };
String::from_utf16_lossy(slice)
}
}
impl Drop for LocalWideString {
fn drop(&mut self) {
if !self.0.is_null() {
// SAFETY: `self.0` was allocated by `ConvertSidToStringSidW`,
// which documents `LocalFree` as the correct release for it.
unsafe {
LocalFree(self.0 as HLOCAL);
}
}
}
}
/// An owned `LocalAlloc`-backed security descriptor, e.g. as returned by
/// `ConvertStringSecurityDescriptorToSecurityDescriptorW` or
/// `GetNamedSecurityInfoW`, freed via `LocalFree` on drop.
pub(super) struct SecurityDescriptor(PSECURITY_DESCRIPTOR);
impl SecurityDescriptor {
fn from_sddl(sddl: &str) -> io::Result<Self> {
let wide = to_wide_null(OsStr::new(sddl));
let mut descriptor: PSECURITY_DESCRIPTOR = ptr::null_mut();
// SAFETY: `wide` is a valid null-terminated wide string live for
// the call. The resulting descriptor is allocated via
// `LocalAlloc` and is owned by the returned `Self`.
let ok = unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
wide.as_ptr(),
SDDL_REVISION_1,
&mut descriptor,
ptr::null_mut(),
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(Self(descriptor))
}
/// Takes ownership of a security descriptor already allocated by a
/// Win32 API that documents `LocalFree` as its release (e.g.
/// `GetNamedSecurityInfoW`), used by tests to inspect an applied ACL.
#[cfg(test)]
pub(super) fn from_raw(descriptor: PSECURITY_DESCRIPTOR) -> Self {
Self(descriptor)
}
/// Used by tests to independently verify security-descriptor control
/// flags (e.g. `SE_DACL_PROTECTED`) on the applied ACL.
#[cfg(test)]
pub(super) fn raw(&self) -> PSECURITY_DESCRIPTOR {
self.0
}
/// Returns the DACL embedded in this security descriptor. The
/// returned pointer's lifetime is tied to `self`.
pub(super) fn dacl(&self) -> io::Result<*const ACL> {
let mut present = 0;
let mut dacl: *mut ACL = ptr::null_mut();
let mut defaulted = 0;
// SAFETY: `self.0` is a valid security descriptor kept alive for
// at least as long as the returned pointer is used.
let ok =
unsafe { GetSecurityDescriptorDacl(self.0, &mut present, &mut dacl, &mut defaulted) };
if ok == 0 {
return Err(io::Error::last_os_error());
}
if present == 0 {
return Err(io::Error::other(
"security descriptor unexpectedly has no DACL",
));
}
Ok(dacl)
}
}
impl Drop for SecurityDescriptor {
fn drop(&mut self) {
if !self.0.is_null() {
// SAFETY: `self.0` was allocated by either
// `ConvertStringSecurityDescriptorToSecurityDescriptorW` or
// `GetNamedSecurityInfoW`, both of which document `LocalFree`
// as the correct release for it.
unsafe {
LocalFree(self.0 as HLOCAL);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW;
use windows_sys::Win32::Security::{
GetAce, GetSecurityDescriptorControl, ACCESS_ALLOWED_ACE, CONTAINER_INHERIT_ACE,
OBJECT_INHERIT_ACE, SE_DACL_PROTECTED,
};
// winnt.h `ACCESS_ALLOWED_ACE_TYPE`; not worth an extra windows-sys
// feature for a single test-only constant.
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0;
// winnt.h `FILE_ALL_ACCESS`; likewise kept local to this test.
const FILE_ALL_ACCESS: u32 = 0x001F_01FF;
/// Reads back the DACL Win32 actually stored for `path` (as opposed to
/// the DACL passed to `SetNamedSecurityInfoW`), so the assertions below
/// exercise the real, applied ACL rather than our own construction of
/// it.
fn read_dacl(path: &Path) -> (SecurityDescriptor, *const ACL) {
let path_wide = to_wide_null(path.as_os_str());
let mut dacl: *mut ACL = ptr::null_mut();
let mut descriptor: PSECURITY_DESCRIPTOR = ptr::null_mut();
// SAFETY: `path_wide` is a valid null-terminated wide string live
// for the call. The returned descriptor is `LocalAlloc`-backed and
// is wrapped below so it is freed on drop; `dacl` points inside it.
let result = unsafe {
GetNamedSecurityInfoW(
path_wide.as_ptr(),
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
ptr::null_mut(),
ptr::null_mut(),
&mut dacl,
ptr::null_mut(),
&mut descriptor,
)
};
assert_eq!(result, 0, "GetNamedSecurityInfoW failed: {result}");
(SecurityDescriptor::from_raw(descriptor), dacl)
}
#[test]
fn apply_owner_only_acl_grants_exactly_user_system_and_admins() {
let dir = tempfile::tempdir().unwrap();
apply_owner_only_acl(dir.path()).unwrap();
let (descriptor, dacl) = read_dacl(dir.path());
let mut control: u16 = 0;
let mut revision: u32 = 0;
// SAFETY: `descriptor.raw()` is a valid security descriptor from
// `read_dacl` above, kept alive by `descriptor`.
let ok =
unsafe { GetSecurityDescriptorControl(descriptor.raw(), &mut control, &mut revision) };
assert_ne!(ok, 0, "GetSecurityDescriptorControl failed");
assert_ne!(
control & SE_DACL_PROTECTED,
0,
"DACL must be protected (no inheritance from the parent directory)"
);
// SAFETY: `dacl` points into `descriptor`, which is still alive.
let ace_count = unsafe { (*dacl).AceCount };
assert_eq!(ace_count, 3, "expected exactly one ACE per granted SID");
let mut granted_sids = Vec::new();
for index in 0..ace_count as u32 {
let mut ace_ptr: *mut core::ffi::c_void = ptr::null_mut();
// SAFETY: `index` is within `[0, ace_count)`; `dacl` is valid.
let ok = unsafe { GetAce(dacl, index, &mut ace_ptr) };
assert_ne!(ok, 0, "GetAce failed for index {index}");
// SAFETY: every ACE in an SDDL-built `(A;...)` DACL is an
// `ACCESS_ALLOWED_ACE`, and `ace_ptr` was just validated above.
let ace = unsafe { &*(ace_ptr as *const ACCESS_ALLOWED_ACE) };
assert_eq!(ace.Header.AceType, ACCESS_ALLOWED_ACE_TYPE);
let inherit_flags = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE;
assert_eq!(
ace.Header.AceFlags as u32 & inherit_flags,
inherit_flags,
"ACE must carry object + container inherit flags for index {index}"
);
assert_eq!(ace.Mask, FILE_ALL_ACCESS, "ACE must grant full control");
// SAFETY: `SidStart` is the address of the variable-length SID
// data that immediately follows the fixed `ACCESS_ALLOWED_ACE`
// fields, per its documented layout.
let sid = &ace.SidStart as *const u32 as PSID;
granted_sids.push(sid_to_string(sid).unwrap());
}
granted_sids.sort();
let mut expected = vec![
current_user_sid_string().unwrap(),
"S-1-5-18".to_string(), // LOCAL SYSTEM
"S-1-5-32-544".to_string(), // BUILTIN\Administrators
];
expected.sort();
assert_eq!(granted_sids, expected);
}
}
+3 -2
View File
@@ -201,8 +201,9 @@ pub async fn serve(
let (exit_barrier, signal_exit) = new_barrier();
// Kick off the agent host supervisor in the background. The supervisor
// is the only process that binds the user-facing TCP listener and owns
// the canonical lockfile; we never spawn an in-process sidecar here.
// is the only process that binds the user-facing TCP listener and
// publishes the canonical registry entry; we never spawn an
// in-process sidecar here.
// We deliberately do NOT await this here — the tunnel needs to start
// accepting connections immediately. Consumers that need the
// supervisor's endpoint (currently `handle_serve` for the
+326
View File
@@ -0,0 +1,326 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
//! Resolves the platform "user data" directory used to home the shared
//! agent-host discovery registry (`<userDataPath>/agent-host/local-endpoint/metadata.json`).
//!
//! Mirrors the precedence and per-platform rules implemented by the
//! TypeScript resolver in `src/vs/platform/environment/node/userDataPath.ts`
//! (which is passed `product.nameShort`), with one deliberate ordering
//! change: an explicit `--user-data-dir` always wins here. In the Electron
//! main process, `VSCODE_PORTABLE`/`VSCODE_APPDATA` are checked *before* the
//! CLI argument only to work around Electron implicitly re-injecting
//! `--user-data-dir` into argv; that quirk does not apply to this
//! standalone-CLI-only flag, so we can use the simpler, more predictable
//! "explicit flag always wins" order.
use std::path::PathBuf;
use crate::constants::PRODUCT_NAME_SHORT;
/// The platform family used to select default user-data directory rules.
/// Kept distinct from `std::env::consts::OS` so unit tests can exercise all
/// three branches regardless of the host running the tests.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserDataOs {
Windows,
MacOs,
Linux,
}
impl UserDataOs {
pub fn current() -> Self {
if cfg!(target_os = "windows") {
UserDataOs::Windows
} else if cfg!(target_os = "macos") {
UserDataOs::MacOs
} else {
UserDataOs::Linux
}
}
}
/// Snapshot of the environment variables (and home directory) consulted by
/// the resolver. Injectable so tests never need to mutate real process-wide
/// environment variables (which would make tests order-dependent / racy).
#[derive(Debug, Clone, Default)]
pub struct UserDataPathEnv {
pub vscode_portable: Option<String>,
pub vscode_appdata: Option<String>,
pub appdata: Option<String>,
pub userprofile: Option<String>,
pub xdg_config_home: Option<String>,
pub home_dir: Option<PathBuf>,
}
impl UserDataPathEnv {
/// Reads the real process environment / home directory.
pub fn from_process() -> Self {
Self {
vscode_portable: non_empty_env("VSCODE_PORTABLE"),
vscode_appdata: non_empty_env("VSCODE_APPDATA"),
appdata: non_empty_env("APPDATA"),
userprofile: non_empty_env("USERPROFILE"),
xdg_config_home: non_empty_env("XDG_CONFIG_HOME"),
home_dir: dirs::home_dir(),
}
}
}
fn non_empty_env(name: &str) -> Option<String> {
match std::env::var(name) {
Ok(v) if !v.is_empty() => Some(v),
_ => None,
}
}
/// Resolves the user data directory using the real process environment and
/// the built-in [`PRODUCT_NAME_SHORT`].
pub fn resolve_user_data_path(explicit: Option<&str>) -> PathBuf {
resolve_user_data_path_with(
explicit,
PRODUCT_NAME_SHORT,
UserDataOs::current(),
&UserDataPathEnv::from_process(),
)
}
/// Core, dependency-injected resolver. Precedence:
/// 1. Explicit `--user-data-dir`, if provided.
/// 2. `VSCODE_PORTABLE` -> `<portable>/user-data`.
/// 3. `VSCODE_APPDATA` -> `<appdata>/<productName>`.
/// 4. The platform default (see [`default_user_data_path_with`]).
///
/// Relative paths (explicit or portable) are resolved against the current
/// working directory, matching the TypeScript resolver's behavior of
/// resolving non-absolute paths against `process.cwd()`.
pub fn resolve_user_data_path_with(
explicit: Option<&str>,
product_name: &str,
os: UserDataOs,
env: &UserDataPathEnv,
) -> PathBuf {
if let Some(explicit) = explicit {
return resolve_relative_to_cwd(explicit, os);
}
if let Some(portable) = &env.vscode_portable {
return join_component(resolve_relative_to_cwd(portable, os), "user-data", os);
}
if let Some(appdata) = &env.vscode_appdata {
return join_component(resolve_relative_to_cwd(appdata, os), product_name, os);
}
default_user_data_path_with(product_name, os, env)
}
/// Resolves the platform default user data directory using the real process
/// environment and the built-in [`PRODUCT_NAME_SHORT`].
pub fn default_user_data_path() -> PathBuf {
default_user_data_path_with(
PRODUCT_NAME_SHORT,
UserDataOs::current(),
&UserDataPathEnv::from_process(),
)
}
/// Core, dependency-injected platform-default resolver, mirroring
/// `getDefaultUserDataPath` in the TypeScript resolver:
/// - Windows: `%APPDATA%\<productName>`, falling back to
/// `%USERPROFILE%\AppData\Roaming\<productName>`.
/// - macOS: `~/Library/Application Support/<productName>`.
/// - Linux: `${XDG_CONFIG_HOME:-~/.config}/<productName>`.
pub fn default_user_data_path_with(
product_name: &str,
os: UserDataOs,
env: &UserDataPathEnv,
) -> PathBuf {
match os {
UserDataOs::Windows => {
let base = if let Some(appdata) = &env.appdata {
PathBuf::from(appdata)
} else if let Some(userprofile) = &env.userprofile {
PathBuf::from(userprofile).join("AppData").join("Roaming")
} else {
home_dir_or_empty(env)
};
base.join(product_name)
}
UserDataOs::MacOs => home_dir_or_empty(env)
.join("Library")
.join("Application Support")
.join(product_name),
UserDataOs::Linux => {
let base = if let Some(xdg) = &env.xdg_config_home {
PathBuf::from(xdg)
} else {
home_dir_or_empty(env).join(".config")
};
base.join(product_name)
}
}
}
fn home_dir_or_empty(env: &UserDataPathEnv) -> PathBuf {
env.home_dir.clone().unwrap_or_else(|| PathBuf::from(""))
}
/// Returns whether `path` is absolute under the *given* platform's rules,
/// independent of the host the code is actually compiled/running on. Plain
/// `Path::is_absolute` can't be used here because on Windows it returns
/// `false` for POSIX-style rooted paths like `/mnt/portable` (they're
/// "drive-relative", not absolute), which would otherwise let a supposedly
/// absolute override silently get re-rooted under the current directory.
/// This only matters for exercising all three `UserDataOs` branches from a
/// single test host; in production `os` always matches the real host.
fn is_absolute_for_os(path: &str, os: UserDataOs) -> bool {
match os {
UserDataOs::Windows => {
let bytes = path.as_bytes();
let has_drive_root = bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/');
has_drive_root || path.starts_with("\\\\") || path.starts_with("//")
}
UserDataOs::MacOs | UserDataOs::Linux => path.starts_with('/'),
}
}
/// Joins `component` onto `base` using the path separator for `os`, without
/// going through `PathBuf::join`'s host-native (and, for a rooted-but-no-prefix
/// `base` on Windows, surprising) semantics.
fn join_component(base: PathBuf, component: &str, os: UserDataOs) -> PathBuf {
let sep = match os {
UserDataOs::Windows => '\\',
UserDataOs::MacOs | UserDataOs::Linux => '/',
};
let mut s = base.to_string_lossy().into_owned();
if !s.ends_with(['/', '\\']) {
s.push(sep);
}
s.push_str(component);
PathBuf::from(s)
}
fn resolve_relative_to_cwd(path: &str, os: UserDataOs) -> PathBuf {
if is_absolute_for_os(path, os) {
return PathBuf::from(path);
}
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(_) => PathBuf::from(path),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env_with_home(home: &str) -> UserDataPathEnv {
UserDataPathEnv {
home_dir: Some(PathBuf::from(home)),
..Default::default()
}
}
#[test]
fn default_windows_path_uses_appdata() {
let mut env = env_with_home(r"C:\Users\test");
env.appdata = Some(r"C:\Users\test\AppData\Roaming".to_string());
let path = default_user_data_path_with("Code - OSS", UserDataOs::Windows, &env);
assert_eq!(
path,
PathBuf::from(r"C:\Users\test\AppData\Roaming\Code - OSS")
);
}
#[test]
fn default_windows_path_falls_back_to_userprofile() {
let mut env = env_with_home(r"C:\Users\test");
env.userprofile = Some(r"C:\Users\test".to_string());
let path = default_user_data_path_with("Code - OSS", UserDataOs::Windows, &env);
assert_eq!(
path,
PathBuf::from(r"C:\Users\test\AppData\Roaming\Code - OSS")
);
}
#[test]
fn default_macos_path_uses_application_support() {
let env = env_with_home("/Users/test");
let path = default_user_data_path_with("Code - OSS", UserDataOs::MacOs, &env);
assert_eq!(
path,
PathBuf::from("/Users/test/Library/Application Support/Code - OSS")
);
}
#[test]
fn default_linux_path_uses_xdg_config_home() {
let mut env = env_with_home("/home/test");
env.xdg_config_home = Some("/home/test/.config".to_string());
let path = default_user_data_path_with("Code - OSS", UserDataOs::Linux, &env);
assert_eq!(path, PathBuf::from("/home/test/.config/Code - OSS"));
}
#[test]
fn default_linux_path_falls_back_to_home_dot_config() {
let env = env_with_home("/home/test");
let path = default_user_data_path_with("Code - OSS", UserDataOs::Linux, &env);
assert_eq!(path, PathBuf::from("/home/test/.config/Code - OSS"));
}
#[test]
fn vscode_portable_overrides_default() {
let mut env = env_with_home("/home/test");
env.vscode_portable = Some("/mnt/portable".to_string());
let path = resolve_user_data_path_with(None, "Code - OSS", UserDataOs::Linux, &env);
assert_eq!(path, PathBuf::from("/mnt/portable/user-data"));
}
#[test]
fn vscode_appdata_overrides_default() {
let mut env = env_with_home("/home/test");
env.vscode_appdata = Some("/mnt/appdata".to_string());
let path = resolve_user_data_path_with(None, "Code - OSS", UserDataOs::Linux, &env);
assert_eq!(path, PathBuf::from("/mnt/appdata/Code - OSS"));
}
#[test]
fn explicit_dir_takes_precedence_over_portable_and_appdata() {
let mut env = env_with_home("/home/test");
env.vscode_portable = Some("/mnt/portable".to_string());
env.vscode_appdata = Some("/mnt/appdata".to_string());
let path = resolve_user_data_path_with(
Some("/explicit/dir"),
"Code - OSS",
UserDataOs::Linux,
&env,
);
assert_eq!(path, PathBuf::from("/explicit/dir"));
}
#[test]
fn relative_explicit_dir_resolves_against_cwd() {
let env = env_with_home("/home/test");
let path = resolve_user_data_path_with(
Some("relative-dir"),
"Code - OSS",
UserDataOs::Linux,
&env,
);
assert_eq!(path, std::env::current_dir().unwrap().join("relative-dir"));
}
#[test]
fn falls_back_to_platform_default_when_nothing_set() {
let env = env_with_home("/home/test");
let path = resolve_user_data_path_with(None, "Code - OSS", UserDataOs::Linux, &env);
assert_eq!(path, PathBuf::from("/home/test/.config/Code - OSS"));
}
}
+10
View File
@@ -523,6 +523,16 @@ pub enum CodeError {
ServerUnexpectedExit(String),
#[error("Server binary is not executable: {0}")]
ServerNotExecutable(String),
#[error("no agent host could be reached: {0}")]
NoAgentHostReachable(String),
#[error("no session matching \"{0}\" was found on any discovered agent host")]
SessionNotFoundOnAnyHost(String),
#[error("could not confirm whether session \"{0}\" exists: {1} could not be searched")]
IncompleteSessionSearch(String, String),
#[error("multiple live standalone agent hosts are registered ({0}); pass --instance-id to select one")]
AmbiguousAgentHostInstance(String),
#[error("no live standalone agent host with instance id \"{0}\" was found")]
UnknownAgentHostInstance(String),
}
makeAnyError!(
+14
View File
@@ -67,3 +67,17 @@ pub fn prompt_placeholder(question: &str, placeholder: &str) -> Result<String, W
.interact_text()
.map_err(|e| wrap(e, "Failed to read confirm input"))
}
/// Like [`prompt_options`], but for a list of dynamically formatted
/// (non-`Copy`) string labels. Returns the *index* of the selected item
/// into `options` rather than a copy of it, so callers can associate each
/// label with a non-`Copy` value (e.g. a full registry entry) by keeping
/// a parallel `Vec`.
pub fn prompt_index(text: impl Into<String>, options: &[String]) -> Result<usize, WrappedError> {
Select::with_theme(&ColorfulTheme::default())
.with_prompt(text)
.items(options)
.default(0)
.interact()
.map_err(|e| wrap(e, "Failed to read select input"))
}