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
+1 -1
View File
@@ -67,7 +67,7 @@ winresource = "0.1"
[target.'cfg(windows)'.dependencies]
winreg = "0.56"
winapi = "0.3.9"
windows-sys = { version = "0.61", features = ["Win32_System_Console", "Win32_UI_Input_KeyboardAndMouse"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Console", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.9.3"
+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"))
}
+79 -17
View File
@@ -20,44 +20,75 @@ the product quality and any `--user-data-dir` argument. Implementations
should resolve the active user data directory rather than assuming the default
Stable or Insiders location.
The file is a **shared registry**: every locally running agent host process
(this editor's own utility process, other editor windows, and the standalone
`code agent host` CLI) upserts its own entry into the same file, so any one
process can discover every other live local agent host.
The file is optional. If VS Code cannot prepare or publish the external
endpoint, it logs the error and continues running the agent host over its
internal MessagePort transport.
## File format
The current schema version is `1`:
The current schema version is `2`:
```json
[
{
"schemaVersion": 2,
"type": "editor",
"schemaVersion": 1,
"pid": 12345,
"instanceId": "base64url-instance-id",
"endpointPath": "\\\\.\\pipe\\vscode-agent-host-...",
"protocolVersion": "0.7.0",
"connectionToken": "base64url-bearer-token",
"protocolVersion": "0.7.0"
"endpoint": {
"type": "socket",
"path": "\\\\.\\pipe\\vscode-agent-host-..."
}
}
]
```
| Property | Description |
|---|---|
| `type` | Kind of local server. Currently always `editor`. |
| `schemaVersion` | Metadata schema version. Clients should reject unsupported versions. |
| `pid` | PID of the agent host utility process that owns the endpoint. |
| `instanceId` | Random identity used to distinguish successive endpoint owners. |
| `endpointPath` | Windows named pipe or Unix domain socket path. |
| `connectionToken` | Random bearer token required during the WebSocket upgrade. |
| `schemaVersion` | Metadata schema version. Clients must ignore entries whose version they do not understand rather than rejecting the whole file. |
| `type` | Kind of process that owns the endpoint: `editor` (a VS Code utility process) or `standalone` (the `code agent host` CLI). This controls ownership/default-selection policy on the client; it is not a measure of trust. |
| `pid` | PID of the process that owns the endpoint. |
| `instanceId` | Random identity used to distinguish successive endpoint owners. Combined with `type` and `pid`, this forms the entry's identity for dedupe/upsert/removal, since PIDs can be reused after a process exits. |
| `protocolVersion` | AHP version spoken by the host. Clients must still perform the normal AHP `initialize` negotiation. |
| `connectionToken` | Random bearer token required during the WebSocket upgrade. |
| `endpoint` | Discriminated union describing how to connect: `{ "type": "socket", "path": string }` for a Windows named pipe or Unix domain socket (used by the editor today), or `{ "type": "tcp", "host": string, "port": number }` for a TCP listener (used by the standalone CLI). |
| `quality` | Optional. Product quality of a standalone CLI endpoint. Not part of entry identity. |
| `tunnelName` | Optional. Tunnel name associated with a standalone CLI endpoint. Not part of entry identity. |
Readers should treat every entry and field as untrusted input.
Readers must treat every entry and field as untrusted input:
- structurally validate every entry, dropping malformed ones individually
rather than failing the whole read;
- ignore entries with an unsupported `schemaVersion`;
- ignore entries whose PID is confirmed dead, when a PID liveness check is
possible;
- deduplicate entries by `(type, pid, instanceId)`;
- never reconstruct `endpoint` values — always use the published address as-is;
- perform the normal AHP `initialize` negotiation after connecting, regardless
of the advertised `protocolVersion`.
The shared parser/model lives in
[`common/agentHostEndpointRegistry.ts`](common/agentHostEndpointRegistry.ts) so
every local reader and writer (the editor publisher, a future registry
watcher, SSH discovery, and tests) validates entries identically.
Schema version `1` (a flat `{ endpointPath: string }` shape, `type` always
`"editor"`) is the format previously written by the editor alone. Version-`2`
readers ignore version-`1` entries rather than attempting to interpret them,
consistent with the "ignore unsupported schema versions" rule above.
## Connecting
Connect to `endpointPath` using WebSocket framing and provide
`connectionToken` in the standard VS Code connection-token query parameter:
Connect to `endpoint.path` (socket endpoints) using WebSocket framing and
provide `connectionToken` in the standard VS Code connection-token query
parameter:
```text
?tkn=<connectionToken>
@@ -72,7 +103,7 @@ Connections without the token, or with the wrong token, are rejected with HTTP
## Endpoint paths
On Windows, `endpointPath` is a named pipe:
On Windows, the editor's `endpoint.path` is a named pipe:
```text
\\.\pipe\vscode-agent-host-<user-data-hash>-<instance-id>
@@ -87,6 +118,34 @@ user-data-specific directory to stay within Unix socket path-length limits:
Clients must use the path from the metadata file rather than reconstructing it.
## Multi-writer safety
Because more than one process can publish to `metadata.json` at once, an
atomic rename by itself is not sufficient: two writers could read the same
array concurrently and each overwrite the other's addition. Every writer
therefore:
1. Acquires an exclusive lock: an atomically-created sibling lock directory
(`metadata.json.lock`) containing an `owner.json` file recording the
lock holder's `(pid, instanceId)`.
2. Reads the current array.
3. Drops entries whose PID is confirmed dead.
4. Upserts its own `(type, pid, instanceId)` entry.
5. Writes a mode-`0600` temporary file and atomically renames it over
`metadata.json`.
6. Releases the lock.
Lock acquisition is bounded: if the lock directory already exists, a
contender inspects `owner.json`. If the recorded PID is no longer alive, the
lock is stale and is reclaimed immediately; otherwise acquisition is retried
until a short timeout elapses. On timeout, the writer does **not** silently
bypass the lock and write anyway — it logs the failure and continues running
undiscoverable, exactly as when the endpoint cannot be published at all.
Readers never take the lock: because the registry file is only ever observed
in a fully-written state (via atomic rename), reads are always safe without
coordination.
## Security and lifecycle
- The metadata directory and file are restricted to the current user. On
@@ -95,14 +154,17 @@ Clients must use the path from the metadata file rather than reconstructing it.
metadata token is required to complete the WebSocket upgrade.
- Metadata is written atomically only after the endpoint is listening and the
protocol handler is installed.
- On shutdown, VS Code removes the metadata only if its PID and `instanceId`
still match. This prevents an older process from deleting a newer process's
endpoint record.
- On shutdown, VS Code reacquires the write lock and removes only the entry
whose `(type, pid, instanceId)` exactly matches its own. This prevents an
older process from deleting a newer process's endpoint record, and prevents
a writer from ever deleting another live writer's entry. The file itself is
deleted only when the resulting array is empty.
- Clients should handle a missing file, a stale PID, endpoint closure, and the
metadata being replaced while reconnecting.
The implementation and lifecycle wiring live in:
- [`common/agentHostEndpointRegistry.ts`](common/agentHostEndpointRegistry.ts)
- [`node/localAgentHostMetadata.ts`](node/localAgentHostMetadata.ts)
- [`node/agentHostMain.ts`](node/agentHostMain.ts)
- [`node/webSocketTransport.ts`](node/webSocketTransport.ts)
@@ -0,0 +1,182 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { vEnum, vLiteral, vNumber, vObj, vOptionalProp, vString, vUnion } from '../../../base/common/validation.js';
/**
* Schema version for the shared local agent-host endpoint registry at
* `<userDataPath>/agent-host/local-endpoint/metadata.json`. This schema is
* shared with the Rust CLI (`cli/src/tunnels/agent_host_metadata.rs`); field
* renames or removals MUST be coordinated across both languages.
*
* Version 1 was the editor-only, socket-path-only schema
* (`ILocalAgentHostEndpointMetadata` in `node/localAgentHostMetadata.ts`).
* Version 2 generalizes the registry to hold both editor (socket/pipe) and
* standalone CLI (TCP) endpoints in the same file so every locally running
* agent host is discoverable from one place.
*/
export const AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION = 2;
/**
* Kind of process that owns an agent host endpoint. Controls
* ownership/default-selection policy on the client; it is not by itself a
* measure of trust or of registry identity (see {@link IAgentHostEndpointIdentity}).
*/
export type AgentHostServerType = 'editor' | 'standalone';
/**
* How to physically connect to an endpoint. Editor endpoints are always a
* Unix domain socket or Windows named pipe; the standalone Rust CLI
* currently only ever publishes a TCP endpoint.
*/
export type AgentHostEndpointAddress =
| { readonly type: 'socket'; readonly path: string }
| { readonly type: 'tcp'; readonly host: string; readonly port: number };
/**
* One entry of the shared local agent-host endpoint registry. The registry
* file itself is a JSON array of these entries
* (`AgentHostEndpointMetadata[]`).
*/
export interface IAgentHostEndpointMetadata {
readonly schemaVersion: typeof AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION;
readonly type: AgentHostServerType;
readonly pid: number;
readonly instanceId: string;
readonly protocolVersion: string;
readonly connectionToken: string;
readonly endpoint: AgentHostEndpointAddress;
readonly quality?: string;
readonly tunnelName?: string;
}
/**
* The subset of {@link IAgentHostEndpointMetadata} that identifies a unique
* registry entry/owner: `(type, pid, instanceId)`. `instanceId` makes
* identity safe across PID reuse and rapid process replacement; `pid` alone
* is not a safe key because operating systems recycle PIDs.
*/
export interface IAgentHostEndpointIdentity {
readonly type: AgentHostServerType;
readonly pid: number;
readonly instanceId: string;
}
const endpointAddressValidator = vUnion(
vObj({ type: vLiteral('socket'), path: vString() }),
vObj({ type: vLiteral('tcp'), host: vString(), port: vNumber() }),
);
const entryValidator = vObj({
schemaVersion: vNumber(),
type: vEnum('editor', 'standalone'),
pid: vNumber(),
instanceId: vString(),
protocolVersion: vString(),
connectionToken: vString(),
endpoint: endpointAddressValidator,
quality: vOptionalProp(vString()),
tunnelName: vOptionalProp(vString()),
});
/**
* Structurally validates one raw registry entry and returns it typed, or
* `undefined` if it is malformed or its `schemaVersion` is not the one this
* build understands. Every field is treated as untrusted input.
*/
export function parseAgentHostEndpointMetadataEntry(raw: unknown): IAgentHostEndpointMetadata | undefined {
const { content, error } = entryValidator.validate(raw);
if (error) {
return undefined;
}
if (content.schemaVersion !== AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION) {
// Unsupported (older/newer) schema version: ignore rather than reject
// the whole registry, so a foreign writer's entry cannot hide every
// other live writer's endpoint.
return undefined;
}
if (!Number.isSafeInteger(content.pid) || content.pid <= 0) {
return undefined;
}
if (content.endpoint.type === 'tcp' && (!Number.isSafeInteger(content.endpoint.port) || content.endpoint.port <= 0 || content.endpoint.port > 65535)) {
return undefined;
}
return {
schemaVersion: AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION,
type: content.type,
pid: content.pid,
instanceId: content.instanceId,
protocolVersion: content.protocolVersion,
connectionToken: content.connectionToken,
endpoint: content.endpoint,
quality: content.quality,
tunnelName: content.tunnelName,
};
}
/**
* Parses the raw contents of the registry file (expected to be
* `AgentHostEndpointMetadata[]`). Every entry is validated independently:
* malformed entries and entries with an unsupported `schemaVersion` are
* dropped rather than failing the entire read. A non-array top-level value
* yields an empty registry.
*/
export function parseAgentHostEndpointRegistry(raw: unknown): IAgentHostEndpointMetadata[] {
if (!Array.isArray(raw)) {
return [];
}
const entries: IAgentHostEndpointMetadata[] = [];
for (const item of raw) {
const entry = parseAgentHostEndpointMetadataEntry(item);
if (entry) {
entries.push(entry);
}
}
return entries;
}
/** Stable string key for `(type, pid, instanceId)`, suitable for use as a Map key. */
export function getAgentHostEndpointIdentityKey(identity: IAgentHostEndpointIdentity): string {
return `${identity.type}:${identity.pid}:${identity.instanceId}`;
}
export function isSameAgentHostEndpointIdentity(a: IAgentHostEndpointIdentity, b: IAgentHostEndpointIdentity): boolean {
return a.type === b.type && a.pid === b.pid && a.instanceId === b.instanceId;
}
/**
* Deduplicates entries by `(type, pid, instanceId)`. If a duplicate identity
* appears more than once (for example a crashed writer left a stale copy
* before another writer's cleanup ran), the entry encountered later in
* `entries` wins, since it is presumed to be the most recently written copy.
*/
export function dedupeAgentHostEndpointMetadata(entries: readonly IAgentHostEndpointMetadata[]): IAgentHostEndpointMetadata[] {
const byIdentity = new Map<string, IAgentHostEndpointMetadata>();
for (const entry of entries) {
byIdentity.set(getAgentHostEndpointIdentityKey(entry), entry);
}
return [...byIdentity.values()];
}
/**
* Returns `entries` with any existing entry sharing `metadata`'s identity
* replaced by `metadata`. Used by a writer to upsert its own registry entry
* without disturbing other writers' entries.
*/
export function upsertAgentHostEndpointMetadata(entries: readonly IAgentHostEndpointMetadata[], metadata: IAgentHostEndpointMetadata): IAgentHostEndpointMetadata[] {
const remaining = entries.filter(entry => !isSameAgentHostEndpointIdentity(entry, metadata));
remaining.push(metadata);
return remaining;
}
/**
* Returns `entries` with the exact-identity-matching entry removed, if any.
* Used on shutdown so a writer only ever removes its own entry, never a
* newer process's entry that happens to share its PID.
*/
export function removeAgentHostEndpointMetadata(entries: readonly IAgentHostEndpointMetadata[], owner: IAgentHostEndpointIdentity): IAgentHostEndpointMetadata[] {
return entries.filter(entry => !isSameAgentHostEndpointIdentity(entry, owner));
}
@@ -401,7 +401,7 @@ async function startAgentHost(): Promise<void> {
server.registerChannel(AgentHostIpcChannels.Protocol, messagePortProtocolServer);
if (localEndpoint) {
try {
await publishLocalAgentHostEndpointMetadata(environmentService.userDataPath, localEndpoint.metadata);
await publishLocalAgentHostEndpointMetadata(environmentService.userDataPath, localEndpoint.metadata, logService);
localDataPlaneDisposables.add(toDisposable(() => {
cleanupLocalAgentHostEndpoint(environmentService.userDataPath, localEndpoint.metadata, logService);
}));
@@ -557,7 +557,7 @@ async function startLocalAgentHostEndpoint(
}
server = await WebSocketProtocolServer.create(
{
socketPath: endpointMetadata.endpointPath,
socketPath: endpointMetadata.endpoint.path,
connectionTokenValidate: token => token === endpointMetadata.connectionToken,
},
logService,
@@ -585,12 +585,12 @@ function cleanupLocalAgentHostEndpoint(
logService: ILogService,
): void {
try {
cleanupLocalAgentHostEndpointMetadataSync(userDataPath, metadata);
cleanupLocalAgentHostEndpointMetadataSync(userDataPath, metadata, logService);
} catch (error) {
logService.error('[AgentHost] Failed to clean up local protocol metadata', error);
}
try {
cleanupLocalAgentHostEndpointSocketSync(metadata.endpointPath);
cleanupLocalAgentHostEndpointSocketSync(metadata.endpoint.path);
} catch (error) {
logService.error('[AgentHost] Failed to clean up local protocol socket', error);
}
@@ -8,42 +8,37 @@ import { createHash, randomBytes } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import { join } from '../../../base/common/path.js';
import { vArray, vLiteral, vNumber, vObj, vString } from '../../../base/common/validation.js';
import { ILogService } from '../../log/common/log.js';
import {
AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION,
IAgentHostEndpointIdentity,
IAgentHostEndpointMetadata,
dedupeAgentHostEndpointMetadata,
parseAgentHostEndpointRegistry,
removeAgentHostEndpointMetadata,
upsertAgentHostEndpointMetadata,
} from '../common/agentHostEndpointRegistry.js';
import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js';
import { isPidAlive } from './agentHostLockfile.js';
const metadataSchemaVersion = 1;
const metadataDirectoryName = 'agent-host';
const endpointDirectoryName = 'local-endpoint';
const metadataFileName = 'metadata.json';
export interface ILocalAgentHostEndpointMetadata {
/** The editor's own entry in the shared local agent host endpoint registry. */
export type ILocalAgentHostEndpointMetadata = IAgentHostEndpointMetadata & {
readonly type: 'editor';
readonly schemaVersion: typeof metadataSchemaVersion;
readonly pid: number;
readonly instanceId: string;
readonly endpointPath: string;
readonly connectionToken: string;
readonly protocolVersion: string;
}
const metadataValidator = vArray(vObj({
type: vLiteral('editor'),
schemaVersion: vNumber(),
pid: vNumber(),
instanceId: vString(),
endpointPath: vString(),
connectionToken: vString(),
protocolVersion: vString(),
}));
readonly endpoint: { readonly type: 'socket'; readonly path: string };
};
export function createLocalAgentHostEndpointMetadata(userDataPath: string): ILocalAgentHostEndpointMetadata {
const instanceId = randomBytes(16).toString('base64url');
return {
schemaVersion: AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION,
type: 'editor',
schemaVersion: metadataSchemaVersion,
pid: process.pid,
instanceId,
endpointPath: getEndpointPath(userDataPath, instanceId),
endpoint: { type: 'socket', path: getEndpointPath(userDataPath, instanceId) },
connectionToken: randomBytes(32).toString('base64url'),
protocolVersion: PROTOCOL_VERSION,
};
@@ -82,37 +77,64 @@ export async function prepareLocalAgentHostEndpointSocketDirectory(userDataPath:
}
}
export async function publishLocalAgentHostEndpointMetadata(userDataPath: string, metadata: ILocalAgentHostEndpointMetadata): Promise<void> {
/**
* Upserts `metadata` into the shared local agent host endpoint registry.
*
* Multiple processes (this editor, other editor windows, and the standalone
* `code agent host` CLI) can publish to the same registry file concurrently,
* so an atomic rename alone is not sufficient: two writers could otherwise
* read the same array and overwrite each other's addition. This acquires a
* sibling exclusive lock first, so the read-prune-upsert-write sequence
* below is serialized across all writers. Readers remain lock-free because
* the final write is an atomic rename.
*
* Throws if the lock cannot be acquired within a bounded timeout, or if any
* filesystem operation fails; callers must treat that as "continue running,
* but undiscoverable" and must not fall back to a non-atomic write.
*/
export async function publishLocalAgentHostEndpointMetadata(userDataPath: string, metadata: ILocalAgentHostEndpointMetadata, logService?: ILogService): Promise<void> {
const metadataPath = getMetadataPath(userDataPath);
const temporaryPath = `${metadataPath}.${metadata.instanceId}.tmp`;
const entries = readMetadata(metadataPath).filter(entry => entry.pid !== metadata.pid || entry.type !== metadata.type);
entries.push(metadata);
const handle = await fs.promises.open(temporaryPath, 'wx', 0o600);
try {
await handle.writeFile(JSON.stringify(entries), 'utf8');
await handle.sync();
} finally {
await handle.close();
const release = await acquireRegistryLockAsync(userDataPath, metadata, logService);
if (!release) {
throw new Error(`Timed out acquiring the local agent host endpoint registry lock at ${getLockDirectoryPath(userDataPath)}`);
}
try {
await fs.promises.rename(temporaryPath, metadataPath);
const current = await readRegistryAsync(metadataPath);
const live = pruneDeadAgentHostEndpointMetadata(current, logService);
const next = upsertAgentHostEndpointMetadata(dedupeAgentHostEndpointMetadata(live), metadata);
await writeRegistryAtomicAsync(metadataPath, metadata.instanceId, next);
} finally {
await fs.promises.rm(temporaryPath, { force: true });
await release();
}
}
export function cleanupLocalAgentHostEndpointMetadataSync(userDataPath: string, owner: ILocalAgentHostEndpointMetadata): void {
/**
* Removes exactly `owner`'s `(type, pid, instanceId)` entry from the
* registry, reacquiring the write lock first. Deletes the file entirely
* only when the resulting registry is empty. This is a best-effort shutdown
* operation: failures are logged, never thrown, so process exit is never
* blocked by cleanup.
*/
export function cleanupLocalAgentHostEndpointMetadataSync(userDataPath: string, owner: ILocalAgentHostEndpointMetadata, logService?: ILogService): void {
const metadataPath = getMetadataPath(userDataPath);
const entries = readMetadata(metadataPath);
const remaining = entries.filter(entry => entry.pid !== owner.pid || entry.instanceId !== owner.instanceId || entry.type !== owner.type);
if (remaining.length === entries.length) {
const release = acquireRegistryLockSync(userDataPath, owner, logService);
if (!release) {
logService?.error(`[AgentHost] Timed out acquiring the local agent host endpoint registry lock while removing our entry from ${metadataPath}`);
return;
}
if (remaining.length === 0) {
fs.rmSync(metadataPath, { force: true });
} else {
fs.writeFileSync(metadataPath, JSON.stringify(remaining), { encoding: 'utf8', mode: 0o600 });
try {
const current = readRegistrySync(metadataPath);
const remaining = removeAgentHostEndpointMetadata(current, owner);
if (remaining.length === current.length) {
return;
}
if (remaining.length === 0) {
fs.rmSync(metadataPath, { force: true });
} else {
writeRegistryAtomicSync(metadataPath, owner.instanceId, remaining);
}
} finally {
release();
}
}
@@ -122,6 +144,16 @@ export function cleanupLocalAgentHostEndpointSocketSync(endpointPath: string): v
}
}
/**
* Reads and validates every live entry in the shared local agent host
* endpoint registry, without taking the write lock. Safe to call frequently
* (e.g. from a file watcher) because the registry file is only ever
* observed in a fully-written state via atomic rename.
*/
export async function readLocalAgentHostEndpointRegistry(userDataPath: string): Promise<IAgentHostEndpointMetadata[]> {
return readRegistryAsync(getMetadataPath(userDataPath));
}
function getMetadataDirectory(userDataPath: string): string {
return join(userDataPath, metadataDirectoryName, endpointDirectoryName);
}
@@ -130,6 +162,14 @@ function getMetadataPath(userDataPath: string): string {
return join(getMetadataDirectory(userDataPath), metadataFileName);
}
function getLockDirectoryPath(userDataPath: string): string {
return `${getMetadataPath(userDataPath)}.lock`;
}
function getLockOwnerFilePath(lockDirectoryPath: string): string {
return join(lockDirectoryPath, 'owner.json');
}
function getSocketDirectory(userDataPath: string): string {
const owner = process.getuid?.().toString() ?? '';
const hash = createHash('sha256').update(`${owner}:${userDataPath}`).digest('hex').slice(0, 12);
@@ -144,25 +184,299 @@ function getEndpointPath(userDataPath: string, instanceId: string): string {
return join(getSocketDirectory(userDataPath), `${instanceId}.sock`);
}
function readMetadata(path: string): ILocalAgentHostEndpointMetadata[] {
async function readRegistryAsync(metadataPath: string): Promise<IAgentHostEndpointMetadata[]> {
let raw: string;
try {
const stat = fs.lstatSync(path);
const stat = await fs.promises.lstat(metadataPath);
if (!stat.isFile() || stat.isSymbolicLink()) {
return [];
}
const result = metadataValidator.validate(JSON.parse(fs.readFileSync(path, 'utf8')));
if (result.error) {
return [];
}
return result.content
.filter(entry => entry.schemaVersion === metadataSchemaVersion)
.map(entry => ({ ...entry, schemaVersion: metadataSchemaVersion }));
raw = await fs.promises.readFile(metadataPath, 'utf8');
} catch (error) {
if (isNotFound(error) || error instanceof SyntaxError) {
if (isNotFound(error)) {
return [];
}
throw error;
}
return parseRegistryJson(raw);
}
function readRegistrySync(metadataPath: string): IAgentHostEndpointMetadata[] {
let raw: string;
try {
const stat = fs.lstatSync(metadataPath);
if (!stat.isFile() || stat.isSymbolicLink()) {
return [];
}
raw = fs.readFileSync(metadataPath, 'utf8');
} catch (error) {
if (isNotFound(error)) {
return [];
}
throw error;
}
return parseRegistryJson(raw);
}
function parseRegistryJson(raw: string): IAgentHostEndpointMetadata[] {
try {
return parseAgentHostEndpointRegistry(JSON.parse(raw));
} catch (error) {
if (error instanceof SyntaxError) {
return [];
}
throw error;
}
}
/**
* Drops entries whose PID is confirmed dead. Entries are only ever pruned
* here (i.e. when death is certain via a PID liveness check); a live PID, or
* a PID we cannot check, is always kept.
*/
function pruneDeadAgentHostEndpointMetadata(entries: readonly IAgentHostEndpointMetadata[], logService?: ILogService): IAgentHostEndpointMetadata[] {
return entries.filter(entry => {
if (isPidAlive(entry.pid)) {
return true;
}
logService?.info(`[AgentHost] Pruning stale local endpoint registry entry: ${entry.type} PID ${entry.pid} (instance ${entry.instanceId}) is no longer running`);
return false;
});
}
async function writeRegistryAtomicAsync(metadataPath: string, uniqueSuffix: string, entries: readonly IAgentHostEndpointMetadata[]): Promise<void> {
const temporaryPath = `${metadataPath}.${uniqueSuffix}.tmp`;
const handle = await fs.promises.open(temporaryPath, 'wx', 0o600);
try {
await handle.writeFile(JSON.stringify(entries), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
try {
await fs.promises.rename(temporaryPath, metadataPath);
} finally {
await fs.promises.rm(temporaryPath, { force: true });
}
}
function writeRegistryAtomicSync(metadataPath: string, uniqueSuffix: string, entries: readonly IAgentHostEndpointMetadata[]): void {
const temporaryPath = `${metadataPath}.${uniqueSuffix}.tmp`;
const fd = fs.openSync(temporaryPath, 'wx', 0o600);
try {
fs.writeFileSync(fd, JSON.stringify(entries), 'utf8');
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
try {
fs.renameSync(temporaryPath, metadataPath);
} finally {
fs.rmSync(temporaryPath, { force: true });
}
}
// #region Multi-writer lock
//
// The lock is a sibling directory to metadata.json (metadata.json.lock).
// `mkdir` without `recursive` is used as the exclusive-acquire primitive
// because directory creation is atomic on every platform we support and
// requires no native/optional dependency. The lock holder's `(pid,
// instanceId)` is written into an owner file inside the directory so a
// contending process can recognize and reclaim an abandoned lock: if the
// recorded PID is confirmed dead, the lock is stale and is reclaimed
// immediately; otherwise acquisition is retried until a bounded timeout
// elapses, after which the caller is told to log and continue
// undiscoverable rather than silently bypassing the lock.
interface ILockOwner {
readonly pid: number;
readonly instanceId: string;
}
const asyncLockAcquireTimeoutMs = 3000;
const asyncLockRetryDelayMs = 40;
const syncLockAcquireTimeoutMs = 500;
const syncLockRetryDelayMs = 10;
/** Grace period for a lock directory whose owner file has not appeared yet, to avoid racing a concurrent acquirer that is mid-write. */
const lockOwnerGraceMs = 2000;
async function acquireRegistryLockAsync(userDataPath: string, owner: ILockOwner, logService?: ILogService): Promise<(() => Promise<void>) | undefined> {
const lockDirectoryPath = getLockDirectoryPath(userDataPath);
const deadline = Date.now() + asyncLockAcquireTimeoutMs;
for (; ;) {
try {
await fs.promises.mkdir(lockDirectoryPath);
await fs.promises.writeFile(getLockOwnerFilePath(lockDirectoryPath), JSON.stringify(owner), { encoding: 'utf8', mode: 0o600 });
return () => releaseRegistryLockAsync(lockDirectoryPath, owner, logService);
} catch (error) {
if (!isAlreadyExists(error)) {
throw error;
}
if (await tryReclaimStaleLockAsync(lockDirectoryPath, logService)) {
continue;
}
if (Date.now() >= deadline) {
return undefined;
}
await delay(asyncLockRetryDelayMs);
}
}
}
async function releaseRegistryLockAsync(lockDirectoryPath: string, owner: ILockOwner, logService?: ILogService): Promise<void> {
try {
const current = await readLockOwnerAsync(lockDirectoryPath);
if (current && !isSameLockOwner(current, owner)) {
// Another process already reclaimed this lock as stale; it now owns
// this lock's lifecycle, so leave it alone.
return;
}
await fs.promises.rm(lockDirectoryPath, { recursive: true, force: true });
} catch (error) {
logService?.error('[AgentHost] Failed to release the local agent host endpoint registry lock', error);
}
}
async function tryReclaimStaleLockAsync(lockDirectoryPath: string, logService?: ILogService): Promise<boolean> {
const owner = await readLockOwnerAsync(lockDirectoryPath);
if (owner) {
if (isPidAlive(owner.pid)) {
return false;
}
} else if (!(await isLockDirectoryStaleWithoutOwnerAsync(lockDirectoryPath))) {
return false;
}
try {
await fs.promises.rm(lockDirectoryPath, { recursive: true, force: true });
} catch {
return false;
}
logService?.warn(`[AgentHost] Reclaimed a stale local agent host endpoint registry lock${owner ? ` from PID ${owner.pid}` : ''}`);
return true;
}
async function readLockOwnerAsync(lockDirectoryPath: string): Promise<ILockOwner | undefined> {
try {
return parseLockOwner(JSON.parse(await fs.promises.readFile(getLockOwnerFilePath(lockDirectoryPath), 'utf8')));
} catch {
return undefined;
}
}
async function isLockDirectoryStaleWithoutOwnerAsync(lockDirectoryPath: string): Promise<boolean> {
try {
const stat = await fs.promises.stat(lockDirectoryPath);
return Date.now() - stat.mtimeMs > lockOwnerGraceMs;
} catch {
// The directory disappeared already (another process reclaimed it);
// let the caller retry acquisition.
return true;
}
}
function acquireRegistryLockSync(userDataPath: string, owner: ILockOwner, logService?: ILogService): (() => void) | undefined {
const lockDirectoryPath = getLockDirectoryPath(userDataPath);
const deadline = Date.now() + syncLockAcquireTimeoutMs;
for (; ;) {
try {
fs.mkdirSync(lockDirectoryPath);
fs.writeFileSync(getLockOwnerFilePath(lockDirectoryPath), JSON.stringify(owner), { encoding: 'utf8', mode: 0o600 });
return () => releaseRegistryLockSync(lockDirectoryPath, owner, logService);
} catch (error) {
if (!isAlreadyExists(error)) {
throw error;
}
if (tryReclaimStaleLockSync(lockDirectoryPath, logService)) {
continue;
}
if (Date.now() >= deadline) {
return undefined;
}
sleepSync(syncLockRetryDelayMs);
}
}
}
function releaseRegistryLockSync(lockDirectoryPath: string, owner: ILockOwner, logService?: ILogService): void {
try {
const current = readLockOwnerSync(lockDirectoryPath);
if (current && !isSameLockOwner(current, owner)) {
return;
}
fs.rmSync(lockDirectoryPath, { recursive: true, force: true });
} catch (error) {
logService?.error('[AgentHost] Failed to release the local agent host endpoint registry lock', error);
}
}
function tryReclaimStaleLockSync(lockDirectoryPath: string, logService?: ILogService): boolean {
const owner = readLockOwnerSync(lockDirectoryPath);
if (owner) {
if (isPidAlive(owner.pid)) {
return false;
}
} else if (!isLockDirectoryStaleWithoutOwnerSync(lockDirectoryPath)) {
return false;
}
try {
fs.rmSync(lockDirectoryPath, { recursive: true, force: true });
} catch {
return false;
}
logService?.warn(`[AgentHost] Reclaimed a stale local agent host endpoint registry lock${owner ? ` from PID ${owner.pid}` : ''}`);
return true;
}
function readLockOwnerSync(lockDirectoryPath: string): ILockOwner | undefined {
try {
return parseLockOwner(JSON.parse(fs.readFileSync(getLockOwnerFilePath(lockDirectoryPath), 'utf8')));
} catch {
return undefined;
}
}
function isLockDirectoryStaleWithoutOwnerSync(lockDirectoryPath: string): boolean {
try {
const stat = fs.statSync(lockDirectoryPath);
return Date.now() - stat.mtimeMs > lockOwnerGraceMs;
} catch {
return true;
}
}
function parseLockOwner(raw: unknown): ILockOwner | undefined {
if (typeof raw !== 'object' || raw === null) {
return undefined;
}
const obj = raw as Record<string, unknown>;
if (typeof obj.pid !== 'number' || typeof obj.instanceId !== 'string') {
return undefined;
}
return { pid: obj.pid, instanceId: obj.instanceId };
}
function isSameLockOwner(a: IAgentHostEndpointIdentity | ILockOwner, b: IAgentHostEndpointIdentity | ILockOwner): boolean {
return a.pid === b.pid && a.instanceId === b.instanceId;
}
function isAlreadyExists(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === 'EEXIST';
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** Synchronous bounded sleep, only used on the shutdown/cleanup path where an `async` wait is not usable (dispose() is synchronous). */
function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
// #endregion
function isNotFound(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
}
async function applyWindowsOwnerOnlyAcl(path: string): Promise<void> {
@@ -193,7 +507,3 @@ function runWindowsCommand(command: string, args: readonly string[]): Promise<st
execFile(command, [...args], { encoding: 'utf8', windowsHide: true }, (error, stdout) => error ? reject(error) : resolve(String(stdout)));
});
}
function isNotFound(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
}
@@ -0,0 +1,110 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import {
AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION,
IAgentHostEndpointMetadata,
dedupeAgentHostEndpointMetadata,
getAgentHostEndpointIdentityKey,
parseAgentHostEndpointMetadataEntry,
parseAgentHostEndpointRegistry,
removeAgentHostEndpointMetadata,
upsertAgentHostEndpointMetadata,
} from '../../common/agentHostEndpointRegistry.js';
function createEntry(overrides: Partial<IAgentHostEndpointMetadata> = {}): IAgentHostEndpointMetadata {
return {
schemaVersion: AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION,
type: 'editor',
pid: 100,
instanceId: 'a',
protocolVersion: '0.7.0',
connectionToken: 'token',
endpoint: { type: 'socket', path: '/tmp/vscode-ah/a.sock' },
...overrides,
};
}
suite('Agent Host Endpoint Registry (schema v2)', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('structurally validates every entry, discriminating the endpoint union', () => {
const valid = createEntry();
const validTcp = createEntry({ type: 'standalone', endpoint: { type: 'tcp', host: '127.0.0.1', port: 12345 } });
const cases: unknown[] = [
valid,
validTcp,
null,
42,
'not an object',
{ ...valid, pid: 'not-a-number' },
{ ...valid, pid: -1 },
{ ...valid, type: 'bogus-type' },
{ ...valid, endpoint: { type: 'bogus-kind', path: '/x' } },
{ ...valid, endpoint: { type: 'tcp', host: '127.0.0.1', port: 'not-a-number' } },
{ ...valid, endpoint: { type: 'tcp', host: '127.0.0.1', port: 70000 } },
{ ...valid, connectionToken: undefined },
];
assert.deepStrictEqual(
cases.map(entry => parseAgentHostEndpointMetadataEntry(entry) !== undefined),
[true, true, false, false, false, false, false, false, false, false, false, false],
);
});
test('ignores unsupported schema versions without failing the whole array', () => {
const current = createEntry({ instanceId: 'current' });
const raw = [
current,
{ ...current, instanceId: 'v1', schemaVersion: 1 },
{ ...current, instanceId: 'v3', schemaVersion: 3 },
{ not: 'a valid entry' },
'garbage',
];
assert.deepStrictEqual(
parseAgentHostEndpointRegistry(raw).map(entry => entry.instanceId),
['current'],
);
});
test('a non-array top-level value yields an empty registry', () => {
assert.deepStrictEqual(parseAgentHostEndpointRegistry({ not: 'an array' }), []);
assert.deepStrictEqual(parseAgentHostEndpointRegistry(null), []);
});
test('dedupes by (type, pid, instanceId), the later entry winning', () => {
const stale = createEntry({ instanceId: 'dup', connectionToken: 'stale-token' });
const fresh = createEntry({ instanceId: 'dup', connectionToken: 'fresh-token' });
const other = createEntry({ pid: 200, instanceId: 'other' });
const sameIdsDifferentType = createEntry({ type: 'standalone', instanceId: 'dup', endpoint: { type: 'tcp', host: '127.0.0.1', port: 1 } });
const deduped = dedupeAgentHostEndpointMetadata([stale, other, sameIdsDifferentType, fresh]);
assert.deepStrictEqual(
deduped.map(getAgentHostEndpointIdentityKey).sort(),
[other, sameIdsDifferentType, fresh].map(getAgentHostEndpointIdentityKey).sort(),
);
assert.strictEqual(deduped.find(entry => entry.type === 'editor' && entry.instanceId === 'dup')?.connectionToken, 'fresh-token');
});
test('upsert replaces only the matching identity, leaving other writers untouched', () => {
const a = createEntry({ instanceId: 'a' });
const b = createEntry({ pid: 200, instanceId: 'b', type: 'standalone', endpoint: { type: 'tcp', host: '127.0.0.1', port: 1 } });
const updatedA = createEntry({ instanceId: 'a', connectionToken: 'updated-token' });
assert.deepStrictEqual(upsertAgentHostEndpointMetadata([a, b], updatedA), [b, updatedA]);
});
test('remove takes only the exact-identity entry (same PID is not enough)', () => {
const owner = createEntry({ instanceId: 'a' });
const impostor = createEntry({ instanceId: 'a-impostor' }); // same (type, pid), different instanceId
const other = createEntry({ pid: 200, instanceId: 'b' });
assert.deepStrictEqual(removeAgentHostEndpointMetadata([owner, impostor, other], owner), [impostor, other]);
});
});
@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import { dirname, join } from '../../../../base/common/path.js';
@@ -37,10 +38,10 @@ suite('Local Agent Host Endpoint Metadata', () => {
pid: metadata.pid,
protocolVersion: metadata.protocolVersion,
tokenLength: metadata.connectionToken.length,
isScoped: metadata.endpointPath !== other.endpointPath,
isScoped: metadata.endpoint.path !== other.endpoint.path,
}, {
type: 'editor',
schemaVersion: 1,
schemaVersion: 2,
pid: process.pid,
protocolVersion: metadata.protocolVersion,
tokenLength: 43,
@@ -54,8 +55,8 @@ suite('Local Agent Host Endpoint Metadata', () => {
const metadata = createLocalAgentHostEndpointMetadata(deeplyNested);
assert.deepStrictEqual({
isUnderTemp: dirname(dirname(metadata.endpointPath)) === os.tmpdir(),
isShort: Buffer.byteLength(metadata.endpointPath) < 104,
isUnderTemp: dirname(dirname(metadata.endpoint.path)) === os.tmpdir(),
isShort: Buffer.byteLength(metadata.endpoint.path) < 104,
}, {
isUnderTemp: true,
isShort: true,
@@ -68,27 +69,90 @@ suite('Local Agent Host Endpoint Metadata', () => {
});
}
test('atomically replaces and owner-checks metadata', async () => {
test('preserves distinct writers and removes only the exact owner', async () => {
const first = createLocalAgentHostEndpointMetadata(userDataPath);
const second = createLocalAgentHostEndpointMetadata(userDataPath);
await publishLocalAgentHostEndpointMetadata(userDataPath, first);
await publishLocalAgentHostEndpointMetadata(userDataPath, second);
const publishedBoth = JSON.parse(await fs.promises.readFile(metadataPath, 'utf8'));
cleanupLocalAgentHostEndpointMetadataSync(userDataPath, first);
const published = JSON.parse(await fs.promises.readFile(metadataPath, 'utf8'));
const publishedAfterFirstRemoved = JSON.parse(await fs.promises.readFile(metadataPath, 'utf8'));
cleanupLocalAgentHostEndpointMetadataSync(userDataPath, second);
assert.deepStrictEqual({
published,
publishedBoth,
publishedAfterFirstRemoved,
removed: !fs.existsSync(metadataPath),
files: await fs.promises.readdir(dirname(metadataPath)),
}, {
published: [second],
publishedBoth: [first, second],
publishedAfterFirstRemoved: [second],
removed: true,
files: [],
});
});
test('concurrent writers preserve every entry (no lost updates)', async () => {
const writers = Array.from({ length: 5 }, () => createLocalAgentHostEndpointMetadata(userDataPath));
await Promise.all(writers.map(metadata => publishLocalAgentHostEndpointMetadata(userDataPath, metadata)));
const published: Array<{ instanceId: string }> = JSON.parse(await fs.promises.readFile(metadataPath, 'utf8'));
assert.deepStrictEqual(
new Set(published.map(entry => entry.instanceId)),
new Set(writers.map(writer => writer.instanceId)),
);
});
test('reclaims a lock abandoned by a dead process', async () => {
// A process that has already exited by the time spawnSync returns, so
// its PID is guaranteed to no longer be alive. `process.execPath` is
// Electron under the unit test runner, so it must be told to run as
// plain Node (matching the pattern used elsewhere in this codebase,
// e.g. node/claude/claudeSdkOptions.ts) rather than launch the full app.
const deadPid = spawnSync(process.execPath, ['-e', '0'], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } }).pid!;
const lockDirectoryPath = `${metadataPath}.lock`;
await fs.promises.mkdir(lockDirectoryPath);
await fs.promises.writeFile(join(lockDirectoryPath, 'owner.json'), JSON.stringify({ pid: deadPid, instanceId: 'stale-owner' }), 'utf8');
const metadata = createLocalAgentHostEndpointMetadata(userDataPath);
await publishLocalAgentHostEndpointMetadata(userDataPath, metadata);
assert.deepStrictEqual({
published: JSON.parse(await fs.promises.readFile(metadataPath, 'utf8')).map((entry: { instanceId: string }) => entry.instanceId),
lockRemoved: !fs.existsSync(lockDirectoryPath),
}, {
published: [metadata.instanceId],
lockRemoved: true,
});
});
test('fails closed (throws) rather than bypassing a lock a live process holds', async function () {
this.timeout(10_000);
// Our own PID is alive by definition, so recording it (with a different
// instanceId) as the lock owner simulates another live writer holding
// the lock for the entire bounded acquisition window.
const lockDirectoryPath = `${metadataPath}.lock`;
await fs.promises.mkdir(lockDirectoryPath);
await fs.promises.writeFile(join(lockDirectoryPath, 'owner.json'), JSON.stringify({ pid: process.pid, instanceId: 'other-live-writer' }), 'utf8');
const metadata = createLocalAgentHostEndpointMetadata(userDataPath);
await assert.rejects(() => publishLocalAgentHostEndpointMetadata(userDataPath, metadata));
assert.deepStrictEqual({
metadataFileWritten: fs.existsSync(metadataPath),
lockStillHeld: fs.existsSync(lockDirectoryPath),
}, {
metadataFileWritten: false,
lockStillHeld: true,
});
await fs.promises.rm(lockDirectoryPath, { recursive: true, force: true });
});
if (process.platform !== 'win32') {
test('writes owner-only metadata permissions', async () => {
const metadata = createLocalAgentHostEndpointMetadata(userDataPath);