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}