Skip to main content

github_copilot_sdk/
session_fs.rs

1//! Session filesystem provider — virtualizable filesystem layer over JSON-RPC.
2//!
3//! When [`ClientOptions::session_fs`] is set, the SDK tells the CLI to delegate
4//! all per-session filesystem operations (`readFile`, `writeFile`, `stat`, ...)
5//! to a [`SessionFsProvider`] registered on each session. This lets host
6//! applications sandbox sessions, project files into in-memory or remote
7//! storage, and apply permission policies before bytes move.
8//!
9//! # Concurrency
10//!
11//! Each inbound `sessionFs.*` request is dispatched on its own spawned task,
12//! so provider implementations MUST be safe for concurrent invocation across
13//! distinct paths. Use internal synchronization (e.g. [`tokio::sync::Mutex`]
14//! keyed by path) if your backing store needs ordering.
15//!
16//! # Errors
17//!
18//! Provider methods return [`Result<T, FsError>`]. The SDK adapts these into
19//! the schema's `{ ..., error: Option<SessionFsError> }` payload, mapping
20//! [`FsErrorKind::NotFound`](crate::session_fs::FsErrorKind::NotFound) to
21//! the wire's `ENOENT` and everything else to `UNKNOWN`.
22//! A [`From<std::io::Error>`] conversion is provided so handlers
23//! backed by [`tokio::fs`](https://docs.rs/tokio/latest/tokio/fs/index.html)
24//! can propagate `io::Error` with `?`.
25//!
26//! # Example
27//!
28//! ```no_run
29//! use std::sync::Arc;
30//! use async_trait::async_trait;
31//! use github_copilot_sdk::types::{SessionFsProvider, FsError, FileInfo, DirEntry};
32//!
33//! struct MyProvider;
34//!
35//! #[async_trait]
36//! impl SessionFsProvider for MyProvider {
37//!     async fn read_file(&self, path: &str) -> Result<String, FsError> {
38//!         std::fs::read_to_string(path)
39//!             .map_err(FsError::from)
40//!     }
41//! }
42//! ```
43
44use std::borrow::{Borrow, Cow};
45use std::collections::HashMap;
46use std::fmt;
47
48use async_trait::async_trait;
49
50pub use crate::generated::api_types::SessionFsSqliteQueryType;
51use crate::generated::api_types::{
52    SessionFsError, SessionFsErrorCode, SessionFsReaddirWithTypesEntry,
53    SessionFsReaddirWithTypesEntryType, SessionFsSetProviderConventions, SessionFsStatResult,
54};
55use crate::{Custom, Repr};
56
57/// Optional capabilities declared by a session filesystem provider.
58#[non_exhaustive]
59#[derive(Debug, Clone, Default)]
60pub struct SessionFsCapabilities {
61    /// Whether the provider supports SQLite query/exists operations.
62    pub sqlite: bool,
63}
64
65impl SessionFsCapabilities {
66    /// Create a new capabilities struct with default values.
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Enable SQLite support.
72    pub fn with_sqlite(mut self, sqlite: bool) -> Self {
73        self.sqlite = sqlite;
74        self
75    }
76}
77
78/// Configuration for a custom session filesystem provider.
79///
80/// When set on [`ClientOptions::session_fs`](crate::ClientOptions::session_fs),
81/// the SDK calls `sessionFs.setProvider` during [`Client::start`](crate::Client::start)
82/// to tell the CLI to route per-session filesystem operations to the SDK.
83#[non_exhaustive]
84#[derive(Debug, Clone)]
85pub struct SessionFsConfig {
86    /// Initial working directory for sessions (the user's project directory).
87    pub initial_cwd: String,
88    /// Path within each session's SessionFs where the runtime stores
89    /// session-scoped files (events, workspace, checkpoints, etc.).
90    pub session_state_path: String,
91    /// Path conventions used by this filesystem provider.
92    pub conventions: SessionFsConventions,
93    /// Optional capabilities such as SQLite support.
94    pub capabilities: Option<SessionFsCapabilities>,
95}
96
97impl SessionFsConfig {
98    /// Build a new config with the required fields.
99    pub fn new(
100        initial_cwd: impl Into<String>,
101        session_state_path: impl Into<String>,
102        conventions: SessionFsConventions,
103    ) -> Self {
104        Self {
105            initial_cwd: initial_cwd.into(),
106            session_state_path: session_state_path.into(),
107            conventions,
108            capabilities: None,
109        }
110    }
111
112    /// Set the capabilities on this config and return it (builder pattern).
113    pub fn with_capabilities(mut self, capabilities: SessionFsCapabilities) -> Self {
114        self.capabilities = Some(capabilities);
115        self
116    }
117}
118
119/// Path conventions used by a session filesystem provider.
120///
121/// Hand-authored consumer-facing enum (rather than reusing
122/// [`SessionFsSetProviderConventions`]) to avoid exposing the generated
123/// catch-all `Unknown` variant on the input side. The SDK rejects unknown
124/// conventions at validation time with a typed error.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum SessionFsConventions {
127    /// POSIX-style paths (`/foo/bar`).
128    Posix,
129    /// Windows-style paths (`C:\foo\bar`).
130    Windows,
131}
132
133impl SessionFsConventions {
134    pub(crate) fn into_wire(self) -> SessionFsSetProviderConventions {
135        match self {
136            Self::Posix => SessionFsSetProviderConventions::Posix,
137            Self::Windows => SessionFsSetProviderConventions::Windows,
138        }
139    }
140}
141
142/// Error kind returned by a [`SessionFsProvider`] method.
143///
144/// The SDK maps this onto the wire schema's `SessionFsError`:
145/// [`FsErrorKind::NotFound`] becomes `ENOENT`, everything else becomes `UNKNOWN`.
146#[derive(Clone, Debug, PartialEq, Eq)]
147#[non_exhaustive]
148pub enum FsErrorKind {
149    /// File or directory does not exist.
150    NotFound(String),
151
152    /// Any other filesystem error (permission denied, I/O error, etc.).
153    Other,
154}
155
156impl fmt::Display for FsErrorKind {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            FsErrorKind::NotFound(path) => write!(f, "not found: {path}"),
160            FsErrorKind::Other => write!(f, "filesystem error"),
161        }
162    }
163}
164
165/// Error returned by a [`crate::session_fs::SessionFsProvider`] method.
166///
167/// The SDK maps this onto the wire schema's `SessionFsError`:
168/// [`FsErrorKind::NotFound`] becomes `ENOENT`, everything else becomes `UNKNOWN`.
169#[derive(Debug)]
170pub struct FsError {
171    repr: Repr<FsErrorKind>,
172}
173
174impl FsError {
175    /// Construct a `FsError` wrapping a source error.
176    pub fn new<E>(kind: FsErrorKind, error: E) -> Self
177    where
178        E: Into<Box<dyn std::error::Error + Send + Sync>>,
179    {
180        Self {
181            repr: Repr::Custom(Custom {
182                kind,
183                error: error.into(),
184            }),
185        }
186    }
187
188    /// The [`FsErrorKind`] of this error.
189    pub fn kind(&self) -> &FsErrorKind {
190        match &self.repr {
191            Repr::Simple(k) | Repr::SimpleMessage(k, ..) | Repr::Custom(Custom { kind: k, .. }) => {
192                k
193            }
194        }
195    }
196
197    /// The message provided when this error was constructed, or `None`.
198    pub fn message(&self) -> Option<&str> {
199        match &self.repr {
200            Repr::SimpleMessage(_, m) => Some(m.borrow()),
201            _ => None,
202        }
203    }
204
205    /// Create a `FsError` with a custom message.
206    #[must_use]
207    pub fn with_message<C>(kind: FsErrorKind, message: C) -> Self
208    where
209        C: Into<Cow<'static, str>>,
210    {
211        Self {
212            repr: Repr::SimpleMessage(kind, message.into()),
213        }
214    }
215
216    pub(crate) fn into_wire(self) -> SessionFsError {
217        match self.kind() {
218            FsErrorKind::NotFound(message) => SessionFsError {
219                code: SessionFsErrorCode::ENOENT,
220                message: Some(message.clone()),
221            },
222            FsErrorKind::Other => SessionFsError {
223                code: SessionFsErrorCode::UNKNOWN,
224                message: Some(self.to_string()),
225            },
226        }
227    }
228}
229
230impl fmt::Display for FsError {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match &self.repr {
233            Repr::Simple(k) => write!(f, "{k}"),
234            Repr::SimpleMessage(_, m) => write!(f, "{m}"),
235            Repr::Custom(Custom { error, .. }) => write!(f, "{error}"),
236        }
237    }
238}
239
240impl std::error::Error for FsError {
241    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
242        match &self.repr {
243            Repr::Custom(Custom { error, .. }) => Some(&**error),
244            _ => None,
245        }
246    }
247}
248
249impl From<FsErrorKind> for FsError {
250    fn from(kind: FsErrorKind) -> Self {
251        Self {
252            repr: Repr::Simple(kind),
253        }
254    }
255}
256
257impl From<std::io::Error> for FsError {
258    fn from(err: std::io::Error) -> Self {
259        match err.kind() {
260            std::io::ErrorKind::NotFound => Self::new(FsErrorKind::NotFound(err.to_string()), err),
261            _ => Self::new(FsErrorKind::Other, err),
262        }
263    }
264}
265
266/// File or directory metadata returned by [`SessionFsProvider::stat`].
267///
268/// The SDK adapts this into the wire's [`SessionFsStatResult`].
269#[non_exhaustive]
270#[derive(Debug, Clone)]
271pub struct FileInfo {
272    /// Whether the path is a regular file.
273    pub is_file: bool,
274    /// Whether the path is a directory.
275    pub is_directory: bool,
276    /// File size in bytes.
277    pub size: i64,
278    /// ISO 8601 timestamp of last modification.
279    pub mtime: String,
280    /// ISO 8601 timestamp of creation.
281    pub birthtime: String,
282}
283
284impl FileInfo {
285    /// Build a metadata record. The mtime/birthtime arguments are caller-
286    /// supplied ISO 8601 strings — the SDK does not format timestamps for
287    /// you.
288    pub fn new(
289        is_file: bool,
290        is_directory: bool,
291        size: i64,
292        mtime: impl Into<String>,
293        birthtime: impl Into<String>,
294    ) -> Self {
295        Self {
296            is_file,
297            is_directory,
298            size,
299            mtime: mtime.into(),
300            birthtime: birthtime.into(),
301        }
302    }
303
304    pub(crate) fn into_wire(self) -> SessionFsStatResult {
305        SessionFsStatResult {
306            is_file: self.is_file,
307            is_directory: self.is_directory,
308            size: self.size,
309            mtime: self.mtime,
310            birthtime: self.birthtime,
311            error: None,
312        }
313    }
314}
315
316/// Kind of entry returned by [`SessionFsProvider::readdir_with_types`].
317///
318/// The wire schema's `Unknown` forward-compat variant is intentionally absent
319/// from this consumer-facing enum — providers must classify each entry as
320/// either a file or a directory.
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum DirEntryKind {
323    /// Regular file.
324    File,
325    /// Directory.
326    Directory,
327}
328
329impl DirEntryKind {
330    fn into_wire(self) -> SessionFsReaddirWithTypesEntryType {
331        match self {
332            Self::File => SessionFsReaddirWithTypesEntryType::File,
333            Self::Directory => SessionFsReaddirWithTypesEntryType::Directory,
334        }
335    }
336}
337
338/// Single entry in a directory listing returned by
339/// [`SessionFsProvider::readdir_with_types`].
340#[non_exhaustive]
341#[derive(Debug, Clone)]
342pub struct DirEntry {
343    /// Entry name (basename, not full path).
344    pub name: String,
345    /// Whether the entry is a file or a directory.
346    pub kind: DirEntryKind,
347}
348
349impl DirEntry {
350    /// Build a new directory entry.
351    pub fn new(name: impl Into<String>, kind: DirEntryKind) -> Self {
352        Self {
353            name: name.into(),
354            kind,
355        }
356    }
357
358    pub(crate) fn into_wire(self) -> SessionFsReaddirWithTypesEntry {
359        SessionFsReaddirWithTypesEntry {
360            name: self.name,
361            r#type: self.kind.into_wire(),
362        }
363    }
364}
365
366/// Implementor-supplied filesystem backing for a session.
367///
368/// Each method takes a path using the conventions declared in
369/// [`SessionFsConfig::conventions`] and returns the operation's result. The
370/// SDK adapts every `Result<_, FsError>` into the JSON-RPC response shape
371/// expected by the GitHub Copilot CLI.
372///
373/// # Concurrency
374///
375/// Implementations MUST be `Send + Sync` and safe for concurrent invocation
376/// across distinct paths. The SDK dispatches each inbound `sessionFs.*`
377/// request on its own spawned task. Use internal synchronization (e.g.
378/// [`tokio::sync::Mutex`] keyed by path) if your backing store requires
379/// ordering.
380///
381/// # Forward compatibility
382///
383/// Methods on this trait have default implementations that return
384/// `Err(FsError::with_message(FsErrorKind::Other, "operation not supported"))`. When the CLI
385/// schema grows new `sessionFs.*` methods, the SDK adds them to this trait
386/// with default impls so existing implementations continue to compile.
387/// Override only the methods relevant to your backing store.
388#[async_trait]
389pub trait SessionFsProvider: Send + Sync + 'static {
390    /// Read the full contents of a file as UTF-8.
391    async fn read_file(&self, path: &str) -> Result<String, FsError> {
392        let _ = path;
393        Err(FsError::with_message(
394            FsErrorKind::Other,
395            "read_file not supported",
396        ))
397    }
398
399    /// Write content to a file, creating parent directories if needed.
400    async fn write_file(
401        &self,
402        path: &str,
403        content: &str,
404        mode: Option<i64>,
405    ) -> Result<(), FsError> {
406        let _ = (path, content, mode);
407        Err(FsError::with_message(
408            FsErrorKind::Other,
409            "write_file not supported",
410        ))
411    }
412
413    /// Append content to a file, creating parent directories if needed.
414    async fn append_file(
415        &self,
416        path: &str,
417        content: &str,
418        mode: Option<i64>,
419    ) -> Result<(), FsError> {
420        let _ = (path, content, mode);
421        Err(FsError::with_message(
422            FsErrorKind::Other,
423            "append_file not supported",
424        ))
425    }
426
427    /// Check whether a path exists.
428    ///
429    /// Returns `Ok(false)` for non-existent paths, not [`FsErrorKind::NotFound`].
430    async fn exists(&self, path: &str) -> Result<bool, FsError> {
431        let _ = path;
432        Err(FsError::with_message(
433            FsErrorKind::Other,
434            "exists not supported",
435        ))
436    }
437
438    /// Get metadata about a file or directory.
439    async fn stat(&self, path: &str) -> Result<FileInfo, FsError> {
440        let _ = path;
441        Err(FsError::with_message(
442            FsErrorKind::Other,
443            "stat not supported",
444        ))
445    }
446
447    /// Create a directory. When `recursive`, missing parents are also created.
448    async fn mkdir(&self, path: &str, recursive: bool, mode: Option<i64>) -> Result<(), FsError> {
449        let _ = (path, recursive, mode);
450        Err(FsError::with_message(
451            FsErrorKind::Other,
452            "mkdir not supported",
453        ))
454    }
455
456    /// List entry names in a directory.
457    async fn readdir(&self, path: &str) -> Result<Vec<String>, FsError> {
458        let _ = path;
459        Err(FsError::with_message(
460            FsErrorKind::Other,
461            "readdir not supported",
462        ))
463    }
464
465    /// List directory entries with type information.
466    async fn readdir_with_types(&self, path: &str) -> Result<Vec<DirEntry>, FsError> {
467        let _ = path;
468        Err(FsError::with_message(
469            FsErrorKind::Other,
470            "readdir_with_types not supported",
471        ))
472    }
473
474    /// Remove a file or directory. When `force`, missing paths are not an
475    /// error. When `recursive`, directory contents are removed as well.
476    async fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<(), FsError> {
477        let _ = (path, recursive, force);
478        Err(FsError::with_message(
479            FsErrorKind::Other,
480            "rm not supported",
481        ))
482    }
483
484    /// Rename or move a file or directory.
485    async fn rename(&self, src: &str, dest: &str) -> Result<(), FsError> {
486        let _ = (src, dest);
487        Err(FsError::with_message(
488            FsErrorKind::Other,
489            "rename not supported",
490        ))
491    }
492
493    /// Return a reference to the SQLite provider, if this provider supports
494    /// SQLite operations. The default returns `None`. Providers that support
495    /// SQLite should also implement [`SessionFsSqliteProvider`] and override
496    /// this to return `Some(self)`.
497    fn sqlite(&self) -> Option<&dyn SessionFsSqliteProvider> {
498        None
499    }
500}
501
502/// Optional trait for providers that support SQLite operations.
503///
504/// Providers are already session-scoped (created per session by the factory),
505/// so these methods do not take a `session_id` parameter.
506///
507/// To opt in, implement this trait on your provider and override
508/// [`SessionFsProvider::sqlite`] to return `Some(self)`:
509///
510/// ```ignore
511/// impl SessionFsSqliteProvider for MyProvider { /* ... */ }
512///
513/// #[async_trait]
514/// impl SessionFsProvider for MyProvider {
515///     fn sqlite(&self) -> Option<&dyn SessionFsSqliteProvider> {
516///         Some(self)
517///     }
518///     // ... other methods ...
519/// }
520/// ```
521#[async_trait]
522pub trait SessionFsSqliteProvider: Send + Sync {
523    /// Execute a SQLite query against the provider's per-session database.
524    async fn sqlite_query(
525        &self,
526        query_type: SessionFsSqliteQueryType,
527        query: &str,
528        params: Option<&HashMap<String, serde_json::Value>>,
529    ) -> Result<Option<SessionFsSqliteQueryResult>, FsError>;
530
531    /// Check whether the provider has a SQLite database for this session.
532    async fn sqlite_exists(&self) -> Result<bool, FsError>;
533}
534
535/// Result of a SQLite query execution via [`SessionFsSqliteProvider::sqlite_query`].
536///
537/// Same shape as the generated RPC type but without the `error` field,
538/// since providers signal errors by returning `Err`.
539#[derive(Debug, Clone, Default)]
540pub struct SessionFsSqliteQueryResult {
541    /// Column names from the result set.
542    pub columns: Vec<String>,
543    /// For SELECT: array of row objects. For others: empty array.
544    pub rows: Vec<HashMap<String, serde_json::Value>>,
545    /// Number of rows affected (for INSERT/UPDATE/DELETE).
546    pub rows_affected: i64,
547    /// Last inserted row ID (for INSERT).
548    pub last_insert_rowid: Option<i64>,
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn fs_error_maps_io_not_found_to_enoent() {
557        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing.txt");
558        let fs_err: FsError = io_err.into();
559        assert!(
560            matches!(fs_err.kind(), FsErrorKind::NotFound(message) if message == "missing.txt")
561        );
562        let wire = fs_err.into_wire();
563        assert_eq!(wire.code, SessionFsErrorCode::ENOENT);
564    }
565
566    #[test]
567    fn fs_error_maps_other_io_to_unknown() {
568        let io_err = std::io::Error::other("disk full");
569        let fs_err: FsError = io_err.into();
570        assert!(matches!(fs_err.kind(), FsErrorKind::Other));
571        let wire = fs_err.into_wire();
572        assert_eq!(wire.code, SessionFsErrorCode::UNKNOWN);
573        assert!(wire.message.unwrap().contains("disk full"));
574    }
575
576    #[test]
577    fn conventions_maps_to_wire() {
578        assert_eq!(
579            SessionFsConventions::Posix.into_wire(),
580            SessionFsSetProviderConventions::Posix
581        );
582        assert_eq!(
583            SessionFsConventions::Windows.into_wire(),
584            SessionFsSetProviderConventions::Windows
585        );
586    }
587
588    struct DefaultProvider;
589    #[async_trait]
590    impl SessionFsProvider for DefaultProvider {}
591
592    #[tokio::test]
593    async fn default_impls_return_unsupported() {
594        let p = DefaultProvider;
595        let err = p.read_file("/x").await.unwrap_err();
596        assert!(
597            matches!(err.kind(), FsErrorKind::Other) && err.to_string().contains("not supported")
598        );
599    }
600}