diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index a5be9a14..8240c80e 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -388,6 +388,7 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState { profile: Some(selector), initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, }, ); diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index fc5d006f..69ded6a4 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -263,13 +263,26 @@ pub struct WorkerLookupResult { /// Browser-safe worker spawn request shape. /// /// The request carries Browser-facing launch semantics only: workspace intent, -/// optional display identity, acceptance policy, optional profile selector, and -/// optional initial input. Runtime execution authority is resolved by the host +/// optional display identity, acceptance policy, optional profile selector, +/// optional initial input, and optional configured Repository selector for +/// execution-workspace materialization. Runtime execution authority is resolved by the host /// into a synced ConfigBundle before the canonical Runtime create request is /// built. Raw workspace roots, child cwd, executable paths, tool scope, /// credentials, raw config stores, sockets, sessions, and storage paths are not /// accepted from Workspace API callers. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkerSpawnExecutionWorkspaceRequest { + /// Safe configured Repository id. The host resolves this id to repository + /// authority from server-side config; browser callers cannot provide raw + /// source paths or runtime-internal storage paths. + pub repository_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct WorkerSpawnRequest { pub intent: WorkerSpawnIntent, #[serde(skip_serializing_if = "Option::is_none")] @@ -279,8 +292,13 @@ pub struct WorkerSpawnRequest { pub profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub initial_input: Option, + /// Optional safe execution-workspace selector. The Workspace server resolves + /// this into a runtime-internal `ExecutionWorkspaceRequest` from configured + /// repositories before calling a host. #[serde(default, skip_serializing_if = "Option::is_none")] - pub execution_workspace: Option, + pub execution_workspace: Option, + #[serde(skip, default)] + pub resolved_execution_workspace: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1323,7 +1341,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { profile, config_bundle, initial_input: request.initial_input.clone(), - execution_workspace: request.execution_workspace.clone(), + execution_workspace: request.resolved_execution_workspace.clone(), }; match self.runtime.create_worker(create_request) { Ok(detail) => { @@ -1996,7 +2014,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { profile, config_bundle, initial_input: request.initial_input.clone(), - execution_workspace: request.execution_workspace.clone(), + execution_workspace: request.resolved_execution_workspace.clone(), }; match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) { Ok(response) => WorkerSpawnResult { @@ -3153,6 +3171,7 @@ mod tests { profile: None, initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, } } @@ -3283,6 +3302,7 @@ mod tests { profile: None, initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, }, ) .unwrap(); @@ -3383,6 +3403,7 @@ mod tests { profile: Some(ProfileSelector::Builtin("builtin:coder".to_string())), initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, }, ) .unwrap(); @@ -3412,6 +3433,7 @@ mod tests { profile: None, initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, }, ) .unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ed10ca71..7e266f3a 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -25,8 +25,8 @@ use crate::hosts::{ HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry, RuntimeRegistryUnregisterResult, RuntimeSummary, WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState, - WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, - WorkerSummary, WorkerTranscriptProjection, + WorkerSpawnAcceptanceRequirement, WorkerSpawnExecutionWorkspaceRequest, WorkerSpawnIntent, + WorkerSpawnRequest, WorkerSpawnResult, WorkerSummary, WorkerTranscriptProjection, }; use crate::identity::WorkspaceIdentity; use crate::observation::{ @@ -917,6 +917,51 @@ async fn get_worker_launch_options( Ok(Json(worker_launch_options_response(&api))) } +fn execution_workspace_request_from_repository( + repository: &ConfiguredRepository, + selector: Option<&str>, +) -> ExecutionWorkspaceRequest { + ExecutionWorkspaceRequest { + repository: ExecutionWorkspaceRepository { + id: repository.id.clone(), + provider: repository.provider.clone(), + uri: repository.uri.clone(), + local_path: Some(repository.path.clone()), + selector: selector + .map(|selector| RuntimeRepositorySelector::from(selector.to_string())) + .or_else(|| { + repository + .default_selector + .clone() + .map(RuntimeRepositorySelector) + }) + .or_else(|| Some(RuntimeRepositorySelector::from("HEAD"))), + }, + materializer: MaterializerKind::LocalGitWorktree, + dirty_state_policy: DirtyStatePolicy::CleanPointOnly, + } +} + +fn configured_execution_workspace_request( + config: &ServerConfig, + request: &WorkerSpawnExecutionWorkspaceRequest, +) -> Result { + let repository = config + .repositories + .iter() + .find(|repository| repository.id == request.repository_id) + .ok_or_else(|| { + Error::Config(format!( + "unknown repository id `{}` for Worker execution workspace", + request.repository_id + )) + })?; + Ok(execution_workspace_request_from_repository( + repository, + request.selector.as_deref(), + )) +} + fn default_execution_workspace_request( config: &ServerConfig, repository_id: Option<&str>, @@ -941,21 +986,9 @@ fn default_execution_workspace_request( }, }; - Ok(Some(ExecutionWorkspaceRequest { - repository: ExecutionWorkspaceRepository { - id: repository.id.clone(), - provider: repository.provider.clone(), - uri: repository.uri.clone(), - local_path: Some(repository.path.clone()), - selector: repository - .default_selector - .clone() - .map(RuntimeRepositorySelector) - .or_else(|| Some(RuntimeRepositorySelector::from("HEAD"))), - }, - materializer: MaterializerKind::LocalGitWorktree, - dirty_state_policy: DirtyStatePolicy::CleanPointOnly, - })) + Ok(Some(execution_workspace_request_from_repository( + repository, None, + ))) } async fn create_workspace_worker( @@ -997,7 +1030,8 @@ async fn create_workspace_worker( }, profile: Some(profile_selector), initial_input, - execution_workspace, + execution_workspace: None, + resolved_execution_workspace: execution_workspace, }, ) .map_err(|err| err.into_error())?; @@ -1082,8 +1116,15 @@ struct RuntimeConfigBundleAvailabilityQuery { async fn create_runtime_worker( State(api): State, AxumPath(runtime_id): AxumPath, - Json(request): Json, + Json(mut request): Json, ) -> ApiResult> { + request.resolved_execution_workspace = request + .execution_workspace + .as_ref() + .map(|execution_workspace| { + configured_execution_workspace_request(&api.config, execution_workspace) + }) + .transpose()?; let result = api .runtime .spawn_worker(&runtime_id, request) @@ -2895,6 +2936,71 @@ mod tests { assert!(!projected.contains("http://")); } + #[tokio::test] + async fn runtime_worker_spawn_rejects_raw_execution_workspace_fields() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + let response = request_json( + app, + "POST", + "/api/runtimes/embedded-worker-runtime/workers", + Some(serde_json::json!({ + "intent": { + "kind": "ticket_role", + "ticket_id": "00001KVZSGT0Q", + "role": "coder" + }, + "acceptance": { + "kind": "run_accepted", + "expected_segments": 0 + }, + "execution_workspace": { + "repository_id": TEST_REPOSITORY_ID, + "local_path": dir.path().display().to_string() + } + })), + StatusCode::UNPROCESSABLE_ENTITY, + ) + .await; + assert!( + response["message"] + .as_str() + .unwrap_or_default() + .contains("unknown field"), + "raw execution workspace field should be rejected: {response}" + ); + } + + #[tokio::test] + async fn runtime_worker_spawn_accepts_safe_repository_selector() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + let response = request_json( + app, + "POST", + "/api/runtimes/embedded-worker-runtime/workers", + Some(serde_json::json!({ + "intent": { + "kind": "ticket_role", + "ticket_id": "00001KVZSGT0Q", + "role": "coder" + }, + "acceptance": { + "kind": "run_accepted", + "expected_segments": 0 + }, + "execution_workspace": { + "repository_id": TEST_REPOSITORY_ID, + "selector": "HEAD" + } + })), + StatusCode::OK, + ) + .await; + let projected = serde_json::to_string(&response).unwrap(); + assert!(!projected.contains(dir.path().to_string_lossy().as_ref())); + } + #[tokio::test] async fn browser_worker_create_rejects_extra_request_fields() { let dir = tempfile::tempdir().unwrap(); @@ -3308,6 +3414,7 @@ mod tests { profile: None, initial_input: None, execution_workspace: None, + resolved_execution_workspace: None, }, ) .expect("spawn worker");