app-server: return environment-native workspace roots

This commit is contained in:
Adam Perry
2026-06-16 16:42:45 +00:00
parent 42830cf677
commit bbf3179cc4
8 changed files with 174 additions and 22 deletions
@@ -161,7 +161,7 @@ pub struct ThreadStartResponse {
/// `:workspace_roots`.
#[experimental("thread/start.runtimeWorkspaceRoots")]
#[serde(default)]
pub runtime_workspace_roots: Vec<AbsolutePathBuf>,
pub runtime_workspace_roots: Vec<ApiPathString>,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<AbsolutePathBuf>,
+1 -1
View File
@@ -133,7 +133,7 @@ Example with notification opt-out:
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. In a `thread/start` response, the root corresponding to a selected environment's `cwd` uses that environment's native path spelling.
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
@@ -495,6 +495,7 @@ mod process_exec_processor;
mod remote_control_processor;
mod search;
mod thread_processor;
mod thread_response_paths;
mod token_usage_replay;
mod turn_processor;
mod windows_sandbox_processor;
@@ -598,6 +599,7 @@ use self::config_errors::*;
use self::request_errors::*;
use self::thread_goal_processor::api_thread_goal_from_state;
use self::thread_lifecycle::*;
use self::thread_response_paths::thread_response_runtime_workspace_roots;
use self::thread_resume_redaction::*;
use self::thread_summary::*;
@@ -1103,6 +1103,7 @@ impl ThreadRequestProcessor {
.map_err(|err| config_load_error(&err))?;
}
let runtime_workspace_roots_explicit = config.workspace_roots_explicit;
let environments = environments.unwrap_or_else(|| {
listener_task_context
.thread_manager
@@ -1247,6 +1248,10 @@ impl ThreadRequestProcessor {
"could not render thread cwd `{cwd_uri}` using {cwd_convention}: {err}"
))
})?;
let runtime_workspace_roots = thread_response_runtime_workspace_roots(
&config_snapshot,
runtime_workspace_roots_explicit,
)?;
let active_permission_profile =
thread_response_active_permission_profile(config_snapshot.active_permission_profile);
@@ -1256,7 +1261,7 @@ impl ThreadRequestProcessor {
model_provider: config_snapshot.model_provider_id,
service_tier: config_snapshot.service_tier,
cwd,
runtime_workspace_roots: config_snapshot.workspace_roots,
runtime_workspace_roots,
instruction_sources,
approval_policy: config_snapshot.approval_policy.into(),
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
@@ -0,0 +1,47 @@
use crate::error_code::internal_error;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_core::ThreadConfigSnapshot;
use codex_utils_path_uri::ApiPathString;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
pub(super) fn thread_response_runtime_workspace_roots(
config_snapshot: &ThreadConfigSnapshot,
runtime_workspace_roots_explicit: bool,
) -> Result<Vec<ApiPathString>, JSONRPCErrorError> {
let selected_environment_cwd = config_snapshot
.environment_selections()
.first()
.map(|environment| &environment.cwd);
config_snapshot
.workspace_roots
.iter()
.map(|root| {
if !runtime_workspace_roots_explicit
&& root == config_snapshot.cwd()
&& let Some(environment_cwd) = selected_environment_cwd
{
let convention = environment_cwd.infer_path_convention().ok_or_else(|| {
internal_error(format!(
"could not infer the path convention for runtime workspace root `{environment_cwd}`"
))
})?;
return render_path_uri(environment_cwd, convention);
}
render_path_uri(&PathUri::from_abs_path(root), PathConvention::native())
})
.collect()
}
fn render_path_uri(
path: &PathUri,
convention: PathConvention,
) -> Result<ApiPathString, JSONRPCErrorError> {
ApiPathString::from_path_uri(path, convention).map_err(|err| {
internal_error(format!(
"could not render runtime workspace root `{path}` using {convention}: {err}"
))
})
}
@@ -233,7 +233,58 @@ async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> {
} = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(response_cwd.as_str(), cwd.to_string_lossy());
assert_eq!(runtime_workspace_roots, vec![extra_root.abs()]);
assert_eq!(
runtime_workspace_roots,
vec![ApiPathString::from_abs_path(
&extra_root.abs(),
PathConvention::native(),
)?]
);
Ok(())
}
#[tokio::test]
async fn thread_start_preserves_explicit_root_equal_to_host_fallback_with_selected_environment()
-> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let fallback_cwd = TempDir::new()?;
let environment_cwd = TempDir::new()?;
let explicit_root = fallback_cwd.path().to_path_buf().abs();
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_thread_start_request(ThreadStartParams {
cwd: Some(fallback_cwd.path().display().to_string()),
runtime_workspace_roots: Some(vec![explicit_root.clone()]),
environments: Some(vec![TurnEnvironmentParams {
environment_id: "local".to_string(),
cwd: ApiPathString::from_abs_path(
&environment_cwd.path().to_path_buf().abs(),
PathConvention::native(),
)?,
}]),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ThreadStartResponse = to_response(response)?;
assert_eq!(
response.runtime_workspace_roots,
vec![ApiPathString::from_abs_path(
&explicit_root,
PathConvention::native(),
)?]
);
Ok(())
}
@@ -273,7 +324,10 @@ async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_ro
assert_eq!(
runtime_workspace_roots,
vec![cwd.path().to_path_buf().abs()]
vec![ApiPathString::from_abs_path(
&cwd.path().to_path_buf().abs(),
PathConvention::native(),
)?]
);
Ok(())
@@ -2,7 +2,6 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::PathBufExt;
use app_test_support::TestAppServer;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::to_response;
@@ -189,6 +188,7 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul
const AGENTS_INSTRUCTIONS: &str = "remote Windows workspace instructions";
const CALL_ID: &str = "wine-cmd-smoke";
const COMMAND: &str = "Get-Content AGENTS.md -ErrorAction Stop";
const NATIVE_CWD: &str = r"C:\windows";
WineExecServer
.scope(|exec_server_url| async move {
@@ -255,7 +255,7 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul
.send_thread_start_request(ThreadStartParams {
environments: Some(vec![TurnEnvironmentParams {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: serde_json::from_value::<ApiPathString>(json!(r"C:\windows"))?,
cwd: serde_json::from_value::<ApiPathString>(json!(NATIVE_CWD))?,
}]),
..Default::default()
})
@@ -267,10 +267,8 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul
.await??;
let response: ThreadStartResponse = to_response(response)?;
assert!(!response.thread.id.is_empty());
let host_cwd = codex_home.path().to_path_buf().abs();
assert_eq!(response.cwd.as_str(), NATIVE_CWD);
// TODO(anp): Derive runtime workspace roots from the selected remote environment.
assert_eq!(response.runtime_workspace_roots, vec![host_cwd]);
assert_eq!(response.runtime_workspace_roots, vec![response.cwd.clone()]);
// TODO(anp): Discover and report instruction sources from the remote filesystem.
assert_eq!(response.instruction_sources, Vec::new());
// TODO(anp): Report the implicit built-in permission profile instead of None.
+58 -12
View File
@@ -1540,18 +1540,14 @@ async fn thread_session_state_from_thread_start_response(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Result<ThreadSessionState, String> {
let cwd_convention = response
.cwd
.infer_absolute_path_convention()
.ok_or_else(|| format!("thread cwd `{}` is not absolute", response.cwd.as_str()))?;
let cwd_uri = response
.cwd
.to_path_uri(cwd_convention)
let cwd = api_path_to_native_absolute_path(&response.cwd)
.map_err(|err| format!("thread cwd is invalid: {err}"))?;
let cwd = ApiPathString::from_path_uri(&cwd_uri, PathConvention::native())
.map_err(|err| format!("thread cwd cannot be represented on this host: {err}"))?;
let cwd = AbsolutePathBuf::try_from(cwd.into_string())
.map_err(|err| format!("thread cwd is invalid on this host: {err}"))?;
let runtime_workspace_roots = response
.runtime_workspace_roots
.iter()
.map(api_path_to_native_runtime_workspace_root)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| format!("runtime workspace root is invalid: {err}"))?;
let permission_profile = display_permission_profile_from_thread_response(
&response.sandbox,
cwd.as_path(),
@@ -1571,7 +1567,7 @@ async fn thread_session_state_from_thread_start_response(
permission_profile,
response.active_permission_profile.clone().map(Into::into),
cwd,
response.runtime_workspace_roots.clone(),
runtime_workspace_roots,
response.instruction_sources.clone(),
response.reasoning_effort.clone(),
config,
@@ -1579,6 +1575,39 @@ async fn thread_session_state_from_thread_start_response(
.await
}
fn api_path_to_native_absolute_path(path: &ApiPathString) -> Result<AbsolutePathBuf, String> {
let convention = path
.infer_absolute_path_convention()
.ok_or_else(|| format!("path `{}` is not absolute", path.as_str()))?;
let path_uri = path
.to_path_uri(convention)
.map_err(|err| format!("path `{}` is invalid: {err}", path.as_str()))?;
let native_path =
ApiPathString::from_path_uri(&path_uri, PathConvention::native()).map_err(|err| {
format!(
"path `{}` cannot be represented on this host: {err}",
path.as_str()
)
})?;
AbsolutePathBuf::try_from(native_path.into_string())
.map_err(|err| format!("path `{}` is invalid on this host: {err}", path.as_str()))
}
fn api_path_to_native_runtime_workspace_root(
path: &ApiPathString,
) -> Result<AbsolutePathBuf, String> {
let convention = path
.infer_absolute_path_convention()
.ok_or_else(|| format!("path `{}` is not absolute", path.as_str()))?;
if convention != PathConvention::native() {
return Err(format!(
"foreign path `{}` cannot be installed in the TUI's host-native config",
path.as_str()
));
}
api_path_to_native_absolute_path(path)
}
async fn thread_session_state_from_thread_resume_response(
response: &ThreadResumeResponse,
config: &Config,
@@ -1781,6 +1810,23 @@ mod tests {
.expect("config should build")
}
#[test]
fn foreign_runtime_workspace_root_is_rejected_instead_of_projected_onto_the_tui_host() {
let (path, convention) = match PathConvention::native() {
PathConvention::Posix => (r"C:\workspace", PathConvention::Windows),
PathConvention::Windows => ("/workspace", PathConvention::Posix),
};
let root = ApiPathString::from_native_absolute_path(path, convention)
.expect("foreign path should be valid");
assert_eq!(
api_path_to_native_runtime_workspace_root(&root),
Err(format!(
"foreign path `{path}` cannot be installed in the TUI's host-native config"
))
);
}
fn rate_limit_snapshot(limit_id: &str) -> RateLimitSnapshot {
RateLimitSnapshot {
limit_id: Some(limit_id.to_string()),