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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user