feat: add privileged workspace worker discovery
This commit is contained in:
@@ -27,6 +27,7 @@ toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tools = { workspace = true }
|
||||
workdir = { workspace = true }
|
||||
workspace-api = { workspace = true }
|
||||
minijinja = "2.19.0"
|
||||
chrono = "0.4"
|
||||
config-source = { path = "../config-source" }
|
||||
|
||||
@@ -958,6 +958,23 @@ where
|
||||
crate::feature::builtin::manage_workdir::manage_workdir_feature(workspace_client),
|
||||
);
|
||||
}
|
||||
if feature_config.workspace_worker_discovery.enabled {
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let has_workspace_identity = workspace_client.workspace_id().is_some_and(|workspace_id| {
|
||||
!workspace_id.is_empty() && !workspace_id.chars().any(char::is_control)
|
||||
});
|
||||
if !workspace_client.is_available() || !has_workspace_identity {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Workspace Worker discovery requires Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
feature_registry.add_module(
|
||||
crate::feature::builtin::workspace_worker_discovery::workspace_worker_discovery_feature(
|
||||
workspace_client,
|
||||
),
|
||||
);
|
||||
}
|
||||
if feature_config.worker.enabled {
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let has_workspace_identity = workspace_client.workspace_id().is_some_and(|workspace_id| {
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod session_explore;
|
||||
pub mod task;
|
||||
pub mod ticket;
|
||||
pub mod worker_observation;
|
||||
pub mod workspace_worker_discovery;
|
||||
|
||||
pub(crate) use memory_extract::{MemoryExtractFeature, MemoryExtractState, render_extract_input};
|
||||
pub(crate) use session_explore::{SessionExploreFeature, SessionExploreState};
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Privileged, read-only discovery of Workspace-visible Workers.
|
||||
//!
|
||||
//! This feature deliberately stays separate from the canonical `WorkerList`
|
||||
//! control-grant surface. Discovery results carry the typed subject needed by a
|
||||
//! later control operation, but discovery itself grants no control authority.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
|
||||
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceWorkerDiscoveryRequest};
|
||||
|
||||
const FEATURE_ID: &str = "workspace-worker-discovery";
|
||||
const TOOL_NAME: &str = "ListWorkspaceWorkers";
|
||||
const DEFAULT_LIMIT: usize = 50;
|
||||
const MAX_LIMIT: usize = 100;
|
||||
const INSTRUCTION_ID: &str = "workspace-worker-discovery.policy";
|
||||
const PROMPT_REF: &str = "common.workspace_worker_discovery";
|
||||
const DESCRIPTION: &str = "List or directly find Workspace-visible Workers through Backend authority. Results include each W-key and the typed runtime_worker subject needed by later Worker control calls, but do not grant control authority.";
|
||||
|
||||
fn instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
FeatureInstructionId::builtin(INSTRUCTION_ID),
|
||||
PROMPT_REF,
|
||||
"Workspace Worker discovery and control-authority separation",
|
||||
)
|
||||
.expect("static Workspace Worker discovery instruction is valid")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceWorkerDiscoveryFeature {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
pub fn workspace_worker_discovery_feature(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
) -> WorkspaceWorkerDiscoveryFeature {
|
||||
WorkspaceWorkerDiscoveryFeature { client }
|
||||
}
|
||||
|
||||
impl FeatureModule for WorkspaceWorkerDiscoveryFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin(FEATURE_ID, "Workspace Worker Discovery")
|
||||
.with_description(DESCRIPTION)
|
||||
.with_instruction(instruction())
|
||||
.with_tool(ToolDeclaration::new(TOOL_NAME, DESCRIPTION))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context
|
||||
.instructions()
|
||||
.register(FeatureInstructionContribution::new(instruction()))?;
|
||||
let client = self.client.clone();
|
||||
let definition: ToolDefinition = Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(TOOL_NAME)
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(input_schema()),
|
||||
Arc::new(ListWorkspaceWorkersTool {
|
||||
client: client.clone(),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
});
|
||||
context
|
||||
.tools()
|
||||
.register(ToolContribution::new(TOOL_NAME, definition))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ListWorkspaceWorkersInput {
|
||||
#[serde(default)]
|
||||
cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
query: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ListWorkspaceWorkersTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ListWorkspaceWorkersTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: ListWorkspaceWorkersInput = serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
let limit = input.limit.unwrap_or(DEFAULT_LIMIT);
|
||||
if !(1..=MAX_LIMIT).contains(&limit) {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"limit must be between 1 and {MAX_LIMIT}"
|
||||
)));
|
||||
}
|
||||
let query = input.query.map(|query| query.trim().to_string());
|
||||
if query.as_deref().is_some_and(str::is_empty) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"query must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if query.as_ref().is_some_and(|query| query.len() > 128) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"query must not exceed 128 bytes".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let page = self
|
||||
.client
|
||||
.list_workspace_workers(WorkspaceWorkerDiscoveryRequest {
|
||||
cursor: input.cursor,
|
||||
limit,
|
||||
query,
|
||||
})
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
let count = page.workers.len();
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Listed {count} Workspace Worker(s)"),
|
||||
content: Some(serde_json::to_string_pretty(&page).map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"encode Workspace Worker discovery result: {error}"
|
||||
))
|
||||
})?),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn input_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"description": "Opaque cursor returned by a prior page."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": MAX_LIMIT,
|
||||
"default": DEFAULT_LIMIT
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"description": "Exact W-key or Worker display name lookup."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use workspace_api::{
|
||||
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordingClient {
|
||||
requests: Mutex<Vec<WorkspaceWorkerDiscoveryRequest>>,
|
||||
result: WorkspaceWorkerDiscoveryPage,
|
||||
unavailable: bool,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for RecordingClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-1")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"recording"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
!self.unavailable
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
panic!("discovery must not use generic Workspace request authority")
|
||||
}
|
||||
|
||||
fn list_workspace_workers(
|
||||
&self,
|
||||
request: WorkspaceWorkerDiscoveryRequest,
|
||||
) -> Result<WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
if self.unavailable {
|
||||
Err(WorkspaceClientError::Unavailable("denied".to_string()))
|
||||
} else {
|
||||
Ok(self.result.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn page() -> WorkspaceWorkerDiscoveryPage {
|
||||
WorkspaceWorkerDiscoveryPage {
|
||||
workers: vec![WorkspaceWorkerDiscoveryItem {
|
||||
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
||||
runtime_id: "arcadia".to_string(),
|
||||
worker_id: "worker-1".to_string(),
|
||||
},
|
||||
resource_key: "W-12".to_string(),
|
||||
display_name: "coder-one".to_string(),
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
status: Some("idle".to_string()),
|
||||
}],
|
||||
next_cursor: Some("v1:1".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_preserves_typed_subject_and_forwards_lookup() {
|
||||
let client = Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
result: page(),
|
||||
unavailable: false,
|
||||
});
|
||||
let tool = ListWorkspaceWorkersTool {
|
||||
client: client.clone(),
|
||||
};
|
||||
let output = tool
|
||||
.execute(
|
||||
r#"{"query":" W-12 ","limit":1}"#,
|
||||
ToolExecutionContext::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(value["workers"][0]["resource_key"], "W-12");
|
||||
assert_eq!(value["workers"][0]["subject"]["kind"], "runtime_worker");
|
||||
assert_eq!(value["workers"][0]["subject"]["runtime_id"], "arcadia");
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].query.as_deref(), Some("W-12"));
|
||||
assert_eq!(requests[0].limit, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_backend_authority_fails_closed() {
|
||||
let client = Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
result: page(),
|
||||
unavailable: true,
|
||||
});
|
||||
let error = ListWorkspaceWorkersTool { client }
|
||||
.execute("{}", ToolExecutionContext::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("denied"));
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,6 @@ pub use worker::{
|
||||
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
|
||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
|
||||
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspacePromptCatalogResolution,
|
||||
WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, apply_worker_manifest,
|
||||
marker_workspace_client, unavailable_workspace_client,
|
||||
WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, WorkspaceWorkerDiscoveryRequest,
|
||||
apply_worker_manifest, marker_workspace_client, unavailable_workspace_client,
|
||||
};
|
||||
|
||||
@@ -251,6 +251,13 @@ impl WorkspacePromptCatalogResolution {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceWorkerDiscoveryRequest {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: usize,
|
||||
pub query: Option<String>,
|
||||
}
|
||||
|
||||
/// Path-free Workspace operation authority injected by Runtime/host code.
|
||||
///
|
||||
/// Workers receive this trait object rather than a Backend URL. The concrete
|
||||
@@ -263,6 +270,18 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
||||
fn execute(&self, request: WorkspaceRequest)
|
||||
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
|
||||
/// Lists Workspace-visible Workers through dedicated Runtime-owned source
|
||||
/// proof. Implementations must not fall back to generic Workspace request
|
||||
/// authority.
|
||||
fn list_workspace_workers(
|
||||
&self,
|
||||
_request: WorkspaceWorkerDiscoveryRequest,
|
||||
) -> Result<workspace_api::WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
|
||||
Err(WorkspaceClientError::Unavailable(
|
||||
"Workspace Worker discovery authority is unavailable".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolve the Workspace's current immutable Prompt projection for future
|
||||
/// operation boundaries. Creation and restore continue to use persisted
|
||||
/// launch/session state; this hook never reconstructs historical prompts.
|
||||
|
||||
@@ -180,6 +180,30 @@ async fn make_worker_with_pwd(
|
||||
make_worker_with_pwd_and_manifest(client, MANIFEST_TOML).await
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AvailableWorkspaceClient;
|
||||
|
||||
impl WorkspaceClient for AvailableWorkspaceClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-1")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
Err(WorkspaceClientError::Unavailable("test".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NoopWorkspaceClient;
|
||||
|
||||
@@ -776,6 +800,45 @@ permission = "write"
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_worker_discovery_requires_workspace_authority_and_stays_separate_from_worker_list()
|
||||
{
|
||||
let manifest_toml = r#"
|
||||
[worker]
|
||||
name = "workspace-worker-discovery-test"
|
||||
pwd = "./"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[engine]
|
||||
max_tokens = 100
|
||||
|
||||
[feature.workspace_worker_discovery]
|
||||
enabled = true
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "write"
|
||||
"#;
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let client_for_assert = client.clone();
|
||||
let (worker, _pwd) = make_worker_with_pwd_manifest_and_workspace_context(
|
||||
client,
|
||||
manifest_toml,
|
||||
WorkerWorkspaceContext::with_client(None, Arc::new(AvailableWorkspaceClient)),
|
||||
)
|
||||
.await;
|
||||
let handle = spawn_controller(worker).await;
|
||||
handle.send(Method::run_text("Hello")).await.unwrap();
|
||||
wait_for_status(&handle, WorkerStatus::Idle).await;
|
||||
let request = wait_for_captured_request(&client_for_assert).await;
|
||||
let names = request_tool_names(&request);
|
||||
assert!(names.iter().any(|name| name == "ListWorkspaceWorkers"));
|
||||
assert!(!names.iter().any(|name| name == "WorkerList"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_worker_feature_exposure_does_not_require_delegation_scope() {
|
||||
let manifest = r#"
|
||||
|
||||
Reference in New Issue
Block a user