mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
feat(runtime-api): serve workspace files and session artifacts for native clients (#6163)
GPUI's Files and Preview modules probed GET /v1/artifacts and GET /v1/files
and found neither. Reconciled against the route table: file suggestions
(/v1/workspace/files/search) and /v1/workspace/status already existed, but
there was no listing, no bounded read, and no revision-checked write over
HTTP; artifacts in Core are session-scoped ArtifactRecords stored under
sessions/<id>/artifacts/, which no route exposed.
Add GET /v1/workspace/files (bounded, sandboxed directory listing; .git is
never served and symlinks are listed by kind but never followed),
GET /v1/workspace/files/read (a bounded byte window with a whole-file SHA-256
revision, utf-8 or base64), and PUT /v1/workspace/files (atomic write through
the existing confined WorkspaceFile opener; 201 on create, 200 on an
overwrite whose expected_revision matches, 409 on drift naming the current
revision, 413 above 4 MiB with a route-level body limit sized so the handler
answers instead of dropping the connection). Add
GET /v1/sessions/{id}/artifacts and GET /v1/sessions/{id}/artifacts/{artifact_id}
over the existing records, rooted at the server's sessions dir. No second file
store, cache or index; the workspace root is the server's configured workspace.
Gates: cargo fmt --check clean; clippy (CI flags) exit 0;
runtime_api:: under scripts/with-hermetic-test-home.sh + nextest ci profile:
241 passed, 0 failed; npm test 66+14+470 passed; npm run check:web exit 0;
check-versions.sh exit 0. Live probe against an isolated app-server built
from this tree: 15 requests, every status as documented, nothing written
outside the workspace.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
433685b202
commit
1758b78a68
8 files changed
+1298
-6
No files matched your search
@@ -20,6 +20,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
blank lines, and the toast names the copied cell count.
|
||||
`tui.selection_copy_markdown = false` keeps the rendered-text payload
|
||||
(#6156).
|
||||
- The Runtime API serves the workspace files a native client browses and edits:
|
||||
`GET /v1/workspace/files` lists one directory, `GET /v1/workspace/files/read`
|
||||
returns a bounded byte window with a whole-file SHA-256 revision, and
|
||||
`PUT /v1/workspace/files` writes atomically through the confined opener with
|
||||
revision-checked overwrites (409 on drift). `.git` is never served and
|
||||
symlinks are never followed. A saved session's oversized tool outputs are
|
||||
served as artifacts at `GET /v1/sessions/{id}/artifacts` and
|
||||
`GET /v1/sessions/{id}/artifacts/{artifact_id}`. (#6163)
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
blank lines, and the toast names the copied cell count.
|
||||
`tui.selection_copy_markdown = false` keeps the rendered-text payload
|
||||
(#6156).
|
||||
- The Runtime API serves the workspace files a native client browses and edits:
|
||||
`GET /v1/workspace/files` lists one directory, `GET /v1/workspace/files/read`
|
||||
returns a bounded byte window with a whole-file SHA-256 revision, and
|
||||
`PUT /v1/workspace/files` writes atomically through the confined opener with
|
||||
revision-checked overwrites (409 on drift). `.git` is never served and
|
||||
symlinks are never followed. A saved session's oversized tool outputs are
|
||||
served as artifacts at `GET /v1/sessions/{id}/artifacts` and
|
||||
`GET /v1/sessions/{id}/artifacts/{artifact_id}`. (#6163)
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -105,14 +105,18 @@ use self::auth::{
|
||||
runtime_request_is_authorized,
|
||||
};
|
||||
use self::sessions::{
|
||||
create_session_from_thread, delete_session, get_session, list_sessions, list_sessions_summary,
|
||||
patch_session, resume_session_thread, save_current_session,
|
||||
create_session_from_thread, delete_session, get_session, list_session_artifacts, list_sessions,
|
||||
list_sessions_summary, patch_session, read_session_artifact, resume_session_thread,
|
||||
save_current_session,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use self::sessions::{messages_from_thread_detail, session_to_detail};
|
||||
#[cfg(test)]
|
||||
use self::workspace::collect_workspace_status;
|
||||
use self::workspace::{collect_workspace_git_metadata, workspace_file_search, workspace_status};
|
||||
use self::workspace::{
|
||||
collect_workspace_git_metadata, workspace_file_read, workspace_file_search,
|
||||
workspace_file_write, workspace_files_list, workspace_status,
|
||||
};
|
||||
|
||||
const RUNTIME_TOKEN_ENV: &str = "CODEWHALE_RUNTIME_TOKEN";
|
||||
const LEGACY_RUNTIME_TOKEN_ENV: &str = "DEEPSEEK_RUNTIME_TOKEN";
|
||||
@@ -1089,8 +1093,22 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
"/v1/sessions/{id}/resume-thread",
|
||||
post(resume_session_thread),
|
||||
)
|
||||
.route("/v1/sessions/{id}/artifacts", get(list_session_artifacts))
|
||||
.route(
|
||||
"/v1/sessions/{id}/artifacts/{artifact_id}",
|
||||
get(read_session_artifact),
|
||||
)
|
||||
.route("/v1/workspace/status", get(workspace_status))
|
||||
.route("/v1/workspace/files/search", get(workspace_file_search))
|
||||
.route(
|
||||
"/v1/workspace/files",
|
||||
get(workspace_files_list)
|
||||
.put(workspace_file_write)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
self::workspace::FILE_WRITE_BODY_LIMIT_BYTES,
|
||||
)),
|
||||
)
|
||||
.route("/v1/workspace/files/read", get(workspace_file_read))
|
||||
.route("/v1/agent-runs", get(list_agent_runs))
|
||||
.route("/v1/agent-runs/{run_id}", get(get_agent_run))
|
||||
.route("/v1/fleet/profiles", get(list_fleet_profiles))
|
||||
@@ -8146,6 +8164,13 @@ impl ApiError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_too_large(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::PAYLOAD_TOO_LARGE,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
|
||||
@@ -1103,3 +1103,125 @@ mod resume_thread_error_tests {
|
||||
assert_eq!(storage.status, StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session artifacts (#6163): the oversized tool outputs a session recorded as
|
||||
// `ArtifactRecord`s live under `sessions/<id>/artifacts/`. These routes list
|
||||
// the records a saved session carries and read one artifact through the same
|
||||
// confined opener the workspace file routes use. Nothing is copied anywhere.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct SessionArtifactSummary {
|
||||
id: String,
|
||||
kind: crate::artifacts::ArtifactKind,
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
byte_size: u64,
|
||||
preview: String,
|
||||
/// Session-relative storage path with `/` separators.
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct SessionArtifactsResponse {
|
||||
session_id: String,
|
||||
artifacts: Vec<SessionArtifactSummary>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct SessionArtifactReadQuery {
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct SessionArtifactReadResponse {
|
||||
artifact: SessionArtifactSummary,
|
||||
size: u64,
|
||||
revision: String,
|
||||
offset: usize,
|
||||
bytes: usize,
|
||||
truncated: bool,
|
||||
encoding: &'static str,
|
||||
content: String,
|
||||
}
|
||||
|
||||
fn artifact_summary(record: &crate::artifacts::ArtifactRecord) -> SessionArtifactSummary {
|
||||
SessionArtifactSummary {
|
||||
id: record.id.clone(),
|
||||
kind: record.kind.clone(),
|
||||
tool_call_id: record.tool_call_id.clone(),
|
||||
tool_name: record.tool_name.clone(),
|
||||
created_at: record.created_at,
|
||||
byte_size: record.byte_size,
|
||||
preview: record.preview.clone(),
|
||||
path: crate::artifacts::format_artifact_relative_path(&record.storage_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn list_session_artifacts(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<SessionArtifactsResponse>, ApiError> {
|
||||
let manager = SessionManager::new(state.sessions_dir.clone())
|
||||
.map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
|
||||
let session = manager
|
||||
.load_session(&id)
|
||||
.map_err(|e| map_session_err(&id, e, "read"))?;
|
||||
Ok(Json(SessionArtifactsResponse {
|
||||
session_id: session.metadata.id.clone(),
|
||||
artifacts: session.artifacts.iter().map(artifact_summary).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn read_session_artifact(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path((id, artifact_id)): Path<(String, String)>,
|
||||
Query(query): Query<SessionArtifactReadQuery>,
|
||||
) -> Result<Json<SessionArtifactReadResponse>, ApiError> {
|
||||
let (offset, limit) = super::workspace::parse_read_window(query.offset, query.limit)?;
|
||||
let manager = SessionManager::new(state.sessions_dir.clone())
|
||||
.map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
|
||||
let session = manager
|
||||
.load_session(&id)
|
||||
.map_err(|e| map_session_err(&id, e, "read"))?;
|
||||
let record = session
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|record| record.id == artifact_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| ApiError::not_found(format!("artifact '{artifact_id}' not found")))?;
|
||||
if record.storage_path.is_absolute()
|
||||
|| !crate::fleet::files::path_is_confined(&record.storage_path)
|
||||
|| !crate::artifacts::is_valid_session_id(&session.metadata.id)
|
||||
{
|
||||
return Err(ApiError::forbidden(
|
||||
"artifact record is not confined to the session directory",
|
||||
));
|
||||
}
|
||||
let sessions_dir = state.sessions_dir.clone();
|
||||
let relative = PathBuf::from(&session.metadata.id).join(&record.storage_path);
|
||||
let summary = artifact_summary(&record);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let file = super::workspace::open_confined_file(&sessions_dir, &relative, false)?;
|
||||
let read = super::workspace::read_confined_bytes(&file)?;
|
||||
let (window, truncated) = super::workspace::read_window(&read.bytes, offset, limit);
|
||||
let (encoding, content) = super::workspace::encode_window(window);
|
||||
Ok(SessionArtifactReadResponse {
|
||||
artifact: summary,
|
||||
size: read.size,
|
||||
revision: read.revision,
|
||||
offset: offset.min(read.bytes.len()),
|
||||
bytes: window.len(),
|
||||
truncated,
|
||||
encoding,
|
||||
content,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("session artifact read failed"))?
|
||||
.map(Json)
|
||||
}
|
||||
@@ -14846,3 +14846,548 @@ async fn output_cap_compatibility_stream_rejects_before_thread_creation() -> Res
|
||||
server.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_files_list_read_write_bounds_and_confinement() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let workspace = tmp.path().join("workspace");
|
||||
fs::create_dir_all(workspace.join("src"))?;
|
||||
fs::create_dir_all(workspace.join("bin"))?;
|
||||
fs::create_dir_all(workspace.join("empty"))?;
|
||||
fs::create_dir_all(workspace.join(".git/hooks"))?;
|
||||
fs::write(workspace.join("src/main.rs"), "fn main() {}\n")?;
|
||||
fs::write(workspace.join("README.md"), "# readme\n")?;
|
||||
fs::write(workspace.join("bin/blob"), [0u8, 1, 2, 255])?;
|
||||
fs::write(workspace.join(".git/config"), "[core]\n")?;
|
||||
let outside = tmp.path().join("outside");
|
||||
fs::create_dir_all(&outside)?;
|
||||
fs::write(outside.join("secret.txt"), "never served")?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&outside, workspace.join("link-dir"))?;
|
||||
std::os::unix::fs::symlink(outside.join("secret.txt"), workspace.join("link-file"))?;
|
||||
}
|
||||
let (addr, _, handle) = spawn_test_server_with_root_token_mobile_workspace(
|
||||
tmp.path().join("runtime"),
|
||||
tmp.path().join("sessions"),
|
||||
Some("files-token".to_string()),
|
||||
false,
|
||||
workspace.clone(),
|
||||
)
|
||||
.await?
|
||||
.context("files test requires a loopback listener")?;
|
||||
let client = crate::tls::reqwest_client();
|
||||
let base = format!("http://{addr}");
|
||||
let url = |route: &str, pairs: &[(&str, &str)]| {
|
||||
let mut url = reqwest::Url::parse(&format!("{base}{route}")).unwrap();
|
||||
url.query_pairs_mut().extend_pairs(pairs.iter().copied());
|
||||
url
|
||||
};
|
||||
|
||||
// Authentication is required on every route.
|
||||
for route in [
|
||||
"/v1/workspace/files",
|
||||
"/v1/workspace/files/read?path=README.md",
|
||||
] {
|
||||
let status = client.get(format!("{base}{route}")).send().await?.status();
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED, "{route}");
|
||||
}
|
||||
|
||||
// Listing: directories first, `.git` hidden, links named but never followed.
|
||||
let root_listing: Value = client
|
||||
.get(url("/v1/workspace/files", &[]))
|
||||
.bearer_auth("files-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let names: Vec<(String, String)> = root_listing["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry["name"].as_str().unwrap().to_string(),
|
||||
entry["kind"].as_str().unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(!names.iter().any(|(name, _)| name == ".git"));
|
||||
assert!(names.contains(&("bin".to_string(), "directory".to_string())));
|
||||
assert!(names.contains(&("src".to_string(), "directory".to_string())));
|
||||
assert!(names.contains(&("README.md".to_string(), "file".to_string())));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert!(names.contains(&("link-dir".to_string(), "symlink".to_string())));
|
||||
assert!(names.contains(&("link-file".to_string(), "symlink".to_string())));
|
||||
}
|
||||
let last_directory = names
|
||||
.iter()
|
||||
.rposition(|(_, kind)| kind == "directory")
|
||||
.unwrap();
|
||||
assert!(
|
||||
names[..=last_directory]
|
||||
.iter()
|
||||
.all(|(_, kind)| kind == "directory"),
|
||||
"directories are listed first: {names:?}"
|
||||
);
|
||||
let readme = root_listing["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|entry| entry["name"] == "README.md")
|
||||
.unwrap();
|
||||
assert_eq!(readme["size"], 9);
|
||||
assert_eq!(readme["path"], "README.md");
|
||||
assert!(readme["modified"].is_string());
|
||||
assert_eq!(root_listing["truncated"], false);
|
||||
|
||||
let src_listing: Value = client
|
||||
.get(url("/v1/workspace/files", &[("path", "src/")]))
|
||||
.bearer_auth("files-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(src_listing["path"], "src");
|
||||
assert_eq!(src_listing["entries"][0]["path"], "src/main.rs");
|
||||
let limited: Value = client
|
||||
.get(url("/v1/workspace/files", &[("limit", "1")]))
|
||||
.bearer_auth("files-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(limited["entries"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(limited["truncated"], true);
|
||||
let mut rejected_listings = vec![
|
||||
("../", StatusCode::BAD_REQUEST),
|
||||
("/etc", StatusCode::BAD_REQUEST),
|
||||
("src/../src", StatusCode::BAD_REQUEST),
|
||||
("src\\main", StatusCode::BAD_REQUEST),
|
||||
(".git", StatusCode::FORBIDDEN),
|
||||
("README.md", StatusCode::BAD_REQUEST),
|
||||
("missing", StatusCode::NOT_FOUND),
|
||||
];
|
||||
if cfg!(unix) {
|
||||
rejected_listings.push(("link-dir", StatusCode::FORBIDDEN));
|
||||
}
|
||||
for (path, expected) in rejected_listings {
|
||||
let status = client
|
||||
.get(url("/v1/workspace/files", &[("path", path)]))
|
||||
.bearer_auth("files-token")
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, expected, "list path={path:?}");
|
||||
}
|
||||
let status = client
|
||||
.get(url("/v1/workspace/files", &[("limit", "0")]))
|
||||
.bearer_auth("files-token")
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
|
||||
// Reads: whole-file revision, byte windows, binary fallback, confinement.
|
||||
let read: Value = client
|
||||
.get(url("/v1/workspace/files/read", &[("path", "src/main.rs")]))
|
||||
.bearer_auth("files-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let expected_revision = {
|
||||
let digest = sha2::Sha256::digest(b"fn main() {}\n");
|
||||
digest
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
assert_eq!(read["path"], "src/main.rs");
|
||||
assert_eq!(read["encoding"], "utf-8");
|
||||
assert_eq!(read["content"], "fn main() {}\n");
|
||||
assert_eq!(read["size"], 13);
|
||||
assert_eq!(read["bytes"], 13);
|
||||
assert_eq!(read["truncated"], false);
|
||||
assert_eq!(read["revision"], expected_revision);
|
||||
let window: Value = client
|
||||
.get(url(
|
||||
"/v1/workspace/files/read",
|
||||
&[("path", "src/main.rs"), ("offset", "3"), ("limit", "4")],
|
||||
))
|
||||
.bearer_auth("files-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(window["content"], "main");
|
||||
assert_eq!(window["offset"], 3);
|
||||
assert_eq!(window["bytes"], 4);
|
||||
assert_eq!(window["truncated"], true);
|
||||
assert_eq!(
|
||||
window["revision"], expected_revision,
|
||||
"revision covers the whole file"
|
||||
);
|
||||
let binary: Value = client
|
||||
.get(url("/v1/workspace/files/read", &[("path", "bin/blob")]))
|
||||
.bearer_auth("files-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(binary["encoding"], "base64");
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(binary["content"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
vec![0u8, 1, 2, 255]
|
||||
);
|
||||
let mut rejected_reads = vec![
|
||||
("", StatusCode::BAD_REQUEST),
|
||||
("../outside/secret.txt", StatusCode::BAD_REQUEST),
|
||||
(".git/config", StatusCode::FORBIDDEN),
|
||||
("src", StatusCode::BAD_REQUEST),
|
||||
("missing.txt", StatusCode::NOT_FOUND),
|
||||
("src/missing.rs", StatusCode::NOT_FOUND),
|
||||
];
|
||||
if cfg!(unix) {
|
||||
rejected_reads.push(("link-file", StatusCode::FORBIDDEN));
|
||||
rejected_reads.push(("link-dir/secret.txt", StatusCode::FORBIDDEN));
|
||||
}
|
||||
for (path, expected) in rejected_reads {
|
||||
let status = client
|
||||
.get(url("/v1/workspace/files/read", &[("path", path)]))
|
||||
.bearer_auth("files-token")
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, expected, "read path={path:?}");
|
||||
}
|
||||
let status = client
|
||||
.get(url(
|
||||
"/v1/workspace/files/read",
|
||||
&[("path", "README.md"), ("limit", "0")],
|
||||
))
|
||||
.bearer_auth("files-token")
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
|
||||
// Writes: create, then optimistic concurrency on the whole-file revision.
|
||||
let put = |body: Value| {
|
||||
client
|
||||
.put(format!("{base}/v1/workspace/files"))
|
||||
.bearer_auth("files-token")
|
||||
.json(&body)
|
||||
};
|
||||
let status = client
|
||||
.put(format!("{base}/v1/workspace/files"))
|
||||
.json(&json!({"path": "notes/todo.md", "content": "x"}))
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
let created = put(json!({"path": "notes/todo.md", "content": "- first\n"}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let created: Value = created.json().await?;
|
||||
assert_eq!(created["created"], true);
|
||||
assert_eq!(created["path"], "notes/todo.md");
|
||||
assert_eq!(created["size"], 8);
|
||||
assert_eq!(
|
||||
fs::read_to_string(workspace.join("notes/todo.md"))?,
|
||||
"- first\n"
|
||||
);
|
||||
let first_revision = created["revision"].as_str().unwrap().to_string();
|
||||
let read_back: Value = client
|
||||
.get(url(
|
||||
"/v1/workspace/files/read",
|
||||
&[("path", "notes/todo.md")],
|
||||
))
|
||||
.bearer_auth("files-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(read_back["revision"], first_revision);
|
||||
|
||||
// Overwrites need the revision that was read; a stale one is refused.
|
||||
let status = put(json!({"path": "notes/todo.md", "content": "- second\n"}))
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::CONFLICT,
|
||||
"overwrite without expected_revision"
|
||||
);
|
||||
let stale = put(json!({
|
||||
"path": "notes/todo.md",
|
||||
"content": "- second\n",
|
||||
"expected_revision": "0".repeat(64),
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(stale.status(), StatusCode::CONFLICT);
|
||||
let stale: Value = stale.json().await?;
|
||||
assert!(
|
||||
stale["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(&first_revision),
|
||||
"conflict names the current revision"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(workspace.join("notes/todo.md"))?,
|
||||
"- first\n"
|
||||
);
|
||||
let updated = put(json!({
|
||||
"path": "notes/todo.md",
|
||||
"content": "- second\n",
|
||||
"expected_revision": first_revision,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(updated.status(), StatusCode::OK);
|
||||
let updated: Value = updated.json().await?;
|
||||
assert_eq!(updated["created"], false);
|
||||
assert_ne!(updated["revision"], first_revision);
|
||||
assert_eq!(
|
||||
fs::read_to_string(workspace.join("notes/todo.md"))?,
|
||||
"- second\n"
|
||||
);
|
||||
let status = put(json!({
|
||||
"path": "notes/new.md",
|
||||
"content": "x",
|
||||
"expected_revision": first_revision,
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::CONFLICT,
|
||||
"expected_revision on a new file"
|
||||
);
|
||||
assert!(!workspace.join("notes/new.md").exists());
|
||||
|
||||
// Binary payloads, bad encodings, oversized bodies and confinement.
|
||||
let encoded = put(json!({
|
||||
"path": "bin/written.bin",
|
||||
"content": base64::engine::general_purpose::STANDARD.encode([7u8, 0, 9]),
|
||||
"encoding": "base64",
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(encoded.status(), StatusCode::CREATED);
|
||||
assert_eq!(
|
||||
fs::read(workspace.join("bin/written.bin"))?,
|
||||
vec![7u8, 0, 9]
|
||||
);
|
||||
let status = put(json!({"path": "bin/x", "content": "x", "encoding": "hex"}))
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
let status = put(json!({"path": "bin/x", "content": "***", "encoding": "base64"}))
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
let status = put(json!({"path": "big.txt", "content": "a".repeat(4 * 1024 * 1024 + 1)}))
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
|
||||
let mut rejected_writes = vec![
|
||||
("../outside/x.txt", StatusCode::BAD_REQUEST),
|
||||
("/tmp/x.txt", StatusCode::BAD_REQUEST),
|
||||
(".git/hooks/pre-commit", StatusCode::FORBIDDEN),
|
||||
("src", StatusCode::BAD_REQUEST),
|
||||
];
|
||||
if cfg!(unix) {
|
||||
rejected_writes.push(("link-dir/x.txt", StatusCode::FORBIDDEN));
|
||||
rejected_writes.push(("link-file", StatusCode::FORBIDDEN));
|
||||
}
|
||||
for (path, expected) in rejected_writes {
|
||||
let status = put(json!({"path": path, "content": "x"}))
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, expected, "write path={path:?}");
|
||||
}
|
||||
assert!(!outside.join("x.txt").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(outside.join("secret.txt"))?,
|
||||
"never served"
|
||||
);
|
||||
assert!(!workspace.join(".git/hooks/pre-commit").exists());
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_artifacts_list_and_bounded_read() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let sessions_dir = tmp.path().join("sessions");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&workspace)?;
|
||||
let manager = crate::session_manager::SessionManager::new(sessions_dir.clone())?;
|
||||
let session_id = "sess_artifacts_route".to_string();
|
||||
let mut session = crate::session_manager::create_saved_session_with_id_and_mode(
|
||||
session_id.clone(),
|
||||
&[],
|
||||
"deepseek-v4-pro",
|
||||
&workspace,
|
||||
0,
|
||||
None,
|
||||
Some("plan"),
|
||||
);
|
||||
let artifact_dir = sessions_dir.join(&session_id).join("artifacts");
|
||||
fs::create_dir_all(&artifact_dir)?;
|
||||
let body = "line one\nline two\n".repeat(64);
|
||||
fs::write(artifact_dir.join("art_call_1.txt"), &body)?;
|
||||
let served = crate::artifacts::record_tool_output_artifact_with_size(
|
||||
&session_id,
|
||||
"call_1",
|
||||
"bash",
|
||||
PathBuf::from("artifacts/art_call_1.txt"),
|
||||
body.len() as u64,
|
||||
"line one",
|
||||
);
|
||||
let escaping = crate::artifacts::record_tool_output_artifact_with_size(
|
||||
&session_id,
|
||||
"call_2",
|
||||
"bash",
|
||||
PathBuf::from("../../escape.txt"),
|
||||
4,
|
||||
"nope",
|
||||
);
|
||||
fs::write(tmp.path().join("escape.txt"), "nope")?;
|
||||
let missing = crate::artifacts::record_tool_output_artifact_with_size(
|
||||
&session_id,
|
||||
"call_3",
|
||||
"bash",
|
||||
PathBuf::from("artifacts/gone.txt"),
|
||||
4,
|
||||
"gone",
|
||||
);
|
||||
session.artifacts = vec![served.clone(), escaping.clone(), missing.clone()];
|
||||
manager.save_session(&session)?;
|
||||
|
||||
let (addr, _, handle) = spawn_test_server_with_root_token_mobile_workspace(
|
||||
tmp.path().join("runtime"),
|
||||
sessions_dir.clone(),
|
||||
Some("artifacts-token".to_string()),
|
||||
false,
|
||||
workspace,
|
||||
)
|
||||
.await?
|
||||
.context("artifacts test requires a loopback listener")?;
|
||||
let client = crate::tls::reqwest_client();
|
||||
let base = format!("http://{addr}");
|
||||
|
||||
let status = client
|
||||
.get(format!("{base}/v1/sessions/{session_id}/artifacts"))
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
let listing: Value = client
|
||||
.get(format!("{base}/v1/sessions/{session_id}/artifacts"))
|
||||
.bearer_auth("artifacts-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(listing["session_id"], session_id);
|
||||
let artifacts = listing["artifacts"].as_array().unwrap();
|
||||
assert_eq!(artifacts.len(), 3);
|
||||
assert_eq!(artifacts[0]["id"], served.id);
|
||||
assert_eq!(artifacts[0]["kind"], "tool_output");
|
||||
assert_eq!(artifacts[0]["tool_name"], "bash");
|
||||
assert_eq!(artifacts[0]["tool_call_id"], "call_1");
|
||||
assert_eq!(artifacts[0]["byte_size"], body.len() as u64);
|
||||
assert_eq!(artifacts[0]["path"], "artifacts/art_call_1.txt");
|
||||
assert_eq!(artifacts[0]["preview"], "line one");
|
||||
assert!(artifacts[0].get("storage_path").is_none());
|
||||
|
||||
let read: Value = client
|
||||
.get(format!(
|
||||
"{base}/v1/sessions/{session_id}/artifacts/{}",
|
||||
served.id
|
||||
))
|
||||
.bearer_auth("artifacts-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(read["artifact"]["id"], served.id);
|
||||
assert_eq!(read["encoding"], "utf-8");
|
||||
assert_eq!(read["content"], body);
|
||||
assert_eq!(read["size"], body.len() as u64);
|
||||
assert_eq!(read["truncated"], false);
|
||||
let window: Value = client
|
||||
.get(format!(
|
||||
"{base}/v1/sessions/{session_id}/artifacts/{}?offset=5&limit=3",
|
||||
served.id
|
||||
))
|
||||
.bearer_auth("artifacts-token")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(window["content"], "one");
|
||||
assert_eq!(window["truncated"], true);
|
||||
assert_eq!(window["revision"], read["revision"]);
|
||||
|
||||
for (artifact_id, expected) in [
|
||||
(escaping.id.as_str(), StatusCode::FORBIDDEN),
|
||||
(missing.id.as_str(), StatusCode::NOT_FOUND),
|
||||
("art_unknown", StatusCode::NOT_FOUND),
|
||||
] {
|
||||
let status = client
|
||||
.get(format!(
|
||||
"{base}/v1/sessions/{session_id}/artifacts/{artifact_id}"
|
||||
))
|
||||
.bearer_auth("artifacts-token")
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, expected, "artifact={artifact_id}");
|
||||
}
|
||||
let status = client
|
||||
.get(format!(
|
||||
"{base}/v1/sessions/{session_id}/artifacts/{}?limit=0",
|
||||
served.id
|
||||
))
|
||||
.bearer_auth("artifacts-token")
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
let status = client
|
||||
.get(format!("{base}/v1/sessions/sess_missing/artifacts"))
|
||||
.bearer_auth("artifacts-token")
|
||||
.send()
|
||||
.await?
|
||||
.status();
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
|
||||
handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::io::Read as _;
|
||||
use std::path::{Component, Path as FsPath, PathBuf};
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::dependencies::{ExternalTool as _, Git};
|
||||
|
||||
@@ -213,6 +217,525 @@ fn current_git_head(workspace: &FsPath) -> Option<String> {
|
||||
(!head.is_empty()).then(|| head.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace files (#6163): a bounded directory listing, bounded reads that
|
||||
// carry a content revision, and revision-checked writes. The server's
|
||||
// configured workspace is the only root and every path is workspace-relative
|
||||
// with `/` separators. Symlinks are never followed, `.git` is never served,
|
||||
// and there is no second file store: bytes go straight to the workspace
|
||||
// through the same confined opener Fleet artifacts use.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FILE_LIST_LIMIT_DEFAULT: usize = 200;
|
||||
const FILE_LIST_LIMIT_MAX: usize = 2_000;
|
||||
pub(super) const FILE_READ_LIMIT_DEFAULT: usize = 256 * 1024;
|
||||
pub(super) const FILE_READ_LIMIT_MAX: usize = 4 * 1024 * 1024;
|
||||
/// Files above this size are not served at all: the revision is a digest of
|
||||
/// the whole file, and a Files browser should not page through larger blobs.
|
||||
const FILE_SERVE_MAX_BYTES: u64 = 16 * 1024 * 1024;
|
||||
pub(super) const FILE_WRITE_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
/// Request-body ceiling for the write route: the content cap plus headroom for
|
||||
/// base64 expansion and the JSON envelope, so an oversized `content` reaches
|
||||
/// the handler and gets a 413 with a message instead of a dropped connection.
|
||||
pub(super) const FILE_WRITE_BODY_LIMIT_BYTES: usize = FILE_WRITE_MAX_BYTES * 4 / 3 + 64 * 1024;
|
||||
const FILE_PATH_MAX_BYTES: usize = 4_096;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct WorkspaceFilesListQuery {
|
||||
#[serde(default)]
|
||||
path: String,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct WorkspaceFileEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
/// `file`, `directory`, `symlink` (listed by name, never followed) or `other`.
|
||||
kind: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
size: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
modified: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct WorkspaceFilesListResponse {
|
||||
path: String,
|
||||
entries: Vec<WorkspaceFileEntry>,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct WorkspaceFileReadQuery {
|
||||
path: String,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct WorkspaceFileReadResponse {
|
||||
path: String,
|
||||
size: u64,
|
||||
/// SHA-256 of the whole file, not of the returned window.
|
||||
revision: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
modified: Option<String>,
|
||||
offset: usize,
|
||||
bytes: usize,
|
||||
truncated: bool,
|
||||
encoding: &'static str,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct WorkspaceFileWriteRequest {
|
||||
path: String,
|
||||
content: String,
|
||||
/// `utf-8` (default) or `base64`.
|
||||
#[serde(default)]
|
||||
encoding: Option<String>,
|
||||
/// Required to overwrite an existing file; must be absent for a new one.
|
||||
#[serde(default)]
|
||||
expected_revision: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct WorkspaceFileWriteResponse {
|
||||
path: String,
|
||||
size: u64,
|
||||
revision: String,
|
||||
created: bool,
|
||||
written_at: String,
|
||||
}
|
||||
|
||||
/// Bytes of one confined file plus the facts a client needs to reason about
|
||||
/// them: total size, the whole-file revision and the modification time.
|
||||
pub(super) struct ConfinedFileBytes {
|
||||
pub(super) size: u64,
|
||||
pub(super) revision: String,
|
||||
pub(super) modified: Option<String>,
|
||||
pub(super) bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(super) fn parse_read_window(
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<(usize, usize), ApiError> {
|
||||
let limit = limit.unwrap_or(FILE_READ_LIMIT_DEFAULT);
|
||||
if !(1..=FILE_READ_LIMIT_MAX).contains(&limit) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"limit must be between 1 and {FILE_READ_LIMIT_MAX} bytes"
|
||||
)));
|
||||
}
|
||||
Ok((offset.unwrap_or(0), limit))
|
||||
}
|
||||
|
||||
/// Byte window `[offset, offset + limit)` clamped to the content.
|
||||
pub(super) fn read_window(bytes: &[u8], offset: usize, limit: usize) -> (&[u8], bool) {
|
||||
let start = offset.min(bytes.len());
|
||||
let end = start.saturating_add(limit).min(bytes.len());
|
||||
(&bytes[start..end], end < bytes.len())
|
||||
}
|
||||
|
||||
/// Text windows come back as UTF-8; anything with a NUL byte, invalid UTF-8,
|
||||
/// or a window that splits a multi-byte character comes back as base64.
|
||||
pub(super) fn encode_window(window: &[u8]) -> (&'static str, String) {
|
||||
if !window.contains(&0)
|
||||
&& let Ok(text) = std::str::from_utf8(window)
|
||||
{
|
||||
return ("utf-8", text.to_string());
|
||||
}
|
||||
(
|
||||
"base64",
|
||||
base64::engine::general_purpose::STANDARD.encode(window),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn content_revision(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rfc3339(time: std::time::SystemTime) -> String {
|
||||
chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339()
|
||||
}
|
||||
|
||||
pub(super) fn map_fs_error(error: std::io::Error, what: &str) -> ApiError {
|
||||
use std::io::ErrorKind;
|
||||
match error.kind() {
|
||||
ErrorKind::NotFound => ApiError::not_found(format!("{what} not found")),
|
||||
ErrorKind::PermissionDenied => ApiError::forbidden(format!("{what} is not readable")),
|
||||
ErrorKind::InvalidInput | ErrorKind::InvalidData => ApiError::forbidden(format!(
|
||||
"{what} must be a regular, non-linked path inside the workspace"
|
||||
)),
|
||||
ErrorKind::Unsupported => {
|
||||
ApiError::not_implemented("confined file access is unavailable on this platform")
|
||||
}
|
||||
_ => ApiError::internal(format!("{what} access failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A workspace-relative request path. Empty and `.` mean the root, which only
|
||||
/// listing accepts. Absolute paths, `..`, backslashes and `.git` are refused.
|
||||
fn relative_request_path(raw: &str, allow_root: bool) -> Result<PathBuf, ApiError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.len() > FILE_PATH_MAX_BYTES {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"path must be at most {FILE_PATH_MAX_BYTES} UTF-8 bytes"
|
||||
)));
|
||||
}
|
||||
if trimmed.contains('\\') {
|
||||
return Err(ApiError::bad_request("path must use / separators"));
|
||||
}
|
||||
if trimmed.starts_with('/') || FsPath::new(trimmed).is_absolute() {
|
||||
return Err(ApiError::bad_request("path must be workspace-relative"));
|
||||
}
|
||||
let trimmed = trimmed.trim_end_matches('/');
|
||||
if trimmed.is_empty() || trimmed == "." {
|
||||
return if allow_root {
|
||||
Ok(PathBuf::new())
|
||||
} else {
|
||||
Err(ApiError::bad_request("path is required"))
|
||||
};
|
||||
}
|
||||
let path = PathBuf::from(trimmed);
|
||||
if !crate::fleet::files::path_is_confined(&path) {
|
||||
return Err(ApiError::bad_request(
|
||||
"path must be workspace-relative without . or .. components",
|
||||
));
|
||||
}
|
||||
if path
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::Normal(name) if name == ".git"))
|
||||
{
|
||||
return Err(ApiError::forbidden("the .git directory is not served"));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn canonical_workspace(workspace: &FsPath) -> Result<PathBuf, ApiError> {
|
||||
workspace
|
||||
.canonicalize()
|
||||
.map_err(|_| ApiError::internal("workspace is unavailable"))
|
||||
}
|
||||
|
||||
fn relative_display(path: &FsPath) -> String {
|
||||
path.components()
|
||||
.filter_map(|component| match component {
|
||||
Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
/// Walk `relative` from the root one component at a time, refusing any link
|
||||
/// or reparse point on the way, and confirm the result is a directory that
|
||||
/// still resolves inside the root.
|
||||
fn confined_directory(root: &FsPath, relative: &FsPath) -> Result<PathBuf, ApiError> {
|
||||
let mut directory = root.to_path_buf();
|
||||
for component in relative.components() {
|
||||
directory.push(component);
|
||||
let metadata = std::fs::symlink_metadata(&directory)
|
||||
.map_err(|error| map_fs_error(error, "directory"))?;
|
||||
if metadata.file_type().is_symlink()
|
||||
|| crate::plugins::metadata_is_link_or_reparse(&metadata)
|
||||
{
|
||||
return Err(ApiError::forbidden("symlinks are not followed"));
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
return Err(ApiError::bad_request("path is not a directory"));
|
||||
}
|
||||
}
|
||||
let resolved = directory
|
||||
.canonicalize()
|
||||
.map_err(|error| map_fs_error(error, "directory"))?;
|
||||
if !resolved.starts_with(root) {
|
||||
return Err(ApiError::forbidden("path resolves outside the workspace"));
|
||||
}
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
pub(super) async fn workspace_files_list(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<WorkspaceFilesListQuery>,
|
||||
) -> Result<Json<WorkspaceFilesListResponse>, ApiError> {
|
||||
let relative = relative_request_path(&query.path, true)?;
|
||||
let limit = query.limit.unwrap_or(FILE_LIST_LIMIT_DEFAULT);
|
||||
if !(1..=FILE_LIST_LIMIT_MAX).contains(&limit) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"limit must be between 1 and {FILE_LIST_LIMIT_MAX}"
|
||||
)));
|
||||
}
|
||||
let workspace = state.workspace.clone();
|
||||
tokio::task::spawn_blocking(move || list_workspace_directory(&workspace, &relative, limit))
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("workspace listing failed"))?
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
fn list_workspace_directory(
|
||||
workspace: &FsPath,
|
||||
relative: &FsPath,
|
||||
limit: usize,
|
||||
) -> Result<WorkspaceFilesListResponse, ApiError> {
|
||||
let root = canonical_workspace(workspace)?;
|
||||
let directory = confined_directory(&root, relative)?;
|
||||
let mut entries = Vec::new();
|
||||
let read_dir =
|
||||
std::fs::read_dir(&directory).map_err(|error| map_fs_error(error, "directory"))?;
|
||||
for entry in read_dir {
|
||||
let entry = entry.map_err(|error| map_fs_error(error, "directory"))?;
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
if name == ".git" {
|
||||
continue;
|
||||
}
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
let metadata = entry.metadata().ok();
|
||||
let is_link = file_type.is_symlink()
|
||||
|| metadata
|
||||
.as_ref()
|
||||
.is_some_and(crate::plugins::metadata_is_link_or_reparse);
|
||||
let kind = if is_link {
|
||||
"symlink"
|
||||
} else if file_type.is_dir() {
|
||||
"directory"
|
||||
} else if file_type.is_file() {
|
||||
"file"
|
||||
} else {
|
||||
"other"
|
||||
};
|
||||
let file_metadata = (kind == "file").then_some(metadata).flatten();
|
||||
entries.push(WorkspaceFileEntry {
|
||||
path: relative_display(&relative.join(&name)),
|
||||
name,
|
||||
kind,
|
||||
size: file_metadata.as_ref().map(std::fs::Metadata::len),
|
||||
modified: file_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.modified().ok())
|
||||
.map(rfc3339),
|
||||
});
|
||||
}
|
||||
entries.sort_by(|left, right| {
|
||||
(left.kind != "directory")
|
||||
.cmp(&(right.kind != "directory"))
|
||||
.then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
});
|
||||
let truncated = entries.len() > limit;
|
||||
entries.truncate(limit);
|
||||
Ok(WorkspaceFilesListResponse {
|
||||
path: relative_display(relative),
|
||||
entries,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn open_confined_file(
|
||||
root: &FsPath,
|
||||
relative: &FsPath,
|
||||
create: bool,
|
||||
) -> Result<crate::fleet::files::WorkspaceFile, ApiError> {
|
||||
crate::fleet::files::WorkspaceFile::open(root, relative, create)
|
||||
.map_err(|error| map_fs_error(error, "file"))
|
||||
}
|
||||
|
||||
/// Read one confined file completely (bounded by `FILE_SERVE_MAX_BYTES`) so
|
||||
/// the revision always describes the whole file.
|
||||
pub(super) fn read_confined_bytes(
|
||||
file: &crate::fleet::files::WorkspaceFile,
|
||||
) -> Result<ConfinedFileBytes, ApiError> {
|
||||
let mut handle = file
|
||||
.open_file()
|
||||
.map_err(|error| map_fs_error(error, "file"))?;
|
||||
let metadata = handle
|
||||
.metadata()
|
||||
.map_err(|error| map_fs_error(error, "file"))?;
|
||||
if metadata.len() > FILE_SERVE_MAX_BYTES {
|
||||
return Err(ApiError::payload_too_large(format!(
|
||||
"file is larger than the {FILE_SERVE_MAX_BYTES}-byte serving limit"
|
||||
)));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||||
handle
|
||||
.by_ref()
|
||||
.take(FILE_SERVE_MAX_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| map_fs_error(error, "file"))?;
|
||||
if bytes.len() as u64 > FILE_SERVE_MAX_BYTES {
|
||||
return Err(ApiError::payload_too_large(format!(
|
||||
"file grew past the {FILE_SERVE_MAX_BYTES}-byte serving limit while it was read"
|
||||
)));
|
||||
}
|
||||
Ok(ConfinedFileBytes {
|
||||
size: bytes.len() as u64,
|
||||
revision: content_revision(&bytes),
|
||||
modified: metadata.modified().ok().map(rfc3339),
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse links and non-files before the confined opener runs, so a client
|
||||
/// sees a precise status instead of a generic confinement error.
|
||||
fn precheck_file_target(
|
||||
root: &FsPath,
|
||||
relative: &FsPath,
|
||||
) -> Result<Option<std::fs::Metadata>, ApiError> {
|
||||
if let Some(parent) = relative
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
// Every directory on the way must be a real directory; a link would
|
||||
// otherwise surface as a confinement error from the opener.
|
||||
confined_directory(root, parent).or_else(|error| {
|
||||
if error.status == StatusCode::NOT_FOUND {
|
||||
Ok(PathBuf::new())
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
})?;
|
||||
}
|
||||
match std::fs::symlink_metadata(root.join(relative)) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink()
|
||||
|| crate::plugins::metadata_is_link_or_reparse(&metadata)
|
||||
{
|
||||
return Err(ApiError::forbidden("symlinks are not followed"));
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
return Err(ApiError::bad_request("path is a directory"));
|
||||
}
|
||||
Ok(Some(metadata))
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(map_fs_error(error, "file")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn workspace_file_read(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<WorkspaceFileReadQuery>,
|
||||
) -> Result<Json<WorkspaceFileReadResponse>, ApiError> {
|
||||
let relative = relative_request_path(&query.path, false)?;
|
||||
let (offset, limit) = parse_read_window(query.offset, query.limit)?;
|
||||
let workspace = state.workspace.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let root = canonical_workspace(&workspace)?;
|
||||
if precheck_file_target(&root, &relative)?.is_none() {
|
||||
return Err(ApiError::not_found("file not found"));
|
||||
}
|
||||
let file = open_confined_file(&root, &relative, false)?;
|
||||
let read = read_confined_bytes(&file)?;
|
||||
let (window, truncated) = read_window(&read.bytes, offset, limit);
|
||||
let (encoding, content) = encode_window(window);
|
||||
Ok(WorkspaceFileReadResponse {
|
||||
path: relative_display(&relative),
|
||||
size: read.size,
|
||||
revision: read.revision,
|
||||
modified: read.modified,
|
||||
offset: offset.min(read.bytes.len()),
|
||||
bytes: window.len(),
|
||||
truncated,
|
||||
encoding,
|
||||
content,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("workspace file read failed"))?
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
pub(super) async fn workspace_file_write(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(request): Json<WorkspaceFileWriteRequest>,
|
||||
) -> Result<(StatusCode, Json<WorkspaceFileWriteResponse>), ApiError> {
|
||||
let relative = relative_request_path(&request.path, false)?;
|
||||
let bytes = match request.encoding.as_deref().unwrap_or("utf-8") {
|
||||
"utf-8" => request.content.into_bytes(),
|
||||
"base64" => base64::engine::general_purpose::STANDARD
|
||||
.decode(request.content.as_bytes())
|
||||
.map_err(|_| ApiError::bad_request("content is not valid base64"))?,
|
||||
_ => return Err(ApiError::bad_request("encoding must be utf-8 or base64")),
|
||||
};
|
||||
if bytes.len() > FILE_WRITE_MAX_BYTES {
|
||||
return Err(ApiError::payload_too_large(format!(
|
||||
"content must be at most {FILE_WRITE_MAX_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
let expected_revision = request
|
||||
.expected_revision
|
||||
.map(|revision| revision.trim().to_ascii_lowercase())
|
||||
.filter(|revision| !revision.is_empty());
|
||||
let workspace = state.workspace.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
write_workspace_file(&workspace, &relative, &bytes, expected_revision.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("workspace file write failed"))?
|
||||
}
|
||||
|
||||
fn write_workspace_file(
|
||||
workspace: &FsPath,
|
||||
relative: &FsPath,
|
||||
bytes: &[u8],
|
||||
expected_revision: Option<&str>,
|
||||
) -> Result<(StatusCode, Json<WorkspaceFileWriteResponse>), ApiError> {
|
||||
let root = canonical_workspace(workspace)?;
|
||||
let created = precheck_file_target(&root, relative)?.is_none();
|
||||
match (created, expected_revision) {
|
||||
(true, Some(_)) => {
|
||||
return Err(ApiError::conflict(
|
||||
"expected_revision was given but the file does not exist",
|
||||
));
|
||||
}
|
||||
(false, None) => {
|
||||
return Err(ApiError::conflict(
|
||||
"expected_revision is required to overwrite an existing file; read it first",
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Parents are created only for a new file, and only through the confined
|
||||
// opener, which refuses links at every component.
|
||||
let file = open_confined_file(&root, relative, created)?;
|
||||
if let Some(expected) = expected_revision {
|
||||
let current = read_confined_bytes(&file)?;
|
||||
if current.revision != expected {
|
||||
return Err(ApiError::conflict(format!(
|
||||
"file changed since it was read; current revision is {}",
|
||||
current.revision
|
||||
)));
|
||||
}
|
||||
}
|
||||
file.replace(bytes)
|
||||
.map_err(|error| map_fs_error(error, "file"))?;
|
||||
Ok((
|
||||
if created {
|
||||
StatusCode::CREATED
|
||||
} else {
|
||||
StatusCode::OK
|
||||
},
|
||||
Json(WorkspaceFileWriteResponse {
|
||||
path: relative_display(relative),
|
||||
size: bytes.len() as u64,
|
||||
revision: content_revision(bytes),
|
||||
created,
|
||||
written_at: chrono::Utc::now().to_rfc3339(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -101,6 +101,62 @@ can finish after that budget. Each request scans anew; there is no new index or
|
||||
cache. This read-only endpoint does not alter sessions or the pinned model
|
||||
prompt/tool prefix.
|
||||
|
||||
### Workspace files and session artifacts
|
||||
|
||||
Native clients (the GPUI desktop's Files and Preview modules) browse and edit
|
||||
the server's configured workspace through three authenticated routes. They
|
||||
read and write the workspace directly; there is no second file store, cache
|
||||
or index, and no path override: the workspace root is the only root.
|
||||
|
||||
- `GET /v1/workspace/files?path=<dir>&limit=<1-2000>` lists one directory.
|
||||
`path` is workspace-relative with `/` separators; empty or `.` is the root.
|
||||
Each entry carries `name`, `path`, `kind` (`file`, `directory`, `symlink`,
|
||||
`other`), and for files `size` and `modified` (RFC 3339). Directories sort
|
||||
first, then names case-insensitively. `limit` defaults to 200; `truncated`
|
||||
reports a cut. `.git` is never listed or served, and symlinks are listed by
|
||||
name only: they are never followed, so `path=<link>` returns 403.
|
||||
- `GET /v1/workspace/files/read?path=<file>&offset=<bytes>&limit=<1-4194304>`
|
||||
returns one byte window of a regular file with `size`, `revision` (the
|
||||
SHA-256 hex of the **whole** file, not of the window), `modified`,
|
||||
`offset`, `bytes`, `truncated`, `encoding` and `content`. Text windows are
|
||||
`utf-8`; a window with a NUL byte, invalid UTF-8, or a split multi-byte
|
||||
character is `base64`. `limit` defaults to 256 KiB. Files above 16 MiB are
|
||||
refused with 413; a directory is 400; a link is 403; a missing file is 404.
|
||||
- `PUT /v1/workspace/files` with `{"path", "content", "encoding"?,
|
||||
"expected_revision"?}` writes one file atomically through the same confined
|
||||
opener Fleet artifacts use. `encoding` is `utf-8` (default) or `base64`;
|
||||
bodies above 4 MiB are 413. Creating a new file requires **no**
|
||||
`expected_revision` (and creates missing parent directories inside the
|
||||
workspace); overwriting requires the `revision` from the read that the
|
||||
edit was based on, and a stale or missing one is 409 with the current
|
||||
revision in the error message so the client can re-read and merge. This is
|
||||
optimistic concurrency, not a lock: two writers racing between the check and
|
||||
the write can still interleave. The response carries `path`, `size`,
|
||||
`revision`, `created` and `written_at`; 201 for a new file, 200 otherwise.
|
||||
Writes through a link, into `.git`, or to a directory are refused.
|
||||
|
||||
Every path is validated before any filesystem access: absolute paths,
|
||||
backslashes, `.` or `..` components are 400, and each directory on the way is
|
||||
opened without following links (`O_NOFOLLOW` per component on Unix, reparse
|
||||
point checks on Windows). These routes use the runtime bearer token like every
|
||||
other `/v1/*` route; they do not consult the model's tool permission posture,
|
||||
because the caller is the authenticated operator, not the model.
|
||||
|
||||
Session artifacts are the oversized tool outputs a session recorded as
|
||||
`ArtifactRecord`s (`crates/tui/src/artifacts.rs`), stored under
|
||||
`sessions/<id>/artifacts/`:
|
||||
|
||||
- `GET /v1/sessions/{id}/artifacts` lists the records a saved session carries:
|
||||
`id`, `kind`, `tool_call_id`, `tool_name`, `created_at`, `byte_size`,
|
||||
`preview` and the session-relative `path`.
|
||||
- `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=` reads one
|
||||
artifact with the same window, `revision` and `encoding` contract as the
|
||||
workspace file read. A record whose stored path is absolute or leaves the
|
||||
session directory is 403; a record whose file is gone is 404.
|
||||
|
||||
Fleet receipt artifacts keep their own route
|
||||
(`GET /v1/fleet/runs/{run_id}/receipts/{task_id}/evidence`).
|
||||
|
||||
### Runtime and account identity
|
||||
|
||||
`GET /v1/runtime/info` reports `codewhale_version` plus the full 40-character
|
||||
@@ -563,6 +619,8 @@ a TLS or verified transport boundary.
|
||||
- `PATCH /v1/sessions/{id}` (`{ "title"?: string, "archived"?: bool }`)
|
||||
- `DELETE /v1/sessions/{id}`
|
||||
- `POST /v1/sessions/{id}/resume-thread`
|
||||
- `GET /v1/sessions/{id}/artifacts` and `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=`
|
||||
(see workspace files and session artifacts above)
|
||||
|
||||
Sessions and threads answer the same `include_archived` / `archived_only` pair
|
||||
with the same meaning, and `search` is the same fuzzy match (title, id,
|
||||
@@ -1072,6 +1130,8 @@ human gate. Auto-merge is `scripts/check-auto-merge.py --repo … --pr …
|
||||
**Introspection**
|
||||
- `GET /v1/workspace/status`
|
||||
- `GET /v1/workspace/files/search?query=<partial>&limit=<1-100>` (see workspace file suggestions above)
|
||||
- `GET /v1/workspace/files?path=<dir>&limit=<1-2000>`, `GET /v1/workspace/files/read?path=<file>&offset=&limit=`
|
||||
and `PUT /v1/workspace/files` (see workspace files and session artifacts above)
|
||||
- `GET /v1/skills`
|
||||
- `GET /v1/apps/mcp/servers`
|
||||
- `GET /v1/apps/mcp/tools?server=<optional>`
|
||||
|
||||
@@ -31,9 +31,10 @@ export const CHANGELOG: ChangelogRelease[] = [
|
||||
"heading": "Added",
|
||||
"items": [
|
||||
"The interactive approval card can be bounded: [approval] timeout_seconds resolves an unanswered card to deny when the window elapses — the same fail-closed decision the external approval path takes — and the transcript says the bound denied the call, not the operator. Omitted or 0 keeps today's unbounded wait, so nothing changes unless you opt in (#6101).",
|
||||
"Transcript drag selection copies Markdown source by default: every cell the selection touches serializes through the same canonical path Ctrl-Y and /copy use, partial intersections round out to whole cells joined with blank lines, and the toast names the copied cell count. tui.selection_copy_markdown = false keeps the rendered-text payload (#6156)."
|
||||
"Transcript drag selection copies Markdown source by default: every cell the selection touches serializes through the same canonical path Ctrl-Y and /copy use, partial intersections round out to whole cells joined with blank lines, and the toast names the copied cell count. tui.selection_copy_markdown = false keeps the rendered-text payload (#6156).",
|
||||
"The Runtime API serves the workspace files a native client browses and edits: GET /v1/workspace/files lists one directory, GET /v1/workspace/files/read returns a bounded byte window with a whole-file SHA-256 revision, and PUT /v1/workspace/files writes atomically through the confined opener with revision-checked overwrites (409 on drift). .git is never served and symlinks are never followed. A saved session's oversized tool outputs are served as artifacts at GET…"
|
||||
],
|
||||
"itemCount": 2
|
||||
"itemCount": 3
|
||||
},
|
||||
{
|
||||
"heading": "Changed",
|
||||
|
||||
Reference in new issue
Block a user