返回 CodeWhale
workspace.rs
根目录 / crates / tui / src / runtime_api / workspace.rs
1 use std::io::Read as _;
2 use std::path::{Component, Path as FsPath, PathBuf};
3
4 use axum::Json;
5 use axum::extract::{Query, State};
6 use axum::http::StatusCode;
7 use base64::Engine as _;
8 use serde::{Deserialize, Serialize};
9 use sha2::{Digest, Sha256};
10
11 use crate::dependencies::Git;
12 use crate::snapshot::is_git_metadata_name;
13
14 use super::{ApiError, RuntimeApiState};
15
16 #[derive(Deserialize)]
17 #[serde(deny_unknown_fields)]
18 pub(super) struct WorkspaceFileSearchQuery {
19 #[serde(default)]
20 query: String,
21 limit: Option<usize>,
22 }
23
24 #[derive(Debug, Serialize)]
25 pub(super) struct WorkspaceFileSearchResponse {
26 paths: Vec<String>,
27 }
28
29 /// Read-only suggestions: never use the process cwd or a caller-selected root.
30 pub(super) async fn workspace_file_search(
31 State(state): State<RuntimeApiState>,
32 Query(query): Query<WorkspaceFileSearchQuery>,
33 ) -> Result<Json<WorkspaceFileSearchResponse>, ApiError> {
34 if query.query.len() > 256 {
35 return Err(ApiError::bad_request(
36 "query must be at most 256 UTF-8 bytes",
37 ));
38 }
39 let limit = query.limit.unwrap_or(20);
40 if !(1..=100).contains(&limit) {
41 return Err(ApiError::bad_request("limit must be between 1 and 100"));
42 }
43 if query.query.trim().is_empty() {
44 return Ok(Json(WorkspaceFileSearchResponse { paths: Vec::new() }));
45 }
46 let paths = tokio::task::spawn_blocking(move || {
47 collect_workspace_file_suggestions(&state.workspace, &query.query, limit)
48 })
49 .await
50 .map_err(|_| ApiError::internal("workspace file search failed"))??;
51 Ok(Json(WorkspaceFileSearchResponse { paths }))
52 }
53
54 fn collect_workspace_file_suggestions(
55 root: &FsPath,
56 query: &str,
57 limit: usize,
58 ) -> Result<Vec<String>, ApiError> {
59 use crate::working_set::{Workspace, rank_completion_candidates};
60
61 let root = root
62 .canonicalize()
63 .map_err(|_| ApiError::internal("workspace is unavailable"))?;
64 let workspace = Workspace::with_cwd(root.clone(), None);
65 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
66 // Reuse the composer's bounded discovery and ignore policy, without its
67 // divergent-cwd pass or symlink-directory traversal. No persistent index.
68 let candidates = workspace
69 .completion_discovery_candidates(20_000, &|| std::time::Instant::now() >= deadline);
70 Ok(
71 rank_completion_candidates(&candidates, query, candidates.len())
72 .into_iter()
73 .filter(|candidate| {
74 let path = FsPath::new(candidate);
75 path.components()
76 .all(|component| matches!(component, std::path::Component::Normal(_)))
77 && root
78 .join(path)
79 .canonicalize()
80 .is_ok_and(|resolved| resolved.starts_with(&root) && resolved.is_file())
81 })
82 .take(limit)
83 .collect(),
84 )
85 }
86
87 #[derive(Debug, Serialize)]
88 pub(super) struct WorkspaceStatusResponse {
89 pub(super) workspace: PathBuf,
90 pub(super) git_repo: bool,
91 pub(super) branch: Option<String>,
92 pub(super) head: Option<String>,
93 pub(super) dirty: bool,
94 pub(super) staged: usize,
95 pub(super) unstaged: usize,
96 pub(super) untracked: usize,
97 pub(super) ahead: Option<u32>,
98 pub(super) behind: Option<u32>,
99 }
100
101 #[derive(Debug, Default)]
102 pub(super) struct WorkspaceGitMetadata {
103 pub(super) branch: Option<String>,
104 pub(super) head: Option<String>,
105 pub(super) dirty: bool,
106 }
107
108 /// Status is several blocking `git` spawns, so it runs off the async workers
109 /// (#6149) like every other git read on this surface. It deliberately keeps
110 /// normal Git filters and untracked settings rather than the full review
111 /// command, so its counts match `git status` and the operator writes (see
112 /// `git.rs`), but it never runs a repository-configured helper: fsmonitor and
113 /// hooks are off, as on every read-only review path (see `run_git`).
114 pub(super) async fn workspace_status(
115 State(state): State<RuntimeApiState>,
116 ) -> Result<Json<WorkspaceStatusResponse>, ApiError> {
117 let workspace = state.workspace.clone();
118 tokio::task::spawn_blocking(move || collect_workspace_status(&workspace))
119 .await
120 .map(Json)
121 .map_err(|_| ApiError::internal("workspace status failed"))
122 }
123
124 pub(super) fn collect_workspace_status(workspace: &FsPath) -> WorkspaceStatusResponse {
125 let mut status = WorkspaceStatusResponse {
126 workspace: workspace.to_path_buf(),
127 git_repo: false,
128 branch: None,
129 head: None,
130 dirty: false,
131 staged: 0,
132 unstaged: 0,
133 untracked: 0,
134 ahead: None,
135 behind: None,
136 };
137
138 let Some(repo_check) = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]) else {
139 return status;
140 };
141 if repo_check.trim() != "true" {
142 return status;
143 }
144
145 status.git_repo = true;
146 let metadata = collect_workspace_git_metadata(workspace);
147 status.branch = metadata.branch;
148 status.head = metadata.head;
149 status.dirty = metadata.dirty;
150
151 if let Some(porcelain) = run_git(workspace, &["status", "--porcelain=v1"]) {
152 for line in porcelain.lines() {
153 if line.starts_with("??") {
154 status.untracked += 1;
155 continue;
156 }
157 let chars: Vec<char> = line.chars().collect();
158 if chars.len() >= 2 {
159 if chars[0] != ' ' {
160 status.staged += 1;
161 }
162 if chars[1] != ' ' {
163 status.unstaged += 1;
164 }
165 }
166 }
167 }
168
169 if let Some(counts) = run_git(
170 workspace,
171 &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
172 ) {
173 let mut parts = counts.split_whitespace();
174 if let (Some(behind), Some(ahead)) = (parts.next(), parts.next()) {
175 status.behind = behind.parse::<u32>().ok();
176 status.ahead = ahead.parse::<u32>().ok();
177 }
178 }
179
180 status
181 }
182
183 pub(super) fn collect_workspace_git_metadata(workspace: &FsPath) -> WorkspaceGitMetadata {
184 let Some(repo_check) = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]) else {
185 return WorkspaceGitMetadata::default();
186 };
187 if repo_check.trim() != "true" {
188 return WorkspaceGitMetadata::default();
189 }
190
191 WorkspaceGitMetadata {
192 branch: current_git_branch(workspace),
193 head: current_git_head(workspace),
194 dirty: run_git(workspace, &["status", "--porcelain=v1"])
195 .is_some_and(|porcelain| !porcelain.trim().is_empty()),
196 }
197 }
198
199 /// Read-only git for status and metadata. `Git::review_base` disables
200 /// `core.fsmonitor`, `core.hooksPath`, lazy fetch and replace objects: a
201 /// status poll must not execute a helper the repository's config names.
202 fn run_git(workspace: &FsPath, args: &[&str]) -> Option<String> {
203 let mut command = Git::review_base(workspace).ok()?;
204 let output = command.args(args).output().ok()?;
205 if !output.status.success() {
206 return None;
207 }
208 String::from_utf8(output.stdout).ok()
209 }
210
211 fn current_git_branch(workspace: &FsPath) -> Option<String> {
212 let repo_check = run_git(workspace, &["rev-parse", "--is-inside-work-tree"])?;
213 if repo_check.trim() != "true" {
214 return None;
215 }
216 let branch = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"])?;
217 let branch = branch.trim();
218 if branch.is_empty() {
219 return None;
220 }
221 if branch != "HEAD" {
222 return Some(branch.to_string());
223 }
224 let short_hash = run_git(workspace, &["rev-parse", "--short", "HEAD"])?;
225 let short_hash = short_hash.trim();
226 (!short_hash.is_empty()).then(|| format!("detached@{short_hash}"))
227 }
228
229 fn current_git_head(workspace: &FsPath) -> Option<String> {
230 let head = run_git(workspace, &["rev-parse", "--short", "HEAD"])?;
231 let head = head.trim();
232 (!head.is_empty()).then(|| head.to_string())
233 }
234
235 // ---------------------------------------------------------------------------
236 // Workspace files (#6163): a bounded directory listing, bounded reads that
237 // carry a content revision, and revision-checked writes. The server's
238 // configured workspace is the only root and every path is workspace-relative
239 // with `/` separators. Symlinks are never followed, `.git` is never served,
240 // and there is no second file store: bytes go straight to the workspace
241 // through the same confined opener Fleet artifacts use.
242 // ---------------------------------------------------------------------------
243
244 const FILE_LIST_LIMIT_DEFAULT: usize = 200;
245 const FILE_LIST_LIMIT_MAX: usize = 2_000;
246 pub(super) const FILE_READ_LIMIT_DEFAULT: usize = 256 * 1024;
247 pub(super) const FILE_READ_LIMIT_MAX: usize = 4 * 1024 * 1024;
248 /// Files above this size are not served at all: the revision is a digest of
249 /// the whole file, and a Files browser should not page through larger blobs.
250 pub(super) const FILE_SERVE_MAX_BYTES: u64 = 16 * 1024 * 1024;
251 pub(super) const FILE_WRITE_MAX_BYTES: usize = 4 * 1024 * 1024;
252 /// Request-body ceiling for the write route: the content cap plus headroom for
253 /// base64 expansion and the JSON envelope, so an oversized `content` reaches
254 /// the handler and gets a 413 with a message instead of a dropped connection.
255 pub(super) const FILE_WRITE_BODY_LIMIT_BYTES: usize = FILE_WRITE_MAX_BYTES * 4 / 3 + 64 * 1024;
256 const FILE_PATH_MAX_BYTES: usize = 4_096;
257
258 #[derive(Deserialize)]
259 #[serde(deny_unknown_fields)]
260 pub(super) struct WorkspaceFilesListQuery {
261 #[serde(default)]
262 path: String,
263 limit: Option<usize>,
264 }
265
266 #[derive(Debug, Serialize)]
267 pub(super) struct WorkspaceFileEntry {
268 name: String,
269 path: String,
270 /// `file`, `directory`, `symlink` (listed by name, never followed) or `other`.
271 kind: &'static str,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 size: Option<u64>,
274 #[serde(skip_serializing_if = "Option::is_none")]
275 modified: Option<String>,
276 }
277
278 #[derive(Debug, Serialize)]
279 pub(super) struct WorkspaceFilesListResponse {
280 path: String,
281 entries: Vec<WorkspaceFileEntry>,
282 truncated: bool,
283 }
284
285 #[derive(Deserialize)]
286 #[serde(deny_unknown_fields)]
287 pub(super) struct WorkspaceFileReadQuery {
288 path: String,
289 offset: Option<usize>,
290 limit: Option<usize>,
291 }
292
293 #[derive(Debug, Serialize)]
294 pub(super) struct WorkspaceFileReadResponse {
295 path: String,
296 size: u64,
297 /// SHA-256 of the whole file, not of the returned window.
298 revision: String,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 modified: Option<String>,
301 offset: usize,
302 bytes: usize,
303 truncated: bool,
304 encoding: &'static str,
305 content: String,
306 }
307
308 #[derive(Deserialize)]
309 #[serde(deny_unknown_fields)]
310 pub(super) struct WorkspaceFileWriteRequest {
311 path: String,
312 content: String,
313 /// `utf-8` (default) or `base64`.
314 #[serde(default)]
315 encoding: Option<String>,
316 /// Required to overwrite an existing file; must be absent for a new one.
317 #[serde(default)]
318 expected_revision: Option<String>,
319 }
320
321 #[derive(Debug, Serialize)]
322 pub(super) struct WorkspaceFileWriteResponse {
323 path: String,
324 size: u64,
325 revision: String,
326 created: bool,
327 written_at: String,
328 }
329
330 /// Bytes of one confined file plus the facts a client needs to reason about
331 /// them: total size, the whole-file revision and the modification time.
332 pub(super) struct ConfinedFileBytes {
333 pub(super) size: u64,
334 pub(super) revision: String,
335 pub(super) modified: Option<String>,
336 pub(super) bytes: Vec<u8>,
337 }
338
339 pub(super) fn parse_read_window(
340 offset: Option<usize>,
341 limit: Option<usize>,
342 ) -> Result<(usize, usize), ApiError> {
343 let limit = limit.unwrap_or(FILE_READ_LIMIT_DEFAULT);
344 if !(1..=FILE_READ_LIMIT_MAX).contains(&limit) {
345 return Err(ApiError::bad_request(format!(
346 "limit must be between 1 and {FILE_READ_LIMIT_MAX} bytes"
347 )));
348 }
349 Ok((offset.unwrap_or(0), limit))
350 }
351
352 /// Byte window `[offset, offset + limit)` clamped to the content.
353 pub(super) fn read_window(bytes: &[u8], offset: usize, limit: usize) -> (&[u8], bool) {
354 let start = offset.min(bytes.len());
355 let end = start.saturating_add(limit).min(bytes.len());
356 (&bytes[start..end], end < bytes.len())
357 }
358
359 /// Text windows come back as UTF-8; anything with a NUL byte, invalid UTF-8,
360 /// or a window that splits a multi-byte character comes back as base64.
361 pub(super) fn encode_window(window: &[u8]) -> (&'static str, String) {
362 if !window.contains(&0)
363 && let Ok(text) = std::str::from_utf8(window)
364 {
365 return ("utf-8", text.to_string());
366 }
367 (
368 "base64",
369 base64::engine::general_purpose::STANDARD.encode(window),
370 )
371 }
372
373 pub(super) fn content_revision(bytes: &[u8]) -> String {
374 Sha256::digest(bytes)
375 .iter()
376 .map(|byte| format!("{byte:02x}"))
377 .collect()
378 }
379
380 fn rfc3339(time: std::time::SystemTime) -> String {
381 chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339()
382 }
383
384 pub(super) fn map_fs_error(error: std::io::Error, what: &str) -> ApiError {
385 use std::io::ErrorKind;
386 match error.kind() {
387 ErrorKind::NotFound => ApiError::not_found(format!("{what} not found")),
388 ErrorKind::PermissionDenied => ApiError::forbidden(format!("{what} is not readable")),
389 ErrorKind::InvalidInput | ErrorKind::InvalidData => ApiError::forbidden(format!(
390 "{what} must be a regular, non-linked path inside the workspace"
391 )),
392 ErrorKind::Unsupported => {
393 ApiError::not_implemented("confined file access is unavailable on this platform")
394 }
395 _ => ApiError::internal(format!("{what} access failed: {error}")),
396 }
397 }
398
399 /// A workspace-relative request path. Empty and `.` mean the root, which only
400 /// listing accepts. Absolute paths, `..`, backslashes and `.git` are refused.
401 pub(super) fn relative_request_path(raw: &str, allow_root: bool) -> Result<PathBuf, ApiError> {
402 let trimmed = raw.trim();
403 if trimmed.len() > FILE_PATH_MAX_BYTES {
404 return Err(ApiError::bad_request(format!(
405 "path must be at most {FILE_PATH_MAX_BYTES} UTF-8 bytes"
406 )));
407 }
408 if trimmed.contains('\\') {
409 return Err(ApiError::bad_request("path must use / separators"));
410 }
411 if trimmed.starts_with('/') || FsPath::new(trimmed).is_absolute() {
412 return Err(ApiError::bad_request("path must be workspace-relative"));
413 }
414 let trimmed = trimmed.trim_end_matches('/');
415 if trimmed.is_empty() || trimmed == "." {
416 return if allow_root {
417 Ok(PathBuf::new())
418 } else {
419 Err(ApiError::bad_request("path is required"))
420 };
421 }
422 let path = PathBuf::from(trimmed);
423 if !crate::fleet::files::path_is_confined(&path) {
424 return Err(ApiError::bad_request(
425 "path must be workspace-relative without . or .. components",
426 ));
427 }
428 if path
429 .components()
430 .any(|component| matches!(component, Component::Normal(name) if is_git_metadata_name(name)))
431 {
432 return Err(ApiError::forbidden("the .git directory is not served"));
433 }
434 Ok(path)
435 }
436
437 pub(super) fn canonical_workspace(workspace: &FsPath) -> Result<PathBuf, ApiError> {
438 workspace
439 .canonicalize()
440 .map_err(|_| ApiError::internal("workspace is unavailable"))
441 }
442
443 fn relative_display(path: &FsPath) -> String {
444 path.components()
445 .filter_map(|component| match component {
446 Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
447 _ => None,
448 })
449 .collect::<Vec<_>>()
450 .join("/")
451 }
452
453 /// Walk `relative` from the root one component at a time, refusing any link
454 /// or reparse point on the way, and confirm the result is a directory that
455 /// still resolves inside the root.
456 fn confined_directory(root: &FsPath, relative: &FsPath) -> Result<PathBuf, ApiError> {
457 let mut directory = root.to_path_buf();
458 for component in relative.components() {
459 directory.push(component);
460 let metadata = std::fs::symlink_metadata(&directory)
461 .map_err(|error| map_fs_error(error, "directory"))?;
462 if metadata.file_type().is_symlink()
463 || crate::plugins::metadata_is_link_or_reparse(&metadata)
464 {
465 return Err(ApiError::forbidden("symlinks are not followed"));
466 }
467 if !metadata.is_dir() {
468 return Err(ApiError::bad_request("path is not a directory"));
469 }
470 }
471 let resolved = directory
472 .canonicalize()
473 .map_err(|error| map_fs_error(error, "directory"))?;
474 if !resolved.starts_with(root) {
475 return Err(ApiError::forbidden("path resolves outside the workspace"));
476 }
477 Ok(directory)
478 }
479
480 pub(super) async fn workspace_files_list(
481 State(state): State<RuntimeApiState>,
482 Query(query): Query<WorkspaceFilesListQuery>,
483 ) -> Result<Json<WorkspaceFilesListResponse>, ApiError> {
484 let relative = relative_request_path(&query.path, true)?;
485 let limit = query.limit.unwrap_or(FILE_LIST_LIMIT_DEFAULT);
486 if !(1..=FILE_LIST_LIMIT_MAX).contains(&limit) {
487 return Err(ApiError::bad_request(format!(
488 "limit must be between 1 and {FILE_LIST_LIMIT_MAX}"
489 )));
490 }
491 let workspace = state.workspace.clone();
492 tokio::task::spawn_blocking(move || list_workspace_directory(&workspace, &relative, limit))
493 .await
494 .map_err(|_| ApiError::internal("workspace listing failed"))?
495 .map(Json)
496 }
497
498 fn list_workspace_directory(
499 workspace: &FsPath,
500 relative: &FsPath,
501 limit: usize,
502 ) -> Result<WorkspaceFilesListResponse, ApiError> {
503 let root = canonical_workspace(workspace)?;
504 let directory = confined_directory(&root, relative)?;
505 let mut entries = Vec::new();
506 let read_dir =
507 std::fs::read_dir(&directory).map_err(|error| map_fs_error(error, "directory"))?;
508 for entry in read_dir {
509 let entry = entry.map_err(|error| map_fs_error(error, "directory"))?;
510 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
511 continue;
512 };
513 if is_git_metadata_name(std::ffi::OsStr::new(&name)) {
514 continue;
515 }
516 let Ok(file_type) = entry.file_type() else {
517 continue;
518 };
519 let metadata = entry.metadata().ok();
520 let is_link = file_type.is_symlink()
521 || metadata
522 .as_ref()
523 .is_some_and(crate::plugins::metadata_is_link_or_reparse);
524 let kind = if is_link {
525 "symlink"
526 } else if file_type.is_dir() {
527 "directory"
528 } else if file_type.is_file() {
529 "file"
530 } else {
531 "other"
532 };
533 let file_metadata = (kind == "file").then_some(metadata).flatten();
534 entries.push(WorkspaceFileEntry {
535 path: relative_display(&relative.join(&name)),
536 name,
537 kind,
538 size: file_metadata.as_ref().map(std::fs::Metadata::len),
539 modified: file_metadata
540 .as_ref()
541 .and_then(|metadata| metadata.modified().ok())
542 .map(rfc3339),
543 });
544 }
545 entries.sort_by(|left, right| {
546 (left.kind != "directory")
547 .cmp(&(right.kind != "directory"))
548 .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
549 .then_with(|| left.name.cmp(&right.name))
550 });
551 let truncated = entries.len() > limit;
552 entries.truncate(limit);
553 Ok(WorkspaceFilesListResponse {
554 path: relative_display(relative),
555 entries,
556 truncated,
557 })
558 }
559
560 pub(super) fn open_confined_file(
561 root: &FsPath,
562 relative: &FsPath,
563 create: bool,
564 ) -> Result<crate::fleet::files::WorkspaceFile, ApiError> {
565 crate::fleet::files::WorkspaceFile::open(root, relative, create)
566 .map_err(|error| map_fs_error(error, "file"))
567 }
568
569 /// Read one confined file completely (bounded by `FILE_SERVE_MAX_BYTES`) so
570 /// the revision always describes the whole file.
571 pub(super) fn read_confined_bytes(
572 file: &crate::fleet::files::WorkspaceFile,
573 ) -> Result<ConfinedFileBytes, ApiError> {
574 let mut handle = file
575 .open_file()
576 .map_err(|error| map_fs_error(error, "file"))?;
577 let metadata = handle
578 .metadata()
579 .map_err(|error| map_fs_error(error, "file"))?;
580 if metadata.len() > FILE_SERVE_MAX_BYTES {
581 return Err(ApiError::payload_too_large(format!(
582 "file is larger than the {FILE_SERVE_MAX_BYTES}-byte serving limit"
583 )));
584 }
585 let mut bytes = Vec::with_capacity(metadata.len() as usize);
586 handle
587 .by_ref()
588 .take(FILE_SERVE_MAX_BYTES + 1)
589 .read_to_end(&mut bytes)
590 .map_err(|error| map_fs_error(error, "file"))?;
591 if bytes.len() as u64 > FILE_SERVE_MAX_BYTES {
592 return Err(ApiError::payload_too_large(format!(
593 "file grew past the {FILE_SERVE_MAX_BYTES}-byte serving limit while it was read"
594 )));
595 }
596 Ok(ConfinedFileBytes {
597 size: bytes.len() as u64,
598 revision: content_revision(&bytes),
599 modified: metadata.modified().ok().map(rfc3339),
600 bytes,
601 })
602 }
603
604 /// Refuse links and non-files before the confined opener runs, so a client
605 /// sees a precise status instead of a generic confinement error.
606 pub(super) fn precheck_file_target(
607 root: &FsPath,
608 relative: &FsPath,
609 ) -> Result<Option<std::fs::Metadata>, ApiError> {
610 if let Some(parent) = relative
611 .parent()
612 .filter(|parent| !parent.as_os_str().is_empty())
613 {
614 // Every directory on the way must be a real directory; a link would
615 // otherwise surface as a confinement error from the opener.
616 confined_directory(root, parent).or_else(|error| {
617 if error.status == StatusCode::NOT_FOUND {
618 Ok(PathBuf::new())
619 } else {
620 Err(error)
621 }
622 })?;
623 }
624 match std::fs::symlink_metadata(root.join(relative)) {
625 Ok(metadata) => {
626 if metadata.file_type().is_symlink()
627 || crate::plugins::metadata_is_link_or_reparse(&metadata)
628 {
629 return Err(ApiError::forbidden("symlinks are not followed"));
630 }
631 if metadata.is_dir() {
632 return Err(ApiError::bad_request("path is a directory"));
633 }
634 Ok(Some(metadata))
635 }
636 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
637 Err(error) => Err(map_fs_error(error, "file")),
638 }
639 }
640
641 pub(super) async fn workspace_file_read(
642 State(state): State<RuntimeApiState>,
643 Query(query): Query<WorkspaceFileReadQuery>,
644 ) -> Result<Json<WorkspaceFileReadResponse>, ApiError> {
645 let relative = relative_request_path(&query.path, false)?;
646 let (offset, limit) = parse_read_window(query.offset, query.limit)?;
647 let workspace = state.workspace.clone();
648 tokio::task::spawn_blocking(move || {
649 let root = canonical_workspace(&workspace)?;
650 if precheck_file_target(&root, &relative)?.is_none() {
651 return Err(ApiError::not_found("file not found"));
652 }
653 let file = open_confined_file(&root, &relative, false)?;
654 let read = read_confined_bytes(&file)?;
655 let (window, truncated) = read_window(&read.bytes, offset, limit);
656 let (encoding, content) = encode_window(window);
657 Ok(WorkspaceFileReadResponse {
658 path: relative_display(&relative),
659 size: read.size,
660 revision: read.revision,
661 modified: read.modified,
662 offset: offset.min(read.bytes.len()),
663 bytes: window.len(),
664 truncated,
665 encoding,
666 content,
667 })
668 })
669 .await
670 .map_err(|_| ApiError::internal("workspace file read failed"))?
671 .map(Json)
672 }
673
674 pub(super) async fn workspace_file_write(
675 State(state): State<RuntimeApiState>,
676 Json(request): Json<WorkspaceFileWriteRequest>,
677 ) -> Result<(StatusCode, Json<WorkspaceFileWriteResponse>), ApiError> {
678 let relative = relative_request_path(&request.path, false)?;
679 let bytes = match request.encoding.as_deref().unwrap_or("utf-8") {
680 "utf-8" => request.content.into_bytes(),
681 "base64" => base64::engine::general_purpose::STANDARD
682 .decode(request.content.as_bytes())
683 .map_err(|_| ApiError::bad_request("content is not valid base64"))?,
684 _ => return Err(ApiError::bad_request("encoding must be utf-8 or base64")),
685 };
686 if bytes.len() > FILE_WRITE_MAX_BYTES {
687 return Err(ApiError::payload_too_large(format!(
688 "content must be at most {FILE_WRITE_MAX_BYTES} bytes"
689 )));
690 }
691 let expected_revision = request
692 .expected_revision
693 .map(|revision| revision.trim().to_ascii_lowercase())
694 .filter(|revision| !revision.is_empty());
695 let workspace = state.workspace.clone();
696 tokio::task::spawn_blocking(move || {
697 write_workspace_file(&workspace, &relative, &bytes, expected_revision.as_deref())
698 })
699 .await
700 .map_err(|_| ApiError::internal("workspace file write failed"))?
701 }
702
703 fn write_workspace_file(
704 workspace: &FsPath,
705 relative: &FsPath,
706 bytes: &[u8],
707 expected_revision: Option<&str>,
708 ) -> Result<(StatusCode, Json<WorkspaceFileWriteResponse>), ApiError> {
709 let root = canonical_workspace(workspace)?;
710 let created = precheck_file_target(&root, relative)?.is_none();
711 match (created, expected_revision) {
712 (true, Some(_)) => {
713 return Err(ApiError::conflict(
714 "expected_revision was given but the file does not exist",
715 ));
716 }
717 (false, None) => {
718 return Err(ApiError::conflict(
719 "expected_revision is required to overwrite an existing file; read it first",
720 ));
721 }
722 _ => {}
723 }
724 // Parents are created only for a new file, and only through the confined
725 // opener, which refuses links at every component. This is the user's own
726 // file, not a private store: keep its mode on edit, follow the umask on
727 // creation.
728 let file = crate::fleet::files::WorkspaceFile::open_shared(&root, relative, created)
729 .map_err(|error| map_fs_error(error, "file"))?;
730 if let Some(expected) = expected_revision {
731 let current = read_confined_bytes(&file)?;
732 if current.revision != expected {
733 return Err(ApiError::conflict(format!(
734 "file changed since it was read; current revision is {}",
735 current.revision
736 )));
737 }
738 }
739 file.replace(bytes)
740 .map_err(|error| map_fs_error(error, "file"))?;
741 Ok((
742 if created {
743 StatusCode::CREATED
744 } else {
745 StatusCode::OK
746 },
747 Json(WorkspaceFileWriteResponse {
748 path: relative_display(relative),
749 size: bytes.len() as u64,
750 revision: content_revision(bytes),
751 created,
752 written_at: chrono::Utc::now().to_rfc3339(),
753 }),
754 ))
755 }
756
757 // ── Effective instruction sources (#6168) ────────────────────────────────
758
759 #[derive(Debug, Serialize)]
760 pub(super) struct WorkspaceInstructionSource {
761 /// `project` | `rule` | `global` | `fragment` | `configured` |
762 /// `constitution` | `ignored`.
763 kind: &'static str,
764 /// `loaded` | `shadowed` | `skipped` | `missing`.
765 status: &'static str,
766 scope_dir: PathBuf,
767 path: PathBuf,
768 /// Workspace-relative spelling when the path sits under the workspace.
769 relative_path: Option<String>,
770 exists: bool,
771 bytes: Option<u64>,
772 warning: Option<String>,
773 }
774
775 #[derive(Debug, Serialize)]
776 pub(super) struct WorkspaceInstructionsResponse {
777 workspace: PathBuf,
778 sources: Vec<WorkspaceInstructionSource>,
779 /// Foreign instruction formats the operator opted into.
780 foreign_imports: Vec<String>,
781 /// True when no file-based instructions exist anywhere and the
782 /// prompt carries the ephemeral generated context instead.
783 generated_fallback: bool,
784 /// Aggregate byte ceiling applied across instructions and rules at
785 /// render (`project_context::MAX_PROJECT_INSTRUCTION_BYTES`).
786 aggregate_budget_bytes: usize,
787 /// Warnings from the real load pass — includes assembly-level notices
788 /// (unimported foreign formats, ignored WHALE.md, constitution parse)
789 /// alongside what per-source `warning` fields already carry.
790 warnings: Vec<String>,
791 }
792
793 fn instruction_source_kind_name(
794 kind: crate::project_context::InstructionSourceKind,
795 ) -> &'static str {
796 use crate::project_context::InstructionSourceKind as Kind;
797 match kind {
798 Kind::Project => "project",
799 Kind::Rule => "rule",
800 Kind::Global => "global",
801 Kind::Fragment => "fragment",
802 Kind::Configured => "configured",
803 Kind::Constitution => "constitution",
804 Kind::Ignored => "ignored",
805 }
806 }
807
808 fn instruction_source_status_name(
809 status: crate::project_context::InstructionSourceStatus,
810 ) -> &'static str {
811 use crate::project_context::InstructionSourceStatus as Status;
812 match status {
813 Status::Loaded => "loaded",
814 Status::Shadowed => "shadowed",
815 Status::Skipped => "skipped",
816 Status::Missing => "missing",
817 }
818 }
819
820 /// Read-only listing of the effective instruction sources for this
821 /// workspace (#6168): repository-root → workspace chain candidates, rules
822 /// files, the global fallback layer, opted-in foreign fragments, configured
823 /// `instructions = [...]` files, and the constitution — each with the status
824 /// the prompt loaders give it. Edits go through the workspace file routes.
825 pub(super) async fn workspace_instructions(
826 State(state): State<RuntimeApiState>,
827 ) -> Result<Json<WorkspaceInstructionsResponse>, ApiError> {
828 let workspace = state.workspace.clone();
829 let home = crate::config::effective_home_dir();
830 let configured = state.config.read().instructions_paths();
831
832 let (sources, generated_fallback, warnings, workspace_root) =
833 tokio::task::spawn_blocking(move || {
834 let sources = crate::project_context::project_instruction_sources(
835 &workspace,
836 home.as_deref(),
837 &configured,
838 );
839 // The real load pass supplies assembly-level warnings and tells us
840 // whether the ephemeral generated context is what the prompt
841 // carries. Cached — this is the same call the engine makes.
842 let ctx = crate::project_context::load_project_context_with_parents(&workspace);
843 let generated_fallback = ctx.instructions.is_some() && ctx.source_path.is_none();
844 // `project_instruction_sources` canonicalizes the workspace (the
845 // `/var` → `/private/var` class of alias), so relative paths must
846 // be computed against the canonical spelling or every strip fails.
847 // It rides this closure rather than the async body because
848 // `canonicalize` is a blocking syscall, and one on a Tokio worker
849 // is one too many (#6149).
850 let workspace_root =
851 std::fs::canonicalize(&workspace).unwrap_or_else(|_| workspace.clone());
852 (sources, generated_fallback, ctx.warnings, workspace_root)
853 })
854 .await
855 .map_err(|_| ApiError::internal("instruction source listing failed"))?;
856 Ok(Json(WorkspaceInstructionsResponse {
857 workspace: workspace_root.clone(),
858 sources: sources
859 .into_iter()
860 .map(|source| WorkspaceInstructionSource {
861 kind: instruction_source_kind_name(source.kind),
862 status: instruction_source_status_name(source.status),
863 // Forward slashes on every platform. `display()` emits the
864 // native separator, which would make a wire field describing a
865 // repo-relative path differ between a Windows and a Unix host
866 // and force every client to branch on the server's OS. The
867 // absolute `path` below stays native, because that one is only
868 // meaningful on the machine that produced it.
869 relative_path: source
870 .path
871 .strip_prefix(&workspace_root)
872 .ok()
873 .map(|relative| {
874 relative
875 .components()
876 .map(|part| part.as_os_str().to_string_lossy())
877 .collect::<Vec<_>>()
878 .join("/")
879 }),
880 scope_dir: source.scope_dir,
881 path: source.path,
882 exists: source.exists,
883 bytes: source.bytes,
884 warning: source.warning,
885 })
886 .collect(),
887 foreign_imports: crate::project_context::foreign_instruction_imports()
888 .keys()
889 .into_iter()
890 .map(str::to_string)
891 .collect(),
892 generated_fallback,
893 aggregate_budget_bytes: crate::project_context::MAX_PROJECT_INSTRUCTION_BYTES,
894 warnings,
895 }))
896 }
897
898 #[cfg(test)]
899 mod tests {
900 use super::*;
901 #[cfg(unix)]
902 use crate::dependencies::ExternalTool as _;
903
904 #[cfg(unix)]
905 #[test]
906 fn workspace_status_does_not_run_the_repository_fsmonitor() -> anyhow::Result<()> {
907 use std::os::unix::fs::PermissionsExt;
908 let tmp = tempfile::tempdir()?;
909 let repo = tmp.path().join("repo");
910 std::fs::create_dir_all(&repo)?;
911 let git = |args: &[&str]| Git::status(args, &repo).is_ok_and(|status| status.success());
912 if !git(&["init", "-q"]) {
913 return Ok(());
914 }
915 std::fs::write(repo.join("file.txt"), "hello\n")?;
916 assert!(git(&["add", "file.txt"]));
917 assert!(git(&[
918 "-c",
919 "user.name=t",
920 "-c",
921 "user.email=t@example.invalid",
922 "commit",
923 "-qm",
924 "init",
925 ]));
926 std::fs::write(repo.join("file.txt"), "hello\nworld\n")?;
927 let marker = tmp.path().join("ran");
928 let hook = tmp.path().join("fsmonitor.sh");
929 std::fs::write(
930 &hook,
931 format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()),
932 )?;
933 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))?;
934 assert!(git(&["config", "core.fsmonitor", &hook.to_string_lossy()]));
935
936 let status = collect_workspace_status(&repo);
937 assert!(status.git_repo);
938 assert_eq!(status.unstaged, 1);
939 assert!(!marker.exists(), "the repository's fsmonitor hook ran");
940 Ok(())
941 }
942
943 #[test]
944 fn git_metadata_directory_is_refused_in_any_letter_case() {
945 for raw in [
946 ".git",
947 ".git/config",
948 ".GIT/config",
949 ".Git/hooks/pre-commit",
950 "sub/.gIt/HEAD",
951 ] {
952 let error = relative_request_path(raw, false).expect_err(raw);
953 assert_eq!(error.status, StatusCode::FORBIDDEN, "{raw}");
954 }
955 for raw in [".github/workflows/ci.yml", "a.git/b", ".gitignore", "git"] {
956 assert!(relative_request_path(raw, false).is_ok(), "{raw}");
957 }
958 }
959
960 #[test]
961 fn listing_hides_git_metadata_directory_in_any_letter_case() -> anyhow::Result<()> {
962 let tmp = tempfile::tempdir()?;
963 std::fs::create_dir_all(tmp.path().join(".Git"))?;
964 std::fs::write(tmp.path().join("kept.txt"), "kept")?;
965 let listing = list_workspace_directory(tmp.path(), FsPath::new(""), 100)
966 .map_err(|error| anyhow::anyhow!(error.message))?;
967 let names: Vec<&str> = listing
968 .entries
969 .iter()
970 .map(|entry| entry.name.as_str())
971 .collect();
972 assert_eq!(names, ["kept.txt"]);
973 Ok(())
974 }
975
976 #[test]
977 fn workspace_file_search_reuses_discovery_ignores() -> anyhow::Result<()> {
978 let tmp = tempfile::tempdir()?;
979 let root = tmp.path();
980 std::fs::create_dir_all(root.join(".git"))?;
981 std::fs::create_dir_all(root.join(".agents"))?;
982 std::fs::create_dir_all(root.join("target"))?;
983 std::fs::write(root.join(".gitignore"), ".agents/\ntarget/\nlocal.rs\n")?;
984 std::fs::write(root.join(".ignore"), "blocked.rs\n")?;
985 std::fs::write(root.join(".deepseekignore"), "private.rs\n")?;
986 for name in [
987 "local.rs",
988 "blocked.rs",
989 "private.rs",
990 ".agents/guide.rs",
991 "target/build.rs",
992 ] {
993 std::fs::write(root.join(name), "not returned")?;
994 }
995 let paths = collect_workspace_file_suggestions(root, ".rs", 100)
996 .map_err(|error| anyhow::anyhow!(error.message))?;
997 assert_eq!(paths, [".agents/guide.rs", "local.rs"]);
998 Ok(())
999 }
1000
1001 #[cfg(unix)]
1002 #[test]
1003 fn workspace_file_search_contains_symlinks_and_excludes_other_workspaces() -> anyhow::Result<()>
1004 {
1005 use std::os::unix::fs::symlink;
1006
1007 let tmp = tempfile::tempdir()?;
1008 let root = tmp.path().join("workspace");
1009 let other = tmp.path().join("workspace-other");
1010 std::fs::create_dir_all(&root)?;
1011 std::fs::create_dir_all(&other)?;
1012 std::fs::write(root.join("inside.rs"), "inside")?;
1013 std::fs::write(other.join("outside.rs"), "outside")?;
1014 symlink(root.join("inside.rs"), root.join("alias.rs"))?;
1015 symlink(other.join("outside.rs"), root.join("escape.rs"))?;
1016 symlink(&other, root.join("external-directory"))?;
1017 symlink(&other, root.join(".agents"))?;
1018 symlink(other.join("missing.rs"), root.join("broken.rs"))?;
1019 symlink(&root, root.join("loop"))?;
1020 let paths = collect_workspace_file_suggestions(&root, ".rs", 100)
1021 .map_err(|error| anyhow::anyhow!(error.message))?;
1022 assert_eq!(paths, ["alias.rs", "inside.rs"]);
1023 Ok(())
1024 }
1025 }
1026
1026 lines RUST