Merge remote-tracking branch 'origin/develop' into work/t-533-plugin-worker-composed
# Conflicts: # crates/server-api/src/lib.rs # crates/server-api/src/plugin.rs # crates/server-api/src/plugin_types.rs # crates/worker-runtime/Cargo.toml # crates/worker-runtime/src/workspace_request/plugin_delivery.rs # crates/workspace-server/Cargo.toml # crates/workspace-server/src/lib.rs # crates/workspace-server/src/server.rs # crates/workspace-server/src/server/tests/subject_authorization_tests.rs # crates/workspace-server/src/server_plugin_delivery.rs # crates/workspace-server/src/server_plugin_delivery_tests.rs # openapi/server-api.json # web/workspace/src/lib/generated/companion-api.ts # web/workspace/src/lib/generated/plugin-api.ts # web/workspace/src/lib/generated/repository-api.ts # web/workspace/src/lib/generated/runtime-api.ts # web/workspace/src/lib/generated/ticket-api.ts # web/workspace/src/lib/generated/workdir-api.ts # web/workspace/src/lib/generated/worker-launch-api.ts
This commit is contained in:
@@ -546,6 +546,7 @@ fn durable_parent_notification_target<St: Store + Clone + Send + Sync + 'static>
|
||||
) -> crate::spawn::tool::ParentNotificationTarget {
|
||||
crate::spawn::tool::ParentNotificationTarget::Durable(Arc::new(move |method| {
|
||||
let Method::NotifyTracked {
|
||||
receipt_scope,
|
||||
notification_request_id,
|
||||
message,
|
||||
source,
|
||||
@@ -554,6 +555,7 @@ fn durable_parent_notification_target<St: Store + Clone + Send + Sync + 'static>
|
||||
return;
|
||||
};
|
||||
let (source_namespace, provenance) = resolved_input_source(&pending_submissions, &source);
|
||||
let source_namespace = receipt_scope.namespace(source_namespace);
|
||||
match pending_submissions.accept_notification_from_source(
|
||||
notification_request_id,
|
||||
message,
|
||||
@@ -1499,7 +1501,7 @@ where
|
||||
// Keep the alias-routed schemas installed for the Worker lifetime. The
|
||||
// attachment set may be empty or mutate after registration, so target
|
||||
// resolution and provider capability checks happen per invocation.
|
||||
let workdir_tools = if restrict_workdir_tools_to_current_capabilities {
|
||||
let mut workdir_tools = if restrict_workdir_tools_to_current_capabilities {
|
||||
tools::routed_builtin_tools_for_current_capabilities(
|
||||
workdir_sessions.clone(),
|
||||
tracker.clone(),
|
||||
@@ -1512,6 +1514,11 @@ where
|
||||
bash_output.clone(),
|
||||
)
|
||||
};
|
||||
if let Some(policy) = &spawner_manifest.permissions {
|
||||
workdir_tools.retain(|definition| {
|
||||
!crate::permission::tool_is_unconditionally_denied(policy, &definition().0.name)
|
||||
});
|
||||
}
|
||||
worker.engine_mut().register_tools(workdir_tools);
|
||||
}
|
||||
if feature_config.image.enabled && model_supports_image_attachments(&spawner_manifest.model) {
|
||||
@@ -1658,6 +1665,15 @@ where
|
||||
}
|
||||
feature_prompt_contribution = Some((Some(resident_summary_source), None));
|
||||
}
|
||||
if backend_job.is_none()
|
||||
&& let Some(chat) =
|
||||
crate::feature::builtin::subjektiv_chat::SubjektivChatFeature::from_resolved_config(
|
||||
&worker.manifest().feature.subjektiv,
|
||||
subjektiv_host.clone(),
|
||||
)?
|
||||
{
|
||||
feature_registry.add_module(chat);
|
||||
}
|
||||
if ordinary_subjektiv_features_enabled
|
||||
&& let Some(subjektiv_memory) =
|
||||
crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config(
|
||||
@@ -2275,12 +2291,14 @@ where
|
||||
None
|
||||
}
|
||||
Method::NotifyTracked {
|
||||
receipt_scope,
|
||||
notification_request_id,
|
||||
message,
|
||||
source,
|
||||
} => {
|
||||
let (source_namespace, provenance) =
|
||||
resolved_input_source(pending_submissions, &source);
|
||||
let source_namespace = receipt_scope.namespace(source_namespace);
|
||||
durably_accept_notification(
|
||||
notification_request_id,
|
||||
message,
|
||||
@@ -2756,12 +2774,14 @@ async fn controller_loop<C, St>(
|
||||
}
|
||||
|
||||
Method::NotifyTracked {
|
||||
receipt_scope,
|
||||
notification_request_id,
|
||||
message,
|
||||
source,
|
||||
} => {
|
||||
let (source_namespace, provenance) =
|
||||
resolved_input_source(&pending_submissions, &source);
|
||||
let source_namespace = receipt_scope.namespace(source_namespace);
|
||||
durably_accept_notification(
|
||||
notification_request_id,
|
||||
message,
|
||||
@@ -3678,12 +3698,14 @@ where
|
||||
});
|
||||
}
|
||||
Some(Method::NotifyTracked {
|
||||
receipt_scope,
|
||||
notification_request_id,
|
||||
message,
|
||||
source,
|
||||
}) => {
|
||||
let (source_namespace, provenance) =
|
||||
resolved_input_source(pending_submissions, &source);
|
||||
let source_namespace = receipt_scope.namespace(source_namespace);
|
||||
if durably_accept_notification(
|
||||
notification_request_id,
|
||||
message,
|
||||
@@ -4907,6 +4929,7 @@ mod tests {
|
||||
let pending = PendingSubmissionHandle::for_test(dir.path());
|
||||
let (event_tx, mut event_rx) = broadcast::channel(4);
|
||||
let method = Method::NotifyTracked {
|
||||
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||
notification_request_id: "completion-1".into(),
|
||||
message: "subworker completed".into(),
|
||||
source: protocol::AuthenticatedInputSource::UntrustedWire,
|
||||
|
||||
@@ -36,6 +36,7 @@ pub mod orchestration;
|
||||
mod resource_projection;
|
||||
pub mod session_explore;
|
||||
pub(crate) mod sub_worker;
|
||||
pub(crate) mod subjektiv_chat;
|
||||
pub(crate) mod subjektiv_memory;
|
||||
pub(crate) mod subjektiv_session;
|
||||
pub mod task;
|
||||
|
||||
@@ -210,6 +210,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
request: server_api::RuntimeWorkerInputRequest {
|
||||
submission_request_id: None,
|
||||
kind: Some(kind.to_string()),
|
||||
content,
|
||||
segments: None,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Explicit public conversation access for the connected Subject body.
|
||||
//!
|
||||
//! This module installs no output or lifecycle hooks. Internal assistant text,
|
||||
//! WorkerNotify reports and turn completion never become public messages here.
|
||||
//! Current body ownership, conversation access and observed-input/idempotency checks
|
||||
//! remain Backend-owned; the model supplies neither sender nor Subject identity.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use server_api::{
|
||||
ChatMessagePageQuery, ChatSendMessageRequest, ChatWorkerOperation, ChatWorkerResponse,
|
||||
};
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
};
|
||||
use crate::subjektiv::SubjektivHostConnection;
|
||||
|
||||
const READ_TOOL: &str = "ConversationRead";
|
||||
const SEND_TOOL: &str = "ConversationSend";
|
||||
const READ_DESCRIPTION: &str = "Read a bounded page of committed text-only messages in an authorized conversation for the explicitly connected Subject. Returns the current last_user_message_id; use after_sequence for continuation. This reads public messages, not internal Worker logs or notification-delivery evidence.";
|
||||
const SEND_DESCRIPTION: &str = "Explicitly send one text-only public message as the connected Subject to an authorized conversation. Use a stable operation_id and the observed expected_last_user_message_id. Sender identity is Host-bound. Exact retries retain the full request and operation_id; conflicts require rereading and reconsideration, while a lost response is not proof of failure. This is the only operation here that publishes assistant text; turn completion and background WorkerNotify reports do not send messages. No attachments or file-path attachment substitution.";
|
||||
const MAX_TOOL_OUTPUT_BYTES: usize = 128 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SubjektivChatFeature {
|
||||
host: SubjektivHostConnection,
|
||||
}
|
||||
|
||||
impl SubjektivChatFeature {
|
||||
pub(crate) fn from_resolved_config(
|
||||
config: &manifest::ResolvedSubjektivFeatureConfig,
|
||||
host: Option<SubjektivHostConnection>,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
if !config.profile.enabled || !config.profile.chat.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
config.validate_execution().map_err(std::io::Error::other)?;
|
||||
let host = host.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"subjektiv chat requires an explicit live Subject Host connection",
|
||||
)
|
||||
})?;
|
||||
if config.profile.consolidation_tools {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"subjektiv chat is a body capability, not a consolidation Job capability",
|
||||
));
|
||||
}
|
||||
Ok(Some(Self { host }))
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for SubjektivChatFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("subjektiv-chat", "Subject Conversation")
|
||||
.with_description("Explicit text-only public conversation read and send operations.")
|
||||
.with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION))
|
||||
.with_tool(ToolDeclaration::new(SEND_TOOL, SEND_DESCRIPTION))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
for operation in [Operation::Read, Operation::Send] {
|
||||
context.tools().register(ToolContribution::new(
|
||||
operation.name(),
|
||||
tool_definition(self.host.clone(), operation),
|
||||
))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Operation {
|
||||
Read,
|
||||
Send,
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => READ_TOOL,
|
||||
Self::Send => SEND_TOOL,
|
||||
}
|
||||
}
|
||||
|
||||
fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => READ_DESCRIPTION,
|
||||
Self::Send => SEND_DESCRIPTION,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ReadParams {
|
||||
conversation_id: String,
|
||||
after_sequence: Option<u64>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
fn schema<T: JsonSchema>() -> serde_json::Value {
|
||||
serde_json::to_value(schemars::schema_for!(T)).expect("serializable chat Tool schema")
|
||||
}
|
||||
|
||||
fn tool_definition(host: SubjektivHostConnection, operation: Operation) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = match operation {
|
||||
Operation::Read => schema::<ReadParams>(),
|
||||
Operation::Send => schema::<ChatSendMessageRequest>(),
|
||||
};
|
||||
let tool: Arc<dyn Tool> = Arc::new(ConversationTool {
|
||||
host: host.clone(),
|
||||
operation,
|
||||
});
|
||||
(
|
||||
ToolMeta::new(operation.name())
|
||||
.description(operation.description())
|
||||
.input_schema(schema),
|
||||
tool,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
struct ConversationTool {
|
||||
host: SubjektivHostConnection,
|
||||
operation: Operation,
|
||||
}
|
||||
|
||||
fn parse<T: serde::de::DeserializeOwned>(input: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ConversationTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_context: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let operation = match self.operation {
|
||||
Operation::Read => {
|
||||
let input: ReadParams = parse(input_json)?;
|
||||
if input.conversation_id.trim().is_empty()
|
||||
|| input.limit.is_some_and(|limit| {
|
||||
!(1..=server_api::CHAT_MAX_PAGE_LIMIT).contains(&limit)
|
||||
})
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"conversation_id is required and limit must be within 1..=100".into(),
|
||||
));
|
||||
}
|
||||
ChatWorkerOperation::Read {
|
||||
conversation_id: input.conversation_id,
|
||||
query: ChatMessagePageQuery {
|
||||
after_sequence: input.after_sequence,
|
||||
// Small default pages keep the chat body responsive; callers
|
||||
// can explicitly request more within the Backend ceiling.
|
||||
limit: Some(input.limit.unwrap_or(5)),
|
||||
},
|
||||
}
|
||||
}
|
||||
Operation::Send => {
|
||||
let request: ChatSendMessageRequest = parse(input_json)?;
|
||||
if request.conversation_id.trim().is_empty()
|
||||
|| request.operation_id.trim().is_empty()
|
||||
|| request.text.trim().is_empty()
|
||||
|| request.text.len() > server_api::CHAT_MAX_TEXT_BYTES
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"conversation_id, operation_id and nonblank text are required; text exceeds the limit if larger than 16 KiB".into(),
|
||||
));
|
||||
}
|
||||
ChatWorkerOperation::Send { request }
|
||||
}
|
||||
};
|
||||
let response = self.host.chat(operation).map_err(ToolError::from)?;
|
||||
let (summary, value) = match (self.operation, response) {
|
||||
(Operation::Read, ChatWorkerResponse::Messages { page }) => (
|
||||
format!(
|
||||
"Read {} committed conversation message(s).",
|
||||
page.messages.len()
|
||||
),
|
||||
serde_json::to_value(page),
|
||||
),
|
||||
(Operation::Send, ChatWorkerResponse::Sent { message }) => (
|
||||
"Committed conversation message.".to_string(),
|
||||
serde_json::to_value(message),
|
||||
),
|
||||
_ => {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"unexpected chat Backend response".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let content = serde_json::to_string(
|
||||
&value.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
|
||||
)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if content.len() > MAX_TOOL_OUTPUT_BYTES {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"conversation page exceeds the model-visible output budget; read a smaller page"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(content),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,465 @@
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
||||
use server_api::{
|
||||
ChatConversation, ChatMessage, ChatMessagePage, ChatMessageSender, ChatWorkerRequest,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicUsize, Ordering};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ChatBackendFixture {
|
||||
allowed: Mutex<Vec<&'static str>>,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
status: AtomicU16,
|
||||
unavailable: AtomicBool,
|
||||
wrong_variant: AtomicBool,
|
||||
page_text_bytes: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Default for ChatBackendFixture {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allowed: Mutex::new(vec!["chat.read", "chat.send"]),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
status: AtomicU16::new(200),
|
||||
unavailable: AtomicBool::new(false),
|
||||
wrong_variant: AtomicBool::new(false),
|
||||
page_text_bytes: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn message(request: ChatSendMessageRequest) -> ChatMessage {
|
||||
ChatMessage {
|
||||
id: "message-committed".into(),
|
||||
conversation_id: request.conversation_id,
|
||||
sequence: 7,
|
||||
sender: ChatMessageSender::Subject {
|
||||
subject_id: "subject-fixture".into(),
|
||||
},
|
||||
text: request.text,
|
||||
reply_to: request.reply_to,
|
||||
work_ref: request.work_ref,
|
||||
operation_id: request.operation_id,
|
||||
last_user_message_id: request.expected_last_user_message_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn send_input() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"conversation_id": "conversation-1",
|
||||
"operation_id": "stable-operation-1",
|
||||
"text": "I evaluated the background report.",
|
||||
"reply_to": "user-message-1",
|
||||
"work_ref": "T-766",
|
||||
"expected_last_user_message_id": "user-message-3"
|
||||
})
|
||||
}
|
||||
|
||||
impl WorkspaceClient for ChatBackendFixture {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-chat")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"chat-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request.clone());
|
||||
if let Some(response) = crate::subjektiv::test_subject_authorization_response(
|
||||
&request,
|
||||
"workspace-chat",
|
||||
&self.allowed.lock().unwrap(),
|
||||
) {
|
||||
return Ok(response);
|
||||
}
|
||||
assert_eq!(request.path, "/api/w/workspace-chat/subjektiv/chat/worker");
|
||||
assert_eq!(request.method, crate::WorkspaceRequestMethod::Post);
|
||||
if self.unavailable.load(Ordering::SeqCst) {
|
||||
return Err(WorkspaceClientError::Unavailable(
|
||||
"response lost; acceptance unknown".into(),
|
||||
));
|
||||
}
|
||||
let status = self.status.load(Ordering::SeqCst);
|
||||
if status != 200 {
|
||||
return Ok(WorkspaceResponse {
|
||||
status,
|
||||
body: serde_json::json!({"message":"Backend rejected chat operation"}).to_string(),
|
||||
});
|
||||
}
|
||||
let input: ChatWorkerRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
let response = match input.operation {
|
||||
ChatWorkerOperation::Send { request } if !self.wrong_variant.load(Ordering::SeqCst) => {
|
||||
ChatWorkerResponse::Sent {
|
||||
message: message(request),
|
||||
}
|
||||
}
|
||||
_ => ChatWorkerResponse::Messages {
|
||||
page: ChatMessagePage {
|
||||
conversation: ChatConversation {
|
||||
id: "conversation-1".into(),
|
||||
subject_id: "subject-fixture".into(),
|
||||
participant_account_id: "account-fixture".into(),
|
||||
last_user_message_id: Some("user-message-3".into()),
|
||||
},
|
||||
messages: if self.page_text_bytes.load(Ordering::SeqCst) > 0 {
|
||||
let mut request: ChatSendMessageRequest =
|
||||
serde_json::from_value(send_input()).unwrap();
|
||||
request.text = "x".repeat(self.page_text_bytes.load(Ordering::SeqCst));
|
||||
vec![message(request)]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
has_more: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&response).unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatBackendFixture {
|
||||
fn chat_requests(&self) -> Vec<ChatWorkerRequest> {
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|request| request.path.ends_with("/chat/worker"))
|
||||
.map(|request| serde_json::from_str(request.body.as_deref().unwrap()).unwrap())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn tool(client: Arc<ChatBackendFixture>, operation: Operation) -> ConversationTool {
|
||||
ConversationTool {
|
||||
host: crate::subjektiv::test_connection(client),
|
||||
operation,
|
||||
}
|
||||
}
|
||||
|
||||
async fn invoke(
|
||||
tool: &ConversationTool,
|
||||
input: serde_json::Value,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
tool.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-chat", "batch", 0),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_is_opt_in_and_requires_explicit_connection_without_hooks() {
|
||||
let mut config = manifest::ResolvedSubjektivFeatureConfig::default();
|
||||
config.profile.enabled = true;
|
||||
assert!(
|
||||
SubjektivChatFeature::from_resolved_config(&config, None)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
config.profile.chat.enabled = true;
|
||||
assert!(
|
||||
SubjektivChatFeature::from_resolved_config(&config, None)
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("explicit live Subject")
|
||||
);
|
||||
let host = crate::subjektiv::test_connection(Arc::new(ChatBackendFixture::default()));
|
||||
let feature = SubjektivChatFeature::from_resolved_config(&config, Some(host.clone()))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let descriptor = feature.descriptor();
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
[READ_TOOL, SEND_TOOL]
|
||||
);
|
||||
assert!(
|
||||
descriptor.hooks.is_empty(),
|
||||
"no assistant/output/turn hooks may publish chat"
|
||||
);
|
||||
config.profile.consolidation_tools = true;
|
||||
assert!(SubjektivChatFeature::from_resolved_config(&config, Some(host)).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registry_installs_only_explicit_tools_and_does_no_startup_io() {
|
||||
let client = Arc::new(ChatBackendFixture::default());
|
||||
let feature = SubjektivChatFeature {
|
||||
host: crate::subjektiv::test_connection(client.clone()),
|
||||
};
|
||||
let mut tools = Vec::new();
|
||||
let mut hooks = crate::hook::HookRegistryBuilder::new();
|
||||
let report = crate::feature::FeatureRegistryBuilder::new()
|
||||
.with_module(feature)
|
||||
.install_into_pending(&mut tools, &mut hooks);
|
||||
assert!(!report.has_errors());
|
||||
assert_eq!(
|
||||
tools.iter().map(|tool| tool().0.name).collect::<Vec<_>>(),
|
||||
[READ_TOOL, SEND_TOOL]
|
||||
);
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
let (_, read) = tools[0]();
|
||||
let (_, send) = tools[1]();
|
||||
read.execute(
|
||||
r#"{"conversation_id":"conversation-1"}"#,
|
||||
ToolExecutionContext::new("read", "batch", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for ordinal in 0..2 {
|
||||
let mut input = send_input();
|
||||
input["operation_id"] = serde_json::json!(format!("explicit-send-{ordinal}"));
|
||||
send.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("send", "batch", ordinal),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
client.chat_requests().len(),
|
||||
3,
|
||||
"one read and two explicit sends, no implicit publication"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_are_explicit_text_only_and_never_accept_identity() {
|
||||
let send = schema::<ChatSendMessageRequest>();
|
||||
let read = schema::<ReadParams>();
|
||||
for schema in [&send, &read] {
|
||||
assert_eq!(schema["additionalProperties"], false);
|
||||
for forbidden in [
|
||||
"sender",
|
||||
"subject_id",
|
||||
"account_id",
|
||||
"session_id",
|
||||
"worker_id",
|
||||
"attachments",
|
||||
] {
|
||||
assert!(
|
||||
schema["properties"].get(forbidden).is_none(),
|
||||
"{forbidden} must not be model input"
|
||||
);
|
||||
}
|
||||
}
|
||||
for required in [
|
||||
"conversation_id",
|
||||
"operation_id",
|
||||
"text",
|
||||
"expected_last_user_message_id",
|
||||
] {
|
||||
assert!(
|
||||
send["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!(required))
|
||||
);
|
||||
}
|
||||
assert!(read["properties"].get("after_sequence").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_preserves_observed_input_reply_work_and_stable_retry_identity() {
|
||||
let client = Arc::new(ChatBackendFixture::default());
|
||||
let tool = tool(client.clone(), Operation::Send);
|
||||
let input = send_input();
|
||||
for _ in 0..2 {
|
||||
let output = invoke(&tool, input.clone()).await.unwrap();
|
||||
let committed: ChatMessage =
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(committed.id, "message-committed");
|
||||
assert_eq!(committed.operation_id, input["operation_id"]);
|
||||
assert_eq!(
|
||||
committed.last_user_message_id.as_deref(),
|
||||
Some("user-message-3")
|
||||
);
|
||||
assert!(output.attachments.is_empty());
|
||||
}
|
||||
let requests = client.chat_requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&requests[0]).unwrap(),
|
||||
serde_json::to_value(&requests[1]).unwrap()
|
||||
);
|
||||
let ChatWorkerOperation::Send { request } = &requests[0].operation else {
|
||||
panic!("Send required")
|
||||
};
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), input);
|
||||
// The fixture confirms forwarding, not storage exactly-once or real model quality.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_preserves_cursor_and_returns_last_user_message_with_small_default_page() {
|
||||
let client = Arc::new(ChatBackendFixture::default());
|
||||
let tool = tool(client.clone(), Operation::Read);
|
||||
let output = invoke(
|
||||
&tool,
|
||||
serde_json::json!({"conversation_id":"conversation-1","after_sequence":6}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let page: ChatMessagePage = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
page.conversation.last_user_message_id.as_deref(),
|
||||
Some("user-message-3")
|
||||
);
|
||||
let requests = client.chat_requests();
|
||||
let ChatWorkerOperation::Read {
|
||||
conversation_id,
|
||||
query,
|
||||
} = &requests[0].operation
|
||||
else {
|
||||
panic!("Read required")
|
||||
};
|
||||
assert_eq!(conversation_id, "conversation-1");
|
||||
assert_eq!(query.after_sequence, Some(6));
|
||||
assert_eq!(query.limit, Some(5));
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
1,
|
||||
"read must not automatically send anything"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_and_send_use_distinct_live_bound_subject_permissions() {
|
||||
for (operation, input, allowed) in [
|
||||
(
|
||||
Operation::Read,
|
||||
serde_json::json!({"conversation_id":"conversation-1"}),
|
||||
"chat.send",
|
||||
),
|
||||
(Operation::Send, send_input(), "chat.read"),
|
||||
] {
|
||||
let client = Arc::new(ChatBackendFixture::default());
|
||||
*client.allowed.lock().unwrap() = vec![allowed];
|
||||
let tool = tool(client.clone(), operation);
|
||||
let error = invoke(&tool, input.clone()).await.err().unwrap();
|
||||
assert!(error.to_string().contains("permission denied"));
|
||||
assert!(
|
||||
client.chat_requests().is_empty(),
|
||||
"denied content must not be read or written"
|
||||
);
|
||||
*client.allowed.lock().unwrap() = vec!["chat.read", "chat.send"];
|
||||
invoke(&tool, input.clone()).await.unwrap();
|
||||
client.allowed.lock().unwrap().clear();
|
||||
assert!(
|
||||
invoke(&tool, input).await.is_err(),
|
||||
"saved connection must not cache permission"
|
||||
);
|
||||
assert_eq!(client.chat_requests().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_rejection_conflict_and_unknown_acceptance_never_report_sent_or_auto_retry() {
|
||||
for status in [403, 409, 500] {
|
||||
let client = Arc::new(ChatBackendFixture::default());
|
||||
client.status.store(status, Ordering::SeqCst);
|
||||
let error = invoke(&tool(client.clone(), Operation::Send), send_input())
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
if status == 409 {
|
||||
assert!(matches!(error, ToolError::StructuredConflict { .. }));
|
||||
} else {
|
||||
assert!(matches!(error, ToolError::ExecutionFailed(_)));
|
||||
}
|
||||
assert_eq!(client.chat_requests().len(), 1);
|
||||
}
|
||||
let client = Arc::new(ChatBackendFixture::default());
|
||||
client.unavailable.store(true, Ordering::SeqCst);
|
||||
assert!(
|
||||
invoke(&tool(client.clone(), Operation::Send), send_input())
|
||||
.await
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("acceptance unknown")
|
||||
);
|
||||
assert_eq!(client.chat_requests().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_or_identity_claiming_requests_are_rejected_before_host_access() {
|
||||
let client = Arc::new(ChatBackendFixture::default());
|
||||
let send = tool(client.clone(), Operation::Send);
|
||||
for forbidden in ["sender", "subject_id", "account_id", "attachments"] {
|
||||
let mut input = send_input();
|
||||
input[forbidden] = serde_json::json!("forged");
|
||||
assert!(matches!(
|
||||
invoke(&send, input).await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
for (field, value) in [
|
||||
("conversation_id", serde_json::json!(" ")),
|
||||
("operation_id", serde_json::json!("")),
|
||||
("text", serde_json::json!(" ")),
|
||||
(
|
||||
"text",
|
||||
serde_json::json!("x".repeat(server_api::CHAT_MAX_TEXT_BYTES + 1)),
|
||||
),
|
||||
("expected_last_user_message_id", serde_json::json!(-1)),
|
||||
] {
|
||||
let mut input = send_input();
|
||||
input[field] = value;
|
||||
assert!(matches!(
|
||||
invoke(&send, input).await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
let read = tool(client.clone(), Operation::Read);
|
||||
for limit in [0, server_api::CHAT_MAX_PAGE_LIMIT + 1] {
|
||||
assert!(
|
||||
invoke(
|
||||
&read,
|
||||
serde_json::json!({"conversation_id":"conversation-1","limit":limit})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unexpected_response_variants_and_oversized_reads_fail_closed() {
|
||||
let client = Arc::new(ChatBackendFixture::default());
|
||||
client.wrong_variant.store(true, Ordering::SeqCst);
|
||||
assert!(
|
||||
invoke(&tool(client.clone(), Operation::Send), send_input())
|
||||
.await
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("unexpected chat")
|
||||
);
|
||||
client
|
||||
.page_text_bytes
|
||||
.store(MAX_TOOL_OUTPUT_BYTES + 1, Ordering::SeqCst);
|
||||
let error = invoke(
|
||||
&tool(client.clone(), Operation::Read),
|
||||
serde_json::json!({"conversation_id":"conversation-1"}),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.to_string().contains("smaller page"));
|
||||
assert_eq!(client.chat_requests().len(), 2);
|
||||
}
|
||||
@@ -52,6 +52,28 @@ impl Hook<PreToolCall> for PermissionHook {
|
||||
}
|
||||
}
|
||||
|
||||
// Only prune schemas when every possible argument is denied. Conditional
|
||||
// rules remain visible and are checked by the normal execution boundary.
|
||||
// This is surface composition, not another permission source.
|
||||
pub(crate) fn tool_is_unconditionally_denied(
|
||||
config: &ToolPermissionConfig,
|
||||
tool_name: &str,
|
||||
) -> bool {
|
||||
for rule in config
|
||||
.rules
|
||||
.iter()
|
||||
.filter(|rule| rule.tool.eq_ignore_ascii_case(tool_name))
|
||||
{
|
||||
if rule.action != ToolPermissionAction::Deny {
|
||||
return false;
|
||||
}
|
||||
if !rule.pattern.is_empty() && rule.pattern.bytes().all(|byte| byte == b'*') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
config.default_action == ToolPermissionAction::Deny
|
||||
}
|
||||
|
||||
pub(crate) fn permission_action_for(
|
||||
config: &ToolPermissionConfig,
|
||||
tool_name: &str,
|
||||
@@ -138,6 +160,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_tool_pruning_respects_rule_order_and_never_hides_conditional_allow() {
|
||||
let mut policy = ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Deny,
|
||||
rules: Vec::new(),
|
||||
};
|
||||
assert!(tool_is_unconditionally_denied(&policy, "Bash"));
|
||||
policy.rules.push(ToolPermissionRule {
|
||||
tool: "bash".into(),
|
||||
pattern: "git *".into(),
|
||||
action: ToolPermissionAction::Allow,
|
||||
});
|
||||
assert!(!tool_is_unconditionally_denied(&policy, "Bash"));
|
||||
policy.rules.push(ToolPermissionRule {
|
||||
tool: "Bash".into(),
|
||||
pattern: "*".into(),
|
||||
action: ToolPermissionAction::Deny,
|
||||
});
|
||||
assert!(!tool_is_unconditionally_denied(&policy, "Bash"));
|
||||
policy.rules.swap(0, 1);
|
||||
assert!(tool_is_unconditionally_denied(&policy, "Bash"));
|
||||
policy.default_action = ToolPermissionAction::Allow;
|
||||
assert!(tool_is_unconditionally_denied(&policy, "Bash"));
|
||||
assert!(!tool_is_unconditionally_denied(&policy, "Read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_matching_rule_wins_by_declaration_order() {
|
||||
let hook = PermissionHook::new(ToolPermissionConfig {
|
||||
|
||||
@@ -272,6 +272,7 @@ impl ParentNotificationTarget {
|
||||
|
||||
pub(crate) fn notify(&self, child_session_id: String, message: String) {
|
||||
let method = Method::NotifyTracked {
|
||||
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||
notification_request_id: protocol::new_submission_request_id(),
|
||||
message,
|
||||
source: protocol::AuthenticatedInputSource::SubWorker {
|
||||
|
||||
@@ -162,6 +162,17 @@ pub trait SubjektivHost: Send + Sync + 'static {
|
||||
fn workspace_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// Text-only public conversation access. Restricted Job and standalone Hosts
|
||||
/// inherit denial, not the connected Subject body's posting capability.
|
||||
fn chat(
|
||||
&self,
|
||||
_context: &SubjektivHostContext,
|
||||
_operation: ChatWorkerOperation,
|
||||
) -> Result<ChatWorkerResponse, SubjektivHostError> {
|
||||
Err(SubjektivHostError::PermissionDenied(
|
||||
"conversation access is not granted by this Host".into(),
|
||||
))
|
||||
}
|
||||
fn memory(
|
||||
&self,
|
||||
context: &SubjektivHostContext,
|
||||
@@ -399,6 +410,18 @@ impl SubjektivHost for BackendSubjektivHost {
|
||||
fn consolidation_granted(&self) -> bool {
|
||||
self.consolidation_granted
|
||||
}
|
||||
fn chat(
|
||||
&self,
|
||||
context: &SubjektivHostContext,
|
||||
operation: ChatWorkerOperation,
|
||||
) -> Result<ChatWorkerResponse, SubjektivHostError> {
|
||||
self.authorize_operation(context, chat_operation_name(&operation))?;
|
||||
// Backend revalidates current singleton ownership and conversation access
|
||||
// at the read/write boundary; this preflight cannot replace those checks.
|
||||
self.execute(WorkspaceServerOperation::ChatWorker(ChatWorkerRequest {
|
||||
operation,
|
||||
}))
|
||||
}
|
||||
fn memory(
|
||||
&self,
|
||||
context: &SubjektivHostContext,
|
||||
@@ -574,6 +597,13 @@ impl JournalJobHost for BackendJournalJobHost {
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_operation_name(operation: &ChatWorkerOperation) -> &'static str {
|
||||
match operation {
|
||||
ChatWorkerOperation::Read { .. } => "chat.read",
|
||||
ChatWorkerOperation::Send { .. } => "chat.send",
|
||||
}
|
||||
}
|
||||
|
||||
/// Live connection with Worker-supplied authority. Not serializable and never
|
||||
/// inherited by an ordinary SubWorker.
|
||||
#[derive(Clone)]
|
||||
@@ -632,6 +662,13 @@ impl SubjektivHostConnection {
|
||||
) -> Result<MemoryJournalResponse, SubjektivHostError> {
|
||||
self.host.memory_journal(&self.context, request)
|
||||
}
|
||||
pub fn chat(
|
||||
&self,
|
||||
operation: ChatWorkerOperation,
|
||||
) -> Result<ChatWorkerResponse, SubjektivHostError> {
|
||||
self.authorize_operation(chat_operation_name(&operation))?;
|
||||
self.host.chat(&self.context, operation)
|
||||
}
|
||||
pub fn memory(
|
||||
&self,
|
||||
operation: SubjektivMemoryBackendOperation,
|
||||
@@ -769,6 +806,28 @@ mod tests {
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[test]
|
||||
fn non_chat_local_hosts_do_not_inherit_public_conversation_access() {
|
||||
let host = Arc::new(LocalHost::default());
|
||||
let context = SubjektivHostContext {
|
||||
worker_id: "worker".into(),
|
||||
session_id: "session".into(),
|
||||
};
|
||||
let read = ChatWorkerOperation::Read {
|
||||
conversation_id: "conversation".into(),
|
||||
query: ChatMessagePageQuery::default(),
|
||||
};
|
||||
assert!(matches!(
|
||||
host.chat(&context, read.clone()),
|
||||
Err(SubjektivHostError::PermissionDenied(_))
|
||||
));
|
||||
let connection = SubjektivHostConnection::new(host, context).unwrap();
|
||||
assert!(matches!(
|
||||
connection.chat(read),
|
||||
Err(SubjektivHostError::PermissionDenied(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LocalHost {
|
||||
startup: Option<JournalStartupSnapshot>,
|
||||
|
||||
@@ -792,6 +792,7 @@ pub enum WorkspaceServerOperation {
|
||||
WorkerObservationCapture(server_api::WorkerObservationSubjectRef),
|
||||
SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest),
|
||||
SubjektivRecordSession(server_api::SubjektivRecordSessionRequest),
|
||||
ChatWorker(server_api::ChatWorkerRequest),
|
||||
SubjektivMemory(server_api::SubjektivMemoryBackendRequest),
|
||||
SubjektivSession(server_api::SubjektivSessionBackendRequest),
|
||||
}
|
||||
@@ -1025,6 +1026,9 @@ fn workspace_server_operation_request(
|
||||
WorkspaceServerOperation::SubjektivRecordSession(request) => {
|
||||
workspace_server_json_request(format!("{base}/subjektiv/sessions"), &request)
|
||||
}
|
||||
WorkspaceServerOperation::ChatWorker(request) => {
|
||||
workspace_server_json_request(format!("{base}/subjektiv/chat/worker"), &request)
|
||||
}
|
||||
WorkspaceServerOperation::SubjektivMemory(request) => {
|
||||
workspace_server_json_request(format!("{base}/subjektiv/memory"), &request)
|
||||
}
|
||||
@@ -12940,6 +12944,19 @@ mod build_summary_prompt_tests {
|
||||
{
|
||||
1
|
||||
}
|
||||
LogEntry::Extension {
|
||||
domain, payload, ..
|
||||
} if domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN
|
||||
&& payload["pending_notifications"]
|
||||
.as_array()
|
||||
.is_some_and(|messages| {
|
||||
messages
|
||||
.iter()
|
||||
.any(|message| message["message"] == "persist-failure")
|
||||
}) =>
|
||||
{
|
||||
6
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. }
|
||||
if input_schema(entry) == Some("feature:invocation-result") =>
|
||||
{
|
||||
@@ -18319,6 +18336,112 @@ permission = "write"
|
||||
assert_eq!(committed.annotation.origin, provenance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_admission_save_failure_leaves_no_receipt_and_same_request_can_retry() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let original = PendingSubmissionHandle::for_test(temp.path());
|
||||
let handle = PendingSubmissionHandle {
|
||||
state: original.state.clone(),
|
||||
writer: LogWriterHandle {
|
||||
store: FailRunTerminalStore {
|
||||
inner: original.writer.store.clone(),
|
||||
fail_run_terminal: Arc::new(AtomicBool::new(false)),
|
||||
invocation_failure: Arc::new(AtomicUsize::new(6)),
|
||||
},
|
||||
state: original.writer.state.clone(),
|
||||
sink: original.writer.sink.clone(),
|
||||
in_flight: None,
|
||||
},
|
||||
};
|
||||
assert!(
|
||||
handle
|
||||
.accept_notification("stable-request".into(), "persist-failure".into())
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(handle.snapshot().notification_count, 0);
|
||||
assert!(original.persisted_entries_for_test().is_empty());
|
||||
assert!(
|
||||
handle
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.notification_receipts
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
handle
|
||||
.accept_notification("stable-request".into(), "persist-failure".into())
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!handle
|
||||
.accept_notification("stable-request".into(), "persist-failure".into())
|
||||
.unwrap()
|
||||
);
|
||||
let saved = original.persisted_entries_for_test();
|
||||
let LogEntry::Extension { payload, .. } = saved.last().unwrap() else {
|
||||
panic!("saved admission");
|
||||
};
|
||||
let state: PendingActivationState = serde_json::from_value(payload.clone()).unwrap();
|
||||
assert_eq!(state.pending_notifications.len(), 1);
|
||||
assert_eq!(state.notification_receipts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_admission_limits_do_not_persist_rejection_or_evict_accepted_input() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let handle = PendingSubmissionHandle::for_test(temp.path());
|
||||
for index in 0..MAX_PENDING_SUBMISSIONS {
|
||||
assert!(
|
||||
handle
|
||||
.accept_notification(format!("notice-{index}"), "bounded".into())
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
let saved = handle.persisted_entries_for_test();
|
||||
assert!(matches!(
|
||||
handle.accept_notification("overflow".into(), "bounded".into()),
|
||||
Err(PendingSubmissionError::CountLimit)
|
||||
));
|
||||
assert_eq!(handle.persisted_entries_for_test().len(), saved.len());
|
||||
assert_eq!(
|
||||
handle.snapshot().notification_count as usize,
|
||||
MAX_PENDING_SUBMISSIONS
|
||||
);
|
||||
// An exact retry remains accepted even at capacity; a changed body never does.
|
||||
assert!(
|
||||
!handle
|
||||
.accept_notification("notice-0".into(), "bounded".into())
|
||||
.unwrap()
|
||||
);
|
||||
assert!(matches!(
|
||||
handle.accept_notification("notice-0".into(), "changed".into()),
|
||||
Err(PendingSubmissionError::IdempotencyConflict)
|
||||
));
|
||||
let bytes = tempfile::tempdir().unwrap();
|
||||
let handle = PendingSubmissionHandle::for_test(bytes.path());
|
||||
assert!(
|
||||
handle
|
||||
.accept_notification(
|
||||
"full-byte-budget".into(),
|
||||
"x".repeat(MAX_PENDING_SUBMISSION_BYTES as usize)
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
let saved = handle.persisted_entries_for_test();
|
||||
assert!(matches!(
|
||||
handle.accept_notification("byte-overflow".into(), "x".into()),
|
||||
Err(PendingSubmissionError::ByteLimit)
|
||||
));
|
||||
assert_eq!(handle.persisted_entries_for_test().len(), saved.len());
|
||||
let LogEntry::Extension { payload, .. } = saved.last().unwrap() else {
|
||||
panic!("saved admission");
|
||||
};
|
||||
let state: PendingActivationState = serde_json::from_value(payload.clone()).unwrap();
|
||||
assert_eq!(state.pending_notifications.len(), 1);
|
||||
assert_eq!(state.notification_receipts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_retry_identity_is_scoped_to_authenticated_source() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -67,6 +67,72 @@ fn assert_publication(
|
||||
assert_eq!(prompt.contains("## SubWorker orchestration"), sub_worker);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_chat_profile_prunes_execution_tools_without_granting_authority() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let _sandbox = worker_allocation::test_util::RuntimeDirSandbox::new(temp.path());
|
||||
let resolved = manifest::ProfileResolver::new()
|
||||
.with_workspace_base(temp.path())
|
||||
.resolve(
|
||||
&manifest::ProfileSelector::source_named(
|
||||
manifest::ProfileRegistrySource::Builtin,
|
||||
"async-chat",
|
||||
),
|
||||
manifest::ProfileResolveOptions::with_worker_name("chat-composition"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut manifest = resolved.manifest;
|
||||
// This case isolates registration restrictions from independently tested
|
||||
// Subject startup Memory/Journal authorization and chat Host requirements.
|
||||
manifest.feature.subjektiv.profile.enabled = false;
|
||||
let (_, store) = feature_restore_fixture(&temp);
|
||||
let client = Arc::new(FeatureAdmissionClient::default());
|
||||
let controller = bootstrap(&temp, manifest, store, Some(client.clone()))
|
||||
.prepare()
|
||||
.await
|
||||
.unwrap()
|
||||
.start()
|
||||
.await
|
||||
.unwrap();
|
||||
let names = controller.handle.shared_state.greeting().tools;
|
||||
for forbidden in [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"SubWorkerSpawn",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"SpawnTicketWorker",
|
||||
"JobRun",
|
||||
] {
|
||||
assert!(
|
||||
!names.iter().any(|name| name == forbidden),
|
||||
"chat body exposed {forbidden}: {names:?}"
|
||||
);
|
||||
}
|
||||
for required in [
|
||||
"WorkerSpawn",
|
||||
"WorkerNotify",
|
||||
"WorkerStop",
|
||||
"WorkerList",
|
||||
"QueryObjective",
|
||||
"ShowTicket",
|
||||
] {
|
||||
assert!(
|
||||
names.iter().any(|name| name == required),
|
||||
"chat body lacks {required}: {names:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
client.requests.lock().unwrap().is_empty(),
|
||||
"Profile activation must not issue authority or read protected content"
|
||||
);
|
||||
stop_body_publication_controller(controller).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn objective_and_sub_worker_saved_composition_survives_create_and_restore_matrix() {
|
||||
for mode in [manifest::WorkerMode::Tools, manifest::WorkerMode::Wip] {
|
||||
|
||||
@@ -1935,6 +1935,7 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
||||
let completion_request_id = "subworker-completion-during-compact";
|
||||
handle
|
||||
.send(Method::NotifyTracked {
|
||||
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||
notification_request_id: completion_request_id.into(),
|
||||
message: "subworker completed".into(),
|
||||
source: protocol::AuthenticatedInputSource::UntrustedWire,
|
||||
|
||||
@@ -601,6 +601,7 @@ fn notify_rejects_typed_side_effect_message_payloads() {
|
||||
message: "/prepare(first)".into(),
|
||||
},
|
||||
protocol::Method::NotifyTracked {
|
||||
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||
notification_request_id: "n-1".into(),
|
||||
message: "/prepare(first)".into(),
|
||||
source: protocol::AuthenticatedInputSource::UntrustedWire,
|
||||
|
||||
Reference in New Issue
Block a user