fix: align Worker operation grants and known-target observation
Compose the published T-803 candidate 51f0c7e019 with T-766 without target adoption. Preserve operation-use versus directional control, notify-only replies, live revocation, and T-766 receipt/provenance boundaries.
This commit is contained in:
@@ -2061,7 +2061,7 @@ where
|
||||
crate::feature::builtin::objective::composition::compose(
|
||||
&mut feature_registry,
|
||||
feature_config.objective.enabled,
|
||||
workspace_client,
|
||||
workspace_client.clone(),
|
||||
objective_namespace.map(|route| (route, wip_permissions.clone())),
|
||||
)?;
|
||||
|
||||
@@ -2082,6 +2082,15 @@ where
|
||||
) {
|
||||
observation_providers.push(provider);
|
||||
}
|
||||
// A scoped Backend provider belongs to the Worker Feature's composition,
|
||||
// including ordinary spawn and restore. Runtime's optional local peer
|
||||
// provider is not its enablement or authority: every actual capture still
|
||||
// requires current operation use and the target's Backend control grant.
|
||||
if worker_enabled {
|
||||
observation_providers.push(Arc::new(
|
||||
crate::feature::builtin::worker_observation::WorkspaceClientWorkerObservationProvider::new(workspace_client),
|
||||
));
|
||||
}
|
||||
if let Some(provider) = host_worker_observation_provider {
|
||||
observation_providers.push(provider);
|
||||
}
|
||||
@@ -4163,6 +4172,82 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_observation_is_composed_only_with_runtime_worker_service_without_startup_access()
|
||||
{
|
||||
for enabled in [false, true] {
|
||||
let root = TempDir::new().unwrap();
|
||||
let mut manifest = manifest::WorkerManifest::from_toml(
|
||||
"[worker]\nname = 'observation-wiring'\n[model]\nscheme = 'anthropic'\nmodel_id = 'test'\n[engine]\n[scope]\n"
|
||||
).unwrap();
|
||||
manifest.feature.worker.enabled = enabled;
|
||||
manifest.feature.sub_worker.enabled = false;
|
||||
manifest.feature.workdir_catalog.enabled = false;
|
||||
let model = ScriptedClient::new(vec![]);
|
||||
let engine =
|
||||
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(
|
||||
model,
|
||||
);
|
||||
let store = session_store::CombinedStore::new(
|
||||
session_store::FsStore::new(root.path().join("sessions")).unwrap(),
|
||||
session_store::FsWorkerStore::new(root.path().join("workers")).unwrap(),
|
||||
);
|
||||
let operations = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let host = Arc::new(DriveWorkspaceHost {
|
||||
available: true,
|
||||
workspace: Some("workspace-1"),
|
||||
granted: false,
|
||||
operations: operations.clone(),
|
||||
});
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
engine,
|
||||
store,
|
||||
crate::worker::WorkerWorkspaceContext::with_client(
|
||||
Some(crate::worker::WorkspaceId::new("workspace-1").unwrap()),
|
||||
host,
|
||||
),
|
||||
crate::worker::WorkerFilesystemAuthority::None,
|
||||
manifest::Scope::empty(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worker.set_system_prompt_template(
|
||||
crate::SystemPromptTemplate::parse(
|
||||
"default",
|
||||
crate::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let prepared = WorkerController::initialize(worker, root.path(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let Event::Snapshot { greeting, .. } =
|
||||
crate::ipc::protocol_session::subscribe_worker_protocol_session(
|
||||
&prepared.handle,
|
||||
)
|
||||
.snapshot_event
|
||||
else {
|
||||
panic!("initial committed greeting");
|
||||
};
|
||||
for name in [
|
||||
"ViewSessionOverview",
|
||||
"SearchSessionEntries",
|
||||
"ReadSessionEntry",
|
||||
] {
|
||||
assert_eq!(
|
||||
greeting.tools.iter().any(|tool| tool == name),
|
||||
enabled,
|
||||
"Worker service configuration must determine observation publication, not client availability alone"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
operations.lock().unwrap().is_empty(),
|
||||
"provider composition may not authorize or read protected resources"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_controller_registers_without_authorization_and_checks_each_access_in_both_modes()
|
||||
{
|
||||
|
||||
@@ -41,22 +41,18 @@ struct WorkspaceWorkerControlService {
|
||||
}
|
||||
|
||||
impl WorkspaceWorkerControlService {
|
||||
fn authorize_runtime_worker(
|
||||
&self,
|
||||
operation: &str,
|
||||
runtime_id: &str,
|
||||
worker_id: &str,
|
||||
) -> Result<(), WorkspaceClientError> {
|
||||
fn authorize_runtime_operation(&self, operation: &str) -> Result<(), WorkspaceClientError> {
|
||||
if !self.runtime_worker_control {
|
||||
return Err(WorkspaceClientError::Unavailable(
|
||||
"Runtime Worker control is not enabled for this Worker".into(),
|
||||
));
|
||||
}
|
||||
// Common authorization permits using the operation. The Backend separately
|
||||
// checks the authenticated caller's control grant for the actual target.
|
||||
self.client.authorize_operation(
|
||||
operation,
|
||||
&PermissionResource::Worker {
|
||||
runtime_id: runtime_id.into(),
|
||||
worker_id: worker_id.into(),
|
||||
&PermissionResource::WorkerOperation {
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -201,14 +197,10 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => {
|
||||
self.authorize_runtime_worker(
|
||||
match kind {
|
||||
WorkerControlInputKind::User => "worker.send_input",
|
||||
WorkerControlInputKind::Notify => "worker.notify",
|
||||
},
|
||||
&runtime_id,
|
||||
&worker_id,
|
||||
)?;
|
||||
self.authorize_runtime_operation(match kind {
|
||||
WorkerControlInputKind::User => "worker.send_input",
|
||||
WorkerControlInputKind::Notify => "worker.messaging",
|
||||
})?;
|
||||
let kind = match kind {
|
||||
WorkerControlInputKind::User => "user",
|
||||
WorkerControlInputKind::Notify => "notify",
|
||||
@@ -273,14 +265,10 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => {
|
||||
self.authorize_runtime_worker(
|
||||
match kind {
|
||||
WorkerControlStopKind::Cancel => "worker.cancel",
|
||||
WorkerControlStopKind::Stop => "worker.stop",
|
||||
},
|
||||
&runtime_id,
|
||||
&worker_id,
|
||||
)?;
|
||||
self.authorize_runtime_operation(match kind {
|
||||
WorkerControlStopKind::Cancel => "worker.cancel",
|
||||
WorkerControlStopKind::Stop => "worker.stop",
|
||||
})?;
|
||||
let operation = match kind {
|
||||
WorkerControlStopKind::Cancel => {
|
||||
crate::worker::WorkspaceServerOperation::WorkerControlCancel {
|
||||
@@ -361,7 +349,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
"Runtime Worker control is not enabled for this Worker".to_string(),
|
||||
));
|
||||
}
|
||||
self.authorize_runtime_worker("worker.restore", &runtime_id, &worker_id)?;
|
||||
self.authorize_runtime_operation("worker.restore")?;
|
||||
self.client.execute_server_operation(
|
||||
crate::worker::WorkspaceServerOperation::WorkerControlRestore {
|
||||
runtime_id,
|
||||
@@ -382,7 +370,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
"Runtime Worker control is not enabled for this Worker".to_string(),
|
||||
));
|
||||
}
|
||||
self.authorize_runtime_worker("worker.remove", runtime_id, worker_id)?;
|
||||
self.authorize_runtime_operation("worker.remove")?;
|
||||
self.client
|
||||
.execute_worker_remove(runtime_id, worker_id, reason)
|
||||
}
|
||||
@@ -416,50 +404,26 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
"Runtime Worker control is not enabled for this Worker".to_string(),
|
||||
));
|
||||
}
|
||||
self.authorize_runtime_worker(
|
||||
&format!("worker.{permission}"),
|
||||
runtime_id,
|
||||
worker_id,
|
||||
)?;
|
||||
let operation = if permission == "notify" {
|
||||
"worker.messaging".to_owned()
|
||||
} else {
|
||||
format!("worker.{permission}")
|
||||
};
|
||||
self.authorize_runtime_operation(&operation)?;
|
||||
let response = self.client.execute_server_operation(
|
||||
crate::worker::WorkspaceServerOperation::WorkerControlList,
|
||||
crate::worker::WorkspaceServerOperation::WorkerControlAuthorize {
|
||||
runtime_id: runtime_id.clone(),
|
||||
worker_id: worker_id.clone(),
|
||||
permission: permission.into(),
|
||||
},
|
||||
)?;
|
||||
if !response.is_success() {
|
||||
return Err(WorkspaceClientError::Request(format!(
|
||||
"Workspace control request returned {}: {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(&response.body).map_err(|error| {
|
||||
WorkspaceClientError::Request(format!(
|
||||
"invalid Workspace control response: {error}"
|
||||
))
|
||||
})?;
|
||||
let granted = body
|
||||
.get("items")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|item| {
|
||||
item.get("subject")
|
||||
.and_then(|value| value.get("runtime_id"))
|
||||
== Some(&serde_json::Value::String(runtime_id.clone()))
|
||||
&& item.get("subject").and_then(|value| value.get("worker_id"))
|
||||
== Some(&serde_json::Value::String(worker_id.clone()))
|
||||
&& item
|
||||
.get("permissions")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|permissions| {
|
||||
permissions.iter().any(|candidate| candidate == permission)
|
||||
})
|
||||
});
|
||||
if granted {
|
||||
if response.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WorkspaceClientError::Request(
|
||||
"unknown Worker or permission not granted".to_string(),
|
||||
))
|
||||
Err(WorkspaceClientError::Request(format!(
|
||||
"Worker target permission check returned {}: {}",
|
||||
response.status, response.body
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1106,6 +1070,32 @@ fn tool_output(
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
if operation == WorkerOperation::Notify {
|
||||
let notified: server_api::RuntimeWorkerInputResult = serde_json::from_str(&response.body)
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!("invalid WorkerNotify response: {error}"))
|
||||
})?;
|
||||
// HTTP transport success is not Runtime acceptance. Preserve uncertain
|
||||
// diagnostics in the error; callers must inspect history, not resend or
|
||||
// implicitly Restore a stopped destination.
|
||||
if notified.state != server_api::WorkerOperationState::Accepted
|
||||
|| notified
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "worker_input_outcome_unknown")
|
||||
{
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"WorkerNotify was not confirmed accepted (no automatic retry or Restore): {}",
|
||||
response.body
|
||||
)));
|
||||
}
|
||||
return Ok(ToolOutput {
|
||||
summary: "WorkerNotify accepted; model processing and work completion are unconfirmed"
|
||||
.into(),
|
||||
content: Some(response.body),
|
||||
attachments: Vec::new(),
|
||||
});
|
||||
}
|
||||
if operation == WorkerOperation::Restore {
|
||||
let restored: server_api::WorkerRestoreResponse = serde_json::from_str(&response.body)
|
||||
.map_err(|error| {
|
||||
@@ -1288,6 +1278,8 @@ mod tests {
|
||||
struct RecordingWorkspaceClient {
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
authorizations: Mutex<Vec<server_api::authorization::OperationAuthorizationRequest>>,
|
||||
bound_authorizations:
|
||||
Mutex<Vec<server_api::authorization::BoundOperationAuthorizationRequest>>,
|
||||
denied: std::sync::atomic::AtomicBool,
|
||||
removals: Mutex<Vec<(String, String, String)>>,
|
||||
}
|
||||
@@ -1321,7 +1313,16 @@ mod tests {
|
||||
body: r#"{"scope":null}"#.into(),
|
||||
});
|
||||
}
|
||||
if request.path.ends_with("/authorization/bound-check") {
|
||||
let auth = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
self.bound_authorizations.lock().unwrap().push(auth);
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: r#"{"scope":null}"#.into(),
|
||||
});
|
||||
}
|
||||
let is_worker_list = request.path.ends_with("/worker-control/workers");
|
||||
let is_worker_input = request.path.ends_with("/input");
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
@@ -1333,6 +1334,7 @@ mod tests {
|
||||
"runtime_id": "runtime-a",
|
||||
"worker_id": "worker-a"
|
||||
},
|
||||
"permissions": ["observe"],
|
||||
"summary": { "status": "idle" }
|
||||
},
|
||||
{
|
||||
@@ -1340,11 +1342,14 @@ mod tests {
|
||||
"runtime_id": "runtime-a",
|
||||
"worker_id": "worker-a"
|
||||
},
|
||||
"permissions": ["observe"],
|
||||
"summary": { "status": "idle" }
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string()
|
||||
} else if is_worker_input {
|
||||
serde_json::json!({"state":"accepted","runtime_id":"runtime-a","worker_id":"worker-a","diagnostics":[]}).to_string()
|
||||
} else {
|
||||
"{}".to_string()
|
||||
},
|
||||
@@ -1476,12 +1481,48 @@ mod tests {
|
||||
fn test_control(client: Arc<RecordingWorkspaceClient>) -> Arc<dyn WorkerControlService> {
|
||||
Arc::new(WorkspaceWorkerControlService {
|
||||
client,
|
||||
workspace_id: "workspace%2Ftest".to_string(),
|
||||
workspace_id: "workspace/test".to_string(),
|
||||
registry: None,
|
||||
runtime_worker_control: true,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_transport_success_requires_runtime_acceptance_and_preserves_unknown_diagnostics() {
|
||||
for body in [
|
||||
serde_json::json!({"state":"rejected","runtime_id":"r","worker_id":"w","diagnostics":[]}),
|
||||
serde_json::json!({"state":"unsupported","runtime_id":"r","worker_id":"w","diagnostics":[]}),
|
||||
serde_json::json!({"state":"rejected","runtime_id":"r","worker_id":"w","diagnostics":[{"code":"worker_input_outcome_unknown","severity":"warning","message":"inspect current session before retrying"}]}),
|
||||
] {
|
||||
let error = tool_output(
|
||||
WorkerOperation::Notify,
|
||||
WorkspaceResponse {
|
||||
status: 200,
|
||||
body: body.to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("not confirmed accepted"), "{error}");
|
||||
assert!(error.contains("no automatic retry or Restore"), "{error}");
|
||||
if !body["diagnostics"].as_array().unwrap().is_empty() {
|
||||
assert!(error.contains("worker_input_outcome_unknown"));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
tool_output(
|
||||
WorkerOperation::Notify,
|
||||
WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let accepted = tool_output(WorkerOperation::Notify,WorkspaceResponse{status:200,body:serde_json::json!({"state":"accepted","runtime_id":"r","worker_id":"w","diagnostics":[]}).to_string()}).unwrap();
|
||||
assert!(accepted.summary.contains("unconfirmed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_restore_forwards_exact_observation_and_request_identity() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
@@ -1679,7 +1720,7 @@ mod tests {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let control = WorkspaceWorkerControlService {
|
||||
client: client.clone(),
|
||||
workspace_id: "workspace%2Ftest".to_string(),
|
||||
workspace_id: "workspace/test".to_string(),
|
||||
registry: None,
|
||||
runtime_worker_control: true,
|
||||
};
|
||||
@@ -1715,7 +1756,7 @@ mod tests {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let control = WorkspaceWorkerControlService {
|
||||
client: client.clone(),
|
||||
workspace_id: "workspace%2Ftest".to_string(),
|
||||
workspace_id: "workspace/test".to_string(),
|
||||
registry: None,
|
||||
runtime_worker_control: true,
|
||||
};
|
||||
@@ -1764,7 +1805,7 @@ mod tests {
|
||||
let registry = SpawnedWorkerRegistry::new(runtime_dir);
|
||||
let control: Arc<dyn WorkerControlService> = Arc::new(WorkspaceWorkerControlService {
|
||||
client: client.clone(),
|
||||
workspace_id: "workspace%2Ftest".to_string(),
|
||||
workspace_id: "workspace/test".to_string(),
|
||||
registry: Some(registry),
|
||||
runtime_worker_control: false,
|
||||
});
|
||||
@@ -1832,7 +1873,7 @@ mod tests {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let control: Arc<dyn WorkerControlService> = Arc::new(WorkspaceWorkerControlService {
|
||||
client: client.clone(),
|
||||
workspace_id: "workspace%2Ftest".to_string(),
|
||||
workspace_id: "workspace/test".to_string(),
|
||||
registry: None,
|
||||
runtime_worker_control: false,
|
||||
});
|
||||
@@ -2138,6 +2179,28 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let check = client
|
||||
.authorizations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last()
|
||||
.cloned()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
check.operation,
|
||||
match operation {
|
||||
WorkerOperation::SendInput => "worker.send_input",
|
||||
WorkerOperation::Notify => "worker.messaging",
|
||||
WorkerOperation::Cancel => "worker.cancel",
|
||||
_ => unreachable!(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
check.resource,
|
||||
PermissionResource::WorkerOperation {
|
||||
workspace_id: "workspace/test".into(),
|
||||
}
|
||||
);
|
||||
let request = client.requests.lock().unwrap().last().cloned().unwrap();
|
||||
assert!(request.path.ends_with(expected_suffix));
|
||||
assert!(request.path.contains("/worker-control/workers/"));
|
||||
@@ -2326,12 +2389,12 @@ mod tests {
|
||||
};
|
||||
match op {
|
||||
"worker.list" => control.list_workers().await,
|
||||
"worker.send_input" | "worker.notify" => {
|
||||
"worker.send_input" | "worker.messaging" => {
|
||||
control
|
||||
.send_input(
|
||||
subject,
|
||||
"input".into(),
|
||||
if op == "worker.notify" {
|
||||
if op == "worker.messaging" {
|
||||
WorkerControlInputKind::Notify
|
||||
} else {
|
||||
WorkerControlInputKind::User
|
||||
@@ -2369,6 +2432,21 @@ mod tests {
|
||||
"target-worker",
|
||||
"requested removal",
|
||||
),
|
||||
"worker.observe" => {
|
||||
control
|
||||
.ensure_permission(
|
||||
&WorkerObservationSubjectRef::RuntimeWorker {
|
||||
runtime_id: "runtime-a".into(),
|
||||
worker_id: "worker-a".into(),
|
||||
},
|
||||
"observe",
|
||||
)
|
||||
.await?;
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
})
|
||||
}
|
||||
"worker.create" => {
|
||||
control
|
||||
.spawn_worker(WorkerLifecycleSpawnRequest {
|
||||
@@ -2391,11 +2469,12 @@ mod tests {
|
||||
for op in [
|
||||
"worker.list",
|
||||
"worker.send_input",
|
||||
"worker.notify",
|
||||
"worker.messaging",
|
||||
"worker.cancel",
|
||||
"worker.stop",
|
||||
"worker.restore",
|
||||
"worker.remove",
|
||||
"worker.observe",
|
||||
"worker.create",
|
||||
] {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
@@ -2433,23 +2512,134 @@ mod tests {
|
||||
check.resource,
|
||||
if matches!(op, "worker.list" | "worker.create") {
|
||||
PermissionResource::Workspace {
|
||||
workspace_id: if op == "worker.list" {
|
||||
"workspace%2Ftest"
|
||||
} else {
|
||||
"workspace/test"
|
||||
}
|
||||
.into(),
|
||||
workspace_id: "workspace/test".into(),
|
||||
}
|
||||
} else {
|
||||
PermissionResource::Worker {
|
||||
runtime_id: "target-runtime".into(),
|
||||
worker_id: "target-worker".into(),
|
||||
PermissionResource::WorkerOperation {
|
||||
workspace_id: "workspace/test".into(),
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Connection: published Tool registration and dispatch, production typed control
|
||||
// service and authorization transport; the Host and Backend are fixture boundaries.
|
||||
#[tokio::test]
|
||||
async fn published_runtime_tools_reject_missing_operation_grant_without_backend_effects() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = crate::HookRegistryBuilder::default();
|
||||
let report = crate::feature::FeatureRegistryBuilder::new()
|
||||
.with_module(manage_worker_feature(client.clone(), None, true))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
assert!(report.reports[0].installed);
|
||||
let tools: Vec<_> = pending_tools
|
||||
.into_iter()
|
||||
.map(|definition| definition())
|
||||
.collect();
|
||||
client
|
||||
.denied
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
for (tool_name, operation, fields) in [
|
||||
(
|
||||
"WorkerSendInput",
|
||||
"worker.send_input",
|
||||
serde_json::json!({"content":"continue"}),
|
||||
),
|
||||
(
|
||||
"WorkerNotify",
|
||||
"worker.messaging",
|
||||
serde_json::json!({"content":"result ready"}),
|
||||
),
|
||||
(
|
||||
"WorkerCancel",
|
||||
"worker.cancel",
|
||||
serde_json::json!({"reason":"cancel"}),
|
||||
),
|
||||
(
|
||||
"WorkerStop",
|
||||
"worker.stop",
|
||||
serde_json::json!({"reason":"stop"}),
|
||||
),
|
||||
(
|
||||
"WorkerRestore",
|
||||
"worker.restore",
|
||||
serde_json::json!({"expected_observation_token":"observed"}),
|
||||
),
|
||||
(
|
||||
"WorkerRemove",
|
||||
"worker.remove",
|
||||
serde_json::json!({"reason":"retire"}),
|
||||
),
|
||||
] {
|
||||
let (_, tool) = tools
|
||||
.iter()
|
||||
.find(|(meta, _)| meta.name == tool_name)
|
||||
.unwrap();
|
||||
for worker_id in ["worker-a", "worker-b"] {
|
||||
let mut input = fields.clone();
|
||||
input["subject"] = serde_json::json!({
|
||||
"kind":"runtime_worker", "runtime_id":"runtime-a", "worker_id":worker_id,
|
||||
});
|
||||
let error = tool
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("authorization denied"),
|
||||
"{tool_name}: {error}"
|
||||
);
|
||||
let checks = client.authorizations.lock().unwrap();
|
||||
let check = checks.last().unwrap();
|
||||
assert_eq!(check.operation, operation);
|
||||
assert_eq!(
|
||||
check.resource,
|
||||
PermissionResource::WorkerOperation {
|
||||
workspace_id: "workspace/test".into(),
|
||||
}
|
||||
);
|
||||
assert!(client.requests.lock().unwrap().is_empty(), "{tool_name}");
|
||||
assert!(client.removals.lock().unwrap().is_empty(), "{tool_name}");
|
||||
}
|
||||
}
|
||||
assert_eq!(client.authorizations.lock().unwrap().len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_operation_denial_does_not_replace_host_bound_subworker_authorization() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
client
|
||||
.denied
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let control = WorkspaceWorkerControlService {
|
||||
client: client.clone(),
|
||||
workspace_id: "workspace/test".into(),
|
||||
registry: None,
|
||||
runtime_worker_control: true,
|
||||
};
|
||||
for operation in [
|
||||
"subworker.send_input",
|
||||
"subworker.stop",
|
||||
"subworker.observe",
|
||||
] {
|
||||
control.authorize_subworker(operation, "child").unwrap();
|
||||
let checks = client.bound_authorizations.lock().unwrap();
|
||||
let check = checks.last().unwrap();
|
||||
assert_eq!(check.operation, operation);
|
||||
assert_eq!(
|
||||
check.target,
|
||||
server_api::authorization::BoundAuthorizationTarget::SubWorker {
|
||||
name: "child".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(client.bound_authorizations.lock().unwrap().len(), 3);
|
||||
assert!(client.authorizations.lock().unwrap().is_empty());
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
assert!(client.removals.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_explicit_standalone_retains_local_subworker_authority_without_host() {
|
||||
for workspace_id in [None, Some("bound-workspace".to_string())] {
|
||||
|
||||
@@ -125,14 +125,10 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
|
||||
)
|
||||
})?;
|
||||
match subject {
|
||||
WorkerObservationSubjectRef::RuntimeWorker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => self.client.authorize_operation(
|
||||
WorkerObservationSubjectRef::RuntimeWorker { .. } => self.client.authorize_operation(
|
||||
"worker.observe",
|
||||
&server_api::authorization::PermissionResource::Worker {
|
||||
runtime_id: runtime_id.clone(),
|
||||
worker_id: worker_id.clone(),
|
||||
&server_api::authorization::PermissionResource::WorkerOperation {
|
||||
workspace_id: _workspace_id.into(),
|
||||
},
|
||||
),
|
||||
WorkerObservationSubjectRef::SubWorker { name } => self
|
||||
|
||||
@@ -762,6 +762,11 @@ pub enum WorkspaceServerOperation {
|
||||
result_id: String,
|
||||
},
|
||||
WorkerControlList,
|
||||
WorkerControlAuthorize {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
permission: String,
|
||||
},
|
||||
WorkerControlSpawn(server_api::CreateWorkspaceWorkerRequest),
|
||||
WorkerControlInput {
|
||||
runtime_id: String,
|
||||
@@ -960,6 +965,16 @@ fn workspace_server_operation_request(
|
||||
WorkspaceServerOperation::WorkerControlList => Ok(WorkspaceRequest::get(format!(
|
||||
"{base}/worker-control/workers"
|
||||
))),
|
||||
WorkspaceServerOperation::WorkerControlAuthorize {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
permission,
|
||||
} => Ok(WorkspaceRequest::get(format!(
|
||||
"{base}/worker-control/workers/{}/{}/permissions/{}",
|
||||
encode_workspace_path_segment(&runtime_id),
|
||||
encode_workspace_path_segment(&worker_id),
|
||||
encode_workspace_path_segment(&permission)
|
||||
))),
|
||||
WorkspaceServerOperation::WorkerControlSpawn(request) => {
|
||||
workspace_server_json_request(format!("{base}/worker-control/workers"), &request)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user