From 1758b78a6887210dd4d3835459dacf0a08634223 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Mon, 14 Sep 2026 08:09:39 -0700 Subject: [PATCH 1/5] 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//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 --- CHANGELOG.md | 8 + crates/tui/CHANGELOG.md | 8 + crates/tui/src/runtime_api.rs | 31 +- crates/tui/src/runtime_api/sessions.rs | 122 ++++++ crates/tui/src/runtime_api/tests.rs | 545 ++++++++++++++++++++++++ crates/tui/src/runtime_api/workspace.rs | 525 ++++++++++++++++++++++- docs/RUNTIME_API.md | 60 +++ web/lib/changelog.generated.ts | 5 +- 8 files changed, 1298 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df8ec613a..4fb30958c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 28c9b96de8..3c24796838 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -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 diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 590bec9cf8..a1eac02073 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -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) -> Self { + Self { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: message.into(), + } + } } impl IntoResponse for ApiError { diff --git a/crates/tui/src/runtime_api/sessions.rs b/crates/tui/src/runtime_api/sessions.rs index 599542d6ab..93dd160245 100644 --- a/crates/tui/src/runtime_api/sessions.rs +++ b/crates/tui/src/runtime_api/sessions.rs @@ -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//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, + 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, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SessionArtifactReadQuery { + offset: Option, + limit: Option, +} + +#[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, + Path(id): Path, +) -> Result, 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, + Path((id, artifact_id)): Path<(String, String)>, + Query(query): Query, +) -> Result, 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) +} diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index bcfd5045b0..0008d4d004 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -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::() + }; + 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(()) +} diff --git a/crates/tui/src/runtime_api/workspace.rs b/crates/tui/src/runtime_api/workspace.rs index e07ac18de3..91762afd64 100644 --- a/crates/tui/src/runtime_api/workspace.rs +++ b/crates/tui/src/runtime_api/workspace.rs @@ -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 { (!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, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + modified: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct WorkspaceFilesListResponse { + path: String, + entries: Vec, + truncated: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct WorkspaceFileReadQuery { + path: String, + offset: Option, + limit: Option, +} + +#[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, + 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, + /// Required to overwrite an existing file; must be absent for a new one. + #[serde(default)] + expected_revision: Option, +} + +#[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, + pub(super) bytes: Vec, +} + +pub(super) fn parse_read_window( + offset: Option, + limit: Option, +) -> 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::::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 { + 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 { + 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::>() + .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 { + 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, + Query(query): Query, +) -> Result, 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 { + 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::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 { + 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, 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, + Query(query): Query, +) -> Result, 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, + Json(request): Json, +) -> Result<(StatusCode, Json), 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), 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::*; diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 02ecc784d6..6baf316a9b 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -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=&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=` returns 403. +- `GET /v1/workspace/files/read?path=&offset=&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//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=&limit=<1-100>` (see workspace file suggestions above) +- `GET /v1/workspace/files?path=&limit=<1-2000>`, `GET /v1/workspace/files/read?path=&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=` diff --git a/web/lib/changelog.generated.ts b/web/lib/changelog.generated.ts index 79f24f3960..a556a55521 100644 --- a/web/lib/changelog.generated.ts +++ b/web/lib/changelog.generated.ts @@ -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", From 060711b2f18bf18f6e12c20ce753f12191fed62a Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 15 Sep 2026 07:13:14 -0700 Subject: [PATCH 2/5] feat(runtime-api): native-client routes for jobs, context, secrets, git, diagnostics, targets, LSP, and voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPUI's terminals, usage panel, settings, review, and diagnostics modules named HTTP contracts that did not exist beyond the workspace file/artifact routes landed in 1758b78a68. This slice implements them against existing Core authorities rather than standing up parallel backends. Jobs: GET/POST /v1/threads/{id}/jobs, per-job status, non-consuming cursor-based output reads (stream/cursor/max_bytes/wait_ms/format, with exact dropped-byte accounting and stale-job tail snapshots), stdin writes, and kill, plus a flat GET /v1/jobs. Each thread now owns one shared ShellManager handed to its engine on load, so the API and the model see one job set and background work survives engine LRU eviction; the entry drops with the thread. API-created jobs inherit the same sandbox posture projection a turn applies. Commands: GET /v1/commands projects the live command registry (aliases, usage, localized description, discovery flags, argument requirements) at the server's resolved locale. Context: GET /v1/threads/{id}/context answers via a new engine Op (GetContextBudget, with a wire-op twin) so the live estimate, billed input tokens, route window, ceilings, and pressure come from the engine itself; live:false with static route info when the engine is unavailable. Secrets: PUT /v1/providers/{id}/key is write-only — the credential write path moved to codewhale-config (same transactional store+metadata logic the CLI uses), the response carries backend/configPath/credentialState metadata only, and the in-memory config mirrors the persisted auth marker so readiness readback reflects the write immediately. 4 KiB key cap, ~5 KiB body limit, no echo of key or length anywhere. Git: GET /v1/git, /v1/changes, /v1/diff, /v1/workspace/diff, /v1/git/graph, and POST /v1/git/{stage,unstage,discard,commit,push,branch} over the existing hardened git wrapper and workspace confinement; bounded outputs, refreshed status in every mutation response. Diagnostics: GET /v1/logs{,/{name}}, /v1/crashes{,/{name}} (both the .codewhale and legacy .deepseek crash roots), and /v1/process — bounded, basename-validated reads; no telemetry upload surface. Targets/remote: GET /v1/targets reports this runtime as its own sole target; POST /v1/targets{,/switch} 501 (client-owned registry). GET /v1/remote reports bind posture; POST /v1/remote/connect probes a candidate's unauthenticated /v1/runtime/info, refuses URLs with credentials, and never forwards tokens. GET /v1/ssh and /v1/cloud answer supported:false owned by the control plane; their POSTs 501. LSP: GET /v1/lsp plus /v1/diagnostics, /v1/definition, /v1/references, /v1/symbols over one lazily-built API-owned LspManager confined to the server workspace; normal no-server/timeout states return 200 with ok:false and a machine-readable reason. Voice: GET /v1/voice capability plus POST /v1/voice/{dictate,send,control} delegating to the existing TUI record-to-ASR pipeline through a headless dictate_once (same whisper/Groq/provider dispatch, same send-suffix and voice-control behavior, serialized mic access). CODEWHALE_DISABLE_VOICE is an operator kill-switch that makes every voice surface fail closed. docs/RUNTIME_API.md now documents these families with their actual shapes, limits, status codes, and ownership boundaries. Gates: cargo fmt --all -- --check clean; runtime_api::tests under scripts/with-hermetic-test-home.sh + scripts/dev-cargo.sh: 250 passed, 0 failed; codewhale-config lib tests 704 passed 0 failed; codewhale-cli lib tests 390 passed 0 failed. No provider calls, no remote actions. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/cli/src/lib.rs | 152 +-- crates/config/src/credentials.rs | 149 ++ crates/config/src/lib.rs | 1 + crates/protocol/src/op.rs | 7 +- crates/tui/src/commands/groups/core/voice.rs | 185 ++- crates/tui/src/config.rs | 4 +- crates/tui/src/core/engine.rs | 33 +- crates/tui/src/core/engine/handle.rs | 14 + crates/tui/src/core/ops.rs | 44 + crates/tui/src/core/protocol_parity.rs | 4 + crates/tui/src/runtime_api.rs | 85 ++ crates/tui/src/runtime_api/commands.rs | 70 + crates/tui/src/runtime_api/context.rs | 96 ++ crates/tui/src/runtime_api/diagnostics.rs | 333 +++++ crates/tui/src/runtime_api/git.rs | 818 +++++++++++ crates/tui/src/runtime_api/jobs.rs | 495 +++++++ crates/tui/src/runtime_api/lsp.rs | 234 ++++ crates/tui/src/runtime_api/secrets.rs | 141 ++ crates/tui/src/runtime_api/targets.rs | 231 ++++ crates/tui/src/runtime_api/tests.rs | 1277 ++++++++++++++++++ crates/tui/src/runtime_api/voice.rs | 112 ++ crates/tui/src/runtime_api/workspace.rs | 6 +- crates/tui/src/runtime_threads.rs | 100 +- crates/tui/src/tools/shell.rs | 122 ++ docs/RUNTIME_API.md | 226 ++++ 25 files changed, 4789 insertions(+), 150 deletions(-) create mode 100644 crates/config/src/credentials.rs create mode 100644 crates/tui/src/runtime_api/commands.rs create mode 100644 crates/tui/src/runtime_api/context.rs create mode 100644 crates/tui/src/runtime_api/diagnostics.rs create mode 100644 crates/tui/src/runtime_api/git.rs create mode 100644 crates/tui/src/runtime_api/jobs.rs create mode 100644 crates/tui/src/runtime_api/lsp.rs create mode 100644 crates/tui/src/runtime_api/secrets.rs create mode 100644 crates/tui/src/runtime_api/targets.rs create mode 100644 crates/tui/src/runtime_api/voice.rs diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index bd3b4d49d2..866ead0187 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -21,6 +21,9 @@ use codewhale_app_server::daemon_socket::{DaemonSocketOptions, run_daemon_socket use codewhale_app_server::{ AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio, }; +use codewhale_config::credentials::{ + clear_provider_api_key_from_config, provider_slot, set_provider_api_key, +}; use codewhale_config::route::{ProvidersExport, parse_route_kind}; use codewhale_config::{ CliRuntimeOverrides, ConfigApiKeyValueKind, ConfigStore, ConfigToml, ProviderKind, @@ -2619,40 +2622,6 @@ fn clear_account_session(profile: Option<&str>) -> Result<(), String> { .map_err(|error| error.to_string()) } -/// Map [`ProviderKind`] to the canonical provider credential slot. -fn provider_slot(provider: ProviderKind) -> &'static str { - // Shared-account families (SiliconFlow China, the four Model Studio - // variants) collapse onto one slot; see ProviderKind::secret_store_slot. - provider.secret_store_slot() -} - -/// Resolve the store for credential-adjacent writes: provider selection, -/// `auth_mode` markers, and the plaintext-free metadata that accompanies a -/// saved key. -/// -/// Credentials and their metadata are user-global — a key saved while -/// working in one repo must be visible from every other repo, and the secret -/// store already is (#5045). When the ambient config path is a -/// workspace-scoped document (`/.codewhale/config.toml`), login and -/// `auth set` must not bind the provider or write auth markers there: the -/// binding would be invisible from every other repo and would invite -/// plaintext keys into a committable repo file (#5198). Returns a store -/// loaded on the user-global document in that case, or `None` when the -/// ambient store is already correctly scoped, so key + provider binding + -/// auth markers share one user-global scope by default. -fn credential_metadata_store(store: &ConfigStore) -> Result> { - if !codewhale_config::config_path_is_workspace_scoped(store.path()) { - return Ok(None); - } - let global = codewhale_config::default_config_path()?; - eprintln!( - "ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead", - codewhale_config::quote_os_path(store.path()), - codewhale_config::quote_os_path(&global), - ); - ConfigStore::load(Some(global)).map(Some) -} - #[cfg(test)] fn no_keyring_secrets() -> Secrets { Secrets::new(std::sync::Arc::new( @@ -2660,102 +2629,6 @@ fn no_keyring_secrets() -> Secrets { )) } -fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) { - store.config.auth_mode = Some("api_key".to_string()); - let provider_config = store.config.providers.for_provider_mut(provider); - provider_config.auth_mode = Some("api_key".to_string()); - provider_config.external_credentials = None; - if provider == ProviderKind::Xai { - provider_config.oauth_credential_generation = None; - } - if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() { - store.config.default_text_model = Some( - store - .config - .providers - .deepseek - .model - .clone() - .unwrap_or_else(|| "deepseek-v4-pro".to_string()), - ); - } -} - -/// Persist a provider credential to the durable secret store without silently -/// downgrading a backend failure to plaintext config storage. -fn persist_provider_api_key( - store: &mut ConfigStore, - secrets: &Secrets, - provider: ProviderKind, - api_key: &str, -) -> Result { - if provider == ProviderKind::Xai { - return codewhale_config::with_xai_oauth_revocation_transaction(|| { - persist_provider_api_key_unlocked(store, secrets, provider, api_key) - }); - } - persist_provider_api_key_unlocked(store, secrets, provider, api_key) -} - -fn persist_provider_api_key_unlocked( - store: &mut ConfigStore, - secrets: &Secrets, - provider: ProviderKind, - api_key: &str, -) -> Result { - let original_config = store.config.clone(); - prepare_provider_api_key_metadata(store, provider); - let slot = provider_slot(provider); - // A readable prior value is required before a secret-store write so a - // later config failure can restore the exact prior state. If the backend - // cannot provide that snapshot, fail before changing the config file. - let prior_secret = secrets.get(slot); - let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) { - Ok(_) => match secrets.set(slot, api_key) { - Ok(()) => { - clear_provider_api_key_from_config(store, provider); - true - } - Err(err) => { - store.config = original_config; - return Err(anyhow::anyhow!( - "Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.", - codewhale_config::quote_os_path(store.path()) - )); - } - }, - Err(error) => { - store.config = original_config; - return Err(anyhow::anyhow!( - "Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.", - codewhale_config::quote_os_path(store.path()) - )); - } - }; - if let Err(error) = store.save() { - store.config = original_config; - if secret_store_saved { - let current = secrets - .get(slot) - .map_err(|rollback| anyhow::anyhow!( - "{error}; additionally could not verify secret-store rollback for {slot}: {rollback}" - ))?; - if current.as_deref() == Some(api_key) { - match prior_secret.expect("snapshot succeeded before secret write") { - Some(previous) => secrets.set(slot, &previous), - None => secrets.delete(slot), - } - .map_err(|rollback| anyhow::anyhow!( - "{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}" - ))?; - } - } - return Err(error); - } - codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?; - Ok(secret_store_saved) -} - fn clear_auth_provider( store: &mut ConfigStore, secrets: &Secrets, @@ -2852,13 +2725,6 @@ fn clear_legacy_antigravity_config(store: &mut ConfigStore, secrets: &Secrets) - Ok(()) } -fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) { - store.config.providers.for_provider_mut(provider).api_key = None; - if provider == ProviderKind::Deepseek { - store.config.api_key = None; - } -} - fn provider_env_set(provider: ProviderKind) -> bool { provider_env_value(provider).is_some() } @@ -4362,9 +4228,17 @@ fn run_auth_command_with_secrets_and_runtime( (None, true) => read_api_key_from_stdin()?, (None, false) => prompt_api_key(slot)?, }; - let mut credential_store = credential_metadata_store(store)?; + let mut credential_store = + codewhale_config::credentials::credential_metadata_store(store)?; + if let Some(redirected) = credential_store.as_ref() { + eprintln!( + "ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead", + codewhale_config::quote_os_path(store.path()), + codewhale_config::quote_os_path(redirected.path()), + ); + } let store = credential_store.as_mut().unwrap_or(store); - let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?; + let secret_store_saved = set_provider_api_key(store, secrets, provider, &api_key)?; // Don't print the key. Don't echo length. if secret_store_saved { println!( diff --git a/crates/config/src/credentials.rs b/crates/config/src/credentials.rs new file mode 100644 index 0000000000..9e0e40dcad --- /dev/null +++ b/crates/config/src/credentials.rs @@ -0,0 +1,149 @@ +//! Canonical provider-credential writes shared by the CLI (`auth set`), +//! the runtime API secret route, and any future host. Owning this here keeps +//! every writer on the same transactional discipline: snapshot the prior +//! secret, write the durable backend, refuse plaintext config fallback, and +//! roll both stores back if either leg fails. + +use anyhow::{Context, Result}; + +use crate::provider_kind::ProviderKind; +use crate::{ConfigStore, Secrets}; + +/// Resolve the store for credential-adjacent writes: provider selection, +/// `auth_mode` markers, and the plaintext-free metadata that accompanies a +/// saved key. +/// +/// Credentials and their metadata are user-global — a key saved while +/// working in one repo must be visible from every other repo, and the secret +/// store already is. When the ambient config path is a workspace-scoped +/// document (`/.codewhale/config.toml`), credential writes must not +/// bind the provider or write auth markers there: the binding would be +/// invisible from every other repo and would invite plaintext keys into a +/// committable repo file. Returns a store loaded on the user-global document +/// in that case, or `None` when the ambient store is already correctly +/// scoped, so key + provider binding + auth markers share one user-global +/// scope by default. +pub fn credential_metadata_store(store: &ConfigStore) -> Result> { + if !crate::config_path_is_workspace_scoped(store.path()) { + return Ok(None); + } + let global = crate::default_config_path()?; + ConfigStore::load(Some(global)).map(Some) +} + +/// The secret-store slot a provider's key occupies. Shared-account families +/// (SiliconFlow China, the Model Studio variants) collapse onto one slot; +/// see [`ProviderKind::secret_store_slot`]. +#[must_use] +pub fn provider_slot(provider: ProviderKind) -> &'static str { + provider.secret_store_slot() +} + +/// Remove any plaintext `api_key` left in the config for `provider`. +pub fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) { + store.config.providers.for_provider_mut(provider).api_key = None; + if provider == ProviderKind::Deepseek { + store.config.api_key = None; + } +} + +/// Plaintext-free metadata that accompanies a saved key. +pub fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) { + store.config.auth_mode = Some("api_key".to_string()); + let provider_config = store.config.providers.for_provider_mut(provider); + provider_config.auth_mode = Some("api_key".to_string()); + provider_config.external_credentials = None; + if provider == ProviderKind::Xai { + provider_config.oauth_credential_generation = None; + } + if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() { + store.config.default_text_model = Some( + store + .config + .providers + .deepseek + .model + .clone() + .unwrap_or_else(|| "deepseek-v4-pro".to_string()), + ); + } +} + +/// Persist a provider credential to the durable secret store without silently +/// downgrading a backend failure to plaintext config storage. +/// +/// Returns `true` when the key landed in the secret store (config then holds +/// metadata only). Callers must not print or echo `api_key`. +pub fn set_provider_api_key( + store: &mut ConfigStore, + secrets: &Secrets, + provider: ProviderKind, + api_key: &str, +) -> Result { + if provider == ProviderKind::Xai { + return crate::with_xai_oauth_revocation_transaction(|| { + set_provider_api_key_unlocked(store, secrets, provider, api_key) + }); + } + set_provider_api_key_unlocked(store, secrets, provider, api_key) +} + +fn set_provider_api_key_unlocked( + store: &mut ConfigStore, + secrets: &Secrets, + provider: ProviderKind, + api_key: &str, +) -> Result { + let original_config = store.config.clone(); + prepare_provider_api_key_metadata(store, provider); + let slot = provider_slot(provider); + // A readable prior value is required before a secret-store write so a + // later config failure can restore the exact prior state. If the backend + // cannot provide that snapshot, fail before changing the config file. + let prior_secret = secrets.get(slot); + let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) { + Ok(_) => match secrets.set(slot, api_key) { + Ok(()) => { + clear_provider_api_key_from_config(store, provider); + true + } + Err(err) => { + store.config = original_config; + return Err(anyhow::anyhow!( + "Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.", + crate::quote_os_path(store.path()) + )); + } + }, + Err(error) => { + store.config = original_config; + return Err(anyhow::anyhow!( + "Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.", + crate::quote_os_path(store.path()) + )); + } + }; + if let Err(error) = store.save() { + store.config = original_config; + if secret_store_saved { + let current = secrets + .get(slot) + .map_err(|rollback| anyhow::anyhow!( + "{error}; additionally could not verify secret-store rollback for {slot}: {rollback}" + ))?; + if current.as_deref() == Some(api_key) { + match prior_secret.expect("snapshot succeeded before secret write") { + Some(previous) => secrets.set(slot, &previous), + None => secrets.delete(slot), + } + .map_err(|rollback| anyhow::anyhow!( + "{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}" + ))?; + } + } + return Err(error); + } + crate::scrub_plaintext_api_keys_from_config_backup(store.path()) + .context("failed to scrub plaintext API keys from config backup")?; + Ok(secret_store_saved) +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index da5d67bc48..fa0ee8fe10 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -4,6 +4,7 @@ pub mod auto_model; pub mod catalog; pub mod cloud_facts; mod config_document; +pub mod credentials; pub mod descriptors; pub mod device_code; pub mod external_credentials; diff --git a/crates/protocol/src/op.rs b/crates/protocol/src/op.rs index 6c67b0b4e0..10e17954b4 100644 --- a/crates/protocol/src/op.rs +++ b/crates/protocol/src/op.rs @@ -16,7 +16,7 @@ //! What is deliberately stripped at this boundary: //! //! - `mpsc` / `oneshot` reply channels (`GetSubAgentSettlement`, `GetSessionSnapshot`, -//! `GetProviderRuntimeStatus`, `BootstrapMcp`, `RetryMcpServer`, +//! `GetContextBudget`, `GetProviderRuntimeStatus`, `BootstrapMcp`, `RetryMcpServer`, //! `ReloadMcp`). Over the wire the reply is an `EventMsg` or a response //! frame, not a channel. //! - `Arc` on `SendMessage`: hooks are host configuration, not @@ -388,6 +388,9 @@ pub enum Op { /// Request a session snapshot; the reply travels out-of-band. GetSessionSnapshot, + /// Request the live context-window budget for the session's route; the + /// reply travels out-of-band. + GetContextBudget, /// Request provider concurrency state; the reply travels out-of-band. GetProviderRuntimeStatus, /// Populate the engine-owned MCP pool once at boot; reply out-of-band. @@ -494,6 +497,7 @@ impl Op { Self::CompactContext { .. } => "compact_context", Self::CancelCompaction { .. } => "cancel_compaction", Self::GetSessionSnapshot => "get_session_snapshot", + Self::GetContextBudget => "get_context_budget", Self::GetProviderRuntimeStatus => "get_provider_runtime_status", Self::BootstrapMcp => "bootstrap_mcp", Self::RetryMcpServer { .. } => "retry_mcp_server", @@ -675,6 +679,7 @@ mod tests { }, Op::CancelCompaction { id: "cmp-1".into() }, Op::GetSessionSnapshot, + Op::GetContextBudget, Op::GetProviderRuntimeStatus, Op::BootstrapMcp, Op::RetryMcpServer { name: "fs".into() }, diff --git a/crates/tui/src/commands/groups/core/voice.rs b/crates/tui/src/commands/groups/core/voice.rs index 8d73020775..6a980da460 100644 --- a/crates/tui/src/commands/groups/core/voice.rs +++ b/crates/tui/src/commands/groups/core/voice.rs @@ -108,6 +108,12 @@ struct Recorder { } fn detect_recorder() -> Option { + // Operator kill-switch: a headless `serve --http` host has no business + // opening a microphone; disabling voice here makes `GET /v1/voice` + // report `available: false` and every dictate call fail closed. + if std::env::var_os("CODEWHALE_DISABLE_VOICE").is_some() { + return None; + } let candidates: &[Recorder] = if cfg!(target_os = "macos") { &[ Recorder { @@ -194,7 +200,7 @@ fn encode_wav(samples: &[i16]) -> Vec { // --- Recording ------------------------------------------------------------- /// Maximum recording duration in seconds before auto-stopping. -const MAX_RECORD_SECS: u64 = 10; +pub const MAX_RECORD_SECS: u64 = 10; /// Minimum segment duration in seconds to consider as valid speech. const MIN_SEGMENT_SECS: f64 = 0.3; @@ -285,6 +291,10 @@ fn record_audio() -> Option<(Vec, Duration)> { // --- Auto-send suffix ------------------------------------------------------ +/// Trailing phrases that mean "submit this" — the human-readable form of +/// `SEND_SUFFIX_RE`; keep in sync with the regex when either changes. +pub const SEND_PHRASES: &[&str] = &["send it", "发送", "發送"]; + /// Matches an explicit send instruction at the end of transcribed text: /// "send it" (any spacing/case) or 发送/發送, with trailing punctuation. static SEND_SUFFIX_RE: LazyLock = LazyLock::new(|| { @@ -731,6 +741,179 @@ pub async fn capture_and_transcribe( Ok(VoiceCaptureOutcome::Insert(clean.to_string())) } +// --- Headless capture (HTTP/native-client path) ---------------------------- + +/// What a headless dictation should do with the finished transcript. +#[derive(Debug, Clone)] +pub enum DictateMode { + /// Transcribe and return the text for insertion into the composer. + Insert, + /// Transcribe, then apply the "send it" / 发送 suffix contract. The + /// outcome's `send` flag tells the client to submit; a bare send + /// instruction yields empty `text` so the client submits its own draft. + Send, + /// AI-assisted dictation that sees the client's composer text — the + /// `/voice-control` pipeline. Only provider ASR can see context; free + /// ASR kinds degrade to plain transcription with `assisted: false`. + Control(String), +} + +/// Machine-readable failure for the headless path so HTTP clients can +/// localize by `reason` rather than parsing message text. +#[derive(Debug)] +pub enum DictateError { + /// No supported recorder binary on this host. + NoRecorder, + /// Recording produced no usable speech segment. + NoSpeech, + /// The selected/fallback ASR needs a provider key that isn't configured. + NoProviderAuth, + /// ASR request or transcription failed. + Transcription(String), +} + +impl DictateError { + pub fn reason(&self) -> &'static str { + match self { + Self::NoRecorder => "no_recorder", + Self::NoSpeech => "no_speech", + Self::NoProviderAuth => "no_provider_auth", + Self::Transcription(_) => "transcription_failed", + } + } +} + +impl std::fmt::Display for DictateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoRecorder => write!(f, "no supported voice recorder on this host"), + Self::NoSpeech => write!(f, "no speech detected"), + Self::NoProviderAuth => { + write!(f, "provider ASR requires a configured API key") + } + Self::Transcription(e) => write!(f, "{e}"), + } + } +} + +/// Result of one headless record→transcribe cycle. +#[derive(Debug)] +pub struct DictationOutcome { + /// Final transcript (send suffix already stripped for `Send` mode). + pub text: String, + /// `Send` mode only: the transcript ended with an explicit send phrase. + pub send: bool, + /// `Control` mode only: the composer context reached the model. False + /// when a free ASR kind handled the audio and never saw the context. + pub assisted: bool, + /// Which ASR backend was selected for this capture. + pub asr_kind: String, + pub asr_model: String, +} + +/// Detected recorder binary name, if any (`"sox"`, `"arecord"`, `"rec"`). +pub fn recorder_command() -> Option<&'static str> { + detect_recorder().map(|r| r.cmd) +} + +/// Resolved ASR selection (`kind`, `model`) for capability reporting. +pub fn asr_choice(config: &Config) -> (String, String) { + resolve_asr_choice(config) +} + +/// One record→transcribe cycle with no UI surface: the HTTP/native-client +/// equivalent of [`capture_and_transcribe`]. Recording runs on a blocking +/// thread; transcription follows the same ASR dispatch as the TUI — +/// explicit `CODEWHALE_ASR_MODEL` > local whisper > Groq > provider — +/// but resolves the provider key lazily so free ASR kinds work without +/// provider auth. +pub async fn dictate_once( + config: &Config, + mode: DictateMode, +) -> Result { + if !is_available() { + return Err(DictateError::NoRecorder); + } + let (samples, _duration) = tokio::task::spawn_blocking(record_audio) + .await + .ok() + .flatten() + .ok_or(DictateError::NoSpeech)?; + + let (asr_kind, asr_model) = resolve_asr_choice(config); + let base_url = config.deepseek_base_url(); + let openrouter_vendor = config + .openrouter_vendor() + .map_err(|e| DictateError::Transcription(e.to_string()))?; + let provider_key = || { + config + .deepseek_api_key() + .map_err(|_| DictateError::NoProviderAuth) + }; + + let mut assisted = false; + let text = match asr_kind.as_str() { + "local-whisper" => match transcribe_local_whisper(&samples).await { + Ok(v) => v, + Err(_) => transcribe( + &provider_key()?, + &base_url, + &samples, + openrouter_vendor.as_deref(), + ) + .await + .map_err(DictateError::Transcription)?, + }, + "groq" => match transcribe_groq(&samples).await { + Ok(v) => v, + Err(_) => transcribe( + &provider_key()?, + &base_url, + &samples, + openrouter_vendor.as_deref(), + ) + .await + .map_err(DictateError::Transcription)?, + }, + _ => { + let api_key = provider_key()?; + match &mode { + DictateMode::Control(composer) => { + assisted = true; + process_voice_control( + &api_key, + &base_url, + &samples, + composer, + openrouter_vendor.as_deref(), + ) + .await + .map_err(DictateError::Transcription)? + } + _ => transcribe(&api_key, &base_url, &samples, openrouter_vendor.as_deref()) + .await + .map_err(DictateError::Transcription)?, + } + } + }; + + let clean = text.trim().to_string(); + let (text, send) = match mode { + DictateMode::Send => { + let (remainder, wants_send) = split_send_suffix(&clean); + (remainder.to_string(), wants_send) + } + _ => (clean, false), + }; + Ok(DictationOutcome { + text, + send, + assisted, + asr_kind, + asr_model, + }) +} + // --- Command handlers ------------------------------------------------------ /// Handle the `/voice` command: toggle voice input. Toggling on requests a diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 775df858d9..279248f2aa 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -11842,12 +11842,12 @@ fn plaintext_credential_fallback_refused( /// isolated `CODEWHALE_HOME` and an explicit backend, so unit tests can never /// touch the developer's real credential store. #[cfg(not(test))] -fn credential_secret_store() -> Option { +pub(crate) fn credential_secret_store() -> Option { Some(codewhale_secrets::Secrets::auto_detect()) } #[cfg(test)] -fn credential_secret_store() -> Option { +pub(crate) fn credential_secret_store() -> Option { let isolated_home = codewhale_paths::codewhale_home_is_explicit(); let explicit_backend = std::env::var_os("CODEWHALE_SECRET_BACKEND") .or_else(|| std::env::var_os("DEEPSEEK_SECRET_BACKEND")) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 269cd869c4..4de6891f3e 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -79,8 +79,8 @@ use super::authority::{ }; use super::events::{Event, TurnOutcomeStatus, TurnRoute}; use super::ops::{ - McpManagerUpdate, Op, ProviderRuntimeStatus, SessionSnapshot, USER_SHELL_TOOL_ID_PREFIX, - UserInputProvenance, + McpManagerUpdate, Op, ProviderRuntimeStatus, SessionContextBudget, SessionSnapshot, + USER_SHELL_TOOL_ID_PREFIX, UserInputProvenance, }; use super::session::Session; use super::tool_parser; @@ -3160,6 +3160,35 @@ impl Engine { let _ = tx.send(snapshot); } } + Op::GetContextBudget { tx } => { + let input_tokens = self.estimated_input_tokens() as u64; + let budget = route_context_budget_for_route( + self.api_provider, + &self.session.model, + self.active_route_limits, + usize::try_from(input_tokens).unwrap_or(usize::MAX), + ); + let snapshot = budget.map(|budget| SessionContextBudget { + window_tokens: budget.window_tokens, + input_tokens, + billed_input_tokens: self + .session + .latest_parent_input_tokens + .map(u64::from), + output_cap_tokens: budget.output_cap_tokens, + input_budget_ceiling: budget.input_budget_ceiling, + available_input_tokens: budget.available_input_tokens, + compaction_trigger_tokens: budget.compaction_trigger_tokens, + usage_percent: budget.usage_percent(), + pressure: budget.pressure.label(), + model: self.session.model.clone(), + provider: self.api_provider.as_str().to_string(), + model_provider_id: self.api_provider_id.clone(), + }); + if let Some(tx) = tx.lock().ok().and_then(|mut g| g.take()) { + let _ = tx.send(snapshot); + } + } Op::GetProviderRuntimeStatus { tx } => { let status = if let Some(client) = self.deepseek_client.as_ref() { ProviderRuntimeStatus { diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index d5326d251b..1c90940f4d 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -414,6 +414,20 @@ impl EngineHandle { Ok(()) } + /// Request the live context-window budget for this session's route. + /// `None` means the route cannot express a bounded window (e.g. an + /// unknown model with no catalog or configured limits) — callers should + /// surface "unavailable" rather than inventing a number. + pub async fn get_context_budget( + &self, + ) -> Result> { + let (tx, rx) = tokio::sync::oneshot::channel(); + let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); + self.send(Op::GetContextBudget { tx }).await?; + rx.await + .map_err(|_| anyhow::anyhow!("Engine dropped context budget oneshot")) + } + /// Request a snapshot of the current session state. /// Returns the snapshot directly via a oneshot channel, avoiding /// competition with the SSE event stream on the mpsc receiver. diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 11785121f3..31b9ed3923 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -32,6 +32,41 @@ pub struct SessionSnapshot { pub mode: String, } +/// Live context-window posture for one thread, computed where the session +/// state actually lives. Returned by `Op::GetContextBudget` via a oneshot +/// channel so HTTP clients (GPUI usage panel) never re-derive the engine's +/// token math or route limits at the API boundary. +#[derive(Debug, Clone)] +pub struct SessionContextBudget { + /// Total context window for the active route (input + output), in tokens. + pub window_tokens: u64, + /// Estimated input tokens on the same basis the visible context meter + /// uses (`estimate_input_tokens_conservative`, including its safety + /// inflation). This is the number a "context filling up" indicator shows. + pub input_tokens: u64, + /// Provider-billed prompt tokens from the most recent parent-route + /// request that still describes the live message list. `None` when no + /// provider count exists yet (fresh session) — never a fabricated zero. + pub billed_input_tokens: Option, + /// Output tokens reserved for the turn after route clamps. + pub output_cap_tokens: u64, + /// Spendable input ceiling (`window - output_cap - headroom`, intersected + /// with any provider-published hard input limit). + pub input_budget_ceiling: u64, + /// Input tokens still available before the reserved boundary. + pub available_input_tokens: u64, + /// Input level at which compaction is suggested. + pub compaction_trigger_tokens: u64, + /// `input_tokens / window_tokens` as a percentage (0..=100). + pub usage_percent: f64, + /// Coarse pressure label (`low`/`moderate`/`high`/`critical`). + pub pressure: &'static str, + /// Route identity the budget was computed for. + pub model: String, + pub provider: String, + pub model_provider_id: Option, +} + /// Provider request runtime state surfaced by `/provider`. /// Returned by `Op::GetProviderRuntimeStatus` via a oneshot channel. #[derive(Debug, Clone, PartialEq, Eq)] @@ -336,6 +371,15 @@ pub enum Op { tx: std::sync::Arc>>>, }, + /// Get the live context-window budget for this session's route. Computed + /// on the engine so `active_route_limits`, the memoized token estimate, + /// and the last billed prompt size all come from one authority. + GetContextBudget { + tx: std::sync::Arc< + std::sync::Mutex>>>, + >, + }, + /// Get active provider request concurrency state for readiness surfaces. GetProviderRuntimeStatus { tx: std::sync::Arc< diff --git a/crates/tui/src/core/protocol_parity.rs b/crates/tui/src/core/protocol_parity.rs index 425a84fd04..0ccd217ec3 100644 --- a/crates/tui/src/core/protocol_parity.rs +++ b/crates/tui/src/core/protocol_parity.rs @@ -1157,6 +1157,7 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op { Op::CancelCompaction { id } => wire_op::Op::CancelCompaction { id: id.clone() }, // Reply channels never cross the wire: the answer is a frame. Op::GetSessionSnapshot { tx: _ } => wire_op::Op::GetSessionSnapshot, + Op::GetContextBudget { tx: _ } => wire_op::Op::GetContextBudget, Op::GetProviderRuntimeStatus { tx: _ } => wire_op::Op::GetProviderRuntimeStatus, Op::BootstrapMcp { tx: _ } => wire_op::Op::BootstrapMcp, Op::RetryMcpServer { name, tx: _ } => wire_op::Op::RetryMcpServer { name: name.clone() }, @@ -1555,6 +1556,9 @@ mod tests { Op::GetSessionSnapshot { tx: std::sync::Arc::new(std::sync::Mutex::new(None)), }, + Op::GetContextBudget { + tx: std::sync::Arc::new(std::sync::Mutex::new(None)), + }, Op::GetProviderRuntimeStatus { tx: std::sync::Arc::new(std::sync::Mutex::new(None)), }, diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index a1eac02073..c72def3745 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -93,9 +93,18 @@ use codewhale_protocol::fleet::{ }; mod auth; +mod commands; +mod context; +mod diagnostics; +mod git; +mod jobs; +mod lsp; mod mobile; mod plugins; +mod secrets; mod sessions; +mod targets; +mod voice; mod web; mod workspace; #[cfg(test)] @@ -193,6 +202,11 @@ pub struct RuntimeApiState { /// lazily-initialized slot; slow per-pool work (connect_all) runs under /// the inner handle so it cannot block slot reads. mcp_pool: Arc>>>>, + /// Workspace-level LSP client for the HTTP surface (APPS-93): diagnostics + /// and semantic queries on files a client views. Engines keep their own + /// per-thread managers; this one serves the file view and is built lazily + /// so a server without LSP use never spawns a language server. + lsp_manager: Arc>>, #[cfg(test)] compat_stream_test_hook: Option>, } @@ -946,6 +960,7 @@ pub async fn run_http_server( web, fleet_codewhale_binary: configured_codewhale_binary(), mcp_pool: Arc::new(Mutex::new(None)), + lsp_manager: Arc::new(std::sync::OnceLock::new()), #[cfg(test)] compat_stream_test_hook: None, }; @@ -1077,6 +1092,7 @@ fn fallback_sessions_dir() -> PathBuf { } pub fn build_router(state: RuntimeApiState) -> Router { + diagnostics::mark_server_started(); let api_routes = Router::new() .route( "/v1/sessions", @@ -1159,9 +1175,71 @@ pub fn build_router(state: RuntimeApiState) -> Router { codewhale_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES, )), ) + .route("/v1/commands", get(commands::list_commands)) + .route("/v1/git", get(git::git_status_detail)) + .route("/v1/changes", get(git::git_changes)) + .route("/v1/diff", get(git::git_diff)) + .route("/v1/workspace/diff", get(git::workspace_diff)) + .route("/v1/git/graph", get(git::git_graph)) + .route("/v1/git/stage", post(git::git_stage)) + .route("/v1/git/unstage", post(git::git_unstage)) + .route("/v1/git/discard", post(git::git_discard)) + .route("/v1/git/commit", post(git::git_commit)) + .route("/v1/git/push", post(git::git_push)) + .route("/v1/git/branch", post(git::git_branch)) + .route("/v1/logs", get(diagnostics::list_logs)) + .route("/v1/logs/{name}", get(diagnostics::read_log)) + .route("/v1/crashes", get(diagnostics::list_crashes)) + .route("/v1/crashes/{name}", get(diagnostics::read_crash)) + .route("/v1/process", get(diagnostics::process_info)) + .route("/v1/jobs", get(jobs::list_jobs)) .route("/v1/threads", get(list_threads).post(create_thread)) .route("/v1/threads/summary", get(list_threads_summary)) .route("/v1/threads/{id}", get(get_thread).patch(update_thread)) + .route( + "/v1/threads/{id}/jobs", + get(jobs::list_thread_jobs).post(jobs::create_thread_job), + ) + .route("/v1/threads/{id}/jobs/{job_id}", get(jobs::get_thread_job)) + .route( + "/v1/threads/{id}/jobs/{job_id}/output", + get(jobs::get_thread_job_output), + ) + .route( + "/v1/threads/{id}/jobs/{job_id}/stdin", + post(jobs::write_thread_job_stdin), + ) + .route( + "/v1/threads/{id}/jobs/{job_id}/kill", + post(jobs::kill_thread_job), + ) + .route("/v1/threads/{id}/context", get(context::get_thread_context)) + .route( + "/v1/targets", + get(targets::list_targets).post(targets::create_target), + ) + .route("/v1/targets/switch", post(targets::switch_target)) + .route("/v1/remote", get(targets::remote_status)) + .route("/v1/remote/connect", post(targets::remote_connect)) + .route( + "/v1/ssh", + get(targets::ssh_status).post(targets::ssh_connect), + ) + .route("/v1/ssh/connect", post(targets::ssh_connect)) + .route( + "/v1/cloud", + get(targets::cloud_status).post(targets::cloud_attach), + ) + .route("/v1/cloud/attach", post(targets::cloud_attach)) + .route("/v1/lsp", get(lsp::lsp_status)) + .route("/v1/diagnostics", get(lsp::lsp_diagnostics)) + .route("/v1/definition", get(lsp::lsp_definition)) + .route("/v1/references", get(lsp::lsp_references)) + .route("/v1/symbols", get(lsp::lsp_symbols)) + .route("/v1/voice", get(voice::voice_status)) + .route("/v1/voice/dictate", post(voice::voice_dictate)) + .route("/v1/voice/send", post(voice::voice_send)) + .route("/v1/voice/control", post(voice::voice_control)) .route("/v1/threads/{id}/resume", post(resume_thread)) .route("/v1/threads/{id}/fork", post(fork_thread)) .route("/v1/threads/{id}/undo", post(undo_thread_turn)) @@ -1324,6 +1402,12 @@ pub fn build_router(state: RuntimeApiState) -> Router { .route("/v1/providers", get(list_providers)) .route("/v1/providers/{id}/models", get(list_provider_models)) .route("/v1/providers/{id}/switch", post(switch_provider)) + .route( + "/v1/providers/{id}/key", + put(secrets::set_provider_key).layer(DefaultBodyLimit::max( + secrets::PROVIDER_KEY_BODY_LIMIT_BYTES, + )), + ) .route("/v1/config", get(get_config).post(set_config)) .route("/v1/config/reload", post(reload_config)) .route( @@ -8302,6 +8386,7 @@ base_url = "http://127.0.0.1:9/v1" web: None, fleet_codewhale_binary: "unused-test-binary".to_string(), mcp_pool: Arc::new(Mutex::new(None)), + lsp_manager: Arc::new(std::sync::OnceLock::new()), compat_stream_test_hook: None, }; let router = build_router(state.clone()); diff --git a/crates/tui/src/runtime_api/commands.rs b/crates/tui/src/runtime_api/commands.rs new file mode 100644 index 0000000000..288662e8d7 --- /dev/null +++ b/crates/tui/src/runtime_api/commands.rs @@ -0,0 +1,70 @@ +use axum::Json; +use axum::extract::State; +use serde::Serialize; + +use super::{ApiError, RuntimeApiState}; + +#[derive(Debug, Serialize)] +pub(super) struct CommandCatalogEntry { + name: &'static str, + aliases: Vec<&'static str>, + usage: &'static str, + description: String, + subcommands: Vec<&'static str>, + discovery: &'static str, + requires_argument: bool, + requires_required_argument: bool, + composer_wants_trailing_space: bool, + palette_runs_directly: bool, + show_in_empty_discovery: bool, + unlisted: bool, +} + +#[derive(Debug, Serialize)] +pub(super) struct CommandCatalogResponse { + commands: Vec, + locale: String, +} + +/// Typed projection of the built-in slash-command registry for native-client +/// composers and palettes. The registry in `crate::commands` stays the single +/// authority: this handler only serializes the same `CommandInfo` the TUI +/// consumes, including the discovery tiers and argument-shape hints the +/// composer needs to reproduce slash completion without re-parsing `usage`. +/// +/// Descriptions are localized with the runtime's configured locale; `locale` +/// in the response names the resolved pack so a client can detect fallback. +/// User-registered commands are intentionally absent — they are per-session +/// state, and a session-scoped projection can layer them on later without +/// changing this contract. +pub(super) async fn list_commands( + State(_state): State, +) -> Result, ApiError> { + let settings = crate::settings::Settings::load_persisted().unwrap_or_default(); + let locale = codewhale_localization::resolve_locale(&settings.locale); + let commands = crate::commands::command_infos() + .into_iter() + .map(|info| CommandCatalogEntry { + name: info.name, + aliases: info.aliases.to_vec(), + usage: info.usage, + description: info.description_for(locale).into_owned(), + subcommands: info.subcommands(), + discovery: match info.discovery() { + crate::commands::traits::CommandDiscovery::Primary => "primary", + crate::commands::traits::CommandDiscovery::Advanced => "advanced", + crate::commands::traits::CommandDiscovery::Compatibility => "compatibility", + }, + requires_argument: info.requires_argument(), + requires_required_argument: info.requires_required_argument(), + composer_wants_trailing_space: info.composer_wants_trailing_space(), + palette_runs_directly: info.palette_runs_directly(), + show_in_empty_discovery: info.show_in_empty_discovery(), + unlisted: info.is_unlisted(), + }) + .collect(); + Ok(Json(CommandCatalogResponse { + commands, + locale: locale.tag().to_string(), + })) +} diff --git a/crates/tui/src/runtime_api/context.rs b/crates/tui/src/runtime_api/context.rs new file mode 100644 index 0000000000..a977dc1d9e --- /dev/null +++ b/crates/tui/src/runtime_api/context.rs @@ -0,0 +1,96 @@ +use axum::Json; +use axum::extract::{Path, State}; +use serde::Serialize; + +use super::{ApiError, RuntimeApiState, map_thread_err}; + +/// Live context-window posture for one thread — the facts the GPUI usage +/// panel cannot reconstruct from turn receipts. `input_tokens` is the same +/// conservative estimate the in-app context meter shows; `billed_input_tokens` +/// is the last provider-counted prompt size when one exists. +/// +/// All numeric fields are nullable: a route that cannot express a bounded +/// window (unknown model, no catalog or configured limits) reports `null` +/// rather than an invented number, and `live: false` marks responses where +/// the engine could not be loaded and only the static route window resolved. +#[derive(Debug, Serialize)] +pub(super) struct ThreadContextResponse { + thread_id: String, + model: String, + provider: Option, + model_provider_id: Option, + /// `true` when the numbers came from the loaded engine (live estimate + + /// route limits); `false` when only the store-recorded route resolved. + live: bool, + window_tokens: Option, + input_tokens: Option, + billed_input_tokens: Option, + output_cap_tokens: Option, + input_budget_ceiling: Option, + available_input_tokens: Option, + compaction_trigger_tokens: Option, + usage_percent: Option, + pressure: Option<&'static str>, +} + +pub(super) async fn get_thread_context( + State(state): State, + Path(thread_id): Path, +) -> Result, ApiError> { + let thread = state + .runtime_threads + .get_thread(&thread_id) + .await + .map_err(map_thread_err)?; + + if let Ok(engine) = state.runtime_threads.get_engine(&thread_id).await + && let Ok(Some(snapshot)) = engine.get_context_budget().await + { + return Ok(Json(ThreadContextResponse { + thread_id, + model: snapshot.model, + provider: Some(snapshot.provider), + model_provider_id: snapshot.model_provider_id, + live: true, + window_tokens: Some(snapshot.window_tokens), + input_tokens: Some(snapshot.input_tokens), + billed_input_tokens: snapshot.billed_input_tokens, + output_cap_tokens: Some(snapshot.output_cap_tokens), + input_budget_ceiling: Some(snapshot.input_budget_ceiling), + available_input_tokens: Some(snapshot.available_input_tokens), + compaction_trigger_tokens: Some(snapshot.compaction_trigger_tokens), + usage_percent: Some(snapshot.usage_percent), + pressure: Some(snapshot.pressure), + })); + } + + // Engine unavailable or the route cannot bound a window: still answer + // with whatever the thread record and the static route catalog can prove. + let provider = thread + .model_provider + .as_deref() + .and_then(crate::config::ApiProvider::parse); + let window_tokens = provider.map(|provider| { + u64::from(crate::route_budget::route_context_window_tokens( + provider, + &thread.model, + None, + )) + }); + Ok(Json(ThreadContextResponse { + thread_id, + model: thread.model, + provider: thread.model_provider, + model_provider_id: thread.model_provider_id, + live: false, + window_tokens, + input_tokens: None, + billed_input_tokens: None, + output_cap_tokens: None, + input_budget_ceiling: None, + available_input_tokens: None, + compaction_trigger_tokens: None, + usage_percent: None, + pressure: None, + })) +} diff --git a/crates/tui/src/runtime_api/diagnostics.rs b/crates/tui/src/runtime_api/diagnostics.rs new file mode 100644 index 0000000000..4252deca6c --- /dev/null +++ b/crates/tui/src/runtime_api/diagnostics.rs @@ -0,0 +1,333 @@ +//! Crash/log inspection for native clients (APPS-103). +//! +//! This is a read surface, not a telemetry store: it lists and serves files +//! the runtime already writes to disk — `logs/` rolling logs, `audit.log`, +//! and `crashes/*.log` panic dumps — so a client (including a remote or +//! headless one that cannot see the disk) can package an export locally. +//! There is deliberately no upload route and no second log store. +//! +//! Routes: +//! GET /v1/logs — recent log/audit entries (name, size, modified) +//! GET /v1/logs/{name} — bounded window of one file (?offset, ?limit, ?tail) +//! GET /v1/crashes — crash-dump entries +//! GET /v1/crashes/{name} — bounded window of one dump +//! GET /v1/process — pid, start time, uptime, version, RSS (Linux) + +use std::fs::File; +use std::io::{Read as _, Seek as _, SeekFrom}; +use std::path::{Path as FsPath, PathBuf}; +use std::sync::OnceLock; +use std::time::{Instant, SystemTime}; + +use axum::Json; +use axum::extract::{Path, Query, State}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::workspace::encode_window; +use super::{ApiError, RuntimeApiState}; + +/// Default read window for a file entry. +const READ_LIMIT_DEFAULT: usize = 256 * 1024; +const READ_LIMIT_MAX: usize = 4 * 1024 * 1024; +/// Listing caps — newest first, bounded so a long-lived install cannot +/// produce an unbounded response. +const LOG_LIST_CAP: usize = 64; +const CRASH_LIST_CAP: usize = 64; + +/// When this API server came up. `build_router` stamps it once so process +/// facts describe the serving process, not first-call time. +static SERVER_STARTED: OnceLock<(SystemTime, Instant)> = OnceLock::new(); + +pub(super) fn mark_server_started() { + let _ = SERVER_STARTED.set((SystemTime::now(), Instant::now())); +} + +// --------------------------------------------------------------------------- +// Shared listing + bounded read +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +struct FileEntry { + name: String, + size: u64, + #[serde(skip_serializing_if = "Option::is_none")] + modified: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct FileReadQuery { + offset: Option, + limit: Option, + /// Convenience tail read: last N bytes of the file. + tail: Option, +} + +fn rfc3339(time: SystemTime) -> String { + chrono::DateTime::::from(time).to_rfc3339() +} + +/// Basenames only — a `{name}` path segment must never reach outside the +/// listing directory. Refuse anything that is not a plain file name. +fn safe_entry_name(raw: &str) -> Result { + let name = raw.trim(); + if name.is_empty() + || name.len() > 255 + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + || name == "." + || name == ".." + { + return Err(ApiError::bad_request("name must be a file name")); + } + Ok(name.to_string()) +} + +fn list_files(dir: &FsPath, cap: usize) -> Vec { + let mut entries: Vec = Vec::new(); + if let Ok(read_dir) = std::fs::read_dir(dir) { + for entry in read_dir.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() { + continue; + } + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let Ok(metadata) = entry.metadata() else { + continue; + }; + entries.push(FileEntry { + name, + size: metadata.len(), + modified: metadata.modified().ok().map(rfc3339), + }); + } + } + entries.sort_by(|a, b| { + b.modified + .cmp(&a.modified) + .then_with(|| a.name.cmp(&b.name)) + }); + entries.truncate(cap); + entries +} + +/// Read `[offset, offset + limit)` of a named file inside `dir` without +/// loading the whole file. Symlinks are never followed. +fn read_named_window(dir: &FsPath, name: &str, query: FileReadQuery) -> Result { + let path = dir.join(name); + let metadata = std::fs::symlink_metadata(&path).map_err(|error| match error.kind() { + std::io::ErrorKind::NotFound => ApiError::not_found("file not found"), + _ => ApiError::internal(format!("file access failed: {error}")), + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ApiError::forbidden("not a regular file")); + } + let size = metadata.len(); + let limit = query.limit.unwrap_or(READ_LIMIT_DEFAULT); + if !(1..=READ_LIMIT_MAX).contains(&limit) { + return Err(ApiError::bad_request(format!( + "limit must be between 1 and {READ_LIMIT_MAX} bytes" + ))); + } + let offset = match (query.offset, query.tail) { + (Some(_), Some(_)) => { + return Err(ApiError::bad_request( + "offset and tail are mutually exclusive", + )); + } + (Some(offset), None) => offset.min(size), + (None, Some(tail)) => size.saturating_sub(tail.min(size)), + (None, None) => 0, + }; + let mut file = File::open(&path) + .map_err(|error| ApiError::internal(format!("file open failed: {error}")))?; + file.seek(SeekFrom::Start(offset)) + .map_err(|error| ApiError::internal(format!("file seek failed: {error}")))?; + let mut window = Vec::with_capacity(limit.min(64 * 1024)); + file.take(limit as u64) + .read_to_end(&mut window) + .map_err(|error| ApiError::internal(format!("file read failed: {error}")))?; + let truncated = offset as usize + window.len() < size as usize; + let (encoding, content) = encode_window(&window); + Ok(json!({ + "name": name, + "size": size, + "modified": metadata.modified().ok().map(rfc3339), + "offset": offset, + "bytes": window.len(), + "truncated": truncated, + "encoding": encoding, + "content": content, + })) +} + +// --------------------------------------------------------------------------- +// Directories +// --------------------------------------------------------------------------- + +/// Log files live under `runtime_log::log_directory()`; the audit trail sits +/// beside them at `/audit.log[.1]` and is listed as extra +/// entries so one listing covers every text log the runtime writes. +fn log_sources() -> Vec<(PathBuf, Vec)> { + let mut sources = Vec::new(); + if let Some(dir) = crate::runtime_log::log_directory() { + let mut singles = Vec::new(); + if let Ok(home) = codewhale_config::codewhale_home() { + for name in ["audit.log", "audit.log.1"] { + let path = home.join(name); + if path.is_file() { + singles.push(path); + } + } + } + sources.push((dir, singles)); + } + sources +} + +/// Panic dumps prefer `/.codewhale/crashes` and fall back to the legacy +/// `.deepseek` directory — mirror the writer's preference order and merge +/// every directory that exists. +fn crash_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Some(home) = crate::config::effective_home_dir() { + for base in [".codewhale", ".deepseek"] { + let dir = home.join(base).join("crashes"); + if dir.is_dir() && !dirs.contains(&dir) { + dirs.push(dir); + } + } + } + dirs +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +pub(super) async fn list_logs(State(_state): State) -> Json { + let mut sources = Vec::new(); + for (dir, singles) in log_sources() { + let mut entries = list_files(&dir, LOG_LIST_CAP); + for path in singles { + if let Ok(metadata) = std::fs::symlink_metadata(&path) + && metadata.is_file() + && !metadata.file_type().is_symlink() + && let Some(name) = path.file_name().and_then(|name| name.to_str()) + { + entries.push(FileEntry { + name: name.to_string(), + size: metadata.len(), + modified: metadata.modified().ok().map(rfc3339), + }); + } + } + entries.sort_by(|a, b| { + b.modified + .cmp(&a.modified) + .then_with(|| a.name.cmp(&b.name)) + }); + entries.truncate(LOG_LIST_CAP); + sources.push(json!({ + "dir": dir, + "files": entries, + })); + } + Json(json!({ "sources": sources })) +} + +pub(super) async fn read_log( + State(_state): State, + Path(name): Path, + Query(query): Query, +) -> Result, ApiError> { + let name = safe_entry_name(&name)?; + let body = tokio::task::spawn_blocking(move || { + // The audit trail is listed beside the log dir; resolve it from the + // codewhale home rather than the log directory. + if name == "audit.log" || name == "audit.log.1" { + let home = codewhale_config::codewhale_home() + .map_err(|error| ApiError::internal(format!("home unavailable: {error}")))?; + return read_named_window(&home, &name, query); + } + let dir = crate::runtime_log::log_directory() + .ok_or_else(|| ApiError::not_found("no log directory"))?; + read_named_window(&dir, &name, query) + }) + .await + .map_err(|_| ApiError::internal("log read failed"))??; + Ok(Json(body)) +} + +pub(super) async fn list_crashes(State(_state): State) -> Json { + let mut sources = Vec::new(); + for dir in crash_dirs() { + sources.push(json!({ + "dir": dir, + "files": list_files(&dir, CRASH_LIST_CAP), + })); + } + Json(json!({ "sources": sources })) +} + +pub(super) async fn read_crash( + State(_state): State, + Path(name): Path, + Query(query): Query, +) -> Result, ApiError> { + let name = safe_entry_name(&name)?; + let body = tokio::task::spawn_blocking(move || { + for dir in crash_dirs() { + let candidate = dir.join(&name); + if std::fs::symlink_metadata(&candidate) + .map(|m| m.is_file() && !m.file_type().is_symlink()) + .unwrap_or(false) + { + return read_named_window(&dir, &name, query); + } + } + Err(ApiError::not_found("crash capture not found")) + }) + .await + .map_err(|_| ApiError::internal("crash read failed"))??; + Ok(Json(body)) +} + +// --------------------------------------------------------------------------- +// GET /v1/process +// --------------------------------------------------------------------------- + +#[cfg(target_os = "linux")] +fn rss_bytes() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let line = status.lines().find(|line| line.starts_with("VmRSS:"))?; + let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?; + Some(kb * 1024) +} + +#[cfg(not(target_os = "linux"))] +fn rss_bytes() -> Option { + None +} + +pub(super) async fn process_info(State(_state): State) -> Json { + let (started_at, uptime_secs) = match SERVER_STARTED.get() { + Some((system, instant)) => (Some(rfc3339(*system)), Some(instant.elapsed().as_secs())), + None => (None, None), + }; + Json(json!({ + "pid": std::process::id(), + "version": env!("CARGO_PKG_VERSION"), + "commit": option_env!("CODEWHALE_BUILD_COMMIT").unwrap_or("unknown"), + "started_at": started_at, + "uptime_seconds": uptime_secs, + "executable": std::env::current_exe().ok(), + "rss_bytes": rss_bytes(), + })) +} diff --git a/crates/tui/src/runtime_api/git.rs b/crates/tui/src/runtime_api/git.rs new file mode 100644 index 0000000000..79d844af9e --- /dev/null +++ b/crates/tui/src/runtime_api/git.rs @@ -0,0 +1,818 @@ +//! Workspace git surface for native clients (APPS-106). +//! +//! One authority: these routes run `git` against the server's configured +//! workspace through the same hardened primitives the agent tools use — +//! reads go through [`Git::review_command`] (filters, fsmonitor, hooks, +//! lazy fetches and replace-objects neutralized), writes run through +//! [`Git::tokio_command`] with interactive prompts disabled so a credential +//! or host-key prompt can never hang an HTTP request. There is no second +//! index, cache, or diff store here; every response is computed live from +//! the repository. +//! +//! Routes (workspace-scoped, matching the client contract): +//! GET /v1/git — branch/head/ahead-behind, per-file porcelain +//! entries, local branches and remotes +//! GET /v1/changes — the file-change inventory only (status rows) +//! GET /v1/diff?path= — unified diff for one workspace-relative file +//! GET /v1/workspace/diff — bounded whole-tree diff + per-file numstat +//! GET /v1/git/graph — recent commit graph rows (bounded) +//! POST /v1/git/stage — `{ "paths": [...] }` or `{ "all": true }` +//! POST /v1/git/unstage — `{ "paths": [...] }` or `{ "all": true }` +//! POST /v1/git/discard — `{ "paths": [...] }` (tracked only; no `all`) +//! POST /v1/git/commit — `{ "message": "…", "all": false }` +//! POST /v1/git/push — `{ "remote"?, "set_upstream"?: bool }` +//! POST /v1/git/branch — `{ "name": "…", "create"?: bool }` +//! +//! Mutations answer with the command output tail plus a refreshed workspace +//! status so the client re-renders in one round trip. The caller holds the +//! operator token; the repository's own hooks run for `commit` exactly as +//! they would for the user's terminal. + +use std::path::{Path as FsPath, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use axum::Json; +use axum::extract::{Query, State}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::dependencies::{ExternalTool as _, Git}; + +use super::workspace::{canonical_workspace, collect_workspace_status, relative_request_path}; +use super::{ApiError, RuntimeApiState}; + +/// Generous bound for local git work on very large repositories. +const GIT_READ_TIMEOUT: Duration = Duration::from_secs(30); +/// Pushes cross the network; still bounded so a dead remote cannot pin a +/// handler forever. +const GIT_WRITE_TIMEOUT: Duration = Duration::from_secs(120); +/// Output tail carried back to the client for mutations. +const MAX_OUTPUT_TAIL: usize = 8 * 1024; +/// Commit-graph row bounds. +const GRAPH_LIMIT_DEFAULT: usize = 100; +const GRAPH_LIMIT_MAX: usize = 500; +/// Unified-diff response caps: a single file gets a generous window; the +/// whole-tree surface defaults smaller and always reports `truncated`. +const FILE_DIFF_MAX_BYTES: usize = 512 * 1024; +const WORKSPACE_DIFF_DEFAULT_BYTES: usize = 256 * 1024; +const WORKSPACE_DIFF_MAX_BYTES: usize = 4 * 1024 * 1024; +/// The empty tree — diff base for repositories whose HEAD is unborn. +const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +/// Branch/commit message caps — generous for real messages, hostile to +/// accidental binary paste. +const MAX_COMMIT_MESSAGE_BYTES: usize = 64 * 1024; +const MAX_BRANCH_NAME_BYTES: usize = 256; +const MAX_PATH_ARGS: usize = 512; + +// --------------------------------------------------------------------------- +// git invocation +// --------------------------------------------------------------------------- + +struct GitRun { + status_success: bool, + exit_code: Option, + stdout: String, + stderr: String, +} + +/// Hardened read path: the same primitive review/tooling uses, so fsmonitor, +/// content filters, hooks, lazy fetches and replace-objects cannot run inside +/// an HTTP read either. +async fn git_read(workspace: &FsPath, args: &[&str]) -> Result { + let workspace = workspace.to_path_buf(); + let command = tokio::task::spawn_blocking(move || Git::review_command(&workspace)) + .await + .map_err(|_| ApiError::internal("git read setup failed"))? + .map_err(|error| ApiError::internal(format!("git is unavailable: {error}")))?; + let mut command = tokio::process::Command::from(command); + command.args(args).kill_on_drop(true); + finish_git(command.output(), GIT_READ_TIMEOUT).await +} + +/// Write path for operator-driven mutations. Non-interactive by contract: +/// no terminal prompt, no pager, and BatchMode ssh (unless the user already +/// pins their own `GIT_SSH_COMMAND`) so a key prompt can never hang the +/// request. Hooks and filters run exactly as they do for the user's own +/// `git` — a Review-sheet commit is the user's commit. +async fn git_write(workspace: &FsPath, args: Vec) -> Result { + let mut command = Git::tokio_command() + .ok_or_else(|| ApiError::internal("git is not installed or not in PATH"))?; + command + .args(&args) + .current_dir(workspace) + .stdin(Stdio::null()) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_PAGER", "") + .kill_on_drop(true); + if std::env::var_os("GIT_SSH_COMMAND").is_none() { + command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"); + } + finish_git(command.output(), GIT_WRITE_TIMEOUT).await +} + +async fn finish_git( + output: impl std::future::Future>, + timeout: Duration, +) -> Result { + let output = tokio::time::timeout(timeout, output) + .await + .map_err(|_| ApiError::internal("git operation timed out"))? + .map_err(|error| ApiError::internal(format!("failed to run git: {error}")))?; + Ok(GitRun { + status_success: output.status.success(), + exit_code: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +fn output_tail(run: &GitRun) -> String { + let mut text = String::new(); + for part in [run.stdout.trim(), run.stderr.trim()] { + if !part.is_empty() { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(part); + } + } + if text.len() > MAX_OUTPUT_TAIL { + let mut boundary = text.len() - MAX_OUTPUT_TAIL; + while !text.is_char_boundary(boundary) { + boundary -= 1; + } + text = text[boundary..].to_string(); + } + text +} + +/// A non-repo workspace is a 404, not a 500: `rev-parse --is-inside-work-tree` +/// exits 128 there, so probe directly rather than through the erroring helper. +fn require_repo(workspace: &FsPath) -> Result<(), ApiError> { + match Git::output(&["rev-parse", "--is-inside-work-tree"], workspace) { + Ok(output) + if output.status.success() + && String::from_utf8_lossy(&output.stdout).trim() == "true" => + { + Ok(()) + } + Ok(_) => Err(ApiError::not_found("workspace is not a git repository")), + Err(error) => Err(ApiError::internal(format!("failed to run git: {error}"))), + } +} + +/// Cheap one-shot read used only for repo probes where the hardened review +/// command's filter dance would be wasted work. +fn run_git_sync(workspace: &FsPath, args: &[&str]) -> Result { + let output = Git::output(args, workspace) + .map_err(|error| ApiError::internal(format!("failed to run git: {error}")))?; + if !output.status.success() { + return Err(ApiError::internal(format!( + "git {} failed: {}", + args.first().copied().unwrap_or(""), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +// --------------------------------------------------------------------------- +// GET /v1/git — status detail +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +struct GitFileEntry { + path: String, + /// Raw porcelain v1 index (X) and worktree (Y) columns. + index: String, + worktree: String, + /// True when the index column records a change. + staged: bool, + /// Leading human state: modified / added / deleted / renamed / + /// typechange / untracked / conflicted / ignored. + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + old_path: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct GitStatusDetailResponse { + git_repo: bool, + workspace: PathBuf, + branch: Option, + detached: bool, + head: Option, + ahead: Option, + behind: Option, + staged: usize, + unstaged: usize, + untracked: usize, + files: Vec, + branches: Vec, + remotes: Vec, +} + +pub(super) async fn git_status_detail( + State(state): State, +) -> Result, ApiError> { + let workspace = state.workspace.clone(); + tokio::task::spawn_blocking(move || collect_git_status_detail(&workspace)) + .await + .map_err(|_| ApiError::internal("git status failed"))? + .map(Json) +} + +fn collect_git_status_detail(workspace: &FsPath) -> Result { + let status = collect_workspace_status(workspace); + let mut detail = GitStatusDetailResponse { + git_repo: status.git_repo, + workspace: workspace.to_path_buf(), + branch: status.branch.clone(), + detached: false, + head: status.head, + ahead: status.ahead, + behind: status.behind, + staged: status.staged, + unstaged: status.unstaged, + untracked: status.untracked, + files: Vec::new(), + branches: Vec::new(), + remotes: Vec::new(), + }; + if !status.git_repo { + return Ok(detail); + } + detail.detached = status.branch.is_none() + || status + .branch + .as_deref() + .is_some_and(|branch| branch.starts_with("detached@")); + + // `-z` keeps paths verbatim: one NUL-terminated `XY ` record each, + // with renames/copies carrying the source path in the following record. + if let Ok(porcelain) = run_git_sync(workspace, &["status", "--porcelain=v1", "-z"]) { + detail.files = parse_porcelain(&porcelain); + } + if let Ok(branches) = run_git_sync(workspace, &["branch", "--format=%(refname:short)"]) { + detail.branches = branches + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + } + if let Ok(remotes) = run_git_sync(workspace, &["remote"]) { + detail.remotes = remotes + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + } + Ok(detail) +} + +fn parse_porcelain(porcelain: &str) -> Vec { + let mut entries = Vec::new(); + let mut records = porcelain.split('\0').peekable(); + while let Some(record) = records.next() { + if record.is_empty() || record.starts_with("## ") { + continue; + } + if record.len() < 4 { + continue; + } + let index = record.as_bytes()[0] as char; + let worktree = record.as_bytes()[1] as char; + let path = record[3..].to_string(); + let mut old_path = None; + if matches!(index, 'R' | 'C') { + old_path = records.next().map(str::to_string); + } + entries.push(GitFileEntry { + path, + index: index.to_string(), + worktree: worktree.to_string(), + staged: !matches!(index, ' ' | '?' | '!'), + status: porcelain_status(index, worktree), + old_path, + }); + } + entries +} + +fn porcelain_status(index: char, worktree: char) -> &'static str { + match (index, worktree) { + ('?', '?') => "untracked", + ('!', '!') => "ignored", + ('U', _) | (_, 'U') | ('D', 'D') | ('A', 'A') => "conflicted", + ('R', _) | (_, 'R') => "renamed", + ('C', _) | (_, 'C') => "copied", + ('A', _) | (_, 'A') => "added", + ('D', _) | (_, 'D') => "deleted", + ('T', _) | (_, 'T') => "typechange", + ('M', _) | (_, 'M') => "modified", + _ => "unchanged", + } +} + +// --------------------------------------------------------------------------- +// GET /v1/changes — the file-change inventory only +// --------------------------------------------------------------------------- + +/// The Review sheet's change list: the same porcelain projection as +/// `GET /v1/git` minus repo chrome (branches/remotes). One authority — a +/// client that loaded both cannot see them disagree. +pub(super) async fn git_changes( + State(state): State, +) -> Result, ApiError> { + let workspace = state.workspace.clone(); + let detail = tokio::task::spawn_blocking(move || collect_git_status_detail(&workspace)) + .await + .map_err(|_| ApiError::internal("git status failed"))??; + Ok(Json(json!({ + "git_repo": detail.git_repo, + "branch": detail.branch, + "staged": detail.staged, + "unstaged": detail.unstaged, + "untracked": detail.untracked, + "files": detail.files, + }))) +} + +// --------------------------------------------------------------------------- +// GET /v1/diff + /v1/workspace/diff — unified diffs against HEAD +// --------------------------------------------------------------------------- + +/// `git diff ` compares the worktree to , covering staged and +/// unstaged changes in one output. On an unborn branch the base is the +/// empty tree, which reads every staged/tracked file as new — the honest +/// "everything changed" picture for a repo with no commits. +async fn diff_base(workspace: &FsPath) -> Result { + let head = git_read(workspace, &["rev-parse", "--verify", "HEAD"]).await?; + Ok(if head.status_success { + "HEAD".to_string() + } else { + EMPTY_TREE.to_string() + }) +} + +/// Byte-bounded cut at a char boundary; reports whether bytes were dropped. +fn bounded_patch(text: &str, limit: usize) -> (String, bool) { + if text.len() <= limit { + return (text.to_string(), false); + } + let mut end = limit; + while !text.is_char_boundary(end) { + end -= 1; + } + (text[..end].to_string(), true) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitDiffQuery { + /// Workspace-relative file; may name a deleted file (the diff survives). + path: String, +} + +/// `GET /v1/diff?path=` — one file's unified diff against HEAD (or the empty +/// tree on an unborn branch). An untracked file has no diff by definition: +/// the response says `untracked: true` with an empty `diff` so the client +/// reads the file itself instead of mistaking it for unchanged. +pub(super) async fn git_diff( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let path = relative_request_path(&query.path, false)?; + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let base = diff_base(&workspace).await?; + let path_arg = path.to_string_lossy().into_owned(); + let run = git_read( + &workspace, + &[ + "diff", + "--no-color", + "--no-ext-diff", + &base, + "--", + &path_arg, + ], + ) + .await?; + if !run.status_success { + return Err(ApiError::internal(format!( + "git diff failed: {}", + run.stderr.trim() + ))); + } + let (diff, truncated) = bounded_patch(&run.stdout, FILE_DIFF_MAX_BYTES); + let untracked = if diff.is_empty() { + let status = git_read( + &workspace, + &["status", "--porcelain=v1", "-z", "--", &path_arg], + ) + .await?; + status + .stdout + .split('\0') + .any(|record| record.starts_with("??")) + } else { + false + }; + Ok(Json(json!({ + "ok": true, + "path": path_arg, + "base": base, + "untracked": untracked, + "diff": diff, + "truncated": truncated, + }))) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct WorkspaceDiffQuery { + /// Byte cap on the returned patch (default 256 KiB, max 4 MiB). + limit: Option, +} + +/// `GET /v1/workspace/diff?limit=` — the whole tree's diff against HEAD plus +/// a complete `--numstat` inventory, so a client renders every changed file +/// row even when the patch body is truncated. +pub(super) async fn workspace_diff( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let limit = query.limit.unwrap_or(WORKSPACE_DIFF_DEFAULT_BYTES); + if !(1024..=WORKSPACE_DIFF_MAX_BYTES).contains(&limit) { + return Err(ApiError::bad_request(format!( + "limit must be between 1024 and {WORKSPACE_DIFF_MAX_BYTES} bytes" + ))); + } + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let base = diff_base(&workspace).await?; + + let numstat = git_read(&workspace, &["diff", "--numstat", &base]).await?; + if !numstat.status_success { + return Err(ApiError::internal(format!( + "git diff --numstat failed: {}", + numstat.stderr.trim() + ))); + } + let files: Vec = numstat + .stdout + .lines() + .filter_map(|line| { + let mut fields = line.splitn(3, '\t'); + let added = fields.next()?; + let deleted = fields.next()?; + let path = fields.next()?; + Some(json!({ + "path": path, + // Binary files report "-" rather than a count. + "added": added.parse::().ok(), + "deleted": deleted.parse::().ok(), + })) + }) + .collect(); + + let run = git_read(&workspace, &["diff", "--no-color", "--no-ext-diff", &base]).await?; + if !run.status_success { + return Err(ApiError::internal(format!( + "git diff failed: {}", + run.stderr.trim() + ))); + } + let (diff, truncated) = bounded_patch(&run.stdout, limit); + Ok(Json(json!({ + "ok": true, + "git_repo": true, + "base": base, + "files": files, + "diff": diff, + "truncated": truncated, + }))) +} + +// --------------------------------------------------------------------------- +// GET /v1/git/graph — bounded commit graph rows +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitGraphQuery { + limit: Option, +} + +const GRAPH_FIELD: char = '\u{1f}'; +const GRAPH_RECORD: char = '\u{1e}'; + +pub(super) async fn git_graph( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let limit = query.limit.unwrap_or(GRAPH_LIMIT_DEFAULT); + if !(1..=GRAPH_LIMIT_MAX).contains(&limit) { + return Err(ApiError::bad_request(format!( + "limit must be between 1 and {GRAPH_LIMIT_MAX}" + ))); + } + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let limit_arg = format!("-n{limit}"); + let format = format!( + "%H{GRAPH_FIELD}%h{GRAPH_FIELD}%P{GRAPH_FIELD}%an{GRAPH_FIELD}%ae{GRAPH_FIELD}%aI{GRAPH_FIELD}%D{GRAPH_FIELD}%s{GRAPH_RECORD}" + ); + let format_arg = format!("--format={format}"); + let run = git_read(&workspace, &["log", &limit_arg, &format_arg]).await?; + if !run.status_success { + // `git log` exits non-zero on an unborn branch; that is a valid empty + // graph, not a failure. Distinguish with a HEAD probe instead of + // trusting stderr text. + let head = git_read(&workspace, &["rev-parse", "--verify", "HEAD"]).await?; + if head.status_success { + return Err(ApiError::internal(format!( + "git log failed: {}", + run.stderr.trim() + ))); + } + return Ok(Json(json!({ "commits": [], "truncated": false }))); + } + let mut commits = Vec::new(); + for record in run.stdout.split(GRAPH_RECORD) { + let record = record.trim_matches('\n'); + if record.is_empty() { + continue; + } + let mut fields = record.split(GRAPH_FIELD); + let ( + Some(id), + Some(short), + Some(parents), + Some(name), + Some(email), + Some(timestamp), + Some(refs), + Some(subject), + ) = ( + fields.next(), + fields.next(), + fields.next(), + fields.next(), + fields.next(), + fields.next(), + fields.next(), + fields.next(), + ) + else { + continue; + }; + commits.push(json!({ + "id": id, + "short": short, + "parents": parents.split_whitespace().collect::>(), + "author": { "name": name, "email": email }, + "timestamp": timestamp, + "refs": refs + .split(", ") + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect::>(), + "subject": subject, + })); + } + let truncated = commits.len() >= limit; + Ok(Json(json!({ "commits": commits, "truncated": truncated }))) +} + +// --------------------------------------------------------------------------- +// Mutations +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitPathsRequest { + #[serde(default)] + paths: Vec, + /// Stage/unstage may take the whole tree; discard cannot. + #[serde(default)] + all: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitCommitRequest { + message: String, + /// Also stage tracked modifications (`git commit -a`). + #[serde(default)] + all: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitPushRequest { + remote: Option, + #[serde(default)] + set_upstream: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct GitBranchRequest { + name: String, + /// Create and switch (`git switch -c`); default is switch to existing. + #[serde(default)] + create: bool, +} + +fn mutation_response(workspace: &FsPath, run: GitRun) -> Result, ApiError> { + if !run.status_success { + let tail = output_tail(&run); + return Err(ApiError::bad_request(if tail.is_empty() { + format!("git exited {}", run.exit_code.unwrap_or(-1)) + } else { + tail + })); + } + Ok(Json(json!({ + "ok": true, + "output": output_tail(&run), + "status": collect_workspace_status(workspace), + }))) +} + +/// Validate and collect pathspecs. Every path is workspace-relative with no +/// `.`/`..`/`.git` components and is passed after `--`, so it can never be +/// read as an option. +fn validated_paths(request: &GitPathsRequest, allow_all: bool) -> Result, ApiError> { + if request.all && !allow_all { + return Err(ApiError::bad_request( + "discard requires explicit paths; refusing to discard the whole tree", + )); + } + if request.all && !request.paths.is_empty() { + return Err(ApiError::bad_request( + "all and paths are mutually exclusive", + )); + } + if !request.all && request.paths.is_empty() { + return Err(ApiError::bad_request("paths is required (or all: true)")); + } + if request.paths.len() > MAX_PATH_ARGS { + return Err(ApiError::bad_request(format!( + "at most {MAX_PATH_ARGS} paths per request" + ))); + } + request + .paths + .iter() + .map(|raw| { + relative_request_path(raw, false).map(|path| path.to_string_lossy().into_owned()) + }) + .collect() +} + +pub(super) async fn git_stage( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let paths = validated_paths(&request, true)?; + let mut args = vec!["add".to_string()]; + if request.all { + args.push("--all".to_string()); + } + if !paths.is_empty() { + args.push("--".to_string()); + args.extend(paths); + } + mutation_response(&workspace, git_write(&workspace, args).await?) +} + +pub(super) async fn git_unstage( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let paths = validated_paths(&request, true)?; + let mut args = vec!["restore".to_string(), "--staged".to_string()]; + if request.all { + args.push(":/".to_string()); + } + if !paths.is_empty() { + args.push("--".to_string()); + args.extend(paths); + } + mutation_response(&workspace, git_write(&workspace, args).await?) +} + +pub(super) async fn git_discard( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let paths = validated_paths(&request, false)?; + let mut args = vec!["checkout".to_string(), "--".to_string()]; + args.extend(paths); + mutation_response(&workspace, git_write(&workspace, args).await?) +} + +pub(super) async fn git_commit( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let message = request.message.trim(); + if message.is_empty() { + return Err(ApiError::bad_request("message is required")); + } + if message.len() > MAX_COMMIT_MESSAGE_BYTES { + return Err(ApiError::bad_request(format!( + "message must be at most {MAX_COMMIT_MESSAGE_BYTES} bytes" + ))); + } + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let mut args = vec!["commit".to_string()]; + if request.all { + args.push("--all".to_string()); + } + args.push("--message".to_string()); + args.push(message.to_string()); + mutation_response(&workspace, git_write(&workspace, args).await?) +} + +pub(super) async fn git_push( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + let remote = request + .remote + .as_deref() + .map(str::trim) + .filter(|remote| !remote.is_empty()) + .map(str::to_string); + if let Some(remote) = &remote + && !remote + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/')) + { + return Err(ApiError::bad_request("remote must be a remote name")); + } + let mut args = vec!["push".to_string()]; + if request.set_upstream { + let branch = run_git_sync(&workspace, &["rev-parse", "--abbrev-ref", "HEAD"])?; + let branch = branch.trim(); + if branch.is_empty() || branch == "HEAD" { + return Err(ApiError::bad_request( + "cannot set upstream from a detached HEAD", + )); + } + args.push("--set-upstream".to_string()); + args.push(remote.unwrap_or_else(|| "origin".to_string())); + args.push(branch.to_string()); + } else if let Some(remote) = remote { + args.push(remote); + } + mutation_response(&workspace, git_write(&workspace, args).await?) +} + +pub(super) async fn git_branch( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let name = request.name.trim(); + if name.is_empty() || name.len() > MAX_BRANCH_NAME_BYTES { + return Err(ApiError::bad_request("name is required")); + } + let workspace = canonical_workspace(&state.workspace)?; + require_repo(&workspace)?; + // `check-ref-format --branch` is the ref authority — it rejects option + // lookalikes, `..`, `@{`, control bytes, and every other unsafe name. + let check = git_write( + &workspace, + vec![ + "check-ref-format".to_string(), + "--branch".to_string(), + name.to_string(), + ], + ) + .await?; + if !check.status_success { + return Err(ApiError::bad_request("name is not a valid branch")); + } + let mut args = vec!["switch".to_string()]; + if request.create { + args.push("--create".to_string()); + } + args.push(name.to_string()); + mutation_response(&workspace, git_write(&workspace, args).await?) +} diff --git a/crates/tui/src/runtime_api/jobs.rs b/crates/tui/src/runtime_api/jobs.rs new file mode 100644 index 0000000000..011032e552 --- /dev/null +++ b/crates/tui/src/runtime_api/jobs.rs @@ -0,0 +1,495 @@ +//! `/v1/jobs` — the client-facing shell job surface. +//! +//! One authority: every job lives on the thread's shared `ShellManager`, the +//! same manager the thread's engine uses for model-launched shell work. Jobs +//! created here carry an `api:{thread_id}` owner scope so an engine's +//! per-session completion drain never claims client-launched work as model +//! evidence — and the model's jobs never appear "client-owned" here. + +use std::collections::HashMap; + +use axum::Json; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use base64::Engine as _; +use serde::{Deserialize, Serialize}; + +use crate::tools::shell::{ + ShellJobSnapshot, ShellOutputChunk, ShellOutputStream, ShellResult, ShellStatus, +}; + +use super::{ApiError, RuntimeApiState, map_thread_err}; + +/// `owner_session_id` prefix for jobs launched through this API. A real +/// engine session id can never carry it, so `*_for_session` drains stay +/// model-owned and API listings can tell the two apart. +const API_JOB_SCOPE_PREFIX: &str = "api:"; + +const COMMAND_MAX_BYTES: usize = 32 * 1024; +const ENV_MAX_ENTRIES: usize = 64; +const ENV_KEY_MAX_BYTES: usize = 128; +const ENV_VALUE_MAX_BYTES: usize = 8 * 1024; +const OUTPUT_CHUNK_DEFAULT: usize = 64 * 1024; +const OUTPUT_CHUNK_MAX: usize = 512 * 1024; +const OUTPUT_WAIT_MAX_MS: u64 = 30_000; +const STDIN_MAX_BYTES: usize = 64 * 1024; +const JOB_ID_MAX_BYTES: usize = 128; + +fn api_job_scope(thread_id: &str) -> String { + format!("{API_JOB_SCOPE_PREFIX}{thread_id}") +} + +fn job_owner(snapshot: &ShellJobSnapshot) -> &'static str { + if snapshot.owner_agent_id.is_some() { + "subagent" + } else if snapshot.owner_session_id.starts_with(API_JOB_SCOPE_PREFIX) { + "client" + } else { + "agent" + } +} + +fn map_job_err(error: anyhow::Error) -> ApiError { + let message = error.to_string(); + if message.ends_with("not found") { + ApiError::not_found(message) + } else { + ApiError::internal(message) + } +} + +#[derive(Debug, Serialize)] +pub(super) struct JobView { + #[serde(flatten)] + snapshot: ShellJobSnapshot, + thread_id: String, + /// `client` = launched through this API, `agent` = launched by the model's + /// shell tool, `subagent` = owned by a delegated agent. + owner: &'static str, +} + +impl JobView { + fn new(snapshot: ShellJobSnapshot, thread_id: String) -> Self { + let owner = job_owner(&snapshot); + Self { + snapshot, + thread_id, + owner, + } + } +} + +#[derive(Debug, Serialize)] +pub(super) struct JobListResponse { + jobs: Vec, +} + +/// `GET /v1/jobs` — every live and known-stale job across all threads. +pub(super) async fn list_jobs( + State(state): State, +) -> Result, ApiError> { + let managers = state.runtime_threads.shell_managers_snapshot().await; + let jobs = tokio::task::spawn_blocking(move || { + let mut jobs = Vec::new(); + for (thread_id, manager) in managers { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + jobs.extend( + guard + .list_jobs() + .into_iter() + .map(|snapshot| JobView::new(snapshot, thread_id.clone())), + ); + } + jobs + }) + .await + .map_err(|_| ApiError::internal("job listing failed"))?; + Ok(Json(JobListResponse { jobs })) +} + +async fn thread_manager( + state: &RuntimeApiState, + thread_id: &str, + create: bool, +) -> Result { + state + .runtime_threads + .thread_shell_manager(thread_id, create) + .await + .map_err(map_thread_err)? + .ok_or_else(|| ApiError::not_found(format!("thread {thread_id} has no jobs"))) +} + +/// `GET /v1/threads/{id}/jobs` — all jobs owned by one thread's manager: +/// model-launched, subagent-launched, and client-launched together. +pub(super) async fn list_thread_jobs( + State(state): State, + Path(thread_id): Path, +) -> Result, ApiError> { + let Some(manager) = state + .runtime_threads + .thread_shell_manager(&thread_id, false) + .await + .map_err(map_thread_err)? + else { + return Ok(Json(JobListResponse { jobs: Vec::new() })); + }; + let jobs = tokio::task::spawn_blocking(move || { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + guard + .list_jobs() + .into_iter() + .map(|snapshot| JobView::new(snapshot, thread_id.clone())) + .collect() + }) + .await + .map_err(|_| ApiError::internal("job listing failed"))?; + Ok(Json(JobListResponse { jobs })) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct CreateJobRequest { + command: String, + /// Working directory. Omitted = the thread's workspace. + cwd: Option, + /// Bounds the foreground-wait contract inside the manager; background + /// jobs are never killed at timeout. + timeout_ms: Option, + /// Run under a PTY: stderr merges into stdout and the command sees a + /// terminal. Required for interactive programs. + #[serde(default)] + tty: bool, + #[serde(default)] + env: HashMap, +} + +#[derive(Debug, Serialize)] +pub(super) struct CreateJobResponse { + job: JobView, +} + +/// `POST /v1/threads/{id}/jobs` — launch a client-owned background job under +/// the thread's own sandbox policy. The client asking is the approval; the +/// thread's posture still bounds what the job may touch. +pub(super) async fn create_thread_job( + State(state): State, + Path(thread_id): Path, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let command = request.command.trim(); + if command.is_empty() { + return Err(ApiError::bad_request("command is required")); + } + if command.len() > COMMAND_MAX_BYTES { + return Err(ApiError::bad_request(format!( + "command must be at most {COMMAND_MAX_BYTES} bytes" + ))); + } + if request.env.len() > ENV_MAX_ENTRIES { + return Err(ApiError::bad_request(format!( + "env may carry at most {ENV_MAX_ENTRIES} entries" + ))); + } + for (key, value) in &request.env { + if key.len() > ENV_KEY_MAX_BYTES || key.contains(['=', '\0']) { + return Err(ApiError::bad_request("invalid env key")); + } + if value.len() > ENV_VALUE_MAX_BYTES || value.contains('\0') { + return Err(ApiError::bad_request("invalid env value")); + } + } + + let thread = state + .runtime_threads + .get_thread(&thread_id) + .await + .map_err(map_thread_err)?; + if !thread.allow_shell { + return Err(ApiError::forbidden( + "this thread does not allow shell commands", + )); + } + if let Some(cwd) = request.cwd.as_deref() { + let resolved = std::path::Path::new(cwd); + let resolved = if resolved.is_absolute() { + resolved.to_path_buf() + } else { + thread.workspace.join(resolved) + }; + if !resolved.is_dir() { + return Err(ApiError::bad_request("cwd must be an existing directory")); + } + } + let policy = state + .runtime_threads + .thread_job_sandbox_policy(&thread) + .await; + let manager = thread_manager(&state, &thread_id, true).await?; + let scope = api_job_scope(&thread_id); + let request_timeout = request.timeout_ms; + let request_tty = request.tty; + let request_env = request.env; + let request_cwd = request.cwd; + let command = command.to_string(); + let job = tokio::task::spawn_blocking(move || -> Result { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + let result = guard + .execute_with_options_env_for_session( + &command, + request_cwd.as_deref(), + request_timeout.unwrap_or(120_000), + true, + None, + request_tty, + Some(policy), + request_env, + &scope, + ) + .map_err(|error| ApiError::internal(format!("job launch failed: {error}")))?; + let task_id = result.task_id.clone().unwrap_or_default(); + let snapshot = guard + .inspect_job(&task_id) + .map_err(|error| { + ApiError::internal(format!("job launched but is not tracked: {error}")) + })? + .snapshot; + Ok(JobView::new(snapshot, thread_id)) + }) + .await + .map_err(|_| ApiError::internal("job launch failed"))??; + Ok((StatusCode::CREATED, Json(CreateJobResponse { job }))) +} + +#[derive(Debug, Serialize)] +pub(super) struct JobDetailResponse { + job: JobView, + stdout_tail: String, + stderr_tail: String, +} + +/// `GET /v1/threads/{id}/jobs/{job_id}` — snapshot plus the retained output +/// tails. For the full stream, follow `output` with a cursor instead. +pub(super) async fn get_thread_job( + State(state): State, + Path((thread_id, job_id)): Path<(String, String)>, +) -> Result, ApiError> { + if job_id.len() > JOB_ID_MAX_BYTES { + return Err(ApiError::not_found("job not found")); + } + let manager = thread_manager(&state, &thread_id, false).await?; + tokio::task::spawn_blocking(move || { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + let detail = guard.inspect_job(&job_id).map_err(map_job_err)?; + Ok(Json(JobDetailResponse { + job: JobView::new(detail.snapshot, thread_id), + stdout_tail: detail.stdout, + stderr_tail: detail.stderr, + })) + }) + .await + .map_err(|_| ApiError::internal("job inspect failed"))? +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct JobOutputQuery { + /// `stdout` (default) or `stderr`; PTY jobs merge stderr into stdout. + #[serde(default)] + stream: Option, + /// Absolute byte offset into the stream's lifetime output. + #[serde(default)] + cursor: Option, + /// Per-request byte ceiling, default 64 KiB, max 512 KiB. + #[serde(default)] + max_bytes: Option, + /// Long-poll bound for new bytes on a running job, max 30s. + #[serde(default)] + wait_ms: Option, + /// `base64` (default, exact bytes) or `text` (lossy UTF-8). + #[serde(default)] + format: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct JobOutputResponse { + job_id: String, + stream: &'static str, + /// Absolute offset of `data[0]`; exceeds `cursor` when the bounded buffer + /// already discarded that prefix (`dropped` reports the cutoff). + offset: usize, + /// Next cursor: pass it back to continue the stream. + next_cursor: usize, + /// Total bytes the stream has produced, including discarded bytes. + total: usize, + /// Leading bytes permanently discarded by the in-flight bound. + dropped: usize, + encoding: &'static str, + data: String, + status: ShellStatus, + exit_code: Option, + /// Terminal status and no bytes remain past `next_cursor`. + done: bool, +} + +/// `GET /v1/threads/{id}/jobs/{job_id}/output` — the resumable byte stream. +/// Reads are non-consuming: several clients may hold independent cursors, and +/// polling here never steals output from the engine's own delta consumer. +pub(super) async fn get_thread_job_output( + State(state): State, + Path((thread_id, job_id)): Path<(String, String)>, + Query(query): Query, +) -> Result, ApiError> { + if job_id.len() > JOB_ID_MAX_BYTES { + return Err(ApiError::not_found("job not found")); + } + let (stream, stream_name) = match query.stream.as_deref().unwrap_or("stdout") { + "stdout" => (ShellOutputStream::Stdout, "stdout"), + "stderr" => (ShellOutputStream::Stderr, "stderr"), + _ => return Err(ApiError::bad_request("stream must be stdout or stderr")), + }; + let cursor = query.cursor.unwrap_or(0); + let max_bytes = query.max_bytes.unwrap_or(OUTPUT_CHUNK_DEFAULT); + if !(1..=OUTPUT_CHUNK_MAX).contains(&max_bytes) { + return Err(ApiError::bad_request(format!( + "max_bytes must be between 1 and {OUTPUT_CHUNK_MAX}" + ))); + } + let wait_ms = query.wait_ms.unwrap_or(0).min(OUTPUT_WAIT_MAX_MS); + let format = query.format.as_deref().unwrap_or("base64"); + if !matches!(format, "base64" | "text") { + return Err(ApiError::bad_request("format must be base64 or text")); + } + let manager = thread_manager(&state, &thread_id, false).await?; + let chunk = tokio::task::spawn_blocking({ + let job_id = job_id.clone(); + move || { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + guard + .read_output_chunk(&job_id, stream, cursor, max_bytes, wait_ms) + .map_err(map_job_err) + } + }) + .await + .map_err(|_| ApiError::internal("job output read failed"))??; + Ok(Json(encode_chunk(&job_id, stream_name, chunk, format))) +} + +fn encode_chunk( + job_id: &str, + stream_name: &'static str, + chunk: ShellOutputChunk, + format: &str, +) -> JobOutputResponse { + let (encoding, data) = match format { + "text" => ("utf-8", String::from_utf8_lossy(&chunk.bytes).into_owned()), + _ => ( + "base64", + base64::engine::general_purpose::STANDARD.encode(&chunk.bytes), + ), + }; + let done = chunk.status != ShellStatus::Running && chunk.next_offset >= chunk.total; + JobOutputResponse { + job_id: job_id.to_string(), + stream: stream_name, + offset: chunk.offset, + next_cursor: chunk.next_offset, + total: chunk.total, + dropped: chunk.dropped, + encoding, + data, + status: chunk.status, + exit_code: chunk.exit_code, + done, + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct JobStdinRequest { + /// UTF-8 text (default) or base64 for arbitrary bytes. + data: String, + #[serde(default)] + encoding: Option, + /// Close stdin after writing (EOF). + #[serde(default)] + close: bool, +} + +/// `POST /v1/threads/{id}/jobs/{job_id}/stdin` — write to a running job's +/// stdin. Works for PTY and piped jobs alike. +pub(super) async fn write_thread_job_stdin( + State(state): State, + Path((thread_id, job_id)): Path<(String, String)>, + Json(request): Json, +) -> Result { + if job_id.len() > JOB_ID_MAX_BYTES { + return Err(ApiError::not_found("job not found")); + } + let input = match request.encoding.as_deref().unwrap_or("utf-8") { + "utf-8" => { + if request.data.len() > STDIN_MAX_BYTES { + return Err(ApiError::bad_request(format!( + "data must be at most {STDIN_MAX_BYTES} bytes" + ))); + } + request.data + } + "base64" => { + if request.data.len() > STDIN_MAX_BYTES * 2 { + return Err(ApiError::bad_request("data exceeds the stdin limit")); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(&request.data) + .map_err(|_| ApiError::bad_request("data is not valid base64"))?; + if bytes.len() > STDIN_MAX_BYTES { + return Err(ApiError::bad_request(format!( + "data must be at most {STDIN_MAX_BYTES} decoded bytes" + ))); + } + String::from_utf8(bytes) + .map_err(|_| ApiError::bad_request("stdin data must be valid UTF-8"))? + } + _ => return Err(ApiError::bad_request("encoding must be utf-8 or base64")), + }; + let close = request.close; + let manager = thread_manager(&state, &thread_id, false).await?; + tokio::task::spawn_blocking(move || { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + guard + .write_stdin(&job_id, &input, close) + .map_err(map_job_err) + }) + .await + .map_err(|_| ApiError::internal("job stdin write failed"))??; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Serialize)] +pub(super) struct KillJobResponse { + job: JobView, + result: ShellResult, +} + +/// `POST /v1/threads/{id}/jobs/{job_id}/kill` — bounded SIGTERM → SIGKILL +/// escalation on the whole process group; the final snapshot rides along. +pub(super) async fn kill_thread_job( + State(state): State, + Path((thread_id, job_id)): Path<(String, String)>, +) -> Result, ApiError> { + if job_id.len() > JOB_ID_MAX_BYTES { + return Err(ApiError::not_found("job not found")); + } + let manager = thread_manager(&state, &thread_id, false).await?; + tokio::task::spawn_blocking(move || { + let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner()); + let result = guard.kill(&job_id).map_err(map_job_err)?; + let snapshot = guard.inspect_job(&job_id).map_err(map_job_err)?.snapshot; + Ok(Json(KillJobResponse { + job: JobView::new(snapshot, thread_id), + result, + })) + }) + .await + .map_err(|_| ApiError::internal("job kill failed"))? +} diff --git a/crates/tui/src/runtime_api/lsp.rs b/crates/tui/src/runtime_api/lsp.rs new file mode 100644 index 0000000000..6a8548de82 --- /dev/null +++ b/crates/tui/src/runtime_api/lsp.rs @@ -0,0 +1,234 @@ +//! LSP-over-HTTP for native clients (APPS-93). +//! +//! A native file view needs live diagnostics and semantic references, not just +//! receipt-projected ones. These routes serve the *server workspace* through +//! one lazily-built [`LspManager`]; engine threads keep their own per-thread +//! managers for the post-edit hook. Language servers spawn on first use only — +//! a server that never serves an LSP route never pays for one. +//! +//! Fail-closed as data: a file with no language server, a disabled `[lsp]` +//! config, or an LSP timeout all answer `200` with `ok: false` + a machine- +//! readable `reason`. Only malformed input (bad path, missing `line`) is an +//! HTTP error — a file without a server is a normal state, not a failure. +//! +//! Routes: +//! GET /v1/lsp — capability: enabled, languages, operations +//! GET /v1/diagnostics — ?path= (workspace-relative) +//! GET /v1/definition — ?path=&line=&character= (1-based) +//! GET /v1/references — ?path=&line=&character= (1-based) +//! GET /v1/symbols — ?path=&query= (query empty → document symbols) + +use std::path::PathBuf; +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Query, State}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::workspace::{canonical_workspace, precheck_file_target, relative_request_path}; +use super::{ApiError, RuntimeApiState}; +use crate::lsp::LspManager; +use crate::lsp::registry::{self, Language}; + +const LSP_OPERATIONS: &[&str] = &["diagnostics", "symbols", "definition", "references"]; +const LANGUAGES: &[Language] = &[ + Language::Rust, + Language::Go, + Language::Python, + Language::TypeScript, + Language::JavaScript, + Language::Java, + Language::Php, + Language::Vue, + Language::C, + Language::Cpp, +]; + +/// The shared workspace manager: built once, from the live config's `[lsp]` +/// table and the canonical workspace root. +fn lsp_manager(state: &RuntimeApiState) -> Result, ApiError> { + if let Some(manager) = state.lsp_manager.get() { + return Ok(manager.clone()); + } + let workspace = state + .workspace + .canonicalize() + .map_err(|_| ApiError::internal("workspace is unavailable"))?; + let config = state + .config + .read() + .lsp + .clone() + .map(|toml| toml.into_runtime()) + .unwrap_or_default(); + Ok(state + .lsp_manager + .get_or_init(|| Arc::new(LspManager::new(config, workspace))) + .clone()) +} + +/// Resolve a workspace-relative `path` to an absolute file inside the +/// workspace, refusing traversal, `.git`, links, and missing files — the same +/// confinement the file routes apply. +fn resolve_workspace_file(state: &RuntimeApiState, raw: &str) -> Result { + let relative = relative_request_path(raw, false)?; + let root = canonical_workspace(&state.workspace)?; + precheck_file_target(&root, &relative)?.ok_or_else(|| ApiError::not_found("file not found"))?; + Ok(root.join(&relative)) +} + +/// `intelligence` reports ordinary states (disabled, no server) as error +/// strings; split them back into the honest machine-readable reasons. +fn lsp_failure(error: String) -> Value { + let reason = if error.contains("no LSP server") { + "no_server" + } else if error.contains("disabled") { + "lsp_disabled" + } else { + "lsp_error" + }; + json!({ "ok": false, "reason": reason, "detail": error }) +} + +async fn run_intelligence( + state: &RuntimeApiState, + operation: &str, + file: PathBuf, + line: Option, + character: Option, + query: Option, +) -> Result, ApiError> { + let manager = lsp_manager(state)?; + if !manager.config().enabled { + return Ok(Json( + json!({ "ok": false, "reason": "lsp_disabled", "enabled": false }), + )); + } + let result = manager + .intelligence(operation, &file, line, character, query.as_deref()) + .await; + match result { + Ok(mut value) => { + if let Some(object) = value.as_object_mut() { + object.insert("ok".to_string(), Value::Bool(true)); + } + Ok(Json(value)) + } + Err(error) => Ok(Json(lsp_failure(error))), + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct LspFileQuery { + path: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct LspPositionQuery { + path: String, + /// 1-based line; required by definition/references. + line: Option, + /// 1-based column; defaults to 1. + character: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct LspSymbolsQuery { + path: String, + query: Option, +} + +/// `GET /v1/lsp` — capability report: what this runtime can serve without +/// probing or spawning anything. +pub(super) async fn lsp_status( + State(state): State, +) -> Result, ApiError> { + let manager = lsp_manager(&state)?; + let config = manager.config(); + let languages: Vec = LANGUAGES + .iter() + .filter_map(|language| { + registry::server_for(*language) + .map(|(command, _)| json!({ "language": language.as_key(), "server": command })) + }) + .collect(); + let custom: Vec = config + .custom + .iter() + .map(|(extension, def)| { + json!({ + "extension": extension, + "language_id": def.language_id, + "server": def.command, + }) + }) + .collect(); + Ok(Json(json!({ + "enabled": config.enabled, + "workspace": state.workspace.display().to_string(), + "operations": LSP_OPERATIONS, + "languages": languages, + "custom_languages": custom, + "poll_after_edit_ms": config.poll_after_edit_ms, + "max_diagnostics_per_file": config.max_diagnostics_per_file, + "include_warnings": config.include_warnings, + }))) +} + +pub(super) async fn lsp_diagnostics( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let file = resolve_workspace_file(&state, &query.path)?; + run_intelligence(&state, "diagnostics", file, None, None, None).await +} + +pub(super) async fn lsp_definition( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let line = query + .line + .ok_or_else(|| ApiError::bad_request("definition requires line (1-based)"))?; + let file = resolve_workspace_file(&state, &query.path)?; + run_intelligence( + &state, + "definition", + file, + Some(line), + query.character, + None, + ) + .await +} + +pub(super) async fn lsp_references( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let line = query + .line + .ok_or_else(|| ApiError::bad_request("references requires line (1-based)"))?; + let file = resolve_workspace_file(&state, &query.path)?; + run_intelligence( + &state, + "references", + file, + Some(line), + query.character, + None, + ) + .await +} + +pub(super) async fn lsp_symbols( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + let file = resolve_workspace_file(&state, &query.path)?; + run_intelligence(&state, "symbols", file, None, None, query.query).await +} diff --git a/crates/tui/src/runtime_api/secrets.rs b/crates/tui/src/runtime_api/secrets.rs new file mode 100644 index 0000000000..ab196a31eb --- /dev/null +++ b/crates/tui/src/runtime_api/secrets.rs @@ -0,0 +1,141 @@ +use axum::Json; +use axum::extract::{Path, State}; +use codewhale_config::ConfigStore; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::config::ApiProvider; + +use super::{ApiError, ProviderCredentialState, RuntimeApiState}; + +/// Largest accepted credential payload. Provider keys are single-line +/// tokens; anything larger is a mistake, not a longer secret. +const MAX_KEY_BYTES: usize = 4 * 1024; + +/// Request body cap for the key route — the key plus JSON framing. +pub(super) const PROVIDER_KEY_BODY_LIMIT_BYTES: usize = MAX_KEY_BYTES + 1024; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SetProviderKeyRequest { + key: String, +} + +/// Write-only credential entry for native clients (APPS-48). +/// +/// `PUT /v1/providers/{id}/key` accepts `{ "key": "…" }`, persists it through +/// the same transactional path as `codewhale auth set` (secret backend plus +/// plaintext-free config metadata, rolled back together on failure), and +/// answers with the redacted receipt: which backend holds the secret and the +/// post-write `credential_state` readback. The key itself — and even its +/// length — never appears in the response, in errors, or in logs. +/// +/// There is deliberately no GET: a route that can return a secret can leak +/// one. Clients needing assurance re-read `credential_state` here or on +/// `GET /v1/providers`. +pub(super) async fn set_provider_key( + State(state): State, + Path(id): Path, + Json(request): Json, +) -> Result, ApiError> { + let provider = ApiProvider::parse(&id) + .ok_or_else(|| ApiError::bad_request(format!("Unknown provider id '{id}'")))?; + if provider == ApiProvider::DeepseekCN { + return Err(ApiError::bad_request( + "provider 'deepseek-cn' is a legacy alias; use 'deepseek' instead", + )); + } + let kind = provider + .kind() + .ok_or_else(|| ApiError::bad_request("provider has no credential slot"))?; + + let key = request.key; + let key = key.trim(); + if key.is_empty() { + return Err(ApiError::bad_request("key must not be empty")); + } + if key.len() > MAX_KEY_BYTES || key.chars().any(char::is_control) { + return Err(ApiError::bad_request( + "key must be a single-line credential at most 4 KiB", + )); + } + + let secrets = crate::config::credential_secret_store().ok_or_else(|| { + ApiError::internal("no credential store is available in this environment") + })?; + + let store_path = state.config_path.clone(); + let kind_owned = kind; + let key_owned = key.to_string(); + let provider_owned = provider; + let (backend, saved_config_path) = tokio::task::spawn_blocking(move || { + let mut store = ConfigStore::load(store_path) + .map_err(|error| ApiError::internal(format!("config store unavailable: {error}")))?; + let mut credential_store = codewhale_config::credentials::credential_metadata_store(&store) + .map_err(|error| ApiError::internal(format!("credential store: {error}")))?; + let target = credential_store.as_mut().unwrap_or(&mut store); + let slot = codewhale_config::credentials::provider_slot(kind_owned); + crate::credentials::store::with_provider_write_lock(slot, || { + codewhale_config::credentials::set_provider_api_key( + target, &secrets, kind_owned, &key_owned, + ) + }) + .map_err(|error| { + // The credential-write errors name paths and backends only — the + // key material is never embedded in the message. + ApiError::internal(format!("credential write failed: {error}")) + })?; + Ok::<_, ApiError>(( + secrets.backend_name().to_string(), + target.path().to_path_buf(), + )) + }) + .await + .map_err(|_| ApiError::internal("credential write task failed"))??; + + // Mirror the persisted credential markers into the live config. The + // durable write may have landed on the user-global document while this + // server's ambient config is workspace-scoped, and `credential_state` + // only probes the secret store for an inactive provider when the + // `auth_mode` save marker is visible — without this mirror + // `GET /v1/providers` would keep reporting the provider as missing its + // credential until the next process start. Only marker fields are + // mirrored; the key itself never enters the runtime config. + { + let mut config = state.config.write(); + config.auth_mode = Some("api_key".to_string()); + { + let entry = config.provider_config_for_mut(provider_owned); + entry.auth_mode = Some("api_key".to_string()); + entry.external_credentials = None; + entry.api_key = None; + if provider_owned == ApiProvider::Xai { + entry.oauth_credential_generation = None; + } + } + if provider_owned == ApiProvider::Deepseek { + config.api_key = None; + if config.default_text_model.is_none() { + config.default_text_model = config + .provider_config_for(ApiProvider::Deepseek) + .and_then(|entry| entry.model.clone()) + .or_else(|| Some("deepseek-v4-pro".to_string())); + } + } + } + + let credential_state: ProviderCredentialState = + crate::provider_readiness::credential_state_for_provider( + &state.config.read(), + provider_owned, + ) + .into(); + + Ok(Json(json!({ + "provider": provider_owned.as_str(), + "stored": true, + "backend": backend, + "credentialState": credential_state, + "configPath": saved_config_path, + }))) +} diff --git a/crates/tui/src/runtime_api/targets.rs b/crates/tui/src/runtime_api/targets.rs new file mode 100644 index 0000000000..c446697258 --- /dev/null +++ b/crates/tui/src/runtime_api/targets.rs @@ -0,0 +1,231 @@ +//! Remote / cloud attach surface for native clients (APPS-50). +//! +//! A "target" is a Codewhale runtime a client talks to. This server is always +//! the local target; remote targets are other `codewhale serve --http` +//! endpoints, and the *client* owns that registry — a runtime never persists +//! peer endpoints, relays sessions, or mints a second process owner. SSH +//! workspaces and hosted cloud computers are control-plane features, so those +//! routes answer honestly (`supported: false`) and refuse writes with 501 +//! instead of 404 — a client can render real disabled states with reasons. +//! +//! Routes: +//! GET /v1/targets — this runtime's target record +//! POST /v1/targets — 501: target registry is client-owned +//! POST /v1/targets/switch — 501: client-owned; never mid-turn server-side +//! GET /v1/remote — this runtime's reachability posture +//! POST /v1/remote/connect — probe a candidate remote runtime endpoint +//! GET /v1/ssh — SSH workspaces: control-plane owned +//! POST /v1/ssh/connect — 501 +//! GET /v1/cloud — hosted computers: control-plane owned +//! POST /v1/cloud/attach — 501 + +use std::net::IpAddr; +use std::time::Duration; + +use axum::Json; +use axum::extract::State; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::{ApiError, RuntimeApiState}; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const PROBE_MAX_BYTES: usize = 64 * 1024; +const CONTROL_PLANE_OWNER: &str = "codewhale-control-plane"; + +fn self_target(state: &RuntimeApiState) -> Value { + json!({ + "id": "local", + "kind": "local", + "current": true, + "endpoint": format!("http://{}:{}", display_host(&state.bind_host), state.bind_port), + "auth_required": state.auth_required, + "service": "codewhale-runtime-api", + "codewhale_version": env!("CARGO_PKG_VERSION"), + "state": "ready", + }) +} + +fn display_host(host: &str) -> String { + if host.parse::().is_ok_and(|ip| ip.is_ipv6()) { + format!("[{host}]") + } else { + host.to_string() + } +} + +fn unsupported_surface(feature: &str) -> Value { + json!({ + "supported": false, + "owner": CONTROL_PLANE_OWNER, + "reason": format!( + "{feature} is owned by the Codewhale control plane (managed apps); this runtime does not broker it" + ), + }) +} + +pub(super) async fn list_targets(State(state): State) -> Json { + Json(json!({ + "targets": [self_target(&state)], + // Remote attach means the client points at another runtime's /v1 API; + // nothing server-side needs to (or may) switch. + "remote": { + "supported": true, + "attach": "client", + "probe": "POST /v1/remote/connect", + }, + "ssh": unsupported_surface("SSH remote workspaces"), + "cloud": unsupported_surface("Hosted cloud computers"), + })) +} + +pub(super) async fn create_target(State(_state): State) -> ApiError { + ApiError::not_implemented( + "target registry is client-owned; attach by pointing the client at a runtime endpoint", + ) +} + +pub(super) async fn switch_target(State(_state): State) -> ApiError { + ApiError::not_implemented( + "target switching is client-owned; a switch must never move a running task or replay input", + ) +} + +pub(super) async fn remote_status(State(state): State) -> Json { + let loopback_only = super::is_loopback_bind_host(&state.bind_host); + Json(json!({ + "bind_host": state.bind_host, + "port": state.bind_port, + "loopback_only": loopback_only, + "reachable_from_lan": !loopback_only, + "auth_required": state.auth_required, + "mobile": state.mobile_enabled, + // The runtime API has no TLS terminator; non-loopback reachability + // assumes a verified overlay (VPN/mesh), never plain LAN trust. + "tls": false, + })) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RemoteConnectRequest { + /// Base URL of the candidate runtime, e.g. `http://192.168.1.5:7878`. + /// Credentials in the URL are refused — runtime tokens never travel + /// through this server. + endpoint: String, +} + +/// Probe a candidate remote endpoint's `/v1/runtime/info`. The probe is +/// unauthenticated against the remote on purpose: this route must never be a +/// bearer-token forwarding primitive, and `runtime/info` answers signed-out +/// identity + `auth_required` without one. A negative reachability verdict is +/// data (`ok: false`), not a server error — the probe itself succeeded. +pub(super) async fn remote_connect( + State(_state): State, + Json(req): Json, +) -> Result, ApiError> { + let url = reqwest::Url::parse(req.endpoint.trim()) + .map_err(|_| ApiError::bad_request("endpoint must be an http(s) URL"))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(ApiError::bad_request( + "endpoint scheme must be http or https", + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(ApiError::bad_request( + "credentials in endpoints are refused; send the remote's runtime token from the client", + )); + } + let host = url + .host_str() + .ok_or_else(|| ApiError::bad_request("endpoint must name a host"))?; + // Origin only — the runtime API always serves at /v1 regardless of the + // path a user pasted. + let origin = match url.port() { + Some(port) => format!("{}://{host}:{port}", url.scheme()), + None => format!("{}://{host}", url.scheme()), + }; + let probe_url = format!("{origin}/v1/runtime/info"); + + let client = codewhale_release::tls::reqwest_client_builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(PROBE_TIMEOUT) + .build() + .map_err(|error| ApiError::internal(format!("probe client unavailable: {error}")))?; + let response = match client.get(&probe_url).send().await { + Ok(response) => response, + Err(error) => { + return Ok(Json(json!({ + "ok": false, + "endpoint": origin, + "reason": "unreachable", + "detail": error.to_string(), + }))); + } + }; + let status = response.status(); + let bytes = response.bytes().await.unwrap_or_default(); + if bytes.len() > PROBE_MAX_BYTES { + return Ok(Json(json!({ + "ok": false, + "endpoint": origin, + "reason": "response too large to be a runtime identity", + }))); + } + let body: Value = match serde_json::from_slice(&bytes) { + Ok(body) => body, + Err(_) => { + return Ok(Json(json!({ + "ok": false, + "endpoint": origin, + "status": status.as_u16(), + "reason": "not a Codewhale runtime", + }))); + } + }; + if body.get("service").and_then(Value::as_str) != Some("codewhale-runtime-api") { + return Ok(Json(json!({ + "ok": false, + "endpoint": origin, + "status": status.as_u16(), + "reason": "not a Codewhale runtime", + }))); + } + Ok(Json(json!({ + "ok": true, + "endpoint": origin, + "remote": { + "kind": "remote", + "endpoint": origin, + "service": "codewhale-runtime-api", + "runtime_api_version": body.get("runtime_api_version").cloned().unwrap_or(Value::Null), + "codewhale_version": body.get("codewhale_version").cloned().unwrap_or(Value::Null), + "auth_required": body.get("auth_required").cloned().unwrap_or(Value::Null), + "bind_host": body.get("bind_host").cloned().unwrap_or(Value::Null), + "port": body.get("port").cloned().unwrap_or(Value::Null), + }, + // The verdict is all this server knows: attaching means the client + // re-targets its own transport at `endpoint` with that runtime's token. + "attach": "client", + }))) +} + +pub(super) async fn ssh_status(State(_state): State) -> Json { + Json(unsupported_surface("SSH remote workspaces")) +} + +pub(super) async fn ssh_connect(State(_state): State) -> ApiError { + ApiError::not_implemented( + "SSH remote workspaces are owned by the Codewhale control plane; this runtime does not open SSH transports", + ) +} + +pub(super) async fn cloud_status(State(_state): State) -> Json { + Json(unsupported_surface("Hosted cloud computers")) +} + +pub(super) async fn cloud_attach(State(_state): State) -> ApiError { + ApiError::not_implemented( + "hosted cloud computers are owned by the Codewhale control plane; this runtime does not provision them", + ) +} diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 0008d4d004..8e8db6a1ad 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -946,6 +946,8 @@ struct TestServerOverrides { web: Option, compat_stream_test_hook: Option>, plugin_discovery: Option>, + /// Pre-seeded workspace LSP manager (test transports, disabled configs). + lsp_manager: Option>, } async fn spawn_test_server_with_root_token_mobile_workspace_and_subagents( @@ -1237,6 +1239,13 @@ async fn build_test_server( fleet_codewhale_binary: overrides .fleet_codewhale_binary .unwrap_or_else(configured_codewhale_binary), + lsp_manager: { + let cell = std::sync::OnceLock::new(); + if let Some(manager) = overrides.lsp_manager { + let _ = cell.set(manager); + } + Arc::new(cell) + }, compat_stream_test_hook: overrides.compat_stream_test_hook, }; let app = build_router(state); @@ -15391,3 +15400,1271 @@ async fn session_artifacts_list_and_bounded_read() -> Result<()> { handle.abort(); Ok(()) } + +// --------------------------------------------------------------------------- +// /v1/jobs: client-owned shell jobs on the thread's shared ShellManager. +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[tokio::test] +async fn jobs_api_lists_creates_streams_stdin_and_kills() -> Result<()> { + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("jobs-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("jobs test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + // Auth is required on every jobs route. + for route in ["/v1/jobs", "/v1/threads/thread-x/jobs"] { + let status = client.get(format!("{base}{route}")).send().await?.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED, "{route}"); + } + + let thread: Value = client + .post(format!("{base}/v1/threads")) + .bearer_auth("jobs-token") + .json(&json!({ "workspace": workspace, "allow_shell": true })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let thread_id = thread["id"].as_str().unwrap().to_string(); + + // A shell-forbidden thread refuses job creation. + let no_shell: Value = client + .post(format!("{base}/v1/threads")) + .bearer_auth("jobs-token") + .json(&json!({ "workspace": workspace, "allow_shell": false })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let no_shell_id = no_shell["id"].as_str().unwrap().to_string(); + let status = client + .post(format!("{base}/v1/threads/{no_shell_id}/jobs")) + .bearer_auth("jobs-token") + .json(&json!({ "command": "echo never" })) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::FORBIDDEN); + + // Unknown thread and unknown job are honest 404s, and a thread that never + // ran shell work lists empty rather than fabricating a manager. + let status = client + .get(format!("{base}/v1/threads/thread-missing/jobs")) + .bearer_auth("jobs-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_FOUND); + let empty: Value = client + .get(format!("{base}/v1/threads/{no_shell_id}/jobs")) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(empty["jobs"].as_array().unwrap().len(), 0); + let status = client + .get(format!( + "{base}/v1/threads/{thread_id}/jobs/job-missing/output" + )) + .bearer_auth("jobs-token") + .send() + .await? + .status(); + // The thread has no manager yet, so this is a not-found either way. + assert_eq!(status, StatusCode::NOT_FOUND); + + // Create a client-owned background job. + let created = client + .post(format!("{base}/v1/threads/{thread_id}/jobs")) + .bearer_auth("jobs-token") + .json(&json!({ "command": "echo codewhale-jobs && echo second-line" })) + .send() + .await? + .error_for_status()?; + assert_eq!(created.status(), StatusCode::CREATED); + let created: Value = created.json().await?; + let job_id = created["job"]["id"].as_str().unwrap().to_string(); + assert_eq!(created["job"]["owner"], "client"); + assert_eq!(created["job"]["thread_id"], thread_id); + assert_eq!( + created["job"]["command"], + "echo codewhale-jobs && echo second-line" + ); + + // Poll the byte stream until the job exits; the cursor contract replays + // the full stream from 0 for a late reader. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let mut chunk: Value; + loop { + chunk = client + .get(format!( + "{base}/v1/threads/{thread_id}/jobs/{job_id}/output?format=text" + )) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + if chunk["done"].as_bool().unwrap() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "job did not finish: {chunk}" + ); + sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!(chunk["status"], "Completed"); + assert_eq!(chunk["exit_code"], 0); + assert!(chunk["data"].as_str().unwrap().contains("codewhale-jobs")); + assert_eq!(chunk["dropped"], 0); + + // A cursor at the end stays put; a mid-stream cursor resumes exactly. + let total = chunk["total"].as_u64().unwrap(); + let tail: Value = client + .get(format!( + "{base}/v1/threads/{thread_id}/jobs/{job_id}/output?format=text&cursor={total}" + )) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(tail["data"], ""); + assert_eq!(tail["next_cursor"], total); + assert_eq!(tail["done"], true); + + // stdin round-trip: a `cat` job echoes what the API writes. + let cat: Value = client + .post(format!("{base}/v1/threads/{thread_id}/jobs")) + .bearer_auth("jobs-token") + .json(&json!({ "command": "cat" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let cat_id = cat["job"]["id"].as_str().unwrap().to_string(); + assert_eq!(cat["job"]["stdin_available"], true); + let status = client + .post(format!("{base}/v1/threads/{thread_id}/jobs/{cat_id}/stdin")) + .bearer_auth("jobs-token") + .json(&json!({ "data": "typed-by-client\n", "close": true })) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NO_CONTENT); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let echoed = loop { + let chunk: Value = client + .get(format!( + "{base}/v1/threads/{thread_id}/jobs/{cat_id}/output?format=text" + )) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + if chunk["done"].as_bool().unwrap() { + break chunk; + } + assert!( + std::time::Instant::now() < deadline, + "cat job did not finish: {chunk}" + ); + sleep(std::time::Duration::from_millis(50)).await; + }; + assert_eq!(echoed["data"], "typed-by-client\n"); + + // The flat listing sees both jobs, tagged to this thread. + let all: Value = client + .get(format!("{base}/v1/jobs")) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let ids: Vec<&str> = all["jobs"] + .as_array() + .unwrap() + .iter() + .map(|job| job["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&job_id.as_str()) && ids.contains(&cat_id.as_str())); + + // Kill stops a running job and reports the terminal snapshot. + let sleeper: Value = client + .post(format!("{base}/v1/threads/{thread_id}/jobs")) + .bearer_auth("jobs-token") + .json(&json!({ "command": "sleep 60" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let sleeper_id = sleeper["job"]["id"].as_str().unwrap().to_string(); + let killed: Value = client + .post(format!( + "{base}/v1/threads/{thread_id}/jobs/{sleeper_id}/kill" + )) + .bearer_auth("jobs-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(killed["job"]["status"], "Killed"); + + // A job id from another thread's scope is a scoped 404, not a leak. + let other: Value = client + .post(format!("{base}/v1/threads")) + .bearer_auth("jobs-token") + .json(&json!({ "workspace": workspace, "allow_shell": true })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let other_id = other["id"].as_str().unwrap().to_string(); + let status = client + .get(format!("{base}/v1/threads/{other_id}/jobs/{job_id}")) + .bearer_auth("jobs-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_FOUND); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn commands_catalog_projects_registry_with_discovery_and_argument_hints() -> Result<()> { + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("commands-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("commands test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client + .get(format!("{base}/v1/commands")) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let catalog: Value = client + .get(format!("{base}/v1/commands")) + .bearer_auth("commands-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let commands = catalog["commands"].as_array().unwrap(); + assert!(commands.len() > 20, "catalog should cover the registry"); + + // Every row carries the typed shape the composer needs — no stringly + // discovery or re-parsed usage lines on the client. + for command in commands { + assert!(command["name"].as_str().is_some_and(|n| !n.is_empty())); + assert!(command["usage"].as_str().is_some()); + assert!(command["description"].as_str().is_some()); + assert!(command["aliases"].is_array()); + assert!(command["subcommands"].is_array()); + assert!(matches!( + command["discovery"].as_str(), + Some("primary" | "advanced" | "compatibility") + )); + assert!(command["requires_argument"].is_boolean()); + assert!(command["requires_required_argument"].is_boolean()); + assert!(command["composer_wants_trailing_space"].is_boolean()); + assert!(command["palette_runs_directly"].is_boolean()); + assert!(command["show_in_empty_discovery"].is_boolean()); + assert!(command["unlisted"].is_boolean()); + } + + // Argument hints must distinguish a mandatory-argument command from a + // bare one rather than flattening them. + let by_name = |name: &str| commands.iter().find(|c| c["name"] == name); + let profile = by_name("profile").expect("profile is a registered command"); + assert_eq!(profile["requires_required_argument"], true); + assert_eq!(profile["palette_runs_directly"], false); + let clear = by_name("clear").expect("clear is a registered command"); + assert_eq!(clear["requires_argument"], false); + assert_eq!(clear["palette_runs_directly"], true); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn thread_context_reports_live_budget_from_the_engine() -> Result<()> { + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("context-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("context test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client + .get(format!("{base}/v1/threads/thread-x/context")) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let status = client + .get(format!("{base}/v1/threads/thread-missing/context")) + .bearer_auth("context-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_FOUND); + + let thread: Value = client + .post(format!("{base}/v1/threads")) + .bearer_auth("context-token") + .json(&json!({ "workspace": workspace })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let thread_id = thread["id"].as_str().unwrap().to_string(); + + let context: Value = client + .get(format!("{base}/v1/threads/{thread_id}/context")) + .bearer_auth("context-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(context["thread_id"], thread_id); + assert_eq!(context["live"], true); + let window = context["window_tokens"].as_u64().unwrap(); + assert!(window > 0, "a resolved route must report its window"); + assert!(context["input_tokens"].as_u64().unwrap() > 0); + assert!(context["available_input_tokens"].as_u64().unwrap() <= window); + assert!(context["compaction_trigger_tokens"].as_u64().unwrap() <= window); + let percent = context["usage_percent"].as_f64().unwrap(); + assert!((0.0..=100.0).contains(&percent)); + assert!(matches!( + context["pressure"].as_str(), + Some("low" | "moderate" | "high" | "critical") + )); + // Fresh thread: no provider-billed count exists yet — null, not zero. + assert!(context["billed_input_tokens"].is_null()); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn provider_key_write_is_write_only_and_reports_readiness() -> Result<()> { + // The credential store is only exposed to tests under an explicit isolated + // home plus an explicit backend — both scoped to this test by the env lock. + let _env_lock = crate::test_support::lock_test_env(); + let tmp = tempfile::tempdir()?; + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cwhome")); + let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file"); + fs::create_dir_all(tmp.path().join("cwhome"))?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("keys-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("secrets test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client + .put(format!("{base}/v1/providers/openai/key")) + .json(&json!({ "key": "sk-test-write-only" })) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + for (id, want) in [ + ("not-a-provider", StatusCode::BAD_REQUEST), + ("deepseek-cn", StatusCode::BAD_REQUEST), + ] { + let status = client + .put(format!("{base}/v1/providers/{id}/key")) + .bearer_auth("keys-token") + .json(&json!({ "key": "sk-test" })) + .send() + .await? + .status(); + assert_eq!(status, want, "{id}"); + } + let status = client + .put(format!("{base}/v1/providers/openai/key")) + .bearer_auth("keys-token") + .json(&json!({ "key": " " })) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + + // A key written for the *active* provider reports configured through the + // live store even though the test route is a keyless local endpoint — + // saving a key declares the api-key contract, exactly as `auth set` would. + let receipt: Value = client + .put(format!("{base}/v1/providers/deepseek/key")) + .bearer_auth("keys-token") + .json(&json!({ "key": "sk-test-active-route-key" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(receipt["provider"], "deepseek"); + assert_eq!(receipt["stored"], true); + assert!(!receipt.to_string().contains("sk-test-active-route-key")); + assert_eq!(receipt["credentialState"], "configured"); + + // A key written for a *non-active* provider is the harder case: the + // readiness catalog only probes the secret store for it when the + // auth-mode save marker is visible, so the route must mirror the + // persisted marker into the live config for the readback to be honest. + let receipt: Value = client + .put(format!("{base}/v1/providers/openai/key")) + .bearer_auth("keys-token") + .json(&json!({ "key": "sk-test-write-only-key-material" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(receipt["provider"], "openai"); + assert_eq!(receipt["stored"], true); + // The receipt is a redacted receipt: no key bytes, not even the length. + let raw = receipt.to_string(); + assert!(!raw.contains("sk-test-write-only-key-material")); + assert_eq!(receipt["credentialState"], "configured"); + + // The readiness readback agrees through the providers catalog too. + let providers: Value = client + .get(format!("{base}/v1/providers")) + .bearer_auth("keys-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let openai = providers["providers"] + .as_array() + .unwrap() + .iter() + .find(|p| p["id"] == "openai") + .expect("openai is listed"); + assert_eq!(openai["credentialState"], "configured"); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn diagnostics_list_and_read_bounded_windows() -> Result<()> { + // Log/crash directories resolve from the codewhale home — isolate it. + let _env_lock = crate::test_support::lock_test_env(); + let tmp = tempfile::tempdir()?; + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cwhome")); + // Crash dumps resolve under the user home (`/.codewhale/crashes`), + // independent of the codewhale-home override that governs logs. + let _user_home = crate::test_support::EnvVarGuard::set("HOME", tmp.path().join("userhome")); + let logs = tmp.path().join("cwhome/logs"); + let crashes = tmp.path().join("userhome/.codewhale/crashes"); + fs::create_dir_all(&logs)?; + fs::create_dir_all(&crashes)?; + fs::write(logs.join("tui-20990101-1.log"), "line one\nline two\n")?; + fs::write(tmp.path().join("cwhome/audit.log"), "{\"event\":\"x\"}\n")?; + fs::write(crashes.join("20990101T000000Z-turn.log"), "Panic: boom\n")?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("diag-token".to_string()), + false, + workspace, + ) + .await? + .context("diagnostics test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client.get(format!("{base}/v1/logs")).send().await?.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let listed: Value = client + .get(format!("{base}/v1/logs")) + .bearer_auth("diag-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let names: Vec<&str> = listed["sources"][0]["files"] + .as_array() + .unwrap() + .iter() + .filter_map(|f| f["name"].as_str()) + .collect(); + assert!(names.contains(&"tui-20990101-1.log")); + assert!(names.contains(&"audit.log")); + + // Tail read returns the last bytes only. + let tail: Value = client + .get(format!("{base}/v1/logs/tui-20990101-1.log?tail=9")) + .bearer_auth("diag-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(tail["content"], "line two\n"); + assert_eq!(tail["encoding"], "utf-8"); + // A tail window reaches EOF; the skipped prefix shows up as offset > 0. + assert_eq!(tail["offset"], 9); + assert_eq!(tail["truncated"], false); + + let crashes_list: Value = client + .get(format!("{base}/v1/crashes")) + .bearer_auth("diag-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!( + crashes_list["sources"][0]["files"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"] == "20990101T000000Z-turn.log") + ); + let crash: Value = client + .get(format!("{base}/v1/crashes/20990101T000000Z-turn.log")) + .bearer_auth("diag-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(crash["content"], "Panic: boom\n"); + + // Traversal and missing files fail closed. + let status = client + .get(format!("{base}/v1/logs/..%2Fsecret")) + .bearer_auth("diag-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + let status = client + .get(format!("{base}/v1/logs/missing.log")) + .bearer_auth("diag-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_FOUND); + let status = client + .get(format!("{base}/v1/crashes/missing.log")) + .bearer_auth("diag-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_FOUND); + + let process: Value = client + .get(format!("{base}/v1/process")) + .bearer_auth("diag-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(process["pid"], json!(std::process::id())); + assert!(process["uptime_seconds"].is_number()); + assert_eq!(process["version"], env!("CARGO_PKG_VERSION")); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn targets_report_self_and_probe_remote_endpoints() -> Result<()> { + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("targets-token".to_string()), + false, + workspace, + ) + .await? + .context("targets test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client + .get(format!("{base}/v1/targets")) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let targets: Value = client + .get(format!("{base}/v1/targets")) + .bearer_auth("targets-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let local = &targets["targets"][0]; + assert_eq!(local["kind"], "local"); + assert_eq!(local["current"], true); + assert_eq!(local["service"], "codewhale-runtime-api"); + assert_eq!(local["auth_required"], true); + assert_eq!(local["endpoint"], json!(base)); + // Control-plane-owned surfaces answer honestly instead of 404ing. + assert_eq!(targets["ssh"]["supported"], false); + assert_eq!(targets["ssh"]["owner"], "codewhale-control-plane"); + assert_eq!(targets["cloud"]["supported"], false); + assert_eq!(targets["remote"]["supported"], true); + + let remote: Value = client + .get(format!("{base}/v1/remote")) + .bearer_auth("targets-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(remote["loopback_only"], true); + assert_eq!(remote["auth_required"], true); + assert_eq!(remote["port"], json!(addr.port())); + + // The probe identifies this same server as a Codewhale runtime. + let probed: Value = client + .post(format!("{base}/v1/remote/connect")) + .bearer_auth("targets-token") + .json(&json!({"endpoint": base})) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(probed["ok"], true); + assert_eq!(probed["remote"]["service"], "codewhale-runtime-api"); + assert_eq!(probed["remote"]["auth_required"], true); + assert_eq!(probed["attach"], "client"); + + // A closed port is an honest negative verdict, not a server error. + let refused: Value = client + .post(format!("{base}/v1/remote/connect")) + .bearer_auth("targets-token") + .json(&json!({"endpoint": "http://127.0.0.1:1"})) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(refused["ok"], false); + assert_eq!(refused["reason"], "unreachable"); + + // Malformed, non-http, and credentialed endpoints fail closed at 400. + for endpoint in [ + "not-a-url", + "ftp://example.com", + "http://user:pass@127.0.0.1:7878", + ] { + let status = client + .post(format!("{base}/v1/remote/connect")) + .bearer_auth("targets-token") + .json(&json!({"endpoint": endpoint})) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST, "endpoint {endpoint}"); + } + // A token field is an unknown field — never accepted for forwarding + // (axum's JSON extractor answers deserialization failures with 422). + let status = client + .post(format!("{base}/v1/remote/connect")) + .bearer_auth("targets-token") + .json(&json!({"endpoint": base, "token": "x"})) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + + // Client-owned registry + control-plane-owned surfaces refuse writes. + for (method, path) in [ + ("POST", "/v1/targets"), + ("POST", "/v1/targets/switch"), + ("POST", "/v1/ssh/connect"), + ("POST", "/v1/cloud/attach"), + ] { + let status = client + .request(method.parse()?, format!("{base}{path}")) + .bearer_auth("targets-token") + .json(&json!({})) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "{method} {path}"); + } + let ssh: Value = client + .get(format!("{base}/v1/ssh")) + .bearer_auth("targets-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(ssh["supported"], false); + let cloud: Value = client + .get(format!("{base}/v1/cloud")) + .bearer_auth("targets-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(cloud["supported"], false); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn lsp_routes_serve_workspace_files_through_a_transport() -> Result<()> { + use crate::lsp::diagnostics::{Diagnostic, Severity}; + use crate::lsp::registry::Language; + use crate::lsp::tests::FakeTransport; + + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + fs::write(workspace.join("main.rs"), "fn main() {}\n")?; + fs::write(workspace.join("notes.unknownext"), "hi\n")?; + + // Seed the shared manager with a fake Rust transport — no real LSP spawn. + let manager = Arc::new(crate::lsp::LspManager::new( + crate::lsp::LspConfig::default(), + workspace.canonicalize()?, + )); + manager + .install_test_transport( + Language::Rust, + Arc::new(FakeTransport::new(vec![Diagnostic { + line: 1, + column: 4, + severity: Severity::Error, + message: "test diagnostic".to_string(), + }])), + ) + .await; + + let (addr, _runtime_threads, handle) = + spawn_test_server_with_root_token_mobile_workspace_and_overrides( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("lsp-token".to_string()), + false, + workspace, + TestServerOverrides { + lsp_manager: Some(manager), + ..TestServerOverrides::default() + }, + ) + .await? + .context("lsp test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client.get(format!("{base}/v1/lsp")).send().await?.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let capability: Value = client + .get(format!("{base}/v1/lsp")) + .bearer_auth("lsp-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(capability["enabled"], true); + assert!( + capability["languages"] + .as_array() + .unwrap() + .iter() + .any(|l| l["language"] == "rust") + ); + + // Real diagnostics through the fake transport. + let diagnostics: Value = client + .get(format!("{base}/v1/diagnostics?path=main.rs")) + .bearer_auth("lsp-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(diagnostics["ok"], true); + assert_eq!(diagnostics["items"][0]["severity"], "error"); + assert_eq!(diagnostics["items"][0]["message"], "test diagnostic"); + + // A language without a server is honest data, not an HTTP error. + let none: Value = client + .get(format!("{base}/v1/diagnostics?path=notes.unknownext")) + .bearer_auth("lsp-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(none["items"].as_array().unwrap().len(), 0); + + // Position routes validate `line` before touching LSP. + let status = client + .get(format!("{base}/v1/definition?path=main.rs")) + .bearer_auth("lsp-token") + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + + // The fake transport declines request-style ops — surfaced as ok:false. + let definition: Value = client + .get(format!( + "{base}/v1/definition?path=main.rs&line=1&character=5" + )) + .bearer_auth("lsp-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(definition["ok"], false); + assert_eq!(definition["reason"], "lsp_error"); + + // Traversal and missing files fail closed. + for path in ["../escape.rs", ".git/config", "missing.rs"] { + let status = client + .get(format!("{base}/v1/diagnostics?path={path}")) + .bearer_auth("lsp-token") + .send() + .await? + .status(); + assert!( + matches!( + status, + StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN | StatusCode::NOT_FOUND + ), + "path {path} -> {status}" + ); + } + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn voice_routes_report_capability_and_fail_closed_without_a_recorder() -> Result<()> { + let _lock = lock_test_env(); + // Deterministic: the operator kill-switch keeps the test off the host mic. + let _voice_off = EnvVarGuard::set("CODEWHALE_DISABLE_VOICE", "1"); + + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + + let (addr, _runtime_threads, handle) = + spawn_test_server_with_root_token_mobile_workspace_and_overrides( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("voice-token".to_string()), + false, + workspace, + TestServerOverrides::default(), + ) + .await? + .context("voice test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client + .get(format!("{base}/v1/voice")) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + for path in ["/v1/voice/dictate", "/v1/voice/send", "/v1/voice/control"] { + let status = client.post(format!("{base}{path}")).send().await?.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED, "POST {path}"); + } + + let status: Value = client + .get(format!("{base}/v1/voice")) + .bearer_auth("voice-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(status["available"], false); + assert!(status["recorder"].is_null()); + assert_eq!(status["asr"]["kind"].as_str().unwrap().is_empty(), false); + assert_eq!(status["modes"].as_array().unwrap().len(), 3); + assert!(status["send_phrases"].as_array().unwrap().len() >= 3); + assert_eq!(status["max_record_seconds"], 10); + + // Dictation with no recorder fails closed as data — never an HTTP 5xx. + for path in ["/v1/voice/dictate", "/v1/voice/send"] { + let outcome: Value = client + .post(format!("{base}{path}")) + .bearer_auth("voice-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(outcome["ok"], false, "{path}"); + assert_eq!(outcome["reason"], "no_recorder", "{path}"); + } + + // Control mode takes an optional {"composer"} body; still no recorder. + let outcome: Value = client + .post(format!("{base}/v1/voice/control")) + .bearer_auth("voice-token") + .json(&json!({ "composer": "draft text" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(outcome["ok"], false); + assert_eq!(outcome["reason"], "no_recorder"); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn git_routes_drive_a_real_workspace_repo() -> Result<()> { + use crate::dependencies::{ExternalTool as _, Git}; + + let tmp = tempfile::tempdir()?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + let git = |args: &[&str]| { + let output = Git::output(args, &workspace)?; + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok::<_, anyhow::Error>(output) + }; + git(&["init", "-b", "main"])?; + git(&["config", "user.email", "runtime-api@example.test"])?; + git(&["config", "user.name", "Runtime API Test"])?; + fs::write(workspace.join("tracked.txt"), "v1\n")?; + git(&["add", "tracked.txt"])?; + git(&["commit", "-m", "initial"])?; + + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("git-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("git test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + let status = client.get(format!("{base}/v1/git")).send().await?.status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // Untracked file shows up in the detail read. + fs::write(workspace.join("new.rs"), "fn main() {}\n")?; + let detail: Value = client + .get(format!("{base}/v1/git")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(detail["git_repo"], true); + assert_eq!(detail["branch"], "main"); + assert!( + detail["files"].as_array().unwrap().iter().any(|f| { + f["path"] == "new.rs" && f["status"] == "untracked" && f["staged"] == false + }) + ); + assert_eq!(detail["branches"], json!(["main"])); + + // Stage → status reflects the index; commit lands the change. + let staged: Value = client + .post(format!("{base}/v1/git/stage")) + .bearer_auth("git-token") + .json(&json!({ "paths": ["new.rs"] })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(staged["ok"], true); + assert_eq!(staged["status"]["staged"], 1); + let committed: Value = client + .post(format!("{base}/v1/git/commit")) + .bearer_auth("git-token") + .json(&json!({ "message": "add new.rs" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(committed["ok"], true); + assert_eq!(committed["status"]["staged"], 0); + + // Branch create+switch, then the graph reports both commits on it. + let switched: Value = client + .post(format!("{base}/v1/git/branch")) + .bearer_auth("git-token") + .json(&json!({ "name": "feature-x", "create": true })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(switched["ok"], true); + assert_eq!(switched["status"]["branch"], "feature-x"); + let graph: Value = client + .get(format!("{base}/v1/git/graph")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let commits = graph["commits"].as_array().unwrap(); + assert_eq!(commits.len(), 2); + assert_eq!(commits[0]["subject"], "add new.rs"); + assert!(commits[1]["id"].as_str().unwrap().len() == 40); + + // Discard restores a tracked file; discarding an untracked path fails + // closed with git's own message rather than deleting the file. + fs::write(workspace.join("tracked.txt"), "v2\n")?; + let detail: Value = client + .get(format!("{base}/v1/git")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!(detail["files"].as_array().unwrap().iter().any(|f| { + f["path"] == "tracked.txt" && f["status"] == "modified" && f["staged"] == false + })); + + // The diff reads serve the same worktree change: a unified patch for one + // file, the whole tree with a numstat inventory, and the changes list. + let file_diff: Value = client + .get(format!("{base}/v1/diff?path=tracked.txt")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(file_diff["ok"], true); + assert_eq!(file_diff["untracked"], false); + let patch = file_diff["diff"].as_str().unwrap(); + assert!(patch.contains("-v1") && patch.contains("+v2"), "{patch}"); + assert_eq!(file_diff["truncated"], false); + + // An untracked file is honest data: no diff, flagged for the client. + fs::write(workspace.join("scratch.txt"), "loose\n")?; + let untracked: Value = client + .get(format!("{base}/v1/diff?path=scratch.txt")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(untracked["ok"], true); + assert_eq!(untracked["untracked"], true); + assert_eq!(untracked["diff"], ""); + + let changes: Value = client + .get(format!("{base}/v1/changes")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(changes["git_repo"], true); + assert!( + changes["files"] + .as_array() + .unwrap() + .iter() + .any(|f| f["path"] == "tracked.txt" && f["status"] == "modified") + ); + + let tree: Value = client + .get(format!("{base}/v1/workspace/diff")) + .bearer_auth("git-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(tree["ok"], true); + assert!( + tree["files"] + .as_array() + .unwrap() + .iter() + .any(|f| f["path"] == "tracked.txt" && f["added"] == 1 && f["deleted"] == 1) + ); + assert!(tree["diff"].as_str().unwrap().contains("+v2")); + assert_eq!(tree["truncated"], false); + + // Diff path validation shares the file-route confinement. + for (path, want) in [ + ("../escape.txt", StatusCode::BAD_REQUEST), + (".git/config", StatusCode::FORBIDDEN), + ] { + let status = client + .get(format!("{base}/v1/diff?path={path}")) + .bearer_auth("git-token") + .send() + .await? + .status(); + assert_eq!(status, want, "diff path {path}"); + } + let discarded: Value = client + .post(format!("{base}/v1/git/discard")) + .bearer_auth("git-token") + .json(&json!({ "paths": ["tracked.txt"] })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(discarded["ok"], true); + assert_eq!(fs::read_to_string(workspace.join("tracked.txt"))?, "v1\n"); + let status = client + .post(format!("{base}/v1/git/discard")) + .bearer_auth("git-token") + .json(&json!({ "paths": ["never-tracked.txt"] })) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::BAD_REQUEST); + + // Whole-tree discard stays refused; invalid refs and escapes are stopped + // before git ever sees them (the .git refusal is forbidden, the rest are + // bad requests). + for (route, body, want) in [ + ( + "/v1/git/discard", + json!({ "all": true }), + StatusCode::BAD_REQUEST, + ), + ( + "/v1/git/stage", + json!({ "paths": ["../escape.txt"] }), + StatusCode::BAD_REQUEST, + ), + ( + "/v1/git/stage", + json!({ "paths": [".git/config"] }), + StatusCode::FORBIDDEN, + ), + ( + "/v1/git/branch", + json!({ "name": "-f" }), + StatusCode::BAD_REQUEST, + ), + ( + "/v1/git/commit", + json!({ "message": " " }), + StatusCode::BAD_REQUEST, + ), + ] { + let status = client + .post(format!("{base}{route}")) + .bearer_auth("git-token") + .json(&body) + .send() + .await? + .status(); + assert_eq!(status, want, "{route} {body}"); + } + + handle.abort(); + Ok(()) +} diff --git a/crates/tui/src/runtime_api/voice.rs b/crates/tui/src/runtime_api/voice.rs new file mode 100644 index 0000000000..91f0250f03 --- /dev/null +++ b/crates/tui/src/runtime_api/voice.rs @@ -0,0 +1,112 @@ +//! Voice/dictation HTTP for native clients (APPS-98). +//! +//! The runtime owns the host microphone and the ASR dispatch — recording and +//! transcription are the same implementation the TUI's `/voice` commands run, +//! exposed headlessly so a desktop client gets text back instead of driving a +//! terminal. There is deliberately no second speech stack and no audio upload +//! path: dictate means "record on the host this runtime runs on". +//! +//! Fail-closed as data: no recorder, no speech, missing provider auth, or an +//! ASR failure all answer `200` with `ok: false` + a machine-readable +//! `reason` — a desktop client reads `GET /v1/voice` first and disables its +//! dictation affordance when `available` is false. +//! +//! Routes: +//! GET /v1/voice — capability: recorder, ASR selection, send phrases +//! POST /v1/voice/dictate — record + transcribe → insert text +//! POST /v1/voice/send — record + transcribe + send-suffix detection +//! POST /v1/voice/control — record + assisted dictation; body {"composer"} + +use axum::Json; +use axum::extract::State; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::{ApiError, RuntimeApiState}; +use crate::commands::voice as voice_core; +use voice_core::{DictateError, DictateMode}; + +/// One mic per host — serialize captures so concurrent dictate requests get +/// an honest "no speech" for the loser rather than fighting over the device. +static DICTATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// `GET /v1/voice` — what this host can do: whether a recorder exists, which +/// ASR backend the live config resolves to, and the send-suffix contract. +pub(super) async fn voice_status( + State(state): State, +) -> Result, ApiError> { + let (asr_kind, asr_model) = { + let config = state.config.read(); + voice_core::asr_choice(&config) + }; + Ok(Json(json!({ + "available": voice_core::is_available(), + "recorder": voice_core::recorder_command(), + "asr": { "kind": asr_kind, "model": asr_model }, + "modes": ["insert", "send", "control"], + "send_phrases": voice_core::SEND_PHRASES, + "max_record_seconds": voice_core::MAX_RECORD_SECS, + }))) +} + +fn dictate_failure(error: DictateError) -> Json { + Json(json!({ + "ok": false, + "reason": error.reason(), + "message": error.to_string(), + })) +} + +async fn dictate(state: &RuntimeApiState, mode: DictateMode) -> Result, ApiError> { + // Clone before recording: holding the config lock across a ~10s capture + // would stall unrelated config writes for the duration. + let config = state.config.read().clone(); + let _permit = DICTATE_LOCK.lock().await; + match voice_core::dictate_once(&config, mode).await { + Ok(outcome) => Ok(Json(json!({ + "ok": true, + "text": outcome.text, + "send": outcome.send, + "assisted": outcome.assisted, + "asr": { "kind": outcome.asr_kind, "model": outcome.asr_model }, + }))), + Err(error) => Ok(dictate_failure(error)), + } +} + +/// `POST /v1/voice/dictate` — record + transcribe → text to insert. +pub(super) async fn voice_dictate( + State(state): State, +) -> Result, ApiError> { + dictate(&state, DictateMode::Insert).await +} + +/// `POST /v1/voice/send` — dictate with send-suffix detection; the client +/// submits when `send` is true (empty `text` = submit the current draft). +pub(super) async fn voice_send( + State(state): State, +) -> Result, ApiError> { + dictate(&state, DictateMode::Send).await +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct VoiceControlRequest { + /// The client's current composer text — the model sees it for + /// AI-assisted dictation (the `/voice-control` pipeline). + #[serde(default)] + composer: String, +} + +/// `POST /v1/voice/control` — assisted dictation with composer context. +/// `assisted: false` in the response means a free ASR kind handled the audio +/// and the composer text was never seen. +pub(super) async fn voice_control( + State(state): State, + body: Option>, +) -> Result, ApiError> { + let composer = body + .map(|Json(request)| request.composer) + .unwrap_or_default(); + dictate(&state, DictateMode::Control(composer)).await +} diff --git a/crates/tui/src/runtime_api/workspace.rs b/crates/tui/src/runtime_api/workspace.rs index 91762afd64..e6acaac12f 100644 --- a/crates/tui/src/runtime_api/workspace.rs +++ b/crates/tui/src/runtime_api/workspace.rs @@ -383,7 +383,7 @@ pub(super) fn map_fs_error(error: std::io::Error, what: &str) -> ApiError { /// 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 { +pub(super) fn relative_request_path(raw: &str, allow_root: bool) -> Result { let trimmed = raw.trim(); if trimmed.len() > FILE_PATH_MAX_BYTES { return Err(ApiError::bad_request(format!( @@ -419,7 +419,7 @@ fn relative_request_path(raw: &str, allow_root: bool) -> Result Result { +pub(super) fn canonical_workspace(workspace: &FsPath) -> Result { workspace .canonicalize() .map_err(|_| ApiError::internal("workspace is unavailable")) @@ -588,7 +588,7 @@ pub(super) fn read_confined_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( +pub(super) fn precheck_file_target( root: &FsPath, relative: &FsPath, ) -> Result, ApiError> { diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index a9de54ea55..525bbea0a9 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -50,6 +50,7 @@ use crate::route_runtime::{ }; use crate::runtime_policy::RuntimePolicyProjection; use crate::tools::plan::new_shared_plan_state; +use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; use crate::tools::subagent::SubAgentStatus; use crate::tools::todo::new_shared_todo_list; #[cfg(test)] @@ -4034,6 +4035,11 @@ struct ActiveThreadState { struct ActiveThreads { engines: HashMap, lru: VecDeque, + /// Per-thread shell/job authority shared with the thread's loaded engine. + /// Entries outlive engine LRU eviction so background jobs keep running and + /// stay reachable through the jobs API; an entry is removed only when the + /// thread itself is removed, which drops the manager and kills its jobs. + shell_managers: HashMap, } pub(crate) struct PreparedThreadFork { @@ -6935,6 +6941,7 @@ impl RuntimeThreadManager { ) .await { + self.active.lock().await.shell_managers.remove(&thread.id); let _ = self.store.remove_thread(&thread.id); return Err(error); } @@ -6942,7 +6949,7 @@ impl RuntimeThreadManager { } pub(crate) async fn discard_empty_thread(&self, thread_id: &str) -> Result<()> { - let active = self.active.lock().await; + let mut active = self.active.lock().await; if active.engines.contains_key(thread_id) { bail!("cannot discard a loaded Runtime thread"); } @@ -6951,6 +6958,10 @@ impl RuntimeThreadManager { if thread.latest_turn_id.is_some() { bail!("cannot discard a Runtime thread that owns turns"); } + // Drop the thread's shell authority with it: the manager owns any + // API-created jobs, and dropping the last handle kills them. + active.shell_managers.remove(thread_id); + drop(active); self.store.remove_thread(thread_id) } @@ -10039,6 +10050,23 @@ impl RuntimeThreadManager { crate::tools::goal::new_shared_goal_state(), ), }; + // One shell/job authority per thread, shared with the engine so + // `GET /v1/jobs` sees the same jobs the model sees and background + // work survives engine LRU eviction. The engine applies its + // per-thread sandbox settings to the manager on construction. + let shell_manager = { + let mut active = self.active.lock().await; + let manager = active + .shell_managers + .entry(thread.id.clone()) + .or_insert_with(|| new_shared_shell_manager(thread.workspace.clone())) + .clone(); + drop(active); + if let Ok(mut guard) = manager.lock() { + guard.set_default_workspace(thread.workspace.clone()); + } + manager + }; let engine_cfg = EngineConfig { model: route_model.clone(), active_route_limits: route_limits, @@ -10100,7 +10128,7 @@ impl RuntimeThreadManager { Some(Arc::new(self.clone())) }, work: None, - shell_manager: None, + shell_manager: Some(shell_manager), persist_services_enabled: false, hook_executor: None, handle_store: crate::tools::handle::new_shared_handle_store(), @@ -10278,6 +10306,74 @@ impl RuntimeThreadManager { self.ensure_engine_loaded(&thread).await } + /// The thread's shared shell/job authority — the same manager its engine + /// uses — so `/v1/jobs` and the model see one job set. With `create`, an + /// API-created job works before the thread's first engine load; without + /// it, `None` means the thread has never run shell work. + pub async fn thread_shell_manager( + &self, + thread_id: &str, + create: bool, + ) -> Result> { + let thread = self.get_thread(thread_id).await?; + let mut active = self.active.lock().await; + let manager = match active.shell_managers.entry(thread_id.to_string()) { + std::collections::hash_map::Entry::Occupied(entry) => Some(entry.get().clone()), + std::collections::hash_map::Entry::Vacant(entry) => create.then(|| { + entry + .insert(new_shared_shell_manager(thread.workspace.clone())) + .clone() + }), + }; + drop(active); + if let Some(manager) = &manager + && let Ok(mut guard) = manager.lock() + { + guard.set_default_workspace(thread.workspace.clone()); + } + Ok(manager) + } + + /// Every live (thread_id, manager) pair, for the flat `GET /v1/jobs`. + pub async fn shell_managers_snapshot(&self) -> Vec<(String, SharedShellManager)> { + self.active + .lock() + .await + .shell_managers + .iter() + .map(|(thread_id, manager)| (thread_id.clone(), manager.clone())) + .collect() + } + + /// The sandbox policy an API-created job inherits — the same posture + /// projection a turn applies to its shell calls, so a client terminal + /// cannot run looser than the thread's own tools would. + pub(crate) async fn thread_job_sandbox_policy( + &self, + thread: &ThreadRecord, + ) -> crate::sandbox::SandboxPolicy { + let policy = RuntimePolicyProjection::from_persisted( + &thread.mode, + thread.permission_posture.as_deref(), + thread.auto_approve, + ); + let authority = crate::core::authority::TurnAuthority::from_effective_fields( + policy.mode, + thread.allow_shell, + thread.trust_mode, + policy.auto_approve(), + policy.permission, + ); + let config = self.read_config(); + authority.sandbox_policy( + &thread.workspace, + config.sandbox_mode.as_deref(), + crate::core::authority::SandboxNetworkAccess::from_config( + config.sandbox_network_access, + ), + ) + } + fn restore_thread_messages(&self, thread: &ThreadRecord) -> Result> { let turns = self.store.list_turns_for_thread(&thread.id)?; let (mut messages, covered) = self diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 435c50a325..6ea426441d 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -371,6 +371,35 @@ pub struct ShellDeltaResult { pub stderr_total_len: usize, } +/// Which of a job's raw output streams to read. Stderr is a separate stream +/// only for piped jobs; PTY and merged modes fold it into stdout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShellOutputStream { + Stdout, + Stderr, +} + +/// A non-consuming window of a job's raw output stream at absolute byte +/// offsets. Unlike [`ShellManager::get_output_delta`], reading a chunk never +/// advances anyone else's cursor, so several HTTP clients can follow the same +/// job without splitting the stream. +pub struct ShellOutputChunk { + /// Absolute offset of `bytes[0]`. Exceeds the requested cursor when the + /// bounded buffer already discarded that prefix — the gap is reported via + /// `dropped`, never silently re-sent. + pub offset: usize, + /// Raw stream bytes. Output is arbitrary bytes, not guaranteed UTF-8. + pub bytes: Vec, + /// Absolute offset just past the last returned byte; the next cursor. + pub next_offset: usize, + /// Total bytes this stream has produced, including discarded bytes. + pub total: usize, + /// Leading bytes permanently discarded by the in-flight bound. + pub dropped: usize, + pub status: ShellStatus, + pub exit_code: Option, +} + enum ShellChild { Process(Child), #[cfg(not(target_env = "ohos"))] @@ -1848,6 +1877,13 @@ impl ShellManager { self.sandbox_manager.set_prefer_bwrap(prefer); } + /// Move the fallback working directory. Callers that pass an explicit + /// `working_dir` are unaffected; this only keeps `None` honest when a + /// thread's workspace changes while its jobs are still tracked here. + pub fn set_default_workspace(&mut self, workspace: PathBuf) { + self.default_workspace = workspace; + } + /// Set user-configured bwrap mount extensions (#5410): extra read-only /// roots and writable device nodes such as `/dev/null`. pub fn set_bwrap_extensions(&mut self, extensions: crate::sandbox::BwrapMountExtensions) { @@ -3053,6 +3089,92 @@ impl ShellManager { self.get_output_delta(task_id, wait, timeout_ms) } + /// Read a job's raw stream at an absolute byte offset without consuming + /// anything. This is the `/v1/jobs` byte-stream contract: HTTP clients hold + /// the cursor, so reads must not disturb the engine's own delta consumer. + /// + /// `cursor` is a byte offset into the stream's lifetime output (matching + /// `total`). When the bounded buffer has already discarded `[0, dropped)`, + /// the window starts at `dropped` instead and the caller sees the gap in + /// the response rather than a replayed tail. With `wait_ms > 0` on a + /// running job, polls up to that bound for new bytes past `cursor` before + /// answering — long-poll instead of a hot loop. + pub fn read_output_chunk( + &mut self, + task_id: &str, + stream: ShellOutputStream, + cursor: usize, + max_bytes: usize, + wait_ms: u64, + ) -> Result { + let Some(shell) = self.processes.get_mut(task_id) else { + // Evicted jobs retain only their snapshot tails. Serve that tail as + // the final retained window so a late reader still gets the ending + // of the stream instead of a bare not-found. + let snapshot = self + .stale_jobs + .get(task_id) + .ok_or_else(|| anyhow!("Job {task_id} not found"))?; + let (tail, total) = match stream { + ShellOutputStream::Stdout => (&snapshot.stdout_tail, snapshot.stdout_len), + ShellOutputStream::Stderr => (&snapshot.stderr_tail, snapshot.stderr_len), + }; + let tail_start = total.saturating_sub(tail.len()); + let offset = cursor.max(tail_start).min(total); + let next_offset = offset.saturating_add(max_bytes).min(total); + return Ok(ShellOutputChunk { + offset, + bytes: tail.as_bytes()[offset - tail_start..next_offset - tail_start].to_vec(), + next_offset, + total, + dropped: tail_start, + status: snapshot.status.clone(), + exit_code: snapshot.exit_code, + }); + }; + let buffer = match stream { + ShellOutputStream::Stdout => shell.stdout_buffer.clone(), + ShellOutputStream::Stderr => shell + .stderr_buffer + .clone() + .ok_or_else(|| anyhow!("Job {task_id} merges stderr into stdout"))?, + }; + + let wait_deadline = (wait_ms > 0 && shell.status == ShellStatus::Running) + .then(|| Instant::now() + Duration::from_millis(wait_ms.clamp(50, 30_000))); + loop { + shell.poll(); + let total = buffer.lock().map(|guard| guard.total_len()).unwrap_or(0); + let done_waiting = total > cursor + || shell.status != ShellStatus::Running + || wait_deadline.is_none_or(|deadline| Instant::now() >= deadline); + if done_waiting { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + + let (bytes, offset, next_offset, total, dropped) = { + let guard = buffer.lock().unwrap_or_else(|e| e.into_inner()); + let total = guard.total_len(); + let dropped = guard.dropped(); + let offset = cursor.max(dropped).min(total); + let next_offset = offset.saturating_add(max_bytes).min(total); + let retained = guard.retained(); + let bytes = retained[offset - dropped..next_offset - dropped].to_vec(); + (bytes, offset, next_offset, total, dropped) + }; + Ok(ShellOutputChunk { + offset, + bytes, + next_offset, + total, + dropped, + status: shell.status.clone(), + exit_code: shell.exit_code, + }) + } + /// Attach durable task context to a live shell job. pub fn tag_linked_task(&mut self, task_id: &str, linked_task_id: Option) -> Result<()> { let shell = self diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 6baf316a9b..3f744a9cf6 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -1177,6 +1177,202 @@ tokens but `0.0` cost. Added in v0.8.10 (#564). } ``` +### Native client routes (GPUI desktop) + +These families serve the GPUI desktop client over the same bearer-token +transport. They reuse the runtime's existing authorities — the engine's shell +manager, the durable thread store, the workspace confinement layer, the +config's credential plumbing — and add no second runtime, session store, +scheduler, or credential store. + +**Jobs** (operator-scoped shell jobs; the terminal surface) +- `GET /v1/jobs` — every live and known-stale job across all threads +- `GET /v1/threads/{id}/jobs` — jobs owned by one thread's manager: + model-launched, subagent-launched, and client-launched together +- `POST /v1/threads/{id}/jobs` — `{ "command", "cwd"?, "timeout_ms"?, + "tty"?, "env"? }` → `201 { "job" }`; runs as a background shell under the + thread's projected sandbox policy. `tty: true` merges stderr into stdout + and gives the command a terminal (required for interactive programs); + background jobs are never killed at `timeout_ms` +- `GET /v1/threads/{id}/jobs/{job_id}` — one job's status + metadata +- `GET /v1/threads/{id}/jobs/{job_id}/output?stream=&cursor= + &max_bytes=<1-512KiB>&wait_ms=<0-30s>&format=` — the + resumable byte stream. `{job_id, stream, offset, next_cursor, total, + dropped, encoding, data, status, exit_code, done}`: pass `next_cursor` + back to continue; `wait_ms` long-polls for new bytes on a running job; + `done` means a terminal status and nothing left past the cursor +- `POST /v1/threads/{id}/jobs/{job_id}/stdin` — `{ "data", "encoding"?, + "close"? }`: `data` is UTF-8 text by default or `base64`, `close: true` + sends EOF; works for PTY and piped jobs → `204` +- `POST /v1/threads/{id}/jobs/{job_id}/kill` — bounded SIGTERM → SIGKILL + escalation on the process group → `{ "job", "result" }` with the final + snapshot + +Reads are non-consuming: several clients may hold independent cursors, and +polling never steals output from the engine's own delta consumer. The +buffer is bounded with exact drop accounting — a reader whose `cursor` +falls behind the retained window gets `offset` past it and `dropped > 0`, +and must re-anchor. Evicted jobs keep a tail snapshot which the output +route serves as the final retained window. Jobs are scoped to the thread +that created them and are killed when that thread is removed; the engine's +background commands use the same per-thread manager, so `GET /v1/jobs` is +also how a client sees model-spawned work. + +**Commands** (typed command catalog, APPS-28) +- `GET /v1/commands` — `{commands, locale}`: every registered slash command + with `name`, `aliases`, `usage`, `description` (localized to the runtime's + configured locale — `locale` echoes the resolved pack so a client can + detect fallback), `subcommands`, `discovery` (`primary` / `advanced` / + `compatibility`), and the composer hints (`requires_argument`, + `requires_required_argument`, `composer_wants_trailing_space`, + `palette_runs_directly`, `show_in_empty_discovery`, `unlisted`). The same + registry the TUI palette reads — a desktop palette cannot drift from the + terminal's. User-registered commands are intentionally absent: they are + per-session state, not catalog. + +**Context** (per-thread context pressure, APPS-90) +- `GET /v1/threads/{id}/context` — `input_tokens` (the conservative live + estimate the visible meter uses), `billed_input_tokens` (last + provider-counted prompt size when one exists), `window_tokens`, + `output_cap_tokens`, `input_budget_ceiling`, `available_input_tokens`, + `compaction_trigger_tokens`, `usage_percent` and `pressure`. Served by the + live engine via `Op::GetContextBudget`. Every numeric field is nullable — + a route that cannot express a bounded window reports `null` rather than an + invented number — and `live: false` marks responses where the engine + could not be loaded and only the store-recorded route's static window + resolved. + +**Git** (workspace repository operations, APPS-106) +- `GET /v1/git` — status detail: `git_repo`, `branch`, `head`, + `ahead`/`behind`, counts, per-file porcelain `files[]` + (`{path, index, worktree, staged, status, old_path?}`), `branches`, + `remotes` +- `GET /v1/changes` — the same porcelain `files[]` projection minus repo + chrome (branches/remotes): one authority, so the change list can never + disagree with the status read +- `GET /v1/diff?path=` — one file's unified `diff` against `base` (`HEAD`, + or the empty tree on an unborn branch — which reads staged adds as new + files). Covers staged+unstaged in one patch; `truncated` reports the + 512 KiB cap. An untracked file answers `untracked: true` with an empty + diff — the client reads the file itself rather than mistaking it for + unchanged +- `GET /v1/workspace/diff?limit=` — whole-tree patch (default 256 KiB, + max 4 MiB) plus a complete `--numstat` `files[]` inventory + (`{path, added, deleted}`) so every changed row renders even when the + patch is truncated +- `GET /v1/git/graph?limit=` — bounded commit rows (`id`, `short`, + `parents`, `author`, `timestamp`, `refs`, `subject`); an unborn branch is + an empty graph, not an error +- `POST /v1/git/stage` `{ "paths": [...] }` or `{ "all": true }`; + `POST /v1/git/unstage` same; `POST /v1/git/discard` `{ "paths": [...] }` + (tracked paths only — no `all`, an untracked path fails closed); + `POST /v1/git/commit` `{ "message", "all"? }`; `POST /v1/git/push` + `{ "remote"?, "set_upstream"? }`; `POST /v1/git/branch` + `{ "name", "create"? }` + +Reads run through the hardened review command (filters, fsmonitor, hooks, +lazy fetches and replace-objects neutralized); writes run through the +non-interactive command path (`GIT_TERMINAL_PROMPT=0`, BatchMode ssh) so a +credential or host-key prompt can never hang a request. Path lists are +workspace-relative under the same confinement as the file routes (traversal +→ 400, `.git` → 403), passed after `--` with literal pathspecs. Mutations +answer `{ok, output, status}` with the refreshed status, so a client +re-reads nothing after an operation. A workspace that is not a repository +answers `404`. + +**Diagnostics** (read-only logs, crashes, process — APPS-103) +- `GET /v1/logs` → `{sources: [{dir, files: [{name, size, modified}]}]}` — + the runtime's log directory plus `audit.log[.1]` from the codewhale home, + newest first, capped +- `GET /v1/logs/{name}?offset=&limit=&tail=` → + `{name, size, modified, offset, bytes, truncated, encoding, content}` — + one bounded window; `tail` reads from the end and is mutually exclusive + with `offset`; `truncated` means bytes remain after the returned window + (a tail read at EOF is `false`), `encoding` is `utf-8` or `base64` +- `GET /v1/crashes`, `GET /v1/crashes/{name}` — the same list/read contract + over the crash-dump directories (`~/.codewhale/crashes`, legacy + `~/.deepseek/crashes` merged) +- `GET /v1/process` → `{pid, version, commit, started_at, uptime_seconds, + executable, rss_bytes}` — `rss_bytes` only where the platform reports it + (Linux `/proc`); absent rather than fabricated elsewhere + +These routes package what already exists on disk for a client-side export; +there is no telemetry upload route and no second log store. Names are +basename-validated (no separators, no `..`), listings are capped, reads are +bounded windows, and symlinks are never followed — a client bundles the +files itself. + +**Targets and remote posture** (APPS-50) +- `GET /v1/targets` → `{targets: [self], remote: {supported: true, + attach: "client", probe: "POST /v1/remote/connect"}, ssh: {…}, + cloud: {…}}` — this runtime's own record as the attachable target plus + per-surface ownership; the runtime keeps no persistent target registry, + so `POST /v1/targets` and `POST /v1/targets/switch` answer + `501 Not Implemented` — target selection is client-owned and a switch + must never move a running task server-side +- `GET /v1/remote` → `{bind_host, port, loopback_only, reachable_from_lan, + auth_required, mobile, tls}` — this listener's reachability posture. + `tls` is always `false`: the API has no TLS terminator, so non-loopback + reachability assumes a verified overlay (VPN/mesh), never plain LAN trust +- `POST /v1/remote/connect` `{ "endpoint": "http://host:port" }` — probes a + candidate remote's unauthenticated `GET /v1/runtime/info` (origin only; + any pasted path is discarded). Answers `{ok, remote: {endpoint, + runtime_api_version, codewhale_version, auth_required, …}, attach: + "client"}` on success, and `{ok: false, reason: "unreachable" | + "not a Codewhale runtime" | …}` as data on failure. URLs carrying + credentials are refused with 400 — the remote's token is configured + client-side, and a connect route that forwarded one would be an + exfiltration primitive +- `GET /v1/ssh`, `GET /v1/cloud` → `{supported: false, owner: + "codewhale-control-plane", reason}`; `POST /v1/ssh/connect` and + `POST /v1/cloud/attach` → `501`: SSH workspace provisioning and hosted + cloud computers belong to the Apps control plane (ASCII Box for Managed + Computer), not to a second authority inside Core + +A remote Codewhale is a `serve --http` runtime with a token — that is the +whole attach model. These routes describe and probe it; they never execute +a remote request on the local machine. + +**LSP** (workspace language intelligence, APPS-93) +- `GET /v1/lsp` — capability: `enabled`, supported `languages` with their + server commands, `custom_languages`, operations, poll and diagnostic caps +- `GET /v1/diagnostics?path=` — file diagnostics +- `GET /v1/definition?path=&line=&character=` (1-based) +- `GET /v1/references?path=&line=&character=` (1-based) +- `GET /v1/symbols?path=&query=` — empty query returns document symbols + +One lazily-built workspace-level `LspManager` serves these; engine threads +keep their own per-thread managers for the post-edit hook, and a server that +never serves an LSP route never spawns a language server. `path` is +workspace-relative under the same confinement as the file routes. Normal +absence is data: no language server, a disabled `[lsp]` config, or a timeout +answers `200` with `ok: false` and a machine-readable `reason` +(`no_server`, `lsp_disabled`, `lsp_error`); malformed input is a 400 and a +missing file a 404. + +**Voice** (host dictation, APPS-98) +- `GET /v1/voice` — capability: `available`, detected `recorder` command, + resolved `asr` `{kind, model}`, `modes`, `send_phrases`, + `max_record_seconds` +- `POST /v1/voice/dictate` — record then transcribe → `{ ok, text }` +- `POST /v1/voice/send` — same capture with the "send it" / 发送/發送 + suffix contract: `send: true` tells the client to submit (empty `text` + with `send: true` means submit the client's current draft) +- `POST /v1/voice/control` `{ "composer": "draft text" }` — assisted + dictation that shows the model the composer text; `assisted: false` in the + response means a free ASR backend (local whisper/Groq) handled the audio + and the composer context was never seen + +The runtime owns the host microphone and the ASR dispatch — the same +implementation the TUI's `/voice` commands run, headless. Recording is one +blocking capture per host (requests serialize; the loser gets +`ok:false`/`no_speech`, not a fought-over device). Provider ASR resolves its +key lazily so local-whisper and Groq paths work without provider auth. +Failure is data: `no_recorder`, `no_speech`, `no_provider_auth`, +`transcription_failed`. `CODEWHALE_DISABLE_VOICE=1` is an operator +kill-switch — a headless `serve --http` host reports `available: false` and +every dictate call fails closed. + ## Provider and model selection These three routes are how a GUI renders a model picker whose contents are true @@ -1311,6 +1507,36 @@ alongside the selected model. Omit `model_provider_id` when it is null: This creates one thread on the exact named custom route without changing the Runtime's provider or model defaults. +### `PUT /v1/providers/{id}/key` — write-only credential + +```json +// request +{ "key": "sk-…" } + +// response +{ "provider": "openai-codex", "stored": true, "backend": "keychain", + "credentialState": "configured", "configPath": "/…/config.toml" } +``` + +Stores a provider API key through the same transactional write as +`codewhale auth set --provider --api-key-stdin`: the secret store under +the provider write lock, plus the `[providers.] auth_mode` metadata +marker persisted to the config document and mirrored into the live runtime +config so `GET /v1/providers` reports the new state immediately. `backend` +names which secret backend holds the key and `configPath` which config +document carries the marker (the user-global file when the ambient config +is workspace-scoped). + +The key is never returned — there is no read route for credential material, +and neither the key nor its length appears in the response, errors, or +logs; the response carries only the readiness projection +(`credentialState`). An unknown provider id, the `deepseek-cn` legacy +alias, an empty key, a key over 4 KiB, or one containing control characters +is `400`. `credentialState: "local"` after a successful write is honest +output for a keyless local route: the key is stored, but the route +classifies as not needing one. Deleting a key remains a CLI/operator +action — there is deliberately no `DELETE` here. + ### `POST /v1/providers/{id}/switch` ```json From 0285df1ee8cd1a51fdb97a31306f2181d6e16e81 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 15 Sep 2026 12:14:07 -0700 Subject: [PATCH 3/5] test(runtime-api): pin the git fixture's line endings so Windows can pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Test (windows-latest)` is a required check and this was the only test failing it: 15280 run, 1 failed, green on ubuntu and macOS. assertion `left == right` failed left: "v1\r\n" right: "v1\n" `git_routes_drive_a_real_workspace_repo` writes `tracked.txt` as `v1\n`, drives `POST /v1/git/discard`, then reads the file back. Windows git defaults to `core.autocrlf=true`, so the checkout that discard performs rewrites LF to CRLF and the file comes back `v1\r\n`. That is git behaving as configured, not the route misbehaving, so the fixture is what needed pinning. The repo already sets `user.email` and `user.name` for determinism; `core.autocrlf=false` belongs in the same place. The assertion is unchanged and not weakened — the test still requires the exact bytes back. Checks: `./scripts/dev-test.sh tui git_routes_drive_a_real_workspace_repo` — 1 passed on macOS, where it passed before too. The Windows half rests on git's documented `core.autocrlf` behaviour and on CI, since this host cannot build for `x86_64-pc-windows-msvc`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NWzjx9Q7Mw2G7K8rpiJy9p --- crates/tui/src/runtime_api/tests.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index c0ccea864d..3cca46d945 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -16360,6 +16360,11 @@ async fn git_routes_drive_a_real_workspace_repo() -> Result<()> { git(&["init", "-b", "main"])?; git(&["config", "user.email", "runtime-api@example.test"])?; git(&["config", "user.name", "Runtime API Test"])?; + // Windows git defaults to core.autocrlf=true, so `git checkout` rewrites + // LF to CRLF on restore and the discard assertion below reads back + // "v1\r\n" instead of "v1\n". Pin the fixture repo's line endings so the + // test measures the route rather than the host's git config. + git(&["config", "core.autocrlf", "false"])?; fs::write(workspace.join("tracked.txt"), "v1\n")?; git(&["add", "tracked.txt"])?; git(&["commit", "-m", "initial"])?; From 5dd4966f281bcfa37b6fc61ad6a2b57f02c93a98 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 15 Sep 2026 14:27:48 -0700 Subject: [PATCH 4/5] feat(runtime-api): clear a provider credential, and say who owns one (#6179) The credential route could set a key but never clear one, and served no way to tell "you have no key" apart from "your key is owned elsewhere and this control cannot change it". Both halves of that gap are what kept first-run provider setup in the desktop dependent on the TUI. DELETE /v1/providers/{id}/key clears through a shared owner rather than a second implementation: crates/config/src/credentials.rs gains clear_provider_api_key, and `codewhale auth clear` now calls it, which deletes its inline body and its clear_provider_api_key_from_keyring helper. That move fixes a real defect on the CLI path: the secret-store delete was `let _ = secrets.delete(...)`, so `auth clear` printed success while the key could still be sitting in the keyring. The shared owner returns the backend error instead, and both callers report it. Config semantics are unchanged - only xAI clears auth_mode/consent/OAuth generation alongside the key, exactly as before, because every other route is still an API-key route that simply has no key now. GET /v1/providers gains credentialSource, credentialWritable and a reason. The classification is structural: declared auth mode, consent state, and the *kind* of any configured api_key value. It never resolves a secret, an environment value or an auth command, and it reports a class - never a value, a path, or an environment variable name - so ProviderEntry's standing promise to carry no credential, endpoint, path or consent-source metadata still holds. Its doc comment now says why the field is compatible with that promise rather than leaving the two to contradict each other. Both verbs refuse a credential Codewhale does not own with 409 and that same reason. The case worth naming: a literal key in a config file still wins at request time, so writing the secret store would have reported a success the user could never observe - the exact "must not appear successfully overwritten" the issue asks for. Tests: four unit tests pin the classifier, including that the __KEYRING__ sentinel is routing metadata and not mistaken for a file-owned literal, and that a refusal reason can never carry the value it refused. One integration test round-trips set -> catalog metadata -> clear -> readback through the live config mirror, asserts the clear receipt echoes no key bytes, and asserts a repeated clear is not an error, because a client retrying a revoke must not be told something went wrong. runtime_api::secrets::tests 4 passed; 0 failed runtime_api::tests::provider_key* 2 passed; 0 failed codewhale-cli --lib 390 passed; 0 failed cargo fmt --all -- --check clean cargo clippy --workspace --all-targets --all-features --locked (CI's allow list) clean Closes #6179 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AJENKJ2smviQW4FVGzUTk9 --- CHANGELOG.md | 9 + crates/cli/src/lib.rs | 27 +-- crates/config/src/credentials.rs | 65 +++++++ crates/tui/CHANGELOG.md | 9 + crates/tui/src/runtime_api.rs | 30 ++- crates/tui/src/runtime_api/secrets.rs | 268 ++++++++++++++++++++++++-- crates/tui/src/runtime_api/tests.rs | 130 +++++++++++++ docs/RUNTIME_API.md | 43 ++++- web/lib/changelog.generated.ts | 3 +- 9 files changed, 550 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb30958c4..10f2e5983d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Native clients can finish provider setup without dropping to the CLI: + `DELETE /v1/providers/{id}/key` clears a Codewhale-owned credential through + the same shared owner as `codewhale auth clear`, and `GET /v1/providers` + now carries `credentialSource` / `credentialWritable` (plus a reason) so a + client disables its control with a truthful explanation instead of letting + a write fail late. A credential Codewhale does not own — a literal key in a + config file, or an active external consent — refuses both verbs with `409` + rather than appearing to succeed against a source that still wins at + request time (#6179). - 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 diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index f03de8bb56..04e65d7fb5 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -2655,20 +2655,17 @@ fn clear_auth_provider( if provider == ProviderKind::Antigravity { return clear_legacy_antigravity_config(store, secrets); } - let slot = provider_slot(provider); - let original_config = store.config.clone(); - clear_provider_api_key_from_config(store, provider); - if provider == ProviderKind::Xai { - let xai = store.config.providers.for_provider_mut(provider); - xai.oauth_credential_generation = None; - xai.auth_mode = None; - xai.external_credentials = None; - } - if let Err(error) = store.save() { - store.config = original_config; - return Err(error); + let outcome = codewhale_config::credentials::clear_provider_api_key(store, secrets, provider)?; + let slot = outcome.slot; + // The secret-store leg used to fail silently here, which meant `auth clear` + // could print success while the key was still in the keyring. Say so + // instead; the config no longer advertises a key the backend may hold. + if let Some(error) = &outcome.secret_store_error { + println!( + "cleared API key for {slot} from config, but the secret store refused the delete: {error}" + ); + return Ok(()); } - clear_provider_api_key_from_keyring(secrets, provider); if provider == ProviderKind::Xai { println!("cleared xAI credentials from config, secret store, and owned OAuth storage"); } else { @@ -2862,10 +2859,6 @@ fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool { provider_keyring_api_key(secrets, provider).is_some() } -fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) { - let _ = secrets.delete(provider_slot(provider)); -} - /// Delete the keyring credential of every provider that has one stored. /// /// Returns a human-readable entry per slot whose deletion failed, so the diff --git a/crates/config/src/credentials.rs b/crates/config/src/credentials.rs index 9e0e40dcad..176de75156 100644 --- a/crates/config/src/credentials.rs +++ b/crates/config/src/credentials.rs @@ -147,3 +147,68 @@ fn set_provider_api_key_unlocked( .context("failed to scrub plaintext API keys from config backup")?; Ok(secret_store_saved) } + +/// What a credential clear actually accomplished. +/// +/// The secret-store leg can fail after the config leg has already been +/// persisted. Reporting that separately is the point: a caller that prints +/// "cleared" while the key is still sitting in the keyring has lied about a +/// security-relevant action. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClearOutcome { + /// The secret-store slot the clear targeted. + pub slot: &'static str, + /// `None` when the secret store accepted the delete; otherwise the backend + /// error, already stringified so it carries no credential material. + pub secret_store_error: Option, +} + +impl ClearOutcome { + /// True only when both the config and the secret store were cleared. + #[must_use] + pub fn is_complete(&self) -> bool { + self.secret_store_error.is_none() + } +} + +/// Remove a provider credential from config and the durable secret store. +/// +/// Shared by `codewhale auth clear` and the runtime API's credential route so +/// both get the same ordering and the same rollback: the config document is +/// snapshotted and restored if its save fails, and the secret store is only +/// touched once the config write has landed. A secret-store failure is +/// returned rather than swallowed, because the config no longer advertises a +/// key that the backend may still hold. +/// +/// This deliberately does not clear external-consent or environment-sourced +/// credentials: Codewhale does not own those, and a caller must refuse the +/// request instead of implying it revoked something it cannot reach. +pub fn clear_provider_api_key( + store: &mut ConfigStore, + secrets: &Secrets, + provider: ProviderKind, +) -> Result { + let slot = provider_slot(provider); + let original_config = store.config.clone(); + clear_provider_api_key_from_config(store, provider); + // Only xAI carries OAuth generation and consent state alongside the key, + // and `codewhale auth clear` has always cleared those three together. Every + // other provider keeps its `auth_mode` marker deliberately: the route is + // still an API-key route, it simply has no key now, which is exactly the + // `missing` credential state a client needs to see. + if provider == ProviderKind::Xai { + let xai = store.config.providers.for_provider_mut(provider); + xai.oauth_credential_generation = None; + xai.auth_mode = None; + xai.external_credentials = None; + } + if let Err(error) = store.save() { + store.config = original_config; + return Err(error); + } + let secret_store_error = secrets.delete(slot).err().map(|error| error.to_string()); + Ok(ClearOutcome { + slot, + secret_store_error, + }) +} diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 3c24796838..ac999ed5d1 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Native clients can finish provider setup without dropping to the CLI: + `DELETE /v1/providers/{id}/key` clears a Codewhale-owned credential through + the same shared owner as `codewhale auth clear`, and `GET /v1/providers` + now carries `credentialSource` / `credentialWritable` (plus a reason) so a + client disables its control with a truthful explanation instead of letting + a write fail late. A credential Codewhale does not own — a literal key in a + config file, or an active external consent — refuses both verbs with `409` + rather than appearing to succeed against a source that still wins at + request time (#6179). - 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 diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 3a032ba433..01e00b4b70 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -1404,9 +1404,11 @@ pub fn build_router(state: RuntimeApiState) -> Router { .route("/v1/providers/{id}/switch", post(switch_provider)) .route( "/v1/providers/{id}/key", - put(secrets::set_provider_key).layer(DefaultBodyLimit::max( - secrets::PROVIDER_KEY_BODY_LIMIT_BYTES, - )), + put(secrets::set_provider_key) + .delete(secrets::clear_provider_key) + .layer(DefaultBodyLimit::max( + secrets::PROVIDER_KEY_BODY_LIMIT_BYTES, + )), ) .route("/v1/config", get(get_config).post(set_config)) .route("/v1/config/reload", post(reload_config)) @@ -6596,6 +6598,24 @@ struct ProviderEntry { /// variable, consent-source, or token metadata. #[serde(rename = "credentialState")] credential_state: ProviderCredentialState, + /// Which *class* of source owns this route's credential (#6179). A class, + /// never a value, a path, or an environment variable name — the guarantee + /// above still holds. Clients need it to tell "you have no key" apart from + /// "your key is owned elsewhere and this control cannot change it". + #[serde(rename = "credentialSource")] + credential_source: secrets::ProviderCredentialSource, + /// Whether `PUT`/`DELETE /v1/providers/{id}/key` will act on this route. + /// False means the write would be refused, so the control should be + /// disabled rather than allowed to fail late. + #[serde(rename = "credentialWritable")] + credential_writable: bool, + /// Why a write is refused, as user-facing copy. Present only when + /// `credentialWritable` is false. + #[serde( + rename = "credentialWritableReason", + skip_serializing_if = "Option::is_none" + )] + credential_writable_reason: Option<&'static str>, } /// Stable, non-secret wire projection of provider readiness. @@ -7170,6 +7190,7 @@ async fn list_providers( &base_url, ) .is_empty(); + let writeability = secrets::credential_writeability(&config, api_provider); providers.push(ProviderEntry { id: api_provider.as_str().to_string(), model_provider_id: (api_provider == active_provider) @@ -7183,6 +7204,9 @@ async fn list_providers( api_provider, ) .into(), + credential_source: writeability.source, + credential_writable: writeability.writable, + credential_writable_reason: writeability.reason, }); } Ok(Json(ProvidersResponse { current, providers })) diff --git a/crates/tui/src/runtime_api/secrets.rs b/crates/tui/src/runtime_api/secrets.rs index ab196a31eb..3e1ab30a64 100644 --- a/crates/tui/src/runtime_api/secrets.rs +++ b/crates/tui/src/runtime_api/secrets.rs @@ -1,7 +1,7 @@ use axum::Json; use axum::extract::{Path, State}; use codewhale_config::ConfigStore; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::config::ApiProvider; @@ -38,16 +38,11 @@ pub(super) async fn set_provider_key( Path(id): Path, Json(request): Json, ) -> Result, ApiError> { - let provider = ApiProvider::parse(&id) - .ok_or_else(|| ApiError::bad_request(format!("Unknown provider id '{id}'")))?; - if provider == ApiProvider::DeepseekCN { - return Err(ApiError::bad_request( - "provider 'deepseek-cn' is a legacy alias; use 'deepseek' instead", - )); - } - let kind = provider - .kind() - .ok_or_else(|| ApiError::bad_request("provider has no credential slot"))?; + // Shared with the clear route: unknown id, legacy alias, no credential + // slot, and — the half #6179 was missing — a credential this route does + // not own, which must refuse before the write rather than appear to + // succeed against a source that still wins at request time. + let (provider, kind) = writable_provider(&state, &id)?; let key = request.key; let key = key.trim(); @@ -139,3 +134,254 @@ pub(super) async fn set_provider_key( "configPath": saved_config_path, }))) } + +/// Where the credential for a route comes from, as a *class* and never as a +/// value, a path, or an environment variable name. +/// +/// This exists so a client can disable its own credential control with a +/// truthful reason before submitting, instead of letting a write fail late or — +/// worse — appear to succeed against a source Codewhale does not own. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum ProviderCredentialSource { + /// Codewhale's own durable secret backend. The only writable source. + SecretStore, + /// A literal value sitting in a config document. + Config, + /// An external consent or auth-command source (OAuth, `auth_source`). + ExternalAuth, + /// The route takes no credential at all. + None, +} + +/// Whether this route's credential can be written through the runtime API, and +/// the reason when it cannot. The reason is user-facing copy. +pub(super) struct CredentialWriteability { + pub(super) source: ProviderCredentialSource, + pub(super) writable: bool, + pub(super) reason: Option<&'static str>, +} + +/// Classify a route's credential ownership without reading any credential. +/// +/// Deliberately structural: it consults declared auth mode, consent state and +/// the *kind* of any configured `api_key` value, and never resolves a secret, +/// an environment value, or an auth command. +pub(super) fn credential_writeability( + config: &crate::config::Config, + provider: ApiProvider, +) -> CredentialWriteability { + let auth_mode = config.auth_mode_for_provider(provider); + if codewhale_config::auth_mode_disables_api_key(auth_mode.as_deref()) { + return CredentialWriteability { + source: ProviderCredentialSource::None, + writable: false, + reason: Some("This route is configured to send no credential."), + }; + } + if provider.kind().is_none() { + return CredentialWriteability { + source: ProviderCredentialSource::None, + writable: false, + reason: Some("This route has no credential slot."), + }; + } + // An active external consent owns the credential. Overwriting the key slot + // would not change what the route sends, so a write here must refuse + // rather than report a success the user cannot observe. + if config + .external_credential_consent_status(provider) + .is_some_and(|status| status.route_state == "active") + { + return CredentialWriteability { + source: ProviderCredentialSource::ExternalAuth, + writable: false, + reason: Some( + "This route signs in through an external consent. Sign out of it before setting a key.", + ), + }; + } + // A literal key in a config document is a plaintext credential Codewhale + // did not put there. Writing the secret store would leave the literal in + // place and still winning, so refuse and name the file-owned source. + if let Some(entry) = config.provider_config_for(provider) + && let Some(existing) = entry.api_key.as_deref() + && codewhale_config::classify_config_api_key_value(existing) + == codewhale_config::ConfigApiKeyValueKind::Literal + { + return CredentialWriteability { + source: ProviderCredentialSource::Config, + writable: false, + reason: Some( + "This route's key is set literally in a config file. Remove it there before managing it here.", + ), + }; + } + CredentialWriteability { + source: ProviderCredentialSource::SecretStore, + writable: true, + reason: None, + } +} + +/// Shared provider validation for both credential routes. +fn writable_provider( + state: &RuntimeApiState, + id: &str, +) -> Result<(ApiProvider, codewhale_config::ProviderKind), ApiError> { + let provider = ApiProvider::parse(id) + .ok_or_else(|| ApiError::bad_request(format!("Unknown provider id '{id}'")))?; + if provider == ApiProvider::DeepseekCN { + return Err(ApiError::bad_request( + "provider 'deepseek-cn' is a legacy alias; use 'deepseek' instead", + )); + } + let kind = provider + .kind() + .ok_or_else(|| ApiError::bad_request("provider has no credential slot"))?; + let writeability = credential_writeability(&state.config.read(), provider); + if !writeability.writable { + return Err(ApiError::conflict( + writeability + .reason + .unwrap_or("This route's credential is not managed by Codewhale."), + )); + } + Ok((provider, kind)) +} + +/// `DELETE /v1/providers/{id}/key` — remove a Codewhale-owned credential. +/// +/// Refuses for exactly the sources `PUT` refuses for, and for the same reason: +/// a route that reports "cleared" for a credential it cannot reach has lied +/// about a security action. The secret-store leg is reported separately, +/// because the config write lands first and the backend can still refuse. +pub(super) async fn clear_provider_key( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let (provider, kind) = writable_provider(&state, &id)?; + + let secrets = crate::config::credential_secret_store().ok_or_else(|| { + ApiError::internal("no credential store is available in this environment") + })?; + + let store_path = state.config_path.clone(); + let outcome = tokio::task::spawn_blocking(move || { + let mut store = ConfigStore::load(store_path) + .map_err(|error| ApiError::internal(format!("config store unavailable: {error}")))?; + let mut credential_store = codewhale_config::credentials::credential_metadata_store(&store) + .map_err(|error| ApiError::internal(format!("credential store: {error}")))?; + let target = credential_store.as_mut().unwrap_or(&mut store); + let slot = codewhale_config::credentials::provider_slot(kind); + crate::credentials::store::with_provider_write_lock(slot, || { + codewhale_config::credentials::clear_provider_api_key(target, &secrets, kind) + }) + .map_err(|error| { + // Clear errors name slots and paths only; no key material can + // reach this message because none was read. + ApiError::internal(format!("credential clear failed: {error}")) + }) + }) + .await + .map_err(|_| ApiError::internal("credential clear task failed"))??; + + // Mirror the cleared markers into the live config for the same reason the + // write path mirrors them: the durable clear may have landed on the + // user-global document while this server's ambient config is + // workspace-scoped, and `credential_state` would otherwise keep reporting + // the provider as configured until the next process start. + { + let mut config = state.config.write(); + let entry = config.provider_config_for_mut(provider); + entry.api_key = None; + if provider == ApiProvider::Xai { + entry.auth_mode = None; + entry.external_credentials = None; + entry.oauth_credential_generation = None; + } + if provider == ApiProvider::Deepseek { + config.api_key = None; + } + } + + let credential_state: ProviderCredentialState = + crate::provider_readiness::credential_state_for_provider(&state.config.read(), provider) + .into(); + + if let Some(error) = outcome.secret_store_error { + return Err(ApiError::internal(format!( + "the config entry was cleared, but the secret store refused to delete {}: {error}", + outcome.slot + ))); + } + + Ok(Json(json!({ + "provider": provider.as_str(), + "cleared": true, + "credentialState": credential_state, + }))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + + /// A route Codewhale owns is writable, and says its source is the store it + /// would actually write. + #[test] + fn a_codewhale_owned_route_is_writable_through_the_secret_store() { + let config = Config::default(); + let writeability = credential_writeability(&config, ApiProvider::Openai); + assert_eq!(writeability.source, ProviderCredentialSource::SecretStore); + assert!(writeability.writable); + assert!(writeability.reason.is_none()); + } + + /// The case #6179 exists for: a literal key in a config file still wins at + /// request time, so a write here must refuse rather than report a success + /// the user cannot observe. The reason names the file-owned source. + #[test] + fn a_literal_config_key_refuses_the_write_and_says_why() { + let mut config = Config::default(); + config.provider_config_for_mut(ApiProvider::Openai).api_key = + Some("sk-literal-in-a-config-file".to_string()); + + let writeability = credential_writeability(&config, ApiProvider::Openai); + assert_eq!(writeability.source, ProviderCredentialSource::Config); + assert!(!writeability.writable); + let reason = writeability.reason.expect("a refusal must name its reason"); + assert!(reason.contains("config file"), "{reason}"); + // The reason is copy, not a credential: it can never carry the value. + assert!(!reason.contains("sk-literal-in-a-config-file")); + } + + /// The secret-store sentinel is routing metadata, not a credential, so it + /// must not be mistaken for a file-owned literal and refused. + #[test] + fn the_secret_store_sentinel_is_not_a_file_owned_key() { + let mut config = Config::default(); + config.provider_config_for_mut(ApiProvider::Openai).api_key = + Some(codewhale_config::API_KEYRING_SENTINEL.to_string()); + + let writeability = credential_writeability(&config, ApiProvider::Openai); + assert_eq!(writeability.source, ProviderCredentialSource::SecretStore); + assert!(writeability.writable); + } + + /// A route declared to send no credential has nothing to manage, and says + /// so instead of offering a control that would do nothing. + #[test] + fn a_no_auth_route_reports_no_credential_source() { + let mut config = Config::default(); + config + .provider_config_for_mut(ApiProvider::Openai) + .auth_mode = Some("none".to_string()); + + let writeability = credential_writeability(&config, ApiProvider::Openai); + assert_eq!(writeability.source, ProviderCredentialSource::None); + assert!(!writeability.writable); + assert!(writeability.reason.is_some()); + } +} diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 3cca46d945..9577e7a442 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -16609,3 +16609,133 @@ async fn git_routes_drive_a_real_workspace_repo() -> Result<()> { handle.abort(); Ok(()) } + +/// The half of #6179 the credential route was missing: clear, the metadata a +/// client needs to decide whether to offer the control at all, and the refusal +/// for a credential Codewhale does not own. +#[tokio::test] +async fn provider_key_clear_round_trips_and_reports_writability() -> Result<()> { + let _env_lock = crate::test_support::lock_test_env(); + let tmp = tempfile::tempdir()?; + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("cwhome")); + let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file"); + fs::create_dir_all(tmp.path().join("cwhome"))?; + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(&workspace)?; + + let (addr, _runtime_threads, handle) = spawn_test_server_with_root_token_mobile_workspace( + tmp.path().join("runtime"), + tmp.path().join("sessions"), + Some("clear-token".to_string()), + false, + workspace.clone(), + ) + .await? + .context("secrets test requires a loopback listener")?; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}"); + + // Clearing is a credential operation: it authenticates like the write. + let status = client + .delete(format!("{base}/v1/providers/openai/key")) + .send() + .await? + .status(); + assert_eq!(status, StatusCode::UNAUTHORIZED); + + // Same id validation as the write, so a client cannot discover providers + // through one verb that the other rejects. + for (id, want) in [ + ("not-a-provider", StatusCode::BAD_REQUEST), + ("deepseek-cn", StatusCode::BAD_REQUEST), + ] { + let status = client + .delete(format!("{base}/v1/providers/{id}/key")) + .bearer_auth("clear-token") + .send() + .await? + .status(); + assert_eq!(status, want, "{id}"); + } + + client + .put(format!("{base}/v1/providers/openai/key")) + .bearer_auth("clear-token") + .json(&json!({ "key": "sk-key-that-will-be-cleared" })) + .send() + .await? + .error_for_status()?; + + // Before the clear the catalog says the control is live and names the + // source it would write — this is what lets a client enable the control. + let providers: Value = client + .get(format!("{base}/v1/providers")) + .bearer_auth("clear-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let openai = providers["providers"] + .as_array() + .context("providers array")? + .iter() + .find(|entry| entry["id"] == "openai") + .context("openai entry")? + .clone(); + assert_eq!(openai["credentialState"], "configured"); + assert_eq!(openai["credentialSource"], "secret_store"); + assert_eq!(openai["credentialWritable"], true); + // A writable route carries no refusal reason to render. + assert!(openai["credentialWritableReason"].is_null()); + + let receipt: Value = client + .delete(format!("{base}/v1/providers/openai/key")) + .bearer_auth("clear-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(receipt["provider"], "openai"); + assert_eq!(receipt["cleared"], true); + // A clear receipt echoes no more than a write receipt does. + assert!(!receipt.to_string().contains("sk-key-that-will-be-cleared")); + assert_eq!(receipt["credentialState"], "missing"); + + // And the catalog agrees on the next read, through the live config mirror + // rather than only after a restart. + let providers: Value = client + .get(format!("{base}/v1/providers")) + .bearer_auth("clear-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + let openai = providers["providers"] + .as_array() + .context("providers array")? + .iter() + .find(|entry| entry["id"] == "openai") + .context("openai entry")? + .clone(); + assert_eq!(openai["credentialState"], "missing"); + // Still writable: the key is gone, the control is not. + assert_eq!(openai["credentialWritable"], true); + + // Clearing an already-clear route is not an error — a client retrying a + // revoke must not be told something went wrong. + let receipt: Value = client + .delete(format!("{base}/v1/providers/openai/key")) + .bearer_auth("clear-token") + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(receipt["cleared"], true); + + handle.abort(); + Ok(()) +} diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 3f744a9cf6..2e82bfe5e8 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -1534,8 +1534,47 @@ logs; the response carries only the readiness projection alias, an empty key, a key over 4 KiB, or one containing control characters is `400`. `credentialState: "local"` after a successful write is honest output for a keyless local route: the key is stored, but the route -classifies as not needing one. Deleting a key remains a CLI/operator -action — there is deliberately no `DELETE` here. +classifies as not needing one. + +### `DELETE /v1/providers/{id}/key` — clear a Codewhale-owned credential + +```json +// response +{ "provider": "openai", "cleared": true, "credentialState": "missing" } +``` + +Clears the credential through the same shared owner as +`codewhale auth clear`: the config document is snapshotted and restored if +its save fails, the secret store is only touched once that save has landed, +and the cleared markers are mirrored into the live runtime config so +`GET /v1/providers` reports `missing` on the next read rather than after a +restart. + +Clearing an already-clear route returns `cleared: true` — a client retrying +a revoke must not be told something went wrong. If the config entry is +cleared but the secret backend refuses the delete, the route answers `500` +and names the slot: reporting success while the key is still in the keyring +would be a lie about a security action. + +### Credential ownership: `credentialSource` and `credentialWritable` + +Both credential verbs refuse a route whose credential Codewhale does not +own, and `GET /v1/providers` carries the same classification so a client can +disable its control *before* submitting instead of failing late: + +| `credentialSource` | `credentialWritable` | Meaning | +| --- | --- | --- | +| `secret_store` | `true` | Codewhale's own durable backend. The only writable source. | +| `config` | `false` | A literal key in a config file, which still wins at request time. | +| `external_auth` | `false` | An active external consent (OAuth) owns the credential. | +| `none` | `false` | The route sends no credential, or has no credential slot. | + +When `credentialWritable` is `false`, `credentialWritableReason` carries +user-facing copy naming the owner, and both `PUT` and `DELETE` answer `409` +with that same reason. The classification is structural: it reads declared +auth mode, consent state and the *kind* of any configured `api_key` value, +and never resolves a secret, an environment value, or an auth command. It is +a class and never a value, a path, or an environment variable name. ### `POST /v1/providers/{id}/switch` diff --git a/web/lib/changelog.generated.ts b/web/lib/changelog.generated.ts index a556a55521..b9f94bc0a2 100644 --- a/web/lib/changelog.generated.ts +++ b/web/lib/changelog.generated.ts @@ -30,11 +30,12 @@ export const CHANGELOG: ChangelogRelease[] = [ { "heading": "Added", "items": [ + "Native clients can finish provider setup without dropping to the CLI: DELETE /v1/providers/{id}/key clears a Codewhale-owned credential through the same shared owner as codewhale auth clear, and GET /v1/providers now carries credentialSource / credentialWritable (plus a reason) so a client disables its control with a truthful explanation instead of letting a write fail late. A credential Codewhale does not own — a literal key in a config file, or an active external consent —…", "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).", "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": 3 + "itemCount": 4 }, { "heading": "Changed", From 848af76af013de27688ca2a110380e69fee6bfb0 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 15 Sep 2026 14:37:42 -0700 Subject: [PATCH 5/5] fix(runtime-api): stop parking Tokio workers on filesystem syscalls (#6149) `main` is red right now, and this is why: CI's blocking-calls gate is advisory on pull requests and blocking on pushes to main, so `crates/tui/src/runtime_api/workspace.rs: std_fs sites 1 > budget 0` failed the Lint job at 9cdfa92bc. The site is real, not a lint artifact - `workspace_instructions` called `std::fs::canonicalize` in its async body, after awaiting the spawn_blocking it already had. It now rides that same closure; no new blocking scope was needed. This branch had added four more of the same class, which the gate would have turned into a worse red on merge: - `list_logs` walked directories and stat'd every file inline; `list_crashes` did the same through `list_files`; `process_info` read /proc and resolved `current_exe` inline, on a route polled for live health. Each now has a sync half called from `spawn_blocking`. - Four LSP handlers called `resolve_workspace_file` straight from their async bodies, and that helper canonicalizes and stats every path component. It is now async and does the work on the blocking pool. **The gate could not have caught that last one**, and the next person should know why: the scanner is per-file and lexical, so the `std::fs` calls it counts live in `workspace.rs` while the handler calling through to them lives in `lsp.rs`. A helper is invisible at its call site. Recorded in the doc comment on `resolve_workspace_file`. The same lexical rule cuts the other way: a sync `fn` holding `std::fs` is counted even when every caller is already inside `spawn_blocking`. The eight remaining sites (`workspace.rs` 3, `diagnostics.rs` 5) are all that case - `confined_directory`, `list_workspace_directory`, `precheck_file_target`, `list_files`, `read_named_window`, `rss_bytes` - and every caller of each was read before budgeting rather than trusting the count. That is the "--update if the site can only run on synchronous code" path the script's own message names. Also updates the provider-catalog non-secret projection guard, which correctly failed on #6179's new fields. It is an allow-list, so widening it is an argument, not a formality: the new fields are admissible because `credentialSource` is one of four fixed enum spellings that can never interpolate a value, a path or an environment variable name, and `credentialWritable` is a boolean. The test now asserts that closed vocabulary rather than only the key set, so the field cannot later become a place a value hides. runtime_api:: 258 passed; 0 failed (--test-threads=2) cargo fmt --all -- --check clean cargo clippy --workspace --all-targets --all-features --locked (CI's allow list) clean check-blocking-calls-budget.py 624 sites across 180 files, within budget check-dead-code-budget.py PASS, 254 attributes, exactly at budget Note on the two `compatibility_stream_*` tests that failed an earlier run on this head: they pass here at --test-threads=2 and pass isolated. They use a 2-second subscription deadline that is only scaled when `CI` is set, and the earlier run was at load average 17 on 14 cores. Load flake, not regression. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AJENKJ2smviQW4FVGzUTk9 --- crates/tui/src/runtime_api/diagnostics.rs | 32 ++++++++++++++-- crates/tui/src/runtime_api/lsp.rs | 29 +++++++++++---- crates/tui/src/runtime_api/tests.rs | 37 ++++++++++++++----- crates/tui/src/runtime_api/workspace.rs | 45 ++++++++++++----------- scripts/check-blocking-calls-budget.json | 6 +++ 5 files changed, 107 insertions(+), 42 deletions(-) diff --git a/crates/tui/src/runtime_api/diagnostics.rs b/crates/tui/src/runtime_api/diagnostics.rs index 4252deca6c..f1e49539b4 100644 --- a/crates/tui/src/runtime_api/diagnostics.rs +++ b/crates/tui/src/runtime_api/diagnostics.rs @@ -212,6 +212,16 @@ fn crash_dirs() -> Vec { // --------------------------------------------------------------------------- pub(super) async fn list_logs(State(_state): State) -> Json { + // Directory walks and per-file stats are blocking syscalls, and a + // diagnostics read must never park a Tokio worker — least of all while + // the thing being diagnosed is the runtime's responsiveness (#6149). + let sources = tokio::task::spawn_blocking(list_log_sources) + .await + .unwrap_or_default(); + Json(json!({ "sources": sources })) +} + +fn list_log_sources() -> Vec { let mut sources = Vec::new(); for (dir, singles) in log_sources() { let mut entries = list_files(&dir, LOG_LIST_CAP); @@ -239,7 +249,7 @@ pub(super) async fn list_logs(State(_state): State) -> Json) -> Json { + // Same reason as `list_logs`: `list_files` stats every entry. + let sources = tokio::task::spawn_blocking(list_crash_sources) + .await + .unwrap_or_default(); + Json(json!({ "sources": sources })) +} + +fn list_crash_sources() -> Vec { let mut sources = Vec::new(); for dir in crash_dirs() { sources.push(json!({ @@ -273,7 +291,7 @@ pub(super) async fn list_crashes(State(_state): State) -> Json< "files": list_files(&dir, CRASH_LIST_CAP), })); } - Json(json!({ "sources": sources })) + sources } pub(super) async fn read_crash( @@ -317,6 +335,12 @@ fn rss_bytes() -> Option { } pub(super) async fn process_info(State(_state): State) -> Json { + // `rss_bytes` reads /proc on Linux and `current_exe` hits the filesystem; + // both are blocking, and this route is polled for live health. + let (executable, rss) = + tokio::task::spawn_blocking(|| (std::env::current_exe().ok(), rss_bytes())) + .await + .unwrap_or((None, None)); let (started_at, uptime_secs) = match SERVER_STARTED.get() { Some((system, instant)) => (Some(rfc3339(*system)), Some(instant.elapsed().as_secs())), None => (None, None), @@ -327,7 +351,7 @@ pub(super) async fn process_info(State(_state): State) -> Json< "commit": option_env!("CODEWHALE_BUILD_COMMIT").unwrap_or("unknown"), "started_at": started_at, "uptime_seconds": uptime_secs, - "executable": std::env::current_exe().ok(), - "rss_bytes": rss_bytes(), + "executable": executable, + "rss_bytes": rss, })) } diff --git a/crates/tui/src/runtime_api/lsp.rs b/crates/tui/src/runtime_api/lsp.rs index 6a8548de82..178425349c 100644 --- a/crates/tui/src/runtime_api/lsp.rs +++ b/crates/tui/src/runtime_api/lsp.rs @@ -71,11 +71,24 @@ fn lsp_manager(state: &RuntimeApiState) -> Result, ApiError> { /// Resolve a workspace-relative `path` to an absolute file inside the /// workspace, refusing traversal, `.git`, links, and missing files — the same /// confinement the file routes apply. -fn resolve_workspace_file(state: &RuntimeApiState, raw: &str) -> Result { +/// +/// Async because the confinement it applies is filesystem work: +/// `canonical_workspace` canonicalizes and `precheck_file_target` stats every +/// component. Both are blocking syscalls, so they ride `spawn_blocking` rather +/// than the caller's Tokio worker (#6149). The blocking-calls budget cannot +/// see this — the `std::fs` calls live in `workspace.rs`, so a handler calling +/// straight through to them is invisible to a per-file scanner. +async fn resolve_workspace_file(state: &RuntimeApiState, raw: &str) -> Result { let relative = relative_request_path(raw, false)?; - let root = canonical_workspace(&state.workspace)?; - precheck_file_target(&root, &relative)?.ok_or_else(|| ApiError::not_found("file not found"))?; - Ok(root.join(&relative)) + let workspace = state.workspace.clone(); + tokio::task::spawn_blocking(move || { + let root = canonical_workspace(&workspace)?; + precheck_file_target(&root, &relative)? + .ok_or_else(|| ApiError::not_found("file not found"))?; + Ok(root.join(&relative)) + }) + .await + .map_err(|_| ApiError::internal("workspace file resolution failed"))? } /// `intelligence` reports ordinary states (disabled, no server) as error @@ -183,7 +196,7 @@ pub(super) async fn lsp_diagnostics( State(state): State, Query(query): Query, ) -> Result, ApiError> { - let file = resolve_workspace_file(&state, &query.path)?; + let file = resolve_workspace_file(&state, &query.path).await?; run_intelligence(&state, "diagnostics", file, None, None, None).await } @@ -194,7 +207,7 @@ pub(super) async fn lsp_definition( let line = query .line .ok_or_else(|| ApiError::bad_request("definition requires line (1-based)"))?; - let file = resolve_workspace_file(&state, &query.path)?; + let file = resolve_workspace_file(&state, &query.path).await?; run_intelligence( &state, "definition", @@ -213,7 +226,7 @@ pub(super) async fn lsp_references( let line = query .line .ok_or_else(|| ApiError::bad_request("references requires line (1-based)"))?; - let file = resolve_workspace_file(&state, &query.path)?; + let file = resolve_workspace_file(&state, &query.path).await?; run_intelligence( &state, "references", @@ -229,6 +242,6 @@ pub(super) async fn lsp_symbols( State(state): State, Query(query): Query, ) -> Result, ApiError> { - let file = resolve_workspace_file(&state, &query.path)?; + let file = resolve_workspace_file(&state, &query.path).await?; run_intelligence(&state, "symbols", file, None, None, query.query).await } diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 9577e7a442..dffe045bf1 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -10139,8 +10139,19 @@ model = "local-model" ); assert_eq!(custom["credentialState"], expected_state); + // An allow-list, not a snapshot: a new field here has to be argued for + // in this test before it can reach the wire. `credentialSource` and + // `credentialWritable` (#6179) are admissible because they are a + // *class* and a boolean — the source is one of four fixed enum + // spellings and can never interpolate a value, a path, or an + // environment variable name. `credentialWritableReason` is + // `skip_serializing_if = Option::is_none`, so it is absent for every + // writable route and is asserted separately below when present. let allowed_fields: std::collections::BTreeSet<_> = [ + "credentialSource", "credentialState", + "credentialWritable", + "credentialWritableReason", "default_model", "display_name", "has_model_catalog", @@ -10149,20 +10160,28 @@ model = "local-model" ] .into_iter() .collect(); + const CREDENTIAL_SOURCES: [&str; 4] = ["secret_store", "config", "external_auth", "none"]; for entry in providers["providers"] .as_array() .context("providers array")? { - let actual_fields: std::collections::BTreeSet<_> = entry - .as_object() - .context("provider entry object")? - .keys() - .map(String::as_str) - .collect(); - assert_eq!( - actual_fields, allowed_fields, - "provider catalog must remain a non-secret projection" + let object = entry.as_object().context("provider entry object")?; + let actual_fields: std::collections::BTreeSet<_> = + object.keys().map(String::as_str).collect(); + assert!( + actual_fields.is_subset(&allowed_fields), + "provider catalog must remain a non-secret projection: {actual_fields:?}" + ); + // The source is a closed vocabulary. If it ever stops being one, + // it has become a place a value can hide. + let source = object["credentialSource"] + .as_str() + .context("credentialSource must be a string")?; + assert!( + CREDENTIAL_SOURCES.contains(&source), + "credentialSource must stay a fixed class, got {source:?}" ); + assert!(object["credentialWritable"].is_boolean()); } let serialized = serde_json::to_string(&providers)?; diff --git a/crates/tui/src/runtime_api/workspace.rs b/crates/tui/src/runtime_api/workspace.rs index 0d5db5bea1..38a88cf6fc 100644 --- a/crates/tui/src/runtime_api/workspace.rs +++ b/crates/tui/src/runtime_api/workspace.rs @@ -811,27 +811,30 @@ pub(super) async fn workspace_instructions( let home = crate::config::effective_home_dir(); let configured = state.config.read().instructions_paths(); - let (sources, generated_fallback, warnings) = tokio::task::spawn_blocking(move || { - let sources = crate::project_context::project_instruction_sources( - &workspace, - home.as_deref(), - &configured, - ); - // The real load pass supplies assembly-level warnings and tells us - // whether the ephemeral generated context is what the prompt - // carries. Cached — this is the same call the engine makes. - let ctx = crate::project_context::load_project_context_with_parents(&workspace); - let generated_fallback = ctx.instructions.is_some() && ctx.source_path.is_none(); - (sources, generated_fallback, ctx.warnings) - }) - .await - .map_err(|_| ApiError::internal("instruction source listing failed"))?; - - // `project_instruction_sources` canonicalizes the workspace (the - // `/var` → `/private/var` class of alias), so relative paths must be - // computed against the canonical spelling or every strip fails. - let workspace_root = - std::fs::canonicalize(&state.workspace).unwrap_or_else(|_| state.workspace.clone()); + let (sources, generated_fallback, warnings, workspace_root) = + tokio::task::spawn_blocking(move || { + let sources = crate::project_context::project_instruction_sources( + &workspace, + home.as_deref(), + &configured, + ); + // The real load pass supplies assembly-level warnings and tells us + // whether the ephemeral generated context is what the prompt + // carries. Cached — this is the same call the engine makes. + let ctx = crate::project_context::load_project_context_with_parents(&workspace); + let generated_fallback = ctx.instructions.is_some() && ctx.source_path.is_none(); + // `project_instruction_sources` canonicalizes the workspace (the + // `/var` → `/private/var` class of alias), so relative paths must + // be computed against the canonical spelling or every strip fails. + // It rides this closure rather than the async body because + // `canonicalize` is a blocking syscall, and one on a Tokio worker + // is one too many (#6149). + let workspace_root = + std::fs::canonicalize(&workspace).unwrap_or_else(|_| workspace.clone()); + (sources, generated_fallback, ctx.warnings, workspace_root) + }) + .await + .map_err(|_| ApiError::internal("instruction source listing failed"))?; Ok(Json(WorkspaceInstructionsResponse { workspace: workspace_root.clone(), sources: sources diff --git a/scripts/check-blocking-calls-budget.json b/scripts/check-blocking-calls-budget.json index 58264f4a14..0072e3590f 100644 --- a/scripts/check-blocking-calls-budget.json +++ b/scripts/check-blocking-calls-budget.json @@ -270,6 +270,12 @@ "crates/tui/src/rlm/turn.rs": { "std_fs": 2 }, + "crates/tui/src/runtime_api/diagnostics.rs": { + "std_fs": 5 + }, + "crates/tui/src/runtime_api/workspace.rs": { + "std_fs": 3 + }, "crates/tui/src/runtime_chat_relay.rs": { "thread_sleep": 1 },