Skip to main content

kcode_dev_tools/
lib.rs

1//! Session-scoped tools for three managed-source backends.
2
3mod backend;
4mod registry;
5
6use std::error::Error as StdError;
7use std::fmt;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::Duration;
11
12use http::StatusCode;
13use serde_json::Value;
14use zeroize::Zeroizing;
15
16use registry::RegistryState;
17
18pub const CREATE_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/create";
19pub const OPEN_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/open";
20pub const DOCS_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/docs";
21pub const WRITE_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/write";
22pub const WRITE_FILE_FREEFORM_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/write-file-freeform";
23pub const DELETE_FILE_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/delete-file";
24pub const CHECK_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/check";
25pub const PUBLISH_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/publish";
26pub const PREVIEW_WRITE_FILE_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/internal-preview-write-file";
27
28pub const CREATE_WEB_LIB_TOOL: &str = "kcode-web-libs/create";
29pub const OPEN_WEB_LIB_TOOL: &str = "kcode-web-libs/open";
30pub const DOCS_WEB_LIB_TOOL: &str = "kcode-web-libs/docs";
31pub const WRITE_WEB_LIB_TOOL: &str = "kcode-web-libs/write";
32pub const WRITE_FILE_FREEFORM_WEB_LIB_TOOL: &str = "kcode-web-libs/write-file-freeform";
33pub const ATTACH_OBJECT_WEB_LIB_TOOL: &str = "kcode-web-libs/attach-object";
34pub const DELETE_FILE_WEB_LIB_TOOL: &str = "kcode-web-libs/delete-file";
35pub const CHECK_WEB_LIB_TOOL: &str = "kcode-web-libs/check";
36pub const PUBLISH_WEB_LIB_TOOL: &str = "kcode-web-libs/publish";
37pub const PREVIEW_WRITE_FILE_WEB_LIB_TOOL: &str = "kcode-web-libs/internal-preview-write-file";
38
39pub const CREATE_RUST_BIN_TOOL: &str = "kcode-rust-bins/create";
40pub const OPEN_RUST_BIN_TOOL: &str = "kcode-rust-bins/open";
41pub const DOCS_RUST_BIN_TOOL: &str = "kcode-rust-bins/docs";
42pub const WRITE_RUST_BIN_TOOL: &str = "kcode-rust-bins/write";
43pub const WRITE_FILE_FREEFORM_RUST_BIN_TOOL: &str = "kcode-rust-bins/write-file-freeform";
44pub const DELETE_FILE_RUST_BIN_TOOL: &str = "kcode-rust-bins/delete-file";
45pub const CHECK_RUST_BIN_TOOL: &str = "kcode-rust-bins/check";
46pub const PUBLISH_RUST_BIN_TOOL: &str = "kcode-rust-bins/publish";
47pub const CALL_RUST_BIN_TOOL: &str = "kcode-rust-bins/call";
48pub const PREVIEW_WRITE_FILE_RUST_BIN_TOOL: &str = "kcode-rust-bins/internal-preview-write-file";
49
50pub const RUST_LIB_TOOLS: [&str; 8] = [
51    CREATE_RUST_LIB_TOOL,
52    OPEN_RUST_LIB_TOOL,
53    DOCS_RUST_LIB_TOOL,
54    WRITE_RUST_LIB_TOOL,
55    WRITE_FILE_FREEFORM_RUST_LIB_TOOL,
56    DELETE_FILE_RUST_LIB_TOOL,
57    CHECK_RUST_LIB_TOOL,
58    PUBLISH_RUST_LIB_TOOL,
59];
60
61pub const WEB_LIB_TOOLS: [&str; 9] = [
62    CREATE_WEB_LIB_TOOL,
63    OPEN_WEB_LIB_TOOL,
64    DOCS_WEB_LIB_TOOL,
65    WRITE_WEB_LIB_TOOL,
66    WRITE_FILE_FREEFORM_WEB_LIB_TOOL,
67    ATTACH_OBJECT_WEB_LIB_TOOL,
68    DELETE_FILE_WEB_LIB_TOOL,
69    CHECK_WEB_LIB_TOOL,
70    PUBLISH_WEB_LIB_TOOL,
71];
72
73pub const RUST_BIN_TOOLS: [&str; 9] = [
74    CREATE_RUST_BIN_TOOL,
75    OPEN_RUST_BIN_TOOL,
76    DOCS_RUST_BIN_TOOL,
77    WRITE_RUST_BIN_TOOL,
78    WRITE_FILE_FREEFORM_RUST_BIN_TOOL,
79    DELETE_FILE_RUST_BIN_TOOL,
80    CHECK_RUST_BIN_TOOL,
81    PUBLISH_RUST_BIN_TOOL,
82    CALL_RUST_BIN_TOOL,
83];
84
85const SESSION_LEASE: Duration = Duration::from_secs(24 * 60 * 60);
86const MAX_SESSION_ID_BYTES: usize = 128;
87
88pub struct Config {
89    pub rust_libraries_root: PathBuf,
90    pub web_libraries_root: PathBuf,
91    pub web_publications_root: PathBuf,
92    pub rust_binaries_root: PathBuf,
93    pub rust_binary_publications_root: PathBuf,
94    pub crates_io_registry_token: String,
95}
96
97#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
98pub enum ManagedSourceKind {
99    RustLibrary,
100    WebLibrary,
101    RustBinary,
102}
103
104impl ManagedSourceKind {
105    pub fn label(self) -> &'static str {
106        match self {
107            Self::RustLibrary => "Rust library",
108            Self::WebLibrary => "Web library",
109            Self::RustBinary => "Rust binary",
110        }
111    }
112
113    pub fn open_tool(self) -> &'static str {
114        match self {
115            Self::RustLibrary => OPEN_RUST_LIB_TOOL,
116            Self::WebLibrary => OPEN_WEB_LIB_TOOL,
117            Self::RustBinary => OPEN_RUST_BIN_TOOL,
118        }
119    }
120
121    pub(crate) fn code(self, suffix: &'static str) -> &'static str {
122        match (self, suffix) {
123            (Self::RustLibrary, "already_open") => "rust_lib_already_open",
124            (Self::RustLibrary, "not_open") => "rust_lib_not_open",
125            (Self::RustLibrary, "session_ending") => "rust_lib_session_ending",
126            (Self::WebLibrary, "already_open") => "web_lib_already_open",
127            (Self::WebLibrary, "not_open") => "web_lib_not_open",
128            (Self::WebLibrary, "session_ending") => "web_lib_session_ending",
129            (Self::RustBinary, "already_open") => "rust_bin_already_open",
130            (Self::RustBinary, "not_open") => "rust_bin_not_open",
131            (Self::RustBinary, "session_ending") => "rust_bin_session_ending",
132            _ => "managed_source_error",
133        }
134    }
135}
136
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct SourceSnapshot {
139    pub kind: ManagedSourceKind,
140    pub name: String,
141    pub text: String,
142}
143
144#[derive(Clone, Debug, Eq, PartialEq)]
145pub struct ToolExecution {
146    pub text: String,
147    pub objects: Vec<Vec<u8>>,
148    pub snapshot: Option<SourceSnapshot>,
149}
150
151#[derive(Debug)]
152pub struct ToolError {
153    pub status: StatusCode,
154    pub code: &'static str,
155    pub message: String,
156}
157
158#[derive(Clone)]
159pub struct Service {
160    pub(crate) inner: Arc<ServiceInner>,
161}
162
163pub(crate) struct ServiceInner {
164    pub(crate) rust_libraries_root: PathBuf,
165    pub(crate) web_libraries_root: PathBuf,
166    pub(crate) web_publications_root: PathBuf,
167    pub(crate) rust_binaries_root: PathBuf,
168    pub(crate) rust_binary_publications_root: PathBuf,
169    pub(crate) registry_token: Zeroizing<String>,
170    pub(crate) registry: Arc<RegistryState>,
171}
172
173impl Service {
174    pub fn open(config: Config) -> Result<Self, ToolError> {
175        let registry_token = config.crates_io_registry_token.trim();
176        if registry_token.is_empty() {
177            return Err(ToolError::unavailable(
178                "registry_token_unavailable",
179                "The crates.io registry token is empty.",
180            ));
181        }
182
183        let web_libraries_root = checked_root(&config.web_libraries_root, "managed Web libraries")?;
184        let web_publications_root =
185            checked_root(&config.web_publications_root, "Web library publications")?;
186        if web_libraries_root.starts_with(&web_publications_root)
187            || web_publications_root.starts_with(&web_libraries_root)
188        {
189            return Err(ToolError::unprocessable(
190                "dev_tools_invalid_root",
191                "Managed Web source and publication roots must be disjoint.",
192            ));
193        }
194
195        Ok(Self {
196            inner: Arc::new(ServiceInner {
197                rust_libraries_root: checked_root(
198                    &config.rust_libraries_root,
199                    "managed Rust libraries",
200                )?,
201                web_libraries_root,
202                web_publications_root,
203                rust_binaries_root: checked_root(
204                    &config.rust_binaries_root,
205                    "managed Rust binaries",
206                )?,
207                rust_binary_publications_root: checked_root(
208                    &config.rust_binary_publications_root,
209                    "Rust binary publications",
210                )?,
211                registry_token: Zeroizing::new(registry_token.to_owned()),
212                registry: Arc::new(RegistryState::default()),
213            }),
214        })
215    }
216
217    pub fn web_libraries_root(&self) -> &Path {
218        &self.inner.web_libraries_root
219    }
220
221    pub fn web_publications_root(&self) -> &Path {
222        &self.inner.web_publications_root
223    }
224
225    pub async fn execute(
226        &self,
227        session_id: impl Into<String>,
228        tool_name: impl Into<String>,
229        arguments: Value,
230        objects: Vec<Vec<u8>>,
231    ) -> Result<ToolExecution, ToolError> {
232        let session_id = session_id.into();
233        validate_session_id(&session_id)?;
234        let tool_name = tool_name.into();
235        let service = self.clone();
236        tokio::task::spawn_blocking(move || {
237            service.execute_blocking(&session_id, &tool_name, arguments, objects)
238        })
239        .await
240        .map_err(|error| {
241            tracing::error!(error=%error, "managed-source tool worker failed");
242            ToolError::internal("The managed-source tool worker stopped unexpectedly.")
243        })?
244    }
245
246    pub async fn release(&self, session_id: impl Into<String>) -> Result<usize, ToolError> {
247        let session_id = session_id.into();
248        validate_session_id(&session_id)?;
249        let service = self.clone();
250        tokio::task::spawn_blocking(move || service.release_blocking(&session_id))
251            .await
252            .map_err(|error| {
253                tracing::error!(error=%error, "managed-source release worker failed");
254                ToolError::internal("The managed-source release worker stopped unexpectedly.")
255            })?
256    }
257}
258
259impl ToolExecution {
260    pub(crate) fn plain(text: impl Into<String>) -> Self {
261        Self {
262            text: text.into(),
263            objects: Vec::new(),
264            snapshot: None,
265        }
266    }
267
268    pub(crate) fn snapshot(snapshot: SourceSnapshot) -> Self {
269        Self {
270            text: snapshot.text.clone(),
271            objects: Vec::new(),
272            snapshot: Some(snapshot),
273        }
274    }
275
276    pub(crate) fn with_snapshot(text: impl Into<String>, snapshot: SourceSnapshot) -> Self {
277        Self {
278            text: text.into(),
279            objects: Vec::new(),
280            snapshot: Some(snapshot),
281        }
282    }
283
284    pub(crate) fn binary(text: String, objects: Vec<Vec<u8>>) -> Self {
285        Self {
286            text,
287            objects,
288            snapshot: None,
289        }
290    }
291}
292
293impl ToolError {
294    pub(crate) fn invalid(message: impl Into<String>) -> Self {
295        Self {
296            status: StatusCode::BAD_REQUEST,
297            code: "invalid_arguments",
298            message: message.into(),
299        }
300    }
301
302    pub(crate) fn not_found(code: &'static str, message: impl Into<String>) -> Self {
303        Self {
304            status: StatusCode::NOT_FOUND,
305            code,
306            message: message.into(),
307        }
308    }
309
310    pub(crate) fn conflict(code: &'static str, message: impl Into<String>) -> Self {
311        Self {
312            status: StatusCode::CONFLICT,
313            code,
314            message: message.into(),
315        }
316    }
317
318    pub(crate) fn unprocessable(code: &'static str, message: impl Into<String>) -> Self {
319        Self {
320            status: StatusCode::UNPROCESSABLE_ENTITY,
321            code,
322            message: message.into(),
323        }
324    }
325
326    pub(crate) fn unavailable(code: &'static str, message: impl Into<String>) -> Self {
327        Self {
328            status: StatusCode::SERVICE_UNAVAILABLE,
329            code,
330            message: message.into(),
331        }
332    }
333
334    pub(crate) fn internal(message: impl Into<String>) -> Self {
335        Self {
336            status: StatusCode::INTERNAL_SERVER_ERROR,
337            code: "dev_tools_internal_error",
338            message: message.into(),
339        }
340    }
341}
342
343impl fmt::Display for ToolError {
344    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
345        write!(formatter, "{}: {}", self.code, self.message)
346    }
347}
348
349impl StdError for ToolError {}
350
351pub fn proposed_write_snapshot(tool_name: &str, arguments: &Value) -> Option<SourceSnapshot> {
352    backend::proposed_write_snapshot(tool_name, arguments)
353}
354
355fn checked_root(path: &Path, label: &str) -> Result<PathBuf, ToolError> {
356    std::fs::create_dir_all(path).map_err(|error| {
357        ToolError::unavailable(
358            "dev_tools_storage_unavailable",
359            format!("Could not create {label} root {}: {error}", path.display()),
360        )
361    })?;
362    let metadata = std::fs::symlink_metadata(path).map_err(|error| {
363        ToolError::unavailable(
364            "dev_tools_storage_unavailable",
365            format!("Could not inspect {label} root {}: {error}", path.display()),
366        )
367    })?;
368    if metadata.file_type().is_symlink() || !metadata.is_dir() {
369        return Err(ToolError::unprocessable(
370            "dev_tools_invalid_root",
371            format!("{label} root {} must be a real directory.", path.display()),
372        ));
373    }
374    std::fs::canonicalize(path).map_err(|error| {
375        ToolError::unavailable(
376            "dev_tools_storage_unavailable",
377            format!(
378                "Could not canonicalize {label} root {}: {error}",
379                path.display()
380            ),
381        )
382    })
383}
384
385fn validate_session_id(session_id: &str) -> Result<(), ToolError> {
386    if session_id.is_empty()
387        || session_id.len() > MAX_SESSION_ID_BYTES
388        || !session_id
389            .bytes()
390            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':'))
391    {
392        return Err(ToolError::invalid("The hidden tool session ID is invalid."));
393    }
394    Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399    use super::ToolExecution;
400
401    #[test]
402    fn binary_execution_preserves_exact_text_and_ordered_bytes() {
403        let execution =
404            ToolExecution::binary(" exact text\n".into(), vec![vec![0, 1], vec![255, 2]]);
405        assert_eq!(execution.text, " exact text\n");
406        assert_eq!(execution.objects, vec![vec![0, 1], vec![255, 2]]);
407        assert!(execution.snapshot.is_none());
408    }
409}