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