fix: restrict worker send input to idle admission
This commit is contained in:
@@ -696,6 +696,8 @@ pub struct WorkerLifecycleResult {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerInputKind {
|
||||
User,
|
||||
/// Agent input that must never enter the human Submit queue.
|
||||
UserIfIdle,
|
||||
Notify,
|
||||
Compact,
|
||||
ListRewindTargets,
|
||||
@@ -3037,6 +3039,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
let input = EmbeddedWorkerInput {
|
||||
kind: match request.kind {
|
||||
WorkerInputKind::User => EmbeddedWorkerInputKind::User,
|
||||
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
|
||||
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
|
||||
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
|
||||
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
|
||||
@@ -5028,6 +5031,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
let input = EmbeddedWorkerInput {
|
||||
kind: match request.kind {
|
||||
WorkerInputKind::User => EmbeddedWorkerInputKind::User,
|
||||
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
|
||||
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
|
||||
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
|
||||
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
|
||||
|
||||
@@ -17659,7 +17659,30 @@ async fn send_known_worker_input(
|
||||
&path.worker,
|
||||
permission,
|
||||
)?;
|
||||
scoped_send_runtime_worker_input(State(api), AxumPath(path), Json(request)).await
|
||||
let mut request = worker_input_request_from_api(request)?;
|
||||
let idle_only = request.kind == WorkerInputKind::User;
|
||||
if idle_only {
|
||||
request.kind = WorkerInputKind::UserIfIdle;
|
||||
}
|
||||
let Json(result) =
|
||||
dispatch_runtime_worker_input(api, path.worker.runtime_id, path.worker.worker_id, request)?;
|
||||
if result.state != server_api::WorkerOperationState::Accepted {
|
||||
let detail = result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| diagnostic.message.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
let message = if idle_only {
|
||||
format!(
|
||||
"WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress. {detail}"
|
||||
)
|
||||
} else {
|
||||
detail
|
||||
};
|
||||
return Err(Error::InvalidInput(message).into());
|
||||
}
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn cancel_known_worker(
|
||||
@@ -24426,6 +24449,15 @@ async fn send_runtime_worker_input(
|
||||
Json(request): Json<server_api::RuntimeWorkerInputRequest>,
|
||||
) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> {
|
||||
let request = worker_input_request_from_api(request)?;
|
||||
dispatch_runtime_worker_input(api, runtime_id, worker_id, request)
|
||||
}
|
||||
|
||||
fn dispatch_runtime_worker_input(
|
||||
api: WorkspaceApi,
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
request: WorkerInputRequest,
|
||||
) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> {
|
||||
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
||||
if api
|
||||
.store
|
||||
@@ -24535,7 +24567,9 @@ pub(crate) fn authorize_browser_worker_method(
|
||||
message,
|
||||
source: source.clone(),
|
||||
}),
|
||||
protocol::Method::SubmitTracked { .. } | protocol::Method::NotifyTracked { .. } => {
|
||||
protocol::Method::SubmitTracked { .. }
|
||||
| protocol::Method::SubmitIfIdle { .. }
|
||||
| protocol::Method::NotifyTracked { .. } => {
|
||||
Err("authenticated Worker input source is server-owned")
|
||||
}
|
||||
other => Ok(other),
|
||||
@@ -29862,6 +29896,7 @@ mod tests {
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct WorkdirlessFixtureRuntime {
|
||||
input_requests: Arc<Mutex<Vec<WorkerInputRequest>>>,
|
||||
workers: Arc<Mutex<Vec<InternalWorkerSummary>>>,
|
||||
spawn_requests: Arc<Mutex<Vec<WorkerSpawnRequest>>>,
|
||||
reject_next_spawn: Arc<Mutex<bool>>,
|
||||
@@ -29988,6 +30023,30 @@ mod tests {
|
||||
}
|
||||
|
||||
impl crate::hosts::WorkspaceWorkerRuntime for WorkdirlessFixtureRuntime {
|
||||
fn send_input(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
request: WorkerInputRequest,
|
||||
) -> crate::hosts::WorkerInputResult {
|
||||
let idle_only = request.kind == WorkerInputKind::UserIfIdle;
|
||||
self.input_requests.lock().unwrap().push(request);
|
||||
crate::hosts::WorkerInputResult {
|
||||
state: if idle_only {
|
||||
InternalWorkerOperationState::Rejected
|
||||
} else {
|
||||
InternalWorkerOperationState::Accepted
|
||||
},
|
||||
disposition: if idle_only {
|
||||
crate::hosts::WorkerInputDisposition::Rejected
|
||||
} else {
|
||||
crate::hosts::WorkerInputDisposition::Accepted
|
||||
},
|
||||
worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id),
|
||||
runtime_run_id: None,
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_id(&self) -> &str {
|
||||
Self::RUNTIME_ID
|
||||
}
|
||||
@@ -33327,6 +33386,76 @@ mod tests {
|
||||
assert_eq!(current.assignment_id, "existing-coder-assignment");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_control_input_is_idle_only_and_returns_rejection_as_http_error() {
|
||||
let fixture = guarded_spawn_fixture().await;
|
||||
let response = post_guarded_spawn(
|
||||
&fixture,
|
||||
guarded_spawn_payload(&fixture, "idle-only-control"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let created: BrowserCreateWorkerResponse = serde_json::from_slice(&bytes).unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-yoi-runtime-id",
|
||||
fixture.controller.runtime_id.parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-yoi-worker-id",
|
||||
fixture.controller.worker_id.parse().unwrap(),
|
||||
);
|
||||
let path = || ScopedRuntimeWorkerPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
worker: RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id),
|
||||
};
|
||||
let request = |kind: &str| server_api::RuntimeWorkerInputRequest {
|
||||
kind: Some(kind.to_string()),
|
||||
content: "follow-up".to_string(),
|
||||
segments: None,
|
||||
};
|
||||
let error = send_known_worker_input(
|
||||
State(fixture.api.clone()),
|
||||
AxumPath(path()),
|
||||
headers.clone(),
|
||||
Json(request("user")),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("WorkerNotify"));
|
||||
// Machine Notify is unchanged; the human endpoint must still use User.
|
||||
let Json(notify) = send_known_worker_input(
|
||||
State(fixture.api.clone()),
|
||||
AxumPath(path()),
|
||||
headers,
|
||||
Json(request("notify")),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(notify.state, server_api::WorkerOperationState::Accepted);
|
||||
let Json(human) = scoped_send_runtime_worker_input(
|
||||
State(fixture.api.clone()),
|
||||
AxumPath(path()),
|
||||
Json(request("user")),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(human.state, server_api::WorkerOperationState::Accepted);
|
||||
let requests = fixture.runtime.input_requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests.iter().map(|r| r.kind.clone()).collect::<Vec<_>>(),
|
||||
vec![
|
||||
WorkerInputKind::UserIfIdle,
|
||||
WorkerInputKind::Notify,
|
||||
WorkerInputKind::User
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_control_spawn_retry_converges_on_one_worker_and_one_grant() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user