mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
app-server: return environment-native workspace roots
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user