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}