feat: enforce explicit Subject permissions and delegated Worker grants
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,13 @@ where
|
||||
pub async fn prepare(
|
||||
self,
|
||||
) -> Result<PreparedWorker<Box<dyn LlmClient>, St>, WorkerBootstrapError> {
|
||||
crate::feature::authorize_requested_manifest(
|
||||
self.workspace_context.client_handle().as_ref(),
|
||||
&self.manifest,
|
||||
)
|
||||
.map_err(|error| {
|
||||
WorkerBootstrapError::Worker(WorkerError::FeatureLifecycle(error.to_string()))
|
||||
})?;
|
||||
let mut worker = Worker::from_manifest_with_context_and_model_client(
|
||||
self.manifest,
|
||||
self.store,
|
||||
@@ -197,6 +204,13 @@ where
|
||||
.await
|
||||
.map_err(WorkerBootstrapError::Worker)?;
|
||||
|
||||
crate::feature::authorize_requested_manifest(
|
||||
worker.workspace_client_handle().as_ref(),
|
||||
worker.manifest(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
WorkerBootstrapError::Worker(WorkerError::FeatureLifecycle(error.to_string()))
|
||||
})?;
|
||||
if let Some(workdir_session) = self.workdir_session {
|
||||
worker.bind_single_workdir_session(Some(workdir_session));
|
||||
}
|
||||
|
||||
@@ -1453,6 +1453,14 @@ where
|
||||
// Worker-immutable snapshots taken before the mutable worker borrow
|
||||
// below so the worker borrow doesn't conflict with reads on `worker`.
|
||||
let feature_config = worker.manifest().feature.clone();
|
||||
// First boundary: before any Feature initialization, Tool/WIP/service
|
||||
// registration, prompt contribution, or discovery side effect. Host derives
|
||||
// the live caller grant; the Profile only requests these switches.
|
||||
crate::feature::authorize_requested_manifest(
|
||||
worker.workspace_client_handle().as_ref(),
|
||||
worker.manifest(),
|
||||
)
|
||||
.map_err(|error| std::io::Error::new(std::io::ErrorKind::PermissionDenied, error))?;
|
||||
let workdir_sessions = worker.workdir_sessions();
|
||||
let restrict_workdir_tools_to_current_capabilities = inherited_workdir_tool_broker.is_some();
|
||||
let workdir_tool_broker = Some(
|
||||
@@ -4139,6 +4147,13 @@ mod tests {
|
||||
request: crate::worker::WorkspaceRequest,
|
||||
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError>
|
||||
{
|
||||
if request.path == "/api/w/workspace-1/authorization/check" {
|
||||
assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Post);
|
||||
return Ok(crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::json!({"scope":null}).to_string(),
|
||||
});
|
||||
}
|
||||
// Native discovery reads metadata and the current caller's ceiling, never inventory or mutations.
|
||||
assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Get);
|
||||
if request.path == "/api/w/workspace-1/drive/capabilities" {
|
||||
@@ -4217,7 +4232,7 @@ mod tests {
|
||||
worker.workdir_sessions(),
|
||||
)
|
||||
.descriptor();
|
||||
register_worker_tools(
|
||||
let registered = register_worker_tools(
|
||||
&mut worker,
|
||||
root.path().join("bash-output"),
|
||||
root.path().join("runtime"),
|
||||
@@ -4225,8 +4240,16 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
if enabled && (!available || workspace.is_none()) {
|
||||
assert_eq!(
|
||||
registered.unwrap_err().kind(),
|
||||
std::io::ErrorKind::PermissionDenied
|
||||
);
|
||||
assert!(requests.lock().unwrap().is_empty());
|
||||
continue;
|
||||
}
|
||||
registered.unwrap();
|
||||
let configured = enabled && available && workspace.is_some();
|
||||
let tools = worker.engine().tool_server_handle();
|
||||
tools.flush_pending();
|
||||
|
||||
@@ -1751,6 +1751,59 @@ pub enum FeaturePlanError {
|
||||
/// Builder/installer for enabled feature modules.
|
||||
pub struct FeatureRegistryBuilder {
|
||||
modules: Vec<Arc<dyn FeatureModule>>,
|
||||
activation_guard: Option<Arc<dyn Fn() -> Result<(), String> + Send + Sync>>,
|
||||
}
|
||||
|
||||
/// Profile switches request activation, not authority. Names are the actual
|
||||
/// public config keys (not internal module ids such as `builtin:sub-worker-control`).
|
||||
/// Check even switches whose runtime prerequisites will later make them inert.
|
||||
/// No parent/child permission inheritance is performed here.
|
||||
pub(crate) fn authorize_requested_features(
|
||||
client: &dyn crate::worker::WorkspaceClient,
|
||||
config: &manifest::FeatureConfig,
|
||||
) -> Result<(), crate::worker::WorkspaceClientError> {
|
||||
for (name, enabled) in [
|
||||
("task", config.task.enabled),
|
||||
("memory", config.memory.enabled()),
|
||||
("subjektiv", config.subjektiv.enabled()),
|
||||
("web", config.web.enabled),
|
||||
("image", config.image.enabled),
|
||||
("sub_worker", config.sub_worker.enabled),
|
||||
("flow", config.flow.enabled),
|
||||
("worker", config.worker.enabled),
|
||||
(
|
||||
"workspace_worker_discovery",
|
||||
config.workspace_worker_discovery.enabled,
|
||||
),
|
||||
("objective", config.objective.enabled),
|
||||
("manage_workdir", config.manage_workdir.enabled),
|
||||
("workdir_catalog", config.workdir_catalog.enabled),
|
||||
("workspace_config", config.workspace_config.enabled),
|
||||
("drive", config.drive.enabled),
|
||||
("ticket", config.ticket.enabled),
|
||||
("merge_request", config.merge_request.any()),
|
||||
("orchestration", config.orchestration.enabled),
|
||||
] {
|
||||
if enabled {
|
||||
client.authorize_feature_activation(name).map_err(|error| {
|
||||
crate::worker::WorkspaceClientError::Request(format!(
|
||||
"Feature `{name}` activation rejected: {error}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn authorize_requested_manifest(
|
||||
client: &dyn crate::worker::WorkspaceClient,
|
||||
manifest: &manifest::WorkerManifest,
|
||||
) -> Result<(), crate::worker::WorkspaceClientError> {
|
||||
authorize_requested_features(client, &manifest.feature)?;
|
||||
if !manifest.mcp.stdio_servers.is_empty() {
|
||||
client.authorize_feature_activation("mcp")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_feature_install_plan(
|
||||
@@ -1945,6 +1998,7 @@ impl Default for FeatureRegistryBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
modules: Vec::new(),
|
||||
activation_guard: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1954,6 +2008,22 @@ impl FeatureRegistryBuilder {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add a trusted, live preflight that runs before *any* module install. Worker
|
||||
/// hosts use this in addition to the earlier pre-construction preflight.
|
||||
pub fn with_activation_guard(
|
||||
mut self,
|
||||
guard: impl Fn() -> Result<(), String> + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
let previous = self.activation_guard.take();
|
||||
self.activation_guard = Some(Arc::new(move || {
|
||||
if let Some(previous) = &previous {
|
||||
previous()?;
|
||||
}
|
||||
guard()
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_module<M>(&mut self, module: M) -> &mut Self
|
||||
where
|
||||
M: FeatureModule + 'static,
|
||||
@@ -2025,6 +2095,42 @@ impl FeatureRegistryBuilder {
|
||||
hook_builder: &mut HookRegistryBuilder,
|
||||
mut installed_tool_names: HashMap<String, FeatureId>,
|
||||
) -> FeatureRegistryInstallReport {
|
||||
if let Some(error) = self
|
||||
.activation_guard
|
||||
.as_ref()
|
||||
.and_then(|guard| guard().err())
|
||||
{
|
||||
return FeatureRegistryInstallReport {
|
||||
reports: {
|
||||
let mut descriptors = self.descriptors();
|
||||
if descriptors.is_empty() {
|
||||
descriptors.push(FeatureDescriptor::builtin(
|
||||
"activation",
|
||||
"Feature activation",
|
||||
));
|
||||
}
|
||||
descriptors
|
||||
.iter()
|
||||
.map(|descriptor| {
|
||||
let mut report = FeatureInstallReport::new(descriptor);
|
||||
report
|
||||
.diagnostics
|
||||
.push(FeatureDiagnostic::error(error.clone()));
|
||||
report.mark_skipped(
|
||||
FeatureContributionKind::Diagnostic,
|
||||
descriptor.id.to_string(),
|
||||
"Feature activation rejected before installation",
|
||||
);
|
||||
report
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
services: FeatureServiceRegistry::default(),
|
||||
background_tasks: FeatureBackgroundTaskRegistry::default(),
|
||||
chat_invocations: FeatureInvocationRegistry::default(),
|
||||
plan_error: None,
|
||||
};
|
||||
}
|
||||
let descriptors: Vec<_> = self
|
||||
.modules
|
||||
.iter()
|
||||
@@ -2300,6 +2406,170 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ActivationClient {
|
||||
allowed: Vec<String>,
|
||||
requested: std::sync::Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl crate::worker::WorkspaceClient for ActivationClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"activation-fixture"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: crate::worker::WorkspaceRequest,
|
||||
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
|
||||
use server_api::authorization::{OperationAuthorizationRequest, PermissionResource};
|
||||
assert_eq!(request.path, "/api/w/workspace/authorization/check");
|
||||
let input: OperationAuthorizationRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(input.resource, PermissionResource::Feature);
|
||||
self.requested.lock().unwrap().push(input.operation.clone());
|
||||
Ok(crate::worker::WorkspaceResponse {
|
||||
status: if self.allowed.contains(&input.operation) {
|
||||
200
|
||||
} else {
|
||||
403
|
||||
},
|
||||
body: "{}".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_activation_uses_exact_public_config_names_before_install() {
|
||||
let mut config = manifest::FeatureConfig::default();
|
||||
config.workdir_catalog.enabled = false;
|
||||
// Enabling each config key independently also detects default-on flags.
|
||||
for name in [
|
||||
"task",
|
||||
"memory",
|
||||
"subjektiv",
|
||||
"web",
|
||||
"image",
|
||||
"sub_worker",
|
||||
"flow",
|
||||
"worker",
|
||||
"workspace_worker_discovery",
|
||||
"objective",
|
||||
"manage_workdir",
|
||||
"workdir_catalog",
|
||||
"workspace_config",
|
||||
"drive",
|
||||
"ticket",
|
||||
"merge_request",
|
||||
"orchestration",
|
||||
] {
|
||||
let mut value = serde_json::to_value(&config).unwrap();
|
||||
if name == "memory" || name == "subjektiv" {
|
||||
value[name]["profile"]["enabled"] = json!(true);
|
||||
} else if name == "merge_request" {
|
||||
value[name]["show"] = json!(true);
|
||||
} else {
|
||||
value[name]["enabled"] = json!(true);
|
||||
}
|
||||
let requested: manifest::FeatureConfig = serde_json::from_value(value).unwrap();
|
||||
let client = ActivationClient {
|
||||
allowed: vec![name.into()],
|
||||
requested: Default::default(),
|
||||
};
|
||||
authorize_requested_features(&client, &requested).unwrap();
|
||||
assert_eq!(*client.requested.lock().unwrap(), [name], "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_operation_permission_does_not_enable_feature_or_install_services() {
|
||||
let mut config = manifest::FeatureConfig::default();
|
||||
config.workdir_catalog.enabled = false;
|
||||
config.ticket.enabled = true;
|
||||
for allowed in [vec![], vec!["ticket.create"], vec!["ticket"]] {
|
||||
let client = Arc::new(ActivationClient {
|
||||
allowed: allowed.iter().map(|name| name.to_string()).collect(),
|
||||
requested: Default::default(),
|
||||
});
|
||||
let module = PlannedServiceFeature::new(
|
||||
FeatureDescriptor::builtin("ticket", "Ticket")
|
||||
.with_provided_service(provided_service("ticket.test", "1")),
|
||||
);
|
||||
let calls = module.install_calls.clone();
|
||||
let config = config.clone();
|
||||
let registry = FeatureRegistryBuilder::new()
|
||||
.with_module(module)
|
||||
.with_activation_guard(move || {
|
||||
authorize_requested_features(client.as_ref(), &config)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
let mut tools = Vec::new();
|
||||
let report = registry.install_into_pending(&mut tools, &mut HookRegistryBuilder::new());
|
||||
let permitted = allowed == ["ticket"];
|
||||
assert_eq!(report.has_errors(), !permitted);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), usize::from(permitted));
|
||||
assert!(tools.is_empty());
|
||||
if !permitted {
|
||||
assert!(report.error_message().contains("ticket"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_compatibility_is_explicit_and_never_grants_workspace_operations() {
|
||||
use crate::worker::{WorkspaceClient, unavailable_workspace_client};
|
||||
use server_api::authorization::PermissionResource;
|
||||
let local = unavailable_workspace_client(None, "standalone");
|
||||
assert!(local.authorize_feature_activation("task").is_ok());
|
||||
assert!(
|
||||
local
|
||||
.authorize_operation(
|
||||
"ticket.create",
|
||||
&PermissionResource::Workspace {
|
||||
workspace_id: "workspace".into()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let unbound = ActivationClient {
|
||||
allowed: vec!["ticket".into()],
|
||||
requested: Default::default(),
|
||||
};
|
||||
assert!(unbound.authorize_feature_activation("ticket").is_ok());
|
||||
let local_files = crate::worker::WorkerWorkspaceContext::local_filesystem(None);
|
||||
assert!(
|
||||
local_files
|
||||
.client_handle()
|
||||
.authorize_feature_activation("task")
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
local_files
|
||||
.client_handle()
|
||||
.authorize_operation(
|
||||
"ticket.create",
|
||||
&PermissionResource::Workspace {
|
||||
workspace_id: "workspace".into()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let marker = crate::worker::marker_workspace_client(None, "local-filesystem");
|
||||
assert!(
|
||||
marker.authorize_feature_activation("task").is_err(),
|
||||
"display kind is not authority"
|
||||
);
|
||||
let missing = unavailable_workspace_client(
|
||||
Some(&crate::worker::WorkspaceId::new("workspace").unwrap()),
|
||||
"Host unavailable",
|
||||
);
|
||||
assert!(missing.authorize_feature_activation("ticket").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_feature_composition_has_no_dynamic_plugin_install_path() {
|
||||
let feature_source = include_str!("feature.rs")
|
||||
|
||||
@@ -102,6 +102,26 @@ impl WorkspaceClient for InternalChildWorkspaceClient {
|
||||
self.inner.is_available()
|
||||
}
|
||||
|
||||
fn authorize_operation(
|
||||
&self,
|
||||
operation: &str,
|
||||
resource: &server_api::authorization::PermissionResource,
|
||||
) -> Result<(), WorkspaceClientError> {
|
||||
self.inner.authorize_operation(operation, resource)
|
||||
}
|
||||
|
||||
fn authorize_operation_scope(
|
||||
&self,
|
||||
operation: &str,
|
||||
resource: &server_api::authorization::PermissionResource,
|
||||
) -> Result<Option<server_api::authorization::PermissionResource>, WorkspaceClientError> {
|
||||
self.inner.authorize_operation_scope(operation, resource)
|
||||
}
|
||||
|
||||
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
|
||||
self.inner.authorize_feature_activation(feature)
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
|
||||
@@ -6,7 +6,6 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta
|
||||
use async_trait::async_trait;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use protocol::Segment;
|
||||
use server_api::{
|
||||
@@ -502,7 +501,10 @@ pub struct WorkerLifecycleSpawnRequest {
|
||||
pub profile: String,
|
||||
pub singleton_key: Option<String>,
|
||||
pub ticket_id: Option<String>,
|
||||
/// Required for ordinary and Ticket spawn; exact retries reuse this ID and full input.
|
||||
pub operation_id: Option<String>,
|
||||
/// Explicit requested authority. Profile selection alone grants nothing.
|
||||
pub execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
|
||||
pub display_name: String,
|
||||
pub initial_submit: Vec<Segment>,
|
||||
}
|
||||
@@ -514,25 +516,27 @@ struct WorkspaceWorkerLifecycleService {
|
||||
fn workspace_worker_create_request(
|
||||
request: WorkerLifecycleSpawnRequest,
|
||||
) -> Result<CreateWorkspaceWorkerRequest, WorkspaceClientError> {
|
||||
let ticket_assignment = match (request.ticket_id, request.operation_id) {
|
||||
(Some(ticket_id), Some(operation_id)) => {
|
||||
Some(CreateWorkspaceWorkerTicketAssignmentRequest {
|
||||
let control_operation_id = request.operation_id.filter(|id| {
|
||||
!id.trim().is_empty() && id.len() <= 256 && !id.chars().any(char::is_control)
|
||||
}).ok_or_else(|| WorkspaceClientError::Request(
|
||||
"operation_id is required for every Worker spawn (reuse the same ID and full input after an unknown outcome)".into()
|
||||
))?;
|
||||
if request.ticket_id.is_none() && request.orchestrator_assignment_id.is_some() {
|
||||
return Err(WorkspaceClientError::Request(
|
||||
"orchestrator_assignment_id requires ticket_id".into(),
|
||||
));
|
||||
}
|
||||
if let Some(grant) = &request.execution_grant {
|
||||
grant.validate().map_err(WorkspaceClientError::Request)?;
|
||||
}
|
||||
let ticket_assignment =
|
||||
request
|
||||
.ticket_id
|
||||
.map(|ticket_id| CreateWorkspaceWorkerTicketAssignmentRequest {
|
||||
orchestrator_assignment_id: request.orchestrator_assignment_id,
|
||||
ticket_id,
|
||||
operation_id,
|
||||
})
|
||||
}
|
||||
(None, None) => None,
|
||||
_ => {
|
||||
return Err(WorkspaceClientError::Request(
|
||||
"ticket_id and operation_id must be provided together".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let control_operation_id = ticket_assignment
|
||||
.as_ref()
|
||||
.map(|assignment| assignment.operation_id.clone())
|
||||
.unwrap_or_else(|| format!("worker-spawn-{}", Uuid::now_v7()));
|
||||
operation_id: control_operation_id.clone(),
|
||||
});
|
||||
Ok(CreateWorkspaceWorkerRequest {
|
||||
runtime_id: request.runtime_id,
|
||||
display_name: request.display_name,
|
||||
@@ -551,6 +555,7 @@ fn workspace_worker_create_request(
|
||||
.collect(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: Some(control_operation_id),
|
||||
execution_grant: request.execution_grant,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -744,7 +749,13 @@ struct WorkerSpawnInput {
|
||||
runtime_id: String,
|
||||
/// Singular Workdir selection wrapped into the shared attachment collection
|
||||
/// under the stable `workdir` alias.
|
||||
working_directory_id: String,
|
||||
#[serde(default)]
|
||||
working_directory_id: Option<String>,
|
||||
/// Stable caller-chosen intent ID. Exact retries retain this ID and full input.
|
||||
operation_id: String,
|
||||
/// Explicit permission/resource request; selecting a Profile grants nothing.
|
||||
#[serde(default)]
|
||||
execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
|
||||
profile: String,
|
||||
/// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted.
|
||||
#[serde(default)]
|
||||
@@ -931,13 +942,23 @@ impl Tool for WorkspaceWorkerTool {
|
||||
.ticket_id
|
||||
.map(|ticket_id| authority_id(&ticket_id, "ticket_id"))
|
||||
.transpose()?;
|
||||
let operation_id = ticket_id
|
||||
.as_ref()
|
||||
.map(|ticket_id| {
|
||||
let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?;
|
||||
Ok::<_, ToolError>(format!("worker-spawn:{ticket_id}:{call_id}"))
|
||||
})
|
||||
.transpose()?;
|
||||
let operation_id = Some(authority_id(&input.operation_id, "operation_id")?);
|
||||
let workdir_attachments = match input.working_directory_id {
|
||||
Some(id) => vec![WorkerLifecycleWorkdirAttachment {
|
||||
alias: INITIAL_WORKDIR_ALIAS.to_string(),
|
||||
working_directory_id: authority_id(&id, "working_directory_id")?,
|
||||
relative_cwd: input
|
||||
.relative_cwd
|
||||
.map(|value| validate_relative_cwd(&value))
|
||||
.transpose()?,
|
||||
}],
|
||||
None if input.relative_cwd.is_none() => Vec::new(),
|
||||
None => {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"relative_cwd requires working_directory_id".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
self.control
|
||||
.spawn_worker(WorkerLifecycleSpawnRequest {
|
||||
orchestrator_assignment_id: input
|
||||
@@ -945,17 +966,8 @@ impl Tool for WorkspaceWorkerTool {
|
||||
.map(|id| authority_id(&id, "orchestrator_assignment_id"))
|
||||
.transpose()?,
|
||||
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
|
||||
workdir_attachments: vec![WorkerLifecycleWorkdirAttachment {
|
||||
alias: INITIAL_WORKDIR_ALIAS.to_string(),
|
||||
working_directory_id: authority_id(
|
||||
&input.working_directory_id,
|
||||
"working_directory_id",
|
||||
)?,
|
||||
relative_cwd: input
|
||||
.relative_cwd
|
||||
.map(|value| validate_relative_cwd(&value))
|
||||
.transpose()?,
|
||||
}],
|
||||
workdir_attachments,
|
||||
execution_grant: input.execution_grant,
|
||||
profile: non_empty(input.profile, "profile")?,
|
||||
singleton_key: input.singleton_key,
|
||||
ticket_id,
|
||||
@@ -1594,6 +1606,7 @@ mod tests {
|
||||
"relative_cwd": "crates/worker",
|
||||
"profile": "builtin:coder",
|
||||
"ticket_id": "00001KZ9E0DBS",
|
||||
"operation_id": "stable-ticket-intent",
|
||||
"initial_submit": [
|
||||
{ "kind": "flow", "selector": "builtin:coder-review" },
|
||||
{ "kind": "text", "content": "Implement Ticket 00001" }
|
||||
@@ -1623,7 +1636,7 @@ mod tests {
|
||||
body["ticket_assignment"],
|
||||
serde_json::json!({
|
||||
"ticket_id": "00001KZ9E0DBS",
|
||||
"operation_id": "worker-spawn:00001KZ9E0DBS:call-1"
|
||||
"operation_id": "stable-ticket-intent"
|
||||
})
|
||||
);
|
||||
assert_eq!(body["workdir_attachments"].as_array().unwrap().len(), 1);
|
||||
@@ -1910,10 +1923,80 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_spawn_requires_stable_id_and_forwards_grant_without_ticket_or_workdir() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let tool = WorkspaceWorkerTool {
|
||||
operation: WorkerOperation::Spawn,
|
||||
control: test_control(client.clone()),
|
||||
runtime_worker_control: true,
|
||||
};
|
||||
let mut input = serde_json::json!({"runtime_id":"runtime-1", "profile":"builtin:coder"});
|
||||
assert!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
input["operation_id"] = serde_json::json!("ordinary-intent-1");
|
||||
input["execution_grant"] = serde_json::json!({"permissions":[{"operation":"ticket", "resource":{"kind":"feature"}}], "redelegation":[]});
|
||||
for call_id in ["call-1", "retry-another-call"] {
|
||||
tool.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new(call_id, "batch", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].body, requests[1].body);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(body["control_operation_id"], "ordinary-intent-1");
|
||||
assert_eq!(body["execution_grant"], input["execution_grant"]);
|
||||
assert_eq!(body["workdir_attachments"], serde_json::json!([]));
|
||||
assert!(
|
||||
body.get("ticket_assignment")
|
||||
.is_none_or(|value| value.is_null())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_spawn_rejects_missing_or_blank_id_before_backend() {
|
||||
for operation_id in [
|
||||
None,
|
||||
Some("".into()),
|
||||
Some(" ".into()),
|
||||
Some("bad\nidentity".into()),
|
||||
] {
|
||||
let result = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
|
||||
orchestrator_assignment_id: None,
|
||||
runtime_id: "runtime-1".into(),
|
||||
workdir_attachments: Vec::new(),
|
||||
profile: "builtin:coder".into(),
|
||||
singleton_key: None,
|
||||
ticket_id: None,
|
||||
operation_id,
|
||||
execution_grant: None,
|
||||
display_name: "Worker".into(),
|
||||
initial_submit: Vec::new(),
|
||||
});
|
||||
assert!(result.unwrap_err().to_string().contains("operation_id"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_spawn_schema_exposes_normal_typed_segment_variants() {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(WorkerSpawnInput)).unwrap();
|
||||
let text = serde_json::to_string(&schema).unwrap();
|
||||
assert!(
|
||||
schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!("operation_id"))
|
||||
);
|
||||
assert!(text.contains("execution_grant"));
|
||||
assert!(text.contains("initial_submit"));
|
||||
assert!(text.contains("ticket_id"));
|
||||
assert!(text.contains("selector"));
|
||||
@@ -1941,7 +2024,8 @@ mod tests {
|
||||
profile: "builtin:coder".to_string(),
|
||||
singleton_key: Some("subjektiv:subject-1".to_string()),
|
||||
ticket_id: None,
|
||||
operation_id: None,
|
||||
operation_id: Some("ordinary-spawn-intent-1".into()),
|
||||
execution_grant: None,
|
||||
display_name: "Coder".to_string(),
|
||||
initial_submit: vec![
|
||||
Segment::Flow {
|
||||
@@ -1991,6 +2075,7 @@ mod tests {
|
||||
singleton_key: None,
|
||||
ticket_id: Some("T-719".to_string()),
|
||||
operation_id: Some("spawn-ticket-worker:T-719:call-1".to_string()),
|
||||
execution_grant: None,
|
||||
display_name: "Worker · T-719".to_string(),
|
||||
initial_submit: vec![Segment::text("Investigate Ticket T-719.")],
|
||||
})
|
||||
|
||||
@@ -147,6 +147,7 @@ impl Tool for SpawnTicketWorkerTool {
|
||||
operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)),
|
||||
display_name: format!("Worker · {}", ticket.resource_key),
|
||||
initial_submit,
|
||||
execution_grant: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
|
||||
@@ -509,6 +509,15 @@ const TICKET_SERVICE_VERSION: &str = "1";
|
||||
|
||||
pub trait TicketService: Send + Sync {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
|
||||
|
||||
/// Typed creation without a synthetic Tool context. The default keeps legacy
|
||||
/// handoff-only fixture implementations fail-closed; production installs
|
||||
/// provide creation through the shared authorized backend dispatch.
|
||||
fn create(&self, _input: NewTicket) -> TicketResult<TicketRef> {
|
||||
Err(TicketError::Conflict(
|
||||
"Ticket creation service is unavailable".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -526,6 +535,7 @@ fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
|
||||
|
||||
struct WorkspaceTicketService {
|
||||
backend: WorkspaceHttpTicketBackend,
|
||||
access: TicketFeatureAccess,
|
||||
}
|
||||
|
||||
fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketError> {
|
||||
@@ -545,6 +555,17 @@ impl TicketService for WorkspaceTicketService {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
|
||||
}
|
||||
|
||||
fn create(&self, input: NewTicket) -> TicketResult<TicketRef> {
|
||||
// Services retain the same Profile ceiling as Tools and WIP. Host
|
||||
// permission cannot turn a read-only Feature surface into an author.
|
||||
if !self.access.authoring {
|
||||
return Err(TicketError::Conflict(
|
||||
"Ticket creation is disabled by the Profile authoring policy".into(),
|
||||
));
|
||||
}
|
||||
self.backend.create(input)
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_workflow_instruction() -> FeatureInstructionDeclaration {
|
||||
@@ -801,8 +822,17 @@ impl FeatureModule for TicketFeature {
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
let workspace_client = self.workspace_client();
|
||||
// Direct registry installs do not necessarily have a matching manifest
|
||||
// switch. The public module identity still requires live activation
|
||||
// authority before any Tool, instruction or service contribution.
|
||||
workspace_client
|
||||
.authorize_feature_activation(FEATURE_ID)
|
||||
.map_err(|error| {
|
||||
FeatureInstallError::Install(format!("Ticket activation rejected: {error}"))
|
||||
})?;
|
||||
let ticket_service: Arc<dyn TicketService> = Arc::new(WorkspaceTicketService {
|
||||
backend: WorkspaceHttpTicketBackend::new(workspace_client.clone()),
|
||||
access: self.access,
|
||||
});
|
||||
context.services().provide(
|
||||
ServiceDeclaration::new(
|
||||
@@ -1104,6 +1134,22 @@ impl WorkspaceHttpTicketBackend {
|
||||
"Tickets must be created open; closing requires a case judgment".into(),
|
||||
));
|
||||
}
|
||||
// Shared trusted boundary for Tool, WIP and Ticket service callers.
|
||||
// Activation (`ticket`) is not `ticket.create`. Other Ticket operations
|
||||
// are not yet adapted to this seam; missing checks there are not
|
||||
// supplied automatically by a universal Tool executor.
|
||||
client
|
||||
.authorize_operation(
|
||||
"ticket.create",
|
||||
&server_api::authorization::PermissionResource::Workspace {
|
||||
workspace_id: workspace_id.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|error| {
|
||||
TicketError::Conflict(format!(
|
||||
"Ticket create authorization failed: {error}"
|
||||
))
|
||||
})?;
|
||||
let ticket = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
@@ -3541,9 +3587,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn workspace_feature(access: TicketFeatureAccess) -> TicketFeature {
|
||||
let client: Arc<dyn WorkspaceClient> = Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
|
||||
);
|
||||
let client: Arc<dyn WorkspaceClient> = Arc::new(CreateAuthorizationClient {
|
||||
workspace: Some("workspace"),
|
||||
activation_allowed: true,
|
||||
allowed: true,
|
||||
backend_status: 200,
|
||||
requests: Default::default(),
|
||||
});
|
||||
ticket_tools_feature(client, access)
|
||||
}
|
||||
|
||||
@@ -4075,6 +4125,7 @@ mod tests {
|
||||
});
|
||||
|
||||
let service = WorkspaceTicketService {
|
||||
access: TicketFeatureAccess::read_only(),
|
||||
backend: WorkspaceHttpTicketBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
|
||||
)),
|
||||
@@ -4095,11 +4146,322 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// This suite keeps the real Feature/Tool/WIP/backend-dispatch code; only the
|
||||
// Host transport is replaced. Host grant/storage behavior is tested at Server.
|
||||
#[derive(Debug)]
|
||||
struct CreateAuthorizationClient {
|
||||
workspace: Option<&'static str>,
|
||||
activation_allowed: bool,
|
||||
allowed: bool,
|
||||
backend_status: u16,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for CreateAuthorizationClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.workspace
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"create-authorization-fixture"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
|
||||
let authorization = request.path.ends_with("/authorization/check");
|
||||
let mut permitted = self.allowed;
|
||||
if authorization {
|
||||
assert_eq!(request.method, WorkspaceRequestMethod::Post);
|
||||
let body: Value = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
if body["operation"] == "ticket" {
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({"operation":"ticket", "resource":{"kind":"feature"}})
|
||||
);
|
||||
permitted = self.activation_allowed;
|
||||
} else {
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({"operation":"ticket.create", "resource":{"kind":"workspace","workspace_id":"workspace"}})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(request.path, "/api/w/workspace/tickets");
|
||||
}
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(crate::worker::WorkspaceResponse {
|
||||
status: if authorization {
|
||||
if permitted { 200 } else { 403 }
|
||||
} else {
|
||||
self.backend_status
|
||||
},
|
||||
body: if authorization {
|
||||
if permitted {
|
||||
"{}".into()
|
||||
} else {
|
||||
json!({"message":"Ticket permission revoked"}).to_string()
|
||||
}
|
||||
} else if self.backend_status != 200 {
|
||||
json!({"error":"Forbidden", "message":"backend create denied", "diagnostics":[]}).to_string()
|
||||
} else {
|
||||
serde_json::to_string(&TicketRef {
|
||||
id: "created-1".into(),
|
||||
resource_key: Some("T-1".into()),
|
||||
slug: "created".into(),
|
||||
status: ticket::TicketStatus::Open,
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_authorization_guards_tool_wip_installed_service_and_backend_without_local_root()
|
||||
{
|
||||
for (allowed, backend_status) in [(false, 200), (true, 200), (true, 403)] {
|
||||
for surface in ["tool", "wip", "service", "backend"] {
|
||||
let client = Arc::new(CreateAuthorizationClient {
|
||||
workspace: Some("workspace"),
|
||||
activation_allowed: true,
|
||||
allowed,
|
||||
backend_status,
|
||||
requests: Default::default(),
|
||||
});
|
||||
let result = match surface {
|
||||
"backend" => WorkspaceHttpTicketBackend::new(client.clone())
|
||||
.create(NewTicket::new("Create"))
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string()),
|
||||
"service" => {
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature(
|
||||
client.clone(),
|
||||
TicketFeatureAccess::workspace_authoring(),
|
||||
))
|
||||
.install_into_pending(
|
||||
&mut Vec::new(),
|
||||
&mut HookRegistryBuilder::default(),
|
||||
);
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
let service = report
|
||||
.services
|
||||
.service::<dyn TicketService>(&ServiceId::builtin(TICKET_SERVICE_ID))
|
||||
.unwrap();
|
||||
service
|
||||
.create(NewTicket::new("Create"))
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
"tool" => {
|
||||
let tools = enabled_ticket_definitions(
|
||||
client.clone(),
|
||||
TicketFeatureAccess::workspace_authoring(),
|
||||
);
|
||||
let (_, tool) = tools
|
||||
.into_iter()
|
||||
.find(|definition| definition().0.name == "TicketCreate")
|
||||
.unwrap()();
|
||||
tool.execute(r#"{"title":"Create"}"#, ToolExecutionContext::direct())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
_ => {
|
||||
let tools = native_ticket_tools(
|
||||
client.clone(),
|
||||
TicketFeatureAccess::workspace_authoring(),
|
||||
)
|
||||
.unwrap();
|
||||
let handler = TicketCollectionWipHandler {
|
||||
tools: operation_map(tools),
|
||||
permissions: None,
|
||||
collection_route: "/tickets".into(),
|
||||
observations: Arc::new(Mutex::new(TicketObservationState::default())),
|
||||
};
|
||||
handler
|
||||
.call(
|
||||
"create",
|
||||
&BTreeMap::from([(
|
||||
"title".into(),
|
||||
WipValue::String("Create".into()),
|
||||
)]),
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "test".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|error| match error {
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code: ProtocolErrorCode::PermissionDenied,
|
||||
message,
|
||||
..
|
||||
}) => message,
|
||||
_ => panic!("a definitive denial is not outcome-unknown"),
|
||||
})
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
result.is_ok(),
|
||||
allowed && backend_status == 200,
|
||||
"{surface}: {result:?}"
|
||||
);
|
||||
if allowed && backend_status == 403 {
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.contains("HTTP status 403"), "{surface}: {error}");
|
||||
assert!(
|
||||
error.contains("backend create denied"),
|
||||
"{surface}: {error}"
|
||||
);
|
||||
}
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1 + usize::from(allowed) + usize::from(surface == "service"),
|
||||
"{surface}"
|
||||
);
|
||||
assert!(requests[0].path.ends_with("/authorization/check"));
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| request.path.ends_with("/tickets"))
|
||||
.count(),
|
||||
usize::from(allowed)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_ticket_install_requires_activation_even_without_manifest_switch() {
|
||||
assert!(!manifest::FeatureConfig::default().ticket.enabled);
|
||||
for create_allowed in [false, true] {
|
||||
let client = Arc::new(CreateAuthorizationClient {
|
||||
workspace: Some("workspace"),
|
||||
activation_allowed: false,
|
||||
allowed: create_allowed,
|
||||
backend_status: 200,
|
||||
requests: Default::default(),
|
||||
});
|
||||
let mut tools = Vec::new();
|
||||
// No manifest-config guard: this is the explicit module install path.
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature(
|
||||
client.clone(),
|
||||
TicketFeatureAccess::workspace_authoring(),
|
||||
))
|
||||
.install_into_pending(&mut tools, &mut HookRegistryBuilder::default());
|
||||
assert!(report.has_errors());
|
||||
assert!(
|
||||
report
|
||||
.error_message()
|
||||
.contains("Ticket activation rejected")
|
||||
);
|
||||
assert!(report.error_message().contains("HTTP status 403"));
|
||||
assert!(tools.is_empty());
|
||||
assert!(report.installed_instruction_contributions().is_empty());
|
||||
assert!(report.services.providers().is_empty());
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_ticket_service_readonly_profile_cannot_create_with_host_permission() {
|
||||
let client = Arc::new(CreateAuthorizationClient {
|
||||
workspace: Some("workspace"),
|
||||
activation_allowed: true,
|
||||
allowed: true,
|
||||
backend_status: 200,
|
||||
requests: Default::default(),
|
||||
});
|
||||
let mut tools = Vec::new();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature(
|
||||
client.clone(),
|
||||
TicketFeatureAccess::read_only(),
|
||||
))
|
||||
.install_into_pending(&mut tools, &mut HookRegistryBuilder::default());
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
assert!(!tools.iter().any(|tool| tool().0.name == "TicketCreate"));
|
||||
let service = report
|
||||
.services
|
||||
.service::<dyn TicketService>(&ServiceId::builtin(TICKET_SERVICE_ID))
|
||||
.unwrap();
|
||||
assert!(
|
||||
service
|
||||
.create(NewTicket::new("No profile bypass"))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Profile authoring policy")
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"only activation; no create check or backend effect"
|
||||
);
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(body["operation"], "ticket");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_handoff_only_service_does_not_gain_creation() {
|
||||
struct LegacyService;
|
||||
impl TicketService for LegacyService {
|
||||
fn ticket_handoff(&self, _: &str) -> Result<TicketHandoff, TicketError> {
|
||||
unreachable!("creation must not call handoff")
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
LegacyService
|
||||
.create(NewTicket::new("No implicit creation"))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unavailable")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_without_workspace_identity_fails_before_transport_and_backend_denial_is_preserved() {
|
||||
for (workspace, allowed, backend_status, expected_calls) in
|
||||
[(None, true, 200, 0), (Some("workspace"), true, 403, 2)]
|
||||
{
|
||||
let client = Arc::new(CreateAuthorizationClient {
|
||||
workspace,
|
||||
activation_allowed: true,
|
||||
allowed,
|
||||
backend_status,
|
||||
requests: Default::default(),
|
||||
});
|
||||
let result =
|
||||
WorkspaceHttpTicketBackend::new(client.clone()).create(NewTicket::new("Create"));
|
||||
assert!(result.is_err());
|
||||
assert_eq!(client.requests.lock().unwrap().len(), expected_calls);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_http_backend_executes_ticket_create_operation() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
let (mut authorization, _) = listener.accept().unwrap();
|
||||
authorization
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = authorization.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(request.starts_with("POST /api/w/workspace-a/authorization/check HTTP/1.1"));
|
||||
assert!(request.contains("\"operation\":\"ticket.create\""));
|
||||
assert!(request.contains("\"workspace_id\":\"workspace-a\""));
|
||||
write!(authorization, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}").unwrap();
|
||||
drop(authorization);
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
pub mod authorized_workdir;
|
||||
mod backend_job;
|
||||
pub use backend_job::BackendJobExecutionBinding;
|
||||
pub mod bootstrap;
|
||||
|
||||
@@ -326,9 +326,32 @@ mod tests {
|
||||
use crate::feature::builtin::merge_request::MergeRequestFeature;
|
||||
use crate::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess};
|
||||
use crate::hook::HookRegistryBuilder;
|
||||
use crate::worker::TestWorkspaceHttpClient;
|
||||
use crate::worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
|
||||
};
|
||||
use manifest::MergeRequestFeatureConfig;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MetadataOnlyHost;
|
||||
impl WorkspaceClient for MetadataOnlyHost {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"metadata-fixture"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
|
||||
assert_eq!(feature, "ticket");
|
||||
Ok(())
|
||||
}
|
||||
fn execute(&self, _: WorkspaceRequest) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
panic!("metadata-only prompt fixture must not perform protected operations")
|
||||
}
|
||||
}
|
||||
|
||||
// These builtin resources are the model-visible workflow compatibility contract.
|
||||
// Fixtures prove composition and tool descriptions, not an agent's real judgment.
|
||||
fn ticket_workflow_fixture(
|
||||
@@ -351,7 +374,7 @@ mod tests {
|
||||
mr_config: MergeRequestFeatureConfig,
|
||||
access: TicketFeatureAccess,
|
||||
) -> (String, Vec<agen::tool::ToolMeta>) {
|
||||
let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
|
||||
let client = Arc::new(MetadataOnlyHost);
|
||||
let mut tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
|
||||
@@ -1987,6 +1987,12 @@ enabled = false
|
||||
"test"
|
||||
}
|
||||
|
||||
fn authorize_feature_activation(&self, _feature: &str) -> Result<(), WorkspaceClientError> {
|
||||
// This fixture represents a non-Subject Host admitting ordinary
|
||||
// Internal Worker configuration, not an operation permission grant.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -6108,6 +6108,10 @@ mod tests {
|
||||
};
|
||||
let authoring_client = Arc::new(ScriptedWorkspaceClient::new([
|
||||
query_response,
|
||||
crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: json!({"scope": null}).to_string(),
|
||||
},
|
||||
create_response,
|
||||
comment_response,
|
||||
]));
|
||||
@@ -6189,11 +6193,12 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let authoring_requests = authoring_client.requests();
|
||||
assert_eq!(authoring_requests.len(), 3);
|
||||
assert_eq!(authoring_requests.len(), 4);
|
||||
assert!(authoring_requests[0].path.ends_with("/tickets/query"));
|
||||
assert!(authoring_requests[1].path.ends_with("/tickets"));
|
||||
assert!(authoring_requests[1].path.ends_with("/authorization/check"));
|
||||
assert!(authoring_requests[2].path.ends_with("/tickets"));
|
||||
assert!(
|
||||
authoring_requests[2]
|
||||
authoring_requests[3]
|
||||
.path
|
||||
.ends_with("/tickets/T-9/thread-events")
|
||||
);
|
||||
|
||||
+376
-1
@@ -1052,6 +1052,116 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
||||
self.execute(request)
|
||||
}
|
||||
|
||||
/// Ask the Host using this client's authenticated source, never a model-supplied
|
||||
/// identity. Trusted Features call this before protected effects on every surface.
|
||||
/// This is not a universal Tool executor: a missing call is a Feature bug, and
|
||||
/// operations not yet adapted to this seam retain only their existing checks.
|
||||
fn authorize_operation(
|
||||
&self,
|
||||
operation: &str,
|
||||
resource: &server_api::authorization::PermissionResource,
|
||||
) -> Result<(), WorkspaceClientError> {
|
||||
self.authorize_operation_scope(operation, resource)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Return the Host-selected matching scope for provider-resolved containment.
|
||||
/// `None` is only the Host's explicit legacy decision, never a transport fallback.
|
||||
fn authorize_operation_scope(
|
||||
&self,
|
||||
operation: &str,
|
||||
resource: &server_api::authorization::PermissionResource,
|
||||
) -> Result<Option<server_api::authorization::PermissionResource>, WorkspaceClientError> {
|
||||
use server_api::authorization::{
|
||||
OperationAuthorizationRequest, OperationAuthorizationResponse, ScopedPermission,
|
||||
};
|
||||
if !self.is_available() {
|
||||
return Err(WorkspaceClientError::Unavailable(
|
||||
"operation authorization requires a Host-bound Workspace client".into(),
|
||||
));
|
||||
}
|
||||
let workspace_id = self
|
||||
.workspace_id()
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WorkspaceClientError::Unavailable(
|
||||
"operation authorization requires Workspace identity".into(),
|
||||
)
|
||||
})?;
|
||||
ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: resource.clone(),
|
||||
}
|
||||
.validate()
|
||||
.map_err(WorkspaceClientError::Request)?;
|
||||
let request = workspace_server_json_request(
|
||||
format!(
|
||||
"/api/w/{}/authorization/check",
|
||||
encode_workspace_path_segment(workspace_id)
|
||||
),
|
||||
&OperationAuthorizationRequest {
|
||||
operation: operation.into(),
|
||||
resource: resource.clone(),
|
||||
},
|
||||
)?;
|
||||
// Blocking transports must not run inside a Tokio runtime. The bound
|
||||
// transport enforces this timeout; no authorization success is cached.
|
||||
let execute = || self.execute_with_timeout(request, Duration::from_secs(10));
|
||||
let response = if tokio::runtime::Handle::try_current().is_ok() {
|
||||
std::thread::scope(|scope| scope.spawn(execute).join()).map_err(|_| {
|
||||
WorkspaceClientError::Request("authorization request thread panicked".into())
|
||||
})??
|
||||
} else {
|
||||
execute()?
|
||||
};
|
||||
if response.body.len() > 16 * 1024 {
|
||||
return Err(WorkspaceClientError::Request(
|
||||
"authorization response exceeded 16 KiB".into(),
|
||||
));
|
||||
}
|
||||
if !response.is_success() {
|
||||
// Expose only the bounded public reason, not provider diagnostics or
|
||||
// arbitrary response data. Keep status parseable by Feature adapters.
|
||||
let value: serde_json::Value = serde_json::from_str(&response.body).unwrap_or_default();
|
||||
let reason: String = value
|
||||
.get("message")
|
||||
.or_else(|| value.get("error"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("Host rejected authorization")
|
||||
.chars()
|
||||
.take(1024)
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect();
|
||||
return Err(WorkspaceClientError::Request(format!(
|
||||
"authorization denied for {operation}: HTTP status {}: {reason}",
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
let authorization = serde_json::from_str::<OperationAuthorizationResponse>(&response.body)
|
||||
.map_err(|error| {
|
||||
WorkspaceClientError::Request(format!("invalid authorization response: {error}"))
|
||||
})?;
|
||||
if authorization
|
||||
.scope
|
||||
.as_ref()
|
||||
.is_some_and(|scope| !scope.covers(resource))
|
||||
{
|
||||
return Err(WorkspaceClientError::Request(
|
||||
"Host authorization scope does not cover requested resource".into(),
|
||||
));
|
||||
}
|
||||
Ok(authorization.scope)
|
||||
}
|
||||
|
||||
/// Activation is separate from operation authority. Only explicit standalone
|
||||
/// contexts with no Workspace binding retain legacy activation without a Host.
|
||||
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
|
||||
self.authorize_operation(
|
||||
feature,
|
||||
&server_api::authorization::PermissionResource::Feature,
|
||||
)
|
||||
}
|
||||
|
||||
/// Binary transport is unavailable unless a host-bound implementation opts in.
|
||||
fn execute_binary(
|
||||
&self,
|
||||
@@ -1170,6 +1280,28 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
||||
self.inner.current_prompt_projection()
|
||||
}
|
||||
|
||||
fn authorize_operation(
|
||||
&self,
|
||||
operation: &str,
|
||||
resource: &server_api::authorization::PermissionResource,
|
||||
) -> Result<(), WorkspaceClientError> {
|
||||
// The query has no protected effect. Keep the review capability's
|
||||
// mutation filter intact while asking the bound Host for live authority.
|
||||
self.inner.authorize_operation(operation, resource)
|
||||
}
|
||||
|
||||
fn authorize_operation_scope(
|
||||
&self,
|
||||
operation: &str,
|
||||
resource: &server_api::authorization::PermissionResource,
|
||||
) -> Result<Option<server_api::authorization::PermissionResource>, WorkspaceClientError> {
|
||||
self.inner.authorize_operation_scope(operation, resource)
|
||||
}
|
||||
|
||||
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
|
||||
self.inner.authorize_feature_activation(feature)
|
||||
}
|
||||
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
@@ -1322,6 +1454,7 @@ struct MarkerWorkspaceClient {
|
||||
workspace_id: Option<String>,
|
||||
kind: String,
|
||||
available: bool,
|
||||
legacy_local_activation: bool,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
@@ -1338,6 +1471,18 @@ impl WorkspaceClient for MarkerWorkspaceClient {
|
||||
self.available
|
||||
}
|
||||
|
||||
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
|
||||
// Explicit compatibility for standalone Workers with no Workspace binding.
|
||||
// A missing/broken Workspace client must never become an implicit grant.
|
||||
if self.workspace_id.is_none() && self.legacy_local_activation {
|
||||
return Ok(());
|
||||
}
|
||||
self.authorize_operation(
|
||||
feature,
|
||||
&server_api::authorization::PermissionResource::Feature,
|
||||
)
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_request: WorkspaceRequest,
|
||||
@@ -1346,6 +1491,196 @@ impl WorkspaceClient for MarkerWorkspaceClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod operation_authorization_tests {
|
||||
use super::*;
|
||||
use server_api::authorization::PermissionResource;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BoundClient {
|
||||
response: Mutex<WorkspaceResponse>,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for BoundClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace/test")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"bound-authorization-fixture"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(&self, _: WorkspaceRequest) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
panic!("authorization must select the bounded transport")
|
||||
}
|
||||
fn execute_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
assert_eq!(timeout, Duration::from_secs(10));
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(self.response.lock().unwrap().clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorization_is_bounded_live_and_contains_no_caller_identity() {
|
||||
let client = BoundClient {
|
||||
response: Mutex::new(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
}),
|
||||
requests: Default::default(),
|
||||
};
|
||||
let resource = PermissionResource::Workspace {
|
||||
workspace_id: "workspace/test".into(),
|
||||
};
|
||||
client
|
||||
.authorize_operation("ticket.create", &resource)
|
||||
.unwrap();
|
||||
*client.response.lock().unwrap() = WorkspaceResponse {
|
||||
status: 403,
|
||||
body: r#"{"message":"permission revoked","diagnostics":[{"message":"private"}]}"#
|
||||
.into(),
|
||||
};
|
||||
let reason = client
|
||||
.authorize_operation("ticket.create", &resource)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(reason.contains("HTTP status 403"));
|
||||
assert!(reason.contains("permission revoked"));
|
||||
assert!(!reason.contains("private"));
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2, "success must not become a cached grant");
|
||||
for request in requests.iter() {
|
||||
assert_eq!(request.method, WorkspaceRequestMethod::Post);
|
||||
assert_eq!(request.path, "/api/w/workspace%2Ftest/authorization/check");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(request.body.as_deref().unwrap())
|
||||
.unwrap(),
|
||||
serde_json::json!({ "operation":"ticket.create", "resource":{"kind":"workspace", "workspace_id":"workspace/test"} })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_authorization_preserves_matching_scope_and_rejects_cross_resource() {
|
||||
let resource = PermissionResource::Workdir {
|
||||
working_directory_id: "wd-1".into(),
|
||||
path: "docs/file.md".into(),
|
||||
recursive: false,
|
||||
};
|
||||
let granted = PermissionResource::Workdir {
|
||||
working_directory_id: "wd-1".into(),
|
||||
path: "docs".into(),
|
||||
recursive: true,
|
||||
};
|
||||
let client = BoundClient {
|
||||
response: Mutex::new(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::json!({"scope":granted}).to_string(),
|
||||
}),
|
||||
requests: Default::default(),
|
||||
};
|
||||
assert_eq!(
|
||||
client
|
||||
.authorize_operation_scope("filesystem.read", &resource)
|
||||
.unwrap(),
|
||||
Some(granted)
|
||||
);
|
||||
*client.response.lock().unwrap() = WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::json!({"scope":{"kind":"workspace","workspace_id":"workspace/test"}})
|
||||
.to_string(),
|
||||
};
|
||||
assert!(
|
||||
client
|
||||
.authorize_operation_scope("filesystem.read", &resource)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_query_preserves_host_scope_and_does_not_widen_mutation_access() {
|
||||
let scope = PermissionResource::Workdir {
|
||||
working_directory_id: "wd-1".into(),
|
||||
path: "docs".into(),
|
||||
recursive: true,
|
||||
};
|
||||
let inner = Arc::new(BoundClient {
|
||||
response: Mutex::new(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::json!({"scope":scope}).to_string(),
|
||||
}),
|
||||
requests: Default::default(),
|
||||
});
|
||||
let reviewer = ReviewerChildWorkspaceClient::new(
|
||||
inner.clone(),
|
||||
ReviewerContext {
|
||||
ticket_id: "T-1".into(),
|
||||
merge_request_id: "MR-1".into(),
|
||||
},
|
||||
"capability".into(),
|
||||
);
|
||||
let requested = PermissionResource::Workdir {
|
||||
working_directory_id: "wd-1".into(),
|
||||
path: "docs/file.md".into(),
|
||||
recursive: false,
|
||||
};
|
||||
assert_eq!(
|
||||
reviewer
|
||||
.authorize_operation_scope("filesystem.read", &requested)
|
||||
.unwrap(),
|
||||
Some(scope)
|
||||
);
|
||||
assert!(
|
||||
reviewer
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/w/workspace%2Ftest/tickets",
|
||||
"{}"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
inner.requests.lock().unwrap().len(),
|
||||
1,
|
||||
"only the bounded authorization query reaches the Host"
|
||||
);
|
||||
*inner.response.lock().unwrap() = WorkspaceResponse {
|
||||
status: 403,
|
||||
body: "{}".into(),
|
||||
};
|
||||
assert!(
|
||||
reviewer
|
||||
.authorize_operation_scope("filesystem.read", &requested)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_oversized_authorization_responses_fail_closed() {
|
||||
for body in [
|
||||
"not-json".into(),
|
||||
"{\"unexpected_grant\":true}".into(),
|
||||
" ".repeat(16 * 1024 + 1),
|
||||
] {
|
||||
let client = BoundClient {
|
||||
response: Mutex::new(WorkspaceResponse { status: 200, body }),
|
||||
requests: Default::default(),
|
||||
};
|
||||
assert!(
|
||||
client
|
||||
.authorize_operation("ticket", &PermissionResource::Feature)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reviewer_client_tests {
|
||||
use super::*;
|
||||
@@ -1587,6 +1922,7 @@ pub fn unavailable_workspace_client(
|
||||
workspace_id: workspace_id.map(|id| id.as_str().to_string()),
|
||||
kind: "unavailable".to_string(),
|
||||
available: false,
|
||||
legacy_local_activation: workspace_id.is_none(),
|
||||
reason: reason.into(),
|
||||
})
|
||||
}
|
||||
@@ -1601,6 +1937,7 @@ pub fn marker_workspace_client(
|
||||
reason: format!("workspace client kind `{kind}` does not expose Workspace operations"),
|
||||
kind,
|
||||
available: true,
|
||||
legacy_local_activation: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1649,7 +1986,15 @@ impl WorkerWorkspaceContext {
|
||||
}
|
||||
|
||||
pub fn local_filesystem(workspace_id: Option<WorkspaceId>) -> Self {
|
||||
let client = marker_workspace_client(workspace_id.as_ref(), "local-filesystem");
|
||||
// This constructor explicitly selects legacy standalone activation only
|
||||
// without Workspace identity; it never authorizes protected operations.
|
||||
let client = Arc::new(MarkerWorkspaceClient {
|
||||
workspace_id: workspace_id.as_ref().map(|id| id.as_str().to_string()),
|
||||
kind: "local-filesystem".into(),
|
||||
available: true,
|
||||
legacy_local_activation: workspace_id.is_none(),
|
||||
reason: "local filesystem context does not expose Workspace operations".into(),
|
||||
});
|
||||
Self {
|
||||
workspace_id,
|
||||
client,
|
||||
@@ -3844,6 +4189,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
registry: FeatureRegistryBuilder,
|
||||
) -> FeatureRegistryInstallReport {
|
||||
self.backend_job.get_or_init(|| None);
|
||||
let client = self.workspace_client_handle();
|
||||
let manifest = self.manifest().clone();
|
||||
let registry = registry.with_activation_guard(move || {
|
||||
crate::feature::authorize_requested_manifest(client.as_ref(), &manifest)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
let worker = self.engine.as_mut().expect("worker taken during run");
|
||||
let report = registry.install_into_engine(worker, &mut self.hook_builder);
|
||||
if report.has_errors() {
|
||||
@@ -10552,6 +10903,12 @@ mod build_summary_prompt_tests {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if request.path.ends_with("/authorization/check") {
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
});
|
||||
}
|
||||
let request: server_api::MemoryBackendRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||
assert!(matches!(
|
||||
@@ -10613,6 +10970,12 @@ mod build_summary_prompt_tests {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if request.path.ends_with("/authorization/check") {
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
});
|
||||
}
|
||||
let request: server_api::SubjektivMemoryBackendRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||
assert!(matches!(
|
||||
@@ -10953,6 +11316,12 @@ mod build_summary_prompt_tests {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if request.path.ends_with("/authorization/check") {
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
});
|
||||
}
|
||||
self.requests
|
||||
.lock()
|
||||
.expect("recorded workspace requests lock")
|
||||
@@ -10985,6 +11354,12 @@ mod build_summary_prompt_tests {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if request.path.ends_with("/authorization/check") {
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
});
|
||||
}
|
||||
self.requests
|
||||
.lock()
|
||||
.expect("Flow source request lock")
|
||||
|
||||
@@ -225,8 +225,14 @@ impl WorkspaceClient for AvailableWorkspaceClient {
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_request: WorkspaceRequest,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if request.path.ends_with("/authorization/check") {
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
});
|
||||
}
|
||||
Err(WorkspaceClientError::Unavailable("test".to_string()))
|
||||
}
|
||||
}
|
||||
@@ -249,8 +255,14 @@ impl WorkspaceClient for NoopWorkspaceClient {
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_request: WorkspaceRequest,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if request.path.ends_with("/authorization/check") {
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
});
|
||||
}
|
||||
Err(WorkspaceClientError::Unavailable(
|
||||
"test client does not execute requests".to_string(),
|
||||
))
|
||||
@@ -912,6 +924,12 @@ impl WorkspaceClient for JournalWorkspaceClient {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if request.path.ends_with("/authorization/check") {
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
});
|
||||
}
|
||||
self.requests.lock().unwrap().push(request.clone());
|
||||
let (status, body) = if request.path.ends_with("/subjektiv/journal/job/finish") {
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user