fix: keep execution workspace paths internal

This commit is contained in:
Keisuke Hirata 2026-07-07 04:16:04 +09:00
parent 8b7a5da031
commit c90ebf2468
No known key found for this signature in database
3 changed files with 154 additions and 24 deletions

View File

@ -388,6 +388,7 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
profile: Some(selector),
initial_input: None,
execution_workspace: None,
resolved_execution_workspace: None,
},
);

View File

@ -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<String>,
}
#[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<ProfileSelector>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_input: Option<EmbeddedWorkerInput>,
/// 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<ExecutionWorkspaceRequest>,
pub execution_workspace: Option<WorkerSpawnExecutionWorkspaceRequest>,
#[serde(skip, default)]
pub resolved_execution_workspace: Option<ExecutionWorkspaceRequest>,
}
#[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();

View File

@ -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<ExecutionWorkspaceRequest> {
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<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>,
Json(request): Json<WorkerSpawnRequest>,
Json(mut request): Json<WorkerSpawnRequest>,
) -> ApiResult<Json<WorkerSpawnResult>> {
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");