Skip to main content

netget/server/npm/
actions.rs

1//! NPM protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::EventType;
8use crate::state::app_state::AppState;
9use anyhow::{Context, Result};
10use serde_json::json;
11use std::sync::LazyLock;
12use tracing::debug;
13
14use crate::protocol::log_template::LogTemplate;
15
16// NPM event type constants. `get_npm_event_types()` returns clones of these, so the docs, the
17// event-handler action catalog and the list `call_llm` advertises to the model can never drift.
18//
19// Every event carries the reply action(s) that endpoint can actually produce, plus `npm_error`:
20// `call_llm` prompts the model with the *event's* action list, so an event that declares none
21// leaves the model with only set_memory/show_message and every npm action it returns is rejected.
22pub static NPM_PACKAGE_REQUEST: LazyLock<EventType> = LazyLock::new(|| {
23    EventType::new(
24        "NPM_PACKAGE_REQUEST",
25        "Triggered when a client requests package metadata (GET /{package})",
26        json!({
27            "type": "npm_package_metadata",
28            "metadata": {"name": "express", "version": "4.18.2", "description": "Fast web framework"}
29        }),
30    )
31    .with_actions(vec![package_metadata_action(), npm_error_action()])
32});
33
34pub static NPM_TARBALL_REQUEST: LazyLock<EventType> = LazyLock::new(|| {
35    EventType::new(
36        "NPM_TARBALL_REQUEST",
37        "A client is downloading a package tarball (GET /{package}/-/{tarball}). Answer with \
38         npm_package_tarball only if the base64-encoded .tgz was given to you — it is binary \
39         and cannot be invented. Otherwise answer npm_error with 404.",
40        json!({
41            "type": "npm_package_tarball",
42            "tarball_data": "H4sIAI0lgmoC/+3NvQrCMBSG4cxehZxZYxJrB+8myKHUn7Q0tYt470YdBGcRwfdZ3sO3nD7uDrHRVf+s3ecumQ9zRV1VjxbvdW7jX/d9974Owcyd+YJzHuNQ3pv/dJEUTypbSTo2Oi6P7aSykEmH3Hap7N4Gu5brzAAAAAAAAAAAAAAAAAAAfskNxBOA2gAoAAA="
43        }),
44    )
45    .with_actions(vec![package_tarball_action(), npm_error_action()])
46});
47
48pub static NPM_LIST_REQUEST: LazyLock<EventType> = LazyLock::new(|| {
49    EventType::new(
50        "NPM_LIST_REQUEST",
51        "Triggered when a client requests the package listing (GET /-/all)",
52        json!({"type": "npm_package_list", "packages": {"express": {"version": "4.18.2"}}}),
53    )
54    .with_actions(vec![package_list_action(), npm_error_action()])
55});
56
57pub static NPM_SEARCH_REQUEST: LazyLock<EventType> = LazyLock::new(|| {
58    EventType::new(
59        "NPM_SEARCH_REQUEST",
60        "Triggered when a client searches for packages (GET /-/v1/search?text=...)",
61        json!({"type": "npm_package_search", "results": {"objects": [], "total": 0}}),
62    )
63    .with_actions(vec![package_search_action(), npm_error_action()])
64});
65
66/// NPM protocol action handler
67pub struct NpmProtocol {}
68
69impl NpmProtocol {
70    pub fn new() -> Self {
71        Self {}
72    }
73}
74
75// Implement Protocol trait (common functionality)
76impl Protocol for NpmProtocol {
77    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
78        vec![]
79    }
80    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
81        vec![
82            package_metadata_action(),
83            package_tarball_action(),
84            package_list_action(),
85            package_search_action(),
86            npm_error_action(),
87        ]
88    }
89    fn protocol_name(&self) -> &'static str {
90        "NPM"
91    }
92    fn get_event_types(&self) -> Vec<EventType> {
93        get_npm_event_types()
94    }
95    fn stack_name(&self) -> &'static str {
96        "ETH>IP>TCP>HTTP>NPM"
97    }
98    fn keywords(&self) -> Vec<&'static str> {
99        vec!["npm"]
100    }
101    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
102        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
103
104        ProtocolMetadataV2::builder()
105                .state(DevelopmentState::Beta)
106                .implementation("hyper HTTP server with NPM registry endpoints")
107                .llm_control("LLM controls package metadata, tarballs, listings, and search results")
108                .e2e_testing("The real npm CLI, in tests/server/npm/e2e_test.rs::test_npm_with_real_cli, which is not #[ignore]d: `npm view --json` resolves netget-test-pkg@1.0.0 from the packument this server serves, and `npm install` then downloads the tarball, verifies its integrity and unpacks it into node_modules/. Both are asserted; the test also fails rather than skipping if the npm CLI is absent.")
109                .notes("Implements NPM registry protocol: package metadata (GET /{package}), tarballs (GET /{package}/-/{tarball}), listing (GET /-/all), and search (GET /-/v1/search). Beta because npm itself completes both a metadata resolution and a full install against it - not merely that an HTTP client got a 200.")
110                .request_only("The npm registry is HTTP request/response; a server cannot send a peer anything unprompted")
111                .build()
112    }
113    fn description(&self) -> &'static str {
114        "NPM registry server with LLM-controlled package responses"
115    }
116    fn example_prompt(&self) -> &'static str {
117        "Start an NPM registry on port 4873 that serves express package"
118    }
119    fn group_name(&self) -> &'static str {
120        "Package Management"
121    }
122
123    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
124        use crate::llm::actions::StartupExamples;
125
126        StartupExamples::new(
127            // LLM mode
128            json!({
129                "type": "open_server",
130                "port": 4873,
131                "base_stack": "npm",
132                "instruction": "NPM registry server. Serve 'express' package with version 4.18.2. Return package metadata for GET /{package} and search results for /-/v1/search."
133            }),
134            // Script mode
135            json!({
136                "type": "open_server",
137                "port": 4873,
138                "base_stack": "npm",
139                "event_handlers": [{
140                    "event_pattern": "NPM_PACKAGE_REQUEST",
141                    "handler": {
142                        "type": "script",
143                        "language": "python",
144                        "code": "return {type='npm_package_metadata', metadata={name='express', version='4.18.2', description='Fast web framework', versions={['4.18.2']={name='express', version='4.18.2'}}}}"
145                    }
146                }, {
147                    "event_pattern": "NPM_SEARCH_REQUEST",
148                    "handler": {
149                        "type": "script",
150                        "language": "python",
151                        "code": "return {type='npm_package_search', results={objects={{package={name='express', version='4.18.2'}}}, total=1}}"
152                    }
153                }]
154            }),
155            // Static mode
156            json!({
157                "type": "open_server",
158                "port": 4873,
159                "base_stack": "npm",
160                "event_handlers": [{
161                    "event_pattern": "NPM_PACKAGE_REQUEST",
162                    "handler": {
163                        "type": "static",
164                        "actions": [{
165                            "type": "npm_package_metadata",
166                            "metadata": {
167                                "name": "express",
168                                "version": "4.18.2",
169                                "description": "Fast, unopinionated, minimalist web framework"
170                            }
171                        }]
172                    }
173                }]
174            }),
175        )
176    }
177}
178
179// Implement Server trait (server-specific functionality)
180impl Server for NpmProtocol {
181    fn spawn(
182        &self,
183        ctx: crate::protocol::SpawnContext,
184    ) -> std::pin::Pin<
185        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
186    > {
187        Box::pin(async move {
188            use crate::server::npm::NpmServer;
189            NpmServer::spawn_with_llm_actions(
190                ctx.legacy_listen_addr(),
191                ctx.llm_client,
192                ctx.state,
193                ctx.status_tx,
194                ctx.server_id,
195            )
196            .await
197        })
198    }
199    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
200        let action_type = action
201            .get("type")
202            .and_then(|v| v.as_str())
203            .context("Missing 'type' field in action")?;
204
205        match action_type {
206            "npm_package_metadata" => self.execute_package_metadata(action),
207            "npm_package_tarball" => self.execute_package_tarball(action),
208            "npm_package_list" => self.execute_package_list(action),
209            "npm_package_search" => self.execute_package_search(action),
210            "npm_error" => self.execute_npm_error(action),
211            _ => Err(anyhow::anyhow!("Unknown NPM action: {}", action_type)),
212        }
213    }
214}
215
216impl NpmProtocol {
217    fn execute_package_metadata(&self, action: serde_json::Value) -> Result<ActionResult> {
218        let metadata = action.get("metadata").context("Missing 'metadata' field")?;
219
220        debug!("NPM package metadata response");
221
222        Ok(ActionResult::Custom {
223            name: "npm_package_metadata".to_string(),
224            data: json!({
225                "metadata": metadata
226            }),
227        })
228    }
229
230    fn execute_package_tarball(&self, action: serde_json::Value) -> Result<ActionResult> {
231        let tarball_data = action
232            .get("tarball_data")
233            .and_then(|v| v.as_str())
234            .context("Missing 'tarball_data' field")?;
235
236        debug!("NPM package tarball response");
237
238        Ok(ActionResult::Custom {
239            name: "npm_package_tarball".to_string(),
240            data: json!({
241                "tarball_data": tarball_data
242            }),
243        })
244    }
245
246    fn execute_package_list(&self, action: serde_json::Value) -> Result<ActionResult> {
247        let packages = action.get("packages").context("Missing 'packages' field")?;
248
249        debug!("NPM package list response");
250
251        Ok(ActionResult::Custom {
252            name: "npm_package_list".to_string(),
253            data: json!({
254                "packages": packages
255            }),
256        })
257    }
258
259    fn execute_package_search(&self, action: serde_json::Value) -> Result<ActionResult> {
260        let results = action.get("results").context("Missing 'results' field")?;
261
262        debug!("NPM package search response");
263
264        Ok(ActionResult::Custom {
265            name: "npm_package_search".to_string(),
266            data: json!({
267                "results": results
268            }),
269        })
270    }
271
272    fn execute_npm_error(&self, action: serde_json::Value) -> Result<ActionResult> {
273        let error_message = action
274            .get("error")
275            .and_then(|v| v.as_str())
276            .unwrap_or("Unknown error");
277
278        // `as u16` wraps: a `status_code` of 65736 is 200, so `npm_error` — the model's only
279        // way to refuse a request — told the client the fetch had succeeded. Refuse the
280        // out-of-range value rather than clamping it, and name the range for the repair loop.
281        let status_code = match action.get("status_code") {
282            None => 500,
283            Some(v) if v.is_null() => 500,
284            Some(v) => {
285                let raw = v.as_u64().ok_or_else(|| {
286                    anyhow::anyhow!("npm_error 'status_code' must be a number, got {v}")
287                })?;
288                if !(100..=599).contains(&raw) {
289                    return Err(anyhow::anyhow!(
290                        "npm_error 'status_code' {raw} is not an HTTP status code; use 100-599 \
291                         (404 for an unknown package, 403 for a forbidden one, 500 for a \
292                         registry fault)"
293                    ));
294                }
295                raw as u16
296            }
297        };
298
299        debug!("NPM error response: {} ({})", error_message, status_code);
300
301        Ok(ActionResult::Custom {
302            name: "npm_error".to_string(),
303            data: json!({
304                "error": error_message,
305                "status_code": status_code
306            }),
307        })
308    }
309}
310
311// Action definitions
312fn package_metadata_action() -> ActionDefinition {
313    ActionDefinition {
314        name: "npm_package_metadata".to_string(),
315        description: "Return NPM package metadata (package.json manifest)".to_string(),
316        parameters: vec![Parameter {
317            name: "metadata".to_string(),
318            type_hint: "object".to_string(),
319            description:
320                "Package metadata JSON object with name, version, description, dependencies, etc."
321                    .to_string(),
322            required: true,
323        }],
324        example: json!({
325            "type": "npm_package_metadata",
326            "metadata": {
327                "name": "express",
328                "version": "4.18.2",
329                "description": "Fast, unopinionated, minimalist web framework"
330            }
331        }),
332        log_template: Some(
333            LogTemplate::new()
334                .with_info("-> NPM package metadata")
335                .with_debug("NPM npm_package_metadata: returned package info"),
336        ),
337    }
338}
339
340fn package_tarball_action() -> ActionDefinition {
341    ActionDefinition {
342        name: "npm_package_tarball".to_string(),
343        description: "Return the package tarball (.tgz). A tarball is binary, so base64 is the \
344                      only faithful form — but that also makes it the one npm response you \
345                      cannot invent: send it only when the encoded bytes were given to you. \
346                      With no tarball to serve, answer npm_error (404) instead."
347            .to_string(),
348        parameters: vec![Parameter {
349            name: "tarball_data".to_string(),
350            type_hint: "string".to_string(),
351            description: "The complete .tgz contents, base64-encoded. It is decoded before it \
352                          reaches the client, so it must be valid base64 in full: an \
353                          abbreviation ending in \"...\" is refused with a 500, not sent."
354                .to_string(),
355            required: true,
356        }],
357        // A real, decodable base64 string (a 32-byte gzip stream), not an elided one.
358        // The previous "H4sIAAAAAAAAA..." example was itself undecodable, so a model
359        // that copied its shape produced exactly the malformed answer that used to be
360        // served as an empty 200.
361        example: json!({
362            "type": "npm_package_tarball",
363            "tarball_data": "H4sIAI0lgmoC/+3NvQrCMBSG4cxehZxZYxJrB+8myKHUn7Q0tYt470YdBGcRwfdZ3sO3nD7uDrHRVf+s3ecumQ9zRV1VjxbvdW7jX/d9974Owcyd+YJzHuNQ3pv/dJEUTypbSTo2Oi6P7aSykEmH3Hap7N4Gu5brzAAAAAAAAAAAAAAAAAAAfskNxBOA2gAoAAA="
364        }),
365        log_template: Some(
366            LogTemplate::new()
367                .with_info("-> NPM tarball ({tarball_data_len}B)")
368                .with_debug("NPM npm_package_tarball: sending tarball data"),
369        ),
370    }
371}
372
373fn package_list_action() -> ActionDefinition {
374    ActionDefinition {
375        name: "npm_package_list".to_string(),
376        description: "Return list of all available NPM packages".to_string(),
377        parameters: vec![Parameter {
378            name: "packages".to_string(),
379            type_hint: "object".to_string(),
380            description: "JSON object mapping package names to their metadata".to_string(),
381            required: true,
382        }],
383        example: json!({
384            "type": "npm_package_list",
385            "packages": {
386                "express": {"version": "4.18.2"},
387                "lodash": {"version": "4.17.21"}
388            }
389        }),
390        log_template: Some(
391            LogTemplate::new()
392                .with_info("-> NPM package list")
393                .with_debug("NPM npm_package_list: listing packages"),
394        ),
395    }
396}
397
398fn package_search_action() -> ActionDefinition {
399    ActionDefinition {
400        name: "npm_package_search".to_string(),
401        description: "Return NPM package search results".to_string(),
402        parameters: vec![Parameter {
403            name: "results".to_string(),
404            type_hint: "object".to_string(),
405            description: "Search results JSON object with objects array and total count"
406                .to_string(),
407            required: true,
408        }],
409        example: json!({
410            "type": "npm_package_search",
411            "results": {
412                "objects": [
413                    {"package": {"name": "express", "version": "4.18.2"}}
414                ],
415                "total": 1
416            }
417        }),
418        log_template: Some(
419            LogTemplate::new()
420                .with_info("-> NPM search results")
421                .with_debug("NPM npm_package_search: returning search results"),
422        ),
423    }
424}
425
426fn npm_error_action() -> ActionDefinition {
427    ActionDefinition {
428        name: "npm_error".to_string(),
429        description: "Return an NPM error response".to_string(),
430        parameters: vec![
431            Parameter {
432                name: "error".to_string(),
433                type_hint: "string".to_string(),
434                description: "Error message".to_string(),
435                required: true,
436            },
437            Parameter {
438                name: "status_code".to_string(),
439                type_hint: "number".to_string(),
440                description: "HTTP status code (default: 500)".to_string(),
441                required: false,
442            },
443        ],
444        example: json!({
445            "type": "npm_error",
446            "error": "Package not found",
447            "status_code": 404
448        }),
449        log_template: Some(
450            LogTemplate::new()
451                .with_info("-> NPM error {status_code}: {error}")
452                .with_debug("NPM npm_error: status={status_code} error={error}"),
453        ),
454    }
455}
456
457/// Get NPM-specific event types
458///
459/// These are clones of the constants `mod.rs` passes to `call_llm`, so the documented event
460/// catalog and the action list the model is actually offered cannot diverge.
461fn get_npm_event_types() -> Vec<EventType> {
462    vec![
463        NPM_PACKAGE_REQUEST.clone(),
464        NPM_TARBALL_REQUEST.clone(),
465        NPM_LIST_REQUEST.clone(),
466        NPM_SEARCH_REQUEST.clone(),
467    ]
468}