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//! matching Node's behavior. Provider implementations MUST be safe for
13//! concurrent invocation across distinct paths. Use internal synchronization
14//! (e.g. [`tokio::sync::Mutex`] keyed by path) if your backing store needs
15//! ordering.
16//!
17//! # Errors
18//!
19//! Provider methods return [`Result<T, FsError>`]. The SDK adapts these into
20//! the schema's `{ ..., error: Option<SessionFsError> }` payload, mapping
21//! [`FsError::NotFound`] to the wire's `ENOENT` and everything else to
22//! `UNKNOWN`. 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 async_trait::async_trait;
45
46use crate::generated::api_types::{
47    SessionFsError, SessionFsErrorCode, SessionFsReaddirWithTypesEntry,
48    SessionFsReaddirWithTypesEntryType, SessionFsSetProviderConventions, SessionFsStatResult,
49};
50
51/// Configuration for a custom session filesystem provider.
52///
53/// When set on [`ClientOptions::session_fs`](crate::ClientOptions::session_fs),
54/// the SDK calls `sessionFs.setProvider` during [`Client::start`](crate::Client::start)
55/// to tell the CLI to route per-session filesystem operations to the SDK.
56#[non_exhaustive]
57#[derive(Debug, Clone)]
58pub struct SessionFsConfig {
59    /// Initial working directory for sessions (the user's project directory).
60    pub initial_cwd: String,
61    /// Path within each session's SessionFs where the runtime stores
62    /// session-scoped files (events, workspace, checkpoints, etc.).
63    pub session_state_path: String,
64    /// Path conventions used by this filesystem provider.
65    pub conventions: SessionFsConventions,
66}
67
68impl SessionFsConfig {
69    /// Build a new config with the required fields.
70    pub fn new(
71        initial_cwd: impl Into<String>,
72        session_state_path: impl Into<String>,
73        conventions: SessionFsConventions,
74    ) -> Self {
75        Self {
76            initial_cwd: initial_cwd.into(),
77            session_state_path: session_state_path.into(),
78            conventions,
79        }
80    }
81}
82
83/// Path conventions used by a session filesystem provider.
84///
85/// Hand-authored consumer-facing enum (rather than reusing
86/// [`SessionFsSetProviderConventions`]) to avoid exposing the generated
87/// catch-all `Unknown` variant on the input side. The SDK rejects unknown
88/// conventions at validation time with a typed error.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum SessionFsConventions {
91    /// POSIX-style paths (`/foo/bar`).
92    Posix,
93    /// Windows-style paths (`C:\foo\bar`).
94    Windows,
95}
96
97impl SessionFsConventions {
98    pub(crate) fn into_wire(self) -> SessionFsSetProviderConventions {
99        match self {
100            Self::Posix => SessionFsSetProviderConventions::Posix,
101            Self::Windows => SessionFsSetProviderConventions::Windows,
102        }
103    }
104}
105
106/// Error returned by a [`SessionFsProvider`] method.
107///
108/// The SDK maps this onto the wire schema's [`SessionFsError`]:
109/// [`FsError::NotFound`] becomes `ENOENT`, everything else becomes `UNKNOWN`.
110#[non_exhaustive]
111#[derive(Debug, Clone, thiserror::Error)]
112pub enum FsError {
113    /// File or directory does not exist.
114    #[error("not found: {0}")]
115    NotFound(String),
116
117    /// Any other filesystem error (permission denied, I/O error, etc.).
118    ///
119    /// The wire mapping always uses `UNKNOWN` as the code; the message is
120    /// preserved for diagnostics.
121    #[error("{0}")]
122    Other(String),
123}
124
125impl FsError {
126    pub(crate) fn into_wire(self) -> SessionFsError {
127        match self {
128            Self::NotFound(message) => SessionFsError {
129                code: SessionFsErrorCode::ENOENT,
130                message: Some(message),
131            },
132            Self::Other(message) => SessionFsError {
133                code: SessionFsErrorCode::UNKNOWN,
134                message: Some(message),
135            },
136        }
137    }
138}
139
140impl From<std::io::Error> for FsError {
141    fn from(err: std::io::Error) -> Self {
142        match err.kind() {
143            std::io::ErrorKind::NotFound => Self::NotFound(err.to_string()),
144            _ => Self::Other(err.to_string()),
145        }
146    }
147}
148
149/// File or directory metadata returned by [`SessionFsProvider::stat`].
150///
151/// The SDK adapts this into the wire's [`SessionFsStatResult`].
152#[non_exhaustive]
153#[derive(Debug, Clone)]
154pub struct FileInfo {
155    /// Whether the path is a regular file.
156    pub is_file: bool,
157    /// Whether the path is a directory.
158    pub is_directory: bool,
159    /// File size in bytes.
160    pub size: i64,
161    /// ISO 8601 timestamp of last modification.
162    pub mtime: String,
163    /// ISO 8601 timestamp of creation.
164    pub birthtime: String,
165}
166
167impl FileInfo {
168    /// Build a metadata record. The mtime/birthtime arguments are caller-
169    /// supplied ISO 8601 strings — the SDK does not format timestamps for
170    /// you.
171    pub fn new(
172        is_file: bool,
173        is_directory: bool,
174        size: i64,
175        mtime: impl Into<String>,
176        birthtime: impl Into<String>,
177    ) -> Self {
178        Self {
179            is_file,
180            is_directory,
181            size,
182            mtime: mtime.into(),
183            birthtime: birthtime.into(),
184        }
185    }
186
187    pub(crate) fn into_wire(self) -> SessionFsStatResult {
188        SessionFsStatResult {
189            is_file: self.is_file,
190            is_directory: self.is_directory,
191            size: self.size,
192            mtime: self.mtime,
193            birthtime: self.birthtime,
194            error: None,
195        }
196    }
197}
198
199/// Kind of entry returned by [`SessionFsProvider::readdir_with_types`].
200///
201/// The wire schema's `Unknown` forward-compat variant is intentionally absent
202/// from this consumer-facing enum — providers must classify each entry as
203/// either a file or a directory.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum DirEntryKind {
206    /// Regular file.
207    File,
208    /// Directory.
209    Directory,
210}
211
212impl DirEntryKind {
213    fn into_wire(self) -> SessionFsReaddirWithTypesEntryType {
214        match self {
215            Self::File => SessionFsReaddirWithTypesEntryType::File,
216            Self::Directory => SessionFsReaddirWithTypesEntryType::Directory,
217        }
218    }
219}
220
221/// Single entry in a directory listing returned by
222/// [`SessionFsProvider::readdir_with_types`].
223#[non_exhaustive]
224#[derive(Debug, Clone)]
225pub struct DirEntry {
226    /// Entry name (basename, not full path).
227    pub name: String,
228    /// Whether the entry is a file or a directory.
229    pub kind: DirEntryKind,
230}
231
232impl DirEntry {
233    /// Build a new directory entry.
234    pub fn new(name: impl Into<String>, kind: DirEntryKind) -> Self {
235        Self {
236            name: name.into(),
237            kind,
238        }
239    }
240
241    pub(crate) fn into_wire(self) -> SessionFsReaddirWithTypesEntry {
242        SessionFsReaddirWithTypesEntry {
243            name: self.name,
244            r#type: self.kind.into_wire(),
245        }
246    }
247}
248
249/// Implementor-supplied filesystem backing for a session.
250///
251/// Each method takes a path using the conventions declared in
252/// [`SessionFsConfig::conventions`] and returns the operation's result. The
253/// SDK adapts every `Result<_, FsError>` into the JSON-RPC response shape
254/// expected by the GitHub Copilot CLI.
255///
256/// # Concurrency
257///
258/// Implementations MUST be `Send + Sync` and safe for concurrent invocation
259/// across distinct paths. The SDK dispatches each inbound `sessionFs.*`
260/// request on its own spawned task. Use internal synchronization (e.g.
261/// [`tokio::sync::Mutex`] keyed by path) if your backing store requires
262/// ordering.
263///
264/// # Forward compatibility
265///
266/// Methods on this trait have default implementations that return
267/// `Err(FsError::Other("operation not supported".into()))`. When the CLI
268/// schema grows new `sessionFs.*` methods, the SDK adds them to this trait
269/// with default impls so existing implementations continue to compile.
270/// Override only the methods relevant to your backing store.
271#[async_trait]
272pub trait SessionFsProvider: Send + Sync + 'static {
273    /// Read the full contents of a file as UTF-8.
274    async fn read_file(&self, path: &str) -> Result<String, FsError> {
275        let _ = path;
276        Err(FsError::Other("read_file not supported".to_string()))
277    }
278
279    /// Write content to a file, creating parent directories if needed.
280    async fn write_file(
281        &self,
282        path: &str,
283        content: &str,
284        mode: Option<i64>,
285    ) -> Result<(), FsError> {
286        let _ = (path, content, mode);
287        Err(FsError::Other("write_file not supported".to_string()))
288    }
289
290    /// Append content to a file, creating parent directories if needed.
291    async fn append_file(
292        &self,
293        path: &str,
294        content: &str,
295        mode: Option<i64>,
296    ) -> Result<(), FsError> {
297        let _ = (path, content, mode);
298        Err(FsError::Other("append_file not supported".to_string()))
299    }
300
301    /// Check whether a path exists.
302    ///
303    /// Returns `Ok(false)` for non-existent paths, not [`FsError::NotFound`].
304    async fn exists(&self, path: &str) -> Result<bool, FsError> {
305        let _ = path;
306        Err(FsError::Other("exists not supported".to_string()))
307    }
308
309    /// Get metadata about a file or directory.
310    async fn stat(&self, path: &str) -> Result<FileInfo, FsError> {
311        let _ = path;
312        Err(FsError::Other("stat not supported".to_string()))
313    }
314
315    /// Create a directory. When `recursive`, missing parents are also created.
316    async fn mkdir(&self, path: &str, recursive: bool, mode: Option<i64>) -> Result<(), FsError> {
317        let _ = (path, recursive, mode);
318        Err(FsError::Other("mkdir not supported".to_string()))
319    }
320
321    /// List entry names in a directory.
322    async fn readdir(&self, path: &str) -> Result<Vec<String>, FsError> {
323        let _ = path;
324        Err(FsError::Other("readdir not supported".to_string()))
325    }
326
327    /// List directory entries with type information.
328    async fn readdir_with_types(&self, path: &str) -> Result<Vec<DirEntry>, FsError> {
329        let _ = path;
330        Err(FsError::Other(
331            "readdir_with_types not supported".to_string(),
332        ))
333    }
334
335    /// Remove a file or directory. When `force`, missing paths are not an
336    /// error. When `recursive`, directory contents are removed as well.
337    async fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<(), FsError> {
338        let _ = (path, recursive, force);
339        Err(FsError::Other("rm not supported".to_string()))
340    }
341
342    /// Rename or move a file or directory.
343    async fn rename(&self, src: &str, dest: &str) -> Result<(), FsError> {
344        let _ = (src, dest);
345        Err(FsError::Other("rename not supported".to_string()))
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn fs_error_maps_io_not_found_to_enoent() {
355        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing.txt");
356        let fs_err: FsError = io_err.into();
357        assert!(matches!(fs_err, FsError::NotFound(_)));
358        let wire = fs_err.into_wire();
359        assert_eq!(wire.code, SessionFsErrorCode::ENOENT);
360    }
361
362    #[test]
363    fn fs_error_maps_other_io_to_unknown() {
364        let io_err = std::io::Error::other("disk full");
365        let fs_err: FsError = io_err.into();
366        assert!(matches!(fs_err, FsError::Other(_)));
367        let wire = fs_err.into_wire();
368        assert_eq!(wire.code, SessionFsErrorCode::UNKNOWN);
369        assert!(wire.message.unwrap().contains("disk full"));
370    }
371
372    #[test]
373    fn conventions_maps_to_wire() {
374        assert_eq!(
375            SessionFsConventions::Posix.into_wire(),
376            SessionFsSetProviderConventions::Posix
377        );
378        assert_eq!(
379            SessionFsConventions::Windows.into_wire(),
380            SessionFsSetProviderConventions::Windows
381        );
382    }
383
384    struct DefaultProvider;
385    #[async_trait]
386    impl SessionFsProvider for DefaultProvider {}
387
388    #[tokio::test]
389    async fn default_impls_return_unsupported() {
390        let p = DefaultProvider;
391        let err = p.read_file("/x").await.unwrap_err();
392        assert!(matches!(err, FsError::Other(ref m) if m.contains("not supported")));
393    }
394}