Skip to main content

netget/client/npm/
mod.rs

1//! NPM Registry client implementation
2pub mod actions;
3
4pub use actions::NpmClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info, warn};
11
12use crate::client::http_fetch::FetchClient;
13use crate::client::llm_budget::call_llm_for_client;
14use crate::client::npm::actions::{
15    NPM_CLIENT_PACKAGE_INFO_RECEIVED_EVENT, NPM_CLIENT_SEARCH_RESULTS_RECEIVED_EVENT,
16};
17use crate::llm::actions::client_trait::{Client, ClientActionResult};
18use crate::llm::ollama_client::OllamaClient;
19use crate::llm::ClientLlmResult;
20use crate::logging::emit::Log;
21use crate::protocol::Event;
22use crate::state::app_state::AppState;
23use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
24use crate::state::{AccessLogOwner, ClientId, ClientStatus};
25
26/// One completed package-info fetch.
27///
28/// Split out of [`NpmClient::get_package_info`] so the injected-command loop can await the
29/// registry round-trip - and report a truthful outcome - without also awaiting the LLM call
30/// the `npm_package_info_received` event triggers.
31pub struct NpmPackageInfo {
32    pub package_name: String,
33    pub version: String,
34    pub description: String,
35    pub versions: Vec<String>,
36    pub dist: Option<serde_json::Value>,
37}
38
39/// One completed package search, split from its event for the same reason.
40pub struct NpmSearchResults {
41    pub query: String,
42    pub results: Vec<serde_json::Value>,
43    pub total: u64,
44}
45
46/// What one executed action did.
47enum Applied {
48    /// The action ran; `detail` says what it did.
49    Executed(String),
50    /// The action asked to end the session.
51    Disconnect,
52}
53
54/// The one `reqwest::Client` this protocol uses, built once.
55///
56/// Building a client is a **blocking** operation: it sets up the rustls stack and
57/// loads the platform root store, which on macOS reads the keychain through
58/// Security.framework, synchronously and serialised across processes. Every request
59/// here used to build a fresh one on the async runtime, which parks a tokio worker —
60/// the systemic defect `CLAUDE.md` records as having stalled a whole client runtime.
61///
62/// So: `OnceCell`, and `spawn_blocking` for the build itself.
63#[cfg(not(target_arch = "wasm32"))]
64static SHARED_HTTP_CLIENT: tokio::sync::OnceCell<reqwest::Client> =
65    tokio::sync::OnceCell::const_new();
66
67#[cfg(not(target_arch = "wasm32"))]
68async fn reqwest_client() -> Result<reqwest::Client> {
69    SHARED_HTTP_CLIENT
70        .get_or_try_init(|| async {
71            tokio::task::spawn_blocking(|| {
72                reqwest::Client::builder()
73                    .timeout(std::time::Duration::from_secs(120))
74                    .user_agent("NetGet NPM Client/1.0")
75                    .build()
76            })
77            .await
78            .context("building the HTTP client panicked")?
79            .context("failed to build the HTTP client")
80        })
81        .await
82        .cloned()
83}
84
85/// The HTTP client every request here uses: the shared reqwest client natively
86/// ([`reqwest_client`]), and in the browser build the hyper HTTP/1.1 transport over the page's
87/// virtual loopback ([`crate::client::http_fetch`]) with the same timeout and User-Agent. The body bound is the 64 MiB tarball cap (`MAX_TARBALL_BYTES`) rather than the transport's 8 MiB default, so the protocol's own bound is the one that governs.
88async fn shared_http_client() -> Result<FetchClient> {
89    #[cfg(not(target_arch = "wasm32"))]
90    let client = FetchClient::from_reqwest(reqwest_client().await?);
91    #[cfg(target_arch = "wasm32")]
92    let client = FetchClient::transport(std::time::Duration::from_secs(120))
93        .with_user_agent("NetGet NPM Client/1.0")
94        .with_max_body(MAX_TARBALL_BYTES as usize);
95    Ok(client)
96}
97
98/// Ceiling on a tarball fetched by `download_tarball`.
99///
100/// The tarball URL comes out of the registry's own packument, so its size is
101/// chosen by whatever this client was pointed at rather than by us. 64 MiB is
102/// well past any real npm package and far short of exhausting the process.
103const MAX_TARBALL_BYTES: u64 = 64 * 1024 * 1024;
104
105/// Turn an operator- or model-supplied address into a registry base URL, **or refuse**.
106///
107/// There is no default, and that is deliberate. A client that loses its target must fail
108/// rather than reach the real service: an address that merely failed to arrive is
109/// indistinguishable, further down, from one the caller deliberately omitted, so a fallback
110/// turns "I forgot to say where" into "talk to the public registry" with nothing logged that
111/// an operator would read as a problem. It is the DynamoDB shape from the root `CLAUDE.md`,
112/// milder only because nothing here is signed with anybody's credentials. `openai`'s
113/// `api_base_for` faced the identical choice and refuses; this is the same exit.
114///
115/// Serving the public registry is a legitimate thing to ask this client for — say so, with
116/// `remote_addr: "registry.npmjs.org"`, and it is honoured. Arriving there because nobody said
117/// otherwise is not.
118///
119/// A scheme is left as given; without one, `https://` is added natively — and `http://` in
120/// the browser build, whose transport speaks nothing else and whose registrys are servers on the
121/// page's virtual network — rather than the address being discarded, which was an earlier
122/// incarnation of this same bug (`127.0.0.1:8080` silently
123/// became the public registry).
124fn resolve_registry_url(remote_addr: &str) -> Result<String> {
125    let trimmed = remote_addr.trim().trim_end_matches('/');
126    if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
127        return Ok(trimmed.to_string());
128    }
129    // `"npm"` is the protocol's own name, not an address — it reaches here when a caller fills
130    // `remote_addr` with the thing it is starting rather than the thing it is talking to.
131    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("npm") {
132        anyhow::bail!(
133            "NPM client needs a registry in remote_addr (for example \
134             http://127.0.0.1:4873 for a local Verdaccio, or name the public registry \
135             explicitly if that is what you want); refusing to reach the public npm registry \
136             by omission"
137        );
138    }
139    let scheme = if cfg!(target_arch = "wasm32") {
140        "http"
141    } else {
142        "https"
143    };
144    Ok(format!("{scheme}://{trimmed}"))
145}
146
147/// NPM Registry client that queries packages
148pub struct NpmClient;
149
150impl NpmClient {
151    /// Connect to NPM registry with integrated LLM actions
152    pub async fn connect_with_llm_actions(
153        remote_addr: String,
154        llm_client: OllamaClient,
155        app_state: Arc<AppState>,
156        status_tx: mpsc::UnboundedSender<String>,
157        client_id: ClientId,
158    ) -> Result<SocketAddr> {
159        // For NPM, "connection" is logical - we're accessing a REST API.
160        //
161        // A scheme-less address used to be **discarded** and silently replaced with
162        // the public registry, so an operator who typed `127.0.0.1:8080` had their
163        // requests sent there with no warning — and this protocol's own startup
164        // example (`"remote_addr": "registry.npmjs.org"`) takes exactly that branch.
165        // A host is now given the `https://` it was missing, and an address that is
166        // missing entirely is an error rather than a silent trip to the vendor.
167        let registry_url = resolve_registry_url(&remote_addr)?;
168        // In the browser build the transport has no TLS: an `https://` registry is refused here,
169        // with the reason, rather than on the first request.
170        crate::client::http_fetch::check_url(&registry_url)?;
171
172        info!("NPM client {} initialized for {}", client_id, registry_url);
173
174        // No client is built here. One used to be, bound to `_http_client` and
175        // dropped immediately — paying the blocking rustls/keychain cost at connect
176        // for a value nothing read. The shared client above is built on first use.
177
178        // Store client in protocol_data
179        app_state
180            .with_client_mut(client_id, |client| {
181                client
182                    .set_protocol_field("npm_client".to_string(), serde_json::json!("initialized"));
183                client.set_protocol_field(
184                    "registry_url".to_string(),
185                    serde_json::json!(registry_url),
186                );
187            })
188            .await;
189
190        // Update status
191        app_state
192            .update_client_status(client_id, ClientStatus::Connected)
193            .await;
194        Log::new(Some(&status_tx)).info(format!(
195            "NPM client {} ready for {}",
196            client_id, registry_url
197        ));
198        let _ = status_tx.send("__UPDATE_UI__".to_string());
199
200        // Command channel for injected actions (the dashboard's [ send ] row).
201        // Registered as soon as the client is usable and *before* anything that can
202        // block for a human: a dashboard-created client defaults to a `*` -> manual
203        // routing rule, and [ send ] must work while such an event is parked.
204        let command_rx =
205            crate::client::command_support::register_command_channel(&app_state, client_id).await;
206
207        // The command loop replaces the old 5s "is the client gone yet?" poll: when the
208        // client is removed its handle is dropped, the channel closes and `recv()`
209        // returns None, so the loop notices removal immediately instead of up to 5s later.
210        // Registered with AppState so stop_client can abort it —
211        // dropping a JoinHandle only detaches it in Tokio.
212        let task_registrar = app_state.clone();
213        let connected_llm_client = llm_client.clone();
214        let connected_status_tx = status_tx.clone();
215        let task_handle = tokio::spawn(Self::command_loop(
216            command_rx, client_id, app_state, llm_client, status_tx,
217        ));
218        task_registrar
219            .register_client_task(client_id, task_handle)
220            .await;
221
222        // Raise the connected event.
223        //
224        // It was declared and never emitted, so the model was never consulted when a
225        // NPM client came up -- and this protocol's own
226        // `get_startup_examples()` shows a `npm_connected` handler, which could not
227        // possibly have fired.
228        //
229        // Raised from its own registered task rather than inline: a dashboard-created
230        // client defaults to a `*` -> manual rule, and awaiting a parked answer here would
231        // block client creation itself.
232        let connected_state = task_registrar.clone();
233        let connected_llm = connected_llm_client;
234        let connected_status = connected_status_tx;
235        let connected = tokio::spawn(async move {
236            let Some(instruction) = connected_state.get_instruction_for_client(client_id).await
237            else {
238                return;
239            };
240            let protocol = crate::client::npm::actions::NpmClientProtocol::new();
241            let event = Event::new(
242                &crate::client::npm::actions::NPM_CLIENT_CONNECTED_EVENT,
243                serde_json::json!({ "registry_url": registry_url.clone(), }),
244            );
245            match crate::client::llm_budget::call_llm_for_client(
246                &connected_llm,
247                &connected_state,
248                client_id.to_string(),
249                &instruction,
250                "",
251                Some(&event),
252                &protocol,
253                &connected_status,
254            )
255            .await
256            {
257                Ok(result) => {
258                    if let Some(mem) = result.memory_updates {
259                        connected_state.set_memory_for_client(client_id, mem).await;
260                    }
261                    Self::run_follow_ups(
262                        client_id,
263                        result.actions,
264                        &connected_state,
265                        &connected_status,
266                    )
267                    .await;
268                }
269                Err(e) => error!(
270                    "NPM client {} LLM error on connected event: {}",
271                    client_id, e
272                ),
273            }
274        });
275        task_registrar
276            .register_client_task(client_id, connected)
277            .await;
278
279        // Return a dummy local address (NPM is HTTP-based)
280        Ok("0.0.0.0:0".parse().unwrap())
281    }
282
283    /// Get information about a package and hand it to the LLM.
284    pub async fn get_package_info(
285        client_id: ClientId,
286        package_name: String,
287        version: String,
288        app_state: Arc<AppState>,
289        llm_client: OllamaClient,
290        status_tx: mpsc::UnboundedSender<String>,
291    ) -> Result<()> {
292        let info = Self::perform_get_package_info(
293            client_id,
294            package_name,
295            version,
296            &app_state,
297            &status_tx,
298        )
299        .await?;
300        Self::notify_package_info(client_id, info, app_state, llm_client, status_tx).await;
301        Ok(())
302    }
303
304    /// Fetch package metadata only. No LLM involvement, so a caller can await this and
305    /// know exactly what the registry answered.
306    pub async fn perform_get_package_info(
307        client_id: ClientId,
308        package_name: String,
309        version: String,
310        app_state: &AppState,
311        status_tx: &mpsc::UnboundedSender<String>,
312    ) -> Result<NpmPackageInfo> {
313        // Get registry URL from client
314        let registry_url = app_state
315            .with_client_mut(client_id, |client| {
316                client
317                    .get_protocol_field("registry_url")
318                    .and_then(|v| v.as_str())
319                    .map(|s| s.to_string())
320            })
321            .await
322            .flatten()
323            .context("No registry URL found")?;
324
325        // Encode package name for URL (handles scoped packages like @types/node)
326        let encoded_name = package_name.replace("/", "%2f");
327
328        let url = if version == "latest" {
329            format!("{}/{}", registry_url, encoded_name)
330        } else {
331            format!("{}/{}/{}", registry_url, encoded_name, version)
332        };
333
334        info!(
335            "NPM client {} getting package info: {} ({})",
336            client_id, package_name, version
337        );
338
339        // Build HTTP client
340        let http_client = shared_http_client().await?;
341
342        // Make request
343        match http_client.get(&url).send().await {
344            Ok(response) => {
345                let status = response.status();
346
347                if !status.is_success() {
348                    let error_text = response
349                        .text()
350                        .await
351                        .unwrap_or_else(|_| "Unknown error".to_string());
352                    Log::new(Some(status_tx)).error(format!(
353                        "NPM client {} failed to get package {}: {} - {}",
354                        client_id, package_name, status, error_text
355                    ));
356                    return Err(anyhow::anyhow!("NPM request failed: {}", status));
357                }
358
359                // Parse JSON response
360                let package_data: serde_json::Value = response
361                    .json()
362                    .await
363                    .context("Failed to parse NPM response")?;
364
365                info!(
366                    "NPM client {} received package info for {}",
367                    client_id, package_name
368                );
369
370                // Extract relevant fields
371                let description = package_data
372                    .get("description")
373                    .and_then(|v| v.as_str())
374                    .unwrap_or("")
375                    .to_string();
376
377                let dist_tags = package_data.get("dist-tags");
378                let latest_version = dist_tags
379                    .and_then(|dt| dt.get("latest"))
380                    .and_then(|v| v.as_str())
381                    .unwrap_or("unknown")
382                    .to_string();
383
384                let versions = package_data
385                    .get("versions")
386                    .and_then(|v| v.as_object())
387                    .map(|obj| obj.keys().cloned().collect::<Vec<_>>())
388                    .unwrap_or_default();
389
390                let dist = if version == "latest" {
391                    package_data
392                        .get("dist-tags")
393                        .and_then(|dt| dt.get("latest"))
394                        .and_then(|lv| {
395                            package_data
396                                .get("versions")
397                                .and_then(|vs| vs.get(lv.as_str().unwrap_or("")))
398                        })
399                        .and_then(|v| v.get("dist"))
400                        .cloned()
401                } else {
402                    package_data.get("dist").cloned()
403                };
404
405                let resolved_version = if version == "latest" {
406                    latest_version
407                } else {
408                    version
409                };
410
411                Ok(NpmPackageInfo {
412                    package_name,
413                    version: resolved_version,
414                    description,
415                    versions,
416                    dist,
417                })
418            }
419            Err(e) => {
420                Log::new(Some(status_tx))
421                    .error(format!("NPM client {} request failed: {}", client_id, e));
422                Err(e)
423            }
424        }
425    }
426
427    /// Raise `npm_package_info_received` for a completed fetch.
428    /// Run the actions the model returned for a response event.
429    ///
430    /// They were discarded at both notify sites (`actions: _`), so answering
431    /// npm_package_info_received or npm_search_results did nothing at all -- the whole
432    /// point of raising those events.
433    ///
434    /// Everything here goes through the `perform_*` round-trips, which raise no event.
435    /// That bounds the loop -- a follow-up cannot trigger another response event and drive
436    /// the model in circles -- and it is also the only shape that compiles, since routing
437    /// back through the notifying entry points makes notify -> perform -> notify a
438    /// self-referential async chain rustc cannot prove `Send`.
439    async fn run_follow_ups(
440        client_id: ClientId,
441        actions: Vec<serde_json::Value>,
442        app_state: &Arc<AppState>,
443        status_tx: &mpsc::UnboundedSender<String>,
444    ) {
445        use crate::llm::actions::client_trait::{Client, ClientActionResult};
446        let protocol = crate::client::npm::actions::NpmClientProtocol::new();
447        for action in actions {
448            // `Disconnect` used to be filtered out here by a `let ... else { continue }`
449            // that only admitted `Custom`, so a model answering a response event with
450            // `{"type": "disconnect"}` — which this protocol's own `get_startup_examples()`
451            // documents as a static handler — did nothing at all.
452            let (name, data) = match protocol.execute_action(action.clone()) {
453                Ok(ClientActionResult::Custom { name, data }) => (name, data),
454                Ok(ClientActionResult::Disconnect) => {
455                    info!(
456                        "NPM client {} disconnecting on the model's answer",
457                        client_id
458                    );
459                    app_state
460                        .update_client_status(client_id, ClientStatus::Disconnected)
461                        .await;
462                    return;
463                }
464                Ok(_) => continue,
465                Err(e) => {
466                    warn!(
467                        "NPM client {} could not execute the model's follow-up action: {}",
468                        client_id, e
469                    );
470                    continue;
471                }
472            };
473            let outcome: Result<()> = match name.as_str() {
474                "npm_get_package" => Self::perform_get_package_info(
475                    client_id,
476                    data["package_name"]
477                        .as_str()
478                        .unwrap_or_default()
479                        .to_string(),
480                    data["version"].as_str().unwrap_or("latest").to_string(),
481                    app_state,
482                    status_tx,
483                )
484                .await
485                .map(|_| ()),
486                "npm_search" => Self::perform_search_packages(
487                    client_id,
488                    data["query"].as_str().unwrap_or_default().to_string(),
489                    data["limit"].as_u64().unwrap_or(10),
490                    app_state,
491                    status_tx,
492                )
493                .await
494                .map(|_| ()),
495                // `download_tarball` raises no event and makes no LLM call, so it is
496                // safe here — it was reaching the catch-all below and being logged as
497                // "has no non-notifying path", which was simply untrue.
498                "npm_download_tarball" => Self::download_tarball(
499                    client_id,
500                    data["package_name"]
501                        .as_str()
502                        .unwrap_or_default()
503                        .to_string(),
504                    data["version"].as_str().unwrap_or("latest").to_string(),
505                    app_state.clone(),
506                    status_tx.clone(),
507                )
508                .await
509                .map(|_| ()),
510                other => {
511                    info!(
512                        "NPM client {} follow-up '{}' has no non-notifying path; skipped",
513                        client_id, other
514                    );
515                    Ok(())
516                }
517            };
518            if let Err(e) = outcome {
519                error!("NPM client {} follow-up action failed: {}", client_id, e);
520            }
521        }
522    }
523
524    async fn notify_package_info(
525        client_id: ClientId,
526        info: NpmPackageInfo,
527        app_state: Arc<AppState>,
528        llm_client: OllamaClient,
529        status_tx: mpsc::UnboundedSender<String>,
530    ) {
531        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
532            return;
533        };
534
535        let protocol = Arc::new(crate::client::npm::actions::NpmClientProtocol::new());
536        let event = Event::new(
537            &NPM_CLIENT_PACKAGE_INFO_RECEIVED_EVENT,
538            serde_json::json!({
539                "package_name": info.package_name,
540                "version": info.version,
541                "description": info.description,
542                "versions": info.versions,
543                "dist": info.dist,
544            }),
545        );
546
547        let memory = app_state
548            .get_memory_for_client(client_id)
549            .await
550            .unwrap_or_default();
551
552        match call_llm_for_client(
553            &llm_client,
554            &app_state,
555            client_id.to_string(),
556            &instruction,
557            &memory,
558            Some(&event),
559            protocol.as_ref(),
560            &status_tx,
561        )
562        .await
563        {
564            Ok(ClientLlmResult {
565                actions,
566                memory_updates,
567            }) => {
568                // Update memory
569                if let Some(mem) = memory_updates {
570                    app_state.set_memory_for_client(client_id, mem).await;
571                }
572                Self::run_follow_ups(client_id, actions, &app_state, &status_tx).await;
573            }
574            Err(e) => {
575                error!("LLM error for NPM client {}: {}", client_id, e);
576            }
577        }
578    }
579
580    /// Search for packages and hand the results to the LLM.
581    pub async fn search_packages(
582        client_id: ClientId,
583        query: String,
584        limit: u64,
585        app_state: Arc<AppState>,
586        llm_client: OllamaClient,
587        status_tx: mpsc::UnboundedSender<String>,
588    ) -> Result<()> {
589        let results =
590            Self::perform_search_packages(client_id, query, limit, &app_state, &status_tx).await?;
591        Self::notify_search_results(client_id, results, app_state, llm_client, status_tx).await;
592        Ok(())
593    }
594
595    /// Run the search only. No LLM involvement, so a caller can await this and know
596    /// exactly what the registry answered.
597    pub async fn perform_search_packages(
598        client_id: ClientId,
599        query: String,
600        limit: u64,
601        app_state: &AppState,
602        status_tx: &mpsc::UnboundedSender<String>,
603    ) -> Result<NpmSearchResults> {
604        // Search the registry this client was configured with, not always the public one.
605        // The `registry_url` startup parameter was declared and honoured by every other
606        // verb, but search hardcoded https://registry.npmjs.org/-/v1/search -- so a client
607        // pointed at a private registry (or, in a test, at a loopback listener) silently
608        // queried the public registry instead.
609        let registry_url = app_state
610            .with_client_mut(client_id, |client| {
611                client
612                    .get_protocol_field("registry_url")
613                    .and_then(|v| v.as_str())
614                    .map(|s| s.to_string())
615            })
616            .await
617            .flatten()
618            .context("No registry URL found")?;
619        let search_url = format!("{}/-/v1/search", registry_url.trim_end_matches('/'));
620
621        info!(
622            "NPM client {} searching for: {} (limit: {})",
623            client_id, query, limit
624        );
625
626        // Build HTTP client
627        let http_client = shared_http_client().await?;
628
629        // Build query parameters
630        let url = format!(
631            "{}?text={}&size={}",
632            search_url,
633            urlencoding::encode(&query),
634            limit
635        );
636
637        // Make request
638        match http_client.get(&url).send().await {
639            Ok(response) => {
640                let status = response.status();
641
642                if !status.is_success() {
643                    let error_text = response
644                        .text()
645                        .await
646                        .unwrap_or_else(|_| "Unknown error".to_string());
647                    Log::new(Some(status_tx)).error(format!(
648                        "NPM client {} search failed: {} - {}",
649                        client_id, status, error_text
650                    ));
651                    return Err(anyhow::anyhow!("NPM search failed: {}", status));
652                }
653
654                // Parse JSON response
655                let search_data: serde_json::Value = response
656                    .json()
657                    .await
658                    .context("Failed to parse NPM search response")?;
659
660                let results = search_data.get("objects")
661                    .and_then(|v| v.as_array())
662                    .map(|arr| {
663                        arr.iter().map(|obj| {
664                            let package = obj.get("package");
665                            serde_json::json!({
666                                "name": package.and_then(|p| p.get("name")).and_then(|v| v.as_str()).unwrap_or(""),
667                                "version": package.and_then(|p| p.get("version")).and_then(|v| v.as_str()).unwrap_or(""),
668                                "description": package.and_then(|p| p.get("description")).and_then(|v| v.as_str()).unwrap_or(""),
669                            })
670                        }).collect::<Vec<_>>()
671                    })
672                    .unwrap_or_default();
673
674                let total = search_data
675                    .get("total")
676                    .and_then(|v| v.as_u64())
677                    .unwrap_or(results.len() as u64);
678
679                info!(
680                    "NPM client {} received {} search results",
681                    client_id,
682                    results.len()
683                );
684
685                Ok(NpmSearchResults {
686                    query,
687                    results,
688                    total,
689                })
690            }
691            Err(e) => {
692                Log::new(Some(status_tx))
693                    .error(format!("NPM client {} search failed: {}", client_id, e));
694                Err(e)
695            }
696        }
697    }
698
699    /// Raise `npm_search_results_received` for a completed search.
700    async fn notify_search_results(
701        client_id: ClientId,
702        results: NpmSearchResults,
703        app_state: Arc<AppState>,
704        llm_client: OllamaClient,
705        status_tx: mpsc::UnboundedSender<String>,
706    ) {
707        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
708            return;
709        };
710
711        let protocol = Arc::new(crate::client::npm::actions::NpmClientProtocol::new());
712        let event = Event::new(
713            &NPM_CLIENT_SEARCH_RESULTS_RECEIVED_EVENT,
714            serde_json::json!({
715                "query": results.query,
716                "results": results.results,
717                "total": results.total,
718            }),
719        );
720
721        let memory = app_state
722            .get_memory_for_client(client_id)
723            .await
724            .unwrap_or_default();
725
726        match call_llm_for_client(
727            &llm_client,
728            &app_state,
729            client_id.to_string(),
730            &instruction,
731            &memory,
732            Some(&event),
733            protocol.as_ref(),
734            &status_tx,
735        )
736        .await
737        {
738            Ok(ClientLlmResult {
739                actions,
740                memory_updates,
741            }) => {
742                // Update memory
743                if let Some(mem) = memory_updates {
744                    app_state.set_memory_for_client(client_id, mem).await;
745                }
746                Self::run_follow_ups(client_id, actions, &app_state, &status_tx).await;
747            }
748            Err(e) => {
749                error!("LLM error for NPM client {}: {}", client_id, e);
750            }
751        }
752    }
753
754    /// Fetch a package tarball and report what came back.
755    ///
756    /// **Nothing is written to disk.** This used to end in
757    /// `tokio::fs::write(&output_path, bytes)` with `output_path` taken verbatim from
758    /// the model's action - an arbitrary-file-write driven by LLM output, and a
759    /// straight violation of the rule that a protocol implements no storage. A model
760    /// that merely misunderstood the parameter could overwrite `~/.ssh/authorized_keys`.
761    /// PyPI's equivalent (`perform_download_package`) already fetched and reported
762    /// without persisting, so this is the family's own precedent rather than a new
763    /// design.
764    ///
765    /// What the caller gets instead is the byte count and the integrity string the
766    /// registry advertised, which is what a model can actually reason about.
767    pub async fn download_tarball(
768        client_id: ClientId,
769        package_name: String,
770        version: String,
771        app_state: Arc<AppState>,
772        status_tx: mpsc::UnboundedSender<String>,
773    ) -> Result<String> {
774        // First get package info to find tarball URL
775        let registry_url = app_state
776            .with_client_mut(client_id, |client| {
777                client
778                    .get_protocol_field("registry_url")
779                    .and_then(|v| v.as_str())
780                    .map(|s| s.to_string())
781            })
782            .await
783            .flatten()
784            .context("No registry URL found")?;
785
786        let encoded_name = package_name.replace("/", "%2f");
787        let info_url = format!("{}/{}", registry_url, encoded_name);
788
789        info!(
790            "NPM client {} downloading tarball for {} ({})",
791            client_id, package_name, version
792        );
793
794        let http_client = shared_http_client().await?;
795
796        // Get package info
797        let package_data: serde_json::Value = http_client
798            .get(&info_url)
799            .send()
800            .await?
801            .json()
802            .await
803            .context("Failed to get package info")?;
804
805        // Find the `dist` block for the requested version, and the tarball URL in it.
806        let dist = if version == "latest" {
807            package_data
808                .get("dist-tags")
809                .and_then(|dt| dt.get("latest"))
810                .and_then(|lv| {
811                    package_data
812                        .get("versions")
813                        .and_then(|vs| vs.get(lv.as_str().unwrap_or("")))
814                })
815                .and_then(|v| v.get("dist"))
816        } else {
817            package_data
818                .get("versions")
819                .and_then(|vs| vs.get(&version))
820                .and_then(|v| v.get("dist"))
821        }
822        .context("Could not find a dist block for the requested version")?
823        .clone();
824
825        let tarball_url = dist
826            .get("tarball")
827            .and_then(|t| t.as_str())
828            .context("Could not find tarball URL")?
829            .to_string();
830
831        // The integrity the registry claims for these bytes, reported alongside the
832        // size so the model sees what it was promised as well as what arrived.
833        let advertised = dist
834            .get("integrity")
835            .or_else(|| dist.get("shasum"))
836            .and_then(|v| v.as_str())
837            .unwrap_or("(none advertised)")
838            .to_string();
839
840        info!("NPM client {} downloading from: {}", client_id, tarball_url);
841
842        // Bounded. The URL comes from the registry's own JSON, so its size is chosen
843        // by whatever we are pointed at, and `bytes()` would buffer all of it.
844        // Streaming with a cap means a hostile or merely enormous tarball costs at
845        // most the cap.
846        let mut response = http_client.get(&tarball_url).send().await?;
847        if let Some(len) = response.content_length() {
848            if len > MAX_TARBALL_BYTES {
849                anyhow::bail!(
850                    "{} advertises {} bytes, over the {} byte limit; refusing to download it",
851                    tarball_url,
852                    len,
853                    MAX_TARBALL_BYTES
854                );
855            }
856        }
857        let mut received: u64 = 0;
858        while let Some(chunk) = response.chunk().await? {
859            received += chunk.len() as u64;
860            if received > MAX_TARBALL_BYTES {
861                anyhow::bail!(
862                    "{} exceeded the {} byte limit mid-transfer; aborting",
863                    tarball_url,
864                    MAX_TARBALL_BYTES
865                );
866            }
867        }
868
869        let summary = format!(
870            "{}@{}: {} bytes from {} (registry integrity: {}); not saved - NetGet stores nothing",
871            package_name, version, received, tarball_url, advertised
872        );
873        Log::new(Some(&status_tx)).info(format!(
874            "NPM client {} fetched tarball {}",
875            client_id, summary
876        ));
877
878        Ok(summary)
879    }
880
881    /// Apply one already-parsed action against the live NPM client.
882    ///
883    /// The single place NPM actions are turned into registry traffic, so an action injected
884    /// from the dashboard behaves exactly like one the model produced.
885    ///
886    /// Only the **network** half is awaited. The response event - and the LLM call it makes -
887    /// is raised from its own registered task afterwards, so a `*` -> manual routing rule
888    /// parking that event cannot wedge the command loop for the length of a human's think
889    /// time. The outcome stays truthful because it describes what the registry actually
890    /// answered, which is known before the event is raised.
891    async fn apply_action(
892        client_id: ClientId,
893        result: ClientActionResult,
894        app_state: &Arc<AppState>,
895        llm_client: &OllamaClient,
896        status_tx: &mpsc::UnboundedSender<String>,
897    ) -> Result<Applied> {
898        match result {
899            ClientActionResult::Custom { name, data } => match name.as_str() {
900                "npm_get_package" => {
901                    let package_name = data["package_name"]
902                        .as_str()
903                        .unwrap_or_default()
904                        .to_string();
905                    let version = data["version"].as_str().unwrap_or("latest").to_string();
906                    let requested = format!("{package_name}@{version}");
907
908                    let info = Self::perform_get_package_info(
909                        client_id,
910                        package_name,
911                        version,
912                        app_state,
913                        status_tx,
914                    )
915                    .await?;
916                    let detail = format!(
917                        "get_package_info {requested} -> version {}, {} published version(s)",
918                        info.version,
919                        info.versions.len()
920                    );
921                    Self::spawn_notify(
922                        client_id,
923                        app_state,
924                        Self::notify_package_info(
925                            client_id,
926                            info,
927                            app_state.clone(),
928                            llm_client.clone(),
929                            status_tx.clone(),
930                        ),
931                    )
932                    .await;
933                    Ok(Applied::Executed(detail))
934                }
935                "npm_search" => {
936                    let query = data["query"].as_str().unwrap_or_default().to_string();
937                    let limit = data["limit"].as_u64().unwrap_or(20);
938                    let requested = query.clone();
939
940                    let results = Self::perform_search_packages(
941                        client_id, query, limit, app_state, status_tx,
942                    )
943                    .await?;
944                    let detail = format!(
945                        "search_packages {requested:?} -> {} result(s) of {} total",
946                        results.results.len(),
947                        results.total
948                    );
949                    Self::spawn_notify(
950                        client_id,
951                        app_state,
952                        Self::notify_search_results(
953                            client_id,
954                            results,
955                            app_state.clone(),
956                            llm_client.clone(),
957                            status_tx.clone(),
958                        ),
959                    )
960                    .await;
961                    Ok(Applied::Executed(detail))
962                }
963                "npm_download_tarball" => {
964                    // No split needed: download_tarball raises no event and makes no LLM
965                    // call, so awaiting it awaits nothing but the network.
966                    let package_name = data["package_name"]
967                        .as_str()
968                        .unwrap_or_default()
969                        .to_string();
970                    let version = data["version"].as_str().unwrap_or("latest").to_string();
971                    let summary = Self::download_tarball(
972                        client_id,
973                        package_name,
974                        version,
975                        app_state.clone(),
976                        status_tx.clone(),
977                    )
978                    .await?;
979                    Ok(Applied::Executed(format!("download_tarball {summary}")))
980                }
981                other => Ok(Applied::Executed(format!(
982                    "unknown NPM custom result '{other}' was not executed"
983                ))),
984            },
985            ClientActionResult::Disconnect => Ok(Applied::Disconnect),
986            ClientActionResult::NoAction => Ok(Applied::Executed("no_action".to_string())),
987            ClientActionResult::WaitForMore => Ok(Applied::Executed("wait_for_more".to_string())),
988            ClientActionResult::SendData(_) => Ok(Applied::Executed(
989                "NPM owns no socket; raw send_data cannot be put on the wire".to_string(),
990            )),
991            ClientActionResult::Multiple(_) => Ok(Applied::Executed(
992                "NPM produces no Multiple results; nothing executed".to_string(),
993            )),
994        }
995    }
996
997    /// Raise a response event from its own task, registered so `stop_client` aborts it.
998    async fn spawn_notify(
999        client_id: ClientId,
1000        app_state: &Arc<AppState>,
1001        notify: impl std::future::Future<Output = ()> + Send + 'static,
1002    ) {
1003        let handle = tokio::spawn(notify);
1004        app_state.register_client_task(client_id, handle).await;
1005    }
1006
1007    /// Serve injected commands until the client goes away.
1008    async fn command_loop(
1009        mut command_rx: mpsc::Receiver<ClientCommand>,
1010        client_id: ClientId,
1011        app_state: Arc<AppState>,
1012        llm_client: OllamaClient,
1013        status_tx: mpsc::UnboundedSender<String>,
1014    ) {
1015        use crate::llm::actions::protocol_trait::Protocol;
1016        let protocol = crate::client::npm::actions::NpmClientProtocol::new();
1017
1018        while let Some(command) = command_rx.recv().await {
1019            let action = command.action.clone();
1020            let outcome = match protocol.execute_action(action.clone()) {
1021                Err(e) => Ok(ClientSendOutcome::Rejected {
1022                    error: e.to_string(),
1023                }),
1024                // Never `Sent`: reqwest owns the socket and does not report how many bytes
1025                // the request serialised to, so a byte count here would be invented.
1026                // `Executed` carries what the registry answered instead, which is both
1027                // true and more useful.
1028                Ok(result) => {
1029                    match Self::apply_action(client_id, result, &app_state, &llm_client, &status_tx)
1030                        .await
1031                    {
1032                        Ok(Applied::Executed(detail)) => Ok(ClientSendOutcome::Executed { detail }),
1033                        Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
1034                        Err(e) => Err(e),
1035                    }
1036                }
1037            };
1038
1039            let outcome_json = match &outcome {
1040                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
1041                Err(e) => serde_json::json!({"error": e.to_string()}),
1042            };
1043            app_state
1044                .record_access_log(
1045                    AccessLogOwner::Client(client_id.as_u32()),
1046                    protocol.protocol_name(),
1047                    None,
1048                    "injected_action",
1049                    action,
1050                    vec![outcome_json],
1051                )
1052                .await;
1053
1054            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
1055            if let Err(e) = &outcome {
1056                error!("NPM client {} injected action failed: {}", client_id, e);
1057                let _ = status_tx.send(format!(
1058                    "[WARN] Client {} injected action failed: {}",
1059                    client_id, e
1060                ));
1061            }
1062            let _ = status_tx.send("__UPDATE_UI__".to_string());
1063            crate::client::command_support::reply(command, outcome);
1064
1065            if disconnect {
1066                app_state
1067                    .update_client_status(client_id, ClientStatus::Disconnected)
1068                    .await;
1069                break;
1070            }
1071        }
1072
1073        info!("NPM client {} command loop finished", client_id);
1074        app_state.remove_client_handle(client_id).await;
1075        let _ = status_tx.send("__UPDATE_UI__".to_string());
1076    }
1077}