diff --git a/Cargo.lock b/Cargo.lock index 122e578dc..f2901cb1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5266,6 +5266,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "uuid", "workdir", ] @@ -6705,6 +6706,7 @@ dependencies = [ name = "yoi-e2e" version = "0.0.0" dependencies = [ + "chrono", "libc", "reqwest", "serde", @@ -6714,6 +6716,7 @@ dependencies = [ "tempfile", "webauthn-authenticator-rs", "webauthn-rs-proto", + "yoi-workspace-server", ] [[package]] diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index ee5957e52..5be0d0eab 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -908,6 +908,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }; let response = create_backend_worker_with_client(&target, &create, &api) diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 7aafe46e7..ad85206ce 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -567,6 +567,7 @@ impl BackendWorkspaceProductClient { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }; let workspace_id = self.workspace_id.clone(); let response: BrowserCreateWorkerResponse = self.generated(move |client| async move { diff --git a/crates/fs-operation/src/local.rs b/crates/fs-operation/src/local.rs index a93a17a63..9caf814b6 100644 --- a/crates/fs-operation/src/local.rs +++ b/crates/fs-operation/src/local.rs @@ -200,6 +200,7 @@ fn run_read_with_limits( total_lines, content_hash: content_hasher.finalize().into(), truncated: end < total_lines || byte_truncated, + byte_truncated, }) } @@ -885,6 +886,34 @@ mod bounded_read_tests { assert!(error.to_string().contains("exceeds provider limit 4096")); } + #[test] + fn bounded_read_distinguishes_utf8_byte_cut_from_complete_line_continuation() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + fs::write(root.join("text"), "雪雪needle\nnext").unwrap(); + for (offset, max_bytes, bytes, more, cut) in [ + (0, 5, "雪", true, true), + (0, 64, "雪雪needle\n", true, false), + (1, 64, "next", false, false), + ] { + let result = run_read_bounded( + &root, + ReadRequest { + path: FsPath::new("text").unwrap(), + offset, + limit: 1, + max_bytes, + }, + &RootAccess(root.clone()), + BoundedReadLimits::new(1024, 64).unwrap(), + ) + .unwrap(); + assert_eq!(result.bytes, bytes.as_bytes()); + assert_eq!(result.truncated, more); + assert_eq!(result.byte_truncated, cut); + } + } + #[test] fn bounded_read_caps_retained_response_independently_of_request() { let temp = tempfile::tempdir().unwrap(); @@ -907,6 +936,7 @@ mod bounded_read_tests { assert_eq!(result.bytes, b"alpha"); assert_eq!(result.total_lines, 3); assert!(result.truncated); + assert!(result.byte_truncated); } } diff --git a/crates/fs-operation/src/operation.rs b/crates/fs-operation/src/operation.rs index a2dc6d18f..a064c8509 100644 --- a/crates/fs-operation/src/operation.rs +++ b/crates/fs-operation/src/operation.rs @@ -204,7 +204,11 @@ pub struct ReadResult { #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub total_lines: usize, pub content_hash: ContentHash, + /// More source lines remain, or the selected window exceeded the response byte budget. pub truncated: bool, + /// The selected line window was cut by its byte budget, possibly within a line. + /// Line-based cursors must not treat such a window as completely scanned. + pub byte_truncated: bool, } /// Binary-safe byte range read with an optional whole-file version fence. diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index f7baece98..8be8c3d9a 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -408,7 +408,7 @@ pub enum Method { /// Transport is fire-and-forget; receivers must tolerate out-of-order /// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same /// child Worker). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(tag = "kind", rename_all = "snake_case")] @@ -982,6 +982,17 @@ pub enum SessionEntryProvenance { LegacyUnknown, } +/// Public use decisions contain no Feature identity or private payload. Absence +/// denotes the older projection contract, not permission to expose System input. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +#[serde(deny_unknown_fields)] +pub struct SessionEntryUsePolicy { + pub observation_readable: bool, + pub memory_evidence: bool, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] @@ -994,6 +1005,8 @@ pub struct SessionSnapshotEntry { pub provenance: SessionEntryProvenance, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub derived_from: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub use_policy: Option, #[serde(flatten)] pub data: SessionSnapshotEntryData, } @@ -1180,12 +1193,10 @@ pub enum Event { }, /// One agent-injected system item committed to history. /// - /// Carries the JSON form of `session_store::SystemItem`. Covers - /// `Method::Notify` echoes, child-Worker lifecycle events from - /// `Method::WorkerEvent`, `@` resolution payloads, and any future - /// Clients dispatch on the `kind` tag for typed rendering instead - /// of parsing free-text prefixes like `[Notification] …` or - /// `[File: …]`. + /// Carries the allowlisted JSON form of `session_store::PublicSystemItem`, + /// not the storage record. Its descriptive `kind` is independent from the + /// typed `presentation` used for notification, reminder, or context rendering. + /// Private Feature payloads and prompt provenance are never event data. /// /// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and /// wire-side are 1:1. @@ -2621,6 +2632,7 @@ mod tests { session: SessionSnapshot { pending_submissions: PendingSubmissionsSnapshot::default(), entries: vec![SessionSnapshotEntry { + use_policy: None, entry_id: "entry-1".into(), timestamp: 1, provenance: SessionEntryProvenance::HumanInput, diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs index d2fc61d91..192cf2a41 100644 --- a/crates/protocol/src/typescript.rs +++ b/crates/protocol/src/typescript.rs @@ -17,12 +17,12 @@ use crate::{ PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, ReasoningConfig, RewindSummary, RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance, - SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot, - SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, - SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, - WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope, - WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState, - WorkerStateSnapshot, WorkerStatus, + SessionEntryUsePolicy, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, + SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, + SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult, + UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement, + WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent, + WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus, subscription::{ EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES, MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS, @@ -117,6 +117,7 @@ pub fn generated_protocol_types() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); diff --git a/crates/server-api/src/authorization.rs b/crates/server-api/src/authorization.rs new file mode 100644 index 000000000..6d3de37f1 --- /dev/null +++ b/crates/server-api/src/authorization.rs @@ -0,0 +1,475 @@ +//! Neutral explicit-allow authorization contracts. Resource containment never crosses kinds. +//! +//! # Trusted Feature boundary +//! +//! A Profile requests execution configuration, not authority. The Worker asks the +//! Host for each enabled Feature's exact activation name before initialization, +//! then trusted Feature code asks for each protected operation and its semantic +//! resource before access or effects. `ticket` on [`PermissionResource::Feature`] +//! activates Ticket; it implies neither `ticket.create` nor any future operation. +//! `ticket.create` on a Workspace permits creation there but cannot activate the +//! Feature. There are no wildcard permissions or implicit dotted-name inheritance. +//! +//! Identity is absent from the authorization request intentionally: the Host +//! authenticates the bound Runtime/Worker and resolves current source permissions, +//! body ownership and any per-Worker execution grant. A shared client, Profile, +//! remembered receipt or caller-supplied Subject claim is not authority. Features +//! depend on this contract, never on a particular Subject policy implementation. +//! +//! Supported cooperative boundaries are Feature activation, ordinary Worker +//! delegation, Ticket creation (Tool/native WIP/typed service), Workdir filesystem +//! read/write and whole-attachment command execution, and scoped result notify. +//! Workdir providers still enforce resolved paths, traversal, capability, Ticket +//! and provider ceilings. The returned scope is for provider narrowing, not a +//! transferable credential. Scoped checkout/overlap operations are refused until +//! their complete access semantics are adapted. Other Feature operations retain +//! their existing owner checks; they do not automatically acquire this operation +//! policy. Missing calls are trusted Feature bugs, not interception by a universal +//! Tool executor or protection against malicious Feature code. Connection tests +//! must exercise each exposed surface, assert a genuine permitted effect and +//! assert zero downstream calls on denial, including revocation and provider denial. +//! +//! # Delegation callers and recovery +//! +//! Callers such as a chat Feature supply explicit operation/resource selections +//! and retain the same public operation ID and full request after an unknown +//! result. Normal Profile/Worker allocation and control/resource grants are reused; +//! there is no Subject Worker registry. Effective grants bind immutable Profile +//! resolution, the reserved Worker and source assignment. Permission replacement +//! invalidates existing children rather than refreshing them. Body replacement, +//! retirement and restore must recheck current authority; historical result lookup +//! is not new execution permission. Cancellation acceptance is distinct from +//! terminal confirmation and does not undo completed effects. +//! +//! A child receives no Subject body identity, singleton, whole Memory or public +//! conversation-post authority. `worker.result` is explicit notify authority to a +//! named origin Worker, not arbitrary control or publication. Chat delivery and +//! conversation correlation belong to the caller integration, not this contract. +//! Unset existing Subject policies deny new Subject execution/delegation; ordinary +//! non-Subject Workers explicitly retain existing Backend/provider contracts. +//! Standalone non-Workspace activation compatibility grants no Workspace operation. +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const MAX_SCOPED_PERMISSIONS: usize = 256; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PermissionResource { + Feature, + Workspace { + workspace_id: String, + }, + Workdir { + working_directory_id: String, + path: String, + recursive: bool, + }, + Worker { + runtime_id: String, + worker_id: String, + }, +} + +// Serde's internally tagged unit variants ignore extra fields even with deny_unknown_fields. +// A struct-shaped wire variant preserves the public unit variant while rejecting those claims. +impl<'de> Deserialize<'de> for PermissionResource { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] + enum Wire { + Feature {}, + Workspace { + workspace_id: String, + }, + Workdir { + working_directory_id: String, + path: String, + recursive: bool, + }, + Worker { + runtime_id: String, + worker_id: String, + }, + } + Ok(match Wire::deserialize(deserializer)? { + Wire::Feature {} => Self::Feature, + Wire::Workspace { workspace_id } => Self::Workspace { workspace_id }, + Wire::Workdir { + working_directory_id, + path, + recursive, + } => Self::Workdir { + working_directory_id, + path, + recursive, + }, + Wire::Worker { + runtime_id, + worker_id, + } => Self::Worker { + runtime_id, + worker_id, + }, + }) + } +} + +impl PermissionResource { + pub fn validate(&self) -> Result<(), String> { + match self { + Self::Feature => Ok(()), + Self::Workspace { workspace_id } => validate_authorization_identifier(workspace_id), + Self::Workdir { + working_directory_id, + path, + .. + } => { + validate_authorization_identifier(working_directory_id)?; + validate_relative_permission_path(path) + } + Self::Worker { + runtime_id, + worker_id, + } => { + validate_authorization_identifier(runtime_id)?; + validate_authorization_identifier(worker_id) + } + } + } + + pub fn covers(&self, other: &Self) -> bool { + if self.validate().is_err() || other.validate().is_err() { + return false; + } + match (self, other) { + (Self::Feature, Self::Feature) => true, + (Self::Workspace { workspace_id: a }, Self::Workspace { workspace_id: b }) => a == b, + ( + Self::Worker { + runtime_id: ar, + worker_id: aw, + }, + Self::Worker { + runtime_id: br, + worker_id: bw, + }, + ) => ar == br && aw == bw, + ( + Self::Workdir { + working_directory_id: a, + path: ap, + recursive: ar, + }, + Self::Workdir { + working_directory_id: b, + path: bp, + recursive: br, + }, + ) => { + a == b + && if *ar { + ap == "." + || ap == bp + || bp + .strip_prefix(ap) + .is_some_and(|tail| tail.starts_with('/')) + } else { + ap == bp && !br + } + } + _ => false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ScopedPermission { + pub operation: String, + pub resource: PermissionResource, +} + +impl ScopedPermission { + pub fn validate(&self) -> Result<(), String> { + validate_operation_name(&self.operation)?; + self.resource.validate()?; + if matches!(self.resource, PermissionResource::Feature) && self.operation.contains('.') { + return Err("Feature resources authorize activation names only, not operations".into()); + } + Ok(()) + } + + pub fn covers(&self, other: &Self) -> bool { + self.validate().is_ok() + && other.validate().is_ok() + && self.operation == other.operation + && self.resource.covers(&other.resource) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectPermissions { + pub change_id: String, + pub execution: Vec, + pub delegation: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectPermissionsUpdate { + pub operation_id: String, + pub expected_change_id: Option, + pub execution: Vec, + pub delegation: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkerExecutionGrant { + pub subject_id: String, + pub origin_runtime_id: String, + pub origin_worker_id: String, + pub source_change_id: String, + pub runtime_id: String, + pub worker_id: String, + pub profile: String, + pub profile_digest: String, + pub permissions: Vec, + pub redelegation: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkerDelegationRequest { + pub permissions: Vec, + pub redelegation: Vec, +} + +impl WorkerDelegationRequest { + pub fn validate(&self) -> Result<(), String> { + validate_scoped_permissions(&self.permissions)?; + validate_scoped_permissions(&self.redelegation) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct OperationAuthorizationRequest { + pub operation: String, + pub resource: PermissionResource, +} + +impl OperationAuthorizationRequest { + pub fn validate(&self) -> Result<(), String> { + ScopedPermission { + operation: self.operation.clone(), + resource: self.resource.clone(), + } + .validate() + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct OperationAuthorizationResponse { + /// Matching granted scope for provider-resolved checks, not a credential. + /// `None` identifies the explicit non-Subject legacy authorization path. + #[serde(default)] + pub scope: Option, +} + +pub fn validate_operation_name(value: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 256 + || !value.split('.').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + }) + { + return Err( + "permission operation must be an exact, nonempty dot-separated name (no wildcards)" + .into(), + ); + } + Ok(()) +} + +pub fn validate_scoped_permissions(permissions: &[ScopedPermission]) -> Result<(), String> { + if permissions.len() > MAX_SCOPED_PERMISSIONS { + return Err("too many scoped permissions".into()); + } + for permission in permissions { + permission.validate()?; + } + Ok(()) +} + +pub fn validate_authorization_identifier(value: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 256 + || value.trim() != value + || value.chars().any(|c| c.is_control() || c == '*') + { + return Err("authorization identity must be nonempty, bounded and exact".into()); + } + Ok(()) +} + +/// Paths are already-normalized lexical paths, not host filesystem paths. The Host must +/// still enforce symlink/provider boundaries; lexical containment grants no filesystem access. +pub fn validate_relative_permission_path(path: &str) -> Result<(), String> { + if path == "." { + return Ok(()); + } + if path.is_empty() + || path.len() > 4096 + || path.contains(['\\', ':']) + || path.chars().any(char::is_control) + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err("permission path must be a normalized relative path without escape".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workdir(path: &str, recursive: bool) -> PermissionResource { + PermissionResource::Workdir { + working_directory_id: "wd".into(), + path: path.into(), + recursive, + } + } + + #[test] + fn lexical_containment_rejects_escape_and_respects_component_and_recursion_boundaries() { + for path in [ + "", + "/src", + "../src", + "src/../secret", + "src/./a", + "src//a", + "src/", + "C:/src", + "src\\a", + ] { + assert!(workdir(path, true).validate().is_err(), "{path}"); + } + let root = workdir(".", true); + let src = workdir("src", true); + assert!(root.covers(&src)); + assert!(src.covers(&workdir("src/a", true))); + assert!(!src.covers(&workdir("src-other", false))); + assert!(!workdir("src", false).covers(&src)); + assert!(workdir("src", false).covers(&workdir("src", false))); + assert!(!workdir("src", false).covers(&workdir("src/a", false))); + assert!(!src.covers(&PermissionResource::Workdir { + working_directory_id: "other".into(), + path: "src".into(), + recursive: false + })); + } + + #[test] + fn operation_hierarchy_never_implies_inheritance_or_feature_activation() { + for name in [ + "", + "*", + "ticket.*", + ".ticket", + "ticket.", + "ticket..read", + "ticket read", + ] { + assert!(validate_operation_name(name).is_err(), "{name}"); + } + let activation = ScopedPermission { + operation: "ticket".into(), + resource: PermissionResource::Feature, + }; + let create = ScopedPermission { + operation: "ticket.create".into(), + resource: PermissionResource::Workspace { + workspace_id: "ws".into(), + }, + }; + let parent_name = ScopedPermission { + operation: "ticket".into(), + resource: create.resource.clone(), + }; + assert!(!parent_name.covers(&create)); + assert!(!create.covers(&parent_name)); + assert!(!activation.covers(&create)); + assert!(!create.covers(&activation)); + assert!( + ScopedPermission { + operation: "ticket.create".into(), + resource: PermissionResource::Feature + } + .validate() + .is_err() + ); + assert!( + !PermissionResource::Workspace { + workspace_id: "ws".into() + } + .covers(&workdir(".", true)) + ); + } + + #[test] + fn wire_contract_rejects_self_claimed_identity_and_unknown_fields() { + let request = serde_json::json!({"permissions":[],"redelegation":[],"subject_id":"forged"}); + assert!(serde_json::from_value::(request).is_err()); + let resource: PermissionResource = + serde_json::from_value(serde_json::json!({"kind":"feature"})).unwrap(); + assert_eq!(resource, PermissionResource::Feature); + assert!( + serde_json::from_value::( + serde_json::json!({"kind":"feature","workspace_id":"ws"}) + ) + .is_err() + ); + } + + #[test] + fn authorization_response_carries_matching_scope_and_defaults_legacy_absence() { + let legacy: OperationAuthorizationResponse = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(legacy.scope, None); + assert_eq!( + serde_json::to_value(legacy).unwrap(), + serde_json::json!({"scope":null}) + ); + let granted = OperationAuthorizationResponse { + scope: Some(workdir("src", true)), + }; + let wire = serde_json::to_value(&granted).unwrap(); + assert_eq!( + wire, + serde_json::json!({"scope":{"kind":"workdir","working_directory_id":"wd","path":"src","recursive":true}}) + ); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + granted + ); + } +} diff --git a/crates/server-api/src/drive.rs b/crates/server-api/src/drive.rs index 7ec7a729a..3167faa98 100644 --- a/crates/server-api/src/drive.rs +++ b/crates/server-api/src/drive.rs @@ -134,6 +134,14 @@ pub enum DriveEntryKind { File, } +/// Current caller's advisory publication ceiling, never a grant or execution credential. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveCapabilities { + pub write: bool, +} + /// Metadata only: content is returned through bounded text or binary routes. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -491,6 +499,7 @@ impl api_macros::HttpRequestError for DriveApiError { impl api_macros::openapi::OpenApiSchema for DriveApiError {} impl api_macros::openapi::OpenApiSchema for DriveEntry {} +impl api_macros::openapi::OpenApiSchema for DriveCapabilities {} impl api_macros::openapi::OpenApiSchema for DriveEntryQuery {} impl api_macros::openapi::OpenApiSchema for DriveListQuery {} impl api_macros::openapi::OpenApiSchema for DriveSearchQuery {} @@ -518,6 +527,7 @@ pub fn drive_api_typescript() -> String { DriveEntryRef::decl(&config), DriveEntryKind::decl(&config), DriveEntry::decl(&config), + DriveCapabilities::decl(&config), DriveEntryQuery::decl(&config), DriveListQuery::decl(&config), DriveSearchQuery::decl(&config), diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 1e30192d7..1de9be8e8 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -6,6 +6,7 @@ use api_macros::api; 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 drive; mod notification; pub use notification::*; @@ -23,6 +24,34 @@ use webauthn_rs_proto::{ }; pub use workspace_config::*; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectPermissionsResponse { + pub permissions: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectPermissionsMutationResponse { + pub permissions: authorization::SubjectPermissions, + /// New operations are already fenced. These are cancellation requests and + /// Backend command cleanup evidence, not proof that a Runtime turn stopped. + pub interruptions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectPermissionInterruption { + pub runtime_id: String, + pub worker_id: String, + pub cancellation_accepted: bool, + pub backend_command_sessions_closed: bool, + pub diagnostics: Vec, +} + #[allow(dead_code)] #[derive(JsonSchema)] struct ServerJsonSafeU64(#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] u64); @@ -168,6 +197,11 @@ macro_rules! impl_openapi_schema { } impl_openapi_schema!( + SubjectPermissionsResponse, + SubjectPermissionsMutationResponse, + SubjectPermissionInterruption, + authorization::SubjectPermissions, + authorization::SubjectPermissionsUpdate, AuthBootstrapUserRequest, AuthPublicConfig, HostingSettings, @@ -1298,6 +1332,51 @@ pub trait ServerApi { #[body] request: MemoryConsolidateStagingRequest, ) -> Result; + #[post( + "/api/w/{workspace_id}/authorization/check", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + openapi = false, + )] + async fn operation_authorize( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: authorization::OperationAuthorizationRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/permissions", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_subject_permissions_get( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] subject_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/permissions", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + bearer_auth = true, + browser_auth = true, + )] + async fn subjektiv_subject_permissions_set( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] subject_id: String, + #[body] request: authorization::SubjectPermissionsUpdate, + ) -> Result; + #[post( "/api/w/{workspace_id}/subjektiv/subjects", status = 201, @@ -2762,6 +2841,12 @@ pub trait ServerApi { #[extension] context: ServerRequestContext, #[path] workspace_id: String, ) -> Result; + #[get("/api/w/{workspace_id}/drive/capabilities", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] + async fn drive_capabilities( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; #[get("/api/w/{workspace_id}/drive/metadata", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] async fn drive_metadata( &self, @@ -4025,6 +4110,17 @@ impl RequestActor { #[serde(deny_unknown_fields)] pub struct WhoamiResponse { pub actor: Option, + pub session: BrowserSessionState, +} + +/// Browser navigation evidence, not an authorization grant or a generic 401 classification. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum BrowserSessionState { + Anonymous {}, + Authenticated { expires_at: Option }, + Expired {}, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -10428,6 +10524,10 @@ pub struct CreateWorkspaceWorkerRequest { /// Backend idempotency key used only for authenticated Worker-owned spawn/control. #[serde(default)] pub control_operation_id: Option, + /// Requested least-authority child permissions. Host derives the origin; this + /// input cannot assert Subject identity or create a body/singleton attachment. + #[serde(default)] + pub execution_grant: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -11533,6 +11633,7 @@ pub fn auth_api_typescript() -> String { ActorAuthMethod::decl(&config), AuthenticatedUser::decl(&config), RequestActor::decl(&config), + BrowserSessionState::decl(&config), WhoamiResponse::decl(&config), AuthBootstrapUserRequest::decl(&config), AuthUserResponse::decl(&config), @@ -12929,6 +13030,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }) .unwrap(); assert_eq!( @@ -12943,6 +13045,7 @@ mod tests { "workdir_attachments": [], "feature_connections": {}, "control_operation_id": null, + "execution_grant": null, }) ); } @@ -13961,7 +14064,8 @@ mod tests { "handle": "hare", "display_name": "Hare", "auth_method": "browser_session" - } + }, + "session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" } }); let decoded = serde_json::from_value::(whoami.clone()) .expect("server whoami fixture should match shared DTO"); @@ -14143,11 +14247,36 @@ mod tests { let unexpected_field = serde_json::json!({ "actor": null, + "session": { "kind": "anonymous" }, "access_token": "must-not-be-accepted" }); assert!(serde_json::from_value::(unexpected_field).is_err()); } + #[test] + fn whoami_session_json_distinguishes_anonymous_expired_and_token_auth() { + for session in [ + serde_json::json!({"kind":"anonymous"}), + serde_json::json!({"kind":"expired"}), + serde_json::json!({"kind":"authenticated", "expires_at":null}), + ] { + let value = serde_json::json!({"actor":null, "session":session}); + let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(response).unwrap(), value); + } + for session in [ + serde_json::json!({"kind":"unknown"}), + serde_json::json!({"kind":"expired", "expires_at":null}), + ] { + assert!( + serde_json::from_value::( + serde_json::json!({"actor":null, "session":session}) + ) + .is_err() + ); + } + } + #[test] fn device_login_debug_output_redacts_secret_material() { let start = DeviceLoginStartResponse { @@ -14532,6 +14661,7 @@ mod openapi_artifact_tests { // identity, or a one-use Reviewer capability. They remain generated Rust client/Axum // operations, but must not appear as unauthenticated operations in the public OpenAPI. const SIGNED_INTERNAL: &[&str] = &[ + "operation_authorize", "current_worker_workspace_config_get", "current_worker_workspace_config_attach", "current_worker_workspace_config_observe", diff --git a/crates/session-store/src/fs_store.rs b/crates/session-store/src/fs_store.rs index b6cade6b5..26226a5fc 100644 --- a/crates/session-store/src/fs_store.rs +++ b/crates/session-store/src/fs_store.rs @@ -361,6 +361,7 @@ impl Store for FsStore { segment_id: SegmentId, entry: &LogEntry, ) -> Result<(), StoreError> { + entry.validate_input_metadata()?; let _retention = self.retention().lock_live(session_id)?; let line = serde_json::to_string(entry)?; self.append_line(&self.log_path(session_id, segment_id), &line) @@ -448,6 +449,9 @@ impl Store for FsStore { segment_id: SegmentId, entries: &[LogEntry], ) -> Result<(), StoreError> { + for entry in entries { + entry.validate_input_metadata()?; + } let _retention = self.retention().lock_live(session_id)?; let path = self.log_path(session_id, segment_id); if let Some(parent) = path.parent() { diff --git a/crates/session-store/src/history.rs b/crates/session-store/src/history.rs index 4997a3b83..4170cdf60 100644 --- a/crates/session-store/src/history.rs +++ b/crates/session-store/src/history.rs @@ -67,6 +67,10 @@ pub enum LoggedSessionHistoryOrigin { ToolOutput { worker: LoggedWorkerSubject, }, + /// Descriptive producer identity; use policies live in separate metadata. + Provided { + producer: String, + }, /// Host-produced opening context summary; not auto-read or TaskStore material. CompactionSummary, /// Frozen Host Journal reference, never independent observation evidence. @@ -81,19 +85,32 @@ pub struct LoggedHistoryDerivation { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct LoggedSessionHistoryMetadata { pub entry_id: LoggedSessionHistoryEntryId, pub origin: LoggedSessionHistoryOrigin, #[serde(default, skip_serializing_if = "Option::is_none")] pub derivation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_metadata: Option, } impl LoggedSessionHistoryMetadata { + pub(crate) fn validate_input_metadata(&self) -> Result<(), crate::InputMetadataError> { + if matches!(self.origin, LoggedSessionHistoryOrigin::Provided { .. }) + && self.input_metadata.is_none() + { + return Err(crate::InputMetadataError::MissingMetadata); + } + Ok(()) + } + pub fn legacy_unknown() -> Self { Self { entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, + input_metadata: None, } } } @@ -101,20 +118,146 @@ impl LoggedSessionHistoryMetadata { /// Persisted item and metadata are one value so transforms cannot reorder or /// truncate one without the other. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "HistoryEntryWire")] pub struct LoggedHistoryEntry { pub item: LoggedItem, pub metadata: LoggedSessionHistoryMetadata, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct HistoryEntryWire { + item: LoggedItem, + metadata: LoggedSessionHistoryMetadata, +} + +impl TryFrom for LoggedHistoryEntry { + type Error = crate::InputMetadataError; + + fn try_from(wire: HistoryEntryWire) -> Result { + let entry = Self { + item: wire.item, + metadata: wire.metadata, + }; + entry.validate_input_metadata()?; + Ok(entry) + } +} + +impl LoggedHistoryEntry { + pub fn validate_input_metadata(&self) -> Result<(), crate::InputMetadataError> { + self.metadata.validate_input_metadata()?; + if let Some(input) = &self.metadata.input_metadata { + match &self.item { + LoggedItem::Message { + role: crate::LoggedRole::System, + content, + } => { + let [crate::LoggedContentPart::Text { text }] = content.as_slice() else { + return Err(crate::InputMetadataError::InconsistentRecord); + }; + if text.len() > crate::input_metadata::MAX_SYSTEM_INPUT_BYTES { + return Err(crate::InputMetadataError::ResourceLimit); + } + } + // Other logical roles may carry use policy, but cannot masquerade + // as the System-only public/payload extension contract. + _ if input.feature_payload().is_some() || input.public_item().is_some() => { + return Err(crate::InputMetadataError::InconsistentRecord); + } + _ => {} + } + } + Ok(()) + } +} + /// Typed system-item history record. The typed system event remains available /// to client replay while its model-visible projection carries the same stable /// metadata used by live history. #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(try_from = "SystemEntryWire")] pub struct LoggedSystemHistoryEntry { pub item: crate::SystemItem, pub metadata: LoggedSessionHistoryMetadata, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SystemEntryWire { + item: crate::SystemItem, + metadata: LoggedSessionHistoryMetadata, +} + +impl TryFrom for LoggedSystemHistoryEntry { + type Error = crate::InputMetadataError; + + fn try_from(wire: SystemEntryWire) -> Result { + let entry = Self { + item: wire.item, + metadata: wire.metadata, + }; + entry.validate_input_metadata()?; + Ok(entry) + } +} + +impl LoggedSystemHistoryEntry { + pub fn from_input( + origin: LoggedSessionHistoryOrigin, + derivation: Option, + input: crate::SystemInputEnvelope, + ) -> Self { + let (body, input_metadata) = input.into_parts(); + Self { + item: crate::SystemItem::Input { body }, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId::new(), + origin, + derivation, + input_metadata: Some(input_metadata), + }, + } + } + + pub fn validate_input_metadata(&self) -> Result<(), crate::InputMetadataError> { + self.metadata.validate_input_metadata()?; + match (&self.item, &self.metadata.input_metadata) { + (crate::SystemItem::Input { body }, Some(_)) => { + if body.len() > crate::input_metadata::MAX_SYSTEM_INPUT_BYTES { + return Err(crate::InputMetadataError::ResourceLimit); + } + } + (crate::SystemItem::Input { .. }, None) => { + return Err(crate::InputMetadataError::MissingMetadata); + } + (_, Some(_)) => return Err(crate::InputMetadataError::InconsistentRecord), + (_, None) => {} + } + Ok(()) + } + + pub fn to_history_entry(&self) -> LoggedHistoryEntry { + LoggedHistoryEntry { + item: self.item.to_history_item().into(), + metadata: self.metadata.clone(), + } + } + + pub fn public_item(&self) -> crate::PublicSystemItem { + if matches!(self.item, crate::SystemItem::Input { .. }) { + return self + .metadata + .input_metadata + .as_ref() + .and_then(crate::HistoryInputMetadata::public_item) + .cloned() + .unwrap_or_else(|| self.item.public_item()); + } + self.item.public_item() + } +} + #[cfg(test)] mod tests { use super::*; @@ -132,6 +275,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::HumanInput { account_id: "account-1".into(), @@ -150,6 +294,64 @@ mod tests { ); } + #[test] + fn external_system_records_reject_missing_policy_role_mismatch_and_overlimit_body() { + let valid = serde_json::json!({ + "item": { "kind": "input", "body": "context" }, + "metadata": { + "entry_id": "stable-entry", "origin": { "kind": "provided", "producer": "owner" }, + "input_metadata": { "usage": { + "recording_material": "exclude", "memory_evidence": "exclude", + "observation": "hidden", "compaction": "reference_input" + } } + } + }); + let restored: LoggedSystemHistoryEntry = serde_json::from_value(valid.clone()).unwrap(); + let flat = serde_json::to_value(restored.to_history_entry()).unwrap(); + assert!(serde_json::from_value::(flat.clone()).is_ok()); + for generic in [true, false] { + let wire = if generic { &valid } else { &flat }; + let decode = |wire| { + if generic { + serde_json::from_value::(wire).is_err() + } else { + serde_json::from_value::(wire).is_err() + } + }; + let mut missing = wire.clone(); + missing["metadata"] + .as_object_mut() + .unwrap() + .remove("input_metadata"); + assert!(decode(missing)); + let mut absent_policy = wire.clone(); + absent_policy["metadata"]["input_metadata"] + .as_object_mut() + .unwrap() + .remove("usage"); + assert!(decode(absent_policy)); + let mut invalid_role = wire.clone(); + invalid_role["item"] = + serde_json::to_value(LoggedItem::from(agen::Item::user_message("not system"))) + .unwrap(); + invalid_role["metadata"]["input_metadata"]["public"] = serde_json::json!({ + "kind": "context", "body": "public system projection", "presentation": {"kind": "context"} + }); + assert!(decode(invalid_role)); + let mut oversized = wire.clone(); + if generic { + oversized["item"]["body"] = "x" + .repeat(crate::input_metadata::MAX_SYSTEM_INPUT_BYTES + 1) + .into(); + } else { + oversized["item"]["content"][0]["text"] = "x" + .repeat(crate::input_metadata::MAX_SYSTEM_INPUT_BYTES + 1) + .into(); + } + assert!(decode(oversized)); + } + } + #[test] fn annotated_segment_start_is_restore_visible_without_projecting_metadata() { let session_id = uuid::Uuid::now_v7(); diff --git a/crates/session-store/src/input_metadata.rs b/crates/session-store/src/input_metadata.rs new file mode 100644 index 000000000..eeed42b9c --- /dev/null +++ b/crates/session-store/src/input_metadata.rs @@ -0,0 +1,563 @@ +//! Validated, Feature-neutral history-input policy and System extensions. +//! +//! The enclosing history annotation owns identity, origin and derivation. None of +//! those descriptive facts selects a use policy here. Model text, independent use +//! policies, private Feature data and an explicit public projection travel as one +//! value. Restoring this value does not load a Feature or invoke domain behavior. +//! +//! Payload validation bounds the decoded value; enclosing log readers must also +//! enforce their record allocation limit before decoding untrusted persisted input. + +use agen::Item; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::PublicSystemItem; + +pub const MAX_FEATURE_PAYLOAD_BYTES: usize = 128 * 1024; +pub const MAX_SYSTEM_INPUT_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_SYSTEM_PUBLIC_BYTES: usize = 256 * 1024; +const MAX_SCHEMA_BYTES: usize = 128; +const MAX_JSON_DEPTH: usize = 16; +const MAX_JSON_NODES: usize = 8192; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum InputMetadataError { + #[error("history input payload schema is invalid")] + InvalidSchema, + #[error("history input payload version must be nonzero")] + InvalidVersion, + #[error("system input resource limit exceeded")] + ResourceLimit, + #[error("history input payload could not be serialized")] + Serialization, + #[error("history input payload schema or version is unsupported by this consumer")] + UnsupportedPayload, + #[error("history input payload does not match the owner's typed contract")] + InvalidTypedPayload, + #[error("provided input is missing explicit metadata")] + MissingMetadata, + #[error("input metadata does not match its logical record")] + InconsistentRecord, +} + +/// One private payload owned by a Feature's versioned typed decoder. +/// Unknown valid schemas/versions remain data, never execution instructions. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "PayloadWire")] +pub struct FeatureInputPayload { + schema: String, + version: u32, + value: Value, +} + +impl std::fmt::Debug for FeatureInputPayload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FeatureInputPayload") + .field("schema", &self.schema) + .field("version", &self.version) + .finish_non_exhaustive() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PayloadWire { + schema: String, + version: u32, + value: Value, +} + +impl TryFrom for FeatureInputPayload { + type Error = InputMetadataError; + + fn try_from(wire: PayloadWire) -> Result { + if wire.schema.is_empty() + || wire.schema.len() > MAX_SCHEMA_BYTES + || !wire.schema.as_bytes()[0].is_ascii_lowercase() + || !wire.schema.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"-_.:/".contains(&byte) + }) + { + return Err(InputMetadataError::InvalidSchema); + } + if wire.version == 0 { + return Err(InputMetadataError::InvalidVersion); + } + validate_json(&wire.value, MAX_FEATURE_PAYLOAD_BYTES)?; + Ok(Self { + schema: wire.schema, + version: wire.version, + value: wire.value, + }) + } +} + +impl FeatureInputPayload { + pub fn encode( + schema: impl Into, + version: u32, + value: &T, + ) -> Result { + PayloadWire { + schema: schema.into(), + version, + value: serde_json::to_value(value).map_err(|_| InputMetadataError::Serialization)?, + } + .try_into() + } + + pub fn schema(&self) -> &str { + &self.schema + } + + pub fn version(&self) -> u32 { + self.version + } + + /// The owner explicitly selects its contract before decoding. A valid unknown + /// payload can be retained and round-tripped without calling this operation. + pub fn decode( + &self, + schema: &str, + version: u32, + ) -> Result { + if self.schema != schema || self.version != version { + return Err(InputMetadataError::UnsupportedPayload); + } + serde_json::from_value(self.value.clone()) + .map_err(|_| InputMetadataError::InvalidTypedPayload) + } +} + +/// Eligibility is supplied independently for recording and Memory evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InputEvidenceUse { + Include, + Exclude, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InputObservationUse { + Readable, + Hidden, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InputCompactionUse { + /// Ordinary history eligible for the selected retained tail. + Retain, + /// A full opening summary consumed separately and superseded by compaction. + OpeningSummary, + /// Full historical context for the compactor, not an accumulating retained tail. + ReferenceInput, +} + +/// No permissive defaults: malformed/unknown policies cannot turn into eligibility. +/// Public presentation is deliberately not one of these use decisions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HistoryInputUsePolicy { + pub recording_material: InputEvidenceUse, + pub memory_evidence: InputEvidenceUse, + pub observation: InputObservationUse, + pub compaction: InputCompactionUse, +} + +/// Use policy is independent of logical role. Non-System derived Tool context +/// carries policy only; private Feature payload and public presentation belong +/// to the System envelope and are rejected on other roles at the history boundary. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(try_from = "MetadataWire")] +pub struct HistoryInputMetadata { + usage: HistoryInputUsePolicy, + feature_payload: Option, + public: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MetadataWire { + usage: HistoryInputUsePolicy, + feature_payload: Option, + public: Option, +} + +impl TryFrom for HistoryInputMetadata { + type Error = InputMetadataError; + + fn try_from(wire: MetadataWire) -> Result { + if let Some(public) = &wire.public { + // Kind is a descriptive label, not a Feature dispatch or authority key. + if public.kind.is_empty() + || public.kind.len() > MAX_SCHEMA_BYTES + || public.kind.chars().any(char::is_control) + { + return Err(InputMetadataError::InvalidSchema); + } + let value = + serde_json::to_value(public).map_err(|_| InputMetadataError::Serialization)?; + validate_json(&value, MAX_SYSTEM_PUBLIC_BYTES)?; + } + Ok(Self { + usage: wire.usage, + feature_payload: wire.feature_payload, + public: wire.public, + }) + } +} + +impl HistoryInputMetadata { + pub fn new( + usage: HistoryInputUsePolicy, + feature_payload: Option, + public: Option, + ) -> Result { + MetadataWire { + usage, + feature_payload, + public, + } + .try_into() + } + + pub fn usage(&self) -> HistoryInputUsePolicy { + self.usage + } + + pub fn feature_payload(&self) -> Option<&FeatureInputPayload> { + self.feature_payload.as_ref() + } + + pub fn public_item(&self) -> Option<&PublicSystemItem> { + self.public.as_ref() + } + + pub fn public_use_policy(&self) -> protocol::SessionEntryUsePolicy { + protocol::SessionEntryUsePolicy { + observation_readable: self.usage.observation == InputObservationUse::Readable, + memory_evidence: self.usage.memory_evidence == InputEvidenceUse::Include, + } + } + + pub fn public_system_use_policy(&self) -> protocol::SessionEntryUsePolicy { + if self.public.is_some() { + self.public_use_policy() + } else { + protocol::SessionEntryUsePolicy { + observation_readable: false, + memory_evidence: false, + } + } + } + + pub fn public_projection(&self) -> PublicSystemItem { + self.public.clone().unwrap_or_else(|| PublicSystemItem { + kind: "input".into(), + body: String::new(), + presentation: crate::PublicSystemPresentation::Ignored, + }) + } +} + +/// System role is invariant; provenance and use policies are attached data. +/// An absent public projection never implicitly publishes the model body. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(try_from = "InputWire")] +pub struct SystemInputEnvelope { + body: String, + metadata: HistoryInputMetadata, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct InputWire { + body: String, + metadata: HistoryInputMetadata, +} + +impl TryFrom for SystemInputEnvelope { + type Error = InputMetadataError; + + fn try_from(wire: InputWire) -> Result { + if wire.body.len() > MAX_SYSTEM_INPUT_BYTES { + return Err(InputMetadataError::ResourceLimit); + } + Ok(Self { + body: wire.body, + metadata: wire.metadata, + }) + } +} + +impl SystemInputEnvelope { + pub fn new( + body: impl Into, + metadata: HistoryInputMetadata, + ) -> Result { + InputWire { + body: body.into(), + metadata, + } + .try_into() + } + + pub fn body(&self) -> &str { + &self.body + } + + pub fn metadata(&self) -> &HistoryInputMetadata { + &self.metadata + } + + pub fn to_history_item(&self) -> Item { + Item::system_message(self.body.clone()) + } + + pub fn into_parts(self) -> (String, HistoryInputMetadata) { + (self.body, self.metadata) + } +} + +fn validate_json(value: &Value, max_bytes: usize) -> Result<(), InputMetadataError> { + fn walk(value: &Value, depth: usize, remaining: &mut usize) -> Result<(), InputMetadataError> { + if depth > MAX_JSON_DEPTH || *remaining == 0 { + return Err(InputMetadataError::ResourceLimit); + } + *remaining -= 1; + match value { + Value::Array(values) => { + for value in values { + walk(value, depth + 1, remaining)?; + } + } + Value::Object(values) => { + for value in values.values() { + walk(value, depth + 1, remaining)?; + } + } + _ => {} + } + Ok(()) + } + let mut remaining = MAX_JSON_NODES; + walk(value, 0, &mut remaining)?; + let bytes = serde_json::to_vec(value).map_err(|_| InputMetadataError::Serialization)?; + if bytes.len() > max_bytes { + return Err(InputMetadataError::ResourceLimit); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{LoggedItem, LoggedRole, PublicSystemPresentation}; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + struct OwnerPayload { + source_digest: String, + omitted: bool, + } + + fn reference_policy() -> HistoryInputUsePolicy { + HistoryInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::ReferenceInput, + } + } + + #[test] + fn unknown_valid_payload_round_trips_without_domain_decode_or_public_exposure() { + let payload = FeatureInputPayload::encode( + "future-owner:context", + 99, + &OwnerPayload { + source_digest: "private-digest".into(), + omitted: true, + }, + ) + .unwrap(); + let envelope = SystemInputEnvelope::new( + "private model context", + HistoryInputMetadata::new(reference_policy(), Some(payload), None).unwrap(), + ) + .unwrap(); + let restored: SystemInputEnvelope = + serde_json::from_slice(&serde_json::to_vec(&envelope).unwrap()).unwrap(); + assert_eq!(restored.body(), "private model context"); + assert!(restored.metadata().public_item().is_none()); + let saved_payload = restored.metadata().feature_payload().unwrap(); + assert_eq!(saved_payload.schema(), "future-owner:context"); + assert_eq!(saved_payload.version(), 99); + assert_eq!( + saved_payload.decode::("known-owner:context", 1), + Err(InputMetadataError::UnsupportedPayload) + ); + assert_eq!( + serde_json::to_value(&restored).unwrap(), + serde_json::to_value(&envelope).unwrap() + ); + assert!(!format!("{saved_payload:?}").contains("private-digest")); + } + + #[test] + fn owner_decodes_typed_payload_only_for_its_explicit_contract() { + let input = OwnerPayload { + source_digest: "rev".into(), + omitted: false, + }; + let payload = FeatureInputPayload::encode("owner:context", 1, &input).unwrap(); + assert_eq!( + payload.decode::("owner:context", 1).unwrap(), + input + ); + let invalid = FeatureInputPayload::encode( + "owner:context", + 1, + &serde_json::json!({ "omitted": "not a bool" }), + ) + .unwrap(); + assert_eq!( + invalid.decode::("owner:context", 1), + Err(InputMetadataError::InvalidTypedPayload) + ); + } + + #[test] + fn malformed_or_oversized_payload_is_rejected_at_construction_and_restore() { + for (schema, version, value) in [ + ("", 1, Value::Null), + ("invalid schema", 1, Value::Null), + ("owner:context", 0, Value::Null), + ( + "owner:context", + 1, + Value::String("x".repeat(MAX_FEATURE_PAYLOAD_BYTES)), + ), + ( + "owner:context", + 1, + Value::Array(vec![Value::Null; MAX_JSON_NODES]), + ), + ( + "owner:context", + 1, + (0..=MAX_JSON_DEPTH).fold(Value::Null, |inner, _| Value::Array(vec![inner])), + ), + ] { + assert!(FeatureInputPayload::encode(schema, version, &value).is_err()); + assert!( + serde_json::from_value::( + serde_json::json!({ "schema": schema, "version": version, "value": value }) + ) + .is_err() + ); + } + } + + #[test] + fn policy_is_required_and_unknown_or_malformed_fields_fail_closed() { + let valid = serde_json::to_value(reference_policy()).unwrap(); + for field in [ + "recording_material", + "memory_evidence", + "observation", + "compaction", + ] { + let mut absent = valid.clone(); + absent.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(absent).is_err()); + let mut unknown = valid.clone(); + unknown[field] = Value::String("future-allow".into()); + assert!(serde_json::from_value::(unknown).is_err()); + } + let mut extra = valid; + extra["feature_name"] = Value::String("infer-policy-from-me".into()); + assert!(serde_json::from_value::(extra).is_err()); + } + + #[test] + fn feature_name_and_model_body_do_not_select_policies_or_public_projection() { + for (schema, body) in [ + ("owner:journal", "past context"), + ("unrelated:skill", "do not extract this; legitimate wording"), + ] { + let public = PublicSystemItem { + kind: "context-label".into(), + body: "explicit public text".into(), + presentation: PublicSystemPresentation::Context, + }; + let metadata = HistoryInputMetadata::new( + reference_policy(), + Some( + FeatureInputPayload::encode( + schema, + 1, + &OwnerPayload { + source_digest: "private".into(), + omitted: false, + }, + ) + .unwrap(), + ), + Some(public), + ) + .unwrap(); + let input = SystemInputEnvelope::new(body, metadata).unwrap(); + assert_eq!(input.metadata().usage(), reference_policy()); + let history: LoggedItem = input.to_history_item().into(); + assert!(matches!( + history, + LoggedItem::Message { + role: LoggedRole::System, + .. + } + )); + assert_eq!(input.to_history_item().as_text(), Some(body)); + let public = serde_json::to_string(input.metadata().public_item().unwrap()).unwrap(); + assert!(!public.contains("private")); + assert!(!public.contains(schema)); + assert!(!public.contains(body)); + } + } + + #[test] + fn use_policies_remain_independent_of_each_other_and_of_public_display() { + let usage = HistoryInputUsePolicy { + recording_material: InputEvidenceUse::Include, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Readable, + compaction: InputCompactionUse::Retain, + }; + let metadata = HistoryInputMetadata::new(usage, None, None).unwrap(); + let restored: HistoryInputMetadata = + serde_json::from_value(serde_json::to_value(&metadata).unwrap()).unwrap(); + assert_eq!(restored.usage(), usage); + assert!(restored.public_item().is_none()); + assert!(restored.feature_payload().is_none()); + } + + #[test] + fn model_body_and_public_projection_have_independent_resource_limits() { + let metadata = HistoryInputMetadata::new(reference_policy(), None, None).unwrap(); + assert!( + SystemInputEnvelope::new("x".repeat(MAX_SYSTEM_INPUT_BYTES + 1), metadata).is_err() + ); + let public = PublicSystemItem { + kind: "context".into(), + body: "x".repeat(MAX_SYSTEM_PUBLIC_BYTES), + presentation: PublicSystemPresentation::Context, + }; + assert!(HistoryInputMetadata::new(reference_policy(), None, Some(public)).is_err()); + let invalid = serde_json::json!({ "body": "small", "metadata": { "public": null, "feature_payload": null } }); + assert!(serde_json::from_value::(invalid).is_err()); + } +} diff --git a/crates/session-store/src/legacy_session_log.rs b/crates/session-store/src/legacy_session_log.rs index 5c465d0b9..dc91caf07 100644 --- a/crates/session-store/src/legacy_session_log.rs +++ b/crates/session-store/src/legacy_session_log.rs @@ -1,12 +1,12 @@ -//! Versioned decoder for Session schemas that predate canonical annotated history. +//! Versioned decoder for Session schemas v1-5, including frozen Feature wire. //! //! These types are intentionally private to `session-store`. Current writers, //! replay, and public projections use [`crate::LogEntry`] exclusively; only the -//! Worker Session schema migration is allowed to deserialize these shapes. +//! explicit Session schema migration is allowed to deserialize these shapes. use agen::llm_client::types::RequestConfig; use protocol::Segment; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[cfg(test)] mod tests; @@ -17,6 +17,212 @@ use crate::{ SegmentOrigin, SessionExtension, SessionId, SystemItem, }; +// Frozen v1-4 Feature wire. These types are deliberately not exported and are +// never used by LogEntry/SystemItem's current deserializers. Payload structs +// retain the owner's v1 shape rather than erasing typed fields into arbitrary JSON. +#[derive(Debug, Serialize, Deserialize)] +struct JournalStartupPayload { + snapshot: serde_json::Value, + snapshot_sha256: String, + prompt_provenance: crate::PromptRenderProvenance, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ResidentSummaryPayload { + #[serde(default)] + prompt_provenance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SubjectBehaviorPayload { + #[serde(default)] + behavior_md: String, + #[serde(default)] + prompt_provenance: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SkillActivationPayload { + name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct InvocationResultPayload { + result: protocol::FeatureInvocationResult, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum LegacyFeatureSystemItem { + JournalStartup { + body: String, + #[serde(flatten)] + payload: JournalStartupPayload, + }, + ResidentSummaryRefresh { + body: String, + #[serde(flatten)] + payload: ResidentSummaryPayload, + }, + SubjectBehaviorRefresh { + body: String, + #[serde(flatten)] + payload: SubjectBehaviorPayload, + }, + SkillActivation { + body: String, + #[serde(flatten)] + payload: SkillActivationPayload, + }, + FeatureInvocationResult { + body: String, + #[serde(flatten)] + payload: InvocationResultPayload, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum LegacySystemItem { + Feature(LegacyFeatureSystemItem), + // Includes already-migrated Input records for interrupted migration retry. + Core(SystemItem), +} + +impl LegacySystemItem { + fn into_entry( + self, + mut metadata: LoggedSessionHistoryMetadata, + ) -> Result { + use crate::InputCompactionUse::{ReferenceInput, Retain}; + let item = match self { + Self::Core(item) => item, + Self::Feature(feature) => { + metadata.validate_input_metadata()?; + if metadata.input_metadata.is_some() { + return Err(crate::InputMetadataError::InconsistentRecord); + } + let (body, input_metadata) = match feature { + LegacyFeatureSystemItem::JournalStartup { body, payload } => { + migrated_feature_input( + body, + "journal_startup", + "journal:startup", + payload, + ReferenceInput, + )? + } + LegacyFeatureSystemItem::ResidentSummaryRefresh { body, payload } => { + migrated_feature_input( + body, + "resident_summary_refresh", + "memory:resident-summary", + payload, + Retain, + )? + } + LegacyFeatureSystemItem::SubjectBehaviorRefresh { body, payload } => { + migrated_feature_input( + body, + "subject_behavior_refresh", + "subject:behavior", + payload, + Retain, + )? + } + LegacyFeatureSystemItem::SkillActivation { body, payload } => { + migrated_feature_input( + body, + "skill_activation", + "skill:activation", + payload, + Retain, + )? + } + LegacyFeatureSystemItem::FeatureInvocationResult { body, payload } => { + migrated_feature_input( + body, + "feature_invocation_result", + "feature:invocation-result", + payload, + Retain, + )? + } + }; + // Identity/origin/derivation are retained, never re-inferred. + metadata.input_metadata = Some(input_metadata); + SystemItem::Input { body } + } + }; + let entry = LoggedSystemHistoryEntry { item, metadata }; + entry.validate_input_metadata()?; + Ok(entry) + } +} + +fn migrated_feature_input( + body: String, + label: &str, + schema: &str, + payload: T, + compaction: crate::InputCompactionUse, +) -> Result<(String, crate::HistoryInputMetadata), crate::InputMetadataError> { + let metadata = crate::HistoryInputMetadata::new( + migration_policy(compaction), + Some(crate::FeatureInputPayload::encode(schema, 1, &payload)?), + Some(crate::PublicSystemItem { + kind: label.into(), + body: body.clone(), + presentation: crate::PublicSystemPresentation::Context, + }), + )?; + Ok((body, metadata)) +} + +/// Annotated old wire used by v3/v4, and by already-converted files when an +/// older marker survived an interrupted migration. No ordinary decoder uses it. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum LegacyAnnotatedLogEntry { + AnnotatedSystemItem { + ts: u64, + entry: LegacySystemHistoryEntry, + #[serde(default)] + extensions: Vec, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LegacySystemHistoryEntry { + item: LegacySystemItem, + metadata: LoggedSessionHistoryMetadata, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum LegacyAnnotatedOrCurrent { + Annotated(LegacyAnnotatedLogEntry), + Current(LogEntry), +} + +impl LegacyAnnotatedOrCurrent { + fn into_current(self) -> Result { + match self { + Self::Annotated(LegacyAnnotatedLogEntry::AnnotatedSystemItem { + ts, + entry, + extensions, + }) => Ok(LogEntry::AnnotatedSystemItem { + ts, + entry: entry.item.into_entry(entry.metadata)?, + extensions, + }), + Self::Current(entry) => Ok(entry), + } + } +} + #[derive(Debug, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] enum LegacyHistoryLogEntry { @@ -47,7 +253,9 @@ enum LegacyHistoryLogEntry { }, SystemItem { ts: u64, - item: SystemItem, + item: LegacySystemItem, + #[serde(default)] + extensions: Vec, }, } @@ -57,7 +265,7 @@ enum LegacyHistoryLogEntry { #[serde(untagged)] enum LegacySessionLogEntryV1 { History(LegacyHistoryLogEntry), - Current(LogEntry), + Current(LegacyAnnotatedOrCurrent), } /// Schema v2 retained the v1 history shapes while adding non-history records. @@ -67,7 +275,7 @@ enum LegacySessionLogEntryV1 { #[serde(untagged)] enum LegacySessionLogEntryV2 { History(LegacyHistoryLogEntry), - Current(LogEntry), + Current(LegacyAnnotatedOrCurrent), } pub(crate) fn decode_entry( @@ -77,24 +285,75 @@ pub(crate) fn decode_entry( segment_id: SegmentId, line_index: usize, ) -> Result { + let mut wire: serde_json::Value = serde_json::from_str(line)?; + migrate_legacy_metadata_field(&mut wire)?; let entry = match schema_version { - 1 => match serde_json::from_str::(line)? { + 1 => match serde_json::from_value::(wire)? { LegacySessionLogEntryV1::History(entry) => Entry::History(entry), LegacySessionLogEntryV1::Current(entry) => Entry::Current(entry), }, - 2 => match serde_json::from_str::(line)? { + 2 => match serde_json::from_value::(wire)? { LegacySessionLogEntryV2::History(entry) => Entry::History(entry), LegacySessionLogEntryV2::Current(entry) => Entry::Current(entry), }, - 3 => Entry::Current(serde_json::from_str(line)?), + 3 | 4 => Entry::Current(serde_json::from_value(wire)?), + 5 => return serde_json::from_value(wire), _ => unreachable!("legacy decoder called for unsupported schema {schema_version}"), }; - Ok(match entry { + match entry { Entry::History(entry) => { canonicalize_history_entry(session_id, segment_id, line_index, entry) } - Entry::Current(entry) => entry, - }) + Entry::Current(entry) => entry.into_current(), + } + .map_err(serde::de::Error::custom) +} + +// The v5 System-only metadata field was widened to role-neutral input metadata +// in v6. Touch only known history slots, never arbitrary Feature payload objects. +// Both keys are an ambiguous source, not permission to discard either value. +fn migrate_legacy_metadata_field(record: &mut serde_json::Value) -> Result<(), serde_json::Error> { + fn entry(value: &mut serde_json::Value) -> Result<(), serde_json::Error> { + if let Some(metadata) = value + .get_mut("metadata") + .and_then(serde_json::Value::as_object_mut) + { + if let Some(old) = metadata.remove("system_input") { + if metadata.contains_key("input_metadata") { + return Err(serde::de::Error::custom( + "ambiguous legacy input metadata fields", + )); + } + metadata.insert("input_metadata".into(), old); + } + } + Ok(()) + } + let kind = record + .get("kind") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + if matches!( + kind.as_str(), + "annotated_system_item" + | "annotated_assistant_item" + | "annotated_tool_result" + | "tool_result_corrected" + ) { + if let Some(value) = record.get_mut("entry") { + entry(value)?; + } + } + if let Some(history) = record + .get_mut("history") + .and_then(serde_json::Value::as_array_mut) + { + for value in history { + entry(value)?; + } + } + Ok(()) } /// Schema 3 used DerivedSummary for Host compaction seeds. Only the trusted @@ -152,9 +411,81 @@ pub(crate) fn migrate_compaction_origins( } } +/// Explicit legacy-to-current boundary. No Feature inference belongs in ordinary +/// readers/restoration. Invalid or oversized payloads fail migration; the generic +/// payload/body/public limits are deliberately not relaxed for legacy data. +pub(crate) fn migrate_system_inputs(entry: &mut LogEntry) -> Result<(), crate::InputMetadataError> { + entry.validate_input_metadata()?; + match entry { + // Feature wire has already been converted by the explicit old-schema + // decoder. Ordinary LogEntry can contain only generic Input/core events. + LogEntry::AnnotatedSystemItem { entry, .. } => entry.validate_input_metadata()?, + LogEntry::AnnotatedSegmentStart { history, .. } + | LogEntry::AnnotatedUserInput { history, .. } => { + for entry in history { + migrate_flattened_system_input(entry)?; + } + } + LogEntry::AnnotatedAssistantItem { entry, .. } + | LogEntry::AnnotatedToolResult { entry, .. } + | LogEntry::ToolResultCorrected { entry, .. } => { + migrate_flattened_system_input(entry)?; + } + _ => {} + } + entry.validate_input_metadata() +} + +fn migration_policy(compaction: crate::InputCompactionUse) -> crate::HistoryInputUsePolicy { + crate::HistoryInputUsePolicy { + recording_material: crate::InputEvidenceUse::Exclude, + memory_evidence: crate::InputEvidenceUse::Exclude, + observation: crate::InputObservationUse::Hidden, + compaction, + } +} + +fn migrate_flattened_system_input( + entry: &mut LoggedHistoryEntry, +) -> Result<(), crate::InputMetadataError> { + // Check before supplying legacy policy: Provided without metadata is a + // malformed NEW record, not permission to synthesize a producer policy. + entry.validate_input_metadata()?; + if entry.metadata.input_metadata.is_none() + && (matches!( + entry.item, + LoggedItem::Message { + role: crate::LoggedRole::System, + .. + } + ) || matches!( + entry.metadata.origin, + LoggedSessionHistoryOrigin::DerivedSummary + | LoggedSessionHistoryOrigin::CompactionSummary + )) + { + let compaction = match entry.metadata.origin { + LoggedSessionHistoryOrigin::JournalContext => crate::InputCompactionUse::ReferenceInput, + LoggedSessionHistoryOrigin::CompactionSummary => { + crate::InputCompactionUse::OpeningSummary + } + _ => crate::InputCompactionUse::Retain, + }; + // Legacy derived context was excluded from independent evidence. Preserve + // that established decision explicitly, including synthetic Tool roles; + // never guess from Tool names or body text. Payload/public stay absent. + entry.metadata.input_metadata = Some(crate::HistoryInputMetadata::new( + migration_policy(compaction), + None, + None, + )?); + } + entry.validate_input_metadata() +} + enum Entry { History(LegacyHistoryLogEntry), - Current(LogEntry), + Current(LegacyAnnotatedOrCurrent), } fn legacy_metadata( @@ -167,6 +498,7 @@ fn legacy_metadata( identity.extend_from_slice(&(line_index as u64).to_be_bytes()); identity.extend_from_slice(&(item_index as u64).to_be_bytes()); LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId(format!( "l-{}", base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, identity) @@ -181,8 +513,8 @@ fn canonicalize_history_entry( segment_id: SegmentId, line_index: usize, entry: LegacyHistoryLogEntry, -) -> LogEntry { - match entry { +) -> Result { + Ok(match entry { LegacyHistoryLogEntry::SegmentStart { ts, session_id, @@ -236,13 +568,14 @@ fn canonicalize_history_entry( metadata: legacy_metadata(segment_id, line_index, 0), }, }, - LegacyHistoryLogEntry::SystemItem { ts, item } => LogEntry::AnnotatedSystemItem { + LegacyHistoryLogEntry::SystemItem { ts, - entry: LoggedSystemHistoryEntry { - item, - metadata: legacy_metadata(segment_id, line_index, 0), - }, - extensions: Vec::new(), + item, + extensions, + } => LogEntry::AnnotatedSystemItem { + ts, + entry: item.into_entry(legacy_metadata(segment_id, line_index, 0))?, + extensions, }, - } + }) } diff --git a/crates/session-store/src/legacy_session_log/tests.rs b/crates/session-store/src/legacy_session_log/tests.rs index 3c72462c5..e879d0ab1 100644 --- a/crates/session-store/src/legacy_session_log/tests.rs +++ b/crates/session-store/src/legacy_session_log/tests.rs @@ -1,5 +1,7 @@ use super::*; +mod v5; + fn seed( origin: LoggedSessionHistoryOrigin, compacted: bool, @@ -22,6 +24,7 @@ fn seed( content: vec![crate::LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId("summary".into()), origin, derivation: Some(crate::LoggedHistoryDerivation { diff --git a/crates/session-store/src/legacy_session_log/tests/fixtures/v1-system-inputs.jsonl b/crates/session-store/src/legacy_session_log/tests/fixtures/v1-system-inputs.jsonl new file mode 100644 index 000000000..742ab5557 --- /dev/null +++ b/crates/session-store/src/legacy_session_log/tests/fixtures/v1-system-inputs.jsonl @@ -0,0 +1,3 @@ +{"kind":"system_item","ts":1,"item":{"kind":"skill_activation","name":"legacy-skill","body":"legacy skill body"}} +{"kind":"user_input","ts":2,"segments":[{"kind":"text","content":"legacy user body"}],"extensions":[{"domain":"old","payload":{"kept":true}}]} +{"kind":"assistant_item","ts":3,"item":{"kind":"message","role":"assistant","content":[{"kind":"text","text":"legacy reply"}]}} diff --git a/crates/session-store/src/legacy_session_log/tests/fixtures/v4-system-inputs.jsonl b/crates/session-store/src/legacy_session_log/tests/fixtures/v4-system-inputs.jsonl new file mode 100644 index 000000000..59a391d01 --- /dev/null +++ b/crates/session-store/src/legacy_session_log/tests/fixtures/v4-system-inputs.jsonl @@ -0,0 +1,8 @@ +{"kind":"annotated_system_item","ts":101,"entry":{"item":{"kind":"journal_startup","body":"journal body\nunchanged","snapshot":{"entries":[{"text":"private snapshot"}]},"snapshot_sha256":"snapshot-digest","prompt_provenance":{"workspace_id":"workspace","source_digest":"source","projection_digest":"projection","logical_name":"journal_startup"}},"metadata":{"entry_id":"journal-id","origin":{"kind":"legacy_unknown"},"derivation":{"sources":["source-id"]}}},"extensions":[{"domain":"migration-fixture","payload":{"kept":true}}]} +{"kind":"annotated_system_item","ts":102,"entry":{"item":{"kind":"resident_summary_refresh","body":"resident body"},"metadata":{"entry_id":"resident-id","origin":{"kind":"derived_summary"}}}} +{"kind":"annotated_system_item","ts":103,"entry":{"item":{"kind":"subject_behavior_refresh","body":"rendered subject body","behavior_md":"exact behavior","prompt_provenance":{"source_digest":"subject-source","projection_digest":"subject-projection","logical_name":"subject_behavior"}},"metadata":{"entry_id":"subject-id","origin":{"kind":"backend_instruction","operation_id":"op"}}}} +{"kind":"annotated_system_item","ts":104,"entry":{"item":{"kind":"skill_activation","body":"skill body","name":"example-skill"},"metadata":{"entry_id":"skill-id","origin":{"kind":"legacy_unknown"}}}} +{"kind":"annotated_system_item","ts":105,"entry":{"item":{"kind":"feature_invocation_result","body":"result body","result":{"invocation_id":"invoke-id","identity":"builtin:test/prepare","status":"outcome_unknown","message":"may have committed","context":null}},"metadata":{"entry_id":"result-id","origin":{"kind":"legacy_unknown"}}}} +{"kind":"annotated_user_input","ts":106,"segments":[],"history":[{"item":{"kind":"message","role":"system","content":[{"kind":"text","text":"[Journal] prefix is not Feature evidence"}]},"metadata":{"entry_id":"flat-journal","origin":{"kind":"journal_context"},"derivation":{"sources":["journal-id"]}}},{"item":{"kind":"message","role":"system","content":[{"kind":"text","text":"summary body"}]},"metadata":{"entry_id":"flat-summary","origin":{"kind":"compaction_summary"}}},{"item":{"kind":"message","role":"system","content":[{"kind":"text","text":"[Skill: example] never infer Feature"}]},"metadata":{"entry_id":"flat-other","origin":{"kind":"legacy_unknown"}}}]} +{"kind":"annotated_system_item","ts":107,"entry":{"item":{"kind":"notification","message":"raw message","body":"wrapped message"},"metadata":{"entry_id":"core-id","origin":{"kind":"legacy_unknown"}}}} +{"kind":"annotated_system_item","ts":108,"entry":{"item":{"kind":"input","body":"already generic body"},"metadata":{"entry_id":"generic-id","origin":{"kind":"provided","producer":"future-owner"},"derivation":{"sources":["source-id"]},"system_input":{"usage":{"recording_material":"include","memory_evidence":"exclude","observation":"readable","compaction":"retain"},"feature_payload":{"schema":"future:unknown","version":99,"value":{"opaque":"retained"}},"public":null}}}} diff --git a/crates/session-store/src/legacy_session_log/tests/fixtures/v5-role-metadata.jsonl b/crates/session-store/src/legacy_session_log/tests/fixtures/v5-role-metadata.jsonl new file mode 100644 index 000000000..533bf518d --- /dev/null +++ b/crates/session-store/src/legacy_session_log/tests/fixtures/v5-role-metadata.jsonl @@ -0,0 +1,5 @@ +{"kind":"annotated_system_item","ts":201,"entry":{"item":{"kind":"input","body":"frozen v5 reference"},"metadata":{"entry_id":"reference-v5","origin":{"kind":"provided","producer":"owner-v5"},"system_input":{"usage":{"recording_material":"exclude","memory_evidence":"exclude","observation":"hidden","compaction":"reference_input"},"feature_payload":{"schema":"future:owner","version":8,"value":{"system_input":"opaque owner field must not be renamed"}},"public":null}}}} +{"kind":"annotated_assistant_item","ts":202,"entry":{"item":{"kind":"tool_call","call_id":"original-call","name":"ArbitraryTool","arguments":"{}"},"metadata":{"entry_id":"original-call-entry","origin":{"kind":"model_output","worker":{"worker_id":"body"}}}}} +{"kind":"annotated_tool_result","ts":203,"entry":{"item":{"kind":"tool_result","call_id":"original-call","summary":"original result"},"metadata":{"entry_id":"original-result-entry","origin":{"kind":"tool_output","worker":{"worker_id":"body"}}}}} +{"kind":"annotated_assistant_item","ts":204,"entry":{"item":{"kind":"tool_call","call_id":"derived-call","name":"ArbitraryTool","arguments":"{}"},"metadata":{"entry_id":"derived-call-entry","origin":{"kind":"derived_summary"},"derivation":{"sources":["original-call-entry","original-result-entry"]}}}} +{"kind":"annotated_tool_result","ts":205,"entry":{"item":{"kind":"tool_result","call_id":"derived-call","summary":"derived retrieval result"},"metadata":{"entry_id":"derived-result-entry","origin":{"kind":"derived_summary"},"derivation":{"sources":["original-call-entry","original-result-entry"]}}}} diff --git a/crates/session-store/src/legacy_session_log/tests/v5.rs b/crates/session-store/src/legacy_session_log/tests/v5.rs new file mode 100644 index 000000000..847b81bf0 --- /dev/null +++ b/crates/session-store/src/legacy_session_log/tests/v5.rs @@ -0,0 +1,703 @@ +//! Fixed old wire bytes, not fixtures generated by the current serializer. +use super::*; +use crate::{FsStore, InputCompactionUse, Store, WorkerSessionStore}; +use serde_json::{Value, json}; +use std::{fs, path::Path}; + +const V4: &str = include_str!("fixtures/v4-system-inputs.jsonl"); +const V1: &str = include_str!("fixtures/v1-system-inputs.jsonl"); + +fn decode_fixture(version: u32, source: &str, segment: SegmentId) -> Vec { + source + .lines() + .enumerate() + .map(|(index, line)| decode_entry(version, line, SessionId::nil(), segment, index).unwrap()) + .collect() +} + +fn encode(entries: &[LogEntry]) -> Vec { + let mut bytes = Vec::new(); + for entry in entries { + serde_json::to_writer(&mut bytes, entry).unwrap(); + bytes.push(b'\n'); + } + bytes +} + +fn migrate(entries: &mut [LogEntry]) { + for entry in entries { + migrate_system_inputs(entry).unwrap(); + entry.validate_input_metadata().unwrap(); + } +} + +fn write_worker(root: &Path, version: u32, source: &[u8]) -> (SessionId, SegmentId) { + let session = SessionId::now_v7(); + let segment = SegmentId::from_u128(1); + fs::create_dir_all(root.join("segments")).unwrap(); + fs::write( + root.join("session.json"), + serde_json::to_vec(&json!({ + "schema_version": version, "session_id": session, + })) + .unwrap(), + ) + .unwrap(); + fs::write( + root.join("segments").join(format!("{segment}.jsonl")), + source, + ) + .unwrap(); + (session, segment) +} + +fn assert_marker(root: &Path, version: u32) { + let manifest: Value = + serde_json::from_slice(&fs::read(root.join("session.json")).unwrap()).unwrap(); + assert_eq!(manifest["schema_version"], version); +} + +#[test] +fn v4_fixed_wire_preserves_identity_body_payload_public_and_independent_policy() { + let mut entries = decode_fixture(4, V4, SegmentId::nil()); + let before = entries.clone(); + migrate(&mut entries); + let schemas = [ + "journal:startup", + "memory:resident-summary", + "subject:behavior", + "skill:activation", + "feature:invocation-result", + ]; + let old_wire: Vec = V4 + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + for index in 0..5 { + let LogEntry::AnnotatedSystemItem { + ts, + entry, + extensions, + } = &entries[index] + else { + panic!() + }; + let old = &old_wire[index]; + let old_item = &old["entry"]["item"]; + let old_metadata: LoggedSessionHistoryMetadata = + serde_json::from_value(old["entry"]["metadata"].clone()).unwrap(); + let old_ext: Vec = old + .get("extensions") + .map(|value| serde_json::from_value(value.clone()).unwrap()) + .unwrap_or_default(); + assert_eq!(*ts, old["ts"].as_u64().unwrap()); + assert_eq!(extensions, &old_ext); + assert!(matches!(entry.item, SystemItem::Input { .. })); + assert_eq!( + entry.item.history_text(), + old_item["body"].as_str().unwrap() + ); + assert_eq!(entry.metadata.entry_id, old_metadata.entry_id); + assert_eq!(entry.metadata.origin, old_metadata.origin); + assert_eq!(entry.metadata.derivation, old_metadata.derivation); + let metadata = entry.metadata.input_metadata.as_ref().unwrap(); + let public = metadata.public_item().unwrap(); + assert_eq!(public.kind, old_item["kind"].as_str().unwrap()); + assert_eq!(public.body, old_item["body"].as_str().unwrap()); + assert_eq!( + public.presentation, + crate::PublicSystemPresentation::Context + ); + assert_eq!( + metadata.usage(), + migration_policy(if index == 0 { + InputCompactionUse::ReferenceInput + } else { + InputCompactionUse::Retain + }) + ); + let payload = metadata.feature_payload().unwrap(); + assert_eq!(payload.schema(), schemas[index]); + assert_eq!(payload.version(), 1); + let value: Value = payload.decode(schemas[index], 1).unwrap(); + // Independently require each frozen owner's typed v1 payload contract. + match index { + 0 => { + let _: JournalStartupPayload = payload.decode(schemas[index], 1).unwrap(); + } + 1 => { + let _: ResidentSummaryPayload = payload.decode(schemas[index], 1).unwrap(); + } + 2 => { + let _: SubjectBehaviorPayload = payload.decode(schemas[index], 1).unwrap(); + } + 3 => { + let _: SkillActivationPayload = payload.decode(schemas[index], 1).unwrap(); + } + 4 => { + let _: InvocationResultPayload = payload.decode(schemas[index], 1).unwrap(); + } + _ => unreachable!(), + } + let mut expected = old_item.clone(); + expected.as_object_mut().unwrap().remove("kind"); + expected.as_object_mut().unwrap().remove("body"); + // Optional provenance is an explicit Option in the v1 payload contract. + if index == 1 { + expected["prompt_provenance"] = Value::Null; + } + if index == 4 { + let result: protocol::FeatureInvocationResult = + serde_json::from_value(old_item["result"].clone()).unwrap(); + let typed: InvocationResultPayload = payload.decode(schemas[index], 1).unwrap(); + assert_eq!(typed.result, result); + // Preserve the protocol's typed serialization contract, including + // its canonical representation of optional result context. + expected["result"] = serde_json::to_value(result).unwrap(); + } + assert_eq!(value, expected); + assert!(value.get("body").is_none()); + } + let LogEntry::AnnotatedUserInput { history, .. } = &entries[5] else { + panic!() + }; + let LogEntry::AnnotatedUserInput { history: old, .. } = &before[5] else { + panic!() + }; + for (index, compaction) in [ + InputCompactionUse::ReferenceInput, + InputCompactionUse::OpeningSummary, + InputCompactionUse::Retain, + ] + .into_iter() + .enumerate() + { + assert_eq!(history[index].item, old[index].item); + assert_eq!( + history[index].metadata.entry_id, + old[index].metadata.entry_id + ); + assert_eq!(history[index].metadata.origin, old[index].metadata.origin); + assert_eq!( + history[index].metadata.derivation, + old[index].metadata.derivation + ); + let metadata = history[index].metadata.input_metadata.as_ref().unwrap(); + assert_eq!(metadata.usage(), migration_policy(compaction)); + assert!(metadata.public_item().is_none()); + assert!(metadata.feature_payload().is_none()); + } + // Core notification is unchanged; existing generic Input and unknown valid + // owner payload are retained without decoding/reclassifying a Feature. + assert_eq!(encode(&entries[6..]), encode(&before[6..])); + let once = encode(&entries); + migrate(&mut entries); + assert_eq!(encode(&entries), once); +} + +#[test] +fn v1_through_v4_worker_migration_reopen_and_mixed_file_retry() { + for version in 1..=4 { + let source = if version <= 2 { V1 } else { V4 }; + let root = tempfile::tempdir().unwrap(); + let (session, segment) = write_worker(root.path(), version, source.as_bytes()); + let path = root + .path() + .join("segments") + .join(format!("{segment}.jsonl")); + // Simulate an interrupted publication: v5 records precede legacy records + // in one file, and a fully new file coexists with an untouched old file. + let mut converted = decode_fixture(version, source, segment); + migrate(&mut converted); + let mut mixed = encode(&converted[..1]); + mixed.extend_from_slice(source.split_once('\n').unwrap().1.as_bytes()); + fs::write(&path, mixed).unwrap(); + let new_path = root + .path() + .join("segments") + .join(format!("{}.jsonl", SegmentId::from_u128(2))); + let new_bytes = encode(&converted); + fs::write(&new_path, &new_bytes).unwrap(); + let old_path = root + .path() + .join("segments") + .join(format!("{}.jsonl", SegmentId::from_u128(3))); + fs::write(&old_path, source).unwrap(); + + let store = WorkerSessionStore::new(root.path()).unwrap(); + assert_marker(root.path(), 6); + let restored = store.read_all(session, segment).unwrap(); + assert_eq!(encode(&restored), new_bytes); + assert_eq!(fs::read(&new_path).unwrap(), new_bytes); + let once = fs::read(&path).unwrap(); + let old_once = fs::read(&old_path).unwrap(); + drop(store); + let reopened = WorkerSessionStore::new(root.path()).unwrap(); + assert_eq!(encode(&reopened.read_all(session, segment).unwrap()), once); + assert_eq!(fs::read(&path).unwrap(), once); + assert_eq!(fs::read(&old_path).unwrap(), old_once); + } +} + +#[test] +fn all_segments_preflight_keeps_old_bytes_on_decode_or_conversion_failure() { + let mut oversized: Value = serde_json::from_str(V4.lines().next().unwrap()).unwrap(); + oversized["entry"]["item"]["snapshot"] = + json!({"large": "x".repeat(crate::input_metadata::MAX_FEATURE_PAYLOAD_BYTES + 1)}); + let mut unknown: Value = serde_json::from_str(V4.lines().next().unwrap()).unwrap(); + unknown["entry"]["item"]["kind"] = json!("unsupported_future_feature"); + let mut malformed = unknown.clone(); + malformed["entry"]["item"] = json!({"kind":"journal_startup", "body":"missing snapshot"}); + let mut missing_metadata: Value = serde_json::from_str(V4.lines().last().unwrap()).unwrap(); + missing_metadata["entry"]["metadata"] + .as_object_mut() + .unwrap() + .remove("system_input"); + let mut missing_input = missing_metadata.clone(); + missing_input["entry"]["metadata"]["origin"] = json!({"kind":"legacy_unknown"}); + let mut invalid_policy: Value = serde_json::from_str(V4.lines().last().unwrap()).unwrap(); + invalid_policy["entry"]["metadata"]["system_input"]["usage"]["compaction"] = json!("unknown"); + let mut invalid_payload = invalid_policy.clone(); + invalid_payload["entry"]["metadata"]["system_input"]["usage"]["compaction"] = json!("retain"); + invalid_payload["entry"]["metadata"]["system_input"]["feature_payload"]["version"] = json!(0); + let mut flat_provided: Value = serde_json::from_str(V4.lines().nth(5).unwrap()).unwrap(); + flat_provided["history"][0]["metadata"]["origin"] = + json!({"kind":"provided", "producer":"owner"}); + for bad in [ + "{broken-json}\n".to_owned(), + format!("{}\n", oversized), + format!("{}\n", unknown), + format!("{}\n", malformed), + format!("{}\n", missing_metadata), + format!("{}\n", invalid_policy), + format!("{}\n", invalid_payload), + format!("{}\n", flat_provided), + format!("{}\n", missing_input), + ] { + let root = tempfile::tempdir().unwrap(); + let (_, segment) = write_worker(root.path(), 4, V4.as_bytes()); + let first = root + .path() + .join("segments") + .join(format!("{segment}.jsonl")); + let later = root + .path() + .join("segments") + .join(format!("{}.jsonl", SegmentId::from_u128(2))); + fs::write(&later, &bad).unwrap(); + let marker = fs::read(root.path().join("session.json")).unwrap(); + assert!(WorkerSessionStore::new(root.path()).is_err()); + assert_eq!(fs::read(&first).unwrap(), V4.as_bytes()); + assert_eq!(fs::read(&later).unwrap(), bad.as_bytes()); + assert_eq!(fs::read(root.path().join("session.json")).unwrap(), marker); + } +} + +#[test] +fn unsupported_schema_preserves_all_old_bytes() { + for version in [0, 7, 99] { + let root = tempfile::tempdir().unwrap(); + let (_, segment) = write_worker(root.path(), version, V4.as_bytes()); + let path = root + .path() + .join("segments") + .join(format!("{segment}.jsonl")); + let marker = fs::read(root.path().join("session.json")).unwrap(); + assert!(WorkerSessionStore::new(root.path()).is_err()); + assert_eq!(fs::read(&path).unwrap(), V4.as_bytes()); + assert_eq!(fs::read(root.path().join("session.json")).unwrap(), marker); + } +} + +#[test] +fn standalone_store_uses_same_v1_through_v4_migration_and_reopens() { + // None represents the existing unmarked annotated standalone layout (v3). + for version in [None, Some(1), Some(2), Some(3), Some(4)] { + let root = tempfile::tempdir().unwrap(); + let session = SessionId::now_v7(); + let segment = SegmentId::from_u128(1); + let dir = root.path().join(session.to_string()); + fs::create_dir_all(&dir).unwrap(); + let source = if version.is_some_and(|v| v <= 2) { + V1 + } else { + V4 + }; + let path = dir.join(format!("{segment}.jsonl")); + fs::write(&path, source).unwrap(); + if let Some(version) = version { + fs::write(dir.join("session-schema.json"), version.to_string()).unwrap(); + } + let store = FsStore::new(root.path()).unwrap(); + let entries = store.read_all(session, segment).unwrap(); + for entry in entries { + entry.validate_input_metadata().unwrap(); + } + assert_eq!( + fs::read_to_string(dir.join("session-schema.json")) + .unwrap() + .trim(), + "6" + ); + let once = fs::read(&path).unwrap(); + drop(store); + FsStore::new(root.path()).unwrap(); + assert_eq!(fs::read(&path).unwrap(), once); + } +} + +#[test] +fn compaction_origin_upgrade_is_retained_only_for_pre_v4_sources() { + for version in [3, 4] { + let root = tempfile::tempdir().unwrap(); + let creation = seed( + LoggedSessionHistoryOrigin::DerivedSummary, + true, + crate::LoggedRole::System, + "[Compacted context summary]\n\nold summary", + ); + let inherited = seed( + LoggedSessionHistoryOrigin::DerivedSummary, + false, + crate::LoggedRole::System, + "[Compacted context summary]\n\nold summary", + ); + let (session, segment) = + write_worker(root.path(), version, &encode(&[creation, inherited])); + let store = WorkerSessionStore::new(root.path()).unwrap(); + for record in store.read_all(session, segment).unwrap() { + let LogEntry::AnnotatedSegmentStart { history, .. } = record else { + panic!() + }; + let metadata = &history[0].metadata; + assert_eq!(metadata.entry_id.0, "summary"); + assert_eq!(metadata.derivation.as_ref().unwrap().sources[0].0, "source"); + assert_eq!( + metadata.origin, + if version == 3 { + LoggedSessionHistoryOrigin::CompactionSummary + } else { + LoggedSessionHistoryOrigin::DerivedSummary + } + ); + assert_eq!( + metadata.input_metadata.as_ref().unwrap().usage().compaction, + if version == 3 { + InputCompactionUse::OpeningSummary + } else { + InputCompactionUse::Retain + } + ); + } + } +} + +#[test] +fn standalone_preflight_and_unsupported_marker_leave_sources_unchanged() { + for version in [4, 99] { + let root = tempfile::tempdir().unwrap(); + let session = SessionId::now_v7(); + let dir = root.path().join(session.to_string()); + fs::create_dir_all(&dir).unwrap(); + let first = dir.join(format!("{}.jsonl", SegmentId::from_u128(1))); + let later = dir.join(format!("{}.jsonl", SegmentId::from_u128(2))); + fs::write(&first, V4).unwrap(); + fs::write(&later, "{broken-json}\n").unwrap(); + let marker = version.to_string(); + fs::write(dir.join("session-schema.json"), &marker).unwrap(); + assert!(FsStore::new(root.path()).is_err()); + assert_eq!(fs::read(&first).unwrap(), V4.as_bytes()); + assert_eq!(fs::read_to_string(&later).unwrap(), "{broken-json}\n"); + assert_eq!( + fs::read_to_string(dir.join("session-schema.json")).unwrap(), + marker + ); + } +} + +#[test] +fn current_v5_rejects_all_legacy_feature_kinds_without_rewriting_sources() { + for line in V4.lines().take(5) { + // Current serde, writable open and read-only restore share the same + // current wire contract. None may dispatch to the old Feature decoder. + assert!(serde_json::from_str::(line).is_err()); + let bytes = format!("{line}\n"); + let root = tempfile::tempdir().unwrap(); + let (session, segment) = write_worker(root.path(), 6, bytes.as_bytes()); + let path = root + .path() + .join("segments") + .join(format!("{segment}.jsonl")); + let marker = fs::read(root.path().join("session.json")).unwrap(); + let readonly = WorkerSessionStore::open_read_only(root.path()).unwrap(); + assert!(readonly.read_all_read_only(session, segment).is_err()); + assert!(WorkerSessionStore::new(root.path()).is_err()); + assert_eq!(fs::read(&path).unwrap(), bytes.as_bytes()); + assert_eq!(fs::read(root.path().join("session.json")).unwrap(), marker); + + let standalone = tempfile::tempdir().unwrap(); + let dir = standalone.path().join(session.to_string()); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{segment}.jsonl")); + fs::write(&path, &bytes).unwrap(); + fs::write(dir.join("session-schema.json"), "5").unwrap(); + assert!(FsStore::new(standalone.path()).is_err()); + assert_eq!(fs::read(&path).unwrap(), bytes.as_bytes()); + assert_eq!( + fs::read_to_string(dir.join("session-schema.json")).unwrap(), + "5" + ); + } +} + +#[test] +fn v1_v2_feature_wire_uses_the_same_typed_payloads_and_stable_legacy_identity() { + let segment = SegmentId::from_u128(42); + for version in [1, 2] { + for (index, line) in V4.lines().take(5).enumerate() { + let annotated: Value = serde_json::from_str(line).unwrap(); + let wire = json!({ + "kind": "system_item", "ts": annotated["ts"], + "item": annotated["entry"]["item"], + "extensions": [{"domain":"old-owner", "payload":{"kept":true}}], + }) + .to_string(); + let entry = decode_entry(version, &wire, SessionId::nil(), segment, index).unwrap(); + let retry = decode_entry(version, &wire, SessionId::nil(), segment, index).unwrap(); + assert_eq!(encode(&[entry.clone()]), encode(&[retry])); + let LogEntry::AnnotatedSystemItem { + ts, + entry, + extensions, + } = entry + else { + panic!() + }; + assert_eq!(ts, annotated["ts"].as_u64().unwrap()); + assert!(matches!(entry.item, SystemItem::Input { .. })); + assert_eq!( + entry.item.history_text(), + annotated["entry"]["item"]["body"].as_str().unwrap() + ); + assert_eq!( + entry.metadata.entry_id, + legacy_metadata(segment, index, 0).entry_id + ); + assert_eq!( + entry.metadata.origin, + LoggedSessionHistoryOrigin::LegacyUnknown + ); + assert!(entry.metadata.derivation.is_none()); + assert_eq!(extensions[0].domain, "old-owner"); + assert_eq!(extensions[0].payload, json!({"kept":true})); + let LogEntry::AnnotatedSystemItem { entry: v4, .. } = + decode_entry(4, line, SessionId::nil(), segment, index).unwrap() + else { + panic!() + }; + assert_eq!(entry.metadata.input_metadata, v4.metadata.input_metadata); + } + } +} + +#[test] +fn migration_rejects_malformed_typed_feature_fields_instead_of_retaining_unchecked_json() { + let mut items: Vec = V4 + .lines() + .take(5) + .map(|line| serde_json::from_str::(line).unwrap()["entry"]["item"].clone()) + .collect(); + items[0]["snapshot_sha256"] = json!(17); + items[1]["prompt_provenance"] = json!({"logical_name":"missing digests"}); + items[2]["behavior_md"] = json!(["not a string"]); + items[3]["name"] = json!({"not":"a name"}); + items[4]["result"]["status"] = json!("unsupported_status"); + for version in 1..=4 { + for item in &items { + let wire = if version <= 2 { + json!({"kind":"system_item", "ts":1, "item":item}) + } else { + json!({"kind":"annotated_system_item", "ts":1, "entry":{ + "item":item, "metadata":{ + "entry_id":"malformed-id", "origin":{"kind":"legacy_unknown"}, + }, + }}) + } + .to_string(); + assert!(decode_entry(version, &wire, SessionId::nil(), SegmentId::nil(), 0).is_err()); + } + } +} + +#[test] +fn frozen_behavior_counter_is_accepted_only_at_the_old_schema_boundary() { + for version in 1..=4 { + let item = json!({ + "kind":"subject_behavior_refresh", "body":"exact archived body", + "behavior_revision":7, + }); + let wire = if version <= 2 { + json!({"kind":"system_item", "ts":1, "item":item}) + } else { + json!({"kind":"annotated_system_item", "ts":1, "entry":{ + "item":item, "metadata":{ + "entry_id":"behavior-id", "origin":{"kind":"legacy_unknown"}, + }, + }}) + } + .to_string(); + assert!(serde_json::from_str::(&wire).is_err()); + let LogEntry::AnnotatedSystemItem { entry, .. } = + decode_entry(version, &wire, SessionId::nil(), SegmentId::nil(), 0).unwrap() + else { + panic!() + }; + assert_eq!(entry.item.history_text(), "exact archived body"); + let payload = entry + .metadata + .input_metadata + .as_ref() + .unwrap() + .feature_payload() + .unwrap(); + let typed: SubjectBehaviorPayload = payload.decode("subject:behavior", 1).unwrap(); + assert_eq!(typed.behavior_md, ""); + assert!(typed.prompt_provenance.is_none()); + assert_eq!( + serde_json::to_value(typed).unwrap(), + json!({ + "behavior_md":"", "prompt_provenance":null, + }) + ); + } +} + +#[test] +fn current_schema_rejects_generic_input_without_metadata_instead_of_migrating_it() { + let mut missing: Value = serde_json::from_str(V4.lines().last().unwrap()).unwrap(); + missing["entry"]["metadata"] + .as_object_mut() + .unwrap() + .remove("system_input"); + let bytes = format!("{}\n", missing); + let root = tempfile::tempdir().unwrap(); + let (_, segment) = write_worker(root.path(), 6, bytes.as_bytes()); + assert!(WorkerSessionStore::new(root.path()).is_err()); + assert_eq!( + fs::read( + root.path() + .join("segments") + .join(format!("{segment}.jsonl")) + ) + .unwrap(), + bytes.as_bytes() + ); + assert_marker(root.path(), 6); +} + +const V5: &str = include_str!("fixtures/v5-role-metadata.jsonl"); + +#[test] +fn v5_metadata_and_derived_tool_context_upgrade_without_changing_roles_or_original_evidence() { + for standalone in [false, true] { + let root = tempfile::tempdir().unwrap(); + let (session, segment) = if standalone { + let session = SessionId::now_v7(); + let segment = SegmentId::now_v7(); + let dir = root.path().join(session.to_string()); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("session-schema.json"), b"5").unwrap(); + fs::write(dir.join(format!("{segment}.jsonl")), V5).unwrap(); + (session, segment) + } else { + write_worker(root.path(), 5, V5.as_bytes()) + }; + let entries = if standalone { + FsStore::new(root.path()) + .unwrap() + .read_all(session, segment) + .unwrap() + } else { + WorkerSessionStore::new(root.path()) + .unwrap() + .read_all(session, segment) + .unwrap() + }; + let originals: Vec = V5 + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(entries.len(), originals.len()); + for (index, (entry, old)) in entries.iter().zip(originals).enumerate() { + let wire = serde_json::to_value(entry).unwrap(); + assert_eq!(wire["ts"], old["ts"]); + assert_eq!(wire["entry"]["item"], old["entry"]["item"]); + for field in ["entry_id", "origin", "derivation"] { + assert_eq!( + wire["entry"]["metadata"][field], + old["entry"]["metadata"][field] + ); + } + let metadata = &wire["entry"]["metadata"]; + if index == 0 { + assert_eq!( + metadata["input_metadata"], + old["entry"]["metadata"]["system_input"] + ); + assert_eq!( + metadata["input_metadata"]["feature_payload"]["value"]["system_input"], + "opaque owner field must not be renamed" + ); + } else if index < 3 { + assert!( + metadata.get("input_metadata").is_none(), + "ordinary model Tool originals must not acquire exclusion policy" + ); + } else { + assert_eq!( + metadata["input_metadata"]["usage"], + json!({ + "recording_material":"exclude", "memory_evidence":"exclude", "observation":"hidden", "compaction":"retain" + }) + ); + } + } + let before = encode(&entries); + let reopened = if standalone { + FsStore::new(root.path()) + .unwrap() + .read_all(session, segment) + .unwrap() + } else { + WorkerSessionStore::new(root.path()) + .unwrap() + .read_all(session, segment) + .unwrap() + }; + assert_eq!(encode(&reopened), before); + } +} + +#[test] +fn legacy_metadata_alias_conflict_fails_preflight_without_dropping_either_value() { + let mut bad: Value = serde_json::from_str(V5.lines().next().unwrap()).unwrap(); + bad["entry"]["metadata"]["input_metadata"] = bad["entry"]["metadata"]["system_input"].clone(); + for version in [4, 5] { + let root = tempfile::tempdir().unwrap(); + let (_, segment) = write_worker(root.path(), version, V5.as_bytes()); + let path = root + .path() + .join("segments") + .join(format!("{segment}.jsonl")); + let later = root + .path() + .join("segments") + .join(format!("{}.jsonl", SegmentId::from_u128(2))); + let bad_bytes = format!("{bad}\n"); + fs::write(&later, &bad_bytes).unwrap(); + assert!(WorkerSessionStore::new(root.path()).is_err()); + assert_eq!(fs::read(path).unwrap(), V5.as_bytes()); + assert_eq!(fs::read(later).unwrap(), bad_bytes.as_bytes()); + assert_marker(root.path(), version); + } +} diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index a164159d9..d767defca 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -41,6 +41,7 @@ pub(crate) fn read_without_atime_bounded(path: &Path, max_bytes: u64) -> io::Res pub mod event_trace; pub mod fs_store; pub mod history; +pub mod input_metadata; mod legacy_session_log; pub mod logged_item; mod paste_artifact; @@ -65,6 +66,10 @@ pub use history::{ LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, LoggedWorkerSubject, }; +pub use input_metadata::{ + FeatureInputPayload, HistoryInputMetadata, HistoryInputUsePolicy, InputCompactionUse, + InputEvidenceUse, InputMetadataError, InputObservationUse, SystemInputEnvelope, +}; pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged}; pub use paste_artifact::PasteArtifactLimits; pub use public_index::{ @@ -98,7 +103,8 @@ pub use segment::{ pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state}; pub use store::{Store, StoreError}; pub use system_item::{ - PromptRenderProvenance, SystemItem, SystemReminder, SystemReminderSource, render_worker_event, + PromptRenderProvenance, PublicSystemItem, PublicSystemPresentation, SystemItem, SystemReminder, + SystemReminderSource, render_worker_event, }; pub use uploaded_file::{ DEFAULT_MAX_FILES_PER_SUBMISSION, DEFAULT_MAX_SESSION_ARTIFACT_BYTES, diff --git a/crates/session-store/src/public_index.rs b/crates/session-store/src/public_index.rs index 24932e397..eff4c04ad 100644 --- a/crates/session-store/src/public_index.rs +++ b/crates/session-store/src/public_index.rs @@ -1296,6 +1296,7 @@ mod tests { LoggedHistoryEntry { item, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId(id.to_owned()), origin, derivation: None, @@ -1537,6 +1538,7 @@ mod tests { prompt_provenance: None, }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId("system-item".into()), origin: LoggedSessionHistoryOrigin::BackendInstruction { operation_id: None, diff --git a/crates/session-store/src/public_material.rs b/crates/session-store/src/public_material.rs index 6487bdade..150852174 100644 --- a/crates/session-store/src/public_material.rs +++ b/crates/session-store/src/public_material.rs @@ -148,15 +148,24 @@ struct SegmentHeader { file_len: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PublicMaterialUse { + Recording, + MemoryEvidence, +} + /// A fixed material catalog. Opening a new catalog observes newly appended /// records; reading a body through this catalog verifies its saved digest and /// byte length again. Missing/corrected/deleted sources are explicit failures. +/// Evidence eligibility never publishes private model text or Feature payload: +/// System inputs also require an explicit public projection for every public use. pub struct SessionPublicMaterialReader { session_id: SessionId, active_segment_id: SegmentId, layout: Layout, limits: PublicMaterialLimits, materials: Vec, + purpose: PublicMaterialUse, retention: Option, } @@ -180,7 +189,13 @@ impl SessionPublicMaterialReader { active_segment_id: SegmentId, limits: PublicMaterialLimits, ) -> Result { - Self::open_worker_guarded(session_root, active_segment_id, limits, None) + Self::open_worker_guarded( + session_root, + active_segment_id, + limits, + None, + PublicMaterialUse::Recording, + ) } /// Uses the Host's aggregate-external control root for the whole scan and every body read. @@ -191,7 +206,30 @@ impl SessionPublicMaterialReader { limits: PublicMaterialLimits, retention: crate::retention::SessionRetentionStore, ) -> Result { - Self::open_worker_guarded(session_root, active_segment_id, limits, Some(retention)) + Self::open_worker_for_use( + session_root, + active_segment_id, + limits, + retention, + PublicMaterialUse::Recording, + ) + } + + /// Selects the evidence purpose independently of public display and observation. + pub fn open_worker_for_use( + session_root: &Path, + active_segment_id: SegmentId, + limits: PublicMaterialLimits, + retention: crate::retention::SessionRetentionStore, + purpose: PublicMaterialUse, + ) -> Result { + Self::open_worker_guarded( + session_root, + active_segment_id, + limits, + Some(retention), + purpose, + ) } fn open_worker_guarded( @@ -199,6 +237,7 @@ impl SessionPublicMaterialReader { active_segment_id: SegmentId, limits: PublicMaterialLimits, retention: Option, + purpose: PublicMaterialUse, ) -> Result { let store = WorkerSessionStore::open_read_only(session_root) .map_err(|_| PublicMaterialError::Unavailable)?; @@ -212,6 +251,7 @@ impl SessionPublicMaterialReader { Layout::Worker(session_root.into()), limits, retention, + purpose, ) } @@ -237,6 +277,25 @@ impl SessionPublicMaterialReader { active_segment_id: SegmentId, limits: PublicMaterialLimits, retention: crate::retention::SessionRetentionStore, + ) -> Result { + Self::open_fs_for_use( + sessions_root, + session_id, + active_segment_id, + limits, + retention, + PublicMaterialUse::Recording, + ) + } + + /// Uses retained originals with the independently selected evidence policy. + pub fn open_fs_for_use( + sessions_root: &Path, + session_id: SessionId, + active_segment_id: SegmentId, + limits: PublicMaterialLimits, + retention: crate::retention::SessionRetentionStore, + purpose: PublicMaterialUse, ) -> Result { Self::open( session_id, @@ -244,6 +303,7 @@ impl SessionPublicMaterialReader { Layout::Fs(sessions_root.join(session_id.to_string())), limits, Some(retention), + purpose, ) } @@ -263,6 +323,7 @@ impl SessionPublicMaterialReader { layout: Layout, limits: PublicMaterialLimits, retention: Option, + purpose: PublicMaterialUse, ) -> Result { let _guard = retention .as_ref() @@ -310,11 +371,10 @@ impl SessionPublicMaterialReader { let source_ids: Option> = if compact { let summaries: Vec<_> = history .iter() - .filter(|e| { - matches!( - e.metadata.origin, - LoggedSessionHistoryOrigin::CompactionSummary - ) + .filter(|entry| { + entry.metadata.input_metadata.as_ref().is_some_and(|input| { + input.usage().compaction == crate::InputCompactionUse::OpeningSummary + }) }) .collect(); if summaries.len() != 1 { @@ -435,23 +495,19 @@ impl SessionPublicMaterialReader { } => turn = *total_turn_count, _ => {} } + let system_history; let entries: &[LoggedHistoryEntry] = match &record { LogEntry::AnnotatedSegmentStart { history, .. } | LogEntry::AnnotatedUserInput { history, .. } => history, LogEntry::AnnotatedAssistantItem { entry, .. } | LogEntry::AnnotatedToolResult { entry, .. } | LogEntry::ToolResultCorrected { entry, .. } => std::slice::from_ref(entry), + LogEntry::AnnotatedSystemItem { entry, .. } => { + system_history = entry.to_history_entry(); + std::slice::from_ref(&system_history) + } _ => &[], }; - if let LogEntry::AnnotatedSystemItem { entry, .. } = &record { - charge(&mut examined, limits.max_entries)?; - if entry.metadata.entry_id.0.is_empty() - || !history_seen.insert(entry.metadata.entry_id.0.clone()) - { - return Err(PublicMaterialError::Inconsistent); - } - history_ids.push(entry.metadata.entry_id.0.clone()); - } for (history_index, entry) in entries.iter().enumerate() { charge(&mut examined, limits.max_entries)?; let id = &entry.metadata.entry_id.0; @@ -468,7 +524,7 @@ impl SessionPublicMaterialReader { if in_run && ai_materialized(&entry.item) { safe = true; } - let Some((sha256, total_bytes)) = canonical_metadata(entry)? else { + let Some((sha256, total_bytes)) = canonical_metadata(entry, purpose)? else { continue; }; let location = Location { @@ -621,6 +677,7 @@ impl SessionPublicMaterialReader { layout, limits, materials, + purpose, retention: retention.clone(), }) } @@ -660,6 +717,7 @@ impl SessionPublicMaterialReader { let Some((_, record)) = reader.next()? else { return Err(PublicMaterialError::Missing); }; + let system_history; let (body, origin) = if let Some(boundary) = run_boundary(&record) { if boundary_reference( &record, @@ -683,12 +741,16 @@ impl SessionPublicMaterialReader { LogEntry::AnnotatedAssistantItem { entry, .. } | LogEntry::AnnotatedToolResult { entry, .. } | LogEntry::ToolResultCorrected { entry, .. } => Some(entry), + LogEntry::AnnotatedSystemItem { entry, .. } => { + system_history = entry.to_history_entry(); + Some(&system_history) + } _ => None, } .filter(|e| format!("E{}", e.metadata.entry_id.0) == descriptor.identity.entry_ref) .ok_or(PublicMaterialError::Changed)?; ( - canonical_body(entry).ok_or(PublicMaterialError::Changed)?, + canonical_body(entry, self.purpose).ok_or(PublicMaterialError::Changed)?, PublicMaterialOrigin::History { origin: entry.metadata.origin.clone(), }, @@ -765,7 +827,7 @@ enum CanonicalBody<'a> { Message { origin: &'a LoggedSessionHistoryOrigin, role: LoggedRole, - content: &'a [crate::LoggedContentPart], + content: std::borrow::Cow<'a, [crate::LoggedContentPart]>, }, ToolCall { origin: &'a LoggedSessionHistoryOrigin, @@ -828,14 +890,33 @@ fn boundary_reference( .unwrap_or_else(|| format!("El-run-{session}-{segment}-{offset}")) } -fn canonical_body(entry: &LoggedHistoryEntry) -> Option> { - if matches!( - entry.metadata.origin, - LoggedSessionHistoryOrigin::DerivedSummary - | LoggedSessionHistoryOrigin::CompactionSummary - | LoggedSessionHistoryOrigin::JournalContext - ) { - return None; +fn canonical_body( + entry: &LoggedHistoryEntry, + purpose: PublicMaterialUse, +) -> Option> { + if let Some(input) = &entry.metadata.input_metadata { + let usage = input.usage(); + let allowed = match purpose { + PublicMaterialUse::Recording => usage.recording_material, + PublicMaterialUse::MemoryEvidence => usage.memory_evidence, + }; + if allowed == crate::InputEvidenceUse::Exclude { + return None; + } + if let LoggedItem::Message { + role: LoggedRole::System, + .. + } = &entry.item + { + let public = input.public_item()?; + return Some(CanonicalBody::Message { + origin: &entry.metadata.origin, + role: LoggedRole::System, + content: std::borrow::Cow::Owned(vec![crate::LoggedContentPart::Text { + text: public.body.clone(), + }]), + }); + } } match &entry.item { LoggedItem::Message { @@ -847,7 +928,7 @@ fn canonical_body(entry: &LoggedHistoryEntry) -> Option> { LoggedItem::Message { role, content } => Some(CanonicalBody::Message { origin: &entry.metadata.origin, role: *role, - content, + content: std::borrow::Cow::Borrowed(content), }), LoggedItem::ToolCall { call_id, @@ -880,8 +961,9 @@ fn canonical_body(entry: &LoggedHistoryEntry) -> Option> { fn canonical_metadata( entry: &LoggedHistoryEntry, + purpose: PublicMaterialUse, ) -> Result, PublicMaterialError> { - let Some(body) = canonical_body(entry) else { + let Some(body) = canonical_body(entry, purpose) else { return Ok(None); }; let mut sink = BodySink::new(0, 0); diff --git a/crates/session-store/src/public_material/tests.rs b/crates/session-store/src/public_material/tests.rs index b77642840..230b81055 100644 --- a/crates/session-store/src/public_material/tests.rs +++ b/crates/session-store/src/public_material/tests.rs @@ -9,6 +9,7 @@ fn entry(id: &str, item: LoggedItem) -> LoggedHistoryEntry { LoggedHistoryEntry { item, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId(id.into()), origin: LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, @@ -24,6 +25,27 @@ fn message(id: &str, role: LoggedRole, text: &str) -> LoggedHistoryEntry { }, ) } +fn system_context( + id: &str, + text: &str, + compaction: crate::InputCompactionUse, +) -> LoggedHistoryEntry { + let mut entry = message(id, LoggedRole::System, text); + entry.metadata.input_metadata = Some( + crate::HistoryInputMetadata::new( + crate::HistoryInputUsePolicy { + recording_material: crate::InputEvidenceUse::Exclude, + memory_evidence: crate::InputEvidenceUse::Exclude, + observation: crate::InputObservationUse::Hidden, + compaction, + }, + None, + None, + ) + .unwrap(), + ); + entry +} fn seed(session: SessionId, history: Vec) -> LogEntry { LogEntry::AnnotatedSegmentStart { ts: 1, @@ -347,7 +369,11 @@ fn yield_resume_cancel_keep_original_boundary_identity_across_compaction() { .descriptors() .cloned() .collect(); - let mut summary = message("summary", LoggedRole::User, "must not replace originals"); + let mut summary = system_context( + "summary", + "must not replace originals", + crate::InputCompactionUse::OpeningSummary, + ); summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { sources: vec![ @@ -543,14 +569,18 @@ fn reasoning_proves_rollback_safety_but_private_and_derived_items_are_not_materi let store = FsStore::new(temp.path()).unwrap(); let session = crate::new_session_id(); let segment = crate::new_segment_id(); - let mut derived = message("derived", LoggedRole::User, "summary"); + let mut derived = system_context("derived", "summary", crate::InputCompactionUse::Retain); derived.metadata.origin = LoggedSessionHistoryOrigin::DerivedSummary; - let mut journal = message("journal", LoggedRole::User, "frozen Journal reference"); + let mut journal = system_context( + "journal", + "frozen Journal reference", + crate::InputCompactionUse::ReferenceInput, + ); journal.metadata.origin = LoggedSessionHistoryOrigin::JournalContext; - let mut summary = message( + let mut summary = system_context( "compaction-summary", - LoggedRole::User, "complementary summary", + crate::InputCompactionUse::OpeningSummary, ); summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; let reasoning = entry( @@ -579,18 +609,23 @@ fn reasoning_proves_rollback_safety_but_private_and_derived_items_are_not_materi LogEntry::AnnotatedSystemItem { ts: 1, entry: crate::LoggedSystemHistoryEntry { - item: crate::SystemItem::JournalStartup { + item: crate::SystemItem::Input { body: "previous Journal must not become new recording material".into(), - snapshot: serde_json::json!({"journals": []}), - snapshot_sha256: "retained-digest".into(), - prompt_provenance: crate::PromptRenderProvenance { - workspace_id: None, - source_digest: "source".into(), - projection_digest: "projection".into(), - logical_name: "internal.subjektiv_journal_startup".into(), - }, }, metadata: LoggedSessionHistoryMetadata { + input_metadata: Some( + crate::HistoryInputMetadata::new( + crate::HistoryInputUsePolicy { + recording_material: crate::InputEvidenceUse::Exclude, + memory_evidence: crate::InputEvidenceUse::Exclude, + observation: crate::InputObservationUse::Hidden, + compaction: crate::InputCompactionUse::ReferenceInput, + }, + None, + None, + ) + .unwrap(), + ), entry_id: LoggedSessionHistoryEntryId("startup-journal".into()), origin: LoggedSessionHistoryOrigin::DerivedSummary, derivation: None, @@ -777,6 +812,19 @@ fn schema_v3_compaction_migrates_persistently_and_strict_reader_keeps_original_i panic!() }; summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; + summary.metadata.input_metadata = Some( + crate::HistoryInputMetadata::new( + crate::HistoryInputUsePolicy { + recording_material: crate::InputEvidenceUse::Exclude, + memory_evidence: crate::InputEvidenceUse::Exclude, + observation: crate::InputObservationUse::Hidden, + compaction: crate::InputCompactionUse::OpeningSummary, + }, + None, + None, + ) + .unwrap(), + ); assert_eq!(history, &[summary]); // Run-transition ids synthesized from byte offsets must be frozen before // the changed summary record shifts every subsequent record offset. @@ -825,8 +873,14 @@ fn midturn_compaction_uses_exact_source_ids_and_preserves_discarded_originals() ], ) .unwrap(); - let mut summary = message("summary", LoggedRole::User, "never use as original"); - summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; + let mut summary = system_context( + "summary", + "never use as original", + crate::InputCompactionUse::OpeningSummary, + ); + summary.metadata.origin = LoggedSessionHistoryOrigin::Provided { + producer: "renamed-summary-owner".into(), + }; summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { sources: vec![ LoggedSessionHistoryEntryId("u".into()), @@ -1030,7 +1084,11 @@ fn zero_turn_fork_from_seed_and_consecutive_compactions_preserve_original_identi for index in 0..3 { let compact = crate::new_segment_id(); let summary_id = format!("summary-{index}"); - let mut summary = message(&summary_id, LoggedRole::User, "not original"); + let mut summary = system_context( + &summary_id, + "not original", + crate::InputCompactionUse::OpeningSummary, + ); summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { sources: source_ids @@ -1178,7 +1236,11 @@ fn correction_preserves_original_identity_across_compaction_without_summary_fall ], ) .unwrap(); - let mut summary = message("summary", LoggedRole::User, "not original"); + let mut summary = system_context( + "summary", + "not original", + crate::InputCompactionUse::OpeningSummary, + ); summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { sources: vec![LoggedSessionHistoryEntryId("result".into())], @@ -1226,7 +1288,11 @@ fn compaction_without_exact_derivation_or_with_invalid_turn_is_rejected() { ), ] { let segment = crate::new_segment_id(); - let mut summary = message("summary", LoggedRole::User, "not original"); + let mut summary = system_context( + "summary", + "not original", + crate::InputCompactionUse::OpeningSummary, + ); summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; summary.metadata.derivation = derivation; let mut start = seed(session, vec![summary]); @@ -1243,3 +1309,181 @@ fn compaction_without_exact_derivation_or_with_invalid_turn_is_rejected() { )); } } + +#[test] +fn generic_system_material_uses_explicit_evidence_purpose_not_producer_or_body() { + use crate::{ + FeatureInputPayload, HistoryInputMetadata, HistoryInputUsePolicy, InputCompactionUse, + InputEvidenceUse, InputObservationUse, LoggedSystemHistoryEntry, SystemInputEnvelope, + }; + for worker_layout in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let session = crate::new_session_id(); + let segment = crate::new_segment_id(); + let fork = crate::new_segment_id(); + let make = + |producer: &str, recording_material, memory_evidence, body: &str, public: bool| { + LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: producer.into(), + }, + None, + SystemInputEnvelope::new( + format!("private model text: {body}"), + HistoryInputMetadata::new( + HistoryInputUsePolicy { + recording_material, + memory_evidence, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::Retain, + }, + Some( + FeatureInputPayload::encode( + "unknown:future", + 99, + &serde_json::json!({"private": "not-public"}), + ) + .unwrap(), + ), + public.then(|| crate::PublicSystemItem { + kind: "context".into(), + body: body.into(), + presentation: crate::PublicSystemPresentation::Context, + }), + ) + .unwrap(), + ) + .unwrap(), + ) + }; + let inputs = [ + make( + "same-owner", + InputEvidenceUse::Include, + InputEvidenceUse::Exclude, + "do not extract this; original material", + true, + ), + make( + "same-owner", + InputEvidenceUse::Exclude, + InputEvidenceUse::Include, + "memory-only original", + true, + ), + make( + "renamed-owner", + InputEvidenceUse::Exclude, + InputEvidenceUse::Exclude, + "reference without warning text", + true, + ), + make( + "private-only", + InputEvidenceUse::Include, + InputEvidenceUse::Include, + "must not publish without projection", + false, + ), + ]; + let root = if worker_layout { + temp.path().join("worker") + } else { + temp.path().join(session.to_string()) + }; + let store: Box = if worker_layout { + Box::new(WorkerSessionStore::new(&root).unwrap()) + } else { + Box::new(FsStore::new(temp.path()).unwrap()) + }; + store + .create_segment(session, segment, &[seed(session, vec![])]) + .unwrap(); + for entry in &inputs { + store + .append( + session, + segment, + &LogEntry::AnnotatedSystemItem { + ts: 2, + entry: entry.clone(), + extensions: vec![], + }, + ) + .unwrap(); + } + store + .append( + session, + segment, + &LogEntry::TurnEnd { + ts: 3, + turn_count: 1, + }, + ) + .unwrap(); + let mut fork_seed = seed( + session, + inputs + .iter() + .map(LoggedSystemHistoryEntry::to_history_entry) + .collect(), + ); + if let LogEntry::AnnotatedSegmentStart { forked_from, .. } = &mut fork_seed { + *forked_from = Some(crate::SegmentOrigin { + segment_id: segment, + at_turn_index: 1, + }); + } + store.create_segment(session, fork, &[fork_seed]).unwrap(); + let open = |active, purpose| { + if worker_layout { + SessionPublicMaterialReader::open_worker_for_use( + &root, + active, + Default::default(), + crate::retention::fs_retention(temp.path()), + purpose, + ) + .unwrap() + } else { + SessionPublicMaterialReader::open_fs_for_use( + temp.path(), + session, + active, + Default::default(), + crate::retention::fs_retention(temp.path()), + purpose, + ) + .unwrap() + } + }; + for (purpose, selected, expected_text) in [ + ( + PublicMaterialUse::Recording, + 0, + "do not extract this; original material", + ), + (PublicMaterialUse::MemoryEvidence, 1, "memory-only original"), + ] { + let original = open(segment, purpose); + let inherited = open(fork, purpose); + assert_eq!( + ids(&original), + vec![format!("E{}", inputs[selected].metadata.entry_id.0)] + ); + assert_eq!(ids(&inherited), ids(&original)); + let descriptor = inherited.descriptors().next().unwrap(); + let body = inherited + .read_content(descriptor, 0, 16 * 1024) + .unwrap() + .content; + let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(value["role"], "system"); + assert_eq!(value["content"][0]["text"], expected_text); + assert!(!body.contains("not-public")); + assert!(!body.contains("private model text")); + assert!(!body.contains("unknown:future")); + } + } +} diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index 824d7dffd..cf03cf752 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -27,7 +27,7 @@ use thiserror::Error; use crate::{ LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, - LoggedSessionHistoryOrigin, SegmentId, SegmentOrigin, SessionId, StoreError, SystemItem, + LoggedSessionHistoryOrigin, SegmentId, SegmentOrigin, SessionId, StoreError, WorkerAggregateStore, WorkerSessionStore, WorkerStoreError, }; @@ -1165,7 +1165,7 @@ fn capture_tool_correction( let LogEntry::ToolResultCorrected { entry, .. } = record else { return false; }; - if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) { + if let Some(data) = project_history_item(entry) { // We read backwards: the first correction seen is the latest one. corrections .entry(entry.metadata.entry_id.0.clone()) @@ -1600,12 +1600,12 @@ pub(crate) fn project_session_snapshot_for_segment( } => extend_history(&mut entries, history, Some(segments), *ts), LogEntry::AnnotatedAssistantItem { ts, entry } | LogEntry::AnnotatedToolResult { ts, entry } => { - if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) { + if let Some(data) = project_history_item(entry) { entries.push(history_entry(entry, *ts, data)); } } LogEntry::ToolResultCorrected { ts, entry } => { - if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) { + if let Some(data) = project_history_item(entry) { if let Some(existing) = entries .iter_mut() .find(|existing| existing.entry_id == entry.metadata.entry_id.0) @@ -1618,11 +1618,16 @@ pub(crate) fn project_session_snapshot_for_segment( } } LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry( - &entry.item, + &entry.public_item(), entry.metadata.entry_id.0.clone(), *ts, provenance(&entry.metadata.origin), derivation_ids(entry), + entry + .metadata + .input_metadata + .as_ref() + .map(crate::HistoryInputMetadata::public_system_use_policy), )), LogEntry::RunYielded { ts, @@ -1775,7 +1780,7 @@ fn extend_history( segments: input_segments.cloned().unwrap_or_default(), } } else { - let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) else { + let Some(data) = project_history_item(entry) else { continue; }; data @@ -1790,6 +1795,19 @@ fn history_entry( data: SessionSnapshotEntryData, ) -> SessionSnapshotEntry { SessionSnapshotEntry { + use_policy: entry.metadata.input_metadata.as_ref().map(|input| { + if matches!( + entry.item, + LoggedItem::Message { + role: LoggedRole::System, + .. + } + ) { + input.public_system_use_policy() + } else { + input.public_use_policy() + } + }), entry_id: entry.metadata.entry_id.0.clone(), timestamp, provenance: provenance(&entry.metadata.origin), @@ -1833,6 +1851,7 @@ fn run_transition_entry( data: SessionSnapshotEntryData, ) -> SessionSnapshotEntry { SessionSnapshotEntry { + use_policy: None, entry_id: persisted_entry_id .map(|entry_id| entry_id.0.clone()) .unwrap_or_else(|| legacy_entry_id(session_key, segment_id, log_index, 0)), @@ -1852,6 +1871,7 @@ fn legacy_entry( data: SessionSnapshotEntryData, ) -> SessionSnapshotEntry { SessionSnapshotEntry { + use_policy: None, entry_id: legacy_entry_id(session_key, segment_id, log_index, item_index), timestamp, provenance: SessionEntryProvenance::LegacyUnknown, @@ -1884,9 +1904,8 @@ fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance { LoggedSessionHistoryOrigin::FlowInstruction { .. } => { SessionEntryProvenance::FlowInstruction } - LoggedSessionHistoryOrigin::BackendInstruction { .. } => { - SessionEntryProvenance::BackendInstruction - } + LoggedSessionHistoryOrigin::BackendInstruction { .. } + | LoggedSessionHistoryOrigin::Provided { .. } => SessionEntryProvenance::BackendInstruction, LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput, LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput, LoggedSessionHistoryOrigin::CompactionSummary @@ -1914,6 +1933,35 @@ pub fn session_tool_attachment_id( URL_SAFE_NO_PAD.encode(hash.finalize()) } +fn project_history_item(entry: &LoggedHistoryEntry) -> Option { + if let Some(input) = &entry.metadata.input_metadata { + if matches!( + entry.item, + LoggedItem::Message { + role: LoggedRole::System, + .. + } + ) { + let public = input.public_projection(); + return Some(SessionSnapshotEntryData::SystemItem { + item_kind: public.kind.clone(), + content: public.body.clone(), + data: Some( + serde_json::to_value(public).expect("public system input is serializable"), + ), + }); + } + let usage = input.usage(); + if usage.recording_material == crate::InputEvidenceUse::Exclude + && usage.memory_evidence == crate::InputEvidenceUse::Exclude + && usage.observation == crate::InputObservationUse::Hidden + { + return None; + } + } + project_item(&entry.metadata.entry_id.0, &entry.item) +} + fn project_item(entry_id: &str, item: &LoggedItem) -> Option { match item { LoggedItem::Message { role, content } => { @@ -1986,30 +2034,26 @@ fn project_item(entry_id: &str, item: &LoggedItem) -> Option, + use_policy: Option, ) -> SessionSnapshotEntry { - let mut data = serde_json::to_value(item).ok(); - if let Some(serde_json::Value::Object(object)) = data.as_mut() { - object.remove("prompt_provenance"); - } - let item_kind = data - .as_ref() - .and_then(|value| value.get("kind")) - .and_then(serde_json::Value::as_str) - .unwrap_or("system_item") - .to_owned(); + let public = item.clone(); + let data = Some(serde_json::to_value(&public).expect("public system input is serializable")); + let item_kind = public.kind; + let content = public.body; SessionSnapshotEntry { + use_policy, entry_id, timestamp, provenance, derived_from, data: SessionSnapshotEntryData::SystemItem { item_kind, - content: item.history_text(), + content, data, }, } @@ -2291,6 +2335,7 @@ mod tests { content: vec![LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId(entry_id.into()), origin, derivation: None, @@ -3589,6 +3634,7 @@ mod tests { .map(|item| LoggedHistoryEntry { item, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -3643,6 +3689,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: user_entry_id, origin: LoggedSessionHistoryOrigin::HumanInput { account_id: "account-1".into(), @@ -3712,6 +3759,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::FlowInstruction { selector: "builtin:coder-review".into(), @@ -3731,6 +3779,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: user_entry_id.clone(), origin: origin.clone(), derivation: Some(LoggedHistoryDerivation { @@ -3761,6 +3810,7 @@ mod tests { fn annotated_projection_preserves_identity_and_provenance() { let session_id = crate::new_session_id(); let metadata = LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::ModelOutput { worker: LoggedWorkerSubject { @@ -4323,6 +4373,7 @@ mod tests { is_error: false, }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId("ancestor-image".into()), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -4417,6 +4468,7 @@ mod tests { is_error: false, }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId("tool-images".into()), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/session-store/src/public_snapshot/run_stats.rs b/crates/session-store/src/public_snapshot/run_stats.rs index 2d5b49902..4806e1da2 100644 --- a/crates/session-store/src/public_snapshot/run_stats.rs +++ b/crates/session-store/src/public_snapshot/run_stats.rs @@ -103,6 +103,7 @@ fn finish( stats.insert( anchor.clone(), SessionSnapshotEntry { + use_policy: None, entry_id: format!("run-stats:{anchor}"), timestamp: ended_at, provenance: SessionEntryProvenance::LegacyUnknown, diff --git a/crates/session-store/src/segment_log.rs b/crates/session-store/src/segment_log.rs index 97f347dad..d54a2659d 100644 --- a/crates/session-store/src/segment_log.rs +++ b/crates/session-store/src/segment_log.rs @@ -254,6 +254,23 @@ pub struct SegmentOrigin { } impl LogEntry { + /// Validate generic System records before a Store publishes any bytes. + pub fn validate_input_metadata(&self) -> Result<(), crate::InputMetadataError> { + match self { + Self::AnnotatedSegmentStart { history, .. } + | Self::AnnotatedUserInput { history, .. } => { + for entry in history { + entry.validate_input_metadata()?; + } + } + Self::AnnotatedAssistantItem { entry, .. } + | Self::AnnotatedToolResult { entry, .. } + | Self::ToolResultCorrected { entry, .. } => entry.validate_input_metadata()?, + Self::AnnotatedSystemItem { entry, .. } => entry.validate_input_metadata()?, + _ => {} + } + Ok(()) + } /// Allocate the durable public identity for a newly committed Run /// transition. Calling this repeatedly is idempotent so the exact value /// written to storage is also the value published to live subscribers. @@ -537,6 +554,7 @@ mod tests { LoggedHistoryEntry { item: LoggedItem::from(item), metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/session-store/src/store.rs b/crates/session-store/src/store.rs index a876b7429..ef26359c7 100644 --- a/crates/session-store/src/store.rs +++ b/crates/session-store/src/store.rs @@ -27,6 +27,9 @@ pub enum StoreError { #[error("serialization error: {0}")] Serde(#[from] serde_json::Error), + #[error("invalid history input metadata: {0}")] + InvalidInputMetadata(#[from] crate::InputMetadataError), + #[error("segment not found: {0}")] NotFound(SegmentId), diff --git a/crates/session-store/src/system_item.rs b/crates/session-store/src/system_item.rs index 54b815bda..c7bc6a3b9 100644 --- a/crates/session-store/src/system_item.rs +++ b/crates/session-store/src/system_item.rs @@ -86,20 +86,16 @@ pub struct PromptRenderProvenance { /// `notify_wrapper` template) re-shape the live rendering on a later /// resume. /// -/// New variants get added here as fresh injection kinds come online -/// (e.g. `TaskReminder`). The `kind` JSON tag is the snake_case form of -/// the variant name. +/// Feature-owned inputs use `Input` plus explicit annotated metadata; adding a +/// Feature must not extend this enum or ordinary replay. Only core events have +/// dedicated variants. Pre-v5 Feature kinds exist exclusively in the migration +/// decoder, not in this current wire contract. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SystemItem { - /// One-time derived Journal reference context. The bounded Host snapshot is - /// retained as JSON independently of its rendered body and prompt projection. - JournalStartup { - body: String, - snapshot: serde_json::Value, - snapshot_sha256: String, - prompt_provenance: PromptRenderProvenance, - }, + /// Generic System body; private metadata and public presentation are supplied + /// independently by the producer in the same annotated history record. + Input { body: String }, /// Free-form notification sent in by an external caller via /// `Method::Notify`. `message` is the raw caller-supplied text; /// `body` is the wrapped LLM-context form (Worker renders it via @@ -122,14 +118,6 @@ pub enum SystemItem { prompt_provenance: Option, }, - /// Result of an explicitly selected chat Feature invocation. The typed - /// result is retained for clients; `body` is the exact bounded context made - /// visible to the Worker model. - FeatureInvocationResult { - result: protocol::FeatureInvocationResult, - body: String, - }, - /// `@` file reference resolution. `body` is the rendered /// LLM-context text (`[File: ]\n…` for regular files, /// `[Dir: ]\n…` for directory listings, possibly with a @@ -137,10 +125,6 @@ pub enum SystemItem { /// byte-identical to what was sent. FileAttachment { path: String, body: String }, - /// Explicit Agent Skill activation. `body` is the exact LLM-facing - /// Skill text committed before any subsequent LLM request can use it. - SkillActivation { name: String, body: String }, - /// Historical persisted Knowledge reference resolution. Knowledge is no /// longer active, so restored sessions ignore this item instead of replaying /// archived record text into model context. @@ -164,29 +148,6 @@ pub enum SystemItem { prompt_provenance: Option, }, - /// Restore-boundary refresh of the current resident Memory projection. - /// The exact rendered body is retained so replay is byte-identical; a - /// refresh with no ready summary explicitly supersedes older resident - /// context without deleting or rewriting history. - ResidentSummaryRefresh { - body: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - prompt_provenance: Option, - }, - - /// A Host-fetched change to user-managed Subject behavior. Each changed - /// body is appended rather than rewriting prior prompt/session content. - SubjectBehaviorRefresh { - body: String, - /// Exact user-managed behavior acknowledged after durable commit. Old - /// logs omit this value; an empty value is not an acknowledgement of a - /// nonempty behavior and does not replace their stored rendered body. - #[serde(default)] - behavior_md: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - prompt_provenance: Option, - }, - /// Synthetic note inserted after an interrupted turn before the next /// user input. `body` is the exact LLM-context text explaining that the /// previous turn was cut short. @@ -197,24 +158,67 @@ pub enum SystemItem { }, } +/// Allowlisted presentation data shared by live events and retained snapshots. +/// Private Feature payloads and render provenance never cross this boundary. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct PublicSystemItem { + pub kind: String, + pub body: String, + pub presentation: PublicSystemPresentation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PublicSystemPresentation { + Notification { message: String }, + WorkerEvent { event: WorkerEvent }, + Context, + TaskReminder, + Ignored, +} + impl SystemItem { + pub fn public_item(&self) -> PublicSystemItem { + let presentation = match self { + Self::Notification { message, .. } => PublicSystemPresentation::Notification { + message: message.clone(), + }, + Self::WorkerEvent { event, .. } => PublicSystemPresentation::WorkerEvent { + event: event.clone(), + }, + Self::TaskReminder { .. } => PublicSystemPresentation::TaskReminder, + Self::Input { .. } + | Self::LegacyKnowledgeIgnored { .. } + | Self::LegacyIgnored { .. } => PublicSystemPresentation::Ignored, + Self::FileAttachment { .. } | Self::Interrupt { .. } => { + PublicSystemPresentation::Context + } + }; + PublicSystemItem { + kind: self.kind_label().to_owned(), + body: if matches!(presentation, PublicSystemPresentation::Ignored) { + String::new() + } else { + self.history_text() + }, + presentation, + } + } + /// Free-text body the LLM sees inside its `role:system` message /// for this item. Returns the variant's stored `body` verbatim. pub fn history_text(&self) -> String { match self { - SystemItem::JournalStartup { body, .. } => body.clone(), + SystemItem::Input { body } => body.clone(), SystemItem::Notification { body, .. } => body.clone(), SystemItem::WorkerEvent { body, .. } => body.clone(), - SystemItem::FeatureInvocationResult { body, .. } => body.clone(), SystemItem::FileAttachment { body, .. } => body.clone(), - SystemItem::SkillActivation { body, .. } => body.clone(), SystemItem::LegacyKnowledgeIgnored { .. } => String::new(), SystemItem::LegacyIgnored { slug } => { format!("Ignored legacy procedure item: /{slug}") } SystemItem::TaskReminder { body, .. } => body.clone(), - SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(), - SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(), SystemItem::Interrupt { body, .. } => body.clone(), } } @@ -229,17 +233,13 @@ impl SystemItem { /// wire — keep flexible. pub fn kind_label(&self) -> &'static str { match self { - SystemItem::JournalStartup { .. } => "journal_startup", + SystemItem::Input { .. } => "input", SystemItem::Notification { .. } => "notification", SystemItem::WorkerEvent { .. } => "worker_event", - SystemItem::FeatureInvocationResult { .. } => "feature_invocation_result", SystemItem::FileAttachment { .. } => "file_attachment", - SystemItem::SkillActivation { .. } => "skill_activation", SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored", SystemItem::LegacyIgnored { .. } => "legacy_ignored", SystemItem::TaskReminder { .. } => "task_reminder", - SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh", - SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh", SystemItem::Interrupt { .. } => "interrupt", } } @@ -297,33 +297,45 @@ mod tests { } #[test] - fn subject_behavior_refresh_round_trips_with_exact_body() { - let item = SystemItem::SubjectBehaviorRefresh { - body: "current Subject context".to_string(), - behavior_md: "exact behavior".to_string(), - prompt_provenance: None, - }; - let raw = serde_json::to_string(&item).unwrap(); - let parsed: SystemItem = serde_json::from_str(&raw).unwrap(); - assert_eq!(parsed.history_text(), "current Subject context"); - assert!( - matches!(parsed, SystemItem::SubjectBehaviorRefresh { behavior_md, .. } if behavior_md == "exact behavior") - ); + fn current_system_item_rejects_frozen_feature_kinds() { + // Even well-formed old payloads cannot enter normal v5 decode/replay. + for line in include_str!("legacy_session_log/tests/fixtures/v4-system-inputs.jsonl") + .lines() + .take(5) + { + let record: serde_json::Value = serde_json::from_str(line).unwrap(); + assert!(serde_json::from_value::(record["entry"]["item"].clone()).is_err()); + assert!( + serde_json::from_value::(record["entry"].clone()) + .is_err() + ); + assert!(serde_json::from_str::(line).is_err()); + } } #[test] - fn frozen_subject_behavior_log_reads_old_counter_without_republishing_it() { - let old = r#"{"kind":"subject_behavior_refresh","body":"exact archived body","behavior_revision":7}"#; - let item: SystemItem = serde_json::from_str(old).unwrap(); - assert_eq!(item.history_text(), "exact archived body"); - assert_eq!( - serde_json::to_value(item).unwrap(), - serde_json::json!({ - "kind": "subject_behavior_refresh", - "body": "exact archived body", - "behavior_md": "" - }) - ); + fn public_system_input_decoder_rejects_private_fields_and_unknown_presentations() { + let valid = serde_json::json!({ + "kind": "new-feature-label", + "body": "explicit public content", + "presentation": { "kind": "context" }, + }); + let public: PublicSystemItem = serde_json::from_value(valid.clone()).unwrap(); + assert_eq!(public.body, "explicit public content"); + for private_field in [ + "payload", + "prompt_provenance", + "policy", + "snapshot", + "behavior_md", + ] { + let mut malformed = valid.clone(); + malformed[private_field] = serde_json::json!({ "private": true }); + assert!(serde_json::from_value::(malformed).is_err()); + } + let mut unknown = valid; + unknown["presentation"] = serde_json::json!({ "kind": "execute_feature_payload" }); + assert!(serde_json::from_value::(unknown).is_err()); } #[test] @@ -419,29 +431,6 @@ mod tests { } } - #[test] - fn invocation_result_retains_typed_outcome_and_exact_context() { - let result = protocol::FeatureInvocationResult { - invocation_id: "stable-invoke".into(), - identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()), - status: protocol::FeatureInvocationStatus::OutcomeUnknown, - message: "operation may have committed".into(), - context: None, - }; - let body = "Outcome unknown; do not retry automatically"; - let item = SystemItem::FeatureInvocationResult { - result: result.clone(), - body: body.into(), - }; - let restored: SystemItem = - serde_json::from_str(&serde_json::to_string(&item).unwrap()).unwrap(); - assert_eq!(restored.history_text(), body); - assert_eq!(restored.kind_label(), "feature_invocation_result"); - assert!( - matches!(restored, SystemItem::FeatureInvocationResult { result: saved, .. } if saved == result) - ); - } - #[test] fn round_trip_worker_event() { let item = SystemItem::WorkerEvent { diff --git a/crates/session-store/src/worker_metadata.rs b/crates/session-store/src/worker_metadata.rs index e2a8ffe72..997419ba7 100644 --- a/crates/session-store/src/worker_metadata.rs +++ b/crates/session-store/src/worker_metadata.rs @@ -210,18 +210,6 @@ impl WorkerMetadata { } } -fn replace_active_session(metadata: &mut WorkerMetadata, active: Option) { - if let Some(session_id) = active.as_ref().map(|active| active.session_id) - && metadata - .subjektiv_session_attribution - .as_ref() - .is_some_and(|state| state.session_id() != session_id) - { - metadata.subjektiv_session_attribution = None; - } - metadata.active = active; -} - /// Sync persistence backend for Worker metadata. pub trait WorkerMetadataStore: Send + Sync { /// Create or replace metadata for its `worker_name` key. @@ -297,7 +285,7 @@ pub trait WorkerMetadataStore: Send + Sync { workspace_root: Option, ) -> Result { self.update_by_name(worker_name, |metadata| { - replace_active_session(metadata, active); + metadata.active = active; metadata.resolved_manifest_snapshot = resolved_manifest_snapshot; if let Some(workspace_id) = workspace_id { metadata.workspace_id = Some(workspace_id); @@ -317,7 +305,7 @@ pub trait WorkerMetadataStore: Send + Sync { workspace_id: Option, ) -> Result { self.update_by_name(worker_name, |metadata| { - replace_active_session(metadata, active); + metadata.active = active; metadata.resolved_manifest_snapshot = resolved_manifest_snapshot; if let Some(workspace_id) = workspace_id { metadata.workspace_id = Some(workspace_id); @@ -334,7 +322,7 @@ pub trait WorkerMetadataStore: Send + Sync { workspace_root: Option, ) -> Result { self.update_by_name(worker_name, |metadata| { - replace_active_session(metadata, active); + metadata.active = active; metadata.resolved_manifest_snapshot = resolved_manifest_snapshot; if let Some(workspace_root) = workspace_root { metadata.workspace_root = Some(workspace_root); @@ -342,18 +330,6 @@ pub trait WorkerMetadataStore: Send + Sync { }) } - /// Set the Host-owned Subjektiv Session attribution lifecycle state while - /// preserving the active Session pointer and every unrelated field. - fn set_subjektiv_session_attribution( - &self, - worker_name: &str, - state: SubjektivSessionAttributionState, - ) -> Result { - self.update_by_name(worker_name, |metadata| { - metadata.subjektiv_session_attribution = Some(state); - }) - } - /// Set spawned-child registry state while preserving active pointer and manifest snapshot. fn set_spawned_children( &self, @@ -1087,13 +1063,13 @@ mod tests { )) .unwrap(); store - .set_subjektiv_session_attribution( - "subject-worker", - SubjektivSessionAttributionState::OutcomeUnknown { - session_id, - diagnostic: "request interrupted".to_string(), - }, - ) + .update_by_name("subject-worker", |metadata| { + metadata.subjektiv_session_attribution = + Some(SubjektivSessionAttributionState::OutcomeUnknown { + session_id, + diagnostic: "request interrupted".to_string(), + }); + }) .unwrap(); store .set_active( @@ -1117,7 +1093,7 @@ mod tests { } #[test] - fn new_active_session_clears_prior_session_attribution_state() { + fn generic_active_update_does_not_interpret_host_attribution() { let tmp = tempfile::TempDir::new().unwrap(); let store = FsWorkerStore::new(tmp.path()).unwrap(); let old_session_id = crate::new_session_id(); @@ -1129,13 +1105,13 @@ mod tests { )) .unwrap(); store - .set_subjektiv_session_attribution( - "subject-worker", - SubjektivSessionAttributionState::Confirmed { - session_id: old_session_id, - subject_id: "subject-1".to_string(), - }, - ) + .update_by_name("subject-worker", |metadata| { + metadata.subjektiv_session_attribution = + Some(SubjektivSessionAttributionState::Confirmed { + session_id: old_session_id, + subject_id: "subject-1".to_string(), + }); + }) .unwrap(); store @@ -1151,7 +1127,9 @@ mod tests { restored.active, Some(WorkerActiveSegmentRef::pending_segment(new_session_id)) ); - assert!(restored.subjektiv_session_attribution.is_none()); + assert!( + matches!(restored.subjektiv_session_attribution, Some(SubjektivSessionAttributionState::Confirmed { session_id, .. }) if session_id == old_session_id) + ); } #[test] diff --git a/crates/session-store/src/worker_session_store.rs b/crates/session-store/src/worker_session_store.rs index b19180b3f..9c6d8db00 100644 --- a/crates/session-store/src/worker_session_store.rs +++ b/crates/session-store/src/worker_session_store.rs @@ -36,7 +36,9 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; -const SESSION_SCHEMA_VERSION: u32 = 4; +const SESSION_SCHEMA_VERSION: u32 = 6; +const SYSTEM_INPUT_SESSION_SCHEMA_VERSION: u32 = 5; +const COMPACTION_SESSION_SCHEMA_VERSION: u32 = 4; const ANNOTATED_SESSION_SCHEMA_VERSION: u32 = 3; const PREVIOUS_SESSION_SCHEMA_VERSION: u32 = 2; const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1; @@ -204,7 +206,15 @@ impl WorkerSessionStore { /// Open the Session store rooted at `/session`. pub fn new(root: impl Into) -> Result { - let root = root.into(); + Self::new_with_migration_writer(root.into(), atomic_write_bytes) + } + + // Keep fault injection at the storage publication boundary, without global + // flags or a public alternate restore API. Production always uses atomic writes. + fn new_with_migration_writer( + root: PathBuf, + write_segment: impl FnMut(&Path, &[u8]) -> Result<(), StoreError>, + ) -> Result { fs::create_dir_all(root.join(SEGMENTS_DIR))?; let session_id = match fs::read(root.join(SESSION_FILE)) { Ok(bytes) => { @@ -215,11 +225,14 @@ impl WorkerSessionStore { } LEGACY_SESSION_SCHEMA_VERSION | PREVIOUS_SESSION_SCHEMA_VERSION - | ANNOTATED_SESSION_SCHEMA_VERSION => { - migrate_segment_logs_to_v4( + | ANNOTATED_SESSION_SCHEMA_VERSION + | COMPACTION_SESSION_SCHEMA_VERSION + | SYSTEM_INPUT_SESSION_SCHEMA_VERSION => { + migrate_segment_logs_to_current( segment_log_paths(&root)?, manifest.session_id, manifest.schema_version, + write_segment, )?; manifest.schema_version = SESSION_SCHEMA_VERSION; atomic_write_json(&root.join(SESSION_FILE), &manifest)?; @@ -742,6 +755,7 @@ impl Store for WorkerSessionStore { segment_id: SegmentId, entry: &LogEntry, ) -> Result<(), StoreError> { + entry.validate_input_metadata()?; let _retention = self.lock_publication(session_id)?; self.ensure_session(session_id, true)?; self.append_log_entry(&self.log_path(segment_id), entry) @@ -800,6 +814,9 @@ impl Store for WorkerSessionStore { segment_id: SegmentId, entries: &[LogEntry], ) -> Result<(), StoreError> { + for entry in entries { + entry.validate_input_metadata()?; + } let _retention = self.lock_publication(session_id)?; self.ensure_session(session_id, true)?; let mut content = Vec::new(); @@ -1158,10 +1175,11 @@ fn segment_log_paths_in_directory( Ok(paths) } -fn migrate_segment_logs_to_v4( +fn migrate_segment_logs_to_current( paths: Vec<(SegmentId, PathBuf)>, session_id: SessionId, source_schema_version: u32, + mut write_segment: impl FnMut(&Path, &[u8]) -> Result<(), StoreError>, ) -> Result<(), StoreError> { struct MigrationPlan { path: PathBuf, @@ -1192,12 +1210,19 @@ fn migrate_segment_logs_to_v4( } // Resolve trusted creation boundaries first, then update inherited copies by // logical identity too (fork/rewind seeds retain the same entry id). - let summaries = crate::legacy_session_log::compaction_summary_ids( - plans.iter().flat_map(|plan| plan.entries.iter()), - ); + let summaries = if source_schema_version < COMPACTION_SESSION_SCHEMA_VERSION { + crate::legacy_session_log::compaction_summary_ids( + plans.iter().flat_map(|plan| plan.entries.iter()), + ) + } else { + // v4 already made the explicit origin migration. Preserve its origins. + Default::default() + }; for plan in &mut plans { crate::legacy_session_log::migrate_compaction_origins(&mut plan.entries, &summaries); - for entry in std::mem::take(&mut plan.entries) { + for mut entry in std::mem::take(&mut plan.entries) { + crate::legacy_session_log::migrate_system_inputs(&mut entry)?; + entry.validate_input_metadata()?; serde_json::to_writer(&mut plan.output, &entry)?; plan.output.push(b'\n'); } @@ -1219,7 +1244,7 @@ fn migrate_segment_logs_to_v4( } for plan in plans { - atomic_write_bytes(&plan.path, &plan.output)?; + write_segment(&plan.path, &plan.output)?; } Ok(()) } @@ -1236,9 +1261,20 @@ pub(crate) fn migrate_fs_session(root: &Path, session_id: SessionId) -> Result<( Err(error) => return Err(error.into()), }; match version { - SESSION_SCHEMA_VERSION => Ok(()), - ANNOTATED_SESSION_SCHEMA_VERSION => { - migrate_segment_logs_to_v4(segment_log_paths_in_directory(root)?, session_id, version)?; + SESSION_SCHEMA_VERSION => { + validate_canonical_segment_paths(segment_log_paths_in_directory(root)?) + } + LEGACY_SESSION_SCHEMA_VERSION + | PREVIOUS_SESSION_SCHEMA_VERSION + | ANNOTATED_SESSION_SCHEMA_VERSION + | COMPACTION_SESSION_SCHEMA_VERSION + | SYSTEM_INPUT_SESSION_SCHEMA_VERSION => { + migrate_segment_logs_to_current( + segment_log_paths_in_directory(root)?, + session_id, + version, + atomic_write_bytes, + )?; atomic_write_json(&marker, &SESSION_SCHEMA_VERSION) } _ => Err(StoreError::Corrupt { @@ -1249,8 +1285,15 @@ pub(crate) fn migrate_fs_session(root: &Path, session_id: SessionId) -> Result<( } fn validate_canonical_segment_logs(root: &Path) -> Result<(), StoreError> { - for (_, path) in segment_log_paths(root)? { - let _: Vec = parse_jsonl(&fs::read(&path)?)?; + validate_canonical_segment_paths(segment_log_paths(root)?) +} + +fn validate_canonical_segment_paths(paths: Vec<(SegmentId, PathBuf)>) -> Result<(), StoreError> { + for (_, path) in paths { + let entries: Vec = parse_jsonl(&fs::read(&path)?)?; + for entry in entries { + entry.validate_input_metadata()?; + } } Ok(()) } @@ -1430,6 +1473,7 @@ mod tests { LoggedHistoryEntry { item: LoggedItem::from(item), metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -2017,6 +2061,146 @@ mod tests { ); } + #[test] + fn v5_migration_retries_after_second_file_publication_fails() { + fn identity_and_body(entries: &[LogEntry]) -> Vec { + entries + .iter() + .flat_map(|record| match record { + LogEntry::AnnotatedSystemItem { entry, .. } => vec![entry.to_history_entry()], + LogEntry::AnnotatedUserInput { history, .. } => history.clone(), + _ => panic!("unexpected record in fixed migration fixture"), + }) + .map(|entry| { + serde_json::json!({ + "entry_id": entry.metadata.entry_id, + "origin": entry.metadata.origin, + "derivation": entry.metadata.derivation, + "item": entry.item, + }) + }) + .collect() + } + + const SOURCE: &str = + include_str!("legacy_session_log/tests/fixtures/v4-system-inputs.jsonl"); + let root = tempfile::tempdir().unwrap(); + let session_id = new_session_id(); + let first_id = SegmentId::from_u128(1); + let second_id = SegmentId::from_u128(2); + fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap(); + let marker_path = root.path().join(SESSION_FILE); + atomic_write_json( + &marker_path, + &SessionManifest { + schema_version: COMPACTION_SESSION_SCHEMA_VERSION, + session_id, + }, + ) + .unwrap(); + let old_marker = fs::read(&marker_path).unwrap(); + let first_path = root + .path() + .join(SEGMENTS_DIR) + .join(format!("{first_id}.jsonl")); + let second_path = root + .path() + .join(SEGMENTS_DIR) + .join(format!("{second_id}.jsonl")); + let second_source = SOURCE.replace("-id", "-second-id"); + fs::write(&first_path, SOURCE).unwrap(); + fs::write(&second_path, &second_source).unwrap(); + let old_first = fs::read(&first_path).unwrap(); + let old_second = fs::read(&second_path).unwrap(); + let first_identity = + identity_and_body(&parse_legacy_jsonl(4, session_id, first_id, &old_first).unwrap()); + let second_identity = + identity_and_body(&parse_legacy_jsonl(4, session_id, second_id, &old_second).unwrap()); + + let mut writes = 0; + let mut first_published = None; + let failure = WorkerSessionStore::new_with_migration_writer( + root.path().to_path_buf(), + |path, bytes| { + writes += 1; + match writes { + 1 => { + assert_eq!(path, first_path.as_path()); + // Both files are still old when the first publication starts. + assert_eq!(fs::read(&first_path).unwrap(), old_first); + assert_eq!(fs::read(&second_path).unwrap(), old_second); + atomic_write_bytes(path, bytes)?; + first_published = Some(bytes.to_vec()); + Ok(()) + } + 2 => { + assert_eq!(path, second_path.as_path()); + assert_eq!( + fs::read(&first_path).unwrap(), + *first_published.as_ref().unwrap() + ); + assert_eq!(fs::read(&second_path).unwrap(), old_second); + // Fail only this atomic-write boundary, after complete + // preflight and a real successful first-file publication. + Err(std::io::Error::other("injected second migration write failure").into()) + } + _ => panic!("publication continued after its write failure"), + } + }, + ); + let error = match failure { + Ok(_) => panic!("the injected publication failure must reject store open"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("injected second migration write failure") + ); + assert_eq!(writes, 2); + let first_published = first_published.unwrap(); + assert_ne!(first_published, old_first); + assert_eq!(fs::read(&first_path).unwrap(), first_published); + assert_eq!(fs::read(&second_path).unwrap(), old_second); + assert_eq!(fs::read(&marker_path).unwrap(), old_marker); + let updated_first: Vec = parse_jsonl(&first_published).unwrap(); + for entry in &updated_first { + entry.validate_input_metadata().unwrap(); + } + assert!(matches!( + &updated_first[0], + LogEntry::AnnotatedSystemItem { + entry: crate::LoggedSystemHistoryEntry { + item: crate::SystemItem::Input { .. }, + .. + }, + .. + } + )); + assert_eq!(identity_and_body(&updated_first), first_identity); + + // Retry via the public production constructor, without any injection or + // hand-built mixed fixture. The old marker drives the same migration. + let store = WorkerSessionStore::new(root.path()).unwrap(); + let first = store.read_all(session_id, first_id).unwrap(); + let second = store.read_all(session_id, second_id).unwrap(); + assert_eq!(identity_and_body(&first), first_identity); + assert_eq!(identity_and_body(&second), second_identity); + for entry in first.iter().chain(second.iter()) { + entry.validate_input_metadata().unwrap(); + } + assert_eq!(fs::read(&first_path).unwrap(), first_published); + let second_published = fs::read(&second_path).unwrap(); + assert_ne!(second_published, old_second); + let manifest: SessionManifest = + serde_json::from_slice(&fs::read(&marker_path).unwrap()).unwrap(); + assert_eq!(manifest.schema_version, SESSION_SCHEMA_VERSION); + drop(store); + WorkerSessionStore::new(root.path()).unwrap(); + assert_eq!(fs::read(&first_path).unwrap(), first_published); + assert_eq!(fs::read(&second_path).unwrap(), second_published); + } + #[test] fn current_jsonl_requires_annotations_across_append_rewrite_and_reopen() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/session-store/tests/fs_store_test.rs b/crates/session-store/tests/fs_store_test.rs index 1ecb81d01..9673980c8 100644 --- a/crates/session-store/tests/fs_store_test.rs +++ b/crates/session-store/tests/fs_store_test.rs @@ -11,6 +11,7 @@ fn annotated(item: Item) -> LoggedHistoryEntry { LoggedHistoryEntry { item: LoggedItem::from(item), metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -421,3 +422,177 @@ fn lookup_session_of_finds_owning_session() { assert_eq!(store.lookup_session_of(segid).unwrap(), Some(sid)); } + +// Storage/projection component test: both production layouts, temporary originals, +// no Feature, model, runtime or Host authority is substituted into this boundary. +#[test] +fn generic_system_append_and_fork_preserve_metadata_without_public_payload_leak() { + use session_store::{ + FeatureInputPayload, HistoryInputMetadata, HistoryInputUsePolicy, InputCompactionUse, + InputEvidenceUse, InputObservationUse, LoggedHistoryDerivation, LoggedSystemHistoryEntry, + PublicSystemItem, PublicSystemPresentation, SystemInputEnvelope, WorkerSessionStore, + }; + for worker_layout in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let sid = new_session_id(); + let segment = new_segment_id(); + let fork = new_segment_id(); + let store: Box = if worker_layout { + Box::new(WorkerSessionStore::new(dir.path()).unwrap()) + } else { + Box::new(FsStore::new(dir.path()).unwrap()) + }; + store + .create_segment(sid, segment, &[nil_session_start(1, sid)]) + .unwrap(); + let entry = LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: "new-feature".into(), + }, + Some(LoggedHistoryDerivation { + sources: vec![LoggedSessionHistoryEntryId("source-entry".into())], + }), + SystemInputEnvelope::new( + "private model body", + HistoryInputMetadata::new( + HistoryInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::ReferenceInput, + }, + Some( + FeatureInputPayload::encode( + "new-feature:context", + 101, + &serde_json::json!({"private": "secret-detail"}), + ) + .unwrap(), + ), + Some(PublicSystemItem { + kind: "context".into(), + body: "explicit public body".into(), + presentation: PublicSystemPresentation::Context, + }), + ) + .unwrap(), + ) + .unwrap(), + ); + store + .append( + sid, + segment, + &LogEntry::AnnotatedSystemItem { + ts: 2, + entry: entry.clone(), + extensions: vec![], + }, + ) + .unwrap(); + let saved = store.read_all(sid, segment).unwrap(); + let LogEntry::AnnotatedSystemItem { + entry: restored, .. + } = &saved[1] + else { + panic!("missing system input") + }; + assert_eq!(restored.to_history_entry(), entry.to_history_entry()); + assert_eq!( + collect_state(&saved).history[0].as_text(), + Some("private model body") + ); + let mut fork_seed = nil_session_start(3, sid); + if let LogEntry::AnnotatedSegmentStart { + history, + forked_from, + .. + } = &mut fork_seed + { + history.push(restored.to_history_entry()); + *forked_from = Some(session_store::SegmentOrigin { + segment_id: segment, + at_turn_index: 0, + }); + } + store.create_segment(sid, fork, &[fork_seed]).unwrap(); + let fork_saved = store.read_all(sid, fork).unwrap(); + let LogEntry::AnnotatedSegmentStart { history, .. } = &fork_saved[0] else { + unreachable!() + }; + assert_eq!(history, &[entry.to_history_entry()]); + let original = session_store::public_snapshot::project_session_snapshot(sid, &saved); + let inherited = session_store::public_snapshot::project_session_snapshot(sid, &fork_saved); + let public = serde_json::to_string(&original.entries).unwrap(); + assert!(public.contains("explicit public body")); + for private in ["private model body", "secret-detail", "new-feature:context"] { + assert!(!public.contains(private), "private field leaked: {private}"); + } + assert_eq!(original.entries.len(), 1); + assert_eq!(inherited.entries.len(), 1); + assert_eq!(original.entries[0].entry_id, inherited.entries[0].entry_id); + assert_eq!( + original.entries[0].derived_from, + inherited.entries[0].derived_from + ); + assert_eq!( + original.entries[0].provenance, + inherited.entries[0].provenance + ); + assert_eq!( + serde_json::to_value(&original.entries[0].data).unwrap(), + serde_json::to_value(&inherited.entries[0].data).unwrap() + ); + } +} + +#[test] +fn invalid_generic_system_input_rejects_append_create_and_external_json_without_changing_originals() +{ + use session_store::{StoreError, SystemItem, WorkerSessionStore}; + for worker_layout in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let sid = new_session_id(); + let segment = new_segment_id(); + let store: Box = if worker_layout { + Box::new(WorkerSessionStore::new(dir.path()).unwrap()) + } else { + Box::new(FsStore::new(dir.path()).unwrap()) + }; + let seed = nil_session_start(1, sid); + store + .create_segment(sid, segment, std::slice::from_ref(&seed)) + .unwrap(); + let path = if worker_layout { + dir.path().join("segments").join(format!("{segment}.jsonl")) + } else { + dir.path() + .join(sid.to_string()) + .join(format!("{segment}.jsonl")) + }; + let before = std::fs::read(&path).unwrap(); + let invalid = LogEntry::AnnotatedSystemItem { + ts: 2, + extensions: vec![], + entry: session_store::LoggedSystemHistoryEntry { + item: SystemItem::Input { + body: "no policy".into(), + }, + metadata: LoggedSessionHistoryMetadata::legacy_unknown(), + }, + }; + assert!(matches!( + store.append(sid, segment, &invalid), + Err(StoreError::InvalidInputMetadata(_)) + )); + assert!(matches!( + store.create_segment(sid, segment, &[seed, invalid.clone()]), + Err(StoreError::InvalidInputMetadata(_)) + )); + assert!( + serde_json::from_value::(serde_json::to_value(invalid).unwrap()).is_err() + ); + assert_eq!(std::fs::read(path).unwrap(), before); + assert_eq!(store.read_all(sid, segment).unwrap().len(), 1); + } +} diff --git a/crates/session-store/tests/session_test.rs b/crates/session-store/tests/session_test.rs index 901c0578f..dd6346be9 100644 --- a/crates/session-store/tests/session_test.rs +++ b/crates/session-store/tests/session_test.rs @@ -24,6 +24,7 @@ fn annotated(items: &[Item]) -> Vec { .map(|item| session_store::LoggedHistoryEntry { item: session_store::LoggedItem::from(item), metadata: session_store::LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: session_store::LoggedSessionHistoryEntryId::new(), origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/standalone/src/host.rs b/crates/standalone/src/host.rs index 60c239b91..6d55edb67 100644 --- a/crates/standalone/src/host.rs +++ b/crates/standalone/src/host.rs @@ -23,9 +23,7 @@ use session_store::{ CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore, }; use thiserror::Error; -use worker::bootstrap::{ - WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, bash_output_dir_for_worker_id, -}; +use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout}; use worker::controller::WorkerControllerTransport; use worker::ipc::protocol_session::{ WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event, @@ -216,7 +214,6 @@ impl StandaloneHost { WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone()); let workspace_context = WorkerWorkspaceContext::local_filesystem(None); let runtime_base = store.runtime_dir(worker_id); - let bash_output_dir = bash_output_dir_for_worker_id(worker_id); let mut bootstrap = WorkerBootstrap::new( bootstrap_manifest, @@ -224,10 +221,7 @@ impl StandaloneHost { launch.prompt_catalog, workspace_context, filesystem_authority, - WorkerBootstrapLayout::Direct { - runtime_base, - bash_output_dir, - }, + WorkerBootstrapLayout::Direct { runtime_base }, WorkerControllerTransport::InProcess, ); if let Some(subject) = &subject { @@ -428,7 +422,6 @@ impl StandaloneHost { ); let workspace_context = WorkerWorkspaceContext::local_filesystem(None); let runtime_base = store.runtime_dir(worker_id); - let bash_output_dir = bash_output_dir_for_worker_id(worker_id); let mut bootstrap = WorkerBootstrap::new( manifest, @@ -436,10 +429,7 @@ impl StandaloneHost { worker::PromptCatalogSource::builtins_only(), workspace_context, filesystem_authority, - WorkerBootstrapLayout::Direct { - runtime_base, - bash_output_dir, - }, + WorkerBootstrapLayout::Direct { runtime_base }, WorkerControllerTransport::InProcess, ); if let Some(subject) = &subject { diff --git a/crates/standalone/src/subjektiv/journal/tests.rs b/crates/standalone/src/subjektiv/journal/tests.rs index 3eacfeeb0..c488b4879 100644 --- a/crates/standalone/src/subjektiv/journal/tests.rs +++ b/crates/standalone/src/subjektiv/journal/tests.rs @@ -212,6 +212,7 @@ fn message(id: &str, text: &str) -> session_store::LoggedHistoryEntry { content: vec![session_store::LoggedContentPart::Text { text: text.into() }], }, metadata: session_store::LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: session_store::LoggedSessionHistoryEntryId(id.into()), origin: session_store::LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, diff --git a/crates/standalone/src/subjektiv/journal/tests/compaction.rs b/crates/standalone/src/subjektiv/journal/tests/compaction.rs index b1aba547f..c9ebe8352 100644 --- a/crates/standalone/src/subjektiv/journal/tests/compaction.rs +++ b/crates/standalone/src/subjektiv/journal/tests/compaction.rs @@ -215,6 +215,7 @@ async fn public_mid_turn_compactions_preserve_originals_and_drain_after_cancel_a } protocol::Event::TextDelta { text } if text.contains(PARTIAL) => break, protocol::Event::RunEnd { .. } => panic!("dialog ended before cancellation"), + protocol::Event::SessionEntryCommitted { entry } if matches!(entry.data, protocol::SessionSnapshotEntryData::RunError { .. }) => panic!("dialog failed before cancellation: {entry:?}"), protocol::Event::Error { code, message } => panic!("{code:?}: {message}"), _ => {} } @@ -294,6 +295,32 @@ async fn public_mid_turn_compactions_preserve_originals_and_drain_after_cancel_a .iter() .flat_map(|segment| source_store.read_all(session, *segment).unwrap()) .collect(); + for history in raw.iter().filter_map(|record| match record { + session_store::LogEntry::AnnotatedSegmentStart { history, compacted_from: Some(_), .. } => Some(history), + _ => None, + }) { + let mut calls = std::collections::HashSet::new(); + let mut results = 0; + for entry in history { + let is_tool = matches!(entry.item, session_store::LoggedItem::ToolCall { .. } | session_store::LoggedItem::ToolResult { .. }); + let Some(metadata) = entry.metadata.input_metadata.as_ref().filter(|_| is_tool) else { continue; }; + assert_eq!(metadata.usage().recording_material, session_store::InputEvidenceUse::Exclude); + assert_eq!(metadata.usage().memory_evidence, session_store::InputEvidenceUse::Exclude); + assert_eq!(metadata.usage().observation, session_store::InputObservationUse::Hidden); + assert!(metadata.public_item().is_none()); + assert!(entry.metadata.derivation.is_some()); + match &entry.item { + session_store::LoggedItem::ToolCall { call_id, .. } => { calls.insert(call_id.clone()); } + session_store::LoggedItem::ToolResult { call_id, .. } => { + assert!(calls.contains(call_id), "derived Tool result must follow its real logical call"); + results += 1; + } + _ => unreachable!(), + } + } + assert!(!calls.is_empty()); + assert!(results >= calls.len()); + } assert_eq!( raw.iter() .filter(|entry| matches!( diff --git a/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs b/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs index 04032a8d7..b3295945b 100644 --- a/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs +++ b/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs @@ -11,7 +11,7 @@ use agen::llm_client::{ types::Item, }; use futures::Stream; -use session_store::{CombinedStore, FsWorkerStore, LogEntry, LoggedSessionHistoryOrigin}; +use session_store::{CombinedStore, FsWorkerStore, LogEntry}; use std::pin::Pin; const BEFORE: &str = "FROZEN_POSITION: B partially implemented; tests not run; result unconfirmed"; @@ -126,7 +126,7 @@ async fn local_host_compaction_freezes_pending_journal_through_request_and_durab worker::PromptCatalogSource::builtins_only(), worker::WorkerWorkspaceContext::no_workspace(), worker::WorkerFilesystemAuthority::None, - worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime"), bash_output_dir: fixture._temp.path().join("bash") }, + worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime") }, worker::WorkerControllerTransport::InProcess, ).with_model_client(model.clone()).with_subjektiv_host(fixture.host().clone()).prepare_restored("journal-test").await.unwrap(); assert_eq!(prepared.worker().session_id().to_string(), fixture.context.session_id); @@ -139,14 +139,14 @@ async fn local_host_compaction_freezes_pending_journal_through_request_and_durab let worker = prepared.worker(); assert_ne!(worker.segment_id(), source_segment); let seed = worker.store().read_all(worker.session_id(), worker.segment_id()).unwrap().into_iter().find_map(|entry| match entry { LogEntry::AnnotatedSegmentStart { history, .. } => Some(history), _ => None }).expect("durable compacted seed"); - let journal_items: Vec<_> = seed.iter().filter(|entry| entry.metadata.origin == LoggedSessionHistoryOrigin::JournalContext).collect(); + let journal_items: Vec<_> = seed.iter().filter(|entry| entry.metadata.input_metadata.as_ref().is_some_and(|input| input.usage().compaction == session_store::InputCompactionUse::ReferenceInput)).collect(); assert_eq!(journal_items.len(), 1); let journal_item = Item::from(journal_items[0].item.clone()); let body = journal_item.as_text().unwrap(); assert!(body.contains(&expected_json), "durable seed contains the full selected JSON and processing checkpoint"); assert!(body.contains(BEFORE)); assert!(!body.contains(AFTER)); - assert!(seed.iter().any(|entry| entry.metadata.origin == LoggedSessionHistoryOrigin::CompactionSummary && Item::from(entry.item.clone()).as_text().is_some_and(|text| text.contains(COMPLEMENT)))); + assert!(seed.iter().any(|entry| entry.metadata.input_metadata.as_ref().is_some_and(|input| input.usage().compaction == session_store::InputCompactionUse::OpeningSummary) && Item::from(entry.item.clone()).as_text().is_some_and(|text| text.contains(COMPLEMENT)))); let requests = model.requests.lock().unwrap(); let input = requests[0].items.iter().find_map(|item| match item { Item::Message { role: agen::llm_client::types::Role::User, .. } => item.as_text().filter(|text| text.contains("## Conversation overview/index")), diff --git a/crates/standalone/src/subjektiv/journal/tests/handoff.rs b/crates/standalone/src/subjektiv/journal/tests/handoff.rs index 4b571a313..62d511c8f 100644 --- a/crates/standalone/src/subjektiv/journal/tests/handoff.rs +++ b/crates/standalone/src/subjektiv/journal/tests/handoff.rs @@ -79,7 +79,7 @@ async fn recorded_idle_body_handoff_preserves_open_after_source_deletion_and_see let sessions = session_store::FsStore::new_with_retention(workers.sessions_dir(second.worker_id()), crate::store::session_retention(&state)).unwrap(); let pointer = session_store::FsWorkerStore::new(workers.worker_metadata_dir(second.worker_id())).unwrap().read_by_name(&second.record().storage_key).unwrap().unwrap().active.unwrap(); let entries = sessions.read_all(session, pointer.segment_id.unwrap()).unwrap(); - let journal_items = entries.iter().filter(|entry| matches!(entry, session_store::LogEntry::AnnotatedSystemItem { entry, .. } if matches!(entry.item, session_store::SystemItem::JournalStartup { .. }))).count(); + let journal_items = entries.iter().filter(|entry| matches!(entry, session_store::LogEntry::AnnotatedSystemItem { entry, .. } if entry.metadata.input_metadata.as_ref().and_then(|input| input.feature_payload()).is_some_and(|payload| payload.schema() == "journal:startup"))).count(); assert_eq!(journal_items, 1); assert_eq!(catalog.store.subject(&subject.id).unwrap().unwrap().behavior_md, "BEHAVIOR_ORIGINAL_UNCHANGED"); second.shutdown().await.unwrap(); diff --git a/crates/standalone/src/subjektiv_sessions.rs b/crates/standalone/src/subjektiv_sessions.rs index 36a75df06..9eee16adc 100644 --- a/crates/standalone/src/subjektiv_sessions.rs +++ b/crates/standalone/src/subjektiv_sessions.rs @@ -1621,6 +1621,7 @@ allow = [] let history = |id: &str, item: LoggedItem| LoggedHistoryEntry { item, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId(id.into()), origin: LoggedSessionHistoryOrigin::BackendInstruction { operation_id: None }, derivation: None, diff --git a/crates/subjektiv/src/api.rs b/crates/subjektiv/src/api.rs index e8785e549..4ce172e3d 100644 --- a/crates/subjektiv/src/api.rs +++ b/crates/subjektiv/src/api.rs @@ -1299,6 +1299,14 @@ pub type OperationResult = std::result::Result; pub fn subjektiv_store_error(error: crate::SubjektivError) -> OperationError { match error { + crate::SubjektivError::AuthorizationDenied(reason) => { + OperationError::PermissionDenied(reason) + } + crate::SubjektivError::SubjectPermissionsConflict(_) + | crate::SubjektivError::PermissionOperationConflict(_) + | crate::SubjektivError::WorkerExecutionGrantConflict(_) => { + OperationError::Conflict(error.to_string()) + } crate::SubjektivError::SubjectScopeMismatch { .. } => { OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}")) } diff --git a/crates/subjektiv/src/legacy_migrations.rs b/crates/subjektiv/src/legacy_migrations.rs index 51ebe661f..8a3c45fd0 100644 --- a/crates/subjektiv/src/legacy_migrations.rs +++ b/crates/subjektiv/src/legacy_migrations.rs @@ -929,7 +929,7 @@ mod tests { let (_manager, _workspace, store) = open_store(&root, "workspace-a"); let migrated_subject = store.subject(&subject_id).unwrap().unwrap(); assert!(migrated_subject.behavior_md.is_empty()); - assert!(migrated_subject.behavior_md.is_empty()); + assert!(store.subject_permissions(&subject_id).unwrap().is_none()); let lifecycle_attribution = attribution( &subject_id, "runtime-1", diff --git a/crates/subjektiv/src/lib.rs b/crates/subjektiv/src/lib.rs index 59ae976f5..86ba70a6e 100644 --- a/crates/subjektiv/src/lib.rs +++ b/crates/subjektiv/src/lib.rs @@ -4,6 +4,7 @@ pub mod api; pub mod job; pub mod journal; mod legacy_migrations; +pub mod permissions; mod schema; use legacy_migrations::{ add_candidate_decision_receipts, add_subject_behavior, add_subject_session_attribution, @@ -82,6 +83,16 @@ pub enum SubjektivError { }, #[error("subject `{subject_id}` behavior content changed")] SubjectBehaviorConflict { subject_id: String }, + #[error("subject `{0}` permission source changed")] + SubjectPermissionsConflict(String), + #[error("permission operation `{0}` was reused with different input")] + PermissionOperationConflict(String), + #[error("authorization denied: {0}")] + AuthorizationDenied(String), + #[error("Worker execution grant binding conflicts for `{0}`")] + WorkerExecutionGrantConflict(String), + #[error("Subject `{subject_id}` has more than {limit} retained Worker execution grants")] + WorkerExecutionGrantListOverflow { subject_id: String, limit: usize }, #[error("surface generation conflict: {0}")] SurfaceGenerationConflict(String), #[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")] @@ -623,6 +634,11 @@ static MIGRATIONS: &[FeatureMigration] = &[ "add immutable candidate Journal context links", journal::memory_links::migrate, ), + FeatureMigration::new( + 12, + "add explicit Subject permission sources and immutable child execution grants", + permissions::migrate, + ), ]; pub const REGISTRATION: FeatureRegistration = diff --git a/crates/subjektiv/src/permissions.rs b/crates/subjektiv/src/permissions.rs new file mode 100644 index 000000000..06e9df5a3 --- /dev/null +++ b/crates/subjektiv/src/permissions.rs @@ -0,0 +1,288 @@ +//! Host-administered permission sources and immutable child grants, separate from behavior. +//! +//! The Host authenticates administrators before source writes and binds verified Worker/Profile +//! identities before grant writes. These records are not a Worker registry. The Host alone +//! authenticates root execution ownership against its canonical Backend singleton and checks +//! every live lineage edge; Journal bindings and retained grants cannot establish ownership. +//! Replays return historical evidence without restoring a source or granting current authority. +use super::*; +use server_api::authorization::{ + PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate, + WorkerExecutionGrant, validate_authorization_identifier, validate_scoped_permissions, +}; +use sha2::{Digest, Sha256}; + +pub const MAX_SUBJECT_WORKER_EXECUTION_GRANTS: usize = 1_000; + +pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { + tx.execute_batch(include_str!("permissions.sql"))?; + Ok(()) +} + +impl SubjektivStore { + /// Unset sources are `None` (deny), including migrated Subjects. This is an admin read, + /// not an effective-authorization decision; retired Subjects retain their source evidence. + pub fn subject_permissions(&self, subject_id: &str) -> Result> { + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + source_in_connection(connection, subject_id) + }) + } + + /// Host-admin-only replacement. Every replacement issues a fresh change ID, including + /// identical contents. Revocation is an explicit empty replacement. A scope-wide operation + /// ID binds Subject, CAS input, and both permission vectors; result and receipt commit together. + pub fn set_subject_permissions( + &self, + subject_id: &str, + update: &SubjectPermissionsUpdate, + ) -> Result { + validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?; + validate_authorization_identifier(&update.operation_id) + .map_err(SubjektivError::InvalidRecord)?; + if let Some(change) = &update.expected_change_id { + validate_authorization_identifier(change).map_err(SubjektivError::InvalidRecord)?; + } + validate_scope(self.scope_id(), &update.execution)?; + validate_scope(self.scope_id(), &update.delegation)?; + let fingerprint: String = Sha256::digest(serde_json::to_vec(&(subject_id, update))?) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + self.database.try_transaction(|tx| { + require_subject(tx, subject_id)?; + let receipt: Option<(String, String)> = tx.query_row( + "SELECT request_fingerprint, change_id FROM subject_permission_receipts WHERE operation_id=?1", + [&update.operation_id], |row| Ok((row.get(0)?, row.get(1)?)), + ).optional()?; + if let Some((saved, change_id)) = receipt { + if saved != fingerprint { + return Err(SubjektivError::PermissionOperationConflict(update.operation_id.clone())); + } + let raw: String = tx.query_row( + "SELECT record_json FROM subject_permission_changes WHERE subject_id=?1 AND change_id=?2", + params![subject_id, change_id], |row| row.get(0), + )?; + return Ok(serde_json::from_str(&raw)?); + } + require_active_subject(tx, subject_id)?; + let current = source_in_connection(tx, subject_id)?; + if current.as_ref().map(|source| &source.change_id) != update.expected_change_id.as_ref() { + return Err(SubjektivError::SubjectPermissionsConflict(subject_id.into())); + } + let source = SubjectPermissions { + change_id: issued_id("permissions"), + execution: update.execution.clone(), + delegation: update.delegation.clone(), + }; + tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)", + params![subject_id, source.change_id, serde_json::to_string(&source)?])?; + tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2) ON CONFLICT(subject_id) DO UPDATE SET change_id=excluded.change_id", + params![subject_id, source.change_id])?; + tx.execute("INSERT INTO subject_permission_receipts(operation_id,subject_id,request_fingerprint,change_id) VALUES(?1,?2,?3,?4)", + params![update.operation_id, subject_id, fingerprint, source.change_id])?; + Ok(source) + }) + } + + /// Binds child grants only. Exact retries recover an immutable binding even after revocation; + /// that success is not authorization to execute or redelegate. New bindings require the + /// current source, an active Subject and explicit delegation containment. The Host must + /// authenticate root singleton ownership and every live lineage edge before calling; + /// a missing retained parent is not ownership evidence. Profile resolution, provider scope, + /// Worker creation and caller authentication also remain Host duties. + pub fn bind_worker_execution_grant(&self, grant: &WorkerExecutionGrant) -> Result<()> { + validate_grant(self.scope_id(), grant)?; + self.database.try_transaction(|tx| { + if let Some(existing) = grant_in_connection(tx, &grant.runtime_id, &grant.worker_id)? { + return if existing == *grant { Ok(()) } else { + Err(SubjektivError::WorkerExecutionGrantConflict(format!("{}/{}", grant.runtime_id, grant.worker_id))) + }; + } + require_active_subject(tx, &grant.subject_id)?; + let source = source_in_connection(tx, &grant.subject_id)? + .ok_or_else(|| denied("Subject permissions are unset"))?; + if source.change_id != grant.source_change_id { + return Err(denied("Subject permission source is no longer current")); + } + require_covered(&source.delegation, &grant.permissions)?; + require_covered(&source.delegation, &grant.redelegation)?; + // A child grant cannot turn a body (including a released historical body) into a + // delegated child or attach a Worker historically attributed to a different Subject. + let invalid_target: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM journal_writer_bindings WHERE runtime_id=?1 AND worker_id=?2) OR EXISTS(SELECT 1 FROM subject_session_attributions WHERE runtime_id=?1 AND worker_id=?2 AND subject_id<>?3)", + params![grant.runtime_id, grant.worker_id, grant.subject_id], |row| row.get(0), + )?; + if invalid_target { return Err(denied("target is a body or belongs to another Subject")); } + let origin = require_retained_origin(tx, grant)?; + if let Some(origin) = origin { + require_covered(&origin.redelegation, &grant.permissions)?; + require_covered(&origin.redelegation, &grant.redelegation)?; + } + tx.execute("INSERT INTO worker_execution_grants(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)", + params![grant.runtime_id, grant.worker_id, grant.subject_id, grant.source_change_id, serde_json::to_string(grant)?])?; + Ok(()) + }) + } + + /// Returns this Subject's retained grants, including stale grants needed for cancellation + /// and reporting. Never an ownership list or effective-authorization decision. Overflow is + /// explicit rather than silently omitting Workers from a cancellation/reporting pass. + pub fn list_subject_worker_execution_grants( + &self, + subject_id: &str, + ) -> Result> { + validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?; + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + let mut statement = connection.prepare( + "SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2", + )?; + let mut rows = statement.query(params![subject_id, MAX_SUBJECT_WORKER_EXECUTION_GRANTS + 1])?; + let mut grants = Vec::new(); + while let Some(row) = rows.next()? { + if grants.len() == MAX_SUBJECT_WORKER_EXECUTION_GRANTS { + return Err(SubjektivError::WorkerExecutionGrantListOverflow { + subject_id: subject_id.into(), + limit: MAX_SUBJECT_WORKER_EXECUTION_GRANTS, + }); + } + grants.push(serde_json::from_str(&row.get::<_, String>(0)?)?); + } + Ok(grants) + }) + } + + /// Returns retained binding evidence, not current authorization. The Host must compare the + /// active Subject, source change, current origin ownership, Profile and provider constraints. + pub fn worker_execution_grant( + &self, + runtime: &str, + worker: &str, + ) -> Result> { + validate_authorization_identifier(runtime).map_err(SubjektivError::InvalidRecord)?; + validate_authorization_identifier(worker).map_err(SubjektivError::InvalidRecord)?; + self.database + .try_with_connection(|connection| grant_in_connection(connection, runtime, worker)) + } +} + +fn source_in_connection( + connection: &Connection, + subject: &str, +) -> Result> { + let raw: Option = connection.query_row( + "SELECT c.record_json FROM subject_permissions p JOIN subject_permission_changes c ON c.subject_id=p.subject_id AND c.change_id=p.change_id WHERE p.subject_id=?1", + [subject], |row| row.get(0), + ).optional()?; + raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)) + .transpose() +} + +fn grant_in_connection( + connection: &Connection, + runtime: &str, + worker: &str, +) -> Result> { + let raw: Option = connection + .query_row( + "SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2", + params![runtime, worker], + |row| row.get(0), + ) + .optional()?; + raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)) + .transpose() +} + +fn validate_scope(scope: &str, permissions: &[ScopedPermission]) -> Result<()> { + validate_scoped_permissions(permissions).map_err(SubjektivError::InvalidRecord)?; + for permission in permissions { + if let PermissionResource::Workspace { workspace_id } = &permission.resource + && workspace_id != scope + { + return Err(denied("Workspace permission is outside this store scope")); + } + } + Ok(()) +} + +fn validate_grant(scope: &str, grant: &WorkerExecutionGrant) -> Result<()> { + for value in [ + &grant.subject_id, + &grant.origin_runtime_id, + &grant.origin_worker_id, + &grant.source_change_id, + &grant.runtime_id, + &grant.worker_id, + &grant.profile, + &grant.profile_digest, + ] { + validate_authorization_identifier(value).map_err(SubjektivError::InvalidRecord)?; + } + if grant.origin_runtime_id == grant.runtime_id && grant.origin_worker_id == grant.worker_id { + return Err(denied("a child grant cannot originate from itself")); + } + validate_scope(scope, &grant.permissions)?; + validate_scope(scope, &grant.redelegation) +} + +fn require_covered(ceiling: &[ScopedPermission], requested: &[ScopedPermission]) -> Result<()> { + if requested + .iter() + .all(|permission| ceiling.iter().any(|allowed| allowed.covers(permission))) + { + Ok(()) + } else { + Err(denied( + "requested permission or redelegation exceeds its explicit ceiling", + )) + } +} + +fn require_retained_origin( + tx: &Transaction<'_>, + grant: &WorkerExecutionGrant, +) -> Result> { + let mut runtime = grant.origin_runtime_id.clone(); + let mut worker = grant.origin_worker_id.clone(); + let mut direct_origin = None; + let mut visited = HashSet::from([(grant.runtime_id.clone(), grant.worker_id.clone())]); + // Validate retained lineage only. Absence terminates at a Host-authenticated root; + // neither Journal active state nor absence of a grant proves current singleton ownership. + for _ in 0..64 { + if !visited.insert((runtime.clone(), worker.clone())) { + return Err(denied("cyclic grant origin")); + } + let foreign_attribution: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM journal_writer_bindings WHERE runtime_id=?1 AND worker_id=?2 AND subject_id<>?3) OR EXISTS(SELECT 1 FROM subject_session_attributions WHERE runtime_id=?1 AND worker_id=?2 AND subject_id<>?3)", + params![runtime, worker, grant.subject_id], |row| row.get(0), + )?; + if foreign_attribution { + return Err(denied("grant origin is attributed to another Subject")); + } + let Some(origin) = grant_in_connection(tx, &runtime, &worker)? else { + return Ok(direct_origin); + }; + if origin.subject_id != grant.subject_id + || origin.source_change_id != grant.source_change_id + { + return Err(denied( + "grant origin belongs to another Subject or permission source", + )); + } + if direct_origin.is_none() { + direct_origin = Some(origin.clone()); + } + runtime = origin.origin_runtime_id; + worker = origin.origin_worker_id; + } + Err(denied("grant origin exceeds maximum delegation depth")) +} + +fn denied(reason: &str) -> SubjektivError { + SubjektivError::AuthorizationDenied(reason.into()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/subjektiv/src/permissions.sql b/crates/subjektiv/src/permissions.sql new file mode 100644 index 000000000..a663f192a --- /dev/null +++ b/crates/subjektiv/src/permissions.sql @@ -0,0 +1,45 @@ +CREATE TABLE subject_permission_changes ( + subject_id TEXT NOT NULL REFERENCES subjects(subject_id) ON DELETE RESTRICT, + change_id TEXT NOT NULL CHECK (length(change_id) > 0), + record_json TEXT NOT NULL, + PRIMARY KEY (subject_id, change_id) +); +CREATE TABLE subject_permissions ( + subject_id TEXT PRIMARY KEY REFERENCES subjects(subject_id) ON DELETE RESTRICT, + change_id TEXT NOT NULL, + FOREIGN KEY (subject_id, change_id) REFERENCES subject_permission_changes(subject_id, change_id) ON DELETE RESTRICT +); +CREATE TABLE subject_permission_receipts ( + operation_id TEXT PRIMARY KEY CHECK (length(operation_id) > 0), + subject_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + change_id TEXT NOT NULL, + FOREIGN KEY (subject_id, change_id) REFERENCES subject_permission_changes(subject_id, change_id) ON DELETE RESTRICT +); +CREATE TABLE worker_execution_grants ( + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + subject_id TEXT NOT NULL, + source_change_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (runtime_id, worker_id), + FOREIGN KEY (subject_id, source_change_id) REFERENCES subject_permission_changes(subject_id, change_id) ON DELETE RESTRICT +); +CREATE TRIGGER subject_permission_changes_no_update BEFORE UPDATE ON subject_permission_changes BEGIN + SELECT RAISE(ABORT, 'Subject permission changes are immutable'); +END; +CREATE TRIGGER subject_permission_changes_no_delete BEFORE DELETE ON subject_permission_changes BEGIN + SELECT RAISE(ABORT, 'Subject permission changes are retained'); +END; +CREATE TRIGGER subject_permission_receipts_no_update BEFORE UPDATE ON subject_permission_receipts BEGIN + SELECT RAISE(ABORT, 'Subject permission receipts are immutable'); +END; +CREATE TRIGGER subject_permission_receipts_no_delete BEFORE DELETE ON subject_permission_receipts BEGIN + SELECT RAISE(ABORT, 'Subject permission receipts are retained'); +END; +CREATE TRIGGER worker_execution_grants_no_update BEFORE UPDATE ON worker_execution_grants BEGIN + SELECT RAISE(ABORT, 'Worker execution grants are immutable'); +END; +CREATE TRIGGER worker_execution_grants_no_delete BEFORE DELETE ON worker_execution_grants BEGIN + SELECT RAISE(ABORT, 'Worker execution grants are retained'); +END; diff --git a/crates/subjektiv/src/permissions/tests.rs b/crates/subjektiv/src/permissions/tests.rs new file mode 100644 index 000000000..ab426bf2a --- /dev/null +++ b/crates/subjektiv/src/permissions/tests.rs @@ -0,0 +1,656 @@ +//! Real SQLite component tests through store APIs; Host authentication, canonical singleton +//! ownership and live lineage checks are outside this suite. Root origins are Host-attested inputs; +//! Journal fixtures exercise defensive attribution only, never execution authority. +use super::*; +use crate::tests::{open_store, role}; +use feature_storage::FeatureStorage; +use std::path::Path; + +fn permission(operation: &str, path: &str, recursive: bool) -> ScopedPermission { + ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workdir { + working_directory_id: "wd".into(), + path: path.into(), + recursive, + }, + } +} + +fn update( + operation: &str, + expected: Option<&str>, + delegation: Vec, +) -> SubjectPermissionsUpdate { + SubjectPermissionsUpdate { + operation_id: operation.into(), + expected_change_id: expected.map(Into::into), + execution: Vec::new(), + delegation, + } +} + +fn grant(subject: &str, source: &str, worker: &str) -> WorkerExecutionGrant { + WorkerExecutionGrant { + subject_id: subject.into(), + origin_runtime_id: "runtime".into(), + origin_worker_id: "body".into(), + source_change_id: source.into(), + runtime_id: "runtime".into(), + worker_id: worker.into(), + profile: "builtin:coder".into(), + profile_digest: "resolved-profile-sha256".into(), + permissions: vec![permission("file.read", "src", false)], + redelegation: Vec::new(), + } +} + +fn schema(store: &SubjektivStore) -> Vec<(String, String)> { + store.database.try_with_connection::<_, SubjektivError>(|connection| { + let mut statement = connection.prepare("SELECT name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY name")?; + Ok(statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?.collect::>()?) + }).unwrap() +} + +fn open_version( + root: &Path, + version: usize, +) -> (FeatureStorage, ScopedFeatureStorage, SubjektivStore) { + let manager = FeatureStorage::new(root); + let scope = manager.scope("workspace-a").unwrap(); + let registration = scope + .register(FeatureRegistration::new( + SUBJEKTIV_FEATURE_ID, + &MIGRATIONS[..version], + )) + .unwrap(); + let store = SubjektivStore::open(&scope, ®istration).unwrap(); + (manager, scope, store) +} + +#[test] +fn replacements_are_cas_fenced_replayable_and_never_restore_old_permissions() { + let temp = tempfile::tempdir().unwrap(); + let subject; + let first; + let first_update = update("allow", None, vec![permission("file.read", ".", true)]); + { + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + subject = store.create_subject(role()).unwrap().id; + assert!(store.subject_permissions(&subject).unwrap().is_none()); + first = store + .set_subject_permissions(&subject, &first_update) + .unwrap(); + assert_eq!( + store + .set_subject_permissions(&subject, &first_update) + .unwrap(), + first + ); + assert!(matches!( + store.set_subject_permissions(&subject, &update("stale", None, Vec::new())), + Err(SubjektivError::SubjectPermissionsConflict(_)) + )); + assert!(matches!( + store.set_subject_permissions(&subject, &update("allow", None, Vec::new())), + Err(SubjektivError::PermissionOperationConflict(_)) + )); + let other = store.create_subject(role()).unwrap(); + assert!(matches!( + store.set_subject_permissions(&other.id, &first_update), + Err(SubjektivError::PermissionOperationConflict(_)) + )); + let same = store + .set_subject_permissions( + &subject, + &update("same", Some(&first.change_id), first.delegation.clone()), + ) + .unwrap(); + assert_ne!(same.change_id, first.change_id); + let revoked = store + .set_subject_permissions( + &subject, + &update("revoke", Some(&same.change_id), Vec::new()), + ) + .unwrap(); + assert!(revoked.delegation.is_empty()); + assert_eq!( + store + .set_subject_permissions(&subject, &first_update) + .unwrap(), + first + ); + assert_eq!(store.subject_permissions(&subject).unwrap(), Some(revoked)); + } + let (_reopened_manager, _reopened_scope, reopened) = open_store(temp.path(), "workspace-a"); + assert!( + reopened + .subject_permissions(&subject) + .unwrap() + .unwrap() + .delegation + .is_empty() + ); + assert_eq!( + reopened + .set_subject_permissions(&subject, &first_update) + .unwrap(), + first + ); +} + +#[test] +fn failed_receipt_insert_rolls_back_source_history_and_projection() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let first = store + .set_subject_permissions(&subject, &update("first", None, Vec::new())) + .unwrap(); + store.database.try_transaction::<_, SubjektivError>(|tx| { + tx.execute_batch("CREATE TRIGGER fail_permission_receipt BEFORE INSERT ON subject_permission_receipts BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?; + Ok(()) + }).unwrap(); + let replacement = update( + "second", + Some(&first.change_id), + vec![permission("file.read", ".", true)], + ); + assert!( + store + .set_subject_permissions(&subject, &replacement) + .is_err() + ); + assert_eq!(store.subject_permissions(&subject).unwrap(), Some(first)); + store.database.try_transaction::<_, SubjektivError>(|tx| { + let counts: (u32, u32) = tx.query_row("SELECT (SELECT count(*) FROM subject_permission_changes),(SELECT count(*) FROM subject_permission_receipts)", [], |r| Ok((r.get(0)?, r.get(1)?)))?; + assert_eq!(counts, (1, 1)); + tx.execute_batch("DROP TRIGGER fail_permission_receipt")?; + Ok(()) + }).unwrap(); + assert!( + store + .set_subject_permissions(&subject, &replacement) + .is_ok() + ); +} + +#[test] +fn scope_and_validation_rejections_do_not_issue_permission_sources() { + let temp = tempfile::tempdir().unwrap(); + let (_a_manager, _a_scope, a) = open_store(&temp.path().join("a"), "workspace-a"); + let (_b_manager, _b_scope, b) = open_store(&temp.path().join("b"), "workspace-b"); + let subject = a.create_subject(role()).unwrap().id; + assert!( + b.set_subject_permissions(&subject, &update("cross-subject", None, Vec::new())) + .is_err() + ); + let cross_workspace = ScopedPermission { + operation: "ticket.create".into(), + resource: PermissionResource::Workspace { + workspace_id: "workspace-b".into(), + }, + }; + assert!( + a.set_subject_permissions( + &subject, + &update("cross-workspace", None, vec![cross_workspace]) + ) + .is_err() + ); + for (operation, path) in [ + ("file.*", "src"), + ("file..read", "src"), + ("file.read", "../src"), + ("file.read", "src/../escape"), + ] { + assert!( + a.set_subject_permissions( + &subject, + &update("invalid", None, vec![permission(operation, path, true)]) + ) + .is_err() + ); + } + assert!(a.subject_permissions(&subject).unwrap().is_none()); +} + +#[test] +fn child_grants_are_immutable_bounded_and_do_not_create_subject_ownership() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", "src", true)]), + ) + .unwrap(); + let child = grant(&subject, &source.change_id, "child"); + store.bind_worker_execution_grant(&child).unwrap(); + store.bind_worker_execution_grant(&child).unwrap(); + assert_eq!( + store.worker_execution_grant("runtime", "child").unwrap(), + Some(child.clone()) + ); + assert!( + store + .journal_body_is_unpublished(&subject, "runtime", "child") + .unwrap() + ); + let mut changed = child.clone(); + changed.profile_digest = "changed-profile".into(); + assert!(matches!( + store.bind_worker_execution_grant(&changed), + Err(SubjektivError::WorkerExecutionGrantConflict(_)) + )); + for (worker, requested) in [ + ("write", permission("file.write", "src", false)), + ("escape", permission("file.read", ".", true)), + ("prefix", permission("file.read", "src-other", false)), + ] { + let mut candidate = grant(&subject, &source.change_id, worker); + candidate.permissions = vec![requested]; + assert!(store.bind_worker_execution_grant(&candidate).is_err()); + assert!( + store + .worker_execution_grant("runtime", worker) + .unwrap() + .is_none() + ); + } + let mut self_origin = child.clone(); + self_origin.worker_id = "body".into(); + assert!(store.bind_worker_execution_grant(&self_origin).is_err()); + let other = store.create_subject(role()).unwrap(); + store + .claim_journal_writer(&other.id, "runtime", "other-body") + .unwrap(); + let mut invalid = grant(&subject, &source.change_id, "other-body"); + assert!(store.bind_worker_execution_grant(&invalid).is_err()); + invalid.worker_id = "forged-child".into(); + invalid.origin_worker_id = "other-body".into(); + assert!(store.bind_worker_execution_grant(&invalid).is_err()); +} + +#[test] +fn redelegation_requires_current_source_and_explicit_retained_parent_ceiling() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", ".", true)]), + ) + .unwrap(); + let mut child = grant(&subject, &source.change_id, "child"); + child.redelegation = vec![permission("file.read", "src", true)]; + store.bind_worker_execution_grant(&child).unwrap(); + let mut descendant = grant(&subject, &source.change_id, "grandchild"); + descendant.origin_worker_id = "child".into(); + store.bind_worker_execution_grant(&descendant).unwrap(); + let mut excessive = descendant.clone(); + excessive.worker_id = "excessive".into(); + excessive.redelegation = vec![permission("file.read", ".", true)]; + assert!(store.bind_worker_execution_grant(&excessive).is_err()); + let mut nondelegator = descendant.clone(); + nondelegator.origin_worker_id = "grandchild".into(); + nondelegator.worker_id = "great-grandchild".into(); + assert!(store.bind_worker_execution_grant(&nondelegator).is_err()); + let mut stale = descendant.clone(); + stale.worker_id = "after-source-replacement".into(); + let revoked = store + .set_subject_permissions( + &subject, + &update("revoke", Some(&source.change_id), Vec::new()), + ) + .unwrap(); + assert_ne!(revoked.change_id, source.change_id); + stale.origin_worker_id = "new-body".into(); + assert!(store.bind_worker_execution_grant(&stale).is_err()); + // Exact retries/readback recover evidence but cannot revive the source or origin. + store.bind_worker_execution_grant(&child).unwrap(); + assert_eq!( + store.worker_execution_grant("runtime", "child").unwrap(), + Some(child) + ); + store.retire_subject(&subject).unwrap(); + stale.source_change_id = revoked.change_id; + assert!(store.bind_worker_execution_grant(&stale).is_err()); +} + +#[test] +fn journal_state_neither_authenticates_roots_nor_bypasses_retained_child_ceilings() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", ".", true)]), + ) + .unwrap(); + // The Host has authenticated root ownership outside this store. No Journal record is + // required; changing Journal writer state cannot supply or invalidate that authentication. + store + .bind_worker_execution_grant(&grant(&subject, &source.change_id, "without-journal")) + .unwrap(); + let journal = store + .claim_journal_writer(&subject, "runtime", "body") + .unwrap(); + store + .bind_worker_execution_grant(&grant(&subject, &source.change_id, "with-active-journal")) + .unwrap(); + store.release_journal_writer(&journal).unwrap(); + store + .bind_worker_execution_grant(&grant(&subject, &source.change_id, "with-released-journal")) + .unwrap(); + // An active Journal binding must never upgrade a child's empty redelegation ceiling. + store + .claim_journal_writer(&subject, "runtime", "without-journal") + .unwrap(); + let mut descendant = grant(&subject, &source.change_id, "descendant"); + descendant.origin_worker_id = "without-journal".into(); + assert!(matches!( + store.bind_worker_execution_grant(&descendant), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!( + store + .worker_execution_grant("runtime", "descendant") + .unwrap() + .is_none() + ); +} + +#[test] +fn retained_origins_reject_cycles_foreign_subjects_and_stale_parent_sources() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", ".", true)]), + ) + .unwrap(); + let mut child = grant(&subject, &source.change_id, "child"); + child.redelegation = source.delegation.clone(); + store.bind_worker_execution_grant(&child).unwrap(); + // Closing a retained chain back through its previously unbound root must not create a cycle. + let mut cycle = grant(&subject, &source.change_id, "body"); + cycle.origin_worker_id = "child".into(); + assert!(matches!( + store.bind_worker_execution_grant(&cycle), + Err(SubjektivError::AuthorizationDenied(_)) + )); + let other = store.create_subject(role()).unwrap().id; + let other_source = store + .set_subject_permissions(&other, &update("other", None, source.delegation.clone())) + .unwrap(); + let mut foreign = grant(&other, &other_source.change_id, "foreign-child"); + foreign.origin_worker_id = "child".into(); + assert!(matches!( + store.bind_worker_execution_grant(&foreign), + Err(SubjektivError::AuthorizationDenied(_)) + )); + let current = store + .set_subject_permissions( + &subject, + &update("replace", Some(&source.change_id), source.delegation), + ) + .unwrap(); + let mut stale_parent = grant(&subject, ¤t.change_id, "stale-parent-child"); + stale_parent.origin_worker_id = "child".into(); + assert!(matches!( + store.bind_worker_execution_grant(&stale_parent), + Err(SubjektivError::AuthorizationDenied(_)) + )); +} + +#[test] +fn failed_grant_insert_leaves_no_binding_and_can_retry_same_grant() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", ".", true)]), + ) + .unwrap(); + let child = grant(&subject, &source.change_id, "child"); + store.database.try_transaction::<_, SubjektivError>(|tx| { + tx.execute_batch("CREATE TRIGGER fail_grant BEFORE INSERT ON worker_execution_grants BEGIN SELECT RAISE(ABORT,'injected grant failure'); END;")?; + Ok(()) + }).unwrap(); + assert!(store.bind_worker_execution_grant(&child).is_err()); + assert!( + store + .worker_execution_grant("runtime", "child") + .unwrap() + .is_none() + ); + store + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute_batch("DROP TRIGGER fail_grant")?; + Ok(()) + }) + .unwrap(); + store.bind_worker_execution_grant(&child).unwrap(); + for sql in [ + "UPDATE worker_execution_grants SET source_change_id='forged'", + "DELETE FROM worker_execution_grants", + "UPDATE subject_permission_changes SET record_json='{}'", + "DELETE FROM subject_permission_receipts", + ] { + assert!( + store + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute_batch(sql)?; + Ok(()) + }) + .is_err(), + "{sql}" + ); + } + assert_eq!( + store.worker_execution_grant("runtime", "child").unwrap(), + Some(child) + ); +} + +#[test] +fn current_schema_migration_preserves_subject_and_matches_new_database() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("migrated"); + let original; + { + let (_old_manager, _old_scope, old) = open_version(&root, 11); + original = old + .create_subject_with_behavior(role(), "Keep behavior separate".into()) + .unwrap(); + } + let (_migrated_manager, _migrated_scope, migrated) = open_store(&root, "workspace-a"); + assert_eq!( + migrated.subject(&original.id).unwrap(), + Some(original.clone()) + ); + assert!( + migrated + .subject_permissions(&original.id) + .unwrap() + .is_none() + ); + let (_fresh_manager, _fresh_scope, fresh) = + open_store(&temp.path().join("fresh"), "workspace-a"); + assert_eq!(schema(&migrated), schema(&fresh)); + let source = migrated + .set_subject_permissions(&original.id, &update("new-source", None, Vec::new())) + .unwrap(); + assert!(!source.change_id.is_empty()); + assert_eq!(migrated.subject(&original.id).unwrap(), Some(original)); +} + +#[test] +fn unset_or_execution_only_source_cannot_issue_child_grants_and_saved_grants_survive_reopen() { + let temp = tempfile::tempdir().unwrap(); + let saved; + { + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + assert!( + store + .bind_worker_execution_grant(&grant(&subject, "unset", "child")) + .is_err() + ); + let mut execution_only = update("execution", None, Vec::new()); + execution_only.execution = vec![permission("file.read", ".", true)]; + let source = store + .set_subject_permissions(&subject, &execution_only) + .unwrap(); + assert!( + store + .bind_worker_execution_grant(&grant(&subject, &source.change_id, "child")) + .is_err() + ); + let source = store + .set_subject_permissions( + &subject, + &update( + "delegation", + Some(&source.change_id), + execution_only.execution, + ), + ) + .unwrap(); + saved = grant(&subject, &source.change_id, "child"); + store.bind_worker_execution_grant(&saved).unwrap(); + } + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + assert_eq!( + store.worker_execution_grant("runtime", "child").unwrap(), + Some(saved.clone()) + ); + store.bind_worker_execution_grant(&saved).unwrap(); + let mut conflicting = saved; + conflicting.profile = "builtin:default".into(); + assert!(matches!( + store.bind_worker_execution_grant(&conflicting), + Err(SubjektivError::WorkerExecutionGrantConflict(_)) + )); +} + +#[test] +fn subject_grant_listing_is_scoped_retained_ordered_and_fails_on_overflow() { + 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; + assert!( + store + .list_subject_worker_execution_grants(&subject) + .unwrap() + .is_empty() + ); + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", ".", true)]), + ) + .unwrap(); + let other_source = store + .set_subject_permissions(&other, &update("other", None, source.delegation.clone())) + .unwrap(); + let other_grant = grant(&other, &other_source.change_id, "other-child"); + store.bind_worker_execution_grant(&other_grant).unwrap(); + for index in (0..MAX_SUBJECT_WORKER_EXECUTION_GRANTS).rev() { + store + .bind_worker_execution_grant(&grant( + &subject, + &source.change_id, + &format!("child-{index:04}"), + )) + .unwrap(); + } + store + .set_subject_permissions( + &subject, + &update("revoke", Some(&source.change_id), Vec::new()), + ) + .unwrap(); + store.retire_subject(&subject).unwrap(); + let listed = store + .list_subject_worker_execution_grants(&subject) + .unwrap(); + assert_eq!(listed.len(), MAX_SUBJECT_WORKER_EXECUTION_GRANTS); + assert!( + listed + .iter() + .all(|grant| grant.subject_id == subject && grant.source_change_id == source.change_id) + ); + assert!( + listed + .windows(2) + .all(|pair| (&pair[0].runtime_id, &pair[0].worker_id) + < (&pair[1].runtime_id, &pair[1].worker_id)) + ); + assert_eq!( + store.list_subject_worker_execution_grants(&other).unwrap(), + vec![other_grant.clone()] + ); + // Seed one more retained row directly: the retired Subject cannot issue new grants. + // Listing must report overflow rather than returning a seemingly complete cancellation set. + let overflow = grant(&subject, &source.change_id, "overflow"); + store.database.try_transaction::<_, SubjektivError>(|tx| { + tx.execute("INSERT INTO worker_execution_grants(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)", + params![overflow.runtime_id, overflow.worker_id, overflow.subject_id, overflow.source_change_id, serde_json::to_string(&overflow)?])?; + Ok(()) + }).unwrap(); + assert!( + matches!(store.list_subject_worker_execution_grants(&subject), Err(SubjektivError::WorkerExecutionGrantListOverflow { subject_id, limit: MAX_SUBJECT_WORKER_EXECUTION_GRANTS }) if subject_id == subject) + ); + assert_eq!( + store.list_subject_worker_execution_grants(&other).unwrap(), + vec![other_grant] + ); + let (_foreign_manager, _foreign_scope, foreign) = + open_store(&temp.path().join("foreign"), "workspace-b"); + assert!(matches!( + foreign.list_subject_worker_execution_grants(&subject), + Err(SubjektivError::SubjectNotFound(_)) + )); +} + +#[test] +fn failed_schema_migration_rolls_back_new_tables_and_preserves_old_data() { + let temp = tempfile::tempdir().unwrap(); + let original; + { + let (_old_manager, _old_scope, old) = open_version(temp.path(), 11); + original = old.create_subject(role()).unwrap(); + old.database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute_batch("CREATE TABLE subject_permissions (collision TEXT)")?; + Ok(()) + }) + .unwrap(); + } + { + let manager = FeatureStorage::new(temp.path()); + let scope = manager.scope("workspace-a").unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + assert!(SubjektivStore::open(&scope, ®istration).is_err()); + } + let (_old_manager, _old_scope, old) = open_version(temp.path(), 11); + assert_eq!(old.database.schema_version().unwrap(), 11); + assert_eq!(old.subject(&original.id).unwrap(), Some(original)); + old.database.try_with_connection::<_, SubjektivError>(|connection| { + let created: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='subject_permission_changes')", [], |row| row.get(0))?; + assert!(!created); + Ok(()) + }).unwrap(); +} diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index f10a68197..22edee14c 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -23,7 +23,8 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["process", "rt", "sync", "time"] } tracing = { workspace = true } workdir = { workspace = true } +uuid = { workspace = true, features = ["v7"] } [dev-dependencies] filetime = "0.2.27" -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } diff --git a/crates/tools/src/bash.rs b/crates/tools/src/bash.rs index 801507c0b..74dcee471 100644 --- a/crates/tools/src/bash.rs +++ b/crates/tools/src/bash.rs @@ -1,19 +1,19 @@ //! Bash Tool using WorkdirSession command capability. //! -//! The selected session owns cwd, process launch, bounded output, timeout, and cancellation. +//! Workdir owns launch and byte transport; Bash owns bounded preview and retained evidence. //! Bash is not a sandbox; the Runtime process and OS user define the execution boundary. //! Per-execution handles and nonces prevent cancellation of another command. Dropping a future -//! does not confirm termination; active registrations remain until cleanup requests cancellation -//! and observes terminal output. +//! does not confirm termination. Registrations remain during bounded cancellation/drain cleanup; +//! an unconfirmed provider outcome leaves partial evidence, never a fabricated terminal fence. use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use crate::bash_output::{BashOutputMetadata, BashOutputView, BashOutputWriter}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use async_trait::async_trait; use schemars::JsonSchema; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use workdir::{ CommandHandle, CommandOutputRequest, CommandRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter, @@ -35,9 +35,9 @@ struct BashParams { timeout: Option, } -pub(crate) struct BashTool { +pub struct BashTool { router: Arc, - output_dir: PathBuf, + output: BashOutputView, state: Arc>, } @@ -63,6 +63,8 @@ struct CommandGuard { execution_id: String, execution_nonce: u64, handle: Option, + cursor: u64, + retained: Option, } impl Drop for CommandGuard { @@ -74,20 +76,66 @@ impl Drop for CommandGuard { let state = Arc::clone(&self.state); let execution_id = self.execution_id.clone(); let execution_nonce = self.execution_nonce; + let mut retained = self.retained.take(); + let cursor = self.cursor; // A dropped provider future is not terminal confirmation. Keep the live // execution registered until cleanup has both requested cancellation and // observed terminal command output, so cancellation/session teardown // cannot race with an apparently empty registry. tokio::spawn(async move { - let _ = workdir.cancel_command(handle.clone()).await; - let _ = workdir - .command_output(CommandOutputRequest { - handle, - cursor: 0, - limit: INLINE_BYTE_BUDGET, - wait: true, - }) - .await; + let result = tokio::time::timeout(std::time::Duration::from_secs(10), async { + let _ = workdir.cancel_command(handle.clone()).await; + let mut cursor = cursor; + loop { + let page = command_page( + &workdir, + CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }, + ) + .await?; + if let Some(writer) = &mut retained { + let mut next = cursor; + for chunk in &page.chunks { + if chunk.sequence != next { + return Err(workdir::WorkdirError::Unavailable( + "command cleanup sequence gap".into(), + )); + } + writer + .append_chunk(chunk) + .map_err(workdir::WorkdirError::Unavailable)?; + next = next.checked_add(chunk.bytes.len() as u64).ok_or_else(|| { + workdir::WorkdirError::Unavailable( + "command cleanup cursor overflow".into(), + ) + })?; + } + if page.next_cursor != next { + return Err(workdir::WorkdirError::Unavailable( + "command cleanup cursor gap".into(), + )); + } + } + cursor = page.next_cursor; + if let Some(terminal) = page.terminal { + if let Some(writer) = &mut retained { + writer + .finish_transport(&terminal) + .map_err(workdir::WorkdirError::Unavailable)?; + } + return Ok::<(), workdir::WorkdirError>(()); + } + } + }) + .await; + // Bounded cleanup can end with an unknown provider outcome. The writer then + // terminalizes partial evidence on drop; retaining a dead collector in `active` + // would leak registrations and falsely suggest it could still collect output. + let _ = result; let mut state = state.lock().unwrap(); if state .active @@ -101,15 +149,49 @@ impl Drop for CommandGuard { } } -#[async_trait] -impl Tool for BashTool { - async fn execute( +// Output pages are replayable only with the exact same command/cursor/limit. +// Retry read transport failures, never command start, and cap both attempts and +// backoff. The caller's deadline bounds a disconnected or unresponsive provider. +async fn command_page( + session: &workdir::WorkdirSessionHandle, + request: CommandOutputRequest, +) -> Result { + for attempt in 0..3 { + match session.command_output(request.clone()).await { + Err( + workdir::WorkdirError::Transport(_) + | workdir::WorkdirError::Unavailable(_) + | workdir::WorkdirError::OutcomeUnknown(_), + ) if attempt < 2 => { + tokio::time::sleep(std::time::Duration::from_millis(100 << attempt)).await; + } + result => return result, + } + } + unreachable!("bounded retry loop returns its last result") +} + +impl BashTool { + pub fn input_schema() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(BashParams)).expect("Bash schema serialization") + } + + pub fn new(router: Arc, output: BashOutputView) -> Self { + Self { + router, + output, + state: Arc::new(Mutex::new(BashExecutionState::default())), + } + } + + pub async fn execute_result( &self, input_json: &str, ctx: agen::tool::ToolExecutionContext, - ) -> Result { + ) -> Result { let params: BashParams = serde_json::from_str(input_json) .map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?; + let retained = self.output.begin().map_err(ToolError::ExecutionFailed)?; let selected = crate::routing::resolve_session( &self.router, params.target_workdir.as_deref(), @@ -134,14 +216,15 @@ impl Tool for BashTool { execution_id: execution_id.clone(), execution_nonce, handle: None, + cursor: 0, + retained: Some(retained), }; let handle = session .start_command(CommandRequest { command: params.command, timeout_secs, - output_limit: INLINE_BYTE_BUDGET, + transport_byte_budget: self.output.byte_budget(), cwd: params.cwd, - spill_dir: Some(self.output_dir.clone()), tool_call_id: Some(call_id.clone()), }) .await @@ -167,15 +250,101 @@ impl Tool for BashTool { .await .map_err(crate::ToolsError::from)?; } - let output = session - .command_output(CommandOutputRequest { - handle, - cursor: 0, - limit: INLINE_BYTE_BUDGET, - wait: true, - }) + let mut cursor = 0; + let mut stdout_preview = Vec::new(); + let mut stderr_preview = Vec::new(); + let mut seen = [0_u64; 2]; + let mut empty_polls = 0; + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs + 30); + let terminal = loop { + let page = tokio::time::timeout_at( + deadline, + command_page( + &session, + CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }, + ), + ) .await + .map_err(|_| { + ToolError::ExecutionFailed( + "command byte transport deadline exceeded; terminal outcome unknown".into(), + ) + })? .map_err(crate::ToolsError::from)?; + let mut next = cursor; + for chunk in &page.chunks { + if chunk.sequence != next || chunk.bytes.is_empty() { + return Err(ToolError::ExecutionFailed( + "command byte transport sequence gap".into(), + )); + } + next = next + .checked_add(chunk.bytes.len() as u64) + .filter(|n| *n <= self.output.byte_budget()) + .ok_or_else(|| { + ToolError::ExecutionFailed( + "command byte transport exceeds delivery budget".into(), + ) + })?; + let index = if chunk.stream == workdir::CommandStream::Stdout { + 0 + } else { + 1 + }; + if chunk.offset != seen[index] { + return Err(ToolError::ExecutionFailed( + "command byte transport offset gap".into(), + )); + } + seen[index] += chunk.bytes.len() as u64; + guard + .retained + .as_mut() + .unwrap() + .append_chunk(chunk) + .map_err(ToolError::ExecutionFailed)?; + let available = + INLINE_BYTE_BUDGET.saturating_sub(stdout_preview.len() + stderr_preview.len()); + let preview = if index == 0 { + &mut stdout_preview + } else { + &mut stderr_preview + }; + preview.extend_from_slice(&chunk.bytes[..available.min(chunk.bytes.len())]); + } + if page.next_cursor != next { + return Err(ToolError::ExecutionFailed( + "command byte transport cursor does not fence the page".into(), + )); + } + if page.chunks.is_empty() && page.terminal.is_none() { + empty_polls += 1; + if empty_polls > 32 { + return Err(ToolError::ExecutionFailed( + "command byte transport made no progress after bounded polls".into(), + )); + } + } else { + empty_polls = 0; + } + cursor = page.next_cursor; + guard.cursor = cursor; + if let Some(terminal) = page.terminal { + break terminal; + } + }; + let metadata = guard + .retained + .as_mut() + .unwrap() + .finish_transport(&terminal) + .map_err(ToolError::ExecutionFailed)?; let cancellation_requested = { let mut state = self.state.lock().unwrap(); let owns_registration = state @@ -193,41 +362,78 @@ impl Tool for BashTool { }; guard.handle = None; - let timed_out = output.timed_out; - let summary = if cancellation_requested { - format!("$ {cmd_summary} (cancelled)") - } else if output.timed_out { - format!("$ {cmd_summary} (timed out after {timeout_secs}s)") - } else { - match output.exit_code { - Some(0) => format!("$ {cmd_summary}"), - Some(code) => format!("$ {cmd_summary} (exit {code})"), - None => format!("$ {cmd_summary} (terminated)"), - } - }; - let content = if output.content.is_empty() { - None - } else if output.truncated { - let notice = match output.output_path { - Some(path) => format!( - "[showing bounded WorkdirSession command output; full output saved to {}]", - path.display() - ), - None => "[showing bounded WorkdirSession command output; additional output was truncated]" - .to_owned(), + let timed_out = terminal.status == workdir::CommandStatus::TimedOut; + let summary = + if cancellation_requested || terminal.status == workdir::CommandStatus::Cancelled { + format!("$ {cmd_summary} (cancelled)") + } else if timed_out { + format!("$ {cmd_summary} (timed out after {timeout_secs}s)") + } else { + match terminal.exit_code { + Some(0) => format!("$ {cmd_summary}"), + Some(code) => format!("$ {cmd_summary} (exit {code})"), + None => format!("$ {cmd_summary} (terminated)"), + } }; - Some(format!("{notice}\n{}", output.content)) - } else { - Some(output.content) - }; - let output = ToolOutput { + Ok(BashResult { summary, - content, - attachments: Vec::new(), - }; - if cancellation_requested { + stdout_preview: String::from_utf8_lossy(&stdout_preview).into_owned(), + stderr_preview: String::from_utf8_lossy(&stderr_preview).into_owned(), + preview_omitted: terminal.stdout_bytes.saturating_add(terminal.stderr_bytes) + > (stdout_preview.len() + stderr_preview.len()) as u64, + metadata, + cancelled: cancellation_requested + || terminal.status == workdir::CommandStatus::Cancelled, + }) + } +} + +#[derive(Debug, Serialize)] +pub struct BashResult { + pub summary: String, + pub stdout_preview: String, + pub stderr_preview: String, + pub preview_omitted: bool, + pub metadata: BashOutputMetadata, + pub cancelled: bool, +} + +impl BashResult { + pub fn stdout_entry(&self) -> String { + format!("/bash-output/{}/stdout", self.metadata.id) + } + pub fn stderr_entry(&self) -> String { + format!("/bash-output/{}/stderr", self.metadata.id) + } + pub fn order_entry(&self) -> String { + format!("/bash-output/{}/order", self.metadata.id) + } + pub fn tool_output(&self) -> ToolOutput { + let content = serde_json::json!({ + "stdout_preview": self.stdout_preview, "stderr_preview": self.stderr_preview, + "preview_omitted": self.preview_omitted, "output": self.metadata, + "stdout_entry": self.stdout_entry(), "stderr_entry": self.stderr_entry(), "order_entry": self.order_entry(), + }); + ToolOutput { + summary: self.summary.clone(), + content: Some(content.to_string()), + attachments: vec![], + } + } +} + +#[async_trait] +impl Tool for BashTool { + async fn execute( + &self, + input: &str, + ctx: agen::tool::ToolExecutionContext, + ) -> Result { + let result = self.execute_result(input, ctx).await?; + let output = result.tool_output(); + if result.cancelled { Err(ToolError::Cancelled(output)) - } else if timed_out { + } else if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) { Err(ToolError::Interrupted(output)) } else { Ok(output) @@ -288,164 +494,19 @@ fn truncate_for_summary(command: &str) -> String { summary } -pub fn bash_tool(session: WorkdirSessionHandle, output_dir: PathBuf) -> ToolDefinition { - routed_bash_tool(crate::routing::singleton_router(session), output_dir) +pub fn bash_tool(session: WorkdirSessionHandle, output: BashOutputView) -> ToolDefinition { + routed_bash_tool(crate::routing::singleton_router(session), output) } pub(crate) fn routed_bash_tool( router: Arc, - output_dir: PathBuf, + output: BashOutputView, ) -> ToolDefinition { Arc::new(move || { - let schema = schemars::schema_for!(BashParams); let meta = ToolMeta::new("Bash") - .description("Execute a shell command in one selected Workdir. Process start, bounded inline output, full-output spill, timeout and cancellation are owned by that WorkdirSession provider. This is not a sandbox.") - .input_schema(serde_json::to_value(schema).expect("Bash schema serialization")); - let tool: Arc = Arc::new(BashTool { - router: router.clone(), - output_dir: output_dir.clone(), - state: Arc::new(Mutex::new(BashExecutionState::default())), - }); + .description("Execute a shell command in one selected Workdir. Workdir owns launch, lossless bounded byte transport, timeout and cancel; Bash owns preview, quota-limited ephemeral output and read-only virtual references. This is not a sandbox.") + .input_schema(BashTool::input_schema()); + let tool: Arc = Arc::new(BashTool::new(router.clone(), output.clone())); (meta, tool) }) } - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; - use tempfile::TempDir; - use workdir::{LocalWorkdirSession, WorkdirSessionHandle}; - - use super::bash_tool; - use crate::{grep::grep_tool, read::read_tool, tracker::Tracker}; - - fn session_with_output_scope(root: &TempDir, output: &TempDir) -> WorkdirSessionHandle { - let scope = Scope::from_config(&ScopeConfig { - allow: vec![ - ScopeRule { - target: root.path().to_path_buf(), - permission: Permission::Write, - recursive: true, - symlink_policy: Default::default(), - }, - ScopeRule { - target: output.path().to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }, - ], - deny: Vec::new(), - }) - .unwrap(); - Arc::new(LocalWorkdirSession::new(scope, root.path().to_path_buf())) - } - - #[tokio::test] - async fn long_output_is_spilled_and_available_to_read_and_grep() { - let root = TempDir::new().unwrap(); - let output = TempDir::new().unwrap(); - let session = session_with_output_scope(&root, &output); - let (_, bash) = bash_tool(session.clone(), output.path().to_path_buf())(); - let command = "i=0; while [ $i -lt 2000 ]; do printf 'line-%04d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'"; - let result = bash - .execute( - &serde_json::json!({ "command": command, "cwd": "." }).to_string(), - Default::default(), - ) - .await - .unwrap(); - let rendered = result.content.expect("bounded Bash output"); - let artifact = std::fs::read_dir(output.path()) - .unwrap() - .next() - .expect("artifact entry") - .unwrap() - .path(); - - assert!(rendered.contains("full output saved to")); - assert!(rendered.contains(&artifact.display().to_string())); - let retained = std::fs::read_to_string(&artifact).unwrap(); - assert!(retained.starts_with("line-0000\n")); - assert!(retained.ends_with("FINAL-NEEDLE\n")); - assert_eq!(retained.lines().count(), 2001); - - let (_, read) = read_tool(session.clone(), Tracker::new())(); - let read_result = read - .execute( - &serde_json::json!({ - "file_path": artifact, - "offset": 2000, - "limit": 1, - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - assert!( - read_result - .content - .expect("Read content") - .contains("FINAL-NEEDLE") - ); - - let (_, grep) = grep_tool(session)(); - let grep_result = grep - .execute( - &serde_json::json!({ - "pattern": "FINAL-NEEDLE", - "path": artifact, - "output_mode": "content", - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - let grep_content = grep_result.content.expect("Grep content"); - assert!( - grep_content.contains("FINAL-NEEDLE"), - "unexpected Grep content: {grep_content:?}" - ); - } - - #[tokio::test] - async fn missing_cwd_is_rejected_before_command_start() { - let root = TempDir::new().unwrap(); - let output = TempDir::new().unwrap(); - let session = session_with_output_scope(&root, &output); - let (_, bash) = bash_tool(session, output.path().to_path_buf())(); - - let error = bash - .execute( - &serde_json::json!({ "command": "printf hidden" }).to_string(), - Default::default(), - ) - .await - .unwrap_err(); - - assert!(error.to_string().contains("missing field `cwd`")); - } - - #[tokio::test] - async fn short_output_does_not_leave_a_spill_artifact() { - let root = TempDir::new().unwrap(); - let output = TempDir::new().unwrap(); - let session = session_with_output_scope(&root, &output); - let (_, bash) = bash_tool(session, output.path().to_path_buf())(); - - let result = bash - .execute( - &serde_json::json!({ "command": "printf short", "cwd": "." }).to_string(), - Default::default(), - ) - .await - .unwrap(); - - assert_eq!(result.content.as_deref(), Some("short")); - assert_eq!(std::fs::read_dir(output.path()).unwrap().count(), 0); - } -} diff --git a/crates/tools/src/bash_output.rs b/crates/tools/src/bash_output.rs new file mode 100644 index 000000000..538b50433 --- /dev/null +++ b/crates/tools/src/bash_output.rs @@ -0,0 +1,605 @@ +//! Bash-owned bounded output, independent of Workdir transport and Console subscribers. +//! +//! Output is ephemeral evidence, not a filesystem grant. Every lookup checks the live view; +//! children can see their own subtree and ancestors can retain evidence after child revocation. +//! Root teardown clears all bytes. A new root (including restore) has a new incarnation and +//! never reconstructs grants from history. Expiry is measured from admission, enforced on +//! lookup and by a weak reaper once commands run in Tokio. Parent and all descendants share +//! one pool: admission reserves a whole command budget, evicts the oldest terminal evidence +//! first, and refuses rather than evicting a live collector. Revoking a child denies access +//! but leaves ancestor evidence until pool eviction/expiry; root teardown clears the pool. + +use std::collections::VecDeque; +use std::sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, Ordering}, +}; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use workdir::{CommandStatus, CommandStream}; + +const COMMAND_BYTES: usize = 4 * 1024 * 1024; +const STORE_BYTES: usize = 32 * 1024 * 1024; +const STORE_COUNT: usize = 64; +const ORDER_COUNT: usize = 4096; +const RETENTION: Duration = Duration::from_secs(3600); + +#[derive(Clone)] +pub struct BashOutputView { + store: Arc>, + lineage: Vec, + active: Arc, + authority: Arc bool + Send + Sync>, +} + +struct Store { + records: VecDeque>>, + reserved: usize, + retention: Duration, + reaper_started: bool, + _shutdown: tokio::sync::watch::Sender<()>, +} + +struct Record { + id: String, + owner: Vec, + created: Instant, + stdout: Vec, + stderr: Vec, + order: Vec, + stdout_seen: u64, + stderr_seen: u64, + truncated: bool, + limit: Option, + transport_counts: Option<(u64, u64)>, + transport_truncation: Option, + terminal: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OutputRange { + pub stream: CommandStream, + pub start: u64, + pub end: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OutputTerminal { + pub status: Option, + pub exit_code: Option, + /// False on a forcibly dropped collector or failed transport: received bytes are retained, + /// but they are not a claim about the unobserved process tail. + pub transport_complete: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RetentionLimit { + ByteQuota, + OrderQuota, +} + +#[derive(Clone, Debug, Serialize)] +pub struct BashOutputMetadata { + pub id: String, + pub stdout_saved: usize, + pub stderr_saved: usize, + pub stdout_seen: u64, + pub stderr_seen: u64, + pub stdout_delivered: u64, + pub stderr_delivered: u64, + pub retention_limit: Option, + pub transport_truncation: Option, + pub storage_truncated: bool, + pub terminal: OutputTerminal, + pub text_encoding: &'static str, + pub ordering: &'static str, +} + +/// A bounded reservation acquired before command start. Drop terminalizes partial evidence. +pub struct BashOutputWriter { + record: Arc>, +} + +impl Default for BashOutputView { + fn default() -> Self { + Self::new() + } +} + +impl BashOutputView { + pub fn new() -> Self { + let (shutdown, _) = tokio::sync::watch::channel(()); + let store = Arc::new(Mutex::new(Store { + records: VecDeque::new(), + reserved: 0, + retention: RETENTION, + reaper_started: false, + _shutdown: shutdown, + })); + let view = Self { + store, + lineage: vec![uuid::Uuid::now_v7().to_string()], + active: Arc::new(AtomicBool::new(true)), + authority: Arc::new(|| true), + }; + view.start_reaper(); + view + } + + fn start_reaper(&self) { + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return; + }; + let mut store = self.store.lock().unwrap(); + if store.reaper_started { + return; + } + let receiver = store._shutdown.subscribe(); + store.reaper_started = true; + let weak = Arc::downgrade(&self.store); + runtime.spawn(reap(weak, receiver)); + } + + pub fn byte_budget(&self) -> u64 { + COMMAND_BYTES as u64 + } + + pub fn child(&self, authority: Arc bool + Send + Sync>) -> Self { + let parent = self.clone(); + let mut lineage = self.lineage.clone(); + lineage.push(uuid::Uuid::now_v7().to_string()); + Self { + store: self.store.clone(), + lineage, + active: Arc::new(AtomicBool::new(true)), + authority: Arc::new(move || parent.is_active() && authority()), + } + } + + pub fn is_active(&self) -> bool { + self.active.load(Ordering::Acquire) && (self.authority)() + } + + pub fn is_root(&self) -> bool { + self.lineage.len() == 1 + } + + pub fn revoke(&self) { + self.active.store(false, Ordering::Release); + if self.lineage.len() == 1 { + let mut store = self.store.lock().unwrap(); + store.records.clear(); + store.reserved = 0; + } + } + + pub fn begin(&self) -> Result { + if !self.is_active() { + return Err("Bash output authority revoked".into()); + } + // A host may construct the Feature before entering Tokio. Command admission + // happens inside the runtime, so idle expiry must start here as well. + self.start_reaper(); + let mut store = self.store.lock().unwrap(); + if !self.is_active() { + return Err("Bash output authority revoked".into()); + } + store.expire(Instant::now()); + while store.records.len() >= STORE_COUNT || store.reserved + COMMAND_BYTES > STORE_BYTES { + let Some(index) = store + .records + .iter() + .position(|r| r.lock().unwrap().terminal.is_some()) + else { + return Err("Bash output capacity is occupied by live commands".into()); + }; + store.records.remove(index); + store.reserved -= COMMAND_BYTES; + } + let record = Arc::new(Mutex::new(Record { + id: uuid::Uuid::now_v7().to_string(), + owner: self.lineage.clone(), + created: Instant::now(), + stdout: Vec::new(), + stderr: Vec::new(), + order: Vec::new(), + stdout_seen: 0, + stderr_seen: 0, + truncated: false, + limit: None, + transport_counts: None, + transport_truncation: None, + terminal: None, + })); + store.records.push_back(record.clone()); + store.reserved += COMMAND_BYTES; + Ok(BashOutputWriter { record }) + } + + fn record(&self, id: &str) -> Option>> { + if !self.is_active() { + return None; + } + let mut store = self.store.lock().unwrap(); + if !self.is_active() { + return None; + } + store.expire(Instant::now()); + store + .records + .iter() + .find(|r| { + let r = r.lock().unwrap(); + r.id == id && r.owner.starts_with(&self.lineage) && r.terminal.is_some() + }) + .cloned() + } + + pub fn metadata(&self, id: &str) -> Option { + let record = self.record(id)?; + let r = record.lock().unwrap(); + Some(r.metadata()) + } + + /// Copies at most the caller's bounded range; never publishes output bodies in descriptors. + pub fn read( + &self, + id: &str, + stream: CommandStream, + offset: usize, + limit: usize, + ) -> Option> { + let record = self.record(id)?; + let r = record.lock().unwrap(); + let bytes = match stream { + CommandStream::Stdout => &r.stdout, + CommandStream::Stderr => &r.stderr, + }; + let start = offset.min(bytes.len()); + Some(bytes[start..start.saturating_add(limit.min(64 * 1024)).min(bytes.len())].to_vec()) + } + + /// Scan the bounded raw source without cloning it, decoding only the requested line window. + /// A long single line uses the common byte reader instead of allocating an oversized text page. + pub fn line_window( + &self, + id: &str, + stream: CommandStream, + offset: usize, + limit: usize, + ) -> Option> { + let record = self.record(id)?; + let r = record.lock().unwrap(); + let bytes = match stream { + CommandStream::Stdout => &r.stdout, + CommandStream::Stderr => &r.stderr, + }; + let mut lines = bytes.split_inclusive(|b| *b == b'\n').skip(offset); + let mut window = Vec::new(); + for line in lines.by_ref().take(limit.min(2000)) { + if window.len() + line.len() > 64 * 1024 { + return Some(Err( + "text source window exceeds 64 KiB; use the common byte reader".into(), + )); + } + window.extend_from_slice(line); + } + let more = lines.next().is_some(); + Some(Ok((String::from_utf8_lossy(&window).into_owned(), more))) + } + + pub fn order(&self, id: &str) -> Option> { + let record = self.record(id)?; + let r = record.lock().unwrap(); + Some(r.order.clone()) + } +} + +impl Record { + fn metadata(&self) -> BashOutputMetadata { + BashOutputMetadata { + id: self.id.clone(), + stdout_saved: self.stdout.len(), + stderr_saved: self.stderr.len(), + stdout_seen: self.transport_counts.map_or(self.stdout_seen, |c| c.0), + stderr_seen: self.transport_counts.map_or(self.stderr_seen, |c| c.1), + stdout_delivered: self.stdout_seen, + stderr_delivered: self.stderr_seen, + retention_limit: self.limit, + transport_truncation: self.transport_truncation.clone(), + storage_truncated: self.truncated || self.transport_truncation.is_some(), + terminal: self.terminal.clone().expect("finalized record"), + text_encoding: "utf8_lossy", + ordering: "provider_read_observation", + } + } +} + +impl Store { + fn expire(&mut self, now: Instant) { + let before = self.records.len(); + self.records + .retain(|r| now.duration_since(r.lock().unwrap().created) < self.retention); + self.reserved -= (before - self.records.len()) * COMMAND_BYTES; + } +} + +async fn reap(weak: Weak>, mut shutdown: tokio::sync::watch::Receiver<()>) { + loop { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(30)) => {}, + _ = shutdown.changed() => return, + } + let Some(store) = weak.upgrade() else { + return; + }; + store.lock().unwrap().expire(Instant::now()); + } +} + +impl BashOutputWriter { + pub fn append_chunk(&mut self, chunk: &workdir::CommandByteChunk) -> Result<(), String> { + let expected = { + let r = self.record.lock().unwrap(); + match chunk.stream { + CommandStream::Stdout => r.stdout_seen, + CommandStream::Stderr => r.stderr_seen, + } + }; + if chunk.offset != expected || chunk.bytes.is_empty() { + return Err("command byte transport stream gap or empty chunk".into()); + } + self.append(chunk.stream, &chunk.bytes); + Ok(()) + } + + pub fn append(&mut self, stream: CommandStream, bytes: &[u8]) { + let mut r = self.record.lock().unwrap(); + let seen = match stream { + CommandStream::Stdout => &mut r.stdout_seen, + CommandStream::Stderr => &mut r.stderr_seen, + }; + let start = *seen; + *seen = seen.saturating_add(bytes.len() as u64); + let available = COMMAND_BYTES.saturating_sub(r.stdout.len() + r.stderr.len()); + let n = if r.truncated || r.order.len() >= ORDER_COUNT { + 0 + } else { + available.min(bytes.len()) + }; + if n < bytes.len() { + r.truncated = true; + if r.limit.is_none() { + r.limit = Some(if r.order.len() >= ORDER_COUNT { + RetentionLimit::OrderQuota + } else { + RetentionLimit::ByteQuota + }); + } + } + if n > 0 { + match stream { + CommandStream::Stdout => r.stdout.extend_from_slice(&bytes[..n]), + CommandStream::Stderr => r.stderr.extend_from_slice(&bytes[..n]), + } + if let Some(last) = r + .order + .last_mut() + .filter(|last| last.stream == stream && last.end == start) + { + last.end += n as u64; + } else { + r.order.push(OutputRange { + stream, + start, + end: start + n as u64, + }); + } + } + } + + pub fn finish_transport( + &mut self, + terminal: &workdir::CommandTerminal, + ) -> Result { + let mut r = self.record.lock().unwrap(); + if terminal.stdout_delivered_bytes != r.stdout_seen + || terminal.stderr_delivered_bytes != r.stderr_seen + || terminal.stdout_bytes < r.stdout_seen + || terminal.stderr_bytes < r.stderr_seen + || ((terminal.stdout_bytes != r.stdout_seen || terminal.stderr_bytes != r.stderr_seen) + != terminal.truncation_reason.is_some()) + { + return Err("command terminal counts do not fence delivered bytes".into()); + } + r.transport_counts = Some((terminal.stdout_bytes, terminal.stderr_bytes)); + r.transport_truncation = terminal.truncation_reason.clone(); + r.terminal = Some(OutputTerminal { + status: Some(terminal.status), + exit_code: terminal.exit_code, + transport_complete: true, + }); + Ok(r.metadata()) + } + + pub fn finish(&mut self, terminal: OutputTerminal) -> BashOutputMetadata { + let mut r = self.record.lock().unwrap(); + r.terminal = Some(terminal); + r.metadata() + } +} + +impl Drop for BashOutputWriter { + fn drop(&mut self) { + let mut r = self.record.lock().unwrap(); + if r.terminal.is_none() { + r.terminal = Some(OutputTerminal { + status: None, + exit_code: None, + transport_complete: false, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn finish(view: &BashOutputView, text: &[u8]) -> String { + let mut writer = view.begin().unwrap(); + writer.append(CommandStream::Stdout, text); + writer + .finish(OutputTerminal { + status: Some(CommandStatus::Completed), + exit_code: Some(0), + transport_complete: true, + }) + .id + } + + #[test] + fn revoked_child_is_denied_while_parent_keeps_evidence_and_sibling_is_denied() { + let root = BashOutputView::new(); + let lease = Arc::new(AtomicBool::new(true)); + let child = root.child({ + let lease = lease.clone(); + Arc::new(move || lease.load(Ordering::Acquire)) + }); + let sibling = root.child(Arc::new(|| true)); + let id = finish(&child, b"child"); + assert_eq!( + child.read(&id, CommandStream::Stdout, 0, 20).unwrap(), + b"child" + ); + assert!(sibling.metadata(&id).is_none()); + assert!(BashOutputView::new().metadata(&id).is_none()); + lease.store(false, Ordering::Release); + assert!(child.metadata(&id).is_none()); + assert_eq!( + root.read(&id, CommandStream::Stdout, 0, 20).unwrap(), + b"child" + ); + root.revoke(); + let restored = BashOutputView::new(); + assert!(root.metadata(&id).is_none()); + assert!(restored.metadata(&id).is_none()); + let new_id = finish(&restored, b"new incarnation"); + assert_ne!(new_id, id); + assert!(child.metadata(&new_id).is_none()); + assert!(root.begin().is_err()); + } + + #[test] + fn parent_and_descendants_share_admission_without_evicting_live_evidence() { + let root = BashOutputView::new(); + let child = root.child(Arc::new(|| true)); + let sibling = root.child(Arc::new(|| true)); + let mut writers = (0..STORE_BYTES / COMMAND_BYTES) + .map(|index| { + if index % 2 == 0 { + root.begin() + } else { + child.begin() + } + .unwrap() + }) + .collect::>(); + assert!(sibling.begin().is_err()); + let mut first = writers.remove(0); + first.append(CommandStream::Stdout, b"finished"); + let metadata = first.finish(OutputTerminal { + status: Some(CommandStatus::Completed), + exit_code: Some(0), + transport_complete: true, + }); + assert!(root.metadata(&metadata.id).is_some()); + let _replacement = sibling.begin().unwrap(); + assert!(root.metadata(&metadata.id).is_none()); + assert!(root.begin().is_err()); + assert_eq!(writers.len(), STORE_BYTES / COMMAND_BYTES - 1); + } + + #[test] + fn quota_and_forced_drop_are_distinct_from_preview_omission() { + let root = BashOutputView::new(); + let mut writer = root.begin().unwrap(); + writer.append(CommandStream::Stdout, &vec![b'x'; COMMAND_BYTES + 1]); + writer.append(CommandStream::Stderr, b"tail"); + let id = writer.record.lock().unwrap().id.clone(); + drop(writer); + let m = root.metadata(&id).unwrap(); + assert_eq!(m.stdout_saved, COMMAND_BYTES); + assert_eq!(m.stdout_seen, COMMAND_BYTES as u64 + 1); + assert_eq!(m.stderr_seen, 4); + assert!(m.storage_truncated); + assert!(!m.terminal.transport_complete); + assert_eq!( + root.read(&id, CommandStream::Stdout, 0, usize::MAX) + .unwrap() + .len(), + 64 * 1024 + ); + } + + #[test] + fn expiry_and_count_eviction_never_reuse_old_references() { + let root = BashOutputView::new(); + let id = finish(&root, b"old"); + root.store.lock().unwrap().retention = Duration::ZERO; + assert!(root.metadata(&id).is_none()); + root.store.lock().unwrap().retention = RETENTION; + let first = finish(&root, b"first"); + for _ in 0..STORE_COUNT { + finish(&root, b"later"); + } + assert!(root.metadata(&first).is_none()); + assert!(root.store.lock().unwrap().reserved <= STORE_BYTES); + } + #[tokio::test(start_paused = true)] + async fn expiry_reclaims_bytes_without_another_read_and_reaper_ends_with_owner() { + // Exercise host construction outside a runtime, followed by real async admission. + let root = std::thread::spawn(BashOutputView::new).join().unwrap(); + finish(&root, b"expires without lookup"); + let mut closed = root.store.lock().unwrap()._shutdown.subscribe(); + tokio::task::yield_now().await; + // The store uses monotonic wall time for publication and Tokio time only for scheduling. + // Age the record deterministically rather than delaying this test in real time. + root.store.lock().unwrap().records[0] + .lock() + .unwrap() + .created = Instant::now() - RETENTION; + tokio::time::advance(Duration::from_secs(31)).await; + tokio::task::yield_now().await; + assert_eq!(root.store.lock().unwrap().reserved, 0); + drop(root); + assert!(closed.changed().await.is_err()); + } + #[test] + fn terminal_counts_must_fence_the_received_prefix_before_marking_evidence_complete() { + let root = BashOutputView::new(); + let mut writer = root.begin().unwrap(); + writer.append(CommandStream::Stdout, b"abc"); + let mut terminal = serde_json::json!({"status":"completed","exit_code":0,"stdout_bytes":9,"stderr_bytes":0,"stdout_delivered_bytes":4,"stderr_delivered_bytes":0,"truncation_reason":"byte_budget"}); + assert!( + writer + .finish_transport(&serde_json::from_value(terminal.clone()).unwrap()) + .is_err() + ); + terminal["stdout_delivered_bytes"] = serde_json::json!(3); + let metadata = writer + .finish_transport(&serde_json::from_value(terminal).unwrap()) + .unwrap(); + assert_eq!(metadata.stdout_saved, 3); + assert_eq!(metadata.stdout_delivered, 3); + assert_eq!(metadata.stdout_seen, 9); + assert!(metadata.storage_truncated); + assert!(metadata.retention_limit.is_none()); + assert_eq!( + metadata.transport_truncation, + Some(workdir::CommandOutputTruncation::ByteBudget) + ); + } +} diff --git a/crates/tools/src/checkout.rs b/crates/tools/src/checkout.rs index 24e2dde31..8a697a67d 100644 --- a/crates/tools/src/checkout.rs +++ b/crates/tools/src/checkout.rs @@ -403,3 +403,71 @@ fn check_pattern(pattern: &str) -> Result<(), ToolError> { #[cfg(test)] #[path = "checkout_tests.rs"] mod tests; + +/// Read a bounded native text window through the same checked target. +/// The returned one-shot action records prior-Read only after the caller's presentation succeeds. +#[allow(clippy::too_many_arguments)] +pub async fn read_checkout_text( + router: Arc, + tracker: Tracker, + alias: &str, + generation: u64, + path: WorkdirPath, + validator: Vec, + offset: usize, + limit: usize, + max_bytes: usize, +) -> Result<(workdir::ReadResult, Option>, impl FnOnce()), ToolError> { + let selected = + crate::routing::resolve_session(&router, Some(alias), WorkdirSessionCapability::Read)?; + if selected.generation != generation { + return Err(stale_checkout("checkout connection changed")); + } + let tracker = tracker.scoped_attachment(&selected.alias, selected.generation); + let target = FileTarget { + session: selected.session.clone(), + path: path.clone(), + validator: Some(validator), + }; + let (result, validator) = target.read(offset, limit, max_bytes).await?; + let hash = result.content_hash.clone(); + let total_lines = result.total_lines; + Ok((result, validator, move || { + tracker.record_workdir_observation(&path, hash, total_lines); + })) +} + +/// Binary-safe checked byte window. This does not establish a text prior-Read observation. +#[allow(clippy::too_many_arguments)] +pub async fn read_checkout_bytes( + router: Arc, + alias: &str, + generation: u64, + path: WorkdirPath, + validator: Vec, + offset: u64, + max_bytes: usize, +) -> Result<(workdir::ReadBytesResult, Vec), ToolError> { + let selected = + crate::routing::resolve_session(&router, Some(alias), WorkdirSessionCapability::Read)?; + if selected.generation != generation { + return Err(stale_checkout("checkout connection changed")); + } + let result = selected + .session + .checkout_execute(workdir::CheckoutRequest { + target: path.clone(), + validator, + operation: workdir::CheckoutOperation::ReadBytes { offset, max_bytes }, + }) + .await + .map_err(crate::file_target::checked_error)?; + match result.output { + workdir::CheckoutOutput::ReadBytes(read) + if read.path == path && read.offset == offset && read.bytes.len() <= max_bytes => + { + Ok((read, result.observation.validator)) + } + _ => Err(crate::file_target::readonly_result_error("ReadBytes")), + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 3bdb410d5..6efd98698 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -5,6 +5,7 @@ pub mod error; pub mod tracker; mod bash; +pub mod bash_output; mod edit; mod file_target; mod glob; @@ -16,7 +17,7 @@ pub mod view_image; mod web; mod write; -pub use bash::bash_tool; +pub use bash::{BashResult, BashTool, bash_tool}; pub use checkout::{CheckoutToolOutput, execute_checkout_tool}; pub use edit::edit_tool; pub use error::ToolsError; @@ -35,7 +36,7 @@ pub use write::write_tool; pub fn core_builtin_tools( session: workdir::WorkdirSessionHandle, tracker: Tracker, - bash_output_dir: std::path::PathBuf, + bash_output: bash_output::BashOutputView, ) -> Vec { use workdir::WorkdirSessionCapability; @@ -58,7 +59,7 @@ pub fn core_builtin_tools( tools.push(grep::routed_grep_tool(router.clone())); } if capabilities.supports(WorkdirSessionCapability::Command) { - tools.push(bash::routed_bash_tool(router, bash_output_dir)); + tools.push(bash::routed_bash_tool(router, bash_output)); } tools } @@ -71,7 +72,7 @@ pub fn core_builtin_tools( pub fn routed_builtin_tools( router: std::sync::Arc, tracker: Tracker, - bash_output_dir: std::path::PathBuf, + bash_output: bash_output::BashOutputView, ) -> Vec { vec![ read::routed_read_tool(router.clone(), tracker.clone()), @@ -79,14 +80,14 @@ pub fn routed_builtin_tools( edit::routed_edit_tool(router.clone(), tracker), glob::routed_glob_tool(router.clone()), grep::routed_grep_tool(router.clone()), - bash::routed_bash_tool(router, bash_output_dir), + bash::routed_bash_tool(router, bash_output), ] } pub fn routed_builtin_tools_for_current_capabilities( router: std::sync::Arc, tracker: Tracker, - bash_output_dir: std::path::PathBuf, + bash_output: bash_output::BashOutputView, ) -> Vec { use workdir::WorkdirSessionCapability; @@ -108,7 +109,7 @@ pub fn routed_builtin_tools_for_current_capabilities( tools.push(grep::routed_grep_tool(router.clone())); } if capabilities.supports(WorkdirSessionCapability::Command) { - tools.push(bash::routed_bash_tool(router, bash_output_dir)); + tools.push(bash::routed_bash_tool(router, bash_output)); } tools } @@ -121,7 +122,7 @@ pub fn read_only_builtin_tools( workdir::WorkdirSessionCapabilities::READ_ONLY, "read-only tool projection requires a read-only Workdir session" ); - core_builtin_tools(session, Tracker::new(), std::path::PathBuf::new()) + core_builtin_tools(session, Tracker::new(), bash_output::BashOutputView::new()) } pub fn read_only_routed_builtin_tools( @@ -173,7 +174,7 @@ mod workdir_tool_tests { SharedScope::new(Scope::writable(dir.path()).unwrap()), WorkdirSessionCapabilities::READ_ONLY, )); - let names = core_builtin_tools(session, Tracker::new(), dir.path().join("output")) + let names = core_builtin_tools(session, Tracker::new(), bash_output::BashOutputView::new()) .into_iter() .map(|definition| definition().0.name) .collect::>(); diff --git a/crates/tools/tests/edge_cases.rs b/crates/tools/tests/edge_cases.rs index 252f6001c..8d5abd689 100644 --- a/crates/tools/tests/edge_cases.rs +++ b/crates/tools/tests/edge_cases.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use agen::tool::{Tool, ToolDefinition}; -use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; +use manifest::Scope; use serde_json::json; use tempfile::TempDir; use tools::{Tracker, core_builtin_tools}; @@ -28,31 +28,23 @@ impl Registry { } } -fn setup() -> (TempDir, TempDir, Registry) { +fn setup() -> (TempDir, Registry) { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let base = Scope::writable(dir.path()).unwrap(); - let mut config = ScopeConfig { - allow: base.allow_rules(), - deny: base.deny_rules(), - }; - config.allow.push(ScopeRule { - target: spill.path().to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }); - let scope = Scope::from_config(&config).unwrap(); + let scope = Scope::writable(dir.path()).unwrap(); let fs: WorkdirSessionHandle = std::sync::Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let tracker = Tracker::new(); - let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf())); - (dir, spill, reg) + let reg = Registry::new(core_builtin_tools( + fs, + tracker, + tools::bash_output::BashOutputView::new(), + )); + (dir, reg) } #[tokio::test] async fn unicode_path_and_content() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("日本語ファイル.txt"); let content = "こんにちは 🦀 世界\nabc\n"; @@ -87,7 +79,7 @@ async fn unicode_path_and_content() { async fn symlink_to_outside_scope_is_rejected_for_write() { use std::os::unix::fs::symlink; - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let outside = TempDir::new().unwrap(); let outside_target = outside.path().join("secret.txt"); std::fs::write(&outside_target, "secret").unwrap(); @@ -151,7 +143,7 @@ async fn symlink_to_outside_scope_is_rejected_for_write() { async fn broken_symlink_reports_target_and_repair_hint() { use std::os::unix::fs::symlink; - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let link = dir.path().join("external-project"); let target = dir.path().join("missing-target"); symlink(&target, &link).unwrap(); @@ -173,7 +165,7 @@ async fn broken_symlink_reports_target_and_repair_hint() { #[tokio::test] async fn empty_file_read_and_edit() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("empty.txt"); std::fs::write(&file, "").unwrap(); @@ -207,7 +199,7 @@ async fn empty_file_read_and_edit() { #[tokio::test] async fn very_long_single_line() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("long.txt"); let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines std::fs::write(&file, &big).unwrap(); @@ -226,7 +218,7 @@ async fn very_long_single_line() { #[tokio::test] async fn absolute_path_requires_matching_read_scope() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let outside = tempfile::tempdir().unwrap(); let outside_file = outside.path().join("outside.txt"); std::fs::write(&outside_file, "secret").unwrap(); @@ -247,7 +239,7 @@ async fn absolute_path_requires_matching_read_scope() { #[tokio::test] async fn directory_target_is_rejected_for_read() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let read = reg.get("Read"); let err = read .execute(&json!({ "file_path": "." }).to_string(), Default::default()) @@ -258,7 +250,7 @@ async fn directory_target_is_rejected_for_read() { #[tokio::test] async fn deeply_nested_new_file_is_created() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let deep = dir.path().join("a/b/c/d/e/deep.txt"); let write = reg.get("Write"); write @@ -277,7 +269,7 @@ async fn deeply_nested_new_file_is_created() { #[tokio::test] async fn replace_preserves_unicode() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("u.txt"); std::fs::write(&file, "🦀 rust 🦀\n").unwrap(); @@ -306,7 +298,7 @@ async fn replace_preserves_unicode() { #[tokio::test] async fn grep_handles_unicode_pattern() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("u.txt"); std::fs::write(&file, "English\n日本語\nрусский\n").unwrap(); diff --git a/crates/tools/tests/integration.rs b/crates/tools/tests/integration.rs index c8c55e8d6..accfb59ef 100644 --- a/crates/tools/tests/integration.rs +++ b/crates/tools/tests/integration.rs @@ -11,7 +11,7 @@ use agen::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle, ToolExecutionTerminal, ToolMeta, }; -use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; +use manifest::Scope; use serde_json::json; use tempfile::TempDir; use tools::{ @@ -23,21 +23,6 @@ use workdir::{ WorkdirToolScopeRule, }; -fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope { - let base = Scope::writable(workspace).unwrap(); - let mut config = ScopeConfig { - allow: base.allow_rules(), - deny: base.deny_rules(), - }; - config.allow.push(ScopeRule { - target: spill.to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }); - Scope::from_config(&config).unwrap() -} - struct Registry { entries: Vec<(ToolMeta, Arc)>, } @@ -61,39 +46,38 @@ impl Registry { } } -fn setup() -> (TempDir, TempDir, Registry) { +fn setup() -> (TempDir, Registry) { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let fs: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let tracker = Tracker::new(); - let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf())); - (dir, spill, reg) + let reg = Registry::new(core_builtin_tools( + fs, + tracker, + tools::bash_output::BashOutputView::new(), + )); + (dir, reg) } -fn setup_routed() -> ( - TempDir, - TempDir, - TempDir, - Arc, - Registry, -) { +fn setup_routed() -> (TempDir, TempDir, Arc, Registry) { let left = TempDir::new().unwrap(); let right = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); let router = Arc::new(WorkdirSessionRouter::new()); for (alias, dir) in [("left", &left), ("right", &right)] { - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); router .attach(WorkdirAttachmentAlias::new(alias).unwrap(), session) .unwrap(); } - let definitions = - routed_builtin_tools(router.clone(), Tracker::new(), spill.path().to_path_buf()); - (left, right, spill, router, Registry::new(definitions)) + let definitions = routed_builtin_tools( + router.clone(), + Tracker::new(), + tools::bash_output::BashOutputView::new(), + ); + (left, right, router, Registry::new(definitions)) } fn valid_gif_with_len(target_len: usize) -> Vec { @@ -137,7 +121,7 @@ async fn call_err(tool: &Arc, input: serde_json::Value) -> agen::tool: #[test] fn core_builtin_tools_registers_full_set() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let mut names = reg.names(); names.sort(); assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]); @@ -145,7 +129,7 @@ fn core_builtin_tools_registers_full_set() { #[test] fn meta_has_description_and_schema() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); for (meta, _) in ®.entries { assert!( !meta.description.is_empty(), @@ -163,7 +147,7 @@ fn meta_has_description_and_schema() { #[test] fn routed_tool_schemas_share_optional_target_workdir() { - let (_left, _right, _spill, router, mut reg) = setup_routed(); + let (_left, _right, router, mut reg) = setup_routed(); reg.entries.push(routed_view_image_tool(router)()); for (meta, _) in ®.entries { let target = &meta.input_schema["properties"]["target_workdir"]; @@ -174,7 +158,7 @@ fn routed_tool_schemas_share_optional_target_workdir() { #[tokio::test] async fn routed_tools_require_alias_only_when_attachment_set_is_ambiguous() { - let (_left, _right, _spill, router, reg) = setup_routed(); + let (_left, _right, router, reg) = setup_routed(); let error = call_err(®.get("Read"), json!({ "file_path": "same.txt" })).await; let message = error.to_string(); assert!(message.contains("target_workdir_required"), "{message}"); @@ -199,7 +183,7 @@ async fn empty_routed_surface_returns_no_attachment_error() { let reg = Registry::new(routed_builtin_tools( router, Tracker::new(), - Path::new("unused").to_path_buf(), + tools::bash_output::BashOutputView::new(), )); let message = call_err(®.get("Read"), json!({ "file_path": "file.txt" })) .await @@ -209,7 +193,7 @@ async fn empty_routed_surface_returns_no_attachment_error() { #[tokio::test] async fn target_alias_routes_each_tool_and_isolates_read_before_edit() { - let (left, right, _spill, router, mut reg) = setup_routed(); + let (left, right, router, mut reg) = setup_routed(); std::fs::write(left.path().join("same.txt"), "left value\n").unwrap(); std::fs::write(right.path().join("same.txt"), "right value\n").unwrap(); std::fs::write(left.path().join("only-left.log"), "LEFT-NEEDLE\n").unwrap(); @@ -282,7 +266,7 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() { ) .await; assert_eq!( - std::fs::canonicalize(bash.content.unwrap().trim()).unwrap(), + std::fs::canonicalize(serde_json::from_str::(&bash.content.unwrap()).unwrap()["stdout_preview"].as_str().unwrap().trim()).unwrap(), std::fs::canonicalize(right.path()).unwrap() ); @@ -304,13 +288,12 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() { async fn selected_attachment_capability_is_checked_without_fallback() { let left = TempDir::new().unwrap(); let right = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); let router = Arc::new(WorkdirSessionRouter::new()); router .attach( WorkdirAttachmentAlias::new("writable").unwrap(), Arc::new(LocalWorkdirSession::new( - scope_with_spill(left.path(), spill.path()), + Scope::writable(left.path()).unwrap(), left.path().to_path_buf(), )), ) @@ -322,7 +305,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() { workdir::Workdir::new("readonly"), right.path().to_path_buf(), right.path().to_path_buf(), - manifest::SharedScope::new(scope_with_spill(right.path(), spill.path())), + manifest::SharedScope::new(Scope::writable(right.path()).unwrap()), workdir::WorkdirSessionCapabilities::READ_ONLY, )), ) @@ -330,7 +313,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() { let reg = Registry::new(routed_builtin_tools( router.clone(), Tracker::new(), - spill.path().to_path_buf(), + tools::bash_output::BashOutputView::new(), )); let message = call_err( @@ -352,7 +335,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() { #[tokio::test] async fn routed_tools_observe_detach_after_registration() { - let (_left, _right, _spill, router, reg) = setup_routed(); + let (_left, _right, router, reg) = setup_routed(); let alias = WorkdirAttachmentAlias::new("left").unwrap(); router.detach(&alias).await.unwrap(); @@ -372,7 +355,6 @@ async fn routed_tools_observe_detach_after_registration() { async fn detach_and_reattach_same_alias_requires_a_fresh_read() { let first = TempDir::new().unwrap(); let second = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); std::fs::write(first.path().join("same.txt"), "same bytes\n").unwrap(); std::fs::write(second.path().join("same.txt"), "same bytes\n").unwrap(); @@ -382,7 +364,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { .attach( alias.clone(), Arc::new(LocalWorkdirSession::new( - scope_with_spill(first.path(), spill.path()), + Scope::writable(first.path()).unwrap(), first.path().to_path_buf(), )), ) @@ -390,7 +372,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { let reg = Registry::new(routed_builtin_tools( router.clone(), Tracker::new(), - spill.path().to_path_buf(), + tools::bash_output::BashOutputView::new(), )); call( ®.get("Read"), @@ -403,7 +385,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { .attach( alias.clone(), Arc::new(LocalWorkdirSession::new( - scope_with_spill(second.path(), spill.path()), + Scope::writable(second.path()).unwrap(), second.path().to_path_buf(), )), ) @@ -432,6 +414,8 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { struct ExternalValidatingSession { inner: LocalWorkdirSession, truncate_first_binary_response: bool, + command_response_losses: std::sync::atomic::AtomicUsize, + lose_terminal_only: bool, mutate_after_first_binary_response: Option, } @@ -550,7 +534,23 @@ impl workdir::WorkdirSession for ExternalValidatingSession { &self, request: workdir::CommandOutputRequest, ) -> Result { - self.inner.command_output(request).await + let output = self.inner.command_output(request).await?; + if (!self.lose_terminal_only || output.terminal.is_some()) + && self + .command_response_losses + .fetch_update( + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + |remaining| remaining.checked_sub(1), + ) + .is_ok() + { + // The production provider has committed a page, but its response is lost. + return Err(workdir::WorkdirError::Transport( + "injected lost response".into(), + )); + } + Ok(output) } async fn cancel_command( @@ -568,11 +568,12 @@ impl workdir::WorkdirSession for ExternalValidatingSession { #[tokio::test] async fn external_view_image_uses_a_protocol_bounded_request() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()), truncate_first_binary_response: false, + command_response_losses: std::sync::atomic::AtomicUsize::new(0), + lose_terminal_only: false, mutate_after_first_binary_response: None, }); let png = include_bytes!("fixtures/view-image/valid.png"); @@ -590,13 +591,14 @@ async fn external_view_image_uses_a_protocol_bounded_request() { #[tokio::test] async fn external_view_image_rejects_truncated_and_cross_version_chunks() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); let image_path = dir.path().join("image.png"); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); std::fs::write(&image_path, include_bytes!("fixtures/view-image/valid.png")).unwrap(); let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()), truncate_first_binary_response: true, + command_response_losses: std::sync::atomic::AtomicUsize::new(0), + lose_terminal_only: false, mutate_after_first_binary_response: None, }); let (_meta, tool) = view_image_tool(truncated)(); @@ -613,6 +615,8 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() { let changing: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()), truncate_first_binary_response: false, + command_response_losses: std::sync::atomic::AtomicUsize::new(0), + lose_terminal_only: false, mutate_after_first_binary_response: Some(image_path), }); let (_meta, tool) = view_image_tool(changing)(); @@ -626,8 +630,7 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() { async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() { const LIMIT: usize = 10 * 1024 * 1024; let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let (_meta, tool) = view_image_tool(session)(); @@ -650,8 +653,7 @@ async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() #[tokio::test] async fn view_image_attaches_valid_supported_image_fixtures() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let (_meta, tool) = view_image_tool(session)(); @@ -726,8 +728,7 @@ async fn delegated_view_image_preserves_caller_relative_path_identity() { #[tokio::test] async fn view_image_reads_scoped_bytes_into_durable_tool_detail() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let png = include_bytes!("fixtures/view-image/valid.png"); @@ -752,7 +753,7 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() { #[tokio::test] async fn read_then_edit_then_read_roundtrip() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "hello world\n").unwrap(); let p = "a.txt"; @@ -784,7 +785,7 @@ async fn read_then_edit_then_read_roundtrip() { #[tokio::test] async fn write_then_grep_finds_content() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let write = reg.get("Write"); let grep = reg.get("Grep"); @@ -813,7 +814,7 @@ async fn write_then_grep_finds_content() { #[tokio::test] async fn glob_finds_written_files() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let write = reg.get("Write"); let glob = reg.get("Glob"); @@ -837,7 +838,7 @@ async fn glob_finds_written_files() { #[tokio::test] async fn absolute_path_is_rejected() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let outside = TempDir::new().unwrap(); let write = reg.get("Write"); @@ -859,7 +860,7 @@ async fn absolute_path_is_rejected() { #[tokio::test] async fn write_to_existing_without_read_fails() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("exists.txt"); std::fs::write(&file, "preexisting").unwrap(); @@ -880,7 +881,7 @@ async fn write_to_existing_without_read_fails() { async fn shared_workdir_across_tools() { // The key invariant: all builtin tools share the same WorkdirSession instance, // so read-history set by Read is visible to Edit and Write. - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("shared.txt"); std::fs::write(&file, "one\n").unwrap(); @@ -907,7 +908,7 @@ async fn shared_workdir_across_tools() { #[tokio::test] async fn edit_requires_read_across_tools() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "foo\n").unwrap(); @@ -928,7 +929,7 @@ async fn edit_requires_read_across_tools() { #[tokio::test] async fn deterministic_tool_order_is_registration_order() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); // Registration order from core_builtin_tools(): Read, Write, Edit, Glob, Grep, Bash let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect(); assert_eq!( @@ -940,7 +941,7 @@ async fn deterministic_tool_order_is_registration_order() { // Regression: tool name capitalization matches Claude Code reference #[test] fn tool_names_match_reference_spec() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); for expected in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] { assert!( reg.entries.iter().any(|(m, _)| m.name == expected), @@ -953,15 +954,14 @@ fn tool_names_match_reference_spec() { async fn tracker_recent_files_tracks_read_write_edit() { // Build a fresh registry that shares a tracker we can query afterwards. let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let fs: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let tracker = Tracker::new(); let reg = Registry::new(core_builtin_tools( fs, tracker.clone(), - spill.path().to_path_buf(), + tools::bash_output::BashOutputView::new(), )); let a = dir.path().join("a.txt"); @@ -1003,118 +1003,314 @@ async fn tracker_recent_files_tracks_read_write_edit() { async fn bash_inherits_workdir_cwd() { // The Bash tool starts at the WorkdirSession's pwd. Without any `cd`, its // `pwd` should canonicalize to the workspace root we set up. - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let bash = reg.get("Bash"); let out = call(&bash, json!({ "command": "pwd", "cwd": "." })).await; let body = out.content.unwrap(); - let actual = std::fs::canonicalize(body.trim()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&body).unwrap(); + let actual = std::fs::canonicalize(parsed["stdout_preview"].as_str().unwrap().trim()).unwrap(); let expected = std::fs::canonicalize(dir.path()).unwrap(); assert_eq!(actual, expected); } +fn output_fixture() -> ( + TempDir, + WorkdirSessionHandle, + tools::bash_output::BashOutputView, + Arc, +) { + let root = TempDir::new().unwrap(); + let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new( + Scope::writable(root.path()).unwrap(), + root.path().into(), + )); + let view = tools::bash_output::BashOutputView::new(); + let (_, bash) = tools::bash_tool(session.clone(), view.clone())(); + (root, session, view, bash) +} + #[tokio::test] -async fn bash_provider_output_exposes_readable_retained_path() { - let (_dir, spill, reg) = setup(); - let bash = reg.get("Bash"); +async fn bash_output_is_retained_without_a_filesystem_grant_or_telemetry_subscriber() { + let (root, _session, view, bash) = output_fixture(); let out = call( &bash, - json!({ "command": "printf 'x%.0s' {1..20480}", "cwd": "." }), + json!({ "command": "printf 'x%.0s' {1..20480}; printf '\\377€' >&2; exit 7", "cwd": "." }), ) .await; - let body = out.content.unwrap(); - assert!(body.contains("bounded WorkdirSession command output")); - assert!(body.contains("full output saved to")); - assert!(body.contains(spill.path().to_str().unwrap())); - let artifact = std::fs::read_dir(spill.path()) - .unwrap() - .next() - .expect("retained output") - .unwrap() - .path(); - assert_eq!(std::fs::metadata(artifact).unwrap().len(), 20_480); + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + assert_eq!(body["output"]["terminal"]["exit_code"], 7); + assert_eq!(body["preview_omitted"], true); + assert_eq!(body["output"]["storage_truncated"], false); + let id = body["output"]["id"].as_str().unwrap(); + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, 0, 65536) + .unwrap(), + vec![b'x'; 20480] + ); + assert_eq!( + view.read(id, workdir::CommandStream::Stderr, 0, 65536) + .unwrap(), + [vec![255], "€".as_bytes().to_vec()].concat() + ); + assert!( + body["stdout_entry"] + .as_str() + .unwrap() + .starts_with("/bash-output/") + ); + assert!(!body.to_string().contains(root.path().to_str().unwrap())); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0); +} + +async fn await_command_output( + events: &mut tokio::sync::broadcast::Receiver, +) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if matches!( + events.recv().await.unwrap(), + workdir::CommandEvent::Output { .. } + ) { + break; + } + } + }) + .await + .expect("command output readiness"); +} +async fn await_command_terminal( + events: &mut tokio::sync::broadcast::Receiver, +) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let workdir::CommandEvent::Terminal { status, .. } = events.recv().await.unwrap() { + assert_eq!(status, workdir::CommandStatus::Cancelled); + break; + } + } + }) + .await + .expect("command terminal fence"); } #[tokio::test] async fn bash_cancellation_returns_bounded_progress_as_terminal_output() { - let (dir, _spill, reg) = setup(); - let marker = dir.path().join("must-not-run-after-cancel"); - let command = format!( - "printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'", - marker.display() - ); - let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap(); + let (_root, session, _view, bash) = output_fixture(); + let mut events = session.subscribe_command_events().unwrap(); let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0); - let bash = reg.get("Bash"); let executing = bash.clone(); let execution_context = context.clone(); - let execution = tokio::spawn(async move { executing.execute(&input, execution_context).await }); - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - bash.cancel_execution(&context) - .await - .expect("signal exact execution cancellation"); - let error = tokio::time::timeout(std::time::Duration::from_secs(2), execution) - .await - .expect("cancelled Bash should terminate inside the Engine grace budget") - .expect("Bash task join"); - - let ToolError::Cancelled(output) = error.expect_err("cancelled command is non-success") else { - panic!("expected typed cancellation result"); + let execution = tokio::spawn(async move { + executing.execute(r#"{"command":"printf 'before'; printf 'err-before' >&2; while :; do :; done","cwd":"."}"#, execution_context).await + }); + await_command_output(&mut events).await; + bash.cancel_execution(&context).await.unwrap(); + let ToolError::Cancelled(output) = + tokio::time::timeout(std::time::Duration::from_secs(5), execution) + .await + .unwrap() + .unwrap() + .unwrap_err() + else { + panic!("expected cancellation"); }; - let content = output.content.expect("bounded progress output"); - assert!( - content.contains("before"), - "missing pre-cancel stdout: {content}" - ); - assert!( - content.contains("err-before"), - "missing pre-cancel stderr: {content}" - ); - assert!( - !content.contains("after"), - "post-cancel output leaked: {content}" - ); - assert!(content.len() <= 16 * 1024, "output must remain bounded"); - - tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; - assert!( - !marker.exists(), - "the cancelled command continued executing after terminal confirmation" - ); + let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(body["stdout_preview"], "before"); + assert_eq!(body["stderr_preview"], "err-before"); + assert_eq!(body["output"]["terminal"]["status"], "cancelled"); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); } #[tokio::test] async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() { - let (dir, _spill, reg) = setup(); - let marker = dir.path().join("must-not-survive-force-close"); - let command = format!("sleep 1; touch {}", marker.display()); - let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap(); - let bash = reg.get("Bash"); + let (_root, session, _view, bash) = output_fixture(); + let mut events = session.subscribe_command_events().unwrap(); let context = ToolExecutionContext::new("call-force", "attempt-force", 0); - let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context); - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let (handle, terminal) = ToolExecutionHandle::start( + bash.clone(), + r#"{"command":"printf ready; while :; do :; done","cwd":"."}"#.into(), + context, + ); + await_command_output(&mut events).await; handle.force_close(); assert!(matches!( terminal.await, ToolExecutionTerminal::OutcomeUnknown )); - - tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; - assert!( - !marker.exists(), - "CommandGuard cleanup allowed a force-closed command to continue" - ); - - let output = bash - .execute( - r#"{"command":"printf 'reused'","cwd":"."}"#, - Default::default(), - ) - .await - .expect("workdir session remains reusable after cleanup"); - assert_eq!(output.content.as_deref(), Some("reused")); + await_command_terminal(&mut events).await; + let output = call(&bash, json!({"command":"printf reused", "cwd":"."})).await; + let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(body["stdout_preview"], "reused"); } // Sanity: unused Path import guard const _: fn() -> &'static Path = || Path::new("/"); + +#[tokio::test] +async fn parent_and_sibling_broker_commands_keep_output_ownership_after_revoke() { + let (root, source, parent_view, parent_bash) = output_fixture(); + let broker = WorkdirToolBroker::new(source); + let mut leases = Vec::new(); + let mut views = Vec::new(); + let mut commands = Vec::new(); + for name in ["left", "right"] { + std::fs::create_dir(root.path().join(name)).unwrap(); + let lease = Arc::new( + broker + .scope(WorkdirToolScope { + rules: vec![WorkdirToolScopeRule { + target: WorkdirPath::new(name).unwrap(), + permission: WorkdirToolScopePermission::Write, + recursive: true, + symlink_policy: Default::default(), + }], + cwd: WorkdirPath::new(name).unwrap(), + command: true, + }) + .await + .unwrap(), + ); + let authority = lease.clone(); + let view = parent_view.child(Arc::new(move || authority.is_active())); + let (_, bash) = tools::bash_tool(lease.tool_session(), view.clone())(); + leases.push(lease); + views.push(view); + commands.push(bash); + } + let left_input = + json!({"command":"printf 'left-%.0s' {1..10000}; printf left-err >&2", "cwd":"."}); + let right_input = + json!({"command":"printf 'right-%.0s' {1..10000}; printf right-err >&2", "cwd":"."}); + let parent_input = json!({"command":"printf parent; printf parent-err >&2", "cwd":"."}); + let (left, right, parent) = tokio::join!( + call(&commands[0], left_input), + call(&commands[1], right_input), + call(&parent_bash, parent_input) + ); + let outputs = [left, right, parent] + .map(|out| serde_json::from_str::(&out.content.unwrap()).unwrap()); + for (index, label) in ["left", "right", "parent"].iter().enumerate() { + let id = outputs[index]["output"]["id"].as_str().unwrap(); + let bytes = parent_view + .read(id, workdir::CommandStream::Stderr, 0, 65536) + .unwrap(); + assert_eq!(bytes, format!("{label}-err").as_bytes()); + for (child_index, view) in views.iter().enumerate() { + assert_eq!(view.metadata(id).is_some(), child_index == index); + } + } + let left_id = outputs[0]["output"]["id"].as_str().unwrap(); + leases[0].close().await.unwrap(); + assert!(views[0].metadata(left_id).is_none()); + assert!(parent_view.metadata(left_id).is_some()); + assert!( + views[1] + .metadata(outputs[1]["output"]["id"].as_str().unwrap()) + .is_some() + ); + leases[1].close().await.unwrap(); + broker.close().await.unwrap(); + parent_view.revoke(); + assert!(parent_view.metadata(left_id).is_none()); +} + +#[tokio::test] +async fn utf8_quota_boundary_preserves_raw_prefix_and_reports_saved_vs_seen() { + let (_root, _session, view, bash) = output_fixture(); + let out = call( + &bash, + json!({"command":"printf '%4194303s' ''; printf '€'", "cwd":"."}), + ) + .await; + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + let id = body["output"]["id"].as_str().unwrap(); + let saved = body["output"]["stdout_saved"].as_u64().unwrap() as usize; + assert_eq!(saved, 4 * 1024 * 1024); + assert_eq!(body["output"]["stdout_seen"], 4 * 1024 * 1024 + 2); + assert_eq!(body["output"]["stderr_seen"], 0); + assert_eq!(body["output"]["storage_truncated"], true); + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, saved - 1, 10) + .unwrap(), + vec![0xe2] + ); + assert!( + view.line_window(id, workdir::CommandStream::Stdout, 0, 1) + .unwrap() + .is_err() + ); + assert!(out.summary.starts_with("$ ")); +} + +#[tokio::test] +async fn timeout_terminal_fences_retained_stdout_and_stderr() { + let (_root, _session, view, bash) = output_fixture(); + let error = tokio::time::timeout(std::time::Duration::from_secs(5), bash.execute(r#"{"command":"printf before; printf err-before >&2; while :; do :; done","cwd":".","timeout":1}"#, Default::default())).await.unwrap().unwrap_err(); + let ToolError::Interrupted(out) = error else { + panic!("expected timeout output"); + }; + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + let id = body["output"]["id"].as_str().unwrap(); + assert_eq!(body["output"]["terminal"]["status"], "timed_out"); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, 0, 65536) + .unwrap(), + b"before" + ); + assert_eq!( + view.read(id, workdir::CommandStream::Stderr, 0, 65536) + .unwrap(), + b"err-before" + ); +} + +// Integration: real process pipes and provider replay into the production Bash collector. +// Only response delivery is fault-injected; Runtime's HTTP suite covers actual wire routing. +#[tokio::test] +async fn lost_page_and_terminal_responses_replay_without_restarting_or_double_appending() { + for lose_terminal_only in [false, true] { + let dir = TempDir::new().unwrap(); + let session = Arc::new(ExternalValidatingSession { + inner: LocalWorkdirSession::new( + Scope::writable(dir.path()).unwrap(), + dir.path().into(), + ), + truncate_first_binary_response: false, + mutate_after_first_binary_response: None, + command_response_losses: std::sync::atomic::AtomicUsize::new(1), + lose_terminal_only, + }); + let view = tools::bash_output::BashOutputView::new(); + let definitions = core_builtin_tools(session.clone(), Tracker::new(), view.clone()); + let bash = Registry::new(definitions).get("Bash"); + let out = call(&bash, json!({"cwd":".","command":"printf once >> effect.txt; printf 'x%.0s' {1..200000}; printf err >&2; exit 3"})).await; + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + assert_eq!( + std::fs::read(dir.path().join("effect.txt")).unwrap(), + b"once" + ); + assert_eq!( + session + .command_response_losses + .load(std::sync::atomic::Ordering::Acquire), + 0 + ); + assert_eq!(body["output"]["stdout_saved"], 200000); + assert_eq!(body["output"]["stdout_delivered"], 200000); + assert_eq!(body["output"]["stderr_saved"], 3); + assert_eq!(body["output"]["terminal"]["exit_code"], 3); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert_eq!(body["output"]["storage_truncated"], false); + let id = body["output"]["id"].as_str().unwrap(); + for offset in (0..200000).step_by(65536) { + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, offset, 65536) + .unwrap(), + vec![b'x'; (200000 - offset).min(65536)] + ); + } + use workdir::WorkdirSession as _; + session.close().await.unwrap(); + } +} diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index a24aaf339..9a56b4c90 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -3013,7 +3013,8 @@ impl App { /// `[File: …]` parsing path: each kind maps directly to a typed /// block (`Block::Notify`, `Block::WorkerEvent`, …). fn apply_system_item(&mut self, value: &serde_json::Value) { - let Ok(item) = serde_json::from_value::(value.clone()) else { + let Ok(item) = serde_json::from_value::(value.clone()) + else { // Unknown / forward-compat shape: fall back to rendering the // raw text payload (if any) as a generic system message. if let Some(text) = value.get("body").and_then(|b| b.as_str()) { @@ -3024,29 +3025,23 @@ impl App { } return; }; - match item { - session_store::SystemItem::Notification { message, .. } => { + let body = item.body; + match item.presentation { + session_store::PublicSystemPresentation::Notification { message } => { self.blocks.push(Block::Notify { message }); } - session_store::SystemItem::WorkerEvent { event, .. } => { + session_store::PublicSystemPresentation::WorkerEvent { event } => { self.blocks.push(Block::WorkerEvent { event }); } - session_store::SystemItem::FeatureInvocationResult { body, .. } - | session_store::SystemItem::FileAttachment { body, .. } - | session_store::SystemItem::SkillActivation { body, .. } - | session_store::SystemItem::JournalStartup { body, .. } - | session_store::SystemItem::ResidentSummaryRefresh { body, .. } - | session_store::SystemItem::SubjectBehaviorRefresh { body, .. } - | session_store::SystemItem::Interrupt { body, .. } => { + session_store::PublicSystemPresentation::Context => { self.task_store.apply_system_message_text(&body); self.blocks.push(Block::SystemMessage { text: body }); } - session_store::SystemItem::TaskReminder { body, .. } => { + session_store::PublicSystemPresentation::TaskReminder => { self.task_store.apply_system_message_text(&body); self.blocks.push(Block::TaskReminder { text: body }); } - session_store::SystemItem::LegacyIgnored { .. } => {} - session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {} + session_store::PublicSystemPresentation::Ignored => {} } } @@ -3572,6 +3567,7 @@ mod completion_flow_tests { session_store::LoggedHistoryEntry { item: session_store::LoggedItem::from(item), metadata: session_store::LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: session_store::LoggedSessionHistoryEntryId::new(), origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -4329,6 +4325,7 @@ mod completion_flow_tests { })); app.handle_worker_event(Event::SessionEntryCommitted { entry: protocol::SessionSnapshotEntry { + use_policy: None, entry_id: "run-failure".into(), timestamp: 2, provenance: protocol::SessionEntryProvenance::LegacyUnknown, @@ -4450,12 +4447,12 @@ mod completion_flow_tests { } #[test] - fn live_legacy_workflow_system_item_is_ignored() { + fn retired_system_input_is_ignored() { let mut app = App::new("test".into()); let item = serde_json::json!({ - "kind": "workflow", - "slug": "build", - "body": "[Workflow /build]\nRun the build", + "kind": "retired_input", + "body": "", + "presentation": { "kind": "ignored" }, }); app.handle_worker_event(Event::SystemItem { entry_id: None, @@ -4861,12 +4858,30 @@ mod completion_flow_tests { ); } + #[test] + fn feature_label_does_not_select_system_input_presentation() { + for label in ["journal_startup", "new-unregistered-feature"] { + let mut app = App::new("test".into()); + app.handle_worker_event(Event::SystemItem { + entry_id: None, + item: serde_json::json!({ + "kind": label, + "body": "explicit public content", + "presentation": { "kind": "context" }, + }), + }); + assert!( + matches!(app.blocks.as_slice(), [Block::SystemMessage { text }] if text == "explicit public content") + ); + } + } + #[test] fn live_system_item_notification_appends_notify_block() { let mut app = App::new("test".into()); let item = serde_json::json!({ "kind": "notification", - "message": "hi", + "presentation": { "kind": "notification", "message": "hi" }, "body": "[Notification] hi", }); app.handle_worker_event(Event::SystemItem { @@ -4884,7 +4899,7 @@ mod completion_flow_tests { let mut app = App::new("test".into()); let item = serde_json::json!({ "kind": "worker_event", - "event": { "kind": "turn_ended", "worker_name": "child" }, + "presentation": { "kind": "worker_event", "event": { "kind": "turn_ended", "worker_name": "child" } }, "body": "[Notification] worker `child` finished a turn", }); app.handle_worker_event(Event::SystemItem { @@ -5285,6 +5300,7 @@ mod completion_flow_tests { item: serde_json::json!({ "kind": "task_reminder", "body": snapshot, + "presentation": { "kind": "task_reminder" }, }), }); diff --git a/crates/tui/src/backend_spawn.rs b/crates/tui/src/backend_spawn.rs index fadfd7599..765557434 100644 --- a/crates/tui/src/backend_spawn.rs +++ b/crates/tui/src/backend_spawn.rs @@ -162,6 +162,7 @@ fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest { ticket_assignment: None, feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, } } diff --git a/crates/workdir/Cargo.toml b/crates/workdir/Cargo.toml index 0afb851e7..b8541897a 100644 --- a/crates/workdir/Cargo.toml +++ b/crates/workdir/Cargo.toml @@ -20,7 +20,7 @@ serde_json.workspace = true sha2.workspace = true tempfile.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["process", "rt", "sync", "time"] } +tokio = { workspace = true, features = ["io-util", "process", "rt", "sync", "time"] } [dev-dependencies] serde_json.workspace = true diff --git a/crates/workdir/src/checkout.rs b/crates/workdir/src/checkout.rs index f61c29210..2b7c243fd 100644 --- a/crates/workdir/src/checkout.rs +++ b/crates/workdir/src/checkout.rs @@ -48,6 +48,10 @@ pub struct CheckoutRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] pub enum CheckoutOperation { + ReadBytes { + offset: u64, + max_bytes: usize, + }, Read { offset: usize, limit: usize, @@ -71,7 +75,7 @@ pub enum CheckoutOperation { impl CheckoutOperation { pub fn capability(&self) -> WorkdirSessionCapability { match self { - Self::Read { .. } => WorkdirSessionCapability::Read, + Self::Read { .. } | Self::ReadBytes { .. } => WorkdirSessionCapability::Read, Self::Edit { .. } => WorkdirSessionCapability::Edit, Self::Write { .. } | Self::Create { .. } => WorkdirSessionCapability::Write, } @@ -86,6 +90,7 @@ impl CheckoutOperation { )] pub enum CheckoutOutput { Read(ReadResult), + ReadBytes(crate::ReadBytesResult), Write(WriteResult), Edit(EditResult), } diff --git a/crates/workdir/src/command_test_support.rs b/crates/workdir/src/command_test_support.rs new file mode 100644 index 000000000..62748a4d4 --- /dev/null +++ b/crates/workdir/src/command_test_support.rs @@ -0,0 +1,43 @@ +//! Bounded collection for tests whose subject is command routing/lifecycle, +//! rather than the byte-page protocol itself. + +use crate::*; + +#[derive(Debug)] +pub(crate) struct CollectedCommand { + pub content: String, + pub status: CommandStatus, + pub exit_code: Option, + pub timed_out: bool, +} + +#[async_trait::async_trait] +pub(crate) trait CollectCommand: WorkdirSession { + async fn collect_command_output( + &self, + mut request: CommandOutputRequest, + ) -> Result { + let mut bytes = Vec::new(); + request.wait = true; + loop { + let page = self.command_output(request.clone()).await?; + for chunk in page.chunks { + bytes.extend(chunk.bytes); + assert!( + bytes.len() <= 16 * 1024 * 1024, + "fixture output exceeded bounded test collection" + ); + } + request.cursor = page.next_cursor; + if let Some(terminal) = page.terminal { + return Ok(CollectedCommand { + content: String::from_utf8_lossy(&bytes).into_owned(), + status: terminal.status, + exit_code: terminal.exit_code, + timed_out: terminal.status == CommandStatus::TimedOut, + }); + } + } + } +} +impl CollectCommand for T {} diff --git a/crates/workdir/src/external.rs b/crates/workdir/src/external.rs index 203701bc0..867d24498 100644 --- a/crates/workdir/src/external.rs +++ b/crates/workdir/src/external.rs @@ -9,7 +9,8 @@ //! COMMAND remains separate explicit authority. General session capability intersections may form //! subsets independently of this grant hierarchy; the codec never implicitly expands nonhierarchical //! grants. Commands run with the shared host's OS permissions, not a sandbox confining processes -//! to a directory. Output is bounded and inline; provider-local spill paths are not exposed. +//! to a directory. Output uses bounded byte pages with cursor acknowledgements; +//! providers never receive caller-selected output paths. use std::path::{Component, Path}; @@ -18,7 +19,7 @@ use serde::{Deserialize, Serialize}; use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError}; use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities}; -pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 2; +pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 3; /// Maximum serialized Backend-to-provider command frame size. pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024; /// Maximum serialized provider-to-Backend result frame size. Read bytes are @@ -33,9 +34,9 @@ const MAX_EXTERNAL_GREP_CONTEXT: usize = 100; const MAX_EXTERNAL_SCOPE_RULES: usize = 64; pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024; pub const MAX_EXTERNAL_COMMAND_BYTES: usize = 64 * 1024; -pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = 512 * 1024; +pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = crate::MAX_COMMAND_READ_BYTES; pub const MAX_EXTERNAL_COMMAND_TIMEOUT_SECS: u64 = 600; -const MAX_EXTERNAL_COMMAND_CURSOR: usize = 1_000_000; +const MAX_EXTERNAL_COMMAND_CURSOR: u64 = crate::MAX_COMMAND_TRANSPORT_BYTES; fn valid_command_handle(handle: &crate::CommandHandle) -> bool { !handle.0.is_empty() @@ -185,6 +186,11 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<() && !request.validator.is_empty() && request.validator.len() <= 256 && match &request.operation { + crate::CheckoutOperation::ReadBytes { offset, max_bytes } => { + *offset <= 9_007_199_254_740_991 + && *max_bytes > 0 + && *max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + } crate::CheckoutOperation::Read { limit, max_bytes, .. } => { @@ -267,11 +273,10 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<() } WorkdirSessionOperation::CommandStart(request) => { !request.command.is_empty() + && request.validate().is_ok() && request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES && (1..=MAX_EXTERNAL_COMMAND_TIMEOUT_SECS).contains(&request.timeout_secs) - && (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.output_limit) && is_root_relative(&request.cwd) - && request.spill_dir.is_none() && request.tool_call_id.as_ref().is_none_or(|tool_call_id| { !tool_call_id.is_empty() && tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES @@ -375,6 +380,13 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<() WorkdirSessionOperationResult::CheckoutExecute(result) => { valid_checkout_observation(&result.observation) && match &result.output { + crate::CheckoutOutput::ReadBytes(read) => { + read.path == result.observation.path + && validate_external_result(&WorkdirSessionOperationResult::ReadBytes( + read.clone(), + )) + .is_ok() + } crate::CheckoutOutput::Read(read) => { read.path == result.observation.path && validate_external_result(&WorkdirSessionOperationResult::Read( @@ -450,11 +462,19 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<() WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle), WorkdirSessionOperationResult::CommandStatus(_) => true, WorkdirSessionOperationResult::CommandOutput(output) => { - output.content.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + output.transport_bounds_valid() + && output.chunks.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES && output - .next_cursor - .is_none_or(|cursor| cursor <= MAX_EXTERNAL_COMMAND_CURSOR) - && output.output_path.is_none() + .chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + && output.next_cursor <= MAX_EXTERNAL_COMMAND_CURSOR + && output + .terminal + .as_ref() + .is_none_or(|terminal| terminal.status != crate::CommandStatus::Running) } WorkdirSessionOperationResult::CommandCancel => true, }; @@ -801,11 +821,16 @@ mod tests { #[test] fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() { - let future = r#"{ - "version": 3, - "message": {"kind": "heartbeat", "generation": 1, "sequence": 1} - }"#; - assert!(serde_json::from_str::(future).is_err()); + for version in [ + EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION - 1, + EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION + 1, + ] { + let unsupported = serde_json::json!({ + "version": version, + "message": {"kind": "heartbeat", "generation": 1, "sequence": 1} + }); + assert!(serde_json::from_value::(unsupported).is_err()); + } let invalid_limits = r#"{ "max_source_bytes": 1024, @@ -825,32 +850,35 @@ mod tests { assert!(ExternalWorkdirOperation::try_from(absolute).is_err()); let command = WorkdirSessionOperation::CommandStart(CommandRequest { - command: "cat /home/operator/private/session.log".to_string(), + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf unsafe".to_string(), timeout_secs: 1, - output_limit: 1024, - cwd: WorkdirPath::root(), - spill_dir: Some("/home/operator/private/output".into()), + + cwd: serde_json::from_value(serde_json::json!("/absolute")).unwrap(), + tool_call_id: None, }); assert!(ExternalWorkdirOperation::try_from(command).is_err()); let command = WorkdirSessionOperation::CommandStart(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf bounded".to_string(), timeout_secs: 30, - output_limit: 12 * 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: Some("tool-call-1".to_string()), }); assert!(ExternalWorkdirOperation::try_from(command).is_ok()); assert!( ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart( CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1), timeout_secs: 30, - output_limit: 12 * 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, } )) @@ -887,13 +915,17 @@ mod tests { assert!( ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput( crate::CommandOutput { - status: crate::CommandStatus::Completed, - exit_code: Some(0), - timed_out: false, - content: "bounded".to_string(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: vec![], + next_cursor: 0, + terminal: Some(crate::CommandTerminal { + status: crate::CommandStatus::Completed, + exit_code: Some(0), + stdout_bytes: 0, + stderr_bytes: 0, + stdout_delivered_bytes: 0, + stderr_delivered_bytes: 0, + truncation_reason: None, + }) } )) .is_ok() @@ -901,13 +933,14 @@ mod tests { assert!( ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput( crate::CommandOutput { - status: crate::CommandStatus::Completed, - exit_code: Some(0), - timed_out: false, - content: "bounded".to_string(), - next_cursor: None, - truncated: true, - output_path: Some("/provider/private/output".into()), + chunks: vec![crate::CommandByteChunk { + sequence: 0, + stream: crate::CommandStream::Stdout, + offset: 0, + bytes: vec![0; MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + 1] + }], + next_cursor: 0, + terminal: None } )) .is_err() @@ -958,7 +991,7 @@ mod tests { #[test] fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() { let unknown = r#"{ - "version": 2, + "version": 3, "message": { "kind": "heartbeat", "generation": 1, @@ -1012,4 +1045,81 @@ mod tests { assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err()); assert!(ExternalWorkdirOperationId::new("operation-1").is_ok()); } + + #[test] + fn command_budget_and_page_metadata_obey_external_hard_bounds() { + fn request(budget: u64) -> WorkdirSessionOperation { + WorkdirSessionOperation::CommandStart(CommandRequest { + command: "true".into(), + timeout_secs: 1, + cwd: WorkdirPath::root(), + transport_byte_budget: budget, + tool_call_id: None, + }) + } + for budget in [0, crate::MAX_COMMAND_TRANSPORT_BYTES] { + let operation = ExternalWorkdirOperation::try_from(request(budget)).unwrap(); + let wire = serde_json::to_value(operation).unwrap(); + assert!(serde_json::from_value::(wire).is_ok()); + } + assert!( + ExternalWorkdirOperation::try_from(request(crate::MAX_COMMAND_TRANSPORT_BYTES + 1)) + .is_err() + ); + fn page() -> crate::CommandOutput { + crate::CommandOutput { + chunks: vec![crate::CommandByteChunk { + sequence: 0, + stream: crate::CommandStream::Stdout, + offset: 0, + bytes: b"abc".to_vec(), + }], + next_cursor: 3, + terminal: Some(crate::CommandTerminal { + status: crate::CommandStatus::Completed, + exit_code: Some(0), + stdout_bytes: 100 * 1024 * 1024, + stderr_bytes: 7, + stdout_delivered_bytes: 3, + stderr_delivered_bytes: 0, + truncation_reason: Some(crate::CommandOutputTruncation::ByteBudget), + }), + } + } + let accepted = ExternalWorkdirOperationResult::try_from( + WorkdirSessionOperationResult::CommandOutput(page()), + ) + .unwrap(); + assert!( + serde_json::from_value::( + serde_json::to_value(accepted).unwrap() + ) + .is_ok() + ); + for mutate in [ + |p: &mut crate::CommandOutput| p.next_cursor = crate::MAX_COMMAND_TRANSPORT_BYTES + 1, + |p: &mut crate::CommandOutput| p.chunks[0].offset = crate::MAX_COMMAND_TRANSPORT_BYTES, + |p: &mut crate::CommandOutput| p.chunks[0].sequence = u64::MAX, + |p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().stdout_delivered_bytes = 4, + |p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().stdout_bytes = 2, + |p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().truncation_reason = None, + ] { + let mut invalid = page(); + mutate(&mut invalid); + let wire = serde_json::to_value(WorkdirSessionOperationResult::CommandOutput(invalid)) + .unwrap(); + assert!(serde_json::from_value::(wire).is_err()); + } + assert!( + ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandOutput( + crate::CommandOutputRequest { + handle: crate::CommandHandle("command-1".into()), + cursor: crate::MAX_COMMAND_TRANSPORT_BYTES + 1, + limit: 1, + wait: false, + } + )) + .is_err() + ); + } } diff --git a/crates/workdir/src/external_local_tests.rs b/crates/workdir/src/external_local_tests.rs index 0a316586f..7332d6a06 100644 --- a/crates/workdir/src/external_local_tests.rs +++ b/crates/workdir/src/external_local_tests.rs @@ -1,6 +1,9 @@ +#[cfg(test)] +use crate::command_test_support::CollectCommand as _; use crate::http::{ WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation, }; + use crate::{ BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot, GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir, @@ -59,11 +62,12 @@ async fn external_local_provider_is_strictly_read_only() { let command = WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "echo changed > item.txt".to_string(), timeout_secs: 1, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -145,11 +149,12 @@ async fn external_local_provider_read_write_is_filesystem_only() { let command = WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "touch command-ran".to_string(), timeout_secs: 1, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -193,17 +198,18 @@ async fn external_command_permission_is_explicit_and_independent_from_file_acces let handle = session .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf command-ok".to_string(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await .unwrap(); let output = session - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 1024, diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index f9ff246db..7464943da 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -693,6 +693,12 @@ mod client { let mutation = request.operation.capability() != crate::WorkdirSessionCapability::Read; let expected_path = request.target.clone(); let expected_output = request.operation.capability(); + let byte_range = match &request.operation { + crate::CheckoutOperation::ReadBytes { offset, max_bytes } => { + Some((*offset, *max_bytes)) + } + _ => None, + }; match self .operate(WorkdirSessionOperation::CheckoutExecute(request)) .await @@ -702,11 +708,30 @@ mod client { && !result.observation.validator.is_empty() && result.observation.validator.len() <= 256 && match (&result.output, expected_output) { + ( + crate::CheckoutOutput::ReadBytes(read), + crate::WorkdirSessionCapability::Read, + ) => byte_range.is_some_and(|(offset, max)| { + read.path == expected_path + && read.offset == offset + && read.bytes.len() <= max + && read + .offset + .checked_add(read.bytes.len() as u64) + .is_some_and(|end| { + end <= read.total_bytes + || (read.bytes.is_empty() && read.eof) + }) + && read.eof + == (read.offset.saturating_add(read.bytes.len() as u64) + >= read.total_bytes) + }), ( crate::CheckoutOutput::Read(read), crate::WorkdirSessionCapability::Read, ) => { - read.path == expected_path + byte_range.is_none() + && read.path == expected_path && read.bytes.len() <= crate::BoundedReadLimits::EXTERNAL_DEFAULT .max_response_bytes @@ -822,6 +847,7 @@ mod client { &self, request: CommandRequest, ) -> Result { + request.validate()?; match self .operate(WorkdirSessionOperation::CommandStart(request)) .await? @@ -848,17 +874,39 @@ mod client { &self, request: CommandOutputRequest, ) -> Result { + if !(1..=crate::MAX_COMMAND_READ_BYTES).contains(&request.limit) { + return Err(WorkdirError::InvalidArgument( + "command byte limit is outside transport bounds".into(), + )); + } let wait = request.wait; - loop { - match self - .operate(WorkdirSessionOperation::CommandOutput(request.clone())) - .await? - { - WorkdirSessionOperationResult::CommandOutput(result) - if wait && result.status == CommandStatus::Running => {} - WorkdirSessionOperationResult::CommandOutput(result) => return Ok(result), - _ => return Err(Self::mismatch("command_output")), + match self + .operate(WorkdirSessionOperation::CommandOutput(request.clone())) + .await? + { + WorkdirSessionOperationResult::CommandOutput(result) => { + let size = result + .chunks + .iter() + .try_fold(0usize, |size, chunk| size.checked_add(chunk.bytes.len())); + if !result.transport_bounds_valid() + || size.is_none_or(|size| { + size > request.limit + || request.cursor.checked_add(size as u64) + != Some(result.next_cursor) + }) + { + return Err(Self::mismatch("bounded command byte page")); + } + if wait && result.chunks.is_empty() && result.terminal.is_none() { + // A waiting request must block at the provider. Never spin HTTP + // requests against an immediate empty response. A caller may retry + // this exact cursor with its own bounded transport retry policy. + return Err(Self::mismatch("waiting command byte page")); + } + return Ok(result); } + _ => return Err(Self::mismatch("command_output")), } } @@ -917,20 +965,47 @@ mod client { response: reqwest::Response, ) -> Result { if response.status().is_success() { - response.json().await.map_err(http_unavailable) + let bytes = bounded_response_bytes(response).await?; + serde_json::from_slice(&bytes) + .map_err(|_| WorkdirError::Transport("Runtime Workdir response was invalid".into())) } else { Err(decode_error(response).await) } } + async fn bounded_response_bytes( + mut response: reqwest::Response, + ) -> Result, WorkdirError> { + const MAX_RESPONSE_BYTES: usize = crate::external::MAX_EXTERNAL_PROVIDER_FRAME_BYTES; + if response + .content_length() + .is_some_and(|size| size > MAX_RESPONSE_BYTES as u64) + { + return Err(WorkdirError::Transport( + "Runtime Workdir response exceeded transport bound".into(), + )); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(http_unavailable)? { + if chunk.len() > MAX_RESPONSE_BYTES.saturating_sub(bytes.len()) { + return Err(WorkdirError::Transport( + "Runtime Workdir response exceeded transport bound".into(), + )); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + async fn decode_error(response: reqwest::Response) -> WorkdirError { - response - .json::() - .await - .map(WorkdirTransportError::into_workdir_error) - .unwrap_or_else(|_| { - WorkdirError::Transport("Runtime Workdir error response was invalid".to_string()) - }) + match bounded_response_bytes(response).await { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map(WorkdirTransportError::into_workdir_error) + .unwrap_or_else(|_| { + WorkdirError::Transport("Runtime Workdir error response was invalid".into()) + }), + Err(error) => error, + } } fn http_unavailable(_error: reqwest::Error) -> WorkdirError { diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index de9e7f8e5..7f052d5be 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -618,3 +618,6 @@ mod capability_tests { assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63); } } + +#[cfg(test)] +mod command_test_support; diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index f750bf0ee..c84970b0f 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -13,7 +13,8 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Debug; -use std::io::{Read as _, Seek as _, SeekFrom, Write as _}; +#[cfg(test)] +use std::io::Write as _; #[cfg(unix)] use std::os::unix::process::CommandExt as _; use std::path::{Path, PathBuf}; @@ -54,15 +55,8 @@ fn command_observed_at_ms() -> u64 { .unwrap_or(u64::MAX) } -#[derive(Debug)] -enum LocalCommand { - Running { - task: JoinHandle>, - completion: watch::Receiver, - cancel: watch::Sender, - }, - Completed(CommandOutput), -} +mod command_transport; +use command_transport::LocalCommand; #[derive(Debug, Clone)] struct CommandTelemetry { @@ -643,7 +637,6 @@ struct LocalWorkdirSessionInner { root: PathBuf, pinned_root: Option>, scope: SharedScope, - command_output_scope: SharedScope, cwd: PathBuf, capabilities: WorkdirSessionCapabilities, read_limits: Option, @@ -662,9 +655,7 @@ impl Drop for LocalWorkdirSessionInner { fn drop(&mut self) { if let Ok(mut commands) = self.commands.try_lock() { for (_, command) in commands.drain() { - if let LocalCommand::Running { task, .. } = command { - task.abort(); - } + command.task.abort(); } } } @@ -864,37 +855,12 @@ impl LocalWorkdirSession { capabilities: WorkdirSessionCapabilities, command_environment: BTreeMap, resources: Vec>, - ) -> Self { - Self::materialized_bound_with_environment_and_command_output_scope( - workdir, - root, - cwd, - scope.clone(), - scope, - capabilities, - command_environment, - resources, - ) - } - - /// Construct a local provider session whose Workdir filesystem authority is - /// independent from the provider-owned command output authority. - pub fn materialized_bound_with_environment_and_command_output_scope( - workdir: Workdir, - root: PathBuf, - cwd: PathBuf, - scope: SharedScope, - command_output_scope: SharedScope, - capabilities: WorkdirSessionCapabilities, - command_environment: BTreeMap, - resources: Vec>, ) -> Self { Self::materialized_bound_with_policy( workdir, root, cwd, scope, - command_output_scope, capabilities, command_environment, resources, @@ -1024,7 +990,6 @@ impl LocalWorkdirSession { workdir, root.clone(), root, - scope.clone(), scope, capabilities, BTreeMap::new(), @@ -1052,7 +1017,6 @@ impl LocalWorkdirSession { root: PathBuf, cwd: PathBuf, scope: SharedScope, - command_output_scope: SharedScope, capabilities: WorkdirSessionCapabilities, command_environment: BTreeMap, resources: Vec>, @@ -1066,7 +1030,6 @@ impl LocalWorkdirSession { root, pinned_root, scope, - command_output_scope, cwd, capabilities, read_limits, @@ -1587,6 +1550,20 @@ impl WorkdirSession for LocalWorkdirSession { } let is_create = matches!(&request.operation, Op::Create { .. }); let output = match request.operation { + Op::ReadBytes { offset, max_bytes } => fs_operation::run_read_bytes_bounded( + &self.inner.root, + ReadBytesRequest { + path: path.clone(), + offset, + max_bytes, + expected_hash: None, + }, + &pinned, + self.inner + .read_limits + .unwrap_or(BoundedReadLimits::EXTERNAL_DEFAULT), + ) + .map(Output::ReadBytes), Op::Read { offset, limit, @@ -1679,7 +1656,7 @@ impl WorkdirSession for LocalWorkdirSession { checkout_execute_error(&path, WorkdirError::from(error)) } })?; - if matches!(&output, Output::Read(_)) { + if matches!(&output, Output::Read(_) | Output::ReadBytes(_)) { pinned.confirm().map_err(|e| checkout_io(&path, e))?; return Ok(crate::CheckoutResult { observation: self @@ -1831,20 +1808,12 @@ impl WorkdirSession for LocalWorkdirSession { } async fn start_command(&self, request: CommandRequest) -> Result { + request.validate()?; self.ensure_capability(WorkdirSessionCapability::Command)?; // Command admission, resource capture and registration must be atomic // with terminal close, which drains commands and releases resources. let _admission = self.inner.close_lock.lock().await; self.ensure_open()?; - if let Some(spill_dir) = request.spill_dir.as_deref() - && !self - .inner - .command_output_scope - .snapshot() - .is_readable(spill_dir) - { - return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf())); - } let cwd = self.resolve(&request.cwd); let scope = self.inner.scope.snapshot(); if !scope.is_readable(&cwd) @@ -1854,48 +1823,45 @@ impl WorkdirSession for LocalWorkdirSession { } let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed); let handle = CommandHandle(format!("command-{id}")); - let (completion_tx, completion) = watch::channel(false); - let command_id = handle.0.clone(); - let telemetry = self.inner.command_telemetry.clone(); - let command_environment = self.inner.command_environment.clone(); - let (cancel, cancel_rx) = watch::channel(false); - // A session drop only schedules command cancellation. Keep its Runtime - // resources (including cleanup exclusion) alive until the task actually - // finishes or its future is dropped, not just until the handle disappears. + let mut commands = self.inner.commands.lock().await; + // Retain bounded retry tombstones, evicting only fully delivered output. + if commands.len() >= command_transport::MAX_COMMANDS { + let mut evict = None; + for (id, command) in commands.iter() { + if command.task.is_finished() + && command + .transport + .try_lock() + .is_ok_and(|state| state.terminal_delivered()) + { + evict = Some(id.clone()); + break; + } + } + if let Some(id) = evict { + commands.remove(&id); + self.inner.command_telemetry.remove(&id); + } else { + return Err(WorkdirError::Unavailable( + "command transport capacity reached; drain existing commands".into(), + )); + } + } let resources = self .inner .resources .lock() .unwrap_or_else(|error| error.into_inner()) .clone(); - let task = tokio::spawn(async move { - let output = run_command( - cwd, - request, - command_id, - telemetry, - command_environment, - cancel_rx, - resources, - ) - .await; - let _ = completion_tx.send(true); - output - }); - let mut commands = self.inner.commands.lock().await; - if let Err(error) = self.ensure_open() { - let _ = cancel.send(true); - task.abort(); - return Err(error); - } - commands.insert( + let command = command_transport::start( + cwd, + request, handle.0.clone(), - LocalCommand::Running { - task, - completion, - cancel, - }, + self.inner.command_telemetry.clone(), + self.inner.command_environment.clone(), + resources, ); + commands.insert(handle.0.clone(), command); Ok(handle) } @@ -1905,17 +1871,11 @@ impl WorkdirSession for LocalWorkdirSession { let command = commands .get(&handle.0) .ok_or_else(|| WorkdirError::UnknownCommand(handle.0.clone()))?; - Ok(match command { - LocalCommand::Running { task, .. } if !task.is_finished() => CommandStatus::Running, - LocalCommand::Running { .. } => self - .inner - .command_telemetry - .snapshot() - .into_iter() - .find(|snapshot| snapshot.command_id == handle.0) - .map_or(CommandStatus::Completed, |snapshot| snapshot.status), - LocalCommand::Completed(output) => output.status, - }) + Ok(command + .status + .borrow() + .as_ref() + .map_or(CommandStatus::Running, |terminal| terminal.status)) } async fn command_output( @@ -1923,70 +1883,22 @@ impl WorkdirSession for LocalWorkdirSession { request: CommandOutputRequest, ) -> Result { self.ensure_capability(WorkdirSessionCapability::Command)?; - let command = loop { - self.ensure_open()?; - let mut commands = self.inner.commands.lock().await; - let Some(command) = commands.get(&request.handle.0) else { - return Err(WorkdirError::UnknownCommand(request.handle.0.clone())); - }; - let completion = match command { - LocalCommand::Running { completion, .. } - if !*completion.borrow() && completion.has_changed().is_ok() => - { - Some(completion.clone()) - } - _ => None, - }; - if let Some(mut completion) = completion { - if !request.wait { - return Ok(CommandOutput { - status: CommandStatus::Running, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, - }); - } - drop(commands); - let _ = completion.changed().await; - continue; - } - if !request.wait - && matches!(command, LocalCommand::Running { task, .. } if !task.is_finished()) - { - return Ok(CommandOutput { - status: CommandStatus::Running, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, - }); - } - break commands - .remove(&request.handle.0) - .expect("command checked above"); - }; - - let output = match command { - LocalCommand::Running { task, .. } => task - .await - .map_err(|error| WorkdirError::Unavailable(error.to_string()))??, - LocalCommand::Completed(output) => output, - }; - let page = command_output_page(&output, request.cursor, request.limit); - if page.next_cursor.is_some() { - let mut commands = self.inner.commands.lock().await; - if !self.inner.closed.load(Ordering::Acquire) { - commands.insert(request.handle.0, LocalCommand::Completed(output)); - } - } else { + self.ensure_open()?; + let transport = self + .inner + .commands + .lock() + .await + .get(&request.handle.0) + .ok_or_else(|| WorkdirError::UnknownCommand(request.handle.0.clone()))? + .transport + .clone(); + let result = transport.lock().await.read(&request).await; + self.ensure_open()?; + if result.as_ref().is_ok_and(|page| page.terminal.is_some()) { self.inner.command_telemetry.remove(&request.handle.0); } - Ok(page) + result } async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { @@ -1996,12 +1908,7 @@ impl WorkdirSession for LocalWorkdirSession { let command = commands .get(&handle.0) .ok_or_else(|| WorkdirError::UnknownCommand(handle.0.clone()))?; - match command { - LocalCommand::Running { task, cancel, .. } if !task.is_finished() => { - Some(cancel.clone()) - } - _ => None, - } + Some(command.cancel.clone()) }; if let Some(cancel) = cancel { let _ = cancel.send(true); @@ -2041,13 +1948,9 @@ impl WorkdirSession for LocalWorkdirSession { .collect::>() }; for command in commands { - match command { - LocalCommand::Running { task, cancel, .. } => { - let _ = cancel.send(true); - let _ = task.await; - } - LocalCommand::Completed(_) => {} - } + let _ = command.cancel.send(true); + command.transport.lock().await.close(); + let _ = command.task.await; } if let Ok(mut resources) = self.inner.resources.lock() { resources.clear(); @@ -2056,27 +1959,6 @@ impl WorkdirSession for LocalWorkdirSession { } } -fn command_output_page(output: &CommandOutput, cursor: usize, limit: usize) -> CommandOutput { - let total_chars = output.content.chars().count(); - let start = cursor.min(total_chars); - let content = output - .content - .chars() - .skip(start) - .take(limit.max(1)) - .collect::(); - let end = start + content.chars().count(); - CommandOutput { - status: output.status, - exit_code: output.exit_code, - timed_out: output.timed_out, - content, - next_cursor: (end < total_chars).then_some(end), - truncated: output.truncated || end < total_chars, - output_path: output.output_path.clone(), - } -} - // Discovery of an absent route is not a stale-validator execution attempt. fn checkout_observe_io(path: &WorkdirPath, error: std::io::Error) -> WorkdirError { if matches!( @@ -2202,167 +2084,6 @@ fn terminate_process_group(process_group_id: u32) { #[cfg(not(unix))] fn terminate_process_group(_process_group_id: u32) {} -async fn run_command( - cwd: PathBuf, - request: CommandRequest, - command_id: String, - telemetry: CommandTelemetry, - command_environment: BTreeMap, - mut cancel: watch::Receiver, - resources: Vec>, -) -> Result { - let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; - let stderr = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; - let stdout_path = stdout.into_temp_path(); - let stderr_path = stderr.into_temp_path(); - let stdout_file = std::fs::File::create(&stdout_path) - .map_err(|error| WorkdirError::io(&stdout_path, error))?; - let stderr_file = std::fs::File::create(&stderr_path) - .map_err(|error| WorkdirError::io(&stderr_path, error))?; - - telemetry.started(&command_id, request.tool_call_id.clone()); - let mut command = Command::new("bash"); - command - .arg("-c") - .arg(&request.command) - .current_dir(&cwd) - .envs(command_environment) - .stdin(Stdio::null()) - .stdout(Stdio::from(stdout_file)) - .stderr(Stdio::from(stderr_file)) - .kill_on_drop(true); - #[cfg(unix)] - command.as_std_mut().process_group(0); - let mut child = match command.spawn() { - Ok(child) => child, - Err(error) => { - telemetry.terminal(&command_id, CommandStatus::Failed, None); - return Err(WorkdirError::io(&cwd, error)); - } - }; - let mut process_group = CommandProcessGroupGuard::new(child.id()); - - let mut stdout_reader = - std::fs::File::open(&stdout_path).map_err(|error| WorkdirError::io(&stdout_path, error))?; - let mut stderr_reader = - std::fs::File::open(&stderr_path).map_err(|error| WorkdirError::io(&stderr_path, error))?; - let mut stdout_decoder = CommandOutputDecoder::default(); - let mut stderr_decoder = CommandOutputDecoder::default(); - let mut timeout = Box::pin(tokio::time::sleep(Duration::from_secs( - request.timeout_secs.max(1), - ))); - let mut interval = tokio::time::interval(Duration::from_millis(50)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - interval.tick().await; - - let (status, exit_code) = loop { - tokio::select! { - exit = child.wait() => { - let exit = exit.map_err(|error| WorkdirError::io(&cwd, error))?; - process_group.terminate(); - break ( - if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed }, - exit.code(), - ); - } - _ = &mut timeout => { - process_group.terminate(); - let _ = child.start_kill(); - let exit_code = child.wait().await.ok().and_then(|status| status.code()); - break (CommandStatus::TimedOut, exit_code); - } - changed = cancel.changed() => { - if changed.is_err() || *cancel.borrow() { - process_group.terminate(); - let _ = child.start_kill(); - let exit_code = child.wait().await.ok().and_then(|status| status.code()); - break (CommandStatus::Cancelled, exit_code); - } - } - _ = interval.tick() => { - publish_available_output( - &mut stdout_reader, - &mut stdout_decoder, - &telemetry, - &command_id, - CommandStream::Stdout, - &stdout_path, - false, - )?; - publish_available_output( - &mut stderr_reader, - &mut stderr_decoder, - &telemetry, - &command_id, - CommandStream::Stderr, - &stderr_path, - false, - )?; - } - } - }; - - publish_available_output( - &mut stdout_reader, - &mut stdout_decoder, - &telemetry, - &command_id, - CommandStream::Stdout, - &stdout_path, - true, - )?; - publish_available_output( - &mut stderr_reader, - &mut stderr_decoder, - &telemetry, - &command_id, - CommandStream::Stderr, - &stderr_path, - true, - )?; - telemetry.terminal(&command_id, status, exit_code); - - let (content, truncated) = - read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?; - let output_path = match (truncated, request.spill_dir) { - (true, Some(spill_dir)) => { - let stdout_path = stdout_path.to_path_buf(); - let stderr_path = stderr_path.to_path_buf(); - Some( - spawn_resource_owned_blocking(resources.clone(), move || { - persist_command_output(&stdout_path, &stderr_path, &spill_dir) - }) - .await - .map_err(|error| { - WorkdirError::Unavailable(format!("Bash output spill task failed: {error}")) - })??, - ) - } - _ => None, - }; - Ok(CommandOutput { - status, - exit_code, - timed_out: status == CommandStatus::TimedOut, - content, - next_cursor: None, - truncated, - output_path, - }) -} - -// Blocking spills cannot be aborted with their async waiter. Transfer resource -// ownership into the actual filesystem task so cleanup waits for its completion. -fn spawn_resource_owned_blocking( - resources: Vec>, - effect: impl FnOnce() -> T + Send + 'static, -) -> JoinHandle { - tokio::task::spawn_blocking(move || { - let _resources = resources; - effect() - }) -} - #[derive(Debug, Default)] struct CommandOutputDecoder { read_offset: u64, @@ -2370,38 +2091,6 @@ struct CommandOutputDecoder { pending: Vec, } -fn publish_available_output( - file: &mut std::fs::File, - decoder: &mut CommandOutputDecoder, - telemetry: &CommandTelemetry, - command_id: &str, - stream: CommandStream, - path: &Path, - flush: bool, -) -> Result<(), WorkdirError> { - file.seek(SeekFrom::Start(decoder.read_offset)) - .map_err(|error| WorkdirError::io(path, error))?; - loop { - let mut buffer = vec![0; COMMAND_EVENT_CHUNK_BYTES]; - let read = file - .read(&mut buffer) - .map_err(|error| WorkdirError::io(path, error))?; - if read == 0 { - publish_decoded_output(decoder, telemetry, command_id, stream, flush); - return Ok(()); - } - decoder.pending.extend_from_slice(&buffer[..read]); - decoder.read_offset = decoder.read_offset.saturating_add(read as u64); - publish_decoded_output(decoder, telemetry, command_id, stream, false); - if read < COMMAND_EVENT_CHUNK_BYTES { - if flush { - publish_decoded_output(decoder, telemetry, command_id, stream, true); - } - return Ok(()); - } - } -} - fn publish_decoded_output( decoder: &mut CommandOutputDecoder, telemetry: &CommandTelemetry, @@ -2428,7 +2117,7 @@ fn publish_decoded_output( } /// Return the byte prefix that can be decoded now without replacing a valid -/// UTF-8 scalar whose remaining bytes may arrive in a later file read. Definite +/// UTF-8 scalar whose remaining bytes may arrive in a later pipe read. Definite /// invalid sequences remain in the prefix and are rendered lossily, preserving /// the existing arbitrary-byte output behavior. fn stable_utf8_prefix_len(bytes: &[u8]) -> usize { @@ -2448,110 +2137,6 @@ fn stable_utf8_prefix_len(bytes: &[u8]) -> usize { inspected } -fn persist_command_output( - stdout_path: &Path, - stderr_path: &Path, - spill_dir: &Path, -) -> Result { - std::fs::create_dir_all(spill_dir).map_err(|error| WorkdirError::io(spill_dir, error))?; - let mut artifact = tempfile::Builder::new() - .prefix("bash-") - .suffix(".log") - .tempfile_in(spill_dir) - .map_err(|error| WorkdirError::io(spill_dir, error))?; - let artifact_path = artifact.path().to_path_buf(); - - let mut stdout = - std::fs::File::open(stdout_path).map_err(|error| WorkdirError::io(stdout_path, error))?; - let stdout_len = stdout - .metadata() - .map_err(|error| WorkdirError::io(stdout_path, error))? - .len(); - std::io::copy(&mut stdout, &mut artifact) - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - - let mut stderr = - std::fs::File::open(stderr_path).map_err(|error| WorkdirError::io(stderr_path, error))?; - let stderr_len = stderr - .metadata() - .map_err(|error| WorkdirError::io(stderr_path, error))? - .len(); - if stdout_len > 0 && stderr_len > 0 { - stdout - .seek(SeekFrom::End(-1)) - .map_err(|error| WorkdirError::io(stdout_path, error))?; - let mut last = [0_u8; 1]; - stdout - .read_exact(&mut last) - .map_err(|error| WorkdirError::io(stdout_path, error))?; - if last[0] != b'\n' { - artifact - .write_all(b"\n") - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - } - } - std::io::copy(&mut stderr, &mut artifact) - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - artifact - .flush() - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - artifact - .keep() - .map(|(_, path)| path) - .map_err(|error| WorkdirError::io(&artifact_path, error.error)) -} - -fn read_command_output_files( - stdout_path: &Path, - stderr_path: &Path, - limit: usize, -) -> Result<(String, bool), WorkdirError> { - let stdout_size = std::fs::metadata(stdout_path) - .map_err(|error| WorkdirError::io(stdout_path, error))? - .len() as usize; - let stderr_size = std::fs::metadata(stderr_path) - .map_err(|error| WorkdirError::io(stderr_path, error))? - .len() as usize; - let total = stdout_size.saturating_add(stderr_size); - let stderr_budget = stderr_size.min(limit / 2); - let stdout_budget = stdout_size.min(limit.saturating_sub(stderr_budget)); - let remaining = limit.saturating_sub(stdout_budget + stderr_budget); - let stdout_budget = (stdout_budget + remaining.min(stdout_size - stdout_budget)).min(limit); - let stderr_budget = limit.saturating_sub(stdout_budget).min(stderr_size); - - let stdout = read_tail(stdout_path, stdout_budget)?; - let stderr = read_tail(stderr_path, stderr_budget)?; - let mut content = String::new(); - if !stdout.is_empty() { - content.push_str(&String::from_utf8_lossy(&stdout)); - } - if !stderr.is_empty() { - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(&String::from_utf8_lossy(&stderr)); - } - Ok((content, total > limit)) -} - -fn read_tail(path: &Path, limit: usize) -> Result, WorkdirError> { - if limit == 0 { - return Ok(Vec::new()); - } - let mut file = std::fs::File::open(path).map_err(|error| WorkdirError::io(path, error))?; - let len = file - .metadata() - .map_err(|error| WorkdirError::io(path, error))? - .len(); - let start = len.saturating_sub(limit as u64); - file.seek(SeekFrom::Start(start)) - .map_err(|error| WorkdirError::io(path, error))?; - let mut bytes = Vec::with_capacity((len - start) as usize); - file.read_to_end(&mut bytes) - .map_err(|error| WorkdirError::io(path, error))?; - Ok(bytes) -} - /// Return the first symlink component in `path`, if one exists. /// /// The function only inspects existing path components. It intentionally uses @@ -2928,11 +2513,12 @@ mod tests { let command = WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".to_owned(), timeout_secs: 60, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -2941,7 +2527,7 @@ mod tests { let waiting_session = session.clone(); let waiting_command = command.clone(); let waiter = tokio::spawn(async move { - WorkdirSession::command_output( + crate::command_test_support::CollectCommand::collect_command_output( &waiting_session, CommandOutputRequest { handle: waiting_command, @@ -3626,17 +3212,18 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "pwd && printf provider-command".into(), timeout_secs: 5, - output_limit: 4096, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) .await .unwrap(); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle, @@ -3728,11 +3315,12 @@ mod tests { let error = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf hidden".into(), timeout_secs: 5, - output_limit: 1024, + cwd, - spill_dir: None, + tool_call_id: None, }, ) @@ -3742,319 +3330,6 @@ mod tests { assert!(matches!(error, WorkdirError::OutOfScope(path) if path == outside.path())); } - #[tokio::test] - async fn command_rejects_spill_directory_without_read_scope() { - let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - - let error = WorkdirSession::start_command( - &workdir, - CommandRequest { - command: "printf hidden".into(), - timeout_secs: 5, - output_limit: 1, - cwd: WorkdirPath::root(), - spill_dir: Some(spill.path().to_path_buf()), - tool_call_id: None, - }, - ) - .await - .unwrap_err(); - - assert!(matches!(error, WorkdirError::OutOfScope(path) if path == spill.path())); - } - - #[tokio::test] - async fn truncated_command_output_is_retained_in_the_requested_spill_directory() { - let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = Scope::from_config(&ScopeConfig { - allow: vec![ - ScopeRule { - target: dir.path().to_path_buf(), - permission: Permission::Write, - recursive: true, - symlink_policy: Default::default(), - }, - ScopeRule { - target: spill.path().to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }, - ], - deny: Vec::new(), - }) - .unwrap(); - let workdir = LocalWorkdirSession::new(scope, dir.path().to_path_buf()); - let handle = WorkdirSession::start_command( - &workdir, - CommandRequest { - command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(), - timeout_secs: 5, - output_limit: 64, - cwd: WorkdirPath::root(), - spill_dir: Some(spill.path().to_path_buf()), - tool_call_id: None, - }, - ) - .await - .unwrap(); - let output = WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle, - cursor: 0, - limit: 4096, - wait: true, - }, - ) - .await - .unwrap(); - - assert!(output.truncated); - let output_path = output.output_path.expect("retained output path"); - assert_eq!(output_path.parent(), Some(spill.path())); - let retained = std::fs::read_to_string(&output_path).unwrap(); - assert!(retained.starts_with("line-000\n")); - assert!(retained.ends_with("FINAL-NEEDLE\n")); - assert_eq!(retained.lines().count(), 201); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - assert_eq!( - std::fs::metadata(output_path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } - } - - #[tokio::test] - async fn completed_command_output_can_be_read_in_bounded_unicode_pages() { - let dir = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - let handle = WorkdirSession::start_command( - &workdir, - CommandRequest { - command: "printf 'aéz'".into(), - timeout_secs: 5, - output_limit: 1024, - cwd: WorkdirPath::root(), - spill_dir: None, - tool_call_id: None, - }, - ) - .await - .unwrap(); - let first = WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle: handle.clone(), - cursor: 0, - limit: 2, - wait: true, - }, - ) - .await - .unwrap(); - assert_eq!(first.content, "aé"); - assert_eq!(first.next_cursor, Some(2)); - - let second = WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle: handle.clone(), - cursor: first.next_cursor.unwrap(), - limit: 2, - wait: false, - }, - ) - .await - .unwrap(); - assert_eq!(second.content, "z"); - assert_eq!(second.next_cursor, None); - assert!(matches!( - WorkdirSession::command_status(&workdir, handle).await, - Err(WorkdirError::UnknownCommand(_)) - )); - } - - #[test] - fn command_output_decoder_preserves_utf8_split_across_file_reads() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("command.out"); - let mut first_write = vec![b'a'; COMMAND_EVENT_CHUNK_BYTES - 1]; - first_write.push(0xe2); - std::fs::write(&path, first_write).unwrap(); - - let telemetry = CommandTelemetry::new(); - let mut events = telemetry.subscribe(); - telemetry.started("command-utf8", None); - let mut decoder = CommandOutputDecoder::default(); - let mut reader = std::fs::File::open(&path).unwrap(); - publish_available_output( - &mut reader, - &mut decoder, - &telemetry, - "command-utf8", - CommandStream::Stdout, - &path, - false, - ) - .unwrap(); - assert_eq!(decoder.pending, vec![0xe2]); - - let mut writer = std::fs::OpenOptions::new() - .append(true) - .open(&path) - .unwrap(); - writer.write_all(&[0x82, 0xac]).unwrap(); - writer.flush().unwrap(); - publish_available_output( - &mut reader, - &mut decoder, - &telemetry, - "command-utf8", - CommandStream::Stdout, - &path, - false, - ) - .unwrap(); - - let output = std::iter::from_fn(|| events.try_recv().ok()) - .filter_map(|event| match event { - CommandEvent::Output { - stream: CommandStream::Stdout, - content, - .. - } => Some(content), - _ => None, - }) - .collect::(); - assert_eq!(output.len(), COMMAND_EVENT_CHUNK_BYTES - 1 + "€".len()); - assert!(output.ends_with('€')); - assert!(!output.contains('\u{fffd}')); - assert!(decoder.pending.is_empty()); - } - - #[tokio::test] - async fn command_output_does_not_rewait_before_join_handle_finishes() { - let dir = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - let handle = CommandHandle("command-completion-race".into()); - let (completion_tx, completion) = watch::channel(false); - let completion_observer = completion_tx.clone(); - let (cancel, _cancel_rx) = watch::channel(false); - let (start_tx, start_rx) = tokio::sync::oneshot::channel::<()>(); - let (completion_sent_tx, completion_sent_rx) = tokio::sync::oneshot::channel::<()>(); - let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); - let task = tokio::spawn(async move { - start_rx.await.unwrap(); - completion_tx.send(true).unwrap(); - completion_sent_tx.send(()).unwrap(); - release_rx.await.unwrap(); - Ok(CommandOutput { - status: CommandStatus::Completed, - exit_code: Some(0), - timed_out: false, - content: "done".into(), - next_cursor: None, - truncated: false, - output_path: None, - }) - }); - workdir.inner.commands.lock().await.insert( - handle.0.clone(), - LocalCommand::Running { - task, - completion, - cancel, - }, - ); - - let waiting_workdir = workdir.clone(); - let waiting_handle = handle.clone(); - let waiter = tokio::spawn(async move { - WorkdirSession::command_output( - &waiting_workdir, - CommandOutputRequest { - handle: waiting_handle, - cursor: 0, - limit: 1024, - wait: true, - }, - ) - .await - }); - for _ in 0..100 { - if completion_observer.receiver_count() >= 2 { - break; - } - tokio::task::yield_now().await; - } - assert_eq!( - completion_observer.receiver_count(), - 2, - "waiter must subscribe before completion is published" - ); - - start_tx.send(()).unwrap(); - completion_sent_rx.await.unwrap(); - tokio::task::yield_now().await; - assert!( - !waiter.is_finished(), - "command_output should await the task after observing completion state" - ); - release_tx.send(()).unwrap(); - - let output = tokio::time::timeout(Duration::from_secs(1), waiter) - .await - .expect("command output must not wait for a second completion notification") - .unwrap() - .unwrap(); - assert_eq!(output.status, CommandStatus::Completed); - assert_eq!(output.content, "done"); - } - - #[tokio::test] - async fn command_output_does_not_wait_forever_when_completion_sender_drops() { - let dir = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - let handle = CommandHandle("command-completion-drop".into()); - let (completion_tx, completion) = watch::channel(false); - let (cancel, _cancel_rx) = watch::channel(false); - let task: JoinHandle> = tokio::spawn(async move { - drop(completion_tx); - panic!("simulated command task panic"); - }); - workdir.inner.commands.lock().await.insert( - handle.0.clone(), - LocalCommand::Running { - task, - completion, - cancel, - }, - ); - - let result = tokio::time::timeout( - Duration::from_secs(1), - WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle, - cursor: 0, - limit: 1024, - wait: true, - }, - ), - ) - .await - .expect("closed completion channel must wake the waiter"); - assert!(matches!(result, Err(WorkdirError::Unavailable(_)))); - } - #[tokio::test] async fn provider_streams_bounded_command_lifecycle_and_distinct_output() { let dir = TempDir::new().unwrap(); @@ -4064,11 +3339,12 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: Some("tool-7".into()), }, ) @@ -4146,7 +3422,7 @@ mod tests { assert_eq!(snapshot[0].stdout.content, "readydone"); assert_eq!(snapshot[0].stderr.content, "warning"); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle, @@ -4169,17 +3445,18 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 1, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) .await .unwrap(); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle: handle.clone(), @@ -4227,11 +3504,12 @@ mod tests { WorkdirSession::start_command( start_session.as_ref(), CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "true".into(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4261,50 +3539,6 @@ mod tests { ); } - #[tokio::test] - async fn blocking_output_spill_retains_resources_after_waiter_is_cancelled() { - #[derive(Debug)] - struct Resource(Option>); - impl Drop for Resource { - fn drop(&mut self) { - let _ = self.0.take().unwrap().send(()); - } - } - let (released, mut release) = tokio::sync::oneshot::channel(); - let resources: Vec> = - vec![Arc::new(Resource(Some(released)))]; - let (entered, started) = tokio::sync::oneshot::channel(); - let (finish, wait) = std::sync::mpsc::channel(); - // Exercise the resource-transfer policy at the same blocking boundary - // used by output persistence, without filesystem timing assumptions. - let waiter = tokio::spawn(async move { - spawn_resource_owned_blocking(resources, move || { - entered.send(()).unwrap(); - wait.recv_timeout(Duration::from_secs(5)).unwrap(); - }) - .await - .unwrap(); - }); - tokio::time::timeout(Duration::from_secs(5), started) - .await - .unwrap() - .unwrap(); - waiter.abort(); - assert!(waiter.await.unwrap_err().is_cancelled()); - assert!( - matches!( - release.try_recv(), - Err(tokio::sync::oneshot::error::TryRecvError::Empty) - ), - "blocking spill lost its resources when the waiter was cancelled" - ); - finish.send(()).unwrap(); - tokio::time::timeout(Duration::from_secs(5), release) - .await - .unwrap() - .unwrap(); - } - #[tokio::test(flavor = "current_thread")] async fn session_drop_retains_resources_until_command_task_cancellation_completes() { #[derive(Debug)] @@ -4328,11 +3562,12 @@ mod tests { WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "true".into(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4387,12 +3622,13 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "(sleep 0.4; echo leaked > child-marker) & echo ready > child-ready; wait" .into(), timeout_secs: 60, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4409,7 +3645,7 @@ mod tests { WorkdirSession::cancel_command(&workdir, handle.clone()) .await .unwrap(); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle, @@ -4435,11 +3671,12 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4454,7 +3691,7 @@ mod tests { let waiting_workdir = workdir.clone(); let waiting_handle = handle.clone(); let waiter = tokio::spawn(async move { - WorkdirSession::command_output( + crate::command_test_support::CollectCommand::collect_command_output( &waiting_workdir, CommandOutputRequest { handle: waiting_handle, @@ -4475,9 +3712,11 @@ mod tests { .unwrap() .unwrap(); assert_eq!(output.status, CommandStatus::Cancelled); - assert!(matches!( - WorkdirSession::command_status(&workdir, handle).await, - Err(WorkdirError::UnknownCommand(_)) - )); + assert_eq!( + WorkdirSession::command_status(&workdir, handle) + .await + .unwrap(), + CommandStatus::Cancelled + ); } } diff --git a/crates/workdir/src/local/command_transport.rs b/crates/workdir/src/local/command_transport.rs new file mode 100644 index 000000000..5b800c0ab --- /dev/null +++ b/crates/workdir/src/local/command_transport.rs @@ -0,0 +1,377 @@ +//! Captured process bytes use a bounded queue, never the lossy Console channel. +//! Pipe backpressure bounds unread process output without temporary-file growth. +//! A replay page survives a lost HTTP response until its next cursor is sent. + +use super::*; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::sync::mpsc; + +const QUEUE_CHUNKS: usize = 8; +pub(super) const MAX_COMMANDS: usize = 64; + +#[derive(Debug)] +enum Message { + Bytes(crate::CommandByteChunk), +} + +#[derive(Debug)] +pub(super) struct LocalCommand { + pub task: JoinHandle<()>, + pub cancel: watch::Sender, + pub status: watch::Receiver>, + pub transport: Arc>, +} + +#[derive(Debug)] +pub(super) struct Transport { + receiver: mpsc::Receiver, + pending: Option, + cursor: u64, + replay: Option<(u64, CommandOutput)>, + terminal: Option, + terminal_status: watch::Receiver>, +} + +impl Transport { + pub async fn read( + &mut self, + request: &CommandOutputRequest, + ) -> Result { + if !(1..=crate::MAX_COMMAND_READ_BYTES).contains(&request.limit) { + return Err(WorkdirError::Conflict( + "command byte limit is outside transport bounds".into(), + )); + } + if let Some((cursor, page)) = &self.replay { + if request.cursor == *cursor { + if page + .chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + > request.limit + { + return Err(WorkdirError::Conflict( + "retry byte limit is smaller than retained page".into(), + )); + } + return Ok(page.clone()); + } + if request.cursor != page.next_cursor { + return Err(WorkdirError::Conflict( + "command cursor is not the current page acknowledgement".into(), + )); + } + self.cursor = page.next_cursor; + self.replay = None; + } + if request.cursor != self.cursor { + return Err(WorkdirError::Conflict( + "command cursor was acknowledged or is ahead of output".into(), + )); + } + let mut page = CommandOutput { + chunks: Vec::new(), + next_cursor: self.cursor, + terminal: self.terminal.clone(), + }; + let mut remaining = request.limit; + loop { + let message = if let Some(chunk) = self.pending.take() { + Some(Message::Bytes(chunk)) + } else if page.chunks.is_empty() && page.terminal.is_none() && request.wait { + self.receiver.recv().await + } else { + match self.receiver.try_recv() { + Ok(message) => Some(message), + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => None, + } + }; + match message { + Some(Message::Bytes(mut chunk)) => { + let count = remaining.min(chunk.bytes.len()); + if count < chunk.bytes.len() { + let tail = chunk.bytes.split_off(count); + self.pending = Some(crate::CommandByteChunk { + sequence: chunk.sequence + count as u64, + stream: chunk.stream, + offset: chunk.offset + count as u64, + bytes: tail, + }); + } + page.next_cursor += count as u64; + remaining -= count; + page.chunks.push(chunk); + if remaining == 0 { + break; + } + } + None if page.terminal.is_some() => break, + None => { + let terminal = self.terminal_status.borrow().clone().ok_or_else(|| { + WorkdirError::Unavailable( + "command transport ended before terminal fence".into(), + ) + })?; + self.terminal = Some(terminal.clone()); + page.terminal = Some(terminal); + break; + } + } + } + // An empty poll must not become a permanently replayed empty page. + if !page.chunks.is_empty() || page.terminal.is_some() { + self.replay = Some((self.cursor, page.clone())); + } + Ok(page) + } + + pub fn terminal_delivered(&self) -> bool { + self.replay + .as_ref() + .is_some_and(|(_, page)| page.terminal.is_some()) + } + + pub fn close(&mut self) { + self.receiver.close(); + } +} + +pub(super) fn start( + cwd: PathBuf, + request: CommandRequest, + command_id: String, + telemetry: CommandTelemetry, + environment: BTreeMap, + resources: Vec>, +) -> LocalCommand { + let (sender, receiver) = mpsc::channel(QUEUE_CHUNKS); + let (cancel, cancel_rx) = watch::channel(false); + let (status_tx, status) = watch::channel(None); + let transport = Arc::new(Mutex::new(Transport { + receiver, + pending: None, + cursor: 0, + replay: None, + terminal: None, + terminal_status: status.clone(), + })); + let task = tokio::spawn(async move { + let _resources = resources; + let terminal = execute( + cwd, + request, + &command_id, + &telemetry, + environment, + cancel_rx, + &sender, + ) + .await; + telemetry.terminal(&command_id, terminal.status, terminal.exit_code); + // Closing the byte queue fences this already-published terminal, without + // requiring spare queue capacity or a receiver to release task resources. + let _ = status_tx.send(Some(terminal)); + }); + LocalCommand { + task, + cancel, + status, + transport, + } +} + +#[derive(Default)] +struct ByteAccounting { + stdout: u64, + stderr: u64, + stdout_delivered: u64, + stderr_delivered: u64, +} + +impl ByteAccounting { + fn terminal(&self, status: CommandStatus, exit_code: Option) -> crate::CommandTerminal { + crate::CommandTerminal { + status, + exit_code, + stdout_bytes: self.stdout, + stderr_bytes: self.stderr, + stdout_delivered_bytes: self.stdout_delivered, + stderr_delivered_bytes: self.stderr_delivered, + truncation_reason: (self.stdout > self.stdout_delivered + || self.stderr > self.stderr_delivered) + .then_some(crate::CommandOutputTruncation::ByteBudget), + } + } +} + +async fn pump( + mut stdout: R, + mut stderr: impl AsyncRead + Unpin, + sender: &mpsc::Sender, + telemetry: &CommandTelemetry, + command_id: &str, + mut remaining: u64, + accounting: &mut ByteAccounting, +) -> std::io::Result<()> { + let mut stdout_done = false; + let mut stderr_done = false; + let mut stdout_decoder = CommandOutputDecoder::default(); + let mut stderr_decoder = CommandOutputDecoder::default(); + let mut sequence = 0; + let mut stdout_buffer = [0; COMMAND_EVENT_CHUNK_BYTES]; + let mut stderr_buffer = [0; COMMAND_EVENT_CHUNK_BYTES]; + while !stdout_done || !stderr_done { + // After quota, drain locally even if nobody acknowledges the queue. + // Before quota, reserve first to retain bounded pipe backpressure. + let permit = if remaining > 0 { + let Ok(permit) = sender.reserve().await else { + return Ok(()); + }; + Some(permit) + } else { + None + }; + let (stream, result) = tokio::select! { + result = stdout.read(&mut stdout_buffer), if !stdout_done => (CommandStream::Stdout, result), + result = stderr.read(&mut stderr_buffer), if !stderr_done => (CommandStream::Stderr, result), + }; + let buffer = match stream { + CommandStream::Stdout => &stdout_buffer, + CommandStream::Stderr => &stderr_buffer, + }; + let count = result?; + let decoder = match stream { + CommandStream::Stdout => &mut stdout_decoder, + CommandStream::Stderr => &mut stderr_decoder, + }; + if count == 0 { + match stream { + CommandStream::Stdout => stdout_done = true, + CommandStream::Stderr => stderr_done = true, + } + publish_decoded_output(decoder, telemetry, command_id, stream, true); + continue; + } + let (seen, delivered) = match stream { + CommandStream::Stdout => (&mut accounting.stdout, &mut accounting.stdout_delivered), + CommandStream::Stderr => (&mut accounting.stderr, &mut accounting.stderr_delivered), + }; + *seen = seen.saturating_add(count as u64); + let captured = remaining.min(count as u64) as usize; + if captured > 0 { + let offset = *delivered; + decoder.read_offset += captured as u64; + decoder.pending.extend_from_slice(&buffer[..captured]); + publish_decoded_output(decoder, telemetry, command_id, stream, false); + permit + .expect("reserved while budget remains") + .send(Message::Bytes(crate::CommandByteChunk { + sequence, + stream, + offset, + bytes: buffer[..captured].to_vec(), + })); + *delivered += captured as u64; + sequence += captured as u64; + remaining -= captured as u64; + } + } + Ok(()) +} + +async fn execute( + cwd: PathBuf, + request: CommandRequest, + command_id: &str, + telemetry: &CommandTelemetry, + environment: BTreeMap, + mut cancel: watch::Receiver, + sender: &mpsc::Sender, +) -> crate::CommandTerminal { + telemetry.started(command_id, request.tool_call_id); + let mut command = Command::new("bash"); + command + .arg("-c") + .arg(request.command) + .current_dir(cwd) + .envs(environment) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(unix)] + command.as_std_mut().process_group(0); + let mut accounting = ByteAccounting::default(); + let Ok(mut child) = command.spawn() else { + return accounting.terminal(CommandStatus::Failed, None); + }; + let mut process_group = CommandProcessGroupGuard::new(child.id()); + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let (status, exit_code) = { + let output = pump( + stdout, + stderr, + sender, + telemetry, + command_id, + request.transport_byte_budget, + &mut accounting, + ); + tokio::pin!(output); + let mut drained = false; + let timeout = tokio::time::sleep(Duration::from_secs(request.timeout_secs.max(1))); + tokio::pin!(timeout); + let terminal = loop { + tokio::select! { + result = &mut output, if !drained => { + drained = true; + if result.is_err() || sender.is_closed() { + process_group.terminate(); + let _ = child.start_kill(); + let _ = child.wait().await; + break (CommandStatus::Failed, None); + } + } + exit = child.wait() => { + process_group.terminate(); + break match exit { + Ok(exit) => (if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed }, exit.code()), + Err(_) => (CommandStatus::Failed, None), + }; + } + _ = &mut timeout => { + process_group.terminate(); + let _ = child.start_kill(); + let code = child.wait().await.ok().and_then(|exit| exit.code()); + break (CommandStatus::TimedOut, code); + } + _ = sender.closed() => { + process_group.terminate(); + let _ = child.start_kill(); + let _ = child.wait().await; + break (CommandStatus::Cancelled, None); + } + changed = cancel.changed() => { + if changed.is_err() || *cancel.borrow() { + process_group.terminate(); + let _ = child.start_kill(); + let code = child.wait().await.ok().and_then(|exit| exit.code()); + break (CommandStatus::Cancelled, code); + } + } + } + }; + // Even after kill, preserve the bounded pipe tail before publishing terminal. + if !drained { + let _ = output.await; + } + terminal + }; + accounting.terminal(status, exit_code) +} + +#[cfg(test)] +mod tests; diff --git a/crates/workdir/src/local/command_transport/tests.rs b/crates/workdir/src/local/command_transport/tests.rs new file mode 100644 index 000000000..796a1f93f --- /dev/null +++ b/crates/workdir/src/local/command_transport/tests.rs @@ -0,0 +1,490 @@ +//! Process/pipe integration through the production local provider. Temporary roots +//! and provider tasks are RAII-owned; byte pulls and bounded timeouts synchronize tests. + +use super::*; +use tempfile::TempDir; + +struct Harness { + session: LocalWorkdirSession, + _root: TempDir, +} + +impl Harness { + fn new() -> Self { + let root = TempDir::new().unwrap(); + let session = LocalWorkdirSession::new( + Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + ); + Self { + session, + _root: root, + } + } + + async fn start(&self, command: &str, timeout_secs: u64) -> CommandHandle { + self.start_budget(command, timeout_secs, crate::MAX_COMMAND_TRANSPORT_BYTES) + .await + } + + fn request(command: &str, timeout_secs: u64, budget: u64) -> CommandRequest { + CommandRequest { + transport_byte_budget: budget, + command: command.into(), + timeout_secs, + cwd: WorkdirPath::root(), + tool_call_id: None, + } + } + + async fn start_budget(&self, command: &str, timeout_secs: u64, budget: u64) -> CommandHandle { + self.session + .start_command(Self::request(command, timeout_secs, budget)) + .await + .unwrap() + } + + async fn page(&self, handle: &CommandHandle, cursor: u64, limit: usize) -> CommandOutput { + tokio::time::timeout( + Duration::from_secs(10), + self.session.command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit, + wait: true, + }), + ) + .await + .expect("bounded command pull") + .unwrap() + } + + async fn collect( + &self, + handle: &CommandHandle, + mut cursor: u64, + ) -> (Vec, crate::CommandTerminal) { + let mut chunks = Vec::new(); + loop { + let page = self + .page(handle, cursor, crate::MAX_COMMAND_READ_BYTES) + .await; + assert!( + page.chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + <= crate::MAX_COMMAND_READ_BYTES + ); + cursor = page.next_cursor; + chunks.extend(page.chunks); + assert!(chunks.iter().map(|chunk| chunk.bytes.len()).sum::() <= 8 * 1024 * 1024); + if let Some(terminal) = page.terminal { + return (chunks, terminal); + } + } + } +} + +fn stream_bytes(chunks: &[crate::CommandByteChunk], stream: CommandStream) -> Vec { + let mut bytes = Vec::new(); + for chunk in chunks.iter().filter(|chunk| chunk.stream == stream) { + assert_eq!(chunk.offset, bytes.len() as u64); + bytes.extend_from_slice(&chunk.bytes); + } + bytes +} + +#[tokio::test] +async fn high_single_line_output_survives_absent_and_lagged_console_subscribers() { + for lagged in [false, true] { + let harness = Harness::new(); + let _lagged_receiver = lagged.then(|| harness.session.subscribe_command_events().unwrap()); + let handle = harness.start("head -c 3145728 /dev/zero | tr '\\0' x; head -c 65536 /dev/zero | tr '\\0' e >&2; exit 7", 20).await; + let (chunks, terminal) = harness.collect(&handle, 0).await; + assert_eq!(terminal.status, CommandStatus::Failed); + assert_eq!(terminal.exit_code, Some(7)); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stdout), + vec![b'x'; 3145728] + ); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stderr), + vec![b'e'; 65536] + ); + let mut cursor = 0; + for chunk in chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + } + harness.session.close().await.unwrap(); + } +} + +#[tokio::test] +async fn one_byte_pages_preserve_raw_utf8_invalid_bytes_and_replay_until_acknowledged() { + let harness = Harness::new(); + let handle = harness + .start( + "printf '\\342\\202\\254\\377\\000'; printf '\\346\\227\\245' >&2", + 5, + ) + .await; + let mut chunks = Vec::new(); + let mut cursor = 0; + loop { + let page = harness.page(&handle, cursor, 1).await; + assert_eq!(harness.page(&handle, cursor, 1).await, page); + cursor = page.next_cursor; + chunks.extend(page.chunks); + if let Some(terminal) = page.terminal { + assert_eq!(terminal.status, CommandStatus::Completed); + break; + } + } + assert_eq!( + stream_bytes(&chunks, CommandStream::Stdout), + b"\xe2\x82\xac\xff\0" + ); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stderr), + b"\xe6\x97\xa5" + ); + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 1, + wait: false + }) + .await + .is_err() + ); +} + +#[tokio::test] +async fn cancellation_while_backpressured_drains_bytes_before_terminal_fence() { + let harness = Harness::new(); + let handle = harness.start("printf ready; exec yes x", 30).await; + let first = harness.page(&handle, 0, 1).await; + harness + .session + .cancel_command(handle.clone()) + .await + .unwrap(); + let (mut chunks, terminal) = harness.collect(&handle, first.next_cursor).await; + let mut all = first.chunks; + all.append(&mut chunks); + assert!(stream_bytes(&all, CommandStream::Stdout).starts_with(b"ready")); + assert_eq!(terminal.status, CommandStatus::Cancelled); + let final_page = harness + .page( + &handle, + all.iter().map(|chunk| chunk.bytes.len() as u64).sum(), + 1, + ) + .await; + assert!(final_page.chunks.is_empty()); + assert_eq!(final_page.terminal, Some(terminal)); +} + +#[tokio::test] +async fn timeout_and_session_close_finalize_without_an_output_subscriber() { + let harness = Harness::new(); + let handle = harness + .start("printf ready; exec tail -f /dev/null", 1) + .await; + let (chunks, terminal) = harness.collect(&handle, 0).await; + assert_eq!(stream_bytes(&chunks, CommandStream::Stdout), b"ready"); + assert_eq!(terminal.status, CommandStatus::TimedOut); + let handle = harness.start("exec yes x", 30).await; + // Receiving one byte establishes actual process output, without a timing sleep. + harness.page(&handle, 0, 1).await; + tokio::time::timeout(Duration::from_secs(5), harness.session.close()) + .await + .unwrap() + .unwrap(); + assert!(harness.session.command_status(handle).await.is_err()); +} + +#[tokio::test] +async fn interleaved_streams_keep_independent_offsets_and_bounded_read_validation() { + let harness = Harness::new(); + let handle = harness + .start( + "for ((i=0;i<100;i++)); do printf 'out%s;' \"$i\"; printf 'err%s;' \"$i\" >&2; done", + 5, + ) + .await; + for limit in [0, crate::MAX_COMMAND_READ_BYTES + 1] { + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: 0, + limit, + wait: false + }) + .await + .is_err() + ); + } + let (chunks, terminal) = harness.collect(&handle, 0).await; + let expected = |prefix| { + (0..100) + .map(|i| format!("{prefix}{i};")) + .collect::() + .into_bytes() + }; + assert_eq!( + stream_bytes(&chunks, CommandStream::Stdout), + expected("out") + ); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stderr), + expected("err") + ); + assert_eq!(terminal.status, CommandStatus::Completed); +} + +#[tokio::test] +async fn byte_budget_zero_exact_partial_and_shared_keep_seen_and_delivered_distinct() { + let harness = Harness::new(); + for (command, budget, stdout_seen, stderr_seen, exit_code) in [ + ("printf abc; printf defg >&2", 0, 3, 4, 0), + ("printf abc", 3, 3, 0, 0), + ("printf abcdefg", 3, 7, 0, 0), + ("printf abcdefg; exit 7", 3, 7, 0, 7), + ( + "head -c 32768 /dev/zero; head -c 32768 /dev/zero >&2", + 12345, + 32768, + 32768, + 0, + ), + ] { + let handle = harness.start_budget(command, 5, budget).await; + let (chunks, terminal) = harness.collect(&handle, 0).await; + let stdout = stream_bytes(&chunks, CommandStream::Stdout); + let stderr = stream_bytes(&chunks, CommandStream::Stderr); + let mut cursor = 0; + for chunk in &chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + } + assert_eq!(cursor, budget.min(stdout_seen + stderr_seen)); + assert_eq!(terminal.stdout_bytes, stdout_seen); + assert_eq!(terminal.stderr_bytes, stderr_seen); + assert_eq!(terminal.stdout_delivered_bytes, stdout.len() as u64); + assert_eq!(terminal.stderr_delivered_bytes, stderr.len() as u64); + assert_eq!(terminal.exit_code, Some(exit_code)); + assert_eq!( + terminal.status, + if exit_code == 0 { + CommandStatus::Completed + } else { + CommandStatus::Failed + } + ); + assert_eq!( + terminal.truncation_reason, + (cursor < stdout_seen + stderr_seen) + .then_some(crate::CommandOutputTruncation::ByteBudget) + ); + } +} + +#[tokio::test] +async fn exhausted_budget_finishes_without_receiver_and_keeps_timeout_cancel_distinct() { + let harness = Harness::new(); + for budget in [0, 1, (QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64] { + let handle = harness + .start_budget("head -c 1048576 /dev/zero; printf error >&2", 5, budget) + .await; + let mut status = harness.session.inner.commands.lock().await[&handle.0] + .status + .clone(); + let terminal = tokio::time::timeout( + Duration::from_secs(5), + status.wait_for(|value| value.is_some()), + ) + .await + .unwrap() + .unwrap() + .clone() + .unwrap(); + assert_eq!(terminal.status, CommandStatus::Completed); + assert_eq!(terminal.stdout_bytes, 1048576); + assert_eq!(terminal.stderr_bytes, 5); + assert_eq!( + terminal.stdout_delivered_bytes + terminal.stderr_delivered_bytes, + budget + ); + // Full capture queue cannot retain the process task or its resources. + { + let mut commands = harness.session.inner.commands.lock().await; + tokio::time::timeout( + Duration::from_secs(5), + &mut commands.get_mut(&handle.0).unwrap().task, + ) + .await + .unwrap() + .unwrap(); + } + // Only now acknowledge the captured queue and terminal fence. + let (_, delivered_terminal) = harness.collect(&handle, 0).await; + assert_eq!(delivered_terminal, terminal); + } + for (cancel, expected) in [ + (false, CommandStatus::TimedOut), + (true, CommandStatus::Cancelled), + ] { + let handle = harness + .start_budget( + "printf ready-extra; head -c 32768 /dev/zero; exec tail -f /dev/null", + 1, + 5, + ) + .await; + let first = harness.page(&handle, 0, 5).await; + assert_eq!(first.next_cursor, 5); + if cancel { + harness + .session + .cancel_command(handle.clone()) + .await + .unwrap(); + } + let (rest, terminal) = harness.collect(&handle, first.next_cursor).await; + assert!(rest.is_empty()); + assert_eq!(terminal.status, expected); + assert_eq!(terminal.stdout_delivered_bytes, 5); + assert!(terminal.stdout_bytes >= 11); + assert_eq!( + terminal.truncation_reason, + Some(crate::CommandOutputTruncation::ByteBudget) + ); + if !cancel { + assert_eq!(terminal.stdout_bytes, 32779); + assert_eq!( + terminal.truncation_reason, + Some(crate::CommandOutputTruncation::ByteBudget) + ); + } + } +} + +#[tokio::test] +async fn budget_replay_preserves_unique_bytes_and_rejects_future_and_old_cursors() { + let harness = Harness::new(); + let handle = harness.start_budget("printf abcdefghij", 5, 7).await; + let first = harness.page(&handle, 0, 2).await; + for _ in 0..3 { + assert_eq!(harness.page(&handle, 0, 2).await, first); + } + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: 8, + limit: 2, + wait: false, + }) + .await + .is_err() + ); + let (rest, terminal) = harness.collect(&handle, first.next_cursor).await; + let mut all = first.chunks; + all.extend(rest); + assert_eq!(stream_bytes(&all, CommandStream::Stdout), b"abcdefg"); + assert_eq!(terminal.stdout_bytes, 10); + assert_eq!(terminal.stdout_delivered_bytes, 7); + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 2, + wait: false, + }) + .await + .is_err() + ); +} + +#[tokio::test] +async fn invalid_budget_is_rejected_before_process_start() { + let harness = Harness::new(); + assert!(matches!( + harness + .session + .start_command(Harness::request( + "touch must-not-exist", + 5, + crate::MAX_COMMAND_TRANSPORT_BYTES + 1, + )) + .await, + Err(WorkdirError::InvalidArgument(_)) + )); + assert!(!harness._root.path().join("must-not-exist").exists()); + assert!(harness.session.inner.commands.lock().await.is_empty()); +} + +#[tokio::test] +async fn slow_receiver_backpressures_before_quota_but_full_queue_does_not_block_discard() { + use std::future::Future; + use std::task::Poll; + let telemetry = CommandTelemetry::new(); + let input = vec![b'x'; 1024 * 1024]; + for budget in [ + crate::MAX_COMMAND_TRANSPORT_BYTES, + (QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64, + ] { + let (sender, mut receiver) = mpsc::channel(QUEUE_CHUNKS); + let mut accounting = ByteAccounting::default(); + { + let output = pump( + input.as_slice(), + &b""[..], + &sender, + &telemetry, + "test", + budget, + &mut accounting, + ); + tokio::pin!(output); + let polled = std::future::poll_fn(|cx| Poll::Ready(output.as_mut().poll(cx))).await; + if budget == crate::MAX_COMMAND_TRANSPORT_BYTES { + assert!(polled.is_pending(), "a slow receiver must stop pipe reads"); + } else { + assert!( + matches!(polled, Poll::Ready(Ok(()))), + "discard must not reserve queue capacity" + ); + } + } + assert_eq!(receiver.len(), QUEUE_CHUNKS); + assert_eq!( + accounting.stdout_delivered, + (QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64 + ); + assert_eq!( + accounting.stdout, + if budget == crate::MAX_COMMAND_TRANSPORT_BYTES { + accounting.stdout_delivered + } else { + input.len() as u64 + } + ); + let mut queued_bytes = 0; + while let Ok(Message::Bytes(chunk)) = receiver.try_recv() { + queued_bytes += chunk.bytes.len(); + } + assert_eq!(queued_bytes, QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES); + } +} diff --git a/crates/workdir/src/operation.rs b/crates/workdir/src/operation.rs index 3c2eb2318..7e180e7aa 100644 --- a/crates/workdir/src/operation.rs +++ b/crates/workdir/src/operation.rs @@ -1,5 +1,3 @@ -use std::path::PathBuf; - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,18 +6,18 @@ use serde::{Deserialize, Serialize}; pub struct CommandHandle(pub String); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct CommandRequest { pub command: String, #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub timeout_secs: u64, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] - pub output_limit: usize, + /// Aggregate unique-byte capture allowance, independent of consumer storage. + /// Providers drain and count excess bytes without delivering their payload. + #[schemars(range(min = 0, max = 67_108_864_u64))] + pub transport_byte_budget: u64, /// Workdir-relative command directory. Providers validate it against the /// active session before process start. pub cwd: fs_operation::FsPath, - /// Provider-local directory where complete output is retained when the - /// inline result exceeds `output_limit`. - pub spill_dir: Option, /// Optional caller-owned correlation id. Bash supplies its tool-call id so /// user-facing command telemetry can update the corresponding Console row /// without exposing provider/session handles. @@ -27,12 +25,23 @@ pub struct CommandRequest { pub tool_call_id: Option, } +impl CommandRequest { + pub fn validate(&self) -> Result<(), crate::WorkdirError> { + if self.transport_byte_budget > MAX_COMMAND_TRANSPORT_BYTES { + return Err(crate::WorkdirError::InvalidArgument( + "command transport byte budget exceeds provider maximum".into(), + )); + } + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct CommandOutputRequest { pub handle: CommandHandle, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] - pub cursor: usize, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + #[schemars(range(min = 0, max = 67_108_864_u64))] + pub cursor: u64, + #[schemars(range(min = 1, max = 65_536_usize))] pub limit: usize, pub wait: bool, } @@ -107,16 +116,150 @@ pub enum CommandEvent { }, } +/// Sequence is the merged transport byte position. Offset addresses raw bytes +/// within the named stream; neither ordering nor offsets depend on UTF-8 decoding. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct CommandOutput { +pub struct CommandByteChunk { + pub sequence: u64, + pub stream: CommandStream, + pub offset: u64, + pub bytes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommandOutputTruncation { + ByteBudget, +} + +/// Raw totals include discarded pipe bytes. Delivered totals count only prefixes +/// admitted to the transport queue, not page reads or retries by a consumer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct CommandTerminal { pub status: CommandStatus, - #[schemars(range(min = -2147483648_i32, max = 2147483647_i32))] pub exit_code: Option, - pub timed_out: bool, - pub content: String, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] - pub next_cursor: Option, - pub truncated: bool, - /// Complete output retained by the provider when `truncated` is true. - pub output_path: Option, + pub stdout_bytes: u64, + pub stderr_bytes: u64, + pub stdout_delivered_bytes: u64, + pub stderr_delivered_bytes: u64, + pub truncation_reason: Option, +} + +/// Sending next_cursor acknowledges this page. Only the current page may be +/// retried; acknowledged bytes expire. Terminal fences all captured bytes, +/// including on timeout/cancel. Consumers must pull while the process runs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CommandOutput { + pub chunks: Vec, + pub next_cursor: u64, + pub terminal: Option, +} + +impl CommandOutput { + pub(crate) fn transport_bounds_valid(&self) -> bool { + let mut end = None; + let mut stream_ends = [None, None]; + for chunk in &self.chunks { + let stream_index = match chunk.stream { + CommandStream::Stdout => 0, + CommandStream::Stderr => 1, + }; + if chunk.bytes.is_empty() + || chunk.offset > chunk.sequence + || stream_ends[stream_index].is_some_and(|end| chunk.offset != end) + || end.is_some_and(|end| chunk.sequence != end) + || chunk + .sequence + .checked_add(chunk.bytes.len() as u64) + .is_none_or(|end| end > MAX_COMMAND_TRANSPORT_BYTES) + || chunk + .offset + .checked_add(chunk.bytes.len() as u64) + .is_none_or(|end| end > MAX_COMMAND_TRANSPORT_BYTES) + { + return false; + } + end = chunk.sequence.checked_add(chunk.bytes.len() as u64); + stream_ends[stream_index] = chunk.offset.checked_add(chunk.bytes.len() as u64); + } + self.next_cursor <= MAX_COMMAND_TRANSPORT_BYTES + && end.is_none_or(|end| end == self.next_cursor) + && self.terminal.as_ref().is_none_or(|terminal| { + let truncated = terminal.stdout_bytes > terminal.stdout_delivered_bytes + || terminal.stderr_bytes > terminal.stderr_delivered_bytes; + stream_ends[0].is_none_or(|end| end == terminal.stdout_delivered_bytes) + && stream_ends[1].is_none_or(|end| end == terminal.stderr_delivered_bytes) + && terminal.status != CommandStatus::Running + && terminal.stdout_delivered_bytes <= terminal.stdout_bytes + && terminal.stderr_delivered_bytes <= terminal.stderr_bytes + && terminal + .stdout_delivered_bytes + .checked_add(terminal.stderr_delivered_bytes) + == Some(self.next_cursor) + && terminal.truncation_reason + == truncated.then_some(CommandOutputTruncation::ByteBudget) + }) + } +} + +pub const MAX_COMMAND_TRANSPORT_BYTES: u64 = 64 * 1024 * 1024; + +pub const MAX_COMMAND_READ_BYTES: usize = 64 * 1024; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_wire_rejects_legacy_output_host_paths() { + let command = serde_json::json!({ + "command": "printf bytes", "timeout_secs": 1, "cwd": "", "transport_byte_budget": 0 + }); + assert!(serde_json::from_value::(command.clone()).is_ok()); + for field in ["spill_dir", "output_limit"] { + let mut legacy = command.clone(); + legacy[field] = serde_json::json!("/host/private"); + assert!(serde_json::from_value::(legacy).is_err()); + } + let output = serde_json::json!({"chunks": [], "next_cursor": 0, "terminal": null, "output_path": "/host/private"}); + assert!(serde_json::from_value::(output).is_err()); + } + + #[test] + fn command_budget_wire_requires_field_and_checks_provider_hard_bound() { + let wire = serde_json::json!({"command": "true", "timeout_secs": 1, "cwd": ""}); + assert!(serde_json::from_value::(wire.clone()).is_err()); + for budget in [ + 0, + MAX_COMMAND_TRANSPORT_BYTES, + MAX_COMMAND_TRANSPORT_BYTES + 1, + ] { + let mut value = wire.clone(); + value["transport_byte_budget"] = serde_json::json!(budget); + let request: CommandRequest = serde_json::from_value(value).unwrap(); + assert_eq!( + request.validate().is_ok(), + budget <= MAX_COMMAND_TRANSPORT_BYTES + ); + } + let mut negative = wire; + negative["transport_byte_budget"] = serde_json::json!(-1); + assert!(serde_json::from_value::(negative).is_err()); + assert_eq!( + serde_json::to_value(CommandOutputTruncation::ByteBudget).unwrap(), + "byte_budget" + ); + let schema = serde_json::to_value(schemars::schema_for!(CommandRequest)).unwrap(); + assert_eq!( + schema["properties"]["transport_byte_budget"]["maximum"], + MAX_COMMAND_TRANSPORT_BYTES + ); + assert!( + schema["required"] + .as_array() + .unwrap() + .contains(&serde_json::json!("transport_byte_budget")) + ); + } } diff --git a/crates/workdir/src/router.rs b/crates/workdir/src/router.rs index 9eb190121..362504eab 100644 --- a/crates/workdir/src/router.rs +++ b/crates/workdir/src/router.rs @@ -341,7 +341,9 @@ impl WorkdirSession for RoutedWorkdirSession { let _active = self.state.enter()?; let handle = request.handle.clone(); let output = self.state.session.command_output(request).await?; - self.state.mark_terminal(&handle, output.status); + if let Some(terminal) = &output.terminal { + self.state.mark_terminal(&handle, terminal.status); + } Ok(output) } @@ -789,11 +791,12 @@ mod tests { .unwrap(); let handle = routed .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf done".to_string(), timeout_secs: 5, - output_limit: 1024, + cwd: crate::WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await @@ -823,11 +826,12 @@ mod tests { let routed = router.attach(alias, Arc::clone(&underlying)).unwrap(); let handle = routed .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 1024, + cwd: crate::WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await @@ -850,11 +854,12 @@ mod tests { let routed = router.attach(alias.clone(), session("wd", &dir)).unwrap(); let handle = routed .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 1024, + cwd: crate::WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index 6fd71cfd3..9a54e818c 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -10,6 +10,9 @@ //! provider-side before name enumeration, descent, or content reads. Returned coordinates are not //! prefixed with cwd again. +#[cfg(test)] +use crate::command_test_support::CollectCommand as _; + use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -124,6 +127,7 @@ impl WorkdirToolBroker { next_lease_id: AtomicU64::new(1), close_lock: Arc::new(tokio::sync::Mutex::new(())), owned_commands: Arc::new(Mutex::new(HashSet::new())), + command_reads: Mutex::new(HashMap::new()), pending_command_events: Arc::new(Mutex::new(HashMap::new())), starting_tool_calls: Arc::new(Mutex::new(HashSet::new())), forwarded_starts: Arc::new(Mutex::new(HashSet::new())), @@ -230,25 +234,48 @@ impl WorkdirScopeLease { .source .cancel_command(handle.clone()) .await; - let terminal = self + let read_cursor = self .broker .authority - .source - .command_output(CommandOutputRequest { - handle, - cursor: 0, - limit: 1, - wait: true, - }) - .await; + .command_reads + .lock() + .expect("command cursor mutex poisoned") + .get(&command_id) + .cloned() + .expect("owned command cursor"); + let mut read_cursor = read_cursor.lock().await; + let mut cursor = *read_cursor; + let terminal = loop { + let output = self + .broker + .authority + .source + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: crate::MAX_COMMAND_READ_BYTES, + wait: true, + }) + .await; + match output { + Ok(output) => { + cursor = output.next_cursor; + *read_cursor = cursor; + if let Some(terminal) = output.terminal { + break Ok(terminal); + } + } + Err(error) => break Err(error), + } + }; match (cancel, terminal) { (_, Ok(output)) => { self.broker.authority.publish_terminal_if_missing( &command_id, output.status, output.exit_code, - output.next_cursor.unwrap_or(output.content.len()) as u64, - &output.content, + cursor, + "", ); self.broker .authority @@ -582,7 +609,12 @@ struct ScopedWorkdirSession { scope_lock: tokio::sync::Mutex<()>, next_lease_id: AtomicU64, close_lock: Arc>, + // Live cleanup responsibility ends at terminal delivery. Read ownership is + // retained separately so a lost terminal response can still be replayed. owned_commands: Arc>>, + // Starting commands prunes completed entries at the bounded replay ceiling; + // current scope validity is still checked before every read or retry. + command_reads: Mutex>>>, pending_command_events: Arc>>>, starting_tool_calls: Arc>>, forwarded_starts: Arc>>, @@ -700,6 +732,11 @@ impl ScopedWorkdirSession { .lock() .expect("scoped command set mutex poisoned") .contains(&handle.0) + || self + .command_reads + .lock() + .expect("command cursor mutex poisoned") + .contains_key(&handle.0) { Ok(()) } else { @@ -1013,6 +1050,7 @@ impl ScopedWorkdirSession { next_lease_id: AtomicU64::new(1), close_lock: close_lock.clone(), owned_commands, + command_reads: Mutex::new(HashMap::new()), pending_command_events, starting_tool_calls, forwarded_starts, @@ -1222,6 +1260,9 @@ impl WorkdirSession for ScopedWorkdirSession { .intersection(WorkdirSessionCapabilities::READ_ONLY); } if let crate::CheckoutOutput::Read(read) = &mut result.output { + read.path = result_path.clone(); + } + if let crate::CheckoutOutput::ReadBytes(read) = &mut result.output { read.path = result_path; } Ok(result) @@ -1407,6 +1448,16 @@ impl WorkdirSession for ScopedWorkdirSession { .lock() .expect("scoped command set mutex poisoned"); owned.insert(handle.0.clone()); + { + let mut reads = self + .command_reads + .lock() + .expect("command cursor mutex poisoned"); + if reads.len() >= 64 { + reads.retain(|id, _| owned.contains(id)); + } + reads.insert(handle.0.clone(), Arc::new(tokio::sync::Mutex::new(0))); + } if let Some(tool_call_id) = &tool_call_id { self.starting_tool_calls .lock() @@ -1457,14 +1508,28 @@ impl WorkdirSession for ScopedWorkdirSession { ) -> Result { self.ensure_owned_command(&request.handle)?; let command_id = request.handle.0.clone(); + let read_cursor = self + .command_reads + .lock() + .expect("command cursor mutex poisoned") + .get(&command_id) + .cloned(); + let mut read_cursor = match &read_cursor { + Some(cursor) => Some(cursor.lock().await), + None => None, + }; + self.ensure_owned_command(&request.handle)?; let output = self.source.command_output(request).await?; - if !matches!(output.status, CommandStatus::Running) { + if let Some(cursor) = &mut read_cursor { + **cursor = output.next_cursor; + } + if let Some(terminal) = &output.terminal { self.publish_terminal_if_missing( &command_id, - output.status, - output.exit_code, - output.next_cursor.unwrap_or(output.content.len()) as u64, - &output.content, + terminal.status, + terminal.exit_code, + output.next_cursor, + "", ); self.owned_commands .lock() @@ -2080,20 +2145,21 @@ mod tests { session: &WorkdirSessionHandle, command: impl Into, tool_call_id: impl Into, - ) -> CommandOutput { + ) -> crate::command_test_support::CollectedCommand { let handle = session .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: command.into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some(tool_call_id.into()), }) .await .unwrap(); session - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 1024, @@ -2112,11 +2178,12 @@ mod tests { .expect("delegation wrapper must preserve command observation"); let handle = parent .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf ready; sleep 0.2; printf done".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("tool-delegated".into()), }) .await @@ -2139,7 +2206,7 @@ mod tests { assert_eq!(snapshots[0].stdout.content, "ready"); let output = parent - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 1024, @@ -2179,11 +2246,12 @@ mod tests { ); let error = child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "pwd".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: None, }) .await @@ -2211,11 +2279,12 @@ mod tests { let handle = first .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "pwd; sleep 0.2".into(), timeout_secs: 5, - output_limit: 4096, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("first-command".into()), }) .await @@ -2239,7 +2308,7 @@ mod tests { )); let output = first - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 4096, @@ -2316,11 +2385,12 @@ mod tests { assert!(matches!( child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf denied".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("read-only-command".into()), }) .await, @@ -2622,11 +2692,12 @@ mod tests { assert!(matches!( child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf revoked".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("revoked-child-command".into()), }) .await, @@ -2724,11 +2795,12 @@ mod tests { for index in 0..MAX_SCOPED_COMMANDS { child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some(format!("command-{index}")), }) .await @@ -2737,11 +2809,12 @@ mod tests { let error = child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("command-over-limit".into()), }) .await @@ -2771,11 +2844,12 @@ mod tests { let command = tokio::spawn(async move { command_child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("racing-command".into()), }) .await @@ -2817,11 +2891,12 @@ mod tests { let mut events = child.subscribe_command_events().unwrap(); let handle = child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30; printf leaked > marker".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("owned-command".into()), }) .await @@ -3012,11 +3087,12 @@ mod tests { assert!(matches!( parent .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf closed".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("closed-parent-command".into()), }) .await, @@ -3028,4 +3104,180 @@ mod tests { "child result after parent close: {child_result:?}" ); } + #[tokio::test] + async fn scoped_byte_cursor_survives_partial_ack_then_revocation_and_terminal_retry() { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join("child")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("child", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + let tool = child.tool_session(); + let handle = tool + .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf ready; exec yes x".into(), + timeout_secs: 10, + cwd: FsPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + for _ in 0..2 { + let page = tool + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 1, + wait: true, + }) + .await + .unwrap(); + cursor = page.next_cursor; + } + tokio::time::timeout(std::time::Duration::from_secs(5), child.close()) + .await + .unwrap() + .unwrap(); + assert!( + tool.command_output(CommandOutputRequest { + handle, + cursor, + limit: 1, + wait: false + }) + .await + .is_err() + ); + let handle = parent + .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf parent".into(), + timeout_secs: 5, + cwd: FsPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + loop { + let request = CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 1024, + wait: true, + }; + let page = parent.command_output(request.clone()).await.unwrap(); + assert_eq!(parent.command_output(request).await.unwrap(), page); + cursor = page.next_cursor; + if page.terminal.is_some() { + break; + } + } + } + #[tokio::test] + async fn scoped_terminal_response_loss_replays_without_duplicate_capture_and_rejects_invalid_cursors() + { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join("child")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("child", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + let tool = child.tool_session(); + let handle = tool + .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf abcdef".into(), + timeout_secs: 5, + cwd: FsPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + let mut captured = Vec::new(); + let terminal_request = loop { + let request = CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 2, + wait: true, + }; + // Simulate losing each produced HTTP response, including terminal: + // append only the retried page, never both copies. + let lost = tool.command_output(request.clone()).await.unwrap(); + let replay = tool.command_output(request.clone()).await.unwrap(); + assert_eq!(replay, lost); + for chunk in &replay.chunks { + captured.extend_from_slice(&chunk.bytes); + } + cursor = replay.next_cursor; + if let Some(terminal) = replay.terminal { + assert_eq!(terminal.status, CommandStatus::Completed); + break request; + } + }; + assert_eq!(captured, b"abcdef"); + assert!( + !child + .broker + .authority + .owned_commands + .lock() + .unwrap() + .contains(&handle.0) + ); + assert!( + child + .broker + .authority + .command_reads + .lock() + .unwrap() + .contains_key(&handle.0) + ); + assert_eq!( + tool.command_status(handle.clone()).await.unwrap(), + CommandStatus::Completed + ); + for invalid in [0, cursor + 1] { + assert!( + tool.command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: invalid, + limit: 2, + wait: false + }) + .await + .is_err() + ); + } + // Invalid cursor requests do not destroy the retained terminal page. + assert!( + tool.command_output(terminal_request) + .await + .unwrap() + .terminal + .is_some() + ); + child.close().await.unwrap(); + assert!( + tool.command_output(CommandOutputRequest { + handle, + cursor, + limit: 2, + wait: false + }) + .await + .is_err() + ); + }) + .await + .expect("bounded scoped terminal replay"); + } } diff --git a/crates/workdir/tests/command_denial_diagnostics.rs b/crates/workdir/tests/command_denial_diagnostics.rs index cfc4c5231..8db54686d 100644 --- a/crates/workdir/tests/command_denial_diagnostics.rs +++ b/crates/workdir/tests/command_denial_diagnostics.rs @@ -103,11 +103,12 @@ impl WorkdirSession for CommandFailureSession { } fn command() -> CommandRequest { CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, command: "fake command, never executed".into(), timeout_secs: 1, - output_limit: 100, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, } } diff --git a/crates/workdir/tests/denial_reasons.rs b/crates/workdir/tests/denial_reasons.rs index 53c64d5dd..41b031f7a 100644 --- a/crates/workdir/tests/denial_reasons.rs +++ b/crates/workdir/tests/denial_reasons.rs @@ -167,11 +167,12 @@ async fn read_only_and_scoped_capability_refusals_do_not_collapse() { assert_reason( child .start_command(workdir::CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, command: "must-not-run".into(), timeout_secs: 1, - output_limit: 1, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index cbc0ad393..0eba6a589 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -56,6 +56,7 @@ server-api = { path = "../server-api" } workdir.workspace = true [dev-dependencies] +workdir = { workspace = true, features = ["http-client"] } futures.workspace = true agen.workspace = true serial_test = "3.4.0" diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index 5b9c35b85..de799b323 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -657,18 +657,32 @@ fn plan_runtime_store_migration( )); } if current_schema_version == SCHEMA_VERSION { + // Runtime and Session schemas evolve independently. Probe only through + // Session Store's non-mutating current-schema admission boundary; any + // conversion still runs on the existing owner-locked staging tree. + let migration_required = crate::retention::sessions_require_migration(root)?; + let excluded_ephemeral_paths = if migration_required { + runtime_tree_exclusions(root)? + } else { + Vec::new() + }; + let archived_orphan_worker_count = if migration_required { + orphaned_worker_directories(root)?.len() + } else { + 0 + }; let plan = FsRuntimeStoreMigrationPlan { current_schema_version, target_schema_version: SCHEMA_VERSION, - migration_required: false, + migration_required, worker_count: 0, - archived_orphan_worker_count: 0, + archived_orphan_worker_count, migrated_worker_aggregate_count: 0, migrated_diagnostic_worker_ref_count: 0, cleared_diagnostic_worker_ref_count: 0, mapping_digest: legacy_worker_identity_mapping_digest(&[]), mappings: Vec::new(), - excluded_ephemeral_paths: Vec::new(), + excluded_ephemeral_paths, }; return Ok((plan, Vec::new())); } @@ -1808,7 +1822,9 @@ fn migrate_runtime_store( Ok(plan) } -fn archive_orphaned_worker_directories(root: &Path) -> Result { +fn orphaned_worker_directories( + root: &Path, +) -> Result, RuntimeError> { let workers_dir = root.join(WORKERS_DIR); let mut orphaned = fs::read_dir(&workers_dir) .map_err(|source| runtime_io_error("read Worker records", &workers_dir, source))? @@ -1821,6 +1837,11 @@ fn archive_orphaned_worker_directories(root: &Path) -> Result>(); orphaned.sort_by(|(left, _), (right, _)| left.cmp(right)); + Ok(orphaned) +} + +fn archive_orphaned_worker_directories(root: &Path) -> Result { + let orphaned = orphaned_worker_directories(root)?; if orphaned.is_empty() { return Ok(0); } @@ -1855,6 +1876,21 @@ fn migrate_runtime_store_in_place( if !plan.migration_required { return Ok(plan); } + if plan.current_schema_version == SCHEMA_VERSION { + // Session-only migration: do not reserialize Runtime/Worker identity, + // Restore guards, recovery fences, or retained execution receipts. + // As in a Runtime schema migration, retain orphan trees outside the + // Worker catalog before validating the completed staging tree. + let archived = archive_orphaned_worker_directories(root)?; + if archived != plan.archived_orphan_worker_count { + return Err(runtime_store_corrupt( + root, + "orphan count changed during Session migration".into(), + )); + } + crate::retention::migrate_staged_sessions(root)?; + return Ok(plan); + } let runtime_path = root.join(RUNTIME_FILE); let bytes = fs::read(&runtime_path).map_err(|error| runtime_io_error("read", &runtime_path, error))?; @@ -1948,11 +1984,9 @@ fn migrate_runtime_store_in_place( ), )); } - // Schema 11 already migrated Sessions; the recovery-fence bump must not - // rewrite retained history or archive attestations. - if plan.current_schema_version != SOURCE_SCHEMA_VERSION { - crate::retention::migrate_staged_sessions(root)?; - } + // A Runtime schema bump says nothing about the retained Session schema. + // Session Store owns versioned conversion, including v4 -> generic v5. + crate::retention::migrate_staged_sessions(root)?; let (document, _) = migrate_runtime_document( document, plan.current_schema_version, @@ -3011,9 +3045,8 @@ mod tests { } else { drop(result.unwrap()); assert_eq!(stored_schema_version(&root), u64::from(SCHEMA_VERSION)); - let manifest: serde_json::Value = - read_json(&session.join("session.json"), "test").unwrap(); - assert_eq!(manifest["schema_version"], 4); + session_store::WorkerSessionStore::open_read_only(&session) + .expect("startup migration must admit the current Session schema"); let once = fs::read(session.join("session.json")).unwrap(); drop(FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap()); assert_eq!(fs::read(session.join("session.json")).unwrap(), once); diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index a916a5f69..57b7d0eb6 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -67,7 +67,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::net::TcpListener; use workdir::{ - CommandOutput, CommandStatus, WorkdirSessionHandle, dispatch_workdir_session_operation, + CommandOutput, WorkdirSessionHandle, dispatch_workdir_session_operation, http::{ OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, WorkdirSessionId, WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirSessionOperationResult, @@ -914,13 +914,9 @@ async fn run_workdir_session_operation( { Ok(result) => result?, Err(_) => CommandOutput { - exit_code: None, - timed_out: false, - status: CommandStatus::Running, - content: String::new(), - next_cursor: Some(cursor), - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: cursor, + terminal: None, }, }; WorkdirSessionOperationResult::CommandOutput(output) @@ -2426,6 +2422,7 @@ pub enum RuntimeHttpServerError { #[cfg(test)] mod tests { + mod command_transport_tests; #[cfg(feature = "fs-store")] mod session_material_tests; diff --git a/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs b/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs new file mode 100644 index 000000000..643e722ab --- /dev/null +++ b/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs @@ -0,0 +1,408 @@ +//! Loopback integration: production RemoteWorkdirSession, signed HTTP middleware, +//! command operation handler, local process pipes, and JSON DTOs. Only opening the +//! pre-seeded session is a fixture; no Runtime Worker/materializer is claimed here. + +use super::*; +use std::time::Duration; +use workdir::http::{ + RemoteWorkdirSession, WorkdirHttpAuthorization, WorkdirHttpAuthorizationFuture, +}; +use workdir::{CommandOutputRequest, CommandRequest, CommandStream, WorkdirSession}; + +struct SignedAuthorization { + identity: RuntimeIdentityMaterial, + trust: WorkspaceIssuerTrustRecord, +} +impl std::fmt::Debug for SignedAuthorization { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignedAuthorization") + } +} +impl WorkdirHttpAuthorization for SignedAuthorization { + fn bearer_token( + &self, + method: String, + path: String, + body: Vec, + ) -> WorkdirHttpAuthorizationFuture { + let claims = signed_claims(&self.trust, &method, &path, &body); + let token = + issue_workspace_capability_token(&self.identity.signing_key().unwrap(), &claims) + .unwrap(); + Box::pin(async move { Ok(token) }) + } +} + +struct Harness { + server: tokio::task::JoinHandle<()>, + session: WorkdirSessionHandle, + _root: tempfile::TempDir, + remote: RemoteWorkdirSession, + client: reqwest::Client, + operation_url: url::Url, + authorization: Arc, +} +impl Drop for Harness { + fn drop(&mut self) { + self.server.abort(); + } +} +impl Harness { + async fn raw_operation(&self, operation: serde_json::Value) -> reqwest::Response { + let body = serde_json::to_vec(&serde_json::json!({"operation": operation})).unwrap(); + let token = self + .authorization + .bearer_token( + "POST".into(), + self.operation_url.path().into(), + body.clone(), + ) + .await + .unwrap(); + self.client + .post(self.operation_url.clone()) + .bearer_auth(token) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap() + } + + async fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound( + Workdir::new("wd-transport"), + root.path().to_path_buf(), + root.path().to_path_buf(), + SharedScope::new(Scope::writable(root.path()).unwrap()), + WorkdirSessionCapabilities::ALL, + )); + let identity = RuntimeIdentityMaterial::generate("transport-key").unwrap(); + let trust = workspace_trust(&identity); + let state = RuntimeHttpState { + runtime: Runtime::new_memory(), + local_token: None, + workspace_auth: Some(Arc::new(workspace_test_auth(vec![trust.clone()]))), + workdir_sessions: Arc::new(Mutex::new(HashMap::from([( + "session-transport".into(), + RuntimeHttpWorkdirSession { + owner: RuntimeWorkspaceScope::new("workspace-a", "https://backend.test"), + session: session.clone(), + }, + )]))), + ssh_keyscan_program: Arc::new(PathBuf::from("ssh-keyscan")), + }; + let app = Router::new() + .route( + "/v1/working-directories/{id}/sessions", + post(|| async { + Json(OpenWorkdirSessionResponse { + session_id: WorkdirSessionId::new("session-transport").unwrap(), + workdir_id: Workdir::new("wd-transport").id().clone(), + capabilities: WorkdirSessionCapabilities::ALL, + }) + }), + ) + .route( + "/v1/workdir-sessions/{session_id}/operations", + post(run_workdir_session_operation), + ) + .route( + "/v1/workdir-sessions/{session_id}", + delete(close_workdir_session), + ) + .with_state(state.clone()) + .layer(middleware::from_fn_with_state(state, require_runtime_auth)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base: url::Url = format!("http://{}", listener.local_addr().unwrap()) + .parse() + .unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + RUNTIME_WORKSPACE_SCOPE_HEADER, + "workspace-a".parse().unwrap(), + ); + let client = reqwest::Client::builder() + .no_proxy() + .default_headers(headers) + .build() + .unwrap(); + let authorization = Arc::new(SignedAuthorization { identity, trust }); + let operation_url = base + .join("/v1/workdir-sessions/session-transport/operations") + .unwrap(); + let remote = RemoteWorkdirSession::open_with_authorization( + client.clone(), + base, + authorization.clone(), + Workdir::new("wd-transport").id().clone(), + OpenWorkdirSessionRequest::default(), + ) + .await + .unwrap(); + Self { + server, + session, + _root: root, + remote, + client, + operation_url, + authorization, + } + } +} + +#[tokio::test] +async fn real_http_command_pages_preserve_binary_streams_replay_and_nonzero_exit() { + tokio::time::timeout(Duration::from_secs(20), async { + let harness = Harness::new().await; + let handle = harness.remote.start_command(CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, + command: "head -c 2097152 /dev/zero | tr '\\0' x; printf '\\342\\202\\254\\377\\000'; printf 'stderr\\346\\227\\245' >&2; exit 9".into(), + timeout_secs: 15, cwd: WorkdirPath::root(), tool_call_id: Some("http-tool-call".into()), + }).await.unwrap(); + let mut cursor = 0; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let terminal = loop { + let request = CommandOutputRequest { handle: handle.clone(), cursor, limit: 16384, wait: true }; + let page = harness.remote.command_output(request.clone()).await.unwrap(); + assert_eq!(harness.remote.command_output(request).await.unwrap(), page); + assert!(page.chunks.iter().map(|chunk| chunk.bytes.len()).sum::() <= 16384); + for chunk in page.chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + let target = match chunk.stream { CommandStream::Stdout => &mut stdout, CommandStream::Stderr => &mut stderr }; + assert_eq!(chunk.offset, target.len() as u64); + target.extend(chunk.bytes); + } + assert_eq!(cursor, page.next_cursor); + if let Some(terminal) = page.terminal { break terminal; } + }; + let mut expected = vec![b'x'; 2097152]; + expected.extend_from_slice(b"\xe2\x82\xac\xff\0"); + assert_eq!(stdout, expected); + assert_eq!(stderr, b"stderr\xe6\x97\xa5"); + assert_eq!(terminal.status, workdir::CommandStatus::Failed); + assert_eq!(terminal.exit_code, Some(9)); + assert!(harness.remote.command_output(CommandOutputRequest { handle, cursor: 0, limit: 1, wait: false }).await.is_err()); + harness.remote.close().await.unwrap(); + assert!(harness.session.command_snapshot().is_empty()); + }).await.expect("bounded HTTP command transport"); +} + +#[tokio::test] +async fn real_http_cancel_drains_tail_before_terminal_and_timeout_is_distinct() { + tokio::time::timeout(Duration::from_secs(15), async { + let harness = Harness::new().await; + for (command, timeout, cancel, expected) in [ + ( + "printf ready; exec yes x", + 10, + true, + workdir::CommandStatus::Cancelled, + ), + ( + "printf ready; exec tail -f /dev/null", + 1, + false, + workdir::CommandStatus::TimedOut, + ), + ] { + let handle = harness + .remote + .start_command(CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, + command: command.into(), + timeout_secs: timeout, + cwd: WorkdirPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + let first = harness + .remote + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 5, + wait: true, + }) + .await + .unwrap(); + assert_eq!( + first + .chunks + .iter() + .flat_map(|chunk| chunk.bytes.iter().copied()) + .collect::>(), + b"ready" + ); + cursor = first.next_cursor; + if cancel { + harness.remote.cancel_command(handle.clone()).await.unwrap(); + } + loop { + let page = harness + .remote + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }) + .await + .unwrap(); + cursor = page.next_cursor; + if let Some(terminal) = page.terminal { + assert_eq!(terminal.status, expected); + break; + } + } + } + harness.remote.close().await.unwrap(); + }) + .await + .expect("bounded cancellation and timeout over HTTP"); +} + +#[tokio::test] +async fn real_http_command_budget_bounds_unique_payload_and_reports_larger_seen_totals() { + tokio::time::timeout(Duration::from_secs(20), async { + let harness = Harness::new().await; + for budget in [0, 12345] { + let handle = harness + .remote + .start_command(CommandRequest { + command: "head -c 1048576 /dev/zero; head -c 65536 /dev/zero >&2; exit 9" + .into(), + timeout_secs: 10, + transport_byte_budget: budget, + cwd: WorkdirPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + let mut delivered = [0_u64; 2]; + let terminal = loop { + let request = CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 997, + wait: true, + }; + let page = harness + .remote + .command_output(request.clone()) + .await + .unwrap(); + assert_eq!(harness.remote.command_output(request).await.unwrap(), page); + for chunk in page.chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + let index = match chunk.stream { + CommandStream::Stdout => 0, + CommandStream::Stderr => 1, + }; + assert_eq!(chunk.offset, delivered[index]); + delivered[index] += chunk.bytes.len() as u64; + } + assert_eq!(cursor, page.next_cursor); + assert!( + cursor <= budget, + "RemoteWorkdirSession must not receive bytes beyond budget" + ); + if let Some(terminal) = page.terminal { + break terminal; + } + }; + assert_eq!(cursor, budget); + assert_eq!(terminal.stdout_bytes, 1048576); + assert_eq!(terminal.stderr_bytes, 65536); + assert_eq!(terminal.stdout_delivered_bytes, delivered[0]); + assert_eq!(terminal.stderr_delivered_bytes, delivered[1]); + assert_eq!( + terminal.truncation_reason, + Some(workdir::CommandOutputTruncation::ByteBudget) + ); + assert_eq!(terminal.status, workdir::CommandStatus::Failed); + assert_eq!(terminal.exit_code, Some(9)); + assert!( + harness + .remote + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: budget + 1, + limit: 1, + wait: false, + }) + .await + .is_err() + ); + if budget > 0 { + assert!( + harness + .remote + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 1, + wait: false, + }) + .await + .is_err() + ); + } + } + harness.remote.close().await.unwrap(); + }) + .await + .expect("bounded HTTP byte-budget transport"); +} + +#[tokio::test] +async fn real_http_rejects_invalid_or_legacy_budget_requests_before_process_start() { + tokio::time::timeout(Duration::from_secs(10), async { + let harness = Harness::new().await; + let request = CommandRequest { + command: "touch must-not-exist".into(), + timeout_secs: 1, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES + 1, + cwd: WorkdirPath::root(), + tool_call_id: None, + }; + let oversize = + serde_json::to_value(WorkdirSessionOperation::CommandStart(request)).unwrap(); + assert_eq!( + harness.raw_operation(oversize.clone()).await.status(), + reqwest::StatusCode::BAD_REQUEST + ); + let mut missing = oversize.clone(); + // CommandStart is externally tagged by operation / request. + let object = missing.as_object_mut().unwrap(); + let request = object.get_mut("request").unwrap().as_object_mut().unwrap(); + request.remove("transport_byte_budget"); + assert_eq!( + harness.raw_operation(missing).await.status(), + reqwest::StatusCode::BAD_REQUEST + ); + let mut legacy = oversize; + let request = legacy["request"].as_object_mut().unwrap(); + request.insert("transport_byte_budget".into(), serde_json::json!(0)); + request.insert("spill_dir".into(), serde_json::json!("/private/host")); + assert_eq!( + harness.raw_operation(legacy).await.status(), + reqwest::StatusCode::BAD_REQUEST + ); + assert!(!harness._root.path().join("must-not-exist").exists()); + harness.remote.close().await.unwrap(); + }) + .await + .expect("bounded HTTP budget admission"); +} diff --git a/crates/worker-runtime/src/http_server/tests/session_material_tests.rs b/crates/worker-runtime/src/http_server/tests/session_material_tests.rs index 33f8555dc..733f658ac 100644 --- a/crates/worker-runtime/src/http_server/tests/session_material_tests.rs +++ b/crates/worker-runtime/src/http_server/tests/session_material_tests.rs @@ -35,6 +35,7 @@ fn message(id: &str, role: LoggedRole, text: &str) -> LoggedHistoryEntry { content: vec![LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId(id.into()), origin: LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, diff --git a/crates/worker-runtime/src/retention.rs b/crates/worker-runtime/src/retention.rs index bc7994de9..6e2496246 100644 --- a/crates/worker-runtime/src/retention.rs +++ b/crates/worker-runtime/src/retention.rs @@ -1204,9 +1204,13 @@ fn archive_storage( } } -/// Called only on the private Runtime migration staging tree under its owner -/// lock. Failure discards the whole tree, including manifests and attestations. -pub(crate) fn migrate_staged_sessions(root: &Path) -> Result<(), RuntimeError> { +// Share discovery and erasure fencing between the read-only Runtime preflight +// and its private staging pass. Session Store remains the authority for schema +// admission and conversion; Runtime must not duplicate its version table. +fn for_each_live_session( + root: &Path, + mut visit: impl FnMut(&Path, bool) -> Result<(), RuntimeError>, +) -> Result<(), RuntimeError> { for directory in ["workers", "orphaned-workers", "archives/workers"] { let directory = root.join(directory); let entries = match fs::read_dir(&directory) { @@ -1253,44 +1257,72 @@ pub(crate) fn migrate_staged_sessions(root: &Path) -> Result<(), RuntimeError> { Err(session_store::StoreError::SessionErased(_)) => continue, Err(error) => return Err(retention_error(error)), } - let archive = if directory == root.join("archives/workers") { - let manifest: WorkerSessionArchiveManifest = read_json( - &aggregate.join("manifest.json"), - "read archive before Session migration", - )?; - Some(validate_existing_archive(&aggregate, &manifest, false)?) - } else { - None - }; - session_store::WorkerSessionStore::new(&session).map_err(|error| { - RuntimeError::StoreCorrupt { - operation: "migrate Session schema", - path: session.clone(), - message: error.to_string(), - } - })?; - if let Some(ingestion) = archive { - let (checksum_sha256, bytes, file_count) = checksum_files( - &collect_files(&session, "migrate archive storage")?, - "migrate archive storage", - )?; - atomic_write_json( - &aggregate.join(ARCHIVE_STORAGE_FILE), - &MigratedArchiveStorage { - schema_version: 1, - ingestion, - checksum_sha256, - bytes, - file_count, - }, - "commit migrated archive storage", - )?; - } + visit(&aggregate, directory == root.join("archives/workers"))?; } } Ok(()) } +/// Read-only admission probe: a rejected Session needs the existing staged +/// migration attempt (which will also reject corrupt/unsupported inputs). +/// Never create a writable Session store or rewrite archive attestations here. +pub(crate) fn sessions_require_migration(root: &Path) -> Result { + let mut required = false; + for_each_live_session(root, |aggregate, _| { + required |= + session_store::WorkerSessionStore::open_read_only(aggregate.join("session")).is_err(); + Ok(()) + })?; + Ok(required) +} + +/// Called only on the private Runtime migration staging tree under its owner +/// lock. Failure discards the whole tree, including manifests and attestations. +pub(crate) fn migrate_staged_sessions(root: &Path) -> Result<(), RuntimeError> { + for_each_live_session(root, |aggregate, is_archive| { + let session = aggregate.join("session"); + let archive = if is_archive { + let manifest: WorkerSessionArchiveManifest = read_json( + &aggregate.join("manifest.json"), + "read archive before Session migration", + )?; + Some(validate_existing_archive(aggregate, &manifest, false)?) + } else { + None + }; + // Current Sessions need no writable admission, and their storage + // attestations must remain byte-for-byte unchanged. + if session_store::WorkerSessionStore::open_read_only(&session).is_ok() { + return Ok(()); + } + session_store::WorkerSessionStore::new(&session).map_err(|error| { + RuntimeError::StoreCorrupt { + operation: "migrate Session schema", + path: session.clone(), + message: error.to_string(), + } + })?; + if let Some(ingestion) = archive { + let (checksum_sha256, bytes, file_count) = checksum_files( + &collect_files(&session, "migrate archive storage")?, + "migrate archive storage", + )?; + atomic_write_json( + &aggregate.join(ARCHIVE_STORAGE_FILE), + &MigratedArchiveStorage { + schema_version: 1, + ingestion, + checksum_sha256, + bytes, + file_count, + }, + "commit migrated archive storage", + )?; + } + Ok(()) + }) +} + fn validate_existing_archive( archive_dir: &Path, expected: &WorkerSessionArchiveManifest, @@ -1753,12 +1785,16 @@ fn temporary_path(path: &Path) -> PathBuf { path.with_file_name(format!(".{name}.tmp-{}-{sequence}", std::process::id())) } +#[cfg(all(test, feature = "fs-store"))] +#[path = "retention/runtime_v5_tests.rs"] +mod runtime_v5_tests; + #[cfg(test)] mod tests { use super::*; use std::sync::{Arc, Barrier}; - fn write_json(path: &Path, value: &impl Serialize) { + pub(super) fn write_json(path: &Path, value: &impl Serialize) { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, serde_json::to_vec_pretty(value).unwrap()).unwrap(); } @@ -1786,7 +1822,7 @@ mod tests { fs::write(worker.join("runs/attempt-a/worker.sock"), b"not retained").unwrap(); } - fn request( + pub(super) fn request( worker_id: WorkerId, disposition: SessionDisposition, ) -> WorkerRetentionExecutionRequest { @@ -1831,6 +1867,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId("original".into()), origin: LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, @@ -1844,6 +1881,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId("summary".into()), origin: LoggedSessionHistoryOrigin::DerivedSummary, derivation: Some(session_store::LoggedHistoryDerivation { diff --git a/crates/worker-runtime/src/retention/fixtures/system-input-v4.jsonl b/crates/worker-runtime/src/retention/fixtures/system-input-v4.jsonl new file mode 100644 index 000000000..00a1557f2 --- /dev/null +++ b/crates/worker-runtime/src/retention/fixtures/system-input-v4.jsonl @@ -0,0 +1,2 @@ +{"kind":"annotated_system_item","ts":101,"entry":{"item":{"kind":"journal_startup","body":"journal body\nunchanged","snapshot":{"entries":[{"text":"private snapshot"}]},"snapshot_sha256":"snapshot-digest","prompt_provenance":{"workspace_id":"workspace","source_digest":"source","projection_digest":"projection","logical_name":"journal_startup"}},"metadata":{"entry_id":"journal-id","origin":{"kind":"legacy_unknown"},"derivation":{"sources":["source-id"]}}},"extensions":[{"domain":"migration-fixture","payload":{"kept":true}}]} +{"kind":"annotated_system_item","ts":103,"entry":{"item":{"kind":"subject_behavior_refresh","body":"rendered subject body","behavior_md":"exact behavior","prompt_provenance":{"source_digest":"subject-source","projection_digest":"subject-projection","logical_name":"subject_behavior"}},"metadata":{"entry_id":"subject-id","origin":{"kind":"backend_instruction","operation_id":"op"}}}} diff --git a/crates/worker-runtime/src/retention/runtime_v5_tests.rs b/crates/worker-runtime/src/retention/runtime_v5_tests.rs new file mode 100644 index 000000000..5527dba73 --- /dev/null +++ b/crates/worker-runtime/src/retention/runtime_v5_tests.rs @@ -0,0 +1,634 @@ +//! Runtime integration, not a second implementation of Session migration. +//! The Feature records are frozen v4 wire copied from the Session Store fixture; +//! only the empty segment envelope is built with current APIs. +use super::tests::{request, write_json}; +use super::*; +use crate::fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; +use serde_json::{Value, json}; +use session_store::{LogEntry, SegmentId, SessionId, Store, WorkerSessionStore}; +use std::collections::BTreeMap; + +const V4_SYSTEM_INPUTS: &str = include_str!("fixtures/system-input-v4.jsonl"); + +fn write_v4_session(aggregate: &Path) -> (SessionId, SegmentId) { + let root = aggregate.join("session"); + let session = session_store::new_session_id(); + let segment = session_store::new_segment_id(); + let store = WorkerSessionStore::new(&root).unwrap(); + store + .create_segment( + session, + segment, + &[LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id: session, + system_prompt: None, + config: Default::default(), + history: vec![], + forked_from: None, + compacted_from: None, + }], + ) + .unwrap(); + let path = root.join("segments").join(format!("{segment}.jsonl")); + let mut bytes = fs::read(&path).unwrap(); + bytes.extend_from_slice(V4_SYSTEM_INPUTS.as_bytes()); + fs::write(path, bytes).unwrap(); + write_json( + &root.join("session.json"), + &json!({ + "schema_version": 4, "session_id": session, + }), + ); + (session, segment) +} + +fn stopped_worker(root: &Path, schema: u32) -> (WorkerId, SessionId, SegmentId) { + let worker = WorkerId::now_v7(); + let aggregate = root.join("workers").join(worker.to_string()); + // Serialize request defaults just as a production aggregate writer does. + let request: crate::catalog::CreateWorkerRequest = serde_json::from_value(json!({ + "worker_id": worker, + "workspace_api": {"workspace_id": "workspace-a", "base_url": "https://workspace.invalid"}, + "create_fingerprint": "runtime-v5-fixture", + "profile": {"kind": "builtin", "value": "builtin:coder"}, + "profile_source": {"kind": "workspace_config", "archive": { + "id": "archive-1", "digest": "sha256:archive", "size_bytes": 0, + "source_graph": {"source_count": 0, "total_source_bytes": 0, + "entrypoints": {}, "import_count": 0} + }} + })) + .unwrap(); + write_json( + &aggregate.join("worker.json"), + &json!({ + "schema_version": schema, + "worker_ref": {"worker_id": worker}, "worker_id": worker, + "profile": request.profile, + "profile_source": request.profile_source.reference(), + "display_name": null, "config_bundle": null, + "created_at_ms": 1234, "status": "stopped", "workspace_id": "workspace-a", + "last_finished_submission_request_id": "retained-submission", + "restore_guard": {"observation_token": "retained-token", + "active_request_id": null, "owners": {}}, + "execution_state": {"state": "available", "execution": { + "request": request, "binding": {}, "restore_intent": "explicit" + }} + }), + ); + let (session, segment) = write_v4_session(&aggregate); + let mut metadata = session_store::WorkerMetadata::new( + format!("worker-runtime-{worker}"), + Some(session_store::WorkerActiveSegmentRef::active_segment( + session, segment, + )), + ); + metadata.workspace_id = Some("workspace-a".into()); + write_json(&aggregate.join("metadata.json"), &metadata); + (worker, session, segment) +} + +fn runtime_fixture(root: &Path, schema: u32) -> (WorkerId, SessionId, SegmentId) { + fs::create_dir_all(root.join("workers")).unwrap(); + let fenced_worker = WorkerId::now_v7(); + write_json( + &root.join("runtime.json"), + &json!({ + "schema_version": schema, "display_name": "retained Runtime", + "backend": "fs_store", "status": "running", "next_diagnostic_id": 1, + "config_bundles": {}, "workspace_owners": {"workspace-a": "owner-a"}, + "diagnostics": [], "subject_releases": [], + "subject_create_recovery_fences": {fenced_worker.to_string(): { + "request": {"workspace_id": "workspace-a", "worker_id": fenced_worker, + "create_fingerprint": "retained-create", "operation_id": "retained-recovery"}, + "response": null + }} + }), + ); + stopped_worker(root, schema) +} + +// Include every file, not just schema markers: failures must preserve original +// history, metadata, archive receipts, checksums and retention evidence together. +fn tree_bytes(root: &Path) -> BTreeMap> { + fn walk(root: &Path, at: &Path, result: &mut BTreeMap>) { + for entry in fs::read_dir(at).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + walk(root, &entry.path(), result); + } else { + result.insert( + entry.path().strip_prefix(root).unwrap().to_path_buf(), + fs::read(entry.path()).unwrap(), + ); + } + } + } + let mut result = BTreeMap::new(); + walk(root, root, &mut result); + result +} + +fn assert_generic(root: &Path, session: SessionId, segment: SegmentId) { + use session_store::{InputCompactionUse, InputEvidenceUse, InputObservationUse}; + let store = WorkerSessionStore::open_read_only(root).unwrap(); + let entries = store.read_all_read_only(session, segment).unwrap(); + assert_eq!(entries.len(), 3); + let originals: Vec = V4_SYSTEM_INPUTS + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + for (log, old) in entries[1..].iter().zip(originals) { + let LogEntry::AnnotatedSystemItem { entry, .. } = log else { + panic!("lost System record: {log:?}"); + }; + let current = serde_json::to_value(log).unwrap(); + assert_eq!(current["ts"], old["ts"]); + for field in ["entry_id", "origin", "derivation"] { + assert_eq!( + current["entry"]["metadata"][field], + old["entry"]["metadata"][field] + ); + } + assert_eq!(current["entry"]["item"]["kind"], "input"); + assert_eq!( + entry.item.history_text(), + old["entry"]["item"]["body"].as_str().unwrap() + ); + let input = entry.metadata.input_metadata.as_ref().unwrap(); + let usage = input.usage(); + assert_eq!(usage.recording_material, InputEvidenceUse::Exclude); + assert_eq!(usage.memory_evidence, InputEvidenceUse::Exclude); + assert_eq!(usage.observation, InputObservationUse::Hidden); + let kind = old["entry"]["item"]["kind"].as_str().unwrap(); + let schema = if kind == "journal_startup" { + assert_eq!(usage.compaction, InputCompactionUse::ReferenceInput); + assert_eq!(current["extensions"], old["extensions"]); + "journal:startup" + } else { + assert_eq!(usage.compaction, InputCompactionUse::Retain); + "subject:behavior" + }; + let payload = input.feature_payload().unwrap(); + assert_eq!(payload.schema(), schema); + assert_eq!(payload.version(), 1); + let mut expected_payload = old["entry"]["item"].clone(); + expected_payload.as_object_mut().unwrap().remove("kind"); + expected_payload.as_object_mut().unwrap().remove("body"); + assert_eq!( + payload.decode::(schema, 1).unwrap(), + expected_payload + ); + let public = serde_json::to_value(input.public_item().unwrap()).unwrap(); + assert_eq!(public["kind"], kind); + assert_eq!(public["body"], old["entry"]["item"]["body"]); + assert_eq!(public["presentation"], json!({"kind": "context"})); + } +} + +fn completed_v4_archive( + root: &Path, + schema: u32, +) -> ( + SessionId, + SegmentId, + WorkerRetentionExecutionRequest, + WorkerRetentionExecutionResult, +) { + let (worker, session, segment) = stopped_worker(root, schema); + let provider = FsWorkerRetentionProvider::new(root); + let request = request(worker, SessionDisposition::Archive); + // Frozen old-runtime receipt: ingestion and removal completed before the + // current strict decoder existed. Do not ask the v5 reader to mint it. + let manifest = + commit_session_archive(&provider, &request, &provider.worker_dir(worker)).unwrap(); + let completed = WorkerRetentionExecutionResult { + operation_id: request.operation_id.clone(), + input_fingerprint: request.input_fingerprint.clone(), + worker_id: worker, + session_disposition: SessionDisposition::Archive, + diagnostics_disposition: DiagnosticsDisposition::Purge, + archive: Some(manifest), + source_removed: true, + diagnostics_retained: false, + }; + write_json( + &provider.operation_path(&request.operation_id).unwrap(), + &RetentionOperationReceipt { + schema_version: OPERATION_SCHEMA_VERSION, + request: request.clone(), + result: completed.clone(), + }, + ); + fs::remove_dir_all(provider.worker_dir(worker)).unwrap(); + (session, segment, request, completed) +} + +#[test] +fn runtime_v11_and_current_route_v4_stopped_orphan_archive_through_staging() { + for runtime_schema in [11, 12] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("runtime"); + let (worker, session, segment) = runtime_fixture(&root, runtime_schema); + let orphan = root + .join("orphaned-workers") + .join(WorkerId::now_v7().to_string()); + let (orphan_session, orphan_segment) = write_v4_session(&orphan); + // Include an unregistered tree still under workers, not just an + // already-archived orphan. Both Runtime source schemas must fence it + // out of the live catalog without discarding its Session. + let loose_id = WorkerId::now_v7(); + let loose = root.join("workers").join(loose_id.to_string()); + let (loose_session, loose_segment) = write_v4_session(&loose); + let retained_loose = root.join("orphaned-workers").join(loose_id.to_string()); + let (archived_session, archived_segment, request, completed) = + completed_v4_archive(&root, runtime_schema); + let provider = FsWorkerRetentionProvider::new(&root); + let archive = provider.archive_dir("archive-a").unwrap(); + // Also cover an archive which already has a local storage attestation + // from an earlier schema migration. Keep ingestion bytes immutable; + // harmless marker whitespace makes current storage differ from them. + if runtime_schema == 12 { + let marker = archive.join("session/session.json"); + let mut bytes = fs::read(&marker).unwrap(); + bytes.push(b'\n'); + fs::write(marker, bytes).unwrap(); + let ingestion = completed.archive.clone().unwrap(); + let (checksum_sha256, bytes, file_count) = checksum_files( + &collect_files(&archive.join("session"), "test").unwrap(), + "test", + ) + .unwrap(); + write_json( + &archive.join(ARCHIVE_STORAGE_FILE), + &MigratedArchiveStorage { + schema_version: 1, + ingestion, + checksum_sha256, + bytes, + file_count, + }, + ); + } + let storage_before = fs::read(archive.join(ARCHIVE_STORAGE_FILE)).ok(); + let manifest_before = fs::read(archive.join("manifest.json")).unwrap(); + let receipt_path = provider.operation_path(&request.operation_id).unwrap(); + let receipt_before = fs::read(&receipt_path).unwrap(); + let stopped = root.join("workers").join(worker.to_string()); + assert!(WorkerSessionStore::open_read_only(stopped.join("session")).is_err()); + // Current decode itself must not be made permissive to fix routing. + for line in V4_SYSTEM_INPUTS.lines() { + assert!(serde_json::from_str::(line).is_err()); + } + let before = tree_bytes(&root); + let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a"); + let plan = FsRuntimeStore::migration_plan(&options).unwrap(); + assert!(plan.migration_required); + assert_eq!(plan.archived_orphan_worker_count, 1); + assert_eq!(plan.current_schema_version, runtime_schema); + assert_eq!(tree_bytes(&root), before, "preflight mutated source"); + // Exercise normal startup, not just the private migration helper. + let opened = FsRuntimeStore::open_or_create(root.clone(), "runtime-a").unwrap(); + assert!( + matches!( + FsRuntimeStore::migrate(&options), + Err(RuntimeError::RuntimeStoreAlreadyOpen { .. }) + ), + "owner lock not fenced" + ); + drop(opened); + assert_generic(&stopped.join("session"), session, segment); + assert_generic(&orphan.join("session"), orphan_session, orphan_segment); + assert!(!loose.exists()); + assert_generic( + &retained_loose.join("session"), + loose_session, + loose_segment, + ); + assert_generic(&archive.join("session"), archived_session, archived_segment); + assert_eq!( + fs::read(archive.join("manifest.json")).unwrap(), + manifest_before + ); + assert_eq!(fs::read(&receipt_path).unwrap(), receipt_before); + let (manifest, archive_session) = provider.session_archive("archive-a").unwrap(); + assert_eq!(completed.archive.as_ref(), Some(&manifest)); + assert_eq!(provider.execute(&request).unwrap(), completed); + if let Some(storage_before) = storage_before { + assert_ne!( + fs::read(archive.join(ARCHIVE_STORAGE_FILE)).unwrap(), + storage_before + ); + } + let storage: MigratedArchiveStorage = + read_json(&archive.join(ARCHIVE_STORAGE_FILE), "test").unwrap(); + assert_eq!(storage.ingestion, manifest); + let (checksum, bytes, count) = + checksum_files(&collect_files(&archive_session, "test").unwrap(), "test").unwrap(); + assert_eq!( + (storage.checksum_sha256, storage.bytes, storage.file_count), + (checksum, bytes, count) + ); + if runtime_schema == 12 { + for name in [ + PathBuf::from("runtime.json"), + PathBuf::from(format!("workers/{worker}/worker.json")), + PathBuf::from(format!("workers/{worker}/metadata.json")), + ] { + assert_eq!(fs::read(root.join(&name)).unwrap(), before[&name]); + } + } + let once = tree_bytes(&root); + assert!( + !FsRuntimeStore::migrate(&options) + .unwrap() + .migration_required + ); + drop(FsRuntimeStore::open_or_create(root.clone(), "runtime-a").unwrap()); + assert_eq!(tree_bytes(&root), once, "retry rewrote migrated evidence"); + assert!(!temp.path().join(".runtime.schema-v12-staging").exists()); + assert!(!temp.path().join(".runtime.pre-schema-v12-backup").exists()); + // session-storage, not the immutable receipt's old checksum, attests + // current bytes. Tampering with those bytes must remain detectable. + let path = archive_session + .join("segments") + .join(format!("{archived_segment}.jsonl")); + fs::write(path, b"tampered\n").unwrap(); + assert!(provider.session_archive("archive-a").is_err()); + } +} + +#[test] +fn runtime_v5_archive_failure_discards_staging_without_losing_originals_and_retry_converges() { + for runtime_schema in [11, 12] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("runtime"); + let (worker, session, segment) = runtime_fixture(&root, runtime_schema); + let (archived_session, archived_segment, request, completed) = + completed_v4_archive(&root, runtime_schema); + let provider = FsWorkerRetentionProvider::new(&root); + let archive = provider.archive_dir("archive-a").unwrap(); + let path = archive + .join("session/segments") + .join(format!("{archived_segment}.jsonl")); + let original = fs::read(&path).unwrap(); + fs::write(&path, b"corrupt archive\n").unwrap(); + let before = tree_bytes(&root); + let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a"); + assert!( + FsRuntimeStore::migration_plan(&options) + .unwrap() + .migration_required + ); + // workers are visited before archives: earlier successful staged + // conversion must not leak into the original tree on this later error. + assert!(FsRuntimeStore::open_or_create(root.clone(), "runtime-a").is_err()); + assert_eq!(tree_bytes(&root), before); + assert!(!archive.join(ARCHIVE_STORAGE_FILE).exists()); + fs::write(&path, original).unwrap(); + FsRuntimeStore::migrate(&options).unwrap(); + assert_generic( + &root + .join("workers") + .join(worker.to_string()) + .join("session"), + session, + segment, + ); + assert_generic(&archive.join("session"), archived_session, archived_segment); + assert_eq!(provider.execute(&request).unwrap(), completed); + } +} + +#[test] +fn runtime_v5_typed_legacy_payload_failure_keeps_valid_archive_evidence_on_retries() { + for (from, to) in [ + ( + "\"snapshot_sha256\":\"snapshot-digest\"", + "\"snapshot_sha256\":42", + ), + ("\"behavior_md\":\"exact behavior\"", "\"behavior_md\":[]"), + ] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("runtime"); + runtime_fixture(&root, 12); + let (worker, _, segment) = stopped_worker(&root, 12); + let provider = FsWorkerRetentionProvider::new(&root); + let path = provider + .worker_dir(worker) + .join("session/segments") + .join(format!("{segment}.jsonl")); + let original = fs::read_to_string(&path).unwrap(); + assert!(original.contains(from)); + fs::write(&path, original.replace(from, to)).unwrap(); + let request = request(worker, SessionDisposition::Archive); + let manifest = + commit_session_archive(&provider, &request, &provider.worker_dir(worker)).unwrap(); + fs::remove_dir_all(provider.worker_dir(worker)).unwrap(); + let archive = provider.archive_dir("archive-a").unwrap(); + // This archive checksum is valid; failure must come from the legacy + // typed decoder, not from deliberately broken ingestion evidence. + validate_existing_archive(&archive, &manifest, false).unwrap(); + let before = tree_bytes(&root); + let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a"); + for _ in 0..2 { + let error = FsRuntimeStore::migrate(&options).unwrap_err(); + let RuntimeError::StoreCorrupt { + operation, path, .. + } = error + else { + panic!("unexpected failure: {error}"); + }; + assert_eq!(operation, "migrate Session schema"); + assert!(path.starts_with(temp.path().join(".runtime.schema-v12-staging"))); + assert_eq!(tree_bytes(&root), before); + validate_existing_archive(&archive, &manifest, false).unwrap(); + } + } +} + +#[test] +fn runtime_v5_probe_and_staging_skip_erased_originals() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("runtime"); + let (worker, live_session, live_segment) = runtime_fixture(&root, 12); + let erased = root + .join("orphaned-workers") + .join(WorkerId::now_v7().to_string()); + let (session, segment) = write_v4_session(&erased); + runtime_session_retention(&root) + .delete( + session, + session_store::retention::SessionDeletionIntent::ErasePermanently, + || Ok(()), + ) + .unwrap(); + fs::write( + erased + .join("session/segments") + .join(format!("{segment}.jsonl")), + b"fenced malformed JSON\n", + ) + .unwrap(); + let before = tree_bytes(&erased); + let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a"); + assert!( + FsRuntimeStore::migration_plan(&options) + .unwrap() + .migration_required + ); + FsRuntimeStore::migrate(&options).unwrap(); + assert_generic( + &root + .join("workers") + .join(worker.to_string()) + .join("session"), + live_session, + live_segment, + ); + assert_eq!(tree_bytes(&erased), before); + assert!( + !FsRuntimeStore::migration_plan(&options) + .unwrap() + .migration_required + ); + assert!(matches!( + runtime_session_retention(&root).ensure_live(session), + Err(session_store::StoreError::SessionErased(_)) + )); +} + +#[test] +fn runtime_v5_rejects_broken_or_future_source_without_rewriting_evidence() { + for corruption in ["broken-json", "future-schema"] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("runtime"); + let (worker, session, segment) = runtime_fixture(&root, 12); + let session_root = root + .join("workers") + .join(worker.to_string()) + .join("session"); + if corruption == "broken-json" { + fs::write( + session_root + .join("segments") + .join(format!("{segment}.jsonl")), + b"{not valid JSON\n", + ) + .unwrap(); + } else { + write_json( + &session_root.join("session.json"), + &json!({ + "schema_version": 999, "session_id": session, + }), + ); + } + let before = tree_bytes(&root); + let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a"); + assert!( + FsRuntimeStore::migration_plan(&options) + .unwrap() + .migration_required + ); + assert_eq!(tree_bytes(&root), before); + assert!(FsRuntimeStore::migrate(&options).is_err()); + assert_eq!(tree_bytes(&root), before); + assert!(!temp.path().join(".runtime.schema-v12-staging").exists()); + assert!(!temp.path().join(".runtime.pre-schema-v12-backup").exists()); + } +} + +#[cfg(unix)] +#[test] +fn current_runtime_session_migration_still_rejects_live_worker_sockets() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("runtime"); + let (worker, _, _) = runtime_fixture(&root, 12); + let before = tree_bytes(&root); + let runs = root.join("workers").join(worker.to_string()).join("runs"); + fs::create_dir_all(runs.join("1")).unwrap(); + let listener = std::os::unix::net::UnixListener::bind(runs.join("1/worker.sock")).unwrap(); + let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a"); + let error = FsRuntimeStore::migrate(&options).unwrap_err(); + assert!(error.to_string().contains("active Worker socket")); + drop(listener); + fs::remove_dir_all(runs).unwrap(); + assert_eq!(tree_bytes(&root), before); + assert!(!temp.path().join(".runtime.schema-v12-staging").exists()); +} + +#[test] +fn current_runtime_upgrades_v5_role_metadata_through_staging_without_reserializing_owner_evidence() +{ + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("runtime"); + let (worker, session, segment) = runtime_fixture(&root, 12); + let aggregate = root.join("workers").join(worker.to_string()); + let path = aggregate + .join("session/segments") + .join(format!("{segment}.jsonl")); + let old = fs::read_to_string(&path).unwrap(); + let header = old.lines().next().unwrap(); + let source = include_str!( + "../../../session-store/src/legacy_session_log/tests/fixtures/v5-role-metadata.jsonl" + ); + fs::write(&path, format!("{header}\n{source}")).unwrap(); + write_json( + &aggregate.join("session/session.json"), + &json!({"schema_version":5, "session_id": session}), + ); + let owner_before = fs::read(root.join("runtime.json")).unwrap(); + let worker_before = fs::read(aggregate.join("worker.json")).unwrap(); + let metadata_before = fs::read(aggregate.join("metadata.json")).unwrap(); + assert!(WorkerSessionStore::open_read_only(aggregate.join("session")).is_err()); + let options = FsRuntimeStoreOptions::new(&root).with_runtime_id("runtime-a"); + assert!( + FsRuntimeStore::migration_plan(&options) + .unwrap() + .migration_required + ); + FsRuntimeStore::migrate(&options).unwrap(); + let store = WorkerSessionStore::open_read_only(aggregate.join("session")).unwrap(); + let entries = store.read_all_read_only(session, segment).unwrap(); + assert_eq!(entries.len(), 6); + for (current, old) in entries[1..].iter().zip(source.lines()) { + let current = serde_json::to_value(current).unwrap(); + let old: Value = serde_json::from_str(old).unwrap(); + assert_eq!(current["ts"], old["ts"]); + assert_eq!(current["entry"]["item"], old["entry"]["item"]); + for field in ["entry_id", "origin", "derivation"] { + assert_eq!( + current["entry"]["metadata"][field], + old["entry"]["metadata"][field] + ); + } + if current["entry"]["metadata"]["origin"]["kind"] == "derived_summary" { + assert_eq!( + current["entry"]["metadata"]["input_metadata"]["usage"]["recording_material"], + "exclude" + ); + assert_eq!( + current["entry"]["metadata"]["input_metadata"]["usage"]["memory_evidence"], + "exclude" + ); + } + } + assert_eq!(fs::read(root.join("runtime.json")).unwrap(), owner_before); + assert_eq!( + fs::read(aggregate.join("worker.json")).unwrap(), + worker_before + ); + assert_eq!( + fs::read(aggregate.join("metadata.json")).unwrap(), + metadata_before + ); + let once = tree_bytes(&aggregate.join("session")); + assert!( + !FsRuntimeStore::migration_plan(&options) + .unwrap() + .migration_required + ); + FsRuntimeStore::migrate(&options).unwrap(); + assert_eq!(tree_bytes(&aggregate.join("session")), once); +} diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 2b671c6e5..c9307a5dc 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -12422,6 +12422,7 @@ mod tests { session: protocol::SessionSnapshot { pending_submissions: protocol::PendingSubmissionsSnapshot::default(), entries: vec![protocol::SessionSnapshotEntry { + use_policy: None, entry_id: "restored-log-entry".to_owned(), timestamp: 1, provenance: protocol::SessionEntryProvenance::LegacyUnknown, diff --git a/crates/worker-runtime/src/session_public.rs b/crates/worker-runtime/src/session_public.rs index dac5d21f0..ebdc2fa87 100644 --- a/crates/worker-runtime/src/session_public.rs +++ b/crates/worker-runtime/src/session_public.rs @@ -398,6 +398,7 @@ mod tests { content: vec![LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId(id.to_string()), origin: LoggedSessionHistoryOrigin::HumanInput { account_id: "account-1".to_string(), diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 74044cde9..78c2380d1 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -147,7 +147,6 @@ use worker::{ Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, - bash_output_dir_for_worker_id, }; #[cfg(feature = "ws-server")] @@ -1090,29 +1089,25 @@ fn runtime_local_workdir_session( root: &Path, cwd: &Path, scope: manifest::SharedScope, - command_output_scope: manifest::SharedScope, capabilities: WorkdirSessionCapabilities, command_environment: std::collections::BTreeMap, resources: Vec>, ) -> WorkdirSessionHandle { - Arc::new( - LocalWorkdirSession::materialized_bound_with_environment_and_command_output_scope( - Workdir::new(workdir_id), - root.to_path_buf(), - cwd.to_path_buf(), - scope, - command_output_scope, - capabilities, - command_environment, - resources, - ), - ) + Arc::new(LocalWorkdirSession::materialized_bound_with_environment( + Workdir::new(workdir_id), + root.to_path_buf(), + cwd.to_path_buf(), + scope, + capabilities, + command_environment, + resources, + )) } fn runtime_local_workdir_router( attachments: &BTreeMap, capabilities: &BTreeMap, - command_output_scope: manifest::SharedScope, + authorization_client: Option>, ) -> Result, String> { let router = Arc::new(WorkdirSessionRouter::new()); for (alias, binding) in attachments { @@ -1121,22 +1116,27 @@ fn runtime_local_workdir_router( .copied() .unwrap_or(WorkdirSessionCapabilities::READ_ONLY); let scope = runtime_attachment_scope(binding.root(), capabilities)?; + let source = runtime_local_workdir_session( + &binding.working_directory.id, + binding.root(), + binding.cwd(), + scope, + capabilities, + binding.command_environment(), + binding + .session_resources() + .map_err(|error| format!("open Workdir attachment `{alias}` resources: {error}"))?, + ); + let source = match authorization_client.as_ref() { + Some(client) => worker::authorized_workdir::authorized_workdir_session( + source, + client.clone(), + binding.working_directory.id.clone(), + ), + None => source, + }; router - .attach( - alias.clone(), - runtime_local_workdir_session( - &binding.working_directory.id, - binding.root(), - binding.cwd(), - scope, - command_output_scope.clone(), - capabilities, - binding.command_environment(), - binding.session_resources().map_err(|error| { - format!("open Workdir attachment `{alias}` resources: {error}") - })?, - ), - ) + .attach(alias.clone(), source) .map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?; } Ok(router) @@ -1146,7 +1146,6 @@ fn restored_workdir_router( local_attachments: &BTreeMap, logical_attachments: &[crate::catalog::LogicalWorkdirAttachment], claims: &[crate::catalog::WorkingDirectoryAttachmentClaim], - scope: manifest::SharedScope, workspace_client: Arc, ) -> Result, String> { let mut local_capabilities = local_attachments @@ -1163,7 +1162,11 @@ fn restored_workdir_router( local_capabilities.insert(attachment.alias.clone(), attachment.capabilities); } } - let router = runtime_local_workdir_router(local_attachments, &local_capabilities, scope)?; + let router = runtime_local_workdir_router( + local_attachments, + &local_capabilities, + Some(workspace_client.clone()), + )?; for attachment in logical_attachments { if let Some(local) = local_attachments.get(&attachment.alias) { if local.working_directory.id != attachment.working_directory_id { @@ -1517,7 +1520,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let store = CombinedStore::new(session_store, worker_metadata_store); let run_dir = self.worker_run_dir(&request.worker_ref)?; - let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id); let bootstrap = WorkerBootstrap::new( manifest, store, @@ -1526,7 +1528,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { filesystem_authority, WorkerBootstrapLayout::RuntimeManagedRun { run_dir: run_dir.clone(), - bash_output_dir, }, self.controller_transport, ); @@ -1571,7 +1572,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { worker.bind_workdir_sessions(runtime_local_workdir_router( &request.workdir_attachments, &workdir_capabilities, - worker.scope().clone(), + Some(worker.workspace_client_handle()), )?); if let (Some(runtime_id), Some(workspace_id)) = (observation_runtime_id, observation_workspace_id.clone()) @@ -1838,7 +1839,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { &request.workdir_attachments, &request.logical_workdir_attachments, &request.request.workdir_attachments, - worker.scope().clone(), worker.workspace_client_handle(), )?; worker.bind_workdir_sessions(workdir_sessions); @@ -1875,13 +1875,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let workspace_client = worker.workspace_client_handle(); let run_dir = self.worker_restore_run_dir(&request.worker_ref, request.operation_id)?; - let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id); let cleanup_sessions = worker.workdir_sessions(); let started = PreparedWorker::new( worker, WorkerBootstrapLayout::RuntimeManagedRun { run_dir: run_dir.clone(), - bash_output_dir, }, self.controller_transport, ) @@ -3755,7 +3753,6 @@ where working_directory_id, binding.root(), binding.cwd(), - scope.clone(), scope, WorkdirSessionCapabilities::ALL, binding.command_environment(), @@ -5033,6 +5030,7 @@ mod tests { }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id, origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -5050,6 +5048,65 @@ mod tests { const SAVED_SUBJEKTIV_SYSTEM_PROMPT: &str = "Committed saved Subject Worker system prompt"; + // Substituted Host transport for restore/controller component tests. This + // fixture has exactly the two activation grants requested by the saved + // Manifest; it grants no protected operations or resources. It does not + // exercise authenticated source derivation, Subject policy, or revocation + // in the real Workspace Host. + fn saved_subjektiv_authorization_fixture( + input: server_api::authorization::OperationAuthorizationRequest, + ) -> Result { + use server_api::authorization::{OperationAuthorizationResponse, PermissionResource}; + if input.validate().is_ok() + && matches!(input.operation.as_str(), "subjektiv" | "workdir_catalog") + && input.resource == PermissionResource::Feature + { + Ok(OperationAuthorizationResponse { + scope: Some(PermissionResource::Feature), + }) + } else { + Err("saved Subject fixture has no matching exact Feature grant") + } + } + + #[test] + fn saved_subjektiv_authorization_fixture_grants_only_exact_feature_activation() { + use server_api::authorization::{OperationAuthorizationRequest, PermissionResource}; + for operation in ["subjektiv", "workdir_catalog"] { + let response = saved_subjektiv_authorization_fixture(OperationAuthorizationRequest { + operation: operation.into(), + resource: PermissionResource::Feature, + }) + .unwrap(); + assert_eq!(response.scope, Some(PermissionResource::Feature)); + } + for (operation, resource) in [ + ("ticket", PermissionResource::Feature), + ("subjektiv.journal", PermissionResource::Feature), + ("subjektiv.*", PermissionResource::Feature), + ( + "subjektiv", + PermissionResource::Workspace { + workspace_id: "workspace-saved-subjektiv".into(), + }, + ), + ( + "subjektiv.journal", + PermissionResource::Workspace { + workspace_id: "workspace-saved-subjektiv".into(), + }, + ), + ] { + assert!( + saved_subjektiv_authorization_fixture(OperationAuthorizationRequest { + operation: operation.into(), + resource, + }) + .is_err() + ); + } + } + fn saved_subjektiv_policy( root: &Path, worker_ref: &WorkerRef, @@ -5214,6 +5271,25 @@ mod tests { })) }), ); + let app = app.route( + "/api/w/workspace-saved-subjektiv/authorization/check", + axum::routing::post( + |axum::Json(input): axum::Json< + server_api::authorization::OperationAuthorizationRequest, + >| async move { + match saved_subjektiv_authorization_fixture(input) { + Ok(response) => ( + axum::http::StatusCode::OK, + axum::Json(serde_json::to_value(response).unwrap()), + ), + Err(message) => ( + axum::http::StatusCode::FORBIDDEN, + axum::Json(serde_json::json!({ "message": message })), + ), + } + }, + ), + ); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); @@ -6804,8 +6880,6 @@ mod tests { #[test] fn restored_router_uses_workspace_proxy_for_logical_only_attachment() { - let root = tempfile::tempdir().unwrap(); - let scope = manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap()); let client = WorkerWorkspaceContext::local_filesystem(Some( WorkspaceId::new("workspace-a").unwrap(), )) @@ -6820,7 +6894,6 @@ mod tests { capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, }], &[], - scope, client, ) .unwrap(); @@ -6865,21 +6938,18 @@ mod tests { .unwrap(); let alias = WorkdirAttachmentAlias::new("docs").unwrap(); let workdir_id = binding.working_directory.id.clone(); - let command_output_scope = manifest::SharedScope::new(manifest::Scope::empty()); let attachments = BTreeMap::from([(alias.clone(), binding)]); let logical = [LogicalWorkdirAttachment { alias: alias.clone(), working_directory_id: workdir_id, capabilities: WorkdirSessionCapabilities::READ_ONLY, }]; - let client = WorkerWorkspaceContext::local_filesystem(Some( - WorkspaceId::new("workspace-a").unwrap(), - )) - .client_handle(); + // Direct local-router capability test: no Workspace Host transport is + // installed. Use an explicit standalone binding, not an unavailable + // Workspace-bound client pretending to authorize operations. + let client = WorkerWorkspaceContext::no_workspace().client_handle(); - let router = - restored_workdir_router(&attachments, &logical, &[], command_output_scope, client) - .unwrap(); + let router = restored_workdir_router(&attachments, &logical, &[], client).unwrap(); let session = router.session(&alias).unwrap(); assert_eq!( @@ -7482,6 +7552,91 @@ mod tests { ); } + // Generic controller tests explicitly substitute the Host's non-Subject + // legacy decision. No Subject authority is modeled here; all other requests + // still use the original transport and its Backend/provider checks. + #[derive(Debug)] + struct LegacyNonSubjectAuthorizationClient(Arc); + + impl WorkspaceClient for LegacyNonSubjectAuthorizationClient { + fn workspace_id(&self) -> Option<&str> { + self.0.workspace_id() + } + + fn kind(&self) -> &str { + self.0.kind() + } + + fn is_available(&self) -> bool { + self.0.is_available() + } + + fn execute( + &self, + request: worker::WorkspaceRequest, + ) -> Result { + if request.method == worker::WorkspaceRequestMethod::Post + && request.path + == format!( + "/api/w/{}/authorization/check", + self.workspace_id() + .expect("legacy fixture needs Workspace identity") + ) + { + let input: server_api::authorization::OperationAuthorizationRequest = + serde_json::from_str(request.body.as_deref().unwrap_or_default()).map_err( + |error| worker::WorkspaceClientError::Request(error.to_string()), + )?; + input + .validate() + .map_err(worker::WorkspaceClientError::Request)?; + return Ok(worker::WorkspaceResponse { + status: 200, + body: serde_json::to_string( + &server_api::authorization::OperationAuthorizationResponse { scope: None }, + ) + .unwrap(), + }); + } + self.0.execute(request) + } + } + + #[test] + fn legacy_non_subject_authorization_fixture_preserves_other_transport_failures() { + use server_api::authorization::PermissionResource; + let client = LegacyNonSubjectAuthorizationClient( + WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())) + .client_handle(), + ); + assert_eq!( + client + .authorize_operation_scope("workdir_catalog", &PermissionResource::Feature) + .unwrap(), + None, + ); + assert_eq!( + client + .authorize_operation_scope( + "ticket.create", + &PermissionResource::Workspace { + workspace_id: "ws-test".into(), + } + ) + .unwrap(), + None, + ); + assert!( + client + .authorize_operation("ticket.*", &PermissionResource::Feature) + .is_err() + ); + assert!(matches!( + client.execute(worker::WorkspaceRequest::get("/api/w/ws-test/tickets")), + Err(worker::WorkspaceClientError::Unavailable(_)), + )); + } + struct MockFactory { client: MockClient, runtime_base: PathBuf, @@ -7547,6 +7702,22 @@ mod tests { None, None, ); + let workspace_context = if workspace_context.client().is_available() + && workspace_context.workspace_id().is_some() + { + assert!( + !request.request.subjektiv_attached, + "generic legacy fixture must never authorize a Subject Worker" + ); + WorkerWorkspaceContext::with_client( + workspace_context.workspace_id().cloned(), + Arc::new(LegacyNonSubjectAuthorizationClient( + workspace_context.client_handle(), + )), + ) + } else { + workspace_context + }; let workspace_client = workspace_context.client_handle(); self.observed_workspace_clients.lock().unwrap().push(( workspace_client.kind().to_string(), @@ -7566,14 +7737,10 @@ mod tests { ) .await .map_err(|err| err.to_string())?; - let bash_output_dir = self.runtime_base.join("bash-output"); - let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed( - worker, - &self.runtime_base, - &bash_output_dir, - ) - .await - .map_err(|err| err.to_string())?; + let (handle, shutdown_rx) = + WorkerController::spawn_runtime_managed(worker, &self.runtime_base) + .await + .map_err(|err| err.to_string())?; Ok(RuntimeWorkerController { handle, shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))), @@ -8697,7 +8864,6 @@ mod tests { "working-directory-42", root.path(), root.path(), - spawned_scope.clone(), spawned_scope, WorkdirSessionCapabilities::READ_ONLY, Default::default(), @@ -8708,7 +8874,6 @@ mod tests { "working-directory-42", root.path(), root.path(), - restored_scope.clone(), restored_scope, WorkdirSessionCapabilities::READ_ONLY, Default::default(), @@ -10233,8 +10398,7 @@ mod tests { let alias = WorkdirAttachmentAlias::new("checkout").unwrap(); let attachments = BTreeMap::from([(alias, binding.clone())]); - let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap()); - let router = runtime_local_workdir_router(&attachments, &BTreeMap::new(), scope).unwrap(); + let router = runtime_local_workdir_router(&attachments, &BTreeMap::new(), None).unwrap(); session_clone.close().await.unwrap(); assert_eq!( materializer.cleanup_working_directory(id).unwrap_err().code, diff --git a/crates/worker/examples/worker_protocol.rs b/crates/worker/examples/worker_protocol.rs index 53ec846d7..b893515da 100644 --- a/crates/worker/examples/worker_protocol.rs +++ b/crates/worker/examples/worker_protocol.rs @@ -47,9 +47,7 @@ async fn main() -> Result<(), Box> { let worker = worker::Worker::from_manifest_toml(&toml, store).await?; let runtime_tmp = tempfile::tempdir()?; - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, _shutdown_rx) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir).await?; + let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?; // Check initial status via shared state println!("[shared_state] {}", handle.shared_state.status_json()); diff --git a/crates/worker/src/authorized_workdir.rs b/crates/worker/src/authorized_workdir.rs new file mode 100644 index 000000000..6967f0db0 --- /dev/null +++ b/crates/worker/src/authorized_workdir.rs @@ -0,0 +1,1115 @@ +//! Live Host authorization for trusted Features using Runtime-local Workdir sessions. +//! +//! Point access uses provider-resolved leases; traversals use provider scope layers. +//! This adapter adds no Tool executor or shell sandbox and retains provider ceilings. + +use std::{future::Future, path::Path, sync::Arc}; + +use async_trait::async_trait; +use server_api::authorization::PermissionResource; +use tokio::sync::{OnceCell, broadcast}; +use workdir::{ + CheckoutObservation, CheckoutOperation, CheckoutRequest, CheckoutResult, + CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult, CommandEvent, + CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandSnapshot, + CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, + ListRequest, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, + StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, WorkdirScopeAuthorizationRequest, + WorkdirScopeOverlapRequest, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle, + WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission, WorkdirToolScopeRule, + WriteRequest, WriteResult, +}; + +use crate::WorkspaceClient; + +const READ: &str = "filesystem.read"; +const WRITE: &str = "filesystem.write"; +const COMMAND: &str = "bash.execute"; + +/// Bind the authority-assigned attachment identity, never a local provider ID or alias. +/// Only explicitly standalone clients bypass Host authorization; an unavailable bound +/// transport must return an authorization error instead of becoming standalone. +pub fn authorized_workdir_session( + session: WorkdirSessionHandle, + client: Arc, + working_directory_id: String, +) -> WorkdirSessionHandle { + if !client.is_available() && client.workspace_id().is_none() && client.kind() == "unavailable" { + return session; + } + Arc::new(AuthorizedWorkdirSession { + session, + client, + working_directory_id, + scope_broker: OnceCell::new(), + }) +} + +#[derive(Debug)] +struct AuthorizedWorkdirSession { + session: WorkdirSessionHandle, + client: Arc, + working_directory_id: String, + scope_broker: OnceCell, +} + +impl AuthorizedWorkdirSession { + fn resource( + &self, + path: &WorkdirPath, + recursive: bool, + ) -> Result { + let normalized = WorkdirPath::new(path.as_str()).map_err(|_| { + WorkdirError::Denied("Host authorization requires attachment-relative paths".into()) + })?; + let resource = PermissionResource::Workdir { + working_directory_id: self.working_directory_id.clone(), + path: normalized.to_string(), + recursive, + }; + resource.validate().map_err(|error| { + WorkdirError::Denied(format!("invalid Workdir authorization resource: {error}").into()) + })?; + Ok(resource) + } + + fn authorize( + &self, + operation: &str, + path: &WorkdirPath, + recursive: bool, + ) -> Result<(), WorkdirError> { + self.client + .authorize_operation(operation, &self.resource(path, recursive)?) + .map_err(authorization_error) + } + + fn authorized_scope( + &self, + operation: &str, + path: &WorkdirPath, + recursive: bool, + ) -> Result, WorkdirError> { + let requested = self.resource(path, recursive)?; + let Some(granted) = self + .client + .authorize_operation_scope(operation, &requested) + .map_err(authorization_error)? + else { + // Only the Host may choose the explicit non-Subject legacy contract. + return Ok(None); + }; + if !granted.covers(&requested) { + return Err(WorkdirError::Denied( + "Host returned a scope that does not cover the operation resource".into(), + )); + } + let PermissionResource::Workdir { + path, recursive, .. + } = granted + else { + return Err(WorkdirError::Denied( + "Host returned a non-Workdir scope".into(), + )); + }; + Ok(Some(WorkdirToolScopeRule { + target: WorkdirPath::new(path)?, + recursive, + permission: match operation { + READ => WorkdirToolScopePermission::Read, + WRITE => WorkdirToolScopePermission::Write, + _ => { + return Err(WorkdirError::Denied( + "operation has no filesystem scope mapping".into(), + )); + } + }, + symlink_policy: manifest::SymlinkPolicy::Resolved, + })) + } + + async fn with_authorized( + &self, + operation: &str, + path: &WorkdirPath, + recursive: bool, + effect: F, + ) -> Result + where + F: FnOnce(WorkdirSessionHandle, WorkdirPath, Option) -> Fut, + Fut: Future>, + { + let Some(rule) = self.authorized_scope(operation, path, recursive)? else { + return effect(self.session.clone(), WorkdirPath::root(), None).await; + }; + let broker = self + .scope_broker + .get_or_init(|| async { WorkdirToolBroker::new(self.session.clone()) }) + .await; + // Resolve against the granted prefix, not the requested path: a symlink + // must not turn its target into the root of its own authorization. + let lease = broker + .scope(WorkdirToolScope { + rules: vec![rule.clone()], + cwd: rule.target.clone(), + command: false, + }) + .await?; + let result = effect(lease.tool_session(), rule.target.clone(), Some(rule)).await; + // Close only the attenuated lease. Source hash history, scope, command + // ownership and attachment lifetime survive separate operations. + match (result, lease.close().await) { + (result, Ok(())) => result, + (Ok(_), Err(cleanup)) => Err(WorkdirError::OutcomeUnknown(format!( + "operation completed but its authorization lease cleanup failed: {cleanup}" + ))), + (Err(operation), Err(cleanup)) => Err(WorkdirError::OutcomeUnknown(format!( + "operation failed: {operation}; authorization lease cleanup also failed: {cleanup}" + ))), + } + } + + fn require_legacy(&self, operation: &str) -> Result<(), WorkdirError> { + if self + .authorized_scope(operation, &WorkdirPath::root(), true)? + .is_some() + { + return Err(WorkdirError::Denied( + "operation has no complete scoped resource mapping".into(), + )); + } + Ok(()) + } + + async fn authorized_search( + &self, + operation: CheckoutSearchOperation, + ) -> Result { + let path = operation.path().clone(); + self.with_authorized(READ, &path, true, |scoped, root, rule| async move { + let Some(rule) = rule else { + return match operation { + CheckoutSearchOperation::List(request) => self + .session + .list(request) + .await + .map(CheckoutSearchResult::List), + CheckoutSearchOperation::Glob(request) => self + .session + .glob(request) + .await + .map(CheckoutSearchResult::Glob), + CheckoutSearchOperation::Grep(request) => self + .session + .grep(request) + .await + .map(CheckoutSearchResult::Grep), + }; + }; + scoped + .authorize_scope_path(WorkdirScopeAuthorizationRequest { + rules: vec![WorkdirToolScopeRule { + target: WorkdirPath::root(), + ..rule.clone() + }], + path: relative_path(&root, operation.path())?, + permission: WorkdirToolScopePermission::Read, + }) + .await?; + // Lease cwd rebases paths. Use the same provider scoped-traversal contract + // with attachment output_root instead, retaining typed paths, cursors and + // Grep's rendered filenames without parsing or post-filtering output. + let mut request = CheckoutSearchRequest::new(operation); + request.scope_layers.push(vec![rule]); + self.session.checkout_search(request).await + }) + .await + } + + fn authorize_command(&self) -> Result<(), WorkdirError> { + // cwd is not a sandbox: arbitrary shell can access the entire attachment. + self.authorize(COMMAND, &WorkdirPath::root(), true) + } +} + +fn authorization_error(error: crate::WorkspaceClientError) -> WorkdirError { + WorkdirError::Denied(format!("Workdir Host authorization failed: {error}").into()) +} + +fn relative_path(root: &WorkdirPath, path: &WorkdirPath) -> Result { + let relative = Path::new(path.as_str()) + .strip_prefix(root.as_str()) + .map_err(|_| { + WorkdirError::Denied("operation path exceeds the granted provider scope".into()) + })?; + WorkdirPath::new( + relative + .to_str() + .ok_or_else(|| WorkdirError::InvalidPath("non-UTF8 scoped path".into()))?, + ) + .map_err(WorkdirError::from) +} + +#[async_trait] +impl WorkdirSession for AuthorizedWorkdirSession { + fn workdir(&self) -> &Workdir { + self.session.workdir() + } + + fn capabilities(&self) -> WorkdirSessionCapabilities { + // This is the provider ceiling, not a cached live authorization decision. + self.session.capabilities() + } + + async fn authorize_scope_path( + &self, + mut request: WorkdirScopeAuthorizationRequest, + ) -> Result<(), WorkdirError> { + let path = request.path.clone(); + self.with_authorized( + match request.permission { + WorkdirToolScopePermission::Read => READ, + WorkdirToolScopePermission::Write => WRITE, + }, + &path, + false, + |session, root, _| async move { + request.path = relative_path(&root, &request.path)?; + for rule in &mut request.rules { + rule.target = relative_path(&root, &rule.target)?; + } + session.authorize_scope_path(request).await + }, + ) + .await + } + + async fn scope_rules_overlap( + &self, + request: WorkdirScopeOverlapRequest, + ) -> Result { + self.require_legacy(READ)?; + self.session.scope_rules_overlap(request).await + } + + async fn checkout_search( + &self, + request: CheckoutSearchRequest, + ) -> Result { + self.require_legacy(READ)?; + self.session.checkout_search(request).await + } + + async fn checkout_observe( + &self, + path: WorkdirPath, + ) -> Result { + self.require_legacy(READ)?; + self.session.checkout_observe(path).await + } + + async fn checkout_execute( + &self, + request: CheckoutRequest, + ) -> Result { + self.require_legacy(match &request.operation { + CheckoutOperation::Read { .. } | CheckoutOperation::ReadBytes { .. } => READ, + CheckoutOperation::Write { .. } + | CheckoutOperation::Edit { .. } + | CheckoutOperation::Create { .. } => WRITE, + })?; + self.session.checkout_execute(request).await + } + + async fn stat(&self, mut request: StatRequest) -> Result { + let path = request.path.clone(); + self.with_authorized(READ, &path, false, |session, root, _| async move { + let original = request.path.clone(); + request.path = relative_path(&root, &request.path)?; + let mut result = session.stat(request).await?; + result.path = original; + Ok(result) + }) + .await + } + + async fn read(&self, mut request: ReadRequest) -> Result { + let path = request.path.clone(); + self.with_authorized(READ, &path, false, |session, root, _| async move { + let original = request.path.clone(); + request.path = relative_path(&root, &request.path)?; + let mut result = session.read(request).await?; + result.path = original; + Ok(result) + }) + .await + } + + async fn read_bytes( + &self, + mut request: ReadBytesRequest, + ) -> Result { + let path = request.path.clone(); + self.with_authorized(READ, &path, false, |session, root, _| async move { + let original = request.path.clone(); + request.path = relative_path(&root, &request.path)?; + let mut result = session.read_bytes(request).await?; + result.path = original; + Ok(result) + }) + .await + } + + async fn write(&self, mut request: WriteRequest) -> Result { + let path = request.path.clone(); + self.with_authorized(WRITE, &path, false, |session, root, _| async move { + request.path = relative_path(&root, &request.path)?; + session.write(request).await + }) + .await + } + + async fn edit(&self, mut request: EditRequest) -> Result { + let path = request.path.clone(); + self.with_authorized(WRITE, &path, false, |session, root, _| async move { + request.path = relative_path(&root, &request.path)?; + session.edit(request).await + }) + .await + } + + async fn list(&self, request: ListRequest) -> Result { + match self + .authorized_search(CheckoutSearchOperation::List(request)) + .await? + { + CheckoutSearchResult::List(result) => Ok(result), + _ => Err(WorkdirError::Unavailable( + "mismatched scoped List result".into(), + )), + } + } + + async fn glob(&self, request: GlobRequest) -> Result { + match self + .authorized_search(CheckoutSearchOperation::Glob(request)) + .await? + { + CheckoutSearchResult::Glob(result) => Ok(result), + _ => Err(WorkdirError::Unavailable( + "mismatched scoped Glob result".into(), + )), + } + } + + async fn grep(&self, request: GrepRequest) -> Result { + match self + .authorized_search(CheckoutSearchOperation::Grep(request)) + .await? + { + CheckoutSearchResult::Grep(result) => Ok(result), + _ => Err(WorkdirError::Unavailable( + "mismatched scoped Grep result".into(), + )), + } + } + + async fn start_command(&self, request: CommandRequest) -> Result { + // Validate coordinates without treating cwd as the command's access scope. + WorkdirPath::new(request.cwd.as_str()).map_err(WorkdirError::from)?; + self.authorize_command()?; + self.session.start_command(request).await + } + + async fn command_status(&self, handle: CommandHandle) -> Result { + // Reconciliation of an owned effect is not new execution authority. + self.session.command_status(handle).await + } + + async fn command_output( + &self, + request: CommandOutputRequest, + ) -> Result { + self.authorize_command()?; + self.session.command_output(request).await + } + + async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { + self.session.cancel_command(handle).await + } + + fn subscribe_command_events(&self) -> Option> { + let runtime = tokio::runtime::Handle::try_current().ok()?; + let mut source = self.session.subscribe_command_events()?; + let (sender, receiver) = broadcast::channel(64); + let client = self.client.clone(); + let resource = PermissionResource::Workdir { + working_directory_id: self.working_directory_id.clone(), + path: ".".into(), + recursive: true, + }; + // A subscription must not freeze the grant at subscription time. Check + // each new output access; terminal/status events remain usable for cleanup. + runtime.spawn(async move { + loop { + let event = tokio::select! { + _ = sender.closed() => break, + event = source.recv() => match event { + Ok(event) => event, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + }, + }; + if matches!(event, CommandEvent::Output { .. }) + && client.authorize_operation(COMMAND, &resource).is_err() + { + continue; + } + if sender.send(event).is_err() { + break; + } + } + }); + Some(receiver) + } + + fn command_snapshot(&self) -> Vec { + if self.authorize_command().is_err() { + return Vec::new(); + } + self.session.command_snapshot() + } + + async fn close(&self) -> Result<(), WorkdirError> { + self.session.close().await + } +} + +#[cfg(test)] +mod tests { + //! Component tests connect this adapter to the real LocalWorkdirSession. + //! Only the Host authorization transport/policy is replaced; these are not E2E tests. + + use super::*; + use crate::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; + use server_api::authorization::ScopedPermission; + use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }; + use tempfile::TempDir; + use workdir::LocalWorkdirSession; + + #[derive(Debug)] + struct AuthorizationClient { + legacy: bool, + revoked: AtomicBool, + requests: Mutex>, + permissions: Vec, + } + + impl AuthorizationClient { + fn new(permissions: Vec) -> Arc { + Arc::new(Self { + legacy: false, + revoked: AtomicBool::new(false), + requests: Mutex::new(Vec::new()), + permissions, + }) + } + } + + impl WorkspaceClient for AuthorizationClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn kind(&self) -> &str { + "mock-bound" + } + fn is_available(&self) -> bool { + true + } + fn execute(&self, _: WorkspaceRequest) -> Result { + panic!("the authorization client must not execute protected effects") + } + fn authorize_operation( + &self, + operation: &str, + resource: &PermissionResource, + ) -> Result<(), WorkspaceClientError> { + self.authorize_operation_scope(operation, resource) + .map(|_| ()) + } + + fn authorize_operation_scope( + &self, + operation: &str, + resource: &PermissionResource, + ) -> Result, WorkspaceClientError> { + let request = ScopedPermission { + operation: operation.into(), + resource: resource.clone(), + }; + self.requests.lock().unwrap().push(request.clone()); + if !self.revoked.load(Ordering::SeqCst) { + if self.legacy { + return Ok(None); + } + if let Some(granted) = self + .permissions + .iter() + .find(|permission| permission.covers(&request)) + { + return Ok(Some(granted.resource.clone())); + } + } + Err(WorkspaceClientError::Request( + "denied by current grant".into(), + )) + } + } + + fn permission(operation: &str, path: &str, recursive: bool) -> ScopedPermission { + ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workdir { + working_directory_id: "attachment-id".into(), + path: path.into(), + recursive, + }, + } + } + + fn session(root: &TempDir, client: Arc) -> WorkdirSessionHandle { + authorized_workdir_session( + Arc::new(LocalWorkdirSession::new( + manifest::Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + )), + client, + "attachment-id".into(), + ) + } + + fn read(path: &str) -> ReadRequest { + ReadRequest { + path: WorkdirPath::new(path).unwrap(), + offset: 0, + limit: 100, + max_bytes: 4096, + } + } + + fn command(command: &str) -> CommandRequest { + CommandRequest { + command: command.into(), + timeout_secs: 10, + transport_byte_budget: 4096, + cwd: WorkdirPath::root(), + tool_call_id: None, + } + } + + #[tokio::test] + async fn writes_check_current_exact_resource_and_denial_has_no_filesystem_effect() { + let root = tempfile::tempdir().unwrap(); + let client = AuthorizationClient::new(vec![permission(WRITE, "allowed.txt", false)]); + let session = session(&root, client.clone()); + session + .write(WriteRequest { + path: WorkdirPath::new("./allowed.txt").unwrap(), + content: b"allowed".to_vec(), + expected_hash: None, + }) + .await + .unwrap(); + assert_eq!( + std::fs::read(root.path().join("allowed.txt")).unwrap(), + b"allowed" + ); + let denied = session + .write(WriteRequest { + path: WorkdirPath::new("outside/new.txt").unwrap(), + content: b"denied".to_vec(), + expected_hash: None, + }) + .await + .unwrap_err(); + assert!(matches!(denied, WorkdirError::Denied(_))); + assert!(!root.path().join("outside").exists()); + client.revoked.store(true, Ordering::SeqCst); + let denied = session + .write(WriteRequest { + path: WorkdirPath::new("allowed.txt").unwrap(), + content: b"revoked".to_vec(), + expected_hash: None, + }) + .await + .unwrap_err(); + assert!(matches!(denied, WorkdirError::Denied(_))); + assert_eq!( + std::fs::read(root.path().join("allowed.txt")).unwrap(), + b"allowed" + ); + assert_eq!( + *client.requests.lock().unwrap(), + vec![ + permission(WRITE, "allowed.txt", false), + permission(WRITE, "outside/new.txt", false), + permission(WRITE, "allowed.txt", false), + ] + ); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn read_revocation_is_checked_again_on_the_same_local_session() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("note"), "private").unwrap(); + let client = AuthorizationClient::new(vec![permission(READ, "note", false)]); + let session = session(&root, client.clone()); + assert_eq!(session.read(read("note")).await.unwrap().bytes, b"private"); + client.revoked.store(true, Ordering::SeqCst); + assert!(matches!( + session.read(read("note")).await, + Err(WorkdirError::Denied(_)) + )); + assert_eq!(client.requests.lock().unwrap().len(), 2); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn narrow_command_grant_does_not_treat_cwd_as_a_sandbox() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("src")).unwrap(); + let client = AuthorizationClient::new(vec![permission(COMMAND, "src", true)]); + let session = session(&root, client.clone()); + let mut request = command("printf denied > escaped"); + request.cwd = WorkdirPath::new("src").unwrap(); + assert!(matches!( + session.start_command(request).await, + Err(WorkdirError::Denied(_)) + )); + assert!(!root.path().join("src/escaped").exists()); + assert_eq!( + *client.requests.lock().unwrap(), + vec![permission(COMMAND, ".", true)] + ); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn revoked_commands_cannot_start_or_read_output_but_status_and_cancel_remain_owned() { + let root = tempfile::tempdir().unwrap(); + let client = AuthorizationClient::new(vec![permission(COMMAND, ".", true)]); + let session = session(&root, client.clone()); + let handle = session + .start_command(command("exec tail -f /dev/null")) + .await + .unwrap(); + client.revoked.store(true, Ordering::SeqCst); + assert!(matches!( + session.start_command(command("printf bad > escaped")).await, + Err(WorkdirError::Denied(_)) + )); + assert!(!root.path().join("escaped").exists()); + assert!(matches!( + session + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: 0, + limit: 4096, + wait: false, + }) + .await, + Err(WorkdirError::Denied(_)) + )); + assert!(session.command_snapshot().is_empty()); + let checks_before_cleanup = client.requests.lock().unwrap().len(); + assert_eq!( + session.command_status(handle.clone()).await.unwrap(), + CommandStatus::Running + ); + assert!(matches!( + session + .cancel_command(CommandHandle("not-owned".into())) + .await, + Err(WorkdirError::UnknownCommand(_)) + )); + session.cancel_command(handle.clone()).await.unwrap(); + session.close().await.unwrap(); + assert_eq!(client.requests.lock().unwrap().len(), checks_before_cleanup); + } + + #[tokio::test] + async fn authorization_success_does_not_expand_provider_scope() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file"), "original").unwrap(); + let client = AuthorizationClient::new(vec![permission(WRITE, ".", true)]); + let local = LocalWorkdirSession::new( + manifest::Scope::from_config(&manifest::ScopeConfig { + allow: vec![manifest::ScopeRule { + target: root.path().to_path_buf(), + permission: manifest::Permission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }], + deny: Vec::new(), + }) + .unwrap(), + root.path().to_path_buf(), + ); + let session = + authorized_workdir_session(Arc::new(local), client.clone(), "attachment-id".into()); + assert!( + session + .write(WriteRequest { + path: WorkdirPath::new("file").unwrap(), + content: b"bad".to_vec(), + expected_hash: None, + }) + .await + .is_err() + ); + assert_eq!( + std::fs::read(root.path().join("file")).unwrap(), + b"original" + ); + assert_eq!(client.requests.lock().unwrap().len(), 1); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn unmappable_absolute_paths_are_denied_before_local_effects() { + let root = tempfile::tempdir().unwrap(); + let client = AuthorizationClient::new(vec![ + permission(WRITE, ".", true), + permission(COMMAND, ".", true), + ]); + let session = session(&root, client.clone()); + assert!(matches!( + session + .write(WriteRequest { + path: WorkdirPath::new_scoped(root.path().join("absolute").to_string_lossy()) + .unwrap(), + content: b"bad".to_vec(), + expected_hash: None, + }) + .await, + Err(WorkdirError::Denied(_)) + )); + assert!(!root.path().join("absolute").exists()); + assert!(client.requests.lock().unwrap().is_empty()); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn scoped_unmapped_checkout_and_overlap_are_denied_even_with_root_grants() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file"), "original").unwrap(); + let client = AuthorizationClient::new(vec![ + permission(READ, ".", true), + permission(WRITE, ".", true), + ]); + let session = session(&root, client); + assert!(matches!( + session.checkout_observe(WorkdirPath::root()).await, + Err(WorkdirError::Denied(_)) + )); + assert!(matches!( + session + .checkout_execute(CheckoutRequest { + target: WorkdirPath::new("file").unwrap(), + validator: vec![1], + operation: CheckoutOperation::Write { + content: b"bad".to_vec(), + expected_hash: [0; 32] + }, + }) + .await, + Err(WorkdirError::Denied(_)) + )); + assert!(matches!( + session + .checkout_execute(CheckoutRequest { + target: WorkdirPath::root(), + validator: vec![1], + operation: CheckoutOperation::Create { + path: WorkdirPath::new("nested/new").unwrap(), + content: b"bad".to_vec() + }, + }) + .await, + Err(WorkdirError::Denied(_)) + )); + assert!(matches!( + session + .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List( + ListRequest { + path: WorkdirPath::root(), + after: None, + limit: 10, + } + ))) + .await, + Err(WorkdirError::Denied(_)) + )); + let rule = WorkdirToolScopeRule { + target: WorkdirPath::root(), + permission: WorkdirToolScopePermission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }; + assert!(matches!( + session + .scope_rules_overlap(WorkdirScopeOverlapRequest { + left: rule.clone(), + right: rule + }) + .await, + Err(WorkdirError::Denied(_)) + )); + assert_eq!( + std::fs::read(root.path().join("file")).unwrap(), + b"original" + ); + assert!(!root.path().join("nested").exists()); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn explicit_legacy_authorization_forwards_checkout_operations() { + let root = tempfile::tempdir().unwrap(); + let client = Arc::new(AuthorizationClient { + legacy: true, + revoked: AtomicBool::new(false), + requests: Mutex::new(Vec::new()), + permissions: Vec::new(), + }); + let session = session(&root, client.clone()); + let parent = session.checkout_observe(WorkdirPath::root()).await.unwrap(); + session + .checkout_execute(CheckoutRequest { + target: parent.path, + validator: parent.validator, + operation: CheckoutOperation::Create { + path: WorkdirPath::new("nested/new").unwrap(), + content: b"legacy".to_vec(), + }, + }) + .await + .unwrap(); + assert_eq!( + session.read(read("nested/new")).await.unwrap().bytes, + b"legacy" + ); + assert_eq!(client.requests.lock().unwrap().len(), 3); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn scoped_read_then_write_keeps_provider_hash_state_and_attachment_paths() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("src")).unwrap(); + std::fs::write(root.path().join("src/file"), "original").unwrap(); + let client = AuthorizationClient::new(vec![ + permission(READ, "src", true), + permission(WRITE, "src", true), + ]); + let session = session(&root, client); + let original = session.read(read("src/file")).await.unwrap(); + assert_eq!(original.path.as_str(), "src/file"); + session + .write(WriteRequest { + path: WorkdirPath::new("src/file").unwrap(), + content: b"replacement".to_vec(), + expected_hash: Some(original.content_hash), + }) + .await + .unwrap(); + let replacement = session.read(read("src/file")).await.unwrap(); + assert_eq!(replacement.bytes, b"replacement"); + assert!(matches!( + session + .write(WriteRequest { + path: WorkdirPath::new("src/file").unwrap(), + content: b"stale".to_vec(), + expected_hash: Some(original.content_hash), + }) + .await, + Err(WorkdirError::Conflict(_)) + )); + session + .edit(EditRequest { + path: WorkdirPath::new("src/file").unwrap(), + old_string: "replacement".into(), + new_string: "edited".into(), + replace_all: false, + expected_hash: replacement.content_hash, + }) + .await + .unwrap(); + assert_eq!( + std::fs::read(root.path().join("src/file")).unwrap(), + b"edited" + ); + session.close().await.unwrap(); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn resolved_subtree_grants_reject_symlink_read_and_write_escape() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("src")).unwrap(); + std::fs::create_dir(root.path().join("private")).unwrap(); + std::fs::write(root.path().join("private/secret"), "private marker").unwrap(); + std::os::unix::fs::symlink("../private", root.path().join("src/link")).unwrap(); + let client = AuthorizationClient::new(vec![ + permission(READ, "src", true), + permission(WRITE, "src", true), + ]); + let session = session(&root, client); + assert!(session.read(read("src/link/secret")).await.is_err()); + assert!( + session + .write(WriteRequest { + path: WorkdirPath::new("src/link/new").unwrap(), + content: b"bad".to_vec(), + expected_hash: None, + }) + .await + .is_err() + ); + assert!(!root.path().join("private/new").exists()); + assert_eq!( + std::fs::read(root.path().join("private/secret")).unwrap(), + b"private marker" + ); + session.close().await.unwrap(); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn provider_scoped_recursive_traversal_does_not_leak_symlink_targets() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("src")).unwrap(); + std::fs::create_dir(root.path().join("private")).unwrap(); + std::fs::write(root.path().join("src/public.txt"), "needle public marker").unwrap(); + std::fs::write( + root.path().join("private/secret.txt"), + "needle private marker", + ) + .unwrap(); + std::os::unix::fs::symlink("../private", root.path().join("src/link")).unwrap(); + std::os::unix::fs::symlink("../private/secret.txt", root.path().join("src/alias.txt")) + .unwrap(); + let client = AuthorizationClient::new(vec![permission(READ, "src", true)]); + let session = session(&root, client); + let result = session + .glob(GlobRequest { + path: WorkdirPath::new("src").unwrap(), + pattern: "**/*.txt".into(), + limit: 100, + }) + .await + .unwrap(); + assert_eq!( + result.paths, + vec![WorkdirPath::new("src/public.txt").unwrap()] + ); + for output_mode in [ + workdir::GrepOutputMode::Content, + workdir::GrepOutputMode::FilesWithMatches, + workdir::GrepOutputMode::Count, + ] { + let result = session + .grep(GrepRequest { + path: WorkdirPath::new("src").unwrap(), + pattern: "needle".into(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode, + limit: 100, + offset: 0, + }) + .await + .unwrap(); + assert_eq!( + result.paths, + vec![WorkdirPath::new("src/public.txt").unwrap()] + ); + assert!(!result.output.contains("private")); + assert!(!result.output.contains("secret")); + assert!(!result.output.contains("alias.txt")); + assert!(result.output.contains("src/public.txt")); + assert_eq!(result.matched_files, 1); + } + // List may reject a symlinked entry instead of skipping it. Either behavior + // must avoid disclosing target metadata; successful entries stay scoped. + if let Ok(listed) = session + .list(ListRequest { + path: WorkdirPath::new("src").unwrap(), + after: None, + limit: 100, + }) + .await + { + assert!( + listed + .entries + .iter() + .all(|entry| !entry.path.as_str().contains("alias.txt") + && !entry.path.as_str().contains("link")) + ); + } + session.close().await.unwrap(); + } + + #[tokio::test] + async fn unavailable_workspace_bound_client_does_not_fall_back_to_local_authority() { + let root = tempfile::tempdir().unwrap(); + let workspace_id = crate::WorkspaceId::new("workspace").unwrap(); + let session = session( + &root, + crate::unavailable_workspace_client(Some(&workspace_id), "transport unavailable"), + ); + assert!(matches!( + session + .write(WriteRequest { + path: WorkdirPath::new("denied").unwrap(), + content: b"bad".to_vec(), + expected_hash: None, + }) + .await, + Err(WorkdirError::Denied(_)) + )); + assert!(!root.path().join("denied").exists()); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn standalone_unavailable_client_preserves_local_compatibility() { + let root = tempfile::tempdir().unwrap(); + let session = session( + &root, + crate::unavailable_workspace_client(None, "standalone test"), + ); + session + .write(WriteRequest { + path: WorkdirPath::new("local").unwrap(), + content: b"standalone".to_vec(), + expected_hash: None, + }) + .await + .unwrap(); + assert_eq!( + std::fs::read(root.path().join("local")).unwrap(), + b"standalone" + ); + session.close().await.unwrap(); + } +} diff --git a/crates/worker/src/bootstrap.rs b/crates/worker/src/bootstrap.rs index d027aa8c9..cb7771a86 100644 --- a/crates/worker/src/bootstrap.rs +++ b/crates/worker/src/bootstrap.rs @@ -17,28 +17,9 @@ use manifest::WorkerManifest; #[derive(Debug, Clone)] pub enum WorkerBootstrapLayout { /// A direct Worker rooted below the supplied runtime base directory. - Direct { - runtime_base: PathBuf, - bash_output_dir: PathBuf, - }, + Direct { runtime_base: PathBuf }, /// A runtime-managed Worker with an exact persisted run directory. - RuntimeManagedRun { - run_dir: PathBuf, - bash_output_dir: PathBuf, - }, -} - -/// Return the temporary Bash spill directory owned by a stable Worker identity. -/// -/// The directory deliberately lives outside session/run-generation storage so a -/// restarted controller for the same Worker keeps the same readable artifact -/// boundary. -pub fn bash_output_dir_for_worker_id(worker_id: impl std::fmt::Display) -> PathBuf { - std::env::temp_dir() - .join("yoi") - .join("workers") - .join(worker_id.to_string()) - .join("bash-output") + RuntimeManagedRun { run_dir: PathBuf }, } /// Construction and controller inputs that are stable for one Worker launch. @@ -145,6 +126,13 @@ where pub async fn prepare( self, ) -> Result, St>, WorkerBootstrapError> { + crate::feature::authorize_requested_manifest( + self.workspace_context.client_handle().as_ref(), + &self.manifest, + ) + .map_err(|error| { + WorkerBootstrapError::Worker(WorkerError::FeatureLifecycle(error.to_string())) + })?; let mut worker = Worker::from_manifest_with_context_and_model_client( self.manifest, self.store, @@ -197,6 +185,13 @@ where .await .map_err(WorkerBootstrapError::Worker)?; + crate::feature::authorize_requested_manifest( + worker.workspace_client_handle().as_ref(), + worker.manifest(), + ) + .map_err(|error| { + WorkerBootstrapError::Worker(WorkerError::FeatureLifecycle(error.to_string())) + })?; if let Some(workdir_session) = self.workdir_session { worker.bind_single_workdir_session(Some(workdir_session)); } @@ -272,27 +267,12 @@ where { let cleanup_sessions = worker.workdir_sessions(); let controller = match layout { - WorkerBootstrapLayout::Direct { - runtime_base, - bash_output_dir, - } => { - WorkerController::spawn_with_transport_owned( - worker, - &runtime_base, - &bash_output_dir, - transport, - ) - .await + WorkerBootstrapLayout::Direct { runtime_base } => { + WorkerController::spawn_with_transport_owned(worker, &runtime_base, transport).await } - WorkerBootstrapLayout::RuntimeManagedRun { - run_dir, - bash_output_dir, - } => { + WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => { WorkerController::spawn_runtime_managed_run_with_transport_owned( - worker, - &run_dir, - &bash_output_dir, - transport, + worker, &run_dir, transport, ) .await } @@ -313,22 +293,3 @@ where } } } - -#[cfg(test)] -mod tests { - use super::bash_output_dir_for_worker_id; - - #[test] - fn bash_output_directory_is_stable_per_worker_below_system_temp() { - let path = bash_output_dir_for_worker_id("019c1234-worker"); - - assert_eq!( - path, - std::env::temp_dir() - .join("yoi") - .join("workers") - .join("019c1234-worker") - .join("bash-output") - ); - } -} diff --git a/crates/worker/src/checkout.rs b/crates/worker/src/checkout.rs index 752c28688..04445de88 100644 --- a/crates/worker/src/checkout.rs +++ b/crates/worker/src/checkout.rs @@ -7,7 +7,7 @@ //! empty children mean "no indexable children", not "empty directory". List/Glob/Grep discover //! content; Inspect checks selected coordinates. //! -//! Interfaces/validators bind the projection incarnation, attachment generation, and provider state +//! Object validators bind the projection incarnation, attachment generation, and provider state //! so alias reuse after restore or detach cannot transfer old observations to a new connection. //! Publication also checks the generation recorded at acquisition. Possessing an Interface grants //! no authority: route-reconstructed permission inputs and current provider capability/scope are @@ -28,9 +28,8 @@ use manifest::{ToolPermissionAction, ToolPermissionConfig}; use serde_json::{Value as Json, json}; use sha2::{Digest, Sha256}; use wip_protocol::{ - Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, - OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode, - ReturnDeclaration, TypeExpr, Value, + Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration, + ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value, }; use workdir::{ CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap, @@ -117,13 +116,23 @@ impl Provider { } Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?)) } - fn wrap_validator(&self, alias: &str, generation: u64, workdir: &str, raw: &[u8]) -> Vec { + fn wrap_validator( + &self, + alias: &str, + generation: u64, + workdir: &str, + target: &WorkdirPath, + capabilities: workdir::WorkdirSessionCapabilities, + raw: &[u8], + ) -> Vec { let mut hash = Sha256::new(); for value in [ self.incarnation.as_bytes(), alias.as_bytes(), &generation.to_be_bytes(), workdir.as_bytes(), + target.as_str().as_bytes(), + format!("{capabilities:?}").as_bytes(), raw, ] { hash.update(value.len().to_be_bytes()); @@ -236,45 +245,17 @@ impl Provider { return Ok(None); } let workdir = selected.session.workdir().id().as_str().to_owned(); - let root = checkout_root(&alias); - // The router guard pins this connection across both observations. Root - // lifetime is attachment lifetime, not directory-content validator state. - let scope = if target.is_root() { - None - } else { - let root_observation = - match selected.session.checkout_observe(WorkdirPath::root()).await { - Ok(o) => o, - Err(_) => return Ok(None), - }; - self.entry_projection( - &root, - &alias, - selected.generation, - &workdir, - WorkdirPath::root(), - root_observation, - ) - }; - let Some(projection) = self.entry_projection( + let projection = self.entry_projection( path, &alias, selected.generation, &workdir, target, observation, - ) else { - return Ok(None); - }; - if path == root { - return WipPublication::self_scoped(projection).map(Some); - } - let Some(scope) = scope else { - return Ok(None); - }; - Ok(Some(WipPublication { + ); + Ok(projection.map(|projection| WipPublication { projection, - scope: Some(scope.object), + scope: None, })) } fn entry_projection( @@ -286,16 +267,44 @@ impl Provider { target: WorkdirPath, observation: CheckoutObservation, ) -> Option { - let ops = self.operations(&observation); - if ops.is_empty() { - return None; - } + use crate::file_operation::Contract as C; + let has = |cap, name| observation.capabilities.supports(cap) && self.may_allow(name); + let mut contracts = Vec::new(); let kind = if observation.kind == EntryKind::File { + if has(Cap::Read, "Read") { + contracts.extend([C::ReadLines, C::SearchText, C::ReadBytes]); + } + if has(Cap::Edit, "Edit") { + contracts.push(C::Edit); + } + if has(Cap::Write, "Write") { + contracts.push(C::Write); + } "file" } else { + if has(Cap::Read, "List") { + contracts.push(C::List); + } + if has(Cap::Glob, "Glob") { + contracts.push(C::Glob); + } + if has(Cap::Grep, "Grep") { + contracts.push(C::Grep); + } + if has(Cap::Write, "Write") { + contracts.push(C::CreateFile); + } "directory" }; - let validator = self.wrap_validator(alias, generation, workdir, &observation.validator); + let primary = *contracts.first()?; + let validator = self.wrap_validator( + alias, + generation, + workdir, + &target, + observation.capabilities, + &observation.validator, + ); let handler = Arc::new(FileHandler { provider: self.clone(), alias: alias.into(), @@ -304,86 +313,17 @@ impl Provider { target, observation, }); - let family = format!("{kind}-g{generation}"); - let mut projection = self.project(path, descriptor(ops), validator, &family, handler); - // Keep path-qualified local names (permissions/capabilities may differ - // per target) while binding every content Interface to its live entrance. - projection.interface.scope = checkout_root(alias); - projection.interface.name.push_str("#target/"); - projection.interface.name.push_str(&encode_identity(path)); - projection.object.interfaces = vec![projection.interface.clone()]; + let mut projection = self.project(path, primary.descriptor(), validator, kind, handler); + projection.interface = primary.reference(); + projection.interface_validator = Some(primary.validator()); + projection.object.interfaces = Vec::new(); + crate::file_operation::expose(&mut projection, contracts); Some(projection) } - - fn operations(&self, o: &CheckoutObservation) -> Vec { - let mut ops = Vec::new(); - let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name); - if o.kind == EntryKind::File { - if has(Cap::Read, "Read") { - ops.push(operation( - "read", - "Read text with line offsets and numbered output", - crate::file_operation::parameters("read", true).unwrap(), - )); - } - if has(Cap::Edit, "Edit") { - ops.push(operation( - "edit", - "Replace a unique string (requires prior Read)", - crate::file_operation::parameters("edit", true).unwrap(), - )); - } - if has(Cap::Write, "Write") { - ops.push(operation( - "write", - "Overwrite this existing file (requires prior Read)", - crate::file_operation::parameters("write", true).unwrap(), - )); - } - } else { - if has(Cap::Read, "List") { - ops.push(operation( - "list", - "List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; pass returned after to continue. Limit defaults to 100, maximum 1000. List permission and provider Read/enumeration authority apply. Includes hidden/ignored names per provider List policy, unlike Glob/Grep.", - vec![parameter("limit", false, TypeExpr::Integer), parameter("after", false, cursor_type())], - )); - } - if has(Cap::Glob, "Glob") { - ops.push(operation( - "glob", - "Glob within this directory", - vec![ - parameter("pattern", true, TypeExpr::String), - parameter("path", false, TypeExpr::String), - ], - )); - } - if has(Cap::Grep, "Grep") { - ops.push(operation( - "grep", - "Search within this directory with the existing Grep contract", - grep_parameters(), - )); - } - if has(Cap::Write, "Write") { - ops.push(operation("create_file", "Create a new relative file, creating permitted missing parents; never overwrite", vec![parameter("path",true,TypeExpr::String),parameter("content",true,TypeExpr::String)])); - } - } - ops - } } #[async_trait] impl WipSubtreeProvider for Arc { - fn interface_target(&self, reference: &wip_protocol::InterfaceReference) -> Option { - if reference.scope == ROOT { - return Some(ROOT.into()); - } - let (_, encoded) = reference.name.rsplit_once("#target/")?; - let path = decode_identity(encoded)?; - let (alias, _) = self.parse(&path)?; - (reference.scope == checkout_root(&alias)).then_some(path) - } async fn publication(&self, path: &str) -> Result, ProtocolError> { let _permit = tokio::time::timeout(self.deadline, self.permits.acquire()) .await @@ -588,8 +528,14 @@ impl FileHandler { if let Some(raw) = result.validator { Ok(WipOperationOutput::native_with_validator( value, - self.provider - .wrap_validator(&self.alias, self.generation, &self.workdir, &raw), + self.provider.wrap_validator( + &self.alias, + self.generation, + &self.workdir, + &self.target, + self.observation.capabilities, + &raw, + ), )) } else { Ok(WipOperationOutput::native(value)) @@ -604,6 +550,127 @@ impl WipOperationHandler for FileHandler { arguments: &BTreeMap, context: WipCallContext, ) -> Result { + if operation == "read_bytes" { + let (offset, limit) = crate::file_operation::byte_range(arguments)?; + if !self.provider.allowed("Read",&json!({"target_workdir":self.alias,"file_path":self.target.as_str(),"offset":offset,"limit":limit})) {return Err(protocol(ProtocolErrorCode::PermissionDenied,"byte read denied"));} + let _permit = + tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire()) + .await + .map_err(|_| { + protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "byte read capacity deadline", + ) + })? + .map_err(|_| protocol(ProtocolErrorCode::Internal, "byte read unavailable"))?; + let (read, raw) = tokio::time::timeout( + self.provider.deadline, + tools::checkout::read_checkout_bytes( + self.provider.router.clone(), + &self.alias, + self.generation, + self.target.clone(), + self.observation.validator.clone(), + offset, + limit, + ), + ) + .await + .map_err(|_| { + protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "byte read deadline", + ) + })? + .map_err(map_tool_error)?; + self.check_connection()?; + let value = crate::file_operation::bytes_result(arguments, &read.bytes, read.eof)?; + return Ok(WipOperationOutput::native_with_validator( + value, + self.provider.wrap_validator( + &self.alias, + self.generation, + &self.workdir, + &self.target, + self.observation.capabilities, + &raw, + ), + )); + } + if matches!(operation, "read_lines" | "search_text") { + let (offset, limit) = + crate::file_operation::window(arguments, operation == "search_text")?; + if !self.provider.allowed("Read",&json!({"target_workdir":self.alias,"file_path":self.target.as_str(),"offset":offset,"limit":limit})) { + return Err(protocol(ProtocolErrorCode::PermissionDenied,"text read denied")); + } + let _permit = + tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire()) + .await + .map_err(|_| { + protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "text read capacity deadline", + ) + })? + .map_err(|_| protocol(ProtocolErrorCode::Internal, "text read unavailable"))?; + let result = tokio::time::timeout( + self.provider.deadline, + tools::checkout::read_checkout_text( + self.provider.router.clone(), + self.provider.tracker.clone(), + &self.alias, + self.generation, + self.target.clone(), + self.observation.validator.clone(), + offset, + limit, + crate::file_operation::MAX_BYTES, + ), + ) + .await + .map_err(|_| { + protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "text read deadline", + ) + })? + .map_err(map_tool_error)?; + let (read, validator, record_read) = result; + self.check_connection()?; + if read.byte_truncated { + return Err(protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "text line window exceeds the provider byte budget; use byte ranges", + )); + } + let text = String::from_utf8_lossy(&read.bytes); + let value = crate::file_operation::page_result( + operation, + arguments, + &text, + offset, + read.truncated, + )?; + // Only a successfully rendered Read can establish mutation authority. + // Search and failed result budgets must leave prior-Read history unchanged. + if operation == "read_lines" { + record_read(); + } + return Ok(match validator { + Some(raw) => WipOperationOutput::native_with_validator( + value, + self.provider.wrap_validator( + &self.alias, + self.generation, + &self.workdir, + &self.target, + self.observation.capabilities, + &raw, + ), + ), + None => WipOperationOutput::native(value), + }); + } let tool = match operation { "list" => "List", "read" => "Read", @@ -752,14 +819,6 @@ fn join_under(parent: &WorkdirPath, relative: &str) -> Result ParameterDeclaration { - ParameterDeclaration { - name: name.into(), - required, - documentation: None, - r#type, - } -} fn operation( name: &str, summary: &str, @@ -774,53 +833,10 @@ fn operation( parameters, returns: ReturnDeclaration { documentation: None, - r#type: result_type(name == "list"), + r#type: TypeExpr::Json, }, } } -fn field(name: &str, required: bool, r#type: TypeExpr) -> FieldDeclaration { - FieldDeclaration { - name: name.into(), - required, - documentation: None, - r#type, - } -} -fn cursor_type() -> TypeExpr { - TypeExpr::Record { - fields: vec![ - field("kind", true, TypeExpr::String), - field("entry", true, TypeExpr::Entry), - ], - } -} -fn result_type(list: bool) -> TypeExpr { - let mut item = vec![field("entry", true, TypeExpr::Entry)]; - if list { - item.extend([ - field("kind", true, TypeExpr::String), - field("size", true, TypeExpr::Integer), - ]); - } - let mut fields = vec![ - field("summary", true, TypeExpr::String), - field("content", false, TypeExpr::String), - field( - "items", - true, - TypeExpr::List { - items: Box::new(TypeExpr::Record { fields: item }), - }, - ), - ]; - if list { - fields.extend([ - field("truncated", true, TypeExpr::Boolean), - field("after", false, cursor_type()), - ]); - } - TypeExpr::Record { fields } -} fn kind_name(kind: EntryKind) -> &'static str { match kind { EntryKind::Directory => "directory", @@ -841,19 +857,6 @@ fn cursor_value(alias: &str, cursor: &workdir::ListCursor) -> Value { fn descriptor(operations: Vec) -> InterfaceDescriptor { InterfaceDescriptor{format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation{summary:"AI-oriented attached Workdir operations".into(),details:Some("The route binds attachment and target. Line offsets are zero-based; text output numbers lines from one. Read-before-write and provider validators are both required. Paths are Worldspace references, never host paths.".into())}),types:Vec::new(),operations} } -fn grep_parameters() -> Vec { - let mut p = vec![parameter("pattern", true, TypeExpr::String)]; - for name in ["path", "glob", "type", "output_mode"] { - p.push(parameter(name, false, TypeExpr::String)); - } - for name in ["-A", "-B", "-C", "head_limit", "offset"] { - p.push(parameter(name, false, TypeExpr::Integer)); - } - for name in ["case_insensitive", "multiline"] { - p.push(parameter(name, false, TypeExpr::Boolean)); - } - p -} fn error(code: ProtocolErrorCode, message: &str) -> ProtocolError { ProtocolError { code, diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index f7df26e26..9c50f3d0f 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -31,11 +31,18 @@ enum WriteResponseFault { LostBody, } +#[derive(Clone, Copy)] +enum CommandResponseFault { + LostTerminal, + EmptyWaiting, +} + struct ProviderState { session: WorkdirSessionHandle, requests: Mutex>, operations: Mutex>, write_fault: Mutex>, + command_fault: Mutex>, observe_refusal: Mutex, } @@ -152,6 +159,21 @@ async fn operate( source: Box::new(WorkdirError::OutOfScope("/private/provider/path".into())), }); } + if let WorkdirSessionOperation::CommandOutput(output) = &request.operation { + if matches!( + *state.command_fault.lock().unwrap(), + Some(CommandResponseFault::EmptyWaiting) + ) { + return AxumJson(workdir::http::WorkdirSessionOperationResult::CommandOutput( + workdir::CommandOutput { + chunks: vec![], + next_cursor: output.cursor, + terminal: None, + }, + )) + .into_response(); + } + } let result = dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await; match result { @@ -159,6 +181,15 @@ async fn operate( Ok(result) => { let fault = if is_write { state.write_fault.lock().unwrap().take() + } else if matches!(&result, workdir::http::WorkdirSessionOperationResult::CommandOutput(output) if output.terminal.is_some()) + { + let mut command_fault = state.command_fault.lock().unwrap(); + if matches!(*command_fault, Some(CommandResponseFault::LostTerminal)) { + command_fault.take(); + Some(WriteResponseFault::LostBody) + } else { + None + } } else { None }; @@ -237,6 +268,7 @@ impl HttpFixture { requests: Mutex::new(Vec::new()), operations: Mutex::new(Vec::new()), write_fault: Mutex::new(None), + command_fault: Mutex::new(None), observe_refusal: Mutex::new(false), }); let app = Router::new() @@ -358,7 +390,8 @@ async fn observe_interface(runtime: &WipRuntime, path: &str) -> wip_protocol::In } async fn native_call(runtime: &WipRuntime, path: &str, operation: &str, args: Json) -> Value { - let interface = observe_interface(runtime, path).await; + observe_interface(runtime, path).await; + let interface = super::checkout_wip_tests::contract(operation).reference(); runtime .call( path.into(), @@ -620,7 +653,8 @@ async fn committed_write_unknown(fault: WriteResponseFault) { let (runtime, router) = native_runtime(remote); let file = "/checkouts/main/src/deep/a.txt"; native_call(&runtime, file, "read", json!({})).await; - let interface = observe_interface(&runtime, file).await; + observe_interface(&runtime, file).await; + let interface = super::checkout_wip_tests::contract("write").reference(); *fixture.state.write_fault.lock().unwrap() = Some(fault); let error = runtime .call( @@ -798,3 +832,175 @@ async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_wit "refusal was retried" ); } + +// Full collection/projection integration: production HTTP client, local process pipes, +// Bash collector/store, WIP codec and common file dispatch. Only HTTP response loss is injected. +#[tokio::test] +async fn remote_native_bash_retains_streams_quota_and_replays_lost_terminal_through_common_files() { + watchdog(async { + let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await; + let remote = Arc::new(f.open(TOKEN).await.unwrap()); + let router = Arc::new(WorkdirSessionRouter::new()); + router.attach(WorkdirAttachmentAlias::new("main").unwrap(), remote.clone()).unwrap(); + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None).unwrap(); + let view = tools::bash_output::BashOutputView::new(); + crate::feature::builtin::bash::mount(&mut registry, router.clone(), view, None).unwrap(); + let runtime = WipRuntime::new(WipHost::new(registry), SecurityContext::new("remote-bash"), 0).unwrap(); + *f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::LostTerminal); + let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":r"printf 'first\nneedle\n'; printf '%200000s' ''; printf '\377€' >&2; exit 7","cwd":"."}}), Default::default()).await.unwrap(); + f.public_output(run.content.as_ref().unwrap()); + let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap(); + assert_eq!(result["metadata"]["stdout_saved"], 200013); + assert_eq!(result["metadata"]["stderr_saved"], 4); + assert_eq!(result["metadata"]["terminal"]["exit_code"], 7); + assert_eq!(result["metadata"]["terminal"]["transport_complete"], true); + assert_eq!(result["metadata"]["storage_truncated"], false); + assert_eq!(result["preview_omitted"], true); + for name in ["stdout_entry", "stderr_entry", "order_entry"] { + runtime.inspect(result[name].as_str().unwrap().into(), false).await.unwrap(); + } + let stdout = result["stdout_entry"].as_str().unwrap(); + let search = runtime.invoke(stdout.into(), crate::file_operation::Contract::SearchText.reference(), "search".into(), json!({"pattern":"needle","limit":2}), Default::default()).await.unwrap(); + assert!(search.content.unwrap().contains("needle")); + let stderr = runtime.invoke(result["stderr_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"limit":4}), Default::default()).await.unwrap(); + let stderr: Json = serde_json::from_str(&stderr.content.unwrap()).unwrap(); + use base64::Engine as _; + assert_eq!(base64::engine::general_purpose::STANDARD.decode(stderr["data"].as_str().unwrap()).unwrap(), [vec![255], "€".as_bytes().to_vec()].concat()); + let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf '%4194303s' ''; printf '€'","cwd":"."}}), Default::default()).await.unwrap(); + let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap(); + assert_eq!(result["metadata"]["stdout_saved"], 4194304); + assert_eq!(result["metadata"]["stdout_seen"], 4194306); + assert_eq!(result["metadata"]["transport_truncation"], "byte_budget"); + assert_eq!(result["metadata"]["storage_truncated"], true); + let tail = runtime.invoke(result["stdout_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"offset":4194303,"limit":4}), Default::default()).await.unwrap(); + let tail: Json = serde_json::from_str(&tail.content.unwrap()).unwrap(); + assert_eq!(base64::engine::general_purpose::STANDARD.decode(tail["data"].as_str().unwrap()).unwrap(), vec![0xe2]); + assert_eq!(tail["eof"], true); + assert_eq!(f.state.operations.lock().unwrap().iter().filter(|op| matches!(op, WorkdirSessionOperation::CommandStart(_))).count(), 2); + assert!(f.state.command_fault.lock().unwrap().is_none()); + router.close_all().await.unwrap(); + f.assert_endpoints(); + }).await; +} + +#[tokio::test] +async fn waiting_remote_command_rejects_empty_response_without_http_busy_loop() { + watchdog(async { + let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await; + let remote = f.open(TOKEN).await.unwrap(); + *f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::EmptyWaiting); + let error = remote + .command_output(workdir::CommandOutputRequest { + handle: workdir::CommandHandle("unused".into()), + cursor: 0, + limit: 1024, + wait: true, + }) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::Unavailable(_))); + assert_eq!( + f.state + .operations + .lock() + .unwrap() + .iter() + .filter(|op| matches!(op, WorkdirSessionOperation::CommandOutput(_))) + .count(), + 1 + ); + remote.close().await.unwrap(); + }) + .await; +} + +#[tokio::test] +async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundaries() { + use base64::Engine as _; + let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await; + let remote = Arc::new(fixture.open(TOKEN).await.unwrap()); + let (runtime, _) = native_runtime(remote); + let path = "/checkouts/main/src/deep/a.txt"; + observe_interface(&runtime, path).await; + let output = runtime + .invoke( + path.into(), + crate::file_operation::Contract::ReadBytes.reference(), + "read".into(), + json!({"offset":1,"limit":3}), + Default::default(), + ) + .await + .unwrap(); + let json: Json = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(json["encoding"], "base64"); + assert_eq!(json["offset"], 1); + assert_eq!(json["next_offset"], 4); + let source = fixture + .state + .session + .read_bytes(workdir::ReadBytesRequest { + path: WorkdirPath::new("src/deep/a.txt").unwrap(), + offset: 1, + max_bytes: 3, + expected_hash: None, + }) + .await + .unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(json["data"].as_str().unwrap()) + .unwrap(), + source.bytes + ); +} + +#[tokio::test] +async fn http_failed_text_read_does_not_authorize_mutations() { + for operation in ["write", "edit"] { + for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] { + let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await; + let remote = fixture.open(TOKEN).await.unwrap(); + checkout_wip_tests::failed_text_read_cannot_authorize_mutation( + Arc::new(remote), + &fixture.dir.path().join("text"), + &oversized, + operation, + ) + .await; + let mutations = fixture + .state + .operations + .lock() + .unwrap() + .iter() + .filter(|op| { + matches!( + op, + WorkdirSessionOperation::CheckoutExecute(CheckoutRequest { + operation: CheckoutOperation::Write { .. } + | CheckoutOperation::Edit { .. }, + .. + }) + ) + }) + .count(); + assert_eq!( + mutations, 1, + "only the mutation after a successful Read may reach the provider" + ); + } + } +} + +#[tokio::test] +async fn http_long_utf8_search_refuses_incomplete_line_and_preserves_line_pagination() { + let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await; + let remote = fixture.open(TOKEN).await.unwrap(); + checkout_wip_tests::assert_long_utf8_search_refuses_incomplete_line( + Arc::new(remote), + fixture.dir.path(), + ) + .await; +} diff --git a/crates/worker/src/checkout_race_tests.rs b/crates/worker/src/checkout_race_tests.rs index da58fc64c..3e585db69 100644 --- a/crates/worker/src/checkout_race_tests.rs +++ b/crates/worker/src/checkout_race_tests.rs @@ -683,7 +683,12 @@ async fn committed_checkout_mutation_losing_connection_is_unknown_not_rejected_o let calls = Arc::new(AtomicUsize::new(0)); let route = object_path("main", &file.target); let mut projection = p.node(&route).await.unwrap().unwrap(); - let interface = projection.interface.clone(); + let interface = match operation { + "write" => crate::file_operation::Contract::Write, + "edit" => crate::file_operation::Contract::Edit, + _ => crate::file_operation::Contract::CreateFile, + } + .reference(); projection.handler = Arc::new(DetachBeforePublication { file, calls: calls.clone(), diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs index c4ca3698f..0577b22f2 100644 --- a/crates/worker/src/checkout_wip_tests.rs +++ b/crates/worker/src/checkout_wip_tests.rs @@ -41,13 +41,28 @@ async fn interface(r: &WipRuntime, path: &str) -> wip_protocol::InterfaceReferen .unwrap(); p.interface } +pub(super) fn contract(operation: &str) -> crate::file_operation::Contract { + use crate::file_operation::Contract as C; + match operation { + "read" => C::ReadLines, + "search" => C::SearchText, + "edit" => C::Edit, + "write" => C::Write, + "list" => C::List, + "glob" => C::Glob, + "grep" => C::Grep, + "create_file" => C::CreateFile, + _ => panic!("unknown file operation {operation}"), + } +} async fn call( r: &WipRuntime, path: &str, operation: &str, args: Json, ) -> Result { - let i = interface(r, path).await; + interface(r, path).await; + let i = contract(operation).reference(); r.call(path.into(), i, operation.into(), args, Default::default()) .await } @@ -336,12 +351,26 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() { let old = r.host.projection_live(path).await.unwrap().unwrap(); router.detach(&alias).await.unwrap(); assert!(r.host.projection_live(path).await.unwrap().is_none()); - assert!(r.host.fetch_interface_live(&old.interface).await.is_err()); + assert_eq!( + r.host + .fetch_interface_live(&old.interface) + .await + .unwrap() + .validator, + old.interface_validator + ); router.attach(alias, session(&b, false)).unwrap(); let new = r.host.projection_live(path).await.unwrap().unwrap(); - assert_ne!(old.interface, new.interface); + assert_eq!(old.interface, new.interface); assert_ne!(old.object.validator, new.object.validator); - assert!(r.host.fetch_interface_live(&old.interface).await.is_err()); + assert_eq!( + r.host + .fetch_interface_live(&old.interface) + .await + .unwrap() + .validator, + old.interface_validator + ); assert!( call( &r, @@ -354,14 +383,16 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() { ); let restored = runtime(router, None); let next = restored.host.projection_live(path).await.unwrap().unwrap(); - assert_ne!(new.interface, next.interface); + assert_eq!(new.interface, next.interface); assert_ne!(new.object.validator, next.object.validator); - assert!( + assert_eq!( restored .host .fetch_interface_live(&new.interface) .await - .is_err() + .unwrap() + .validator, + new.interface_validator ); assert_eq!( std::fs::read_to_string(b.path().join("file.txt")).unwrap(), @@ -446,7 +477,11 @@ async fn checkout_wip_policy_is_not_feature_authority_and_compatibility_is_repla let mut registry = WipMountRegistry::new(); crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None) .unwrap(); - for def in tools::routed_builtin_tools(router, tools::Tracker::new(), dir.path().join("bash")) { + for def in tools::routed_builtin_tools( + router, + tools::Tracker::new(), + tools::bash_output::BashOutputView::new(), + ) { let (meta, tool) = def(); let p = compatibility_projection(meta, tool, None).unwrap(); registry.mount(p).unwrap(); @@ -497,7 +532,7 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() { .unwrap(); let r = runtime(router, None); let path = "/checkouts/main/file.txt"; - let i = interface(&r, path).await; + interface(&r, path).await; // No rediscovery or Interface refresh between any of these operations. for (op, args) in [ ("read", json!({})), @@ -505,16 +540,23 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() { ("write", json!({"content":"saved\n"})), ("read", json!({})), ] { - r.call(path.into(), i.clone(), op.into(), args, Default::default()) - .await - .unwrap(); + r.call( + path.into(), + contract(op).reference(), + op.into(), + args, + Default::default(), + ) + .await + .unwrap(); } assert_eq!( std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), "saved\n" ); let parent = "/checkouts/main"; - let i = interface(&r, parent).await; + interface(&r, parent).await; + let i = contract("create_file").reference(); for name in ["one.txt", "nested/two.txt"] { r.call( parent.into(), @@ -617,11 +659,12 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() { .is_none() ); let file = "/checkouts/child/a.txt"; - let i = interface(&child, file).await; + interface(&child, file).await; + let i = contract("write").reference(); child .call( file.into(), - i.clone(), + contract("read").reference(), "read".into(), json!({}), Default::default(), @@ -686,7 +729,21 @@ pub(super) async fn model_invoke( operation: &str, arguments: Json, ) -> Result { - model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":inspected["interfaces"][0]["reference"], "operation":operation, "arguments":arguments})).await + let reference = inspected["interfaces"] + .as_array() + .unwrap() + .iter() + .map(|item| &item["reference"]) + .find(|r| { + Some(wip_protocol::InterfaceReference { + scope: r["scope"].as_str().unwrap().into(), + name: r["name"].as_str().unwrap().into(), + }) + .and_then(|r| crate::file_operation::Contract::from_reference(&r)) + .is_some_and(|c| c.operation() == operation) + }) + .unwrap_or(&inspected["interfaces"][0]["reference"]); + model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":reference, "operation":operation, "arguments":arguments})).await } #[tokio::test] @@ -752,10 +809,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed let r = Arc::new(runtime(router, None)); let entrance = model_inspect(r.clone(), "/checkouts/main").await; assert_eq!(entrance["path"], "/checkouts/main"); - assert_eq!( - entrance["interfaces"][0]["reference"]["scope"], - "/checkouts/main" - ); + assert_eq!(entrance["interfaces"][0]["reference"]["scope"], "/"); let projection = r .host .projection_live("/checkouts/main") @@ -829,10 +883,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed json!([]) ); let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await; - assert_eq!( - deep["interfaces"][0]["reference"]["scope"], - "/checkouts/main" - ); + assert_eq!(deep["interfaces"][0]["reference"]["scope"], "/"); for (operation, args) in [ ("list", json!({})), ("glob", json!({"pattern":"*.txt"})), @@ -926,7 +977,7 @@ async fn checkout_list_permission_cannot_be_bypassed_with_glob_or_command_identi } #[tokio::test] -async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connection_before_read() { +async fn common_scope_rejects_wrong_namespace_scope_ref_and_old_target_authority() { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("a.txt"), "same\n").unwrap(); let router = Arc::new(WorkdirSessionRouter::new()); @@ -943,8 +994,8 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti let p = r.host.projection_live(path).await.unwrap().unwrap(); let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap(); assert!( - fetched.scope_ref.is_none(), - "ref-less publication is legitimate" + fetched.scope_ref.is_some(), + "common namespace uses the Host root scope" ); // Ordinary content changes do not terminate the entrance's Interface lifetime. std::fs::write(dir.path().join("new.txt"), "fixture").unwrap(); @@ -959,12 +1010,12 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti let request = |reference, scope_ref| CallOperationRequest { target: wip_protocol::Target { path: path.into(), - validator: None, + validator: p.object.validator.clone(), }, interface: wip_protocol::InterfaceTarget { reference, scope_ref, - validator: None, + validator: fetched.validator.clone(), }, operation: "read".into(), arguments: BTreeMap::new(), @@ -996,6 +1047,654 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti router.attach(main, session(&dir, true)).unwrap(); let forged = request(p.interface.clone(), None); assert!( - matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch) + matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::ValidatorMismatch) ); } + +fn observed_common_request( + path: &str, + validator: Option>, + fetched: &FetchInterfaceResponse, + operation: &str, +) -> CallOperationRequest { + CallOperationRequest { + target: wip_protocol::Target { + path: path.into(), + validator, + }, + interface: wip_protocol::InterfaceTarget { + reference: fetched.interface.clone(), + scope_ref: fetched.scope_ref.clone(), + validator: fetched.validator.clone(), + }, + operation: operation.into(), + arguments: BTreeMap::new(), + } +} +fn file_context() -> WipCallContext { + WipCallContext { + execution: Default::default(), + security_context: "file-contract-test".into(), + } +} + +#[tokio::test] +async fn shared_contract_survives_one_attachment_update_but_old_object_authority_never_transfers() { + let left = TempDir::new().unwrap(); + let right = TempDir::new().unwrap(); + for root in [&left, &right] { + std::fs::write(root.path().join("a.txt"), "same\n").unwrap(); + } + std::fs::write(left.path().join("b.txt"), "same\n").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + let alias = WorkdirAttachmentAlias::new("left").unwrap(); + router.attach(alias.clone(), session(&left, false)).unwrap(); + router + .attach( + WorkdirAttachmentAlias::new("right").unwrap(), + session(&right, true), + ) + .unwrap(); + let r = runtime(router.clone(), None); + let path = "/checkouts/left/a.txt"; + let other = "/checkouts/right/a.txt"; + let first = r.host.projection_live(path).await.unwrap().unwrap(); + let same_bytes = r + .host + .projection_live("/checkouts/left/b.txt") + .await + .unwrap() + .unwrap(); + let independent = r.host.projection_live(other).await.unwrap().unwrap(); + assert_eq!(first.interface, independent.interface); + assert_eq!(first.descriptor, independent.descriptor); + assert_ne!(first.object.validator, same_bytes.object.validator); + let definition = r.host.fetch_interface_live(&first.interface).await.unwrap(); + let request = + observed_common_request(path, first.object.validator.clone(), &definition, "read"); + assert!(r.host.call(request.clone(), file_context()).await.is_ok()); + // Replace the same path with a different inode containing exactly the same bytes. + std::fs::write(left.path().join("replacement"), "same\n").unwrap(); + std::fs::rename(left.path().join("replacement"), left.path().join("a.txt")).unwrap(); + assert!( + matches!(r.host.call(request.clone(),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch) + ); + assert_eq!( + r.host + .fetch_interface_live(&first.interface) + .await + .unwrap() + .validator, + definition.validator + ); + assert!( + r.host + .call( + observed_common_request( + other, + independent.object.validator.clone(), + &definition, + "read" + ), + file_context() + ) + .await + .is_ok() + ); + call(&r, path, "read", json!({})).await.unwrap(); + router.detach(&alias).await.unwrap(); + router.attach(alias, session(&left, false)).unwrap(); + assert!( + matches!(r.host.call(request,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch) + ); + assert!( + r.host + .call( + observed_common_request(other, independent.object.validator, &definition, "read"), + file_context() + ) + .await + .is_ok() + ); + call(&r, path, "read", json!({})).await.unwrap(); + let before_restore = r.host.projection_live(path).await.unwrap().unwrap(); + let restored = runtime(router, None); + let restored_definition = restored + .host + .fetch_interface_live(&first.interface) + .await + .unwrap(); + assert_eq!(definition.descriptor, restored_definition.descriptor); + assert_eq!(definition.validator, restored_definition.validator); + assert!( + matches!(restored.host.call(observed_common_request(path,before_restore.object.validator,&restored_definition,"read"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch) + ); + call(&restored, path, "read", json!({})).await.unwrap(); +} + +#[tokio::test] +async fn shared_readonly_contract_rejects_wrong_membership_operation_and_interface_validator() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a.txt"), "雪.*\r\nother\n").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, true), + ) + .unwrap(); + let r = Arc::new(runtime(router, None)); + let path = "/checkouts/main/a.txt"; + let observed = model_inspect(r.clone(), path).await; + let read = json_content( + model_invoke(r.clone(), &observed, "read", json!({"limit":1})) + .await + .unwrap(), + ); + assert_eq!( + read, + json!({"content":" 1\t雪.*\n","next_offset":1,"truncated":true}) + ); + let searched = json_content( + model_invoke( + r.clone(), + &observed, + "search", + json!({"pattern":".*","limit":1}), + ) + .await + .unwrap(), + ); + assert_eq!( + searched, + json!({"matches":[{"line":1,"text":"雪.*"}],"next_offset":1,"truncated":true}) + ); + let p = r.host.projection_live(path).await.unwrap().unwrap(); + let read = r + .host + .fetch_interface_live(&contract("read").reference()) + .await + .unwrap(); + let write = r + .host + .fetch_interface_live(&contract("write").reference()) + .await + .unwrap(); + assert!( + matches!(r.host.call(observed_common_request(path,p.object.validator.clone(),&write,"write"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceMismatch) + ); + assert!( + matches!(r.host.call(observed_common_request(path,p.object.validator.clone(),&read,"missing"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::OperationNotFound) + ); + let mut stale = observed_common_request(path, p.object.validator.clone(), &read, "read"); + stale.interface.validator = Some(vec![0]); + assert!( + matches!(r.host.call(stale,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceValidatorMismatch) + ); + let mut legacy = observed_common_request(path, p.object.validator, &read, "read"); + legacy.interface.reference = InterfaceReference { + scope: "/checkouts/main".into(), + name: "yoi.checkout/old/file/v1#target/old".into(), + }; + assert!( + matches!(r.host.call(legacy,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceMismatch) + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("a.txt")).unwrap(), + "雪.*\r\nother\n" + ); +} + +// The fixture substitutes only a binary source. Real Host/Client/model tools, fixed common +// descriptors, membership, validators and output decoding stay on their production path. +struct ReadonlyBytes { + bytes: Arc>, + version: Arc, + captured: u64, +} +impl ReadonlyBytes { + fn projection(&self) -> WipProjection { + let contract = crate::file_operation::Contract::ReadBytes; + WipProjection { + route: "/virtual".into(), + capability: "virtual:bytes".into(), + kind: WipProjectionKind::Native, + object: Object { + name: "virtual".into(), + description: None, + interfaces: vec![contract.reference()], + r#ref: None, + validator: Some(self.captured.to_be_bytes().to_vec()), + }, + interface: contract.reference(), + descriptor: contract.descriptor(), + interface_validator: Some(contract.validator()), + handler: Arc::new(Self { + bytes: self.bytes.clone(), + version: self.version.clone(), + captured: self.captured, + }), + } + } +} +#[async_trait] +impl WipOperationHandler for ReadonlyBytes { + async fn call( + &self, + op: &str, + args: &BTreeMap, + _: WipCallContext, + ) -> Result { + if op != "read_bytes" { + return Err(WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::OperationNotFound, + "readonly bytes", + ))); + } + if self.captured != self.version.load(Ordering::SeqCst) { + return Err(WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::ValidatorMismatch, + "virtual source replaced", + ))); + } + let (offset, limit) = crate::file_operation::byte_range(args)?; + if offset > self.bytes.len() as u64 { + return Err(WipOperationError::Protocol(protocol_error( + ProtocolErrorCode::InvalidArguments, + "past EOF", + ))); + } + let end = (offset as usize + limit).min(self.bytes.len()); + Ok(WipOperationOutput::native( + crate::file_operation::bytes_result( + args, + &self.bytes[offset as usize..end], + end == self.bytes.len(), + )?, + )) + } +} +#[async_trait] +impl WipSubtreeProvider for ReadonlyBytes { + async fn publication(&self, path: &str) -> Result, ProtocolError> { + Ok((path == "/virtual").then(|| { + let current = Self { + bytes: self.bytes.clone(), + version: self.version.clone(), + captured: self.version.load(Ordering::SeqCst), + }; + WipPublication { + projection: current.projection(), + scope: None, + } + })) + } + async fn children(&self, _: &str) -> Result, ProtocolError> { + Ok(vec![]) + } +} +#[tokio::test] +async fn common_bytes_read_long_lines_invalid_utf8_arbitrary_boundaries_and_eof_without_write_authority() + { + use base64::Engine as _; + let mut bytes = "雪".as_bytes().to_vec(); + bytes.extend_from_slice(&[0xff, 0, 0xc0]); + bytes.extend(vec![b'x'; 100_000]); + let bytes = Arc::new(bytes); + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("raw"), bytes.as_ref()).unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, true), + ) + .unwrap(); + let source = Arc::new(ReadonlyBytes { + bytes: bytes.clone(), + version: Arc::new(std::sync::atomic::AtomicU64::new(1)), + captured: 1, + }); + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), None).unwrap(); + registry.allocate_namespace("virtual", "virtual").unwrap(); + registry.mount(source.projection()).unwrap(); + registry + .mount_subtree(WipSubtreeMount { + root: "/virtual".into(), + provider: source.clone(), + }) + .unwrap(); + let r = Arc::new(WipRuntime::from_mounts(registry, "bytes-test".into()).unwrap()); + let virtual_inspect = model_inspect(r.clone(), "/virtual").await; + let reference = virtual_inspect["interfaces"][0]["reference"].clone(); + let checkout = model_inspect(r.clone(), "/checkouts/main/raw").await; + assert!( + checkout["interfaces"] + .as_array() + .unwrap() + .iter() + .any(|i| i["reference"] == reference) + ); + for path in ["/virtual", "/checkouts/main/raw"] { + for offset in [0, 1, 2, 3, 5, 80_000, bytes.len()] { + let output=json_content(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":{"offset":offset,"limit":17}})).await.unwrap()); + let end = (offset + 17).min(bytes.len()); + assert_eq!(output["encoding"], "base64"); + assert_eq!(output["offset"], offset); + assert_eq!(output["next_offset"], end); + assert_eq!(output["eof"], end == bytes.len()); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(output["data"].as_str().unwrap()) + .unwrap(), + bytes[offset..end] + ); + } + for arguments in [ + json!({"limit":0}), + json!({"limit":32769}), + json!({"offset":-1}), + json!({"offset":9_007_199_254_740_991i64}), + json!({"offset":bytes.len()+1}), + json!({"file_path":"other"}), + ] { + assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":arguments})).await.is_err()); + } + assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":{"scope":"/","name":"yoi.file/write-text/v1"},"operation":"write","arguments":{"content":"lost"}})).await.is_err()); + } + let fetched = r + .host + .fetch_interface_live(&crate::file_operation::Contract::ReadBytes.reference()) + .await + .unwrap(); + let mut request = observed_common_request( + "/virtual", + Some(1u64.to_be_bytes().to_vec()), + &fetched, + "read", + ); + source.version.store(2, Ordering::SeqCst); + assert!( + matches!(r.host.call(request.clone(),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch) + ); + request.target.validator = Some(2u64.to_be_bytes().to_vec()); + assert!(r.host.call(request, file_context()).await.is_ok()); + assert_eq!( + std::fs::read(dir.path().join("raw")).unwrap(), + bytes.as_ref().clone() + ); +} + +#[tokio::test] +async fn scope_downgrade_changes_object_membership_not_common_contract_and_read_remains_available() +{ + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a"), "same").unwrap(); + let scope = manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()); + let session = Arc::new(LocalWorkdirSession::materialized( + dir.path().into(), + dir.path().into(), + scope.clone(), + WorkdirSessionCapabilities::READ_WRITE, + )); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), session) + .unwrap(); + let r = Arc::new(runtime(router, None)); + let path = "/checkouts/main/a"; + let old = model_inspect(r.clone(), path).await; + let current = r.host.projection_live(path).await.unwrap().unwrap(); + let definition = r + .host + .fetch_interface_live(&contract("read").reference()) + .await + .unwrap(); + scope + .update(|_| { + manifest::Scope::from_config(&manifest::ScopeConfig { + allow: vec![manifest::ScopeRule { + target: dir.path().into(), + permission: manifest::Permission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }], + deny: vec![], + }) + }) + .unwrap(); + assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":contract_json("write"),"operation":"write","arguments":{"content":"lost"}})).await.is_err()); + let new = json_content( + model_operation(r.clone(), "Inspect", json!({"path":path,"refresh":true})) + .await + .unwrap(), + ); + assert_ne!(old["object_signature"], new["object_signature"]); + assert!( + !new["interfaces"] + .as_array() + .unwrap() + .iter() + .any(|i| i["reference"]["name"] == "yoi.file/write-text/v1") + ); + assert_eq!( + r.host + .fetch_interface_live(&contract("read").reference()) + .await + .unwrap() + .validator, + definition.validator + ); + assert!( + matches!(r.host.call(observed_common_request(path,current.object.validator,&definition,"read"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch) + ); + assert_eq!( + json_content( + model_invoke(r.clone(), &new, "read", json!({})) + .await + .unwrap() + )["content"], + " 1\tsame\n" + ); +} +fn contract_json(operation: &str) -> Json { + let r = contract(operation).reference(); + json!({"scope":r.scope,"name":r.name}) +} + +#[test] +fn common_contract_registration_rejects_descriptor_redefinition_and_domain_operation_injection() { + let source = ReadonlyBytes { + bytes: Arc::new(vec![]), + version: Arc::new(std::sync::atomic::AtomicU64::new(1)), + captured: 1, + }; + let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("virtual", "virtual").unwrap(); + let mut invalid = source.projection(); + invalid.descriptor.operations[0] + .parameters + .push(ParameterDeclaration { + name: "target_override".into(), + required: false, + documentation: None, + r#type: TypeExpr::String, + }); + assert!(matches!( + registry.mount(invalid), + Err(WipMountError::InvalidProjection { .. }) + )); + let projection = source.projection(); + registry.mount(projection.clone()).unwrap(); + let mut extension = projection.descriptor.clone(); + extension.operations[0].name = "erase".into(); + assert!(matches!( + registry.contribute_operations(WipOperationContribution { + route: "/virtual".into(), + contributor: "other".into(), + interface: projection.interface, + descriptor: extension, + handler: projection.handler + }), + Err(WipMountError::InterfaceCollision { .. }) + )); +} + +pub(super) async fn failed_text_read_cannot_authorize_mutation( + source: WorkdirSessionHandle, + disk_file: &std::path::Path, + oversized: &str, + operation: &str, +) { + let original = format!("{oversized}\nknown\n"); + std::fs::write(disk_file, &original).unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), source) + .unwrap(); + let r = Arc::new(runtime(router, None)); + let inspected = model_inspect(r.clone(), "/checkouts/main/text").await; + let arguments = if operation == "write" { + json!({"content":"changed"}) + } else { + json!({"old_string":"known","new_string":"changed"}) + }; + let failure = model_invoke(r.clone(), &inspected, "read", json!({"limit":1})) + .await + .unwrap_err(); + assert!( + failure.to_string().contains("ResourceLimitExceeded"), + "{failure}" + ); + let denied = model_invoke(r.clone(), &inspected, operation, arguments.clone()).await; + assert!(denied.is_err(), "failed read authorized {operation}"); + let denied = denied.unwrap_err(); + assert!(denied.to_string().contains("InvalidArguments"), "{denied}"); + assert_eq!(std::fs::read_to_string(disk_file).unwrap(), original); + model_invoke(r.clone(), &inspected, "read", json!({"offset":1,"limit":1})) + .await + .unwrap(); + // A failed read must neither grant new authority nor erase an earlier successful Read. + assert!( + model_invoke(r.clone(), &inspected, "read", json!({"limit":1})) + .await + .is_err() + ); + model_invoke(r, &inspected, operation, arguments) + .await + .unwrap(); + let expected = if operation == "write" { + "changed".to_string() + } else { + original.replace("known", "changed") + }; + assert_eq!(std::fs::read_to_string(disk_file).unwrap(), expected); +} + +#[tokio::test] +async fn failed_text_read_does_not_authorize_write() { + for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] { + let dir = TempDir::new().unwrap(); + failed_text_read_cannot_authorize_mutation( + session(&dir, false), + &dir.path().join("text"), + &oversized, + "write", + ) + .await; + } +} + +#[tokio::test] +async fn failed_text_read_does_not_authorize_edit() { + for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] { + let dir = TempDir::new().unwrap(); + failed_text_read_cannot_authorize_mutation( + session(&dir, false), + &dir.path().join("text"), + &oversized, + "edit", + ) + .await; + } +} + +pub(super) async fn assert_long_utf8_search_refuses_incomplete_line( + source: WorkdirSessionHandle, + directory: &std::path::Path, +) { + let original = format!("{}needle\nnext\n", "雪".repeat(22_000)); + std::fs::write(directory.join("text"), &original).unwrap(); + for (name, text) in [ + ("normal", "雪\nneedle\nnext\n"), + ("unterminated", "雪\nneedle\nnext"), + ] { + std::fs::write(directory.join(name), text).unwrap(); + } + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), source) + .unwrap(); + let r = Arc::new(runtime(router, None)); + let inspected = model_inspect(r.clone(), "/checkouts/main/text").await; + let result = model_invoke( + r.clone(), + &inspected, + "search", + json!({"pattern":"needle","limit":1}), + ) + .await; + assert!( + result.is_err(), + "search advanced over a byte-truncated UTF-8 line: {result:?}" + ); + let failure = result.unwrap_err(); + assert!( + failure.to_string().contains("ResourceLimitExceeded"), + "{failure}" + ); + let read = model_invoke(r.clone(), &inspected, "read", json!({"limit":1})) + .await + .unwrap_err(); + assert!(read.to_string().contains("ResourceLimitExceeded"), "{read}"); + let write = model_invoke(r.clone(), &inspected, "write", json!({"content":"lost"})) + .await + .unwrap_err(); + assert!(write.to_string().contains("InvalidArguments"), "{write}"); + assert_eq!( + std::fs::read_to_string(directory.join("text")).unwrap(), + original + ); + for name in ["normal", "unterminated"] { + let inspected = model_inspect(r.clone(), &format!("/checkouts/main/{name}")).await; + for offset in 0..=3 { + let page = json_content( + model_invoke( + r.clone(), + &inspected, + "search", + json!({"pattern":"needle","offset":offset,"limit":1}), + ) + .await + .unwrap(), + ); + assert_eq!(page["next_offset"], (offset + 1).min(3)); + assert_eq!(page["truncated"], offset < 2); + assert_eq!( + page["matches"], + if offset == 1 { + json!([{"line":2,"text":"needle"}]) + } else { + json!([]) + } + ); + } + } +} + +#[tokio::test] +async fn long_utf8_search_refuses_incomplete_line_and_preserves_line_pagination() { + let dir = TempDir::new().unwrap(); + assert_long_utf8_search_refuses_incomplete_line(session(&dir, false), dir.path()).await; +} diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index d8be6da1a..22e528770 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -692,7 +692,6 @@ impl WorkerController { pub async fn spawn( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, @@ -701,7 +700,6 @@ impl WorkerController { let (handle, shutdown, _task) = Self::spawn_inner( worker, runtime_base, - bash_output_dir, false, None, WorkerControllerTransport::UnixSocket, @@ -715,7 +713,7 @@ impl WorkerController { pub async fn spawn_with_transport( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where @@ -723,42 +721,32 @@ impl WorkerController { St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { let (handle, shutdown, _task) = - Self::spawn_with_transport_owned(worker, runtime_base, bash_output_dir, transport) - .await?; + Self::spawn_with_transport_owned(worker, runtime_base, transport).await?; Ok((handle, shutdown)) } pub(crate) async fn spawn_with_transport_owned( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> where C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { - Self::spawn_inner( - worker, - runtime_base, - bash_output_dir, - false, - None, - transport, - ) - .await + Self::spawn_inner(worker, runtime_base, false, None, transport).await } /// Spawn a Worker owned by `worker-runtime`. /// - /// The controller uses an ephemeral directory for Unix sockets while tool - /// spill artifacts use the separately supplied Worker-owned temporary path. + /// The controller uses an ephemeral directory for Unix sockets. Bash output is + /// owned by its live Feature instance, independently of filesystem layout. /// Runtime-managed Workers do not write legacy pid/status/manifest liveness /// projections. pub async fn spawn_runtime_managed( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, @@ -767,7 +755,6 @@ impl WorkerController { let (handle, shutdown, _task) = Self::spawn_inner( worker, runtime_base, - bash_output_dir, true, None, WorkerControllerTransport::UnixSocket, @@ -780,7 +767,6 @@ impl WorkerController { pub async fn spawn_runtime_managed_run( worker: Worker, run_dir: &Path, - bash_output_dir: &Path, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, @@ -789,7 +775,6 @@ impl WorkerController { Self::spawn_runtime_managed_run_with_transport( worker, run_dir, - bash_output_dir, WorkerControllerTransport::UnixSocket, ) .await @@ -800,27 +785,23 @@ impl WorkerController { pub async fn spawn_runtime_managed_run_with_transport( worker: Worker, run_dir: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { - let (handle, shutdown, _task) = Self::spawn_runtime_managed_run_with_transport_owned( - worker, - run_dir, - bash_output_dir, - transport, - ) - .await?; + let (handle, shutdown, _task) = + Self::spawn_runtime_managed_run_with_transport_owned(worker, run_dir, transport) + .await?; Ok((handle, shutdown)) } pub(crate) async fn spawn_runtime_managed_run_with_transport_owned( worker: Worker, run_dir: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> where @@ -830,21 +811,13 @@ impl WorkerController { let parent = run_dir .parent() .ok_or_else(|| std::io::Error::other("run path has no parent"))?; - Self::spawn_inner( - worker, - parent, - bash_output_dir, - true, - Some(run_dir), - transport, - ) - .await + Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await } async fn spawn_inner( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + runtime_managed: bool, runtime_run: Option<&Path>, transport: WorkerControllerTransport, @@ -857,7 +830,6 @@ impl WorkerController { let result = Self::spawn_initialized( worker, runtime_base, - bash_output_dir, runtime_managed, runtime_run, transport, @@ -874,7 +846,7 @@ impl WorkerController { async fn spawn_initialized( mut worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + runtime_managed: bool, runtime_run: Option<&Path>, transport: WorkerControllerTransport, @@ -888,8 +860,7 @@ impl WorkerController { .map_err(std::io::Error::other)?; // === 1. Initialization (channels / RuntimeDir / worker-immutable - // snapshots / SpawnedWorkerRegistry / alerter attach / - // bash-output scope) === + // snapshots / SpawnedWorkerRegistry / alerter attach) === let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let (method_tx, method_rx) = mpsc::channel::(32); let (working_event_tx, _) = broadcast::channel::(256); @@ -897,8 +868,8 @@ impl WorkerController { let in_flight = InFlightEvents::new(working_event_tx.clone()); worker.attach_in_flight_events(in_flight.clone()); - // Runtime directory is created before tool registration because it owns - // bounded tool artifacts, and before initial status/history writes consume + // Runtime directory is created before tool registration and before + // initial status/history writes consume // the greeting we build after registration is complete. let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run { RuntimeDir::create_worker_run(run_dir).await? @@ -935,26 +906,6 @@ impl WorkerController { worker.attach_internal_worker_registry(spawned_registry.clone()); worker.attach_working_event_tx(working_event_tx.clone()); - // Bash spill artifacts are owned by the stable Worker identity rather - // than a controller session/run generation. Push a recursive - // `allow(Read)` for the exact tool output path into the Worker's shared - // runtime scope so the Workdir session and system prompt stay aligned. - let bash_output_dir = bash_output_dir.to_path_buf(); - std::fs::create_dir_all(&bash_output_dir).map_err(|e| { - std::io::Error::other(format!( - "create bash output dir {}: {e}", - bash_output_dir.display() - )) - })?; - worker - .add_scope_rules([manifest::ScopeRule { - target: bash_output_dir.clone(), - permission: manifest::Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }]) - .map_err(std::io::Error::other)?; - // === 1.5. Direct writer wiring === // // Engine callbacks fire `on_history_append` for each assistant @@ -974,7 +925,6 @@ impl WorkerController { // === 3. Tool registration (builtin / memory / spawn-orchestration) === let fs_for_view = register_worker_tools( &mut worker, - bash_output_dir, runtime_base.to_path_buf(), spawned_registry.clone(), Some(method_tx.downgrade()), @@ -1440,7 +1390,7 @@ fn validate_memory_lifecycle_targets( /// the shared state. pub(crate) async fn register_worker_tools( worker: &mut Worker, - bash_output_dir: PathBuf, + runtime_base: PathBuf, spawned_registry: Arc, parent_method_tx: Option>, @@ -1453,6 +1403,14 @@ where // Worker-immutable snapshots taken before the mutable worker borrow // below so the worker borrow doesn't conflict with reads on `worker`. let feature_config = worker.manifest().feature.clone(); + // First boundary: before any Feature initialization, Tool/WIP/service + // registration, prompt contribution, or discovery side effect. Host derives + // the live caller grant; the Profile only requests these switches. + crate::feature::authorize_requested_manifest( + worker.workspace_client_handle().as_ref(), + worker.manifest(), + ) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::PermissionDenied, error))?; let workdir_sessions = worker.workdir_sessions(); let restrict_workdir_tools_to_current_capabilities = inherited_workdir_tool_broker.is_some(); let workdir_tool_broker = Some( @@ -1485,6 +1443,7 @@ where let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none(); let prompts = worker.prompts().clone(); let tracker = tools::Tracker::new(); + let bash_output = spawned_registry.bash_output(); { let paste_store = worker.store().clone(); let paste_session_id = worker.session_id(); @@ -1507,13 +1466,13 @@ where tools::routed_builtin_tools_for_current_capabilities( workdir_sessions.clone(), tracker.clone(), - bash_output_dir.clone(), + bash_output.clone(), ) } else { tools::routed_builtin_tools( workdir_sessions.clone(), tracker.clone(), - bash_output_dir.clone(), + bash_output.clone(), ) }; worker.engine_mut().register_tools(workdir_tools); @@ -1656,10 +1615,10 @@ where if ordinary_subjektiv_features_enabled && lifecycle_enabled && let Some(host) = subjektiv_host.as_ref() - && host.host.journal_startup_granted() + && (host.host.journal_startup_granted() || host.host.journal_compaction_granted()) { - worker.install_initial_context_source(Arc::new( - crate::feature::builtin::journal::JournalStartupContextSource::new( + worker.install_session_context_source(Arc::new( + crate::feature::builtin::journal::JournalContextSource::new( host.clone(), worker.prompts(), ), @@ -1910,6 +1869,20 @@ where ) })?; } + if wip_mode { + crate::feature::builtin::bash::mount( + &mut wip_mount_registry, + worker.workdir_sessions(), + bash_output.clone(), + wip_permissions.clone(), + ) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("mount Bash output WIP projection: {error}"), + ) + })?; + } if wip_mode && let Some(feature) = &workdir_wip_feature { crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip( &mut wip_mount_registry, @@ -2040,7 +2013,6 @@ where spawner_workspace_context, parent_notifications, runtime_base.clone(), - bash_output_dir.clone(), spawner_workspace_root, workdir_tool_broker, spawned_registry.clone(), @@ -3326,6 +3298,8 @@ async fn controller_loop( } } + spawned_registry.bash_output().revoke(); + if let Some(observer) = command_observer.take() { observer.abort(); let _ = observer.await; @@ -4139,7 +4113,21 @@ mod tests { request: crate::worker::WorkspaceRequest, ) -> Result { - // Native discovery must not enumerate or perform any mutation. + if request.path == "/api/w/workspace-1/authorization/check" { + assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Post); + return Ok(crate::worker::WorkspaceResponse { + status: 200, + body: serde_json::json!({"scope":null}).to_string(), + }); + } + // Native discovery reads metadata and the current caller's ceiling, never inventory or mutations. + assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Get); + if request.path == "/api/w/workspace-1/drive/capabilities" { + return Ok(crate::worker::WorkspaceResponse { + status: 200, + body: serde_json::json!({"write":true}).to_string(), + }); + } assert_eq!(request.path, "/api/w/workspace-1/drive/root"); Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({ "entry": {"workspace_id": "workspace-1", "node_id": "1"}, @@ -4210,16 +4198,23 @@ mod tests { worker.workdir_sessions(), ) .descriptor(); - register_worker_tools( + let registered = register_worker_tools( &mut worker, - root.path().join("bash-output"), root.path().join("runtime"), SpawnedWorkerRegistry::new_for_internal_services(), None, None, ) - .await - .unwrap(); + .await; + if enabled && (!available || workspace.is_none()) { + assert_eq!( + registered.unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + assert!(requests.lock().unwrap().is_empty()); + continue; + } + registered.unwrap(); let configured = enabled && available && workspace.is_some(); let tools = worker.engine().tool_server_handle(); tools.flush_pending(); diff --git a/crates/worker/src/controller_subject_release_tests.rs b/crates/worker/src/controller_subject_release_tests.rs index 3cda546fe..d57dc4530 100644 --- a/crates/worker/src/controller_subject_release_tests.rs +++ b/crates/worker/src/controller_subject_release_tests.rs @@ -64,7 +64,6 @@ async fn prepared( WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), }, WorkerControllerTransport::InProcess, ) diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 88745da61..15f15407b 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -635,12 +635,11 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { return ExitCode::FAILURE; } }; - let bash_output_dir = crate::bash_output_dir_for_worker_id(&worker_name); + let started = match start_worker_controller( worker, WorkerBootstrapLayout::Direct { runtime_base: runtime_base.clone(), - bash_output_dir, }, WorkerControllerTransport::UnixSocket, ) diff --git a/crates/worker/src/feature.rs b/crates/worker/src/feature.rs index 3d3ea8983..6c5bf2fdd 100644 --- a/crates/worker/src/feature.rs +++ b/crates/worker/src/feature.rs @@ -1751,6 +1751,59 @@ pub enum FeaturePlanError { /// Builder/installer for enabled feature modules. pub struct FeatureRegistryBuilder { modules: Vec>, + activation_guard: Option Result<(), String> + Send + Sync>>, +} + +/// Profile switches request activation, not authority. Names are the actual +/// public config keys (not internal module ids such as `builtin:sub-worker-control`). +/// Check even switches whose runtime prerequisites will later make them inert. +/// No parent/child permission inheritance is performed here. +pub(crate) fn authorize_requested_features( + client: &dyn crate::worker::WorkspaceClient, + config: &manifest::FeatureConfig, +) -> Result<(), crate::worker::WorkspaceClientError> { + for (name, enabled) in [ + ("task", config.task.enabled), + ("memory", config.memory.enabled()), + ("subjektiv", config.subjektiv.enabled()), + ("web", config.web.enabled), + ("image", config.image.enabled), + ("sub_worker", config.sub_worker.enabled), + ("flow", config.flow.enabled), + ("worker", config.worker.enabled), + ( + "workspace_worker_discovery", + config.workspace_worker_discovery.enabled, + ), + ("objective", config.objective.enabled), + ("manage_workdir", config.manage_workdir.enabled), + ("workdir_catalog", config.workdir_catalog.enabled), + ("workspace_config", config.workspace_config.enabled), + ("drive", config.drive.enabled), + ("ticket", config.ticket.enabled), + ("merge_request", config.merge_request.any()), + ("orchestration", config.orchestration.enabled), + ] { + if enabled { + client.authorize_feature_activation(name).map_err(|error| { + crate::worker::WorkspaceClientError::Request(format!( + "Feature `{name}` activation rejected: {error}" + )) + })?; + } + } + Ok(()) +} + +pub(crate) fn authorize_requested_manifest( + client: &dyn crate::worker::WorkspaceClient, + manifest: &manifest::WorkerManifest, +) -> Result<(), crate::worker::WorkspaceClientError> { + authorize_requested_features(client, &manifest.feature)?; + if !manifest.mcp.stdio_servers.is_empty() { + client.authorize_feature_activation("mcp")?; + } + Ok(()) } fn build_feature_install_plan( @@ -1945,6 +1998,7 @@ impl Default for FeatureRegistryBuilder { fn default() -> Self { Self { modules: Vec::new(), + activation_guard: None, } } } @@ -1954,6 +2008,22 @@ impl FeatureRegistryBuilder { Self::default() } + /// Add a trusted, live preflight that runs before *any* module install. Worker + /// hosts use this in addition to the earlier pre-construction preflight. + pub fn with_activation_guard( + mut self, + guard: impl Fn() -> Result<(), String> + Send + Sync + 'static, + ) -> Self { + let previous = self.activation_guard.take(); + self.activation_guard = Some(Arc::new(move || { + if let Some(previous) = &previous { + previous()?; + } + guard() + })); + self + } + pub fn add_module(&mut self, module: M) -> &mut Self where M: FeatureModule + 'static, @@ -2025,6 +2095,42 @@ impl FeatureRegistryBuilder { hook_builder: &mut HookRegistryBuilder, mut installed_tool_names: HashMap, ) -> FeatureRegistryInstallReport { + if let Some(error) = self + .activation_guard + .as_ref() + .and_then(|guard| guard().err()) + { + return FeatureRegistryInstallReport { + reports: { + let mut descriptors = self.descriptors(); + if descriptors.is_empty() { + descriptors.push(FeatureDescriptor::builtin( + "activation", + "Feature activation", + )); + } + descriptors + .iter() + .map(|descriptor| { + let mut report = FeatureInstallReport::new(descriptor); + report + .diagnostics + .push(FeatureDiagnostic::error(error.clone())); + report.mark_skipped( + FeatureContributionKind::Diagnostic, + descriptor.id.to_string(), + "Feature activation rejected before installation", + ); + report + }) + .collect() + }, + services: FeatureServiceRegistry::default(), + background_tasks: FeatureBackgroundTaskRegistry::default(), + chat_invocations: FeatureInvocationRegistry::default(), + plan_error: None, + }; + } let descriptors: Vec<_> = self .modules .iter() @@ -2300,6 +2406,170 @@ mod tests { use serde_json::json; use std::sync::atomic::{AtomicUsize, Ordering}; + #[derive(Debug)] + struct ActivationClient { + allowed: Vec, + requested: std::sync::Mutex>, + } + + impl crate::worker::WorkspaceClient for ActivationClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn kind(&self) -> &str { + "activation-fixture" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: crate::worker::WorkspaceRequest, + ) -> Result { + use server_api::authorization::{OperationAuthorizationRequest, PermissionResource}; + assert_eq!(request.path, "/api/w/workspace/authorization/check"); + let input: OperationAuthorizationRequest = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + assert_eq!(input.resource, PermissionResource::Feature); + self.requested.lock().unwrap().push(input.operation.clone()); + Ok(crate::worker::WorkspaceResponse { + status: if self.allowed.contains(&input.operation) { + 200 + } else { + 403 + }, + body: "{}".into(), + }) + } + } + + #[test] + fn requested_activation_uses_exact_public_config_names_before_install() { + let mut config = manifest::FeatureConfig::default(); + config.workdir_catalog.enabled = false; + // Enabling each config key independently also detects default-on flags. + for name in [ + "task", + "memory", + "subjektiv", + "web", + "image", + "sub_worker", + "flow", + "worker", + "workspace_worker_discovery", + "objective", + "manage_workdir", + "workdir_catalog", + "workspace_config", + "drive", + "ticket", + "merge_request", + "orchestration", + ] { + let mut value = serde_json::to_value(&config).unwrap(); + if name == "memory" || name == "subjektiv" { + value[name]["profile"]["enabled"] = json!(true); + } else if name == "merge_request" { + value[name]["show"] = json!(true); + } else { + value[name]["enabled"] = json!(true); + } + let requested: manifest::FeatureConfig = serde_json::from_value(value).unwrap(); + let client = ActivationClient { + allowed: vec![name.into()], + requested: Default::default(), + }; + authorize_requested_features(&client, &requested).unwrap(); + assert_eq!(*client.requested.lock().unwrap(), [name], "{name}"); + } + } + + #[test] + fn ticket_operation_permission_does_not_enable_feature_or_install_services() { + let mut config = manifest::FeatureConfig::default(); + config.workdir_catalog.enabled = false; + config.ticket.enabled = true; + for allowed in [vec![], vec!["ticket.create"], vec!["ticket"]] { + let client = Arc::new(ActivationClient { + allowed: allowed.iter().map(|name| name.to_string()).collect(), + requested: Default::default(), + }); + let module = PlannedServiceFeature::new( + FeatureDescriptor::builtin("ticket", "Ticket") + .with_provided_service(provided_service("ticket.test", "1")), + ); + let calls = module.install_calls.clone(); + let config = config.clone(); + let registry = FeatureRegistryBuilder::new() + .with_module(module) + .with_activation_guard(move || { + authorize_requested_features(client.as_ref(), &config) + .map_err(|error| error.to_string()) + }); + let mut tools = Vec::new(); + let report = registry.install_into_pending(&mut tools, &mut HookRegistryBuilder::new()); + let permitted = allowed == ["ticket"]; + assert_eq!(report.has_errors(), !permitted); + assert_eq!(calls.load(Ordering::SeqCst), usize::from(permitted)); + assert!(tools.is_empty()); + if !permitted { + assert!(report.error_message().contains("ticket")); + } + } + } + + #[test] + fn local_compatibility_is_explicit_and_never_grants_workspace_operations() { + use crate::worker::{WorkspaceClient, unavailable_workspace_client}; + use server_api::authorization::PermissionResource; + let local = unavailable_workspace_client(None, "standalone"); + assert!(local.authorize_feature_activation("task").is_ok()); + assert!( + local + .authorize_operation( + "ticket.create", + &PermissionResource::Workspace { + workspace_id: "workspace".into() + } + ) + .is_err() + ); + let unbound = ActivationClient { + allowed: vec!["ticket".into()], + requested: Default::default(), + }; + assert!(unbound.authorize_feature_activation("ticket").is_ok()); + let local_files = crate::worker::WorkerWorkspaceContext::local_filesystem(None); + assert!( + local_files + .client_handle() + .authorize_feature_activation("task") + .is_ok() + ); + assert!( + local_files + .client_handle() + .authorize_operation( + "ticket.create", + &PermissionResource::Workspace { + workspace_id: "workspace".into() + } + ) + .is_err() + ); + let marker = crate::worker::marker_workspace_client(None, "local-filesystem"); + assert!( + marker.authorize_feature_activation("task").is_err(), + "display kind is not authority" + ); + let missing = unavailable_workspace_client( + Some(&crate::worker::WorkspaceId::new("workspace").unwrap()), + "Host unavailable", + ); + assert!(missing.authorize_feature_activation("ticket").is_err()); + } + #[test] fn worker_feature_composition_has_no_dynamic_plugin_install_path() { let feature_source = include_str!("feature.rs") diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index a68b8f992..dac21d1d5 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -5,6 +5,17 @@ //! an external plugin-loading surface. pub mod backend_job_result; +pub(crate) mod bash; + +/// Host metadata participants are independent of model-facing Tool enablement. +/// Disabling a Feature must not preserve stale Session-bound authority evidence. +pub(crate) fn session_metadata_lifecycles() +-> Vec> { + vec![std::sync::Arc::new( + crate::subjektiv_metadata::SessionAttributionMetadata, + )] +} + pub mod chat_invocation; pub mod drive; pub mod flow_transition; diff --git a/crates/worker/src/feature/builtin/bash.rs b/crates/worker/src/feature/builtin/bash.rs new file mode 100644 index 000000000..ecd94428a --- /dev/null +++ b/crates/worker/src/feature/builtin/bash.rs @@ -0,0 +1,451 @@ +//! Bash Feature projection. The view is instance-owned and shared only by explicit parent +//! routing. No output is indexable; committed result entries directly address immutable files. +//! Root namespace knowledge grants nothing: every publication and read checks the current view. + +use crate::{ + file_operation::{self, Contract}, + permission::permission_action_for, + wip::{ + WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount, + WipSubtreeProvider, contextual_reference, json_to_wip, wip_to_json, + }, +}; +use agen::tool::{Tool, ToolError}; +use async_trait::async_trait; +use manifest::{ToolPermissionAction, ToolPermissionConfig}; +use serde_json::{Value as Json, json}; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeMap, sync::Arc}; +use tools::{BashTool, bash_output::BashOutputView}; +use wip_protocol::{ + Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, + OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode, + ReturnDeclaration, TypeExpr, Value, +}; +use workdir::{CommandStream, WorkdirSessionRouter}; + +const ROOT: &str = "/bash-output"; + +pub(crate) fn mount( + registry: &mut WipMountRegistry, + router: Arc, + view: BashOutputView, + permissions: Option, +) -> Result<(), WipMountError> { + registry.allocate_namespace("bash", "bash-output")?; + let provider = Arc::new(Provider { + service: BashTool::new(router, view.clone()), + view, + permissions, + incarnation: uuid::Uuid::now_v7().to_string(), + }); + registry.mount(provider.root())?; + registry.replace_compatibility_tools(ROOT, ["Bash"])?; + registry.mount_subtree(WipSubtreeMount { + root: ROOT.into(), + provider: Arc::new(provider), + }) +} + +struct Provider { + service: BashTool, + view: BashOutputView, + permissions: Option, + incarnation: String, +} +impl Provider { + fn allowed(&self, tool: &str, args: &Json) -> bool { + self.view.is_active() + && self + .permissions + .as_ref() + .is_none_or(|p| permission_action_for(p, tool, args) == ToolPermissionAction::Allow) + } + fn may_allow(&self, tool: &str) -> bool { + if !self.view.is_active() { + return false; + } + let Some(p) = &self.permissions else { + return true; + }; + for rule in p.rules.iter().filter(|r| r.tool.eq_ignore_ascii_case(tool)) { + if rule.action == ToolPermissionAction::Allow { + return true; + } + if rule.pattern == "*" { + return false; + } + } + p.default_action == ToolPermissionAction::Allow + } + fn root(self: &Arc) -> WipProjection { + let interface = contextual_reference("yoi.bash/command/v1", ROOT); + let descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: Some(Documentation { + summary: "Run Bash with bounded ephemeral output; retained bytes are accessed via result entries, not listed".into(), + details: None, + }), + types: vec![], + operations: vec![OperationDeclaration { + name: "run".into(), + documentation: None, + parameters: vec![ParameterDeclaration { + name: "input".into(), + required: true, + documentation: Some(Documentation { + summary: "Bash input, validated by the existing Tool argument contract".into(), + details: Some(BashTool::input_schema().to_string()), + }), + r#type: TypeExpr::Json, + }], + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Record { + fields: vec![ + field("summary", TypeExpr::String), + field("stdout_preview", TypeExpr::String), + field("stderr_preview", TypeExpr::String), + field("preview_omitted", TypeExpr::Boolean), + field("metadata", TypeExpr::Json), + field("stdout_entry", TypeExpr::Entry), + field("stderr_entry", TypeExpr::Entry), + field("order_entry", TypeExpr::Entry), + ], + }, + }, + }], + }; + WipProjection { + route: ROOT.into(), + capability: "bash:command".into(), + kind: WipProjectionKind::Native, + object: Object { + name: "bash-output".into(), + description: Some("Bash command and nonindexable retained output".into()), + interfaces: vec![interface.clone()], + r#ref: Some(format!("bash:{}", self.incarnation)), + validator: Some(self.incarnation.as_bytes().to_vec()), + }, + interface, + interface_validator: Some(Sha256::digest(format!("{descriptor:?}")).to_vec()), + descriptor, + handler: Arc::new(Run { + provider: self.clone(), + }), + } + } + fn parse(&self, path: &str) -> Option<(String, Option)> { + let rest = path.strip_prefix("/bash-output/")?; + let (id, stream) = rest.split_once('/')?; + if uuid::Uuid::parse_str(id).ok()?.to_string() != id { + return None; + } + let stream = match stream { + "stdout" => Some(CommandStream::Stdout), + "stderr" => Some(CommandStream::Stderr), + "order" => None, + _ => return None, + }; + Some((id.into(), stream)) + } +} + +#[async_trait] +impl WipSubtreeProvider for Arc { + async fn publication(&self, path: &str) -> Result, ProtocolError> { + if !self.view.is_active() { + return Ok(None); + } + if path == ROOT { + let projection = self.root(); + return Ok(Some(WipPublication { + scope: Some(projection.object.clone()), + projection, + })); + } + let Some((id, stream)) = self.parse(path) else { + return Ok(None); + }; + let Some(metadata) = self.view.metadata(&id) else { + return Ok(None); + }; + if !self.may_allow("Read") && !self.may_allow("Grep") { + return Ok(None); + } + let mut contracts = vec![]; + if self.may_allow("Read") { + contracts.extend([Contract::ReadLines, Contract::ReadBytes]); + } + if self.may_allow("Grep") { + contracts.push(Contract::SearchText); + } + let primary = contracts[0]; + let interface = primary.reference(); + let descriptor = primary.descriptor(); + let validator = Sha256::digest(format!("{}:{id}:{stream:?}", self.incarnation)).to_vec(); + let mut projection = WipProjection { + route: path.into(), + capability: "bash:output".into(), + kind: WipProjectionKind::Native, + object: Object { + name: path.rsplit('/').next().unwrap().into(), + description: Some(format!( + "Read-only output component (stdout={} bytes, stderr={} bytes); text is UTF-8 lossy; storage_truncated={}", + metadata.stdout_saved, metadata.stderr_saved, metadata.storage_truncated + )), + interfaces: vec![interface.clone()], + r#ref: Some(format!("bash:{}:{id}:{stream:?}", self.incarnation)), + validator: Some(validator.clone()), + }, + interface, + descriptor: descriptor.clone(), + interface_validator: Some(primary.validator()), + handler: Arc::new(File { + provider: self.clone(), + id, + stream, + path: path.into(), + validator, + }), + }; + file_operation::expose(&mut projection, contracts); + Ok(Some(WipPublication { + projection, + scope: None, + })) + } + async fn children(&self, _: &str) -> Result, ProtocolError> { + Ok(vec![]) + } +} + +struct Run { + provider: Arc, +} +#[async_trait] +impl WipOperationHandler for Run { + fn is_visible(&self) -> bool { + self.provider.view.is_active() + } + fn operation_available(&self, op: &str) -> bool { + op == "run" && self.provider.may_allow("Bash") + } + async fn call( + &self, + op: &str, + args: &BTreeMap, + ctx: WipCallContext, + ) -> Result { + if op != "run" { + return Err(fail( + ProtocolErrorCode::OperationNotFound, + "unknown Bash operation", + )); + } + let input = args + .get("input") + .ok_or_else(|| fail(ProtocolErrorCode::InvalidArguments, "input is required"))?; + let input = + wip_to_json(input).map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?; + if !self.provider.allowed("Bash", &input) { + return Err(fail( + ProtocolErrorCode::PermissionDenied, + "Bash not permitted", + )); + } + let result = self + .provider + .service + .execute_result(&input.to_string(), ctx.execution) + .await + .map_err(tool_error)?; + if result.cancelled { + return Err(WipOperationError::Cancelled(result.tool_output())); + } + if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) { + return Err(WipOperationError::Interrupted(result.tool_output())); + } + // Entry fields are typed by the native Descriptor. No display-string parsing is involved. + Ok(WipOperationOutput::native(Value::Record(BTreeMap::from([ + ("summary".into(), Value::String(result.summary.clone())), + ( + "stdout_preview".into(), + Value::String(result.stdout_preview.clone()), + ), + ( + "stderr_preview".into(), + Value::String(result.stderr_preview.clone()), + ), + ( + "preview_omitted".into(), + Value::Boolean(result.preview_omitted), + ), + ("stdout_entry".into(), Value::String(result.stdout_entry())), + ("stderr_entry".into(), Value::String(result.stderr_entry())), + ("order_entry".into(), Value::String(result.order_entry())), + ( + "metadata".into(), + json_to_wip(&serde_json::to_value(result.metadata).unwrap()) + .map_err(|e| fail(ProtocolErrorCode::Internal, &e))?, + ), + ])))) + } + async fn cancel(&self, ctx: &agen::tool::ToolExecutionContext) -> Result<(), ToolError> { + self.provider.service.cancel_execution(ctx).await + } +} + +struct File { + provider: Arc, + id: String, + stream: Option, + path: String, + validator: Vec, +} +impl File { + fn order_text(&self) -> Result { + use std::fmt::Write; + let ranges = self + .provider + .view + .order(&self.id) + .ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?; + let mut text = String::new(); + for r in ranges { + writeln!(&mut text, "{:?} {} {}", r.stream, r.start, r.end).unwrap(); + } + Ok(text) + } +} +#[async_trait] +impl WipOperationHandler for File { + fn is_visible(&self) -> bool { + self.provider.view.metadata(&self.id).is_some() + } + fn object_validator(&self) -> Option> { + self.is_visible().then(|| self.validator.clone()) + } + fn operation_available(&self, op: &str) -> bool { + match op { + "read_lines" | "read_bytes" | "read" => self.provider.may_allow("Read"), + "search_text" | "search" => self.provider.may_allow("Grep"), + _ => false, + } + } + async fn call( + &self, + op: &str, + args: &BTreeMap, + _: WipCallContext, + ) -> Result { + if !self.operation_available(op) { + return Err(fail( + ProtocolErrorCode::PermissionDenied, + "output operation not permitted", + )); + } + let mut input = wip_to_json(&Value::Record(args.clone())) + .map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?; + let tool = if op == "search_text" { + input["path"] = json!(self.path); + "Grep" + } else { + input["file_path"] = json!(self.path); + "Read" + }; + if !self.provider.allowed(tool, &input) { + return Err(fail( + ProtocolErrorCode::PermissionDenied, + "output operation not permitted", + )); + } + let metadata = self + .provider + .view + .metadata(&self.id) + .ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?; + if op == "read_bytes" { + let (offset, limit) = file_operation::byte_range(args)?; + let (bytes, total) = if let Some(stream) = self.stream { + let total = if stream == CommandStream::Stdout { + metadata.stdout_saved + } else { + metadata.stderr_saved + }; + if offset > total as u64 { + return Err(fail( + ProtocolErrorCode::InvalidArguments, + "byte offset exceeds output length", + )); + } + let bytes = self + .provider + .view + .read(&self.id, stream, offset as usize, limit) + .ok_or_else(|| { + fail(ProtocolErrorCode::NotFound, "output expired or revoked") + })?; + (bytes, total) + } else { + let text = self.order_text()?; + if offset > text.len() as u64 { + return Err(fail( + ProtocolErrorCode::InvalidArguments, + "byte offset exceeds output length", + )); + } + ( + text.as_bytes()[offset as usize..(offset as usize + limit).min(text.len())] + .to_vec(), + text.len(), + ) + }; + let eof = offset + bytes.len() as u64 == total as u64; + return Ok(WipOperationOutput::native_with_validator( + file_operation::bytes_result(args, &bytes, eof)?, + self.validator.clone(), + )); + } + let (offset, limit) = file_operation::window(args, op == "search_text")?; + let value = if let Some(stream) = self.stream { + let (text, more) = self + .provider + .view + .line_window(&self.id, stream, offset, limit) + .ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))? + .map_err(|e| fail(ProtocolErrorCode::ResourceLimitExceeded, &e))?; + file_operation::page_result(op, args, &text, offset, more)? + } else { + file_operation::text_result(op, args, &self.order_text()?)? + }; + Ok(WipOperationOutput::native_with_validator( + value, + self.validator.clone(), + )) + } +} + +fn fail(code: ProtocolErrorCode, message: &str) -> WipOperationError { + WipOperationError::Protocol(ProtocolError { + code, + message: message.into(), + }) +} +fn tool_error(error: ToolError) -> WipOperationError { + match error { + ToolError::InvalidArgument(_) => fail(ProtocolErrorCode::InvalidArguments, "invalid Bash arguments"), + ToolError::Cancelled(output) => WipOperationError::Cancelled(output), + ToolError::Interrupted(output) => WipOperationError::Interrupted(output), + _ => WipOperationError::OutcomeUnknown("Bash execution failed; effects or process termination may be unconfirmed; do not automatically restart the command".into()), + } +} +fn field(name: &str, r#type: TypeExpr) -> FieldDeclaration { + FieldDeclaration { + name: name.into(), + required: true, + documentation: None, + r#type, + } +} diff --git a/crates/worker/src/feature/builtin/drive/backend.rs b/crates/worker/src/feature/builtin/drive/backend.rs index c4ec3e986..4c799d1ef 100644 --- a/crates/worker/src/feature/builtin/drive/backend.rs +++ b/crates/worker/src/feature/builtin/drive/backend.rs @@ -138,6 +138,9 @@ impl DriveBackend { ) .await } + pub async fn capabilities(&self) -> Result { + self.get("/capabilities", &serde_json::json!({})).await + } pub async fn root(&self) -> Result { let entry: DriveEntry = self .json( diff --git a/crates/worker/src/feature/builtin/drive/delegation.rs b/crates/worker/src/feature/builtin/drive/delegation.rs index 0162833c2..a0cb8640a 100644 --- a/crates/worker/src/feature/builtin/drive/delegation.rs +++ b/crates/worker/src/feature/builtin/drive/delegation.rs @@ -102,6 +102,26 @@ impl WorkspaceClient for InternalChildWorkspaceClient { self.inner.is_available() } + fn authorize_operation( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result<(), WorkspaceClientError> { + self.inner.authorize_operation(operation, resource) + } + + fn authorize_operation_scope( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result, WorkspaceClientError> { + self.inner.authorize_operation_scope(operation, resource) + } + + fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> { + self.inner.authorize_feature_activation(feature) + } + fn execute( &self, request: WorkspaceRequest, diff --git a/crates/worker/src/feature/builtin/drive/tests.rs b/crates/worker/src/feature/builtin/drive/tests.rs index dffefc3ce..10098a7f0 100644 --- a/crates/worker/src/feature/builtin/drive/tests.rs +++ b/crates/worker/src/feature/builtin/drive/tests.rs @@ -7,12 +7,14 @@ use std::collections::VecDeque; struct Client { responses: Mutex>>, requests: Mutex>, + capabilities: Mutex, } impl Client { fn new(responses: Vec) -> Arc { Arc::new(Self { responses: Mutex::new(responses.into_iter().map(Ok).collect()), requests: Mutex::new(Vec::new()), + capabilities: Mutex::new(response(DriveCapabilities { write: false })), }) } } @@ -30,7 +32,15 @@ impl WorkspaceClient for Client { &self, request: WorkspaceRequest, ) -> Result { + let capabilities = request.path == "/api/w/workspace/drive/capabilities"; self.requests.lock().unwrap().push(request); + if capabilities { + let response = self.capabilities.lock().unwrap(); + return Ok(WorkspaceResponse { + status: response.status, + body: response.body.clone(), + }); + } self.responses .lock() .unwrap() @@ -479,7 +489,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds let feature = feature(client.clone()); let mut mounts = crate::wip::WipMountRegistry::new(); wip::mount_drive_wip(&mut mounts, &feature).unwrap(); - let provider = wip::DriveProvider(feature); + let provider = wip::DriveProvider::new(feature); let path = entry_path(&entry.entry); let publication = provider.publication(&path).await.unwrap().unwrap(); assert_eq!(publication.projection.route, path); @@ -487,7 +497,8 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds publication.scope.as_ref().unwrap().r#ref, publication.projection.object.r#ref ); - assert_eq!(publication.projection.object.validator, Some(b"3".to_vec())); + assert!(publication.projection.object.validator.is_some()); + assert_ne!(publication.projection.object.validator, Some(b"3".to_vec())); assert!(provider.children("/drive").await.unwrap().is_empty()); assert!( provider @@ -504,7 +515,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds .flat_map(|op| &op.parameters) .any(|parameter| parameter.name == "expected_mutation_id") ); - assert_eq!(client.requests.lock().unwrap().len(), 1); + assert_eq!(client.requests.lock().unwrap().len(), 2); } #[derive(Debug)] @@ -656,3 +667,662 @@ async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() { ); assert_eq!(client.json.requests.lock().unwrap().len(), 1); } + +fn native_context() -> crate::wip::WipCallContext { + crate::wip::WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "drive-test".into(), + } +} +fn native_args(value: Value) -> std::collections::BTreeMap { + let wip_protocol::Value::Record(args) = crate::wip::json_to_wip(&value).unwrap() else { + panic!("named arguments required") + }; + args +} + +#[tokio::test] +async fn drive_common_text_reads_use_bounded_complete_source_and_literal_window_search() { + use crate::file_operation::Contract; + use crate::wip::WipSubtreeProvider; + let text = "α\r\nneedle.*\nNeedle.*\nlast\n"; + let mut entry = entry("2", "3"); + entry.size = Some(text.len() as u32); + let read = DriveReadTextResponse { + entry: entry.clone(), + text: text.into(), + truncated: false, + }; + let client = Client::new(vec![ + response(entry.clone()), + response(&read), + response(&read), + ]); + let feature = feature(client.clone()); + let publication = wip::DriveProvider::new(feature) + .publication(&entry_path(&entry.entry)) + .await + .unwrap() + .unwrap(); + let mut projection = publication.projection; + assert!( + projection + .object + .interfaces + .contains(&Contract::ReadLines.reference()) + ); + assert!( + projection + .object + .interfaces + .contains(&Contract::SearchText.reference()) + ); + assert!( + !projection + .object + .interfaces + .contains(&Contract::Write.reference()) + ); + assert!( + !projection + .object + .interfaces + .contains(&Contract::Edit.reference()) + ); + assert!( + projection + .descriptor + .operations + .iter() + .any(|op| op.name == "read" && op.parameters.iter().any(|p| p.name == "max_bytes")) + ); + let original_handler = projection.handler.clone(); + assert!(crate::file_operation::select( + &mut projection, + &Contract::ReadLines.reference() + )); + let read = projection + .handler + .call( + "read", + &native_args(json!({"offset":1,"limit":2})), + native_context(), + ) + .await + .unwrap_or_else(|_| panic!("common read failed")); + let value = crate::wip::wip_to_json(&read.value).unwrap(); + assert_eq!(value["next_offset"], 3); + assert_eq!(value["truncated"], true); + assert!(value["content"].as_str().unwrap().contains("needle.*")); + // The common selector wraps a provider handler once; reuse the original + // publication for the independent SearchText contract. + projection.handler = original_handler; + let request = client.requests.lock().unwrap().last().unwrap().clone(); + assert!(request.path.contains("max_bytes=65536")); + assert!(crate::file_operation::select( + &mut projection, + &Contract::SearchText.reference() + )); + let search = projection + .handler + .call( + "search", + &native_args(json!({"pattern":"needle.*","limit":3})), + native_context(), + ) + .await + .unwrap_or_else(|_| panic!("common search failed")); + let value = crate::wip::wip_to_json(&search.value).unwrap(); + assert_eq!(value["matches"], json!([{"line":2,"text":"needle.*"}])); + assert_eq!(value["next_offset"], 3); + assert_eq!(value["truncated"], true); +} + +fn read_response(entry: &DriveEntry, text: &str) -> DriveReadTextResponse { + DriveReadTextResponse { + entry: entry.clone(), + text: text.into(), + truncated: false, + } +} + +#[tokio::test] +async fn drive_common_text_refuses_truncated_mismatched_stale_and_denied_reads() { + use crate::wip::WipSubtreeProvider; + use wip_protocol::ProtocolErrorCode; + for (case, expected) in [ + ("truncated", ProtocolErrorCode::ResourceLimitExceeded), + ("size", ProtocolErrorCode::ResourceLimitExceeded), + ("stale", ProtocolErrorCode::ValidatorMismatch), + ("denied", ProtocolErrorCode::PermissionDenied), + ] { + let entry = entry("2", "3"); + let mut read = read_response(&entry, "hello"); + match case { + "truncated" => read.truncated = true, + "size" => read.text = "hell".into(), + "stale" => read.entry.last_mutation_id = "4".into(), + _ => {} + } + let read = if case == "denied" { + WorkspaceResponse { + status: 403, + body: "denied".into(), + } + } else { + response(read) + }; + let client = Client::new(vec![response(entry.clone()), read]); + let projection = wip::DriveProvider::new(feature(client.clone())) + .publication(&entry_path(&entry.entry)) + .await + .unwrap() + .unwrap() + .projection; + let result = projection + .handler + .call("read_lines", &native_args(json!({})), native_context()) + .await; + assert!( + matches!(result, Err(crate::wip::WipOperationError::Protocol(ref error)) if error.code == expected), + "{case}" + ); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 3, "{case}: no retries"); + assert!( + requests + .iter() + .all(|request| request.method == crate::worker::WorkspaceRequestMethod::Get) + ); + } +} + +#[tokio::test] +async fn drive_common_text_rejects_oversized_source_before_read_and_remount_changes_object_validator() + { + use crate::wip::WipSubtreeProvider; + let mut entry = entry("2", "3"); + entry.size = Some(server_api::DRIVE_CHUNK_MAX_BYTES + 1); + let client = Client::new(vec![response(entry.clone()), response(entry.clone())]); + let first = feature(client.clone()); + let second = first.clone(); + let path = entry_path(&entry.entry); + let first = wip::DriveProvider::new(first) + .publication(&path) + .await + .unwrap() + .unwrap() + .projection; + let second = wip::DriveProvider::new(second) + .publication(&path) + .await + .unwrap() + .unwrap() + .projection; + assert_ne!(first.object.validator, second.object.validator); + assert_eq!(first.object.interfaces, second.object.interfaces); + let result = first + .handler + .call("read_lines", &native_args(json!({})), native_context()) + .await; + assert!( + matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::ResourceLimitExceeded) + ); + assert_eq!(client.requests.lock().unwrap().len(), 4); +} + +#[tokio::test] +async fn drive_common_text_reads_keep_tool_permission_denial_before_backend_read() { + use crate::wip::WipSubtreeProvider; + let entry = entry("2", "3"); + let client = Client::new(vec![response(entry.clone())]); + let feature = feature(client.clone()).with_permissions(Some(manifest::ToolPermissionConfig { + default_action: manifest::ToolPermissionAction::Deny, + rules: Vec::new(), + })); + let projection = wip::DriveProvider::new(feature) + .publication(&entry_path(&entry.entry)) + .await + .unwrap() + .unwrap() + .projection; + let result = projection + .handler + .call("read_lines", &native_args(json!({})), native_context()) + .await; + assert!( + matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::PermissionDenied) + ); + assert_eq!(client.requests.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn drive_read_only_capability_omits_file_and_folder_mutation_contracts() { + use crate::file_operation::Contract; + use crate::wip::WipSubtreeProvider; + for (kind, root) in [ + (DriveEntryKind::File, false), + (DriveEntryKind::Folder, false), + (DriveEntryKind::Folder, true), + ] { + let mut entry = entry("2", "3"); + entry.kind = kind; + if root { + entry.parent = None; + } + let path = if root { + "/drive".into() + } else { + entry_path(&entry.entry) + }; + let client = Client::new(vec![response(entry.clone())]); + let projection = wip::DriveProvider::new(feature(client.clone())) + .publication(&path) + .await + .unwrap() + .unwrap() + .projection; + assert!( + !projection + .object + .interfaces + .contains(&Contract::Edit.reference()) + ); + assert!( + !projection + .object + .interfaces + .contains(&Contract::Write.reference()) + ); + assert!(!projection.descriptor.operations.iter().any(|op| matches!( + op.name.as_str(), + "write" + | "edit" + | "delete" + | "relocate" + | "create_folder" + | "create_text" + | "save_workdir" + ))); + let result = projection + .handler + .call( + "write", + &native_args(json!({"content":"denied"})), + native_context(), + ) + .await; + assert!( + matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::OperationNotFound) + ); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[1].path, "/api/w/workspace/drive/capabilities"); + assert_eq!( + requests[1].method, + crate::worker::WorkspaceRequestMethod::Get + ); + assert!(requests[1].body.is_none()); + } +} + +#[tokio::test] +async fn drive_capability_failure_never_publishes_an_assumed_write_ceiling() { + use crate::wip::WipSubtreeProvider; + for (status, body) in [ + (403, "denied"), + (500, "unavailable"), + (200, "{}"), + (200, "{\"write\":true,\"grant_id\":\"spoof\"}"), + ] { + let entry = entry("2", "3"); + let client = Client::new(vec![response(entry.clone())]); + *client.capabilities.lock().unwrap() = WorkspaceResponse { + status, + body: body.into(), + }; + let result = wip::DriveProvider::new(feature(client.clone())) + .publication(&entry_path(&entry.entry)) + .await; + assert!(!matches!(result, Ok(Some(_))), "{status}: {body}"); + assert_eq!(client.requests.lock().unwrap().len(), 2); + } +} + +#[tokio::test] +async fn drive_common_mutation_publication_intersects_tool_permission_ceiling() { + use crate::file_operation::Contract; + use crate::wip::WipSubtreeProvider; + let entry = entry("2", "3"); + let client = Client::new(vec![response(entry.clone())]); + *client.capabilities.lock().unwrap() = response(DriveCapabilities { write: true }); + let feature = feature(client).with_permissions(Some(manifest::ToolPermissionConfig { + default_action: manifest::ToolPermissionAction::Allow, + rules: vec![manifest::ToolPermissionRule { + tool: "DriveWrite".into(), + pattern: "*".into(), + action: manifest::ToolPermissionAction::Deny, + }], + })); + let projection = wip::DriveProvider::new(feature) + .publication(&entry_path(&entry.entry)) + .await + .unwrap() + .unwrap() + .projection; + assert!( + !projection + .object + .interfaces + .contains(&Contract::Write.reference()) + ); + assert!( + projection + .object + .interfaces + .contains(&Contract::Edit.reference()) + ); + assert!( + !projection + .descriptor + .operations + .iter() + .any(|op| matches!(op.name.as_str(), "write" | "edit")) + ); +} + +/// Narrow transport fixture for native mutation chaining. The real provider, +/// Client/Host, permission checks and Backend CAS adapter remain in the path; +/// only canonical Drive persistence is replaced by an entry and text value. +#[derive(Debug)] +struct NativeMutations(Mutex<(DriveEntry, String, Vec)>); +impl WorkspaceClient for NativeMutations { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn kind(&self) -> &str { + "drive-native-mutations" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let mut state = self.0.lock().unwrap(); + let suffix = request + .path + .strip_prefix("/api/w/workspace/drive/") + .unwrap(); + Ok(if suffix == "capabilities" { + response(DriveCapabilities { write: true }) + } else if suffix.starts_with("metadata?") { + response(&state.0) + } else if suffix.starts_with("read-text?") { + response(read_response(&state.0, &state.1)) + } else if suffix == "mutate" { + let request: DriveMutationRequest = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + let DriveMutation::UpdateText { + id, + expected_mutation_id, + text, + .. + } = &request.mutation + else { + panic!("unexpected mutation") + }; + assert_eq!(id, &state.0.entry); + assert_eq!(expected_mutation_id, &state.0.last_mutation_id); + state.1 = text.clone(); + state.0.size = Some(text.len() as u32); + state.0.last_mutation_id = request.request_id.clone(); + let output = response(DriveMutationResponse { + request_id: request.request_id.clone(), + entry: Some(state.0.clone()), + }); + state.2.push(request); + output + } else { + panic!("unexpected request: {}", request.path) + }) + } +} + +#[tokio::test] +async fn drive_common_edit_then_write_reuses_exact_post_operation_object_validator() { + use crate::file_operation::Contract; + use crate::wip::{WipMountRegistry, WipRuntime}; + let entry = entry("2", "3"); + let path = entry_path(&entry.entry); + let client = Arc::new(NativeMutations(Mutex::new(( + entry, + "hello".into(), + Vec::new(), + )))); + let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new())); + let mut mounts = WipMountRegistry::new(); + wip::mount_drive_wip(&mut mounts, &feature).unwrap(); + let runtime = WipRuntime::from_mounts(mounts, "drive-test".into()).unwrap(); + runtime + .prepare_call_observations(path.clone(), true) + .await + .unwrap(); + let edit = runtime + .call( + path.clone(), + Contract::Edit.reference(), + "edit".into(), + json!({"old_string":"hello","new_string":"updated"}), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let edit: Value = serde_json::from_str(edit.content.as_deref().unwrap()).unwrap(); + assert!(edit["metadata"]["entry"]["metadata"]["last_mutation_id"].is_string()); + assert_eq!(client.0.lock().unwrap().1, "updated"); + // No intervening Inspect/prepare_call_observations: success depends on the + // exact nonce-bound post-commit validator returned by the first operation. + let write = runtime + .call( + path, + Contract::Write.reference(), + "write".into(), + json!({"content":"final"}), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let write: Value = serde_json::from_str(write.content.as_deref().unwrap()).unwrap(); + assert!(write["metadata"]["entry"]["metadata"]["last_mutation_id"].is_string()); + let state = client.0.lock().unwrap(); + assert_eq!(state.1, "final"); + assert_eq!(state.2.len(), 2); +} + +#[tokio::test] +async fn drive_argument_dependent_write_policy_is_rechecked_and_ask_is_not_published() { + use crate::file_operation::Contract; + use crate::wip::WipSubtreeProvider; + for (default_action, pattern, action, published, code) in [ + ( + manifest::ToolPermissionAction::Deny, + "*permitted*", + manifest::ToolPermissionAction::Allow, + true, + wip_protocol::ProtocolErrorCode::PermissionDenied, + ), + ( + manifest::ToolPermissionAction::Allow, + "*", + manifest::ToolPermissionAction::Ask, + false, + wip_protocol::ProtocolErrorCode::OperationNotFound, + ), + ] { + let entry = entry("2", "3"); + let client = Client::new(vec![response(entry.clone())]); + *client.capabilities.lock().unwrap() = response(DriveCapabilities { write: true }); + let feature = + feature(client.clone()).with_permissions(Some(manifest::ToolPermissionConfig { + default_action, + rules: vec![manifest::ToolPermissionRule { + tool: "DriveWrite".into(), + pattern: pattern.into(), + action, + }], + })); + let projection = wip::DriveProvider::new(feature) + .publication(&entry_path(&entry.entry)) + .await + .unwrap() + .unwrap() + .projection; + assert_eq!( + projection + .object + .interfaces + .contains(&Contract::Write.reference()), + published + ); + let result = projection + .handler + .call( + "write", + &native_args(json!({"content":"denied"})), + native_context(), + ) + .await; + assert!( + matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == code) + ); + assert_eq!(client.requests.lock().unwrap().len(), 2); + } +} + +// A single retained Object observation models a restored Client cache. All later +// publication and execution use the real new-mount provider; the new Host supplies +// the common contract's root scope independently, without replaying its old ref. +struct RetainedObjectOnce { + current: wip::DriveProvider, + retained: Mutex>, +} +#[async_trait] +impl crate::wip::WipSubtreeProvider for RetainedObjectOnce { + async fn publication( + &self, + path: &str, + ) -> Result, wip_protocol::ProtocolError> { + let Some(mut publication) = self.current.publication(path).await? else { + return Ok(None); + }; + if let Some(retained) = self.retained.lock().unwrap().take() { + publication.projection.object = retained; + publication = crate::wip::WipPublication::self_scoped(publication.projection)?; + } + Ok(Some(publication)) + } + async fn children(&self, _: &str) -> Result, wip_protocol::ProtocolError> { + Ok(Vec::new()) + } +} + +#[tokio::test] +async fn drive_same_feature_remount_rejects_retained_object_with_fresh_common_scope_then_reobservation_succeeds() + { + use crate::file_operation::Contract; + use crate::wip::{WipMountRegistry, WipRuntime, WipSubtreeMount, WipSubtreeProvider}; + let entry = entry("2", "3"); + let path = entry_path(&entry.entry); + let client = Arc::new(NativeMutations(Mutex::new(( + entry, + "hello".into(), + Vec::new(), + )))); + let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new())); + // mount_drive_wip constructs exactly one of these providers per mount. Reuse + // the same Feature, whose mutation request identity deliberately does not rotate. + let first = wip::DriveProvider::new(feature.clone()); + let old = first.publication(&path).await.unwrap().unwrap().projection; + let cloned = first + .clone() + .publication(&path) + .await + .unwrap() + .unwrap() + .projection; + assert_eq!(old.object.validator, cloned.object.validator); + let second = wip::DriveProvider::new(feature.clone()); + let current = second.publication(&path).await.unwrap().unwrap().projection; + assert_ne!(old.object.validator, current.object.validator); + let mut old_contract = old.clone(); + let mut current_contract = current.clone(); + assert!(crate::file_operation::select( + &mut old_contract, + &Contract::ReadLines.reference() + )); + assert!(crate::file_operation::select( + &mut current_contract, + &Contract::ReadLines.reference() + )); + assert_eq!(old_contract.interface, current_contract.interface); + assert_eq!(old_contract.descriptor, current_contract.descriptor); + assert_eq!( + old_contract.interface_validator, + current_contract.interface_validator + ); + + // Mount only the selected entry for this cache-restoration fixture. The real + // Host still selects the root common contract and validates target membership, + // scope_ref and Object/Interface validators before running the Drive handler. + let mut mounts = WipMountRegistry::new(); + mounts.allocate_namespace("drive", "drive").unwrap(); + mounts.mount(current).unwrap(); + mounts + .mount_subtree(WipSubtreeMount { + root: path.clone(), + provider: Arc::new(RetainedObjectOnce { + current: second, + retained: Mutex::new(Some(old.object)), + }), + }) + .unwrap(); + let runtime = WipRuntime::from_mounts(mounts, "drive-remount".into()).unwrap(); + runtime.inspect(path.clone(), true).await.unwrap(); + let interface_path = format!( + "{}::{}", + serde_json::to_string("/").unwrap(), + serde_json::to_string(&Contract::ReadLines.reference().name).unwrap() + ); + // This fetch obtains the *new* Host's root scope_ref and the unchanged common + // Interface validator; only the retained target observation remains stale. + runtime.inspect(interface_path, true).await.unwrap(); + let error = runtime + .call( + path.clone(), + Contract::ReadLines.reference(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("ValidatorMismatch"), "{error}"); + assert!(!error.contains("InterfaceMismatch"), "{error}"); + runtime.inspect(path.clone(), true).await.unwrap(); + let result = runtime + .call( + path, + Contract::ReadLines.reference(), + "read".into(), + json!({}), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let result: Value = serde_json::from_str(result.content.as_deref().unwrap()).unwrap(); + assert!(result["content"].as_str().unwrap().contains("hello")); + assert!(client.0.lock().unwrap().2.is_empty()); +} diff --git a/crates/worker/src/feature/builtin/drive/wip.rs b/crates/worker/src/feature/builtin/drive/wip.rs index fd11101ab..d322439f9 100644 --- a/crates/worker/src/feature/builtin/drive/wip.rs +++ b/crates/worker/src/feature/builtin/drive/wip.rs @@ -40,7 +40,11 @@ pub fn mount_drive_wip( registry.allocate_namespace("drive", "drive")?; // Registration is only a namespace entrance. The subtree provides the // current target/scope/descriptor/handler in a single self-scoped snapshot. - let names = root_specs(); + // This unresolved entrance has no live capability evidence. + let names: Vec<_> = root_specs() + .into_iter() + .filter(|spec| !mutation(spec)) + .collect(); let descriptor = descriptor(&names, true); registry.mount(WipProjection { route: ROOT.into(), @@ -63,7 +67,7 @@ pub fn mount_drive_wip( })?; registry.mount_subtree(WipSubtreeMount { root: ROOT.into(), - provider: Arc::new(DriveProvider(feature.clone())), + provider: Arc::new(DriveProvider::new(feature.clone())), })?; registry.replace_compatibility_tools(ROOT, SPECS.iter().map(|spec| spec.name))?; Ok(()) @@ -80,7 +84,19 @@ impl WipOperationHandler for Unresolved { Err(map_error(DriveError::Denied)) } } -pub(super) struct DriveProvider(pub(super) DriveFeature); +#[derive(Clone)] +pub(super) struct DriveProvider { + feature: DriveFeature, + observation_nonce: Arc, +} +impl DriveProvider { + pub(super) fn new(feature: DriveFeature) -> Self { + Self { + feature, + observation_nonce: uuid::Uuid::now_v7().to_string().into(), + } + } +} #[async_trait] impl WipSubtreeProvider for DriveProvider { // Arbitrary tree depth is safe: there are deliberately no indexable entries. @@ -92,27 +108,50 @@ impl WipSubtreeProvider for DriveProvider { } async fn publication(&self, path: &str) -> Result, ProtocolError> { let result = if path == ROOT { - self.0.backend.root().await + self.feature.backend.root().await } else { - let Some(entry) = parse_entry(&self.0, path).map_err(protocol)? else { + let Some(entry) = parse_entry(&self.feature, path).map_err(protocol)? else { return Ok(None); }; - self.0.backend.metadata(&entry).await + self.feature.backend.metadata(&entry).await }; let entry = match result { Ok(entry) => entry, Err(DriveError::Denied | DriveError::NotFound) => return Ok(None), Err(error) => return Err(protocol(error)), }; + let capabilities = match self.feature.backend.capabilities().await { + Ok(capabilities) => capabilities, + Err(DriveError::Denied | DriveError::NotFound) => return Ok(None), + Err(error) => return Err(protocol(error)), + }; let root = path == ROOT; - let specs = if root { + let specs: Vec<_> = if root { root_specs() } else { entry_specs(&entry) - }; + } + .into_iter() + .filter(|spec| { + !mutation(spec) || (capabilities.write && publication_may_allow(&self.feature, spec)) + }) + .collect(); + let common_mutations: Vec<_> = specs + .iter() + .filter_map(|spec| match spec.operation { + "edit" => Some(crate::file_operation::Contract::Edit), + "write" => Some(crate::file_operation::Contract::Write), + _ => None, + }) + .collect(); let descriptor = descriptor(&specs, root); let reference = contextual_reference(INTERFACE, path); - let validator = entry.last_mutation_id.as_bytes().to_vec(); + let validator = object_validator( + &self.observation_nonce, + &entry, + &entry.last_mutation_id, + &specs, + ); let object = Object { name: if root { "drive".into() @@ -130,7 +169,8 @@ impl WipSubtreeProvider for DriveProvider { )), validator: Some(validator), }; - let projection = WipProjection { + let common_read = entry.kind == DriveEntryKind::File; + let mut projection = WipProjection { route: path.into(), capability: "drive:content".into(), kind: WipProjectionKind::Native, @@ -139,18 +179,80 @@ impl WipSubtreeProvider for DriveProvider { interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()), descriptor, handler: Arc::new(DriveHandler { - feature: self.0.clone(), + feature: self.feature.clone(), + observation_nonce: self.observation_nonce.clone(), entry, specs, root, }), }; + // This is publication evidence only. Backend execution rechecks current + // authority and the captured commit precondition on every mutation. + crate::file_operation::expose(&mut projection, common_mutations); + if common_read { + crate::file_operation::expose( + &mut projection, + [ + crate::file_operation::Contract::ReadLines, + crate::file_operation::Contract::SearchText, + ], + ); + } WipPublication::self_scoped(projection).map(Some) } async fn children(&self, _: &str) -> Result, ProtocolError> { Ok(Vec::new()) } } +fn object_validator( + observation_nonce: &str, + entry: &DriveEntry, + mutation: &str, + specs: &[Spec], +) -> Vec { + // Object observations belong to one mount, even when a restored Host reuses + // the same Feature instance. Feature.nonce separately identifies mutations. + // Current membership is part of Object representation, not the shared Interface lifetime. + let operations: Vec<_> = specs.iter().map(|spec| spec.operation).collect(); + digest( + format!( + "{observation_nonce:?}:{:?}:{:?}:{mutation:?}:{operations:?}", + entry.entry.workspace_id, entry.entry.node_id + ) + .as_bytes(), + ) + .into_bytes() +} + +fn mutation(spec: &Spec) -> bool { + matches!( + spec.operation, + "write" | "edit" | "delete" | "relocate" | "create_folder" | "create_text" | "save_workdir" + ) +} + +fn publication_may_allow(feature: &DriveFeature, spec: &Spec) -> bool { + let Some(permissions) = &feature.permissions else { + return true; + }; + // Like checkout publication, retain potentially allowed argument-dependent + // operations but hide an unconditional Deny/Ask ceiling. This is not an + // authorization decision: exact arguments are still checked at execution. + for rule in permissions + .rules + .iter() + .filter(|rule| rule.tool.eq_ignore_ascii_case(spec.name)) + { + if rule.action == manifest::ToolPermissionAction::Allow { + return true; + } + if rule.pattern == "*" { + return false; + } + } + permissions.default_action == manifest::ToolPermissionAction::Allow +} + fn root_specs() -> Vec { SPECS .iter() @@ -194,11 +296,10 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati } } fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor { - InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation { + InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().filter(|spec| !matches!(spec.operation,"edit"|"write")).map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation { "list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)], "search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)], "read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)], - "write"|"edit"=>crate::file_operation::parameters(spec.operation,false).expect("shared text arguments"), "create_text"=>vec![parameter("name",true,TypeExpr::String),parameter("content",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)], "create_folder"=>vec![parameter("name",true,TypeExpr::String)], "relocate"=>vec![parameter("parent",true,TypeExpr::Entry),parameter("name",true,TypeExpr::String)], @@ -209,6 +310,7 @@ fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor { } struct DriveHandler { feature: DriveFeature, + observation_nonce: Arc, entry: DriveEntry, specs: Vec, root: bool, @@ -221,21 +323,34 @@ impl WipOperationHandler for DriveHandler { arguments: &BTreeMap, context: WipCallContext, ) -> Result { + let common_text = matches!(operation, "read_lines" | "search_text"); + let backend_operation = if common_text { "read" } else { operation }; let spec = self .specs .iter() - .find(|spec| spec.operation == operation) + .find(|spec| spec.operation == backend_operation) .ok_or_else(|| { failure( ProtocolErrorCode::OperationNotFound, "Drive operation is not published", ) })?; - let declaration = descriptor(&self.specs, self.root) - .operations - .into_iter() - .find(|op| op.name == operation) - .unwrap(); + let contract = match operation { + "read_lines" => Some(crate::file_operation::Contract::ReadLines), + "search_text" => Some(crate::file_operation::Contract::SearchText), + "edit" => Some(crate::file_operation::Contract::Edit), + "write" => Some(crate::file_operation::Contract::Write), + _ => None, + }; + let declaration = if let Some(contract) = contract { + contract.descriptor().operations.into_iter().next().unwrap() + } else { + descriptor(&self.specs, self.root) + .operations + .into_iter() + .find(|op| op.name == operation) + .unwrap() + }; if arguments .keys() .any(|key| !declaration.parameters.iter().any(|p| p.name == *key)) @@ -244,8 +359,22 @@ impl WipOperationHandler for DriveHandler { "unrecognized arguments; committed-request observations are Client-managed".into(), ))); } - let mut input = wip_to_json(&Value::Record(arguments.clone())) - .map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?; + let mut input = if common_text { + // Validate the common input before performing any source read. Its + // line window is evaluated only after the complete bounded read. + crate::file_operation::text_result(operation, arguments, "")?; + if self + .entry + .size + .is_some_and(|size| size > server_api::DRIVE_CHUNK_MAX_BYTES) + { + return Err(map_error(DriveError::Limit)); + } + json!({"entry": self.entry.entry, "max_bytes": server_api::DRIVE_CHUNK_MAX_BYTES}) + } else { + wip_to_json(&Value::Record(arguments.clone())) + .map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))? + }; let fields = input .as_object_mut() .ok_or_else(|| map_error(DriveError::Invalid("named arguments required".into())))?; @@ -298,6 +427,19 @@ impl WipOperationHandler for DriveHandler { ) .await .map_err(map_error)?; + if common_text { + let content = complete_text(&output.value)?; + let value = crate::file_operation::text_result(operation, arguments, content)?; + return Ok(WipOperationOutput::native_with_validator( + value, + object_validator( + &self.observation_nonce, + &self.entry, + &self.entry.last_mutation_id, + &self.specs, + ), + )); + } let value = json_to_wip(&output.value).map_err(|_| { WipOperationError::OutcomeUnknown( "Completed Drive result unavailable; query request_status before another mutation" @@ -314,13 +456,39 @@ impl WipOperationHandler for DriveHandler { "Committed Drive request identity unavailable".into(), ) })?; - WipOperationOutput::native_with_validator(value, last_mutation_id.as_bytes().to_vec()) + WipOperationOutput::native_with_validator( + value, + object_validator( + &self.observation_nonce, + &self.entry, + last_mutation_id, + &self.specs, + ), + ) } else { WipOperationOutput::native(value) }; Ok(result.with_attachments(output.attachments)) } } +/// A bounded line/search contract cannot claim that a partial blob is the full +/// source. Retain Drive's separate whole-text read with its truncation flag. +fn complete_text(value: &serde_json::Value) -> Result<&str, WipOperationError> { + let content = value["text"] + .as_str() + .ok_or_else(|| map_error(DriveError::Unavailable))?; + let size = value["entry"]["metadata"]["size"] + .as_u64() + .ok_or_else(|| map_error(DriveError::Unavailable))?; + if value["truncated"].as_bool() != Some(false) + || size != content.len() as u64 + || content.len() > server_api::DRIVE_CHUNK_MAX_BYTES as usize + { + return Err(map_error(DriveError::Limit)); + } + Ok(content) +} + fn parse_entry(feature: &DriveFeature, path: &str) -> Result, DriveError> { let workspace = feature.backend.workspace()?; let prefix = format!( diff --git a/crates/worker/src/feature/builtin/journal.rs b/crates/worker/src/feature/builtin/journal.rs index 990cd347a..e5de44038 100644 --- a/crates/worker/src/feature/builtin/journal.rs +++ b/crates/worker/src/feature/builtin/journal.rs @@ -23,7 +23,7 @@ use crate::hook::{ use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use async_trait::async_trait; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::sync::Arc; use subjektiv::journal::transport::*; @@ -32,12 +32,73 @@ use subjektiv::journal::{ MAX_JOURNAL_RESPONSE_BYTES, }; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct JournalStartupInput { + pub snapshot: subjektiv::journal::startup::JournalStartupSnapshot, + pub snapshot_sha256: String, + pub prompt_provenance: session_store::PromptRenderProvenance, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum JournalReadStatus { + Available, + Absent, + Disabled, + Unavailable, +} +impl JournalReadStatus { + fn as_str(self) -> &'static str { + match self { + Self::Available => "available", + Self::Absent => "absent", + Self::Disabled => "disabled", + Self::Unavailable => "unavailable", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct JournalCompactionInput { + status: JournalReadStatus, + snapshot: Option, + snapshot_sha256: Option, + prompt_provenance: session_store::PromptRenderProvenance, +} + +fn journal_input( + body: String, + payload: session_store::FeatureInputPayload, + kind: &str, +) -> Result { + use session_store::*; + let public = PublicSystemItem { + kind: kind.into(), + body: body.clone(), + presentation: PublicSystemPresentation::Context, + }; + let metadata = HistoryInputMetadata::new( + HistoryInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::ReferenceInput, + }, + Some(payload), + Some(public), + ) + .map_err(|e| e.to_string())?; + SystemInputEnvelope::new(body, metadata).map_err(|e| e.to_string()) +} + /// Subjektiv owns startup projection; Core only commits its typed initial items. -pub(crate) struct JournalStartupContextSource { +pub(crate) struct JournalContextSource { host: SubjektivHostConnection, prompts: Arc>, } -impl JournalStartupContextSource { +impl JournalContextSource { pub(crate) fn new( host: SubjektivHostConnection, prompts: Arc>, @@ -46,9 +107,12 @@ impl JournalStartupContextSource { } } #[async_trait] -impl crate::worker::InitialContextSource for JournalStartupContextSource { +impl crate::worker::SessionContextSource for JournalContextSource { async fn load(&self) -> Result, String> { use sha2::{Digest, Sha256}; + if !self.host.host.journal_startup_granted() { + return Ok(Vec::new()); + } let snapshot = self .host .journal_startup_context() @@ -58,8 +122,8 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource { let empty = snapshot.journals.is_empty() && !snapshot.omitted; // Hash the same canonical JSON value retained by SystemItem, so replay // can verify provenance without depending on domain struct field order. - let snapshot = serde_json::to_value(snapshot).map_err(|e| e.to_string())?; - let snapshot_json = serde_json::to_string(&snapshot).map_err(|e| e.to_string())?; + let snapshot_value = serde_json::to_value(&snapshot).map_err(|e| e.to_string())?; + let snapshot_json = serde_json::to_string(&snapshot_value).map_err(|e| e.to_string())?; if snapshot_json.len() > subjektiv::journal::startup::STARTUP_MAX_BYTES { return Err("Host Journal startup snapshot exceeds independent byte budget".into()); } @@ -74,9 +138,10 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource { return Err("Journal startup prompt exceeds rendered byte budget".into()); } let projection = prompts.projection(); - Ok(vec![session_store::LoggedSystemHistoryEntry { - item: session_store::SystemItem::JournalStartup { - body, + let payload = session_store::FeatureInputPayload::encode( + "journal:startup", + 1, + &JournalStartupInput { snapshot, snapshot_sha256: Sha256::digest(snapshot_json.as_bytes()) .iter() @@ -89,11 +154,38 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource { logical_name: "internal.subjektiv_journal_startup".into(), }, }, - metadata: crate::session_history::metadata( - session_store::LoggedSessionHistoryOrigin::JournalContext, - None, - ), - }]) + ) + .map_err(|e| e.to_string())?; + Ok(vec![session_store::LoggedSystemHistoryEntry::from_input( + session_store::LoggedSessionHistoryOrigin::Provided { + producer: "subjektiv.journal".into(), + }, + None, + journal_input(body, payload, "journal_startup")?, + )]) + } + + async fn load_compaction_context( + &self, + session: session_store::SessionId, + ) -> Result, String> { + // A changed Session must never reuse the old source's Host ownership. + let connection = if self.host.context.session_id == session.to_string() { + Ok(Some(self.host.clone())) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "context source belongs to a different Session", + )) + }; + let input = freeze_compaction_journal(connection, &self.prompts.load_full())?; + Ok(vec![session_store::LoggedSystemHistoryEntry::from_input( + session_store::LoggedSessionHistoryOrigin::Provided { + producer: "subjektiv.journal".into(), + }, + None, + input, + )]) } } @@ -103,7 +195,8 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource { pub(crate) fn freeze_compaction_journal( connection: std::io::Result>, prompts: &crate::prompt::catalog::PromptCatalog, -) -> Result { +) -> Result { + use JournalReadStatus::*; let workspace_id = connection.as_ref().ok().and_then(|connection| { connection .as_ref() @@ -116,24 +209,25 @@ pub(crate) fn freeze_compaction_journal( Ok(_) => None, Err(_) => Some(Err(())), }; + let snapshot = read.as_ref().and_then(|read| read.as_ref().ok()).cloned(); let (status, json) = match read { - None => ("disabled", String::new()), + None => (Disabled, String::new()), Some(Ok(snapshot)) => { let status = if snapshot.journals.is_empty() { - "absent" + Absent } else { - "available" + Available }; match serde_json::to_string(&snapshot) { Ok(json) if json.len() <= subjektiv::journal::startup::STARTUP_MAX_BYTES => { (status, json) } - _ => ("unavailable", String::new()), + _ => (Unavailable, String::new()), } } - Some(Err(())) => ("unavailable", String::new()), + Some(Err(())) => (Unavailable, String::new()), }; - if status == "unavailable" { + if status == Unavailable { tracing::warn!("Journal compaction snapshot unavailable; continuing without Journal"); } use sha2::{Digest, Sha256}; @@ -143,12 +237,35 @@ pub(crate) fn freeze_compaction_journal( .map(|byte| format!("{byte:02x}")) .collect() }); - prompts.subjektiv_journal_compaction( - status, - &json, - snapshot_sha256.as_deref(), - workspace_id.as_deref(), + let body = prompts + .subjektiv_journal_compaction( + status.as_str(), + &json, + snapshot_sha256.as_deref(), + workspace_id.as_deref(), + ) + .map_err(|e| e.to_string())?; + let payload = session_store::FeatureInputPayload::encode( + "journal:compaction", + 1, + &JournalCompactionInput { + status, + prompt_provenance: session_store::PromptRenderProvenance { + workspace_id, + source_digest: prompts.projection().source_digest.clone(), + projection_digest: prompts.projection().catalog_digest.clone(), + logical_name: "internal.subjektiv_journal_compaction".into(), + }, + snapshot: if status == Unavailable { + None + } else { + snapshot + }, + snapshot_sha256, + }, ) + .map_err(|e| e.to_string())?; + journal_input(body, payload, "journal_compaction") } #[derive(Clone)] diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 8cd5477be..ad35374e2 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -505,13 +505,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession { } } - async fn start_command( - &self, - mut request: CommandRequest, - ) -> Result { - // `spill_dir` belongs to the Worker host running BashTool. This remote - // WorkdirSession provider cannot safely resolve or write that host path. - request.spill_dir = None; + async fn start_command(&self, request: CommandRequest) -> Result { match self.operate(WorkdirSessionOperation::CommandStart(request))? { WorkdirSessionOperationResult::CommandStart(result) => Ok(result), _ => Err(Self::mismatch("command_start")), @@ -1660,9 +1654,9 @@ mod tests { .start_command(CommandRequest { command: "true".to_string(), timeout_secs: 120, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: Some("/worker-local/bash-output".into()), tool_call_id: Some("call-1".to_string()), }) .await @@ -1675,7 +1669,7 @@ mod tests { assert_eq!(body["operation"]["operation"], "command_start"); assert_eq!(body["operation"]["request"]["command"], "true"); assert_eq!(body["operation"]["request"]["cwd"], ""); - assert!(body["operation"]["request"]["spill_dir"].is_null()); + assert!(body["operation"]["request"].get("spill_dir").is_none()); assert_eq!(body["operation"]["request"]["tool_call_id"], "call-1"); assert!( !requests[0] @@ -1942,9 +1936,9 @@ mod tests { .start_command(CommandRequest { command: "printf done".to_string(), timeout_secs: 5, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index 8e3f3607a..cab1c2a0d 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -6,7 +6,6 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta use async_trait::async_trait; use schemars::JsonSchema; use serde::Deserialize; -use uuid::Uuid; use protocol::Segment; use server_api::{ @@ -502,7 +501,10 @@ pub struct WorkerLifecycleSpawnRequest { pub profile: String, pub singleton_key: Option, pub ticket_id: Option, + /// Required for ordinary and Ticket spawn; exact retries reuse this ID and full input. pub operation_id: Option, + /// Explicit requested authority. Profile selection alone grants nothing. + pub execution_grant: Option, pub display_name: String, pub initial_submit: Vec, } @@ -514,25 +516,27 @@ struct WorkspaceWorkerLifecycleService { fn workspace_worker_create_request( request: WorkerLifecycleSpawnRequest, ) -> Result { - let ticket_assignment = match (request.ticket_id, request.operation_id) { - (Some(ticket_id), Some(operation_id)) => { - Some(CreateWorkspaceWorkerTicketAssignmentRequest { + let control_operation_id = request.operation_id.filter(|id| { + !id.trim().is_empty() && id.len() <= 256 && !id.chars().any(char::is_control) + }).ok_or_else(|| WorkspaceClientError::Request( + "operation_id is required for every Worker spawn (reuse the same ID and full input after an unknown outcome)".into() + ))?; + if request.ticket_id.is_none() && request.orchestrator_assignment_id.is_some() { + return Err(WorkspaceClientError::Request( + "orchestrator_assignment_id requires ticket_id".into(), + )); + } + if let Some(grant) = &request.execution_grant { + grant.validate().map_err(WorkspaceClientError::Request)?; + } + let ticket_assignment = + request + .ticket_id + .map(|ticket_id| CreateWorkspaceWorkerTicketAssignmentRequest { orchestrator_assignment_id: request.orchestrator_assignment_id, ticket_id, - operation_id, - }) - } - (None, None) => None, - _ => { - return Err(WorkspaceClientError::Request( - "ticket_id and operation_id must be provided together".to_string(), - )); - } - }; - let control_operation_id = ticket_assignment - .as_ref() - .map(|assignment| assignment.operation_id.clone()) - .unwrap_or_else(|| format!("worker-spawn-{}", Uuid::now_v7())); + operation_id: control_operation_id.clone(), + }); Ok(CreateWorkspaceWorkerRequest { runtime_id: request.runtime_id, display_name: request.display_name, @@ -551,6 +555,7 @@ fn workspace_worker_create_request( .collect(), feature_connections: Default::default(), control_operation_id: Some(control_operation_id), + execution_grant: request.execution_grant, }) } @@ -744,7 +749,13 @@ struct WorkerSpawnInput { runtime_id: String, /// Singular Workdir selection wrapped into the shared attachment collection /// under the stable `workdir` alias. - working_directory_id: String, + #[serde(default)] + working_directory_id: Option, + /// Stable caller-chosen intent ID. Exact retries retain this ID and full input. + operation_id: String, + /// Explicit permission/resource request; selecting a Profile grants nothing. + #[serde(default)] + execution_grant: Option, profile: String, /// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted. #[serde(default)] @@ -931,13 +942,23 @@ impl Tool for WorkspaceWorkerTool { .ticket_id .map(|ticket_id| authority_id(&ticket_id, "ticket_id")) .transpose()?; - let operation_id = ticket_id - .as_ref() - .map(|ticket_id| { - let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?; - Ok::<_, ToolError>(format!("worker-spawn:{ticket_id}:{call_id}")) - }) - .transpose()?; + let operation_id = Some(authority_id(&input.operation_id, "operation_id")?); + let workdir_attachments = match input.working_directory_id { + Some(id) => vec![WorkerLifecycleWorkdirAttachment { + alias: INITIAL_WORKDIR_ALIAS.to_string(), + working_directory_id: authority_id(&id, "working_directory_id")?, + relative_cwd: input + .relative_cwd + .map(|value| validate_relative_cwd(&value)) + .transpose()?, + }], + None if input.relative_cwd.is_none() => Vec::new(), + None => { + return Err(ToolError::ExecutionFailed( + "relative_cwd requires working_directory_id".into(), + )); + } + }; self.control .spawn_worker(WorkerLifecycleSpawnRequest { orchestrator_assignment_id: input @@ -945,17 +966,8 @@ impl Tool for WorkspaceWorkerTool { .map(|id| authority_id(&id, "orchestrator_assignment_id")) .transpose()?, runtime_id: authority_id(&input.runtime_id, "runtime_id")?, - workdir_attachments: vec![WorkerLifecycleWorkdirAttachment { - alias: INITIAL_WORKDIR_ALIAS.to_string(), - working_directory_id: authority_id( - &input.working_directory_id, - "working_directory_id", - )?, - relative_cwd: input - .relative_cwd - .map(|value| validate_relative_cwd(&value)) - .transpose()?, - }], + workdir_attachments, + execution_grant: input.execution_grant, profile: non_empty(input.profile, "profile")?, singleton_key: input.singleton_key, ticket_id, @@ -1594,6 +1606,7 @@ mod tests { "relative_cwd": "crates/worker", "profile": "builtin:coder", "ticket_id": "00001KZ9E0DBS", + "operation_id": "stable-ticket-intent", "initial_submit": [ { "kind": "flow", "selector": "builtin:coder-review" }, { "kind": "text", "content": "Implement Ticket 00001" } @@ -1623,7 +1636,7 @@ mod tests { body["ticket_assignment"], serde_json::json!({ "ticket_id": "00001KZ9E0DBS", - "operation_id": "worker-spawn:00001KZ9E0DBS:call-1" + "operation_id": "stable-ticket-intent" }) ); assert_eq!(body["workdir_attachments"].as_array().unwrap().len(), 1); @@ -1910,10 +1923,80 @@ mod tests { ); } + #[tokio::test] + async fn ordinary_spawn_requires_stable_id_and_forwards_grant_without_ticket_or_workdir() { + let client = Arc::new(RecordingWorkspaceClient::default()); + let tool = WorkspaceWorkerTool { + operation: WorkerOperation::Spawn, + control: test_control(client.clone()), + runtime_worker_control: true, + }; + let mut input = serde_json::json!({"runtime_id":"runtime-1", "profile":"builtin:coder"}); + assert!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .is_err() + ); + assert!(client.requests.lock().unwrap().is_empty()); + input["operation_id"] = serde_json::json!("ordinary-intent-1"); + input["execution_grant"] = serde_json::json!({"permissions":[{"operation":"ticket", "resource":{"kind":"feature"}}], "redelegation":[]}); + for call_id in ["call-1", "retry-another-call"] { + tool.execute( + &input.to_string(), + ToolExecutionContext::new(call_id, "batch", 0), + ) + .await + .unwrap(); + } + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].body, requests[1].body); + let body: serde_json::Value = + serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["control_operation_id"], "ordinary-intent-1"); + assert_eq!(body["execution_grant"], input["execution_grant"]); + assert_eq!(body["workdir_attachments"], serde_json::json!([])); + assert!( + body.get("ticket_assignment") + .is_none_or(|value| value.is_null()) + ); + } + + #[test] + fn lifecycle_spawn_rejects_missing_or_blank_id_before_backend() { + for operation_id in [ + None, + Some("".into()), + Some(" ".into()), + Some("bad\nidentity".into()), + ] { + let result = workspace_worker_create_request(WorkerLifecycleSpawnRequest { + orchestrator_assignment_id: None, + runtime_id: "runtime-1".into(), + workdir_attachments: Vec::new(), + profile: "builtin:coder".into(), + singleton_key: None, + ticket_id: None, + operation_id, + execution_grant: None, + display_name: "Worker".into(), + initial_submit: Vec::new(), + }); + assert!(result.unwrap_err().to_string().contains("operation_id")); + } + } + #[test] fn worker_spawn_schema_exposes_normal_typed_segment_variants() { let schema = serde_json::to_value(schemars::schema_for!(WorkerSpawnInput)).unwrap(); let text = serde_json::to_string(&schema).unwrap(); + assert!( + schema["required"] + .as_array() + .unwrap() + .contains(&serde_json::json!("operation_id")) + ); + assert!(text.contains("execution_grant")); assert!(text.contains("initial_submit")); assert!(text.contains("ticket_id")); assert!(text.contains("selector")); @@ -1941,7 +2024,8 @@ mod tests { profile: "builtin:coder".to_string(), singleton_key: Some("subjektiv:subject-1".to_string()), ticket_id: None, - operation_id: None, + operation_id: Some("ordinary-spawn-intent-1".into()), + execution_grant: None, display_name: "Coder".to_string(), initial_submit: vec![ Segment::Flow { @@ -1991,6 +2075,7 @@ mod tests { singleton_key: None, ticket_id: Some("T-719".to_string()), operation_id: Some("spawn-ticket-worker:T-719:call-1".to_string()), + execution_grant: None, display_name: "Worker · T-719".to_string(), initial_submit: vec![Segment::text("Investigate Ticket T-719.")], }) diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index c4973cea5..270de8430 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -43,6 +43,80 @@ use crate::worker::{ WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, }; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SubjectBehaviorInput { + pub behavior_md: String, + pub prompt_provenance: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ResidentSummaryInput { + prompt_provenance: Option, +} + +fn resident_input( + body: String, + schema: &str, + payload: &impl serde::Serialize, + kind: &str, +) -> Result { + use session_store::*; + let input = SystemInputEnvelope::new( + body.clone(), + HistoryInputMetadata::new( + HistoryInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::Retain, + }, + Some(FeatureInputPayload::encode(schema, 1, payload)?), + Some(PublicSystemItem { + kind: kind.into(), + body, + presentation: PublicSystemPresentation::Context, + }), + )?, + )?; + Ok(LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: "subjektiv.resident-context".into(), + }, + None, + input, + )) +} + +pub(crate) fn subject_behavior_input( + body: String, + behavior_md: String, + prompt_provenance: Option, +) -> Result { + resident_input( + body, + "subject:behavior", + &SubjectBehaviorInput { + behavior_md, + prompt_provenance, + }, + "subject_behavior_refresh", + ) +} + +pub(crate) fn resident_summary_input( + body: String, + prompt_provenance: Option, +) -> Result { + resident_input( + body, + "memory:resident-summary", + &ResidentSummaryInput { prompt_provenance }, + "resident_summary_refresh", + ) +} + #[derive(Clone, Debug)] pub struct WorkspaceHttpMemoryBackend { client: Arc, @@ -651,7 +725,7 @@ struct SubjectBehaviorRefreshState { struct WorkspaceSubjektivResidentSummarySource { host: SubjektivHostConnection, prompts: Arc>, - behavior_state: Mutex, + behavior_state: Arc>, } fn resident_summary_contribution( @@ -691,6 +765,13 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { } impl WorkspaceSubjektivResidentSummarySource { + fn confirm_resident_context_behavior(&self, behavior_md: &str) { + self.behavior_state + .lock() + .expect("subject behavior state poisoned") + .applied = Some(behavior_md.to_owned()); + } + async fn fetch( &self, ) -> Result { @@ -707,7 +788,7 @@ impl WorkspaceSubjektivResidentSummarySource { fn render( &self, output: &server_api::SubjektivResidentContextOutput, - ) -> Result { + ) -> Result<(String, session_store::PromptRenderProvenance), String> { use memory::backend::MemoryResidentSummaryAvailability as Availability; let availability = match output.memory_surface.availability { Availability::Ready => "ready", @@ -716,53 +797,68 @@ impl WorkspaceSubjektivResidentSummarySource { Availability::Failed => "failed", Availability::Unavailable => "unavailable", }; - self.prompts - .load() + let prompts = self.prompts.load_full(); + let body = prompts .subjektiv_resident_context( &output.behavior_md, availability, output.memory_surface.content.as_deref(), ) - .map_err(|error| error.to_string()) - } - - fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) { - let mut state = self - .behavior_state - .lock() - .expect("subject behavior state poisoned"); - state.applied = Some(output.behavior_md.clone()); + .map_err(|error| error.to_string())?; + let projection = prompts.projection(); + Ok(( + body, + session_store::PromptRenderProvenance { + workspace_id: self.host.host.workspace_id(), + source_digest: projection.source_digest.clone(), + projection_digest: projection.catalog_digest.clone(), + logical_name: "internal.subjektiv_resident_context".into(), + }, + )) } } /// Detect behavior changes against the state reflected in history, not the fetched state. /// -/// Initial load builds a prompt contribution; change refresh does not advance applied on fetch/render alone. -/// The interceptor confirms after durably appending a system item, allowing refetch if append fails. -/// Invalidate applied before rewrite so even identical bodies can be presented in the new history. +/// Fetch/render never advances applied state. Initial preparation captures a +/// source-local completion, invoked only after its prompt and active pointer commit. +/// Later refreshes are acknowledged by the interceptor after durable append. +/// Invalidate applied before rewrite so identical bodies can be represented again. #[async_trait] impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource { async fn load(&self) -> SystemPromptContribution { + self.load_initial().await.0 + } + + async fn load_initial( + &self, + ) -> ( + SystemPromptContribution, + Option, + ) { match self.fetch().await { Ok(output) => match self.render(&output) { - Ok(rendered) => { - self.remember_behavior(&output); - SystemPromptContribution::Ready(rendered) + Ok((rendered, _)) => { + let state = self.behavior_state.clone(); + let behavior = output.behavior_md; + ( + SystemPromptContribution::Ready(rendered), + Some(Box::new(move || { + state + .lock() + .expect("subject behavior state poisoned") + .applied = Some(behavior); + })), + ) } Err(error) => { tracing::error!(%error, "subject resident context render failed"); - SystemPromptContribution::Ready( - "The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory." - .to_string(), - ) + (SystemPromptContribution::Ready("The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory.".into()), None) } }, Err(error) => { tracing::error!(%error, "subject resident context fetch failed"); - SystemPromptContribution::Ready( - "The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory." - .to_string(), - ) + (SystemPromptContribution::Ready("The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory.".into()), None) } } } @@ -781,18 +877,31 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource if !changed { return Ok(None); } - let rendered = self.render(&output)?; + let (rendered, prompt_provenance) = self.render(&output)?; Ok(Some(ResidentContextRefresh { body: rendered, behavior_md: output.behavior_md, + prompt_provenance: Some(prompt_provenance), })) } - fn confirm_resident_context_behavior(&self, behavior_md: &str) { - self.behavior_state - .lock() - .expect("subject behavior state poisoned") - .applied = Some(behavior_md.to_owned()); + fn system_item_committed( + &self, + _item: &session_store::SystemItem, + metadata: &crate::session_history::SessionHistoryMetadata, + ) { + if let Some(input) = metadata + .input_metadata + .as_ref() + .and_then(|input| input.feature_payload()) + .and_then(|payload| { + payload + .decode::("subject:behavior", 1) + .ok() + }) + { + self.confirm_resident_context_behavior(&input.behavior_md); + } } fn invalidate_resident_context_representation(&self) { @@ -944,7 +1053,13 @@ impl Hook for SubjektivBehaviorRefreshHook { "Subject behavior refresh requires durable session append authority", ) })?; - system_items.append_subject_behavior_refresh(refresh.body, refresh.behavior_md); + let input = subject_behavior_input( + refresh.body, + refresh.behavior_md, + refresh.prompt_provenance, + ) + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?; + system_items.append_input(input); } Ok(HookPreRequestAction::Continue) } @@ -969,16 +1084,18 @@ impl Hook for SubjektivResidentRestoreRefreshHook { .resident_memory_restore_section(surface_ready, summary) .map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?; let projection = prompts.projection(); - context.system_items().append_resident_summary_refresh( + let input = resident_summary_input( body, - session_store::PromptRenderProvenance { + Some(session_store::PromptRenderProvenance { workspace_id: self.workspace_id.clone(), source_digest: projection.source_digest.clone(), projection_digest: projection.catalog_digest.clone(), logical_name: WorkerPrompt::ResidentMemoryRestoreSection.key().to_string(), - }, - ); + }), + ) + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?; + context.system_items().append_input(input); Ok(()) } } @@ -1106,7 +1223,7 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source( Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource { host: host.expect("checked explicit connection"), prompts, - behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()), + behavior_state: Arc::new(Mutex::new(SubjectBehaviorRefreshState::default())), }))) } @@ -1723,8 +1840,16 @@ permission = "write" .unwrap() .unwrap(); - let initial = source.load().await; + let (initial, completion) = source.load_initial().await; assert!(matches!(initial, SystemPromptContribution::Ready(_))); + assert!( + source + .load_changed_resident_context() + .await + .unwrap() + .is_some() + ); + completion.expect("successful initial contribution must provide completion")(); assert!( source .load_changed_resident_context() @@ -1741,7 +1866,13 @@ permission = "write" .expect("changed behavior must produce one refresh"); assert_eq!(refresh.behavior_md, "Ask when uncertain."); assert!(refresh.body.contains("Ask when uncertain.")); - source.confirm_resident_context_behavior(&refresh.behavior_md); + let input = subject_behavior_input( + refresh.body.clone(), + refresh.behavior_md.clone(), + refresh.prompt_provenance.clone(), + ) + .unwrap(); + source.system_item_committed(&input.item, &input.metadata); assert!( source .load_changed_resident_context() @@ -1758,7 +1889,13 @@ permission = "write" .expect("cleared behavior must produce one refresh"); assert_eq!(cleared.behavior_md, ""); assert!(cleared.body.contains("No user-managed behavior is set")); - source.confirm_resident_context_behavior(&cleared.behavior_md); + let input = subject_behavior_input( + cleared.body.clone(), + cleared.behavior_md.clone(), + cleared.prompt_provenance.clone(), + ) + .unwrap(); + source.system_item_committed(&input.item, &input.metadata); assert!( source .load_changed_resident_context() @@ -1783,7 +1920,7 @@ permission = "write" // target-side bounded resident request contract. assert_eq!( client.timeouts.lock().unwrap().as_slice(), - &[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7] + &[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 8] ); } @@ -1816,7 +1953,13 @@ permission = "write" .await .unwrap() .unwrap(); - source.confirm_resident_context_behavior(&first.behavior_md); + let input = subject_behavior_input( + first.body.clone(), + first.behavior_md.clone(), + first.prompt_provenance.clone(), + ) + .unwrap(); + source.system_item_committed(&input.item, &input.metadata); assert_eq!( source .load_changed_resident_context() @@ -1825,7 +1968,13 @@ permission = "write" .unwrap(), second ); - source.confirm_resident_context_behavior(&second.behavior_md); + let input = subject_behavior_input( + second.body.clone(), + second.behavior_md.clone(), + second.prompt_provenance.clone(), + ) + .unwrap(); + source.system_item_committed(&input.item, &input.metadata); assert!( source .load_changed_resident_context() diff --git a/crates/worker/src/feature/builtin/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index 179965642..6518b8c76 100644 --- a/crates/worker/src/feature/builtin/orchestration.rs +++ b/crates/worker/src/feature/builtin/orchestration.rs @@ -164,6 +164,7 @@ impl Tool for SpawnTicketWorkerTool { operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)), display_name: format!("Worker · {}", ticket.resource_key), initial_submit, + execution_grant: None, }) .await .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; diff --git a/crates/worker/src/feature/builtin/session_explore.rs b/crates/worker/src/feature/builtin/session_explore.rs index 69d4ced54..aacc16081 100644 --- a/crates/worker/src/feature/builtin/session_explore.rs +++ b/crates/worker/src/feature/builtin/session_explore.rs @@ -371,7 +371,7 @@ fn parse_entry_ref(value: &str) -> Result { fn parse_kind(value: &str) -> Result { ReferenceKind::parse(value).ok_or_else(|| { ToolError::InvalidArgument(format!( - "invalid kind {value:?}; expected user, assistant, or tool" + "invalid kind {value:?}; expected user, assistant, system, or tool" )) }) } diff --git a/crates/worker/src/feature/builtin/task/mod.rs b/crates/worker/src/feature/builtin/task/mod.rs index e45b1b703..0d82bb437 100644 --- a/crates/worker/src/feature/builtin/task/mod.rs +++ b/crates/worker/src/feature/builtin/task/mod.rs @@ -276,7 +276,9 @@ mod tests { use super::*; use crate::hook::{PreRequestInfo, SystemItemAppendHandle}; - fn pre_request_context(pending: Arc>>) -> PreRequestContext { + fn pre_request_context( + pending: Arc>>, + ) -> PreRequestContext { PreRequestContext::new( PreRequestInfo { item_count: 1, @@ -315,7 +317,7 @@ mod tests { let queued = pending.lock().expect("pending queue poisoned"); assert_eq!(queued.len(), 1); - let SystemItem::TaskReminder { body, .. } = &queued[0] else { + let SystemItem::TaskReminder { body, .. } = &queued[0].item else { panic!("unexpected system item: {:?}", queued[0]); }; assert!(body.starts_with("Current session steps are listed below.")); @@ -339,7 +341,7 @@ mod tests { } let queued = pending.lock().expect("pending queue poisoned"); - let SystemItem::TaskReminder { source, body, .. } = &queued[0] else { + let SystemItem::TaskReminder { source, body, .. } = &queued[0].item else { panic!("unexpected system item: {:?}", queued[0]); }; assert_eq!(*source, SystemReminderSource::TaskInactivity); @@ -452,7 +454,7 @@ mod tests { } let queued = pending.lock().expect("pending queue poisoned"); assert_eq!(queued.len(), 1); - let SystemItem::TaskReminder { body, .. } = &queued[0] else { + let SystemItem::TaskReminder { body, .. } = &queued[0].item else { panic!("unexpected system item: {:?}", queued[0]); }; assert!(body.contains("taskid 1 (completed) done")); diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 81c9eecbe..704c5744d 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -509,6 +509,15 @@ const TICKET_SERVICE_VERSION: &str = "1"; pub trait TicketService: Send + Sync { fn ticket_handoff(&self, ticket_ref: &str) -> Result; + + /// Typed creation without a synthetic Tool context. The default keeps legacy + /// handoff-only fixture implementations fail-closed; production installs + /// provide creation through the shared authorized backend dispatch. + fn create(&self, _input: NewTicket) -> TicketResult { + Err(TicketError::Conflict( + "Ticket creation service is unavailable".into(), + )) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -526,6 +535,7 @@ fn is_canonical_ticket_resource_key(resource_key: &str) -> bool { struct WorkspaceTicketService { backend: WorkspaceHttpTicketBackend, + access: TicketFeatureAccess, } fn ticket_handoff_from_record(ticket: Ticket) -> Result { @@ -545,6 +555,17 @@ impl TicketService for WorkspaceTicketService { fn ticket_handoff(&self, ticket_ref: &str) -> Result { ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?) } + + fn create(&self, input: NewTicket) -> TicketResult { + // Services retain the same Profile ceiling as Tools and WIP. Host + // permission cannot turn a read-only Feature surface into an author. + if !self.access.authoring { + return Err(TicketError::Conflict( + "Ticket creation is disabled by the Profile authoring policy".into(), + )); + } + self.backend.create(input) + } } fn ticket_workflow_instruction() -> FeatureInstructionDeclaration { @@ -801,8 +822,17 @@ impl FeatureModule for TicketFeature { fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { let workspace_client = self.workspace_client(); + // Direct registry installs do not necessarily have a matching manifest + // switch. The public module identity still requires live activation + // authority before any Tool, instruction or service contribution. + workspace_client + .authorize_feature_activation(FEATURE_ID) + .map_err(|error| { + FeatureInstallError::Install(format!("Ticket activation rejected: {error}")) + })?; let ticket_service: Arc = Arc::new(WorkspaceTicketService { backend: WorkspaceHttpTicketBackend::new(workspace_client.clone()), + access: self.access, }); context.services().provide( ServiceDeclaration::new( @@ -1104,6 +1134,22 @@ impl WorkspaceHttpTicketBackend { "Tickets must be created open; closing requires a case judgment".into(), )); } + // Shared trusted boundary for Tool, WIP and Ticket service callers. + // Activation (`ticket`) is not `ticket.create`. Other Ticket operations + // are not yet adapted to this seam; missing checks there are not + // supplied automatically by a universal Tool executor. + client + .authorize_operation( + "ticket.create", + &server_api::authorization::PermissionResource::Workspace { + workspace_id: workspace_id.clone(), + }, + ) + .map_err(|error| { + TicketError::Conflict(format!( + "Ticket create authorization failed: {error}" + )) + })?; let ticket = Self::request( client, WorkspaceRequestMethod::Post, @@ -3541,9 +3587,13 @@ mod tests { } fn workspace_feature(access: TicketFeatureAccess) -> TicketFeature { - let client: Arc = Arc::new( - crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"), - ); + let client: Arc = Arc::new(CreateAuthorizationClient { + workspace: Some("workspace"), + activation_allowed: true, + allowed: true, + backend_status: 200, + requests: Default::default(), + }); ticket_tools_feature(client, access) } @@ -4075,6 +4125,7 @@ mod tests { }); let service = WorkspaceTicketService { + access: TicketFeatureAccess::read_only(), backend: WorkspaceHttpTicketBackend::new(Arc::new( crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url), )), @@ -4095,11 +4146,322 @@ mod tests { } } + // This suite keeps the real Feature/Tool/WIP/backend-dispatch code; only the + // Host transport is replaced. Host grant/storage behavior is tested at Server. + #[derive(Debug)] + struct CreateAuthorizationClient { + workspace: Option<&'static str>, + activation_allowed: bool, + allowed: bool, + backend_status: u16, + requests: Mutex>, + } + + impl WorkspaceClient for CreateAuthorizationClient { + fn workspace_id(&self) -> Option<&str> { + self.workspace + } + fn kind(&self) -> &str { + "create-authorization-fixture" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let authorization = request.path.ends_with("/authorization/check"); + let mut permitted = self.allowed; + if authorization { + assert_eq!(request.method, WorkspaceRequestMethod::Post); + let body: Value = serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + if body["operation"] == "ticket" { + assert_eq!( + body, + json!({"operation":"ticket", "resource":{"kind":"feature"}}) + ); + permitted = self.activation_allowed; + } else { + assert_eq!( + body, + json!({"operation":"ticket.create", "resource":{"kind":"workspace","workspace_id":"workspace"}}) + ); + } + } else { + assert_eq!(request.path, "/api/w/workspace/tickets"); + } + self.requests.lock().unwrap().push(request); + Ok(crate::worker::WorkspaceResponse { + status: if authorization { + if permitted { 200 } else { 403 } + } else { + self.backend_status + }, + body: if authorization { + if permitted { + "{}".into() + } else { + json!({"message":"Ticket permission revoked"}).to_string() + } + } else if self.backend_status != 200 { + json!({"error":"Forbidden", "message":"backend create denied", "diagnostics":[]}).to_string() + } else { + serde_json::to_string(&TicketRef { + id: "created-1".into(), + resource_key: Some("T-1".into()), + slug: "created".into(), + status: ticket::TicketStatus::Open, + }) + .unwrap() + }, + }) + } + } + + #[tokio::test] + async fn create_authorization_guards_tool_wip_installed_service_and_backend_without_local_root() + { + for (allowed, backend_status) in [(false, 200), (true, 200), (true, 403)] { + for surface in ["tool", "wip", "service", "backend"] { + let client = Arc::new(CreateAuthorizationClient { + workspace: Some("workspace"), + activation_allowed: true, + allowed, + backend_status, + requests: Default::default(), + }); + let result = match surface { + "backend" => WorkspaceHttpTicketBackend::new(client.clone()) + .create(NewTicket::new("Create")) + .map(|_| ()) + .map_err(|error| error.to_string()), + "service" => { + let report = FeatureRegistryBuilder::new() + .with_module(ticket_tools_feature( + client.clone(), + TicketFeatureAccess::workspace_authoring(), + )) + .install_into_pending( + &mut Vec::new(), + &mut HookRegistryBuilder::default(), + ); + assert!(!report.has_errors(), "{}", report.error_message()); + let service = report + .services + .service::(&ServiceId::builtin(TICKET_SERVICE_ID)) + .unwrap(); + service + .create(NewTicket::new("Create")) + .map(|_| ()) + .map_err(|error| error.to_string()) + } + "tool" => { + let tools = enabled_ticket_definitions( + client.clone(), + TicketFeatureAccess::workspace_authoring(), + ); + let (_, tool) = tools + .into_iter() + .find(|definition| definition().0.name == "TicketCreate") + .unwrap()(); + tool.execute(r#"{"title":"Create"}"#, ToolExecutionContext::direct()) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + _ => { + let tools = native_ticket_tools( + client.clone(), + TicketFeatureAccess::workspace_authoring(), + ) + .unwrap(); + let handler = TicketCollectionWipHandler { + tools: operation_map(tools), + permissions: None, + collection_route: "/tickets".into(), + observations: Arc::new(Mutex::new(TicketObservationState::default())), + }; + handler + .call( + "create", + &BTreeMap::from([( + "title".into(), + WipValue::String("Create".into()), + )]), + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "test".into(), + }, + ) + .await + .map(|_| ()) + .map_err(|error| match error { + WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + message, + .. + }) => message, + _ => panic!("a definitive denial is not outcome-unknown"), + }) + } + }; + assert_eq!( + result.is_ok(), + allowed && backend_status == 200, + "{surface}: {result:?}" + ); + if allowed && backend_status == 403 { + let error = result.unwrap_err(); + assert!(error.contains("HTTP status 403"), "{surface}: {error}"); + assert!( + error.contains("backend create denied"), + "{surface}: {error}" + ); + } + let requests = client.requests.lock().unwrap(); + assert_eq!( + requests.len(), + 1 + usize::from(allowed) + usize::from(surface == "service"), + "{surface}" + ); + assert!(requests[0].path.ends_with("/authorization/check")); + assert_eq!( + requests + .iter() + .filter(|request| request.path.ends_with("/tickets")) + .count(), + usize::from(allowed) + ); + } + } + } + + #[test] + fn direct_ticket_install_requires_activation_even_without_manifest_switch() { + assert!(!manifest::FeatureConfig::default().ticket.enabled); + for create_allowed in [false, true] { + let client = Arc::new(CreateAuthorizationClient { + workspace: Some("workspace"), + activation_allowed: false, + allowed: create_allowed, + backend_status: 200, + requests: Default::default(), + }); + let mut tools = Vec::new(); + // No manifest-config guard: this is the explicit module install path. + let report = FeatureRegistryBuilder::new() + .with_module(ticket_tools_feature( + client.clone(), + TicketFeatureAccess::workspace_authoring(), + )) + .install_into_pending(&mut tools, &mut HookRegistryBuilder::default()); + assert!(report.has_errors()); + assert!( + report + .error_message() + .contains("Ticket activation rejected") + ); + assert!(report.error_message().contains("HTTP status 403")); + assert!(tools.is_empty()); + assert!(report.installed_instruction_contributions().is_empty()); + assert!(report.services.providers().is_empty()); + assert_eq!(client.requests.lock().unwrap().len(), 1); + } + } + + #[test] + fn installed_ticket_service_readonly_profile_cannot_create_with_host_permission() { + let client = Arc::new(CreateAuthorizationClient { + workspace: Some("workspace"), + activation_allowed: true, + allowed: true, + backend_status: 200, + requests: Default::default(), + }); + let mut tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(ticket_tools_feature( + client.clone(), + TicketFeatureAccess::read_only(), + )) + .install_into_pending(&mut tools, &mut HookRegistryBuilder::default()); + assert!(!report.has_errors(), "{}", report.error_message()); + assert!(!tools.iter().any(|tool| tool().0.name == "TicketCreate")); + let service = report + .services + .service::(&ServiceId::builtin(TICKET_SERVICE_ID)) + .unwrap(); + assert!( + service + .create(NewTicket::new("No profile bypass")) + .unwrap_err() + .to_string() + .contains("Profile authoring policy") + ); + let requests = client.requests.lock().unwrap(); + assert_eq!( + requests.len(), + 1, + "only activation; no create check or backend effect" + ); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["operation"], "ticket"); + } + + #[test] + fn legacy_handoff_only_service_does_not_gain_creation() { + struct LegacyService; + impl TicketService for LegacyService { + fn ticket_handoff(&self, _: &str) -> Result { + unreachable!("creation must not call handoff") + } + } + assert!( + LegacyService + .create(NewTicket::new("No implicit creation")) + .unwrap_err() + .to_string() + .contains("unavailable") + ); + } + + #[test] + fn create_without_workspace_identity_fails_before_transport_and_backend_denial_is_preserved() { + for (workspace, allowed, backend_status, expected_calls) in + [(None, true, 200, 0), (Some("workspace"), true, 403, 2)] + { + let client = Arc::new(CreateAuthorizationClient { + workspace, + activation_allowed: true, + allowed, + backend_status, + requests: Default::default(), + }); + let result = + WorkspaceHttpTicketBackend::new(client.clone()).create(NewTicket::new("Create")); + assert!(result.is_err()); + assert_eq!(client.requests.lock().unwrap().len(), expected_calls); + } + } + #[test] fn workspace_http_backend_executes_ticket_create_operation() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); let server = thread::spawn(move || { + let (mut authorization, _) = listener.accept().unwrap(); + authorization + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut buffer = [0_u8; 8192]; + let len = authorization.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..len]); + assert!(request.starts_with("POST /api/w/workspace-a/authorization/check HTTP/1.1")); + assert!(request.contains("\"operation\":\"ticket.create\"")); + assert!(request.contains("\"workspace_id\":\"workspace-a\"")); + write!(authorization, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}").unwrap(); + drop(authorization); let (mut stream, _) = listener.accept().unwrap(); let mut buffer = [0_u8; 8192]; let len = stream.read(&mut buffer).unwrap(); diff --git a/crates/worker/src/feature/builtin/worker_observation.rs b/crates/worker/src/feature/builtin/worker_observation.rs index 325f89615..9b6d5488c 100644 --- a/crates/worker/src/feature/builtin/worker_observation.rs +++ b/crates/worker/src/feature/builtin/worker_observation.rs @@ -813,6 +813,7 @@ mod tests { _ => return None, }; Some(protocol::SessionSnapshotEntry { + use_policy: None, entry_id: format!("fake-{index:08}"), timestamp: index as u64, provenance: protocol::SessionEntryProvenance::LegacyUnknown, diff --git a/crates/worker/src/feature/builtin/workspace_config/content.rs b/crates/worker/src/feature/builtin/workspace_config/content.rs index 234b97eeb..b2f67d342 100644 --- a/crates/worker/src/feature/builtin/workspace_config/content.rs +++ b/crates/worker/src/feature/builtin/workspace_config/content.rs @@ -47,6 +47,12 @@ use crate::wip::{ pub(super) struct ConfigProvider { pub feature: WorkspaceConfigFeature, pub backend: Arc, + // A fresh mount after restore invalidates observations from its predecessor. + pub observation_nonce: Arc, +} + +fn object_validator(nonce: &str, connection: &str, validator: &str) -> Vec { + Sha256::digest(format!("{nonce}:{connection}:{validator}")).to_vec() } /// Backend tree root is empty; source names are canonical relative config paths. @@ -240,7 +246,7 @@ impl ConfigProvider { let descriptor = descriptor(&operations); let interface = contextual_reference("yoi.workspace-config/node/v1", path); let interface_validator = Some(Sha256::digest(format!("{descriptor:?}")).to_vec()); - Ok(Some(WipProjection { + let mut projection = WipProjection { route: path.into(), capability: "workspace-config:content".into(), kind: WipProjectionKind::Native, @@ -252,7 +258,11 @@ impl ConfigProvider { )), interfaces: vec![interface.clone()], r#ref: None, - validator: Some(node.validator.as_bytes().to_vec()), + validator: Some(object_validator( + &self.observation_nonce, + &attachment.connection_id, + &node.validator, + )), }, interface, descriptor, @@ -263,9 +273,12 @@ impl ConfigProvider { attachment, snapshot, node, - operations, + operations: operations.clone(), + observation_nonce: self.observation_nonce.clone(), }), - })) + }; + crate::file_operation::expose(&mut projection, common_contracts(&operations)); + Ok(Some(projection)) } } @@ -335,12 +348,28 @@ fn operations( names.push(name.into()); } } + if names.iter().any(|name| name == "read") { + names.extend(["read_lines".into(), "search_text".into()]); + } if root { names.push("attach".into()); } names } +fn common_contracts(names: &[String]) -> Vec { + use crate::file_operation::Contract; + [ + ("read_lines", Contract::ReadLines), + ("search_text", Contract::SearchText), + ("edit", Contract::Edit), + ("write", Contract::Write), + ] + .into_iter() + .filter_map(|(name, contract)| names.iter().any(|n| n == name).then_some(contract)) + .collect() +} + fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration { ParameterDeclaration { name: name.into(), @@ -356,11 +385,11 @@ fn descriptor(names: &[String]) -> InterfaceDescriptor { summary: "Logical Workspace configuration file operations".into(), details: Some("Backend grants, semantic validation, atomic whole-tree CAS and activation apply. Validators and change digests are managed automatically. apply_changes atomically accepts create/update/delete/rename changes without validators or expected_digest fields. Saving updates configuration for future operation boundaries, not already-running Worker manifests. No OS or command access.".into()), }), - operations: names.iter().map(|name| OperationDeclaration { + operations: names.iter().filter(|name| !matches!(name.as_str(), "read_lines" | "search_text" | "edit" | "write")).map(|name| OperationDeclaration { name: name.clone(), documentation: None, parameters: match name.as_str() { "attach" => vec![parameter("access", false, TypeExpr::String)], - "read" | "write" | "edit" => crate::file_operation::parameters(name, false).unwrap(), + "read" => crate::file_operation::parameters(name, false).unwrap(), "delete" => vec![], "create" => vec![parameter("path", false, TypeExpr::String), parameter("content", true, TypeExpr::String), parameter("content_type", false, TypeExpr::String)], "apply_changes" => vec![parameter("changes", true, TypeExpr::Json)], @@ -378,6 +407,7 @@ struct ConfigHandler { snapshot: WorkspaceConfigObserveResponse, node: WorkspaceConfigNode, operations: Vec, + observation_nonce: Arc, } #[derive(Debug, Deserialize)] @@ -476,13 +506,18 @@ impl ConfigHandler { || response.validator != self.node.validator || Some(&response.digest) != self.node.digest.as_ref() || Some(&response.content_type) != self.node.content_type.as_ref() - || response.content.len() > MAX_TEXT_BYTES { return Err(failure( ProtocolErrorCode::Internal, "Invalid Workspace config read response", )); } + if response.content.len() > MAX_TEXT_BYTES { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "Workspace config source exceeds text limit", + )); + } let mut response = response; response.content = fs_operation::text::read(response.content, text_limits()) .map_err(text_error)? @@ -628,11 +663,33 @@ impl ConfigHandler { )); } // Direct handler calls also enforce parameter shape; no model validators accepted. - let declaration = descriptor(&self.operations) - .operations - .into_iter() - .find(|op| op.name == operation) - .ok_or_else(invalid)?; + let declaration = match operation { + "read_lines" => crate::file_operation::Contract::ReadLines + .descriptor() + .operations + .into_iter() + .next(), + "search_text" => crate::file_operation::Contract::SearchText + .descriptor() + .operations + .into_iter() + .next(), + "edit" => crate::file_operation::Contract::Edit + .descriptor() + .operations + .into_iter() + .next(), + "write" => crate::file_operation::Contract::Write + .descriptor() + .operations + .into_iter() + .next(), + _ => descriptor(&self.operations) + .operations + .into_iter() + .find(|op| op.name == operation), + } + .ok_or_else(invalid)?; if arguments .keys() .any(|name| !declaration.parameters.iter().any(|p| p.name == *name)) @@ -646,6 +703,19 @@ impl ConfigHandler { .call(operation, arguments, context) .await; } + if matches!(operation, "read_lines" | "search_text") { + crate::file_operation::text_result(operation, arguments, "")?; + let result = self.read().await?; + let value = crate::file_operation::text_result(operation, arguments, &result.content)?; + return Ok(WipOperationOutput::native_with_validator( + value, + object_validator( + &self.observation_nonce, + &self.attachment.connection_id, + &result.validator, + ), + )); + } if operation == "read" { let _: fs_operation::text::ReadArgs = decode_text(arguments)?; let result = self.read().await?; @@ -657,7 +727,11 @@ impl ConfigHandler { .map_err(|_| invalid())?; return Ok(WipOperationOutput::native_with_validator( value, - self.node.validator.as_bytes().to_vec(), + object_validator( + &self.observation_nonce, + &self.attachment.connection_id, + &self.node.validator, + ), )); } if self.attachment.access != WorkspaceConfigAccess::ReadWrite { @@ -762,7 +836,11 @@ impl ConfigHandler { "activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?; Ok(WipOperationOutput::native_with_validator( value, - node.validator.as_bytes().to_vec(), + object_validator( + &self.observation_nonce, + &self.attachment.connection_id, + &node.validator, + ), )) } } diff --git a/crates/worker/src/feature/builtin/workspace_config/production_tests.rs b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs index 9c9fd9d63..71649555c 100644 --- a/crates/worker/src/feature/builtin/workspace_config/production_tests.rs +++ b/crates/worker/src/feature/builtin/workspace_config/production_tests.rs @@ -466,8 +466,19 @@ async fn call( runtime .call( path.into(), - interface(path, true), - op.into(), + match op { + "edit" => crate::file_operation::Contract::Edit.reference(), + "write" => crate::file_operation::Contract::Write.reference(), + "read_lines" => crate::file_operation::Contract::ReadLines.reference(), + "search_text" => crate::file_operation::Contract::SearchText.reference(), + _ => interface(path, true), + }, + match op { + "read_lines" => "read", + "search_text" => "search", + _ => op, + } + .into(), args, ToolExecutionContext::direct(), ) @@ -825,11 +836,11 @@ async fn workspace_config_production_reattach_new_lifetime_rejects_old_same_byte client.state.lock().unwrap().active = None; let second = feature.attach("second", None).await.unwrap(); assert_ne!(first.connection_id, second.connection_id); - assert!( - call(&runtime, path, "write", json!({"content":"bad"})) - .await - .is_err() - ); + let error = call(&runtime, path, "write", json!({"content":"bad"})) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("ValidatorMismatch"), "{error}"); assert!(client.state.lock().unwrap().commits.is_empty()); observe_interface(&runtime, path, true).await; call(&runtime, path, "write", json!({"content":"new-lifetime"})) @@ -958,3 +969,189 @@ async fn workspace_config_production_ungranted_root_does_not_poison_worldspace_d .all(|r| r.method == WorkspaceRequestMethod::Get) ); } + +#[tokio::test] +async fn common_read_only_text_windows_search_and_rejections_preserve_backend_authority() { + let client = Router::new(); + { + let mut state = client.state.lock().unwrap(); + state.read_only = true; + state.entries.get_mut("main.dcdl").unwrap().1 = "α\r\nneedle.*\nNeedle.*\nlast\n".into(); + } + let (feature, runtime) = runtime(client.clone()); + feature.attach("read-only", None).await.unwrap(); + let path = "/workspace-config/main.dcdl"; + observe_interface(&runtime, path, true).await; + let object = runtime + .inspect(path.into(), true) + .await + .unwrap() + .content + .unwrap(); + assert!(object.contains(&crate::file_operation::Contract::ReadLines.reference().name)); + assert!(object.contains(&crate::file_operation::Contract::SearchText.reference().name)); + assert!(!object.contains(&crate::file_operation::Contract::Write.reference().name)); + assert!(!object.contains(&crate::file_operation::Contract::Edit.reference().name)); + let read = call(&runtime, path, "read_lines", json!({"offset":1,"limit":2})) + .await + .unwrap(); + let read: Json = serde_json::from_str(read.content.as_deref().unwrap()).unwrap(); + assert!(read["content"].as_str().unwrap().contains("needle.*")); + assert!(read["content"].as_str().unwrap().contains("Needle.*")); + assert!(!read["content"].as_str().unwrap().contains('α')); + assert_eq!(read["next_offset"], 3); + assert_eq!(read["truncated"], true); + let search = call( + &runtime, + path, + "search_text", + json!({"pattern":"needle.*","limit":3}), + ) + .await + .unwrap(); + let search: Json = serde_json::from_str(search.content.as_deref().unwrap()).unwrap(); + assert_eq!(search["matches"], json!([{"line":2,"text":"needle.*"}])); + assert_eq!(search["next_offset"], 3); + assert_eq!(search["truncated"], true); + for (op, arguments) in [ + ("read_lines", json!({"limit":0})), + ("read_lines", json!({"limit":2001})), + ("search_text", json!({"pattern":""})), + ( + "search_text", + json!({"pattern":"x","case_insensitive":true}), + ), + ] { + let before = client + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|r| r.path.ends_with("/read")) + .count(); + assert!(call(&runtime, path, op, arguments).await.is_err()); + let after = client + .state + .lock() + .unwrap() + .requests + .iter() + .filter(|r| r.path.ends_with("/read")) + .count(); + assert_eq!(before, after); + } + assert!( + call(&runtime, path, "write", json!({"content":"denied"})) + .await + .is_err() + ); + client.state.lock().unwrap().granted = false; + assert!(call(&runtime, path, "read_lines", json!({})).await.is_err()); + assert!(client.state.lock().unwrap().commits.is_empty()); +} + +#[tokio::test] +async fn common_text_read_refuses_oversized_rendered_window() { + let client = Router::new(); + let (feature, runtime) = runtime(client.clone()); + feature.attach("bounded", None).await.unwrap(); + let path = "/workspace-config/main.dcdl"; + client + .state + .lock() + .unwrap() + .entries + .get_mut("main.dcdl") + .unwrap() + .1 = "x".repeat(64 * 1024); + observe_interface(&runtime, path, true).await; + let error = call(&runtime, path, "read_lines", json!({})) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("ResourceLimitExceeded"), "{error}"); + assert!(client.state.lock().unwrap().commits.is_empty()); +} + +#[tokio::test] +async fn workspace_config_backend_validators_reject_path_and_connection_transplant_even_for_identical_sources() + { + use crate::wip::WipOperationError; + use wip_protocol::ProtocolErrorCode; + let client = Router::new(); + let path = "profiles/team/deep/model.dcdl"; + client + .state + .lock() + .unwrap() + .entries + .get_mut(path) + .unwrap() + .1 = "model = old".into(); + let (feature, runtime) = runtime(client.clone()); + let first = feature.attach("path-binding", None).await.unwrap(); + let backend = backend::WorkspaceConfigBackend::new(client.clone()); + let (main_validator, deep_validator) = { + let state = client.state.lock().unwrap(); + (state.validator("main.dcdl"), state.validator(path)) + }; + assert_ne!(main_validator, deep_validator); + let read = |connection_id: String, validator: String| WorkspaceConfigReadRequest { + connection_id, + path: path.into(), + validator, + }; + let result = backend + .post::( + "/read", + &read(first.connection_id.clone(), main_validator), + false, + ) + .await; + assert!( + matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch) + ); + let result: WorkspaceConfigReadResponse = backend + .post( + "/read", + &read(first.connection_id.clone(), deep_validator.clone()), + false, + ) + .await + .unwrap_or_else(|_| panic!("correct path validator rejected")); + assert_eq!(result.content, "model = old"); + assert_eq!(result.validator, deep_validator); + client.state.lock().unwrap().active = None; + let second = feature.attach("connection-binding", None).await.unwrap(); + assert_ne!(first.connection_id, second.connection_id); + for connection in [first.connection_id, second.connection_id.clone()] { + let result = backend + .post::( + "/read", + &read(connection, deep_validator.clone()), + false, + ) + .await; + assert!( + matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch) + ); + } + // Fresh path/connection observation restores native common mutation access, + // and the returned exact post-operation validator also supports common reads. + let route = format!("/workspace-config/{path}"); + observe_interface(&runtime, &route, true).await; + call( + &runtime, + &route, + "write", + json!({"content":"model = updated"}), + ) + .await + .unwrap(); + let result = call(&runtime, &route, "read_lines", json!({})) + .await + .unwrap(); + assert!(result.content.unwrap().contains("model = updated")); + assert_eq!(client.state.lock().unwrap().commits.len(), 1); +} diff --git a/crates/worker/src/feature/builtin/workspace_config/wip.rs b/crates/worker/src/feature/builtin/workspace_config/wip.rs index d3aa9d2c8..e03c90db3 100644 --- a/crates/worker/src/feature/builtin/workspace_config/wip.rs +++ b/crates/worker/src/feature/builtin/workspace_config/wip.rs @@ -45,6 +45,7 @@ pub fn mount_workspace_config_wip( provider: Arc::new(super::content::ConfigProvider { feature: feature.clone(), backend: backend.clone(), + observation_nonce: uuid::Uuid::now_v7().to_string().into(), }), })?; } diff --git a/crates/worker/src/feature/invocation.rs b/crates/worker/src/feature/invocation.rs index de8bc6fbe..523be6068 100644 --- a/crates/worker/src/feature/invocation.rs +++ b/crates/worker/src/feature/invocation.rs @@ -39,6 +39,45 @@ const MAX_INVOCATION_DESCRIPTOR_BYTES: usize = protocol::invocation::MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES; const MAX_COMPLETION_RESPONSE_BYTES: usize = 64 * 1024; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct InvocationResultInput { + pub result: FeatureInvocationResult, +} + +pub(crate) fn result_input( + result: FeatureInvocationResult, + body: String, +) -> Result { + use session_store::*; + let payload = FeatureInputPayload::encode( + "feature:invocation-result", + 1, + &InvocationResultInput { result }, + )?; + let metadata = HistoryInputMetadata::new( + HistoryInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::Retain, + }, + Some(payload), + Some(PublicSystemItem { + kind: "feature_invocation_result".into(), + body: body.clone(), + presentation: PublicSystemPresentation::Context, + }), + )?; + Ok(LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: "feature.invocation".into(), + }, + None, + SystemInputEnvelope::new(body, metadata)?, + )) +} + pub(crate) fn invocation_result_is_bounded(result: &FeatureInvocationResult) -> bool { result.message.len() <= MAX_INVOCATION_MESSAGE_BYTES && result diff --git a/crates/worker/src/file_operation.rs b/crates/worker/src/file_operation.rs index 57a5f359f..00f91eb08 100644 --- a/crates/worker/src/file_operation.rs +++ b/crates/worker/src/file_operation.rs @@ -1,5 +1,15 @@ -//! WIP presentation of the provider-neutral file argument contracts. Providers -//! choose which read surface to publish; routing and preconditions are not args. +//! Stable root file contracts, independent of provider and target lifetimes. +//! +//! Each capability has one versioned reference and complete descriptor. Providers publish only +//! supported combinations. Root scope is a namespace, never a file grant. Current Object +//! membership selects the provider handler; target observations, checked operations, prior Read, +//! CAS and grants remain provider-owned. Contract validators contain no target or connection state. +//! +//! Line windows, byte ranges and whole-text domain reads are different meanings, not aliases. +//! Settings atomic commits and Drive management/cursor pagination remain domain extensions. +//! Common mutations return one metadata envelope while providers retain their commit details. +//! Old contextual references in history are neither rewritten nor accepted as aliases; callers +//! reobserve Objects and common definitions after migration or restore. use fs_operation::text::TextOperation; use wip_protocol::{ParameterDeclaration, TypeExpr}; @@ -41,3 +51,651 @@ fn schema_parameters(schema: &schemars::Schema) -> Vec { }) .collect() } + +use crate::wip::{ + WipCallContext, WipOperationError, WipOperationHandler, WipOperationOutput, WipProjection, +}; +use async_trait::async_trait; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeMap, sync::Arc}; +use wip_protocol::{ + Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceReference, + OperationDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, Value, +}; + +pub(crate) const MAX_LINES: usize = 2000; +pub(crate) const MAX_BYTES: usize = 64 * 1024; + +/// Contracts have independent capability membership and no target-dependent descriptor fields. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Contract { + ReadBytes, + ReadLines, + SearchText, + Edit, + Write, + List, + Glob, + Grep, + CreateFile, +} +impl Contract { + pub(crate) fn reference(self) -> InterfaceReference { + crate::wip::root_reference(match self { + Self::ReadLines => "yoi.file/read-lines/v1", + Self::ReadBytes => "yoi.file/read-bytes/v1", + Self::SearchText => "yoi.file/search-text/v1", + Self::Edit => "yoi.file/edit-text/v1", + Self::Write => "yoi.file/write-text/v1", + Self::List => "yoi.directory/list/v1", + Self::Glob => "yoi.directory/glob/v1", + Self::Grep => "yoi.directory/grep/v1", + Self::CreateFile => "yoi.directory/create-file/v1", + }) + } + pub(crate) fn from_reference(r: &InterfaceReference) -> Option { + [ + Self::ReadLines, + Self::ReadBytes, + Self::SearchText, + Self::Edit, + Self::Write, + Self::List, + Self::Glob, + Self::Grep, + Self::CreateFile, + ] + .into_iter() + .find(|c| c.reference() == *r) + } + pub(crate) fn operation(self) -> &'static str { + match self { + Self::ReadLines | Self::ReadBytes => "read", + Self::SearchText => "search", + Self::Edit => "edit", + Self::Write => "write", + Self::List => "list", + Self::Glob => "glob", + Self::Grep => "grep", + Self::CreateFile => "create_file", + } + } + pub(crate) fn handler_operation(self) -> &'static str { + match self { + Self::ReadLines => "read_lines", + Self::ReadBytes => "read_bytes", + Self::SearchText => "search_text", + _ => self.operation(), + } + } + pub(crate) fn descriptor(self) -> InterfaceDescriptor { + let (summary, parameters, result) = match self { + Self::ReadBytes => ( + "Read a bounded binary byte range", + vec![ + parameter("offset", false, TypeExpr::Integer), + parameter("limit", false, TypeExpr::Integer), + ], + TypeExpr::Record { + fields: vec![ + field("encoding", true, TypeExpr::String), + field("data", true, TypeExpr::String), + field("offset", true, TypeExpr::Integer), + field("next_offset", true, TypeExpr::Integer), + field("eof", true, TypeExpr::Boolean), + ], + }, + ), + Self::ReadLines => ( + "Read a bounded text line window", + parameters("read", true).unwrap(), + page_type(false), + ), + Self::SearchText => { + let mut p = parameters("read", true).unwrap(); + p.push(parameter("pattern", true, TypeExpr::String)); + ( + "Search a bounded text line window for a literal", + p, + page_type(true), + ) + } + Self::Edit | Self::Write => ( + "Change existing text under provider preconditions", + parameters(self.operation(), true).unwrap(), + mutation_type(), + ), + Self::List => ( + "List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; limit defaults to 100, maximum 1000; after continues by kind/entry. Includes hidden/ignored names under provider List policy, unlike Glob/Grep.", + vec![ + parameter("limit", false, TypeExpr::Integer), + parameter("after", false, cursor_type()), + ], + result_type(true), + ), + Self::Glob => ( + "Glob within this directory", + vec![ + parameter("pattern", true, TypeExpr::String), + parameter("path", false, TypeExpr::String), + ], + result_type(false), + ), + Self::Grep => ( + "Regex search within this directory", + grep_parameters(), + result_type(false), + ), + Self::CreateFile => ( + "Create a new relative file and permitted missing parents; never overwrite", + vec![ + parameter("path", true, TypeExpr::String), + parameter("content", true, TypeExpr::String), + ], + mutation_type(), + ), + }; + let details = match self { + Self::ReadBytes => { + "Binary-safe byte range, independent of UTF-8 and line boundaries. Offset defaults to 0; limit defaults to 16384 and must be 1..=32768 bytes. Offset must be <=9007199254708223. Returns encoding=base64 with RFC4648 standard padded data, offset and next_offset in bytes, eof when no later bytes remain. At EOF returns empty data, eof=true and next_offset=offset; offsets beyond EOF are InvalidArguments. Encoded pretty JSON result is bounded to 64 KiB. Does not establish text prior-Read for mutation. InvalidArguments rejects ranges/unknown fields, ResourceLimitExceeded bounds, ValidatorMismatch stale source, PermissionDenied authority. Provider source bounds apply; no automatic retry." + } + Self::ReadLines | Self::SearchText => { + "UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry." + } + Self::Edit | Self::Write | Self::CreateFile => { + "UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry." + } + _ => { + "Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry." + } + }; + InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + types: vec![], + documentation: Some(Documentation { + summary: summary.into(), + details: Some(details.into()), + }), + operations: vec![OperationDeclaration { + name: self.operation().into(), + documentation: None, + parameters, + returns: ReturnDeclaration { + documentation: None, + r#type: result, + }, + }], + } + } + pub(crate) fn validator(self) -> Vec { + Sha256::digest(format!("{:?}", self.descriptor())).to_vec() + } +} +pub(crate) fn expose(p: &mut WipProjection, contracts: impl IntoIterator) { + for c in contracts { + let r = c.reference(); + if !p.object.interfaces.contains(&r) { + p.object.interfaces.push(r); + } + } +} + +/// Select only from current provider membership; the selected handler still owns all authority. +pub(crate) fn select(p: &mut WipProjection, r: &InterfaceReference) -> bool { + let Some(c) = Contract::from_reference(r) else { + return false; + }; + if !p.object.interfaces.contains(r) { + return false; + } + p.interface = r.clone(); + p.descriptor = c.descriptor(); + p.interface_validator = Some(c.validator()); + p.handler = Arc::new(Selected { + contract: c, + inner: p.handler.clone(), + }); + true +} +struct Selected { + contract: Contract, + inner: Arc, +} +#[async_trait] +impl WipOperationHandler for Selected { + async fn call( + &self, + _: &str, + args: &BTreeMap, + context: WipCallContext, + ) -> Result { + let mut out = self + .inner + .call(self.contract.handler_operation(), args, context) + .await?; + if matches!( + self.contract, + Contract::Edit | Contract::Write | Contract::CreateFile + ) { + out.value = Value::Record(BTreeMap::from([("metadata".into(), out.value)])); + } + Ok(out) + } + async fn cancel_operation( + &self, + _: &str, + ctx: &agen::tool::ToolExecutionContext, + ) -> Result<(), agen::tool::ToolError> { + self.inner + .cancel_operation(self.contract.handler_operation(), ctx) + .await + } +} +fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError { + WipOperationError::Protocol(ProtocolError { + code, + message: message.into(), + }) +} +pub(crate) fn window( + args: &BTreeMap, + search: bool, +) -> Result<(usize, usize), WipOperationError> { + if args + .keys() + .any(|k| !["offset", "limit"].contains(&k.as_str()) && !(search && k == "pattern")) + { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "unknown text argument", + )); + } + let integer = |name: &str, default: usize| match args.get(name) { + None => Ok(default), + Some(Value::Integer(i)) => usize::try_from(*i) + .map_err(|_| failure(ProtocolErrorCode::InvalidArguments, "negative text range")), + _ => Err(failure( + ProtocolErrorCode::InvalidArguments, + "invalid text range", + )), + }; + let offset = integer("offset", 0)?; + let limit = integer("limit", MAX_LINES)?; + if !(1..=MAX_LINES).contains(&limit) + || (offset as u64) > 9_007_199_254_740_991u64 - MAX_LINES as u64 + { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "text range exceeds bounds", + )); + } + if search { + match args.get("pattern") { + Some(Value::String(p)) if !p.is_empty() && p.len() <= 4096 => {} + _ => { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "search requires a nonempty literal pattern up to 4096 bytes", + )); + } + } + } + Ok((offset, limit)) +} +pub(crate) fn text_result( + op: &str, + args: &BTreeMap, + content: &str, +) -> Result { + let (offset, limit) = window(args, op == "search_text")?; + let mut lines = content.lines().skip(offset); + let mut text = String::new(); + for line in lines.by_ref().take(limit) { + if text.len().saturating_add(line.len()).saturating_add(1) >= MAX_BYTES { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "text window exceeds source byte bound", + )); + } + text.push_str(line); + text.push('\n'); + } + page_result(op, args, &text, offset, lines.next().is_some()) +} +/// A checked provider can supply only the selected window, without copying whole source files. +pub(crate) fn page_result( + op: &str, + args: &BTreeMap, + text: &str, + offset: usize, + more: bool, +) -> Result { + let (_, limit) = window(args, op == "search_text")?; + if text.len() >= MAX_BYTES { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "text window exceeds source byte bound", + )); + } + let count = text.lines().count(); + if count > limit { + return Err(failure( + ProtocolErrorCode::Internal, + "provider returned an invalid line window", + )); + } + let mut fields = BTreeMap::from([ + ( + "next_offset".into(), + Value::Integer((offset + count) as i64), + ), + ("truncated".into(), Value::Boolean(more)), + ]); + if op == "search_text" { + let Some(Value::String(pattern)) = args.get("pattern") else { + unreachable!("window checked pattern") + }; + let matches = text + .lines() + .enumerate() + .filter(|(_, l)| l.contains(pattern)) + .map(|(i, l)| { + Value::Record(BTreeMap::from([ + ("line".into(), Value::Integer((offset + i + 1) as i64)), + ("text".into(), Value::String(l.into())), + ])) + }) + .collect(); + fields.insert("matches".into(), Value::List(matches)); + } else { + let full_len = text + .lines() + .enumerate() + .map(|(i, l)| format!("{:>6}\t", offset + i + 1).len() + l.len() + 1) + .sum::(); + if full_len > MAX_BYTES.saturating_sub(64) { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "numbered window exceeds output byte bound", + )); + } + let rendered = + fs_operation::text::render_numbered(text, offset, offset + count, false, MAX_BYTES); + fields.insert("content".into(), Value::String(rendered.body)); + } + let value = Value::Record(fields); + let json = crate::wip::wip_to_json(&value) + .map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?; + // The model receives pretty JSON, so budget escapes and record/indentation overhead too. + let encoded = serde_json::to_vec_pretty(&json) + .map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?; + if encoded.len() > MAX_BYTES { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "serialized text window exceeds output byte bound", + )); + } + Ok(value) +} +fn parameter(n: &str, r: bool, t: TypeExpr) -> ParameterDeclaration { + ParameterDeclaration { + name: n.into(), + required: r, + documentation: None, + r#type: t, + } +} +fn field(n: &str, r: bool, t: TypeExpr) -> FieldDeclaration { + FieldDeclaration { + name: n.into(), + required: r, + documentation: None, + r#type: t, + } +} +fn mutation_type() -> TypeExpr { + TypeExpr::Record { + fields: vec![field("metadata", true, TypeExpr::Json)], + } +} +fn page_type(search: bool) -> TypeExpr { + let mut fields = vec![ + field("next_offset", true, TypeExpr::Integer), + field("truncated", true, TypeExpr::Boolean), + ]; + fields.push(if search { + field( + "matches", + true, + TypeExpr::List { + items: Box::new(TypeExpr::Record { + fields: vec![ + field("line", true, TypeExpr::Integer), + field("text", true, TypeExpr::String), + ], + }), + }, + ) + } else { + field("content", true, TypeExpr::String) + }); + TypeExpr::Record { fields } +} +fn cursor_type() -> TypeExpr { + TypeExpr::Record { + fields: vec![ + field("kind", true, TypeExpr::String), + field("entry", true, TypeExpr::Entry), + ], + } +} +fn result_type(list: bool) -> TypeExpr { + let mut item = vec![field("entry", true, TypeExpr::Entry)]; + if list { + item.extend([ + field("kind", true, TypeExpr::String), + field("size", true, TypeExpr::Integer), + ]); + } + let mut fields = vec![ + field("summary", true, TypeExpr::String), + field("content", false, TypeExpr::String), + field( + "items", + true, + TypeExpr::List { + items: Box::new(TypeExpr::Record { fields: item }), + }, + ), + ]; + if list { + fields.extend([ + field("truncated", true, TypeExpr::Boolean), + field("after", false, cursor_type()), + ]); + } + TypeExpr::Record { fields } +} +fn grep_parameters() -> Vec { + let mut p = vec![parameter("pattern", true, TypeExpr::String)]; + for n in ["path", "glob", "type", "output_mode"] { + p.push(parameter(n, false, TypeExpr::String)); + } + for n in ["-A", "-B", "-C", "head_limit", "offset"] { + p.push(parameter(n, false, TypeExpr::Integer)); + } + for n in ["case_insensitive", "multiline"] { + p.push(parameter(n, false, TypeExpr::Boolean)); + } + p +} + +#[cfg(test)] +mod tests { + use super::*; + fn args(value: serde_json::Value) -> BTreeMap { + let Value::Record(args) = crate::wip::json_to_wip(&value).unwrap() else { + panic!("record") + }; + args + } + #[test] + fn text_window_preserves_unicode_crlf_offsets_and_literal_search_continuation() { + let input = "first\r\n雪.*\r\nnot a match\n雪.*\n"; + let arguments = args(serde_json::json!({"offset":1,"limit":2,"pattern":".*"})); + let whole = text_result("search_text", &arguments, input) + .unwrap_or_else(|_| panic!("text result refused")); + let partial = page_result("search_text", &arguments, "雪.*\r\nnot a match\n", 1, true) + .unwrap_or_else(|_| panic!("page refused")); + assert_eq!(whole, partial); + let result = crate::wip::wip_to_json(&whole).unwrap(); + assert_eq!( + result, + serde_json::json!({"matches":[{"line":2,"text":"雪.*"}],"next_offset":3,"truncated":true}) + ); + let read = text_result( + "read_lines", + &args(serde_json::json!({"offset":1,"limit":1})), + input, + ) + .unwrap_or_else(|_| panic!("read refused")); + assert_eq!( + crate::wip::wip_to_json(&read).unwrap(), + serde_json::json!({"content":" 2\t雪.*\n","next_offset":2,"truncated":true}) + ); + } + #[test] + fn text_window_refuses_invalid_ranges_patterns_source_and_encoded_output_bounds() { + for value in [ + serde_json::json!({"limit":0}), + serde_json::json!({"limit":2001}), + serde_json::json!({"offset":-1}), + serde_json::json!({"offset":9_007_199_254_740_991i64}), + serde_json::json!({"file_path":"elsewhere"}), + ] { + assert!( + matches!(text_result("read_lines",&args(value),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments) + ); + } + for pattern in [String::new(), "x".repeat(4097)] { + assert!( + matches!(text_result("search_text",&args(serde_json::json!({"pattern":pattern})),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments) + ); + } + for (operation, arguments, source) in [ + ( + "read_lines", + serde_json::json!({}), + "雪".repeat(MAX_BYTES / 3 + 1), + ), + ("read_lines", serde_json::json!({}), "\u{1}".repeat(16000)), + ( + "search_text", + serde_json::json!({"pattern":"x"}), + "x\n".repeat(MAX_LINES), + ), + ( + "search_text", + serde_json::json!({"pattern":"\""}), + format!("{}\n", "\"".repeat(40000)), + ), + ] { + assert!( + matches!(text_result(operation,&args(arguments),&source),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ResourceLimitExceeded), + "{operation}: {} source bytes", + source.len() + ); + } + } +} + +/// Bounds reserve room for base64 expansion, JSON encoding, and exact lossless integer offsets. +pub(crate) fn byte_range( + args: &BTreeMap, +) -> Result<(u64, usize), WipOperationError> { + if args + .keys() + .any(|k| !["offset", "limit"].contains(&k.as_str())) + { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "unknown byte argument", + )); + } + let integer = |name: &str, default: u64| match args.get(name) { + None => Ok(default), + Some(Value::Integer(i)) => u64::try_from(*i) + .map_err(|_| failure(ProtocolErrorCode::InvalidArguments, "negative byte range")), + _ => Err(failure( + ProtocolErrorCode::InvalidArguments, + "invalid byte range", + )), + }; + let offset = integer("offset", 0)?; + let limit = integer("limit", 16384)?; + if offset > 9_007_199_254_708_223 || !(1..=32768).contains(&limit) { + return Err(failure( + ProtocolErrorCode::InvalidArguments, + "byte range exceeds bounds", + )); + } + Ok((offset, limit as usize)) +} +pub(crate) fn bytes_result( + args: &BTreeMap, + bytes: &[u8], + eof: bool, +) -> Result { + use base64::Engine as _; + let (offset, limit) = byte_range(args)?; + if bytes.len() > limit || (bytes.is_empty() && !eof) { + return Err(failure( + ProtocolErrorCode::Internal, + "provider returned an invalid byte range", + )); + } + let value = Value::Record(BTreeMap::from([ + ("encoding".into(), Value::String("base64".into())), + ( + "data".into(), + Value::String(base64::engine::general_purpose::STANDARD.encode(bytes)), + ), + ("offset".into(), Value::Integer(offset as i64)), + ( + "next_offset".into(), + Value::Integer((offset + bytes.len() as u64) as i64), + ), + ("eof".into(), Value::Boolean(eof)), + ])); + let json = crate::wip::wip_to_json(&value) + .map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))?; + if serde_json::to_vec_pretty(&json) + .map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))? + .len() + > MAX_BYTES + { + return Err(failure( + ProtocolErrorCode::ResourceLimitExceeded, + "encoded byte result exceeds bound", + )); + } + Ok(value) +} + +#[cfg(test)] +mod byte_tests { + use super::*; + #[test] + fn maximum_raw_byte_page_reserves_base64_and_complete_json_budget() { + let args = BTreeMap::from([("limit".into(), Value::Integer(32768))]); + let result = bytes_result(&args, &vec![0xff; 32768], false) + .unwrap_or_else(|_| panic!("valid bounded bytes")); + let json = crate::wip::wip_to_json(&result).unwrap(); + assert!(serde_json::to_vec_pretty(&json).unwrap().len() <= MAX_BYTES); + assert_eq!(json["next_offset"], 32768); + assert_eq!(json["eof"], false); + assert!( + matches!(bytes_result(&args,&vec![0;32769],false),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::Internal) + ); + } +} diff --git a/crates/worker/src/hook.rs b/crates/worker/src/hook.rs index a433bb7e3..c480471dc 100644 --- a/crates/worker/src/hook.rs +++ b/crates/worker/src/hook.rs @@ -25,7 +25,9 @@ use agen::tool::{ToolOutput, ToolResult}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; -use session_store::{SystemItem, SystemReminder}; +#[cfg(test)] +use session_store::SystemItem; +use session_store::SystemReminder; use thiserror::Error; use crate::SessionEntryRef; @@ -254,7 +256,7 @@ impl From for TurnEndAction { // Hook context handles // ============================================================================= -/// Host-created handle for appending approved durable [`SystemItem`] requests. +/// Host-created handle for appending approved durable [`session_store::SystemItem`] requests. /// /// Hook code can use this handle only when the Worker host includes it in an /// event-specific context. The handle queues typed requests; the host drains the @@ -263,11 +265,11 @@ impl From for TurnEndAction { /// raw `agen::Item`, history writer, event sender, `Worker`, `Engine`, or /// notification buffer. pub struct SystemItemAppendHandle { - pending: Arc>>, + pending: Arc>>, } impl SystemItemAppendHandle { - pub(crate) fn new(pending: Arc>>) -> Self { + pub(crate) fn new(pending: Arc>>) -> Self { Self { pending } } @@ -279,53 +281,44 @@ impl SystemItemAppendHandle { self.pending .lock() .expect("system-item append queue poisoned") - .push(item); + .push(session_store::LoggedSystemHistoryEntry { + item, + metadata: crate::session_history::metadata( + session_store::LoggedSessionHistoryOrigin::BackendInstruction { + operation_id: None, + }, + None, + ), + }); } - /// Queue a Subject behavior refresh with its exact represented behavior. The caller receives this - /// narrow operation only at a pre-request boundary, and the host commits it - /// before exposing it to the model. - pub fn append_subject_behavior_refresh( - &self, - body: impl Into, - behavior_md: impl Into, - ) { + /// Queue producer-validated System context. Metadata and body are committed together. + /// The Host exposes this capability only at a pre-request boundary. + pub fn append_input(&self, input: session_store::LoggedSystemHistoryEntry) { self.pending .lock() .expect("system-item append queue poisoned") - .push(SystemItem::SubjectBehaviorRefresh { - body: body.into(), - behavior_md: behavior_md.into(), - prompt_provenance: None, - }); + .push(input); } } /// Host-created narrow append authority exposed only at a restore lifecycle -/// boundary. It cannot emit arbitrary system items. +/// boundary. No model-facing tool receives this capability. pub struct RestoreSystemItemAppendHandle { - pending: Arc>>, + pending: Arc>>, } impl RestoreSystemItemAppendHandle { - pub(crate) fn new(pending: Arc>>) -> Self { + pub(crate) fn new(pending: Arc>>) -> Self { Self { pending } } - /// Queue a Feature-rendered resident-summary refresh for durable append at - /// the restore lifecycle boundary. - pub fn append_resident_summary_refresh( - &self, - body: impl Into, - prompt_provenance: session_store::PromptRenderProvenance, - ) { + /// Queue producer-validated System context at the Host restore boundary. + pub fn append_input(&self, input: session_store::LoggedSystemHistoryEntry) { self.pending .lock() .expect("restore system-item append queue poisoned") - .push(SystemItem::ResidentSummaryRefresh { - body: body.into(), - prompt_provenance: Some(prompt_provenance), - }); + .push(input); } } @@ -983,7 +976,7 @@ mod tests { let queued = pending.lock().expect("pending queue poisoned"); assert_eq!(queued.len(), 1); - match &queued[0] { + match &queued[0].item { SystemItem::TaskReminder { body, .. } => { assert_eq!(body, "remember tasks"); } @@ -992,7 +985,7 @@ mod tests { } #[test] - fn restore_append_handle_queues_only_resident_refresh_items() { + fn restore_append_handle_preserves_producer_metadata() { let pending = Arc::new(Mutex::new(Vec::new())); let handle = RestoreSystemItemAppendHandle::new(Arc::clone(&pending)); let provenance = session_store::PromptRenderProvenance { @@ -1003,15 +996,16 @@ mod tests { logical_name: "internal.resident_memory_restore_section".into(), }; - handle.append_resident_summary_refresh("current surface", provenance.clone()); - - assert!(matches!( - pending.lock().unwrap().as_slice(), - [SystemItem::ResidentSummaryRefresh { - body, - prompt_provenance: Some(actual), - }] if body == "current surface" && actual == &provenance - )); + let input = crate::feature::builtin::memory::resident_summary_input( + "current surface".into(), + Some(provenance), + ) + .unwrap(); + let expected = serde_json::to_value(&input).unwrap(); + handle.append_input(input); + let queued = pending.lock().unwrap(); + assert_eq!(queued.len(), 1); + assert_eq!(serde_json::to_value(&queued[0]).unwrap(), expected); } #[test] diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index 7ffc44b42..2c6a52a13 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -1499,6 +1499,7 @@ permission = "write" is_error: false, }, metadata: session_store::LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: session_store::LoggedSessionHistoryEntryId(entry_id.into()), origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index be4f18dda..95860b27b 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -216,15 +216,6 @@ impl WorkerInterceptor { self } - fn confirm_resident_context_item(&self, item: &SystemItem) { - let Some(source) = self.resident_context_source.as_ref() else { - return; - }; - if let SystemItem::SubjectBehaviorRefresh { behavior_md, .. } = item { - source.confirm_resident_context_behavior(behavior_md); - } - } - /// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem` /// entry through the attached writer (no-op when no writer is /// wired). Sync — writes complete before the matching @@ -243,16 +234,20 @@ impl WorkerInterceptor { return Ok(()); }; for (item, extensions, history_provenance) in items { - let entry = writer.commit_system_item_with_extensions( + writer.commit_system_item_with_extensions_then( item.clone(), extensions.clone(), history_provenance.clone(), + &mut |entry| { + self.pending_committed_history + .lock() + .expect("pending committed history poisoned") + .push_back(entry.clone()); + if let Some(source) = self.resident_context_source.as_ref() { + source.system_item_committed(item, &entry.annotation); + } + }, )?; - self.pending_committed_history - .lock() - .expect("pending committed history poisoned") - .push_back(entry); - self.confirm_resident_context_item(item); } Ok(()) } @@ -267,6 +262,31 @@ impl WorkerInterceptor { ) } + fn commit_system_entries( + &self, + entries: &[session_store::LoggedSystemHistoryEntry], + ) -> Result<(), session_store::StoreError> { + let Some(writer) = &self.log_writer else { + return Ok(()); + }; + for entry in entries { + writer.commit_annotated_system_item_then( + entry.clone(), + Vec::new(), + &mut |history| { + self.pending_committed_history + .lock() + .expect("pending committed history poisoned") + .push_back(history.clone()); + if let Some(source) = &self.resident_context_source { + source.system_item_committed(&entry.item, &history.annotation); + } + }, + )?; + } + Ok(()) + } + fn current_turn_index(&self) -> usize { self.next_turn_index .load(Ordering::Relaxed) @@ -392,11 +412,6 @@ impl WorkerInterceptor { } if prompt_provenance.is_none() => { *prompt_provenance = Some(provenance("internal.task_reminder")); } - SystemItem::SubjectBehaviorRefresh { - prompt_provenance, .. - } if prompt_provenance.is_none() => { - *prompt_provenance = Some(provenance("internal.subjektiv_resident_context")); - } SystemItem::Interrupt { prompt_provenance, .. } if prompt_provenance.is_none() => { @@ -622,15 +637,17 @@ impl Interceptor for WorkerInterceptor { return Ok(PreRequestAction::Yield); } - let mut system_items: Vec = std::mem::take( + let mut system_items: Vec = std::mem::take( &mut *pending_hook_system_items .lock() .expect("pending hook system-item queue poisoned"), ); - self.attach_prompt_provenance(&mut system_items); + for entry in &mut system_items { + self.attach_prompt_provenance(std::slice::from_mut(&mut entry.item)); + } let mut appended_items: Vec = system_items .iter() - .map(SystemItem::to_history_item) + .map(|entry| entry.item.to_history_item()) .collect(); let effective_context = if appended_items.is_empty() { Cow::Borrowed(context.as_slice()) @@ -644,7 +661,7 @@ impl Interceptor for WorkerInterceptor { let compact_decision = self.request_compact_decision(current_tokens, effective_context.as_ref()); if !matches!(compact_decision, AutomaticCompactDecision::Continue) { - if let Err(error) = self.commit_system_items(&system_items) { + if let Err(error) = self.commit_system_entries(&system_items) { return Ok(PreRequestAction::Cancel(format!( "session persistence failed: {error}" ))); @@ -688,7 +705,7 @@ impl Interceptor for WorkerInterceptor { if appended_items.is_empty() { return Ok(PreRequestAction::Continue); } - Ok(match self.commit_system_items(&system_items) { + Ok(match self.commit_system_entries(&system_items) { Ok(()) => PreRequestAction::ContinueWith(appended_items), Err(error) => PreRequestAction::Cancel(format!("session persistence failed: {error}")), }) @@ -988,7 +1005,14 @@ mod tests { input .system_items() .expect("test interceptor has durable append authority") - .append_subject_behavior_refresh("current Subject context", "Ask when uncertain."); + .append_input( + crate::feature::builtin::memory::subject_behavior_input( + "current Subject context".into(), + "Ask when uncertain.".into(), + None, + ) + .unwrap(), + ); Ok(HookPreRequestAction::Continue) } } @@ -1003,8 +1027,26 @@ mod tests { SystemPromptContribution::Unavailable } - fn confirm_resident_context_behavior(&self, behavior_md: &str) { - self.confirmed.lock().unwrap().push(behavior_md.to_owned()); + fn system_item_committed( + &self, + _item: &SystemItem, + metadata: &crate::session_history::SessionHistoryMetadata, + ) { + if let Some(input) = metadata + .input_metadata + .as_ref() + .and_then(|input| input.feature_payload()) + .and_then(|payload| { + payload + .decode::( + "subject:behavior", + 1, + ) + .ok() + }) + { + self.confirmed.lock().unwrap().push(input.behavior_md); + } } } @@ -1548,14 +1590,87 @@ mod tests { assert_eq!(*confirmed.lock().unwrap(), ["Ask when uncertain."]); assert!(matches!( committed.lock().unwrap().as_slice(), - [SystemItem::SubjectBehaviorRefresh { - behavior_md, - prompt_provenance: Some(_), - .. - }] if behavior_md == "Ask when uncertain." + [SystemItem::Input { body }] if body == "current Subject context" )); } + #[test] + fn system_input_completion_reports_only_durable_batch_prefix() { + struct PrefixCommitter(Arc>>); + impl SystemItemCommitter for PrefixCommitter { + fn commit_log_entry( + &self, + entry: session_store::LogEntry, + ) -> Result<(), session_store::StoreError> { + let mut committed = self.0.lock().unwrap(); + if !committed.is_empty() { + return Err(std::io::Error::other("injected second append failure").into()); + } + committed.push(entry); + Ok(()) + } + } + struct Observer { + saved: Arc>>, + receipts: Arc>>, + } + #[async_trait] + impl SystemPromptContributionSource for Observer { + async fn load(&self) -> SystemPromptContribution { + SystemPromptContribution::Unavailable + } + fn system_item_committed( + &self, + item: &SystemItem, + metadata: &crate::session_history::SessionHistoryMetadata, + ) { + let saved = self.saved.lock().unwrap(); + let Some(session_store::LogEntry::AnnotatedSystemItem { entry, .. }) = saved.last() + else { + panic!("completion must follow persistence"); + }; + assert_eq!(&entry.metadata, metadata); + assert_eq!(entry.item.history_text(), item.history_text()); + self.receipts.lock().unwrap().push(metadata.clone()); + } + } + let saved = Arc::new(Mutex::new(Vec::new())); + let receipts = Arc::new(Mutex::new(Vec::new())); + let pending = Arc::new(Mutex::new(Vec::new())); + let interceptor = WorkerInterceptor::new( + Arc::new(HookRegistryBuilder::new().build()), + None, + None, + NotifyBuffer::new(), + pending.clone(), + test_prompts(), + Some(Arc::new(PrefixCommitter(saved.clone()))), + ) + .with_resident_context_source(Some(Arc::new(Observer { + saved: saved.clone(), + receipts: receipts.clone(), + }))); + let items = [ + SystemItem::Notification { + message: "first".into(), + body: "first saved input".into(), + prompt_provenance: None, + }, + SystemItem::FileAttachment { + path: "test.rs".into(), + body: "second unsaved input".into(), + }, + ]; + assert!(interceptor.commit_system_items(&items).is_err()); + assert_eq!(saved.lock().unwrap().len(), 1); + let receipts = receipts.lock().unwrap(); + assert_eq!(receipts.len(), 1); + let pending = interceptor.pending_committed_history.lock().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].annotation, receipts[0]); + assert_eq!(pending[0].item.as_text(), Some("first saved input")); + } + #[test] fn behavior_ack_uses_raw_content_including_clear_not_rendered_context() { let committed = Arc::new(Mutex::new(Vec::new())); @@ -1576,12 +1691,15 @@ mod tests { })), ) .with_resident_context_source(Some(source)); - let items = ["raw behavior", ""].map(|behavior_md| SystemItem::SubjectBehaviorRefresh { - body: "same rendered context".into(), - behavior_md: behavior_md.into(), - prompt_provenance: None, + let items = ["raw behavior", ""].map(|behavior_md| { + crate::feature::builtin::memory::subject_behavior_input( + "same rendered context".into(), + behavior_md.into(), + None, + ) + .unwrap() }); - interceptor.commit_system_items(&items).unwrap(); + interceptor.commit_system_entries(&items).unwrap(); assert_eq!(*confirmed.lock().unwrap(), ["raw behavior", ""]); assert_eq!(committed.lock().unwrap().len(), 2); } diff --git a/crates/worker/src/ipc/protocol_session.rs b/crates/worker/src/ipc/protocol_session.rs index 6735afdcf..8fc4a5d97 100644 --- a/crates/worker/src/ipc/protocol_session.rs +++ b/crates/worker/src/ipc/protocol_session.rs @@ -90,7 +90,8 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option { } LogEntry::AnnotatedSystemItem { entry, .. } => { let entry_id = Some(entry.metadata.entry_id.0.clone()); - let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize"); + let value = serde_json::to_value(entry.public_item()) + .expect("public system input is serializable"); Some(Event::SystemItem { entry_id, item: value, @@ -339,21 +340,13 @@ mod tests { #[test] fn system_item_log_entry_keeps_identity_in_live_event() { let entry_id = session_store::LoggedSessionHistoryEntryId::new(); + let mut input = + crate::feature::builtin::memory::resident_summary_input("refresh".into(), None) + .unwrap(); + input.metadata.entry_id = entry_id.clone(); let event = live_log_entry_event(LogEntry::AnnotatedSystemItem { ts: session_store::segment_log::now_millis(), - entry: session_store::LoggedSystemHistoryEntry { - item: session_store::SystemItem::ResidentSummaryRefresh { - body: "refresh".to_string(), - prompt_provenance: None, - }, - metadata: session_store::LoggedSessionHistoryMetadata { - entry_id: entry_id.clone(), - origin: session_store::LoggedSessionHistoryOrigin::BackendInstruction { - operation_id: None, - }, - derivation: None, - }, - }, + entry: input, extensions: Vec::new(), }) .expect("SystemItem must be live-relevant"); @@ -370,6 +363,71 @@ mod tests { } } + #[test] + fn live_and_snapshot_system_projection_exclude_private_feature_fields() { + let provenance = session_store::PromptRenderProvenance { + workspace_id: Some("private-workspace".into()), + source_digest: "private-source".into(), + projection_digest: "private-projection".into(), + logical_name: "private-template".into(), + }; + let items = [ + crate::feature::builtin::memory::subject_behavior_input( + "displayed context".into(), + "private-raw-behavior".into(), + Some(provenance.clone()), + ) + .unwrap(), + crate::feature::builtin::memory::resident_summary_input( + "displayed reference".into(), + Some(provenance), + ) + .unwrap(), + ]; + for input in items { + let entry = LogEntry::AnnotatedSystemItem { + ts: 7, + entry: input, + extensions: vec![], + }; + let persisted: LogEntry = + serde_json::from_slice(&serde_json::to_vec(&entry).unwrap()).unwrap(); + let snapshot = session_store::public_snapshot::project_current_session_snapshot(&[ + persisted.clone(), + ]); + let Event::SystemItem { + entry_id, + item: live, + } = live_log_entry_event(persisted).unwrap() + else { + panic!("expected live system input"); + }; + let snapshot_entry = &snapshot.entries[0]; + assert_eq!(entry_id.as_deref(), Some(snapshot_entry.entry_id.as_str())); + let protocol::SessionSnapshotEntryData::SystemItem { + data: Some(saved), + content, + .. + } = &snapshot_entry.data + else { + panic!("expected canonical public system input"); + }; + assert_eq!(&live, saved); + let public: session_store::PublicSystemItem = + serde_json::from_value(live.clone()).unwrap(); + assert_eq!(&public.body, content); + assert!(matches!( + public.presentation, + session_store::PublicSystemPresentation::Context + )); + let encoded = serde_json::to_string(&snapshot).unwrap(); + assert!(!encoded.contains("private-")); + assert!(live.get("prompt_provenance").is_none()); + assert!(live.get("snapshot").is_none()); + assert!(live.get("behavior_md").is_none()); + } + } + #[test] fn user_input_log_entry_maps_to_user_message_event() { let segments = vec![protocol::Segment::text("hello from log")]; diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 10cf5a6c5..0037fa8cb 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../README.md")] +pub mod authorized_workdir; mod backend_job; pub use backend_job::BackendJobExecutionBinding; pub mod bootstrap; @@ -15,6 +16,7 @@ pub mod hook; pub(crate) mod in_flight; pub mod ipc; pub mod job; +mod metadata_lifecycle; pub mod model_client; mod paste_artifact_tool; pub mod prompt; @@ -26,6 +28,7 @@ mod session_history; pub mod shared_state; mod shutdown_after_idle; mod subject_release; +mod subjektiv_metadata; pub use subject_release::SubjectReleaseReservation; pub mod skill; pub mod spawn; @@ -39,7 +42,7 @@ mod worker; pub use bootstrap::{ BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError, - WorkerBootstrapLayout, bash_output_dir_for_worker_id, start_worker_controller, + WorkerBootstrapLayout, start_worker_controller, }; pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate}; pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle}; diff --git a/crates/worker/src/metadata_lifecycle.rs b/crates/worker/src/metadata_lifecycle.rs new file mode 100644 index 000000000..074e86d60 --- /dev/null +++ b/crates/worker/src/metadata_lifecycle.rs @@ -0,0 +1,69 @@ +//! Atomic metadata transitions with Host-owned Session lifecycle participants. +//! +//! Participants inspect domain evidence inside the metadata update, not after the +//! active pointer has been published. The generic writer never interprets it. + +use session_store::worker_metadata::{ + WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerStoreError, +}; +use std::sync::Arc; + +pub(crate) trait SessionMetadataLifecycle: Send + Sync { + /// Runs under the metadata update lock. Only compare/transform the supplied + /// value: no I/O, reentrant store calls, lock waits, or Feature refresh. + fn active_changing(&self, metadata: &mut WorkerMetadata, next: Option<&WorkerActiveSegmentRef>); +} + +pub(crate) type WorkerMetadataWriter = + Arc Result<(), WorkerStoreError> + Send + Sync>; +pub(crate) type WorkerMetadataSegmentCas = Arc< + dyn Fn(&str, &WorkerActiveSegmentRef, WorkerActiveSegmentRef) -> Result + + Send + + Sync, +>; + +pub(crate) fn worker_metadata_writer_for_store(store: &St) -> WorkerMetadataWriter +where + St: WorkerMetadataStore + Clone + Send + Sync + 'static, +{ + let store = store.clone(); + let participants = crate::feature::builtin::session_metadata_lifecycles(); + Arc::new(move |next| { + store + .update_by_name(&next.worker_name, |metadata| { + for participant in &participants { + participant.active_changing(metadata, next.active.as_ref()); + } + metadata.active = next.active; + metadata.resolved_manifest_snapshot = next.resolved_manifest_snapshot; + if next.workspace_id.is_some() { + metadata.workspace_id = next.workspace_id; + } + if next.workspace_root.is_some() { + metadata.workspace_root = next.workspace_root; + } + }) + .map(|_| ()) + }) +} + +pub(crate) fn worker_metadata_segment_cas_for_store(store: &St) -> WorkerMetadataSegmentCas +where + St: WorkerMetadataStore + Clone + Send + Sync + 'static, +{ + let store = store.clone(); + let participants = crate::feature::builtin::session_metadata_lifecycles(); + Arc::new(move |worker_name, expected, replacement| { + let mut matched = false; + store.update_by_name(worker_name, |metadata| { + if metadata.active.as_ref() == Some(expected) { + for participant in &participants { + participant.active_changing(metadata, Some(&replacement)); + } + metadata.active = Some(replacement); + matched = true; + } + })?; + Ok(matched) + }) +} diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index cfdc3f6b3..17415fbc6 100644 --- a/crates/worker/src/prompt/system.rs +++ b/crates/worker/src/prompt/system.rs @@ -326,9 +326,32 @@ mod tests { use crate::feature::builtin::merge_request::MergeRequestFeature; use crate::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess}; use crate::hook::HookRegistryBuilder; - use crate::worker::TestWorkspaceHttpClient; + use crate::worker::{ + WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse, + }; use manifest::MergeRequestFeatureConfig; + #[derive(Debug)] + struct MetadataOnlyHost; + impl WorkspaceClient for MetadataOnlyHost { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn kind(&self) -> &str { + "metadata-fixture" + } + fn is_available(&self) -> bool { + true + } + fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> { + assert_eq!(feature, "ticket"); + Ok(()) + } + fn execute(&self, _: WorkspaceRequest) -> Result { + panic!("metadata-only prompt fixture must not perform protected operations") + } + } + // These builtin resources are the model-visible workflow compatibility contract. // Fixtures prove composition and tool descriptions, not an agent's real judgment. fn ticket_workflow_fixture( @@ -351,7 +374,7 @@ mod tests { mr_config: MergeRequestFeatureConfig, access: TicketFeatureAccess, ) -> (String, Vec) { - let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")); + let client = Arc::new(MetadataOnlyHost); let mut tools = Vec::new(); let mut hooks = HookRegistryBuilder::default(); let report = FeatureRegistryBuilder::new() diff --git a/crates/worker/src/session_capture.rs b/crates/worker/src/session_capture.rs index c89570146..c255d7dad 100644 --- a/crates/worker/src/session_capture.rs +++ b/crates/worker/src/session_capture.rs @@ -74,6 +74,7 @@ impl std::fmt::Display for SessionEntryRef { pub(crate) enum ReferenceKind { User, Assistant, + System, Tool, } @@ -82,6 +83,7 @@ impl ReferenceKind { match self { Self::User => "user", Self::Assistant => "assistant", + Self::System => "system", Self::Tool => "tool", } } @@ -90,6 +92,7 @@ impl ReferenceKind { match value { "user" => Some(Self::User), "assistant" | "agent" => Some(Self::Assistant), + "system" => Some(Self::System), "tool" => Some(Self::Tool), _ => None, } @@ -131,6 +134,7 @@ pub(crate) struct OverviewItem { #[derive(Debug, Clone)] pub(crate) struct ReferenceEntry { + use_policy: Option, pub id: SessionEntryRef, pub origin: SessionEntryProvenance, pub entry_range: [u64; 2], @@ -242,6 +246,7 @@ pub(crate) struct SessionEntryEvidence { #[derive(Debug, Clone)] struct CapturedHistoryEntry { + use_policy: Option, item: Item, entry_id: session_store::LoggedSessionHistoryEntryId, origin: SessionEntryProvenance, @@ -302,10 +307,12 @@ impl SessionCapture { is_error, attachments: _, } => Item::tool_result_item(call_id, summary, content, is_error), - // Observation deliberately excludes system items and - // controller errors from model-visible session evidence. - SessionSnapshotEntryData::SystemItem { .. } - | SessionSnapshotEntryData::Invoke { .. } + SessionSnapshotEntryData::SystemItem { content, .. } => { + entry.use_policy?; + Item::system_message(content) + } + // Controller errors are not model-visible evidence. + SessionSnapshotEntryData::Invoke { .. } | SessionSnapshotEntryData::Usage { .. } | SessionSnapshotEntryData::RunStats { .. } | SessionSnapshotEntryData::RunYielded { .. } @@ -314,6 +321,7 @@ impl SessionCapture { | SessionSnapshotEntryData::RunError { .. } => return None, }; Some(CapturedHistoryEntry { + use_policy: entry.use_policy, item, entry_id: session_store::LoggedSessionHistoryEntryId(entry.entry_id), origin: entry.provenance, @@ -328,6 +336,7 @@ impl SessionCapture { .into_iter() .enumerate() .map(|(index, item)| CapturedHistoryEntry { + use_policy: None, item, entry_id: session_store::LoggedSessionHistoryEntryId(format!("{index:08}")), origin: SessionEntryProvenance::LegacyUnknown, @@ -342,10 +351,32 @@ impl SessionCapture { ) -> Self { let entries = entries .into_iter() - .map(|entry| CapturedHistoryEntry { - item: entry.item, - entry_id: entry.annotation.entry_id, - origin: public_provenance(&entry.annotation.origin), + .map(|entry| { + let (item, use_policy) = if let Some(input) = &entry.annotation.input_metadata { + let is_system = matches!( + entry.item, + Item::Message { + role: Role::System, + .. + } + ); + if is_system { + ( + Item::system_message(input.public_projection().body), + Some(input.public_system_use_policy()), + ) + } else { + (entry.item, Some(input.public_use_policy())) + } + } else { + (entry.item, None) + }; + CapturedHistoryEntry { + item, + use_policy, + entry_id: entry.annotation.entry_id, + origin: public_provenance(&entry.annotation.origin), + } }) .collect(); Self::from_captured_entries(segment_id, entries) @@ -364,7 +395,8 @@ impl SessionCapture { let entry_range = [idx as u64, idx as u64]; match item { Item::Message { role, content, .. } => { - let Some(kind) = message_reference_kind(&entry.origin, role) else { + let Some(kind) = message_reference_kind(&entry.origin, role, entry.use_policy) + else { continue; }; let text = content @@ -376,6 +408,7 @@ impl SessionCapture { let summary = truncate_chars(&text, 240); let id = SessionEntryRef::from_history_entry_id(&entry.entry_id); index.push(ReferenceEntry { + use_policy: entry.use_policy, id: id.clone(), origin: entry.origin.clone(), entry_range, @@ -386,7 +419,9 @@ impl SessionCapture { summary: summary.clone(), search_text: text.clone(), }); - if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) { + if entry.use_policy.is_none_or(|p| p.observation_readable) + && matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) + { overview.push(OverviewItem { id: id.clone(), origin: entry.origin.clone(), @@ -403,6 +438,7 @@ impl SessionCapture { } => { let text = format!("{name}\n{arguments}"); index.push(ReferenceEntry { + use_policy: entry.use_policy, id: SessionEntryRef::from_history_entry_id(&entry.entry_id), origin: entry.origin.clone(), entry_range, @@ -430,6 +466,7 @@ impl SessionCapture { content.as_deref().unwrap_or_default(), ); index.push(ReferenceEntry { + use_policy: entry.use_policy, id: SessionEntryRef::from_history_entry_id(&entry.entry_id), origin: entry.origin.clone(), entry_range, @@ -510,6 +547,9 @@ impl SessionCapture { let mut hits = Vec::new(); for entry in &self.index { + if entry.use_policy.is_some_and(|p| !p.observation_readable) { + continue; + } if entry.entry_range[0] < min_entry_index || entry.entry_range[0] > max_entry_index { continue; } @@ -581,6 +621,9 @@ impl SessionCapture { }; for entry in selected { + if entry.use_policy.is_some_and(|p| !p.observation_readable) { + continue; + } if entries.len() >= max_items { truncated = true; break; @@ -695,21 +738,11 @@ impl SessionCapture { pub(crate) fn evidence_for(&self, id: &str) -> Option { let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?; - let excerpt = self - .read( - ReadSelector::Id(id), - ReadOptions { - include_tools: true, - tool_part: ToolPart::Both, - detail: ReadDetail::Compact, - max_items: 1, - max_bytes: 2 * 1024, - }, - ) - .entries - .first() - .map(|entry| entry.text.clone()) - .unwrap_or_else(|| entry.summary.clone()); + if entry.use_policy.is_some_and(|p| !p.memory_evidence) { + return None; + } + let item = &self.entries.get(entry.entry_range[0] as usize)?.item; + let excerpt = render_item(item, entry, ReadDetail::Compact, 2 * 1024); Some(SessionEntryEvidence { segment_id: self.segment_id.clone(), entry_ref: entry.id.clone(), @@ -731,9 +764,8 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance } WorkerHistoryProvenance::WorkerInput { .. } => SessionEntryProvenance::WorkerInput, WorkerHistoryProvenance::FlowInstruction { .. } => SessionEntryProvenance::FlowInstruction, - WorkerHistoryProvenance::BackendInstruction { .. } => { - SessionEntryProvenance::BackendInstruction - } + WorkerHistoryProvenance::BackendInstruction { .. } + | WorkerHistoryProvenance::Provided { .. } => SessionEntryProvenance::BackendInstruction, WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput, WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput, WorkerHistoryProvenance::CompactionSummary @@ -746,7 +778,15 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance fn message_reference_kind( origin: &SessionEntryProvenance, provider_role: &Role, + use_policy: Option, ) -> Option { + if use_policy.is_some() { + return match provider_role { + Role::System => Some(ReferenceKind::System), + Role::User => Some(ReferenceKind::User), + Role::Assistant => Some(ReferenceKind::Assistant), + }; + } match origin { SessionEntryProvenance::HumanInput | SessionEntryProvenance::WorkerInput => { Some(ReferenceKind::User) @@ -950,6 +990,328 @@ mod tests { } } + #[test] + fn generic_public_input_observation_and_memory_policies_survive_store_restore_independently() { + use session_store::{ + FeatureInputPayload, FsStore, HistoryInputMetadata, HistoryInputUsePolicy, + InputCompactionUse, InputEvidenceUse, InputObservationUse, LogEntry, + LoggedSystemHistoryEntry, PublicSystemItem, PublicSystemPresentation, Store, + SystemInputEnvelope, + }; + let root = tempfile::tempdir().unwrap(); + let store = FsStore::new(root.path()).unwrap(); + let session = session_store::new_session_id(); + let segment = session_store::new_segment_id(); + store + .create_segment( + session, + segment, + &[LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id: session, + system_prompt: None, + config: Default::default(), + history: vec![], + forked_from: None, + compacted_from: None, + }], + ) + .unwrap(); + let mut inputs = Vec::new(); + for (producer, observation, memory_evidence, public) in [ + ( + "same", + InputObservationUse::Readable, + InputEvidenceUse::Exclude, + true, + ), + ( + "same", + InputObservationUse::Hidden, + InputEvidenceUse::Include, + true, + ), + ( + "renamed", + InputObservationUse::Readable, + InputEvidenceUse::Include, + true, + ), + ( + "renamed", + InputObservationUse::Hidden, + InputEvidenceUse::Exclude, + true, + ), + ( + "private", + InputObservationUse::Readable, + InputEvidenceUse::Include, + false, + ), + ] { + let entry = LoggedSystemHistoryEntry::from_input( + WorkerHistoryProvenance::Provided { + producer: producer.into(), + }, + None, + SystemInputEnvelope::new( + "private model text", + HistoryInputMetadata::new( + HistoryInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence, + observation, + compaction: InputCompactionUse::ReferenceInput, + }, + Some( + FeatureInputPayload::encode( + "future:owner", + 99, + &serde_json::json!({ "secret": "private metadata" }), + ) + .unwrap(), + ), + public.then(|| PublicSystemItem { + kind: "supplied-context".into(), + body: "public context: do not extract this".into(), + presentation: PublicSystemPresentation::Context, + }), + ) + .unwrap(), + ) + .unwrap(), + ); + store + .append( + session, + segment, + &LogEntry::AnnotatedSystemItem { + ts: 2, + entry: entry.clone(), + extensions: vec![], + }, + ) + .unwrap(); + inputs.push(entry); + } + let saved = store.read_all(session, segment).unwrap(); + let restored = + crate::session_history::restore_history_entries(session, segment, &saved).unwrap(); + let snapshot = session_store::public_snapshot::project_session_snapshot(session, &saved); + let snapshot: SessionSnapshot = + serde_json::from_value(serde_json::to_value(snapshot).unwrap()).unwrap(); + for capture in [ + SessionCapture::from_history_entries(segment.to_string(), restored), + SessionCapture::from_session_snapshot(segment.to_string(), snapshot), + ] { + let hits = capture.search(&SearchOptions { + kind: Some(ReferenceKind::System), + ..Default::default() + }); + assert_eq!(hits.len(), 2); + for (index, input) in inputs.iter().enumerate() { + let reference = SessionEntryRef::from_history_entry_id(&input.metadata.entry_id); + let read = + capture.read(ReadSelector::Id(reference.as_str()), ReadOptions::default()); + assert_eq!(!read.entries.is_empty(), matches!(index, 0 | 2)); + let evidence = capture.evidence_for(reference.as_str()); + assert_eq!(evidence.is_some(), matches!(index, 1 | 2)); + for text in read + .entries + .iter() + .map(|entry| entry.text.as_str()) + .chain(evidence.iter().map(|entry| entry.excerpt.as_str())) + { + assert!(text.contains("public context: do not extract this")); + assert!(!text.contains("private model text")); + assert!(!text.contains("private metadata")); + assert!(!text.contains("future:owner")); + } + } + } + } + + // Real FsStore seed/fork/reopen -> ordinary history replay, public snapshot, + // capture and both evidence consumers; no Feature or Tool execution occurs. + #[test] + fn tool_policy_axes_survive_restore_and_fork_without_exposing_derived_context() { + use session_store::public_material::{ + PublicMaterialLimits, PublicMaterialUse, SessionPublicMaterialReader, + }; + use session_store::*; + for recording in [InputEvidenceUse::Include, InputEvidenceUse::Exclude] { + for memory in [InputEvidenceUse::Include, InputEvidenceUse::Exclude] { + for observation in [InputObservationUse::Readable, InputObservationUse::Hidden] { + let root = tempfile::tempdir().unwrap(); + let store = FsStore::new(root.path()).unwrap(); + let session = new_session_id(); + let first = new_segment_id(); + let fork = new_segment_id(); + let items = vec![ + Item::tool_call("original", "ArbitraryTool", "{}"), + Item::tool_result("original", "original result"), + Item::tool_call("derived", "ArbitraryTool", "PRIVATE_SYNTHETIC_CONTEXT"), + Item::tool_result("derived", "PRIVATE_SYNTHETIC_RESULT"), + ]; + let mut history: Vec<_> = items + .iter() + .cloned() + .map(|item| LoggedHistoryEntry { + item: LoggedItem::from(item), + metadata: LoggedSessionHistoryMetadata::legacy_unknown(), + }) + .collect(); + let sources = history[..2] + .iter() + .map(|entry| entry.metadata.entry_id.clone()) + .collect::>(); + for entry in &mut history[..2] { + entry.metadata.input_metadata = Some( + HistoryInputMetadata::new( + HistoryInputUsePolicy { + recording_material: recording, + memory_evidence: memory, + observation, + compaction: InputCompactionUse::Retain, + }, + None, + None, + ) + .unwrap(), + ); + } + for entry in &mut history[2..] { + entry.metadata.derivation = Some(LoggedHistoryDerivation { + sources: sources.clone(), + }); + entry.metadata.input_metadata = Some( + HistoryInputMetadata::new( + HistoryInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::Retain, + }, + None, + None, + ) + .unwrap(), + ); + } + for segment in [first, fork] { + store + .create_segment( + session, + segment, + &[LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id: session, + system_prompt: None, + config: Default::default(), + history: history.clone(), + forked_from: (segment == fork).then_some(SegmentOrigin { + segment_id: first, + at_turn_index: 0, + }), + compacted_from: None, + }], + ) + .unwrap(); + } + let reopened = FsStore::new(root.path()).unwrap(); + let saved = reopened.read_all(session, fork).unwrap(); + let restored = + crate::session_history::restore_history_entries(session, fork, &saved) + .unwrap(); + for (index, entry) in restored.iter().enumerate() { + assert_eq!(entry.item, items[index]); + assert_eq!(entry.annotation, history[index].metadata); + } + let capture = SessionCapture::from_history_entries(fork.to_string(), restored); + assert!( + matches!(&capture.entries[2].item, Item::ToolCall { call_id, .. } if call_id == "derived") + ); + assert!( + matches!(&capture.entries[3].item, Item::ToolResult { call_id, .. } if call_id == "derived") + ); + let snapshot = public_snapshot::project_session_snapshot(session, &saved); + assert!( + !serde_json::to_string(&snapshot) + .unwrap() + .contains("PRIVATE_SYNTHETIC") + ); + for capture in [ + capture, + SessionCapture::from_session_snapshot(fork.to_string(), snapshot), + ] { + assert_eq!( + capture + .search(&SearchOptions { + kind: Some(ReferenceKind::Tool), + ..Default::default() + }) + .len(), + if observation == InputObservationUse::Readable { + 2 + } else { + 0 + } + ); + for (index, entry) in history.iter().enumerate() { + let reference = + SessionEntryRef::from_history_entry_id(&entry.metadata.entry_id); + assert_eq!( + !capture + .read( + ReadSelector::Id(reference.as_str()), + ReadOptions::default() + ) + .entries + .is_empty(), + index < 2 && observation == InputObservationUse::Readable + ); + assert_eq!( + capture.evidence_for(reference.as_str()).is_some(), + index < 2 && memory == InputEvidenceUse::Include + ); + } + } + for purpose in [ + PublicMaterialUse::Recording, + PublicMaterialUse::MemoryEvidence, + ] { + let reader = SessionPublicMaterialReader::open_fs_for_use( + root.path(), + session, + fork, + PublicMaterialLimits::default(), + reopened.retention(), + purpose, + ) + .unwrap(); + let ids = reader + .descriptors() + .map(|entry| entry.identity.entry_ref.clone()) + .collect::>(); + let included = match purpose { + PublicMaterialUse::Recording => recording, + PublicMaterialUse::MemoryEvidence => memory, + } == InputEvidenceUse::Include; + assert_eq!( + ids, + sources + .iter() + .filter(|_| included) + .map(|id| format!("E{}", id.0)) + .collect::>() + ); + } + } + } + } + } + #[test] fn overview_contains_user_and_assistant_only() { let view = SessionCapture::new( diff --git a/crates/worker/src/session_history.rs b/crates/worker/src/session_history.rs index d416ccef1..dda081a38 100644 --- a/crates/worker/src/session_history.rs +++ b/crates/worker/src/session_history.rs @@ -24,6 +24,7 @@ pub(crate) fn test_logged_history_entry(item: impl Into) -> LoggedHistoryE LoggedHistoryEntry { item: session_store::LoggedItem::from(item.into()), metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -38,6 +39,7 @@ pub(crate) fn test_logged_system_entry( session_store::LoggedSystemHistoryEntry { item, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId::new(), origin: LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -58,12 +60,23 @@ pub(crate) fn metadata( derivation: Option, ) -> SessionHistoryMetadata { SessionHistoryMetadata { + input_metadata: None, entry_id: SessionHistoryEntryId::new(), origin, derivation, } } +pub(crate) fn compaction_use( + metadata: &SessionHistoryMetadata, +) -> session_store::InputCompactionUse { + metadata + .input_metadata + .as_ref() + .map(|input| input.usage().compaction) + .unwrap_or(session_store::InputCompactionUse::Retain) +} + #[cfg(test)] pub(crate) fn history_entry( item: Item, @@ -80,6 +93,7 @@ pub(crate) fn history_entry_with_id( HistoryEntry::new( item, SessionHistoryMetadata { + input_metadata: None, entry_id, origin, derivation: None, @@ -130,22 +144,10 @@ pub(crate) fn restore_history_entries( } *target = from_logged(entry); } - LogEntry::AnnotatedSystemItem { entry, .. } => { - history.push(HistoryEntry::new(entry.item.to_history_item(), { - let mut metadata = entry.metadata.clone(); - // Old typed startup records used generic placeholder origins. - // Preserve explicitly supplied metadata on current contributions. - if matches!( - metadata.origin, - WorkerHistoryProvenance::DerivedSummary - | WorkerHistoryProvenance::LegacyUnknown - ) && matches!(entry.item, session_store::SystemItem::JournalStartup { .. }) - { - metadata.origin = WorkerHistoryProvenance::JournalContext; - } - metadata - })) - } + LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new( + entry.item.to_history_item(), + entry.metadata.clone(), + )), _ => {} } } diff --git a/crates/worker/src/skill.rs b/crates/worker/src/skill.rs index f9b15bf88..2ad347a6d 100644 --- a/crates/worker/src/skill.rs +++ b/crates/worker/src/skill.rs @@ -8,6 +8,50 @@ pub use server_api::{ SkillProjectionStatus, SkillProvenance, SkillResourceRef, SkillSourceKind, }; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SkillActivationInput { + pub name: String, +} + +pub(crate) fn activation_input( + activation: &SkillActivationResponse, +) -> Result { + use session_store::*; + let body = format!( + "Agent Skill `{}` activated from {}.\n\n{}", + activation.name, activation.provenance.id, activation.body + ); + let payload = FeatureInputPayload::encode( + "skill:activation", + 1, + &SkillActivationInput { + name: activation.name.clone(), + }, + )?; + let metadata = HistoryInputMetadata::new( + HistoryInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::Retain, + }, + Some(payload), + Some(PublicSystemItem { + kind: "skill_activation".into(), + body: body.clone(), + presentation: PublicSystemPresentation::Context, + }), + )?; + Ok(LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: "skill.activation".into(), + }, + None, + SystemInputEnvelope::new(body, metadata)?, + )) +} + #[derive(Debug, thiserror::Error)] pub enum SkillClientError { #[error("workspace client is unavailable: {0}")] diff --git a/crates/worker/src/spawn/comm_tools.rs b/crates/worker/src/spawn/comm_tools.rs index 9fca770de..59ca3c167 100644 --- a/crates/worker/src/spawn/comm_tools.rs +++ b/crates/worker/src/spawn/comm_tools.rs @@ -77,6 +77,7 @@ mod tests { .into_iter() .enumerate() .map(|(index, value)| protocol::SessionSnapshotEntry { + use_policy: None, entry_id: format!("test-{index}"), timestamp: index as u64, provenance: protocol::SessionEntryProvenance::LegacyUnknown, diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index 05317f491..dd45b6f47 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -88,7 +88,7 @@ impl InternalSpawnedWorkerRecord { pub(crate) fn new( worker_name: String, scope_delegated: Vec, - workdir_tool_scope: WorkdirScopeLeaseSet, + workdir_tool_scope: Arc, #[cfg(test)] installed_tools: Vec, session: InternalWorkerSessionHandle, child_registry: Arc, @@ -97,7 +97,7 @@ impl InternalSpawnedWorkerRecord { Self { worker_name: worker_name.clone(), scope_delegated, - workdir_tool_scope: Arc::new(workdir_tool_scope), + workdir_tool_scope, #[cfg(test)] installed_tools: installed_tools.into(), published: Arc::new(super::published::PublishedProtocol::new( @@ -329,6 +329,7 @@ pub struct SpawnedWorkerRegistry { pending_internal_notify: Notify, internal_spawn_cleanup_failed: AtomicBool, parent_scope: Option, + bash_output: Mutex, parent_protocol: Mutex, String)>>, protocol_publish_lock: Arc>, } @@ -340,6 +341,14 @@ pub struct SpawnedWorkerRegistryLoad { } impl SpawnedWorkerRegistry { + pub(crate) fn bash_output(&self) -> tools::bash_output::BashOutputView { + self.bash_output.lock().unwrap().clone() + } + + pub(crate) fn inherit_bash_output(&self, view: tools::bash_output::BashOutputView) { + *self.bash_output.lock().unwrap() = view; + } + pub(crate) fn new_for_internal_services() -> Arc { Arc::new(Self { internal_records: std::sync::Mutex::new(Vec::new()), @@ -352,6 +361,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: None, + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }) @@ -370,6 +380,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: None, + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }) @@ -387,6 +398,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: Some(parent_scope), + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }) @@ -473,6 +485,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope, + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }), @@ -966,6 +979,10 @@ impl SpawnedWorkerRegistry { "an in-flight Internal SubWorker failed cleanup during shutdown", )); } + let output = self.bash_output(); + if !output.is_root() { + output.revoke(); + } first_error.map_or(Ok(()), Err) } @@ -1111,6 +1128,7 @@ fn internal_worker_live_snapshot(snapshot: InternalWorkerSnapshot) -> Option, registry: Arc, @@ -358,7 +358,7 @@ impl SubWorkerSpawnTool { workspace_context, parent_notifications, runtime_base, - bash_output_dir, + workspace_root, workdir_tool_broker, registry, @@ -430,15 +430,6 @@ impl Tool for SubWorkerSpawnTool { .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; let workdir_rules = parse_workdir_scope(&input.scope)?; - let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name); - tokio::fs::create_dir_all(&child_bash_output_dir) - .await - .map_err(|error| { - ToolError::ExecutionFailed(format!( - "create Internal Worker Bash output directory {}: {error}", - child_bash_output_dir.display() - )) - })?; let workdir_tool_broker = require_workdir_tool_broker(self.workdir_tool_broker.as_ref())?; let tool_scopes = routed_workdir_tool_scopes( workdir_tool_broker, @@ -452,6 +443,7 @@ impl Tool for SubWorkerSpawnTool { .map_err(|error| { ToolError::InvalidArgument(format!("scope parent-owned Workdir tools: {error}")) })?; + let workdir_scope = Arc::new(workdir_scope); let child_workdir_tool_broker = workdir_scope.broker_router(); let spawn_selector = @@ -552,24 +544,17 @@ impl Tool for SubWorkerSpawnTool { ) .await .map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?; - child - .add_scope_rules([ScopeRule { - target: child_bash_output_dir.clone(), - permission: manifest::Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }]) - .map_err(|error| { - ToolError::ExecutionFailed(format!( - "grant Internal Worker Bash output scope: {error}" - )) - })?; let child_scope = child.scope().clone(); child.bind_workdir_sessions(workdir_scope.session_router()); let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope); + let output_lease = workdir_scope.clone(); + child_registry.inherit_bash_output( + self.registry + .bash_output() + .child(Arc::new(move || output_lease.is_active())), + ); register_worker_tools( &mut child, - child_bash_output_dir, self.runtime_base.clone(), child_registry.clone(), None, @@ -1052,7 +1037,7 @@ pub(crate) fn sub_worker_spawn_tool( workspace_context: crate::worker::WorkerWorkspaceContext, parent_notifications: ParentNotificationTarget, runtime_base: PathBuf, - bash_output_dir: PathBuf, + workspace_root: PathBuf, workdir_tool_broker: Option, registry: Arc, @@ -1064,7 +1049,6 @@ pub(crate) fn sub_worker_spawn_tool( workspace_context, parent_notifications, runtime_base, - bash_output_dir, workspace_root, workdir_tool_broker, registry, @@ -1078,7 +1062,7 @@ fn sub_worker_spawn_tool_impl( workspace_context: crate::worker::WorkerWorkspaceContext, parent_notifications: ParentNotificationTarget, runtime_base: PathBuf, - bash_output_dir: PathBuf, + workspace_root: PathBuf, workdir_tool_broker: Option, registry: Arc, @@ -1110,7 +1094,6 @@ fn sub_worker_spawn_tool_impl( workspace_context.clone(), parent_notifications.clone(), runtime_base.clone(), - bash_output_dir.clone(), workspace_root.clone(), workdir_tool_broker.clone(), registry.clone(), @@ -1374,7 +1357,6 @@ enabled = false ParentNotificationTarget::Durable(Arc::new(|_| {})), ), runtime.path().to_path_buf(), - bash_output_dir.clone(), workspace_root.clone(), Some(workdir_tool_broker), registry.clone(), @@ -1425,12 +1407,7 @@ enabled = false .await .expect("spawn project reviewer as Internal Worker"); assert!(output.summary.contains("internal worker `reviewer-child`")); - assert!( - bash_output_dir - .join("sub-workers") - .join("reviewer-child") - .is_dir() - ); + assert!(!bash_output_dir.join("sub-workers").exists()); assert!(spawner_scope.snapshot().is_writable(&workspace_root)); let record = registry .get_internal("reviewer-child") @@ -1460,6 +1437,27 @@ enabled = false record.session.wait_until_idle().await, crate::internal_worker::InternalWorkerSessionStatus::Idle ); + let child_output_view = record.child_registry.bash_output(); + let child_bash = tools::BashTool::new( + record.workdir_tool_scope.session_router(), + child_output_view.clone(), + ); + let child_output = child_bash + .execute_result( + r#"{"command":"printf retained-child-evidence","cwd":"."}"#, + Default::default(), + ) + .await + .unwrap(); + let output_id = child_output.metadata.id.clone(); + assert!(child_output_view.metadata(&output_id).is_some()); + assert_eq!( + registry + .bash_output() + .read(&output_id, workdir::CommandStream::Stdout, 0, 65536) + .unwrap(), + b"retained-child-evidence" + ); assert_eq!(calls.load(Ordering::SeqCst), 1); assert!(!observed_parent_write_revoked.load(Ordering::SeqCst)); assert!(observed_instruction_override.load(Ordering::SeqCst)); @@ -1563,6 +1561,8 @@ enabled = false !record.workdir_tool_scope.is_active(), "stopped child must revoke cloned scoped sessions" ); + assert!(child_output_view.metadata(&output_id).is_none()); + assert!(registry.bash_output().metadata(&output_id).is_some()); assert!(registry.get_internal("reviewer-child").is_some()); assert!( @@ -1573,6 +1573,7 @@ enabled = false .is_some() ); assert!(registry.get_internal("reviewer-child").is_none()); + assert!(registry.bash_output().metadata(&output_id).is_some()); assert!(spawner_scope.snapshot().is_writable(&workspace_root)); fail_requests.store(false, Ordering::SeqCst); @@ -1672,7 +1673,6 @@ enabled = false ), ParentNotificationTarget::Durable(Arc::new(|_| {})), runtime.path().to_path_buf(), - bash_output_dir, workspace_root.clone(), Some(workdir_tool_broker), registry.clone(), @@ -1805,7 +1805,6 @@ enabled = false ParentNotificationTarget::Durable(Arc::new(|_| {})), ), runtime.path().to_path_buf(), - bash_output_dir.clone(), workspace_root.clone(), Some(workdir_tool_broker), registry.clone(), @@ -1987,6 +1986,12 @@ enabled = false "test" } + fn authorize_feature_activation(&self, _feature: &str) -> Result<(), WorkspaceClientError> { + // This fixture represents a non-Subject Host admitting ordinary + // Internal Worker configuration, not an operation permission grant. + Ok(()) + } + fn is_available(&self) -> bool { true } diff --git a/crates/worker/src/subjektiv.rs b/crates/worker/src/subjektiv.rs index 40d4836c6..3d5666d5e 100644 --- a/crates/worker/src/subjektiv.rs +++ b/crates/worker/src/subjektiv.rs @@ -26,6 +26,30 @@ use server_api::*; use crate::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceServerOperation}; +impl + crate::Worker +{ + /// Bind live Host capability and its context provider, not authority inherited + /// from a previous binding. Ordinary Feature/lifecycle gates also apply to embedders. + pub fn bind_subjektiv_host(&mut self, host: Option>) { + self.set_subjektiv_host(host); + if self.manifest_lifecycle_features_enabled() + && self.backend_job().is_none() + && crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(self.manifest()) + && let Ok(Some(connection)) = self.subjektiv_host_connection() + && (connection.host.journal_startup_granted() + || connection.host.journal_compaction_granted()) + { + self.install_session_context_source(Arc::new( + crate::feature::builtin::journal::JournalContextSource::new( + connection, + self.prompts(), + ), + )); + } + } +} + /// Host-issued execution context, never deserialized from model arguments. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SubjektivHostContext { @@ -919,7 +943,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash-output"), }, WorkerControllerTransport::InProcess, ) @@ -1113,7 +1136,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), }, WorkerControllerTransport::InProcess, ) @@ -1130,7 +1152,12 @@ enabled = true let initial = store.read_all(session, segment).unwrap(); let journal = initial.iter().find_map(|entry| match entry { LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::JournalStartup { .. }) => + if entry + .metadata + .input_metadata + .as_ref() + .and_then(|input| input.feature_payload()) + .is_some_and(|payload| payload.schema() == "journal:startup") => { Some(entry) } @@ -1140,22 +1167,33 @@ enabled = true let body = journal.map(|journal| { assert_eq!( journal.metadata.origin, - LoggedSessionHistoryOrigin::JournalContext + LoggedSessionHistoryOrigin::Provided { + producer: "subjektiv.journal".into() + } ); - let SystemItem::JournalStartup { - body, - snapshot: saved, - snapshot_sha256, - prompt_provenance, - } = &journal.item - else { + let SystemItem::Input { body } = &journal.item else { unreachable!() }; - assert_eq!(*saved, serde_json::to_value(&snapshot).unwrap()); + let payload = journal + .metadata + .input_metadata + .as_ref() + .unwrap() + .feature_payload() + .unwrap() + .decode::( + "journal:startup", + 1, + ) + .unwrap(); + let saved = serde_json::to_value(&payload.snapshot).unwrap(); + let snapshot_sha256 = &payload.snapshot_sha256; + let prompt_provenance = &payload.prompt_provenance; + assert_eq!(saved, serde_json::to_value(&snapshot).unwrap()); use sha2::{Digest, Sha256}; assert_eq!( *snapshot_sha256, - Sha256::digest(serde_json::to_vec(saved).unwrap()) + Sha256::digest(serde_json::to_vec(&saved).unwrap()) .iter() .map(|byte| format!("{byte:02x}")) .collect::() @@ -1168,7 +1206,7 @@ enabled = true body.trim_end(), format!( "## Prior Journal context\n\n```json\n{}\n```", - serde_json::to_string(saved).unwrap() + serde_json::to_string(&saved).unwrap() ) ); body.as_str() @@ -1237,7 +1275,7 @@ enabled = true } assert_eq!(host.startup_calls.load(Ordering::SeqCst), 1); let logs = store.read_all(session, segment).unwrap(); - assert_eq!(logs.iter().filter(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if matches!(entry.item, SystemItem::JournalStartup { .. }))).count(), usize::from(contributes_context)); + assert_eq!(logs.iter().filter(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if entry.metadata.input_metadata.as_ref().and_then(|input| input.feature_payload()).is_some_and(|payload| payload.schema() == "journal:startup"))).count(), usize::from(contributes_context)); controller .handle .send(Method::Shutdown { @@ -1279,7 +1317,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), }, WorkerControllerTransport::InProcess, ) @@ -1339,7 +1376,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash-output"), }, WorkerControllerTransport::InProcess, ) diff --git a/crates/worker/src/subjektiv_metadata.rs b/crates/worker/src/subjektiv_metadata.rs new file mode 100644 index 000000000..e24b4c926 --- /dev/null +++ b/crates/worker/src/subjektiv_metadata.rs @@ -0,0 +1,120 @@ +//! Host-owned attribution transitions. Metadata callbacks are synchronous, local +//! comparisons only: no network I/O, Feature lookup, or nested store access. + +use crate::metadata_lifecycle::SessionMetadataLifecycle; +use session_store::{ + SessionId, SubjektivSessionAttributionState, WorkerActiveSegmentRef, WorkerMetadata, + WorkerMetadataStore, +}; + +pub(crate) struct SessionAttributionMetadata; + +impl SessionMetadataLifecycle for SessionAttributionMetadata { + fn active_changing( + &self, + metadata: &mut WorkerMetadata, + next: Option<&WorkerActiveSegmentRef>, + ) { + if metadata.active.as_ref().map(|active| active.session_id) + != next.map(|active| active.session_id) + { + metadata.subjektiv_session_attribution = None; + } + } +} + +pub(crate) fn compare_and_swap_attribution( + store: &St, + worker_name: &str, + session_id: SessionId, + expected: Option<&SubjektivSessionAttributionState>, + replacement: SubjektivSessionAttributionState, +) -> Result<(), crate::WorkerError> { + let mut matched = false; + store.update_by_name(worker_name, |metadata| { + if metadata.active.as_ref().map(|active| active.session_id) == Some(session_id) + && metadata.subjektiv_session_attribution.as_ref() == expected + && replacement.session_id() == session_id + { + metadata.subjektiv_session_attribution = Some(replacement); + matched = true; + } + })?; + if !matched { + return Err(crate::WorkerError::SubjektivSessionAttribution { + state: "attribution_conflict", + message: "active Session or observed attribution changed during Host confirmation" + .into(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + //! Connect the production metadata writer/CAS and Host lifecycle to a real + //! filesystem store; no metadata or persistence boundary is substituted. + use super::*; + use crate::metadata_lifecycle::{ + worker_metadata_segment_cas_for_store, worker_metadata_writer_for_store, + }; + + #[test] + fn host_transition_preserves_same_session_and_invalidates_new_session_atomically() { + let dir = tempfile::tempdir().unwrap(); + let store = session_store::FsWorkerStore::new(dir.path()).unwrap(); + let session = session_store::new_session_id(); + let mut metadata = WorkerMetadata::new( + "body", + Some(WorkerActiveSegmentRef::pending_segment(session)), + ); + metadata.workspace_id = Some("workspace".into()); + metadata.subjektiv_session_attribution = + Some(SubjektivSessionAttributionState::Confirmed { + session_id: session, + subject_id: "subject".into(), + }); + store.write(&metadata).unwrap(); + let writer = worker_metadata_writer_for_store(&store); + let cas = worker_metadata_segment_cas_for_store(&store); + let segment = + WorkerActiveSegmentRef::active_segment(session, session_store::new_segment_id()); + let mut next = WorkerMetadata::new("body", Some(segment.clone())); + writer(next.clone()).unwrap(); + let materialized = store.read_by_name("body").unwrap().unwrap(); + assert_eq!( + materialized.subjektiv_session_attribution, + metadata.subjektiv_session_attribution + ); + assert_eq!(materialized.workspace_id, metadata.workspace_id); + let compacted = + WorkerActiveSegmentRef::active_segment(session, session_store::new_segment_id()); + assert!(cas("body", &segment, compacted.clone()).unwrap()); + assert_eq!( + store + .read_by_name("body") + .unwrap() + .unwrap() + .subjektiv_session_attribution, + metadata.subjektiv_session_attribution + ); + let new_session = WorkerActiveSegmentRef::pending_segment(session_store::new_session_id()); + assert!(!cas("body", &segment, new_session.clone()).unwrap()); + assert!(cas("body", &compacted, new_session.clone()).unwrap()); + let switched = store.read_by_name("body").unwrap().unwrap(); + assert_eq!(switched.active, Some(new_session)); + assert!(switched.subjektiv_session_attribution.is_none()); + assert_eq!(switched.workspace_id, metadata.workspace_id); + // Returning to the old Session cannot recover the invalidated evidence. + next.active = metadata.active; + writer(next).unwrap(); + assert!( + store + .read_by_name("body") + .unwrap() + .unwrap() + .subjektiv_session_attribution + .is_none() + ); + } +} diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 8c4a0849e..6ae4ffa16 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -418,13 +418,22 @@ struct MountedProjection { impl MountedProjection { fn resolved(&self) -> WipProjection { let mut projection = self.projection.clone(); + let mut handlers: BTreeMap<_, _> = self + .operation_handlers + .iter() + .map(|(op, (_, h))| (op.clone(), h.clone())) + .collect(); + for reference in &projection.object.interfaces { + if let Some(contract) = crate::file_operation::Contract::from_reference(reference) { + handlers.insert( + contract.handler_operation().into(), + self.projection.handler.clone(), + ); + } + } projection.handler = Arc::new(OperationDispatchHandler { owner: Arc::clone(&self.projection.handler), - handlers: self - .operation_handlers - .iter() - .map(|(operation, (_, handler))| (operation.clone(), Arc::clone(handler))) - .collect(), + handlers, }); projection } @@ -1017,6 +1026,10 @@ impl WipMountRegistry { descriptor: &InterfaceDescriptor, validator: Option<&[u8]>, ) -> bool { + if let Some(contract) = crate::file_operation::Contract::from_reference(interface) { + return *descriptor != contract.descriptor() + || validator != Some(contract.validator().as_slice()); + } self.mounts.values().any(|mounted| { mounted.projection.interface == *interface && (mounted.projection.descriptor != *descriptor @@ -1082,6 +1095,10 @@ impl WipMountRegistry { validator: Option<&[u8]>, replacing_route: Option<&str>, ) -> bool { + if let Some(contract) = crate::file_operation::Contract::from_reference(interface) { + return *descriptor != contract.descriptor() + || validator != Some(contract.validator().as_slice()); + } self.additional_interfaces.iter().any(|(route, all)| { Some(route.as_str()) != replacing_route && all.iter().any(|p| { @@ -1188,10 +1205,24 @@ fn validate_projection(projection: &WipProjection) -> Result<(), WipMountError> route: projection.route.clone(), message: error.to_string(), })?; - if projection.object.interfaces != [projection.interface.clone()] { + if !projection.object.interfaces.contains(&projection.interface) + || projection.object.interfaces.iter().any(|reference| { + *reference != projection.interface + && crate::file_operation::Contract::from_reference(reference).is_none() + }) + { return Err(WipMountError::InvalidProjection { route: projection.route.clone(), - message: "projected object must expose exactly its declared interface".into(), + message: "projected object must expose its declared interface and only known common contracts".into(), + }); + } + if let Some(contract) = crate::file_operation::Contract::from_reference(&projection.interface) + && (projection.descriptor != contract.descriptor() + || projection.interface_validator.as_deref() != Some(contract.validator().as_slice())) + { + return Err(WipMountError::InvalidProjection { + route: projection.route.clone(), + message: "common contract descriptor/validator differs from its definition".into(), }); } projection @@ -1304,9 +1335,13 @@ impl WipHost { path: &str, reference: &InterfaceReference, ) -> Result, ProtocolError> { - let Some(owner) = self.publication_live(path).await? else { + let Some(mut owner) = self.publication_live(path).await? else { return Ok(None); }; + if crate::file_operation::select(&mut owner.projection, reference) { + owner.scope = self.object_at(&reference.scope); + return Ok(Some(owner)); + } // A live provider owns all its publication metadata; static additional // Interface registrations must not survive its scope lifetime changes. if owner.projection.interface == *reference || self.subtree(path).is_some() { @@ -1333,6 +1368,20 @@ impl WipHost { reference .validate() .map_err(|e| protocol_error(ProtocolErrorCode::InvalidRequest, e.to_string()))?; + if let Some(contract) = crate::file_operation::Contract::from_reference(reference) { + let scope = self.object_at("/").ok_or_else(|| { + protocol_error( + ProtocolErrorCode::InterfaceNotFound, + "root scope unavailable", + ) + })?; + return Ok(FetchInterfaceResponse { + interface: reference.clone(), + scope_ref: scope.r#ref, + descriptor: contract.descriptor(), + validator: Some(contract.validator()), + }); + } let target = if let Some(mount) = self.subtree(&reference.scope) { let target = mount.provider.interface_target(reference).ok_or_else(|| { protocol_error( @@ -1496,6 +1545,9 @@ impl WipHost { &self, reference: &InterfaceReference, ) -> Option<(InterfaceDescriptor, Option>)> { + if let Some(contract) = crate::file_operation::Contract::from_reference(reference) { + return Some((contract.descriptor(), Some(contract.validator()))); + } for (path, all) in &self.registry.additional_interfaces { if self.projection(path).is_none() { continue; @@ -1560,6 +1612,18 @@ impl WipHost { if let Some(validator) = projection.handler.object_validator() { projection.object.validator = Some(validator); } + if let Some(contract) = + crate::file_operation::Contract::from_reference(&projection.interface) + { + if !projection + .handler + .operation_available(contract.handler_operation()) + { + return None; + } + // Common descriptor identity never varies with subject or capability filtering. + return Some(projection); + } let contextual = contextual_family || projection.handler.contextual_interface(); let original_len = projection.descriptor.operations.len(); projection @@ -5502,6 +5566,7 @@ mod tests { entry: session_store::LoggedHistoryEntry { item: session_store::LoggedItem::from(saved), metadata: session_store::LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: session_store::LoggedSessionHistoryEntryId::new(), origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -6044,6 +6109,10 @@ mod tests { }; let authoring_client = Arc::new(ScriptedWorkspaceClient::new([ query_response, + crate::worker::WorkspaceResponse { + status: 200, + body: json!({"scope": null}).to_string(), + }, create_response, comment_response, ])); @@ -6125,11 +6194,12 @@ mod tests { .await .unwrap(); let authoring_requests = authoring_client.requests(); - assert_eq!(authoring_requests.len(), 3); + assert_eq!(authoring_requests.len(), 4); assert!(authoring_requests[0].path.ends_with("/tickets/query")); - assert!(authoring_requests[1].path.ends_with("/tickets")); + assert!(authoring_requests[1].path.ends_with("/authorization/check")); + assert!(authoring_requests[2].path.ends_with("/tickets")); assert!( - authoring_requests[2] + authoring_requests[3] .path .ends_with("/tickets/T-9/thread-events") ); @@ -7057,3 +7127,6 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 0); } } + +#[cfg(test)] +mod bash_output_tests; diff --git a/crates/worker/src/wip/bash_output_tests.rs b/crates/worker/src/wip/bash_output_tests.rs new file mode 100644 index 000000000..21f2b88bc --- /dev/null +++ b/crates/worker/src/wip/bash_output_tests.rs @@ -0,0 +1,510 @@ +//! Native Bash output integration through real WIP Client/Host codec and local process pipes. +//! No telemetry/storage fake: only the model client is absent. Runtime HTTP transport is tested +//! by worker-runtime's command_transport_tests. Each fixture owns its Workdir and ephemeral view. +use super::*; +use crate::file_operation::Contract; +use tempfile::TempDir; +use tools::bash_output::BashOutputView; +use workdir::{ + LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter, +}; + +struct Fixture { + _dir: TempDir, + router: Arc, + view: BashOutputView, + runtime: WipRuntime, +} +impl Fixture { + fn new() -> Self { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("real.txt"), "first\n雪 needle\nlast\n").unwrap(); + let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new( + manifest::Scope::writable(dir.path()).unwrap(), + dir.path().into(), + )); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), session) + .unwrap(); + let view = BashOutputView::new(); + let runtime = runtime(router.clone(), view.clone()); + Self { + _dir: dir, + router, + view, + runtime, + } + } + async fn run(&self, command: &str) -> Json { + value( + self.runtime + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":command,"cwd":"."}}), + Default::default(), + ) + .await + .unwrap(), + ) + } +} +fn runtime(router: Arc, view: BashOutputView) -> WipRuntime { + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None) + .unwrap(); + crate::feature::builtin::bash::mount(&mut registry, router, view, None).unwrap(); + WipRuntime::new(WipHost::new(registry), SecurityContext::new("bash-test"), 0).unwrap() +} +fn value(output: ToolOutput) -> Json { + serde_json::from_str(&output.content.unwrap()).unwrap() +} +async fn invoke( + runtime: &WipRuntime, + path: &str, + contract: Contract, + operation: &str, + args: Json, +) -> Result { + runtime + .invoke( + path.into(), + contract.reference(), + operation.into(), + args, + Default::default(), + ) + .await + .map(value) +} + +#[tokio::test] +async fn native_bash_entries_reuse_checkout_contract_for_direct_inspect_read_search() { + let f = Fixture::new(); + let result = f + .run("printf 'first\n雪 needle\nlast\n'; printf error >&2; exit 3") + .await; + assert_eq!(result["metadata"]["terminal"]["exit_code"], 3); + let stdout = result["stdout_entry"].as_str().unwrap(); + assert!(stdout.starts_with("/bash-output/")); + // The native result Descriptor gives these coordinates TypeExpr::Entry; Object inspection + // contains capabilities and metadata, not the process output body. + let projection = f + .runtime + .host + .projection_live("/bash-output") + .await + .unwrap() + .unwrap(); + let TypeExpr::Record { fields } = &projection.descriptor.operations[0].returns.r#type else { + panic!("native record"); + }; + assert_eq!( + fields + .iter() + .find(|v| v.name == "stdout_entry") + .unwrap() + .r#type, + TypeExpr::Entry + ); + let inspect = f.runtime.inspect(stdout.into(), false).await.unwrap(); + assert!(!inspect.content.unwrap().contains("雪 needle")); + for path in ["/checkouts/main/real.txt", stdout] { + let read = invoke( + &f.runtime, + path, + Contract::ReadLines, + "read", + json!({"offset":1,"limit":1}), + ) + .await + .unwrap(); + assert!(read["content"].as_str().unwrap().contains("雪 needle")); + assert_eq!(read["next_offset"], 2); + let search = invoke( + &f.runtime, + path, + Contract::SearchText, + "search", + json!({"offset":0,"limit":2,"pattern":"needle"}), + ) + .await + .unwrap(); + assert_eq!(search["matches"][0]["line"], 2); + assert_eq!(search["next_offset"], 2); + assert_eq!(search["truncated"], true); + } + for name in ["stdout_entry", "stderr_entry", "order_entry"] { + assert_eq!( + fields.iter().find(|v| v.name == name).unwrap().r#type, + TypeExpr::Entry + ); + f.runtime + .inspect(result[name].as_str().unwrap().into(), false) + .await + .unwrap(); + } + let stderr = invoke( + &f.runtime, + result["stderr_entry"].as_str().unwrap(), + Contract::ReadLines, + "read", + json!({}), + ) + .await + .unwrap(); + assert!(stderr["content"].as_str().unwrap().contains("error")); + let order = invoke( + &f.runtime, + result["order_entry"].as_str().unwrap(), + Contract::ReadLines, + "read", + json!({}), + ) + .await + .unwrap(); + assert!(order["content"].as_str().unwrap().contains("Stdout")); + assert!(order["content"].as_str().unwrap().contains("Stderr")); + assert_eq!(result["metadata"]["ordering"], "provider_read_observation"); + let tree = value( + f.runtime + .tree("/bash-output".into(), 8, true) + .await + .unwrap(), + ); + assert!(!tree.to_string().contains(stdout)); + assert!( + invoke( + &f.runtime, + stdout, + Contract::Write, + "write", + json!({"content":"no"}) + ) + .await + .is_err() + ); + assert!( + invoke( + &f.runtime, + stdout, + Contract::Edit, + "edit", + json!({"old_string":"first","new_string":"no"}) + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn common_byte_reader_handles_long_lines_raw_invalid_utf8_boundaries_and_eof() { + let f = Fixture::new(); + let result = f.run("printf '%100000s' ''; printf '€\\377'").await; + let path = result["stdout_entry"].as_str().unwrap(); + assert!( + invoke( + &f.runtime, + path, + Contract::ReadLines, + "read", + json!({"limit":1}) + ) + .await + .is_err() + ); + for (offset, expected) in [ + (99999, vec![b' ', 0xe2]), + (100001, vec![0x82, 0xac]), + (100003, vec![0xff]), + ] { + let read = invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":offset,"limit":2}), + ) + .await + .unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, expected); + assert_eq!(read["next_offset"], offset + expected.len() as u64); + } + let eof = invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":100004,"limit":2}), + ) + .await + .unwrap(); + assert_eq!(eof["data"], ""); + assert_eq!(eof["eof"], true); + assert!( + invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":100005}) + ) + .await + .is_err() + ); + assert!( + invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"limit":32769}) + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn publication_and_invoke_recheck_child_authority_and_root_teardown_invalidates_old_refs() { + let f = Fixture::new(); + let lease = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let child = f.view.child({ + let lease = lease.clone(); + Arc::new(move || lease.load(std::sync::atomic::Ordering::Acquire)) + }); + let child_runtime = runtime(f.router.clone(), child.clone()); + let result = value( + child_runtime + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":"printf child-evidence","cwd":"."}}), + Default::default(), + ) + .await + .unwrap(), + ); + let path = result["stdout_entry"].as_str().unwrap(); + child_runtime.inspect(path.into(), false).await.unwrap(); + let sibling = runtime(f.router.clone(), f.view.child(Arc::new(|| true))); + let unrelated = runtime(f.router.clone(), BashOutputView::new()); + assert!(sibling.inspect(path.into(), false).await.is_err()); + assert!(unrelated.inspect(path.into(), false).await.is_err()); + lease.store(false, std::sync::atomic::Ordering::Release); + assert!( + invoke(&child_runtime, path, Contract::ReadBytes, "read", json!({})) + .await + .is_err() + ); + assert!( + invoke( + &child_runtime, + path, + Contract::SearchText, + "search", + json!({"pattern":"child"}) + ) + .await + .is_err() + ); + assert!( + invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({})) + .await + .is_ok() + ); + f.view.revoke(); + assert!( + invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({})) + .await + .is_err() + ); + let restored = runtime(f.router.clone(), BashOutputView::new()); + assert!(restored.inspect(path.into(), false).await.is_err()); +} + +#[tokio::test] +async fn native_timeout_and_cancellation_keep_terminal_evidence_instead_of_success() { + let f = Fixture::new(); + let session = f.router.only_session().unwrap(); + let mut events = session.subscribe_command_events().unwrap(); + let runtime = Arc::new(f.runtime); + let context = agen::tool::ToolExecutionContext::new("native-cancel", "native-attempt", 0); + let running = runtime.clone(); + let executing_context = context.clone(); + let execution = tokio::spawn(async move { + running + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":"printf before; while :; do :; done","cwd":"."}}), + executing_context, + ) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if matches!( + events.recv().await.unwrap(), + workdir::CommandEvent::Output { .. } + ) { + break; + } + } + }) + .await + .unwrap(); + runtime.cancel(&context).await.unwrap(); + let ToolError::Cancelled(output) = + tokio::time::timeout(std::time::Duration::from_secs(5), execution) + .await + .unwrap() + .unwrap() + .unwrap_err() + else { + panic!("expected cancelled terminal output") + }; + let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap(); + let id = body["output"]["id"].as_str().unwrap(); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert_eq!(body["output"]["terminal"]["status"], "cancelled"); + assert_eq!( + f.view + .read(id, workdir::CommandStream::Stdout, 0, 20) + .unwrap(), + b"before" + ); + assert!( + invoke( + &runtime, + body["stdout_entry"].as_str().unwrap(), + Contract::ReadBytes, + "read", + json!({}) + ) + .await + .is_ok() + ); + let error = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf timeout; while :; do :; done","cwd":".","timeout":1}}), Default::default()).await.unwrap_err(); + let ToolError::Interrupted(output) = error else { + panic!("expected timeout evidence") + }; + let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(body["output"]["terminal"]["status"], "timed_out"); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert!( + f.view + .metadata(body["output"]["id"].as_str().unwrap()) + .is_some() + ); + session.close().await.unwrap(); +} + +#[tokio::test] +async fn common_read_and_search_permissions_are_independent_from_bash_execution() { + let f = Fixture::new(); + let result = f.run("printf needle").await; + let path = result["stdout_entry"].as_str().unwrap(); + let mut registry = WipMountRegistry::new(); + crate::feature::builtin::bash::mount( + &mut registry, + f.router.clone(), + f.view.clone(), + Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: vec![manifest::ToolPermissionRule { + tool: "Grep".into(), + pattern: "*".into(), + action: ToolPermissionAction::Allow, + }], + }), + ) + .unwrap(); + let restricted = WipRuntime::new( + WipHost::new(registry), + SecurityContext::new("restricted"), + 0, + ) + .unwrap(); + restricted.inspect(path.into(), false).await.unwrap(); + assert!( + invoke(&restricted, path, Contract::ReadBytes, "read", json!({})) + .await + .is_err() + ); + assert!( + invoke(&restricted, path, Contract::ReadLines, "read", json!({})) + .await + .is_err() + ); + let found = invoke( + &restricted, + path, + Contract::SearchText, + "search", + json!({"pattern":"needle"}), + ) + .await + .unwrap(); + assert!(found["matches"].as_array().unwrap().len() == 1); + assert!( + restricted + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":"touch denied","cwd":"."}}), + Default::default() + ) + .await + .is_err() + ); + assert!(!f._dir.path().join("denied").exists()); +} + +#[tokio::test] +async fn multibyte_long_line_search_refuses_instead_of_skipping_an_unread_suffix() { + let f = Fixture::new(); + let result = f + .run("printf '雪%.0s' {1..22000}; printf 'needle\nnext\n'") + .await; + let path = result["stdout_entry"].as_str().unwrap(); + let error = invoke( + &f.runtime, + path, + Contract::SearchText, + "search", + json!({"pattern":"needle","offset":0,"limit":1}), + ) + .await + .unwrap_err(); + assert!( + error.to_string().contains("ResourceLimitExceeded"), + "{error}" + ); + // No successful page claims to advance past a partial line. Raw bytes can still + // retrieve its unread suffix using the same common file contract. + let suffix = invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":66000,"limit":7}), + ) + .await + .unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(suffix["data"].as_str().unwrap()) + .unwrap(), + b"needle\n" + ); + assert_eq!(suffix["next_offset"], 66007); +} diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 6af8d337b..9db91a7fa 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -1052,6 +1052,116 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { self.execute(request) } + /// Ask the Host using this client's authenticated source, never a model-supplied + /// identity. Trusted Features call this before protected effects on every surface. + /// This is not a universal Tool executor: a missing call is a Feature bug, and + /// operations not yet adapted to this seam retain only their existing checks. + fn authorize_operation( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result<(), WorkspaceClientError> { + self.authorize_operation_scope(operation, resource) + .map(|_| ()) + } + + /// Return the Host-selected matching scope for provider-resolved containment. + /// `None` is only the Host's explicit legacy decision, never a transport fallback. + fn authorize_operation_scope( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result, WorkspaceClientError> { + use server_api::authorization::{ + OperationAuthorizationRequest, OperationAuthorizationResponse, ScopedPermission, + }; + if !self.is_available() { + return Err(WorkspaceClientError::Unavailable( + "operation authorization requires a Host-bound Workspace client".into(), + )); + } + let workspace_id = self + .workspace_id() + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + WorkspaceClientError::Unavailable( + "operation authorization requires Workspace identity".into(), + ) + })?; + ScopedPermission { + operation: operation.into(), + resource: resource.clone(), + } + .validate() + .map_err(WorkspaceClientError::Request)?; + let request = workspace_server_json_request( + format!( + "/api/w/{}/authorization/check", + encode_workspace_path_segment(workspace_id) + ), + &OperationAuthorizationRequest { + operation: operation.into(), + resource: resource.clone(), + }, + )?; + // Blocking transports must not run inside a Tokio runtime. The bound + // transport enforces this timeout; no authorization success is cached. + let execute = || self.execute_with_timeout(request, Duration::from_secs(10)); + let response = if tokio::runtime::Handle::try_current().is_ok() { + std::thread::scope(|scope| scope.spawn(execute).join()).map_err(|_| { + WorkspaceClientError::Request("authorization request thread panicked".into()) + })?? + } else { + execute()? + }; + if response.body.len() > 16 * 1024 { + return Err(WorkspaceClientError::Request( + "authorization response exceeded 16 KiB".into(), + )); + } + if !response.is_success() { + // Expose only the bounded public reason, not provider diagnostics or + // arbitrary response data. Keep status parseable by Feature adapters. + let value: serde_json::Value = serde_json::from_str(&response.body).unwrap_or_default(); + let reason: String = value + .get("message") + .or_else(|| value.get("error")) + .and_then(serde_json::Value::as_str) + .unwrap_or("Host rejected authorization") + .chars() + .take(1024) + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + return Err(WorkspaceClientError::Request(format!( + "authorization denied for {operation}: HTTP status {}: {reason}", + response.status + ))); + } + let authorization = serde_json::from_str::(&response.body) + .map_err(|error| { + WorkspaceClientError::Request(format!("invalid authorization response: {error}")) + })?; + if authorization + .scope + .as_ref() + .is_some_and(|scope| !scope.covers(resource)) + { + return Err(WorkspaceClientError::Request( + "Host authorization scope does not cover requested resource".into(), + )); + } + Ok(authorization.scope) + } + + /// Activation is separate from operation authority. Only explicit standalone + /// contexts with no Workspace binding retain legacy activation without a Host. + fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> { + self.authorize_operation( + feature, + &server_api::authorization::PermissionResource::Feature, + ) + } + /// Binary transport is unavailable unless a host-bound implementation opts in. fn execute_binary( &self, @@ -1170,6 +1280,28 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { self.inner.current_prompt_projection() } + fn authorize_operation( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result<(), WorkspaceClientError> { + // The query has no protected effect. Keep the review capability's + // mutation filter intact while asking the bound Host for live authority. + self.inner.authorize_operation(operation, resource) + } + + fn authorize_operation_scope( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result, WorkspaceClientError> { + self.inner.authorize_operation_scope(operation, resource) + } + + fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> { + self.inner.authorize_feature_activation(feature) + } + fn execute_binary( &self, request: WorkspaceBinaryRequest, @@ -1322,6 +1454,7 @@ struct MarkerWorkspaceClient { workspace_id: Option, kind: String, available: bool, + legacy_local_activation: bool, reason: String, } @@ -1338,6 +1471,18 @@ impl WorkspaceClient for MarkerWorkspaceClient { self.available } + fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> { + // Explicit compatibility for standalone Workers with no Workspace binding. + // A missing/broken Workspace client must never become an implicit grant. + if self.workspace_id.is_none() && self.legacy_local_activation { + return Ok(()); + } + self.authorize_operation( + feature, + &server_api::authorization::PermissionResource::Feature, + ) + } + fn execute( &self, _request: WorkspaceRequest, @@ -1346,6 +1491,196 @@ impl WorkspaceClient for MarkerWorkspaceClient { } } +#[cfg(test)] +mod operation_authorization_tests { + use super::*; + use server_api::authorization::PermissionResource; + + #[derive(Debug)] + struct BoundClient { + response: Mutex, + requests: Mutex>, + } + + impl WorkspaceClient for BoundClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace/test") + } + fn kind(&self) -> &str { + "bound-authorization-fixture" + } + fn is_available(&self) -> bool { + true + } + fn execute(&self, _: WorkspaceRequest) -> Result { + panic!("authorization must select the bounded transport") + } + fn execute_with_timeout( + &self, + request: WorkspaceRequest, + timeout: Duration, + ) -> Result { + assert_eq!(timeout, Duration::from_secs(10)); + self.requests.lock().unwrap().push(request); + Ok(self.response.lock().unwrap().clone()) + } + } + + #[tokio::test] + async fn authorization_is_bounded_live_and_contains_no_caller_identity() { + let client = BoundClient { + response: Mutex::new(WorkspaceResponse { + status: 200, + body: "{}".into(), + }), + requests: Default::default(), + }; + let resource = PermissionResource::Workspace { + workspace_id: "workspace/test".into(), + }; + client + .authorize_operation("ticket.create", &resource) + .unwrap(); + *client.response.lock().unwrap() = WorkspaceResponse { + status: 403, + body: r#"{"message":"permission revoked","diagnostics":[{"message":"private"}]}"# + .into(), + }; + let reason = client + .authorize_operation("ticket.create", &resource) + .unwrap_err() + .to_string(); + assert!(reason.contains("HTTP status 403")); + assert!(reason.contains("permission revoked")); + assert!(!reason.contains("private")); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2, "success must not become a cached grant"); + for request in requests.iter() { + assert_eq!(request.method, WorkspaceRequestMethod::Post); + assert_eq!(request.path, "/api/w/workspace%2Ftest/authorization/check"); + assert_eq!( + serde_json::from_str::(request.body.as_deref().unwrap()) + .unwrap(), + serde_json::json!({ "operation":"ticket.create", "resource":{"kind":"workspace", "workspace_id":"workspace/test"} }) + ); + } + } + + #[test] + fn scoped_authorization_preserves_matching_scope_and_rejects_cross_resource() { + let resource = PermissionResource::Workdir { + working_directory_id: "wd-1".into(), + path: "docs/file.md".into(), + recursive: false, + }; + let granted = PermissionResource::Workdir { + working_directory_id: "wd-1".into(), + path: "docs".into(), + recursive: true, + }; + let client = BoundClient { + response: Mutex::new(WorkspaceResponse { + status: 200, + body: serde_json::json!({"scope":granted}).to_string(), + }), + requests: Default::default(), + }; + assert_eq!( + client + .authorize_operation_scope("filesystem.read", &resource) + .unwrap(), + Some(granted) + ); + *client.response.lock().unwrap() = WorkspaceResponse { + status: 200, + body: serde_json::json!({"scope":{"kind":"workspace","workspace_id":"workspace/test"}}) + .to_string(), + }; + assert!( + client + .authorize_operation_scope("filesystem.read", &resource) + .is_err() + ); + } + + #[test] + fn reviewer_query_preserves_host_scope_and_does_not_widen_mutation_access() { + let scope = PermissionResource::Workdir { + working_directory_id: "wd-1".into(), + path: "docs".into(), + recursive: true, + }; + let inner = Arc::new(BoundClient { + response: Mutex::new(WorkspaceResponse { + status: 200, + body: serde_json::json!({"scope":scope}).to_string(), + }), + requests: Default::default(), + }); + let reviewer = ReviewerChildWorkspaceClient::new( + inner.clone(), + ReviewerContext { + ticket_id: "T-1".into(), + merge_request_id: "MR-1".into(), + }, + "capability".into(), + ); + let requested = PermissionResource::Workdir { + working_directory_id: "wd-1".into(), + path: "docs/file.md".into(), + recursive: false, + }; + assert_eq!( + reviewer + .authorize_operation_scope("filesystem.read", &requested) + .unwrap(), + Some(scope) + ); + assert!( + reviewer + .execute(WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + "/api/w/workspace%2Ftest/tickets", + "{}" + )) + .is_err() + ); + assert_eq!( + inner.requests.lock().unwrap().len(), + 1, + "only the bounded authorization query reaches the Host" + ); + *inner.response.lock().unwrap() = WorkspaceResponse { + status: 403, + body: "{}".into(), + }; + assert!( + reviewer + .authorize_operation_scope("filesystem.read", &requested) + .is_err() + ); + } + + #[test] + fn malformed_and_oversized_authorization_responses_fail_closed() { + for body in [ + "not-json".into(), + "{\"unexpected_grant\":true}".into(), + " ".repeat(16 * 1024 + 1), + ] { + let client = BoundClient { + response: Mutex::new(WorkspaceResponse { status: 200, body }), + requests: Default::default(), + }; + assert!( + client + .authorize_operation("ticket", &PermissionResource::Feature) + .is_err() + ); + } + } +} + #[cfg(test)] mod reviewer_client_tests { use super::*; @@ -1587,6 +1922,7 @@ pub fn unavailable_workspace_client( workspace_id: workspace_id.map(|id| id.as_str().to_string()), kind: "unavailable".to_string(), available: false, + legacy_local_activation: workspace_id.is_none(), reason: reason.into(), }) } @@ -1601,6 +1937,7 @@ pub fn marker_workspace_client( reason: format!("workspace client kind `{kind}` does not expose Workspace operations"), kind, available: true, + legacy_local_activation: false, }) } @@ -1649,7 +1986,15 @@ impl WorkerWorkspaceContext { } pub fn local_filesystem(workspace_id: Option) -> Self { - let client = marker_workspace_client(workspace_id.as_ref(), "local-filesystem"); + // This constructor explicitly selects legacy standalone activation only + // without Workspace identity; it never authorizes protected operations. + let client = Arc::new(MarkerWorkspaceClient { + workspace_id: workspace_id.as_ref().map(|id| id.as_str().to_string()), + kind: "local-filesystem".into(), + available: true, + legacy_local_activation: workspace_id.is_none(), + reason: "local filesystem context does not expose Workspace operations".into(), + }); Self { workspace_id, client, @@ -1676,41 +2021,10 @@ pub struct SegmentLocation { pub segment_id: SegmentId, } -type WorkerMetadataWriter = - Arc Result<(), WorkerStoreError> + Send + Sync>; -type WorkerMetadataSegmentCas = Arc< - dyn Fn(&str, &WorkerActiveSegmentRef, WorkerActiveSegmentRef) -> Result - + Send - + Sync, ->; - -fn worker_metadata_writer_for_store(store: &St) -> WorkerMetadataWriter -where - St: WorkerMetadataStore + Clone + Send + Sync + 'static, -{ - let store = store.clone(); - Arc::new(move |metadata| { - store - .set_active_with_workspace_context( - &metadata.worker_name, - metadata.active, - metadata.resolved_manifest_snapshot, - metadata.workspace_id, - metadata.workspace_root, - ) - .map(|_| ()) - }) -} - -fn worker_metadata_segment_cas_for_store(store: &St) -> WorkerMetadataSegmentCas -where - St: WorkerMetadataStore + Clone + Send + Sync + 'static, -{ - let store = store.clone(); - Arc::new(move |worker_name, expected, replacement| { - store.compare_and_swap_active(worker_name, expected, replacement) - }) -} +use crate::metadata_lifecycle::{ + WorkerMetadataSegmentCas, WorkerMetadataWriter, worker_metadata_segment_cas_for_store, + worker_metadata_writer_for_store, +}; /// Lock-free shared session/segment pointer. /// @@ -2733,22 +3047,55 @@ pub trait SystemItemCommitter: Send + Sync { item: SystemItem, extensions: Vec, history_provenance: Option, + ) -> Result, StoreError> { + self.commit_system_item_with_extensions_then( + item, + extensions, + history_provenance, + &mut |_| {}, + ) + } + + /// Notify after durable append through the writer's publication boundary. + /// Completion is a storage fact, not a grant of continuing Feature authority; + /// producers must separately fence their representation lifetime. + fn commit_system_item_with_extensions_then( + &self, + item: SystemItem, + extensions: Vec, + history_provenance: Option, + after_commit: &mut dyn FnMut(&HistoryEntry), ) -> Result, StoreError> { let metadata = new_history_metadata( history_provenance .unwrap_or(WorkerHistoryProvenance::BackendInstruction { operation_id: None }), None, ); - let history_item = item.to_history_item(); - self.commit_log_entry(LogEntry::AnnotatedSystemItem { - ts: segment_log::now_millis(), - entry: session_store::LoggedSystemHistoryEntry { - item, - metadata: metadata.clone(), - }, + self.commit_annotated_system_item_then( + session_store::LoggedSystemHistoryEntry { item, metadata }, extensions, - })?; - Ok(HistoryEntry::new(history_item, metadata)) + after_commit, + ) + } + + fn commit_annotated_system_item_then( + &self, + entry: session_store::LoggedSystemHistoryEntry, + extensions: Vec, + after_commit: &mut dyn FnMut(&HistoryEntry), + ) -> Result, StoreError> { + entry.validate_input_metadata()?; + let history = HistoryEntry::new(entry.item.to_history_item(), entry.metadata.clone()); + self.commit_usage_boundary( + LogEntry::AnnotatedSystemItem { + ts: segment_log::now_millis(), + entry, + extensions, + }, + &mut || after_commit(&history), + &[], + )?; + Ok(history) } } @@ -2905,6 +3252,8 @@ impl WorkerSession { } } +pub(crate) type PromptContributionCompletion = Box; + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum SystemPromptContribution { Ready(String), @@ -2915,30 +3264,53 @@ pub(crate) enum SystemPromptContribution { pub(crate) struct ResidentContextRefresh { pub body: String, pub behavior_md: String, + pub prompt_provenance: Option, } -/// Initial-only System messages and Feature-owned metadata. Core persists and -/// replays each pair unchanged; it does not infer provenance from the payload. -/// Prepared with the head, never at restore/request hooks. +/// Feature-owned System context at explicit creation and compaction boundaries. +/// Core persists and replays each pair unchanged; normal restore never reloads it. #[async_trait::async_trait] -pub(crate) trait InitialContextSource: Send + Sync { +pub(crate) trait SessionContextSource: Send + Sync { async fn load(&self) -> Result, String>; + + async fn load_compaction_context( + &self, + _session: SessionId, + ) -> Result, String> { + Ok(Vec::new()) + } } #[async_trait::async_trait] pub(crate) trait SystemPromptContributionSource: Send + Sync { async fn load(&self) -> SystemPromptContribution; - /// Returns a durable model-visible refresh only when user-managed resident - /// context changed since the last successful load. + /// Prepare an initial contribution with an optional source-bound completion. + /// Completion runs only after its exact prompt and active pointer commit, + /// under the publication/release fence. It must perform bounded local updates + /// only; no I/O, store reentry, or authority grants. Restore never replays it. + async fn load_initial( + &self, + ) -> ( + SystemPromptContribution, + Option, + ) { + (self.load().await, None) + } + + /// Returns a model-visible refresh only when user-managed resident context + /// changed since its last committed representation. async fn load_changed_resident_context( &self, ) -> Result, String> { Ok(None) } - /// Acknowledges the exact behavior represented by a durably committed refresh. - fn confirm_resident_context_behavior(&self, _behavior_md: &str) {} + /// Reports a live durable system-input commit to its producer. Interpretation + /// belongs to the Feature; restoration never invokes this notification. + /// Runs under the publication barrier: only bounded local state updates are + /// allowed, without Host I/O or reentry into history mutation. + fn system_item_committed(&self, _item: &SystemItem, _metadata: &SessionHistoryMetadata) {} /// Invalidates the in-memory representation fence before history may be /// compacted or rewound. @@ -3110,10 +3482,11 @@ pub struct Worker { inject_resident_summary: bool, /// Deferred resident prompt source installed by an enabled Feature. feature_resident_summary_source: Option>, - initial_context_source: Option>, + session_context_source: Option>, /// Exact complete seed retained across ambiguous Store responses. The committed /// segment remains authoritative; metadata must be repaired before accepting input. prepared_initial_seed: Option>, + prepared_initial_prompt_completion: Option<(String, PromptContributionCompletion)>, subjektiv_host: Option>, /// One-shot generic Feature lifecycle notification required after restore. restore_feature_lifecycle_pending: bool, @@ -3382,8 +3755,9 @@ impl Worker { prompts, inject_resident_summary: true, feature_resident_summary_source: None, - initial_context_source: None, + session_context_source: None, prepared_initial_seed: None, + prepared_initial_prompt_completion: None, subjektiv_host: None, restore_feature_lifecycle_pending: false, feature_system_prompt_override: None, @@ -3430,8 +3804,8 @@ impl Worker { self.inject_resident_summary = enabled; } - pub(crate) fn install_initial_context_source(&mut self, source: Arc) { - self.initial_context_source = Some(source); + pub(crate) fn install_session_context_source(&mut self, source: Arc) { + self.session_context_source = Some(source); } pub(crate) fn install_system_prompt_contribution( @@ -3439,6 +3813,7 @@ impl Worker { resident_summary_source: Option>, system_prompt_override: Option, ) { + self.prepared_initial_prompt_completion = None; self.feature_resident_summary_source = resident_summary_source; self.feature_system_prompt_override = system_prompt_override; } @@ -3607,10 +3982,12 @@ impl Worker { self.workspace_context.client_handle() } - /// Bind a live capability before Feature installation. This is deliberately - /// separate from Workspace authority and is not inherited by SubWorkers. - pub fn bind_subjektiv_host(&mut self, host: Option>) { + pub(crate) fn set_subjektiv_host( + &mut self, + host: Option>, + ) { self.subjektiv_host = host; + self.session_context_source = None; } pub fn subjektiv_host_connection( @@ -3678,26 +4055,13 @@ impl Worker { { let activation = self.workspace_client().activate_skill(name)?; self.ensure_segment_head().await?; - let body = format!( - "Agent Skill `{}` activated from {}.\n\n{}", - activation.name, activation.provenance.id, activation.body - ); - let skill_metadata = new_history_metadata( - WorkerHistoryProvenance::BackendInstruction { operation_id: None }, - None, - ); + let input = crate::skill::activation_input(&activation).map_err(StoreError::from)?; + let history_entry = HistoryEntry::new(input.item.to_history_item(), input.metadata.clone()); self.commit_entry(LogEntry::AnnotatedSystemItem { ts: segment_log::now_millis(), - entry: session_store::LoggedSystemHistoryEntry { - item: SystemItem::SkillActivation { - name: activation.name.clone(), - body: body.clone(), - }, - metadata: skill_metadata.clone(), - }, + entry: input, extensions: Vec::new(), })?; - let history_entry = HistoryEntry::new(agen::Item::system_message(body), skill_metadata); let mut annotate = history_annotator( self.log_writer_handle(), vec![history_entry.clone()], @@ -3844,6 +4208,12 @@ impl Worker { registry: FeatureRegistryBuilder, ) -> FeatureRegistryInstallReport { self.backend_job.get_or_init(|| None); + let client = self.workspace_client_handle(); + let manifest = self.manifest().clone(); + let registry = registry.with_activation_guard(move || { + crate::feature::authorize_requested_manifest(client.as_ref(), &manifest) + .map_err(|error| error.to_string()) + }); let worker = self.engine.as_mut().expect("worker taken during run"); let report = registry.install_into_engine(worker, &mut self.hook_builder); if report.has_errors() { @@ -4572,18 +4942,12 @@ impl Worker { .lock() .expect("restore system-item queue poisoned"), ); - for item in system_items { - let history_item = item.to_history_item(); - let metadata = new_history_metadata( - WorkerHistoryProvenance::BackendInstruction { operation_id: None }, - None, - ); + for entry in system_items { + let history_item = entry.item.to_history_item(); + let metadata = entry.metadata.clone(); self.commit_entry(LogEntry::AnnotatedSystemItem { ts: segment_log::now_millis(), - entry: session_store::LoggedSystemHistoryEntry { - item, - metadata: metadata.clone(), - }, + entry, extensions: Vec::new(), })?; let history_entry = HistoryEntry::new(history_item, metadata); @@ -4645,16 +5009,19 @@ impl Worker { } } } + let mut completion = None; let resident_summary = if self.inject_resident_summary { match &self.feature_resident_summary_source { - Some(source) => match source.load().await { - SystemPromptContribution::Ready(summary) if !summary.is_empty() => { - Some(summary) + Some(source) => { + let (value, prepared) = source.load_initial().await; + match value { + SystemPromptContribution::Ready(summary) if !summary.is_empty() => { + completion = prepared; + Some(summary) + } + _ => None, } - SystemPromptContribution::Ready(_) | SystemPromptContribution::Unavailable => { - None - } - }, + } None => None, } } else { @@ -4681,6 +5048,12 @@ impl Worker { let rendered = template .render(&ctx) .map_err(|source| WorkerError::SystemPromptRender { source })?; + self.prepared_initial_prompt_completion = completion.and_then(|complete| { + resident_summary + .as_ref() + .filter(|body| rendered.contains(body.as_str())) + .map(|_| (rendered.clone(), complete)) + }); self.engine .as_mut() .expect("worker present") @@ -5346,17 +5719,14 @@ impl Worker { .invocation_receipts .push(InvocationReceipt::new(invocation, Some(result.clone()))); } - let item = SystemItem::FeatureInvocationResult { result, body }; - let metadata = new_history_metadata( - WorkerHistoryProvenance::BackendInstruction { operation_id: None }, - None, - ); - let history = HistoryEntry::new(item.to_history_item(), metadata.clone()); + let input = + crate::feature::invocation::result_input(result, body).map_err(StoreError::from)?; + let history = HistoryEntry::new(input.item.to_history_item(), input.metadata.clone()); handle .writer .append_entry_locked(LogEntry::AnnotatedSystemItem { ts: segment_log::now_millis(), - entry: session_store::LoggedSystemHistoryEntry { item, metadata }, + entry: input, extensions: vec![pending_activation_extension(&completed)], })?; *state = completed; @@ -5860,7 +6230,7 @@ impl Worker { } else { if self.prepared_initial_seed.is_none() { self.ensure_system_prompt_materialized().await?; - let items = match &self.initial_context_source { + let items = match &self.session_context_source { Some(source) => source.load().await.map_err(WorkerError::InvalidState)?, None => Vec::new(), }; @@ -5936,6 +6306,11 @@ impl Worker { self.system_prompt_template = None; self.feature_system_prompt_override = None; self.prepared_initial_seed = None; + if let Some((prepared, complete)) = self.prepared_initial_prompt_completion.take() { + if system_prompt.as_ref() == Some(&prepared) { + complete(); + } + } Ok(()) } @@ -6917,48 +7292,49 @@ impl Worker { // Freeze one Host-owned read before starting the compactor. Background recording // remains independent; never re-read when building the replacement segment. - let journal_context = crate::feature::builtin::journal::freeze_compaction_journal( - self.subjektiv_host_connection(), - &self.prompts.load_full(), - ) - .map_err(WorkerError::PromptCatalog)?; + let frozen_context = match &self.session_context_source { + Some(source) => source + .load_compaction_context(self.session_id()) + .await + .map_err(WorkerError::InvalidState)?, + None => Vec::new(), + }; + for entry in &frozen_context { + entry + .validate_input_metadata() + .map_err(|e| WorkerError::InvalidState(e.to_string()))?; + } let history_entries = self.session.history().entries(); let retain_from = cut.index.min(history_entries.len()); let initial_summary = initial_compaction_summary(history_entries)?; // The old summary is consumed in full by the compaction service even when // the cut falls before it. The replacement summary supersedes it: retaining // it as well would duplicate inherited context on every compact. - let retained_history_entries = history_entries[retain_from..] + let mut retained_history_entries = history_entries[retain_from..] .iter() .filter(|entry| { - !matches!( - entry.annotation.origin, - WorkerHistoryProvenance::CompactionSummary - | WorkerHistoryProvenance::JournalContext - ) + crate::session_history::compaction_use(&entry.annotation) + == session_store::InputCompactionUse::Retain }) .cloned() .collect::>(); - let mut retained_items = retained_history_entries - .iter() - .map(|entry| entry.item.clone()) - .collect::>(); let default_workdir_alias = (self.workdir_sessions.len() == 1) .then(|| self.workdir_sessions.aliases().into_iter().next()) .flatten() .map(|alias| alias.as_str().to_string()); - let retained_image_references = - project_compaction_attachments(&mut retained_items, default_workdir_alias.as_deref()); + let retained_image_references = project_compaction_attachments( + retained_history_entries + .iter_mut() + .map(|entry| &mut entry.item), + default_workdir_alias.as_deref(), + ); // The compactor explores summaries, text details, and ViewImage call arguments. It // never needs durable image bodies; relevant images are nominated with add_reference. let mut entries_to_summarise = history_entries[..retain_from] .iter() .filter(|entry| { - !matches!( - entry.annotation.origin, - WorkerHistoryProvenance::CompactionSummary - | WorkerHistoryProvenance::JournalContext - ) + crate::session_history::compaction_use(&entry.annotation) + == session_store::InputCompactionUse::Retain }) .cloned() .collect::>(); @@ -7045,24 +7421,29 @@ impl Worker { // Journals. In particular, a failed/empty read must not silently lose context // that the previous summary intentionally delegated to its injected Journal. // Carry them only into the compactor, never alongside the new reference seed. - for entry in history_entries - .iter() - .filter(|entry| entry.annotation.origin == WorkerHistoryProvenance::JournalContext) - { + for entry in history_entries.iter().filter(|entry| { + crate::session_history::compaction_use(&entry.annotation) + == session_store::InputCompactionUse::ReferenceInput + }) { if let Some(body) = entry.item.as_text() { summary_input.text.push_str( - "\n\n## Previous Journal reference (historical, not automatically retained)\n", + "\n\n## Previous reference input (historical, not automatically retained)\n", ); summary_input.text.push_str(body); summary_input .text - .push_str("\n## End previous Journal reference\n"); + .push_str("\n## End previous reference input\n"); } } // Mandatory context, outside overview truncation. The internal Worker's normal // request budget checks include this exact body and the full previous summary. summary_input.text.push_str("\n\n"); - summary_input.text.push_str(&journal_context); + for (index, entry) in frozen_context.iter().enumerate() { + if index != 0 { + summary_input.text.push_str("\n\n"); + } + summary_input.text.push_str(&entry.item.history_text()); + } if summary_input.warning_exceeded { self.alert( AlertLevel::Warn, @@ -7296,8 +7677,14 @@ impl Worker { Vec::new() }; let auto_read_tokens = agen::token_counter::total_tokens(&auto_read_messages, &[]).tokens; - let retained_estimate = agen::token_counter::total_tokens(&retained_items, &[]); - let retained_item_count = u64::try_from(retained_items.len()).unwrap_or(u64::MAX); + let retained_estimate = agen::token_counter::total_tokens( + &retained_history_entries + .iter() + .map(|entry| entry.item.clone()) + .collect::>(), + &[], + ); + let retained_item_count = u64::try_from(retained_history_entries.len()).unwrap_or(u64::MAX); let summarized_item_count = u64::try_from(items_to_summarise.len()).unwrap_or(u64::MAX); let compactor_entries = handle.entries(); let compactor_turns = compactor_entries @@ -7352,25 +7739,19 @@ impl Worker { let reference_message = build_related_file_message(&final_ctx.references, &retained_image_references); - // Count surviving user_messages before consuming `retained_items` + // Count surviving user_messages before consuming the annotated retained tail // — needed to align `self.user_segments` after the swap below. - let retained_user_msgs = retained_items + let retained_user_msgs = retained_history_entries .iter() - .filter(|i| i.is_user_message()) + .filter(|entry| entry.item.is_user_message()) .count(); // Build new history: [summary, ...auto-read, references, ...retained, task snapshot, TaskList synthetic call/result]. // The TaskStore snapshot trails the retained items so that, on resume, // `replay_history` walks any pre-compact Task* calls preserved verbatim - // in retained_items first and the trailing snapshot's `replace_with` + // in the retained tail first and the trailing snapshot's `replace_with` // is the final word — pre-compact `TaskCreate` calls cannot leak as // duplicate entries. - let mut new_history = Vec::with_capacity( - 1 + auto_read_messages.len() - + 3 - + reference_message.is_some() as usize - + retained_items.len(), - ); let mut compact_introduced_system_messages = Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize); let summary_message = Item::system_message(format!( @@ -7378,8 +7759,11 @@ impl Worker { crate::session_history::COMPACTION_SUMMARY_HEADER )); compact_introduced_system_messages.push(summary_message.clone()); - let journal_message = Item::system_message(journal_context); - compact_introduced_system_messages.push(journal_message.clone()); + let context_messages = frozen_context + .iter() + .map(|entry| entry.item.to_history_item()) + .collect::>(); + compact_introduced_system_messages.extend(context_messages.iter().cloned()); compact_introduced_system_messages.extend(auto_read_messages.iter().cloned()); if let Some(msg) = reference_message.as_ref() { compact_introduced_system_messages.push(msg.clone()); @@ -7391,20 +7775,88 @@ impl Worker { )); compact_introduced_system_messages.push(task_snapshot_message.clone()); - new_history.push(summary_message); - new_history.push(journal_message); - new_history.extend(auto_read_messages); - if let Some(msg) = reference_message { - new_history.push(msg); + let original_entries = self.session.history().entries(); + let derived_sources = original_entries + .iter() + .map(|entry| entry.annotation.entry_id.clone()) + .collect::>(); + let generic_summary_input = session_store::SystemInputEnvelope::new( + summary_message + .as_text() + .expect("compaction summary is text"), + session_store::HistoryInputMetadata::new( + session_store::HistoryInputUsePolicy { + recording_material: session_store::InputEvidenceUse::Exclude, + memory_evidence: session_store::InputEvidenceUse::Exclude, + observation: session_store::InputObservationUse::Hidden, + compaction: session_store::InputCompactionUse::OpeningSummary, + }, + None, + None, + ) + .map_err(|e| WorkerError::InvalidState(e.to_string()))?, + ) + .map_err(|e| WorkerError::InvalidState(e.to_string()))?; + let summary_entry = session_store::LoggedSystemHistoryEntry::from_input( + WorkerHistoryProvenance::Provided { + producer: "worker.compaction".into(), + }, + Some(SessionHistoryDerivation { + sources: derived_sources.clone(), + }), + generic_summary_input, + ); + let derived_entry = |item| { + let mut metadata = new_history_metadata( + WorkerHistoryProvenance::DerivedSummary, + Some(SessionHistoryDerivation { + sources: derived_sources.clone(), + }), + ); + metadata.input_metadata = Some( + session_store::HistoryInputMetadata::new( + session_store::HistoryInputUsePolicy { + recording_material: session_store::InputEvidenceUse::Exclude, + memory_evidence: session_store::InputEvidenceUse::Exclude, + observation: session_store::InputObservationUse::Hidden, + compaction: session_store::InputCompactionUse::Retain, + }, + None, + None, + ) + .expect("bounded private derived context policy"), + ); + HistoryEntry::new(item, metadata) + }; + // Retained items and annotations travel together, including when attachment + // projection changes an item. Equal model text never selects an identity. + let mut compacted_history_entries = + vec![HistoryEntry::new(summary_message, summary_entry.metadata)]; + compacted_history_entries.extend( + frozen_context.iter().map(|entry| { + HistoryEntry::new(entry.item.to_history_item(), entry.metadata.clone()) + }), + ); + compacted_history_entries.extend(auto_read_messages.into_iter().map(&derived_entry)); + if let Some(item) = reference_message { + compacted_history_entries.push(derived_entry(item)); } - new_history.extend(retained_items); - new_history.push(task_snapshot_message); - new_history.push(Item::tool_call("compact-tasklist", "TaskList", "{}")); - new_history.push(Item::tool_result_with_content( + compacted_history_entries.extend(retained_history_entries); + compacted_history_entries.push(derived_entry(task_snapshot_message)); + compacted_history_entries.push(derived_entry(Item::tool_call( + "compact-tasklist", + "TaskList", + "{}", + ))); + compacted_history_entries.push(derived_entry(Item::tool_result_with_content( "compact-tasklist", self.task_feature.snapshot_overview(), task_snapshot_text.clone(), - )); + ))); + let new_history = compacted_history_entries + .iter() + .map(|entry| entry.item.clone()) + .collect::>(); let result_estimate = agen::token_counter::total_tokens(&new_history, &[]); if result_context_max_tokens > 0 && result_estimate.tokens > result_context_max_tokens { // This history estimate includes retained items and auto-read files, not @@ -7427,57 +7879,6 @@ impl Worker { ), ); } - let original_entries = self.session.history().entries(); - let derived_sources = original_entries - .iter() - .map(|entry| entry.annotation.entry_id.clone()) - .collect::>(); - let mut original_cursor = 0usize; - let compacted_history_entries = new_history - .iter() - .cloned() - .enumerate() - .map(|(index, item)| { - if index == 0 { - HistoryEntry::new( - item, - new_history_metadata( - WorkerHistoryProvenance::CompactionSummary, - Some(SessionHistoryDerivation { - sources: derived_sources.clone(), - }), - ), - ) - } else if index == 1 { - HistoryEntry::new( - item, - new_history_metadata( - WorkerHistoryProvenance::JournalContext, - Some(SessionHistoryDerivation { - sources: derived_sources.clone(), - }), - ), - ) - } else if let Some((offset, original)) = original_entries[original_cursor..] - .iter() - .enumerate() - .find(|(_, original)| original.item == item) - { - original_cursor += offset + 1; - HistoryEntry::new(item, original.annotation.clone()) - } else { - HistoryEntry::new( - item, - new_history_metadata( - WorkerHistoryProvenance::DerivedSummary, - Some(SessionHistoryDerivation { - sources: derived_sources.clone(), - }), - ), - ) - } - }) - .collect::>(); let retained_user_segments = self .user_segments @@ -7955,8 +8356,9 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, - initial_context_source: None, + session_context_source: None, prepared_initial_seed: None, + prepared_initial_prompt_completion: None, subjektiv_host: None, restore_feature_lifecycle_pending: false, feature_system_prompt_override: None, @@ -7987,6 +8389,7 @@ where subjektiv_attached: bool, lifecycle: SubjektivSessionAttributionLifecycle, ) -> Result<(), WorkerError> { + let _admission = self.segment_state.mutation_authority.enter()?; let worker_name = self.manifest.worker.name.as_str(); let metadata = self.store.read_by_name(worker_name)?.ok_or_else(|| { WorkerError::SubjektivSessionAttribution { @@ -8065,11 +8468,15 @@ where } else { "legacy Session has no local attribution evidence; immutable Backend verification started" }; - self.persist_subjektiv_session_attribution(incomplete_subjektiv_attribution_state( + let pending_attribution = incomplete_subjektiv_attribution_state( session_id, create_if_missing, initial_diagnostic.to_string(), - ))?; + ); + self.persist_subjektiv_session_attribution( + metadata.subjektiv_session_attribution.as_ref(), + pending_attribution.clone(), + )?; let output = if self.subjektiv_host.is_some() { let connection = self .subjektiv_host_connection() @@ -8102,7 +8509,7 @@ where message.clone(), ) }; - self.persist_subjektiv_session_attribution(state)?; + self.persist_subjektiv_session_attribution(Some(&pending_attribution), state)?; return Err(WorkerError::SubjektivSessionAttribution { state: if create_if_missing && rejected { "failed" @@ -8128,6 +8535,7 @@ where "Session attribution transport outcome is unknown: {error}" )); self.persist_subjektiv_session_attribution( + Some(&pending_attribution), incomplete_subjektiv_attribution_state( session_id, create_if_missing, @@ -8156,7 +8564,7 @@ where diagnostic: message.clone(), } }; - self.persist_subjektiv_session_attribution(state)?; + self.persist_subjektiv_session_attribution(Some(&pending_attribution), state)?; return Err(WorkerError::SubjektivSessionAttribution { state: if create_if_missing { "failed" @@ -8174,6 +8582,7 @@ where "Session attribution response outcome is unknown: {error}" )); self.persist_subjektiv_session_attribution( + Some(&pending_attribution), incomplete_subjektiv_attribution_state( session_id, create_if_missing, @@ -8195,20 +8604,26 @@ where let message = "Session attribution response did not confirm the requested immutable identity" .to_string(); - self.persist_subjektiv_session_attribution(incomplete_subjektiv_attribution_state( - session_id, - create_if_missing, - message.clone(), - ))?; + self.persist_subjektiv_session_attribution( + Some(&pending_attribution), + incomplete_subjektiv_attribution_state( + session_id, + create_if_missing, + message.clone(), + ), + )?; return Err(WorkerError::SubjektivSessionAttribution { state: incomplete_subjektiv_attribution_label(create_if_missing), message, }); } - self.persist_subjektiv_session_attribution(SubjektivSessionAttributionState::Confirmed { - session_id, - subject_id: output.subject_id.clone(), - })?; + self.persist_subjektiv_session_attribution( + Some(&pending_attribution), + SubjektivSessionAttributionState::Confirmed { + session_id, + subject_id: output.subject_id.clone(), + }, + )?; self.retain_journal_source_before_publication(session_id, &output.subject_id) } @@ -8249,11 +8664,16 @@ where fn persist_subjektiv_session_attribution( &self, + expected: Option<&SubjektivSessionAttributionState>, state: SubjektivSessionAttributionState, ) -> Result<(), WorkerError> { - self.store - .set_subjektiv_session_attribution(&self.manifest.worker.name, state)?; - Ok(()) + crate::subjektiv_metadata::compare_and_swap_attribution( + &self.store, + &self.manifest.worker.name, + self.session_id(), + expected, + state, + ) } /// Build an in-process Internal Worker without machine-wide allocation or durable Worker metadata. @@ -8336,8 +8756,9 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, - initial_context_source: None, + session_context_source: None, prepared_initial_seed: None, + prepared_initial_prompt_completion: None, subjektiv_host: None, restore_feature_lifecycle_pending: false, feature_system_prompt_override: None, @@ -8468,8 +8889,9 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, - initial_context_source: None, + session_context_source: None, prepared_initial_seed: None, + prepared_initial_prompt_completion: None, subjektiv_host: None, restore_feature_lifecycle_pending: false, feature_system_prompt_override: None, @@ -8879,8 +9301,9 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, - initial_context_source: None, + session_context_source: None, prepared_initial_seed: None, + prepared_initial_prompt_completion: None, subjektiv_host: None, restore_feature_lifecycle_pending: true, feature_system_prompt_override: None, @@ -9162,13 +9585,14 @@ fn project_compactor_capture_attachments(entries: &mut [HistoryEntry]) -> /// Remove every durable binary attachment from the compacted tail and preserve /// re-readable ViewImage origins as related-file references. -fn project_compaction_attachments( - items: &mut [Item], +fn project_compaction_attachments<'a>( + items: impl IntoIterator, default_workdir_alias: Option<&str>, ) -> Vec { + let items = items.into_iter().collect::>(); let view_image_calls = items .iter() - .filter_map(|item| match item { + .filter_map(|item| match &**item { Item::ToolCall { call_id, name, @@ -9274,9 +9698,10 @@ fn summary_shrink_prompt( fn initial_compaction_summary( entries: &[HistoryEntry], ) -> Result, WorkerError> { - let mut summaries = entries - .iter() - .filter(|entry| entry.annotation.origin == WorkerHistoryProvenance::CompactionSummary); + let mut summaries = entries.iter().filter(|entry| { + crate::session_history::compaction_use(&entry.annotation) + == session_store::InputCompactionUse::OpeningSummary + }); let Some(entry) = summaries.next() else { return Ok(None); }; @@ -10552,6 +10977,12 @@ mod build_summary_prompt_tests { &self, request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } let request: server_api::MemoryBackendRequest = serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); assert!(matches!( @@ -10613,6 +11044,12 @@ mod build_summary_prompt_tests { &self, request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } let request: server_api::SubjektivMemoryBackendRequest = serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); assert!(matches!( @@ -10717,13 +11154,32 @@ mod build_summary_prompt_tests { #[test] fn malformed_or_duplicate_host_summaries_fail_instead_of_omitting_context() { use crate::session_history::history_entry; - let valid = history_entry( - Item::system_message(format!( - "{}body", - crate::session_history::COMPACTION_SUMMARY_HEADER - )), - WorkerHistoryProvenance::CompactionSummary, - ); + let opening = |item| { + let mut entry = history_entry( + item, + WorkerHistoryProvenance::Provided { + producer: "arbitrary-summary-source".into(), + }, + ); + entry.annotation.input_metadata = Some( + session_store::HistoryInputMetadata::new( + session_store::HistoryInputUsePolicy { + recording_material: session_store::InputEvidenceUse::Exclude, + memory_evidence: session_store::InputEvidenceUse::Exclude, + observation: session_store::InputObservationUse::Hidden, + compaction: session_store::InputCompactionUse::OpeningSummary, + }, + None, + None, + ) + .unwrap(), + ); + entry + }; + let valid = opening(Item::system_message(format!( + "{}body", + crate::session_history::COMPACTION_SUMMARY_HEADER + ))); assert_eq!( initial_compaction_summary(std::slice::from_ref(&valid)) .unwrap() @@ -10735,13 +11191,7 @@ mod build_summary_prompt_tests { Item::user_message("pasted"), Item::system_message("missing marker"), ] { - assert!( - initial_compaction_summary(&[history_entry( - item, - WorkerHistoryProvenance::CompactionSummary - )]) - .is_err() - ); + assert!(initial_compaction_summary(&[opening(item)]).is_err()); } } @@ -10780,7 +11230,7 @@ mod build_summary_prompt_tests { Item::ToolResult { attachments, .. } if attachments.is_empty() )); - let references = project_compaction_attachments(&mut items, Some("main")); + let references = project_compaction_attachments(items.iter_mut(), Some("main")); assert_eq!( references, @@ -10953,6 +11403,12 @@ mod build_summary_prompt_tests { &self, request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } self.requests .lock() .expect("recorded workspace requests lock") @@ -10985,6 +11441,12 @@ mod build_summary_prompt_tests { &self, request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } self.requests .lock() .expect("Flow source request lock") @@ -11168,7 +11630,7 @@ mod build_summary_prompt_tests { 1 } LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) => + if input_schema(entry) == Some("feature:invocation-result") => { 2 } @@ -11302,9 +11764,9 @@ mod build_summary_prompt_tests { TransportUnknown, } - #[derive(Debug)] struct AttributionLifecycleClient { outcomes: Mutex>, + before_response: Mutex>>, requests: Mutex>, } @@ -11312,11 +11774,18 @@ mod build_summary_prompt_tests { fn new(outcomes: impl IntoIterator) -> Self { Self { outcomes: Mutex::new(outcomes.into_iter().collect()), + before_response: Mutex::new(None), requests: Mutex::new(Vec::new()), } } } + impl std::fmt::Debug for AttributionLifecycleClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("AttributionLifecycleClient") + } + } + impl WorkspaceClient for AttributionLifecycleClient { fn workspace_id(&self) -> Option<&str> { Some("workspace-test") @@ -11335,6 +11804,9 @@ mod build_summary_prompt_tests { request: WorkspaceRequest, ) -> Result { self.requests.lock().unwrap().push(request.clone()); + if let Some(change) = self.before_response.lock().unwrap().take() { + change(); + } match self .outcomes .lock() @@ -11582,6 +12054,110 @@ mod build_summary_prompt_tests { )); } + // Production lifecycle, atomic Host CAS, and filesystem metadata store. Only + // the external Workspace transport is substituted to interleave a newer owner. + #[tokio::test] + async fn delayed_attribution_response_cannot_overwrite_new_session_or_subject_evidence() { + for switch_session in [true, false] { + let client = Arc::new(AttributionLifecycleClient::new([ + AttributionOutcome::Success, + ])); + let (worker, store, _temp) = attribution_lifecycle_worker(client.clone()).await; + let worker_name = worker.manifest().worker.name.clone(); + let replacement_session = if switch_session { + session_store::new_session_id() + } else { + worker.session_id() + }; + let changed = store.clone(); + let name = worker_name.clone(); + *client.before_response.lock().unwrap() = Some(Box::new(move || { + if switch_session { + worker_metadata_writer_for_store(&changed)(WorkerMetadata::new( + name, + Some(WorkerActiveSegmentRef::pending_segment(replacement_session)), + )) + .unwrap(); + } else { + changed + .update_by_name(&name, |metadata| { + metadata.subjektiv_session_attribution = + Some(SubjektivSessionAttributionState::Confirmed { + session_id: replacement_session, + subject_id: "new-subject".into(), + }); + }) + .unwrap(); + } + })); + let error = worker + .finalize_subjektiv_session_attribution( + true, + SubjektivSessionAttributionLifecycle::NewSession, + ) + .unwrap_err(); + assert!( + error.to_string().contains("attribution_conflict"), + "{error}" + ); + let saved = store.read_by_name(&worker_name).unwrap().unwrap(); + assert_eq!( + saved.active.as_ref().unwrap().session_id, + replacement_session + ); + assert_eq!( + saved.subjektiv_session_attribution, + if switch_session { + None + } else { + Some(SubjektivSessionAttributionState::Confirmed { + session_id: replacement_session, + subject_id: "new-subject".into(), + }) + } + ); + assert_eq!(client.requests.lock().unwrap().len(), 1); + } + } + + #[tokio::test] + async fn released_body_cannot_reconfirm_attribution_or_contact_host() { + let client = Arc::new(AttributionLifecycleClient::new([ + AttributionOutcome::Success, + ])); + let (worker, store, _temp) = attribution_lifecycle_worker(client.clone()).await; + let before = store + .read_by_name(&worker.manifest().worker.name) + .unwrap() + .unwrap(); + let control = crate::subject_release::ReleaseControl::new(worker.mutation_authority()); + let request = tokio::spawn({ + let control = control.clone(); + async move { control.request().await } + }); + control.notified().await; + control.respond(|| Ok(())); + let mut reservation = request.await.unwrap().unwrap(); + reservation.commit_disconnect().unwrap(); + drop(reservation); + assert!( + worker + .finalize_subjektiv_session_attribution( + true, + SubjektivSessionAttributionLifecycle::NewSession + ) + .is_err() + ); + assert!(client.requests.lock().unwrap().is_empty()); + assert_eq!( + store + .read_by_name(&worker.manifest().worker.name) + .unwrap() + .unwrap(), + before + ); + } + #[tokio::test] async fn unknown_and_failed_attribution_remain_incomplete_until_lifecycle_retry() { for (first, expected_state) in [ @@ -11694,13 +12270,13 @@ mod build_summary_prompt_tests { ) .unwrap(); store - .set_subjektiv_session_attribution( - &manifest.worker.name, - SubjektivSessionAttributionState::OutcomeUnknown { - session_id, - diagnostic: "response lost before restart".to_string(), - }, - ) + .update_by_name(&manifest.worker.name, |metadata| { + metadata.subjektiv_session_attribution = + Some(SubjektivSessionAttributionState::OutcomeUnknown { + session_id, + diagnostic: "response lost before restart".to_string(), + }); + }) .unwrap(); let client = Arc::new(AttributionLifecycleClient::new([ AttributionOutcome::Success, @@ -14140,13 +14716,7 @@ permission = "write" let entries = store.read_all(session_id, segment_id).unwrap(); assert!(matches!( entries.last(), - Some(LogEntry::AnnotatedSystemItem { - entry: session_store::LoggedSystemHistoryEntry { - item: SystemItem::ResidentSummaryRefresh { .. }, - .. - }, - .. - }) + Some(LogEntry::AnnotatedSystemItem { entry, .. }) if input_schema(entry) == Some("memory:resident-summary") )); } } @@ -14443,15 +15013,10 @@ permission = "write" assert!(entries.iter().any(|entry| { matches!( entry, - LogEntry::AnnotatedSystemItem { - entry: session_store::LoggedSystemHistoryEntry { - item: SystemItem::SkillActivation { name, body }, - .. - }, - .. - } if name == "triage-errors" - && body.contains("# Triage Errors") - && body == history_text + LogEntry::AnnotatedSystemItem { entry, .. } + if entry.metadata.input_metadata.as_ref().and_then(|input| input.feature_payload()).and_then(|payload| payload.decode::("skill:activation", 1).ok()).is_some_and(|payload| payload.name == "triage-errors") + && entry.item.history_text().contains("# Triage Errors") + && entry.item.history_text() == history_text ) })); } @@ -15845,7 +16410,7 @@ permission = "write" .position(|entry| { matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) + if input_schema(entry) == Some("feature:invocation-result") ) }) .expect("invocation result must be durable before the model request"); @@ -15854,7 +16419,7 @@ permission = "write" .position(|entry| { matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::SubjectBehaviorRefresh { .. }) + if input_schema(entry) == Some("subject:behavior") ) }) .expect("Subject behavior refresh must be durable before the model request"); @@ -16093,14 +16658,14 @@ permission = "write" .iter() .filter(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) + if input_schema(entry) == Some("feature:invocation-result") )) .count(), 2 // Explicit replay displays the cached result; it does not repeat the effect. ); assert!(entries.iter().any(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::ResidentSummaryRefresh { .. }) + if input_schema(entry) == Some("memory:resident-summary") ))); } @@ -16143,7 +16708,7 @@ permission = "write" LogEntry::AnnotatedSystemItem { entry, extensions, .. } => { - matches!(entry.item, SystemItem::FeatureInvocationResult { .. }) + input_schema(entry) == Some("feature:invocation-result") && extensions.iter().any(|extension| { extension.domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN && serde_json::from_value::( @@ -16734,34 +17299,70 @@ permission = "write" })); } - fn startup_test_item() -> SystemItem { - SystemItem::JournalStartup { - body: "PRIOR_JOURNAL_REFERENCE".into(), - snapshot: serde_json::json!({"subject_id":"subject", "journals":[], "omitted":false}), - snapshot_sha256: "snapshot-digest".into(), - prompt_provenance: PromptRenderProvenance { - workspace_id: None, - source_digest: "source".into(), - projection_digest: "projection".into(), - logical_name: "internal.subjektiv_journal_startup".into(), + fn input_schema(entry: &session_store::LoggedSystemHistoryEntry) -> Option<&str> { + entry + .metadata + .input_metadata + .as_ref()? + .feature_payload() + .map(|payload| payload.schema()) + } + + fn startup_test_input() -> session_store::LoggedSystemHistoryEntry { + use session_store::*; + let payload = FeatureInputPayload::encode( + "journal:startup", + 1, + &crate::feature::builtin::journal::JournalStartupInput { + snapshot: subjektiv::journal::startup::JournalStartupSnapshot { + subject_id: "subject".into(), + journals: vec![], + omitted: false, + }, + snapshot_sha256: "snapshot-digest".into(), + prompt_provenance: PromptRenderProvenance { + workspace_id: None, + source_digest: "source".into(), + projection_digest: "projection".into(), + logical_name: "internal.subjektiv_journal_startup".into(), + }, }, - } + ) + .unwrap(); + LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: "arbitrary-context-provider".into(), + }, + None, + SystemInputEnvelope::new( + "PRIOR_JOURNAL_REFERENCE", + HistoryInputMetadata::new( + HistoryInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction: InputCompactionUse::ReferenceInput, + }, + Some(payload), + None, + ) + .unwrap(), + ) + .unwrap(), + ) } struct StartupTestSource { calls: Arc, fail: Arc, } #[async_trait] - impl InitialContextSource for StartupTestSource { + impl SessionContextSource for StartupTestSource { async fn load(&self) -> Result, String> { self.calls.fetch_add(1, Ordering::SeqCst); if self.fail.load(Ordering::SeqCst) { return Err("startup read failed".into()); } - Ok(vec![session_store::LoggedSystemHistoryEntry { - item: startup_test_item(), - metadata: new_history_metadata(WorkerHistoryProvenance::JournalContext, None), - }]) + Ok(vec![startup_test_input()]) } } @@ -16769,7 +17370,7 @@ permission = "write" async fn initial_system_messages_preserve_feature_metadata_without_payload_classification() { struct Source(Vec); #[async_trait] - impl InitialContextSource for Source { + impl SessionContextSource for Source { async fn load(&self) -> Result, String> { Ok(self.0.clone()) } @@ -16785,16 +17386,19 @@ permission = "write" }, metadata: new_history_metadata(WorkerHistoryProvenance::JournalContext, None), }, - session_store::LoggedSystemHistoryEntry { - item: startup_test_item(), - metadata: new_history_metadata( + { + let mut input = startup_test_input(); + let custom = new_history_metadata( WorkerHistoryProvenance::BackendInstruction { operation_id: Some("feature-selected-origin".into()), }, Some(SessionHistoryDerivation { sources: vec![SessionHistoryEntryId::new()], }), - ), + ); + input.metadata.origin = custom.origin; + input.metadata.derivation = custom.derivation; + input }, ]; let mut worker = Worker::new( @@ -16807,7 +17411,7 @@ permission = "write" ) .await .unwrap(); - worker.install_initial_context_source(Arc::new(Source(expected.clone()))); + worker.install_session_context_source(Arc::new(Source(expected.clone()))); worker.materialize_durable_session_head().await.unwrap(); let log = store .read_all(worker.session_id(), worker.segment_id()) @@ -16864,7 +17468,7 @@ permission = "write" .await .unwrap(); let calls = Arc::new(AtomicUsize::new(0)); - worker.install_initial_context_source(Arc::new(StartupTestSource { + worker.install_session_context_source(Arc::new(StartupTestSource { calls: calls.clone(), fail: Arc::new(AtomicBool::new(false)), })); @@ -16885,7 +17489,9 @@ permission = "write" assert_eq!(replay[0].item.as_text(), Some("PRIOR_JOURNAL_REFERENCE")); assert_eq!( replay[0].annotation.origin, - WorkerHistoryProvenance::JournalContext + WorkerHistoryProvenance::Provided { + producer: "arbitrary-context-provider".into() + } ); let view = crate::session_capture::SessionCapture::from_history_entries("startup", replay); @@ -16896,7 +17502,7 @@ permission = "write" let entries = store .read_all(worker.session_id(), worker.segment_id()) .unwrap(); - let journal = entries.iter().position(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if matches!(entry.item, SystemItem::JournalStartup { .. }))).unwrap(); + let journal = entries.iter().position(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if input_schema(entry) == Some("journal:startup"))).unwrap(); let user = entries .iter() .position(|entry| matches!(entry, LogEntry::AnnotatedUserInput { .. })) @@ -16922,7 +17528,7 @@ permission = "write" .unwrap(); let calls = Arc::new(AtomicUsize::new(0)); let fail = Arc::new(AtomicBool::new(true)); - worker.install_initial_context_source(Arc::new(StartupTestSource { + worker.install_session_context_source(Arc::new(StartupTestSource { calls: calls.clone(), fail: fail.clone(), })); @@ -16938,6 +17544,110 @@ permission = "write" assert_eq!(worker.history().len(), 1); } + struct PreparedPromptSource { + loads: Arc, + completions: Arc, + } + #[async_trait::async_trait] + impl SystemPromptContributionSource for PreparedPromptSource { + async fn load(&self) -> SystemPromptContribution { + panic!("initial load must use its prepared completion") + } + async fn load_initial( + &self, + ) -> ( + SystemPromptContribution, + Option, + ) { + self.loads.fetch_add(1, Ordering::SeqCst); + let completed = self.completions.clone(); + ( + SystemPromptContribution::Ready("PREPARED_RESIDENT_BODY".into()), + Some(Box::new(move || { + completed.fetch_add(1, Ordering::SeqCst); + })), + ) + } + } + + // Production initial-head preparation/create/reconciliation and real FsStore. + // Only returned write failures and the Feature completion counter are injected. + #[tokio::test] + async fn initial_prompt_completion_waits_for_commit_and_reconciliation_and_never_replays_to_replacement() + { + for failure in [3, 4, 0] { + for replace_source in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let store = FailRunTerminalStore { + inner: session_store::FsStore::new(temp.path().join("sessions")).unwrap(), + fail_run_terminal: Arc::new(AtomicBool::new(false)), + invocation_failure: Arc::new(AtomicUsize::new(failure)), + }; + let mut worker = Worker::new( + minimal_manifest(), + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient), + store.clone(), + WorkerWorkspaceContext::no_workspace(), + WorkerFilesystemAuthority::None, + Scope::empty(), + ) + .await + .unwrap(); + worker.set_system_prompt_template( + SystemPromptTemplate::parse("default", PromptCatalogSource::builtins_only()) + .unwrap(), + ); + let loads = Arc::new(AtomicUsize::new(0)); + let completed = Arc::new(AtomicUsize::new(0)); + worker.install_system_prompt_contribution( + Some(Arc::new(PreparedPromptSource { + loads: loads.clone(), + completions: completed.clone(), + })), + None, + ); + if failure == 0 { + let fail = Arc::new(AtomicBool::new(true)); + worker.worker_metadata_writer = Some(Arc::new(move |_| { + if fail.swap(false, Ordering::SeqCst) { + Err(std::io::Error::other("initial active pointer write failed").into()) + } else { + Ok(()) + } + })); + } + assert!(worker.materialize_durable_session_head().await.is_err()); + assert_eq!(completed.load(Ordering::SeqCst), 0); + let new_completed = Arc::new(AtomicUsize::new(0)); + let new_loads = Arc::new(AtomicUsize::new(0)); + if replace_source { + worker.install_system_prompt_contribution( + Some(Arc::new(PreparedPromptSource { + loads: new_loads.clone(), + completions: new_completed.clone(), + })), + None, + ); + } + worker.materialize_durable_session_head().await.unwrap(); + worker.materialize_durable_session_head().await.unwrap(); + assert_eq!(loads.load(Ordering::SeqCst), 1); + assert_eq!( + completed.load(Ordering::SeqCst), + usize::from(!replace_source) + ); + assert_eq!(new_completed.load(Ordering::SeqCst), 0); + assert_eq!(new_loads.load(Ordering::SeqCst), 0); + let saved = store + .read_all(worker.session_id(), worker.segment_id()) + .unwrap(); + assert!( + matches!(&saved[0], LogEntry::AnnotatedSegmentStart { system_prompt: Some(prompt), .. } if prompt.contains("PREPARED_RESIDENT_BODY")) + ); + } + } + } + #[tokio::test] async fn initial_metadata_failure_repairs_pointer_without_refetch_or_user_append() { let temp = tempfile::tempdir().unwrap(); @@ -16953,7 +17663,7 @@ permission = "write" .await .unwrap(); let calls = Arc::new(AtomicUsize::new(0)); - worker.install_initial_context_source(Arc::new(StartupTestSource { + worker.install_session_context_source(Arc::new(StartupTestSource { calls: calls.clone(), fail: Arc::new(AtomicBool::new(false)), })); @@ -17008,7 +17718,7 @@ permission = "write" .unwrap(); worker.enable_worker_metadata_write_through().unwrap(); let calls = Arc::new(AtomicUsize::new(0)); - worker.install_initial_context_source(Arc::new(StartupTestSource { + worker.install_session_context_source(Arc::new(StartupTestSource { calls: calls.clone(), fail: Arc::new(AtomicBool::new(false)), })); @@ -17030,7 +17740,7 @@ permission = "write" &manifest.worker.name, manifest.clone(), store.clone(), PromptCatalogSource::builtins_only(), WorkerWorkspaceContext::no_workspace(), WorkerFilesystemAuthority::None, Some(Box::new(NoopClient))).await.unwrap(); - restored.install_initial_context_source(Arc::new(StartupTestSource { + restored.install_session_context_source(Arc::new(StartupTestSource { calls: calls.clone(), fail: Arc::new(AtomicBool::new(true)), })); @@ -17214,12 +17924,10 @@ permission = "write" let layout = if runtime_managed { crate::WorkerBootstrapLayout::RuntimeManagedRun { run_dir: temp.path().join("runtime/runs/1"), - bash_output_dir: temp.path().join("bash"), } } else { crate::WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), } }; crate::WorkerBootstrap::new( @@ -17355,7 +18063,7 @@ permission = "write" .iter() .filter_map(|entry| match entry { LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::JournalStartup { .. }) => + if input_schema(entry) == Some("journal:startup") => { Some(entry.item.clone()) } @@ -17406,7 +18114,7 @@ permission = "write" .iter() .filter_map(|entry| match entry { LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(entry.item, SystemItem::JournalStartup { .. }) => + if input_schema(entry) == Some("journal:startup") => { Some(&entry.item) } @@ -17436,6 +18144,57 @@ permission = "write" } } + #[test] + fn system_input_completion_observes_durable_identity_under_publication_barrier() { + let temp = tempfile::tempdir().unwrap(); + let store = session_store::FsStore::new(temp.path()).unwrap(); + let session_id = SessionId::now_v7(); + let segment_id = SegmentId::now_v7(); + let start = LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id, + system_prompt: None, + config: agen::llm_client::RequestConfig::default(), + history: vec![], + forked_from: None, + compacted_from: None, + }; + store + .create_segment(session_id, segment_id, std::slice::from_ref(&start)) + .unwrap(); + let writer = LogWriterHandle { + store: store.clone(), + state: SegmentState::new(session_id, segment_id, 1), + sink: SegmentLogSink::with_initial(vec![start]), + in_flight: None, + }; + let mut receipt = None; + let committed = writer + .commit_system_item_with_extensions_then( + SystemItem::Notification { + message: "message".into(), + body: "exact committed system body".into(), + prompt_provenance: None, + }, + vec![], + None, + &mut |history| { + assert!(writer.state.append_lock.try_lock().is_err()); + let durable = store.read_all(session_id, segment_id).unwrap(); + let Some(LogEntry::AnnotatedSystemItem { entry, .. }) = durable.last() else { + panic!("completion must follow actual storage append"); + }; + assert_eq!(entry.metadata, history.annotation); + assert_eq!(entry.item.to_history_item(), history.item); + assert_eq!(history.item.as_text(), Some("exact committed system body")); + receipt = Some(history.annotation.entry_id.clone()); + }, + ) + .unwrap(); + assert_eq!(receipt, Some(committed.annotation.entry_id)); + assert!(writer.state.append_lock.try_lock().is_ok()); + } + fn minimal_manifest() -> WorkerManifest { let toml_str = r#" [worker] diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index 2aeb8f521..0869c0b5c 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -119,6 +119,7 @@ fn annotated(item: Item) -> session_store::LoggedHistoryEntry { session_store::LoggedHistoryEntry { item: session_store::LoggedItem::from(item), metadata: session_store::LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: session_store::LoggedSessionHistoryEntryId::new(), origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, derivation: None, @@ -1495,11 +1496,9 @@ async fn post_compact_pause_bills_partial_without_rearming_or_consuming_correlat let session_id = worker.session_id(); let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, shutdown_receiver) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path()) + .await + .unwrap(); let mut rx = handle.subscribe(); handle .send(Method::submit_text( @@ -1705,11 +1704,9 @@ async fn controller_does_not_repeat_a_persisted_mid_run_compaction_failure() { let (worker, store) = make_worker_with_manifest_and_store(MID_TURN_MANIFEST_TOML, client).await; let session_id = worker.session_id(); let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, shutdown_receiver) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path()) + .await + .unwrap(); let mut rx = handle.subscribe(); handle @@ -1830,11 +1827,9 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() { let source_session = worker.session_id(); let source_segment = worker.segment_id(); let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, shutdown_receiver) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path()) + .await + .unwrap(); let mut rx = handle.subscribe(); handle @@ -1996,8 +1991,7 @@ async fn controller_compact_method_publishes_progress_and_clear() { ]); let worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await; let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) + let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path()) .await .unwrap(); let mut rx = handle.subscribe(); @@ -2219,10 +2213,48 @@ fn summary_entry( origin: session_store::LoggedSessionHistoryOrigin, ) -> session_store::LoggedHistoryEntry { let mut entry = annotated(item); + entry.metadata.input_metadata = matches!( + &entry.item, + session_store::LoggedItem::Message { + role: session_store::LoggedRole::System, + .. + } + ) + .then(|| { + session_store::HistoryInputMetadata::new( + session_store::HistoryInputUsePolicy { + recording_material: session_store::InputEvidenceUse::Exclude, + memory_evidence: session_store::InputEvidenceUse::Exclude, + observation: session_store::InputObservationUse::Hidden, + compaction: match origin { + session_store::LoggedSessionHistoryOrigin::CompactionSummary => { + session_store::InputCompactionUse::OpeningSummary + } + session_store::LoggedSessionHistoryOrigin::JournalContext => { + session_store::InputCompactionUse::ReferenceInput + } + _ => session_store::InputCompactionUse::Retain, + }, + }, + None, + None, + ) + .unwrap() + }); entry.metadata.origin = origin; entry } +fn compaction_use( + metadata: &session_store::LoggedSessionHistoryMetadata, +) -> session_store::InputCompactionUse { + metadata + .input_metadata + .as_ref() + .map(|input| input.usage().compaction) + .unwrap_or(session_store::InputCompactionUse::Retain) +} + fn long_summary(label: &str) -> String { format!( "## Completed Tasks\n{label}: investigated the transport boundary.\n\n\ @@ -2358,7 +2390,8 @@ async fn full_initial_summary_survives_small_overview_budgets_and_both_retention assert_eq!( replacement .iter() - .filter(|entry| entry.metadata.origin == CompactionSummary) + .filter(|entry| compaction_use(&entry.metadata) + == session_store::InputCompactionUse::OpeningSummary) .count(), 1 ); @@ -2603,7 +2636,9 @@ async fn three_chained_compactions_transport_each_complete_predecessor_without_r let seed = replacement_seed(&fixture.worker); let inherited = seed .iter() - .filter(|entry| entry.metadata.origin == CompactionSummary) + .filter(|entry| { + compaction_use(&entry.metadata) == session_store::InputCompactionUse::OpeningSummary + }) .collect::>(); assert_eq!(inherited.len(), 1); assert_eq!( @@ -2891,7 +2926,7 @@ impl worker::subjektiv::SubjektivHost for CompactionJournalHost { } fn journal_startup_granted(&self) -> bool { - true + false } fn journal_startup_context( @@ -2995,8 +3030,8 @@ fn compaction_snapshot(generation: usize) -> subjektiv::journal::startup::Journa fn journal_body(worker: &Worker) -> String { let seed = replacement_seed(worker); assert_eq!( - seed[1].metadata.origin, - session_store::LoggedSessionHistoryOrigin::JournalContext + compaction_use(&seed[1].metadata), + session_store::InputCompactionUse::ReferenceInput ); let item = Item::from(seed[1].item.clone()); assert!(matches!( @@ -3008,8 +3043,8 @@ fn journal_body(worker: &Worker) -> String { )); assert_eq!( seed.iter() - .filter(|entry| entry.metadata.origin - == session_store::LoggedSessionHistoryOrigin::JournalContext) + .filter(|entry| compaction_use(&entry.metadata) + == session_store::InputCompactionUse::ReferenceInput) .count(), 1 ); @@ -3118,20 +3153,34 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai projection.source_digest = "a".repeat(64); let initial_catalog = Arc::new(worker::PromptCatalog::from_projection(projection).unwrap()); fixture.worker.prompts().store(initial_catalog.clone()); - let legacy_startup = session_store::LoggedSystemHistoryEntry { - item: session_store::SystemItem::JournalStartup { - body: "OLD_TYPED_STARTUP_JOURNAL".into(), - snapshot: serde_json::json!({"journals": []}), - snapshot_sha256: "legacy-startup-digest".into(), - prompt_provenance: session_store::PromptRenderProvenance { - workspace_id: None, - source_digest: "legacy-source".into(), - projection_digest: "legacy-projection".into(), - logical_name: "internal.subjektiv_journal_startup".into(), - }, - }, - metadata: summary_entry(Item::system_message(""), DerivedSummary).metadata, - }; + // Already migrated generic input with unrelated provenance/payload; Core + // must select it by explicit policy, not identify a Journal Feature. + let legacy_startup = session_store::LoggedSystemHistoryEntry::from_input( + DerivedSummary, + None, + session_store::SystemInputEnvelope::new( + "OLD_TYPED_STARTUP_JOURNAL", + session_store::HistoryInputMetadata::new( + session_store::HistoryInputUsePolicy { + recording_material: session_store::InputEvidenceUse::Exclude, + memory_evidence: session_store::InputEvidenceUse::Exclude, + observation: session_store::InputObservationUse::Hidden, + compaction: session_store::InputCompactionUse::ReferenceInput, + }, + Some( + session_store::FeatureInputPayload::encode( + "renamed-owner:reference", + 8, + &serde_json::json!({"private": "retained opaque context"}), + ) + .unwrap(), + ), + None, + ) + .unwrap(), + ) + .unwrap(), + ); let legacy_id = legacy_startup.metadata.entry_id.clone(); fixture .restore_seed_with_system( @@ -3165,7 +3214,7 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai .unwrap() .annotation .origin, - JournalContext + DerivedSummary ); let session = fixture.worker.session_id(); let original_segment = fixture.worker.segment_id(); @@ -3190,8 +3239,8 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai .unwrap() .annotation .origin, - JournalContext, - "legacy startup classification survives a normal turn" + DerivedSummary, + "generic source provenance survives a normal turn unchanged" ); } assert_eq!( @@ -3242,7 +3291,7 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai assert_eq!(host.reads.lock().unwrap().len(), index + 1); let history = fixture.worker.history(); let overview = input - .split("## Previous Journal reference (historical, not automatically retained)") + .split("## Previous reference input (historical, not automatically retained)") .next() .unwrap(); for old in &old_bodies { @@ -3335,6 +3384,17 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai .run_text("normal post-compaction turn") .await .unwrap(); + let frozen_seed = replacement_seed(&fixture.worker) + .into_iter() + .filter(|entry| { + matches!( + compaction_use(&entry.metadata), + session_store::InputCompactionUse::ReferenceInput + | session_store::InputCompactionUse::OpeningSummary + ) + }) + .collect::>(); + assert_eq!(frozen_seed.len(), 2); let records = fixture .worker .store() @@ -3374,6 +3434,20 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai .run_text("normal turn after typed history restore") .await .unwrap(); + for frozen in frozen_seed { + let replayed = fixture + .worker + .session_history() + .entries() + .iter() + .find(|entry| entry.annotation.entry_id == frozen.metadata.entry_id) + .expect("frozen entry identity survives re-restore"); + assert_eq!(replayed.item, Item::from(frozen.item)); + assert_eq!( + replayed.annotation, frozen.metadata, + "origin, derivation, public allowlist, policy and private payload survive atomic seed/restore" + ); + } assert_eq!( host.reads.lock().unwrap().len(), 3, @@ -3444,27 +3518,41 @@ async fn journal_absent_disabled_unavailable_and_over_byte_budget_keep_conversat ) .await; fixture.worker.compact(0).await.unwrap(); - let body = journal_body(&fixture.worker); - let expected = match case { - "no-host" | "disabled" => "disabled", - "absent" => "absent", - _ => "unavailable", - }; - assert!( - body.contains(&format!("Journal read status: {expected}.")), - "{case}" - ); let input = first_compactor_input(&observed.requests()[1]); - assert_frozen_journal_transport(&input, &body); - assert_journal_provenance( - &body, - expected_json.as_deref(), - &fixture.worker.prompts().load_full(), - (case != "no-host").then_some(COMPACTION_HOST_WORKSPACE_ID), - ); + let body = if matches!(case, "disabled" | "no-host") { + assert_eq!( + replacement_seed(&fixture.worker) + .iter() + .filter(|entry| compaction_use(&entry.metadata) + == session_store::InputCompactionUse::ReferenceInput) + .count(), + 0 + ); + assert!(!input.contains("Journal reference frozen at this compaction boundary")); + String::new() + } else { + let body = journal_body(&fixture.worker); + let expected = if case == "absent" { + "absent" + } else { + "unavailable" + }; + assert!( + body.contains(&format!("Journal read status: {expected}.")), + "{case}" + ); + assert_frozen_journal_transport(&input, &body); + assert_journal_provenance( + &body, + expected_json.as_deref(), + &fixture.worker.prompts().load_full(), + Some(COMPACTION_HOST_WORKSPACE_ID), + ); + body + }; assert_full_initial_summary(&input, &inherited); assert!(input.contains("UNRECORDED_FALLBACK_GOAL"), "{case}"); - assert!(input.contains("## Previous Journal reference (historical, not automatically retained)\nPREVIOUS_JOURNAL_DELEGATED_CONTEXT")); + assert!(input.contains("## Previous reference input (historical, not automatically retained)\nPREVIOUS_JOURNAL_DELEGATED_CONTEXT")); assert!( fixture.worker.history().iter().all(|item| !item .as_text() @@ -3567,3 +3655,132 @@ async fn journal_snapshot_counts_against_compactor_hard_input_cap_without_mutati ); } } +#[tokio::test] +async fn identical_reference_and_retained_inputs_preserve_distinct_metadata_after_compaction() { + use session_store::*; + let client = MockClient::new(vec![ + single_text_events("fixture"), + single_text_events("fork checkpoint"), + write_summary_tool_use_events("review-summary", "replacement summary"), + single_text_events("done"), + single_text_events("restore checkpoint"), + ]); + let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await; + let make = |producer: &str, compaction| { + LoggedSystemHistoryEntry::from_input( + LoggedSessionHistoryOrigin::Provided { + producer: producer.into(), + }, + None, + SystemInputEnvelope::new( + "identical model context", + HistoryInputMetadata::new( + HistoryInputUsePolicy { + recording_material: InputEvidenceUse::Exclude, + memory_evidence: InputEvidenceUse::Exclude, + observation: InputObservationUse::Hidden, + compaction, + }, + Some( + FeatureInputPayload::encode( + "review:owner", + 1, + &serde_json::json!({"producer": producer}), + ) + .unwrap(), + ), + None, + ) + .unwrap(), + ) + .unwrap(), + ) + .to_history_entry() + }; + let historical = make("old-reference", InputCompactionUse::ReferenceInput); + let retained = make("retained-context", InputCompactionUse::Retain); + let repeated = make("second-retained-context", InputCompactionUse::Retain); + assert_ne!(historical.metadata.entry_id, retained.metadata.entry_id); + fixture + .restore_seed( + vec![historical.clone(), retained.clone(), repeated.clone()], + false, + ) + .await; + let session = fixture.worker.session_id(); + let original = fixture.worker.segment_id(); + fixture + .worker + .store() + .append( + session, + original, + &LogEntry::Extension { + ts: 2, + domain: "test.fork_drift".into(), + payload: serde_json::json!({}), + }, + ) + .unwrap(); + fixture.worker.run_text("fork checkpoint").await.unwrap(); + let fork = fixture.worker.segment_id(); + assert_ne!(fork, original); + let forked = replacement_seed(&fixture.worker); + for expected in [&historical, &retained, &repeated] { + assert!(forked.contains(expected)); + } + fixture.worker.compact(1_000_000).await.unwrap(); + let seed = replacement_seed(&fixture.worker); + let matching: Vec<_> = seed.iter().filter(|e| e.item == retained.item).collect(); + assert_eq!(matching.len(), 2); + assert_eq!( + matching[0].metadata, retained.metadata, + "a retained logical entry must not inherit identity/policy/payload from an equal-body reference that was removed" + ); + + assert_eq!(matching[1].metadata, repeated.metadata); + let compacted = fixture.worker.segment_id(); + let reopened = FsStore::new(fixture._store_dir.path()).unwrap(); + let records = reopened.read_all(session, compacted).unwrap(); + assert!( + matches!(&records[0], LogEntry::AnnotatedSegmentStart { compacted_from: Some(source), .. } if source.segment_id == fork) + ); + fixture.worker.run_text("restore checkpoint").await.unwrap(); + let records = fixture.worker.store().read_all(session, compacted).unwrap(); + let snapshot = public_snapshot::project_current_session_snapshot(&records); + let checkpoint = snapshot + .entries + .iter() + .rev() + .find(|entry| { + matches!( + entry.data, + protocol::SessionSnapshotEntryData::UserInput { .. } + ) + }) + .unwrap(); + fixture + .worker + .rewind_to(checkpoint.entry_id.clone()) + .await + .unwrap(); + for expected in [&retained, &repeated] { + assert!( + fixture + .worker + .session_history() + .entries() + .iter() + .any(|entry| entry.annotation == expected.metadata + && entry.item == Item::from(expected.item.clone())) + ); + } + assert!( + !fixture + .worker + .session_history() + .entries() + .iter() + .any(|entry| entry.annotation.entry_id == historical.metadata.entry_id) + ); +} diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 1938f0b18..077eadc99 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -225,8 +225,14 @@ impl WorkspaceClient for AvailableWorkspaceClient { fn execute( &self, - _request: WorkspaceRequest, + request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } Err(WorkspaceClientError::Unavailable("test".to_string())) } } @@ -249,8 +255,14 @@ impl WorkspaceClient for NoopWorkspaceClient { fn execute( &self, - _request: WorkspaceRequest, + request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } Err(WorkspaceClientError::Unavailable( "test client does not execute requests".to_string(), )) @@ -311,8 +323,7 @@ async fn spawn_controller(worker: Worker) -> WorkerHandle let tmp = tempfile::tempdir().unwrap(); let runtime_base = tmp.path().to_owned(); std::mem::forget(tmp); - let bash_output_dir = runtime_base.join("bash-output"); - let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base, &bash_output_dir) + let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base) .await .unwrap(); handle @@ -342,13 +353,9 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() { ) .await .unwrap(); - let (handle, shutdown_rx) = WorkerController::spawn( - worker, - &root.path().join("runtime"), - &root.path().join("bash-output"), - ) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, &root.path().join("runtime")) + .await + .unwrap(); let mut rx = handle.subscribe(); let file = handle .upload_file_with_context( @@ -408,24 +415,20 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() { } #[tokio::test] -async fn controller_grants_read_scope_for_exact_bash_output_directory() { +async fn controller_does_not_grant_or_create_a_model_visible_bash_output_directory() { let worker = make_worker(MockClient::new(simple_text_events())).await; let shared_scope = worker.scope().clone(); let runtime_base = tempfile::tempdir().unwrap(); - let worker_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = worker_tmp.path().join("worker-1").join("bash-output"); + let original_rules = shared_scope.snapshot().allow_rules().to_vec(); - let (handle, shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); - assert!(bash_output_dir.is_dir()); - assert!(shared_scope.snapshot().allow_rules().iter().any(|rule| { - rule.target == bash_output_dir - && rule.permission == manifest::Permission::Read - && rule.recursive - })); + assert_eq!( + shared_scope.snapshot().allow_rules(), + original_rules.as_slice() + ); assert!(!handle.runtime_dir.path().join("bash-output").exists()); handle @@ -451,9 +454,9 @@ async fn shutdown_closes_bound_workdir_session() { .start_command(CommandRequest { command: "sleep 30".to_owned(), timeout_secs: 60, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await @@ -461,11 +464,9 @@ async fn shutdown_closes_bound_workdir_session() { worker.bind_single_workdir_session(Some(Arc::clone(&session))); let runtime_base = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); - let (handle, shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); handle .send(Method::Shutdown { command: worker_command(&handle), @@ -501,9 +502,9 @@ async fn controller_filters_workdir_command_not_owned_by_worker() { .start_command(CommandRequest { command: "sleep 0.2".to_owned(), timeout_secs: 5, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: Some("foreign-tool-call".into()), }) .await @@ -529,7 +530,10 @@ async fn controller_filters_workdir_command_not_owned_by_worker() { }) .await .unwrap(); - assert_eq!(output.status, workdir::CommandStatus::Completed); + assert_eq!( + output.terminal.unwrap().status, + workdir::CommandStatus::Completed + ); handle .send(Method::Shutdown { command: worker_command(&handle), @@ -738,9 +742,8 @@ async fn controller_startup_failure_closes_bound_workdir_session() { let invalid_runtime_base = runtime_base.path().join("not-a-directory"); std::fs::write(&invalid_runtime_base, "file").unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); assert!( - WorkerController::spawn(worker, &invalid_runtime_base, &bash_output_dir) + WorkerController::spawn(worker, &invalid_runtime_base) .await .is_err() ); @@ -749,9 +752,9 @@ async fn controller_startup_failure_closes_bound_workdir_session() { .start_command(CommandRequest { command: "printf unreachable".to_owned(), timeout_secs: 5, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await, @@ -912,6 +915,12 @@ impl WorkspaceClient for JournalWorkspaceClient { &self, request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } self.requests.lock().unwrap().push(request.clone()); let (status, body) = if request.path.ends_with("/subjektiv/journal/job/finish") { ( @@ -1553,8 +1562,7 @@ permission = "write" let client = MockClient::new(simple_text_events()); let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0; let tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = tmp.path().join("bash-output"); - let result = WorkerController::spawn(worker, tmp.path(), &bash_output_dir).await; + let result = WorkerController::spawn(worker, tmp.path()).await; assert!( result.is_ok(), "feature exposure must not imply delegation authority" @@ -2927,11 +2935,9 @@ async fn status_json_reflects_worker_name() { async fn shutdown_closes_method_admission_before_terminal_confirmation() { let worker = make_worker(MockClient::new(simple_text_events())).await; let runtime_base = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); - let (handle, mut shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, mut shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); handle .send(Method::Shutdown { command: worker_command(&handle), @@ -2966,11 +2972,9 @@ async fn shutdown_joins_socket_server_with_active_connection() { let worker = make_worker(MockClient::new(simple_text_events())).await; let runtime_base = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); - let (handle, shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); let socket_path = handle.runtime_dir.socket_path(); let _connection = UnixStream::connect(&socket_path).await.unwrap(); @@ -3087,7 +3091,7 @@ async fn socket_worker_event_turn_ended_while_idle_auto_starts_turn() { Ok(Some(Event::SystemItem { ref item, .. })) if item.get("kind").and_then(|k| k.as_str()) == Some("worker_event") && item - .pointer("/event/worker_name") + .pointer("/presentation/event/worker_name") .and_then(|v| v.as_str()) == Some("child") => { saw_worker_event_echo = true; diff --git a/crates/worker/tests/feature_invocation_test.rs b/crates/worker/tests/feature_invocation_test.rs index a08cffda0..91f3969b7 100644 --- a/crates/worker/tests/feature_invocation_test.rs +++ b/crates/worker/tests/feature_invocation_test.rs @@ -14,7 +14,7 @@ use protocol::{ InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue, InvocationCompletion, InvocationValue, Segment, }; -use session_store::{CombinedStore, FsStore, FsWorkerStore, LogEntry, SystemItem}; +use session_store::{CombinedStore, FsStore, FsWorkerStore, LogEntry}; use worker::feature::{ FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext, FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule, FeatureRegistryBuilder, @@ -224,10 +224,20 @@ fn results(worker: &Worker) -> Vec { .0 .into_iter() .filter_map(|entry| match entry { - LogEntry::AnnotatedSystemItem { entry, .. } => match entry.item { - SystemItem::FeatureInvocationResult { result, .. } => Some(result), - _ => None, - }, + LogEntry::AnnotatedSystemItem { entry, .. } => { + #[derive(serde::Deserialize)] + struct SavedResult { + result: FeatureInvocationResult, + } + entry + .metadata + .input_metadata + .as_ref()? + .feature_payload()? + .decode::("feature:invocation-result", 1) + .ok() + .map(|input| input.result) + } _ => None, }) .collect() diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index fc6a302fe..c46c54926 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -91,11 +91,38 @@ pub fn parse_bearer(headers: &HeaderMap) -> Option { } pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option { - let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?; - cookie_header.split(';').find_map(|pair| { - let (name, value) = pair.trim().split_once('=')?; - (name == cookie_name && !value.is_empty()).then(|| value.to_string()) - }) + unique_cookie(headers, cookie_name) + .ok() + .flatten() + .filter(|value| !value.is_empty()) +} + +// Cookie has no path/domain metadata on requests. Duplicates (including identical values +// and multiple Cookie headers) cannot be disambiguated safely after cookie tossing. +fn unique_cookie( + headers: &HeaderMap, + cookie_name: &str, +) -> std::result::Result, ()> { + let mut found = None; + for header in headers.get_all(header::COOKIE) { + let header = header.to_str().map_err(|_| ())?; + for pair in header.split(';') { + let pair = pair.trim(); + let Some((name, value)) = pair.split_once('=') else { + if pair == cookie_name { + return Err(()); + } + continue; + }; + if name == cookie_name { + if found.is_some() { + return Err(()); + } + found = Some(value.to_owned()); + } + } + } + Ok(found) } pub async fn resolve_request_actor( @@ -121,6 +148,14 @@ pub(crate) fn resolve_request_actor_sync( bearer: Option, session: Option, ) -> Result> { + Ok(resolve_credentials_sync(store, bearer, session)?.map(|(actor, _)| actor)) +} + +fn resolve_credentials_sync( + store: &dyn ControlPlaneStore, + bearer: Option, + session: Option, +) -> Result)>> { if let Some(token) = bearer { let token_hash = token_hash(&token); if let Some(api_token) = store.resolve_api_token(&token_hash)? { @@ -132,7 +167,10 @@ pub(crate) fn resolve_request_actor_sync( return Ok(None); } store.mark_api_token_used(&token_hash, &now_rfc3339())?; - return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken); + return Ok( + actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken)? + .map(|actor| (actor, None)), + ); } } @@ -141,13 +179,95 @@ pub(crate) fn resolve_request_actor_sync( if is_expired(&session.expires_at) { return Ok(None); } - return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession); + return Ok( + actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession)? + .map(|actor| (actor, Some(session.expires_at))), + ); } } Ok(None) } +// This marker is a non-authenticating reference to the existing non-revoked session row. +// It outlives the auth cookie so Max-Age expiry remains distinguishable from a new browser. +// No second session store is introduced; clearing all cookies necessarily loses this evidence. +// Keep the established 14-day authentication lifetime. The additional week is only a bounded +// return-navigation window (including a week away); it never extends authentication. The store +// reclaims expired rows past this same boundary on subsequent session issuance. Neither whoami +// nor other reads refresh the marker or the row's absolute expiry. +pub const BROWSER_SESSION_SECONDS: i64 = 14 * 24 * 60 * 60; +pub const SESSION_EXPIRY_GRACE_SECONDS: i64 = 7 * 24 * 60 * 60; + +pub fn session_marker_cookie_name(cookie_name: &str) -> String { + format!("{cookie_name}_expiry") +} + +pub async fn resolve_whoami( + store: std::sync::Arc, + headers: &HeaderMap, + cookie_name: &str, +) -> Result { + let bearer = parse_bearer(headers); + // Even an empty/invalid auth cookie takes precedence over an older marker. + // An ambiguous primary is invalid, not absent; never fall back to an older marker. + let session = unique_cookie(headers, cookie_name).unwrap_or_else(|()| Some(String::new())); + let marker = parse_cookie(headers, &session_marker_cookie_name(cookie_name)); + let executor = store.db_executor(); + executor + .run(move || resolve_whoami_sync(store.as_ref(), bearer, session, marker)) + .await +} + +fn resolve_whoami_sync( + store: &dyn ControlPlaneStore, + bearer: Option, + session_token: Option, + marker: Option, +) -> Result { + use server_api::{BrowserSessionState, WhoamiResponse}; + let anonymous = || WhoamiResponse { + actor: None, + session: BrowserSessionState::Anonymous {}, + }; + // Preserve the existing API-token precedence and fallback rules, including expired tokens. + if let Some((actor, expires_at)) = + resolve_credentials_sync(store, bearer, session_token.clone())? + { + return Ok(WhoamiResponse { + actor: Some(actor), + session: BrowserSessionState::Authenticated { expires_at }, + }); + } + let hash = match session_token { + Some(token) => token_hash(&token), + None => match marker { + Some(hash) => hash, + None => return Ok(anonymous()), + }, + }; + let Some(session) = store.resolve_browser_session(&hash)? else { + return Ok(anonymous()); + }; + if store.get_user(&session.user_id)?.is_none() { + return Ok(anonymous()); + } + if within_session_expiry_grace(&session.expires_at, Utc::now()) { + return Ok(WhoamiResponse { + actor: None, + session: BrowserSessionState::Expired {}, + }); + } + // A marker for a still-valid session cannot restore authentication after partial deletion. + Ok(anonymous()) +} + +fn within_session_expiry_grace(expires_at: &str, now: DateTime) -> bool { + DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| { + expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS) + }) +} + fn actor_for_user( store: &S, user_id: &str, @@ -274,6 +394,13 @@ mod tests { .unwrap(); assert_eq!(actor.user_id, "user-auth"); assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken); + let whoami = resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap(); + assert_eq!( + whoami.session, + server_api::BrowserSessionState::Authenticated { expires_at: None } + ); assert!( store .resolve_api_token(&hash) @@ -363,20 +490,119 @@ mod tests { assert!(futures::poll!(queued.as_mut()).is_pending()); let mut headers = HeaderMap::new(); headers.insert(header::COOKIE, "session=session-secret".parse().unwrap()); + let actor_result = tokio::time::timeout( + std::time::Duration::from_secs(1), + resolve_request_actor(store.clone(), &headers, "session"), + ) + .await; let result = tokio::time::timeout( std::time::Duration::from_secs(1), - resolve_request_actor(store, &headers, "session"), + resolve_whoami(store, &headers, "session"), ) .await; release.send(()).unwrap(); blocker.await.unwrap().unwrap(); queued.await.unwrap(); + assert!(matches!( + actor_result.expect("actor lookup rejects capacity"), + Err(Error::DbCapacityExceeded) + )); assert!(matches!( result.expect("capacity rejection must not wait for queued SQL"), Err(Error::DbCapacityExceeded) )); } + #[tokio::test] + async fn whoami_classifies_only_live_store_backed_session_expiry() { + use server_api::BrowserSessionState; + let store = seeded_store(); + for (secret, offset, revoked) in [ + ("recent", -60, false), + ("old", -SESSION_EXPIRY_GRACE_SECONDS - 60, false), + ("revoked", -60, true), + ] { + store + .create_browser_session(&BrowserSessionRecord { + session_id: format!("session-{secret}"), + token_hash: token_hash(secret), + user_id: "user-auth".into(), + created_at: "1".into(), + expires_at: rfc3339_after(Duration::seconds(offset)), + revoked_at: revoked.then(|| now_rfc3339()), + }) + .unwrap(); + } + let cases = [ + (String::new(), BrowserSessionState::Anonymous {}), + ("session=unknown".into(), BrowserSessionState::Anonymous {}), + ("session=recent".into(), BrowserSessionState::Expired {}), + ( + format!("session_expiry={}", token_hash("recent")), + BrowserSessionState::Expired {}, + ), + ( + format!("session_expiry={}", token_hash("session-secret")), + BrowserSessionState::Anonymous {}, + ), + ( + "session_expiry=unknown".into(), + BrowserSessionState::Anonymous {}, + ), + ( + "session_expiry=recent".into(), + BrowserSessionState::Anonymous {}, + ), + ( + format!("session=unknown; session_expiry={}", token_hash("recent")), + BrowserSessionState::Anonymous {}, + ), + ( + format!("session=; session_expiry={}", token_hash("recent")), + BrowserSessionState::Anonymous {}, + ), + ("session=revoked".into(), BrowserSessionState::Anonymous {}), + ( + format!("session_expiry={}", token_hash("revoked")), + BrowserSessionState::Anonymous {}, + ), + ("session=old".into(), BrowserSessionState::Anonymous {}), + ( + format!("session_expiry={}", token_hash("old")), + BrowserSessionState::Anonymous {}, + ), + ( + format!( + "session=session-secret; session_expiry={}", + token_hash("recent") + ), + BrowserSessionState::Authenticated { + expires_at: Some("9999-01-01T00:00:00Z".into()), + }, + ), + ]; + for (cookie, expected) in cases { + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, cookie.parse().unwrap()); + let response = resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap(); + assert_eq!(response.session, expected, "cookie: {cookie}"); + assert_eq!( + response.actor.is_some(), + matches!(expected, BrowserSessionState::Authenticated { .. }) + ); + if !matches!(expected, BrowserSessionState::Authenticated { .. }) { + assert!( + resolve_request_actor(store.clone(), &headers, "session") + .await + .unwrap() + .is_none() + ); + } + } + } + #[test] fn user_code_uses_the_uuid_random_tail() { let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID"); @@ -395,4 +621,136 @@ mod tests { ); assert_eq!(code, code.to_ascii_uppercase()); } + + #[test] + fn expiry_grace_includes_expiry_but_excludes_its_absolute_end() { + let expiry = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + for (offset, expected) in [ + (-1, false), + (0, true), + (SESSION_EXPIRY_GRACE_SECONDS - 1, true), + (SESSION_EXPIRY_GRACE_SECONDS, false), + ] { + assert_eq!( + within_session_expiry_grace( + &expiry.to_rfc3339(), + expiry + Duration::seconds(offset) + ), + expected + ); + } + assert!(!within_session_expiry_grace("invalid", expiry)); + } + + #[tokio::test] + async fn duplicate_cookies_never_select_an_actor_or_promote_an_ambiguous_marker() { + use server_api::BrowserSessionState; + let store = seeded_store(); + let expired_hash = token_hash("expired-duplicate"); + store + .create_browser_session(&BrowserSessionRecord { + session_id: "expired-duplicate".into(), + token_hash: expired_hash.clone(), + user_id: "user-auth".into(), + created_at: now_rfc3339(), + expires_at: rfc3339_after(Duration::seconds(-60)), + revoked_at: None, + }) + .unwrap(); + for cookies in [ + vec!["session=session-secret; session=unknown".to_owned()], + vec!["session=session-secret; session=session-secret".to_owned()], + vec![ + "session=session-secret".to_owned(), + "session=unknown".to_owned(), + ], + vec![format!( + "session=; session=session-secret; session_expiry={expired_hash}" + )], + vec![format!( + "session_expiry={expired_hash}; session_expiry={expired_hash}" + )], + vec![format!("session; session_expiry={expired_hash}")], + ] { + let mut headers = HeaderMap::new(); + for cookie in cookies { + headers.append(header::COOKIE, cookie.parse().unwrap()); + } + assert!( + resolve_request_actor(store.clone(), &headers, "session") + .await + .unwrap() + .is_none() + ); + assert_eq!( + resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap() + .session, + BrowserSessionState::Anonymous {} + ); + } + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + format!("session=session-secret; session_expiry={expired_hash}") + .parse() + .unwrap(), + ); + assert!(matches!( + resolve_whoami(store, &headers, "session") + .await + .unwrap() + .session, + BrowserSessionState::Authenticated { .. } + )); + } + + #[test] + fn session_issuance_reclaims_only_rows_beyond_recovery_grace() { + let store = seeded_store(); + let record = |secret: &str, offset| BrowserSessionRecord { + session_id: secret.into(), + token_hash: token_hash(secret), + user_id: "user-auth".into(), + created_at: now_rfc3339(), + expires_at: rfc3339_after(Duration::seconds(offset)), + revoked_at: None, + }; + store + .create_browser_session(&record("past-grace", -SESSION_EXPIRY_GRACE_SECONDS - 60)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("past-grace")) + .unwrap() + .is_some() + ); + store + .create_browser_session(&record("within-grace", -SESSION_EXPIRY_GRACE_SECONDS + 60)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("past-grace")) + .unwrap() + .is_none() + ); + store + .create_browser_session(&record("new-session", BROWSER_SESSION_SECONDS)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("within-grace")) + .unwrap() + .is_some() + ); + assert!( + store + .resolve_browser_session(&token_hash("new-session")) + .unwrap() + .is_some() + ); + } } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index f6aea2db6..4027b221a 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -567,6 +567,10 @@ pub struct WorkerSpawnRequest { /// Trusted Backend operation identity used to make Worker-owned spawns replay-safe. #[serde(skip, default)] pub resolved_control_operation: Option, + /// Host-derived child execution ceiling, persisted against the ordinary + /// Worker reservation before Runtime activation. Not accepted from JSON. + #[serde(skip, default)] + pub resolved_execution_grant: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -7892,6 +7896,7 @@ mod tests { resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), resolved_subjektiv_attached: false, @@ -8182,6 +8187,7 @@ mod tests { resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), resolved_subjektiv_attached: false, @@ -8294,6 +8300,7 @@ mod tests { resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), resolved_subjektiv_attached: false, @@ -8335,6 +8342,7 @@ mod tests { resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), resolved_subjektiv_attached: false, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index dd256fea9..1c4bb8dcb 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -26,10 +26,12 @@ use subjektiv::api::*; mod backend_job_profile; +mod subject_authorization; mod subjektiv_job; mod subjektiv_journal; mod subjektiv_journal_browser; mod subjektiv_memory_journal; +mod workdir_authorization; mod worker_operations; mod worker_protocol; pub(crate) use worker_operations::{ @@ -475,6 +477,7 @@ struct WorkdirCommandSession { attachment_alias: String, source: WorkdirSessionHandle, provider_handle: CommandHandle, + terminal_at: Option, } enum RegisteredWorkdirSession { @@ -515,6 +518,7 @@ impl WorkdirSessionRegistry { source: WorkdirSessionHandle, provider_handle: CommandHandle, ) -> CommandHandle { + self.prune_terminal_commands(&worker); let external_handle = loop { let candidate = CommandHandle(Uuid::now_v7().to_string()); if !self @@ -530,6 +534,7 @@ impl WorkdirSessionRegistry { attachment_alias, source, provider_handle, + terminal_at: None, }, ); external_handle @@ -547,6 +552,45 @@ impl WorkdirSessionRegistry { .cloned() } + fn active_command_count(&self, worker: &RuntimeWorkerRef) -> usize { + self.commands + .iter() + .filter(|((owner, _), command)| owner == worker && command.terminal_at.is_none()) + .count() + } + + fn mark_terminal(&mut self, worker: &RuntimeWorkerRef, external_handle: &CommandHandle) { + if let Some(command) = self + .commands + .get_mut(&(worker.clone(), external_handle.clone())) + { + command + .terminal_at + .get_or_insert_with(std::time::Instant::now); + } + self.prune_terminal_commands(worker); + } + + fn prune_terminal_commands(&mut self, worker: &RuntimeWorkerRef) { + let mut terminal = self + .commands + .iter() + .filter_map(|((owner, handle), command)| { + (owner == worker) + .then_some(command.terminal_at) + .flatten() + .map(|at| (handle.clone(), at)) + }) + .collect::>(); + terminal.sort_by_key(|(_, at)| *at); + let excess = terminal + .len() + .saturating_sub(MAX_RETAINED_TERMINAL_COMMANDS); + for (handle, _) in terminal.into_iter().take(excess) { + self.commands.remove(&(worker.clone(), handle)); + } + } + fn take_worker_commands_for_alias( &mut self, worker: &RuntimeWorkerRef, @@ -895,7 +939,27 @@ struct ExternalProviderWorkdirSession { audit: Option, closed: std::sync::atomic::AtomicBool, close_lock: tokio::sync::Mutex<()>, - active_commands: tokio::sync::Mutex>, + active_commands: tokio::sync::Mutex>>, +} + +const MAX_RETAINED_TERMINAL_COMMANDS: usize = 128; +const MAX_ACTIVE_SERVER_COMMANDS: usize = 128; + +struct ExternalProviderCommandState { + handle: workdir::CommandHandle, + // Serialize output ACKs, not cancellation: cancel must unblock a waiting reader. + cursor: tokio::sync::Mutex, + terminal_at: Mutex>, +} + +impl ExternalProviderCommandState { + fn new(handle: workdir::CommandHandle) -> Self { + Self { + handle, + cursor: tokio::sync::Mutex::new(0), + terminal_at: Mutex::new(None), + } + } } impl std::fmt::Debug for ExternalProviderWorkdirSession { @@ -986,17 +1050,24 @@ impl ExternalProviderWorkdirSession { "External Workdir grant has expired".to_string(), )); } - let _admission = self - .connection - .admission - .clone() - .acquire_owned() - .await - .map_err(|_| { - workdir::WorkdirError::Unavailable( - "External Workdir provider admission is closed".to_string(), - ) - })?; + // Cancellation must reach the provider even when waiting output readers + // occupy every data-operation slot. The socket separately bounds control requests. + let _admission = if matches!(&operation, WorkdirSessionOperation::CommandCancel(_)) { + None + } else { + Some( + self.connection + .admission + .clone() + .acquire_owned() + .await + .map_err(|_| { + workdir::WorkdirError::Unavailable( + "External Workdir provider admission is closed".to_string(), + ) + })?, + ) + }; if self .connection .expires_at @@ -1073,10 +1144,10 @@ impl ExternalProviderWorkdirSession { } } - async fn provider_command_handle( + async fn provider_command( &self, external_handle: &workdir::CommandHandle, - ) -> std::result::Result { + ) -> std::result::Result, workdir::WorkdirError> { if self.closed.load(Ordering::Acquire) { return Err(workdir::WorkdirError::SessionClosed); } @@ -1088,51 +1159,111 @@ impl ExternalProviderWorkdirSession { .ok_or_else(|| workdir::WorkdirError::UnknownCommand(external_handle.0.clone())) } + async fn provider_command_handle( + &self, + external_handle: &workdir::CommandHandle, + ) -> std::result::Result { + Ok(self.provider_command(external_handle).await?.handle.clone()) + } + async fn remove_active_command_if_matches( &self, external_handle: &workdir::CommandHandle, - provider_handle: &workdir::CommandHandle, + command: &Arc, ) { let mut commands = self.active_commands.lock().await; - if commands.get(&external_handle.0) == Some(provider_handle) { + if commands + .get(&external_handle.0) + .is_some_and(|current| Arc::ptr_eq(current, command)) + { commands.remove(&external_handle.0); } } + async fn prune_terminal_commands(&self) { + let mut commands = self.active_commands.lock().await; + let mut terminal = commands + .iter() + .filter_map(|(key, command)| { + command + .terminal_at + .lock() + .unwrap() + .map(|at| (key.clone(), at)) + }) + .collect::>(); + terminal.sort_by_key(|(_, at)| *at); + let excess = terminal + .len() + .saturating_sub(MAX_RETAINED_TERMINAL_COMMANDS); + for (key, _) in terminal.into_iter().take(excess) { + commands.remove(&key); + } + } + async fn terminate_provider_command( &self, - provider_handle: workdir::CommandHandle, + command: Arc, + reused_handle: bool, ) -> std::result::Result<(), workdir::WorkdirError> { - // Cancellation is best effort because the command may already be terminal. - // Terminal output is the proof that the provider reaped the process. + if !reused_handle && command.terminal_at.lock().unwrap().is_some() { + return Ok(()); + } + // Do not acquire the output lock until cancellation has unblocked any reader. + // The reader records its last known cursor before cleanup acknowledges bytes. let _ = self .operate(WorkdirSessionOperation::CommandCancel( - provider_handle.clone(), + command.handle.clone(), )) .await; - match self - .operate(WorkdirSessionOperation::CommandOutput( - workdir::CommandOutputRequest { - handle: provider_handle, - cursor: 0, - limit: workdir::external::MAX_EXTERNAL_COMMAND_OUTPUT_BYTES, - wait: true, - }, - )) - .await? - { - WorkdirSessionOperationResult::CommandOutput(output) - if output.status != workdir::CommandStatus::Running => - { - Ok(()) - } - WorkdirSessionOperationResult::CommandOutput(_) => { - Err(workdir::WorkdirError::Unavailable( - "External Workdir provider did not confirm command termination".to_string(), - )) - } - _ => Err(Self::mismatch("command_output")), + let mut cursor = + tokio::time::timeout(std::time::Duration::from_secs(30), command.cursor.lock()) + .await + .map_err(|_| { + workdir::WorkdirError::Unavailable( + "External Workdir reader did not stop after cancellation".to_string(), + ) + })?; + if !reused_handle && command.terminal_at.lock().unwrap().is_some() { + return Ok(()); } + let drain = async { + loop { + match self + .operate(WorkdirSessionOperation::CommandOutput( + workdir::CommandOutputRequest { + handle: command.handle.clone(), + cursor: *cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }, + )) + .await? + { + WorkdirSessionOperationResult::CommandOutput(output) => { + *cursor = output.next_cursor; + if output.terminal.is_some() { + *command.terminal_at.lock().unwrap() = Some(std::time::Instant::now()); + return Ok(()); + } + if output.chunks.is_empty() { + return Err(workdir::WorkdirError::Unavailable( + "External Workdir provider did not confirm command termination" + .to_string(), + )); + } + } + _ => return Err(Self::mismatch("command_output")), + } + } + }; + tokio::time::timeout(std::time::Duration::from_secs(30), drain) + .await + .map_err(|_| { + workdir::WorkdirError::Unavailable( + "External Workdir command drain timed out".to_string(), + ) + })? } fn mismatch(expected: &str) -> workdir::WorkdirError { @@ -1410,17 +1541,27 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { } async fn start_command( &self, - mut request: workdir::CommandRequest, + request: workdir::CommandRequest, ) -> std::result::Result { self.ensure_capability(workdir::WorkdirSessionCapability::Command)?; let _close_guard = self.close_lock.lock().await; if self.closed.load(Ordering::Acquire) { return Err(workdir::WorkdirError::SessionClosed); } - // Bash supplies a Worker-host spill directory. It is neither meaningful - // nor safe to send to a client-hosted provider, so External commands use - // bounded inline output and report truncation without a provider path. - request.spill_dir = None; + self.prune_terminal_commands().await; + if self + .active_commands + .lock() + .await + .values() + .filter(|command| command.terminal_at.lock().unwrap().is_none()) + .count() + >= MAX_ACTIVE_SERVER_COMMANDS + { + return Err(workdir::WorkdirError::Unavailable( + "External Workdir active command limit reached".to_string(), + )); + } match self .operate(WorkdirSessionOperation::CommandStart(request)) .await? @@ -1430,7 +1571,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { let commands = self.active_commands.lock().await; commands .iter() - .filter(|(_, active)| *active == &provider_handle) + .filter(|(_, active)| active.handle == provider_handle) .map(|(external, _)| external.clone()) .collect::>() }; @@ -1439,20 +1580,29 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { // generation. Remove every ambiguous route before attempting // cleanup so no caller can address the wrong process. let mut commands = self.active_commands.lock().await; + let mut quarantined = None; for external_handle in duplicate_handles { - commands.remove(&external_handle); + if let Some(command) = commands.remove(&external_handle) { + quarantined.get_or_insert(command); + } } let quarantine_handle = workdir::CommandHandle(format!("quarantined-command-{}", Uuid::now_v7())); - commands.insert(quarantine_handle.0.clone(), provider_handle.clone()); + let command = quarantined.expect("duplicate command route disappeared"); + *command.terminal_at.lock().unwrap() = None; + commands.insert(quarantine_handle.0.clone(), command.clone()); drop(commands); if self - .terminate_provider_command(provider_handle.clone()) + .terminate_provider_command(command.clone(), true) .await .is_ok() { - self.remove_active_command_if_matches(&quarantine_handle, &provider_handle) + self.remove_active_command_if_matches(&quarantine_handle, &command) .await; + } else { + // An earlier read of the reused handle cannot establish that + // the newly started, ambiguous process has been cleaned up. + *command.terminal_at.lock().unwrap() = None; } return Err(workdir::WorkdirError::Unavailable( "External Workdir provider reused an active command handle".to_string(), @@ -1460,10 +1610,10 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { } let external_handle = workdir::CommandHandle(format!("external-command-{}", Uuid::now_v7())); - self.active_commands - .lock() - .await - .insert(external_handle.0.clone(), provider_handle); + self.active_commands.lock().await.insert( + external_handle.0.clone(), + Arc::new(ExternalProviderCommandState::new(provider_handle)), + ); Ok(external_handle) } _ => Err(Self::mismatch("command_start")), @@ -1489,21 +1639,29 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { ) -> std::result::Result { self.ensure_capability(workdir::WorkdirSessionCapability::Command)?; let external_handle = request.handle.clone(); - let provider_handle = self.provider_command_handle(&external_handle).await?; - request.handle = provider_handle.clone(); + let command = self.provider_command(&external_handle).await?; + let mut cursor = command.cursor.lock().await; + request.handle = command.handle.clone(); + // If the response is lost, this request cursor still replays the provider page. match self .operate(WorkdirSessionOperation::CommandOutput(request)) - .await? + .await { - WorkdirSessionOperationResult::CommandOutput(output) => { - if output.status != workdir::CommandStatus::Running && output.next_cursor.is_none() - { - self.remove_active_command_if_matches(&external_handle, &provider_handle) - .await; + Ok(WorkdirSessionOperationResult::CommandOutput(output)) => { + *cursor = output.next_cursor; + if output.terminal.is_some() { + command + .terminal_at + .lock() + .unwrap() + .get_or_insert_with(std::time::Instant::now); } + drop(cursor); + self.prune_terminal_commands().await; Ok(output) } - _ => Err(Self::mismatch("command_output")), + Ok(_) => Err(Self::mismatch("command_output")), + Err(error) => Err(error), } } async fn cancel_command( @@ -1540,21 +1698,25 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { }) .collect::>(); let mut cleanup_errors = Vec::new(); - for (external_handle, provider_handle) in commands { + for (external_handle, command) in commands { // Detach/session replacement is terminal authority for commands // started through this exact provider generation. Do not report a // successful close until the provider confirms terminal output. match self - .terminate_provider_command(provider_handle.clone()) + .terminate_provider_command(command.clone(), false) .await { Ok(()) => { - self.remove_active_command_if_matches(&external_handle, &provider_handle) + self.remove_active_command_if_matches(&external_handle, &command) .await; } Err(error) => { - let still_active = self.active_commands.lock().await.get(&external_handle.0) - == Some(&provider_handle); + let still_active = self + .active_commands + .lock() + .await + .get(&external_handle.0) + .is_some_and(|current| Arc::ptr_eq(current, &command)); if still_active { cleanup_errors.push(error.to_string()); } @@ -4378,6 +4540,7 @@ impl WorkspaceApi { // main-Subject/Memory attachment or its resident/extraction policy. resolved_subjektiv_attached: request.grants.subjektiv_consolidation.is_some(), resolved_control_operation: Some(control_operation), + resolved_execution_grant: None, }, ) { Ok(spawned) => spawned, @@ -5428,6 +5591,21 @@ impl WorkspaceApi { crate::store::validate_worker_singleton_key(singleton_key)?; } self.validate_worker_spawn_repository_scope(runtime_id, &mut request)?; + if let Some(grant) = request.resolved_execution_grant.as_ref() { + for claim in &mut request.resolved_workdir_attachments { + claim.capabilities = workdir_authorization::intersect_capabilities( + &grant.permissions, + &claim.working_directory_id, + claim.capabilities, + ); + if claim.capabilities == workdir::WorkdirSessionCapabilities::EMPTY { + return Err(Error::WorkspacePermissionDenied( + "attachment has no operation within the delegated execution grant".into(), + ) + .into()); + } + } + } let workspace_api = self.workspace_api_ref(runtime_id); request.resolved_workspace_api = Some(workspace_api.clone()); for access in take_new_workdir_repository_access(&mut request)? { @@ -5527,6 +5705,41 @@ impl WorkspaceApi { }; drop(subject_guard); let worker_id = reservation.worker_id; + if let Some(grant) = request.resolved_execution_grant.as_ref() { + subject_authorization::bind_reserved_grant(self, grant, &worker_id.to_string())?; + } + // A completed exact-source allocation is historical acceptance, not a new + // launch. Source/grant authorization and the full fingerprint were checked + // above. Do not reserve its already-linked attachments again, redispatch + // initial input, or compensate an existing Worker on a replay failure. + let reserved_worker = RuntimeWorkerRef::new(runtime_id, worker_id.to_string()); + if request.resolved_control_operation.is_some() + && !self + .store + .has_active_worker_create_reservation(&self.config.workspace_id, &reserved_worker)? + { + let record = self + .store + .get_worker_registry(&self.config.workspace_id, &reserved_worker)? + .ok_or_else(|| { + Error::Store("completed Worker allocation has no registry evidence".into()) + })?; + let summary = self + .runtime + .worker(&reserved_worker) + .unwrap_or_else(|_| worker_summary_from_registry(&record)); + return Ok(WorkerSpawnResult { + state: InternalWorkerOperationState::Accepted, + worker: Some(summary), + acceptance_evidence: vec![crate::hosts::WorkerSpawnAcceptanceEvidence { + kind: "saved_creation".into(), + detail: + "Exact allocation was previously accepted; no new execution was requested" + .into(), + }], + diagnostics: Vec::new(), + }); + } if let Some(assignment) = request.ticket_assignment.as_ref() { self.store.bind_ticket_assignment_operation_worker( &self.config.workspace_id, @@ -6856,6 +7069,7 @@ async fn attach_server_request_context( } fn generated_auth_contract_router(service: ServerApiContractService) -> Router { + let auth = auth_public_config(&service.auth_api().config); let service = Arc::new(service); let origin_context = Router::new() .merge(server_api::server_api_axum::auth_activation_options( @@ -6887,7 +7101,6 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { .merge(server_api::server_api_axum::auth_device_login_approve( service.clone(), )) - .merge(server_api::server_api_axum::auth_whoami(service.clone())) .layer(middleware::from_fn_with_state( service.as_ref().clone(), attach_server_request_context, @@ -6908,12 +7121,20 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { service.clone(), )) .merge(server_api::server_api_axum::auth_logout(service.clone())) + .merge( + server_api::server_api_axum::auth_whoami(service.clone()) + .layer(middleware::from_fn(attach_server_origin_context)), + ) .merge(server_api::server_api_axum::auth_device_login_start( service.clone(), )) .merge(server_api::server_api_axum::auth_device_login_poll(service)) .merge(origin_context) .merge(actor_context) + .layer(middleware::from_fn_with_state( + auth, + append_session_marker_cookie, + )) .layer(middleware::map_response( |mut response: Response| async move { // Current role/policy and authentication responses must not be cached @@ -6926,6 +7147,62 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { )) } +// The typed auth operations expose one Set-Cookie. At the HTTP boundary, pair only a real +// session Set-Cookie with a non-authenticating expiry marker, including login and logout. +// Mirroring its attributes keeps Secure/Path/Domain/SameSite aligned without a second policy. +async fn append_session_marker_cookie( + State(auth): State, + request: Request, + next: Next, +) -> Response { + let mut response = next.run(request).await; + let marker = response + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .find_map(|value| { + let value = value.to_str().ok()?; + let (pair, attributes) = value.split_once(';')?; + let (name, token) = pair.split_once('=')?; + if name != auth.cookie_name { + return None; + } + let max_age = attributes + .split(';') + .find_map(|attr| attr.trim().strip_prefix("Max-Age=")?.parse::().ok())?; + let (hash, marker_age) = if max_age <= 0 { + (String::new(), 0) + } else { + ( + token_hash(token), + max_age + crate::auth::SESSION_EXPIRY_GRACE_SECONDS, + ) + }; + let attributes = attributes + .split(';') + .map(|attr| { + if attr.trim().starts_with("Max-Age=") { + format!(" Max-Age={marker_age}") + } else { + attr.to_owned() + } + }) + .collect::>() + .join(";"); + HeaderValue::from_str(&format!( + "{}={hash};{attributes}", + crate::auth::session_marker_cookie_name(name) + )) + .ok() + }); + if let Some(marker) = marker { + response + .headers_mut() + .append(axum::http::header::SET_COOKIE, marker); + } + response +} + fn generated_workspace_catalog_contract_router(service: ServerApiContractService) -> Router { let service = Arc::new(service); Router::new() @@ -7034,6 +7311,11 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::memory_consolidation( service.clone(), )) + .merge(server_api::server_api_axum::operation_authorize( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_subject_permissions_get(service.clone())) + .merge(server_api::server_api_axum::subjektiv_subject_permissions_set(service.clone())) .merge(server_api::server_api_axum::subjektiv_subject_create( service.clone(), )) @@ -7616,6 +7898,7 @@ fn worker_spawn_request_from_api( resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, }) } @@ -8151,9 +8434,15 @@ impl server_api::ServerApi for ServerApiContractService { &self, context: server_api::ServerRequestContext, ) -> std::result::Result { - Ok(WhoamiResponse { - actor: context.actor, - }) + let headers = contract_request_headers(&context)?; + let api = self.auth_api(); + crate::auth::resolve_whoami( + api.store.clone(), + &headers, + &auth_public_config(&api.config).cookie_name, + ) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error()) } async fn auth_logout( @@ -8985,6 +9274,114 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn operation_authorize( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::authorization::OperationAuthorizationRequest, + ) -> std::result::Result< + server_api::authorization::OperationAuthorizationResponse, + server_api::RepositoryApiError, + > { + let api = self.workspace_api()?.clone(); + // Runtime startup/restore can hold Host while asking this callback. The + // authority query uses only current stores, never Runtime or Host reentry. + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &workspace_id)?; + let worker = subject_authorization::source_from_context(&context)?; + let scope = subject_authorization::authorize_scope(&api, &worker, &request)?; + Ok(server_api::authorization::OperationAuthorizationResponse { scope }) + }) + .await + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_subject_permissions_get( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + subject_id: String, + ) -> std::result::Result + { + 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)?; + api.store + .db_executor() + .run(move || -> ApiResult<_> { + Ok(open_subjektiv_store(&api)? + .subject_permissions(&subject_id) + .map(|permissions| server_api::SubjectPermissionsResponse { permissions }) + .map_err(subjektiv_store_error)?) + }) + .await + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_subject_permissions_set( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + subject_id: String, + request: server_api::authorization::SubjectPermissionsUpdate, + ) -> std::result::Result< + server_api::SubjectPermissionsMutationResponse, + server_api::RepositoryApiError, + > { + 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)?; + let mutation_api = api.clone(); + let mutation_subject = subject_id.clone(); + let (permissions, changed) = api + .store + .db_executor() + .run(move || -> ApiResult<_> { + let store = open_subjektiv_store(&mutation_api)?; + let before = store + .subject_permissions(&mutation_subject) + .map_err(subjektiv_store_error)?; + 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) + .map_err(subjektiv_store_error)?; + let changed = was_current + && before.as_ref().map(|source| &source.change_id) + != Some(&permissions.change_id); + Ok((permissions, changed)) + }) + .await + .map_err(ApiError::into_repository_api_error)?; + let interruptions = if changed { + subject_authorization::interrupt_subject_workers(&api, &subject_id).await + } else { + Vec::new() + }; + Ok(server_api::SubjectPermissionsMutationResponse { + permissions, + interruptions, + }) + } + async fn subjektiv_subject_create( &self, workspace_id: String, @@ -12534,6 +12931,14 @@ impl server_api::ServerApi for ServerApiContractService { let api = self.workspace_api().map_err(|_| drive::failure(404))?; drive::root(api, &context, &workspace_id).await } + async fn drive_capabilities( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::capabilities(api, &context, &workspace_id).await + } async fn drive_metadata( &self, context: server_api::ServerRequestContext, @@ -16813,6 +17218,18 @@ fn execute_ticket_rest_operation_on_host( let (result, created_ticket) = match operation { TicketBackendOperation::Create { input } => { + if let Some(source) = source.as_ref() { + subject_authorization::authorize( + api, + &RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id), + &server_api::authorization::OperationAuthorizationRequest { + operation: "ticket.create".into(), + resource: server_api::authorization::PermissionResource::Workspace { + workspace_id: workspace_id.into(), + }, + }, + )?; + } let (reference, ticket) = backend.create_with_snapshot(input).map_err(Error::from)?; ( TicketBackendOperationResult::TicketRef(reference), @@ -20802,6 +21219,11 @@ async fn scoped_execute_current_worker_workdir_operation( context.runtime_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.runtime_id)); context.worker_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.worker_id)); let target_workdir = request.target_workdir; + context.stage = "attachment_validation"; + let permission_link = validated_current_worker_attachment_async(&api, &worker, &target_workdir, None).await?; + context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&permission_link.workdir_id)); + context.stage = "operation_authorization"; + workdir_authorization::authorize(&api, &worker, &permission_link, &request.operation)?; let result = match request.operation { WorkdirSessionOperation::CommandStart(command) => { let session_lock = current_worker_session_lock(&api, &worker); @@ -20811,6 +21233,9 @@ async fn scoped_execute_current_worker_workdir_operation( &api, &worker, &target_workdir, Some(&_session_guard), ).await?; context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id)); + if api.workdir_sessions.lock().unwrap().active_command_count(&worker) >= MAX_ACTIVE_SERVER_COMMANDS { + return Err(InternalWorkdirOperationError::Provider(workdir_operation_transport_error(workdir::WorkdirError::Unavailable("Worker active command limit reached".to_string())))); + } context.stage = "session_open"; let source = open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await?; context.stage = "provider_dispatch"; @@ -20852,14 +21277,16 @@ async fn scoped_execute_current_worker_workdir_operation( .map_err(workdir_operation_transport_error)? } WorkdirSessionOperation::CommandOutput(mut output) => { + let external_handle = output.handle.clone(); let (session, provider_handle) = - current_worker_command_session(&api, &worker, &target_workdir, &output.handle, &mut context).await?; + current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context).await?; output.handle = provider_handle; - session - .command_output(output) - .await - .map(WorkdirSessionOperationResult::CommandOutput) - .map_err(workdir_operation_transport_error)? + let page = session.command_output(output).await.map_err(workdir_operation_transport_error)?; + if page.terminal.is_some() { + // Retain the binding for a retry after an unknown HTTP outcome. + api.workdir_sessions.lock().unwrap().mark_terminal(&worker, &external_handle); + } + WorkdirSessionOperationResult::CommandOutput(page) } WorkdirSessionOperation::CommandCancel(external_handle) => { let (session, provider_handle) = @@ -20893,6 +21320,7 @@ async fn scoped_execute_current_worker_workdir_operation( context.stage = "session_open"; let source = open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await?; context.stage = "provider_dispatch"; + let source = worker::authorized_workdir::authorized_workdir_session(source, subject_authorization::bind_operation_client(&api, &worker), link.workdir_id.clone()); execute_workdir_session_operation(&source, operation) .await .map_err(workdir_operation_transport_error)? @@ -23879,6 +24307,14 @@ async fn scoped_subjektiv_memory_backend( fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { match error { + crate::subjektiv::SubjektivError::AuthorizationDenied(_) => { + Error::WorkspacePermissionDenied(error.to_string()) + } + crate::subjektiv::SubjektivError::SubjectPermissionsConflict(_) + | crate::subjektiv::SubjektivError::PermissionOperationConflict(_) + | crate::subjektiv::SubjektivError::WorkerExecutionGrantConflict(_) => { + Error::RepositoryConflict(error.to_string()) + } crate::subjektiv::SubjektivError::SubjectScopeMismatch { .. } => { Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}")) } @@ -24131,6 +24567,7 @@ fn start_memory_staging_consolidation( resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, )?; if result.state != InternalWorkerOperationState::Accepted { @@ -25504,6 +25941,38 @@ fn authorize_known_worker_permission( subject: &RuntimeWorkerRef, permission: &str, ) -> Result { + // Child result return is deliberately narrower than control or public chat + // posting. The execution grant names only this origin Worker and only notify. + if permission == "notify" { + if let Some(permissions) = subject_authorization::worker_permissions(api, controller) + .map_err(|error| error.error)? + { + let requested = server_api::authorization::ScopedPermission { + operation: "worker.result".into(), + resource: server_api::authorization::PermissionResource::Worker { + runtime_id: subject.runtime_id.clone(), + worker_id: subject.worker_id.clone(), + }, + }; + if permissions.iter().any(|granted| granted.covers(&requested)) { + return Ok(WorkerControlGrantRecord { + workspace_id: workspace_id.into(), + grant_id: format!( + "execution-result:{}/{}", + controller.runtime_id, controller.worker_id + ), + controller: controller.clone(), + subject: subject.clone(), + relation: "result_return".into(), + origin: "execution_grant".into(), + permissions: vec!["notify".into()], + operation_id: "execution-result".into(), + created_at: String::new(), + revoked_at: None, + }); + } + } + } let grant = api .store .get_active_worker_control_grant(workspace_id, controller, subject)? @@ -25891,6 +26360,7 @@ async fn scoped_start_workspace_orchestrator( resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, )?; if result.state != InternalWorkerOperationState::Accepted || result.worker.is_none() { @@ -26838,7 +27308,12 @@ async fn serve_external_workdir_provider( })); continue; } - if pending.len() >= 16 { + let pending_limit = if matches!(&operation, WorkdirSessionOperation::CommandCancel(_)) { + 16 + MAX_ACTIVE_SERVER_COMMANDS + } else { + 16 + }; + if pending.len() >= pending_limit { let _ = response.send(Err(WorkdirTransportError { denial_reason: None, code: workdir::http::WorkdirTransportErrorCode::Unavailable, @@ -30307,14 +30782,14 @@ fn issue_browser_session( token_hash: token_hash(&session_token), user_id: user.user_id.clone(), created_at: crate::auth::now_rfc3339(), - expires_at: rfc3339_after(Duration::days(14)), + expires_at: rfc3339_after(Duration::seconds(crate::auth::BROWSER_SESSION_SECONDS)), revoked_at: None, })?; let auth = auth_public_config(&api.config); let cookie = session_set_cookie( session_cookie_policy(&auth), &session_token, - 14 * 24 * 60 * 60, + crate::auth::BROWSER_SESSION_SECONDS, ); Ok(( AuthUserResponse { @@ -30510,6 +30985,16 @@ fn logout_browser_session( .store .revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?; } + // A missing auth cookie after Max-Age must not prevent explicit logout from revoking + // the recovery reference. This hash is never accepted as an authentication credential. + if let Some(hash) = parse_cookie( + &headers, + &crate::auth::session_marker_cookie_name(&auth.cookie_name), + ) { + let _ = api + .store + .revoke_browser_session(&hash, &crate::auth::now_rfc3339())?; + } Ok(( LogoutResponse { status: LogoutStatus::LoggedOut, @@ -31761,6 +32246,7 @@ async fn create_workspace_worker_authenticated_on_host( initial_submit, workdir_attachments, feature_connections, + execution_grant, control_operation_id: _, } = request; let delegation = ticket_assignment @@ -31820,6 +32306,47 @@ async fn create_workspace_worker_authenticated_on_host( &profile, prompt_catalog.as_ref(), )?; + let resolved_execution_grant = if let Some(source) = source.as_ref() { + let resolved = resolved_config_bundle + .as_ref() + .ok_or_else(|| Error::Config("resolved Worker Profile bundle is missing".into()))?; + let profile_digest = format!( + "sha256:{}", + Sha256::digest( + serde_json::to_vec(resolved).map_err(|error| Error::Config(error.to_string()))? + ) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + subject_authorization::prepare_delegation( + &api, + &RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id), + execution_grant.as_ref(), + &runtime_id, + &profile, + &profile_digest, + singleton_key.as_deref(), + feature_connections.subjektiv.is_some(), + )? + } else { + if execution_grant.is_some() { + return Err(Error::WorkspacePermissionDenied( + "execution grants require an authenticated delegation origin".into(), + ) + .into()); + } + None + }; + // Generic creation has no authenticated-origin operation reservation or + // control-grant recovery. A supplied ID alone must not make that path a + // delegation API; require the normal worker-control lifecycle instead. + if resolved_execution_grant.is_some() && resolved_control_operation.is_none() { + return Err(Error::WorkspacePermissionDenied( + "Subject delegation requires worker-control creation with control_operation_id; generic Worker creation is not supported".into(), + ) + .into()); + } let resolved_subjektiv_attached = match feature_connections.subjektiv { None => { validate_caller_worker_singleton_key(singleton_key.as_deref())?; @@ -31899,6 +32426,7 @@ async fn create_workspace_worker_authenticated_on_host( resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation, + resolved_execution_grant, resolved_workspace_api: None, resolved_memory_settings: None, resolved_subjektiv_attached, @@ -37009,6 +37537,7 @@ mod tests { mod api_logging_tests; mod auth_logging_tests; + mod command_transport_tests; mod drive_api_tests; mod drive_tests; mod drive_web_roundtrip_tests; @@ -37016,6 +37545,7 @@ mod tests { mod external_checkout_tests; mod protocol_backpressure_tests; mod restored_parent_control_tests; + mod subject_authorization_tests; mod subject_spawn_tests; mod subjektiv_jobs_tests; mod subjektiv_journal_tests; @@ -37358,9 +37888,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "touch forbidden".to_string(), timeout_secs: 1, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await; @@ -37509,9 +38038,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf forbidden".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await; @@ -37563,9 +38091,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf routed".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: Some("/worker/private/spill".into()), tool_call_id: Some("call-1".to_string()), }) .await @@ -37581,10 +38108,7 @@ mod tests { }; assert!(matches!( operation, - WorkdirSessionOperation::CommandStart(workdir::CommandRequest { - spill_dir: None, - .. - }) + WorkdirSessionOperation::CommandStart(workdir::CommandRequest { .. }) )); let provider_handle = workdir::CommandHandle("command-1".to_string()); response @@ -37637,13 +38161,9 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Running, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: None, }, ))) .unwrap(); @@ -37716,13 +38236,22 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Cancelled, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: Some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + + stdout_bytes: 0, + + stderr_bytes: 0, + + stdout_delivered_bytes: 0, + + stderr_delivered_bytes: 0, + + truncation_reason: None, + }), }, ))) .unwrap(); @@ -37751,9 +38280,8 @@ mod tests { let request = || workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }; let provider_handle = workdir::CommandHandle("command-reused".to_string()); @@ -37810,13 +38338,22 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Cancelled, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: Some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + + stdout_bytes: 0, + + stderr_bytes: 0, + + stdout_delivered_bytes: 0, + + stderr_delivered_bytes: 0, + + truncation_reason: None, + }), }, ))) .unwrap(); @@ -37867,9 +38404,8 @@ mod tests { let request = || workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }; let provider_handle = workdir::CommandHandle("command-1".to_string()); @@ -38103,6 +38639,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -38691,6 +39228,7 @@ mod tests { ], feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -38766,9 +39304,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf forbidden".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await; @@ -38789,9 +39326,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf attached".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await @@ -38894,13 +39430,22 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Cancelled, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: Some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + + stdout_bytes: 0, + + stderr_bytes: 0, + + stdout_delivered_bytes: 0, + + stderr_delivered_bytes: 0, + + truncation_reason: None, + }), }, ))) .unwrap(); @@ -38930,6 +39475,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -38985,11 +39531,9 @@ mod tests { }; let (mut old_provider, _) = connect_async(&url).await.unwrap(); - let old_registration = serde_json::to_string(®ister(1)).unwrap().replacen( - r#""version":2"#, - r#""version":1"#, - 1, - ); + let mut old_registration = serde_json::to_value(register(1)).unwrap(); + old_registration["version"] = serde_json::json!(1); + let old_registration = serde_json::to_string(&old_registration).unwrap(); old_provider .send(Message::Text(old_registration.into())) .await @@ -39003,7 +39547,10 @@ mod tests { .reason .contains("unsupported External Workdir protocol version 1") ); - assert!(frame.reason.contains("requires 2")); + assert!(frame.reason.contains(&format!( + "requires {}", + workdir::external::EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION + ))); let (mut smaller_provider, _) = connect_async(&url).await.unwrap(); let mut smaller_registration = serde_json::to_value(register(1)).unwrap(); @@ -39244,37 +39791,106 @@ mod tests { fs::read_to_string(provider_root.path().join("created.txt")).unwrap(), "created" ); + let binary_handle = broker_session + .start_command(workdir::CommandRequest { + command: "head -c 196609 /dev/zero".to_string(), + timeout_secs: 30, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, + cwd: workdir::WorkdirPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let (terminal_request, terminal_page) = + tokio::time::timeout(std::time::Duration::from_secs(5), async { + let mut cursor = 0; + let mut bytes = Vec::new(); + loop { + let request = workdir::CommandOutputRequest { + handle: binary_handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }; + let page = broker_session + .command_output(request.clone()) + .await + .unwrap(); + for chunk in &page.chunks { + assert_eq!(chunk.stream, workdir::CommandStream::Stdout); + assert_eq!(chunk.sequence, bytes.len() as u64); + assert_eq!(chunk.offset, bytes.len() as u64); + bytes.extend_from_slice(&chunk.bytes); + } + assert!( + page.chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + <= workdir::MAX_COMMAND_READ_BYTES + ); + cursor = page.next_cursor; + if let Some(terminal) = &page.terminal { + assert_eq!(terminal.status, workdir::CommandStatus::Completed); + assert_eq!(terminal.exit_code, Some(0)); + assert_eq!(bytes, vec![0; 196609]); + break (request, page); + } + } + }) + .await + .expect("incremental provider output did not terminate"); + assert_eq!( + broker_session + .command_output(terminal_request.clone()) + .await + .unwrap(), + terminal_page + ); + for cursor in [0, terminal_page.next_cursor + 1] { + assert!( + broker_session + .command_output(workdir::CommandOutputRequest { + cursor, + ..terminal_request.clone() + }) + .await + .is_err() + ); + } + let command_handle = broker_session .start_command(workdir::CommandRequest { - command: "sleep 30 & child=$!; echo $child > child.pid; wait $child".to_string(), + command: "sleep 30 & child=$!; echo $child > child.pid; printf ready; wait $child" + .to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: Some("/worker/private/spill".into()), tool_call_id: Some("call-e2e".to_string()), }) .await .unwrap(); - for _ in 0..100 { - if provider_root.path().join("child.pid").is_file() { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - assert!(provider_root.path().join("child.pid").is_file()); - let command_output = broker_session .command_output(workdir::CommandOutputRequest { handle: command_handle, cursor: 0, limit: 1024, - wait: false, + wait: true, }) .await .unwrap(); - assert_eq!(command_output.status, workdir::CommandStatus::Running); - assert_eq!(command_output.output_path, None); + assert!(command_output.terminal.is_none()); + + assert_eq!( + command_output + .chunks + .iter() + .flat_map(|chunk| chunk.bytes.clone()) + .collect::>(), + b"ready" + ); + assert!(provider_root.path().join("child.pid").is_file()); let Json(revoked) = scoped_revoke_external_workdir_grant( State(api.clone()), @@ -40477,9 +41093,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf ready; sleep 30".to_string(), timeout_secs: 60, - output_limit: 4096, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: Some("tool-call-command-session".to_string()), }) .await @@ -40526,7 +41141,7 @@ mod tests { }) .await .unwrap(); - assert!(matches!(output.status, workdir::CommandStatus::Running)); + assert!(output.terminal.is_none()); assert!(matches!( command .source @@ -40538,14 +41153,21 @@ mod tests { let waiting_source = command.source.clone(); let waiting_handle = command.provider_handle.clone(); let waiting = tokio::spawn(async move { - waiting_source - .command_output(workdir::CommandOutputRequest { - handle: waiting_handle, - cursor: output.next_cursor.unwrap_or(0), - limit: 4096, - wait: true, - }) - .await + let mut cursor = output.next_cursor; + loop { + let page = waiting_source + .command_output(workdir::CommandOutputRequest { + handle: waiting_handle.clone(), + cursor, + limit: 4096, + wait: true, + }) + .await?; + cursor = page.next_cursor; + if page.terminal.is_some() { + return Ok::<_, workdir::WorkdirError>(page); + } + } }); tokio::task::yield_now().await; command @@ -40558,7 +41180,7 @@ mod tests { .expect("cancel should unblock a waiting output read") .unwrap() .unwrap(); - assert!(!matches!(terminal.status, workdir::CommandStatus::Running)); + assert!(terminal.terminal.is_some()); let registered = registry.take_worker(&worker); assert_eq!(registered.len(), 1); @@ -40591,9 +41213,8 @@ mod tests { let request = || workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 4096, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }; let left_provider = left.start_command(request()).await.unwrap(); @@ -42187,6 +42808,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; assert!( api.validate_worker_spawn_repository_scope("runtime-1", &mut workdir_flow_launch) @@ -42446,6 +43068,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; assert!( @@ -42540,6 +43163,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await; @@ -42575,6 +43199,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -42611,6 +43236,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -42702,6 +43328,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -42768,7 +43395,9 @@ mod tests { async fn production_profile_backend_restores_zero_input_orchestrator() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); - let config = test_server_config(workspace.path()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut config = test_server_config(workspace.path()); + config.backend_base_url = Some(format!("http://{}", listener.local_addr().unwrap())); let store = Arc::new(SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap()); seed_test_registered_workspace(store.as_ref(), &config) @@ -42776,6 +43405,8 @@ mod tests { .unwrap(); let api = WorkspaceApi::new_for_test(config, store).await.unwrap(); let workspace_id = api.config.workspace_id.clone(); + let _callback_server = + AuthorizationCallbackServer::start(listener, build_router(api.clone())); let result = scoped_start_workspace_orchestrator( State(api.clone()), @@ -42962,6 +43593,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -43457,6 +44089,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -43758,6 +44391,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -43791,6 +44425,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: Some("control-spawn-retry".to_string()), + execution_grant: None, }; let Json(first) = spawn_known_worker( @@ -43867,6 +44502,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }; let Json(created) = @@ -44031,6 +44667,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -44108,6 +44745,7 @@ mod tests { content: vec![LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId(id.to_string()), origin: LoggedSessionHistoryOrigin::HumanInput { account_id: "account-1".to_string(), @@ -44130,6 +44768,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }; let Json(controller) = create_workspace_worker( State(api.clone()), @@ -44297,6 +44936,7 @@ mod tests { content: vec![LoggedContentPart::Text { text: text.into() }], }, metadata: LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: LoggedSessionHistoryEntryId(id.to_string()), origin: LoggedSessionHistoryOrigin::HumanInput { account_id: "account-1".to_string(), @@ -44344,6 +44984,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -45366,6 +46007,7 @@ mod tests { }), }, control_operation_id: None, + execution_grant: None, } }; @@ -45498,6 +46140,7 @@ mod tests { }), }, control_operation_id: None, + execution_grant: None, }; let Json(created) = scoped_create_workspace_worker( State(api.clone()), @@ -46685,6 +47328,7 @@ mod tests { resolved_workspace_api: None, resolved_memory_settings: None, resolved_subjektiv_attached: false, + resolved_execution_grant: None, resolved_control_operation: Some(WorkerControlOperation { operation_id: operation_id.to_string(), input_fingerprint: format!("sha256:{operation_id}"), @@ -46928,6 +47572,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -46948,6 +47593,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -46990,6 +47636,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -47033,6 +47680,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -48518,6 +49166,22 @@ mod tests { ); } + struct AuthorizationCallbackServer(tokio::task::JoinHandle<()>); + + impl AuthorizationCallbackServer { + fn start(listener: tokio::net::TcpListener, router: Router) -> Self { + Self(tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + })) + } + } + + impl Drop for AuthorizationCallbackServer { + fn drop(&mut self) { + self.0.abort(); + } + } + fn test_server_config(workspace_root: impl Into) -> ServerConfig { let workspace_root = workspace_root.into(); let store_root = workspace_root.join(".test-embedded-runtime-store"); @@ -48548,21 +49212,25 @@ mod tests { let worker_root = root .join(".test-embedded-runtime-store/workers") .join(&worker.worker_id); - fs::create_dir_all(worker_root.join("session/segments")).unwrap(); - fs::write( - worker_root.join("session/session.json"), - serde_json::to_vec_pretty(&json!({ - "schema_version": 1, - "session_id": session_store::new_session_id() - })) - .unwrap(), - ) - .unwrap(); - fs::write( - worker_root.join("session/segments/segment-a.jsonl"), - b"backend job retained session evidence\n", - ) - .unwrap(); + use session_store::Store; + let store = session_store::WorkerSessionStore::new(worker_root.join("session")).unwrap(); + let session = session_store::new_session_id(); + let segment = session_store::new_segment_id(); + store + .create_segment( + session, + segment, + &[session_store::LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id: session, + system_prompt: Some("backend job retained session evidence".into()), + config: Default::default(), + history: vec![], + forked_from: None, + compacted_from: None, + }], + ) + .unwrap(); } fn job_notification_evidence( @@ -48597,6 +49265,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -50014,6 +50683,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -50697,13 +51367,16 @@ mod tests { #[tokio::test] async fn repository_free_workspace_can_be_created_selected_and_used_then_register_repository() { let temp = tempfile::tempdir().unwrap(); - let config = test_server_config(temp.path()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut config = test_server_config(temp.path()); + config.backend_base_url = Some(format!("http://{}", listener.local_addr().unwrap())); let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); let owner = seed_test_api_token(store.as_ref(), "empty-owner"); let foreign = seed_test_api_token(store.as_ref(), "empty-foreign"); let app = build_workspace_server_router(config.clone(), store.clone()) .await .unwrap(); + let _callback_server = AuthorizationCallbackServer::start(listener, app.clone()); let wire = json!({"operation_key": "create-empty-workspace", "display_name": "Empty Workspace"}); request_json( @@ -53509,6 +54182,7 @@ mod tests { .await .unwrap(); assert_eq!(registration_response.status(), StatusCode::OK); + assert_auth_response_has_expiry_marker(registration_response.headers(), &cookie_name); let registration_cookie = registration_response .headers() .get(axum::http::header::SET_COOKIE) @@ -53546,6 +54220,12 @@ mod tests { .unwrap(), ) .unwrap(); + assert_eq!(registered_whoami["session"]["kind"], "authenticated"); + assert!( + registered_whoami["session"]["expires_at"] + .as_str() + .is_some() + ); assert_eq!( registered_whoami["actor"]["handle"], "generated-passkey-user" @@ -53600,6 +54280,7 @@ mod tests { .await .unwrap(); assert_eq!(login_response.status(), StatusCode::OK); + assert_auth_response_has_expiry_marker(login_response.headers(), &cookie_name); let login_cookie = login_response .headers() .get(axum::http::header::SET_COOKIE) @@ -53684,6 +54365,169 @@ mod tests { assert!(logged_out_whoami["actor"].is_null()); } + // Real generated auth routes, DB executor and SQLite; no auth resolution is mocked. + #[tokio::test] + async fn whoami_expiry_marker_survives_auth_cookie_loss_but_logout_revokes_it() { + let workspace = tempfile::tempdir().unwrap(); + let api = test_api(workspace.path()).await; + seed_test_api_token(api.store.as_ref(), "expiry-router"); + let user = api.store.get_user("user-expiry-router").unwrap().unwrap(); + let auth = auth_public_config(&api.config); + let cookie_name = auth.cookie_name; + let hash = token_hash("expired-router-secret"); + api.store + .create_browser_session(&BrowserSessionRecord { + session_id: "expired-router-session".into(), + token_hash: hash.clone(), + user_id: user.user_id, + created_at: crate::auth::now_rfc3339(), + expires_at: rfc3339_after(Duration::minutes(-1)), + revoked_at: None, + }) + .unwrap(); + let marker = format!( + "{}={hash}", + crate::auth::session_marker_cookie_name(&cookie_name) + ); + let app = generated_auth_contract_router(ServerApiContractService::Workspace(api.clone())); + for (cookie, kind) in [ + (String::new(), "anonymous"), + (format!("{cookie_name}=expired-router-secret"), "expired"), + (marker.clone(), "expired"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); + assert!( + !response + .headers() + .contains_key(axum::http::header::SET_COOKIE) + ); + let value: Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(value["actor"], Value::Null); + assert_eq!(value["session"]["kind"], kind); + } + let denied = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/addition/options") + .header(axum::http::header::COOKIE, &marker) + .header(ORIGIN, &auth.origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + // Preserve the operation's existing auth_required/403 contract; no HTML redirect. + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + assert!(!denied.headers().contains_key(axum::http::header::LOCATION)); + let logout = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/logout") + .header(axum::http::header::COOKIE, &marker) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logout.status(), StatusCode::OK); + let cleared: Vec<_> = logout + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + assert_eq!(cleared.len(), 2); + assert!( + cleared + .iter() + .all(|value| value.contains("Max-Age=0") && value.contains("HttpOnly")) + ); + assert!(cleared.iter().any(|value| value.starts_with(&format!( + "{}=;", + crate::auth::session_marker_cookie_name(&cookie_name) + )))); + assert!(api.store.resolve_browser_session(&hash).unwrap().is_none()); + let response = app + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, marker) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let value: Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(value["session"]["kind"], "anonymous"); + } + + fn assert_auth_response_has_expiry_marker(headers: &HeaderMap, cookie_name: &str) { + let cookies: Vec<_> = headers + .get_all(axum::http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().unwrap()) + .collect(); + let auth = cookies + .iter() + .find(|value| value.starts_with(&format!("{cookie_name}="))) + .unwrap(); + let (_, token) = auth.split(';').next().unwrap().split_once('=').unwrap(); + let marker = cookies + .iter() + .find(|value| { + value.starts_with(&format!( + "{}=", + crate::auth::session_marker_cookie_name(cookie_name) + )) + }) + .unwrap(); + let expected = token_hash(token); + assert_eq!( + parse_cookie( + &{ + let mut headers = HeaderMap::new(); + headers.insert(axum::http::header::COOKIE, marker.parse().unwrap()); + headers + }, + &crate::auth::session_marker_cookie_name(cookie_name) + ), + Some(expected) + ); + assert!(auth.contains(&format!("Max-Age={}", crate::auth::BROWSER_SESSION_SECONDS))); + assert!(marker.contains(&format!( + "Max-Age={}", + crate::auth::BROWSER_SESSION_SECONDS + crate::auth::SESSION_EXPIRY_GRACE_SECONDS + ))); + for attribute in ["Path=/", "HttpOnly", "SameSite=Lax", "Secure"] { + assert_eq!( + auth.contains(attribute), + marker.contains(attribute), + "{attribute}" + ); + } + } + #[tokio::test] async fn browser_session_set_and_clear_cookies_follow_public_https_scheme() { for (scheme, secure) in [("http", false), ("https", true)] { @@ -54053,6 +54897,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -54449,6 +55294,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -54666,6 +55512,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -55128,6 +55975,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -55820,6 +56668,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -55923,6 +56772,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; let source_worker = api .runtime @@ -56478,6 +57328,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -56546,6 +57397,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -58278,6 +59130,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -58359,6 +59212,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -58499,6 +59353,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -60061,9 +60916,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 4096, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await @@ -61693,6 +62547,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -64368,6 +65223,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }, None, ) @@ -65153,6 +66009,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .expect("spawn worker"); @@ -66604,6 +67461,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; let spawned = api .spawn_workspace_worker(EMBEDDED_WORKER_RUNTIME_ID, spawn_request) @@ -67471,6 +68329,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; let dedicated_key = "subjektiv-consolidation:dedicated-subject"; diff --git a/crates/workspace-server/src/server/subject_authorization.rs b/crates/workspace-server/src/server/subject_authorization.rs new file mode 100644 index 000000000..0181161eb --- /dev/null +++ b/crates/workspace-server/src/server/subject_authorization.rs @@ -0,0 +1,489 @@ +//! Host binding for cooperative Feature authorization. Profile configuration requests +//! capabilities; it never issues them. Non-Subject Workers retain their existing +//! Backend/provider contracts. Subject bodies with no assignment deny by default. +//! +//! Bindings here are execution grants, not another Worker registry. Current body +//! ownership remains the singleton store. Replacing a permission assignment fences +//! all children issued from it; restoring a Worker cannot refresh that assignment. +//! Checks refuse new effects; they do not undo effects already committed. Trusted +//! Features must call the operation boundary themselves, including alternate surfaces. +//! +//! Subject delegation uses the normal Worker control lifecycle, which binds stable +//! operation identity to the authenticated origin and recovers control-grant writes. +//! Generic Worker creation rejects Subject delegation before reservation, even if +//! the payload supplies an operation ID; its manual allocations are not replay-safe. + +use super::*; +use server_api::authorization::{ + OperationAuthorizationRequest, PermissionResource, ScopedPermission, SubjectPermissions, + WorkerDelegationRequest, WorkerExecutionGrant, +}; +use std::collections::BTreeSet; + +fn denied(message: impl Into) -> ApiError { + Error::WorkspacePermissionDenied(message.into()).into() +} + +fn policy(api: &WorkspaceApi, subject_id: &str) -> ApiResult> { + let store = open_subjektiv_store(api)?; + let subject = store + .subject(subject_id) + .map_err(subjektiv_store_error)? + .ok_or_else(|| denied("Subject permission source no longer exists"))?; + if subject.state != crate::subjektiv::SubjectState::Active { + return Err(denied("Subject permission source is retired")); + } + Ok(store + .subject_permissions(subject_id) + .map_err(subjektiv_store_error)?) +} + +fn current_body_subject( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> ApiResult> { + let retained = api + .store + .retained_worker_singleton_lease(&api.config.workspace_id, worker)?; + let Some(retained) = retained else { + return Ok(None); + }; + let Some(subject) = retained.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) else { + return Ok(None); + }; + // This also rejects a retained lease from a released/replaced body. Never + // fall through to non-Subject compatibility after ownership has ended. + let current = api + .store + .require_current_worker_singleton_owner(&api.config.workspace_id, worker)? + .ok_or_else(|| denied("Subject body no longer owns its singleton"))?; + if current.key != retained.key { + return Err(denied("Subject body ownership changed")); + } + Ok(Some(subject.to_owned())) +} + +fn live_child_grant( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> ApiResult> { + let store = open_subjektiv_store(api)?; + let Some(grant) = store + .worker_execution_grant(&worker.runtime_id, &worker.worker_id) + .map_err(subjektiv_store_error)? + else { + return Ok(None); + }; + let source = + policy(api, &grant.subject_id)?.ok_or_else(|| denied("Subject permissions are unset"))?; + if source.change_id != grant.source_change_id { + return Err(denied( + "delegated execution grant was revoked by permission replacement", + )); + } + let mut current = grant.clone(); + let mut visited = HashSet::new(); + for _ in 0..64 { + if !visited.insert((current.runtime_id.clone(), current.worker_id.clone())) { + return Err(denied("cyclic execution grant lineage")); + } + if current.subject_id != grant.subject_id + || current.source_change_id != source.change_id + || current + .permissions + .iter() + .chain(current.redelegation.iter()) + .any(|permission| { + !source + .delegation + .iter() + .any(|ceiling| ceiling.covers(permission)) + }) + { + return Err(denied( + "delegated execution exceeds the current Subject ceiling", + )); + } + let origin = RuntimeWorkerRef::new(¤t.origin_runtime_id, ¤t.origin_worker_id); + match store + .worker_execution_grant(&origin.runtime_id, &origin.worker_id) + .map_err(subjektiv_store_error)? + { + Some(parent) => { + if current + .permissions + .iter() + .chain(current.redelegation.iter()) + .any(|permission| { + !parent + .redelegation + .iter() + .any(|ceiling| ceiling.covers(permission)) + }) + { + return Err(denied( + "execution grant exceeds its immediate parent's redelegation ceiling", + )); + } + current = parent; + } + None => { + if current_body_subject(api, &origin)?.as_deref() != Some(grant.subject_id.as_str()) + { + return Err(denied( + "delegation origin is no longer the current Subject body", + )); + } + return Ok(Some(grant)); + } + } + } + Err(denied( + "execution grant lineage exceeds the bounded delegation depth", + )) +} + +/// None is explicitly the preexisting non-Subject contract, not an all-powerful +/// grant. Existing Workdir/provider/Ticket checks still apply to those Workers. +pub(super) fn worker_permissions( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> ApiResult>> { + if let Some(grant) = live_child_grant(api, worker)? { + return Ok(Some(grant.permissions)); + } + let Some(subject) = current_body_subject(api, worker)? else { + return Ok(None); + }; + Ok(Some( + policy(api, &subject)? + .map(|policy| policy.execution) + .unwrap_or_default(), + )) +} + +pub(super) fn authorize( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + request: &OperationAuthorizationRequest, +) -> ApiResult<()> { + authorize_scope(api, worker, request).map(|_| ()) +} + +pub(super) fn authorize_scope( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + request: &OperationAuthorizationRequest, +) -> ApiResult> { + request.validate().map_err(denied)?; + if let PermissionResource::Workspace { workspace_id } = &request.resource { + if workspace_id != &api.config.workspace_id { + return Err(denied("resource belongs to another Workspace")); + } + } + let needed = ScopedPermission { + operation: request.operation.clone(), + resource: request.resource.clone(), + }; + let Some(permissions) = worker_permissions(api, worker)? else { + return Ok(None); + }; + if let Some(permission) = permissions + .iter() + .find(|permission| permission.covers(&needed)) + { + return Ok(Some(permission.resource.clone())); + } + Err(denied(format!( + "operation `{}` is not granted for the requested resource", + request.operation + ))) +} + +pub(super) fn source_from_context( + context: &server_api::ServerRequestContext, +) -> ApiResult { + let source = context + .runtime_source + .as_ref() + .ok_or_else(|| denied("authorization requires a verified Runtime source"))?; + let worker_id = source + .worker_id + .as_deref() + .ok_or_else(|| denied("authorization requires Runtime-bound Worker identity"))?; + Ok(RuntimeWorkerRef::new(&source.runtime_id, worker_id)) +} + +/// Resolves authority from the authenticated origin, independently of its enabled +/// Feature/tool set. A model cannot nominate a Subject or inherit body identity. +pub(super) fn prepare_delegation( + api: &WorkspaceApi, + source: &RuntimeWorkerRef, + requested: Option<&WorkerDelegationRequest>, + runtime_id: &str, + profile: &str, + profile_digest: &str, + singleton_key: Option<&str>, + subject_connection: bool, +) -> ApiResult> { + let child = live_child_grant(api, source)?; + let body = if child.is_none() { + current_body_subject(api, source)? + } else { + None + }; + if child.is_none() && body.is_none() { + if requested.is_some() { + return Err(denied( + "only an authenticated Subject delegation source can issue an execution grant", + )); + } + return Ok(None); + } + if singleton_key.is_some() || subject_connection { + return Err(denied( + "delegated Workers cannot acquire a Subject body or singleton identity", + )); + } + let requested = requested.ok_or_else(|| { + denied("Subject delegation requires explicit least-authority permissions") + })?; + requested.validate().map_err(denied)?; + let (subject_id, origin_runtime_id, origin_worker_id, source_change_id, ceiling) = match child { + Some(grant) => ( + grant.subject_id, + source.runtime_id.clone(), + source.worker_id.clone(), + grant.source_change_id, + grant.redelegation, + ), + None => { + let subject = body.expect("checked Subject body"); + let source_policy = policy(api, &subject)?.ok_or_else(|| { + denied( + "Subject permissions are unset; an authorized administrator must assign them", + ) + })?; + ( + subject, + source.runtime_id.clone(), + source.worker_id.clone(), + source_policy.change_id, + source_policy.delegation, + ) + } + }; + if requested + .permissions + .iter() + .chain(requested.redelegation.iter()) + .any(|permission| !ceiling.iter().any(|allowed| allowed.covers(permission))) + { + return Err(denied( + "requested delegation exceeds explicit operations, resources or redelegation ceiling", + )); + } + if requested.redelegation.iter().any(|permission| { + !requested + .permissions + .iter() + .any(|allowed| allowed.covers(permission)) + }) { + return Err(denied( + "redelegation must be within this Worker's effective grant", + )); + } + Ok(Some(WorkerExecutionGrant { + subject_id, + origin_runtime_id, + origin_worker_id, + source_change_id, + runtime_id: runtime_id.to_owned(), + worker_id: String::new(), + profile: profile.to_owned(), + profile_digest: profile_digest.to_owned(), + permissions: requested.permissions.clone(), + redelegation: requested.redelegation.clone(), + })) +} + +pub(super) fn bind_reserved_grant( + api: &WorkspaceApi, + grant: &WorkerExecutionGrant, + worker_id: &str, +) -> ApiResult<()> { + let mut bound = grant.clone(); + bound.worker_id = worker_id.to_owned(); + Ok(open_subjektiv_store(api)? + .bind_worker_execution_grant(&bound) + .map_err(subjektiv_store_error)?) +} + +/// Host-owned binding for an already authenticated Backend operation. No model +/// input can choose the captured Worker or turn a shared client into a grant. +#[derive(Clone)] +struct BoundOperationClient { + api: WorkspaceApi, + worker: RuntimeWorkerRef, +} +impl std::fmt::Debug for BoundOperationClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BoundOperationClient") + .field("worker", &self.worker) + .finish_non_exhaustive() + } +} +impl worker::WorkspaceClient for BoundOperationClient { + fn workspace_id(&self) -> Option<&str> { + Some(&self.api.config.workspace_id) + } + fn kind(&self) -> &str { + "backend-bound-operation" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + _: worker::WorkspaceRequest, + ) -> std::result::Result { + Err(worker::WorkspaceClientError::Unavailable( + "this binding provides authorization only".into(), + )) + } + fn authorize_operation( + &self, + operation: &str, + resource: &PermissionResource, + ) -> std::result::Result<(), worker::WorkspaceClientError> { + authorize( + &self.api, + &self.worker, + &OperationAuthorizationRequest { + operation: operation.into(), + resource: resource.clone(), + }, + ) + .map_err(|error| worker::WorkspaceClientError::Request(error.error.to_string())) + } + fn authorize_operation_scope( + &self, + operation: &str, + resource: &PermissionResource, + ) -> std::result::Result, worker::WorkspaceClientError> { + authorize_scope( + &self.api, + &self.worker, + &OperationAuthorizationRequest { + operation: operation.into(), + resource: resource.clone(), + }, + ) + .map_err(|error| worker::WorkspaceClientError::Request(error.error.to_string())) + } +} + +pub(super) fn bind_operation_client( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> Arc { + Arc::new(BoundOperationClient { + api: api.clone(), + worker: worker.clone(), + }) +} + +pub(super) async fn interrupt_subject_workers( + api: &WorkspaceApi, + subject_id: &str, +) -> Vec { + let mut workers = BTreeSet::new(); + let collected = (|| -> ApiResult<()> { + for grant in open_subjektiv_store(api)? + .list_subject_worker_execution_grants(subject_id) + .map_err(subjektiv_store_error)? + { + workers.insert((grant.runtime_id, grant.worker_id)); + } + let key = crate::subjektiv::subject_worker_singleton_key(subject_id) + .map_err(subjektiv_store_error)?; + if let Some(body) = api + .store + .current_worker_singleton_owner(&api.config.workspace_id, &key)? + { + workers.insert((body.worker.runtime_id, body.worker.worker_id)); + } + Ok(()) + })(); + if let Err(error) = collected { + return vec![server_api::SubjectPermissionInterruption { + runtime_id: String::new(), + worker_id: String::new(), + cancellation_accepted: false, + backend_command_sessions_closed: false, + diagnostics: vec![format!( + "permissions committed; interruption targets could not be fully collected: {}", + error.error + )], + }]; + } + let mut results = Vec::new(); + for (runtime_id, worker_id) in workers { + let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id); + let mut evidence = server_api::SubjectPermissionInterruption { + runtime_id, + worker_id, + cancellation_accepted: false, + backend_command_sessions_closed: false, + diagnostics: Vec::new(), + }; + let cancellation_api = api.clone(); + let cancellation_worker = worker.clone(); + let cancellation = match api.host_executors.host() { + Ok(host) => host.run(move || -> ApiResult<_> { + Ok(cancellation_api.runtime.cancel_worker(&cancellation_worker, WorkerLifecycleRequest { + reason: Some("Subject permission assignment changed; new operations are fenced".into()), ticket_assignment: None, + }).map_err(RuntimeRegistryError::into_error)?) + }).await, + Err(error) => Err(error.into()), + }; + match cancellation { + Ok(result) => { + evidence.cancellation_accepted = + result.state == InternalWorkerOperationState::Accepted; + evidence.diagnostics.extend( + result + .diagnostics + .into_iter() + .map(|diagnostic| diagnostic.message), + ); + } + Err(error) => evidence.diagnostics.push(format!( + "Runtime cancellation acceptance unknown or rejected: {}", + error.error + )), + } + match close_worker_workdir_sessions(&api.workdir_sessions, &worker).await { + Ok(()) => evidence.backend_command_sessions_closed = true, + Err(error) => evidence + .diagnostics + .push(format!("Backend command cleanup not confirmed: {error}")), + } + results.push(evidence); + } + results +} + +pub(super) fn administration_actor( + context: &server_api::ServerRequestContext, +) -> ApiResult<&RequestActor> { + if context.runtime_source.is_some() || context.worker_source.is_some() { + return Err(denied( + "Subject permission administration is not a Worker capability", + )); + } + context + .actor + .as_ref() + .ok_or_else(|| denied("Subject permission administration requires an authenticated owner")) +} diff --git a/crates/workspace-server/src/server/tests/api_logging_tests.rs b/crates/workspace-server/src/server/tests/api_logging_tests.rs index 3c9864195..0b2320b17 100644 --- a/crates/workspace-server/src/server/tests/api_logging_tests.rs +++ b/crates/workspace-server/src/server/tests/api_logging_tests.rs @@ -105,6 +105,7 @@ async fn cleanup_retention_failures_preserve_cause_on_execution_and_recovery() { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -279,6 +280,7 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -471,11 +473,10 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() { )); // Seed an already registered command, without launching a real process. let provider_handle = CommandHandle("logging-command".to_string()); - session - .active_commands - .lock() - .await - .insert(provider_handle.0.clone(), provider_handle.clone()); + session.active_commands.lock().await.insert( + provider_handle.0.clone(), + Arc::new(ExternalProviderCommandState::new(provider_handle.clone())), + ); let handle = fixture .api .workdir_sessions @@ -682,13 +683,24 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t let mut fixture = manual_worker_assignment_fixture().await; let root = tempfile::tempdir().unwrap(); let provider_root = root.path().to_path_buf(); + // Use an attachment-relative path so Host resource validation succeeds; + // the provider's narrower scope still owns this read refusal. + let provider_scope = manifest::Scope::writable(&provider_root) + .unwrap() + .with_added_deny_rules([manifest::ScopeRule { + target: provider_root.join("private"), + permission: manifest::Permission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }]) + .unwrap(); // Fresh sessions exercise the real opening/broker/dispatch path on each request. fixture.runtime.workdir_session_factory = Some(Arc::new(move |id| { Ok(Arc::new(workdir::LocalWorkdirSession::materialized_bound( workdir::Workdir::new(id), provider_root.clone(), provider_root.clone(), - manifest::SharedScope::new(manifest::Scope::writable(&provider_root).unwrap()), + manifest::SharedScope::new(provider_scope.clone()), Caps::READ_ONLY, )) as WorkdirSessionHandle) })); @@ -708,7 +720,7 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t ); let cases = [ ( - json!({"operation": "read", "request": {"path": "/host/private/secret.txt", "offset": 0, "limit": 10, "max_bytes": 1024}}), + json!({"operation": "read", "request": {"path": "private/secret.txt", "offset": 0, "limit": 10, "max_bytes": 1024}}), "read", "out_of_scope", Reason::PathOutOfScope, @@ -720,7 +732,7 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t Reason::ScopedCapabilityDenied, ), ( - json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "output_limit": 1024, "cwd": "", "tool_call_id": secret}}), + json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "transport_byte_budget": 1048576, "cwd": "", "tool_call_id": secret}}), "command_start", "denied", Reason::ScopedCapabilityDenied, @@ -942,8 +954,8 @@ async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatc serde_json::to_vec(&json!({ "target_workdir": "checkout", "operation": {"operation": "command_start", "request": { - "command": "body-secret", "timeout_secs": 1, "output_limit": 1024, - "cwd": "", "spill_dir": null, "tool_call_id": "body-secret" + "command": "body-secret", "timeout_secs": 1, "transport_byte_budget": 1048576, + "cwd": "", "tool_call_id": "body-secret" }} })) .unwrap(), @@ -1071,8 +1083,8 @@ async fn saturated_host_before_command_start_returns_availability_without_a_prov serde_json::to_vec(&json!({ "target_workdir": "checkout", "operation": {"operation": "command_start", "request": { - "command": "not-dispatched", "timeout_secs": 1, "output_limit": 1024, - "cwd": "", "spill_dir": null, "tool_call_id": "not-dispatched" + "command": "not-dispatched", "timeout_secs": 1, "transport_byte_budget": 1048576, + "cwd": "", "tool_call_id": "not-dispatched" }} })) .unwrap(), diff --git a/crates/workspace-server/src/server/tests/command_transport_tests.rs b/crates/workspace-server/src/server/tests/command_transport_tests.rs new file mode 100644 index 000000000..af9d6416b --- /dev/null +++ b/crates/workspace-server/src/server/tests/command_transport_tests.rs @@ -0,0 +1,223 @@ +//! Provider-bridge lifecycle tests. The production bridge runs against a controlled +//! provider boundary; the websocket/CLI-pump test in the parent module covers real byte transport. +use super::*; +use workdir::WorkdirSession; + +fn command_fixture() -> ( + Arc, + tokio::sync::mpsc::Receiver, +) { + let (sender, receiver) = tokio::sync::mpsc::channel(16); + let session = Arc::new(ExternalProviderWorkdirSession::new( + Arc::new(ExternalProviderConnection { + grant_id: "command-grant".into(), + workdir_id: "command-workdir".into(), + provider_instance_id: "command-provider".into(), + generation: 1, + expires_at: None, + capabilities: workdir::WorkdirSessionCapabilities::COMMAND_ONLY, + admission: Arc::new(tokio::sync::Semaphore::new(1)), + shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + sender, + }), + None, + )); + (session, receiver) +} + +fn page(cursor: u64, bytes: &[u8], terminal: bool) -> workdir::CommandOutput { + workdir::CommandOutput { + chunks: if bytes.is_empty() { + Vec::new() + } else { + vec![workdir::CommandByteChunk { + sequence: cursor, + stream: workdir::CommandStream::Stdout, + offset: cursor, + bytes: bytes.to_vec(), + }] + }, + next_cursor: cursor + bytes.len() as u64, + terminal: terminal.then_some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + stdout_bytes: cursor + bytes.len() as u64, + stderr_bytes: 0, + stdout_delivered_bytes: cursor + bytes.len() as u64, + stderr_delivered_bytes: 0, + truncation_reason: None, + }), + } +} + +async fn receive( + receiver: &mut tokio::sync::mpsc::Receiver, +) -> ( + WorkdirSessionOperation, + tokio::sync::oneshot::Sender< + std::result::Result, + >, +) { + let command = tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv()) + .await + .unwrap() + .unwrap(); + let ExternalProviderCommand::Operation { + operation, + response, + .. + } = command + else { + panic!("expected provider operation") + }; + (operation, response) +} + +#[tokio::test] +async fn close_cancels_waiting_reader_then_drains_from_latest_cursor() { + let (session, mut receiver) = command_fixture(); + session.active_commands.lock().await.insert( + "external".into(), + Arc::new(ExternalProviderCommandState::new(CommandHandle( + "provider".into(), + ))), + ); + let reader = { + let session = session.clone(); + tokio::spawn(async move { + session + .command_output(workdir::CommandOutputRequest { + handle: CommandHandle("external".into()), + cursor: 0, + limit: 3, + wait: true, + }) + .await + }) + }; + let (_, response) = receive(&mut receiver).await; + response + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + 0, b"one", false, + )))) + .unwrap(); + assert_eq!(reader.await.unwrap().unwrap().next_cursor, 3); + + let reader = { + let session = session.clone(); + tokio::spawn(async move { + session + .command_output(workdir::CommandOutputRequest { + handle: CommandHandle("external".into()), + cursor: 3, + limit: 3, + wait: true, + }) + .await + }) + }; + let (_, pending_read) = receive(&mut receiver).await; + let closer = { + let session = session.clone(); + tokio::spawn(async move { session.close().await }) + }; + let (operation, response) = receive(&mut receiver).await; + assert!( + matches!(operation, WorkdirSessionOperation::CommandCancel(ref handle) if handle.0 == "provider") + ); + response + .send(Ok(WorkdirSessionOperationResult::CommandCancel)) + .unwrap(); + pending_read + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + 3, b"two", false, + )))) + .unwrap(); + assert_eq!(reader.await.unwrap().unwrap().next_cursor, 6); + + for (cursor, bytes, terminal) in [(6, &b"end"[..], false), (9, &b""[..], true)] { + let (operation, response) = receive(&mut receiver).await; + assert!( + matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == cursor && request.wait) + ); + response + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + cursor, bytes, terminal, + )))) + .unwrap(); + } + tokio::time::timeout(std::time::Duration::from_secs(2), closer) + .await + .unwrap() + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn terminal_output_retains_same_binding_for_current_page_replay() { + let (session, mut receiver) = command_fixture(); + session.active_commands.lock().await.insert( + "external".into(), + Arc::new(ExternalProviderCommandState::new(CommandHandle( + "provider".into(), + ))), + ); + for _ in 0..2 { + let reader = { + let session = session.clone(); + tokio::spawn(async move { + session + .command_output(workdir::CommandOutputRequest { + handle: CommandHandle("external".into()), + cursor: 0, + limit: 3, + wait: true, + }) + .await + }) + }; + let (operation, response) = receive(&mut receiver).await; + assert!( + matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == 0) + ); + response + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + 0, b"end", true, + )))) + .unwrap(); + assert_eq!(reader.await.unwrap().unwrap(), page(0, b"end", true)); + } + assert_eq!(session.active_commands.lock().await.len(), 1); +} + +#[tokio::test] +async fn terminal_registry_retention_is_bounded_and_excludes_active_commands() { + let (source, _) = command_fixture(); + let worker = RuntimeWorkerRef::new("runtime", "worker"); + let mut registry = WorkdirSessionRegistry::default(); + let active = registry.register_command( + worker.clone(), + "checkout".into(), + source.clone(), + CommandHandle("active".into()), + ); + let mut last = None; + for index in 0..MAX_RETAINED_TERMINAL_COMMANDS + 4 { + let handle = registry.register_command( + worker.clone(), + "checkout".into(), + source.clone(), + CommandHandle(format!("terminal-{index}")), + ); + registry.mark_terminal(&worker, &handle); + last = Some(handle); + } + assert_eq!(registry.active_command_count(&worker), 1); + assert_eq!(registry.commands.len(), MAX_RETAINED_TERMINAL_COMMANDS + 1); + assert!(registry.command(&worker, "checkout", &active).is_some()); + assert!( + registry + .command(&worker, "checkout", &last.unwrap()) + .is_some() + ); +} diff --git a/crates/workspace-server/src/server/tests/drive_api_tests.rs b/crates/workspace-server/src/server/tests/drive_api_tests.rs index a7ef41151..3651d6174 100644 --- a/crates/workspace-server/src/server/tests/drive_api_tests.rs +++ b/crates/workspace-server/src/server/tests/drive_api_tests.rs @@ -325,6 +325,7 @@ async fn workers(api: &WorkspaceApi) -> (WorkdirlessFixtureRuntime, Vec RuntimeWorkerRef { workdir_attachments: vec![], feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await diff --git a/crates/workspace-server/src/server/tests/subject_authorization_tests.rs b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs new file mode 100644 index 000000000..ecb2b4b87 --- /dev/null +++ b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs @@ -0,0 +1,2360 @@ +//! Normal Server library boundary tests (`subject_authorization_tests` filter). +//! Router authentication, Host creation/reservation, Subject policy storage, Worker +//! Feature installation and Ticket SQLite writes are real. The original boundary +//! cases substitute WorkdirlessFixtureRuntime. The connected regression below also +//! runs a real ProfileRuntimeWorkerFactory/Controller and materialized Workdir; +//! only its external model stream is scripted. Neither fixture claims OS isolation. +use super::*; +use agen::tool::{Tool, ToolExecutionContext, ToolOutput}; +use server_api::authorization::{ + PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate, +}; +use std::sync::Mutex; +use ticket::NewTicket; +use worker::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess}; +use worker::{ + WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, + WorkspaceResponse, +}; + +fn activation(name: &str) -> ScopedPermission { + ScopedPermission { + operation: name.into(), + resource: PermissionResource::Feature, + } +} +fn create_permission() -> ScopedPermission { + ScopedPermission { + operation: "ticket.create".into(), + resource: PermissionResource::Workspace { + workspace_id: TEST_WORKSPACE_ID.into(), + }, + } +} +fn authorization_path() -> String { + format!("/api/w/{TEST_WORKSPACE_ID}/authorization/check") +} +async fn response(api: &WorkspaceApi, request: Request) -> (StatusCode, Value) { + let response = build_router(api.clone()).oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .unwrap(); + let body = serde_json::from_slice(&bytes) + .unwrap_or_else(|_| json!({"text":String::from_utf8_lossy(&bytes)})); + (status, body) +} +fn human_request(token: &str, method: &str, path: &str, body: Option) -> Request { + Request::builder() + .method(method) + .uri(path) + .header("authorization", format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + body.map(|body| body.to_string()).unwrap_or_default(), + )) + .unwrap() +} + +struct Fixture { + api: WorkspaceApi, + identity: Arc, + runtime: WorkdirlessFixtureRuntime, + subject: String, + source: RuntimeWorkerRef, + owner: String, + _dir: tempfile::TempDir, +} +impl Fixture { + async fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + let runtime = WorkdirlessFixtureRuntime::default(); + api.runtime.register_or_replace(runtime.clone()); + let subject = open_subjektiv_store(&api) + .unwrap() + .create_subject(crate::subjektiv::SubjectRole::new("authorization-boundary").unwrap()) + .unwrap() + .id; + // A normal body attachment, not a forged registry/singleton row. No + // Workdir or Ticket exists. The profile deliberately requests only reads; + // its enabled tool set must not become a ceiling on explicit delegation. + install_read_only_subject_profile(&api); + let Json(created) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.into(), + display_name: "Read-only Subject body".into(), + singleton_key: None, + profile: Some("project:authority-source".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest { + subjektiv: Some(server_api::SubjektivWorkerConnectionRequest { + subject_id: subject.clone(), + }), + }, + control_operation_id: None, + execution_grant: None, + }), + ) + .await + .unwrap(); + let source = RuntimeWorkerRef::new(created.runtime_id, created.worker_id); + let identity = Arc::new(RuntimeIdentityMaterial::generate(&source.runtime_id).unwrap()); + configure_runtime_request_auth(&mut api, &identity, &source.runtime_id); + // The execution fixture does not bootstrap a Session. Exercise the real + // signed startup callback explicitly so the policy store sees the active + // Journal writer, just as it does after normal Runtime startup. + let (status, body) = response(&api, runtime_source_request(&identity, Some(&source.worker_id), "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions"), + serde_json::to_vec(&json!({"session_id":session_store::new_session_id().to_string(),"create_if_missing":true})).unwrap())).await; + assert_eq!(status, StatusCode::OK, "source startup callback: {body}"); + let owner = + seed_test_workspace_owner_token(api.store.as_ref(), "subject-authority-owner").await; + assert!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &source) + .unwrap() + .is_empty() + ); + assert!( + api.store + .require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &source) + .unwrap() + .is_some() + ); + Self { + api, + identity, + runtime, + subject, + source, + owner, + _dir: dir, + } + } + fn policy_path(&self) -> String { + format!( + "/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions", + self.subject + ) + } + async fn assign( + &self, + execution: Vec, + delegation: Vec, + ) -> SubjectPermissions { + let previous = open_subjektiv_store(&self.api) + .unwrap() + .subject_permissions(&self.subject) + .unwrap(); + let update = SubjectPermissionsUpdate { + operation_id: Uuid::now_v7().to_string(), + expected_change_id: previous.map(|policy| policy.change_id), + execution, + delegation, + }; + let (status, body) = response( + &self.api, + human_request( + &self.owner, + "POST", + &self.policy_path(), + Some(serde_json::to_value(update).unwrap()), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let mutation: server_api::SubjectPermissionsMutationResponse = + serde_json::from_value(body).unwrap(); + assert!( + mutation + .interruptions + .iter() + .all(|evidence| evidence.backend_command_sessions_closed), + "Backend cleanup evidence is independent of Runtime cancellation acceptance: {:?}", + mutation.interruptions + ); + mutation.permissions + } + async fn check( + &self, + worker: &RuntimeWorkerRef, + permission: &ScopedPermission, + ) -> (StatusCode, Value) { + response( + &self.api, + runtime_source_request( + &self.identity, + Some(&worker.worker_id), + "POST", + &authorization_path(), + serde_json::to_vec(permission).unwrap(), + ), + ) + .await + } + fn client(&self, worker: &RuntimeWorkerRef) -> Arc { + Arc::new(RouterClient { + api: self.api.clone(), + identity: self.identity.clone(), + worker: worker.clone(), + handle: tokio::runtime::Handle::current(), + requests: Mutex::new(vec![]), + }) + } + fn child_request(&self, operation: &str, permissions: Vec) -> Value { + json!({"runtime_id":self.source.runtime_id,"display_name":"Ordinary delegated editor", + "profile":"builtin:coder","initial_submit":[],"workdir_attachments":[],"feature_connections":{}, + "control_operation_id":operation,"execution_grant":{"permissions":permissions,"redelegation":[]}}) + } + async fn spawn(&self, source: &RuntimeWorkerRef, body: Value) -> (StatusCode, Value) { + response( + &self.api, + runtime_source_request( + &self.identity, + Some(&source.worker_id), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"), + serde_json::to_vec(&body).unwrap(), + ), + ) + .await + } + async fn child(&self, operation: &str, permissions: Vec) -> RuntimeWorkerRef { + let (status, body) = self + .spawn(&self.source, self.child_request(operation, permissions)) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + RuntimeWorkerRef::new( + body["runtime_id"].as_str().unwrap(), + body["worker_id"].as_str().unwrap(), + ) + } + // Historical disconnection is fixture input, not evidence that this suite + // stopped a running Subject controller. Production lease release is real. + fn release_source_lease(&self) { + let lease = self + .api + .store + .require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &self.source) + .unwrap() + .unwrap(); + let binding = "fixture-disconnected-writer"; + let operation = "fixture-disconnected-source"; + self.api + .config_store + .reserve_subject_release(TEST_WORKSPACE_ID, &lease, binding, operation) + .unwrap(); + self.api + .config_store + .confirm_subject_release_proof(TEST_WORKSPACE_ID, &lease, binding, operation, false) + .unwrap(); + self.api + .config_store + .release_subject_singleton(TEST_WORKSPACE_ID, &lease, binding, operation) + .unwrap(); + } +} + +fn install_read_only_subject_profile(api: &WorkspaceApi) { + install_subject_profiles(api, "workspace_read"); +} +fn install_subject_profiles(api: &WorkspaceApi, editor_scope: &str) { + let state = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let main = state + .snapshot + .get(&config_source::VirtualPath::parse("main.dcdl").unwrap()) + .unwrap(); + let content = r#"{ profile = { default_profile = "project:authority-source"; entries = [{ selector = "project:authority-source"; label = "Read-only Subject"; description = "Boundary fixture"; + profile = (import "$builtin/profiles/companion.dcdl") // { slug = "authority-source"; scope = "workspace_read"; delegation_scope = "workspace_read"; }; + }, { selector = "project:authority-editor"; label = "Editor"; description = "Mutable Profile boundary fixture"; + profile = (import "$builtin/profiles/coder.dcdl") // { slug = "authority-editor"; scope = "EDITOR_SCOPE"; }; + }]; }; }"#.replace("EDITOR_SCOPE", editor_scope); + commit_workspace_config_tree( + api, + TEST_WORKSPACE_ID, + &ConfigCommitRequest { + base_digest: state.snapshot.digest.clone(), + entrypoints: state.contract.entrypoints.clone(), + changes: vec![config_source::ConfigTreeChange::Update { + path: main.path.clone(), + expected_digest: main.content_digest.clone(), + content: content.into(), + }], + }, + ) + .unwrap(); +} + +/// Transport only: a fresh real signed proof passes the production router on +/// every call. The request log observes whether a denied Tool/WIP operation +/// crossed the Ticket backend HTTP boundary, rather than inferring from no row. +struct RouterClient { + api: WorkspaceApi, + identity: Arc, + worker: RuntimeWorkerRef, + handle: tokio::runtime::Handle, + requests: Mutex>, +} +impl std::fmt::Debug for RouterClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SignedAuthorizationRouterClient") + .field("worker", &self.worker) + .finish_non_exhaustive() + } +} +impl WorkspaceClient for RouterClient { + fn workspace_id(&self) -> Option<&str> { + Some(TEST_WORKSPACE_ID) + } + fn kind(&self) -> &str { + "signed-in-process" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> std::result::Result { + let method = match request.method { + WorkspaceRequestMethod::Get => "GET", + WorkspaceRequestMethod::Post => "POST", + WorkspaceRequestMethod::Put => "PUT", + WorkspaceRequestMethod::Patch => "PATCH", + WorkspaceRequestMethod::Delete => "DELETE", + }; + self.requests + .lock() + .unwrap() + .push((method.into(), request.path.clone())); + let request = runtime_source_request( + &self.identity, + Some(&self.worker.worker_id), + method, + &request.path, + request.body.unwrap_or_default().into_bytes(), + ); + self.handle.block_on(async { + let response = build_router(self.api.clone()) + .oneshot(request) + .await + .map_err(|e| WorkspaceClientError::Request(e.to_string()))?; + let status = response.status().as_u16(); + let bytes = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .map_err(|e| WorkspaceClientError::Request(e.to_string()))?; + Ok(WorkspaceResponse { + status, + body: String::from_utf8(bytes.to_vec()) + .map_err(|e| WorkspaceClientError::Request(e.to_string()))?, + }) + }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn signed_current_body_uses_explicit_policy_and_old_unset_body_denies() { + let f = Fixture::new().await; + assert!( + open_subjektiv_store(&f.api) + .unwrap() + .subject_permissions(&f.subject) + .unwrap() + .is_none() + ); + for permission in [activation("ticket"), create_permission()] { + let (status, body) = f.check(&f.source, &permission).await; + assert_eq!(status, StatusCode::FORBIDDEN, "unset policy: {body}"); + } + f.assign(vec![activation("ticket"), create_permission()], vec![]) + .await; + for permission in [activation("ticket"), create_permission()] { + let (status, body) = f.check(&f.source, &permission).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body, + json!({"scope":permission.resource}), + "authorization exposes only effective resource scope, not identities or policy/binding material" + ); + } + for permission in [ + activation("web"), + ScopedPermission { + operation: "ticket.create".into(), + resource: PermissionResource::Workspace { + workspace_id: "another-workspace".into(), + }, + }, + ] { + assert_eq!( + f.check(&f.source, &permission).await.0, + StatusCode::FORBIDDEN + ); + } + // A non-Subject Worker keeps the preexisting contract, but cannot nominate a Subject. + let Json(legacy) = create_workspace_worker( + State(f.api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: f.source.runtime_id.clone(), + display_name: "Unattached legacy Worker".into(), + singleton_key: None, + profile: Some("builtin:coder".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: Default::default(), + control_operation_id: None, + execution_grant: None, + }), + ) + .await + .unwrap(); + let legacy = RuntimeWorkerRef::new(legacy.runtime_id, legacy.worker_id); + let (status, body) = f.check(&legacy, &activation("ticket")).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body, + json!({"scope":null}), + "legacy response carries no authority identities" + ); + let (status, _) = f + .spawn( + &legacy, + f.child_request("legacy-cannot-issue", vec![activation("ticket")]), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[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(); + let (status, error) = response( + &f.api, + human_request(&f.owner, "POST", &authorization_path(), Some(body.clone())), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "human actor is not a Worker source: {error}" + ); + let (status, error) = response( + &f.api, + runtime_source_request( + &f.identity, + None, + "POST", + &authorization_path(), + serde_json::to_vec(&body).unwrap(), + ), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "Runtime without Worker: {error}" + ); + let unsigned = Request::builder() + .method("POST") + .uri(authorization_path()) + .header(CONTENT_TYPE, "application/json") + .header("x-yoi-runtime-id", &f.source.runtime_id) + .header("x-yoi-worker-id", &f.source.worker_id) + .body(Body::from(body.to_string())) + .unwrap(); + assert_eq!(response(&f.api, unsigned).await.0, StatusCode::UNAUTHORIZED); + let mut hinted = runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &authorization_path(), + serde_json::to_vec(&activation("web")).unwrap(), + ); + hinted + .headers_mut() + .insert("x-yoi-runtime-id", "unattached-runtime".parse().unwrap()); + hinted + .headers_mut() + .insert("x-yoi-worker-id", "unattached-worker".parse().unwrap()); + assert_eq!( + response(&f.api, hinted).await.0, + StatusCode::FORBIDDEN, + "hints cannot change the verified source into an unrestricted legacy Worker" + ); + for field in ["subject_id", "modelSubject", "runtime_id", "worker_id"] { + let mut claimed = body.clone(); + claimed[field] = json!("self-claimed-identity"); + let (status, error) = response( + &f.api, + runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &authorization_path(), + serde_json::to_vec(&claimed).unwrap(), + ), + ) + .await; + assert!(status.is_client_error(), "{field}: {status} {error}"); + } + let mut tampered = runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &authorization_path(), + serde_json::to_vec(&body).unwrap(), + ); + *tampered.body_mut() = Body::from(serde_json::to_vec(&activation("web")).unwrap()); + assert_eq!(response(&f.api, tampered).await.0, StatusCode::UNAUTHORIZED); + let forged_identity = RuntimeIdentityMaterial::generate(&f.source.runtime_id).unwrap(); + assert_eq!( + response( + &f.api, + runtime_source_request( + &forged_identity, + Some(&f.source.worker_id), + "POST", + &authorization_path(), + serde_json::to_vec(&body).unwrap() + ) + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permissions() { + let f = Fixture::new().await; + let (status, body) = response( + &f.api, + human_request(&f.owner, "GET", &f.policy_path(), None), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body["permissions"].is_null()); + let original = f + .assign( + vec![activation("ticket"), create_permission()], + vec![activation("ticket")], + ) + .await; + let (status, body) = response( + &f.api, + human_request(&f.owner, "GET", &f.policy_path(), None), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["permissions"], + serde_json::to_value(&original).unwrap() + ); + let foreign = seed_test_api_token(f.api.store.as_ref(), "foreign-policy-account"); + let update = json!({"operation_id":"untrusted-admin","expected_change_id":original.change_id, + "execution":[],"delegation":[]}); + for method in ["GET", "POST"] { + let bytes = if method == "POST" { + serde_json::to_vec(&update).unwrap() + } else { + vec![] + }; + for source in [Some(f.source.worker_id.as_str()), None] { + let (status, body) = response( + &f.api, + runtime_source_request( + &f.identity, + source, + method, + &f.policy_path(), + bytes.clone(), + ), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "{method} Runtime/Workspace source {source:?}: {body}" + ); + } + let (status, body) = response( + &f.api, + human_request( + &foreign, + method, + &f.policy_path(), + (method == "POST").then(|| update.clone()), + ), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "foreign account {method}: {body}" + ); + } + assert_eq!( + open_subjektiv_store(&f.api) + .unwrap() + .subject_permissions(&f.subject) + .unwrap(), + Some(original.clone()) + ); + let shrunk = f.assign(vec![activation("ticket")], vec![]).await; + assert_ne!(shrunk.change_id, original.change_id); + assert_eq!( + f.check(&f.source, &create_permission()).await.0, + StatusCode::FORBIDDEN + ); + let (status, body) = response( + &f.api, + human_request(&f.owner, "POST", &f.policy_path(), Some(update)), + ) + .await; + assert_eq!( + status, + StatusCode::CONFLICT, + "stale policy replacement: {body}" + ); + 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, + StatusCode::FORBIDDEN + ); +} + +#[derive(Clone)] +struct UnusedModel; +#[async_trait::async_trait] +impl agen::llm_client::LlmClient for UnusedModel { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( + &self, + _: agen::llm_client::Request, + ) -> std::result::Result + { + panic!("authorization tests must not execute a model") + } +} +async fn ticket_worker_host_with_service( + root: &Path, + client: Arc, +) -> ( + worker::Worker, + bool, + Option>, +) { + let manifest = worker::WorkerManifest::from_toml( + r#" + [worker] + name = "authorization-tools" + [model] + scheme = "anthropic" + model_id = "unused" + [engine] + [scope] + allow = [] + [feature.workdir_catalog] + enabled = false + [feature.ticket] + enabled = true + authoring = true + "#, + ) + .unwrap(); + let engine = + agen::Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated( + UnusedModel, + ); + let mut host = worker::Worker::new( + manifest, + engine, + session_store::FsStore::new(root).unwrap(), + worker::WorkerWorkspaceContext::with_client( + Some(worker::WorkspaceId::new(TEST_WORKSPACE_ID).unwrap()), + client.clone(), + ), + worker::WorkerFilesystemAuthority::None, + manifest::Scope::empty(), + ) + .await + .unwrap(); + let report = host.install_features(worker::feature::FeatureRegistryBuilder::new().with_module( + TicketFeature::new(client, TicketFeatureAccess::workspace_authoring()), + )); + let installed = !report.has_errors(); + let service = if installed { + Some( + report + .services + .service::( + &worker::feature::ServiceId::builtin( + worker::feature::builtin::ticket::TICKET_SERVICE_ID, + ), + ) + .unwrap(), + ) + } else { + None + }; + (host, installed, service) +} +async fn ticket_worker_host( + root: &Path, + client: Arc, +) -> (worker::Worker, bool) { + let (host, installed, _) = ticket_worker_host_with_service(root, client).await; + (host, installed) +} +async fn ticket_tool_host(root: &Path, client: Arc) -> (bool, Option>) { + let (host, installed) = ticket_worker_host(root, client).await; + let server = host.engine().tool_server_handle(); + server.flush_pending(); + let tool = server.get_tool("TicketCreate").map(|(_, tool)| tool); + if !installed { + assert!( + tool.is_none(), + "denied activation must not partially install TicketCreate" + ); + } + (installed, tool) +} +fn ticket_posts(client: &RouterClient) -> usize { + client + .requests + .lock() + .unwrap() + .iter() + .filter(|(method, path)| { + method == "POST" && path == &format!("/api/w/{TEST_WORKSPACE_ID}/tickets") + }) + .count() +} +fn value(output: ToolOutput) -> Value { + serde_json::from_str(output.content.as_deref().unwrap()).unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn worker_feature_activation_is_distinct_from_ticket_create_and_revocation_precedes_backend_call() + { + let f = Fixture::new().await; + let client = f.client(&f.source); + for (name, permissions, installed) in [ + ("unset", vec![], false), + ("operation-only", vec![create_permission()], false), + ("activation-only", vec![activation("ticket")], true), + ] { + f.assign(permissions, vec![]).await; + let (actual, tool) = ticket_tool_host(&f._dir.path().join(name), client.clone()).await; + assert_eq!(actual, installed, "{name}"); + if let Some(tool) = tool { + let result = tool + .execute( + r#"{"title":"must not reach TicketBackend"}"#, + ToolExecutionContext::direct(), + ) + .await; + assert!(result.is_err(), "activation alone cannot create a Ticket"); + assert_eq!( + ticket_posts(&client), + 0, + "denial must precede TicketBackend HTTP call" + ); + } + } + f.assign(vec![activation("ticket"), create_permission()], vec![]) + .await; + let (installed, tool) = ticket_tool_host(&f._dir.path().join("allowed"), client.clone()).await; + assert!(installed); + let tool = tool.unwrap(); + let created = value( + tool.execute( + r#"{"title":"Real authorized Ticket"}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(), + ); + assert_eq!(ticket_posts(&client), 1); + let backend = browser_ticket_backend(&f.api).unwrap(); + let records = backend.list(Default::default()).unwrap(); + assert_eq!( + records.len(), + 1, + "genuine SQLite Ticket backend result: {created}" + ); + f.assign(vec![activation("ticket")], vec![]).await; + let result = tool + .execute( + r#"{"title":"revoked after install"}"#, + ToolExecutionContext::direct(), + ) + .await; + assert!(result.is_err()); + assert_eq!( + ticket_posts(&client), + 1, + "old installed Tool must recheck before backend call" + ); + assert_eq!(backend.list(Default::default()).unwrap().len(), 1); +} + +fn creation_record_counts(f: &Fixture) -> [i64; 3] { + let connection = rusqlite::Connection::open(&f.api.config.database_path).unwrap(); + [ + "worker_create_reservations", + "worker_registry", + "worker_control_grants", + ] + .map(|table| { + connection + .query_row( + &format!("SELECT count(*) FROM {table} WHERE workspace_id=?1"), + [TEST_WORKSPACE_ID], + |row| row.get(0), + ) + .unwrap() + }) +} + +#[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()]; + f.assign(vec![], permissions.clone()).await; + let mut child_request = f.child_request("redelegating-editor", permissions.clone()); + child_request["execution_grant"]["redelegation"] = json!(permissions); + let (status, child) = f.spawn(&f.source, child_request).await; + assert_eq!(status, StatusCode::OK, "{child}"); + let child = RuntimeWorkerRef::new( + child["runtime_id"].as_str().unwrap(), + child["worker_id"].as_str().unwrap(), + ); + let store = open_subjektiv_store(&f.api).unwrap(); + let grants = store + .list_subject_worker_execution_grants(&f.subject) + .unwrap(); + let records = creation_record_counts(&f); + let runtime_calls = f.runtime.spawn_requests().len(); + for source in [&f.source, &child] { + for path in [ + format!("/api/w/{TEST_WORKSPACE_ID}/workers"), + "/api/workers".into(), + ] { + let exact = f.child_request("generic-must-not-allocate", permissions.clone()); + let mut missing_id = exact.clone(); + missing_id + .as_object_mut() + .unwrap() + .remove("control_operation_id"); + let mut blank_id = exact.clone(); + blank_id["control_operation_id"] = json!(" "); + let mut conflicting = exact.clone(); + conflicting["display_name"] = json!("Different payload, same operation ID"); + for request in [exact.clone(), exact, missing_id, blank_id, conflicting] { + let (status, body) = response( + &f.api, + runtime_source_request( + &f.identity, + Some(&source.worker_id), + "POST", + &path, + serde_json::to_vec(&request).unwrap(), + ), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{path}: {body}"); + assert!( + body["message"].as_str().unwrap().contains("worker-control"), + "{body}" + ); + assert_eq!( + creation_record_counts(&f), + records, + "{path}: no reservation, registry or control grant" + ); + assert_eq!( + store + .list_subject_worker_execution_grants(&f.subject) + .unwrap(), + grants + ); + assert_eq!( + f.runtime.spawn_requests().len(), + runtime_calls, + "{path}: no Runtime creation" + ); + } + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn generic_create_routes_preserve_human_and_non_subject_worker_creation() { + let f = Fixture::new().await; + let mut request = f.child_request("unused-on-generic-route", vec![]); + request.as_object_mut().unwrap().remove("execution_grant"); + request + .as_object_mut() + .unwrap() + .remove("control_operation_id"); + for path in [ + format!("/api/w/{TEST_WORKSPACE_ID}/workers"), + "/api/workers".into(), + ] { + let (status, human_child) = response( + &f.api, + human_request(&f.owner, "POST", &path, Some(request.clone())), + ) + .await; + assert_eq!(status, StatusCode::OK, "human {path}: {human_child}"); + let (status, worker_child) = response( + &f.api, + runtime_source_request( + &f.identity, + Some(human_child["worker_id"].as_str().unwrap()), + "POST", + &path, + serde_json::to_vec(&request).unwrap(), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "non-Subject {path}: {worker_child}"); + assert!( + open_subjektiv_store(&f.api) + .unwrap() + .worker_execution_grant( + worker_child["runtime_id"].as_str().unwrap(), + worker_child["worker_id"].as_str().unwrap(), + ) + .unwrap() + .is_none() + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +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()]; + f.assign(vec![], permissions.clone()).await; + let mut missing_id = f.child_request("control-grant-recovery", permissions.clone()); + missing_id + .as_object_mut() + .unwrap() + .remove("control_operation_id"); + let records = creation_record_counts(&f); + let (status, body) = f.spawn(&f.source, missing_id).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert_eq!(creation_record_counts(&f), records); + let connection = rusqlite::Connection::open(&f.api.config.database_path).unwrap(); + connection.execute_batch("CREATE TRIGGER fail_control_grant BEFORE INSERT ON worker_control_grants BEGIN SELECT RAISE(ABORT, 'injected control grant failure'); END;").unwrap(); + let request = f.child_request("control-grant-recovery", permissions); + let (status, body) = f.spawn(&f.source, request.clone()).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR, "{body}"); + let store = open_subjektiv_store(&f.api).unwrap(); + let grants = store + .list_subject_worker_execution_grants(&f.subject) + .unwrap(); + assert_eq!( + grants.len(), + 1, + "Worker exists before control grant persistence fails" + ); + let child = RuntimeWorkerRef::new(&grants[0].runtime_id, &grants[0].worker_id); + assert_eq!( + f.runtime.workers.lock().unwrap().len(), + 2, + "one source and one child" + ); + assert!( + f.api + .store + .list_active_worker_control_grants(TEST_WORKSPACE_ID, &f.source, 10) + .unwrap() + .is_empty() + ); + connection + .execute_batch("DROP TRIGGER fail_control_grant;") + .unwrap(); + let runtime_calls = f.runtime.spawn_requests().len(); + for _ in 0..2 { + // The first success is deliberately not used as a retry credential. + let (status, body) = f.spawn(&f.source, request.clone()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["worker_id"], child.worker_id); + assert_eq!( + store + .list_subject_worker_execution_grants(&f.subject) + .unwrap(), + grants + ); + let control = f + .api + .store + .list_active_worker_control_grants(TEST_WORKSPACE_ID, &f.source, 10) + .unwrap(); + assert_eq!(control.len(), 1); + assert_eq!(control[0].subject, child); + assert_eq!( + f.runtime.spawn_requests().len(), + runtime_calls, + "completed creation must not resubmit to Runtime" + ); + } + let records = creation_record_counts(&f); + let mut conflict = request; + conflict["display_name"] = json!("Changed control retry"); + assert!(!f.spawn(&f.source, conflict).await.0.is_success()); + assert_eq!(creation_record_counts(&f), records); + assert_eq!(f.runtime.spawn_requests().len(), runtime_calls); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_one_worker_and_grant() + { + let f = Fixture::new().await; + let grant_permissions = vec![activation("ticket"), create_permission()]; + let policy = f.assign(vec![], grant_permissions.clone()).await; + assert_eq!( + f.check(&f.source, &create_permission()).await.0, + StatusCode::FORBIDDEN, + "delegation ceiling does not activate operations on the body" + ); + let child = f + .child("ordinary-editor-once", grant_permissions.clone()) + .await; + let requests = f.runtime.spawn_requests(); + let source_manifest = crate::profile_settings::resolve_profile_manifest_from_config_bundle( + requests[0].resolved_config_bundle.as_ref().unwrap(), + "project:authority-source", + ) + .unwrap(); + assert!(!source_manifest.scope.allow.is_empty()); + assert!( + source_manifest + .scope + .allow + .iter() + .all(|rule| rule.permission == manifest::Permission::Read) + ); + let editor_manifest = crate::profile_settings::resolve_profile_manifest_from_config_bundle( + requests[1].resolved_config_bundle.as_ref().unwrap(), + "builtin:coder", + ) + .unwrap(); + assert!( + editor_manifest + .scope + .allow + .iter() + .any(|rule| rule.permission == manifest::Permission::Write), + "editor Profile resolution is independent of the parent's read-only Profile" + ); + assert!( + f.api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &child) + .unwrap() + .is_empty() + ); + assert!( + f.api + .store + .require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &child) + .unwrap() + .is_none() + ); + let store = open_subjektiv_store(&f.api).unwrap(); + let grant = store + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap() + .unwrap(); + assert_eq!(grant.subject_id, f.subject); + assert_eq!(grant.origin_runtime_id, f.source.runtime_id); + assert_eq!(grant.origin_worker_id, f.source.worker_id); + assert_eq!(grant.source_change_id, policy.change_id); + assert_eq!(grant.permissions, grant_permissions); + assert!(grant.redelegation.is_empty()); + assert_eq!(grant.profile, "builtin:coder"); + assert!(!grant.profile_digest.is_empty()); + let (status, replay) = f + .spawn( + &f.source, + f.child_request("ordinary-editor-once", grant_permissions.clone()), + ) + .await; + assert_eq!(status, StatusCode::OK, "{replay}"); + assert_eq!(replay["worker_id"], child.worker_id); + let worker_identities = f + .runtime + .workers + .lock() + .unwrap() + .iter() + .map(|summary| { + ( + summary.worker.runtime_id.clone(), + summary.worker.worker_id.clone(), + ) + }) + .collect::>(); + assert_eq!( + worker_identities.len(), + 2, + "Runtime may receive replayed creation, but only one source and one reserved editor identity exist" + ); + let replay_calls = f.runtime.spawn_requests().len(); + assert_eq!( + store + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap(), + Some(grant) + ); + let mut changed = f.child_request("ordinary-editor-once", grant_permissions); + changed["display_name"] = json!("changed replay payload"); + let (status, body) = f.spawn(&f.source, changed).await; + assert!( + !status.is_success(), + "changed payload must not be adopted: {body}" + ); + assert!( + body["message"] + .as_str() + .unwrap() + .contains("workspace_worker_allocation_conflict"), + "known allocation refusal, not unknown Runtime outcome: {status} {body}" + ); + assert_eq!(f.runtime.spawn_requests().len(), replay_calls); + assert_eq!( + f.check(&child, &create_permission()).await.0, + StatusCode::OK + ); + let (status, body) = f + .spawn( + &child, + f.child_request("undelegatable-grandchild", vec![activation("ticket")]), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "grant has no redelegation: {body}" + ); +} + +#[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")]); + singleton["singleton_key"] = json!("model-chosen-singleton"); + let mut body_attachment = f.child_request("subject-escalation", vec![activation("ticket")]); + body_attachment["feature_connections"] = json!({"subjektiv":{"subject_id":f.subject}}); + let mut spoof = f.child_request("origin-escalation", vec![activation("ticket")]); + spoof["execution_grant"]["subject_id"] = json!(f.subject); + for request in [ + singleton, + body_attachment, + f.child_request("ceiling-escalation", vec![create_permission()]), + spoof, + ] { + let (status, body) = f.spawn(&f.source, request).await; + assert!(status.is_client_error(), "{status}: {body}"); + } + assert_eq!( + f.runtime.spawn_requests().len(), + 1, + "only the genuine body reached Runtime execution" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +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 + ); + let before = open_subjektiv_store(&f.api) + .unwrap() + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap() + .unwrap(); + f.assign(vec![], vec![]).await; + assert_eq!( + f.check(&child, &activation("ticket")).await.0, + StatusCode::FORBIDDEN + ); + let observation = f + .api + .runtime + .worker(&child) + .unwrap() + .restore_observation_token + .unwrap(); + let restore_path = format!( + "/api/w/{TEST_WORKSPACE_ID}/runtimes/{}/workers/{}/restore", + child.runtime_id, child.worker_id + ); + let (status, body) = response(&f.api, human_request(&f.owner, "POST", &restore_path, + 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, + StatusCode::FORBIDDEN + ); + assert_eq!( + open_subjektiv_store(&f.api) + .unwrap() + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap(), + Some(before) + ); + // Regranting identical permissions changes the assignment identity. Old + // grants remain revoked rather than refreshing during restore/reconnect. + f.assign(vec![], vec![activation("ticket")]).await; + assert_eq!( + f.check(&child, &activation("ticket")).await.0, + StatusCode::FORBIDDEN + ); + let current = f + .child("current-before-origin-release", vec![activation("ticket")]) + .await; + assert_eq!( + f.check(¤t, &activation("ticket")).await.0, + StatusCode::OK + ); + f.release_source_lease(); + assert_eq!( + f.check(¤t, &activation("ticket")).await.0, + StatusCode::CONFLICT, + "the captured source lease is no longer current" + ); + assert_eq!( + f.check(&f.source, &activation("ticket")).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")]), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[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; + let client = f.client(&f.source); + let (mut host, installed) = + ticket_worker_host(&f._dir.path().join("wip-host"), client.clone()).await; + assert!(installed); + let mut mounts = worker::wip::WipMountRegistry::new(); + let route = mounts.allocate_namespace("ticket", "tickets").unwrap(); + worker::feature::builtin::ticket::mount_workspace_http_ticket_wip( + &mut mounts, + client.clone(), + TicketFeatureAccess::workspace_authoring(), + None, + &route, + ) + .unwrap(); + worker::wip::install_wip_mode_with_mounts( + host.engine_mut(), + None, + "subject-authorization-wip".into(), + mounts, + ) + .unwrap(); + let server = host.engine().tool_server_handle(); + server.flush_pending(); + assert!( + server.get_tool("TicketCreate").is_none(), + "native surface replaces compatibility Tool" + ); + let inspect = server.get_tool("Inspect").unwrap().1; + let invoke = server.get_tool("Invoke").unwrap().1; + let inspected = value( + inspect + .execute( + r#"{"path":"/tickets","refresh":true}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(), + ); + let reference = inspected["interfaces"] + .as_array() + .unwrap() + .iter() + .find(|interface| interface["reference"]["name"] == "yoi.ticket/collection/v1") + .expect("native Ticket collection interface")["reference"] + .clone(); + let args = json!({"path":"/tickets","interface":reference,"operation":"create","arguments":{"title":"Native authorized Ticket"}}).to_string(); + assert!( + invoke + .execute(&args, ToolExecutionContext::direct()) + .await + .is_err() + ); + assert_eq!( + ticket_posts(&client), + 0, + "WIP denial must not call TicketBackend HTTP" + ); + f.assign(vec![activation("ticket"), create_permission()], vec![]) + .await; + invoke + .execute(&args, ToolExecutionContext::direct()) + .await + .unwrap(); + assert_eq!(ticket_posts(&client), 1); + 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; + assert!( + invoke + .execute(&args, ToolExecutionContext::direct()) + .await + .is_err() + ); + assert_eq!( + ticket_posts(&client), + 1, + "WIP does not cache an old operation allow" + ); + assert_eq!(backend.list(Default::default()).unwrap().len(), 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_not_interrupt_again() + { + let f = Fixture::new().await; + let update = json!({"operation_id":"policy-interruption-once","expected_change_id":null, + "execution":[activation("ticket")],"delegation":[]}); + let (status, body) = response( + &f.api, + human_request(&f.owner, "POST", &f.policy_path(), Some(update.clone())), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let committed: server_api::SubjectPermissionsMutationResponse = + serde_json::from_value(body).unwrap(); + assert_eq!(committed.interruptions.len(), 1); + let evidence = &committed.interruptions[0]; + assert_eq!(evidence.runtime_id, f.source.runtime_id); + assert_eq!(evidence.worker_id, f.source.worker_id); + assert!( + evidence.cancellation_accepted, + "execution fixture accepts cancel, not proof a turn stopped" + ); + assert!( + evidence.backend_command_sessions_closed, + "cleanup evidence is separate from cancel acceptance" + ); + assert_eq!( + open_subjektiv_store(&f.api) + .unwrap() + .subject_permissions(&f.subject) + .unwrap(), + Some(committed.permissions.clone()) + ); + let (status, body) = response( + &f.api, + human_request(&f.owner, "POST", &f.policy_path(), Some(update)), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let replay: server_api::SubjectPermissionsMutationResponse = + serde_json::from_value(body).unwrap(); + assert_eq!(replay.permissions, committed.permissions); + assert!( + replay.interruptions.is_empty(), + "receipt replay must not request a new interruption" + ); + let (status, body) = response( + &f.api, + human_request(&f.owner, "GET", &f.policy_path(), None), + ) + .await; + assert_eq!(status, StatusCode::OK); + let read: server_api::SubjectPermissionsResponse = serde_json::from_value(body).unwrap(); + assert_eq!(read.permissions, Some(replay.permissions)); +} + +#[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()]) + .await; + let mut request = f.child_request( + "redelegating-parent", + vec![activation("ticket"), create_permission()], + ); + request["execution_grant"]["redelegation"] = json!([activation("ticket")]); + let (status, body) = f.spawn(&f.source, request).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let parent = RuntimeWorkerRef::new( + body["runtime_id"].as_str().unwrap(), + body["worker_id"].as_str().unwrap(), + ); + let (status, body) = f + .spawn( + &parent, + f.child_request("allowed-grandchild", vec![activation("ticket")]), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let grandchild = RuntimeWorkerRef::new( + body["runtime_id"].as_str().unwrap(), + body["worker_id"].as_str().unwrap(), + ); + let grant = open_subjektiv_store(&f.api) + .unwrap() + .worker_execution_grant(&grandchild.runtime_id, &grandchild.worker_id) + .unwrap() + .unwrap(); + assert_eq!( + grant.origin_worker_id, parent.worker_id, + "keep immediate lineage instead of flattening to the body" + ); + assert_eq!( + f.check(&grandchild, &activation("ticket")).await.0, + StatusCode::OK + ); + assert_eq!( + f.check(&grandchild, &create_permission()).await.0, + StatusCode::FORBIDDEN + ); + let calls = f.runtime.spawn_requests().len(); + let (status, body) = f + .spawn( + &parent, + f.child_request("root-ceiling-escalation", vec![create_permission()]), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "root permits create but immediate parent cannot pass it: {body}" + ); + assert_eq!(f.runtime.spawn_requests().len(), calls); + f.assign(vec![], vec![]).await; + assert_eq!( + f.check(&grandchild, &activation("ticket")).await.0, + StatusCode::FORBIDDEN + ); +} + +#[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")]); + request["profile"] = json!("project:authority-editor"); + let (status, body) = f.spawn(&f.source, request.clone()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let child = RuntimeWorkerRef::new( + body["runtime_id"].as_str().unwrap(), + body["worker_id"].as_str().unwrap(), + ); + let store = open_subjektiv_store(&f.api).unwrap(); + let original = store + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap() + .unwrap(); + let calls = f.runtime.spawn_requests().len(); + install_subject_profiles(&f.api, "workspace_write"); + let (status, body) = f.spawn(&f.source, request).await; + assert!( + !status.is_success(), + "same-name Profile must not replace the operation's resolved scope: {body}" + ); + assert_eq!( + store + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap(), + Some(original) + ); + assert_eq!( + f.runtime.spawn_requests().len(), + calls, + "changed bundle must fail before Runtime creation" + ); +} + +#[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; + let client = f.client(&f.source); + let (_host, installed, service) = + ticket_worker_host_with_service(&f._dir.path().join("service-host"), client.clone()).await; + assert!(installed); + let service = service.unwrap(); + let denied = service.clone(); + assert!( + tokio::task::spawn_blocking(move || denied.create(NewTicket::new("Service denied"))) + .await + .unwrap() + .is_err() + ); + assert_eq!( + ticket_posts(&client), + 0, + "service denial happens before Ticket backend HTTP" + ); + // A real unattached Worker has the legacy contract; hints cannot substitute + // that principal for the signed Subject body. + let Json(legacy) = create_workspace_worker( + State(f.api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: f.source.runtime_id.clone(), + display_name: "Raw hint target".into(), + singleton_key: None, + profile: Some("builtin:coder".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: Default::default(), + control_operation_id: None, + execution_grant: None, + }), + ) + .await + .unwrap(); + // Bypassing the cooperative Feature check cannot bypass the Server's recheck. + let mut raw = runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets"), + serde_json::to_vec(&NewTicket::new("Raw backend denied")).unwrap(), + ); + raw.headers_mut() + .insert("x-yoi-runtime-id", legacy.runtime_id.parse().unwrap()); + raw.headers_mut() + .insert("x-yoi-worker-id", legacy.worker_id.parse().unwrap()); + let (status, body) = response(&f.api, raw).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "raw source hints cannot become another principal: {body}" + ); + assert!( + browser_ticket_backend(&f.api) + .unwrap() + .list(Default::default()) + .unwrap() + .is_empty() + ); + f.assign(vec![activation("ticket"), create_permission()], vec![]) + .await; + let allowed = service.clone(); + let created = + tokio::task::spawn_blocking(move || allowed.create(NewTicket::new("Service authorized"))) + .await + .unwrap() + .unwrap(); + let backend = browser_ticket_backend(&f.api).unwrap(); + assert_eq!( + backend.show(created.id.into()).unwrap().meta.title, + "Service authorized" + ); + assert_eq!(ticket_posts(&client), 1); + f.assign(vec![activation("ticket")], vec![]).await; + assert!( + tokio::task::spawn_blocking(move || service.create(NewTicket::new("Revoked service"))) + .await + .unwrap() + .is_err() + ); + assert_eq!(ticket_posts(&client), 1); + let raw = runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets"), + serde_json::to_vec(&NewTicket::new("Raw revoked backend")).unwrap(), + ); + assert_eq!(response(&f.api, raw).await.0, StatusCode::FORBIDDEN); + assert_eq!(backend.list(Default::default()).unwrap().len(), 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn signed_host_scope_guards_real_local_read_write_and_revocation_before_effects() { + use workdir::{ReadRequest, WorkdirPath, WriteRequest}; + let f = Fixture::new().await; + let resource = PermissionResource::Workdir { + working_directory_id: "fixture-local-attachment".into(), + path: "src".into(), + recursive: true, + }; + let permissions = vec![ + ScopedPermission { + operation: "filesystem.read".into(), + resource: resource.clone(), + }, + ScopedPermission { + operation: "filesystem.write".into(), + resource: resource.clone(), + }, + ]; + f.assign(vec![], permissions.clone()).await; + let child = f.child("local-resource-child", permissions).await; + // This independently exercises real local operations; the creation Runtime + // fixture never executes the editor. The attachment ID is explicit fixture + // input, not a claim that a real Runtime materialized this local directory. + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("src")).unwrap(); + fs::write(root.path().join("src/source.txt"), b"source").unwrap(); + fs::write(root.path().join("private.txt"), b"secret").unwrap(); + let client = f.client(&child); + let session = worker::authorized_workdir::authorized_workdir_session( + Arc::new(workdir::LocalWorkdirSession::new( + manifest::Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + )), + client, + "fixture-local-attachment".into(), + ); + let read = |path: &str| ReadRequest { + path: WorkdirPath::new(path).unwrap(), + offset: 0, + limit: 100, + max_bytes: 4096, + }; + assert_eq!( + session.read(read("src/source.txt")).await.unwrap().bytes, + b"source" + ); + assert!(session.read(read("private.txt")).await.is_err()); + session + .write(WriteRequest { + path: WorkdirPath::new("src/created.txt").unwrap(), + content: b"created".to_vec(), + expected_hash: None, + }) + .await + .unwrap(); + assert_eq!( + fs::read(root.path().join("src/created.txt")).unwrap(), + b"created" + ); + assert!( + session + .write(WriteRequest { + path: WorkdirPath::new("denied.txt").unwrap(), + content: b"bad".to_vec(), + expected_hash: None + }) + .await + .is_err() + ); + assert!(!root.path().join("denied.txt").exists()); + #[cfg(unix)] + { + std::os::unix::fs::symlink( + root.path().join("private.txt"), + root.path().join("src/link"), + ) + .unwrap(); + assert!( + session.read(read("src/link")).await.is_err(), + "Host's granted src prefix must not follow a symlink outside src" + ); + assert!( + session + .write(WriteRequest { + path: WorkdirPath::new("src/link").unwrap(), + content: b"escaped".to_vec(), + expected_hash: None + }) + .await + .is_err() + ); + assert_eq!( + fs::read(root.path().join("private.txt")).unwrap(), + b"secret" + ); + } + let (_, scope) = f + .check( + &child, + &ScopedPermission { + operation: "filesystem.read".into(), + resource: PermissionResource::Workdir { + working_directory_id: "fixture-local-attachment".into(), + path: "src/source.txt".into(), + recursive: false, + }, + }, + ) + .await; + assert_eq!( + scope, + json!({"scope":resource}), + "response preserves granted prefix rather than turning requested symlink path into a new root" + ); + f.assign(vec![], vec![]).await; + assert!(session.read(read("src/source.txt")).await.is_err()); + assert!( + session + .write(WriteRequest { + path: WorkdirPath::new("src/created.txt").unwrap(), + content: b"revoked".to_vec(), + expected_hash: None + }) + .await + .is_err() + ); + assert_eq!( + fs::read(root.path().join("src/created.txt")).unwrap(), + b"created" + ); + session.close().await.unwrap(); +} + +/// Script only the external model boundary. Tool results come back through the +/// production Controller; this model never reads or writes the fixture itself. +#[derive(Clone)] +struct ConnectedAuthorizationModel { + completed: tokio::sync::mpsc::Sender>, +} + +#[async_trait::async_trait] +impl agen::llm_client::LlmClient for ConnectedAuthorizationModel { + async fn stream( + &self, + request: agen::llm_client::Request, + ) -> std::result::Result< + std::pin::Pin< + Box< + dyn futures::Stream< + Item = std::result::Result< + agen::llm_client::event::Event, + agen::llm_client::error::ClientError, + >, + > + Send, + >, + >, + agen::llm_client::error::ClientError, + > { + use agen::llm_client::{ + event::{Event, StopReason}, + types::Item, + }; + let results: Vec<_> = request + .items + .iter() + .filter_map(|item| match item { + Item::ToolResult { + call_id, + is_error, + content, + summary, + .. + } if matches!(call_id.as_str(), "connected-read" | "connected-write") => Some(( + call_id.clone(), + *is_error, + format!("{summary}; {content:?}"), + )), + _ => None, + }) + .collect(); + let events = if results.iter().any(|(id, _, _)| id == "connected-write") { + // A bounded channel is our completion barrier: no timers, sleeps or + // filesystem polling decide whether the real tools have executed. + let _ = self.completed.try_send(results); + vec![ + Event::text_block_start(0), + Event::text_delta(0, "connected tools finished"), + Event::text_block_stop(0, Some(StopReason::EndTurn)), + ] + } else { + let (id, name, input) = if results.iter().any(|(id, _, _)| id == "connected-read") { + ( + "connected-write", + "Write", + json!({"target_workdir":"workdir", "file_path":"README.md", "content":"MUST_NOT_REPLACE_README\n"}), + ) + } else { + ( + "connected-read", + "Read", + json!({"target_workdir":"workdir", "file_path":"README.md"}), + ) + }; + assert!( + request.tools.iter().any(|tool| tool.name == name), + "editing Profile must expose {name}; denial must not be schema absence" + ); + vec![ + Event::tool_use_start(0, id, name), + Event::tool_input_delta(0, input.to_string()), + Event::tool_use_stop(0), + ] + }; + Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) + } + + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Use the same signed source protocol as RuntimeOwnedWorkspaceClient, with a +/// per-test audience and listener rather than the older server-test/8787 fixture. +async fn connected_authorization_post( + base_url: &str, + identity: &RuntimeIdentityMaterial, + source: &RuntimeWorkerRef, + path: &str, + body: &Value, +) -> (StatusCode, Value) { + let bytes = serde_json::to_vec(body).unwrap(); + let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(identity) + .issue( + base_url, + TEST_WORKSPACE_ID, + Some(&source.worker_id), + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION, + "POST", + path, + &bytes, + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(), + 30, + ) + .unwrap(); + let response = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap() + .post(format!("{base_url}{path}")) + .header(CONTENT_TYPE, "application/json") + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ) + .body(bytes) + .send() + .await + .unwrap(); + let status = StatusCode::from_u16(response.status().as_u16()).unwrap(); + let bytes = response.bytes().await.unwrap(); + let body = serde_json::from_slice(&bytes) + .unwrap_or_else(|_| json!({"text":String::from_utf8_lossy(&bytes)})); + (status, body) +} + +// The Runtime HTTP boundary owns synchronous lifecycle handlers. Give it an +// independent scheduler, matching separate product processes: a blocked Runtime +// request must not pin the Backend callback's Tokio local scheduling queue. +struct ConnectedRuntimeServer { + shutdown: Option>, + thread: Option>, +} +impl ConnectedRuntimeServer { + fn start(listener: tokio::net::TcpListener, router: Router) -> Self { + let listener = listener.into_std().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let thread = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + .unwrap(); + }); + runtime.shutdown_timeout(std::time::Duration::from_secs(5)); + }); + Self { + shutdown: Some(shutdown), + thread: Some(thread), + } + } +} +impl Drop for ConnectedRuntimeServer { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 6)] +async fn connected_read_only_subject_delegates_editing_profile_to_real_runtime_read_but_not_write() +{ + use worker_runtime::working_directory::WorkingDirectoryMaterializer; + + const RUNTIME: &str = "connected-subject-authorization"; + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let backend_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let backend_url = format!("http://{}", backend_listener.local_addr().unwrap()); + let runtime_url = format!("http://{}", runtime_listener.local_addr().unwrap()); + let identity = RuntimeIdentityMaterial::generate(RUNTIME).unwrap(); + let runtime_root = dir.path().join("connected-runtime"); + let materializer = worker_runtime::working_directory::RuntimeGitMaterializer::new( + dir.path().join("connected-workdirs"), + ); + let (completed, mut completion) = tokio::sync::mpsc::channel(4); + let model = ConnectedAuthorizationModel { completed }; + let factory = worker_runtime::worker_backend::ProfileRuntimeWorkerFactory::new( + dir.path().join("connected-profiles"), + ) + .with_remote_worker_mutation_identity(identity.clone()) + .with_workspace_request_client( + worker_runtime::workspace_request::RuntimeWorkspaceRequestClient::new( + TEST_WORKSPACE_ID, + &backend_url, + RUNTIME, + ) + .with_runtime_request_source(&identity, &backend_url), + ) + .with_runtime_store_dir(&runtime_root) + .with_spawn_model_client_factory(move || Box::new(model.clone())); + let execution = worker_runtime::worker_backend::WorkerRuntimeExecutionBackend::new(factory) + .unwrap() + .with_working_directory_materializer(materializer.clone()); + let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend( + worker_runtime::fs_store::FsRuntimeStoreOptions { + root: runtime_root, + runtime_id: RUNTIME.into(), + display_name: None, + }, + Arc::new(execution), + ) + .unwrap(); + + let mut config = test_server_config(dir.path()); + config.backend_base_url = Some(backend_url.clone()); + // The embedded Runtime is unused. Both source and child below execute on the + // connected real Runtime above, not on this existing Host fixture backend. + let control = Arc::new(test_control_store(&config)); + seed_test_registered_workspace(control.as_ref(), &config) + .await + .unwrap(); + let api = WorkspaceApi::new_with_execution_backend( + config, + control, + Arc::new(DeterministicExecutionBackend::default()), + ) + .await + .unwrap(); + let now = now_registry_timestamp(); + api.store + .upsert_workspace_runtime_binding_record( + WorkspaceRuntimeBinding { + workspace_id: TEST_WORKSPACE_ID.into(), + runtime_id: RUNTIME.into(), + display_name: RUNTIME.into(), + base_url: runtime_url.clone(), + public_key: identity.public_key.clone(), + public_key_fingerprint: String::new(), + binding_id: "connected-authorization-binding".into(), + authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, + created_at: now.clone(), + updated_at: now, + revoked_at: None, + }, + false, + ) + .await + .unwrap(); + let binding = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, RUNTIME) + .await + .unwrap() + .unwrap(); + // Repository materialization and Worker startup must authenticate as this + // Workspace, not the Runtime HTTP local-token fallback's `local` Workspace. + let workspace_identity = api + .signing_identities + .provision_existing(TEST_WORKSPACE_ID, &test_owner_actor().account_id) + .unwrap(); + let verifier = worker_runtime::workspace_issuer::WorkspaceCapabilityVerifier::new( + vec![ + worker_runtime::workspace_issuer::WorkspaceIssuerTrustRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + backend_url: backend_url.clone(), + key_id: workspace_identity.key_id.clone(), + algorithm: workspace_identity.algorithm.clone(), + public_key: workspace_identity.public_key.clone().unwrap(), + public_key_fingerprint: workspace_identity.public_key_fingerprint.clone().unwrap(), + trust_id: "connected-authorization-workspace-trust".into(), + state: worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Active, + registered_at_unix: 1, + updated_at_unix: 1, + }, + ], + Arc::new( + worker_runtime::workspace_issuer::InMemoryWorkspaceClaimReplayProtection::default(), + ), + ) + .unwrap(); + let runtime_errors = Arc::new(Mutex::new(Vec::::new())); + let captured_errors = runtime_errors.clone(); + let _runtime_server = ConnectedRuntimeServer::start( + runtime_listener, + worker_runtime::http_server::runtime_http_router_with_workspace_auth( + runtime.clone(), + None, + worker_runtime::http_server::WorkspaceRuntimeHttpAuth { + verifier, + runtime_id: RUNTIME.into(), + }, + ) + .layer(axum::middleware::from_fn( + move |request: axum::extract::Request, next: axum::middleware::Next| { + let errors = captured_errors.clone(); + async move { + let response = next.run(request).await; + if response.status().is_success() { + return response; + } + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + errors.lock().unwrap().push( + body["error"]["message"] + .as_str() + .unwrap_or("unknown fixture error") + .to_owned(), + ); + Response::from_parts(parts, Body::from(bytes)) + } + }, + )), + ); + api.runtime.register_or_replace( + RemoteWorkerRuntime::new( + RemoteRuntimeConfig { + runtime_id: RUNTIME.into(), + workspace_id: Some(TEST_WORKSPACE_ID.into()), + display_name: RUNTIME.into(), + base_url: runtime_url, + bearer_token: None, + workspace_authorization: Some(crate::hosts::WorkspaceRuntimeAuthorization::new( + api.store.clone(), + api.signing_identities.clone(), + backend_url.clone(), + binding, + )), + strict_public_egress: false, + cached_worker_creation_available: true, + cached_os: "linux".into(), + cached_arch: "x86_64".into(), + cached_status: "active".into(), + timeout: std::time::Duration::from_secs(30), + }, + TEST_WORKSPACE_ID.to_string(), + backend_url.clone(), + ) + .unwrap(), + ); + install_subject_profiles(&api, "workspace_write"); + seed_test_delivery_binding(&api); + + let authorizations = Arc::new(Mutex::new(Vec::<(Value, StatusCode)>::new())); + let observed = authorizations.clone(); + let app = build_router(api.clone()).layer(axum::middleware::from_fn( + move |request: axum::extract::Request, next: axum::middleware::Next| { + let observed = observed.clone(); + async move { + if request.uri().path() != authorization_path() { + return next.run(request).await; + } + let (parts, body) = request.into_parts(); + let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap(); + let permission: Value = serde_json::from_slice(&bytes).unwrap(); + let response = next + .run(Request::from_parts(parts, Body::from(bytes))) + .await; + observed + .lock() + .unwrap() + .push((permission, response.status())); + response + } + }, + )); + let _backend_server = AuthorizationCallbackServer::start(backend_listener, app); + let owner = + seed_test_workspace_owner_token(api.store.as_ref(), "connected-authorization-owner").await; + let (status, created_workdir) = response(&api, human_request(&owner, "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/working-directories"), + Some(json!({"runtime_id":RUNTIME, "repository_key":"test-repository", "selector":"HEAD", "operation_id":"connected-authorization-workdir"})))).await; + assert_eq!(status, StatusCode::CREATED, "{created_workdir}"); + let workdir_id = created_workdir["item"]["working_directory_id"] + .as_str() + .unwrap() + .to_owned(); + let binding = materializer + .bind_working_directory(&workdir_id, None) + .unwrap(); + let readme = binding.root().join("README.md"); + assert_eq!(fs::read_to_string(&readme).unwrap(), "clean\n"); + + let subject_store = open_subjektiv_store(&api).unwrap(); + let subject = subject_store + .create_subject(crate::subjektiv::SubjectRole::new("connected-authorization").unwrap()) + .unwrap() + .id; + let read_permission = ScopedPermission { + operation: "filesystem.read".into(), + resource: PermissionResource::Workdir { + working_directory_id: workdir_id.clone(), + path: ".".into(), + recursive: true, + }, + }; + let mut child_permissions: Vec<_> = [ + "task", + "web", + "image", + "sub_worker", + "flow", + "worker", + "workspace_worker_discovery", + "objective", + "manage_workdir", + "workdir_catalog", + "drive", + "ticket", + "merge_request", + "subjektiv", + ] + .into_iter() + .map(activation) + .collect(); + child_permissions.push(read_permission.clone()); + // Set policy before startup: the actual source Controller sees this policy, + // not an unset body or a registry/singleton row manufactured by the test. + let (status, body) = response(&api, human_request(&owner, "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"), + Some(json!({"operation_id":"connected-authorization-policy", "expected_change_id":null, + "execution":(["task", "subjektiv", "web", "image", "sub_worker", "worker", "workspace_worker_discovery", "objective", "manage_workdir", "workdir_catalog", "drive", "ticket"].into_iter().map(activation).collect::>()), + "delegation":child_permissions.clone()})))).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let Json(source) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: RUNTIME.into(), + display_name: "Connected read-only Subject body".into(), + singleton_key: None, + profile: Some("project:authority-source".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest { + subjektiv: Some(server_api::SubjektivWorkerConnectionRequest { + subject_id: subject.clone(), + }), + }, + control_operation_id: None, + execution_grant: None, + }), + ) + .await + .unwrap_or_else(|error| { + panic!( + "source creation: {error:?}; Runtime: {:?}", + runtime_errors.lock().unwrap() + ) + }); + let source = RuntimeWorkerRef::new(source.runtime_id, source.worker_id); + assert!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &source) + .unwrap() + .is_empty() + ); + assert!( + api.store + .require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &source) + .unwrap() + .is_some() + ); + let (status, body) = connected_authorization_post( + &backend_url, + &identity, + &source, + &authorization_path(), + &serde_json::to_value(&read_permission).unwrap(), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "body cannot execute its delegation ceiling: {body}" + ); + + // The normal noBash companion has no direct WorkerSpawn tool. Exercise its + // signed source CreateWorkspaceWorkerRequest transport explicitly, as opposed + // to installing a test-only privileged Feature or forging a child grant row. + let request = CreateWorkspaceWorkerRequest { + runtime_id: RUNTIME.into(), + display_name: "Connected ordinary editing Profile child".into(), + singleton_key: None, + profile: Some("project:authority-editor".into()), + ticket_assignment: None, + initial_submit: vec![protocol::Segment::Text { + content: "Read README.md, then attempt the denied Write.".into(), + }], + workdir_attachments: vec![server_api::BrowserWorkerWorkingDirectorySelection { + alias: "workdir".into(), + working_directory_id: workdir_id.clone(), + relative_cwd: None, + }], + feature_connections: Default::default(), + control_operation_id: Some("connected-authorization-child-once".into()), + execution_grant: Some(server_api::authorization::WorkerDelegationRequest { + permissions: child_permissions.clone(), + redelegation: vec![], + }), + }; + let path = format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"); + authorizations.lock().unwrap().clear(); + let wire = serde_json::to_value(request).unwrap(); + let (status, child) = + connected_authorization_post(&backend_url, &identity, &source, &path, &wire).await; + assert_eq!( + status, + StatusCode::OK, + "normal signed source creation: {child}; Runtime: {:?}", + runtime_errors.lock().unwrap() + ); + let child_ref = RuntimeWorkerRef::new( + child["runtime_id"].as_str().unwrap(), + child["worker_id"].as_str().unwrap(), + ); + let grant = subject_store + .worker_execution_grant(&child_ref.runtime_id, &child_ref.worker_id) + .unwrap() + .unwrap(); + assert_eq!(grant.origin_worker_id, source.worker_id); + assert_eq!(grant.permissions, child_permissions); + assert!(grant.redelegation.is_empty()); + assert!( + api.store + .require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &child_ref) + .unwrap() + .is_none(), + "ordinary Profile child must not become a Subject body" + ); + let (status, replay) = + connected_authorization_post(&backend_url, &identity, &source, &path, &wire).await; + assert_eq!(status, StatusCode::OK, "stable operation replay: {replay}"); + assert_eq!(replay["worker_id"], child["worker_id"]); + + let results = tokio::time::timeout(std::time::Duration::from_secs(30), completion.recv()) + .await + .expect("real child Controller must return both tool results") + .expect("scripted model completion channel"); + let read = results + .iter() + .find(|(id, _, _)| id == "connected-read") + .unwrap(); + assert!( + !read.1, + "actual Read must succeed under the read-only grant: {read:?}" + ); + assert!( + read.2.contains("clean"), + "Read must return materialized repository content: {read:?}" + ); + let write = results + .iter() + .find(|(id, _, _)| id == "connected-write") + .unwrap(); + assert!( + write.1, + "editing Profile must not enlarge the read-only grant: {write:?}" + ); + assert!( + write.2.contains("Workdir session does not support Write"), + "the Host-attenuated attachment must deny before provider dispatch: {write:?}" + ); + // The actual Write is refused even before provider dispatch by the narrowed + // attachment capabilities. Independently query the same child's live Host + // policy, rather than claiming that a refused Tool reached the write adapter. + let mut denied_write = read_permission.clone(); + denied_write.operation = "filesystem.write".into(); + let (status, body) = connected_authorization_post( + &backend_url, + &identity, + &child_ref, + &authorization_path(), + &serde_json::to_value(denied_write).unwrap(), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert_eq!( + fs::read_to_string(&readme).unwrap(), + "clean\n", + "denied Write has no effect on the actual Runtime materialization" + ); + let observations = authorizations.lock().unwrap(); + for (operation, expected) in [ + ("filesystem.read", StatusCode::OK), + ("filesystem.write", StatusCode::FORBIDDEN), + ] { + assert!( + observations + .iter() + .any(|(permission, status)| permission["operation"] == operation + && permission["resource"]["working_directory_id"] == workdir_id + && *status == expected), + "signed Host authorization must observe {operation} with {expected}: {observations:?}" + ); + } + assert!( + browser_ticket_backend(&api) + .unwrap() + .list(Default::default()) + .unwrap() + .is_empty(), + "generic Profile delegation requires no Ticket" + ); +} diff --git a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs index 3e1ff2ddf..03a3dba16 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs @@ -527,6 +527,7 @@ async fn subjektiv_job_legacy_cutover_waits_foreground_then_removes_only_owned_c resolved_worker_observation_enabled: false, resolved_worker_observation_grants: vec![], resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() diff --git a/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs index c3aca207b..4cc4b5c24 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs @@ -260,7 +260,7 @@ async fn connected_create(api: &WorkspaceApi, subject: &str, name: &str) -> Runt workdir_attachments: vec![], feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest { subjektiv: Some(server_api::SubjektivWorkerConnectionRequest { subject_id: subject.into() }), - }, control_operation_id: None, + }, control_operation_id: None, execution_grant: None, }), ).await.unwrap(); RuntimeWorkerRef::new(created.runtime_id, created.worker_id) @@ -386,6 +386,56 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup() let subject = store .create_subject(crate::subjektiv::SubjectRole::new("connected-handoff").unwrap()) .unwrap(); + let owner = + seed_test_workspace_owner_token(api.store.as_ref(), "connected-journal-owner").await; + // This fixture exercises Journal continuity, not unset-policy refusal. Assign + // the companion's exact activation keys through authenticated administration; + // no filesystem, shell or delegation permission is granted to its body. + let update = server_api::authorization::SubjectPermissionsUpdate { + operation_id: "connected-journal-activation".into(), + expected_change_id: None, + execution: [ + "task", + "subjektiv", + "web", + "image", + "sub_worker", + "worker", + "workspace_worker_discovery", + "objective", + "manage_workdir", + "workdir_catalog", + "drive", + "ticket", + ] + .into_iter() + .map(|name| server_api::authorization::ScopedPermission { + operation: name.into(), + resource: server_api::authorization::PermissionResource::Feature, + }) + .collect(), + delegation: Vec::new(), + }; + let response = build_router(api.clone()) + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri(format!( + "/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions", + subject.id + )) + .header("authorization", format!("Bearer {owner}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&update).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .unwrap(); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); let old = connected_create(&api, &subject.id, "connected-old-body").await; let old_attr = store .subject_session_attributions(&subject.id) @@ -547,30 +597,40 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup() let segments = session.list_segments(session_id).unwrap(); assert_eq!(segments.len(), 1); let entries = session.read_all_read_only(session_id, segments[0]).unwrap(); + #[derive(serde::Deserialize)] + struct SavedJournal { + snapshot: serde_json::Value, + snapshot_sha256: String, + prompt_provenance: session_store::PromptRenderProvenance, + } let startup: Vec<_> = entries .iter() .enumerate() - .filter_map(|(index, log)| match log { - session_store::LogEntry::AnnotatedSystemItem { entry, .. } => match &entry.item { - session_store::SystemItem::JournalStartup { - snapshot, - snapshot_sha256, - body, - prompt_provenance, - } => Some((index, snapshot, snapshot_sha256, body, prompt_provenance)), - _ => None, - }, - _ => None, + .filter_map(|(index, log)| { + let session_store::LogEntry::AnnotatedSystemItem { entry, .. } = log else { + return None; + }; + let payload = entry + .metadata + .input_metadata + .as_ref()? + .feature_payload()? + .decode::("journal:startup", 1) + .ok()?; + Some((index, payload, entry.item.history_text())) }) .collect(); assert_eq!(startup.len(), 1, "startup must be durably committed once"); - let (startup_index, snapshot, digest, body, provenance) = startup[0]; + let (startup_index, payload, body) = &startup[0]; + let snapshot = &payload.snapshot; + let digest = &payload.snapshot_sha256; + let provenance = &payload.prompt_provenance; let first_input = entries .iter() .position(|entry| matches!(entry, session_store::LogEntry::AnnotatedUserInput { .. })) .expect("new body persisted its initial human input"); assert!( - startup_index < first_input, + *startup_index < first_input, "startup reference must be persisted before the first human input/model request" ); assert_eq!(snapshot["subject_id"], subject.id); diff --git a/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs index 8f83a29e0..81ff5817a 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs @@ -479,6 +479,7 @@ fn seed_session( vec![session_store::LoggedHistoryEntry { item, metadata: session_store::LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: session_store::LoggedSessionHistoryEntryId("read-source-original".into()), origin, derivation: None, @@ -1686,6 +1687,7 @@ impl SubjectRemovalFixture { }), }, control_operation_id: None, + execution_grant: None, }), ) .await @@ -1706,6 +1708,7 @@ impl SubjectRemovalFixture { .map(|text| session_store::LoggedHistoryEntry { item: message(text), metadata: session_store::LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: session_store::LoggedSessionHistoryEntryId(text.into()), origin: session_store::LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, diff --git a/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs index abcc72632..190924f52 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_memory_journal_tests.rs @@ -67,6 +67,7 @@ impl Fixture { content: vec![session_store::LoggedContentPart::Text { text: text.into() }], }, metadata: session_store::LoggedSessionHistoryMetadata { + input_metadata: None, entry_id: session_store::LoggedSessionHistoryEntryId(format!("original-{index}")), origin: session_store::LoggedSessionHistoryOrigin::LocalHumanInput, derivation: None, diff --git a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs index ee8dba11d..8d7ef1592 100644 --- a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs @@ -132,6 +132,16 @@ impl WorkerExecutionBackend for LaunchExecutionBackend { self.execution.create_working_directory(request) } + fn cleanup_working_directory( + &self, + id: &str, + ) -> std::result::Result< + worker_runtime::catalog::WorkingDirectoryStatus, + worker_runtime::working_directory::WorkingDirectoryDiagnostic, + > { + self.execution.cleanup_working_directory(id) + } + fn spawn_worker(&self, mut request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { self.creates.lock().unwrap().push(request.request.clone()); for claim in &request.request.workdir_attachments { @@ -164,9 +174,9 @@ impl WorkerExecutionBackend for LaunchExecutionBackend { } } -struct LaunchFixture { +pub(super) struct LaunchFixture { _temp: tempfile::TempDir, - api: WorkspaceApi, + pub(super) api: WorkspaceApi, backend: Arc, runtime_id: String, runtime_server: Option>, @@ -182,11 +192,51 @@ impl Drop for LaunchFixture { } impl LaunchFixture { + // Reuse the authenticated Runtime HTTP harness for Drive/source lifetime + // assertions without duplicating its trust/bootstrap/listener lifecycle. + pub(super) async fn with_source_workdir() -> ( + Self, + worker_runtime::working_directory::WorkingDirectoryBinding, + ) { + let fixture = Self::new(true).await; + init_clean_git_workspace(fixture._temp.path()); + let (_, Json(created)) = create_workspace_working_directory( + &fixture.api, + TEST_WORKSPACE_ID, + Some(&fixture.runtime_id), + BrowserWorkingDirectoryCreateRequest { + runtime_id: Some(fixture.runtime_id.clone()), + display_name: None, + repository_key: "test-repository".into(), + selector: Some("HEAD".into()), + operation_id: Some("drive-source-workdir".into()), + }, + ) + .await + .unwrap(); + let binding = fixture + .backend + .execution + .materializer + .bind_working_directory(&created.item.working_directory_id, None) + .unwrap(); + (fixture, binding) + } + async fn new(remote: bool) -> Self { let temp = tempfile::tempdir().unwrap(); let config = test_server_config(temp.path()); let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); - let backend = Arc::new(LaunchExecutionBackend::default()); + let backend = Arc::new(LaunchExecutionBackend { + execution: DeterministicExecutionBackend { + materializer: worker_runtime::working_directory::RuntimeGitMaterializer::new( + temp.path() + .join(".test-embedded-runtime-store/source-workdirs"), + ), + ..Default::default() + }, + ..Default::default() + }); let api = WorkspaceApi::new_with_execution_backend(config, Arc::new(store), backend.clone()) .await @@ -982,6 +1032,7 @@ async fn read_only_ticket_targets_allow_extra_external_grants_without_widening_c resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; api.validate_worker_spawn_repository_scope(EMBEDDED_WORKER_RUNTIME_ID, &mut request) .unwrap(); @@ -1530,6 +1581,7 @@ async fn mr_open_retains_source_before_first_review_and_observed_result() { }], feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await diff --git a/crates/workspace-server/src/server/tests/worker_drive_tests.rs b/crates/workspace-server/src/server/tests/worker_drive_tests.rs index e951a6308..f92b6d172 100644 --- a/crates/workspace-server/src/server/tests/worker_drive_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_drive_tests.rs @@ -1,6 +1,10 @@ -//! Worker tools -> signed WorkspaceClient -> real isolated Server authority. -//! No Git checkout, live DB, network service, or DTO-only assertions. Workdir -//! import tests attach an explicitly read-scoped temporary source session. +//! Tools/native WIP -> signed WorkspaceClient -> isolated Server DB and LocalFileSystem. +//! Default fixtures have no Repository/Workdir and substitute Runtime execution. +//! Lifetime cases use real embedded Runtime stop/removal or the shared authenticated +//! Runtime HTTP harness for actual Git Workdir removal; controller execution remains +//! deterministic. Import sessions have explicit read scope (and real materializer +//! leases in the removal case). No external model, live data, or browser is used. +//! Run with the normal Server lib target and the `worker_drive_tests` filter. use super::*; use agen::tool::{Attachment, ToolError, ToolExecutionContext, ToolOutput}; use std::result::Result; @@ -148,28 +152,46 @@ struct Fixture { } impl Fixture { async fn new(access: [&str; 2]) -> Self { + Self::with_runtime(access, false).await + } + async fn with_runtime(access: [&str; 2], embedded: bool) -> Self { let temp = tempfile::tempdir().unwrap(); let mut config = test_server_config(temp.path()); config.repositories.clear(); let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); - let mut api = WorkspaceApi::new_with_execution_backend( - config, - store, - Arc::new(DeterministicExecutionBackend::default()), - ) - .await - .unwrap(); + let execution = Arc::new(DeterministicExecutionBackend { + materializer: worker_runtime::working_directory::RuntimeGitMaterializer::new( + temp.path().join("runtime-workdirs"), + ), + ..Default::default() + }); + let api = WorkspaceApi::new_with_execution_backend(config, store, execution) + .await + .unwrap(); + Self::on_api(api, temp, access, embedded).await + } + async fn on_api( + mut api: WorkspaceApi, + temp: tempfile::TempDir, + access: [&str; 2], + embedded: bool, + ) -> Self { let owner = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); - register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; - api.runtime - .register_or_replace(WorkdirlessFixtureRuntime::default()); + let runtime_id = if embedded { + EMBEDDED_RUNTIME_ID + } else { + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + api.runtime + .register_or_replace(WorkdirlessFixtureRuntime::default()); + WorkdirlessFixtureRuntime::RUNTIME_ID + }; let mut workers = Vec::new(); for name in ["Drive first", "Drive second"] { let Json(created) = create_workspace_worker( State(api.clone()), HeaderMap::new(), Json(CreateWorkspaceWorkerRequest { - runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.into(), + runtime_id: runtime_id.into(), display_name: name.into(), singleton_key: None, profile: Some("builtin:coder".into()), @@ -178,6 +200,7 @@ impl Fixture { workdir_attachments: vec![], feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -248,6 +271,45 @@ impl Fixture { .unwrap(), ) } + async fn upload_text(&self, name: &str, text: &str) -> Value { + use sha2::{Digest, Sha256}; + let root = self.root(0).await; + let digest: String = Sha256::digest(text.as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("operation", "create") + .append_pair("request_id", &format!("user-upload-{name}")) + .append_pair("entry_workspace_id", TEST_WORKSPACE_ID) + .append_pair("parent_id", root["node_id"].as_str().unwrap()) + .append_pair("name", name) + .append_pair("content_type", "text/markdown") + .append_pair("size", &text.len().to_string()) + .append_pair("sha256", &digest) + .finish(); + let response = build_router(self.api.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("{}/upload?{query}", base())) + .header("authorization", format!("Bearer {}", self.owner)) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(text.to_owned())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&bytes) + ); + serde_json::from_slice(&bytes).unwrap() + } fn importer(&self, worker: usize) -> DriveFeature { let root = self._temp.path().join("import-source"); fs::create_dir_all(root.join("public")).unwrap(); @@ -415,6 +477,353 @@ async fn workdirless_workers_share_markdown_through_current_read_and_write_grant ); } +#[tokio::test] +async fn user_uploaded_markdown_roundtrips_through_workdirless_tools_and_native_wip_at_one_identity() + { + let f = Fixture::new(["read_write", "read_only"]).await; + let original = "# ユーザー資料\n未処理\n"; + let updated_text = "# ユーザー資料\n処理済み ✓\n"; + for native in [false, true] { + let uploaded = f + .upload_text(if native { "native.md" } else { "tools.md" }, original) + .await; + let id = uploaded["entry"]["entry"].clone(); + let updated = if native { + let tools = native_tools(&f.features[0]); + // Resolve the production path projection, not an invented node path. + let metadata = value( + tool(&f.features[0], "DriveMetadata", json!({"entry":id})) + .await + .unwrap(), + ); + let path = metadata["path"].as_str().unwrap(); + let inspected = value( + native_tool(&tools, "Inspect", json!({"path":path,"refresh":true})) + .await + .unwrap(), + ); + let reference = inspected["interfaces"][0]["reference"].clone(); + let read = value( + native_tool( + &tools, + "Invoke", + json!({"path":path,"interface":reference,"operation":"read","arguments":{}}), + ) + .await + .unwrap(), + ); + assert_eq!(read["text"], original); + assert_eq!(read["entry"]["metadata"]["entry"], id); + assert_eq!( + read["entry"]["metadata"]["last_mutation_id"], + uploaded["entry"]["last_mutation_id"] + ); + value(native_tool(&tools, "Invoke", json!({"path":path,"interface":file_write_reference(&inspected),"operation":"write","arguments":{"content":updated_text}})).await.unwrap())["metadata"].clone() + } else { + let read = value( + tool(&f.features[0], "DriveRead", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!(read["text"], original); + assert_eq!(read["entry"]["metadata"]["entry"], id); + assert_eq!( + read["entry"]["metadata"]["last_mutation_id"], + uploaded["entry"]["last_mutation_id"] + ); + value( + tool( + &f.features[0], + "DriveWrite", + json!({"entry":id,"content":updated_text}), + ) + .await + .unwrap(), + ) + }; + assert_eq!(entry(&updated), id); + let mutation = &updated["entry"]["metadata"]["last_mutation_id"]; + assert_ne!(*mutation, uploaded["entry"]["last_mutation_id"]); + assert_eq!(*mutation, updated["request_id"]); + let independent = value( + tool(&f.features[1], "DriveRead", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!(independent["text"], updated_text); + assert_eq!(independent["entry"], updated["entry"]); + let metadata = owner_call( + &f.api, + &f.owner, + "GET", + &format!( + "{}/metadata?entry_workspace_id={TEST_WORKSPACE_ID}&id={}", + base(), + id["node_id"].as_str().unwrap() + ), + None, + ) + .await; + assert_eq!(metadata, updated["entry"]["metadata"]); + let receipt = owner_call( + &f.api, + &f.owner, + "GET", + &format!( + "{}/requests/{}", + base(), + updated["request_id"].as_str().unwrap() + ), + None, + ) + .await; + assert_eq!(receipt["state"], "committed"); + assert_eq!(receipt["response"]["entry"], metadata); + let response = build_router(f.api.clone()).oneshot(Request::builder() + .uri(format!("{}/download?entry_workspace_id={TEST_WORKSPACE_ID}&id={}&expected_mutation_id={}", base(), id["node_id"].as_str().unwrap(), mutation.as_str().unwrap())) + .header("authorization", format!("Bearer {}", f.owner)) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK, "native={native}"); + assert_eq!( + to_bytes(response.into_body(), 65536) + .await + .unwrap() + .as_ref(), + updated_text.as_bytes() + ); + } +} + +#[tokio::test] +async fn source_workdir_removal_keeps_published_drive_bytes_and_receipt() { + let (source, binding) = + super::ticket_worker_launch_tests::LaunchFixture::with_source_workdir().await; + let workdir_id = binding.working_directory.id.clone(); + let f = Fixture::on_api( + source.api.clone(), + tempfile::tempdir().unwrap(), + ["read_write", "read_only"], + false, + ) + .await; + let bytes = b"clean\n"; + let root_path = binding.root().to_owned(); + let materialization_root = binding.working_directory_root().to_owned(); + let scope = manifest::Scope::from_config(&manifest::ScopeConfig { + allow: vec![manifest::ScopeRule { + target: root_path.clone(), + permission: manifest::Permission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }], + deny: vec![], + }) + .unwrap(); + let sessions = Arc::new(WorkdirSessionRouter::default()); + sessions + .attach( + workdir::WorkdirAttachmentAlias::new("source").unwrap(), + Arc::new( + workdir::LocalWorkdirSession::materialized_bound_with_environment( + workdir::Workdir::new(workdir_id.clone()), + root_path.clone(), + root_path.clone(), + manifest::SharedScope::new(scope), + workdir::WorkdirSessionCapabilities::READ_ONLY, + binding.command_environment(), + binding.session_resources().unwrap(), + ), + ), + ) + .unwrap(); + let importer = DriveFeature::new(f.clients[0].clone(), sessions.clone()); + let root = f.root(0).await; + let saved = value(tool(&importer, "DriveSaveWorkdir", json!({"target_workdir":"source","path":"README.md","parent":root,"name":"retained.md","content_type":"text/markdown"})).await.unwrap()); + sessions.close_all().await.unwrap(); + drop(importer); + drop(sessions); + drop(binding); + assert!(root_path.join("README.md").exists()); + let api = source.api.clone(); + let removal_id = workdir_id.clone(); + let removed = tokio::task::spawn_blocking(move || { + execute_workdir_removal( + &api, + &removal_id, + "account:drive-test", + "source no longer needed", + ) + }) + .await + .unwrap() + .unwrap(); + assert_eq!( + removed.disposition, + server_api::WorkingDirectoryRemovalDisposition::Removed, + "{removed:?}" + ); + assert!(!removed.retryable); + assert!( + !materialization_root.exists(), + "actual source materialization must be gone" + ); + assert!( + f.api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, &workdir_id) + .unwrap() + .is_none() + ); + let fresh = DriveFeature::new( + f.clients[1].clone(), + Arc::new(WorkdirSessionRouter::default()), + ); + let read = value( + tool(&fresh, "DriveRead", json!({"entry":entry(&saved)})) + .await + .unwrap(), + ); + assert_eq!(read["entry"], saved["entry"]); + assert_eq!(read["text"].as_str().unwrap().as_bytes(), bytes); + let receipt = value( + tool( + &fresh, + "DriveRequestStatus", + json!({"request_id":saved["request_id"]}), + ) + .await + .unwrap(), + ); + assert_eq!(receipt["state"], "committed"); + assert_eq!(receipt["response"]["entry"], saved["entry"]["metadata"]); +} + +#[tokio::test] +async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replacement() { + // Real embedded Runtime lifecycle/retention; only controller execution is + // deterministic. No Workdir or external model is involved in this case. + let f = Fixture::with_runtime(["read_write", "read_only"], true).await; + let saved = f.create("retained.md", "# Beyond the Worker\n").await; + let id = entry(&saved); + let old = &f.clients[0].worker; + let worker = WorkspaceWorker::resolve_async(&f.api, &old.runtime_id, &old.worker_id) + .await + .unwrap(); + let stopped = worker + .stop( + &WorkerOperationContext::Backend, + WorkerLifecycleRequest { + reason: Some("Drive lifetime test".into()), + ticket_assignment: None, + }, + ) + .await + .unwrap(); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); + assert_eq!(f.api.runtime.worker(old).unwrap().state, "stopped"); + seed_worker_session_for_cleanup(f._temp.path(), old); + let plan = build_runtime_cleanup_plan(&f.api, &old.runtime_id).unwrap(); + let candidate = plan + .workers + .iter() + .find(|c| c.runtime_worker_id == old.worker_id) + .unwrap(); + assert_eq!(candidate.blocking_reason, None); + worker + .delete_from_plan(&WorkerOperationContext::Backend, candidate) + .await + .unwrap(); + assert!(f.api.runtime.worker(old).is_err()); + let client = f.clients[0].clone(); + let capability = tokio::task::spawn_blocking(move || { + client.request( + WorkspaceRequestMethod::Get, + format!("{}/capabilities", base()), + vec![], + 65536, + ) + }) + .await + .unwrap() + .unwrap(); + assert!(!(200..300).contains(&capability.status)); + assert!( + f.api + .store + .get_worker_registry(TEST_WORKSPACE_ID, old) + .unwrap() + .is_none() + ); + denied(tool(&f.features[0], "DriveRead", json!({"entry":id})).await); + let Json(created) = create_workspace_worker( + State(f.api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: EMBEDDED_RUNTIME_ID.into(), + display_name: "Replacement reader".into(), + singleton_key: None, + profile: Some("builtin:coder".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: Default::default(), + control_operation_id: None, + execution_grant: None, + }), + ) + .await + .unwrap(); + assert_ne!(created.worker_id, old.worker_id); + let client = Arc::new(RouterClient { + api: f.api.clone(), + identity: f.clients[0].identity.clone(), + worker: RuntimeWorkerRef::new(created.runtime_id, created.worker_id), + handle: tokio::runtime::Handle::current(), + lose_next_mutation_response: AtomicBool::new(false), + lose_next_upload_response: AtomicBool::new(false), + requests: Mutex::new(vec![]), + }); + let replacement = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::default())); + denied(tool(&replacement, "DriveRead", json!({"entry":id})).await); + owner_call(&f.api, &f.owner, "POST", &format!("{}/grants", base()), Some(json!({"runtime_id":client.worker.runtime_id,"worker_id":client.worker.worker_id,"access":"read_only"}))).await; + let read = value( + tool(&replacement, "DriveRead", json!({"entry":id})) + .await + .unwrap(), + ); + assert_eq!(read["entry"], saved["entry"]); + assert_eq!(read["text"], "# Beyond the Worker\n"); + let receipt = owner_call( + &f.api, + &f.owner, + "GET", + &format!( + "{}/requests/{}", + base(), + saved["request_id"].as_str().unwrap() + ), + None, + ) + .await; + assert_eq!(receipt["state"], "committed"); + assert_eq!(receipt["response"]["entry"], saved["entry"]["metadata"]); + // Stop remaining deterministic controllers before dropping isolated resources. + for target in [&f.clients[1].worker, &client.worker] { + let stopped = f + .api + .runtime + .stop_worker( + target, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); + } +} + #[tokio::test] async fn simultaneous_worker_edits_publish_exactly_one_last_mutation_id_and_preserve_the_loser_conflict() { @@ -1177,11 +1586,30 @@ impl agen::llm_client::LlmClient for NoLlm { panic!("native tool fixture must not call an LLM") } } +fn file_write_reference(observed: &Value) -> Value { + observed["interfaces"] + .as_array() + .unwrap() + .iter() + .find(|i| i["reference"]["name"] == "yoi.file/write-text/v1") + .expect("write capability")["reference"] + .clone() +} fn native_tools( feature: &DriveFeature, +) -> std::collections::BTreeMap> { + native_tools_with_checkouts(feature, None) +} +fn native_tools_with_checkouts( + feature: &DriveFeature, + router: Option>, ) -> std::collections::BTreeMap> { let mut mounts = worker::wip::WipMountRegistry::new(); worker::feature::builtin::drive::wip::mount_drive_wip(&mut mounts, feature).unwrap(); + if let Some(router) = router { + worker::checkout::mount_checkouts(&mut mounts, router, tools::Tracker::new(), None) + .unwrap(); + } let mut engine = agen::Engine::<_, agen::state::Mutable, ()>::new_annotated(NoLlm); engine.register_tools( feature @@ -1257,7 +1685,14 @@ async fn native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_cu ); assert_eq!(read["text"], "native content"); let denied_write = native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":"lost"}})).await; - denied(denied_write); + assert!(denied_write.is_err()); + assert!( + !inspected["interfaces"] + .as_array() + .unwrap() + .iter() + .any(|i| i["reference"]["name"] == "yoi.file/write-text/v1") + ); f.revoke(1).await; assert!( native_tool( @@ -1311,12 +1746,12 @@ async fn native_create_and_write_use_client_managed_committed_requests_and_rejec .await .unwrap(), ); - let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":observed_first["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"committed"}})).await.unwrap()); + let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":file_write_reference(&observed_first),"operation":"write","arguments":{"content":"committed"}})).await.unwrap())["metadata"].clone(); assert_ne!( updated["entry"]["metadata"]["last_mutation_id"], created["entry"]["metadata"]["last_mutation_id"] ); - let error = native_tool(&second, "Invoke", json!({"path":path,"interface":observed_second["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err(); + let error = native_tool(&second, "Invoke", json!({"path":path,"interface":file_write_reference(&observed_second),"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err(); assert!( error.to_string().to_lowercase().contains("validator"), "stale native mutation must report validator conflict: {error:?}" @@ -1439,3 +1874,143 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati "history attachment must not fetch latest Drive content" ); } + +#[tokio::test] +async fn common_file_inspect_invoke_crosses_drive_and_checkout_without_transferring_authority() { + let f = Fixture::new(["read_write", "read_only"]).await; + let created = f.create("common.md", "first\n雪 needle\nlast\n").await; + let path = created["entry"]["path"].as_str().unwrap(); + let checkout = f._temp.path().join("checkout"); + fs::create_dir(&checkout).unwrap(); + fs::write(checkout.join("file"), "first\n雪 needle\nlast\n").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + workdir::WorkdirAttachmentAlias::new("main").unwrap(), + Arc::new(workdir::LocalWorkdirSession::materialized( + checkout.clone(), + checkout.clone(), + manifest::SharedScope::new(manifest::Scope::writable(&checkout).unwrap()), + workdir::WorkdirSessionCapabilities::READ_WRITE, + )), + ) + .unwrap(); + let tools = native_tools_with_checkouts(&f.features[0], Some(router)); + let drive = value( + native_tool(&tools, "Inspect", json!({"path":path})) + .await + .unwrap(), + ); + let local = value( + native_tool(&tools, "Inspect", json!({"path":"/checkouts/main/file"})) + .await + .unwrap(), + ); + for name in [ + "yoi.file/read-lines/v1", + "yoi.file/search-text/v1", + "yoi.file/edit-text/v1", + "yoi.file/write-text/v1", + ] { + let selected = drive["interfaces"] + .as_array() + .unwrap() + .iter() + .find(|i| i["reference"]["name"] == name) + .unwrap(); + let reference = &selected["reference"]; + assert_eq!(reference["scope"], "/"); + assert!( + local["interfaces"] + .as_array() + .unwrap() + .iter() + .any(|i| i["reference"] == *reference) + ); + let signature = value( + native_tool(&tools, "Inspect", json!({"path":selected["path"]})) + .await + .unwrap(), + ); + assert!( + signature["interface_signature"] + .as_str() + .unwrap() + .contains(name) + ); + let (operation, arguments) = match name { + "yoi.file/read-lines/v1" => ("read", json!({"offset":1,"limit":1})), + "yoi.file/search-text/v1" => ("search", json!({"pattern":"needle","limit":2})), + "yoi.file/edit-text/v1" => { + ("edit", json!({"old_string":"needle","new_string":"found"})) + } + _ => ("write", json!({"content":"saved\n"})), + }; + let mut results = vec![]; + for target in [path, "/checkouts/main/file"] { + results.push(value(native_tool(&tools,"Invoke",json!({"path":target,"interface":reference,"operation":operation,"arguments":arguments})).await.unwrap())); + } + if matches!(operation, "read" | "search") { + assert_eq!(results[0], results[1]); + } else { + assert!(results.iter().all(|v| v.get("metadata").is_some())); + } + } + assert_eq!( + fs::read_to_string(checkout.join("file")).unwrap(), + "saved\n" + ); + assert_eq!( + value( + tool( + &f.features[1], + "DriveRead", + json!({"entry":entry(&created)}) + ) + .await + .unwrap() + )["text"], + "saved\n" + ); + // A capability query is not a grant: drop the writer to readonly after the common write was cached. + f.revoke(0).await; + owner_call(&f.api,&f.owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":f.clients[0].worker.runtime_id,"worker_id":f.clients[0].worker.worker_id,"access":"read_only"}))).await; + assert!(native_tool(&tools,"Invoke",json!({"path":path,"interface":file_write_reference(&drive),"operation":"write","arguments":{"content":"lost"}})).await.is_err()); + let refreshed = value( + native_tool(&tools, "Inspect", json!({"path":path,"refresh":true})) + .await + .unwrap(), + ); + assert!( + !refreshed["interfaces"] + .as_array() + .unwrap() + .iter() + .any(|i| i["reference"]["name"] == "yoi.file/write-text/v1") + ); + let common_read = json!({"scope":"/","name":"yoi.file/read-lines/v1"}); + assert_eq!( + value( + native_tool( + &tools, + "Invoke", + json!({"path":path,"interface":common_read,"operation":"read","arguments":{}}) + ) + .await + .unwrap() + )["content"], + " 1\tsaved\n" + ); + assert_eq!( + value( + tool( + &f.features[1], + "DriveRead", + json!({"entry":entry(&created)}) + ) + .await + .unwrap() + )["text"], + "saved\n" + ); +} diff --git a/crates/workspace-server/src/server/tests/workspace_admission_tests.rs b/crates/workspace-server/src/server/tests/workspace_admission_tests.rs index 54f74ebe4..877ce5dff 100644 --- a/crates/workspace-server/src/server/tests/workspace_admission_tests.rs +++ b/crates/workspace-server/src/server/tests/workspace_admission_tests.rs @@ -256,6 +256,7 @@ fn read_result() -> WorkdirSessionOperationResult { total_lines: 1, content_hash: sha2::Sha256::digest(b"visible\n").into(), truncated: false, + byte_truncated: false, }) } diff --git a/crates/workspace-server/src/server/workdir_authorization.rs b/crates/workspace-server/src/server/workdir_authorization.rs new file mode 100644 index 000000000..767f8b862 --- /dev/null +++ b/crates/workspace-server/src/server/workdir_authorization.rs @@ -0,0 +1,521 @@ +//! Subject resource checks at the Workdir provider boundary, in addition to existing ceilings. +//! +//! Paths here are attachment-relative lexical resources. Providers still own resolved-path, +//! symlink, capability and checkout validation; this helper never substitutes for those checks. + +use server_api::authorization::{PermissionResource, ScopedPermission}; +use workdir::{ + WorkdirPath, WorkdirSessionCapabilities, WorkdirToolScopePermission, + http::WorkdirSessionOperation, +}; + +use super::{ApiError, ApiResult, Error, RuntimeWorkerRef, WorkspaceApi}; +use crate::store::WorkerWorkdirLinkRecord; + +const READ: &str = "filesystem.read"; +const WRITE: &str = "filesystem.write"; +const COMMAND: &str = "bash.execute"; + +pub(super) fn authorize( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + link: &WorkerWorkdirLinkRecord, + operation: &WorkdirSessionOperation, +) -> ApiResult<()> { + // These are reconciliation/termination of an already registered effect, not execution. + // Keep the existing worker/attachment/opaque-handle ownership checks on these routes. + if is_command_reconciliation(operation) { + return Ok(()); + } + let permissions = super::subject_authorization::worker_permissions(api, worker)?; + authorize_permissions(permissions.as_deref(), &link.workdir_id, operation) +} + +fn is_command_reconciliation(operation: &WorkdirSessionOperation) -> bool { + matches!( + operation, + WorkdirSessionOperation::CommandCancel(_) | WorkdirSessionOperation::CommandStatus(_) + ) +} + +fn denied(message: &str) -> ApiError { + Error::WorkspacePermissionDenied(format!("Workdir Subject authorization: {message}")).into() +} + +fn resource( + working_directory_id: &str, + operation: &str, + path: &WorkdirPath, + recursive: bool, +) -> ApiResult { + // FsPath also accepts explicitly scoped absolute paths. Subject resources here are + // attachment-relative only, even if the provider separately supports absolute grants. + let normalized = WorkdirPath::new(path.as_str()) + .map_err(|_| denied("operation path must be attachment-relative without escape"))?; + let permission = ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workdir { + working_directory_id: working_directory_id.into(), + path: if normalized.is_root() { + ".".into() + } else { + normalized.as_str().into() + }, + recursive, + }, + }; + permission + .validate() + .map_err(|_| denied("invalid normalized operation resource"))?; + Ok(permission) +} + +fn require( + permissions: &[ScopedPermission], + working_directory_id: &str, + operation: &str, + path: &WorkdirPath, + recursive: bool, +) -> ApiResult<()> { + let requested = resource(working_directory_id, operation, path, recursive)?; + if permissions.iter().any(|granted| granted.covers(&requested)) { + Ok(()) + } else { + Err(denied("operation or resource is outside the current grant")) + } +} + +fn authorize_permissions( + permissions: Option<&[ScopedPermission]>, + working_directory_id: &str, + operation: &WorkdirSessionOperation, +) -> ApiResult<()> { + if is_command_reconciliation(operation) { + return Ok(()); + } + let Some(permissions) = permissions else { + // Non-Subject Workers retain the existing provider/Profile/attachment ceilings. + return Ok(()); + }; + let check = |operation, path: &WorkdirPath, recursive| { + require( + permissions, + working_directory_id, + operation, + path, + recursive, + ) + }; + match operation { + WorkdirSessionOperation::Stat(request) => check(READ, &request.path, false), + WorkdirSessionOperation::Read(request) => check(READ, &request.path, false), + WorkdirSessionOperation::ReadBytes(request) => check(READ, &request.path, false), + WorkdirSessionOperation::Write(request) => check(WRITE, &request.path, false), + WorkdirSessionOperation::Edit(request) => check(WRITE, &request.path, false), + WorkdirSessionOperation::List(request) => { + // Directory enumeration accesses children, not just the directory inode. + check(READ, &request.path, true)?; + if let Some(cursor) = &request.after { + check(READ, &cursor.path, false)?; + } + Ok(()) + } + WorkdirSessionOperation::Glob(request) => check(READ, &request.path, true), + WorkdirSessionOperation::Grep(request) => check(READ, &request.path, true), + WorkdirSessionOperation::AuthorizeScope(request) => check( + match request.permission { + WorkdirToolScopePermission::Read => READ, + WorkdirToolScopePermission::Write => WRITE, + }, + &request.path, + false, + ), + WorkdirSessionOperation::CommandStart(request) => { + // Validating cwd is not a shell confinement mechanism. The command can cd elsewhere, + // so a prefix grant (even one covering cwd recursively) is never enough. + resource(working_directory_id, COMMAND, &request.cwd, false)?; + check(COMMAND, &WorkdirPath::root(), true) + } + WorkdirSessionOperation::CommandOutput(_) => { + // Output may contain anything the original shell read. Unlike status/cancel, + // retrieving its contents requires a current whole-attachment command grant. + check(COMMAND, &WorkdirPath::root(), true) + } + WorkdirSessionOperation::CommandCancel(_) | WorkdirSessionOperation::CommandStatus(_) => { + Ok(()) + } + // These contracts have additional targets, scopes or provider-resolved metadata. + // Deny explicitly until all their accesses can be mapped to scoped resources. + WorkdirSessionOperation::ScopeRulesOverlap(_) + | WorkdirSessionOperation::CheckoutObserve(_) + | WorkdirSessionOperation::CheckoutExecute(_) + | WorkdirSessionOperation::CheckoutSearch(_) => { + Err(denied("operation has no scoped Subject resource mapping")) + } + } +} + +/// Attenuate coarse attachment capabilities, without converting a path grant into root scope. +/// Per-operation authorization above remains required, including for recursive searches. +pub(super) fn intersect_capabilities( + permissions: &[ScopedPermission], + working_directory_id: &str, + ceiling: WorkdirSessionCapabilities, +) -> WorkdirSessionCapabilities { + let has_path_grant = |operation: &str| { + permissions.iter().any(|grant| { + grant.operation == operation + && grant.validate().is_ok() + && matches!(&grant.resource, PermissionResource::Workdir { + working_directory_id: id, .. + } if id == working_directory_id) + }) + }; + let mut allowed = WorkdirSessionCapabilities::EMPTY; + if has_path_grant(READ) { + allowed = allowed.union(WorkdirSessionCapabilities::READ_ONLY); + } + if has_path_grant(WRITE) { + allowed = allowed.union(WorkdirSessionCapabilities::WRITE_ONLY); + } + if require( + permissions, + working_directory_id, + COMMAND, + &WorkdirPath::root(), + true, + ) + .is_ok() + { + allowed = allowed.union(WorkdirSessionCapabilities::COMMAND_ONLY); + } + ceiling.intersection(allowed) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use workdir::{ + CommandHandle, CommandOutputRequest, CommandRequest, EditRequest, GlobRequest, GrepRequest, + ListRequest, ReadBytesRequest, ReadRequest, StatRequest, WriteRequest, + }; + + fn path(value: &str) -> WorkdirPath { + WorkdirPath::new(value).unwrap() + } + + fn grant(operation: &str, target: &str, recursive: bool) -> ScopedPermission { + resource("wd", operation, &path(target), recursive).unwrap() + } + + fn allowed(grants: &[ScopedPermission], operation: &WorkdirSessionOperation) -> bool { + authorize_permissions(Some(grants), "wd", operation).is_ok() + } + + fn read(target: &str) -> WorkdirSessionOperation { + WorkdirSessionOperation::Read(ReadRequest { + path: path(target), + offset: 0, + limit: 10, + max_bytes: 1024, + }) + } + + fn command(cwd: &str) -> WorkdirSessionOperation { + WorkdirSessionOperation::CommandStart(CommandRequest { + command: "cd ..; cat secret".into(), + timeout_secs: 1, + transport_byte_budget: 1024, + cwd: path(cwd), + tool_call_id: None, + }) + } + + #[test] + fn real_filesystem_operations_require_exact_operation_and_attachment_scope() { + let read_grant = grant(READ, "src", true); + let write_grant = grant(WRITE, "src", true); + let reads = [ + read("src/a"), + WorkdirSessionOperation::Stat(StatRequest { + path: path("src/a"), + }), + WorkdirSessionOperation::ReadBytes(ReadBytesRequest { + path: path("src/a"), + offset: 0, + max_bytes: 10, + expected_hash: None, + }), + WorkdirSessionOperation::List(ListRequest { + path: path("src"), + limit: 10, + after: None, + }), + WorkdirSessionOperation::Glob(GlobRequest { + pattern: "**/*".into(), + path: path("src"), + limit: 10, + }), + WorkdirSessionOperation::Grep(GrepRequest { + pattern: "needle".into(), + path: path("src"), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode: Default::default(), + limit: 10, + offset: 0, + }), + ]; + for operation in reads { + assert!( + allowed(std::slice::from_ref(&read_grant), &operation), + "{operation:?}" + ); + assert!( + !allowed(std::slice::from_ref(&write_grant), &operation), + "{operation:?}" + ); + assert!( + authorize_permissions( + Some(std::slice::from_ref(&read_grant)), + "other-wd", + &operation + ) + .is_err() + ); + } + let writes = [ + WorkdirSessionOperation::Write(WriteRequest { + path: path("src/a"), + content: vec![], + expected_hash: None, + }), + WorkdirSessionOperation::Edit(EditRequest { + path: path("src/a"), + old_string: "a".into(), + new_string: "b".into(), + replace_all: false, + expected_hash: [0; 32], + }), + ]; + for operation in writes { + assert!(allowed(std::slice::from_ref(&write_grant), &operation)); + assert!(!allowed(std::slice::from_ref(&read_grant), &operation)); + } + assert!(!allowed(&[read_grant], &read("src-other/a"))); + } + + #[test] + fn activation_workspace_and_parent_operation_names_do_not_grant_file_access() { + for permission in [ + ScopedPermission { + operation: "filesystem".into(), + resource: PermissionResource::Feature, + }, + grant("filesystem", ".", true), + grant("filesystem.read.extra", ".", true), + ScopedPermission { + operation: READ.into(), + resource: PermissionResource::Workspace { + workspace_id: "ws".into(), + }, + }, + ] { + assert!(!allowed(&[permission], &read("src/a"))); + } + assert!(!allowed(&[], &read("src/a"))); + assert!(authorize_permissions(None, "wd", &read("src/a")).is_ok()); + } + + #[test] + fn nonrecursive_grant_cannot_enumerate_descendants_or_search() { + let grants = [grant(READ, "src", false)]; + assert!(allowed(&grants, &read("src"))); + assert!(!allowed(&grants, &read("src/a"))); + assert!(!allowed( + &grants, + &WorkdirSessionOperation::List(ListRequest { + path: path("src"), + limit: 10, + after: None + }) + )); + assert!(!allowed( + &grants, + &WorkdirSessionOperation::Glob(GlobRequest { + pattern: "a".into(), + path: path("src"), + limit: 10 + }) + )); + } + + #[test] + fn wire_paths_are_normalized_and_escape_is_denied_before_provider_dispatch() { + let grants = [grant(READ, "src", true)]; + for bad in [ + "/src/a", + "../src/a", + "src/../secret", + "src\\a", + "C:/src/a", + "src/\u{0}a", + ] { + let operation = serde_json::from_value::(json!({ + "operation": "stat", "request": {"path": bad} + })); + // Some escapes are already rejected by FsPath's wire boundary. Absolute + // provider paths and malformed resource names must also fail this boundary. + if let Ok(operation) = operation { + assert!(!allowed(&grants, &operation), "{bad:?}"); + } + } + let operation: WorkdirSessionOperation = serde_json::from_value(json!({ + "operation": "stat", "request": {"path": "./src//a"} + })) + .unwrap(); + assert!(allowed(&grants, &operation)); + } + + #[test] + fn scope_probes_and_list_cursors_cannot_access_outside_the_granted_resource() { + let grants = [grant(READ, "src", true)]; + for (target, permission, expected) in [ + ("src/a", WorkdirToolScopePermission::Read, true), + ("private/a", WorkdirToolScopePermission::Read, false), + ("src/a", WorkdirToolScopePermission::Write, false), + ] { + let operation = WorkdirSessionOperation::AuthorizeScope( + workdir::WorkdirScopeAuthorizationRequest { + rules: vec![], + path: path(target), + permission, + }, + ); + assert_eq!(allowed(&grants, &operation), expected); + } + let list = |cursor| { + WorkdirSessionOperation::List(ListRequest { + path: path("src"), + limit: 10, + after: Some(workdir::ListCursor { + kind: workdir::EntryKind::File, + path: path(cursor), + }), + }) + }; + assert!(allowed(&grants, &list("src/a"))); + assert!(!allowed(&grants, &list("private/a"))); + } + + #[test] + fn arbitrary_shell_requires_recursive_attachment_root_not_cwd_scope() { + assert!(!allowed(&[grant(COMMAND, "src", true)], &command("src"))); + assert!(!allowed(&[grant(COMMAND, ".", false)], &command("src"))); + assert!(!allowed(&[grant(WRITE, ".", true)], &command("src"))); + assert!(allowed(&[grant(COMMAND, ".", true)], &command("src"))); + } + + #[test] + fn revocation_denies_new_execute_and_output_but_preserves_cancel_and_status() { + let handle = CommandHandle("owned-opaque-handle".into()); + assert!(allowed( + &[], + &WorkdirSessionOperation::CommandCancel(handle.clone()) + )); + assert!(allowed( + &[], + &WorkdirSessionOperation::CommandStatus(handle.clone()) + )); + assert!(!allowed(&[], &command("."))); + let output = WorkdirSessionOperation::CommandOutput(CommandOutputRequest { + handle, + cursor: 0, + limit: 10, + wait: false, + }); + assert!(!allowed(&[], &output)); + assert!(allowed(&[grant(COMMAND, ".", true)], &output)); + } + + #[test] + fn unsupported_checkout_and_scope_comparison_fail_closed_for_scoped_workers() { + let grants = [ + grant(READ, ".", true), + grant(WRITE, ".", true), + grant(COMMAND, ".", true), + ]; + let operations = [ + serde_json::from_value::(json!({ + "operation": "scope_rules_overlap", + "request": { + "left": {"target": "src", "permission": "read", "recursive": true}, + "right": {"target": "private", "permission": "read", "recursive": true} + } + })) + .unwrap(), + WorkdirSessionOperation::CheckoutObserve(path("src")), + WorkdirSessionOperation::CheckoutExecute(workdir::CheckoutRequest { + target: path("src"), + validator: vec![], + operation: workdir::CheckoutOperation::Create { + path: path("outside/a"), + content: vec![], + }, + }), + WorkdirSessionOperation::CheckoutSearch(workdir::CheckoutSearchRequest::new( + workdir::CheckoutSearchOperation::List(ListRequest { + path: path("src"), + limit: 10, + after: None, + }), + )), + ]; + for operation in operations { + assert!(!allowed(&grants, &operation)); + assert!(authorize_permissions(None, "wd", &operation).is_ok()); + } + } + + #[test] + fn readonly_delegation_attenuates_edit_capable_profile_without_losing_narrow_read_scope() { + let grants = [grant(READ, "src", true)]; + assert_eq!( + intersect_capabilities(&grants, "wd", WorkdirSessionCapabilities::ALL), + WorkdirSessionCapabilities::READ_ONLY + ); + assert_eq!( + intersect_capabilities(&grants, "other", WorkdirSessionCapabilities::ALL), + WorkdirSessionCapabilities::EMPTY + ); + assert_eq!( + intersect_capabilities( + &[grant(WRITE, ".", true)], + "wd", + WorkdirSessionCapabilities::READ_ONLY + ), + WorkdirSessionCapabilities::EMPTY + ); + assert_eq!( + intersect_capabilities( + &[grant(COMMAND, "src", true)], + "wd", + WorkdirSessionCapabilities::ALL + ), + WorkdirSessionCapabilities::EMPTY + ); + assert_eq!( + intersect_capabilities( + &[grant(COMMAND, ".", true)], + "wd", + WorkdirSessionCapabilities::READ_WRITE + ), + WorkdirSessionCapabilities::EMPTY + ); + } +} diff --git a/crates/workspace-server/src/server_assignment_attention_tests.rs b/crates/workspace-server/src/server_assignment_attention_tests.rs index 2f5c36ca8..ae622fe8b 100644 --- a/crates/workspace-server/src/server_assignment_attention_tests.rs +++ b/crates/workspace-server/src/server_assignment_attention_tests.rs @@ -466,7 +466,6 @@ impl AttentionWorkerFactory { worker.with_notification_coalesce_delay(std::time::Duration::from_millis(10)), worker::WorkerBootstrapLayout::RuntimeManagedRun { run_dir: root.join(uuid::Uuid::now_v7().to_string()), - bash_output_dir: root.join("bash-output"), }, worker::WorkerControllerTransport::InProcess, ) diff --git a/crates/workspace-server/src/server_assignment_delivery_tests.rs b/crates/workspace-server/src/server_assignment_delivery_tests.rs index 5d397814a..af1948942 100644 --- a/crates/workspace-server/src/server_assignment_delivery_tests.rs +++ b/crates/workspace-server/src/server_assignment_delivery_tests.rs @@ -799,7 +799,6 @@ impl DeliveryWorkerFactory { worker::WorkerFilesystemAuthority::None, worker::WorkerBootstrapLayout::RuntimeManagedRun { run_dir: root.join(uuid::Uuid::now_v7().to_string()), - bash_output_dir: root.join("bash-output"), }, worker::WorkerControllerTransport::InProcess, ) diff --git a/crates/workspace-server/src/server_assignment_start_tests.rs b/crates/workspace-server/src/server_assignment_start_tests.rs index e4f7656fe..fa1b80235 100644 --- a/crates/workspace-server/src/server_assignment_start_tests.rs +++ b/crates/workspace-server/src/server_assignment_start_tests.rs @@ -53,6 +53,7 @@ async fn start_source(api: &mut WorkspaceApi, runtime: &WorkdirlessFixtureRuntim resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ); summary.tags = vec!["orchestrator".into(), "singleton".into()]; diff --git a/crates/workspace-server/src/server_assignment_tracking_tests.rs b/crates/workspace-server/src/server_assignment_tracking_tests.rs index 275bf9ff7..a6b3740e9 100644 --- a/crates/workspace-server/src/server_assignment_tracking_tests.rs +++ b/crates/workspace-server/src/server_assignment_tracking_tests.rs @@ -59,6 +59,7 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_ workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -794,6 +795,7 @@ async fn tracking_fixture( resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ); summary.tags = vec!["orchestrator".into(), "singleton".into()]; diff --git a/crates/workspace-server/src/server_drive.rs b/crates/workspace-server/src/server_drive.rs index b055c3b1b..52581e416 100644 --- a/crates/workspace-server/src/server_drive.rs +++ b/crates/workspace-server/src/server_drive.rs @@ -238,6 +238,25 @@ pub(super) async fn root( let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; blocking(false, move || drive.root()).await.map(public_node) } +/// Query only the authenticated caller's current ceiling using the operation authorizer. +/// This is admission/publication evidence, never authority retained for a later mutation. +pub(super) async fn capabilities( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, +) -> DriveResult { + let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; + let write = blocking(false, move || match drive.check_authorized(true) { + Ok(()) => Ok(true), + Err(workspace_drive::Error::Denied) => { + drive.check_authorized(false)?; + Ok(false) + } + Err(error) => Err(error), + }) + .await?; + Ok(DriveCapabilities { write }) +} pub(super) async fn metadata( api: &WorkspaceApi, context: &ServerRequestContext, @@ -581,6 +600,9 @@ pub(super) fn router(service: Arc) -> Router { .merge(streaming_router(service.clone())) .merge(server_api::server_api_axum::drive_root(service.clone())) .merge(server_api::server_api_axum::drive_metadata(service.clone())) + .merge(server_api::server_api_axum::drive_capabilities( + service.clone(), + )) .merge(server_api::server_api_axum::drive_list(service.clone())) .merge(server_api::server_api_axum::drive_search(service.clone())) .merge(server_api::server_api_axum::drive_read_text( diff --git a/crates/workspace-server/src/server_ticket_tools_backend_tests.rs b/crates/workspace-server/src/server_ticket_tools_backend_tests.rs index 637d44a5d..662bc03b0 100644 --- a/crates/workspace-server/src/server_ticket_tools_backend_tests.rs +++ b/crates/workspace-server/src/server_ticket_tools_backend_tests.rs @@ -14,7 +14,7 @@ use worker::{ struct TicketRouterClient { api: WorkspaceApi, identity: Arc, - source: RuntimeWorkerRef, + source: Mutex, runtime: tokio::runtime::Handle, lose_next_response: AtomicBool, requests: Mutex>, @@ -45,9 +45,10 @@ impl WorkspaceClient for TicketRouterClient { WorkspaceRequestMethod::Patch => "PATCH", WorkspaceRequestMethod::Delete => "DELETE", }; + let source = self.source.lock().unwrap().clone(); let signed = runtime_source_request( &self.identity, - Some(&self.source.worker_id), + Some(&source.worker_id), method, &request.path, request.body.unwrap_or_default().into_bytes(), @@ -225,7 +226,7 @@ fn ticket_client( Arc::new(TicketRouterClient { api: api.clone(), identity: Arc::new(identity), - source: source.clone(), + source: Mutex::new(source.clone()), runtime: tokio::runtime::Handle::current(), lose_next_response: AtomicBool::new(false), requests: Mutex::new(Vec::new()), @@ -307,18 +308,16 @@ async fn ticket_public_roundtrip_observed_answer_replays_without_changing_waitin assert!(error.to_string().contains("409"), "{error}"); // A valid Runtime signature is not a Worker membership grant. The // Backend must reject this unregistered source through the same tools. - let denied = Arc::new(TicketRouterClient { - api: api.clone(), - identity: client.identity.clone(), - source: RuntimeWorkerRef::new( - source.runtime_id.clone(), - "00000000-0000-7000-8000-000000000099", - ), - runtime: tokio::runtime::Handle::current(), - lose_next_response: AtomicBool::new(false), - requests: Mutex::new(Vec::new()), - }); - let denied_surface = TicketSurface::new(&dir.path().join("denied"), denied, wip).await; + // Activate the real Feature while the source is registered, then lose + // membership before dispatch. Otherwise activation itself correctly rejects + // the fixture before it can exercise the operation's authentication check. + let denied = ticket_client(&api, (*client.identity).clone(), &source); + let denied_surface = + TicketSurface::new(&dir.path().join("denied"), denied.clone(), wip).await; + *denied.source.lock().unwrap() = RuntimeWorkerRef::new( + source.runtime_id.clone(), + "00000000-0000-7000-8000-000000000099", + ); let error = denied_surface .call("TicketAssignmentAnswer", input.clone(), context("denied")) .await diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs index 1e989edd8..368a102f0 100644 --- a/crates/workspace-server/src/server_workspace_config_wip_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -187,7 +187,7 @@ async fn config_call( runtime .invoke( path.into(), - config_interface(path), + if matches!(operation,"edit"|"write") {wip_protocol::InterfaceReference {scope:"/".into(),name:format!("yoi.file/{operation}-text/v1")}} else {config_interface(path)}, operation.into(), args, agen::tool::ToolExecutionContext::direct(), @@ -856,6 +856,7 @@ async fn workspace_config_actual_remote_and_embedded_clients_use_authenticated_w workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index b9dd24f8a..d742f0ec6 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -7131,6 +7131,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> { self.with_immediate_transaction(|conn| { activation::require_active(conn, &record.user_id)?; + // Retain the real row throughout the non-authenticating recovery window, not + // forever. Issuance amortizes reclamation without extending marker lifetime or + // introducing another store/scheduler. Inactive stores do not grow between issues. + let cutoff = (chrono::Utc::now() + - chrono::Duration::seconds(crate::auth::SESSION_EXPIRY_GRACE_SECONDS)) + .to_rfc3339(); + conn.execute( + "DELETE FROM browser_sessions WHERE julianday(expires_at) <= julianday(?1) OR julianday(expires_at) IS NULL", + params![cutoff], + )?; conn.execute( r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, @@ -21014,6 +21024,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, resolved_workspace_api: None, resolved_memory_settings: None, resolved_subjektiv_attached: false, diff --git a/docs/development/regression-test-design.md b/docs/development/regression-test-design.md index 255d260f1..91164df56 100644 --- a/docs/development/regression-test-design.md +++ b/docs/development/regression-test-design.md @@ -95,6 +95,8 @@ T-744では既存Rust代表8件が成功(上表以外の公開reissue/case/Ans T-725現行本文・保留trackingを読んだ。`workspace-drive/tests/storage.rs::receipts_survive_response_loss_and_restart_without_double_mutation` は実DB/blob再open後のreceipt一致、同request再送一致、別actor競合拒否、list件数1をassertする。`workspace-server/src/server/tests/worker_drive_tests.rs::lost_mutation_response_is_resolved_by_receipt_without_resending_effect` は本番DriveFeature→署名router/storeを通し、receipt committed・POST mutate数1・別Worker listのlast mutation一致をassertするがRuntime起動はfixtureである。これらの保存/応答喪失規則を再設計しない。実Web adapter接続は `drive_web_roundtrip_tests.rs::repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_reference_lifecycle` の既存ignore/明示実行であり、通常suiteの成功へ含めない。Worker寿命・Web画面までの不足と必要コストはT-725再開時の判断で、今回未確認とする。 +**2026-10-19、T-725再開後の候補実装**は[Drive接続の調査・検証記録](../report/t725-drive-connections.md)へ分離した。通常Server libにuser upload→WorkdirなしTools/WIP→owner/別Workerの同一node・mutation・bytes・receipt、実Runtime Worker停止/削除→明示grant付き交換Worker、実Runtime HTTP経由のsource Workdir削除→Drive資料保持の3件を追加した。保存/応答喪失/GC/backupは既存を維持し、Ticket一巡も複製しない。3件の単独libtest実測合計5.54秒(Cargo wall合計6.324秒、build温存時)であり、通常runner全体の増分・並列負荷は未測定。代表的な本文投影欠落とcleanupによる誤ったDrive blob削除のmutantを検出し、復元後Server全crateを成功確認した。既存ignored Web adapterも別途1件成功したがbrowser/実モデル/全Workspace runner/CIの成功には含めない。上の保留記述はT-761設計時の観測であり、この候補を対象developへ採用・統合したという記録ではない。 + ## 設計時の代表試行と残った検証 全て専用checkoutで逐次実行、Rust buildはjobs 2。表の本体はlibtest/Deno表示、外側は`time -p`の1回測定であり、通常suite全体の増分やCI中央値ではない。 diff --git a/docs/report/t725-drive-connections.md b/docs/report/t725-drive-connections.md new file mode 100644 index 000000000..7017c62c0 --- /dev/null +++ b/docs/report/t725-drive-connections.md @@ -0,0 +1,75 @@ +# Drive共有の接続不足と検証(T-725、2026-10-19) + +## 判断の基準 + +基準 `adfa42297352c5a64fe8100969aad6fda4df8a81` と着手HEAD `cea0225a6e2d9e23c59a7d4a453f99bc2e1eba97` を比較した。差分81 filesはSession保存、Journal、Ticket公開操作、復元親の子操作等であり、以下のDrive保存/API/Worker/Webテストに差分はない。Cargo.lockの `wip-{client,http,protocol,text-view}` はすべてcrates.ioの0.2.0で、ローカル差替えはしていない。 + +検証したテスト実装は専用branch `work/T-725-drive-connections` の `ec6c5bed`。以下の結果はこの実装に対する証拠であり、対象developへの統合判断とは分ける。 + +T-721〜724の保存・公開操作を再実装せず、**別入口の返却値が次の操作へ渡る接続と、cleanupの所有境界**だけを追加した。製品修正は不要だった。Ticket一巡は実装・採用済みT-744の `ticket-regression-test-design.md`、通常suiteの判断はT-761の `regression-test-design.md` と分担する。関連Ticketの変更・再開はしていない。 + +## 優先順位と追加した契約 + +配置は `crates/workspace-server/src/server/tests/worker_drive_tests.rs`。以下はすべて通常のServer lib targetであり、ignoreやテスト専用featureは追加しない。 + +| 優先 | 利用結果/具体故障・コード根拠 | 既存証拠の限界/採否 | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P1 | ユーザーuploadの返却node/mutationをWorkdirなしTools/WIPが読み更新し、owner/別Workerが同一資料を取得する。`server_drive::upload/read_text/public_node`、Worker `drive/backend.rs::read/mutate` とWIPの投影がずれると、個別成功でも本文や観測が別物になる。 | `workdirless_workers_share_markdown_through_current_read_and_write_grants` はWorker作成から別Worker読取り。`image_attachment_retains_original_bytes_after_drive_update_and_revocation` はuser uploadから画像添付。`native_create_and_write_use_client_managed_committed_requests_and_reject_stale_worker` はWIP作成から更新。この間の**user Markdown binary upload→Tools/WIP更新**を新規接続。 | +| P2 | Workerを止め、保持規則に従い削除・交換してもDrive文書の参照と内容が残る。`server/worker_operations.rs::stop/delete_from_plan`、`WorkerRemovalService` と `store::commit_worker_catalog_removal` はWorker権限を終え、Drive資料を削除してはならない。 | `cleanup_service_deletes_stopped_singleton_worker` は実Runtime停止・削除・交換、storeの `drive_grants_migrate_and_preserve_audit_through_revoke_worker_delete_and_restart` はgrant取消監査を確認するが、Drive資料の再取得はない。**元参照→交換Workerの明示grant後読取り**を新規接続。停止だけでgrantが失効するという別契約は追加しない。 | +| P2 | source Workdirを本当に削除した後も保存した資料を使える。`drive.rs` のSaveWorkdirはbytesをAPIへ渡し、`execute_workdir_removal`→Runtime→`working_directory/cleanup.rs` はmaterializationだけを削除する。誤ったroot削除やsource依存を検出する。 | `scoped_workdir_import_publishes_document_and_image_bytes_with_bounded_reads` は独立metadata、完全download bytes、画像添付を確認するがsourceが残る。**実provider削除の後に同node/mutation/bytes/receiptを読む**接続を追加。保存crateのbackup/GC行列は増やさない。 | + +### 入力・fixture・観測点 + +- `user_uploaded_markdown_roundtrips_through_workdirless_tools_and_native_wip_at_one_identity`:既存Fixtureの実SQLite/FeatureStorage/LocalFileSystem、2 Workerの空Workdir links、明示read_write/read_only grantを使用。owner binary PUTはsize/SHA256と日本語Markdown bytesを渡す。Toolsとnative WIPの2経路で読取り・writeを行う。返却Workspace/node、uploadのlast_mutation_id、更新request ID=last_mutation_id、別Worker readのentry/本文、owner metadata、committed receiptのentry、mutation固定downloadの完全bytesを照合する。WIPは本番install/Inspect/Invoke、外部LLMは呼ぶとpanicする。Runtime起動とHTTP transportはfixtureだが、毎要求の署名・router・現grant・storeは本番。 +- `worker_removal_preserves_drive_reference_for_explicitly_granted_replacement`:real embedded Runtime+既存deterministic controller backendでRepository/WorkdirなしWorkerが文書を作る。本番WorkspaceWorker stop→Runtime stopped→cleanup plan→delete_from_planを通し、Runtime/catalog除去、旧Workerの拒否を確認。別IDの交換Workerはgrant前拒否、read_only付与後に**元参照**で完全entry(latest_url、last_mutation_idを含む)と本文が一致し、元receiptもcommitted。保持対象Sessionは既存fixtureで準備する。lifecycle admissionは内部Backend test contextであり、browser/Worker-control認証の新証拠ではない。実controllerのshutdown/モデル挙動は代替されている。 +- `source_workdir_removal_keeps_published_drive_bytes_and_receipt`:`ticket_worker_launch_tests::LaunchFixture` の既存認証付きloopback Runtimeを共有する。通常のServer Workdir createでlocal Git sourceからcleanなREADME.mdをmaterializeする。source sessionはLocalWorkdirSessionでread scopeと**実materializer lease**を渡す(Runtime session transportは代替)。DriveSaveWorkdirはalias `source`+論理path `README.md`、元bytes `clean\n`。session close/drop後、Server removal→認証付きRuntime HTTP→実materializer unlinkを通し、Removed/nonretryable・物理root不存在・registry不存在をassert。その後、新しいWorkdirなしDriveFeatureから元entry/bytes/receiptを照合する。`on_api`のcloneは同じstore/drive_host/Runtimeを共有し、別DBを作らない。 + +Workdir用に新しい起動基盤は作らず、既存Launch backendにcleanupの実materializerへの委譲を足し、materializer配置をfixtureのTempDir配下へ閉じた。listenerは既存Dropでabort、通信は既存有界timeoutを利用する。embedded Server adapterは仕様上Workdir create/status/cleanup非対応(`hosts.rs`)。最初のembedded試行はAttentionRequiredとなったため不採用とし、成功fakeへ変えず既存HTTP harnessへ切り替えた。 + +## 追加しなかった保証と既存assertion + +パスは、断りがなければServer `src/server/tests/` 配下。 + +| 保証/採否 | 主な実在テストと観測、代替境界 | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| stale先勝ち:維持 | `simultaneous_worker_edits_publish_exactly_one_last_mutation_id_and_preserve_the_loser_conflict` は成功1件・敗者StructuredConflict・勝者本文/metadata、`stale_worker_observations_cannot_write_edit_relocate_or_delete_newer_last_mutation_id` はwrite/edit/move/delete拒否と本文/名前保持。nativeは前掲create/write testでvalidator conflictと勝者bytes。Tools/WIP→実署名router、Runtimeのみfixture。 | +| 現grant取消:維持 | `revocation_invalidates_cached_metadata_reads_writes_and_receipt_queries` はread/write/list/search/status等のdeniedと別Worker本文保持。`native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_current_grants` はcached invoke/refreshed inspect拒否。`drive_api_tests::drive_verified_upload_revoked_during_reception_cannot_commit_or_replay` はbody frameで取消、403/denied、uncommitted、list空。最後の注入はverified-source Extensionを使用し、署名ingress自体の証拠とは分ける。 | +| commit後応答喪失:維持 | `lost_mutation_response_is_resolved_by_receipt_without_resending_effect` と `lost_binary_upload_reply_reconciles_same_request_without_duplicate_publication` は本番router成功後の返答だけをtransportで落とす。committed/元node、POSTまたはPUT数1、元requestのGET照会、別Worker一覧1件とmutation一致をassert。単なる失敗応答fakeではない。 | +| Web接続:維持/明示実行 | `drive_web_roundtrip_tests::repositoryless_drive_shares_worker_bytes_and_enforces_current_read_only_grants_over_http` はRepositoryなしbootstrap、実HTTP、Worker grant/revokeの403、一覧entryと実blob bytesを確認。`repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_reference_lifecycle` は実HTTPと本番Deno adapter(parser/operation/grant)を接続する既存ignored test。Worker起動はfixture、browser DOM/Passkey loginは対象外。画面draft/取消等は別のsynthetic API browser suiteを重複しない。 | +| 保存/reopen/backup/GC:所有crateを維持 | `workspace-drive/tests/storage.rs::markdown_image_empty_and_folders_survive_restart_with_stable_identity` はDrive再open後のmetadata/本文/画像。`receipts_survive_response_loss_and_restart_without_double_mutation` はreceipt/replay一致・別actor拒否・list1件。`stopped_writer_backup_restores_ids_last_mutation_ids_receipts_and_sequence_with_blobs` は一致するDB+blobsの復元、元metadata/bytes/receiptと新ID採番。`src/tests.rs::saved_blob_before_metadata_failure_is_not_published_and_restart_collects_it` は未公開・uncommitted・再open回収、`commit_failure_rolls_back_metadata_and_receipt_together` はmetadata/receipt同時rollback。`collection_cannot_remove_in_flight_upload_or_selected_read` はin-flight保護。これらをServer全体再起動や実電源断保証に拡張せず、新たな保存障害行列は不採用。 | + +## 故障検出試行と復元 + +以下は意図的なmutantであり、製品不具合の発見ではない。patchとログは `target/t725/` に保持し、製品sourceは復元した。 + +| 試行 | 注入と検出 | 実行結果(外側wall/libtest) | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `mutant-read` | `server_drive::read_text` の返却本文を `String::new()` にする。P1 filterの元upload本文比較が空文字≠日本語Markdownで失敗。 | exit101、64.156秒/0.41秒(build込み) | +| `mutant-cleanup` | `execute_workdir_removal` に誤ったDrive blob rootのremove_dir_allを挿入。実source削除後のfresh DriveReadが公開error「Drive storage or transport unavailable」で失敗。 | exit101、88.103秒/0.59秒(build込み) | +| 復元 | `server.rs` と `server_drive.rs` に差分がないことを検査後、Server全crateと3つの個別filterを実行。 | 下表の全成功。故障をテストから避ける製品修正はない。 | + +開発中にはsource fixtureの未定義git helperによるcompile error、準備用Repository FK不足、embedded adapter非対応の試行失敗もあった。helperを増やさず既存clean READMEを使用し、最終fixtureは本番Server create+既存HTTP Runtimeを利用した。これらを製品regressionや故障検出成功には数えない。いずれも `cargo test --locked -p yoi-workspace-server --lib source_workdir_removal_keeps` の試行で、`source-removal-first` はcompile exit101/wall6.875秒、`source-removal` はFK失敗exit101/wall84.053秒/libtest1.64秒、`source-removal-fixed` は非対応判定exit101/wall56.519秒/libtest2.14秒、HTTP経路への修正後 `source-removal-http` は1 pass/wall83.709秒/libtest0.61秒。時間採取は `/usr/bin/time` がないためBash `TIMEFORMAT` を使用し、出力のなかった背景起動の試行はbuild成功に数えなかった。 + +## 実行区分・費用とT-761への接続 + +すべて専用checkoutで実行した単発実測であり、CIや全通常suiteの速度ではない。`wall` はCargo起動/build込み、libtest時間は実行のみ。既存dead_code warningsは継続。 + +| command(root) | 結果 | wall/実行秒 | +| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ---------------------- | +| `cargo test --locked -p yoi-workspace-server --lib worker_drive_tests --no-run` | 初回build成功 | 234.981/実行なし | +| `cargo test --locked -p yoi-workspace-server --lib worker_drive_tests -- --test-threads=4` | P1+Worker寿命追加時20 pass | 71.131/9.69 | +| `cargo test --locked -p yoi-workspace-server -- --test-threads=4` | 最終実装1210 lib+12 bin pass、既存3 ignored、doctest対象0 | 400.021/291.85(lib) | +| `cargo test --locked -p yoi-workspace-server --lib user_uploaded_markdown_roundtrips` | 復元後1 pass(Tools/WIP両方) | 0.994/0.72 | +| `cargo test --locked -p yoi-workspace-server --lib worker_removal_preserves_drive_reference` | 復元後1 pass | 4.458/4.20 | +| `cargo test --locked -p yoi-workspace-server --lib source_workdir_removal_keeps` | 復元後1 pass | 0.872/0.62 | +| `cargo test --locked -p workspace-drive` | 40 unit+13 storage pass、doctest対象0 | 4.934/0.21+0.09 | +| `cargo check --locked` | root通常compile closure成功 | 75.368 | +| `cargo test --locked -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored` | **明示**Web adapter1 pass | 1.051/0.75 | + +最終module境界コメントの補正後にも同じ `worker_drive_tests -- --test-threads=4` を実行し21 pass(wall71.296秒、libtest9.75秒)を確認した。`cargo doc --locked -p yoi-workspace-server --no-deps` は成功(wall70.312秒)。`cargo fmt --all -- --check`(3.824秒)、`git diff --check HEAD`(0.004秒)、`env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts`(0.189秒、0 unapproved/0 stale exceptions)、2文書の `deno fmt --check` も成功した。文書の相対参照先は実在する。 + +T-761の通常runnerはCargo通常lib targetを列挙するため、追加3件は同targetのままで選択される。3件の逐次libtest実測合計5.54秒(単独Cargo wall合計6.324秒)。既存runnerのshard/並列負荷やcold build増分は未測定であり、全体時間へ単純加算しない。最も重いWorker寿命4.20秒は実永続Runtime停止・保持・再作成を検出する費用で、既存DB単体や共有2Workerの成功では代替できない。通常suiteへの追加はこの3件だけとし、Web adapterの既存ignoreは変更しない。 + +全Workspace `deno task test`、browser、製品全体E2E、実モデル判断、物理電源断、Server/Runtime全体再起動、CI実行は未実施。新規CI jobは追加しておらず、CIへの自動配線・実行済みとは扱わない。実モデルの調査・待機・完了判断を固定LLM応答で証明せず、追加したのは機構の接続保証である。 + +証拠は `target/t725/*.log`・`*.time` と2つのmutant patch。報告書へ生ログ/秘密は転載しない。稼働環境・対象develop・Ticket caseは変更していない。 diff --git a/openapi/server-api.json b/openapi/server-api.json index e601f843e..720c13a72 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -443,6 +443,56 @@ "additionalProperties": false, "type": "object" }, + "BrowserSessionState": { + "description": "Browser navigation evidence, not an authorization grant or a generic 401 classification.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "anonymous", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expires_at": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "const": "authenticated", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "expired", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, "BrowserTicketState": { "enum": [ "open", @@ -1589,6 +1639,18 @@ "display_name": { "type": "string" }, + "execution_grant": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkerDelegationRequest" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Requested least-authority child permissions. Host derives the origin; this\ninput cannot assert Subject identity or create a body/singleton attachment." + }, "feature_connections": { "$ref": "#/components/schemas/WorkspaceWorkerFeatureConnectionsRequest", "description": "Explicit product Feature connections. Backend validation turns these into\ntrusted host attachments; Runtime and Profile inputs cannot self-attest them." @@ -1951,6 +2013,19 @@ ], "type": "string" }, + "DriveCapabilities": { + "additionalProperties": false, + "description": "Current caller's advisory publication ceiling, never a grant or execution credential.", + "properties": { + "write": { + "type": "boolean" + } + }, + "required": [ + "write" + ], + "type": "object" + }, "DriveDownloadQuery": { "additionalProperties": false, "properties": { @@ -6817,6 +6892,86 @@ ], "type": "object" }, + "PermissionResource": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "feature", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workspace", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workdir", + "type": "string" + }, + "path": { + "type": "string" + }, + "recursive": { + "type": "boolean" + }, + "working_directory_id": { + "type": "string" + } + }, + "required": [ + "kind", + "working_directory_id", + "path", + "recursive" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "worker", + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "kind", + "runtime_id", + "worker_id" + ], + "type": "object" + } + ] + }, "ProfileSettingsResponse": { "additionalProperties": false, "description": "Read-only Profile catalog projected from one active Workspace config tree.", @@ -9514,6 +9669,22 @@ ], "type": "object" }, + "ScopedPermission": { + "additionalProperties": false, + "properties": { + "operation": { + "type": "string" + }, + "resource": { + "$ref": "#/components/schemas/PermissionResource" + } + }, + "required": [ + "operation", + "resource" + ], + "type": "object" + }, "Segment": { "oneOf": [ { @@ -10162,6 +10333,131 @@ ], "type": "object" }, + "SubjectPermissionInterruption": { + "additionalProperties": false, + "properties": { + "backend_command_sessions_closed": { + "type": "boolean" + }, + "cancellation_accepted": { + "type": "boolean" + }, + "diagnostics": { + "items": { + "type": "string" + }, + "type": "array" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "runtime_id", + "worker_id", + "cancellation_accepted", + "backend_command_sessions_closed", + "diagnostics" + ], + "type": "object" + }, + "SubjectPermissions": { + "additionalProperties": false, + "properties": { + "change_id": { + "type": "string" + }, + "delegation": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + }, + "execution": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + } + }, + "required": [ + "change_id", + "execution", + "delegation" + ], + "type": "object" + }, + "SubjectPermissionsMutationResponse": { + "additionalProperties": false, + "properties": { + "interruptions": { + "description": "New operations are already fenced. These are cancellation requests and\nBackend command cleanup evidence, not proof that a Runtime turn stopped.", + "items": { + "$ref": "#/components/schemas/SubjectPermissionInterruption" + }, + "type": "array" + }, + "permissions": { + "$ref": "#/components/schemas/SubjectPermissions" + } + }, + "required": [ + "permissions", + "interruptions" + ], + "type": "object" + }, + "SubjectPermissionsResponse": { + "additionalProperties": false, + "properties": { + "permissions": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubjectPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "SubjectPermissionsUpdate": { + "additionalProperties": false, + "properties": { + "delegation": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + }, + "execution": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + }, + "expected_change_id": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "execution", + "delegation" + ], + "type": "object" + }, "SubjektivMemoryChangeItem": { "additionalProperties": false, "properties": { @@ -14846,8 +15142,14 @@ "type": "null" } ] + }, + "session": { + "$ref": "#/components/schemas/BrowserSessionState" } }, + "required": [ + "session" + ], "type": "object" }, "WorkerBusyState": { @@ -14886,6 +15188,28 @@ } ] }, + "WorkerDelegationRequest": { + "additionalProperties": false, + "properties": { + "permissions": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + }, + "redelegation": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + } + }, + "required": [ + "permissions", + "redelegation" + ], + "type": "object" + }, "WorkerFileDeleteResponse": { "additionalProperties": false, "properties": { @@ -17831,7 +18155,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d" + "x-yoi-source-digest": "sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7" }, "openapi": "3.1.0", "paths": { @@ -22228,6 +22552,113 @@ ] } }, + "/api/w/{workspace_id}/drive/capabilities": { + "get": { + "operationId": "drive_capabilities", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveCapabilities" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/drive/download": { "get": { "operationId": "drive_download", @@ -34880,6 +35311,218 @@ ] } }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/permissions": { + "get": { + "operationId": "subjektiv_subject_permissions_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "subject_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjectPermissionsResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "subjektiv_subject_permissions_set", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "subject_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjectPermissionsUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjectPermissionsMutationResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire": { "post": { "operationId": "subjektiv_subject_retire", diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index 9638ae45a..b308eeb49 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -19,6 +19,8 @@ sha2.workspace = true tempfile.workspace = true [dev-dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock"] } +yoi-workspace-server.workspace = true server-api.workspace = true webauthn-rs-proto = "0.5.5" webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } diff --git a/tests/e2e/browser/auth_sessions.ts b/tests/e2e/browser/auth_sessions.ts new file mode 100644 index 000000000..226ae9e76 --- /dev/null +++ b/tests/e2e/browser/auth_sessions.ts @@ -0,0 +1,435 @@ +/// +// WorkerE2eEnvironment所有の実Backendへ全APIをproxyし、本番static shellを配信する。 +// fixture-serverと同じSPA配信方式。製品APIのwhoami、認証、認可、Set-Cookieは置換しない。 +// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。 +// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。 +import { assert, assertEquals } from "@std/assert"; +import { extname, join, normalize } from "@std/path"; +import { type Browser, type BrowserContext, chromium } from "playwright"; + +const [backend, origin, build, handle] = Deno.args; +if (!backend || !origin || !build || !handle) { + throw new Error( + "Expected isolated Backend URL, browser origin, production build, handle", + ); +} +await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。 +const mime: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", + ".ico": "image/x-icon", +}; +const server = Deno.serve({ + hostname: "127.0.0.1", + port: Number(new URL(origin).port), + onListen() {}, +}, async (request) => { + const url = new URL(request.url); + if (url.pathname === "/__e2e/expired-session" && request.method === "POST") { + if (request.headers.get("origin") !== origin) { + return new Response(null, { status: 403 }); + } + // Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。 + const fixture = await bridge("expired-session", {}) as { + set_cookies: string[]; + }; + const headers = new Headers({ "cache-control": "no-store" }); + for (const cookie of fixture.set_cookies) { + headers.append("set-cookie", cookie); + } + return new Response(null, { status: 204, headers }); + } + if ( + url.pathname === "/__e2e/primary-cookie-present" && request.method === "GET" + ) { + // HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。 + const present = request.headers.get("cookie")?.split(";").some((pair) => + pair.trim().split("=", 1)[0] === "yoi_workspace_session" + ) ?? false; + return Response.json(present, { headers: { "cache-control": "no-store" } }); + } + if (url.pathname.startsWith("/api/")) { + const headers = new Headers(request.headers); + headers.delete("host"); + headers.delete("connection"); + // OriginとCookieを保持し、Set-Cookieを含む実応答をそのまま返す。 + return await fetch(new URL(url.pathname + url.search, backend), { + method: request.method, + headers, + body: ["GET", "HEAD"].includes(request.method) + ? undefined + : await request.arrayBuffer(), + redirect: "manual", + }); + } + const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html"); + if (relative.startsWith("..")) { + return new Response("not found", { status: 404 }); + } + let file = join(build, relative); + try { + if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html"); + return new Response(await Deno.readFile(file), { + headers: { + "content-type": mime[extname(file)] ?? "application/octet-stream", + }, + }); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + if (url.pathname.startsWith("/_app/")) { + return new Response("not found", { status: 404 }); + } + return new Response(await Deno.readFile(join(build, "index.html")), { + headers: { "content-type": mime[".html"] }, + }); + } +}); + +// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。 +const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream()) + .getReader(); +let pending = ""; +async function bridge(operation: string, options: unknown): Promise { + await Deno.stdout.write( + new TextEncoder().encode(JSON.stringify({ operation, options }) + "\n"), + ); + while (!pending.includes("\n")) { + const { done, value } = await input.read(); + if (done) throw new Error("ResidentAuthenticator disconnected"); + pending += value; + if (pending.length > 1024 * 1024) throw new Error("Oversized credential"); + } + const newline = pending.indexOf("\n"); + const credential = JSON.parse(pending.slice(0, newline)); + pending = pending.slice(newline + 1); + return credential; +} +let browser: Browser | undefined; +let context: BrowserContext | undefined; +const terminate = () => { + void (async () => { + await browser?.close(); + await server.shutdown(); + Deno.exit(143); + })(); +}; +Deno.addSignalListener("SIGTERM", terminate); +try { + browser = await chromium.launch({ headless: true, timeout: 15_000 }); + context = await browser.newContext(); + context.setDefaultTimeout(10_000); + await context.route( + "**/*", + (route) => + new URL(route.request().url()).origin === origin + ? route.continue() + : route.abort(), + ); + await context.exposeBinding( + "residentCredential", + (_source, operation: string, options: unknown) => + bridge(operation, options), + ); + await context.addInitScript(() => { + const encode = (value: unknown): unknown => { + if (value instanceof ArrayBuffer) { + return btoa(String.fromCharCode(...new Uint8Array(value))) + .replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + } + if (Array.isArray(value)) return value.map(encode); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, encode(item)]), + ); + } + return value; + }; + const bytes = (value: string) => { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + return Uint8Array.from( + atob(base64), + (character) => character.charCodeAt(0), + ).buffer; + }; + for (const operation of ["create", "get"] as const) { + Object.defineProperty(navigator.credentials, operation, { + configurable: true, + value: async ( + options: CredentialCreationOptions & CredentialRequestOptions, + ) => { + const bridge = (window as unknown as { + residentCredential: ( + operation: string, + options: unknown, + ) => Promise<{ + id: string; + rawId: string; + response: Record; + extensions?: unknown; + }>; + }).residentCredential; + const wire = await bridge(operation, { + publicKey: encode(options.publicKey), + }); + const response = Object.fromEntries( + Object.entries(wire.response).map(( + [key, value], + ) => [key, typeof value === "string" ? bytes(value) : value]), + ); + return { + id: wire.id, + rawId: bytes(wire.rawId), + type: "public-key", + getClientExtensionResults: () => wire.extensions ?? {}, + response: { + ...response, + getTransports: () => wire.response.transports ?? [], + }, + }; + }, + }); + } + }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const at = async (path: string) => { + await page.waitForURL((url) => + url.origin === origin && url.pathname + url.search + url.hash === path + ); + console.error(`navigation-ready: ${new URL(path, origin).pathname}`); + }; + const whoami = async () => { + const response = await page.request.get(`${origin}/api/auth/whoami`); + assertEquals(response.status(), 200); + return await response.json(); + }; + const account = "/account?view=passkeys&filter=a%2Fb#selected"; + const privateRequests: string[] = []; + const observePrivate = (request: { url(): string }) => { + const path = new URL(request.url()).pathname; + if (/^\/api\/(workspaces|w\/|hosting\/)/.test(path)) { + privateRequests.push(path); + } + }; + page.on("request", observePrivate); + await page.goto(origin + account); + await at("/"); + await page.getByRole("heading", { name: "Yoi", exact: true }).waitFor(); + assertEquals((await whoami()).session, { kind: "anonymous" }); + assertEquals( + privateRequests, + [], + "anonymous gate must run before private loaders", + ); + page.off("request", observePrivate); + + await page.goto( + `${origin}/register?${new URLSearchParams({ returnTo: account })}`, + ); + await page.getByLabel("Account ID (handle)", { exact: true }).fill(handle); + await page.getByLabel("Display name", { exact: true }).fill( + "Real browser E2E", + ); + const registration = page.waitForResponse((response) => + new URL(response.url()).pathname === + "/api/auth/passkeys/registration/complete" + ); + await page.getByRole("button", { name: "Register new account", exact: true }) + .click(); + assertEquals((await registration).status(), 200); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + const identity = await whoami(); + assertEquals(identity.actor?.handle, handle); + assertEquals(identity.session.kind, "authenticated"); + assert(typeof identity.session.expires_at === "string"); + const cookies = await context.cookies(origin); + const session = cookies.find((cookie) => + cookie.name === "yoi_workspace_session" + ); + const marker = cookies.find((cookie) => + cookie.name === "yoi_workspace_session_expiry" + ); + assert(session && marker, "Backend must issue both cookies"); + for (const cookie of [session, marker]) { + assert(cookie.httpOnly); + assertEquals(cookie.sameSite, "Lax"); + } + const seconds = (Date.parse(identity.session.expires_at) / 1000) - + Date.now() / 1000; + assert(seconds > 14 * 86400 - 60 && seconds <= 14 * 86400); + assert( + Math.abs(session.expires - Date.parse(identity.session.expires_at) / 1000) < + 2, + ); + assert(Math.abs(marker.expires - session.expires - 7 * 86400) < 2); + await page.goto(origin + account); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + await page.reload(); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + + // 明示logoutは実Serverのrevoke + 両cookie削除を通る。 + await page.getByRole("button", { name: "Log out", exact: true }).click(); + await at("/"); + assertEquals((await whoami()).session, { kind: "anonymous" }); + const afterLogout = await context.cookies(origin); + assert( + !afterLogout.some((cookie) => + [session.name, marker.name].includes(cookie.name) + ), + ); + const revoked = await page.request.get(`${origin}/api/auth/whoami`, { + headers: { + cookie: + `${session.name}=${session.value}; ${marker.name}=${marker.value}`, + }, + }); + assertEquals((await revoked.json()).session, { kind: "anonymous" }); + await page.goto(origin + account); + await at("/"); + + // 実discoverable loginで、確認済みの認証成功後のみpathname/query/fragmentへ復帰する。 + await page.goto( + `${origin}/login?${new URLSearchParams({ returnTo: account })}`, + ); + await page.reload(); + const login = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/passkeys/login/complete" + ); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .click(); + assertEquals((await login).status(), 200); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + assertEquals((await whoami()).actor?.handle, handle); + + // 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、 + // 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。 + const expiredTarget = "/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25"; + const catalogResponse = () => + page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/workspaces" && + response.request().method() === "GET" + ); + const initialCatalog = catalogResponse(); + await page.goto(origin + expiredTarget); + assertEquals((await initialCatalog).status(), 200); + await at(expiredTarget); + await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor(); + const issuedStatus = await page.evaluate(async () => { + const response = await fetch("/__e2e/expired-session", { + method: "POST", + cache: "no-store", + }); + return response.status; + }); + assertEquals(issuedStatus, 204); + const initiallyPresent = await page.evaluate(async () => + await (await fetch("/__e2e/primary-cookie-present", { cache: "no-store" })) + .json() + ); + assertEquals( + initiallyPresent, + true, + "short-lived primary must first reach Chromium", + ); + await page.evaluate(async () => { + const deadline = performance.now() + 10_000; + do { + const response = await fetch("/__e2e/primary-cookie-present", { + cache: "no-store", + }); + if (response.ok && await response.json() === false) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } while (performance.now() < deadline); + throw new Error("Primary session cookie did not naturally expire"); + }); + // 値をassertionへ渡さず、cookie jarにもprimaryが消えmarkerが残ることを確認する。 + const expiredCookieNames = (await context.cookies(origin)).map((cookie) => + cookie.name + ); + assert(!expiredCookieNames.includes(session.name)); + assert(expiredCookieNames.includes(marker.name)); + assertEquals(await whoami(), { + actor: null, + session: { kind: "expired" }, + }); + privateRequests.length = 0; + page.on("request", observePrivate); + const expiredWhoami = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/whoami" + ); + await page.reload(); + const expiredResponse = await expiredWhoami; + assertEquals(expiredResponse.status(), 200); + // SPAがloginへ移った後の旧document bodyをCDPから読み直さない。 + // 分類本文は直前/復帰待ち後にも同じ実Backendから取得し、browser要求はstatusを観測する。 + const expiredLogin = `/login?${new URLSearchParams({ + returnTo: expiredTarget, + })}`; + await at(expiredLogin); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .waitFor(); + assertEquals( + privateRequests, + [], + "expired reload must gate the real private catalog loader", + ); + assertEquals(await whoami(), { actor: null, session: { kind: "expired" } }); + await page.reload(); + await at(expiredLogin); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .waitFor(); + assertEquals( + privateRequests, + [], + "login reload must not lose or privately load returnTo", + ); + page.off("request", observePrivate); + const recoveredCatalog = catalogResponse(); + const relogin = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/passkeys/login/complete" + ); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .click(); + assertEquals((await relogin).status(), 200); + await at(expiredTarget); + assertEquals((await recoveredCatalog).status(), 200); + await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor(); + const recovered = await whoami(); + assertEquals(recovered.session.kind, "authenticated"); + assertEquals(recovered.actor?.user_id, identity.actor.user_id); + assertEquals(recovered.actor?.handle, handle); + await page.goto(origin + account); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + + // fixtureでprimaryだけを除去。Server時計は進めていないため、markerのみでは + // authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。 + await context.clearCookies({ name: session.name }); + const partial = await whoami(); + assertEquals(partial, { actor: null, session: { kind: "anonymous" } }); + await page.reload(); + await at("/"); + await context.clearCookies(); + await page.goto(origin + account); + await at("/"); + assertEquals((await whoami()).session, { kind: "anonymous" }); + assertEquals(errors, []); +} finally { + await context?.close(); + await browser?.close(); + await server.shutdown(); + Deno.removeSignalListener("SIGTERM", terminate); +} +await Deno.stdout.write(new TextEncoder().encode('{"operation":"done"}\n')); +// 親はstdout EOFで完了を確定する。stdin streamのprefetch取消を待つと相互待ちになる。 +Deno.exit(0); diff --git a/tests/e2e/tests/auth_passkeys.rs b/tests/e2e/tests/auth_passkeys.rs index 95dce869b..7195bb3bb 100644 --- a/tests/e2e/tests/auth_passkeys.rs +++ b/tests/e2e/tests/auth_passkeys.rs @@ -1,11 +1,12 @@ //! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、 //! ID不要login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、 -//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(browser UI/OS認証器は対象外)。 +//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(OS/物理認証器は対象外)。 //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。 //! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。 -//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 -//! `/`・`/login`・`/dashboard`のstatic shellは対象外。実Server CLIにはstatic assets設定経路がなく、 -//! shell表示/browser遷移は本番routerのin-process検証やbrowser側の合成HTTP検証と区別する。 +//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 +//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが +//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。 +//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。 use std::error::Error; use std::fs::OpenOptions; @@ -29,6 +30,9 @@ mod resident_model; use resident_model::ResidentAuthenticator; use yoi_e2e::WorkerE2eEnvironment; +#[path = "auth_passkeys/browser_sessions.rs"] +mod browser_sessions; + type Result = std::result::Result>; const BROWSER_ORIGIN: &str = "http://localhost:5173"; diff --git a/tests/e2e/tests/auth_passkeys/browser_sessions.rs b/tests/e2e/tests/auth_passkeys/browser_sessions.rs new file mode 100644 index 000000000..7207e4124 --- /dev/null +++ b/tests/e2e/tests/auth_passkeys/browser_sessions.rs @@ -0,0 +1,233 @@ +//! 実Backend・本番static shell・Chromium cookie jarの接続を検証する。 +//! navigator.credentialsだけを共有ResidentAuthenticatorの署名へ接続する。 +//! 期限切れケースは実登録userへ公開store APIで固定expiry sessionを作り、 +//! 本番cookie生成関数の短命primary/猶予markerをfixture HTTPで配信する。 +//! SQL直書き・resolver/APIの置換・製品clockの追加はしない。 +//! 実発行14日cookieの属性/DTOと短命fixtureの自然消失/復帰を分けて検証する。 +//! OS/物理認証器と14日経過したServer時計は検証しない。 +//! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite: +//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored` +//! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。 +use super::*; +use std::io::{BufRead, BufReader, Read}; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Instant; +use yoi_workspace_server::auth::{ + SESSION_EXPIRY_GRACE_SECONDS, SessionCookiePolicy, mint_secret, new_id, + session_marker_cookie_name, session_set_cookie, token_hash, +}; +use yoi_workspace_server::store::{BrowserSessionRecord, ControlPlaneStore, SqliteWorkspaceStore}; + +// 戻り値はprivate IPC→fixture HTTPのSet-Cookieだけに使い、assert/debug/artifactへ出さない。 +fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result { + // default環境や任意pathへ接続せず、このenvironmentが起動した既存DBだけを開く。 + let database = environment + .server_data_dir + .join("server.db") + .canonicalize()?; + if !database.starts_with(environment.root.canonicalize()?) { + return Err("expired-session fixture DB is outside its environment".into()); + } + let store = SqliteWorkspaceStore::open(database)?; + let user = store + .get_user_by_handle(&environment.account_handle)? + .ok_or("browser registration must create the fixture user first")?; + let token = mint_secret("yoi_sess"); + let hash = token_hash(&token); + let now = chrono::Utc::now(); + // Server時計を動かさず、既に期限切れだが実猶予内のrecordを公開store APIで保存する。 + store.create_browser_session(&BrowserSessionRecord { + session_id: new_id("session"), + token_hash: hash.clone(), + user_id: user.user_id, + created_at: (now - chrono::Duration::days(14) - chrono::Duration::seconds(60)).to_rfc3339(), + expires_at: (now - chrono::Duration::seconds(60)).to_rfc3339(), + revoked_at: None, + })?; + let policy = SessionCookiePolicy { + cookie_name: "yoi_workspace_session", + path: "/", + domain: None, + secure: false, + }; + let marker_name = session_marker_cookie_name(policy.cookie_name); + Ok(json!({"set_cookies": [ + session_set_cookie(policy, &token, 2), + session_set_cookie(SessionCookiePolicy { + cookie_name: &marker_name, + ..policy + }, &hash, SESSION_EXPIRY_GRACE_SECONDS - 60), + ]})) +} + +struct BrowserProcess(Option); +impl BrowserProcess { + fn child(&mut self) -> &mut Child { + self.0.as_mut().expect("browser process retained") + } +} +impl Drop for BrowserProcess { + fn drop(&mut self) { + let Some(mut child) = self.0.take() else { + return; + }; + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + let pid = child.id() as libc::pid_t; + // Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。 + unsafe { + libc::kill(pid, libc::SIGTERM); + } + let (send, receive) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + let _ = send.send(child.wait()); + }); + if receive.recv_timeout(Duration::from_secs(3)).is_err() { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + let _ = waiter.join(); + } +} + +#[test] +#[ignore = "requires production Web assets, Deno and Playwright Chromium"] +fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit_logout() -> Result +{ + // Backend/process/DBの所有は既存harnessに限定する。Web buildは呼出元が用意する。 + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let build = root.join("web/workspace/build"); + if !build.join("index.html").is_file() { + return Err("build web/workspace production assets before selecting browser E2E".into()); + } + let mut environment = WorkerE2eEnvironment::new()?; + let reservation = TcpListener::bind("127.0.0.1:0")?; + let port = reservation.local_addr()?.port(); + let origin = format!("http://localhost:{port}"); + std::fs::write( + &environment.server_config, + format!("[browser]\npublic_url = \"{origin}\"\n"), + )?; + environment.start_empty_backend()?; + let admin = + environment.setup_hosting_admin(&format!("{}-admin", environment.account_handle), true)?; + let bearer = admin["access_token"] + .as_str() + .ok_or("missing setup token")?; + let base = environment + .server_url + .as_deref() + .ok_or("missing Backend URL")?; + success( + http_client()? + .put(format!("{base}/api/hosting/settings")) + .bearer_auth(bearer) + .json(&json!({"self_registration_enabled": true})) + .send()?, + )?; + drop(reservation); + let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?; + let mut process = BrowserProcess(Some( + Command::new("deno") + .current_dir(&root) + .args([ + "run", + "--config", + "tools/web-ux/deno.json", + "--no-lock", + "--allow-env", + "--allow-net", + "--allow-read", + "--allow-write", + "--allow-run", + "--allow-sys", + "tests/e2e/browser/auth_sessions.ts", + base, + &origin, + build.to_str().ok_or("non-UTF8 build path")?, + &environment.account_handle, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(stderr) + .spawn()?, + )); + let stdout = process + .child() + .stdout + .take() + .ok_or("missing browser stdout")?; + let mut stdin = process + .child() + .stdin + .take() + .ok_or("missing browser stdin")?; + let (send, receive) = mpsc::sync_channel(4); + // bounded NDJSONは実WebAuthn options/署名とfixture cookieを運び、artifactには記録しない。 + let reader = std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.by_ref().take(1024 * 1024).read_line(&mut line) { + Ok(0) => break, + Ok(_) if line.ends_with('\n') => { + if send.send(Ok(line)).is_err() { + break; + } + } + _ => { + let _ = send.send(Err("invalid or oversized browser IPC")); + break; + } + } + } + }); + let deadline = Instant::now() + Duration::from_secs(120); + let mut authenticator = ResidentAuthenticator::new(true); + let mut completed = false; + loop { + let message = match receive.recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + Ok(message) => message.map_err(std::io::Error::other)?, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(_) => return Err("browser E2E timed out; see browser.stderr.log".into()), + }; + let request: Value = serde_json::from_str(&message)?; + let credential = match request["operation"].as_str() { + Some("create") => serde_json::to_value(authenticator.do_registration( + origin.parse()?, + serde_json::from_value(request["options"].clone())?, + )?)?, + Some("get") => serde_json::to_value(authenticator.do_authentication( + origin.parse()?, + serde_json::from_value(request["options"].clone())?, + )?)?, + Some("expired-session") => expired_session_fixture(&environment)?, + Some("done") => { + completed = true; + continue; + } + _ => return Err("unexpected browser IPC operation".into()), + }; + serde_json::to_writer(&mut stdin, &credential)?; + writeln!(stdin)?; + stdin.flush()?; + } + reader.join().map_err(|_| "browser reader panicked")?; + assert!( + completed, + "browser did not complete; see browser.stderr.log" + ); + assert!( + process.child().wait()?.success(), + "browser E2E failed; see browser.stderr.log" + ); + drop(process); + let report = environment.cleanup()?; + assert!(report.cleanup_success); + Ok(()) +} diff --git a/tools/state-contract-name-exceptions.json b/tools/state-contract-name-exceptions.json index 07fa26137..933d7a696 100644 --- a/tools/state-contract-name-exceptions.json +++ b/tools/state-contract-name-exceptions.json @@ -70,12 +70,12 @@ ] }, { - "path": "crates/session-store/src/system_item.rs", + "path": "crates/session-store/src/legacy_session_log/tests/v5.rs", "kind": "saved_data", - "reason": "frozen_subject_behavior_log_reads_old_counter_without_republishing_itの保存済みSystemItem fixture。旧JSONを読んでもexact archived bodyを維持することを証明する。削除すると過去Session log形式の読取回帰を検出できない。", + "reason": "旧Subject behavior JSONに保存されていたcounterの実fixture。v1–4専用migration境界で旧綴りを読み、本文を維持して現行payloadへcounterを再公開しないことを検証する。通常v5 decoderは旧Feature kind自体を拒否する。", "lines": [ { - "sha256": "5b5998029cfed30634e8b063b759aefd70e30dab3d02ce9defe9086977fc9811", + "sha256": "54c30cb3198eb16e57c7e580fce4dc058f89e793b77a2955be317aa38b490138", "count": 1 } ] diff --git a/web/workspace/src/lib/generated/auth-api.ts b/web/workspace/src/lib/generated/auth-api.ts index 774a2852b..788ded8eb 100644 --- a/web/workspace/src/lib/generated/auth-api.ts +++ b/web/workspace/src/lib/generated/auth-api.ts @@ -19,7 +19,9 @@ export type AuthenticatedUser = { user_id: string, account_id: string, handle: s export type RequestActor = { user_id: string, account_id: string, handle: string, display_name: string, auth_method: ActorAuthMethod, }; -export type WhoamiResponse = { actor: RequestActor | null, }; +export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", }; + +export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, }; export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, }; diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 146d0ce28..ecd2b3f5d 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/drive-api.ts b/web/workspace/src/lib/generated/drive-api.ts index fa6a379dd..01fecdce2 100644 --- a/web/workspace/src/lib/generated/drive-api.ts +++ b/web/workspace/src/lib/generated/drive-api.ts @@ -31,6 +31,8 @@ export type DriveEntry = { latest_url: string; }; +export type DriveCapabilities = { write: boolean }; + export type DriveEntryQuery = { entry_workspace_id: string; id: string }; export type DriveListQuery = { diff --git a/web/workspace/src/lib/generated/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts index b75fa7738..7b58a3b4b 100644 --- a/web/workspace/src/lib/generated/protocol-validator.ts +++ b/web/workspace/src/lib/generated/protocol-validator.ts @@ -21,7 +21,7 @@ const MAX_CORRELATION_ID_BYTES = 128; const MAX_RESOURCE_ID_BYTES = 256; const MAX_WORKER_IDS_PER_SELECTOR = 256; const MAX_REJECTION_MESSAGE_BYTES = 1024; -const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"id":{"description":"Stable only within the emitting live Worker; not durable alert history.","type":["string","null"]},"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"position":{"anyOf":[{"$ref":"#/$defs/AlertPosition"},{"type":"null"}]},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertPosition":{"description":"Display anchor captured at emission, resolved when pending text is committed.","oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"start","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"entry_id":{"type":"string"},"kind":{"const":"after_entry","type":"string"}},"required":["kind","entry_id"],"type":"object"},{"additionalProperties":false,"properties":{"call_id":{"type":"string"},"kind":{"const":"after_tool_call","type":"string"}},"required":["kind","call_id"],"type":"object"},{"additionalProperties":false,"properties":{"block_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"after_in_flight","type":"string"}},"required":["kind","block_id"],"type":"object"}]},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionContext":{"additionalProperties":false,"properties":{"argument":{"type":["string","null"]},"invocation":{"$ref":"#/$defs/FeatureInvocationIdentity"}},"required":["invocation"],"type":"object"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.","properties":{"description":{"type":["string","null"]},"invocation":{"anyOf":[{"$ref":"#/$defs/FeatureInvocationDescriptor"},{"type":"null"}]},"is_dir":{"default":false,"type":"boolean"},"usage":{"type":["string","null"]},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\n`File` is the existing `@` path lane. `Feature` lists installed chat\ninvocations. `FeatureArgument` resolves a declared argument using the\nsource-qualified [`CompletionContext`].","enum":["file","feature","feature_argument"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Complete FIFO replacement following enqueue, activation, cancel, or clear.\nClients replace their pending view in stream order; reconnect uses the snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Synthetic compatibility events omit it.","type":["string","null"]},"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"},"timestamp_ms":{"description":"Timestamp of the persisted Invoke, not the client's attach time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"timestamp_ms":{"description":"Present for usage delivered from the committed session-log lane.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Authoritative current-context occupancy after a non-request boundary.\n\nUnlike [`Event::Usage`], this is not cumulative request traffic. It lets\nconnected clients converge with reconnect snapshots after run completion,\ncompaction, rewind, and other history rewrites. `None` explicitly means\nthe current value is unavailable and stale occupancy must be cleared.","properties":{"data":{"additionalProperties":false,"properties":{"usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}]}},"type":"object"},"event":{"const":"context_usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity. Apply in arrival order on the parent's serialized stream; a\nreconnect snapshot replaces the projection before live events resume.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"default":"","type":"string"},"request_id":{"type":["string","null"]}},"required":["kind","entries","prefix"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"FeatureInvocation":{"additionalProperties":false,"properties":{"arguments":{"items":{"$ref":"#/$defs/InvocationArgumentValue"},"type":"array"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"invocation_id":{"description":"Stable client-generated id retained across queueing and transport retry.","type":"string"},"name":{"description":"Selected public name, retained for exact draft/history restoration. Host\nresolution is always by `identity`.","type":"string"}},"required":["invocation_id","identity","name","arguments"],"type":"object"},"FeatureInvocationDescriptor":{"additionalProperties":false,"properties":{"aliases":{"default":[],"items":{"type":"string"},"type":"array"},"arguments":{"default":[],"items":{"$ref":"#/$defs/InvocationArgumentDescriptor"},"type":"array"},"client_adapter":{"anyOf":[{"$ref":"#/$defs/InvocationClientAdapter"},{"type":"null"}]},"description":{"type":"string"},"display_name":{"type":"string"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"name":{"type":"string"},"syntax":{"$ref":"#/$defs/FeatureInvocationSyntax"}},"required":["identity","name","display_name","description","syntax","aliases","arguments"],"type":"object"},"FeatureInvocationIdentity":{"description":"Source-qualified identity of one invocation contribution, for example\n`builtin:attachments/attach`. Display names and aliases are never authority.","type":"string"},"FeatureInvocationSyntax":{"oneOf":[{"const":"parenthesized","description":"`/name(value, key=value)` with JSON-style quoted strings and `)` as the\nexplicit boundary before following natural language.","type":"string"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"block_ids":{"description":"Live-Worker identities parallel to the blocks actually included below.","items":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"type":"array"},"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The parent serializes\nsnapshot publication and subsequent live events on the same ordered stream.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","session","status"],"type":"object"},"InvocationArgumentDescriptor":{"additionalProperties":false,"properties":{"completion":{"$ref":"#/$defs/InvocationCompletion","default":{"kind":"none"}},"description":{"type":["string","null"]},"name":{"type":"string"},"position":{"description":"Zero-based positional index. `None` means named-only. A positional\nargument may also be supplied by name; this makes chip editing stable.","format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"required":{"default":false,"type":"boolean"},"value_type":{"$ref":"#/$defs/InvocationArgumentType"}},"required":["name","value_type","completion","required"],"type":"object"},"InvocationArgumentType":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"enum","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"description":"A Worker-readable path. This does not grant filesystem authority.","properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"A resource selected and staged by the client. It must never be resolved\nas a path on the Worker host.","properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"}]},"InvocationArgumentValue":{"additionalProperties":false,"properties":{"name":{"type":"string"},"value":{"$ref":"#/$defs/InvocationValue"}},"required":["name","value"],"type":"object"},"InvocationClientAdapter":{"oneOf":[{"const":"attachment","description":"The client stages a local file and replaces the draft invocation with an\n`UploadedFile` chip. Local paths are not sent to or persisted by Worker.","type":"string"}]},"InvocationCompletion":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"none","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"static","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"Host-owned provider selected by source-qualified id. Metadata lookup is\nside-effect free; implementations never cross this wire boundary.","properties":{"kind":{"const":"provider","type":"string"},"provider":{"type":"string"}},"required":["kind","provider"],"type":"object"}]},"InvocationValue":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"},"value":{"type":"string"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"},"value":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"},"value":{"type":"boolean"}},"required":["kind","value"],"type":"object"}]},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"submission_id":{"type":"string"}},"required":["submission_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove exactly the observed ordered set of submissions, preserving the\nactive run. Reject if the FIFO content changed before admission.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_submission_ids":{"items":{"type":"string"},"type":"array"}},"required":["expected_submission_ids"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"}},"required":["expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Rewind to just before a user input identified by its public Session entry ID.\nValidate the target against the current segment at execution time.\nLater appended history is discarded too; tool side effects are not undone.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"entry_id":{"type":"string"}},"required":["entry_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"},"request_id":{"description":"Client query identity. Echoed verbatim so identical-prefix ABA\nresponses cannot cross edits, target switches, or permission changes.","type":["string","null"]}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Session-owned FIFO content projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Explicitly selected, source-qualified Feature invocation. The host never\npromotes free text to this variant and validates it against the installed\ndeclaration before execution.","properties":{"invocation":{"$ref":"#/$defs/FeatureInvocation"},"kind":{"const":"feature_invoke","type":"string"}},"required":["kind","invocation"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Snapshot-only replay of persisted invocation/usage records. These are\naccounting metadata, never model context or conversation rows.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"invoke","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/InvokeKind"}},"required":["kind","trigger","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"usage","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","input_tokens","cache_read_input_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Display-only accounting reconstructed from persisted run/usage records.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_stats","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"requests":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"upload_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","elapsed_ms","requests","upload_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"attachment_id":{"description":"Stable, bounded identity derived from the owning Session entry, attachment\nposition, and immutable image bytes. It is a locator, not a bearer secret.","type":"string"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"type":"string"}},"required":["attachment_id","media_type","byte_len"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"restore_observation_token":{"description":"Opaque Runtime-owned Restore observation identity, never a display-state hash.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nCommands operate on the controller state at admission time, not a caller's\npreviously observed Run. A controller accepts command ids in strictly\nincreasing order; retrying an accepted id cannot apply it to a later Run.\nExact retries must retain the same command kind. Removed preconditions are\nrejected at the wire boundary rather than treated as arrival-time commands.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"last_finished_submission_request_id":{"description":"Most recent Submit request whose controller Run has returned and whose\npost-Run state transition has been published. This is a teardown fence,\nnot a successful-result claim; cancelled and failed Runs may also finish.","type":["string","null"]},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; +const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"id":{"description":"Stable only within the emitting live Worker; not durable alert history.","type":["string","null"]},"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"position":{"anyOf":[{"$ref":"#/$defs/AlertPosition"},{"type":"null"}]},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertPosition":{"description":"Display anchor captured at emission, resolved when pending text is committed.","oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"start","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"entry_id":{"type":"string"},"kind":{"const":"after_entry","type":"string"}},"required":["kind","entry_id"],"type":"object"},{"additionalProperties":false,"properties":{"call_id":{"type":"string"},"kind":{"const":"after_tool_call","type":"string"}},"required":["kind","call_id"],"type":"object"},{"additionalProperties":false,"properties":{"block_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"after_in_flight","type":"string"}},"required":["kind","block_id"],"type":"object"}]},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionContext":{"additionalProperties":false,"properties":{"argument":{"type":["string","null"]},"invocation":{"$ref":"#/$defs/FeatureInvocationIdentity"}},"required":["invocation"],"type":"object"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.","properties":{"description":{"type":["string","null"]},"invocation":{"anyOf":[{"$ref":"#/$defs/FeatureInvocationDescriptor"},{"type":"null"}]},"is_dir":{"default":false,"type":"boolean"},"usage":{"type":["string","null"]},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\n`File` is the existing `@` path lane. `Feature` lists installed chat\ninvocations. `FeatureArgument` resolves a declared argument using the\nsource-qualified [`CompletionContext`].","enum":["file","feature","feature_argument"],"type":"string"},"ContextTokenSource":{"description":"Provenance of the current-context token count in a [`Greeting`].","oneOf":[{"const":"measured","description":"Provider-reported input occupancy from the most recent LLM request.","type":"string"},{"const":"estimated","description":"Current history projected from the Worker's token accounting records.","type":"string"}]},"ContextUsage":{"additionalProperties":false,"description":"Current prompt-context occupancy, distinct from cumulative run traffic.","properties":{"source":{"$ref":"#/$defs/ContextTokenSource"},"tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["tokens","source"],"type":"object"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Durable notification acceptance. The request identity is persisted with\nthe notification receipt before this event is emitted. Repeating the\nsame request id and exact payload returns the same acknowledgement\nwithout appending the notification twice.","properties":{"data":{"additionalProperties":false,"properties":{"notification_request_id":{"type":"string"}},"required":["notification_request_id"],"type":"object"},"event":{"const":"notification_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated notification rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"},"event":{"const":"notification_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Complete FIFO replacement following enqueue, activation, cancel, or clear.\nClients replace their pending view in stream order; reconnect uses the snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the allowlisted JSON form of `session_store::PublicSystemItem`,\nnot the storage record. Its descriptive `kind` is independent from the\ntyped `presentation` used for notification, reminder, or context rendering.\nPrivate Feature payloads and prompt provenance are never event data.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Synthetic compatibility events omit it.","type":["string","null"]},"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"},"timestamp_ms":{"description":"Timestamp of the persisted Invoke, not the client's attach time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"timestamp_ms":{"description":"Present for usage delivered from the committed session-log lane.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Authoritative current-context occupancy after a non-request boundary.\n\nUnlike [`Event::Usage`], this is not cumulative request traffic. It lets\nconnected clients converge with reconnect snapshots after run completion,\ncompaction, rewind, and other history rewrites. `None` explicitly means\nthe current value is unavailable and stale occupancy must be cleared.","properties":{"data":{"additionalProperties":false,"properties":{"usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}]}},"type":"object"},"event":{"const":"context_usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity. Apply in arrival order on the parent's serialized stream; a\nreconnect snapshot replaces the projection before live events resume.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"default":"","type":"string"},"request_id":{"type":["string","null"]}},"required":["kind","entries","prefix"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"FeatureInvocation":{"additionalProperties":false,"properties":{"arguments":{"items":{"$ref":"#/$defs/InvocationArgumentValue"},"type":"array"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"invocation_id":{"description":"Stable client-generated id retained across queueing and transport retry.","type":"string"},"name":{"description":"Selected public name, retained for exact draft/history restoration. Host\nresolution is always by `identity`.","type":"string"}},"required":["invocation_id","identity","name","arguments"],"type":"object"},"FeatureInvocationDescriptor":{"additionalProperties":false,"properties":{"aliases":{"default":[],"items":{"type":"string"},"type":"array"},"arguments":{"default":[],"items":{"$ref":"#/$defs/InvocationArgumentDescriptor"},"type":"array"},"client_adapter":{"anyOf":[{"$ref":"#/$defs/InvocationClientAdapter"},{"type":"null"}]},"description":{"type":"string"},"display_name":{"type":"string"},"identity":{"$ref":"#/$defs/FeatureInvocationIdentity"},"name":{"type":"string"},"syntax":{"$ref":"#/$defs/FeatureInvocationSyntax"}},"required":["identity","name","display_name","description","syntax","aliases","arguments"],"type":"object"},"FeatureInvocationIdentity":{"description":"Source-qualified identity of one invocation contribution, for example\n`builtin:attachments/attach`. Display names and aliases are never authority.","type":"string"},"FeatureInvocationSyntax":{"oneOf":[{"const":"parenthesized","description":"`/name(value, key=value)` with JSON-style quoted strings and `)` as the\nexplicit boundary before following natural language.","type":"string"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by clients when a session starts empty.\n\nSnapshot metadata contains only resolved public execution information so\nclients do not need their own copy of the manifest.","properties":{"context_tokens":{"default":0,"description":"Legacy numeric mirror retained for mixed-version clients.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_usage":{"anyOf":[{"$ref":"#/$defs/ContextUsage"},{"type":"null"}],"description":"Typed current-context value. Older snapshots omit it; clients may fall\nback to `context_tokens` only when `context_window` is known."},"context_window":{"default":0,"description":"Resolved effective model context window in tokens. Legacy producers use\nzero when this is unavailable; clients must treat that as unknown.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"reasoning":{"anyOf":[{"$ref":"#/$defs/ReasoningConfig"},{"type":"null"}],"description":"Effective reasoning control. Absent means unspecified or unavailable."},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"block_ids":{"description":"Live-Worker identities parallel to the blocks actually included below.","items":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"type":"array"},"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The parent serializes\nsnapshot publication and subsequent live events on the same ordered stream.","properties":{"error":{"type":["string","null"]},"greeting":{"anyOf":[{"$ref":"#/$defs/Greeting"},{"type":"null"}],"description":"Public execution metadata for this child. Older snapshots and service\nworkers may omit it; clients must never substitute the parent's values."},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","session","status"],"type":"object"},"InvocationArgumentDescriptor":{"additionalProperties":false,"properties":{"completion":{"$ref":"#/$defs/InvocationCompletion","default":{"kind":"none"}},"description":{"type":["string","null"]},"name":{"type":"string"},"position":{"description":"Zero-based positional index. `None` means named-only. A positional\nargument may also be supplied by name; this makes chip editing stable.","format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"required":{"default":false,"type":"boolean"},"value_type":{"$ref":"#/$defs/InvocationArgumentType"}},"required":["name","value_type","completion","required"],"type":"object"},"InvocationArgumentType":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"enum","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"description":"A Worker-readable path. This does not grant filesystem authority.","properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"A resource selected and staged by the client. It must never be resolved\nas a path on the Worker host.","properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"}]},"InvocationArgumentValue":{"additionalProperties":false,"properties":{"name":{"type":"string"},"value":{"$ref":"#/$defs/InvocationValue"}},"required":["name","value"],"type":"object"},"InvocationClientAdapter":{"oneOf":[{"const":"attachment","description":"The client stages a local file and replaces the draft invocation with an\n`UploadedFile` chip. Local paths are not sent to or persisted by Worker.","type":"string"}]},"InvocationCompletion":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"none","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"static","type":"string"},"values":{"items":{"type":"string"},"type":"array"}},"required":["kind","values"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"worker_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"client_file","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"description":"Host-owned provider selected by source-qualified id. Metadata lookup is\nside-effect free; implementations never cross this wire boundary.","properties":{"kind":{"const":"provider","type":"string"},"provider":{"type":"string"}},"required":["kind","provider"],"type":"object"}]},"InvocationValue":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"string","type":"string"},"value":{"type":"string"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"integer","type":"string"},"value":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"}},"required":["kind","value"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"boolean","type":"string"},"value":{"type":"boolean"}},"required":["kind","value"],"type":"object"}]},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"submission_id":{"type":"string"}},"required":["submission_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove exactly the observed ordered set of submissions, preserving the\nactive run. Reject if the FIFO content changed before admission.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_submission_ids":{"items":{"type":"string"},"type":"array"}},"required":["expected_submission_ids"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"}},"required":["expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Rewind to just before a user input identified by its public Session entry ID.\nValidate the target against the current segment at execution time.\nLater appended history is discarded too; tool side effects are not undone.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"entry_id":{"type":"string"}},"required":["entry_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"context":{"anyOf":[{"$ref":"#/$defs/CompletionContext"},{"type":"null"}]},"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"},"request_id":{"description":"Client query identity. Echoed verbatim so identical-prefix ABA\nresponses cannot cross edits, target switches, or permission changes.","type":["string","null"]}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Session-owned FIFO content projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"ReasoningConfig":{"description":"Public reasoning configuration resolved for the model client.\n\nThis deliberately exposes only the effective provider control, not the\nWorker manifest or provider credentials. The tagged shape prevents token\nbudgets from being presented as effort labels.","oneOf":[{"additionalProperties":false,"properties":{"effort":{"type":"string"},"kind":{"const":"effort","type":"string"}},"required":["kind","effort"],"type":"object"},{"additionalProperties":false,"properties":{"budget_tokens":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":"integer"},"kind":{"const":"budget_tokens","type":"string"}},"required":["kind","budget_tokens"],"type":"object"}]},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RunFailureKind":{"enum":["engine","compaction"],"type":"string"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"cancelled","description":"The logical Run ended because the user intentionally cancelled it after\nobservable work. Unlike `RolledBack`, committed history is retained.","type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"RunResumeSource":{"enum":["compaction","pause"],"type":"string"},"RunYieldReason":{"enum":["compaction"],"type":"string"},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Explicitly selected, source-qualified Feature invocation. The host never\npromotes free text to this variant and validates it against the installed\ndeclaration before execution.","properties":{"invocation":{"$ref":"#/$defs/FeatureInvocation"},"kind":{"const":"feature_invoke","type":"string"}},"required":["kind","invocation"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionEntryUsePolicy":{"additionalProperties":false,"description":"Public use decisions contain no Feature identity or private payload. Absence\ndenotes the older projection contract, not permission to expose System input.","properties":{"memory_evidence":{"type":"boolean"},"observation_readable":{"type":"boolean"}},"required":["observation_readable","memory_evidence"],"type":"object"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable, non-terminal control return from one logical Run. This is not a\ncompleted run and must not be used to infer the current Worker state.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_yielded","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"reason":{"$ref":"#/$defs/RunYieldReason"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","reason","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable boundary immediately before Engine execution resumes. The source\ndistinguishes automatic compaction continuation from an intentional\nuser-pause resume; it does not prove that later model output occurred.","properties":{"active_run_turn_count":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_resumed","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"source":{"$ref":"#/$defs/RunResumeSource"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","source","active_run_turn_count","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Snapshot-only replay of persisted invocation/usage records. These are\naccounting metadata, never model context or conversation rows.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"invoke","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/InvokeKind"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","trigger","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"usage","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","input_tokens","cache_read_input_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Display-only accounting reconstructed from persisted run/usage records.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_stats","type":"string"},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"requests":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"upload_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","elapsed_ms","requests","upload_tokens","output_tokens","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"description":"Durable terminal for an intentional cancellation. Consumers must not\npresent this lifecycle result as an execution failure.","properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_cancelled","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"failure":{"anyOf":[{"$ref":"#/$defs/RunFailureKind"},{"type":"null"}]},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"use_policy":{"anyOf":[{"$ref":"#/$defs/SessionEntryUsePolicy"},{"type":"null"}]}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"attachment_id":{"description":"Stable, bounded identity derived from the owning Session entry, attachment\nposition, and immutable image bytes. It is a locator, not a bearer secret.","type":"string"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"type":"string"}},"required":["attachment_id","media_type","byte_len"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"job":{"anyOf":[{"$ref":"#/$defs/SubscriptionWorkerJob"},{"type":"null"}],"description":"Backend-owned Job binding. Runtime producers leave this unset; the\nWorkspace projection enriches both snapshots and live updates."},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"restore_observation_token":{"description":"Opaque Runtime-owned Restore observation identity, never a display-state hash.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerJob":{"additionalProperties":false,"properties":{"attempt_id":{"type":"string"},"job_id":{"type":"string"},"purpose":{"type":"string"}},"required":["job_id","attempt_id","purpose"],"type":"object"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nCommands operate on the controller state at admission time, not a caller's\npreviously observed Run. A controller accepts command ids in strictly\nincreasing order; retrying an accepted id cannot apply it to a later Run.\nExact retries must retain the same command kind. Removed preconditions are\nrejected at the wire boundary rather than treated as arrival-time commands.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"last_finished_submission_request_id":{"description":"Most recent Submit request whose controller Run has returned and whose\npost-Run state transition has been published. This is a teardown fence,\nnot a successful-result claim; cancelled and failed Runs may also finish.","type":["string","null"]},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = [{"protocol_version":2,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot":{"topic":"workers","data":{"workers":[{"restore_observation_token":"fixture-restore-observation","worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"notification_count":0,"head_id":null,"submissions":[]}}}}}}}},{"protocol_version":2,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"snapshot","data":{"session":{"pending_submissions":{"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"state":{"last_command_id":0,"state":{"kind":"idle"}},"internal_workers":[{"worker":{"session_id":"fixture-child-session","name":"fixture-child","parent_session_id":"fixture-parent-session","kind":"sub_worker"},"session":{"pending_submissions":{"notification_count":0,"head_id":null,"submissions":[]},"entries":[]},"greeting":{"worker_name":"fixture-worker","cwd":"/fixture","provider":"openai_responses","model":"gpt-6-astra","reasoning":{"kind":"effort","effort":"high"},"scope_summary":"fixture scope","tools":["Read"],"context_window":272000,"context_tokens":142000,"context_usage":{"tokens":142000,"source":"measured"}},"status":"idle"}]}}}}}}}]; const utf8 = new TextEncoder(); diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 8b6870f39..8b252d488 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -178,6 +178,8 @@ compaction?: InFlightCompaction | null, }; export type SessionEntryProvenance = "human_input" | "worker_input" | "flow_instruction" | "backend_instruction" | "model_output" | "tool_output" | "derived_summary" | "legacy_unknown"; +export type SessionEntryUsePolicy = { observation_readable: boolean, memory_evidence: boolean, }; + export type SessionMessageRole = "user" | "assistant"; export type SessionContentPart = { "kind": "text", text: string, } | { "kind": "refusal", refusal: string, }; @@ -206,7 +208,7 @@ entry_id: string, /** * Timestamp copied from the durable log record that commits this entry. */ -timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array, } & ({ "kind": "user_input", segments: Array, } | { "kind": "message", role: SessionMessageRole, content: Array, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_yielded", reason: RunYieldReason, active_run_turn_count: number, } | { "kind": "run_resumed", source: RunResumeSource, active_run_turn_count: number, } | { "kind": "invoke", trigger: InvokeKind, } | { "kind": "usage", input_tokens: number, cache_read_input_tokens: number, output_tokens: number, } | { "kind": "run_stats", elapsed_ms: number, requests: number, upload_tokens: number, output_tokens: number, } | { "kind": "run_cancelled" } | { "kind": "run_error", message: string, failure?: RunFailureKind | null, }); +timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array, use_policy?: SessionEntryUsePolicy | null, } & ({ "kind": "user_input", segments: Array, } | { "kind": "message", role: SessionMessageRole, content: Array, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_yielded", reason: RunYieldReason, active_run_turn_count: number, } | { "kind": "run_resumed", source: RunResumeSource, active_run_turn_count: number, } | { "kind": "invoke", trigger: InvokeKind, } | { "kind": "usage", input_tokens: number, cache_read_input_tokens: number, output_tokens: number, } | { "kind": "run_stats", elapsed_ms: number, requests: number, upload_tokens: number, output_tokens: number, } | { "kind": "run_cancelled" } | { "kind": "run_error", message: string, failure?: RunFailureKind | null, }); export type SessionConversationTurn = { turn_id: string, entries: Array, }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 74a8c9547..bc61338f5 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 138ed0fd1..72647613a 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 2dbcf6109..18b0a6804 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index ae8584b56..99db0b0a5 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 8fff5d2ea..8dd4a964b 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check @@ -11,7 +11,7 @@ export type BrowserWorkerWorkingDirectorySelection = { alias: string; relative_c export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array; disposition: string; online: boolean; worker?: WorkerLaunchWorkerSummary | null; workspace_id: string; }; -export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array; }; +export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; execution_grant?: WorkerDelegationRequest | null; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array; }; export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; orchestrator_assignment_id?: string | null; ticket_id: string; }; @@ -35,6 +35,8 @@ export type PasteArtifactMediaType = "text_plain_utf8"; export type PasteArtifactRef = { artifact_id: string; availability: PasteArtifactAvailability; byte_len: number; char_count: number; created_at_ms: number; line_count: number; media_type: PasteArtifactMediaType; sha256: string; source_entry_id: string; }; +export type PermissionResource = { kind: "feature"; } | { kind: "workspace"; workspace_id: string; } | { kind: "workdir"; path: string; recursive: boolean; working_directory_id: string; } | { kind: "worker"; runtime_id: string; worker_id: string; }; + export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; export type RuntimeWorkerWorkdirAttachmentSummary = { alias: string; working_directory: RuntimeWorkingDirectorySummary; }; @@ -43,6 +45,8 @@ export type RuntimeWorkingDirectoryCleanupTarget = { kind: string; repository_id export type RuntimeWorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; repository_id: string; status: WorkingDirectoryStatusKind; working_directory_id: string; }; +export type ScopedPermission = { operation: string; resource: PermissionResource; }; + export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; }; export type SubjektivWorkerConnectionRequest = { subject_id: string; }; @@ -55,6 +59,8 @@ export type UploadedFileRef = { artifact_id: string; availability: UploadedFileA export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind: "maintenance"; state: WorkerMaintenanceState; }; +export type WorkerDelegationRequest = { permissions: Array; redelegation: Array; }; + export type WorkerImplementationSummary = { display_hint: string; kind: string; }; export type WorkerLaunchFeatureConnectionOptions = { subjektiv: boolean; }; diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 27773b1bc..02dc0f39e 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -1,3 +1,7 @@ +import { + browserSession, + recheckAfterUnauthorized, +} from "#lib/workspace/auth/session-state.ts"; import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts"; import type { SkillCatalogResponse, @@ -102,7 +106,11 @@ export async function loadJson( ): Promise> { let stage: "request" | "decode" | "validate" = "request"; try { + const authenticationGeneration = browserSession.authenticationGeneration; const response = await fetchFn(path, init); + if (response.status === 401) { + recheckAfterUnauthorized(authenticationGeneration); + } if (!response.ok) { if (policy) { await response.body?.cancel(); diff --git a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte index 2dafcddd9..46e41c345 100644 --- a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte +++ b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte @@ -2,7 +2,8 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; - import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey } from './api.ts'; + import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey, logout } from './api.ts'; + import { CLEAR_RETURN_EVENT } from './session-state.ts'; import { BrowserAuth } from './browser-session.svelte.ts'; import { loadRegistrationPolicy } from './hosting.ts'; import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts'; @@ -58,11 +59,16 @@ } } + async function forget() { + await auth.run('Logging out…', 'Logged out.', logout, 'logout'); + } onMount(() => { + const clearReturn = () => { void goto(page.url.pathname, { replaceState: true }); }; + window.addEventListener(CLEAR_RETURN_EVENT, clearReturn); void auth.refresh(); void refreshPolicy(); window.addEventListener('focus', refreshPolicy); - return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); }; + return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); window.removeEventListener(CLEAR_RETURN_EVENT, clearReturn); }; }); @@ -74,11 +80,16 @@ {:else if auth.session.kind === 'error'} + {:else if auth.actor}

Logged in as {auth.actor.display_name} @{auth.actor.handle}.

Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'} Open Account {:else} + {#if auth.session.session.kind === 'expired'} +

Your browser session expired. Log in to return to the requested page.

+ + {/if} {#if mode === 'register'} {#if policyLoading}

Checking registration availability…

diff --git a/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte b/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte new file mode 100644 index 000000000..e910913e6 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte @@ -0,0 +1,71 @@ + + +{#if !protectedPage} + {@render children()} +{:else} + {#if session.kind === 'loading'} +

Loading session…

+ {:else if session.kind === 'error'} +
+ + + Yoi home +
+ {/if} + {#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted} + + + {/if} +{/if} diff --git a/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts index ad24e6937..3cbac0ddf 100644 --- a/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts @@ -63,3 +63,12 @@ export function credentialFixture(authentication = false): Credential { }, } as unknown as Credential; } + +export function whoamiPayload(actor: unknown = null) { + return { + actor, + session: actor === null + ? { kind: "anonymous" } + : { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + }; +} diff --git a/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts index f956e48fa..d69840eb2 100644 --- a/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts +++ b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts @@ -1,16 +1,21 @@ -import { AuthRequestError, loadWhoami } from "./api.ts"; +import { AuthRequestError } from "./api.ts"; +import { + browserSession, + observeBrowserSession, + sessionMutation, +} from "./session-state.ts"; import { notifySessionChanged } from "./hosting.ts"; -import type { RequestActor } from "./model.ts"; +import type { RequestActor, WhoamiResponse } from "./model.ts"; export type BrowserSession = | { kind: "loading" } | { kind: "error" } - | { kind: "ready"; actor: RequestActor | null }; + | ({ kind: "ready" } & WhoamiResponse); export function authErrorMessage(error: unknown): string { if (error instanceof AuthRequestError) { if (error.status === 401) { - return "Your session or authentication challenge expired. Log in again or restart the Passkey operation."; + return "Authentication request was rejected. Check your session or restart the Passkey operation."; } if (error.status === 400 || error.status === 410) { return "The request or authentication challenge is invalid or expired. Restart the operation."; @@ -35,61 +40,77 @@ export class BrowserAuth { busy = $state(false); error = $state(""); status = $state(""); - private request = 0; + private unsubscribe: () => void; private disposed = false; private controller: AbortController | null = null; + constructor() { + observeBrowserSession(); + this.unsubscribe = browserSession.subscribe((snapshot) => { + // 操作pendingはsession確認中と別。formを消してfocus/入力を失わず、busyで再操作を止める。 + if (this.busy && snapshot.kind === "loading") return; + if (!this.disposed) { + this.session = snapshot.kind === "ready" + ? { kind: "ready", ...snapshot.value } + : snapshot; + } + }); + } + get actor(): RequestActor | null { return this.session.kind === "ready" ? this.session.actor : null; } async refresh(): Promise { if (this.busy || this.disposed) return; - await this.readSession(); - } - - private async readSession(): Promise { - const current = ++this.request; - this.session = { kind: "loading" }; - try { - const result = await loadWhoami(); - if (this.disposed || current !== this.request) return; - this.session = { kind: "ready", actor: result.actor }; - } catch { - if (this.disposed || current !== this.request) return; - this.session = { kind: "error" }; - } + await browserSession.refresh(); } async run( pending: string, success: string, operation: (signal: AbortSignal) => Promise, + intent: "authentication" | "logout" = "authentication", ): Promise { - if (this.busy || this.disposed || this.session.kind !== "ready") { + if ( + this.busy || this.disposed || + (intent !== "logout" && this.session.kind !== "ready") + ) { return false; } this.busy = true; - ++this.request; + sessionMutation(intent); + const authenticationGeneration = browserSession.authenticationGeneration; const controller = new AbortController(); this.controller = controller; this.error = ""; this.status = pending; try { await operation(controller.signal); - if (this.disposed) return false; + const superseded = + authenticationGeneration !== browserSession.authenticationGeneration; + sessionMutation(intent); + const confirmationGeneration = browserSession.authenticationGeneration; + await browserSession.refresh(); + if ( + this.disposed || superseded || + confirmationGeneration !== browserSession.authenticationGeneration + ) { + if (!this.disposed) this.status = ""; + return false; + } notifySessionChanged(); - await this.readSession(); - if (this.disposed) return false; const confirmed = this.session.kind === "ready"; this.status = confirmed ? success : ""; return confirmed; } catch (error) { - if (this.disposed) return false; - this.error = authErrorMessage(error); - this.status = ""; + if (!this.disposed) { + this.error = authErrorMessage(error); + this.status = ""; + } // 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。 - await this.readSession(); + sessionMutation(intent); + await browserSession.refresh(); return false; } finally { if (!this.disposed) { @@ -101,7 +122,7 @@ export class BrowserAuth { dispose(): void { this.disposed = true; - ++this.request; + this.unsubscribe(); this.controller?.abort(); } } diff --git a/web/workspace/src/lib/workspace/auth/model.test.ts b/web/workspace/src/lib/workspace/auth/model.test.ts index 177db4b8f..4c17c2df3 100644 --- a/web/workspace/src/lib/workspace/auth/model.test.ts +++ b/web/workspace/src/lib/workspace/auth/model.test.ts @@ -118,10 +118,14 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () }); Deno.test("whoami rejects unknown auth methods and extra fields", () => { - assertEquals(parseWhoamiResponse({ actor: null }), { actor: null }); + assertEquals( + parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }), + { actor: null, session: { kind: "anonymous" } }, + ); assertThrows( () => parseWhoamiResponse({ + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, actor: { user_id: "user-1", account_id: "account-1", @@ -233,3 +237,34 @@ Deno.test("passkey credential conversion fails closed on malformed payloads", () "unknown credential type", ); }); + +Deno.test("whoami fails closed on actor-state mismatch and malformed expiry", () => { + const actor = { + user_id: "u", + account_id: "a", + handle: "alice", + display_name: "Alice", + auth_method: "browser_session", + }; + for ( + const payload of [ + { actor, session: { kind: "anonymous" } }, + { actor, session: { kind: "expired" } }, + { + actor: null, + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + }, + { actor, session: { kind: "authenticated", expires_at: null } }, + { actor, session: { kind: "authenticated", expires_at: "tomorrow" } }, + { actor: null, session: { kind: "future" } }, + { + actor: null, + session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" }, + }, + ] + ) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami"); + assertEquals( + parseWhoamiResponse({ actor: null, session: { kind: "expired" } }), + { actor: null, session: { kind: "expired" } }, + ); +}); diff --git a/web/workspace/src/lib/workspace/auth/model.ts b/web/workspace/src/lib/workspace/auth/model.ts index a52b2e5a4..5923f32b8 100644 --- a/web/workspace/src/lib/workspace/auth/model.ts +++ b/web/workspace/src/lib/workspace/auth/model.ts @@ -463,12 +463,40 @@ function parseActor(value: unknown, path: string): RequestActor { export function parseWhoamiResponse(value: unknown): WhoamiResponse { const response = asRecord(value, "whoami"); - requireExactKeys(response, "whoami", ["actor"]); - return { - actor: response.actor === null + requireExactKeys(response, "whoami", ["actor", "session"]); + const actor = response.actor === null + ? null + : parseActor(response.actor, "whoami.actor"); + const session = asRecord(response.session, "whoami.session"); + if (session.kind === "authenticated") { + requireExactKeys(session, "whoami.session", ["kind", "expires_at"]); + if (actor === null) { + invalid("whoami", "authenticated session requires actor"); + } + const expiry = session.expires_at === null ? null - : parseActor(response.actor, "whoami.actor"), - }; + : boundedString(session.expires_at, "whoami.session.expires_at"); + if ( + (expiry !== null && + (!/^\d{4}-\d\d-\d\dT/.test(expiry) || + !Number.isFinite(Date.parse(expiry)))) || + (actor.auth_method === "browser_session" && expiry === null) + ) { + invalid( + "whoami.session.expires_at", + "must identify the server session expiry", + ); + } + return { actor, session: { kind: "authenticated", expires_at: expiry } }; + } + requireExactKeys(session, "whoami.session", ["kind"]); + if ( + actor !== null || + (session.kind !== "anonymous" && session.kind !== "expired") + ) { + invalid("whoami", "inconsistent actor or session state"); + } + return { actor: null, session: { kind: session.kind } }; } export function parseAuthPublicConfig(value: unknown): AuthPublicConfig { diff --git a/web/workspace/src/lib/workspace/auth/page-session.ts b/web/workspace/src/lib/workspace/auth/page-session.ts new file mode 100644 index 000000000..4ea824ddc --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/page-session.ts @@ -0,0 +1,28 @@ +import { error, redirect } from "@sveltejs/kit"; +import { browserSession, observeBrowserSession } from "./session-state.ts"; +import { isPublicPage, loginUrl } from "./routes.ts"; + +// root loadとそのparentを待つ各loaderが同じ確認を共有する。 +// SSRは無効。fragmentはHTTP/Serverに依存せず、browserの最終URLから回収する。 +export async function requirePageSession(url: URL): Promise { + observeBrowserSession(); + if (isPublicPage(url.pathname)) return; + const snapshot = await browserSession.refresh(); + if (snapshot !== browserSession.snapshot) return requirePageSession(url); + if (snapshot.kind !== "ready") { + error( + 503, + "Your session could not be loaded. Login status is unknown. Retry this page.", + ); + } + if (snapshot.value.session.kind === "authenticated") return; + if (snapshot.value.session.kind === "anonymous") redirect(307, "/"); + const target = new URL(url); + if ( + typeof location !== "undefined" && location.pathname === url.pathname && + location.search === url.search + ) { + target.hash = location.hash; + } + redirect(307, loginUrl(target.pathname + target.search + target.hash)); +} diff --git a/web/workspace/src/lib/workspace/auth/routes.test.ts b/web/workspace/src/lib/workspace/auth/routes.test.ts index c80b69926..feee08cd7 100644 --- a/web/workspace/src/lib/workspace/auth/routes.test.ts +++ b/web/workspace/src/lib/workspace/auth/routes.test.ts @@ -12,16 +12,16 @@ function assertEquals( } import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts"; -Deno.test("return destination permits known app screens without forwarding arbitrary query or fragment", () => { +Deno.test("return destination preserves query and fragment only on known app screens", () => { for ( const [input, expected] of [ ["/", "/dashboard"], - ["/dashboard?query=discard#fragment", "/dashboard"], + ["/dashboard?query=keep#fragment", "/dashboard?query=keep#fragment"], ["/account", "/account"], ["/account?returnTo=//evil.invalid#secret", "/account"], [ "/w/workspace-id/tickets/T-123?filter=all#entry", - "/w/workspace-id/tickets/T-123", + "/w/workspace-id/tickets/T-123?filter=all#entry", ], [ "/login/device?user_code=ABCD-EFGH", @@ -31,7 +31,7 @@ Deno.test("return destination permits known app screens without forwarding arbit "/login/device?code=abcd-1234&returnTo=//evil.invalid", "/login/device?user_code=abcd-1234", ], - ["/login/device?user_code=%252f%252fevil.invalid", "/login/device"], + ["/login/device?user_code=%252f%252fevil.invalid", "/dashboard"], ] ) assertEquals(safeReturnTo(input), expected, input); }); @@ -64,6 +64,11 @@ Deno.test("return destination rejects external, ambiguous encoding and authentic "/account\t", "/account\u0000", "/account%00", + "/account?q=%255c", + "/account?q=%0A", + "/account#%5c", + "/account?q=%ZZ", + "/account?q=%252f%252fevil.invalid", "/api/auth/logout", "/hosting/setup", "/not-an-app-route", @@ -86,3 +91,38 @@ Deno.test("login links encode one validated destination and discard nested auth "/login?returnTo=%2Fdashboard", ); }); + +Deno.test("return destination accepts canonical UTF-8 resource slugs but not encoded ASCII or delimiters", () => { + const target = `/w/team/tickets/T-123-${ + encodeURIComponent("未ログイン") + }?view=comments#entry-2`; + assertEquals(safeReturnTo(target), target); + for ( + const [input, expected] of [ + [ + "/w/team/tickets?filter=100%25#entry", + "/w/team/tickets?filter=100%25#entry", + ], + [ + "/w/team/tickets/%e6%9c%aa?filter=100%25#part%25", + "/w/team/tickets/%e6%9c%aa?filter=100%25#part%25", + ], + [ + "/w/team/tickets/未?query=a%2fb#entry%2f2", + "/w/team/tickets/%E6%9C%AA?query=a%2fb#entry%2f2", + ], + ] + ) { + assertEquals(safeReturnTo(input), expected); + } + for ( + const input of [ + "/w/team/tickets/%54-123", + "/w/team/tickets/T-123%2fother", + "/w/team/tickets/%E0%80%AF", + "/w/team/tickets/%252f", + ] + ) { + assertEquals(safeReturnTo(input), "/dashboard"); + } +}); diff --git a/web/workspace/src/lib/workspace/auth/routes.ts b/web/workspace/src/lib/workspace/auth/routes.ts index 595f8d44e..5324ee5a6 100644 --- a/web/workspace/src/lib/workspace/auth/routes.ts +++ b/web/workspace/src/lib/workspace/auth/routes.ts @@ -11,13 +11,25 @@ export function safeReturnTo(value: string | null): string { } const rawPath = value.split(/[?#]/, 1)[0]; if ( - rawPath.includes("%") || rawPath.includes("//") || + /%[0-7][0-9a-f]/i.test(rawPath) || rawPath.includes("//") || rawPath.split("/").some((part) => part === "." || part === "..") ) { return AUTHENTICATED_HOME; } let url: URL; + let decodedPath: string; try { + decodedPath = decodeURIComponent(rawPath); + // pathは一度だけdecodeして許可画面を判定する。UTF-8 escapeの大小や生Unicodeで + // origin/区切りは変わらないため、再encodeとの文字列一致は求めない。 + // query/fragmentはpathの権限を持たないdata。単独の%25は保持し、次のdecodeで + // 別文字へ変わる二重escapeとcontrol/backslashだけを拒否する。 + const decoded = decodeURIComponent(value); + if ( + /[\\\u0000-\u001f\u007f]/u.test(decoded) || /%[0-9a-f]{2}/i.test(decoded) + ) { + return AUTHENTICATED_HOME; + } url = new URL(value, "https://app.invalid"); } catch { return AUTHENTICATED_HOME; @@ -33,9 +45,12 @@ export function safeReturnTo(value: string | null): string { } if ( url.pathname === AUTHENTICATED_HOME || url.pathname === "/account" || - /^\/w\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_/-]*)?$/.test(url.pathname) + ["/hosting/settings", "/hosting/accounts"].includes(url.pathname) || + /^\/w\/[a-zA-Z0-9_-]+(?:\/[\p{Letter}\p{Number}_/-]*)?$/u.test(decodedPath) ) { - return url.pathname; + // 認証導線の入れ子は復帰先へ持ち込まない。検索条件とfragmentはURLとして保持する。 + if (url.searchParams.has("returnTo")) return url.pathname; + return url.pathname + url.search + url.hash; } return AUTHENTICATED_HOME; } @@ -53,7 +68,29 @@ export function authEntryUrl( })}`; } +export function isPublicPage(path: string): boolean { + return [ + "/", + "/login", + "/register", + "/activate", + "/login/device", + "/hosting/setup", + ] + .includes(path); +} + export function isAuthPage(path: string): boolean { return ["/login", "/register", "/account", "/activate", "/login/device"] .includes(path); } + +// Universal loadのURLにfragmentがなくても同じ表示中URLならbrowserから補完する。 +export function browserUrlSuffix(url: URL): string { + const hash = + typeof location !== "undefined" && location.pathname === url.pathname && + location.search === url.search + ? location.hash + : url.hash; + return url.search + hash; +} diff --git a/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts new file mode 100644 index 000000000..19bf093ef --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import { createRawSnippet } from "svelte"; +import SessionBoundary from "./SessionBoundary.svelte"; +import { browserSession } from "./session-state.ts"; +import { authUser, whoamiPayload } from "./api.test-fixtures.ts"; +import { goto } from "$app/navigation"; +const page = vi.hoisted(() => ({ + url: new URL("http://localhost:3000/w/team/tickets?q=mine#entry"), +})); +vi.mock("$app/state", () => ({ page })); +vi.mock( + "$app/navigation", + () => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }), +); +afterEach(() => { + cleanup(); + browserSession.invalidate(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); +const actor = { ...authUser, auth_method: "browser_session" }; +const children = createRawSnippet(() => ({ + render: () => "

Private fragment

", +})); +async function admitted() { + window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry"); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(whoamiPayload(actor))), + ); + await browserSession.refresh(); + render(SessionBoundary, { children }); + await screen.findByText("Private fragment"); +} + +test("active expired state removes private content and preserves browser query and fragment", async () => { + await admitted(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ actor: null, session: { kind: "expired" } }) + ), + ); + await browserSession.refresh(); + await waitFor(() => + expect(screen.queryByText("Private fragment")).toBeNull() + ); + expect(goto).toHaveBeenCalledWith( + "/login?returnTo=%2Fw%2Fteam%2Ftickets%3Fq%3Dmine%23entry", + { replaceState: true }, + ); +}); + +test("unknown state hides and inerts admitted content instead of declaring anonymous", async () => { + await admitted(); + let release!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn(() => + new Promise((resolve) => { + release = resolve; + }) + ), + ); + const checking = browserSession.refresh(); + await screen.findByText("Loading session…"); + expect(screen.getByText("Private fragment").closest("[hidden][inert]")) + .toBeTruthy(); + release(new Response(null, { status: 503 })); + await checking; + await screen.findByText( + "Your session could not be loaded. Login status is unknown.", + ); + expect(screen.getByText("Private fragment").closest("[hidden][inert]")) + .toBeTruthy(); + expect(goto).not.toHaveBeenCalled(); +}); + +test("another tab changing actor cannot reveal content loaded for the old actor", async () => { + await admitted(); + const reload = vi.spyOn(location, "reload").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + whoamiPayload({ + ...actor, + user_id: "different-user", + account_id: "different-account", + }), + ) + ), + ); + await browserSession.refresh(); + await waitFor(() => expect(reload).toHaveBeenCalledOnce()); + expect(screen.queryByText("Private fragment")).toBeNull(); +}); + +test("a child mount session recheck does not unmount and restart its own loader", async () => { + window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry"); + const fetcher = vi.fn(async () => Response.json(whoamiPayload(actor))); + vi.stubGlobal("fetch", fetcher); + await browserSession.refresh(); + let mounts = 0; + const checkingChild = createRawSnippet(() => ({ + render: () => "

Mounted session reader

", + setup: () => { + ++mounts; + void browserSession.refresh(); + }, + })); + render(SessionBoundary, { children: checkingChild }); + await waitFor(() => expect(browserSession.snapshot.kind).toBe("ready")); + expect(screen.getByText("Mounted session reader").closest("[hidden]")) + .toBeNull(); + expect(mounts).toBe(1); + expect(fetcher).toHaveBeenCalledTimes(2); +}); diff --git a/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts new file mode 100644 index 000000000..5ef8ddf6a --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +// whoami境界だけを代替し、epoch競合/再確認契機を決定的に検査する。 +// cookieと実storeの一致はServer/browser結合suiteが所有する。 +import { afterEach, expect, test, vi } from "vitest"; +import { SessionState } from "./session-state.ts"; +import type { WhoamiResponse } from "./model.ts"; +import { authUser } from "./api.test-fixtures.ts"; + +const states: SessionState[] = []; +afterEach(() => { + for (const state of states.splice(0)) state.dispose(); + vi.useRealTimers(); + localStorage.clear(); +}); +const anonymous: WhoamiResponse = { + actor: null, + session: { kind: "anonymous" }, +}; +const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } }; +const authenticated = ( + expires_at: string | null = "2099-01-01T00:00:00Z", +): WhoamiResponse => ({ + actor: { ...authUser, auth_method: "browser_session" }, + session: { kind: "authenticated", expires_at }, +}); +function gate() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function state(read: () => Promise) { + const result = new SessionState(read); + states.push(result); + return result; +} + +test("concurrent session checks share one authoritative read without classifying loading as anonymous", async () => { + const response = gate(); + const read = vi.fn(() => response.promise); + const session = state(read); + const first = session.refresh(); + const second = session.refresh(); + expect(session.snapshot.kind).toBe("loading"); + expect(read).toHaveBeenCalledTimes(1); + response.resolve(expired); + await Promise.all([first, second]); + expect(session.snapshot).toEqual({ kind: "ready", value: expired }); +}); + +test("delayed expired response cannot replace a new login in the same tab", async () => { + const older = gate(); + const read = vi.fn<() => Promise>().mockReturnValueOnce( + older.promise, + ).mockResolvedValue(authenticated()); + const session = state(read); + const first = session.refresh(); + session.invalidate(); + await session.refresh(); + older.resolve(expired); + await first; + expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() }); +}); + +test("storage generation fences responses even before the other tab storage event is delivered", async () => { + const older = gate(); + const read = vi.fn<() => Promise>().mockReturnValueOnce( + older.promise, + ).mockResolvedValue(authenticated()); + const session = state(read); + const first = session.refresh(); + localStorage.setItem( + "yoi.browser-auth-generation", + JSON.stringify({ id: "new-login", reason: "authentication" }), + ); + older.resolve(expired); + await first; + expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() }); +}); + +test("active deadline rechecks the server and reports failures as unknown, never inferred expiry", async () => { + vi.useFakeTimers(); + const read = vi.fn<() => Promise>().mockResolvedValueOnce( + authenticated(new Date(Date.now() + 5_000).toISOString()), + ).mockRejectedValueOnce(new Error("network")).mockResolvedValue(expired); + const session = state(read); + await session.refresh(); + await vi.advanceTimersByTimeAsync(5_000); + expect(session.snapshot).toEqual({ kind: "error" }); + await session.refresh(); + expect(session.snapshot).toEqual({ kind: "ready", value: expired }); +}); + +test("a challenge failure is not session expiry; recheck may still be authenticated or anonymous", async () => { + for (const value of [authenticated(), anonymous]) { + const session = state(async () => value); + session.invalidate(); + await session.refresh(); + expect(session.snapshot).toEqual({ kind: "ready", value }); + } +}); diff --git a/web/workspace/src/lib/workspace/auth/session-state.ts b/web/workspace/src/lib/workspace/auth/session-state.ts new file mode 100644 index 000000000..2ff372940 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-state.ts @@ -0,0 +1,153 @@ +/// +import { loadWhoami } from "./api.ts"; +import type { WhoamiResponse } from "./model.ts"; + +export type SessionSnapshot = + | { kind: "loading" } + | { kind: "error" } + | { kind: "ready"; value: WhoamiResponse }; + +// 世代は権限ではなく、認証操作より前に開始した応答を捨てるためだけに使う。 +// Cookieの実状態は常にwhoamiが所有し、tab間通知や一般401からexpiredを推測しない。 +const GENERATION_KEY = "yoi.browser-auth-generation"; +export class SessionState { + snapshot: SessionSnapshot = { kind: "loading" }; + private generation = 0; + private pending: Promise | null = null; + private listeners = new Set<(state: SessionSnapshot) => void>(); + private timer: ReturnType | undefined; + + get authenticationGeneration(): number { + return this.generation; + } + + constructor(private readonly read = loadWhoami) {} + + subscribe(listener: (state: SessionSnapshot) => void): () => void { + this.listeners.add(listener); + listener(this.snapshot); + return () => this.listeners.delete(listener); + } + + private publish(snapshot: SessionSnapshot): void { + this.snapshot = snapshot; + clearTimeout(this.timer); + if ( + snapshot.kind === "ready" && + snapshot.value.session.kind === "authenticated" + ) { + const expiry = snapshot.value.session.expires_at; + if (expiry !== null) { + // Timerは再照会の契機。local clockだけで期限切れと断定しない。 + const remaining = Date.parse(expiry) - Date.now(); + this.timer = setTimeout(() => { + void this.refresh(); + }, Math.min(2_147_483_647, Math.max(1_000, remaining))); + } + } + for (const listener of this.listeners) listener(snapshot); + } + + invalidate(): void { + ++this.generation; + this.pending = null; + this.publish({ kind: "loading" }); + } + + async refresh(): Promise { + if (this.pending) return this.pending; + const generation = this.generation; + const stamp = storageStamp(); + this.publish({ kind: "loading" }); + const pending = (async () => { + let result: SessionSnapshot; + try { + result = { kind: "ready", value: await this.read() }; + } catch { + result = { kind: "error" }; + } + if (generation !== this.generation || stamp !== storageStamp()) { + // 新世代が既に確定していれば再取得すら不要。古い完了で確定結果を巻き戻さない。 + if (generation !== this.generation) { + if (this.pending) return await this.pending; + if (this.snapshot.kind !== "loading") return this.snapshot; + } + // 別tabの操作がstorage eventより先に完了していても古い応答を採用しない。 + if (generation === this.generation) this.invalidate(); + return await this.refresh(); + } + this.pending = null; + this.publish(result); + return result; + })(); + this.pending = pending; + return pending; + } + + dispose(): void { + clearTimeout(this.timer); + this.listeners.clear(); + ++this.generation; + } +} + +function storageStamp(): string | null { + try { + return globalThis.localStorage?.getItem(GENERATION_KEY) ?? null; + } catch { + return null; + } +} + +export const browserSession = new SessionState(); + +export function sessionMutation( + reason: "authentication" | "logout" = "authentication", +): void { + browserSession.invalidate(); + const message = JSON.stringify({ id: crypto.randomUUID(), reason }); + try { + localStorage.setItem(GENERATION_KEY, message); + } catch { /* 保存拒否でも同tab fenceは維持。 */ } + if (reason === "logout") clearReturnTo(); +} + +export const CLEAR_RETURN_EVENT = "yoi:clear-auth-return"; +function clearReturnTo(): void { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(CLEAR_RETURN_EVENT)); + } +} + +let installed = false; +export function recheckAfterUnauthorized( + authenticationGeneration: number, +): void { + if ( + installed && + authenticationGeneration === browserSession.authenticationGeneration + ) void browserSession.refresh(); +} +export function observeBrowserSession(): void { + if (installed || typeof window === "undefined") return; + installed = true; + const refresh = () => { + void browserSession.refresh(); + }; + window.addEventListener("focus", refresh); + window.addEventListener("pageshow", refresh); + document.addEventListener("visibilitychange", () => { + if (!document.hidden) refresh(); + }); + window.addEventListener("storage", (event) => { + if (event.key !== GENERATION_KEY && event.key !== null) return; + browserSession.invalidate(); + try { + if ( + event.key === null || + JSON.parse(event.newValue ?? "null")?.reason === "logout" + ) clearReturnTo(); + } catch { /* 不明な通知も権限ではなく再照会だけ。 */ } + refresh(); + }); +} diff --git a/web/workspace/src/lib/workspace/auth/session.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts index b892e0086..559b97782 100644 --- a/web/workspace/src/lib/workspace/auth/session.browser.spec.ts +++ b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom import { afterEach, expect, test, vi } from "vitest"; +import { browserSession, sessionMutation } from "./session-state.ts"; import { BrowserAuth } from "./browser-session.svelte.ts"; import { addPasskey, loginWithPasskey, logout } from "./api.ts"; import { SESSION_CHANGED_EVENT } from "./hosting.ts"; @@ -13,6 +14,7 @@ import { const sessions: BrowserAuth[] = []; afterEach(() => { for (const auth of sessions.splice(0)) auth.dispose(); + browserSession.invalidate(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -33,6 +35,9 @@ function session() { function whoami(authenticated: boolean) { return Response.json({ + session: authenticated + ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } + : { kind: "anonymous" }, actor: authenticated ? { ...authUser, auth_method: "browser_session" } : null, @@ -98,7 +103,11 @@ test("loading and failed whoami are not anonymous readiness and cannot start aut expect(await auth.run("pending", "done", operation)).toBe(false); expect(operation).not.toHaveBeenCalled(); await auth.refresh(); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); }); for ( @@ -117,6 +126,7 @@ for ( ); const auth = session(); const first = auth.refresh(); + browserSession.invalidate(); const second = auth.refresh(); latest.resolve( latestStatus === 200 @@ -146,13 +156,21 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async () const oldResponse = gate(); fetcher.mockReturnValueOnce(oldResponse.promise); const oldRefresh = auth.refresh(); - await auth.refresh(); - expect(auth.actor?.handle).toBe("alice"); - expect(await auth.run("Logging out", "Logged out", logout)).toBe(true); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe( + true, + ); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); oldResponse.resolve(whoami(true)); await oldRefresh; - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(auth.status).toBe("Logged out"); }); @@ -190,7 +208,7 @@ test("busy login rejects duplicate operations and refresh until its post-complet options.resolve(Response.json(loginOptions)); await confirmingStarted.promise; expect(auth.busy).toBe(true); - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false); await auth.refresh(); expect(paths(fetcher).filter((path) => path === "/api/auth/whoami")) @@ -220,7 +238,7 @@ for (const status of [400, 401, 410]) { expect(auth.status).toBe(""); expect(auth.error).toMatch( status === 401 - ? /Your session or authentication challenge expired/ + ? /Authentication request was rejected/ : /The request or authentication challenge is invalid or expired/, ); expect(auth.session.kind).toBe("ready"); @@ -270,7 +288,11 @@ for ( expect(auth.error).not.toContain("secret credential detail"); expect(auth.status).toBe(""); expect(auth.busy).toBe(false); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(paths(fetcher)).toEqual([ "/api/auth/whoami", "/api/auth/passkeys/login/options", @@ -338,7 +360,7 @@ for (const rejected of [false, true]) { }); } -test("dispose aborts WebAuthn and ignores a late credential without completion, session notification or refresh", async () => { +test("dispose aborts WebAuthn without accepting late credentials, while shared session is rechecked", async () => { const { fetcher, get } = boundary(); const auth = session(); await auth.refresh(); @@ -360,12 +382,17 @@ test("dispose aborts WebAuthn and ignores a late credential without completion, expect(signal.aborted).toBe(true); credential.resolve(credentialFixture(true)); expect(await run).toBe(false); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(auth.status).toBe("Waiting"); expect(auth.error).toBe(""); expect(paths(fetcher)).toEqual([ "/api/auth/whoami", "/api/auth/passkeys/login/options", + "/api/auth/whoami", ]); expect( dispatch.mock.calls.filter(([event]) => @@ -409,6 +436,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not "/api/auth/whoami", "/api/auth/passkeys/login/options", "/api/auth/passkeys/login/complete", + "/api/auth/whoami", ]); expect( dispatch.mock.calls.filter(([event]) => @@ -443,15 +471,73 @@ test("dispose during the post-operation whoami prevents its late actor from beco (signal) => loginWithPasskey(signal), ); await started.promise; - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(auth.busy).toBe(true); auth.dispose(); response.resolve(whoami(true)); expect(await run).toBe(false); - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(auth.actor).toBeNull(); }); +for (const newer of ["logout", "another-actor"] as const) { + test(`confirmation superseded by ${newer} cannot report success or navigate using the old return URL`, async () => { + const { fetcher } = boundary(); + const auth = session(); + await auth.refresh(); + const confirming = gate(); + const started = gate(); + const request = fetcher.getMockImplementation()!; + fetcher.mockImplementation((input, init) => { + if (String(input) === "/api/auth/whoami") { + started.resolve(); + return confirming.promise; + } + return request(input, init); + }); + const changed = vi.fn(); + window.addEventListener(SESSION_CHANGED_EVENT, changed); + const navigate = vi.fn(); + try { + const run = auth.run("Waiting", "Logged in", loginWithPasskey).then( + (confirmed) => { + if (confirmed && auth.actor) navigate("/account?old=return#entry"); + return confirmed; + }, + ); + await started.promise; + sessionMutation(newer === "logout" ? "logout" : "authentication"); + fetcher.mockResolvedValueOnce( + newer === "logout" ? whoami(false) : Response.json({ + actor: { + ...authUser, + user_id: "other-user", + account_id: "other-account", + handle: "bob", + auth_method: "browser_session", + }, + session: { + kind: "authenticated", + expires_at: "2099-01-01T00:00:00Z", + }, + }), + ); + await browserSession.refresh(); + confirming.resolve(whoami(true)); + expect(await run).toBe(false); + expect(auth.actor?.handle ?? null).toBe( + newer === "logout" ? null : "bob", + ); + expect(auth.status).toBe(""); + expect(auth.busy).toBe(false); + expect(changed).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + } finally { + window.removeEventListener(SESSION_CHANGED_EVENT, changed); + } + }); +} + for (const purpose of ["login", "addition"] as const) { test(`${purpose} null credential reports cancellation and never sends completion`, async () => { const { fetcher, get, create } = boundary(purpose === "addition"); diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index ad4ec795d..11cfe60ad 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -3943,6 +3943,7 @@ Deno.test("live system items reconcile with committed history by entry identity" const item = { kind: "resident_summary_refresh", body: "Resident memory refresh", + presentation: { kind: "context" }, }; const event: Event = { event: "system_item", diff --git a/web/workspace/src/lib/workspace/navigation/deferred.test.ts b/web/workspace/src/lib/workspace/navigation/deferred.test.ts index 238567212..63b3c85cc 100644 --- a/web/workspace/src/lib/workspace/navigation/deferred.test.ts +++ b/web/workspace/src/lib/workspace/navigation/deferred.test.ts @@ -21,6 +21,7 @@ Deno.test("deferred load returns before API completion and snapshots navigation const api = gate(); const event = { params: { id: "first" }, + parent: async () => ({}), url: new URL("https://example.test/first?q=old"), }; const load = deferPage(async ({ params, url }: typeof event) => ({ @@ -54,9 +55,12 @@ Deno.test("deferred redirects and HTTP failures are handled results, not unhandl ], ] as const ) { - const load = deferPage((_event: { params: {}; url: URL }) => run()); + const load = deferPage(( + _event: { params: {}; url: URL; parent: () => Promise }, + ) => run()); const result = await load({ params: {}, + parent: async () => ({}), url: new URL("https://example.test/"), }).deferredPage; assert( @@ -94,3 +98,39 @@ Deno.test("resolved data preserves page overrides without waiting for the global result.data.workspaceId === "team", ); }); + +Deno.test("private deferred loader starts only after parent admission and never on parent failure", async () => { + for (const outcome of ["admit", "anonymous", "expired", "error"] as const) { + const admission = gate(); + let calls = 0; + const load = deferPage( + async ( + _event: { params: {}; url: URL; parent: () => Promise }, + ) => { + ++calls; + return {}; + }, + ); + const result = load({ + params: {}, + url: new URL("https://app.invalid/w/a/tickets"), + parent: async () => { + await admission.promise; + if (outcome === "anonymous") redirect(307, "/"); + if (outcome === "expired") { + redirect(307, "/login?returnTo=%2Fw%2Fa%2Ftickets"); + } + if (outcome === "error") { + error(503, "Unknown session"); + } + return {}; + }, + }); + await Promise.resolve(); + assert(calls === 0); + admission.release(); + const settled = await result.deferredPage; + assert(calls === (outcome === "admit" ? 1 : 0)); + assert(settled.ok === (outcome === "admit")); + } +}); diff --git a/web/workspace/src/lib/workspace/navigation/deferred.ts b/web/workspace/src/lib/workspace/navigation/deferred.ts index c3baaf145..6e71ab849 100644 --- a/web/workspace/src/lib/workspace/navigation/deferred.ts +++ b/web/workspace/src/lib/workspace/navigation/deferred.ts @@ -39,7 +39,11 @@ export async function settle( } export function deferPage< - E extends { params: Record; url: URL }, + E extends { + params: Record; + url: URL; + parent: () => Promise; + }, T extends object, >( loader: (event: E) => T | Promise, @@ -51,11 +55,13 @@ export function deferPage< params: { ...event.params }, url: new URL(event.url.href), }; - try { - return { deferredPage: settle(loader(snapshot)) }; - } catch (error) { - return { deferredPage: settle(Promise.reject(error)) }; - } + // SvelteKitはparent/childを並列開始する。deferしても認証前fetchを開始しない。 + return { + deferredPage: settle((async () => { + await snapshot.parent(); + return await loader(snapshot); + })()), + }; }; } diff --git a/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts b/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts index 905d3fe88..695e5841f 100644 --- a/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts +++ b/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts @@ -18,6 +18,9 @@ for ( const fetcher = vi.fn().mockImplementation(async (input) => { if (String(input) === "/api/auth/whoami") { return Response.json({ + session: actor + ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } + : { kind: "anonymous" }, actor: actor ? { ...authUser, auth_method: "browser_session" } : null, }); } diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 401c24047..bbaab7c93 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -4,6 +4,7 @@ // navigationの構成はWorkspace identityやpermissionの根拠にはしない。 // Header下辺とSidebar右辺だけで領域境界を示し、viewport外周に線を重ねない。 // MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。 + import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte'; import { page } from '$app/state'; import { setContext } from 'svelte'; import { MediaQuery } from 'svelte/reactivity'; @@ -96,6 +97,7 @@ }); + {#if standalone}
{@render children()} @@ -147,6 +149,7 @@
{/if} +