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:
CodeWhale BotandClaude Fable 5.1 committed 2026-09-14 08:17:16 -07:00
1 parent 433685b202
commit 1758b78a68
8 files changed
+1298 -6

No files matched your search

+8
View File
@@ -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
+8
View File
@@ -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
+28 -3
View File
@@ -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 {
+122
View File
@@ -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)
}
+545
View File
@@ -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(())
}
+524 -1
View File
@@ -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::*;
+60
View File
@@ -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>`
+3 -2
View File
@@ -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",