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:
2026-10-30 07:20:50 +09:00
91 changed files with 12098 additions and 325 deletions
+7
View File
@@ -108,6 +108,13 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:async-chat"),
path: "profiles/async-chat.dcdl",
source: include_str!("../../../resources/profiles/async-chat.dcdl"),
description: "Explicitly connected Subject chat with bounded references and asynchronous Worker delegation.",
imports: NO_IMPORTS,
},
BuiltinProfileResource {
selector: Some("builtin:companion"),
path: "profiles/companion.dcdl",
+19
View File
@@ -39,3 +39,22 @@ fn catalog_import_metadata_has_no_cycles() {
visit(resource.path, &mut BTreeSet::new());
}
}
#[test]
fn async_chat_is_public_and_does_not_inherit_broader_profile_defaults() {
let catalog = builtin_profile_catalog_snapshot();
let path = &catalog.entrypoints["builtin:async-chat"];
let resource = BUILTIN_PROFILE_RESOURCES
.iter()
.find(|resource| resource.path == path)
.unwrap();
assert_eq!(resource.selector, Some("builtin:async-chat"));
assert!(!resource.description.is_empty());
assert!(resource.imports.is_empty());
assert!(
!catalog
.imports
.keys()
.any(|request| request.starts_with(&format!("{path}\0")))
);
}
+82
View File
@@ -299,6 +299,8 @@ pub struct SubjektivFeatureConfigPartial {
#[serde(default)]
pub consolidation_tools: Option<bool>,
#[serde(default)]
pub chat: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
}
@@ -307,6 +309,7 @@ impl SubjektivFeatureConfigPartial {
Self {
enabled: other.enabled.or(self.enabled),
consolidation_tools: other.consolidation_tools.or(self.consolidation_tools),
chat: merge_option(self.chat, other.chat, FeatureFlagConfigPartial::merge),
extraction: merge_option(
self.extraction,
other.extraction,
@@ -558,6 +561,7 @@ impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
profile: SubjektivFeatureProfileConfig {
enabled: value.enabled.unwrap_or_default(),
consolidation_tools: value.consolidation_tools.unwrap_or_default(),
chat: value.chat.map(FeatureFlagConfig::from).unwrap_or_default(),
extraction: MemoryExtractionProfileConfig {
enabled: extraction.enabled.unwrap_or(true),
model: extraction.model,
@@ -578,6 +582,7 @@ impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
Self {
enabled: Some(value.profile.enabled),
consolidation_tools: Some(value.profile.consolidation_tools),
chat: Some(value.profile.chat.into()),
extraction: Some(MemoryExtractionProfileConfigPartial {
enabled: Some(value.profile.extraction.enabled),
model: value.profile.extraction.model,
@@ -2990,6 +2995,83 @@ worker_max_turns = 7
assert_eq!(encoded["enabled"], true);
}
#[test]
fn subjektiv_chat_requires_explicit_opt_in_and_roundtrips() {
for subject in [
"",
"[feature.subjektiv]\nenabled = true\n",
"[feature.subjektiv.chat]\n",
] {
let config = WorkerManifestConfig::from_toml(subject).unwrap();
let feature = FeatureConfig::from(config.feature);
assert!(!feature.subjektiv.profile.chat.enabled);
}
assert!(
!ResolvedSubjektivFeatureConfig::default()
.profile
.chat
.enabled
);
let legacy: ResolvedSubjektivFeatureConfig = serde_json::from_value(serde_json::json!({
"profile": { "enabled": true }
}))
.unwrap();
assert!(!legacy.profile.chat.enabled);
for enabled in [true, false] {
let config = WorkerManifestConfig::from_toml(&format!(
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = {enabled}\n"
)).unwrap();
let resolved = FeatureConfig::from(config.feature).subjektiv;
let json = serde_json::to_value(&resolved).unwrap();
assert_eq!(json["profile"]["chat"]["enabled"], enabled);
assert_eq!(
serde_json::from_value::<ResolvedSubjektivFeatureConfig>(json).unwrap(),
resolved
);
assert!(resolved.workspace_settings.is_none());
assert!(!resolved.execution_enabled());
let partial = SubjektivFeatureConfigPartial::from(resolved.clone());
let toml = toml::to_string(&partial).unwrap();
assert_eq!(
ResolvedSubjektivFeatureConfig::from(
toml::from_str::<SubjektivFeatureConfigPartial>(&toml).unwrap()
),
resolved
);
}
}
#[test]
fn subjektiv_chat_merge_preserves_omissions_and_honors_explicit_disable() {
let base = WorkerManifestConfig::from_toml(
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = true\n[feature.subjektiv.extraction]\nthreshold = 321\n"
).unwrap();
for overlay in [
"",
"[feature.subjektiv]\nconsolidation_tools = false\n",
"[feature.subjektiv.chat]\n",
] {
let merged = base
.clone()
.merge(WorkerManifestConfig::from_toml(overlay).unwrap());
let feature = FeatureConfig::from(merged.feature);
assert!(feature.subjektiv.profile.chat.enabled);
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
}
for enabled in [false, true] {
let overlay = WorkerManifestConfig::from_toml(&format!(
"[feature.subjektiv.chat]\nenabled = {enabled}\n"
))
.unwrap();
let merged = base.clone().merge(overlay);
let feature = FeatureConfig::from(merged.feature);
assert_eq!(feature.subjektiv.profile.chat.enabled, enabled);
assert!(feature.subjektiv.profile.enabled);
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
}
}
#[test]
fn merge_preserves_ref() {
let lower = WorkerManifestConfig {
+3
View File
@@ -419,6 +419,8 @@ pub struct SubjektivFeatureProfileConfig {
pub enabled: bool,
/// Explicit consolidation tool policy; never grants Job or subject authority.
pub consolidation_tools: bool,
/// Opt-in conversation tools; the Host must bind an explicit Subject connection.
pub chat: FeatureFlagConfig,
pub extraction: MemoryExtractionProfileConfig,
}
@@ -427,6 +429,7 @@ impl Default for SubjektivFeatureProfileConfig {
Self {
enabled: false,
consolidation_tools: false,
chat: FeatureFlagConfig::disabled(),
extraction: MemoryExtractionProfileConfig::default(),
}
}
+131
View File
@@ -1730,6 +1730,137 @@ mod tests {
assert!(!resolved.manifest.feature.worker.enabled);
}
#[test]
fn builtin_async_chat_exposes_only_opted_in_chat_and_bounded_features() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "async-chat"),
ProfileResolveOptions::with_worker_name("chat-worker"),
)
.unwrap();
let manifest = resolved.manifest;
let feature = &manifest.feature;
assert_eq!(manifest.engine.instruction, "role.async_chat");
assert!(resolved.raw_artifact["scope"].is_null());
assert!(resolved.raw_artifact["delegation_scope"].is_null());
assert!(manifest.scope.allow.is_empty());
assert!(manifest.delegation_scope.allow.is_empty());
assert!(manifest.mcp.stdio_servers.is_empty());
assert!(feature.subjektiv.profile.enabled);
assert!(feature.subjektiv.profile.chat.enabled);
assert!(!feature.subjektiv.profile.consolidation_tools);
assert!(!feature.subjektiv.profile.extraction.enabled);
assert!(feature.subjektiv.workspace_settings.is_none());
assert!(!feature.subjektiv.execution_enabled());
assert!(!feature.memory.profile.enabled);
assert!(!feature.memory.profile.staging_tools);
assert!(!feature.task.enabled);
assert!(!feature.sub_worker.enabled);
assert!(!feature.flow.enabled);
assert!(feature.worker.enabled);
assert!(feature.worker.direct_spawn);
assert!(!feature.workspace_worker_discovery.enabled);
assert!(feature.objective.enabled);
assert!(feature.ticket.enabled);
assert!(!feature.ticket.authoring);
assert!(!feature.ticket.thread);
assert!(!feature.ticket.intake);
assert!(!feature.ticket.workflow);
assert!(!feature.merge_request.show);
assert!(!feature.merge_request.open);
assert!(!feature.merge_request.review);
assert!(!feature.merge_request.readiness_check);
assert!(!feature.merge_request.complete);
for flag in [
feature.web,
feature.image,
feature.drive,
feature.manage_workdir,
feature.workdir_catalog,
feature.workspace_config,
feature.orchestration,
] {
assert!(!flag.enabled);
}
let permissions = manifest.permissions.unwrap();
assert_eq!(
permissions.default_action,
crate::ToolPermissionAction::Deny
);
// Exact tool names and all-argument rules let the Worker statically hide
// denied filesystem tools while its permission hook enforces each call.
let allowed = permissions
.rules
.iter()
.map(|rule| {
assert_eq!(rule.action, crate::ToolPermissionAction::Allow);
assert_eq!(rule.pattern, "*");
rule.tool.as_str()
})
.collect::<BTreeSet<_>>();
assert_eq!(allowed.len(), permissions.rules.len());
assert_eq!(
allowed,
BTreeSet::from([
"ConversationRead",
"ConversationSend",
"WorkerList",
"WorkerSpawn",
"WorkerSendInput",
"WorkerNotify",
"WorkerCancel",
"WorkerStop",
"WorkerRestore",
"ViewSessionOverview",
"SearchSessionEntries",
"ReadSessionEntry",
"SubjektivSessionList",
"SubjektivSessionSearch",
"SubjektivSessionRead",
"SubjektivMemoryQuery",
"SubjektivMemoryRead",
"SubjektivMemoryListChanges",
"QueryObjective",
"ShowObjective",
"QueryTicket",
"ShowTicket",
"WorkspaceTagList",
"TicketAssignmentTrackingQuery",
"TicketAssignmentTrackingHistory",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketImplementationResultQuery",
"NotificationDiagnostics",
])
);
}
#[test]
fn builtin_profiles_do_not_implicitly_enable_subject_chat() {
for entry in builtin_profile_entrypoints() {
if entry.selector == Some("builtin:async-chat") {
continue;
}
let artifact = resolve_builtin_profile_artifact(entry.selector.unwrap())
.unwrap()
.unwrap();
let feature: FeatureConfigPartial =
serde_json::from_value(artifact["feature"].clone()).unwrap();
assert!(
!crate::FeatureConfig::from(feature)
.subjektiv
.profile
.chat
.enabled,
"{:?} must remain opted out",
entry.selector
);
}
}
#[test]
fn builtin_companion_combines_runtime_and_sub_worker_control_with_discovery() {
let tmp = TempDir::new().unwrap();
+52
View File
@@ -103,6 +103,33 @@ impl AuthenticatedInputSource {
}
}
/// Server-owned receipt partition, independent of client-visible ACK identity.
/// Console Notify keeps its original bounded ID without sharing HTTP/chat receipts.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationReceiptScope {
#[default]
Source,
BrowserConsole,
}
impl NotificationReceiptScope {
pub fn namespace(self, source_namespace: String) -> String {
match self {
Self::Source => source_namespace,
Self::BrowserConsole => format!("browser-console:{source_namespace}"),
}
}
}
// Like provenance, a receipt partition is rebound by the authenticated transport.
fn ignore_wire_notification_scope<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<NotificationReceiptScope, D::Error> {
let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
Ok(NotificationReceiptScope::Source)
}
/// Caller-owned identity for one state-changing Worker command.
///
/// Commands operate on the controller state at admission time, not a caller's
@@ -311,6 +338,8 @@ pub enum Method {
NotifyTracked {
notification_request_id: String,
message: String,
#[serde(default, deserialize_with = "ignore_wire_notification_scope")]
receipt_scope: NotificationReceiptScope,
#[serde(default, deserialize_with = "ignore_wire_input_source")]
source: AuthenticatedInputSource,
},
@@ -2242,6 +2271,29 @@ mod tests {
);
}
#[test]
fn notification_receipt_scope_is_not_selected_by_wire_input() {
for scope in [
serde_json::json!("browser_console"),
serde_json::json!({"forged":true}),
serde_json::Value::Null,
] {
let method: Method = serde_json::from_value(serde_json::json!({
"method":"notify_tracked", "params":{
"notification_request_id":"original", "message":"advisory",
"receipt_scope":scope,
"source":{"kind":"account","account_id":"forged"}
}
}))
.unwrap();
assert!(matches!(method, Method::NotifyTracked {
receipt_scope: NotificationReceiptScope::Source,
source: AuthenticatedInputSource::UntrustedWire,
notification_request_id, ..
} if notification_request_id == "original"));
}
}
#[test]
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
for method in ["notify", "notify_tracked"] {
+27
View File
@@ -408,6 +408,9 @@ pub enum WorkerInputKind {
pub struct WorkerInput {
pub kind: WorkerInputKind,
pub content: String,
/// Provenance stamped by the authenticated Backend, never by public/model input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<protocol::AuthenticatedInputSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1624,6 +1627,30 @@ pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
mod tests {
use super::*;
#[test]
fn trusted_notify_source_round_trips_and_omitted_source_defaults_to_none() {
let mut value = serde_json::json!({
"kind": "notify", "content": "message", "submission_request_id": "stable-id"
});
let legacy: WorkerInput = serde_json::from_value(value.clone()).unwrap();
assert!(legacy.source.is_none());
assert_eq!(serde_json::to_value(legacy).unwrap(), value);
for source in [
protocol::AuthenticatedInputSource::Account {
account_id: "account-a".into(),
},
protocol::AuthenticatedInputSource::Worker {
runtime_id: "runtime-a".into(),
worker_id: "worker-a".into(),
},
] {
value["source"] = serde_json::to_value(&source).unwrap();
let input: WorkerInput = serde_json::from_value(value.clone()).unwrap();
assert_eq!(input.source, Some(source));
assert_eq!(serde_json::to_value(input).unwrap(), value);
}
}
#[test]
fn backend_job_journal_grant_defaults_off_and_does_not_enable_memory_grant() {
let legacy = serde_json::json!({"job_id":"job", "attempt_id":"attempt", "subjektiv_consolidation":true});
+3
View File
@@ -53,6 +53,9 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
mod catalog;
pub use catalog::*;
pub const MAX_SCOPED_PERMISSIONS: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
@@ -0,0 +1,951 @@
//! Management metadata over the existing policy, not an authorization source.
//!
//! Only the management replacement path validates against this catalog. Historical
//! deserialization and execution grants retain their exact spelling and scope. New
//! operations require an explicit catalog entry; dotted prefixes grant nothing.
use super::*;
#[cfg(test)]
#[path = "catalog_tests.rs"]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PermissionResourceKind {
PluginInstallation,
Job,
Ticket,
Objective,
MergeRequest,
Subject,
DriveEntry,
DriveRequest,
Repository,
WorkdirAttachment,
WorkspaceConfig,
Network,
SubWorker,
Feature,
Workspace,
Workdir,
Worker,
WorkerOperation,
}
impl PermissionResource {
pub fn kind(&self) -> PermissionResourceKind {
use PermissionResourceKind as K;
match self {
Self::PluginInstallation { .. } => K::PluginInstallation,
Self::Job { .. } => K::Job,
Self::Ticket { .. } => K::Ticket,
Self::Objective { .. } => K::Objective,
Self::MergeRequest { .. } => K::MergeRequest,
Self::Subject { .. } => K::Subject,
Self::DriveEntry { .. } => K::DriveEntry,
Self::DriveRequest { .. } => K::DriveRequest,
Self::Repository { .. } => K::Repository,
Self::WorkdirAttachment { .. } => K::WorkdirAttachment,
Self::WorkspaceConfig { .. } => K::WorkspaceConfig,
Self::Network { .. } => K::Network,
Self::SubWorker { .. } => K::SubWorker,
Self::Feature => K::Feature,
Self::Workspace { .. } => K::Workspace,
Self::Workdir { .. } => K::Workdir,
Self::Worker { .. } => K::Worker,
Self::WorkerOperation { .. } => K::WorkerOperation,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PermissionOperationAvailability {
Available,
Unavailable { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PermissionOperationDescriptor {
pub operation: String,
pub label: String,
pub description: String,
/// Accepted management input kinds, not a claim that a target exists or is usable.
pub resource_kinds: Vec<PermissionResourceKind>,
pub availability: PermissionOperationAvailability,
}
/// Available means the operation/resource contract is implemented, not that the
/// current Profile, provider, target, assignment or Job permits its execution.
/// Body execution and delegation ceilings use the same descriptors, independently.
pub fn subject_permission_operations() -> Vec<PermissionOperationDescriptor> {
use PermissionResourceKind as K;
let mut catalog = Vec::new();
let mut add = |kind, entries: &[(&str, &str, &str)]| {
catalog.extend(entries.iter().map(|(operation, label, description)| {
PermissionOperationDescriptor {
operation: (*operation).into(),
label: (*label).into(),
description: (*description).into(),
resource_kinds: vec![kind],
availability: PermissionOperationAvailability::Available,
}
}));
};
add(
K::Workdir,
&[
(
"filesystem.read",
"Read files",
"Read files and enumerate directories within the selected path and recursive scope; provider scope still applies.",
),
(
"filesystem.write",
"Edit files",
"Create or edit files within the selected path and recursive scope; does not permit commands.",
),
(
"bash.execute",
"Run commands",
"Requires the whole Workdir: path '.' and recursive true. A shell is not confined to a subdirectory. Explicit provider command authority is also required.",
),
(
"workdir.read",
"Inspect Workdir",
"Read selected Workdir metadata; does not grant file access.",
),
(
"workdir.attach",
"Attach Workdir",
"Attach the selected Workdir subject to existing provider and attachment constraints.",
),
(
"workdir.delete",
"Delete Workdir",
"Delete the selected Workdir only if existing occupancy and retention conditions permit it.",
),
],
);
add(
K::Network,
&[
(
"web.fetch",
"Fetch web page",
"Fetch this exact HTTP(S) URL. Each redirect needs its own permission; private/local hosts remain denied.",
),
(
"web.search",
"Search web",
"Contact this exact search provider endpoint; credentials and network restrictions still apply.",
),
],
);
add(
K::Workspace,
&[
(
"ticket.create",
"Create Tickets",
"Create open Tickets in this Workspace; no read or edit rights to existing Tickets are implied.",
),
(
"ticket.query",
"Search Tickets",
"Query the Workspace Ticket collection.",
),
(
"ticket.list",
"List Tickets",
"List the Workspace Ticket collection.",
),
(
"ticket.doctor",
"Diagnose Tickets",
"Inspect Workspace Ticket diagnostics.",
),
(
"ticket.intake.default_body",
"Read intake template",
"Read the default intake transition body.",
),
(
"ticket.tag.list",
"List classification tags",
"Read Workspace classification tags.",
),
(
"ticket.tag.create",
"Create classification tags",
"Create Workspace classification tags, not workflow authority.",
),
(
"ticket.tag.rename",
"Rename classification tags",
"Rename Workspace tags with existing replay and compare checks.",
),
(
"ticket.tag.delete",
"Delete classification tags",
"Delete Workspace tags and their memberships with existing compare checks.",
),
(
"ticket.relation.query",
"Search Ticket relations",
"Query Workspace Ticket relation metadata.",
),
(
"ticket.orchestration_plan.query",
"Read orchestration plans",
"Query retained orchestration plan metadata; does not start work.",
),
(
"objective.query",
"Search Objectives",
"Query the Workspace Objective collection.",
),
(
"objective.create",
"Create Objectives",
"Create an Objective in this Workspace.",
),
(
"repository.list",
"List repositories",
"List repository metadata, not checkout or credential access.",
),
(
"workdir.list",
"List Workdirs",
"List Workdir metadata; does not grant attachment or file access.",
),
(
"workdir.attachment.list",
"List attachments",
"List Workdir attachment metadata.",
),
(
"worker.list",
"List Workers",
"List known Workers. Discovery does not grant control.",
),
(
"worker.discover",
"Discover Workspace Workers",
"Search bounded Worker metadata. Discovery does not grant control or observation.",
),
(
"worker.observe.list",
"List observable Workers",
"List observation subjects; does not grant access to individual sessions.",
),
(
"worker.create",
"Create background Workers",
"Use Profile-resolved Worker creation with explicit execution grants. Directional Backend grants separately control the created Worker and its message-only reply.",
),
(
"drive.capabilities",
"Read Drive capabilities",
"Read provider-supported Drive operations.",
),
(
"drive.root",
"Find Drive root",
"Read the Workspace Drive root reference.",
),
(
"drive.search",
"Search Drive",
"Search the Workspace Drive; provider visibility still applies.",
),
],
);
add(
K::Ticket,
&[
(
"ticket.read",
"Read Ticket",
"Read the selected Ticket and its thread.",
),
(
"ticket.edit_item",
"Edit Ticket",
"Edit the selected Ticket content; actor and concurrency checks still apply.",
),
(
"ticket.edit_post",
"Edit Ticket post",
"Edit an eligible post using its exact observed body.",
),
(
"ticket.event.add",
"Add Ticket event",
"Append an allowed typed event to the selected Ticket.",
),
(
"ticket.case.update",
"Judge Ticket case",
"Close, reopen or correct closure with explicit judgment and current compare keys.",
),
(
"ticket.dependency.check",
"Check dependencies",
"Inspect selected Ticket dependencies; does not start them.",
),
(
"ticket.intake_summary.add",
"Add intake summary",
"Append an intake summary to the selected Ticket.",
),
(
"ticket.tags.set",
"Classify Ticket",
"Replace selected Ticket classification tags using the observed full set.",
),
(
"ticket.relation.read",
"Read Ticket relations",
"Read selected Ticket relations.",
),
(
"ticket.relation.record",
"Record Ticket relation",
"Record an explicit relation from this Ticket.",
),
(
"ticket.relation.remove",
"Remove Ticket relation",
"Remove an exact relation from this Ticket.",
),
(
"ticket.orchestration_plan.record",
"Record orchestration plan",
"Record plan metadata for this Ticket; does not grant execution authority.",
),
(
"ticket.mark_ready",
"Update intake readiness",
"Record an intake readiness change; does not grant execution authority.",
),
(
"ticket.queue",
"Request Ticket queueing",
"Use the existing authorized Ticket queue route; does not bypass actor restrictions.",
),
(
"ticket.assignment.assign",
"Delegate Ticket",
"Assign to an authorized Subject; current body, Ticket targets and execution grants are checked separately. Assignment does not start a Worker.",
),
(
"ticket.assignment.clear",
"Release Ticket delegation",
"Release the observed current Subject assignment; does not stop Workers.",
),
(
"ticket.assignment.update",
"Report assignment handling",
"Report handling only for the authenticated authorized current delegation with event CAS.",
),
(
"ticket.assignment.answer",
"Answer assignment",
"Append fenced continuing input to the current assignment; not a new assignment.",
),
(
"ticket.assignment.query",
"Read current assignment",
"Read selected Ticket assignment tracking.",
),
(
"ticket.assignment.history",
"Read assignment history",
"Read retained assignment tracking events.",
),
(
"ticket.assignment.diagnostics",
"Read assignment diagnostics",
"Read retained delivery and start evidence, not execution authority.",
),
(
"ticket.implementation_result.write",
"Report implementation result",
"Save a bounded result, handoff or blocker; not approval or closure.",
),
(
"ticket.implementation_result.read",
"Read implementation result",
"Read a saved implementation result; not a new request.",
),
(
"merge_request.open",
"Open Merge Request",
"Open or reuse a repository-scoped Merge Request for this Ticket.",
),
],
);
add(
K::Objective,
&[
(
"objective.read",
"Read Objective",
"Read selected Objective and linked Ticket context.",
),
(
"objective.edit",
"Edit Objective",
"Edit selected Objective content.",
),
(
"objective.set_state",
"Set Objective state",
"Change selected Objective state.",
),
(
"objective.link_ticket",
"Link Ticket",
"Link a Ticket to this Objective; required Ticket read permission is separate.",
),
(
"objective.unlink_ticket",
"Unlink Ticket",
"Unlink a Ticket from this Objective.",
),
],
);
add(
K::MergeRequest,
&[
(
"merge_request.read",
"Read Merge Request",
"Read selected Merge Request and review evidence.",
),
(
"merge_request.readiness",
"Check MR readiness",
"Check recording-workflow readiness; does not adopt changes.",
),
(
"merge_request.review",
"Review Merge Request",
"Submit a review only with independently bound review capability and current source.",
),
(
"merge_request.complete",
"Record MR integration",
"Record an already-applied exact-source integration with existing approval and source checks.",
),
],
);
add(
K::Repository,
&[
(
"repository.read",
"Read repository metadata",
"Read selected repository metadata without exposing secrets.",
),
(
"workdir.create",
"Create Workdir",
"Materialize selected repository using existing provider grants and explicit selectors.",
),
],
);
add(
K::WorkdirAttachment,
&[
(
"workdir.attachment.read",
"Read attachment",
"Read metadata for this exact attachment connection.",
),
(
"workdir.detach",
"Detach Workdir",
"Detach this exact observed connection; stale aliases do not select a replacement.",
),
],
);
add(
K::WorkspaceConfig,
&[
(
"workspace_config.attach",
"Attach configuration",
"Attach the selected configuration path and recursive scope.",
),
(
"workspace_config.observe",
"Observe configuration",
"Observe selected configuration metadata within path scope.",
),
(
"workspace_config.read",
"Read configuration",
"Read selected configuration within path scope; secret rules still apply.",
),
(
"workspace_config.commit",
"Commit configuration",
"Commit selected configuration within path scope and provider compare checks.",
),
],
);
add(
K::DriveEntry,
&[
(
"drive.metadata",
"Read Drive metadata",
"Read metadata for this exact entry.",
),
(
"drive.list",
"List Drive folder",
"List children of this exact folder; does not include descendants implicitly.",
),
(
"drive.read_text",
"Read Drive text",
"Read text of this exact entry.",
),
(
"drive.read_chunk",
"Read Drive chunk",
"Read a bounded chunk of this exact entry.",
),
(
"drive.create_folder",
"Create Drive folder",
"Create a folder under this exact parent.",
),
(
"drive.create_text",
"Create Drive text",
"Create a text entry under this exact parent.",
),
(
"drive.update_text",
"Update Drive text",
"Update this exact entry with provider concurrency checks.",
),
(
"drive.delete",
"Delete Drive entry",
"Delete this exact entry if provider conditions permit.",
),
(
"drive.relocate",
"Move Drive entry",
"Move an entry; source and destination each require explicit permission.",
),
(
"drive.upload",
"Upload Drive file",
"Upload under this exact parent, subject to provider limits.",
),
],
);
add(
K::DriveRequest,
&[(
"drive.request_status",
"Read Drive request",
"Read the status of this exact provider request; no new execution authority.",
)],
);
add(
K::Feature,
&[(
"mcp",
"Enable MCP (temporary coarse permission)",
"Retained MCP activation permission before stdio discovery. Not per-server, per-tool or per-call authorization; no equivalent gate is needed by other Features.",
)],
);
add(
K::Subject,
&[
(
"session.record",
"Record Subject session",
"Attribute and record sessions for this Subject; current body ownership is still required.",
),
(
"session.list",
"List Subject sessions",
"List sessions attributed to this Subject.",
),
(
"session.search",
"Search Subject sessions",
"Search committed sessions attributed to this Subject.",
),
(
"session.read",
"Read Subject session",
"Read committed entries from this Subject's sessions.",
),
(
"memory.query",
"Search Subject Memory",
"Query this Subject's Memory, not Workspace Memory.",
),
(
"memory.read",
"Read Subject Memory",
"Read this Subject's selected Memory document.",
),
(
"memory.changes.list",
"List Memory changes",
"Read this Subject's retained Memory changes.",
),
(
"memory.resident.read",
"Read resident Memory",
"Read this Subject's resident Memory data.",
),
(
"memory.resident.supply",
"Supply resident Memory",
"Supply protected resident Memory to a model; read permission is separate.",
),
(
"memory.staging.write",
"Stage Memory candidate",
"Write a candidate to this Subject's staging area.",
),
(
"memory.staging.list",
"List Memory candidates",
"List this Subject's staging candidates.",
),
(
"memory.staging.read",
"Read Memory candidate",
"Read this Subject's staging candidate.",
),
(
"memory.candidate.decide",
"Decide Memory candidate",
"Decide a staged candidate using existing Job/domain constraints.",
),
(
"memory.proposal.validate",
"Validate Memory proposal",
"Validate a proposal for this Subject.",
),
(
"memory.receipt.read",
"Read Memory receipt",
"Read retained Memory operation evidence, not new authority.",
),
(
"memory.surface.prepare",
"Prepare Memory surface",
"Prepare this Subject's surface update under domain checks.",
),
(
"memory.surface.supply",
"Supply Memory surface",
"Supply protected surface input to a model.",
),
(
"memory.surface.publish",
"Publish Memory surface",
"Publish this Subject's prepared surface under writer and Job checks.",
),
(
"memory.surface.fail",
"Record surface failure",
"Record failure of this Subject's prepared surface.",
),
(
"memory.extract.read",
"Read extraction input",
"Read protected session input for Memory extraction.",
),
(
"memory.extract.supply",
"Supply extraction input",
"Supply protected extraction input to a model.",
),
(
"memory.extract.commit",
"Commit extraction",
"Commit extracted candidates under existing source/writer checks.",
),
(
"memory.consolidation.request",
"Request consolidation",
"Request a bounded consolidation Job; Job grants and origin constraints remain separate.",
),
(
"memory.consolidation.supply",
"Supply consolidation input",
"Supply frozen consolidation input only within its current Job capability.",
),
(
"journal.startup.read",
"Read startup Journal",
"Read this Subject's startup Journal context.",
),
(
"journal.startup.supply",
"Supply startup Journal",
"Supply protected startup Journal context to a model.",
),
(
"journal.compaction.read",
"Read compaction Journal",
"Read this Subject's compaction Journal context.",
),
(
"journal.compaction.supply",
"Supply compaction Journal",
"Supply protected compaction context to a model.",
),
(
"journal.recording.request",
"Request Journal recording",
"Request a bounded recording Job; does not grant arbitrary Job operations.",
),
(
"journal.job.input.read",
"Read Journal Job input",
"Read frozen input only with the bound current Journal Job capability.",
),
(
"journal.job.input.supply",
"Supply Journal Job input",
"Supply frozen input only with current Job, origin and policy authority.",
),
(
"journal.state.read",
"Read Journal state",
"Read this Subject's Journal state.",
),
(
"journal.text.read",
"Read Journal text",
"Read this Subject's Journal text.",
),
(
"journal.operation.query",
"Query Journal operations",
"Query retained Journal operations.",
),
(
"journal.operation.list",
"List Journal operations",
"List retained Journal operations.",
),
(
"journal.operation.coverage.read",
"Read Journal coverage",
"Read operation coverage evidence.",
),
(
"journal.entry.create",
"Create Journal entry",
"Create an entry within the bound Journal Job.",
),
(
"journal.entry.fix",
"Fix Journal entry",
"Fix an entry within the bound Journal Job.",
),
(
"journal.entry.rewrite",
"Rewrite Journal entry",
"Rewrite an entry within the bound Journal Job.",
),
(
"journal.position.update",
"Update Journal position",
"Update position within the current Journal writer/attempt.",
),
(
"journal.unchanged.confirm",
"Confirm unchanged Journal",
"Confirm no change within the current Journal writer/attempt.",
),
(
"journal.checkpoint",
"Checkpoint Journal",
"Checkpoint the current Journal recording Job.",
),
(
"journal.finish",
"Finish Journal recording",
"Finish the bound Journal Job; not a general work-completion judgment.",
),
],
);
add(
K::Workspace,
&[
(
"memory.document.read",
"Read Workspace Memory document",
"Read a legacy Workspace Memory document; not Subject Memory.",
),
(
"memory.document.update",
"Update Workspace Memory document",
"Update a legacy Workspace Memory document with existing storage constraints.",
),
(
"memory.audit.append",
"Append Workspace Memory audit",
"Append a Workspace Memory audit event.",
),
(
"memory.staging.close",
"Close Workspace Memory candidate",
"Close a legacy Workspace staging candidate.",
),
],
);
add(
K::Subject,
&[
(
"chat.read",
"Read Subject conversations",
"Read conversations belonging to the current Subject body; membership and current ownership remain required.",
),
(
"chat.send",
"Send Subject message",
"Explicitly publish to a permitted conversation as the current Subject body. Background Worker messaging does not grant publication.",
),
],
);
// The same exact operation may target either independent Memory domain. This
// adds accepted kinds, never cross-kind containment or a second descriptor.
for descriptor in &mut catalog {
if matches!(
descriptor.operation.as_str(),
"memory.query"
| "memory.resident.read"
| "memory.resident.supply"
| "memory.staging.write"
| "memory.staging.list"
| "memory.staging.read"
| "memory.extract.read"
| "memory.extract.supply"
| "memory.extract.commit"
| "memory.consolidation.request"
) {
descriptor.resource_kinds.push(K::Workspace);
descriptor.description.push_str(" The Workspace kind selects the separate legacy Workspace Memory domain, never all Subjects.");
}
}
catalog.push(PermissionOperationDescriptor {
operation: "notification.diagnostics.read".into(), label: "Read notification diagnostics".into(),
description: "Read bounded saved delivery evidence for the exact source resource; Runtime acceptance does not prove processing.".into(),
resource_kinds: vec![K::Workspace, K::Ticket, K::MergeRequest, K::Job],
availability: PermissionOperationAvailability::Available,
});
// Operation use is Workspace-bound but never grants a target. Legacy Worker
// scopes stay exact; management does not convert or discard their limits.
for (operation, label) in [
("worker.messaging", "Message Workers"),
("worker.send_input", "Submit to Worker"),
("worker.cancel", "Cancel Worker turn"),
("worker.stop", "Stop Worker"),
("worker.restore", "Restore Worker"),
("worker.remove", "Remove Worker"),
("worker.observe", "Observe Worker session"),
] {
catalog.push(PermissionOperationDescriptor {
operation: operation.into(), label: label.into(),
description: "Operation use permission and directional Backend target control grants are separate. Acceptance does not prove processing or success; lifecycle state conditions still apply.".into(),
resource_kinds: vec![K::WorkerOperation],
availability: PermissionOperationAvailability::Available,
});
}
for (operation, label) in [
("subworker.spawn", "Create Internal SubWorker"),
("subworker.send_input", "Submit to Internal SubWorker"),
("subworker.stop", "Stop Internal SubWorker"),
("subworker.observe", "Observe Internal SubWorker"),
] {
catalog.push(PermissionOperationDescriptor {
operation: operation.into(), label: label.into(),
description: "Parent-owned Internal SubWorkers are distinct from independently controlled Runtime Workers. Existing exact parent/name scopes are retained.".into(),
resource_kinds: vec![],
availability: PermissionOperationAvailability::Unavailable { reason: "A safe parent-bound management selector is not yet available. Existing scopes may be retained or removed, not widened by replacing parent identifiers.".into() },
});
}
catalog
}
/// Validate additions only, after receipt lookup and current-policy CAS in the
/// management store transaction. Unsupported entries may be kept exactly in their
/// original lane or removed, never copied to another lane or broadened.
pub fn validate_managed_subject_permissions(
current: Option<&SubjectPermissions>,
update: &SubjectPermissionsUpdate,
) -> Result<(), String> {
let catalog = subject_permission_operations();
for (next, previous) in [
(
&update.execution,
current.map(|p| p.execution.as_slice()).unwrap_or_default(),
),
(
&update.delegation,
current.map(|p| p.delegation.as_slice()).unwrap_or_default(),
),
] {
for permission in next {
if previous.contains(permission) {
continue;
}
permission.validate()?;
let descriptor = catalog
.iter()
.find(|d| d.operation == permission.operation)
.ok_or_else(|| format!("unknown management operation: {}", permission.operation))?;
if !matches!(
descriptor.availability,
PermissionOperationAvailability::Available
) {
return Err(format!(
"operation is unavailable for management: {}",
permission.operation
));
}
if !descriptor
.resource_kinds
.contains(&permission.resource.kind())
{
return Err(format!(
"invalid resource kind for operation: {}",
permission.operation
));
}
if permission.operation == "bash.execute"
&& !matches!(&permission.resource,
PermissionResource::Workdir { path, recursive: true, .. } if path == ".")
{
return Err(
"bash.execute requires the whole Workdir (path '.' and recursive true)".into(),
);
}
}
}
Ok(())
}
@@ -0,0 +1,142 @@
//! Management validation is exercised from JSON; stored legacy policy remains readable.
use super::*;
use serde_json::json;
fn permission(operation: &str, resource: serde_json::Value) -> ScopedPermission {
serde_json::from_value(json!({"operation":operation,"resource":resource})).unwrap()
}
fn update(
execution: Vec<ScopedPermission>,
delegation: Vec<ScopedPermission>,
) -> SubjectPermissionsUpdate {
SubjectPermissionsUpdate {
operation_id: "save".into(),
expected_change_id: Some("observed".into()),
execution,
delegation,
}
}
#[test]
fn managed_additions_reject_unknown_mismatched_and_unavailable_entries() {
let workspace = json!({"kind":"workspace","workspace_id":"w"});
for grant in [
permission("ticket.future", workspace.clone()),
permission("ticket.read", workspace.clone()),
permission(
"ticket.create",
json!({"kind":"ticket","workspace_id":"w","ticket_id":"T-1"}),
),
permission("worker.messaging", workspace.clone()),
permission(
"worker.stop",
json!({"kind":"worker","runtime_id":"r","worker_id":"worker"}),
),
permission("ticket", json!({"kind":"feature"})),
permission(
"bash.execute",
json!({"kind":"workdir","working_directory_id":"wd","path":"src","recursive":true}),
),
] {
assert!(
validate_managed_subject_permissions(None, &update(vec![grant.clone()], vec![]))
.is_err(),
"{grant:?}"
);
assert!(
validate_managed_subject_permissions(None, &update(vec![], vec![grant.clone()]))
.is_err(),
"{grant:?}"
);
}
assert!(validate_managed_subject_permissions(None, &update(vec![permission("ticket.create", workspace)], vec![permission("filesystem.write", json!({"kind":"workdir","working_directory_id":"wd","path":"src","recursive":true}))])).is_ok());
}
#[test]
fn unsupported_legacy_entries_are_exact_lane_preserved_not_converted_or_extended() {
let legacy: SubjectPermissions = serde_json::from_value(json!({
"change_id":"observed",
"execution":[
{"operation":"ticket","resource":{"kind":"feature"}},
{"operation":"worker.notify","resource":{"kind":"worker","runtime_id":"r","worker_id":"one"}},
{"operation":"old.unknown","resource":{"kind":"workspace","workspace_id":"w"}}
],
"delegation":[{"operation":"worker.result","resource":{"kind":"worker","runtime_id":"r","worker_id":"origin"}}]
})).unwrap();
let mut next = update(legacy.execution.clone(), legacy.delegation.clone());
next.execution.push(permission(
"ticket.create",
json!({"kind":"workspace","workspace_id":"w"}),
));
assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_ok());
assert_eq!(
serde_json::to_value(&legacy).unwrap()["execution"][1]["operation"],
"worker.notify"
);
next.delegation.push(legacy.execution[1].clone());
assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_err());
next.delegation.pop();
next.execution[1].resource = PermissionResource::Worker {
runtime_id: "r".into(),
worker_id: "two".into(),
};
assert!(validate_managed_subject_permissions(Some(&legacy), &next).is_err());
assert!(validate_managed_subject_permissions(Some(&legacy), &update(vec![], vec![])).is_ok());
}
#[test]
fn catalog_wire_has_no_worker_target_editors_and_no_implicit_feature_grants() {
let wire = serde_json::to_value(subject_permission_operations()).unwrap();
let entries = wire.as_array().unwrap();
let mut names = std::collections::BTreeSet::new();
for entry in entries {
assert!(
names.insert(entry["operation"].as_str().unwrap()),
"duplicate descriptor: {entry}"
);
assert!(!entry["label"].as_str().unwrap().is_empty());
assert!(!entry["description"].as_str().unwrap().is_empty());
assert!(
!entry["resource_kinds"]
.as_array()
.unwrap()
.contains(&json!("worker"))
);
if entry["availability"]["kind"] == "available" {
assert!(!entry["resource_kinds"].as_array().unwrap().is_empty());
}
}
assert!(!names.contains("ticket"));
assert!(!names.contains("worker.notify"));
assert!(!names.contains("worker.result"));
let mcp = entries.iter().find(|d| d["operation"] == "mcp").unwrap();
assert_eq!(mcp["resource_kinds"], json!(["feature"]));
let messaging = entries
.iter()
.find(|d| d["operation"] == "worker.messaging")
.unwrap();
assert_eq!(messaging["availability"]["kind"], "available");
assert_eq!(messaging["resource_kinds"], json!(["worker_operation"]));
}
#[test]
fn managed_worker_operations_accept_only_current_operation_use_in_each_lane() {
for name in [
"worker.messaging",
"worker.send_input",
"worker.cancel",
"worker.stop",
"worker.restore",
"worker.remove",
"worker.observe",
] {
let p = permission(name, json!({"kind":"worker_operation","workspace_id":"w"}));
assert!(
validate_managed_subject_permissions(None, &update(vec![p.clone()], vec![p])).is_ok()
);
}
for name in ["worker.notify", "worker.result", "worker.future"] {
let p = permission(name, json!({"kind":"worker_operation","workspace_id":"w"}));
assert!(validate_managed_subject_permissions(None, &update(vec![p], vec![])).is_err());
}
}
+223
View File
@@ -0,0 +1,223 @@
//! Public text-only conversations. Stored messages are not Runtime delivery receipts.
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
pub const CHAT_MAX_TEXT_BYTES: usize = 16 * 1024;
pub const CHAT_MAX_PAGE_LIMIT: u32 = 100;
pub const CHAT_DEFAULT_PAGE_LIMIT: u32 = 50;
/// Largest counter representable without loss by the JSON/browser contract.
pub const CHAT_MAX_COUNTER: u64 = 9_007_199_254_740_991;
fn bounded_counter<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
let value = u64::deserialize(deserializer)?;
if value > CHAT_MAX_COUNTER {
return Err(serde::de::Error::custom(
"chat counter exceeds the JSON safe-integer range",
));
}
Ok(value)
}
fn optional_bounded_counter<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<u64>, D::Error> {
let value = Option::<u64>::deserialize(deserializer)?;
if value.is_some_and(|value| value > CHAT_MAX_COUNTER) {
return Err(serde::de::Error::custom(
"chat counter exceeds the JSON safe-integer range",
));
}
Ok(value)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatConversation {
pub id: String,
pub subject_id: String,
pub participant_account_id: String,
/// Latest user input in this conversation; empty conversations use null.
pub last_user_message_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatMessageSender {
User { account_id: String },
Subject { subject_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatMessage {
pub id: String,
pub conversation_id: String,
#[serde(deserialize_with = "bounded_counter")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub sequence: u64,
pub sender: ChatMessageSender,
pub text: String,
pub reply_to: Option<String>,
/// Opaque related-work reference, not a grant or an attachment path.
pub work_ref: Option<String>,
pub operation_id: String,
/// User messages refer to themselves; Subject messages pin the latest observed user input.
pub last_user_message_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatCreateConversationRequest {
pub operation_id: String,
pub subject_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatUserMessageRequest {
pub operation_id: String,
pub text: String,
pub reply_to: Option<String>,
pub work_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatSendMessageRequest {
pub conversation_id: String,
pub operation_id: String,
pub text: String,
pub reply_to: Option<String>,
pub work_ref: Option<String>,
/// Observed user input, independent of the optional reply target. Empty conversations use null.
#[serde(deserialize_with = "required_user_message_identity")]
#[schemars(required)]
pub expected_last_user_message_id: Option<String>,
}
// An explicit null is an observation of an empty conversation; omission is not.
fn required_user_message_identity<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<String>, D::Error> {
Option::<String>::deserialize(deserializer)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatMessagePageQuery {
#[serde(default, deserialize_with = "optional_bounded_counter")]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub after_sequence: Option<u64>,
pub limit: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatMessagePage {
pub conversation: ChatConversation,
pub messages: Vec<ChatMessage>,
pub has_more: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn text_only_requests_reject_attachments_and_claimed_sender() {
for field in ["attachments", "sender", "subject_id", "account_id"] {
let mut input = json!({"operation_id":"op", "text":"hello"});
input[field] = json!([]);
assert!(serde_json::from_value::<ChatUserMessageRequest>(input).is_err());
}
for field in ["attachments", "sender", "subject_id", "account_id"] {
let mut send = json!({"conversation_id":"c", "operation_id":"op", "text":"hello", "expected_last_user_message_id":null});
send[field] = json!([]);
assert!(serde_json::from_value::<ChatSendMessageRequest>(send).is_err());
}
assert!(
serde_json::from_value::<ChatCreateConversationRequest>(
json!({"operation_id":"op", "subject_id":"s", "participant_account_id":"forged"})
)
.is_err()
);
}
#[test]
fn browser_counters_reject_lossy_json_and_schema_keeps_numeric_bounds() {
assert!(
serde_json::from_value::<ChatMessagePageQuery>(
json!({"after_sequence":CHAT_MAX_COUNTER+1})
)
.is_err()
);
let schema = serde_json::to_value(schemars::schema_for!(ChatMessage)).unwrap();
assert_eq!(
schema["properties"]["sequence"]["maximum"],
json!(CHAT_MAX_COUNTER)
);
assert_eq!(schema["additionalProperties"], json!(false));
}
#[test]
fn observed_user_identity_is_nullable_text_not_a_number() {
assert!(
serde_json::from_value::<ChatSendMessageRequest>(
json!({"conversation_id":"c", "operation_id":"op", "text":"answer"})
)
.is_err()
);
for baseline in [serde_json::Value::Null, json!("user-message-id")] {
let value = json!({"conversation_id":"c", "operation_id":"op", "text":"answer", "expected_last_user_message_id":baseline});
let request: ChatSendMessageRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(
serde_json::to_value(request).unwrap()["expected_last_user_message_id"],
baseline
);
}
assert!(serde_json::from_value::<ChatSendMessageRequest>(json!({"conversation_id":"c", "operation_id":"op", "text":"answer", "expected_last_user_message_id":1})).is_err());
let mut message = json!({"id":"m", "conversation_id":"c", "sequence":CHAT_MAX_COUNTER, "sender":{"kind":"subject","subject_id":"s"}, "text":"answer", "operation_id":"op", "last_user_message_id":null});
assert!(serde_json::from_value::<ChatMessage>(message.clone()).is_ok());
message["sequence"] = json!(CHAT_MAX_COUNTER + 1);
assert!(serde_json::from_value::<ChatMessage>(message).is_err());
for baseline in [serde_json::Value::Null, json!("user-message-id")] {
let conversation: ChatConversation = serde_json::from_value(json!({"id":"c", "subject_id":"s", "participant_account_id":"a", "last_user_message_id":baseline})).unwrap();
assert_eq!(
serde_json::to_value(conversation).unwrap()["last_user_message_id"],
baseline
);
}
}
#[test]
fn sender_boundary_rejects_extra_fields_and_unknown_kinds() {
assert!(
serde_json::from_value::<ChatMessageSender>(
json!({"kind":"user","account_id":"a","subject_id":"s"})
)
.is_err()
);
assert!(
serde_json::from_value::<ChatMessageSender>(json!({"kind":"worker","account_id":"a"}))
.is_err()
);
let sender = ChatMessageSender::Subject {
subject_id: "s".into(),
};
assert_eq!(
serde_json::to_value(sender).unwrap(),
json!({"kind":"subject","subject_id":"s"})
);
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Chat publication and Notify admission are independent results. No delivery state
//! below asserts committed Session incorporation, model processing or task completion.
use crate::{ChatMessage, ChatMessagePage, ChatMessagePageQuery, ChatSendMessageRequest};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatDelivery {
NoCurrentBody,
Accepted {
runtime_id: String,
worker_id: String,
},
Rejected {
runtime_id: String,
worker_id: String,
detail: String,
},
Unknown {
runtime_id: String,
worker_id: String,
detail: String,
},
Unavailable {
detail: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatReceiveResponse {
pub message: ChatMessage,
pub delivery: ChatDelivery,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ChatWorkerRequest {
pub operation: ChatWorkerOperation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatWorkerOperation {
Read {
conversation_id: String,
query: ChatMessagePageQuery,
},
Send {
request: ChatSendMessageRequest,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ChatWorkerResponse {
Messages { page: ChatMessagePage },
Sent { message: ChatMessage },
}
+68
View File
@@ -7,6 +7,10 @@ pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
pub mod authorization;
mod chat;
pub use chat::*;
mod chat_transport;
pub use chat_transport::*;
mod drive;
mod notification;
mod plugin;
@@ -36,6 +40,8 @@ pub use workspace_config::*;
#[serde(deny_unknown_fields)]
pub struct SubjectPermissionsResponse {
pub permissions: Option<authorization::SubjectPermissions>,
pub can_manage: bool,
pub operations: Vec<authorization::PermissionOperationDescriptor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -215,6 +221,16 @@ impl_openapi_schema!(
PluginHostRequest,
PluginHostResponse,
PluginHostError,
ChatCreateConversationRequest,
ChatConversation,
ChatUserMessageRequest,
ChatSendMessageRequest,
ChatMessagePageQuery,
ChatMessagePage,
ChatMessage,
ChatReceiveResponse,
ChatWorkerRequest,
ChatWorkerResponse,
PluginInstallRequest,
PluginAddArtifactRequest,
PluginRevokeRequest,
@@ -1629,6 +1645,44 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[body] request: SubjektivRecordSessionRequest,
) -> Result<SubjektivRecordSessionResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/chat/conversations", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn chat_create_conversation(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ChatCreateConversationRequest,
) -> Result<ChatConversation, RepositoryApiError>;
#[get("/api/w/{workspace_id}/chat/conversations/{conversation_id}/messages", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn chat_messages(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] conversation_id: String,
#[query] query: ChatMessagePageQuery,
) -> Result<ChatMessagePage, RepositoryApiError>;
#[post("/api/w/{workspace_id}/chat/conversations/{conversation_id}/messages", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn chat_receive(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] conversation_id: String,
#[body] request: ChatUserMessageRequest,
) -> Result<ChatReceiveResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/subjektiv/chat/worker", status = 200, error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500], openapi = false)]
async fn chat_worker_operation(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ChatWorkerRequest,
) -> Result<ChatWorkerResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/subjektiv/memory",
operation_id = "subjektiv_memory_backend",
@@ -9560,6 +9614,9 @@ pub struct RuntimeWorkerInputRequest {
#[serde(default)]
pub kind: Option<String>,
pub content: String,
/// Stable Notify identity. Non-Notify unary input does not share this replay contract.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<protocol::Segment>>,
}
@@ -11134,6 +11191,16 @@ pub fn memory_api_typescript() -> String {
MemoryStagingRecord::decl(&config),
MemoryStagingEntry::decl(&config),
MemoryStagingListResponse::decl(&config),
authorization::PermissionResource::decl(&config),
authorization::ScopedPermission::decl(&config),
authorization::SubjectPermissions::decl(&config),
authorization::SubjectPermissionsUpdate::decl(&config),
authorization::PermissionResourceKind::decl(&config),
authorization::PermissionOperationAvailability::decl(&config),
authorization::PermissionOperationDescriptor::decl(&config),
SubjectPermissionsResponse::decl(&config),
SubjectPermissionsMutationResponse::decl(&config),
SubjectPermissionInterruption::decl(&config),
];
// These browser DTOs include nested types owned by `memory` and the Worker
// launch API. Keep their exported names explicit so the generated Web
@@ -15003,6 +15070,7 @@ mod openapi_artifact_tests {
"worker_observation_sessions",
"subjektiv_stage_candidate",
"subjektiv_record_session",
"chat_worker_operation",
"subjektiv_memory_backend",
"subjektiv_session_backend",
"subjektiv_memory_consolidation",
+2
View File
@@ -706,6 +706,7 @@ fn authorize_local_input(method: protocol::Method) -> protocol::Method {
message,
..
} => Method::NotifyTracked {
receipt_scope: protocol::NotificationReceiptScope::Source,
notification_request_id,
message,
source: AuthenticatedInputSource::LocalOperator,
@@ -952,6 +953,7 @@ mod local_input_tests {
message: "hello".into(),
},
Method::NotifyTracked {
receipt_scope: protocol::NotificationReceiptScope::Source,
notification_request_id: "tracked-notify".into(),
message: "hello".into(),
source,
+66
View File
@@ -803,6 +803,41 @@ impl LocalSubjectHost {
}
}
impl SubjektivHost for LocalSubjectHost {
fn authorize_operation(
&self,
context: &SubjektivHostContext,
operation: &str,
) -> Result<(), SubjektivHostError> {
// An explicit operator connection issues this local body capability.
// Revalidate its live writer on every access/supply, not Feature enablement.
// Job decisions and Backend conversations are not local body capabilities.
self.body(context)?;
match operation {
"session.record"
| "memory.resident.read"
| "memory.resident.supply"
| "memory.surface.supply"
| "memory.query"
| "memory.read"
| "memory.changes.list"
| "memory.proposal.validate"
| "memory.receipt.read"
| "memory.staging.write"
| "memory.extract.read"
| "memory.extract.supply"
| "memory.extract.commit"
| "memory.consolidation.request"
| "journal.startup.read"
| "journal.startup.supply"
| "journal.compaction.read"
| "journal.compaction.supply"
| "journal.recording.request" => Ok(()),
_ => Err(SubjektivHostError::PermissionDenied(format!(
"operation `{operation}` is not granted by the local Subject body capability"
))),
}
}
fn memory_journal(
&self,
context: &SubjektivHostContext,
@@ -1176,6 +1211,37 @@ impl LocalConsolidationHost {
}
}
impl SubjektivHost for LocalConsolidationHost {
fn authorize_operation(
&self,
context: &SubjektivHostContext,
operation: &str,
) -> Result<(), SubjektivHostError> {
self.live()?;
let attempt = format!("job:{}", self.attempt_id);
if context.worker_id != attempt || context.session_id != attempt {
return Err(SubjektivHostError::PermissionDenied(
"context is not bound to this local consolidation attempt".into(),
));
}
// The Host-issued batch/attempt grant is independent of body permission.
// Domain operations still enforce candidate membership and saved receipts.
match operation {
"memory.query"
| "memory.read"
| "memory.changes.list"
| "memory.staging.list"
| "memory.staging.read"
| "memory.candidate.decide"
| "memory.surface.prepare"
| "memory.surface.supply"
| "memory.surface.publish"
| "memory.surface.fail" => Ok(()),
_ => Err(SubjektivHostError::PermissionDenied(format!(
"operation `{operation}` is not granted by the local consolidation attempt"
))),
}
}
fn settings(&self) -> SubjektivHostSettings {
self.host.settings()
}
+133
View File
@@ -86,11 +86,56 @@ fn closed_capabilities_and_foreign_worker_context_are_rejected() {
session_id: session_store::SessionId::now_v7().to_string(),
};
assert!(port.memory(&owner, read.clone()).is_ok());
let local_operations = [
"memory.resident.read",
"memory.resident.supply",
"memory.staging.write",
"journal.startup.read",
"journal.startup.supply",
"journal.compaction.read",
"journal.compaction.supply",
"journal.recording.request",
];
for operation in local_operations {
assert!(
port.authorize_operation(&owner, operation).is_ok(),
"{operation}"
);
assert!(
port.authorize_operation(&foreign, operation).is_err(),
"{operation}"
);
}
for operation in [
"chat.read",
"chat.send",
"worker.messaging",
"memory.candidate.decide",
"memory.surface.publish",
"memory.staging.list",
"journal",
"journal.startup",
"journal.startup.read.future",
] {
assert!(
matches!(
port.authorize_operation(&owner, operation),
Err(SubjektivHostError::PermissionDenied(_))
),
"local body must not grant {operation}"
);
}
assert!(
port.record_session(&owner, false).is_err(),
"unattributed Session is not read authority"
);
connection.close().unwrap();
for operation in local_operations {
assert!(
port.authorize_operation(&owner, operation).is_err(),
"released capability: {operation}"
);
}
assert!(port.memory(&owner, read).is_err());
}
@@ -134,3 +179,91 @@ fn different_local_roots_do_not_authorize_shared_subject_ids() {
.is_err()
);
}
#[test]
fn consolidation_operation_policy_is_attempt_bound_and_does_not_inherit_body_rights() {
struct Fence(AtomicBool);
impl crate::jobs::JobAttemptFence for Fence {
fn ensure_live(&self) -> Result<(), String> {
self.0
.load(Ordering::Acquire)
.then_some(())
.ok_or("attempt revoked".into())
}
}
let temp = tempfile::tempdir().unwrap();
let catalog = StandaloneSubjects::open(temp.path().join("state")).unwrap();
let subject = catalog.create("owner", None).unwrap();
let mut body = catalog
.connect(&subject.id, protocol::WorkerId::now_v7())
.unwrap();
let fence = Arc::new(Fence(AtomicBool::new(true)));
let job = LocalConsolidationHost {
host: body.host.clone(),
grant: LocalConsolidationGrant {
binding: body.binding.clone(),
candidate_ids: vec![],
},
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
fence: fence.clone(),
};
let context = SubjektivHostContext {
worker_id: "job:attempt-1".into(),
session_id: "job:attempt-1".into(),
};
assert!(
job.authorize_operation(&context, "memory.staging.list")
.is_ok()
);
assert!(
job.authorize_operation(&context, "memory.surface.supply")
.is_ok()
);
let foreign = SubjektivHostContext {
worker_id: "job:old-attempt".into(),
session_id: "job:old-attempt".into(),
};
assert!(
job.authorize_operation(&foreign, "memory.staging.list")
.is_err()
);
for op in [
"session.record",
"memory.resident.read",
"memory.resident.supply",
"memory.extract.read",
"memory.staging.write",
"journal.startup.read",
"chat.send",
"memory",
] {
assert!(
matches!(
job.authorize_operation(&context, op),
Err(SubjektivHostError::PermissionDenied(_))
),
"Job must not gain {op}"
);
}
assert!(
job.memory(
&context,
SubjektivMemoryBackendOperation::ReadCandidate(SubjektivMemoryCandidateReadRequest {
candidate_id: "outside-batch".into()
})
)
.is_err()
);
fence.0.store(false, Ordering::Release);
assert!(
job.authorize_operation(&context, "memory.staging.list")
.is_err()
);
fence.0.store(true, Ordering::Release);
body.close().unwrap();
assert!(
job.authorize_operation(&context, "memory.staging.list")
.is_err()
);
}
+419
View File
@@ -0,0 +1,419 @@
//! Public conversation storage only; no delivery queue, Worker ownership or implicit publication.
//!
//! Hosts supply authenticated principals and hold their execution/authorization guard through
//! commit. Membership, exact replay and the user-input fence are checked in the same IMMEDIATE
//! FeatureStorage transaction as append. A saved message proves publication, not Runtime acceptance.
use crate::{SubjektivError, SubjektivStore, require_active_subject};
use feature_storage::FeatureStorageError;
use rusqlite::{Connection, OptionalExtension, Transaction, params};
use server_api::{
CHAT_DEFAULT_PAGE_LIMIT, CHAT_MAX_COUNTER, CHAT_MAX_PAGE_LIMIT, CHAT_MAX_TEXT_BYTES,
ChatConversation, ChatCreateConversationRequest, ChatMessage, ChatMessagePage,
ChatMessagePageQuery, ChatMessageSender, ChatSendMessageRequest, ChatUserMessageRequest,
};
use uuid::Uuid;
pub type Result<T> = std::result::Result<T, ChatError>;
#[derive(Debug, thiserror::Error)]
pub enum ChatError {
#[error(transparent)]
Storage(#[from] FeatureStorageError),
#[error(transparent)]
Subject(#[from] SubjektivError),
#[error("conversation not found or not accessible")]
ConversationNotFound,
#[error("chat operation `{0}` was reused with different input")]
OperationConflict(String),
#[error("chat user input changed: expected {expected:?}, current {actual:?}")]
UserInputChanged {
expected: Option<String>,
actual: Option<String>,
},
#[error("chat authorization changed")]
AuthorizationChanged,
#[error("invalid chat input: {0}")]
InvalidInput(String),
}
impl From<rusqlite::Error> for ChatError {
fn from(error: rusqlite::Error) -> Self {
Self::Storage(error.into())
}
}
pub(crate) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
tx.execute_batch(include_str!("chat/schema.sql"))?;
Ok(())
}
#[derive(Clone, Copy)]
enum Principal<'a> {
Account(&'a str),
Subject(&'a str),
}
impl Principal<'_> {
fn sender(self) -> (&'static str, String) {
match self {
Self::Account(id) => ("user", id.into()),
Self::Subject(id) => ("subject", id.into()),
}
}
fn member_of(self, conversation: &ChatConversation) -> bool {
match self {
Self::Account(id) => id == conversation.participant_account_id,
Self::Subject(id) => id == conversation.subject_id,
}
}
}
impl SubjektivStore {
/// Exact account-scoped replay returns the original creation result (no user input).
pub fn chat_create_conversation(
&self,
authenticated_account_id: &str,
request: &ChatCreateConversationRequest,
) -> Result<ChatConversation> {
identifier(authenticated_account_id)?;
identifier(&request.operation_id)?;
identifier(&request.subject_id)?;
self.database.try_transaction(|tx| {
let saved = tx.query_row(
"SELECT id,subject_id,participant_account_id,last_user_message_id FROM chat_conversations
WHERE participant_account_id=?1 AND create_operation_id=?2",
params![authenticated_account_id, request.operation_id], conversation_row,
).optional()?;
if let Some(mut conversation) = saved {
if conversation.subject_id != request.subject_id {
return Err(ChatError::OperationConflict(request.operation_id.clone()));
}
require_active_subject(tx, &conversation.subject_id)?;
conversation.last_user_message_id = None;
return Ok(conversation);
}
require_active_subject(tx, &request.subject_id)?;
let conversation = ChatConversation {
id: Uuid::now_v7().to_string(),
subject_id: request.subject_id.clone(),
participant_account_id: authenticated_account_id.into(),
last_user_message_id: None,
};
tx.execute(
"INSERT INTO chat_conversations
(id,subject_id,participant_account_id,last_user_message_id,create_operation_id)
VALUES (?1,?2,?3,NULL,?4)",
params![
conversation.id,
conversation.subject_id,
authenticated_account_id,
request.operation_id
],
)?;
Ok(conversation)
})
}
pub fn chat_conversation_for_account(
&self,
account: &str,
conversation_id: &str,
) -> Result<ChatConversation> {
self.database
.try_transaction(|tx| conversation(tx, Principal::Account(account), conversation_id))
}
pub fn chat_conversation_for_subject(
&self,
subject: &str,
conversation_id: &str,
) -> Result<ChatConversation> {
self.database
.try_transaction(|tx| conversation(tx, Principal::Subject(subject), conversation_id))
}
pub fn chat_messages_for_account(
&self,
account: &str,
conversation_id: &str,
query: &ChatMessagePageQuery,
) -> Result<ChatMessagePage> {
self.chat_messages(Principal::Account(account), conversation_id, query, None)
}
pub fn chat_messages_for_subject(
&self,
subject: &str,
conversation_id: &str,
query: &ChatMessagePageQuery,
) -> Result<ChatMessagePage> {
self.chat_messages(Principal::Subject(subject), conversation_id, query, None)
}
/// The Host must authorize `chat.read`, then pin that policy's existing change ID.
/// It must hold current Subject body ownership through this FeatureStorage transaction.
pub fn chat_messages_for_subject_authorized(
&self,
subject: &str,
conversation_id: &str,
query: &ChatMessagePageQuery,
expected_permission_change_id: &str,
) -> Result<ChatMessagePage> {
self.chat_messages(
Principal::Subject(subject),
conversation_id,
query,
Some(expected_permission_change_id),
)
}
fn chat_messages(
&self,
principal: Principal<'_>,
conversation_id: &str,
query: &ChatMessagePageQuery,
expected_permission_change_id: Option<&str>,
) -> Result<ChatMessagePage> {
if query.after_sequence.is_some_and(|s| s > CHAT_MAX_COUNTER) {
return Err(ChatError::InvalidInput(
"message cursor exceeds JSON safe-integer range".into(),
));
}
let limit = query.limit.unwrap_or(CHAT_DEFAULT_PAGE_LIMIT);
if !(1..=CHAT_MAX_PAGE_LIMIT).contains(&limit) {
return Err(ChatError::InvalidInput(format!(
"page limit must be in 1..={CHAT_MAX_PAGE_LIMIT}"
)));
}
self.database.try_transaction(|tx| {
check_authorization(tx, principal, expected_permission_change_id)?;
let conversation = conversation(tx, principal, conversation_id)?;
let after = query.after_sequence.unwrap_or(0) as i64;
let mut messages = tx.prepare(&format!("SELECT {MESSAGE_COLUMNS} FROM chat_messages WHERE conversation_id=?1 AND sequence>?2 ORDER BY sequence LIMIT ?3"))?
.query_map(params![conversation_id, after, limit+1], message_row)?
.collect::<std::result::Result<Vec<_>, _>>()?;
let has_more = messages.len() > limit as usize;
messages.truncate(limit as usize);
Ok(ChatMessagePage { conversation, messages, has_more })
})
}
pub fn chat_append_user_message(
&self,
account: &str,
conversation_id: &str,
request: &ChatUserMessageRequest,
) -> Result<ChatMessage> {
self.chat_append(
Principal::Account(account),
conversation_id,
Append {
operation_id: &request.operation_id,
text: &request.text,
reply_to: &request.reply_to,
work_ref: &request.work_ref,
expected_last_user_message_id: None,
},
None,
)
}
/// The authenticated Subject is supplied by the Host, never accepted from the DTO.
pub fn chat_send_message(
&self,
subject: &str,
request: &ChatSendMessageRequest,
) -> Result<ChatMessage> {
self.chat_send(subject, request, None)
}
/// The Host must authorize `chat.send`, then pin that policy's existing change ID.
/// The ownership guard must span commit; its callback must not reenter the Backend Store.
pub fn chat_send_message_authorized(
&self,
subject: &str,
request: &ChatSendMessageRequest,
expected_permission_change_id: &str,
) -> Result<ChatMessage> {
self.chat_send(subject, request, Some(expected_permission_change_id))
}
fn chat_send(
&self,
subject: &str,
request: &ChatSendMessageRequest,
expected_permission_change_id: Option<&str>,
) -> Result<ChatMessage> {
self.chat_append(
Principal::Subject(subject),
&request.conversation_id,
Append {
operation_id: &request.operation_id,
text: &request.text,
reply_to: &request.reply_to,
work_ref: &request.work_ref,
expected_last_user_message_id: Some(&request.expected_last_user_message_id),
},
expected_permission_change_id,
)
}
fn chat_append(
&self,
principal: Principal<'_>,
conversation_id: &str,
input: Append<'_>,
expected_permission_change_id: Option<&str>,
) -> Result<ChatMessage> {
identifier(input.operation_id)?;
if input.text.trim().is_empty() || input.text.len() > CHAT_MAX_TEXT_BYTES {
return Err(ChatError::InvalidInput(format!(
"text must be nonblank and at most {CHAT_MAX_TEXT_BYTES} UTF-8 bytes"
)));
}
for reference in [input.reply_to, input.work_ref]
.into_iter()
.flatten()
.chain(input.expected_last_user_message_id.into_iter().flatten())
{
identifier(reference)?;
}
self.database.try_transaction(|tx| {
check_authorization(tx, principal, expected_permission_change_id)?;
let mut conversation = conversation(tx, principal, conversation_id)?;
let (kind, sender_id) = principal.sender();
let saved = tx.query_row(&format!("SELECT {MESSAGE_COLUMNS} FROM chat_messages
WHERE conversation_id=?1 AND sender_kind=?2 AND sender_id=?3 AND operation_id=?4"),
params![conversation_id, kind, sender_id, input.operation_id], message_row).optional()?;
// Replay precedes the current-input fence: ACK loss must remain recoverable after
// another user input. The pinned observed user identity remains part of the full request.
if let Some(saved) = saved {
if saved.text != input.text || &saved.reply_to != input.reply_to || &saved.work_ref != input.work_ref
|| input.expected_last_user_message_id.is_some_and(|r| r != &saved.last_user_message_id) {
return Err(ChatError::OperationConflict(input.operation_id.into()));
}
return Ok(saved);
}
if let Some(expected) = input.expected_last_user_message_id {
if expected != &conversation.last_user_message_id {
return Err(ChatError::UserInputChanged { expected: expected.clone(), actual: conversation.last_user_message_id });
}
}
if let Some(reply_to) = input.reply_to {
let exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE conversation_id=?1 AND id=?2)", params![conversation_id,reply_to], |r| r.get(0))?;
if !exists { return Err(ChatError::InvalidInput("reply_to must reference a message in this conversation".into())); }
}
let message_id = Uuid::now_v7().to_string();
if matches!(principal, Principal::Account(_)) {
conversation.last_user_message_id = Some(message_id.clone());
tx.execute("UPDATE chat_conversations SET last_user_message_id=?1 WHERE id=?2", params![message_id,conversation_id])?;
}
let last_sequence: u64 = tx.query_row("SELECT COALESCE(MAX(sequence),0) FROM chat_messages WHERE conversation_id=?1", [conversation_id], |r| r.get(0))?;
let sequence = last_sequence.checked_add(1).filter(|s| *s <= CHAT_MAX_COUNTER)
.ok_or_else(|| ChatError::InvalidInput("message sequence exhausted".into()))?;
let message = ChatMessage {
id: message_id, conversation_id: conversation_id.into(), sequence,
sender: match principal { Principal::Account(id) => ChatMessageSender::User { account_id: id.into() }, Principal::Subject(id) => ChatMessageSender::Subject { subject_id: id.into() } },
text: input.text.into(), reply_to: input.reply_to.clone(), work_ref: input.work_ref.clone(),
operation_id: input.operation_id.into(), last_user_message_id: conversation.last_user_message_id,
};
tx.execute("INSERT INTO chat_messages (id,conversation_id,sequence,sender_kind,sender_id,text,reply_to,work_ref,operation_id,last_user_message_id)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", params![message.id,conversation_id,sequence,kind,sender_id,message.text,message.reply_to,message.work_ref,message.operation_id,message.last_user_message_id])?;
Ok(message)
})
}
}
struct Append<'a> {
operation_id: &'a str,
text: &'a str,
reply_to: &'a Option<String>,
work_ref: &'a Option<String>,
expected_last_user_message_id: Option<&'a Option<String>>,
}
fn check_authorization(
tx: &Transaction<'_>,
principal: Principal<'_>,
expected: Option<&str>,
) -> Result<()> {
if let Some(expected) = expected {
identifier(expected)?;
let Principal::Subject(subject) = principal else {
return Err(ChatError::AuthorizationChanged);
};
let actual: Option<String> = tx
.query_row(
"SELECT change_id FROM subject_permissions WHERE subject_id=?1",
[subject],
|row| row.get(0),
)
.optional()?;
if actual.as_deref() != Some(expected) {
return Err(ChatError::AuthorizationChanged);
}
}
Ok(())
}
fn identifier(value: &str) -> Result<()> {
if value.trim().is_empty() || value.len() > 512 || value.chars().any(char::is_control) {
return Err(ChatError::InvalidInput(
"identifiers must be nonblank, control-free and at most 512 UTF-8 bytes".into(),
));
}
Ok(())
}
fn conversation(
tx: &Transaction<'_>,
principal: Principal<'_>,
id: &str,
) -> Result<ChatConversation> {
let conversation = tx.query_row("SELECT id,subject_id,participant_account_id,last_user_message_id FROM chat_conversations WHERE id=?1", [id], conversation_row).optional()?
.filter(|c| principal.member_of(c)).ok_or(ChatError::ConversationNotFound)?;
require_active_subject(tx, &conversation.subject_id)?;
Ok(conversation)
}
fn conversation_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChatConversation> {
Ok(ChatConversation {
id: row.get(0)?,
subject_id: row.get(1)?,
participant_account_id: row.get(2)?,
last_user_message_id: row.get(3)?,
})
}
const MESSAGE_COLUMNS: &str = "id,conversation_id,sequence,sender_kind,sender_id,text,reply_to,work_ref,operation_id,last_user_message_id";
fn message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ChatMessage> {
let kind: String = row.get(3)?;
let id = row.get(4)?;
let sender = match kind.as_str() {
"user" => ChatMessageSender::User { account_id: id },
"subject" => ChatMessageSender::Subject { subject_id: id },
_ => return Err(rusqlite::Error::InvalidQuery),
};
Ok(ChatMessage {
id: row.get(0)?,
conversation_id: row.get(1)?,
sequence: row.get(2)?,
sender,
text: row.get(5)?,
reply_to: row.get(6)?,
work_ref: row.get(7)?,
operation_id: row.get(8)?,
last_user_message_id: row.get(9)?,
})
}
pub(crate) fn erase_subject(tx: &Connection, subject: &str) -> crate::Result<()> {
tx.execute("DELETE FROM chat_messages WHERE conversation_id IN (SELECT id FROM chat_conversations WHERE subject_id=?1)", [subject])?;
tx.execute(
"DELETE FROM chat_conversations WHERE subject_id=?1",
[subject],
)?;
Ok(())
}
#[cfg(test)]
mod tests;
+81
View File
@@ -0,0 +1,81 @@
CREATE TABLE chat_conversations (
id TEXT PRIMARY KEY,
subject_id TEXT NOT NULL REFERENCES subjects(subject_id) ON DELETE RESTRICT,
participant_account_id TEXT NOT NULL,
last_user_message_id TEXT,
create_operation_id TEXT NOT NULL,
UNIQUE(participant_account_id, create_operation_id),
FOREIGN KEY(id, last_user_message_id) REFERENCES chat_messages(conversation_id, id)
DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE chat_messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL REFERENCES chat_conversations(id) ON DELETE RESTRICT,
sequence INTEGER NOT NULL CHECK(sequence BETWEEN 1 AND 9007199254740991),
sender_kind TEXT NOT NULL CHECK(sender_kind IN ('user','subject')),
sender_id TEXT NOT NULL,
text TEXT NOT NULL CHECK(length(CAST(text AS BLOB)) BETWEEN 1 AND 16384),
reply_to TEXT,
work_ref TEXT,
operation_id TEXT NOT NULL,
last_user_message_id TEXT,
CHECK(sender_kind<>'user' OR (last_user_message_id IS NOT NULL AND last_user_message_id=id)),
UNIQUE(conversation_id, sequence),
UNIQUE(conversation_id, id),
UNIQUE(conversation_id, sender_kind, sender_id, operation_id),
FOREIGN KEY(conversation_id, reply_to) REFERENCES chat_messages(conversation_id, id)
DEFERRABLE INITIALLY DEFERRED,
FOREIGN KEY(conversation_id, last_user_message_id) REFERENCES chat_messages(conversation_id, id)
DEFERRABLE INITIALLY DEFERRED
);
CREATE TRIGGER chat_conversation_initial_input BEFORE INSERT ON chat_conversations
WHEN NEW.last_user_message_id IS NOT NULL
BEGIN SELECT RAISE(ABORT, 'chat conversations start without user input'); END;
-- The deferred FK requires this fresh identity to be inserted in the same transaction.
-- Membership below requires that row to be user-authored with its own ID as baseline.
CREATE TRIGGER chat_conversation_identity BEFORE UPDATE ON chat_conversations
WHEN OLD.id<>NEW.id OR OLD.subject_id<>NEW.subject_id
OR OLD.participant_account_id<>NEW.participant_account_id
OR OLD.create_operation_id<>NEW.create_operation_id
OR NEW.last_user_message_id IS NULL
OR NEW.last_user_message_id IS OLD.last_user_message_id
OR EXISTS(SELECT 1 FROM chat_messages WHERE id=NEW.last_user_message_id)
BEGIN SELECT RAISE(ABORT, 'chat conversation identity or new user identity is invalid'); END;
CREATE TRIGGER chat_messages_immutable BEFORE UPDATE ON chat_messages
BEGIN SELECT RAISE(ABORT, 'chat messages are immutable'); END;
CREATE TRIGGER chat_messages_member BEFORE INSERT ON chat_messages
WHEN NOT EXISTS(SELECT 1 FROM chat_conversations c WHERE c.id=NEW.conversation_id
AND ((NEW.sender_kind='user' AND c.participant_account_id=NEW.sender_id)
OR (NEW.sender_kind='subject' AND c.subject_id=NEW.sender_id))
AND c.last_user_message_id IS NEW.last_user_message_id)
OR (NEW.sender_kind='subject' AND NEW.last_user_message_id IS NOT NULL
AND NOT EXISTS(SELECT 1 FROM chat_messages m WHERE m.conversation_id=NEW.conversation_id
AND m.id=NEW.last_user_message_id AND m.sender_kind='user'))
BEGIN SELECT RAISE(ABORT, 'chat message membership or observed user identity mismatch'); END;
CREATE TRIGGER chat_conversations_deletion_fence BEFORE INSERT ON chat_conversations
WHEN EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=NEW.subject_id)
BEGIN SELECT RAISE(ABORT, 'Subject deletion has begun'); END;
CREATE TRIGGER chat_conversations_update_fence BEFORE UPDATE ON chat_conversations
WHEN EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id OR subject_id=NEW.subject_id)
BEGIN SELECT RAISE(ABORT, 'Subject deletion has begun'); END;
CREATE TRIGGER chat_messages_deletion_fence BEFORE INSERT ON chat_messages
WHEN EXISTS(SELECT 1 FROM subject_deletions d JOIN chat_conversations c ON c.subject_id=d.subject_id
WHERE c.id=NEW.conversation_id)
BEGIN SELECT RAISE(ABORT, 'Subject deletion has begun'); END;
CREATE TRIGGER chat_messages_retained BEFORE DELETE ON chat_messages
WHEN NOT EXISTS(SELECT 1 FROM subject_deletions d JOIN chat_conversations c ON c.subject_id=d.subject_id
WHERE c.id=OLD.conversation_id)
BEGIN SELECT RAISE(ABORT, 'chat messages are retained until Subject erasure'); END;
CREATE TRIGGER chat_conversations_retained BEFORE DELETE ON chat_conversations
WHEN NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)
BEGIN SELECT RAISE(ABORT, 'chat conversations are retained until Subject erasure'); END;
+938
View File
@@ -0,0 +1,938 @@
//! Real SQLite component tests through FeatureStorage and the chat APIs. Host authentication,
//! operation authorization and Backend singleton ownership are outside this suite; policy IDs
//! supplied here model the Host's observed existing policy, not a new authority source.
use super::*;
use crate::tests::{open_store, role};
use feature_storage::{FeatureRegistration, FeatureStorage};
use std::sync::{Arc, Barrier};
fn create(
store: &SubjektivStore,
account: &str,
subject: &str,
operation: &str,
) -> ChatConversation {
store
.chat_create_conversation(
account,
&ChatCreateConversationRequest {
operation_id: operation.into(),
subject_id: subject.into(),
},
)
.unwrap()
}
fn user(operation: &str, text: &str) -> ChatUserMessageRequest {
ChatUserMessageRequest {
operation_id: operation.into(),
text: text.into(),
reply_to: None,
work_ref: None,
}
}
fn send(
conversation: &ChatConversation,
operation: &str,
last_user_message_id: Option<&str>,
) -> ChatSendMessageRequest {
ChatSendMessageRequest {
conversation_id: conversation.id.clone(),
operation_id: operation.into(),
text: "public answer".into(),
reply_to: None,
work_ref: None,
expected_last_user_message_id: last_user_message_id.map(str::to_owned),
}
}
fn page(store: &SubjektivStore, conversation: &ChatConversation) -> ChatMessagePage {
store
.chat_messages_for_account(
&conversation.participant_account_id,
&conversation.id,
&ChatMessagePageQuery::default(),
)
.unwrap()
}
fn sql(store: &SubjektivStore, sql: &str) {
store
.database
.with_connection(|c| {
c.execute_batch(sql)?;
Ok(())
})
.unwrap();
}
#[test]
fn membership_and_workspace_isolation_cover_reads_writes_and_replay() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let a = store.create_subject(role()).unwrap().id;
let b = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &a, "create");
let input = user("message", "private user message");
store
.chat_append_user_message("alice", &c.id, &input)
.unwrap();
for result in [
store.chat_conversation_for_account("bob", &c.id),
store.chat_conversation_for_subject(&b, &c.id),
] {
assert!(matches!(result, Err(ChatError::ConversationNotFound)));
}
assert!(matches!(
store.chat_messages_for_account("bob", &c.id, &ChatMessagePageQuery::default()),
Err(ChatError::ConversationNotFound)
));
assert!(matches!(
store.chat_messages_for_subject(&b, &c.id, &ChatMessagePageQuery::default()),
Err(ChatError::ConversationNotFound)
));
assert!(matches!(
store.chat_append_user_message("bob", &c.id, &input),
Err(ChatError::ConversationNotFound)
));
assert!(matches!(
store.chat_send_message(&b, &send(&c, "message", None)),
Err(ChatError::ConversationNotFound)
));
let (_other_manager, _other_scope, other) = open_store(temp.path(), "workspace-b");
assert!(matches!(
other.chat_conversation_for_account("alice", &c.id),
Err(ChatError::ConversationNotFound)
));
assert!(matches!(
other.chat_append_user_message("alice", &c.id, &input),
Err(ChatError::ConversationNotFound)
));
assert!(
other
.chat_create_conversation(
"alice",
&ChatCreateConversationRequest {
operation_id: "create".into(),
subject_id: a
}
)
.is_err()
);
assert_eq!(page(&store, &c).messages.len(), 1);
}
#[test]
fn ack_loss_reopens_and_replays_original_results_after_new_input() {
let temp = tempfile::tempdir().unwrap();
let (c, first, answer, request) = {
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let first = store
.chat_append_user_message("alice", &c.id, &user("same", "first"))
.unwrap();
let mut request = send(&c, "same", Some(&first.id)); // Sender namespaces are independent.
request.reply_to = Some(first.id.clone());
request.work_ref = Some("T-766".into());
let answer = store.chat_send_message(&subject, &request).unwrap();
store
.chat_append_user_message("alice", &c.id, &user("next", "later input"))
.unwrap();
(c, first, answer, request)
}; // Drop every connection: simulate committed storage with a lost acknowledgement.
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
assert_eq!(create(&store, "alice", &c.subject_id, "create"), c);
assert_eq!(
store
.chat_append_user_message("alice", &c.id, &user("same", "first"))
.unwrap(),
first
);
assert_eq!(
store.chat_send_message(&c.subject_id, &request).unwrap(),
answer
);
let p = page(&store, &c);
let last = &p.messages[2];
assert_eq!(
p.conversation.last_user_message_id.as_deref(),
Some(last.id.as_str())
);
assert_eq!(
first.last_user_message_id.as_deref(),
Some(first.id.as_str())
);
assert_eq!(
answer.last_user_message_id.as_deref(),
Some(first.id.as_str())
);
assert_eq!(last.last_user_message_id.as_deref(), Some(last.id.as_str()));
assert_eq!(
p.messages.iter().map(|m| m.sequence).collect::<Vec<_>>(),
vec![1, 2, 3]
);
assert!(!p.has_more);
assert_eq!(
store
.chat_messages_for_subject(&c.subject_id, &c.id, &ChatMessagePageQuery::default())
.unwrap(),
p
);
}
#[test]
fn operation_reuse_checks_each_payload_field_and_pinned_observed_user_identity() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let other_subject = store.create_subject(role()).unwrap().id;
assert!(matches!(
store.chat_create_conversation(
"alice",
&ChatCreateConversationRequest {
operation_id: "create".into(),
subject_id: other_subject
}
),
Err(ChatError::OperationConflict(_))
));
// Same creation operation in another account namespace is a different operation.
assert_ne!(create(&store, "bob", &subject, "create").id, c.id);
let input = user("input", "first");
let first = store
.chat_append_user_message("alice", &c.id, &input)
.unwrap();
let mut variants = vec![input.clone(); 3];
variants[0].text = "changed".into();
variants[1].reply_to = Some(first.id.clone());
variants[2].work_ref = Some("work".into());
for variant in variants {
assert!(matches!(
store.chat_append_user_message("alice", &c.id, &variant),
Err(ChatError::OperationConflict(_))
));
}
let request = send(&c, "answer", Some(&first.id));
store.chat_send_message(&subject, &request).unwrap();
let mut variants = vec![request.clone(); 4];
variants[0].text = "changed".into();
variants[1].reply_to = Some(first.id.clone());
variants[2].work_ref = Some("work".into());
variants[3].expected_last_user_message_id = None;
for variant in variants {
assert!(matches!(
store.chat_send_message(&subject, &variant),
Err(ChatError::OperationConflict(_))
));
}
assert_eq!(page(&store, &c).messages.len(), 2);
assert_eq!(
page(&store, &c)
.conversation
.last_user_message_id
.as_deref(),
Some(first.id.as_str())
);
}
#[test]
fn reply_edges_cannot_cross_conversations_and_operations_are_conversation_scoped() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create-a");
let d = create(&store, "alice", &subject, "create-b");
let first = store
.chat_append_user_message("alice", &c.id, &user("same", "a"))
.unwrap();
let mut bad = user("bad", "not a valid reply");
bad.reply_to = Some(first.id.clone());
assert!(matches!(
store.chat_append_user_message("alice", &d.id, &bad),
Err(ChatError::InvalidInput(_))
));
let mut bad_send = send(&d, "bad", None);
bad_send.reply_to = Some(first.id.clone());
assert!(matches!(
store.chat_send_message(&subject, &bad_send),
Err(ChatError::InvalidInput(_))
));
bad.reply_to = Some("missing".into());
assert!(
store
.chat_append_user_message("alice", &c.id, &bad)
.is_err()
);
assert!(page(&store, &d).messages.is_empty());
assert_eq!(page(&store, &d).conversation.last_user_message_id, None);
let other_input = store
.chat_append_user_message("alice", &d.id, &user("same", "b"))
.unwrap();
// The composite FK rejects bypassing the repository too, not merely the ID lookup.
let result = store.database.transaction(|tx| {
tx.execute("INSERT INTO chat_messages (id,conversation_id,sequence,sender_kind,sender_id,text,reply_to,operation_id,last_user_message_id) VALUES ('bad',?1,2,'subject',?2,'bad',?3,'direct',?4)",params![d.id,subject,first.id,other_input.id])?;
Ok(())
});
assert!(result.is_err());
assert_eq!(page(&store, &d).messages.len(), 1);
}
#[test]
fn page_and_utf8_text_bounds_are_enforced_without_silent_clamping() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let exactly = "é".repeat(CHAT_MAX_TEXT_BYTES / 2);
let first = store
.chat_append_user_message("alice", &c.id, &user("limit", &exactly))
.unwrap();
for text in [String::new(), " \n".into(), format!("{exactly}x")] {
assert!(matches!(
store.chat_append_user_message("alice", &c.id, &user("bad", &text)),
Err(ChatError::InvalidInput(_))
));
let mut request = send(&c, "bad", Some(&first.id));
request.text = text;
assert!(matches!(
store.chat_send_message(&subject, &request),
Err(ChatError::InvalidInput(_))
));
}
for n in 2..=102 {
store
.chat_send_message(&subject, &send(&c, &format!("answer-{n}"), Some(&first.id)))
.unwrap();
}
let query = |after, limit| ChatMessagePageQuery {
after_sequence: after,
limit,
};
for limit in [0, 101, u32::MAX] {
assert!(matches!(
store.chat_messages_for_account("alice", &c.id, &query(None, Some(limit))),
Err(ChatError::InvalidInput(_))
));
}
let first = store
.chat_messages_for_account("alice", &c.id, &query(None, Some(100)))
.unwrap();
assert_eq!(first.messages.len(), 100);
assert!(first.has_more);
let tail = store
.chat_messages_for_account("alice", &c.id, &query(Some(100), Some(100)))
.unwrap();
assert_eq!(
tail.messages.iter().map(|m| m.sequence).collect::<Vec<_>>(),
vec![101, 102]
);
assert!(!tail.has_more);
assert_eq!(
page(&store, &c).messages.len(),
CHAT_DEFAULT_PAGE_LIMIT as usize
);
assert!(matches!(
store.chat_messages_for_account("alice", &c.id, &query(Some(u64::MAX), None)),
Err(ChatError::InvalidInput(_))
));
}
#[test]
fn observed_user_identity_fence_and_append_share_one_sqlite_transaction_across_connections() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let (_second_manager, _second_scope, second) = open_store(temp.path(), "workspace-a");
let barrier = Arc::new(Barrier::new(2));
let (input, output) = std::thread::scope(|s| {
let barrier = &barrier;
let c = &c;
let subject = &subject;
let first = s.spawn(move || {
barrier.wait();
store.chat_append_user_message("alice", &c.id, &user("input", "new input"))
});
let other = s.spawn(move || {
barrier.wait();
second.chat_send_message(subject, &send(c, "answer", None))
});
(first.join().unwrap().unwrap(), other.join().unwrap())
});
// A subject send that won the SQLite write lock must precede user input. If input won,
// no stale subject message can commit. Both connections must observe the same ordering.
match output {
Ok(answer) => {
assert_eq!(answer.sequence, 1);
assert_eq!(answer.last_user_message_id, None);
assert_eq!(input.sequence, 2);
}
Err(ChatError::UserInputChanged {
expected: None,
actual,
}) => {
assert_eq!(actual.as_deref(), Some(input.id.as_str()));
assert_eq!(input.sequence, 1);
}
other => panic!("unexpected race outcome: {other:?}"),
}
let (_manager, _scope, reopened) = open_store(temp.path(), "workspace-a");
assert!(matches!(
reopened.chat_send_message(&subject, &send(&c, "stale", None)),
Err(ChatError::UserInputChanged {
expected: None,
actual: Some(ref actual)
}) if actual == &input.id
));
assert_eq!(
page(&reopened, &c)
.conversation
.last_user_message_id
.as_deref(),
Some(input.id.as_str())
);
reopened
.chat_send_message(&subject, &send(&c, "fresh", Some(&input.id)))
.unwrap();
}
#[test]
fn concurrent_exact_user_retries_publish_one_identity() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let (_second_manager, _second_scope, second) = open_store(temp.path(), "workspace-a");
let barrier = Barrier::new(2);
let (a, b) = std::thread::scope(|s| {
let a = s.spawn(|| {
barrier.wait();
store.chat_append_user_message("alice", &c.id, &user("same", "hello"))
});
let b = s.spawn(|| {
barrier.wait();
second.chat_append_user_message("alice", &c.id, &user("same", "hello"))
});
(a.join().unwrap().unwrap(), b.join().unwrap().unwrap())
});
assert_eq!(a, b);
assert_eq!(page(&store, &c).messages.len(), 1);
assert_eq!(
page(&store, &c)
.conversation
.last_user_message_id
.as_deref(),
Some(a.id.as_str())
);
}
#[test]
fn insert_and_commit_failures_rollback_message_baseline_and_operation_identity() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
sql(
&store,
"CREATE TRIGGER fail_chat_save BEFORE INSERT ON chat_messages BEGIN SELECT RAISE(ABORT,'injected save failure'); END;",
);
assert!(matches!(
store.chat_append_user_message("alice", &c.id, &user("retry", "hello")),
Err(ChatError::Storage(_))
));
assert_eq!(page(&store, &c).conversation.last_user_message_id, None);
assert!(page(&store, &c).messages.is_empty());
sql(&store,"DROP TRIGGER fail_chat_save;
CREATE TABLE chat_commit_failure (subject_id TEXT REFERENCES subjects(subject_id) DEFERRABLE INITIALLY DEFERRED);
CREATE TRIGGER fail_chat_commit AFTER INSERT ON chat_messages BEGIN INSERT INTO chat_commit_failure VALUES ('missing'); END;");
assert!(matches!(
store.chat_append_user_message("alice", &c.id, &user("retry", "hello")),
Err(ChatError::Storage(_))
));
assert_eq!(page(&store, &c).conversation.last_user_message_id, None);
assert!(page(&store, &c).messages.is_empty());
assert!(matches!(
store.chat_send_message(&subject, &send(&c, "retry", None)),
Err(ChatError::Storage(_))
));
sql(
&store,
"DROP TRIGGER fail_chat_commit; DROP TABLE chat_commit_failure;",
);
let first = store
.chat_append_user_message("alice", &c.id, &user("retry", "hello"))
.unwrap();
assert_eq!(first.sequence, 1);
assert_eq!(
first.last_user_message_id.as_deref(),
Some(first.id.as_str())
);
let answer = store
.chat_send_message(&subject, &send(&c, "retry", Some(&first.id)))
.unwrap();
assert_eq!(answer.sequence, 2);
assert_eq!(
answer.last_user_message_id.as_deref(),
Some(first.id.as_str())
);
}
#[test]
fn subject_erasure_fences_replay_then_purges_chat_without_touching_other_subject() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let other = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let d = create(&store, "alice", &other, "other");
let first = store
.chat_append_user_message("alice", &c.id, &user("input", "erase me"))
.unwrap();
let mut request = send(&c, "reply", Some(&first.id));
request.reply_to = Some(first.id);
store.chat_send_message(&subject, &request).unwrap();
let retained = store
.chat_append_user_message("alice", &d.id, &user("input", "keep me"))
.unwrap();
store.begin_subject_deletion(&subject, "erase").unwrap();
assert!(store.chat_conversation_for_account("alice", &c.id).is_err());
assert!(
store
.chat_append_user_message("alice", &c.id, &user("input", "erase me"))
.is_err()
);
assert!(store.chat_send_message(&subject, &request).is_err());
assert!(
store
.chat_create_conversation(
"alice",
&ChatCreateConversationRequest {
operation_id: "create".into(),
subject_id: subject.clone()
}
)
.is_err()
);
store.finish_subject_deletion(&subject, "erase").unwrap();
store
.database
.with_connection(|db| {
assert_eq!(
db.query_row("SELECT COUNT(*) FROM chat_conversations", [], |r| r
.get::<_, u64>(0))?,
1
);
assert_eq!(
db.query_row("SELECT COUNT(*) FROM chat_messages", [], |r| r
.get::<_, u64>(0))?,
1
);
Ok(())
})
.unwrap();
assert_eq!(page(&store, &d).messages, vec![retained]);
}
fn schema(store: &SubjektivStore) -> Vec<(String, String, String)> {
store.database.with_connection(|db| {
Ok(db.prepare("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type,name")?
.query_map([],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?.collect::<std::result::Result<Vec<_>,_>>()?)
}).unwrap()
}
#[test]
fn oldest_supported_store_upgrades_to_fresh_schema_preserving_subjects_and_constraints() {
let temp = tempfile::tempdir().unwrap();
let subject = {
let manager = FeatureStorage::new(temp.path());
let scope = manager.workspace("upgraded").unwrap();
let registered = scope
.register(FeatureRegistration::new(
crate::SUBJEKTIV_FEATURE_ID,
&crate::MIGRATIONS[..1],
))
.unwrap();
let old = SubjektivStore::open(&scope, &registered).unwrap();
let subject = crate::SubjectRecord {
schema_version: crate::SUBJEKTIV_SCHEMA_VERSION,
id: "preserved-subject".into(),
role: role(),
behavior_md: String::new(),
state: crate::SubjectState::Active,
created_at: crate::now(),
updated_at: crate::now(),
};
old.database
.with_connection(|db| {
db.execute(
"INSERT INTO subjects VALUES (?1,?2,'active',0,?3,?4,?5)",
params![
subject.id,
subject.role.as_str(),
serde_json::to_string(&subject).unwrap(),
subject.created_at,
subject.updated_at
],
)?;
Ok(())
})
.unwrap();
subject
};
let (_manager, _scope, upgraded) = open_store(temp.path(), "upgraded");
let (_fresh_manager, _fresh_scope, fresh) = open_store(temp.path(), "fresh");
assert_eq!(
upgraded.subject(&subject.id).unwrap(),
Some(subject.clone())
);
assert_eq!(schema(&upgraded), schema(&fresh));
let c = create(&upgraded, "alice", &subject.id, "create");
upgraded
.chat_append_user_message("alice", &c.id, &user("input", "preserved subject"))
.unwrap();
let write = upgraded.database.with_connection(|db| {
db.execute("UPDATE chat_messages SET text='tampered'", [])?;
Ok(())
});
assert!(write.is_err());
let delete = upgraded.database.with_connection(|db| {
db.execute("DELETE FROM chat_messages", [])?;
Ok(())
});
assert!(delete.is_err());
assert_eq!(page(&upgraded, &c).messages[0].text, "preserved subject");
}
#[test]
fn failed_chat_migration_rolls_back_all_ddl_and_can_be_retried() {
let connection = Connection::open_in_memory().unwrap();
connection.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE subjects(subject_id TEXT PRIMARY KEY); CREATE TABLE subject_deletions(subject_id TEXT PRIMARY KEY); CREATE TABLE chat_messages(blocker TEXT);").unwrap();
{
let tx = connection.unchecked_transaction().unwrap();
assert!(migrate(&tx).is_err());
}
let exists: bool = connection
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='chat_conversations')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!exists);
connection
.execute_batch("DROP TABLE chat_messages;")
.unwrap();
let tx = connection.unchecked_transaction().unwrap();
migrate(&tx).unwrap();
tx.commit().unwrap();
assert!(
connection
.execute(
"INSERT INTO chat_conversations VALUES ('c','missing','a',NULL,'op')",
[]
)
.is_err()
);
}
fn policy(
store: &SubjektivStore,
subject: &str,
operation: &str,
expected: Option<&str>,
) -> String {
store
.set_subject_permissions(
subject,
&server_api::authorization::SubjectPermissionsUpdate {
operation_id: operation.into(),
expected_change_id: expected.map(str::to_owned),
execution: Vec::new(),
delegation: Vec::new(),
},
)
.unwrap()
.change_id
}
#[test]
fn missing_or_replaced_policy_refuses_read_append_and_saved_replay() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let request = send(&c, "answer", None);
let query = ChatMessagePageQuery::default();
assert!(matches!(
store.chat_messages_for_subject_authorized(&subject, &c.id, &query, "missing"),
Err(ChatError::AuthorizationChanged)
));
assert!(matches!(
store.chat_send_message_authorized(&subject, &request, "missing"),
Err(ChatError::AuthorizationChanged)
));
let observed = policy(&store, &subject, "initial", None);
let answer = store
.chat_send_message_authorized(&subject, &request, &observed)
.unwrap();
assert_eq!(
store
.chat_messages_for_subject_authorized(&subject, &c.id, &query, &observed)
.unwrap()
.messages,
vec![answer.clone()]
);
let (_other_manager, _other_scope, other) = open_store(temp.path(), "workspace-a");
// An identical replacement still invalidates the Host's earlier observation.
let current = policy(&other, &subject, "replace", Some(&observed));
assert_ne!(current, observed);
assert!(matches!(
store.chat_messages_for_subject_authorized(&subject, &c.id, &query, &observed),
Err(ChatError::AuthorizationChanged)
));
assert!(matches!(
store.chat_send_message_authorized(&subject, &request, &observed),
Err(ChatError::AuthorizationChanged)
));
assert!(matches!(
store.chat_send_message_authorized(&subject, &send(&c, "new", None), &observed),
Err(ChatError::AuthorizationChanged)
));
assert_eq!(page(&store, &c).messages, vec![answer.clone()]);
assert_eq!(
store
.chat_send_message_authorized(&subject, &request, &current)
.unwrap(),
answer
);
}
#[test]
fn policy_replacement_and_send_serialize_across_sqlite_connections() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let observed = policy(&store, &subject, "initial", None);
let (_other_manager, _other_scope, other) = open_store(temp.path(), "workspace-a");
let barrier = Barrier::new(2);
let (result, current) = std::thread::scope(|s| {
let send = s.spawn(|| {
barrier.wait();
store.chat_send_message_authorized(&subject, &send(&c, "answer", None), &observed)
});
let replace = s.spawn(|| {
barrier.wait();
policy(&other, &subject, "replace", Some(&observed))
});
(send.join().unwrap(), replace.join().unwrap())
});
let saved = page(&store, &c).messages;
match result {
Ok(answer) => assert_eq!(saved, vec![answer]),
Err(ChatError::AuthorizationChanged) => assert!(saved.is_empty()),
other => panic!("unexpected policy race outcome: {other:?}"),
}
assert!(matches!(
store.chat_send_message_authorized(&subject, &send(&c, "answer", None), &observed),
Err(ChatError::AuthorizationChanged)
));
assert!(matches!(
store.chat_messages_for_subject_authorized(
&subject,
&c.id,
&ChatMessagePageQuery::default(),
&observed
),
Err(ChatError::AuthorizationChanged)
));
assert_eq!(
store
.chat_messages_for_subject_authorized(
&subject,
&c.id,
&ChatMessagePageQuery::default(),
&current
)
.unwrap()
.messages,
saved
);
}
#[test]
fn null_baseline_replay_survives_user_input_and_reply_target_is_independent() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let opening = send(&c, "opening", None);
let published = store.chat_send_message(&subject, &opening).unwrap();
let first = store
.chat_append_user_message("alice", &c.id, &user("first", "first"))
.unwrap();
let latest = store
.chat_append_user_message("alice", &c.id, &user("latest", "new input"))
.unwrap();
assert_eq!(
store.chat_send_message(&subject, &opening).unwrap(),
published
);
let mut changed = opening.clone();
changed.expected_last_user_message_id = Some(latest.id.clone());
assert!(matches!(
store.chat_send_message(&subject, &changed),
Err(ChatError::OperationConflict(_))
));
let mut request = send(&c, "reply", Some(&latest.id));
request.reply_to = Some(first.id.clone());
let answer = store.chat_send_message(&subject, &request).unwrap();
assert_eq!(answer.reply_to, Some(first.id));
assert_eq!(answer.last_user_message_id, Some(latest.id));
// A subject-authored ID is never a user-input baseline.
let bad = send(&c, "wrong", Some(&answer.id));
assert!(
matches!(store.chat_send_message(&subject, &bad), Err(ChatError::UserInputChanged { expected, actual }) if expected == Some(answer.id.clone()) && actual == answer.last_user_message_id)
);
assert_eq!(page(&store, &c).messages.len(), 4);
}
#[test]
fn schema_rejects_dangling_cross_conversation_or_subject_authored_user_identity() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let other = create(&store, "alice", &subject, "other");
let input = store
.chat_append_user_message("alice", &other.id, &user("input", "other conversation"))
.unwrap();
let answer = store
.chat_send_message(&subject, &send(&c, "answer", None))
.unwrap();
for target in [&input.id, &answer.id, "missing"] {
let result = store.database.transaction(|tx| {
tx.execute(
"UPDATE chat_conversations SET last_user_message_id=?1 WHERE id=?2",
params![target, c.id],
)?;
Ok(())
});
assert!(result.is_err(), "must reject user identity {target}");
}
let result = store.database.transaction(|tx| {
tx.execute("UPDATE chat_conversations SET last_user_message_id='fresh-subject-message' WHERE id=?1", [&c.id])?;
tx.execute("INSERT INTO chat_messages (id,conversation_id,sequence,sender_kind,sender_id,text,operation_id,last_user_message_id) VALUES ('fresh-subject-message',?1,2,'subject',?2,'bad','direct','fresh-subject-message')", params![c.id, subject])?;
Ok(())
});
assert!(result.is_err());
let result = store.database.transaction(|tx| {
tx.execute("INSERT INTO chat_messages (id,conversation_id,sequence,sender_kind,sender_id,text,operation_id,last_user_message_id) VALUES ('bad-user',?1,2,'user','alice','bad','direct',NULL)", [&c.id])?;
Ok(())
});
assert!(result.is_err());
assert_eq!(page(&store, &c).conversation.last_user_message_id, None);
assert_eq!(page(&store, &c).messages, vec![answer]);
}
#[test]
fn previous_schema_upgrade_preserves_policy_and_matches_fresh_database() {
let temp = tempfile::tempdir().unwrap();
let (subject, observed) = {
let manager = FeatureStorage::new(temp.path());
let scope = manager.workspace("upgraded").unwrap();
let registered = scope
.register(FeatureRegistration::new(
crate::SUBJEKTIV_FEATURE_ID,
&crate::MIGRATIONS[..14],
))
.unwrap();
let old = SubjektivStore::open(&scope, &registered).unwrap();
let subject = old
.create_subject_with_behavior(role(), "preserved behavior".into())
.unwrap();
let observed = policy(&old, &subject.id, "initial", None);
(subject, observed)
};
let (_manager, _scope, upgraded) = open_store(temp.path(), "upgraded");
let (_fresh_manager, _fresh_scope, fresh) = open_store(temp.path(), "fresh");
assert_eq!(schema(&upgraded), schema(&fresh));
assert_eq!(
upgraded.subject(&subject.id).unwrap(),
Some(subject.clone())
);
assert_eq!(
upgraded
.subject_permissions(&subject.id)
.unwrap()
.unwrap()
.change_id,
observed
);
let c = create(&upgraded, "alice", &subject.id, "create");
let input = upgraded
.chat_append_user_message("alice", &c.id, &user("input", "preserved subject"))
.unwrap();
upgraded
.chat_send_message_authorized(&subject.id, &send(&c, "answer", Some(&input.id)), &observed)
.unwrap();
assert_eq!(
page(&upgraded, &c).conversation.last_user_message_id,
Some(input.id)
);
}
#[test]
fn policy_replacement_and_read_serialize_across_sqlite_connections() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let c = create(&store, "alice", &subject, "create");
let input = store
.chat_append_user_message("alice", &c.id, &user("input", "private input"))
.unwrap();
let observed = policy(&store, &subject, "initial", None);
let (_other_manager, _other_scope, other) = open_store(temp.path(), "workspace-a");
let barrier = Barrier::new(2);
let (result, current) = std::thread::scope(|s| {
let read = s.spawn(|| {
barrier.wait();
store.chat_messages_for_subject_authorized(
&subject,
&c.id,
&ChatMessagePageQuery::default(),
&observed,
)
});
let replace = s.spawn(|| {
barrier.wait();
policy(&other, &subject, "replace", Some(&observed))
});
(read.join().unwrap(), replace.join().unwrap())
});
match result {
Ok(page) => assert_eq!(page.messages, vec![input.clone()]),
Err(ChatError::AuthorizationChanged) => {}
other => panic!("unexpected policy race outcome: {other:?}"),
}
assert!(matches!(
store.chat_messages_for_subject_authorized(
&subject,
&c.id,
&ChatMessagePageQuery::default(),
&observed
),
Err(ChatError::AuthorizationChanged)
));
assert_eq!(
store
.chat_messages_for_subject_authorized(
&subject,
&c.id,
&ChatMessagePageQuery::default(),
&current
)
.unwrap()
.messages,
vec![input]
);
}
+1
View File
@@ -391,6 +391,7 @@ impl SubjektivStore {
return Ok(saved);
}
tx.execute_batch("PRAGMA defer_foreign_keys=ON;")?;
crate::chat::erase_subject(tx, subject)?;
for (table, predicate) in INDIRECT {
tx.execute(
&format!(
+5 -1
View File
@@ -273,12 +273,16 @@ fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped(
for table in tables {
let columns = c.prepare(&format!("PRAGMA table_info({table})"))?.query_map([], |r| r.get::<_,String>(1))?.collect::<std::result::Result<Vec<_>,_>>()?;
if !columns.iter().any(|v| v == "subject_id") || table.starts_with("subject_delet") { continue; }
assert!(DIRECT.contains(&table.as_str()) || table == "journal_erased_sessions", "unclassified Subject table: {table}");
assert!(DIRECT.contains(&table.as_str()) || table == "journal_erased_sessions" || table == "chat_conversations", "unclassified Subject table: {table}");
}
for table in DIRECT.iter().copied().chain(INDIRECT.iter().map(|v| v.0)).chain(["journal_erased_sessions"]) {
let sql: String = c.query_row("SELECT sql FROM sqlite_master WHERE name=?1", [format!("deletion_fence_{table}_UPDATE")], |r| r.get(0))?;
assert!(sql.contains("OLD.") && sql.contains("NEW."), "{table}: {sql}");
}
// Chat owns its later migration; keep the earlier deletion migration unchanged.
let chat_guard: String = c.query_row("SELECT sql FROM sqlite_master WHERE name='chat_conversations_update_fence'", [], |r| r.get(0))?;
assert!(chat_guard.contains("OLD.subject_id") && chat_guard.contains("NEW.subject_id"));
assert!(c.execute("INSERT INTO chat_conversations VALUES ('late',?1,'alice',NULL,'late')", [&a]).is_err());
// OLD fenced -> NEW unfenced must not evade a guard.
assert!(c.execute("UPDATE subjects SET subject_id=?2 WHERE subject_id=?1", params![a, "new-id"]).is_err());
assert!(c.execute("UPDATE subjects SET subject_id=?2 WHERE subject_id=?1", params![b, a]).is_err());
+2
View File
@@ -1,6 +1,7 @@
#![doc = include_str!("../README.md")]
pub mod api;
pub mod chat;
pub mod deletion;
pub mod job;
pub use deletion::{DeletionSubjectJob, SubjectDeletion};
@@ -656,6 +657,7 @@ static MIGRATIONS: &[FeatureMigration] = &[
"add permanent Subject deletion fences and explicit erasure",
deletion::migrate,
),
FeatureMigration::new(15, "add retained text-only conversations", chat::migrate),
];
pub const REGISTRATION: FeatureRegistration =
+25
View File
@@ -36,6 +36,27 @@ impl SubjektivStore {
&self,
subject_id: &str,
update: &SubjectPermissionsUpdate,
) -> Result<SubjectPermissions> {
self.replace_subject_permissions(subject_id, update, false)
}
/// Management replacement rejects new unsupported operation/resource pairs while
/// retaining exact legacy entries in their original execution/delegation lane.
/// Validation shares the existing CAS/receipt transaction so a stale editor cannot
/// retain a removed legacy grant. Exact replay remains historical evidence.
pub fn set_managed_subject_permissions(
&self,
subject_id: &str,
update: &SubjectPermissionsUpdate,
) -> Result<SubjectPermissions> {
self.replace_subject_permissions(subject_id, update, true)
}
fn replace_subject_permissions(
&self,
subject_id: &str,
update: &SubjectPermissionsUpdate,
managed: bool,
) -> Result<SubjectPermissions> {
validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?;
validate_authorization_identifier(&update.operation_id)
@@ -70,6 +91,10 @@ impl SubjektivStore {
if current.as_ref().map(|source| &source.change_id) != update.expected_change_id.as_ref() {
return Err(SubjektivError::SubjectPermissionsConflict(subject_id.into()));
}
if managed {
server_api::authorization::validate_managed_subject_permissions(current.as_ref(), update)
.map_err(SubjektivError::InvalidRecord)?;
}
let source = SubjectPermissions {
change_id: issued_id("permissions"),
execution: update.execution.clone(),
+13 -1
View File
@@ -1415,6 +1415,14 @@ fn authorize_runtime_protocol_method(
message,
..
} => protocol::Method::NotifyTracked {
receipt_scope: if matches!(
transport_source,
Some(protocol::AuthenticatedInputSource::Account { .. })
) {
protocol::NotificationReceiptScope::BrowserConsole
} else {
protocol::NotificationReceiptScope::Source
},
source: transport_source.cloned().unwrap_or_else(|| {
protocol::AuthenticatedInputSource::Backend {
operation_id: notification_request_id.clone(),
@@ -2985,6 +2993,7 @@ mod tests {
.unwrap()
.expect("account source header must resolve");
let wire = serde_json::to_string(&protocol::Method::NotifyTracked {
receipt_scope: protocol::NotificationReceiptScope::Source,
notification_request_id: "notification-1".into(),
message: "hello".into(),
source: protocol::AuthenticatedInputSource::Account {
@@ -2997,9 +3006,11 @@ mod tests {
assert!(matches!(
authorize_runtime_protocol_method(decoded, Some(&source)),
protocol::Method::NotifyTracked {
receipt_scope: protocol::NotificationReceiptScope::BrowserConsole,
notification_request_id,
source: protocol::AuthenticatedInputSource::Account { account_id },
..
} if account_id == "account-1"
} if account_id == "account-1" && notification_request_id == "notification-1"
));
}
@@ -5312,6 +5323,7 @@ mod ws_tests {
let (mut stream, _) = connect_async(request).await.unwrap();
let _ = next_frame(&mut stream).await;
let notification = protocol::Method::NotifyTracked {
receipt_scope: protocol::NotificationReceiptScope::Source,
notification_request_id: "notification-1".into(),
message: "hello".into(),
source: protocol::AuthenticatedInputSource::Account {
+5
View File
@@ -26,6 +26,9 @@ impl WorkerInputKind {
pub struct WorkerInput {
pub kind: WorkerInputKind,
pub content: String,
/// Provenance stamped by the authenticated Backend, never by public/model input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<protocol::AuthenticatedInputSource>,
/// Authenticated client-generated idempotency key. Runtime generates one
/// only for trusted internal callers that omit it.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -39,6 +42,7 @@ impl WorkerInput {
Self {
kind: WorkerInputKind::User,
content: content.into(),
source: None,
submission_request_id: None,
segments: None,
}
@@ -48,6 +52,7 @@ impl WorkerInput {
Self {
kind: WorkerInputKind::Notify,
content: content.into(),
source: None,
submission_request_id: None,
segments: None,
}
+5
View File
@@ -8201,6 +8201,7 @@ mod tests {
let input = WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
source: None,
submission_request_id: None,
segments: Some(vec![protocol::Segment::Flow {
selector: "builtin:coder-review".to_string(),
@@ -8214,6 +8215,7 @@ mod tests {
let input = WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
source: None,
submission_request_id: None,
segments: Some(Vec::new()),
};
@@ -8229,6 +8231,7 @@ mod tests {
request.initial_input = Some(WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
source: None,
submission_request_id: None,
segments: Some(vec![protocol::Segment::Flow {
selector: "builtin:coder-review".to_string(),
@@ -13696,6 +13699,7 @@ mod tests {
request.initial_input = Some(WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
source: None,
submission_request_id: None,
segments: Some(vec![
protocol::Segment::Flow {
@@ -13739,6 +13743,7 @@ mod tests {
let input = WorkerInput {
kind: WorkerInputKind::User,
content: String::new(),
source: None,
submission_request_id: None,
segments: Some(vec![protocol::Segment::Flow {
selector: "builtin:coder-review".to_string(),
+26 -3
View File
@@ -6576,11 +6576,14 @@ where
WorkerExecutionOperation::Input,
worker,
Method::NotifyTracked {
receipt_scope: protocol::NotificationReceiptScope::Source,
notification_request_id: notification_request_id.clone(),
message: input.content,
source: protocol::AuthenticatedInputSource::Backend {
operation_id: notification_request_id.clone(),
},
source: input.source.unwrap_or_else(|| {
protocol::AuthenticatedInputSource::Backend {
operation_id: notification_request_id.clone(),
}
}),
},
notification_request_id,
);
@@ -9908,6 +9911,11 @@ mod tests {
enum MockResponse {
Complete(Vec<LlmEvent>),
Hang(Vec<LlmEvent>),
Gated {
started: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
events: Vec<LlmEvent>,
},
}
#[derive(Clone)]
@@ -9953,6 +9961,20 @@ mod tests {
MockResponse::Complete(events) => {
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
}
MockResponse::Gated {
started,
release,
events,
} => {
started.notify_one();
Ok(Box::pin(
futures::stream::once(async move {
release.notified().await;
futures::stream::iter(events.into_iter().map(Ok))
})
.flatten(),
))
}
MockResponse::Hang(events) => Ok(Box::pin(
futures::stream::iter(events.into_iter().map(Ok))
.chain(futures::stream::pending()),
@@ -10977,6 +10999,7 @@ mod tests {
#[cfg(feature = "fs-store")]
include!("worker_backend_restore_failure_tests.rs");
include!("worker_backend_restore_candidate_tests.rs");
include!("worker_backend_notify_transport_tests.rs");
struct PendingFailureFactory {
restore_calls: Arc<AtomicUsize>,
@@ -0,0 +1,251 @@
// Concrete Runtime -> Controller -> filesystem receipt/history tests. Only model
// I/O is gated; the Runtime adapter and notification acceptance owner are real.
mod notify_transport {
use super::*;
const WAIT: Duration = Duration::from_secs(5);
async fn accepted(events: &mut tokio::sync::broadcast::Receiver<Event>, id: &str) {
tokio::time::timeout(WAIT, async {
loop {
match events.recv().await.unwrap() {
Event::NotificationAccepted {
notification_request_id,
} if notification_request_id == id => return,
Event::NotificationRejected { message, .. } | Event::Error { message, .. } => {
panic!("notification rejected: {message}")
}
_ => {}
}
}
})
.await
.expect("durable notification acceptance");
}
#[test]
fn busy_notify_retains_source_scoped_receipts_and_commits_provenance_before_next_model_input() {
let started = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let next_started = Arc::new(tokio::sync::Notify::new());
let next_release = Arc::new(tokio::sync::Notify::new());
let client = MockClient::sequential(vec![
MockResponse::Gated {
started: started.clone(),
release: release.clone(),
events: simple_text_events(),
},
MockResponse::Gated {
started: next_started.clone(),
release: next_release.clone(),
events: simple_text_events(),
},
]);
let runtime_base = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
let backend = Arc::new(
WorkerRuntimeExecutionBackend::new(MockFactory {
client: client.clone(),
runtime_base: runtime_base.path().to_path_buf(),
cwd: cwd.path().to_path_buf(),
store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"),
observed_cwds: Arc::new(Mutex::new(Vec::new())),
observed_workspace_clients: Arc::new(Mutex::new(Vec::new())),
})
.unwrap(),
);
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone())
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let detail = runtime
.create_worker(create_request("notify-provenance"))
.unwrap();
let worker_ref = &detail.worker_ref;
let handle = backend.workers.lock().unwrap()[worker_ref].handle.clone();
runtime
.send_input(worker_ref, WorkerInput::user("original request"))
.unwrap();
backend
.run_on_adapter_runtime(async move {
tokio::time::timeout(WAIT, started.notified())
.await
.unwrap();
Ok(())
})
.unwrap();
let account = protocol::AuthenticatedInputSource::Account {
account_id: "account-a".into(),
};
let mut human = WorkerInput::notify("human-notice-body");
human.source = Some(account.clone());
human.submission_request_id = Some("same-client-id".into());
// Simulate an ACK lost above the Controller acceptance boundary. The
// sender retries through Runtime, using the same source, ID and body.
let lost_handle = handle.clone();
backend
.run_on_adapter_runtime(async move {
let mut events = lost_handle.subscribe();
lost_handle
.send(Method::NotifyTracked {
receipt_scope: protocol::NotificationReceiptScope::Source,
notification_request_id: "same-client-id".into(),
message: "human-notice-body".into(),
source: account,
})
.await
.unwrap();
accepted(&mut events, "same-client-id").await;
Ok(())
})
.unwrap();
let first = runtime.send_input(worker_ref, human.clone()).unwrap();
let retry = runtime.send_input(worker_ref, human.clone()).unwrap();
assert_eq!(first.notification, retry.notification);
assert!(first.submission.is_none());
let mut conflict = human.clone();
conflict.content = "changed-body".into();
assert!(runtime.send_input(worker_ref, conflict).is_err());
let mut other_account = human.clone();
other_account.source = Some(protocol::AuthenticatedInputSource::Account {
account_id: "account-b".into(),
});
other_account.content = "other-account-body".into();
runtime.send_input(worker_ref, other_account).unwrap();
let mut report = human.clone();
report.source = Some(protocol::AuthenticatedInputSource::Worker {
runtime_id: "report-runtime".into(),
worker_id: "report-worker".into(),
});
report.content = "worker-report-body".into();
runtime.send_input(worker_ref, report).unwrap();
let mut system = WorkerInput::notify("system-notice-body");
system.submission_request_id = Some("system-notice-id".into());
runtime.send_input(worker_ref, system).unwrap();
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Running);
assert_eq!(
client.call_count.load(Ordering::SeqCst),
1,
"notify must not interrupt the current model stream"
);
let before = handle.committed_entries();
let checkpoint = before
.iter()
.rev()
.find_map(|entry| match entry {
LogEntry::Extension {
domain, payload, ..
} if domain == "worker.pending_activations.v1" => Some(payload),
_ => None,
})
.unwrap();
assert_eq!(
checkpoint["notification_receipts"]
.as_array()
.unwrap()
.len(),
4
);
assert_eq!(
checkpoint["pending_notifications"].as_array().unwrap().len()
+ checkpoint["activating_notification"].as_array().unwrap().len(),
4
);
let sources = checkpoint["notification_receipts"]
.as_array()
.unwrap()
.iter()
.map(|receipt| receipt["source_namespace"].as_str().unwrap())
.collect::<BTreeSet<_>>();
assert_eq!(
sources,
BTreeSet::from([
"account:account-a",
"account:account-b",
"worker:report-runtime:report-worker",
"backend:system-notice-id",
])
);
release.notify_one();
backend
.run_on_adapter_runtime(async move {
tokio::time::timeout(WAIT, next_started.notified())
.await
.unwrap();
Ok(())
})
.unwrap();
let entries = handle.committed_entries();
let history = session_store::collect_state(&entries).annotated_history;
let expected = [
(
"human-notice-body",
LoggedSessionHistoryOrigin::HumanInput {
account_id: "account-a".into(),
},
),
(
"other-account-body",
LoggedSessionHistoryOrigin::HumanInput {
account_id: "account-b".into(),
},
),
(
"worker-report-body",
LoggedSessionHistoryOrigin::WorkerInput {
actor: session_store::LoggedWorkerSubject {
workspace_id: None,
runtime_id: Some("report-runtime".into()),
worker_id: "report-worker".into(),
},
},
),
(
"system-notice-body",
LoggedSessionHistoryOrigin::BackendInstruction {
operation_id: Some("system-notice-id".into()),
},
),
];
let requests = client.captured.lock().unwrap();
let first_items = serde_json::to_string(&requests[0].items).unwrap();
let next_items = serde_json::to_string(&requests[1].items).unwrap();
for (body, origin) in expected {
assert!(!first_items.contains(body));
assert_eq!(next_items.matches(body).count(), 1);
let matching = history
.iter()
.filter(|entry| serde_json::to_string(&entry.item).unwrap().contains(body))
.collect::<Vec<_>>();
assert_eq!(
matching.len(),
1,
"notification must commit exactly once: {body}"
);
assert_eq!(matching[0].metadata.origin, origin);
}
drop(requests);
assert!(!next_items.contains("changed-body"));
// Provenance is annotation, not a grant or promoted Host instruction.
let replay = runtime.send_input(worker_ref, human).unwrap();
assert_eq!(first.notification, replay.notification);
assert_eq!(
serde_json::to_value(handle.committed_entries()).unwrap(),
serde_json::to_value(entries).unwrap(),
"retained receipt replay must not append history"
);
next_release.notify_one();
runtime
.stop_worker(worker_ref, Some("test complete".into()))
.unwrap();
assert!(
runtime
.send_input(worker_ref, WorkerInput::notify("stopped-notice"))
.is_err()
);
}
}
@@ -90,14 +90,27 @@ mod restore_candidate {
}
async fn notify(handle: &WorkerHandle) {
notify_from_source(handle, None).await;
}
async fn notify_from_source(
handle: &WorkerHandle,
source: Option<protocol::AuthenticatedInputSource>,
) {
let mut events = handle.subscribe();
handle
.send(Method::Notify {
let method = match source {
Some(source) => Method::NotifyTracked {
receipt_scope: protocol::NotificationReceiptScope::Source,
notification_request_id: NOTICE.into(),
message: NOTICE.into(),
})
.await
.unwrap();
source,
},
None => Method::Notify {
notification_request_id: NOTICE.into(),
message: NOTICE.into(),
},
};
handle.send(method).await.unwrap();
receive_matching(&mut events, "notification acceptance", |event| {
matches!(event, Event::NotificationAccepted { notification_request_id }
if notification_request_id == NOTICE)
@@ -169,6 +182,13 @@ mod restore_candidate {
}
fn with_seed(no_model: bool) -> Self {
Self::with_seed_source(no_model, None)
}
fn with_seed_source(
no_model: bool,
notice_source: Option<protocol::AuthenticatedInputSource>,
) -> Self {
let root = restore_failure_tempdir();
let model = if no_model {
MockClient::new(Vec::new())
@@ -339,7 +359,7 @@ mod restore_candidate {
disposition: protocol::SubmissionDisposition::Queued, ..
}));
receipts.push(serde_json::to_value(receipt).unwrap());
if id == FIRST { notify(&handle).await; }
if id == FIRST { notify_from_source(&handle, notice_source.clone()).await; }
}
assert_eq!(pending(&handle).await.submissions.len(), 2);
shutdown_profile_controller(seed.0.take().unwrap()).await;
@@ -1307,6 +1327,104 @@ mod restore_candidate {
}
}
#[test]
#[serial_test::serial(worker_allocation)]
fn notify_source_and_lost_ack_receipt_survive_stopped_delivery_and_explicit_restore() {
let source = protocol::AuthenticatedInputSource::Account {
account_id: "saved-chat-account".into(),
};
let fixture = Fixture::with_seed_source(false, Some(source.clone()));
let mut retry = WorkerInput::notify(NOTICE);
retry.source = Some(source.clone());
retry.submission_request_id = Some(NOTICE.into());
let before = fixture.canonical_files();
// A direct notify is not a stopped-Worker mailbox or implicit Restore.
assert!(
!fixture
.backend
.dispatch_input(&fixture.request.worker_ref, retry.clone())
.is_accepted()
);
assert!(fixture.backend.workers.lock().unwrap().is_empty());
assert_eq!(fixture.canonical_files(), before);
let checkpoint = queue_checkpoint(&fixture.entries());
assert_eq!(
checkpoint["notification_receipts"][0]["source_namespace"],
source.namespace()
);
fixture.prepare();
fixture
.backend
.activate_restored_worker(fixture.request.operation_id, &fixture.request.worker_ref)
.unwrap();
let handle = fixture.backend.workers.lock().unwrap()[&fixture.request.worker_ref]
.handle
.clone();
fixture
.backend
.run_on_adapter_runtime(async move {
finished(&handle, SECOND).await;
Ok(())
})
.unwrap();
// The seed Controller accepted durably before shutdown; its caller lost
// that receipt. Replay through the concrete adapter must not append or run.
let committed = fixture.canonical_files();
let calls = fixture.model.call_count.load(Ordering::SeqCst);
let accepted = fixture
.backend
.dispatch_input(&fixture.request.worker_ref, retry.clone());
assert!(accepted.is_accepted(), "{accepted:?}");
assert_eq!(
accepted.notification.unwrap().notification_request_id,
NOTICE
);
let mut conflict = retry;
conflict.content = "different-body".into();
let rejected = fixture
.backend
.dispatch_input(&fixture.request.worker_ref, conflict);
assert_eq!(
rejected.outcome,
crate::execution::WorkerExecutionOutcome::Rejected
);
assert_eq!(fixture.canonical_files(), committed);
assert_eq!(fixture.model.call_count.load(Ordering::SeqCst), calls);
let history = session_store::collect_state(&fixture.entries()).annotated_history;
let notices = history
.iter()
.filter(|entry| match &entry.item {
LoggedItem::Message { content, .. } => content.iter().any(|part| {
matches!(part,
LoggedContentPart::Text { text } if text.contains(NOTICE))
}),
_ => false,
})
.collect::<Vec<_>>();
assert_eq!(notices.len(), 1);
assert_eq!(
notices[0].metadata.origin,
LoggedSessionHistoryOrigin::HumanInput {
account_id: "saved-chat-account".into()
}
);
let requests = fixture.model.captured.lock().unwrap();
assert_eq!(
serde_json::to_string(&requests[0].items)
.unwrap()
.matches(NOTICE)
.count(),
1
);
assert_eq!(
serde_json::to_string(&requests[2].items)
.unwrap()
.matches(NOTICE)
.count(),
1
);
}
#[test]
#[serial_test::serial(worker_allocation)]
fn activate_saved_inputs_drains_fifo_once_and_replays_durable_receipts() {
@@ -789,6 +789,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
client.subjektiv_record_session(workspace_id, request).await,
)
}
WorkspaceServerOperation::ChatWorker(request) => {
generated_workspace_response(
client.chat_worker_operation(workspace_id, request).await,
)
}
WorkspaceServerOperation::SubjektivMemory(request) => {
generated_workspace_response(
client.subjektiv_memory_backend(workspace_id, request).await,
@@ -1144,6 +1149,72 @@ mod tests {
}
}
#[cfg(feature = "http-server")]
#[test]
fn generated_chat_worker_transport_preserves_request_and_signed_worker_source() {
use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source};
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let request = server_api::ChatWorkerRequest {
operation: server_api::ChatWorkerOperation::Send {
request: server_api::ChatSendMessageRequest {
conversation_id: "conversation-a".into(),
operation_id: "send-a".into(),
text: "explicit reply\ncontinued".into(),
reply_to: Some("message-a".into()),
work_ref: Some("work-a".into()),
expected_last_user_message_id: Some("user-message-1".into()),
},
},
};
let reply = serde_json::json!({"kind":"sent","message":{
"id":"message-b","conversation_id":"conversation-a","sequence":2,
"sender":{"kind":"subject","subject_id":"subject-a"},
"text":"explicit reply\ncontinued","reply_to":"message-a","work_ref":"work-a",
"operation_id":"send-a","last_user_message_id":"user-message-1"
}})
.to_string();
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", reply.len(), reply).into_bytes();
let (url, server) = binary_loopback(response);
let client = RuntimeOwnedWorkspaceClient::new("workspace-a", url, "runtime-a", "worker-a")
.with_runtime_request_source(&identity, "server-a");
let result = client
.execute_server_operation(WorkspaceServerOperation::ChatWorker(request.clone()))
.unwrap();
assert_eq!(result.status, 200);
let (head, body) = server.join().unwrap();
let path = "/api/w/workspace-a/subjektiv/chat/worker";
assert!(head.starts_with(&format!("POST {path} HTTP/1.1\r\n")));
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
serde_json::to_value(request).unwrap()
);
let proof = head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
.then(|| value.trim())
})
.unwrap();
let digest = request_body_digest(&body);
verify_runtime_request_source(
proof,
&identity.public_key,
&RuntimeRequestSourceExpectation {
identity_id: "runtime-a",
audience: "server-a",
workspace_id: "workspace-a",
worker_id: Some("worker-a"),
permission: WORKSPACE_REQUEST_PERMISSION,
method: "POST",
path,
body_digest: &digest,
now_unix: unix_now_seconds() as i64,
},
)
.unwrap();
}
#[cfg(feature = "http-server")]
#[test]
fn binary_roundtrip_signs_exact_query_method_worker_and_raw_body() {
+24 -1
View File
@@ -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,
+1
View File
@@ -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);
}
+48
View File
@@ -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 {
+1
View File
@@ -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 {
+59
View File
@@ -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>,
+123
View File
@@ -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,
+13
View File
@@ -617,6 +617,7 @@ fn initial_worker_input(segments: &[Segment]) -> Option<EmbeddedWorkerInput> {
Some(EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::User,
content: Segment::flatten_to_text(segments),
source: None,
submission_request_id: None,
segments: Some(segments.to_vec()),
})
@@ -749,6 +750,9 @@ pub struct WorkerInputRequest {
#[serde(default = "default_worker_input_kind")]
pub kind: WorkerInputKind,
pub content: String,
/// Provenance stamped by the authenticated Backend, never by public/model input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<protocol::AuthenticatedInputSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -3696,6 +3700,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
source: request.source,
submission_request_id: request.submission_request_id,
segments: request.segments,
};
@@ -5915,6 +5920,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
source: request.source,
submission_request_id: request.submission_request_id,
segments: request.segments,
};
@@ -7438,6 +7444,9 @@ mod tests {
let input = EmbeddedWorkerInput {
kind,
content: "contract test".to_string(),
source: Some(protocol::AuthenticatedInputSource::Account {
account_id: "contract-account".into(),
}),
submission_request_id: Some("contract-request".to_string()),
segments: Some(vec![protocol::Segment::text("contract test")]),
};
@@ -7466,6 +7475,7 @@ mod tests {
let input = EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::User,
content: protocol::Segment::flatten_to_text(&segments),
source: None,
submission_request_id: Some("submit-request".into()),
segments: Some(segments),
};
@@ -8295,6 +8305,7 @@ mod tests {
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "hello".to_string(),
source: None,
submission_request_id: Some("tracked-input-1".to_string()),
segments: None,
},
@@ -8396,6 +8407,7 @@ mod tests {
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "hello embedded runtime".to_string(),
source: None,
submission_request_id: None,
segments: None,
},
@@ -8845,6 +8857,7 @@ mod tests {
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "hello remote".to_string(),
source: None,
submission_request_id: None,
segments: None,
},
@@ -261,6 +261,7 @@ pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
let result = api.runtime.send_input(
&input.worker,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::Notify,
content,
submission_request_id: Some(input.request_id.clone()),
@@ -596,6 +597,7 @@ pub(super) fn deliver_job_result(api: &WorkspaceApi, acceptance: &BackendJobResu
let result = api.runtime.send_input(
&target,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::Notify,
content,
submission_request_id: Some(source.delivery_id.clone()),
+117 -11
View File
@@ -26,6 +26,7 @@
use subjektiv::api::*;
mod backend_job_profile;
mod chat;
mod external_operation_audit;
mod subject_authorization;
mod subjektiv_deletion;
@@ -4959,6 +4960,7 @@ impl WorkspaceApi {
let submitted = match self.runtime.send_input(
&worker.worker,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: worker_input,
submission_request_id: Some(runtime_run_id.clone()),
@@ -7668,6 +7670,14 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::subjektiv_record_session(
service.clone(),
))
.merge(server_api::server_api_axum::chat_create_conversation(
service.clone(),
))
.merge(server_api::server_api_axum::chat_messages(service.clone()))
.merge(server_api::server_api_axum::chat_receive(service.clone()))
.merge(server_api::server_api_axum::chat_worker_operation(
service.clone(),
))
.merge(server_api::server_api_axum::subjektiv_memory_backend(
service.clone(),
))
@@ -8243,10 +8253,25 @@ fn worker_input_request_from_api(
)));
}
};
if let Some(id) = request.submission_request_id.as_ref() {
if kind != WorkerInputKind::Notify
|| id.is_empty()
|| id.len() > 256
|| id.trim() != id
|| id.chars().any(char::is_control)
{
return Err(Error::InvalidInput(
"submission_request_id requires Notify and a nonempty bounded identifier".into(),
));
}
}
Ok(WorkerInputRequest {
kind,
content: request.content,
submission_request_id: None,
submission_request_id: request
.submission_request_id
.map(|id| format!("public-notify:{id}")),
source: None,
segments: request.segments,
})
}
@@ -9862,20 +9887,28 @@ impl server_api::ServerApi for ServerApiContractService {
let api = self.workspace_api()?.clone();
let actor = subject_authorization::administration_actor(&context)
.map_err(ApiError::into_repository_api_error)?;
require_workspace_owner(
&api,
&workspace_id,
actor,
"Subject permission administration",
)
.await
.map_err(ApiError::into_repository_api_error)?;
validate_workspace_scope(&api, &workspace_id)
.map_err(ApiError::into_repository_api_error)?;
let workspace = api
.store
.get_workspace(&workspace_id)
.await
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?
.ok_or_else(|| {
ApiError::from(Error::WorkspaceIdMismatch).into_repository_api_error()
})?;
let can_manage = workspace.owner_account_id == actor.account_id;
api.store
.db_executor()
.run(move || -> ApiResult<_> {
Ok(open_subjektiv_store(&api)?
.subject_permissions(&subject_id)
.map(|permissions| server_api::SubjectPermissionsResponse { permissions })
.map(|permissions| server_api::SubjectPermissionsResponse {
permissions,
can_manage,
operations: server_api::authorization::subject_permission_operations(),
})
.map_err(subjektiv_store_error)?)
})
.await
@@ -9916,7 +9949,7 @@ impl server_api::ServerApi for ServerApiContractService {
let was_current = before.as_ref().map(|policy| &policy.change_id)
== request.expected_change_id.as_ref();
let permissions = store
.set_subject_permissions(&mutation_subject, &request)
.set_managed_subject_permissions(&mutation_subject, &request)
.map_err(subjektiv_store_error)?;
let changed = was_current
&& before.as_ref().map(|source| &source.change_id)
@@ -10153,6 +10186,71 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error)
}
async fn chat_create_conversation(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
request: server_api::ChatCreateConversationRequest,
) -> std::result::Result<server_api::ChatConversation, server_api::RepositoryApiError> {
chat::create(
self.workspace_api()?.clone(),
context,
workspace_id,
request,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn chat_messages(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
conversation_id: String,
query: server_api::ChatMessagePageQuery,
) -> std::result::Result<server_api::ChatMessagePage, server_api::RepositoryApiError> {
chat::messages(
self.workspace_api()?.clone(),
context,
workspace_id,
conversation_id,
query,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn chat_receive(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
conversation_id: String,
request: server_api::ChatUserMessageRequest,
) -> std::result::Result<server_api::ChatReceiveResponse, server_api::RepositoryApiError> {
chat::receive(
self.workspace_api()?.clone(),
context,
workspace_id,
conversation_id,
request,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn chat_worker_operation(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
request: server_api::ChatWorkerRequest,
) -> std::result::Result<server_api::ChatWorkerResponse, server_api::RepositoryApiError> {
chat::worker_operation(
self.workspace_api()?.clone(),
context,
workspace_id,
request,
)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_memory_backend(
&self,
context: server_api::ServerRequestContext,
@@ -23017,6 +23115,7 @@ fn send_orchestrator_attention(
let result = api.runtime.send_input(
worker,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::Notify,
content,
submission_request_id: Some(delivery.request_id.clone()),
@@ -25889,6 +25988,7 @@ fn try_reuse_memory_consolidation_worker(
.send_input(
&worker.worker,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: input_content.to_string(),
submission_request_id: None,
@@ -35319,6 +35419,7 @@ pub(crate) fn authorize_browser_worker_method(
notification_request_id,
message,
} => Ok(protocol::Method::NotifyTracked {
receipt_scope: protocol::NotificationReceiptScope::BrowserConsole,
notification_request_id,
message,
source: source.clone(),
@@ -45972,6 +46073,7 @@ mod tests {
worker: RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id),
};
let request = |kind: &str| server_api::RuntimeWorkerInputRequest {
submission_request_id: None,
kind: Some(kind.to_string()),
content: "follow-up".to_string(),
segments: None,
@@ -48000,6 +48102,7 @@ mod tests {
.send_input(
&worker_ref,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "Commit one subject-scoped Session".to_string(),
submission_request_id: None,
@@ -51996,6 +52099,7 @@ mod tests {
State(api.clone()),
AxumPath((worker.runtime_id.clone(), worker.worker_id.clone())),
Json(server_api::RuntimeWorkerInputRequest {
submission_request_id: None,
kind: Some("user".to_string()),
content: "attempt to rerun immutable Job input".to_string(),
segments: None,
@@ -68076,6 +68180,7 @@ mod tests {
.send_input(
&worker_ref,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "persist me".to_string(),
submission_request_id: None,
@@ -68162,6 +68267,7 @@ mod tests {
.send_input(
&worker_ref,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "reject input without live Worker".to_string(),
submission_request_id: None,
+272
View File
@@ -0,0 +1,272 @@
//! Public messages and Runtime notification receipts have separate owners.
//!
//! A receive first commits the account-bound message, then makes one ordinary Notify
//! attempt against the existing Subject singleton. No scheduler, mailbox, implicit
//! restore or replacement body is created. Explicit same-operation retry recovers the
//! saved message and reuses its Notify ID; receipts have the existing bounded retention.
//! Subject publication holds the existing lifecycle gate and rechecks live ownership
//! and operation permission before the FeatureStorage observed-user-message transaction.
use super::*;
use server_api::*;
fn domain(error: crate::subjektiv::chat::ChatError) -> ApiError {
use crate::subjektiv::chat::ChatError;
match error {
ChatError::AuthorizationChanged => Error::WorkspacePermissionDenied(error.to_string()),
ChatError::ConversationNotFound => Error::WorkspacePermissionDenied(error.to_string()),
ChatError::OperationConflict(_) | ChatError::UserInputChanged { .. } => {
Error::RepositoryConflict(error.to_string())
}
ChatError::Storage(_) => Error::Store(error.to_string()),
ChatError::Subject(error) => return subjektiv_store_error(error).into(),
ChatError::InvalidInput(_) => Error::InvalidInput(error.to_string()),
}
.into()
}
async fn account_context(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
) -> ApiResult<(String, WorkerOperationContext)> {
if context.runtime_source.is_some() || context.worker_source.is_some() {
return Err(Error::WorkspacePermissionDenied(
"chat user input requires an authenticated Account, not a Worker".into(),
)
.into());
}
let actor = context.actor.as_ref().ok_or_else(|| {
Error::WorkspacePermissionDenied("chat requires an authenticated Account".into())
})?;
require_workspace_owner(api, workspace, actor, "chat account access").await?;
let authority = WorkerOperationContext::from_request_async(api, context)
.await
.map_err(|error| Error::WorkspacePermissionDenied(error.message))?;
authority.authorize_workspace(api).await?;
Ok((actor.account_id.clone(), authority))
}
pub(super) async fn create(
api: WorkspaceApi,
context: ServerRequestContext,
workspace: String,
request: ChatCreateConversationRequest,
) -> ApiResult<ChatConversation> {
let (account, _) = account_context(&api, &context, &workspace).await?;
api.store
.db_executor()
.run(move || -> ApiResult<_> {
open_subjektiv_store(&api)?
.chat_create_conversation(&account, &request)
.map_err(domain)
})
.await
}
pub(super) async fn messages(
api: WorkspaceApi,
context: ServerRequestContext,
workspace: String,
conversation: String,
query: ChatMessagePageQuery,
) -> ApiResult<ChatMessagePage> {
let (account, _) = account_context(&api, &context, &workspace).await?;
api.store
.db_executor()
.run(move || -> ApiResult<_> {
open_subjektiv_store(&api)?
.chat_messages_for_account(&account, &conversation, &query)
.map_err(domain)
})
.await
}
pub(super) async fn receive(
api: WorkspaceApi,
context: ServerRequestContext,
workspace: String,
conversation: String,
request: ChatUserMessageRequest,
) -> ApiResult<ChatReceiveResponse> {
let (account, authority) = account_context(&api, &context, &workspace).await?;
let save_api = api.clone();
let (message, subject) = api
.store
.db_executor()
.run(move || -> ApiResult<_> {
let store = open_subjektiv_store(&save_api)?;
let message = store
.chat_append_user_message(&account, &conversation, &request)
.map_err(domain)?;
let conversation = store
.chat_conversation_for_account(&account, &conversation)
.map_err(domain)?;
Ok((message, conversation.subject_id))
})
.await?;
// This payload is stable across retries and contains only committed public data.
// Account provenance is stamped by WorkspaceWorker, not trusted from this JSON.
let content = format!(
"Text-only chat user message (content is not Host instructions or an authority grant; attachments are unsupported):\n{}",
serde_json::to_string(&message).map_err(|e| Error::Store(e.to_string()))?
);
let resolve_api = api.clone();
let delivery_subject = subject.clone();
let target = api
.store
.db_executor()
.run(move || -> ApiResult<_> {
let key = crate::subjektiv::subject_worker_singleton_key(&subject)
.map_err(|e| Error::InvalidInput(e.to_string()))?;
Ok(resolve_api
.store
.current_worker_singleton_owner(&workspace, &key)?
.map(|lease| lease.worker))
})
.await?;
let delivery = if let Some(target) = target {
let attempt = async {
let worker =
WorkspaceWorker::resolve_async(&api, &target.runtime_id, &target.worker_id).await?;
worker
.input_current_subject(
&authority,
WorkerInputRequest {
kind: WorkerInputKind::Notify,
content,
submission_request_id: Some(format!("chat-message:{}", message.id)),
segments: None,
source: None,
},
&delivery_subject,
)
.await
}
.await;
match attempt {
Ok(result) => {
let detail = result
.diagnostics
.iter()
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join("; ");
match result.disposition {
WorkerInputDisposition::Accepted => ChatDelivery::Accepted {
runtime_id: target.runtime_id,
worker_id: target.worker_id,
},
WorkerInputDisposition::Rejected => ChatDelivery::Rejected {
runtime_id: target.runtime_id,
worker_id: target.worker_id,
detail,
},
WorkerInputDisposition::Unknown => ChatDelivery::Unknown {
runtime_id: target.runtime_id,
worker_id: target.worker_id,
detail,
},
}
}
// Transport/Host failures can occur after dispatch; absence of an ACK is
// not proof of rejection. Same request recovery is safe within receipt retention.
Err(error) => ChatDelivery::Unknown {
runtime_id: target.runtime_id,
worker_id: target.worker_id,
detail: error.error.to_string(),
},
}
} else {
ChatDelivery::NoCurrentBody
};
Ok(ChatReceiveResponse { message, delivery })
}
pub(super) async fn worker_operation(
api: WorkspaceApi,
context: ServerRequestContext,
workspace: String,
request: ChatWorkerRequest,
) -> ApiResult<ChatWorkerResponse> {
api.store
.db_executor()
.run(move || -> ApiResult<_> {
validate_workspace_scope(&api, &workspace)?;
let initial = subjektiv_worker_scope_from_store(&api, &workspace, &context)?;
if !matches!(initial.authority, SubjektivStoredScopeAuthority::Subject) {
return Err(Error::WorkspacePermissionDenied(
"chat publication and reads require the current Subject body".into(),
)
.into());
}
let gate = subjektiv_journal::lifecycle_lock(&api, &initial.subject_id)?;
let _gate = gate
.lock()
.map_err(|_| Error::Store("Subject lifecycle lock poisoned".into()))?;
let scope = subjektiv_worker_scope_from_store(&api, &workspace, &context)?;
if scope.subject_id != initial.subject_id
|| !matches!(scope.authority, SubjektivStoredScopeAuthority::Subject)
{
return Err(Error::WorkspacePermissionDenied(
"chat Subject body binding changed while waiting for admission".into(),
)
.into());
}
let operation = match &request.operation {
ChatWorkerOperation::Read { .. } => "chat.read",
ChatWorkerOperation::Send { .. } => "chat.send",
};
let store = open_subjektiv_store(&api)?;
// Observe before authorization, not after it: otherwise a revoked policy
// could replace the policy whose permission was just checked.
let policy = store
.subject_permissions(&scope.subject_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| {
Error::WorkspacePermissionDenied("Subject permissions are unset".into())
})?;
authorize_subjektiv_operation(
&api,
&scope.worker,
&workspace,
&scope.subject_id,
operation,
)?;
let lease = api
.store
.require_current_worker_singleton_owner(&workspace, &scope.worker)?
.ok_or_else(|| {
Error::WorkspacePermissionDenied("Subject body ownership is absent".into())
})?;
api.config_store.with_current_subject_body(
&workspace,
&scope.subject_id,
&lease,
|| {
Ok(match request.operation {
ChatWorkerOperation::Read {
conversation_id,
query,
} => store
.chat_messages_for_subject_authorized(
&scope.subject_id,
&conversation_id,
&query,
&policy.change_id,
)
.map(|page| ChatWorkerResponse::Messages { page })
.map_err(domain),
ChatWorkerOperation::Send { request } => store
.chat_send_message_authorized(
&scope.subject_id,
&request,
&policy.change_id,
)
.map(|message| ChatWorkerResponse::Sent { message })
.map_err(domain),
})
},
)?
})
.await
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -410,6 +410,7 @@ async fn restored_runtime_parent_lists_and_inputs_existing_child() {
.send_input(
&parent,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::UserIfIdle,
content: "parent bootstrap".into(),
submission_request_id: None,
@@ -5,8 +5,14 @@
//! runs a real ProfileRuntimeWorkerFactory/Controller and materialized Workdir;
//! only its external model stream is scripted. Neither fixture claims OS isolation.
use super::*;
#[path = "chat_delegation_connected_tests.rs"]
mod chat_delegation_connected;
#[path = "chat_tests.rs"]
mod chat_tests;
#[path = "subject_job_model_supply_tests.rs"]
mod subject_job_model_supply;
#[path = "subject_permission_management_tests.rs"]
mod subject_permission_management;
#[path = "ticket_worker_messaging_connected_tests.rs"]
mod ticket_worker_messaging_connected;
#[path = "worker_messaging_connected_tests.rs"]
@@ -48,6 +54,14 @@ fn create_permission() -> ScopedPermission {
},
}
}
fn query_permission() -> ScopedPermission {
ScopedPermission {
operation: "ticket.query".into(),
resource: PermissionResource::Workspace {
workspace_id: TEST_WORKSPACE_ID.into(),
},
}
}
fn authorization_path() -> String {
format!("/api/w/{TEST_WORKSPACE_ID}/authorization/check")
}
@@ -185,6 +199,27 @@ impl Fixture {
self.subject
)
}
// Seed only intentionally unsupported saved input, never a management save.
// This models pre-catalog policies without teaching the HTTP validator to issue them.
fn seed_saved_policy(
&self,
execution: Vec<ScopedPermission>,
delegation: Vec<ScopedPermission>,
) -> SubjectPermissions {
let store = open_subjektiv_store(&self.api).unwrap();
let previous = store.subject_permissions(&self.subject).unwrap();
store
.set_subject_permissions(
&self.subject,
&SubjectPermissionsUpdate {
operation_id: Uuid::now_v7().to_string(),
expected_change_id: previous.map(|policy| policy.change_id),
execution,
delegation,
},
)
.unwrap()
}
async fn assign(
&self,
execution: Vec<ScopedPermission>,
@@ -418,6 +453,7 @@ async fn signed_current_body_uses_explicit_policy_and_old_unset_body_denies() {
let (status, body) = f.check(&f.source, &permission).await;
assert_eq!(status, StatusCode::FORBIDDEN, "unset policy: {body}");
}
f.seed_saved_policy(vec![activation("ticket")], vec![]);
f.assign(vec![activation("ticket"), create_permission()], vec![])
.await;
for permission in [activation("ticket"), create_permission()] {
@@ -603,7 +639,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
},
})
.collect();
f.assign(workspace_grants.clone(), vec![]).await;
f.seed_saved_policy(workspace_grants.clone(), vec![]);
for permission in &requested {
let (status, body) = f.check(&f.source, permission).await;
assert_eq!(
@@ -618,8 +654,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
.iter()
.map(|permission| permission.operation.split('.').next().unwrap())
.collect();
f.assign(feature_names.into_iter().map(activation).collect(), vec![])
.await;
f.seed_saved_policy(feature_names.into_iter().map(activation).collect(), vec![]);
for permission in &requested {
assert_eq!(
f.check(&f.source, permission).await.0,
@@ -627,7 +662,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
"{permission:?}"
);
}
f.assign(requested.clone(), vec![]).await;
f.seed_saved_policy(requested.clone(), vec![]);
for permission in &requested {
let (status, body) = f.check(&f.source, permission).await;
assert_eq!(
@@ -653,7 +688,7 @@ async fn signed_host_never_expands_saved_workspace_or_feature_grants_to_new_reso
async fn signed_host_rejects_cross_workspace_resource_claims_before_alias_resolution() {
let f = Fixture::new().await;
let permissions = concrete_resource_permissions(&f);
f.assign(permissions.clone(), vec![]).await;
f.seed_saved_policy(permissions.clone(), vec![]);
for permission in permissions
.iter()
.filter(|permission| permission.resource.workspace_id().is_some())
@@ -802,8 +837,8 @@ async fn signed_host_ungranted_operation_does_not_disclose_ticket_or_subject_exi
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn authorization_rejects_missing_source_unknown_identity_fields_and_tampered_signed_body() {
let f = Fixture::new().await;
f.assign(vec![activation("ticket")], vec![]).await;
let body = serde_json::to_value(activation("ticket")).unwrap();
f.assign(vec![create_permission()], vec![]).await;
let body = serde_json::to_value(create_permission()).unwrap();
let (status, error) = response(
&f.api,
human_request(&f.owner, "POST", &authorization_path(), Some(body.clone())),
@@ -901,7 +936,7 @@ async fn authorization_rejects_missing_source_unknown_identity_fields_and_tamper
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permissions() {
async fn only_owner_account_can_assign_shrink_and_revoke_subject_permissions() {
let f = Fixture::new().await;
let (status, body) = response(
&f.api,
@@ -912,8 +947,8 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
assert!(body["permissions"].is_null());
let original = f
.assign(
vec![activation("ticket"), create_permission()],
vec![activation("ticket")],
vec![create_permission(), query_permission()],
vec![create_permission()],
)
.await;
let (status, body) = response(
@@ -965,9 +1000,20 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"foreign account {method}: {body}"
if method == "GET" {
StatusCode::OK
} else {
StatusCode::FORBIDDEN
},
"non-owner account {method}: {body}"
);
if method == "GET" {
assert_eq!(body["can_manage"], false);
assert_eq!(
body["permissions"],
serde_json::to_value(&original).unwrap()
);
}
}
assert_eq!(
open_subjektiv_store(&f.api)
@@ -976,7 +1022,7 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
.unwrap(),
Some(original.clone())
);
let shrunk = f.assign(vec![activation("ticket")], vec![]).await;
let shrunk = f.assign(vec![query_permission()], vec![]).await;
assert_ne!(shrunk.change_id, original.change_id);
assert_eq!(
f.check(&f.source, &create_permission()).await.0,
@@ -995,7 +1041,7 @@ async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permission
let revoked = f.assign(vec![], vec![]).await;
assert!(revoked.execution.is_empty() && revoked.delegation.is_empty());
assert_eq!(
f.check(&f.source, &activation("ticket")).await.0,
f.check(&f.source, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
}
@@ -1146,6 +1192,7 @@ async fn ticket_registration_is_local_and_only_create_permission_allows_backend_
("old-feature-only", vec![activation("ticket")]),
("wrong-resource", vec![wrong_workspace]),
] {
f.seed_saved_policy(permissions.clone(), vec![]);
f.assign(permissions, vec![]).await;
client.requests.lock().unwrap().clear();
let (installed, tool) = ticket_tool_host(&f._dir.path().join(name), client.clone()).await;
@@ -1188,7 +1235,7 @@ async fn ticket_registration_is_local_and_only_create_permission_allows_backend_
1,
"genuine SQLite Ticket backend result: {created}"
);
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
let result = tool
.execute(
r#"{"title":"revoked after install"}"#,
@@ -1528,7 +1575,7 @@ fn creation_record_counts(f: &Fixture) -> [i64; 3] {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn generic_create_routes_refuse_body_and_child_delegation_before_any_creation_effect() {
let f = Fixture::new().await;
let permissions = vec![activation("ticket"), create_permission()];
let permissions = vec![query_permission(), create_permission()];
f.assign(vec![], permissions.clone()).await;
let mut child_request = f.child_request("redelegating-editor", permissions.clone());
child_request["execution_grant"]["redelegation"] = json!(permissions);
@@ -1645,7 +1692,7 @@ async fn generic_create_routes_preserve_human_and_non_subject_worker_creation()
async fn subject_control_creation_recovers_control_grant_failure_and_lost_response_without_duplicates()
{
let f = Fixture::new().await;
let permissions = vec![activation("ticket"), create_permission()];
let permissions = vec![query_permission(), create_permission()];
f.assign(vec![], permissions.clone()).await;
let mut missing_id = f.child_request("control-grant-recovery", permissions.clone());
missing_id
@@ -1842,7 +1889,7 @@ async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_o
let (status, body) = f
.spawn(
&child,
f.child_request("undelegatable-grandchild", vec![activation("ticket")]),
f.child_request("undelegatable-grandchild", vec![query_permission()]),
)
.await;
assert_eq!(
@@ -1855,12 +1902,12 @@ async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_o
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_runtime_spawn() {
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket")]).await;
let mut singleton = f.child_request("singleton-escalation", vec![activation("ticket")]);
f.assign(vec![], vec![query_permission()]).await;
let mut singleton = f.child_request("singleton-escalation", vec![query_permission()]);
singleton["singleton_key"] = json!("model-chosen-singleton");
let mut body_attachment = f.child_request("subject-escalation", vec![activation("ticket")]);
let mut body_attachment = f.child_request("subject-escalation", vec![query_permission()]);
body_attachment["feature_connections"] = json!({"subjektiv":{"subject_id":f.subject}});
let mut spoof = f.child_request("origin-escalation", vec![activation("ticket")]);
let mut spoof = f.child_request("origin-escalation", vec![query_permission()]);
spoof["execution_grant"]["subject_id"] = json!(f.subject);
for request in [
singleton,
@@ -1882,12 +1929,9 @@ async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_
async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_falls_back_to_legacy()
{
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket")]).await;
let child = f.child("revocable-child", vec![activation("ticket")]).await;
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
StatusCode::OK
);
f.assign(vec![], vec![query_permission()]).await;
let child = f.child("revocable-child", vec![query_permission()]).await;
assert_eq!(f.check(&child, &query_permission()).await.0, StatusCode::OK);
let before = open_subjektiv_store(&f.api)
.unwrap()
.worker_execution_grant(&child.runtime_id, &child.worker_id)
@@ -1895,7 +1939,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
.unwrap();
f.assign(vec![], vec![]).await;
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
f.check(&child, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
let observation = f
@@ -1913,7 +1957,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
Some(json!({"expected_observation_token":observation,"request_id":"restore-revoked-child"})))).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
f.check(&child, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
assert_eq!(
@@ -1925,33 +1969,33 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
);
// Regranting identical permissions changes the assignment identity. Old
// grants remain revoked rather than refreshing during restore/reconnect.
f.assign(vec![], vec![activation("ticket")]).await;
f.assign(vec![], vec![query_permission()]).await;
assert_eq!(
f.check(&child, &activation("ticket")).await.0,
f.check(&child, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
let current = f
.child("current-before-origin-release", vec![activation("ticket")])
.child("current-before-origin-release", vec![query_permission()])
.await;
assert_eq!(
f.check(&current, &activation("ticket")).await.0,
f.check(&current, &query_permission()).await.0,
StatusCode::OK
);
f.release_source_lease();
assert_eq!(
f.check(&current, &activation("ticket")).await.0,
f.check(&current, &query_permission()).await.0,
StatusCode::CONFLICT,
"the captured source lease is no longer current"
);
assert_eq!(
f.check(&f.source, &activation("ticket")).await.0,
f.check(&f.source, &query_permission()).await.0,
StatusCode::UNAUTHORIZED,
"released body fails signed-source authentication instead of falling through to legacy compatibility"
);
let (status, _) = f
.spawn(
&f.source,
f.child_request("released-origin-spawn", vec![activation("ticket")]),
f.child_request("released-origin-spawn", vec![query_permission()]),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
@@ -1960,7 +2004,7 @@ async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_backend() {
let f = Fixture::new().await;
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
let client = f.client(&f.source);
let (mut host, installed) =
ticket_worker_host(&f._dir.path().join("wip-host"), client.clone()).await;
@@ -2027,7 +2071,7 @@ async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_bac
let backend = browser_ticket_backend(&f.api).unwrap();
let tickets = backend.list(Default::default()).unwrap();
assert_eq!(tickets.len(), 1);
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
assert!(
invoke
.execute(&args, ToolExecutionContext::direct())
@@ -2047,7 +2091,7 @@ async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_
{
let f = Fixture::new().await;
let update = json!({"operation_id":"policy-interruption-once","expected_change_id":null,
"execution":[activation("ticket")],"delegation":[]});
"execution":[create_permission()],"delegation":[]});
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.policy_path(), Some(update.clone())),
@@ -2101,13 +2145,13 @@ async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling() {
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket"), create_permission()])
f.assign(vec![], vec![query_permission(), create_permission()])
.await;
let mut request = f.child_request(
"redelegating-parent",
vec![activation("ticket"), create_permission()],
vec![query_permission(), create_permission()],
);
request["execution_grant"]["redelegation"] = json!([activation("ticket")]);
request["execution_grant"]["redelegation"] = json!([query_permission()]);
let (status, body) = f.spawn(&f.source, request).await;
assert_eq!(status, StatusCode::OK, "{body}");
let parent = RuntimeWorkerRef::new(
@@ -2117,7 +2161,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
let (status, body) = f
.spawn(
&parent,
f.child_request("allowed-grandchild", vec![activation("ticket")]),
f.child_request("allowed-grandchild", vec![query_permission()]),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
@@ -2135,7 +2179,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
"keep immediate lineage instead of flattening to the body"
);
assert_eq!(
f.check(&grandchild, &activation("ticket")).await.0,
f.check(&grandchild, &query_permission()).await.0,
StatusCode::OK
);
assert_eq!(
@@ -2157,7 +2201,7 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
assert_eq!(f.runtime.spawn_requests().len(), calls);
f.assign(vec![], vec![]).await;
assert_eq!(
f.check(&grandchild, &activation("ticket")).await.0,
f.check(&grandchild, &query_permission()).await.0,
StatusCode::FORBIDDEN
);
}
@@ -2165,8 +2209,8 @@ async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling(
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_operation() {
let f = Fixture::new().await;
f.assign(vec![], vec![activation("ticket")]).await;
let mut request = f.child_request("profile-bound-once", vec![activation("ticket")]);
f.assign(vec![], vec![query_permission()]).await;
let mut request = f.child_request("profile-bound-once", vec![query_permission()]);
request["profile"] = json!("project:authority-editor");
let (status, body) = f.spawn(&f.source, request.clone()).await;
assert_eq!(status, StatusCode::OK, "{body}");
@@ -2202,7 +2246,7 @@ async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_oper
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source() {
let f = Fixture::new().await;
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
let client = f.client(&f.source);
let (_host, installed, service) =
ticket_worker_host_with_service(&f._dir.path().join("service-host"), client.clone()).await;
@@ -2278,7 +2322,7 @@ async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source(
"Service authorized"
);
assert_eq!(ticket_posts(&client), 1);
f.assign(vec![activation("ticket")], vec![]).await;
f.assign(vec![], vec![]).await;
assert!(
tokio::task::spawn_blocking(move || service.create(NewTicket::new("Revoked service")))
.await
@@ -2716,7 +2760,7 @@ impl ConnectedWorkerHarness {
}
async fn start<M: agen::llm_client::LlmClient + Clone + 'static>(model: M) -> Self {
Self::start_configured(model, None).await
Self::start_with_layer(model, |router| router).await
}
/// Keep the existing signed RuntimeHTTP assembly, adding only the production
@@ -2726,12 +2770,20 @@ impl ConnectedWorkerHarness {
model: M,
transport: Arc<crate::plugin_https::HttpsTransport>,
) -> Self {
Self::start_configured(model, Some(transport)).await
Self::start_configured(model, Some(transport), |router| router).await
}
async fn start_with_layer<M: agen::llm_client::LlmClient + Clone + 'static>(
model: M,
app_layer: impl FnOnce(Router) -> Router,
) -> Self {
Self::start_configured(model, None, app_layer).await
}
async fn start_configured<M: agen::llm_client::LlmClient + Clone + 'static>(
model: M,
plugin_https_transport: Option<Arc<crate::plugin_https::HttpsTransport>>,
app_layer: impl FnOnce(Router) -> Router,
) -> Self {
const RUNTIME: &str = CONNECTED_RUNTIME;
let dir = tempfile::tempdir().unwrap();
@@ -2962,7 +3014,7 @@ impl ConnectedWorkerHarness {
}
},
));
let backend_server = AuthorizationCallbackServer::start(backend_listener, app);
let backend_server = AuthorizationCallbackServer::start(backend_listener, app_layer(app));
let owner =
seed_test_workspace_owner_token(api.store.as_ref(), "connected-authorization-owner")
.await;
@@ -0,0 +1,572 @@
//! Management HTTP integration: real router/account authentication, catalog
//! validation, Subject SQLite policy/receipts and deletion; no running service or
//! model. The connected Ticket case also uses the signed Worker Host, real Tool
//! and Ticket backend via the parent suite's isolated Runtime fixture.
use super::*;
struct ManagementFixture {
api: WorkspaceApi,
owner: String,
subject: String,
_dir: tempfile::TempDir,
}
impl ManagementFixture {
async fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let owner = seed_test_workspace_owner_token(api.store.as_ref(), "permission-manager").await;
let subject = open_subjektiv_store(&api)
.unwrap()
.create_subject(crate::subjektiv::SubjectRole::new("permission-management").unwrap())
.unwrap()
.id;
Self {
api,
owner,
subject,
_dir: dir,
}
}
fn path(&self) -> String {
format!(
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions",
self.subject
)
}
async fn read(&self) -> Value {
let (status, body) = response(
&self.api,
human_request(&self.owner, "GET", &self.path(), None),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
body
}
async fn save(&self, update: Value) -> Value {
let (status, body) = response(
&self.api,
human_request(&self.owner, "POST", &self.path(), Some(update)),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
body
}
fn assert_current(&self, expected: &Value) {
let saved = open_subjektiv_store(&self.api)
.unwrap()
.subject_permissions(&self.subject)
.unwrap();
assert_eq!(serde_json::to_value(saved).unwrap(), *expected);
}
}
fn update(screen: &Value, operation: &str, execution: Value, delegation: Value) -> Value {
json!({"operation_id":operation,"expected_change_id":screen["permissions"]["change_id"],
"execution":execution,"delegation":delegation})
}
#[tokio::test]
async fn management_read_exposes_operation_use_without_duplicate_worker_target_grants() {
let f = ManagementFixture::new().await;
let screen = f.read().await;
assert!(screen["permissions"].is_null());
assert_eq!(screen["can_manage"], true);
let operations = screen["operations"].as_array().expect("management catalog");
let create = operations
.iter()
.find(|entry| entry["operation"] == "ticket.create")
.unwrap();
assert_eq!(create["availability"]["kind"], "available");
assert!(
create["resource_kinds"]
.as_array()
.unwrap()
.contains(&json!("workspace"))
);
assert!(!create["label"].as_str().unwrap().is_empty());
assert!(!create["description"].as_str().unwrap().is_empty());
let messaging = operations
.iter()
.find(|entry| entry["operation"] == "worker.messaging")
.unwrap();
assert_eq!(messaging["availability"]["kind"], "available");
for name in [
"worker.messaging",
"worker.send_input",
"worker.cancel",
"worker.stop",
"worker.restore",
"worker.remove",
"worker.observe",
] {
let operation = operations
.iter()
.find(|entry| entry["operation"] == name)
.unwrap();
assert_eq!(
operation["availability"]["kind"], "available",
"implemented Worker contracts: {operation}"
);
assert_eq!(
operation["resource_kinds"],
json!(["worker_operation"]),
"operation use must not offer duplicate target control grants"
);
}
}
#[tokio::test]
async fn two_management_screens_conflict_without_overwriting_saved_execution_or_delegation() {
let f = ManagementFixture::new().await;
let first = f.read().await;
let second = f.read().await;
let saved = f
.save(update(
&first,
"screen-one",
json!([create_permission()]),
json!([query_permission()]),
))
.await;
let stale = update(
&second,
"screen-two",
json!([]),
json!([create_permission()]),
);
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(stale)),
)
.await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert!(
body["message"].is_string(),
"conflict must be visible: {body}"
);
assert_eq!(f.read().await["permissions"], saved["permissions"]);
f.assert_current(&saved["permissions"]);
}
#[tokio::test]
async fn discarded_save_response_recovers_by_exact_replay_and_authoritative_reread() {
let f = ManagementFixture::new().await;
let input = update(
&f.read().await,
"lost-save-response",
json!([create_permission()]),
json!([query_permission()]),
);
// Commit through the real HTTP route, then discard the entire response. This
// models response loss, not process death or rollback of the committed save.
drop(
build_router(f.api.clone())
.oneshot(human_request(
&f.owner,
"POST",
&f.path(),
Some(input.clone()),
))
.await
.unwrap(),
);
let reread = f.read().await;
assert_eq!(reread["permissions"]["execution"], input["execution"]);
assert_eq!(reread["permissions"]["delegation"], input["delegation"]);
let replay = f.save(input.clone()).await;
assert_eq!(replay["permissions"], reread["permissions"]);
assert_eq!(replay["interruptions"], json!([]));
assert_eq!(f.read().await["permissions"], reread["permissions"]);
f.assert_current(&reread["permissions"]);
let mut changed = input;
changed["execution"] = json!([]);
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(changed)),
)
.await;
assert_eq!(
status,
StatusCode::CONFLICT,
"operation identity cannot be reused: {body}"
);
f.assert_current(&reread["permissions"]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn historical_save_receipt_after_shrink_and_revoke_never_restores_current_authority() {
let f = Fixture::new().await;
let (status, screen) = response(
&f.api,
human_request(&f.owner, "GET", &f.policy_path(), None),
)
.await;
assert_eq!(status, StatusCode::OK);
let original_input = update(
&screen,
"before-shrink",
json!([create_permission()]),
json!([create_permission()]),
);
let (status, original) = response(
&f.api,
human_request(
&f.owner,
"POST",
&f.policy_path(),
Some(original_input.clone()),
),
)
.await;
assert_eq!(status, StatusCode::OK, "{original}");
assert_eq!(
f.check(&f.source, &create_permission()).await.0,
StatusCode::OK
);
for delegation in [vec![create_permission()], vec![]] {
let current = f.assign(vec![], delegation).await;
let (status, replay) = response(
&f.api,
human_request(
&f.owner,
"POST",
&f.policy_path(),
Some(original_input.clone()),
),
)
.await;
assert_eq!(status, StatusCode::OK, "historical receipt: {replay}");
assert_eq!(replay["permissions"], original["permissions"]);
assert_eq!(replay["interruptions"], json!([]));
let (status, read) = response(
&f.api,
human_request(&f.owner, "GET", &f.policy_path(), None),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(read["permissions"], serde_json::to_value(&current).unwrap());
assert_ne!(read["permissions"], replay["permissions"]);
assert_eq!(
open_subjektiv_store(&f.api)
.unwrap()
.subject_permissions(&f.subject)
.unwrap(),
Some(current)
);
assert_eq!(
f.check(&f.source, &create_permission()).await.0,
StatusCode::FORBIDDEN
);
}
}
#[tokio::test]
async fn subject_deletion_rejects_stale_screen_save_and_exact_receipt_replay_without_revival() {
let f = ManagementFixture::new().await;
let screen = f.read().await;
let initial_input = update(
&screen,
"save-before-delete",
json!([create_permission()]),
json!([query_permission()]),
);
let saved = f.save(initial_input.clone()).await;
let stale_screen = json!({"permissions":saved["permissions"]});
let late_input = update(
&stale_screen,
"late-screen-save",
json!([query_permission()]),
json!([]),
);
let (status, body) = response(
&f.api,
human_request(
&f.owner,
"DELETE",
&format!(
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}",
f.subject
),
Some(json!({"operation_id":"delete-after-save"})),
),
)
.await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["completed"], true);
for input in [late_input, initial_input] {
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(input)),
)
.await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"deleted Subject cannot be revived: {body}"
);
assert!(
open_subjektiv_store(&f.api)
.unwrap()
.subject(&f.subject)
.unwrap()
.is_none()
);
}
let (status, body) = response(&f.api, human_request(&f.owner, "GET", &f.path(), None)).await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert!(
open_subjektiv_store(&f.api)
.unwrap()
.subject_permissions(&f.subject)
.is_err()
);
}
#[tokio::test]
async fn read_only_account_cannot_save_or_replay_an_owner_permission_change() {
let f = ManagementFixture::new().await;
let account = seed_test_api_token(f.api.store.as_ref(), "permission-reader");
let initial = f.read().await;
let input = update(
&initial,
"owner-only-save",
json!([create_permission()]),
json!([]),
);
let (status, read) = response(&f.api, human_request(&account, "GET", &f.path(), None)).await;
assert_eq!(status, StatusCode::OK, "{read}");
assert_eq!(read["can_manage"], false);
assert_eq!(read["permissions"], initial["permissions"]);
let (status, body) = response(
&f.api,
human_request(&account, "POST", &f.path(), Some(input.clone())),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
f.assert_current(&Value::Null);
let saved = f.save(input.clone()).await;
let (status, body) = response(
&f.api,
human_request(&account, "POST", &f.path(), Some(input)),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"exact owner receipt replay must still authorize the caller: {body}"
);
f.assert_current(&saved["permissions"]);
}
#[tokio::test]
async fn new_unknown_legacy_unavailable_and_operation_resource_mismatch_are_rejected_without_policy_write()
{
let f = ManagementFixture::new().await;
let screen = f.read().await;
let mut unknown = create_permission();
unknown.operation = "ticket.future_operation".into();
let mut mismatch = create_permission();
mismatch.resource = PermissionResource::Ticket {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: "not-a-collection".into(),
};
let mut unavailable = query_permission();
unavailable.operation = "worker.messaging".into();
for (name, permission) in [
("unknown", unknown),
("legacy", activation("ticket")),
("resource-mismatch", mismatch),
("unavailable", unavailable),
] {
for lane in ["execution", "delegation"] {
let mut input = update(
&screen,
&format!("reject-{name}-{lane}"),
json!([]),
json!([]),
);
input[lane] = json!([permission]);
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &f.path(), Some(input)),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{name}/{lane}: {body}");
assert!(body["message"].is_string(), "{body}");
f.assert_current(&Value::Null);
assert!(f.read().await["permissions"].is_null());
}
}
}
#[tokio::test]
async fn unsupported_saved_entries_can_only_be_retained_in_the_same_lane_or_removed() {
let f = ManagementFixture::new().await;
let execution = vec![activation("ticket")];
let mut unknown = query_permission();
unknown.operation = "legacy.custom_operation".into();
let delegation = vec![unknown];
open_subjektiv_store(&f.api)
.unwrap()
.set_subject_permissions(
&f.subject,
&SubjectPermissionsUpdate {
operation_id: "seed-pre-catalog-policy".into(),
expected_change_id: None,
execution: execution.clone(),
delegation: delegation.clone(),
},
)
.unwrap();
let screen = f.read().await;
let saved = f
.save(update(
&screen,
"retain-existing",
json!([execution[0], create_permission()]),
json!(delegation),
))
.await;
assert_eq!(
saved["permissions"]["execution"],
json!([execution[0], create_permission()])
);
assert_eq!(saved["permissions"]["delegation"], json!(delegation));
let screen = f.read().await;
for (name, execution, delegation) in [
(
"copy-legacy-lane",
json!([activation("ticket"), create_permission()]),
json!([activation("ticket")]),
),
(
"copy-unknown-lane",
json!([delegation[0], create_permission()]),
json!(delegation),
),
] {
let (status, body) = response(
&f.api,
human_request(
&f.owner,
"POST",
&f.path(),
Some(update(&screen, name, execution, delegation)),
),
)
.await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"retention cannot expand into another lane: {body}"
);
f.assert_current(&saved["permissions"]);
}
let removed = f
.save(update(
&screen,
"remove-unsupported",
json!([create_permission()]),
json!([]),
))
.await;
assert_eq!(
removed["permissions"]["execution"],
json!([create_permission()])
);
assert_eq!(removed["permissions"]["delegation"], json!([]));
f.assert_current(&removed["permissions"]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn management_saved_policy_controls_real_ticket_tool_and_raw_http_before_backend_writes() {
let f = Fixture::new().await;
let client = f.client(&f.source);
let (installed, tool) = ticket_tool_host(
&f._dir.path().join("management-ticket-tool"),
client.clone(),
)
.await;
assert!(installed);
let tool = tool.unwrap();
assert!(
client.requests.lock().unwrap().is_empty(),
"local registration is not authorization"
);
let backend = browser_ticket_backend(&f.api).unwrap();
for state in ["unset", "out-of-scope", "allowed", "revoked"] {
match state {
"unset" => {}
"out-of-scope" => {
// Intentionally migrated, unsupported kind: management may retain
// this exact saved entry but it must never authorize collection creation.
let mut wrong_kind = create_permission();
wrong_kind.resource = PermissionResource::Ticket {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: "legacy-ticket-scope".into(),
};
f.seed_saved_policy(vec![wrong_kind.clone()], vec![]);
f.assign(vec![wrong_kind], vec![]).await;
}
"allowed" => {
f.assign(vec![create_permission()], vec![]).await;
}
"revoked" => {
f.assign(vec![], vec![]).await;
}
_ => unreachable!(),
}
let before = backend.list(Default::default()).unwrap().len();
let posts = ticket_posts(&client);
let result = tool
.execute(
&json!({"title":format!("Managed policy {state}")}).to_string(),
ToolExecutionContext::direct(),
)
.await;
if state == "allowed" {
let output = value(result.unwrap());
assert_eq!(ticket_posts(&client), posts + 1);
let records = backend.list(Default::default()).unwrap();
assert_eq!(records.len(), before + 1, "real SQLite write: {output}");
assert!(
records
.iter()
.any(|record| record.title == "Managed policy allowed")
);
} else {
assert!(result.is_err(), "{state}: Tool must deny");
assert_eq!(
ticket_posts(&client),
posts,
"{state}: no downstream create dispatch"
);
let (status, body) = response(
&f.api,
runtime_source_request(
&f.identity,
Some(&f.source.worker_id),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets"),
serde_json::to_vec(&NewTicket::new(format!("Raw denied {state}"))).unwrap(),
),
)
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"{state}: direct Backend path also rechecks: {body}"
);
assert_eq!(
backend.list(Default::default()).unwrap().len(),
before,
"{state}: denied writes = 0"
);
}
}
}
@@ -265,6 +265,7 @@ async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_re
.input(
&WorkerOperationContext::Backend,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "Do not run while claim is unresolved".into(),
segments: None,
@@ -530,6 +531,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
.input(
&WorkerOperationContext::Backend,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "Do not submit while the manual claim is pending".into(),
segments: None,
@@ -856,6 +856,7 @@ async fn generic_ticket_worker_unknown_input_outcome_preserves_assignment_and_la
.input(
&WorkerOperationContext::Backend,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::User,
content: "Continue Ticket research".into(),
submission_request_id: Some("unknown-worker-submit".into()),
@@ -2,6 +2,7 @@ use super::*;
fn operation_input(kind: WorkerInputKind) -> WorkerInputRequest {
WorkerInputRequest {
source: None,
kind,
content: "operation-boundary-test".into(),
submission_request_id: None,
@@ -474,6 +475,7 @@ async fn generic_and_scoped_input_aliases_match_handle_idle_only_and_notify_sema
assert_eq!(notify.state, InternalWorkerOperationState::Accepted);
let request = || server_api::RuntimeWorkerInputRequest {
submission_request_id: None,
kind: Some("user".into()),
content: "human input".into(),
segments: None,
@@ -673,6 +675,7 @@ async fn generic_management_aliases_cannot_bypass_worker_grants_or_target_bound_
}))
};
let input = || server_api::RuntimeWorkerInputRequest {
submission_request_id: None,
kind: Some("user".into()),
content: "must not execute".into(),
segments: None,
@@ -48,6 +48,42 @@ pub(crate) enum WorkerOperationContext {
}
impl WorkerOperationContext {
fn bind_input(&self, request: &mut WorkerInputRequest) {
// Public adapters cannot choose provenance or consume another input path's
// receipt IDs. Bind these only after the current caller is authorized.
request.source = match self {
WorkerOperationContext::Browser { source, .. } => Some(source.clone()),
WorkerOperationContext::WorkerControl { controller } => {
Some(protocol::AuthenticatedInputSource::Worker {
runtime_id: controller.runtime_id.clone(),
worker_id: controller.worker_id.clone(),
})
}
#[cfg(test)]
WorkerOperationContext::Backend => request.source.clone(),
};
if let Some(source) = request.source.as_ref() {
if let Some(client_id) = request.submission_request_id.take() {
let path = match request.kind {
WorkerInputKind::Notify => "notify",
WorkerInputKind::User => "user",
WorkerInputKind::UserIfIdle => "user-if-idle",
WorkerInputKind::Compact => "compact",
WorkerInputKind::RegisterPeer => "register-peer",
};
// Fixed-size identity stays below the controller's request-ID ceiling
// even for long Runtime/Account IDs. Tuple framing remains unambiguous.
let canonical = serde_json::to_vec(&(source.namespace(), client_id))
.expect("input namespace contains only strings");
let digest = sha2::Sha256::digest(canonical)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
request.submission_request_id = Some(format!("runtime-input:{path}:{digest}"));
}
}
}
pub(super) async fn authorize_workspace(&self, api: &WorkspaceApi) -> ApiResult<()> {
if let Self::Browser { headers, source } = self {
let actor = resolve_actor(&ServerAuthApi::from(api), headers)
@@ -470,9 +506,28 @@ impl WorkspaceWorker {
}
pub(super) async fn input(
&self,
context: &WorkerOperationContext,
request: WorkerInputRequest,
) -> ApiResult<WorkerInputResult> {
self.input_impl(context, request, None).await
}
pub(super) async fn input_current_subject(
&self,
context: &WorkerOperationContext,
request: WorkerInputRequest,
expected_subject: &str,
) -> ApiResult<WorkerInputResult> {
self.input_impl(context, request, Some(expected_subject.to_owned()))
.await
}
async fn input_impl(
&self,
context: &WorkerOperationContext,
mut request: WorkerInputRequest,
expected_subject: Option<String>,
) -> ApiResult<WorkerInputResult> {
let permission = if request.kind == WorkerInputKind::Notify {
"notify"
@@ -488,12 +543,41 @@ impl WorkspaceWorker {
{
request.kind = WorkerInputKind::UserIfIdle;
}
context.bind_input(&mut request);
let worker = self.clone();
self.api
.host_executors
.host()?
.run(move || -> ApiResult<_> {
let (_control, _session) = (guard, session_guard);
// Re-resolve while excluding Subject replacement through Runtime
// admission. The DB read ends before Runtime I/O; a DB transaction
// fence here would deadlock reentrant Host operations.
let subject_gate = expected_subject
.as_deref()
.map(|subject| subjektiv_journal::lifecycle_lock(&worker.api, subject))
.transpose()?;
let _subject_guard = subject_gate
.as_ref()
.map(|gate| {
gate.lock()
.map_err(|_| Error::Store("Subject lifecycle lock poisoned".into()))
})
.transpose()?;
if let Some(subject) = expected_subject.as_deref() {
let key = crate::subjektiv::subject_worker_singleton_key(subject)
.map_err(|error| Error::InvalidInput(error.to_string()))?;
let owner = worker
.api
.store
.current_worker_singleton_owner(worker.api.workspace_id(), &key)?;
if !owner.is_some_and(|lease| lease.worker == worker.identity) {
return Err(Error::WorkspacePermissionDenied(
"chat input target is no longer the current Subject body".into(),
)
.into());
}
}
Ok(worker
.api
.runtime
@@ -789,3 +873,90 @@ impl WorkspaceWorkerMethodSender {
})
}
}
#[cfg(test)]
mod input_binding_tests {
use super::*;
fn notify(client_id: &str) -> WorkerInputRequest {
WorkerInputRequest {
kind: WorkerInputKind::Notify,
content: "message".into(),
source: Some(protocol::AuthenticatedInputSource::Backend {
operation_id: "forged-source".into(),
}),
submission_request_id: Some(client_id.into()),
segments: None,
}
}
#[test]
fn generic_input_binds_caller_source_and_isolates_client_ids_from_chat_and_other_paths() {
let browser = |id: &str| WorkerOperationContext::Browser {
headers: HeaderMap::new(),
source: protocol::AuthenticatedInputSource::Account {
account_id: id.into(),
},
};
let context = browser("account-a");
let raw_id = "chat-message:stored-message-uuid";
let mut request = notify(raw_id);
context.bind_input(&mut request);
assert_eq!(
request.source,
Some(protocol::AuthenticatedInputSource::Account {
account_id: "account-a".into(),
})
);
let bounded = request.submission_request_id.as_deref().unwrap();
assert!(bounded.starts_with("runtime-input:notify:"));
assert!(bounded.len() < 128);
assert_ne!(bounded, raw_id);
let mut retry = notify(raw_id);
context.bind_input(&mut retry);
assert_eq!(retry.submission_request_id, request.submission_request_id);
let mut other = notify(raw_id);
browser("account-b").bind_input(&mut other);
assert_ne!(other.submission_request_id, request.submission_request_id);
let mut submit = notify(raw_id);
submit.kind = WorkerInputKind::User;
context.bind_input(&mut submit);
assert_ne!(submit.submission_request_id, request.submission_request_id);
// JSON tuple framing keeps delimiter/quote characters unambiguous.
let mut delimiter = notify("a\",\"b");
context.bind_input(&mut delimiter);
assert_ne!(
delimiter.submission_request_id,
request.submission_request_id
);
let mut no_client_id = notify(raw_id);
no_client_id.submission_request_id = None;
context.bind_input(&mut no_client_id);
assert!(no_client_id.submission_request_id.is_none());
}
#[test]
fn worker_control_provenance_is_the_authenticated_controller_not_a_body_or_source_claim() {
let context = WorkerOperationContext::WorkerControl {
controller: RuntimeWorkerRef::new("controller-runtime", "controller-worker"),
};
let mut request = notify("same-id");
request.content = r#"{"source":{"kind":"account","account_id":"spoofed"}}"#.into();
context.bind_input(&mut request);
assert_eq!(
request.source,
Some(protocol::AuthenticatedInputSource::Worker {
runtime_id: "controller-runtime".into(),
worker_id: "controller-worker".into(),
})
);
assert!(
request
.submission_request_id
.as_deref()
.unwrap()
.starts_with("runtime-input:notify:")
);
assert!(request.submission_request_id.as_ref().unwrap().len() < 128);
}
}
@@ -1060,6 +1060,7 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
.send_input(
&worker,
WorkerInputRequest {
source: None,
kind: WorkerInputKind::Notify,
content: first.content.clone(),
submission_request_id: Some(first_id.clone()),
@@ -85,29 +85,37 @@ async fn delivery_worker(api: &WorkspaceApi, subject: Option<&str>) -> RuntimeWo
delivery_worker_profile(api, subject, "builtin:companion").await
}
async fn delivery_worker_profile(api: &WorkspaceApi, subject: Option<&str>, profile: &str) -> RuntimeWorkerRef {
async fn delivery_worker_profile(
api: &WorkspaceApi,
subject: Option<&str>,
profile: &str,
) -> RuntimeWorkerRef {
let Json(worker) = create_workspace_worker(
State(api.clone()), HeaderMap::new(), Json(delivery_create_request(subject, profile)),
).await.unwrap();
State(api.clone()),
HeaderMap::new(),
Json(delivery_create_request(subject, profile)),
)
.await
.unwrap();
RuntimeWorkerRef::new(worker.runtime_id, worker.worker_id)
}
fn delivery_create_request(subject: Option<&str>, profile: &str) -> CreateWorkspaceWorkerRequest {
CreateWorkspaceWorkerRequest {
runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.into(),
display_name: "Artifact consumer".into(),
singleton_key: None,
profile: Some(profile.into()),
ticket_assignment: None,
initial_submit: vec![],
workdir_attachments: vec![],
feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest {
subjektiv: subject.map(|subject| server_api::SubjektivWorkerConnectionRequest {
subject_id: subject.into(),
}),
},
control_operation_id: None,
execution_grant: None,
runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.into(),
display_name: "Artifact consumer".into(),
singleton_key: None,
profile: Some(profile.into()),
ticket_assignment: None,
initial_submit: vec![],
workdir_attachments: vec![],
feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest {
subjektiv: subject.map(|subject| server_api::SubjektivWorkerConnectionRequest {
subject_id: subject.into(),
}),
},
control_operation_id: None,
execution_grant: None,
}
}
+31
View File
@@ -3729,6 +3729,37 @@ impl SqliteWorkspaceStore {
})
}
/// Keep the Backend singleton writer fence through a bounded FeatureStorage commit.
/// The callback must not reenter this Store or call Runtime/network. This is not a
/// cross-database atomic commit: a lost response is recovered by the feature's
/// stable operation, but ownership cannot change before its publication commits.
pub(crate) fn with_current_subject_body<T>(
&self,
workspace_id: &str,
subject_id: &str,
expected: &WorkerSingletonLease,
publish: impl FnOnce() -> Result<T>,
) -> Result<T> {
let key = crate::subjektiv::subject_worker_singleton_key(subject_id)
.map_err(|error| Error::InvalidInput(error.to_string()))?;
if expected.key != key {
return Err(Error::WorkspacePermissionDenied(
"chat requires Subject body ownership".into(),
));
}
self.with_immediate_transaction(|tx| {
require_current_worker_singleton_lease(tx, workspace_id, expected)?;
if current_worker_singleton_owner(tx, workspace_id, &expected.key)?
.is_none_or(|(_, state)| state.as_deref() == Some("removed"))
{
return Err(Error::WorkspacePermissionDenied(
"Subject body was released".into(),
));
}
publish()
})
}
pub(crate) fn complete_worker_create_reservation(
&self,
workspace_id: &str,