feat(runtime-api): expose workspace file suggestions

Reuse composer discovery and ranking for authenticated, bounded workspace-relative file suggestions. Preserve workspace containment and respect directory symlink opt-out for special AI scan roots. Include endpoint and discovery regression coverage and API documentation. Refs #6095.

Signed-off-by: Isabel Wu <[email protected]>
This commit is contained in:
Isabel Wu committed 2026-09-12 20:43:44 -07:00
1 parent 6ae17de09a
commit 2623a3ac1a
7 files changed
+309 -4

No files matched your search

+7
View File
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Authenticated Runtime API workspace file suggestions reuse TUI `@file`
matching and discovery, with bounded queries/results and workspace-contained
relative paths only (`GET /v1/workspace/files/search`, #6095). Shared discovery
now honors disabled symlink following for AI-tool directory scan roots too.
## [0.9.13] - 2026-09-12
Codewhale v0.9.13 addresses integrity issues in 0.9.12:
+7
View File
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Authenticated Runtime API workspace file suggestions reuse TUI `@file`
matching and discovery, with bounded queries/results and workspace-contained
relative paths only (`GET /v1/workspace/files/search`, #6095). Shared discovery
now honors disabled symlink following for AI-tool directory scan roots too.
## [0.9.13] - 2026-09-12
Codewhale v0.9.13 addresses integrity issues in 0.9.12:
+2 -1
View File
@@ -112,7 +112,7 @@ use self::sessions::{
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_status};
use self::workspace::{collect_workspace_git_metadata, workspace_file_search, workspace_status};
const RUNTIME_TOKEN_ENV: &str = "CODEWHALE_RUNTIME_TOKEN";
const LEGACY_RUNTIME_TOKEN_ENV: &str = "DEEPSEEK_RUNTIME_TOKEN";
@@ -1088,6 +1088,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
post(resume_session_thread),
)
.route("/v1/workspace/status", get(workspace_status))
.route("/v1/workspace/files/search", get(workspace_file_search))
.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))
+103
View File
@@ -2087,6 +2087,109 @@ async fn thread_summary_includes_workspace_branch_metadata() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn workspace_file_search_auth_matching_and_bounds() -> Result<()> {
let tmp = tempfile::tempdir()?;
let workspace = tmp.path().join("workspace");
fs::create_dir_all(workspace.join("src"))?;
fs::create_dir_all(workspace.join("match-directory"))?;
fs::write(
workspace.join("match.rs"),
"contents must never be returned",
)?;
fs::write(workspace.join("src/match.rs"), "private file contents")?;
for index in 0..105 {
fs::write(workspace.join(format!("bounded-{index:03}.rs")), "")?;
}
let (addr, _, handle) = spawn_test_server_with_root_token_mobile_workspace(
tmp.path().join("runtime"),
tmp.path().join("sessions"),
Some("file-search-token".to_string()),
false,
workspace,
)
.await?
.context("file search test requires a loopback listener")?;
let client = crate::tls::reqwest_client();
let url = format!("http://{addr}/v1/workspace/files/search");
let search_url = |pairs: &[(&str, &str)]| {
let mut url = reqwest::Url::parse(&url).unwrap();
url.query_pairs_mut().extend_pairs(pairs.iter().copied());
url
};
for token in [None, Some("wrong-token")] {
let mut request = client.get(search_url(&[("query", "match")]));
if let Some(token) = token {
request = request.bearer_auth(token);
}
assert_eq!(request.send().await?.status(), StatusCode::UNAUTHORIZED);
}
for (query, expected) in [
("MATCH", json!(["match.rs", "src/match.rs"])),
("src/ma", json!(["src/match.rs"])),
("", json!([])),
(" ", json!([])),
("no-such-file", json!([])),
("../", json!([])),
("/etc/passwd", json!([])),
] {
let body: Value = client
.get(search_url(&[("query", query)]))
.bearer_auth("file-search-token")
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(body, json!({"paths": expected}), "query={query:?}");
}
for (limit, expected_count) in [(None, 20), (Some("1"), 1), (Some("100"), 100)] {
let mut url = search_url(&[("query", "bounded")]);
if let Some(limit) = limit {
url.query_pairs_mut().append_pair("limit", limit);
}
let body: Value = client
.get(url)
.bearer_auth("file-search-token")
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(body["paths"].as_array().unwrap().len(), expected_count);
}
for (key, value) in [
("limit", "0".to_string()),
("limit", "101".to_string()),
("limit", "-1".to_string()),
("limit", "abc".to_string()),
("query", "é".repeat(129)),
("workspace", tmp.path().display().to_string()),
] {
assert_eq!(
client
.get(search_url(&[(key, &value)]))
.bearer_auth("file-search-token")
.send()
.await?
.status(),
StatusCode::BAD_REQUEST,
"invalid {key}"
);
}
let missing_query: Value = client
.get(&url)
.bearer_auth("file-search-token")
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(missing_query, json!({"paths": []}));
handle.abort();
Ok(())
}
#[tokio::test]
async fn workspace_and_automation_endpoints_work() -> Result<()> {
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
+128 -2
View File
@@ -1,13 +1,84 @@
use std::path::{Path as FsPath, PathBuf};
use axum::Json;
use axum::extract::State;
use serde::Serialize;
use axum::extract::{Query, State};
use serde::{Deserialize, Serialize};
use crate::dependencies::{ExternalTool as _, Git};
use super::{ApiError, RuntimeApiState};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct WorkspaceFileSearchQuery {
#[serde(default)]
query: String,
limit: Option<usize>,
}
#[derive(Debug, Serialize)]
pub(super) struct WorkspaceFileSearchResponse {
paths: Vec<String>,
}
/// Read-only suggestions: never use the process cwd or a caller-selected root.
pub(super) async fn workspace_file_search(
State(state): State<RuntimeApiState>,
Query(query): Query<WorkspaceFileSearchQuery>,
) -> Result<Json<WorkspaceFileSearchResponse>, ApiError> {
if query.query.len() > 256 {
return Err(ApiError::bad_request(
"query must be at most 256 UTF-8 bytes",
));
}
let limit = query.limit.unwrap_or(20);
if !(1..=100).contains(&limit) {
return Err(ApiError::bad_request("limit must be between 1 and 100"));
}
if query.query.trim().is_empty() {
return Ok(Json(WorkspaceFileSearchResponse { paths: Vec::new() }));
}
let paths = tokio::task::spawn_blocking(move || {
collect_workspace_file_suggestions(&state.workspace, &query.query, limit)
})
.await
.map_err(|_| ApiError::internal("workspace file search failed"))??;
Ok(Json(WorkspaceFileSearchResponse { paths }))
}
fn collect_workspace_file_suggestions(
root: &FsPath,
query: &str,
limit: usize,
) -> Result<Vec<String>, ApiError> {
use crate::working_set::{Workspace, rank_completion_candidates};
let root = root
.canonicalize()
.map_err(|_| ApiError::internal("workspace is unavailable"))?;
let workspace = Workspace::with_cwd(root.clone(), None);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
// Reuse the composer's bounded discovery and ignore policy, without its
// divergent-cwd pass or symlink-directory traversal. No persistent index.
let candidates = workspace
.completion_discovery_candidates(20_000, &|| std::time::Instant::now() >= deadline);
Ok(
rank_completion_candidates(&candidates, query, candidates.len())
.into_iter()
.filter(|candidate| {
let path = FsPath::new(candidate);
path.components()
.all(|component| matches!(component, std::path::Component::Normal(_)))
&& root
.join(path)
.canonicalize()
.is_ok_and(|resolved| resolved.starts_with(&root) && resolved.is_file())
})
.take(limit)
.collect(),
)
}
#[derive(Debug, Serialize)]
pub(super) struct WorkspaceStatusResponse {
pub(super) workspace: PathBuf,
@@ -141,3 +212,58 @@ fn current_git_head(workspace: &FsPath) -> Option<String> {
let head = head.trim();
(!head.is_empty()).then(|| head.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_file_search_reuses_discovery_ignores() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let root = tmp.path();
std::fs::create_dir_all(root.join(".git"))?;
std::fs::create_dir_all(root.join(".agents"))?;
std::fs::create_dir_all(root.join("target"))?;
std::fs::write(root.join(".gitignore"), ".agents/\ntarget/\nlocal.rs\n")?;
std::fs::write(root.join(".ignore"), "blocked.rs\n")?;
std::fs::write(root.join(".deepseekignore"), "private.rs\n")?;
for name in [
"local.rs",
"blocked.rs",
"private.rs",
".agents/guide.rs",
"target/build.rs",
] {
std::fs::write(root.join(name), "not returned")?;
}
let paths = collect_workspace_file_suggestions(root, ".rs", 100)
.map_err(|error| anyhow::anyhow!(error.message))?;
assert_eq!(paths, [".agents/guide.rs", "local.rs"]);
Ok(())
}
#[cfg(unix)]
#[test]
fn workspace_file_search_contains_symlinks_and_excludes_other_workspaces() -> anyhow::Result<()>
{
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir()?;
let root = tmp.path().join("workspace");
let other = tmp.path().join("workspace-other");
std::fs::create_dir_all(&root)?;
std::fs::create_dir_all(&other)?;
std::fs::write(root.join("inside.rs"), "inside")?;
std::fs::write(other.join("outside.rs"), "outside")?;
symlink(root.join("inside.rs"), root.join("alias.rs"))?;
symlink(other.join("outside.rs"), root.join("escape.rs"))?;
symlink(&other, root.join("external-directory"))?;
symlink(&other, root.join(".agents"))?;
symlink(other.join("missing.rs"), root.join("broken.rs"))?;
symlink(&root, root.join("loop"))?;
let paths = collect_workspace_file_suggestions(&root, ".rs", 100)
.map_err(|error| anyhow::anyhow!(error.message))?;
assert_eq!(paths, ["alias.rs", "inside.rs"]);
Ok(())
}
}
+25 -1
View File
@@ -613,7 +613,9 @@ fn walk_always_discoverable_dirs(
break;
}
let dot_dir = walk_root.join(dir_name);
if !dot_dir.is_dir() {
// A walker follows a symlink passed as its root even when ordinary
// symlink following is off. Keep that opt-out for the extra roots too.
if !dot_dir.is_dir() || (!follow_links && dot_dir.is_symlink()) {
continue;
}
let mut builder = WalkBuilder::new(&dot_dir);
@@ -1593,6 +1595,28 @@ mod tests {
use codewhale_models::Role;
use tempfile::TempDir;
#[cfg(unix)]
#[test]
fn workspace_file_search_discovery_respects_symlink_opt_out_for_ai_directories() {
let workspace = TempDir::new().unwrap();
let external = TempDir::new().unwrap();
fs::write(external.path().join("outside.rs"), "").unwrap();
std::os::unix::fs::symlink(external.path(), workspace.path().join(".agents")).unwrap();
for follow_links in [false, true] {
let resolver = Workspace::with_cwd_depth_and_follow_links(
workspace.path().to_path_buf(),
None,
DEFAULT_COMPLETIONS_WALK_DEPTH,
follow_links,
);
let candidates = resolver.completion_discovery_candidates(100, &|| false);
assert_eq!(
candidates.iter().any(|path| path == ".agents/outside.rs"),
follow_links
);
}
}
fn make_message(role: &str, text: &str) -> Message {
Message {
role: Role::from(role),
+37
View File
@@ -65,6 +65,42 @@ The legacy in-process `codewhale app-server` also requires an explicit
`--auth-token` or `CODEWHALE_APP_SERVER_TOKEN` before binding a non-loopback
host; its generated one-time `cwapp_*` token is loopback-only.
### Workspace file suggestions
`GET /v1/workspace/files/search?query=runtime&limit=20` returns
`{"paths":["src/runtime.rs"]}` through the existing authenticated `/v1/*`
router. It searches only the server's configured workspace, not a thread's
workspace or the process's current directory. No workspace/path override is
accepted. The response contains workspace-relative file paths with `/`
separators, never file contents, absolute paths, or directories.
- `query` is a literal partial filename/path, without an `@` prefix, at most
256 UTF-8 bytes. Missing, empty, or whitespace-only queries return an empty
list without walking the filesystem. No match also returns an empty list.
- `limit` defaults to 20; accepted values are 1–100. Invalid limits, oversized
queries, and unknown query parameters return HTTP 400.
- Matching reuses TUI fuzzy `@file` discovery/ranking: case-insensitive path
prefix matches first, then substring matches, alphabetically within each
group. This is not glob, subsequence, content, or semantic search, and does
not apply the TUI's personal frecency boosts.
- Discovery shares the composer's ignore policy, including `.ignore` and
`.deepseekignore`, always-discoverable AI directories, and the bounded
hidden/gitignored local-reference fallback. The special `.agents`, `.claude`,
`.cursor`, and `.deepseek` walks intentionally bypass ignore rules, as in
the TUI. Ignore files are not confidentiality boundaries.
- Directory symlinks are not traversed. Files are canonicalized and filtered
for containment in the workspace before applying the result limit; external
and broken file symlinks are omitted. In-workspace file symlinks may appear
by their relative names. Suggestions are a filesystem snapshot, not
authorization to read a file later; consumers must revalidate when opening it.
Discovery runs off the async executor, with the shared default depth of 10,
at most 20,000 candidates, and a cooperative two-second discovery budget.
Results are best-effort, not an exhaustive listing; a slow filesystem operation
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.
### Runtime and account identity
`GET /v1/runtime/info` reports `codewhale_version` plus the full 40-character
@@ -934,6 +970,7 @@ human gate. Auto-merge is `scripts/check-auto-merge.py --repo … --pr …
**Introspection**
- `GET /v1/workspace/status`
- `GET /v1/workspace/files/search?query=<partial>&limit=<1-100>` (see workspace file suggestions above)
- `GET /v1/skills`
- `GET /v1/apps/mcp/servers`
- `GET /v1/apps/mcp/tools?server=<optional>`