chore: merge develop into hare/develop

This commit is contained in:
2026-10-20 15:39:09 +09:00
226 changed files with 27725 additions and 3705 deletions
Generated
+3
View File
@@ -5266,6 +5266,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
"uuid",
"workdir", "workdir",
] ]
@@ -6705,6 +6706,7 @@ dependencies = [
name = "yoi-e2e" name = "yoi-e2e"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"chrono",
"libc", "libc",
"reqwest", "reqwest",
"serde", "serde",
@@ -6714,6 +6716,7 @@ dependencies = [
"tempfile", "tempfile",
"webauthn-authenticator-rs", "webauthn-authenticator-rs",
"webauthn-rs-proto", "webauthn-rs-proto",
"yoi-workspace-server",
] ]
[[package]] [[package]]
+1
View File
@@ -908,6 +908,7 @@ mod tests {
workdir_attachments: Vec::new(), workdir_attachments: Vec::new(),
feature_connections: Default::default(), feature_connections: Default::default(),
control_operation_id: None, control_operation_id: None,
execution_grant: None,
}; };
let response = create_backend_worker_with_client(&target, &create, &api) let response = create_backend_worker_with_client(&target, &create, &api)
+1
View File
@@ -567,6 +567,7 @@ impl BackendWorkspaceProductClient {
workdir_attachments: Vec::new(), workdir_attachments: Vec::new(),
feature_connections: Default::default(), feature_connections: Default::default(),
control_operation_id: None, control_operation_id: None,
execution_grant: None,
}; };
let workspace_id = self.workspace_id.clone(); let workspace_id = self.workspace_id.clone();
let response: BrowserCreateWorkerResponse = self.generated(move |client| async move { let response: BrowserCreateWorkerResponse = self.generated(move |client| async move {
+30
View File
@@ -200,6 +200,7 @@ fn run_read_with_limits(
total_lines, total_lines,
content_hash: content_hasher.finalize().into(), content_hash: content_hasher.finalize().into(),
truncated: end < total_lines || byte_truncated, 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")); 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] #[test]
fn bounded_read_caps_retained_response_independently_of_request() { fn bounded_read_caps_retained_response_independently_of_request() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -907,6 +936,7 @@ mod bounded_read_tests {
assert_eq!(result.bytes, b"alpha"); assert_eq!(result.bytes, b"alpha");
assert_eq!(result.total_lines, 3); assert_eq!(result.total_lines, 3);
assert!(result.truncated); assert!(result.truncated);
assert!(result.byte_truncated);
} }
} }
+4
View File
@@ -204,7 +204,11 @@ pub struct ReadResult {
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub total_lines: usize, pub total_lines: usize,
pub content_hash: ContentHash, pub content_hash: ContentHash,
/// More source lines remain, or the selected window exceeded the response byte budget.
pub truncated: bool, 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. /// Binary-safe byte range read with an optional whole-file version fence.
+19 -7
View File
@@ -408,7 +408,7 @@ pub enum Method {
/// Transport is fire-and-forget; receivers must tolerate out-of-order /// Transport is fire-and-forget; receivers must tolerate out-of-order
/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same /// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same
/// child Worker). /// 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 = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
@@ -982,6 +982,17 @@ pub enum SessionEntryProvenance {
LegacyUnknown, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -994,6 +1005,8 @@ pub struct SessionSnapshotEntry {
pub provenance: SessionEntryProvenance, pub provenance: SessionEntryProvenance,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub derived_from: Vec<String>, pub derived_from: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_policy: Option<SessionEntryUsePolicy>,
#[serde(flatten)] #[serde(flatten)]
pub data: SessionSnapshotEntryData, pub data: SessionSnapshotEntryData,
} }
@@ -1180,12 +1193,10 @@ pub enum Event {
}, },
/// One agent-injected system item committed to history. /// One agent-injected system item committed to history.
/// ///
/// Carries the JSON form of `session_store::SystemItem`. Covers /// Carries the allowlisted JSON form of `session_store::PublicSystemItem`,
/// `Method::Notify` echoes, child-Worker lifecycle events from /// not the storage record. Its descriptive `kind` is independent from the
/// `Method::WorkerEvent`, `@<path>` resolution payloads, and any future /// typed `presentation` used for notification, reminder, or context rendering.
/// Clients dispatch on the `kind` tag for typed rendering instead /// Private Feature payloads and prompt provenance are never event data.
/// of parsing free-text prefixes like `[Notification] …` or
/// `[File: …]`.
/// ///
/// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and /// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and
/// wire-side are 1:1. /// wire-side are 1:1.
@@ -2621,6 +2632,7 @@ mod tests {
session: SessionSnapshot { session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(), pending_submissions: PendingSubmissionsSnapshot::default(),
entries: vec![SessionSnapshotEntry { entries: vec![SessionSnapshotEntry {
use_policy: None,
entry_id: "entry-1".into(), entry_id: "entry-1".into(),
timestamp: 1, timestamp: 1,
provenance: SessionEntryProvenance::HumanInput, provenance: SessionEntryProvenance::HumanInput,
+7 -6
View File
@@ -17,12 +17,12 @@ use crate::{
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
ReasoningConfig, RewindSummary, RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ReasoningConfig, RewindSummary, RunFailureKind, RunResult, RunResumeSource, RunYieldReason,
ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance, ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionEntryUsePolicy, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole,
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope, UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
WorkerStateSnapshot, WorkerStatus, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
subscription::{ subscription::{
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES, EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS, MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
@@ -117,6 +117,7 @@ pub fn generated_protocol_types() -> String {
push_decl::<InFlightCompaction>(&cfg, &mut output); push_decl::<InFlightCompaction>(&cfg, &mut output);
push_decl::<InFlightSnapshot>(&cfg, &mut output); push_decl::<InFlightSnapshot>(&cfg, &mut output);
push_decl::<SessionEntryProvenance>(&cfg, &mut output); push_decl::<SessionEntryProvenance>(&cfg, &mut output);
push_decl::<SessionEntryUsePolicy>(&cfg, &mut output);
push_decl::<SessionMessageRole>(&cfg, &mut output); push_decl::<SessionMessageRole>(&cfg, &mut output);
push_decl::<SessionContentPart>(&cfg, &mut output); push_decl::<SessionContentPart>(&cfg, &mut output);
push_decl::<SessionToolAttachment>(&cfg, &mut output); push_decl::<SessionToolAttachment>(&cfg, &mut output);
+475
View File
@@ -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<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[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<ScopedPermission>,
pub delegation: Vec<ScopedPermission>,
}
#[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<String>,
pub execution: Vec<ScopedPermission>,
pub delegation: Vec<ScopedPermission>,
}
#[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<ScopedPermission>,
pub redelegation: Vec<ScopedPermission>,
}
#[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<ScopedPermission>,
pub redelegation: Vec<ScopedPermission>,
}
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<PermissionResource>,
}
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::<WorkerDelegationRequest>(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::<PermissionResource>(
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::<OperationAuthorizationResponse>(wire).unwrap(),
granted
);
}
}
+10
View File
@@ -134,6 +134,14 @@ pub enum DriveEntryKind {
File, 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. /// Metadata only: content is returned through bounded text or binary routes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[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 DriveApiError {}
impl api_macros::openapi::OpenApiSchema for DriveEntry {} 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 DriveEntryQuery {}
impl api_macros::openapi::OpenApiSchema for DriveListQuery {} impl api_macros::openapi::OpenApiSchema for DriveListQuery {}
impl api_macros::openapi::OpenApiSchema for DriveSearchQuery {} impl api_macros::openapi::OpenApiSchema for DriveSearchQuery {}
@@ -518,6 +527,7 @@ pub fn drive_api_typescript() -> String {
DriveEntryRef::decl(&config), DriveEntryRef::decl(&config),
DriveEntryKind::decl(&config), DriveEntryKind::decl(&config),
DriveEntry::decl(&config), DriveEntry::decl(&config),
DriveCapabilities::decl(&config),
DriveEntryQuery::decl(&config), DriveEntryQuery::decl(&config),
DriveListQuery::decl(&config), DriveListQuery::decl(&config),
DriveSearchQuery::decl(&config), DriveSearchQuery::decl(&config),
+131 -1
View File
@@ -6,6 +6,7 @@ use api_macros::api;
pub use api_macros::axum as server_support; pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_support; pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
pub mod authorization;
mod drive; mod drive;
mod notification; mod notification;
pub use notification::*; pub use notification::*;
@@ -23,6 +24,34 @@ use webauthn_rs_proto::{
}; };
pub use workspace_config::*; 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<authorization::SubjectPermissions>,
}
#[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<SubjectPermissionInterruption>,
}
#[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<String>,
}
#[allow(dead_code)] #[allow(dead_code)]
#[derive(JsonSchema)] #[derive(JsonSchema)]
struct ServerJsonSafeU64(#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] u64); 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!( impl_openapi_schema!(
SubjectPermissionsResponse,
SubjectPermissionsMutationResponse,
SubjectPermissionInterruption,
authorization::SubjectPermissions,
authorization::SubjectPermissionsUpdate,
AuthBootstrapUserRequest, AuthBootstrapUserRequest,
AuthPublicConfig, AuthPublicConfig,
HostingSettings, HostingSettings,
@@ -1298,6 +1332,51 @@ pub trait ServerApi {
#[body] request: MemoryConsolidateStagingRequest, #[body] request: MemoryConsolidateStagingRequest,
) -> Result<MemoryConsolidationResponse, RepositoryApiError>; ) -> Result<MemoryConsolidationResponse, RepositoryApiError>;
#[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<authorization::OperationAuthorizationResponse, RepositoryApiError>;
#[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<SubjectPermissionsResponse, RepositoryApiError>;
#[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<SubjectPermissionsMutationResponse, RepositoryApiError>;
#[post( #[post(
"/api/w/{workspace_id}/subjektiv/subjects", "/api/w/{workspace_id}/subjektiv/subjects",
status = 201, status = 201,
@@ -2762,6 +2841,12 @@ pub trait ServerApi {
#[extension] context: ServerRequestContext, #[extension] context: ServerRequestContext,
#[path] workspace_id: String, #[path] workspace_id: String,
) -> Result<DriveEntry, DriveApiError>; ) -> Result<DriveEntry, DriveApiError>;
#[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<DriveCapabilities, DriveApiError>;
#[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)] #[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( async fn drive_metadata(
&self, &self,
@@ -4025,6 +4110,17 @@ impl RequestActor {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WhoamiResponse { pub struct WhoamiResponse {
pub actor: Option<RequestActor>, pub actor: Option<RequestActor>,
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<String> },
Expired {},
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[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. /// Backend idempotency key used only for authenticated Worker-owned spawn/control.
#[serde(default)] #[serde(default)]
pub control_operation_id: Option<String>, pub control_operation_id: Option<String>,
/// 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<authorization::WorkerDelegationRequest>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -11533,6 +11633,7 @@ pub fn auth_api_typescript() -> String {
ActorAuthMethod::decl(&config), ActorAuthMethod::decl(&config),
AuthenticatedUser::decl(&config), AuthenticatedUser::decl(&config),
RequestActor::decl(&config), RequestActor::decl(&config),
BrowserSessionState::decl(&config),
WhoamiResponse::decl(&config), WhoamiResponse::decl(&config),
AuthBootstrapUserRequest::decl(&config), AuthBootstrapUserRequest::decl(&config),
AuthUserResponse::decl(&config), AuthUserResponse::decl(&config),
@@ -12929,6 +13030,7 @@ mod tests {
workdir_attachments: Vec::new(), workdir_attachments: Vec::new(),
feature_connections: Default::default(), feature_connections: Default::default(),
control_operation_id: None, control_operation_id: None,
execution_grant: None,
}) })
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -12943,6 +13045,7 @@ mod tests {
"workdir_attachments": [], "workdir_attachments": [],
"feature_connections": {}, "feature_connections": {},
"control_operation_id": null, "control_operation_id": null,
"execution_grant": null,
}) })
); );
} }
@@ -13961,7 +14064,8 @@ mod tests {
"handle": "hare", "handle": "hare",
"display_name": "Hare", "display_name": "Hare",
"auth_method": "browser_session" "auth_method": "browser_session"
} },
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" }
}); });
let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone()) let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone())
.expect("server whoami fixture should match shared DTO"); .expect("server whoami fixture should match shared DTO");
@@ -14143,11 +14247,36 @@ mod tests {
let unexpected_field = serde_json::json!({ let unexpected_field = serde_json::json!({
"actor": null, "actor": null,
"session": { "kind": "anonymous" },
"access_token": "must-not-be-accepted" "access_token": "must-not-be-accepted"
}); });
assert!(serde_json::from_value::<WhoamiResponse>(unexpected_field).is_err()); assert!(serde_json::from_value::<WhoamiResponse>(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::<WhoamiResponse>(
serde_json::json!({"actor":null, "session":session})
)
.is_err()
);
}
}
#[test] #[test]
fn device_login_debug_output_redacts_secret_material() { fn device_login_debug_output_redacts_secret_material() {
let start = DeviceLoginStartResponse { let start = DeviceLoginStartResponse {
@@ -14532,6 +14661,7 @@ mod openapi_artifact_tests {
// identity, or a one-use Reviewer capability. They remain generated Rust client/Axum // 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. // operations, but must not appear as unauthenticated operations in the public OpenAPI.
const SIGNED_INTERNAL: &[&str] = &[ const SIGNED_INTERNAL: &[&str] = &[
"operation_authorize",
"current_worker_workspace_config_get", "current_worker_workspace_config_get",
"current_worker_workspace_config_attach", "current_worker_workspace_config_attach",
"current_worker_workspace_config_observe", "current_worker_workspace_config_observe",
+4
View File
@@ -361,6 +361,7 @@ impl Store for FsStore {
segment_id: SegmentId, segment_id: SegmentId,
entry: &LogEntry, entry: &LogEntry,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
entry.validate_input_metadata()?;
let _retention = self.retention().lock_live(session_id)?; let _retention = self.retention().lock_live(session_id)?;
let line = serde_json::to_string(entry)?; let line = serde_json::to_string(entry)?;
self.append_line(&self.log_path(session_id, segment_id), &line) self.append_line(&self.log_path(session_id, segment_id), &line)
@@ -448,6 +449,9 @@ impl Store for FsStore {
segment_id: SegmentId, segment_id: SegmentId,
entries: &[LogEntry], entries: &[LogEntry],
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
for entry in entries {
entry.validate_input_metadata()?;
}
let _retention = self.retention().lock_live(session_id)?; let _retention = self.retention().lock_live(session_id)?;
let path = self.log_path(session_id, segment_id); let path = self.log_path(session_id, segment_id);
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
+202
View File
@@ -67,6 +67,10 @@ pub enum LoggedSessionHistoryOrigin {
ToolOutput { ToolOutput {
worker: LoggedWorkerSubject, 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. /// Host-produced opening context summary; not auto-read or TaskStore material.
CompactionSummary, CompactionSummary,
/// Frozen Host Journal reference, never independent observation evidence. /// Frozen Host Journal reference, never independent observation evidence.
@@ -81,19 +85,32 @@ pub struct LoggedHistoryDerivation {
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LoggedSessionHistoryMetadata { pub struct LoggedSessionHistoryMetadata {
pub entry_id: LoggedSessionHistoryEntryId, pub entry_id: LoggedSessionHistoryEntryId,
pub origin: LoggedSessionHistoryOrigin, pub origin: LoggedSessionHistoryOrigin,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub derivation: Option<LoggedHistoryDerivation>, pub derivation: Option<LoggedHistoryDerivation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_metadata: Option<crate::HistoryInputMetadata>,
} }
impl LoggedSessionHistoryMetadata { 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 { pub fn legacy_unknown() -> Self {
Self { Self {
entry_id: LoggedSessionHistoryEntryId::new(), entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown, origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None, derivation: None,
input_metadata: None,
} }
} }
} }
@@ -101,20 +118,146 @@ impl LoggedSessionHistoryMetadata {
/// Persisted item and metadata are one value so transforms cannot reorder or /// Persisted item and metadata are one value so transforms cannot reorder or
/// truncate one without the other. /// truncate one without the other.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "HistoryEntryWire")]
pub struct LoggedHistoryEntry { pub struct LoggedHistoryEntry {
pub item: LoggedItem, pub item: LoggedItem,
pub metadata: LoggedSessionHistoryMetadata, pub metadata: LoggedSessionHistoryMetadata,
} }
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct HistoryEntryWire {
item: LoggedItem,
metadata: LoggedSessionHistoryMetadata,
}
impl TryFrom<HistoryEntryWire> for LoggedHistoryEntry {
type Error = crate::InputMetadataError;
fn try_from(wire: HistoryEntryWire) -> Result<Self, Self::Error> {
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 /// Typed system-item history record. The typed system event remains available
/// to client replay while its model-visible projection carries the same stable /// to client replay while its model-visible projection carries the same stable
/// metadata used by live history. /// metadata used by live history.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(try_from = "SystemEntryWire")]
pub struct LoggedSystemHistoryEntry { pub struct LoggedSystemHistoryEntry {
pub item: crate::SystemItem, pub item: crate::SystemItem,
pub metadata: LoggedSessionHistoryMetadata, pub metadata: LoggedSessionHistoryMetadata,
} }
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SystemEntryWire {
item: crate::SystemItem,
metadata: LoggedSessionHistoryMetadata,
}
impl TryFrom<SystemEntryWire> for LoggedSystemHistoryEntry {
type Error = crate::InputMetadataError;
fn try_from(wire: SystemEntryWire) -> Result<Self, Self::Error> {
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<LoggedHistoryDerivation>,
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -132,6 +275,7 @@ mod tests {
}], }],
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId::new(), entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::HumanInput { origin: LoggedSessionHistoryOrigin::HumanInput {
account_id: "account-1".into(), 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::<LoggedHistoryEntry>(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::<LoggedSystemHistoryEntry>(wire).is_err()
} else {
serde_json::from_value::<LoggedHistoryEntry>(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] #[test]
fn annotated_segment_start_is_restore_visible_without_projecting_metadata() { fn annotated_segment_start_is_restore_visible_without_projecting_metadata() {
let session_id = uuid::Uuid::now_v7(); let session_id = uuid::Uuid::now_v7();
+563
View File
@@ -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<PayloadWire> for FeatureInputPayload {
type Error = InputMetadataError;
fn try_from(wire: PayloadWire) -> Result<Self, Self::Error> {
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<T: Serialize>(
schema: impl Into<String>,
version: u32,
value: &T,
) -> Result<Self, InputMetadataError> {
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<T: DeserializeOwned>(
&self,
schema: &str,
version: u32,
) -> Result<T, InputMetadataError> {
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<FeatureInputPayload>,
public: Option<PublicSystemItem>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MetadataWire {
usage: HistoryInputUsePolicy,
feature_payload: Option<FeatureInputPayload>,
public: Option<PublicSystemItem>,
}
impl TryFrom<MetadataWire> for HistoryInputMetadata {
type Error = InputMetadataError;
fn try_from(wire: MetadataWire) -> Result<Self, Self::Error> {
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<FeatureInputPayload>,
public: Option<PublicSystemItem>,
) -> Result<Self, InputMetadataError> {
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<InputWire> for SystemInputEnvelope {
type Error = InputMetadataError;
fn try_from(wire: InputWire) -> Result<Self, Self::Error> {
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<String>,
metadata: HistoryInputMetadata,
) -> Result<Self, InputMetadataError> {
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::<OwnerPayload>("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::<OwnerPayload>("owner:context", 1).unwrap(),
input
);
let invalid = FeatureInputPayload::encode(
"owner:context",
1,
&serde_json::json!({ "omitted": "not a bool" }),
)
.unwrap();
assert_eq!(
invalid.decode::<OwnerPayload>("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::<FeatureInputPayload>(
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::<HistoryInputUsePolicy>(absent).is_err());
let mut unknown = valid.clone();
unknown[field] = Value::String("future-allow".into());
assert!(serde_json::from_value::<HistoryInputUsePolicy>(unknown).is_err());
}
let mut extra = valid;
extra["feature_name"] = Value::String("infer-policy-from-me".into());
assert!(serde_json::from_value::<HistoryInputUsePolicy>(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::<SystemInputEnvelope>(invalid).is_err());
}
}
+354 -21
View File
@@ -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, //! These types are intentionally private to `session-store`. Current writers,
//! replay, and public projections use [`crate::LogEntry`] exclusively; only the //! 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 agen::llm_client::types::RequestConfig;
use protocol::Segment; use protocol::Segment;
use serde::Deserialize; use serde::{Deserialize, Serialize};
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
@@ -17,6 +17,212 @@ use crate::{
SegmentOrigin, SessionExtension, SessionId, SystemItem, 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<crate::PromptRenderProvenance>,
}
#[derive(Debug, Serialize, Deserialize)]
struct SubjectBehaviorPayload {
#[serde(default)]
behavior_md: String,
#[serde(default)]
prompt_provenance: Option<crate::PromptRenderProvenance>,
}
#[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<LoggedSystemHistoryEntry, crate::InputMetadataError> {
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<T: Serialize>(
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<SessionExtension>,
},
}
#[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<LogEntry, crate::InputMetadataError> {
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)] #[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
enum LegacyHistoryLogEntry { enum LegacyHistoryLogEntry {
@@ -47,7 +253,9 @@ enum LegacyHistoryLogEntry {
}, },
SystemItem { SystemItem {
ts: u64, ts: u64,
item: SystemItem, item: LegacySystemItem,
#[serde(default)]
extensions: Vec<SessionExtension>,
}, },
} }
@@ -57,7 +265,7 @@ enum LegacyHistoryLogEntry {
#[serde(untagged)] #[serde(untagged)]
enum LegacySessionLogEntryV1 { enum LegacySessionLogEntryV1 {
History(LegacyHistoryLogEntry), History(LegacyHistoryLogEntry),
Current(LogEntry), Current(LegacyAnnotatedOrCurrent),
} }
/// Schema v2 retained the v1 history shapes while adding non-history records. /// Schema v2 retained the v1 history shapes while adding non-history records.
@@ -67,7 +275,7 @@ enum LegacySessionLogEntryV1 {
#[serde(untagged)] #[serde(untagged)]
enum LegacySessionLogEntryV2 { enum LegacySessionLogEntryV2 {
History(LegacyHistoryLogEntry), History(LegacyHistoryLogEntry),
Current(LogEntry), Current(LegacyAnnotatedOrCurrent),
} }
pub(crate) fn decode_entry( pub(crate) fn decode_entry(
@@ -77,24 +285,75 @@ pub(crate) fn decode_entry(
segment_id: SegmentId, segment_id: SegmentId,
line_index: usize, line_index: usize,
) -> Result<LogEntry, serde_json::Error> { ) -> Result<LogEntry, serde_json::Error> {
let mut wire: serde_json::Value = serde_json::from_str(line)?;
migrate_legacy_metadata_field(&mut wire)?;
let entry = match schema_version { let entry = match schema_version {
1 => match serde_json::from_str::<LegacySessionLogEntryV1>(line)? { 1 => match serde_json::from_value::<LegacySessionLogEntryV1>(wire)? {
LegacySessionLogEntryV1::History(entry) => Entry::History(entry), LegacySessionLogEntryV1::History(entry) => Entry::History(entry),
LegacySessionLogEntryV1::Current(entry) => Entry::Current(entry), LegacySessionLogEntryV1::Current(entry) => Entry::Current(entry),
}, },
2 => match serde_json::from_str::<LegacySessionLogEntryV2>(line)? { 2 => match serde_json::from_value::<LegacySessionLogEntryV2>(wire)? {
LegacySessionLogEntryV2::History(entry) => Entry::History(entry), LegacySessionLogEntryV2::History(entry) => Entry::History(entry),
LegacySessionLogEntryV2::Current(entry) => Entry::Current(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}"), _ => unreachable!("legacy decoder called for unsupported schema {schema_version}"),
}; };
Ok(match entry { match entry {
Entry::History(entry) => { Entry::History(entry) => {
canonicalize_history_entry(session_id, segment_id, line_index, 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 /// 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 { enum Entry {
History(LegacyHistoryLogEntry), History(LegacyHistoryLogEntry),
Current(LogEntry), Current(LegacyAnnotatedOrCurrent),
} }
fn legacy_metadata( 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(&(line_index as u64).to_be_bytes());
identity.extend_from_slice(&(item_index as u64).to_be_bytes()); identity.extend_from_slice(&(item_index as u64).to_be_bytes());
LoggedSessionHistoryMetadata { LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId(format!( entry_id: LoggedSessionHistoryEntryId(format!(
"l-{}", "l-{}",
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, identity) base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, identity)
@@ -181,8 +513,8 @@ fn canonicalize_history_entry(
segment_id: SegmentId, segment_id: SegmentId,
line_index: usize, line_index: usize,
entry: LegacyHistoryLogEntry, entry: LegacyHistoryLogEntry,
) -> LogEntry { ) -> Result<LogEntry, crate::InputMetadataError> {
match entry { Ok(match entry {
LegacyHistoryLogEntry::SegmentStart { LegacyHistoryLogEntry::SegmentStart {
ts, ts,
session_id, session_id,
@@ -236,13 +568,14 @@ fn canonicalize_history_entry(
metadata: legacy_metadata(segment_id, line_index, 0), metadata: legacy_metadata(segment_id, line_index, 0),
}, },
}, },
LegacyHistoryLogEntry::SystemItem { ts, item } => LogEntry::AnnotatedSystemItem { LegacyHistoryLogEntry::SystemItem {
ts, ts,
entry: LoggedSystemHistoryEntry {
item, item,
metadata: legacy_metadata(segment_id, line_index, 0), extensions,
}, } => LogEntry::AnnotatedSystemItem {
extensions: Vec::new(), ts,
entry: item.into_entry(legacy_metadata(segment_id, line_index, 0))?,
extensions,
}, },
} })
} }
@@ -1,5 +1,7 @@
use super::*; use super::*;
mod v5;
fn seed( fn seed(
origin: LoggedSessionHistoryOrigin, origin: LoggedSessionHistoryOrigin,
compacted: bool, compacted: bool,
@@ -22,6 +24,7 @@ fn seed(
content: vec![crate::LoggedContentPart::Text { text: text.into() }], content: vec![crate::LoggedContentPart::Text { text: text.into() }],
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId("summary".into()), entry_id: LoggedSessionHistoryEntryId("summary".into()),
origin, origin,
derivation: Some(crate::LoggedHistoryDerivation { derivation: Some(crate::LoggedHistoryDerivation {
@@ -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"}]}}
@@ -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}}}}
@@ -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"]}}}}
@@ -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<LogEntry> {
source
.lines()
.enumerate()
.map(|(index, line)| decode_entry(version, line, SessionId::nil(), segment, index).unwrap())
.collect()
}
fn encode(entries: &[LogEntry]) -> Vec<u8> {
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<Value> = 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<crate::SessionExtension> = 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::<LogEntry>(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<Value> = V4
.lines()
.take(5)
.map(|line| serde_json::from_str::<Value>(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::<LogEntry>(&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<Value> = 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);
}
}
+7 -1
View File
@@ -41,6 +41,7 @@ pub(crate) fn read_without_atime_bounded(path: &Path, max_bytes: u64) -> io::Res
pub mod event_trace; pub mod event_trace;
pub mod fs_store; pub mod fs_store;
pub mod history; pub mod history;
pub mod input_metadata;
mod legacy_session_log; mod legacy_session_log;
pub mod logged_item; pub mod logged_item;
mod paste_artifact; mod paste_artifact;
@@ -65,6 +66,10 @@ pub use history::{
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
LoggedWorkerSubject, 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 logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use paste_artifact::PasteArtifactLimits; pub use paste_artifact::PasteArtifactLimits;
pub use public_index::{ pub use public_index::{
@@ -98,7 +103,8 @@ pub use segment::{
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state}; pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state};
pub use store::{Store, StoreError}; pub use store::{Store, StoreError};
pub use system_item::{ pub use system_item::{
PromptRenderProvenance, SystemItem, SystemReminder, SystemReminderSource, render_worker_event, PromptRenderProvenance, PublicSystemItem, PublicSystemPresentation, SystemItem, SystemReminder,
SystemReminderSource, render_worker_event,
}; };
pub use uploaded_file::{ pub use uploaded_file::{
DEFAULT_MAX_FILES_PER_SUBMISSION, DEFAULT_MAX_SESSION_ARTIFACT_BYTES, DEFAULT_MAX_FILES_PER_SUBMISSION, DEFAULT_MAX_SESSION_ARTIFACT_BYTES,
+2
View File
@@ -1296,6 +1296,7 @@ mod tests {
LoggedHistoryEntry { LoggedHistoryEntry {
item, item,
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId(id.to_owned()), entry_id: LoggedSessionHistoryEntryId(id.to_owned()),
origin, origin,
derivation: None, derivation: None,
@@ -1537,6 +1538,7 @@ mod tests {
prompt_provenance: None, prompt_provenance: None,
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId("system-item".into()), entry_id: LoggedSessionHistoryEntryId("system-item".into()),
origin: LoggedSessionHistoryOrigin::BackendInstruction { origin: LoggedSessionHistoryOrigin::BackendInstruction {
operation_id: None, operation_id: None,
+110 -28
View File
@@ -148,15 +148,24 @@ struct SegmentHeader {
file_len: u64, 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 /// A fixed material catalog. Opening a new catalog observes newly appended
/// records; reading a body through this catalog verifies its saved digest and /// records; reading a body through this catalog verifies its saved digest and
/// byte length again. Missing/corrected/deleted sources are explicit failures. /// 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 { pub struct SessionPublicMaterialReader {
session_id: SessionId, session_id: SessionId,
active_segment_id: SegmentId, active_segment_id: SegmentId,
layout: Layout, layout: Layout,
limits: PublicMaterialLimits, limits: PublicMaterialLimits,
materials: Vec<Material>, materials: Vec<Material>,
purpose: PublicMaterialUse,
retention: Option<crate::retention::SessionRetentionStore>, retention: Option<crate::retention::SessionRetentionStore>,
} }
@@ -180,7 +189,13 @@ impl SessionPublicMaterialReader {
active_segment_id: SegmentId, active_segment_id: SegmentId,
limits: PublicMaterialLimits, limits: PublicMaterialLimits,
) -> Result<Self, PublicMaterialError> { ) -> Result<Self, PublicMaterialError> {
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. /// Uses the Host's aggregate-external control root for the whole scan and every body read.
@@ -191,7 +206,30 @@ impl SessionPublicMaterialReader {
limits: PublicMaterialLimits, limits: PublicMaterialLimits,
retention: crate::retention::SessionRetentionStore, retention: crate::retention::SessionRetentionStore,
) -> Result<Self, PublicMaterialError> { ) -> Result<Self, PublicMaterialError> {
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, PublicMaterialError> {
Self::open_worker_guarded(
session_root,
active_segment_id,
limits,
Some(retention),
purpose,
)
} }
fn open_worker_guarded( fn open_worker_guarded(
@@ -199,6 +237,7 @@ impl SessionPublicMaterialReader {
active_segment_id: SegmentId, active_segment_id: SegmentId,
limits: PublicMaterialLimits, limits: PublicMaterialLimits,
retention: Option<crate::retention::SessionRetentionStore>, retention: Option<crate::retention::SessionRetentionStore>,
purpose: PublicMaterialUse,
) -> Result<Self, PublicMaterialError> { ) -> Result<Self, PublicMaterialError> {
let store = WorkerSessionStore::open_read_only(session_root) let store = WorkerSessionStore::open_read_only(session_root)
.map_err(|_| PublicMaterialError::Unavailable)?; .map_err(|_| PublicMaterialError::Unavailable)?;
@@ -212,6 +251,7 @@ impl SessionPublicMaterialReader {
Layout::Worker(session_root.into()), Layout::Worker(session_root.into()),
limits, limits,
retention, retention,
purpose,
) )
} }
@@ -237,6 +277,25 @@ impl SessionPublicMaterialReader {
active_segment_id: SegmentId, active_segment_id: SegmentId,
limits: PublicMaterialLimits, limits: PublicMaterialLimits,
retention: crate::retention::SessionRetentionStore, retention: crate::retention::SessionRetentionStore,
) -> Result<Self, PublicMaterialError> {
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, PublicMaterialError> { ) -> Result<Self, PublicMaterialError> {
Self::open( Self::open(
session_id, session_id,
@@ -244,6 +303,7 @@ impl SessionPublicMaterialReader {
Layout::Fs(sessions_root.join(session_id.to_string())), Layout::Fs(sessions_root.join(session_id.to_string())),
limits, limits,
Some(retention), Some(retention),
purpose,
) )
} }
@@ -263,6 +323,7 @@ impl SessionPublicMaterialReader {
layout: Layout, layout: Layout,
limits: PublicMaterialLimits, limits: PublicMaterialLimits,
retention: Option<crate::retention::SessionRetentionStore>, retention: Option<crate::retention::SessionRetentionStore>,
purpose: PublicMaterialUse,
) -> Result<Self, PublicMaterialError> { ) -> Result<Self, PublicMaterialError> {
let _guard = retention let _guard = retention
.as_ref() .as_ref()
@@ -310,11 +371,10 @@ impl SessionPublicMaterialReader {
let source_ids: Option<Vec<String>> = if compact { let source_ids: Option<Vec<String>> = if compact {
let summaries: Vec<_> = history let summaries: Vec<_> = history
.iter() .iter()
.filter(|e| { .filter(|entry| {
matches!( entry.metadata.input_metadata.as_ref().is_some_and(|input| {
e.metadata.origin, input.usage().compaction == crate::InputCompactionUse::OpeningSummary
LoggedSessionHistoryOrigin::CompactionSummary })
)
}) })
.collect(); .collect();
if summaries.len() != 1 { if summaries.len() != 1 {
@@ -435,23 +495,19 @@ impl SessionPublicMaterialReader {
} => turn = *total_turn_count, } => turn = *total_turn_count,
_ => {} _ => {}
} }
let system_history;
let entries: &[LoggedHistoryEntry] = match &record { let entries: &[LoggedHistoryEntry] = match &record {
LogEntry::AnnotatedSegmentStart { history, .. } LogEntry::AnnotatedSegmentStart { history, .. }
| LogEntry::AnnotatedUserInput { history, .. } => history, | LogEntry::AnnotatedUserInput { history, .. } => history,
LogEntry::AnnotatedAssistantItem { entry, .. } LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } | LogEntry::AnnotatedToolResult { entry, .. }
| LogEntry::ToolResultCorrected { entry, .. } => std::slice::from_ref(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() { for (history_index, entry) in entries.iter().enumerate() {
charge(&mut examined, limits.max_entries)?; charge(&mut examined, limits.max_entries)?;
let id = &entry.metadata.entry_id.0; let id = &entry.metadata.entry_id.0;
@@ -468,7 +524,7 @@ impl SessionPublicMaterialReader {
if in_run && ai_materialized(&entry.item) { if in_run && ai_materialized(&entry.item) {
safe = true; safe = true;
} }
let Some((sha256, total_bytes)) = canonical_metadata(entry)? else { let Some((sha256, total_bytes)) = canonical_metadata(entry, purpose)? else {
continue; continue;
}; };
let location = Location { let location = Location {
@@ -621,6 +677,7 @@ impl SessionPublicMaterialReader {
layout, layout,
limits, limits,
materials, materials,
purpose,
retention: retention.clone(), retention: retention.clone(),
}) })
} }
@@ -660,6 +717,7 @@ impl SessionPublicMaterialReader {
let Some((_, record)) = reader.next()? else { let Some((_, record)) = reader.next()? else {
return Err(PublicMaterialError::Missing); return Err(PublicMaterialError::Missing);
}; };
let system_history;
let (body, origin) = if let Some(boundary) = run_boundary(&record) { let (body, origin) = if let Some(boundary) = run_boundary(&record) {
if boundary_reference( if boundary_reference(
&record, &record,
@@ -683,12 +741,16 @@ impl SessionPublicMaterialReader {
LogEntry::AnnotatedAssistantItem { entry, .. } LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } | LogEntry::AnnotatedToolResult { entry, .. }
| LogEntry::ToolResultCorrected { entry, .. } => Some(entry), | LogEntry::ToolResultCorrected { entry, .. } => Some(entry),
LogEntry::AnnotatedSystemItem { entry, .. } => {
system_history = entry.to_history_entry();
Some(&system_history)
}
_ => None, _ => None,
} }
.filter(|e| format!("E{}", e.metadata.entry_id.0) == descriptor.identity.entry_ref) .filter(|e| format!("E{}", e.metadata.entry_id.0) == descriptor.identity.entry_ref)
.ok_or(PublicMaterialError::Changed)?; .ok_or(PublicMaterialError::Changed)?;
( (
canonical_body(entry).ok_or(PublicMaterialError::Changed)?, canonical_body(entry, self.purpose).ok_or(PublicMaterialError::Changed)?,
PublicMaterialOrigin::History { PublicMaterialOrigin::History {
origin: entry.metadata.origin.clone(), origin: entry.metadata.origin.clone(),
}, },
@@ -765,7 +827,7 @@ enum CanonicalBody<'a> {
Message { Message {
origin: &'a LoggedSessionHistoryOrigin, origin: &'a LoggedSessionHistoryOrigin,
role: LoggedRole, role: LoggedRole,
content: &'a [crate::LoggedContentPart], content: std::borrow::Cow<'a, [crate::LoggedContentPart]>,
}, },
ToolCall { ToolCall {
origin: &'a LoggedSessionHistoryOrigin, origin: &'a LoggedSessionHistoryOrigin,
@@ -828,15 +890,34 @@ fn boundary_reference(
.unwrap_or_else(|| format!("El-run-{session}-{segment}-{offset}")) .unwrap_or_else(|| format!("El-run-{session}-{segment}-{offset}"))
} }
fn canonical_body(entry: &LoggedHistoryEntry) -> Option<CanonicalBody<'_>> { fn canonical_body(
if matches!( entry: &LoggedHistoryEntry,
entry.metadata.origin, purpose: PublicMaterialUse,
LoggedSessionHistoryOrigin::DerivedSummary ) -> Option<CanonicalBody<'_>> {
| LoggedSessionHistoryOrigin::CompactionSummary if let Some(input) = &entry.metadata.input_metadata {
| LoggedSessionHistoryOrigin::JournalContext 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; 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 { match &entry.item {
LoggedItem::Message { LoggedItem::Message {
role: LoggedRole::System, role: LoggedRole::System,
@@ -847,7 +928,7 @@ fn canonical_body(entry: &LoggedHistoryEntry) -> Option<CanonicalBody<'_>> {
LoggedItem::Message { role, content } => Some(CanonicalBody::Message { LoggedItem::Message { role, content } => Some(CanonicalBody::Message {
origin: &entry.metadata.origin, origin: &entry.metadata.origin,
role: *role, role: *role,
content, content: std::borrow::Cow::Borrowed(content),
}), }),
LoggedItem::ToolCall { LoggedItem::ToolCall {
call_id, call_id,
@@ -880,8 +961,9 @@ fn canonical_body(entry: &LoggedHistoryEntry) -> Option<CanonicalBody<'_>> {
fn canonical_metadata( fn canonical_metadata(
entry: &LoggedHistoryEntry, entry: &LoggedHistoryEntry,
purpose: PublicMaterialUse,
) -> Result<Option<(String, u64)>, PublicMaterialError> { ) -> Result<Option<(String, u64)>, PublicMaterialError> {
let Some(body) = canonical_body(entry) else { let Some(body) = canonical_body(entry, purpose) else {
return Ok(None); return Ok(None);
}; };
let mut sink = BodySink::new(0, 0); let mut sink = BodySink::new(0, 0);
+263 -19
View File
@@ -9,6 +9,7 @@ fn entry(id: &str, item: LoggedItem) -> LoggedHistoryEntry {
LoggedHistoryEntry { LoggedHistoryEntry {
item, item,
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId(id.into()), entry_id: LoggedSessionHistoryEntryId(id.into()),
origin: LoggedSessionHistoryOrigin::LocalHumanInput, origin: LoggedSessionHistoryOrigin::LocalHumanInput,
derivation: None, 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<LoggedHistoryEntry>) -> LogEntry { fn seed(session: SessionId, history: Vec<LoggedHistoryEntry>) -> LogEntry {
LogEntry::AnnotatedSegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 1, ts: 1,
@@ -347,7 +369,11 @@ fn yield_resume_cancel_keep_original_boundary_identity_across_compaction() {
.descriptors() .descriptors()
.cloned() .cloned()
.collect(); .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.origin = LoggedSessionHistoryOrigin::CompactionSummary;
summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { summary.metadata.derivation = Some(crate::LoggedHistoryDerivation {
sources: vec![ 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 store = FsStore::new(temp.path()).unwrap();
let session = crate::new_session_id(); let session = crate::new_session_id();
let segment = crate::new_segment_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; 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; journal.metadata.origin = LoggedSessionHistoryOrigin::JournalContext;
let mut summary = message( let mut summary = system_context(
"compaction-summary", "compaction-summary",
LoggedRole::User,
"complementary summary", "complementary summary",
crate::InputCompactionUse::OpeningSummary,
); );
summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary;
let reasoning = entry( let reasoning = entry(
@@ -579,18 +609,23 @@ fn reasoning_proves_rollback_safety_but_private_and_derived_items_are_not_materi
LogEntry::AnnotatedSystemItem { LogEntry::AnnotatedSystemItem {
ts: 1, ts: 1,
entry: crate::LoggedSystemHistoryEntry { entry: crate::LoggedSystemHistoryEntry {
item: crate::SystemItem::JournalStartup { item: crate::SystemItem::Input {
body: "previous Journal must not become new recording material".into(), 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 { 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()), entry_id: LoggedSessionHistoryEntryId("startup-journal".into()),
origin: LoggedSessionHistoryOrigin::DerivedSummary, origin: LoggedSessionHistoryOrigin::DerivedSummary,
derivation: None, derivation: None,
@@ -777,6 +812,19 @@ fn schema_v3_compaction_migrates_persistently_and_strict_reader_keeps_original_i
panic!() panic!()
}; };
summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; 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]); assert_eq!(history, &[summary]);
// Run-transition ids synthesized from byte offsets must be frozen before // Run-transition ids synthesized from byte offsets must be frozen before
// the changed summary record shifts every subsequent record offset. // 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(); .unwrap();
let mut summary = message("summary", LoggedRole::User, "never use as original"); let mut summary = system_context(
summary.metadata.origin = LoggedSessionHistoryOrigin::CompactionSummary; "summary",
"never use as original",
crate::InputCompactionUse::OpeningSummary,
);
summary.metadata.origin = LoggedSessionHistoryOrigin::Provided {
producer: "renamed-summary-owner".into(),
};
summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { summary.metadata.derivation = Some(crate::LoggedHistoryDerivation {
sources: vec![ sources: vec![
LoggedSessionHistoryEntryId("u".into()), LoggedSessionHistoryEntryId("u".into()),
@@ -1030,7 +1084,11 @@ fn zero_turn_fork_from_seed_and_consecutive_compactions_preserve_original_identi
for index in 0..3 { for index in 0..3 {
let compact = crate::new_segment_id(); let compact = crate::new_segment_id();
let summary_id = format!("summary-{index}"); 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.origin = LoggedSessionHistoryOrigin::CompactionSummary;
summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { summary.metadata.derivation = Some(crate::LoggedHistoryDerivation {
sources: source_ids sources: source_ids
@@ -1178,7 +1236,11 @@ fn correction_preserves_original_identity_across_compaction_without_summary_fall
], ],
) )
.unwrap(); .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.origin = LoggedSessionHistoryOrigin::CompactionSummary;
summary.metadata.derivation = Some(crate::LoggedHistoryDerivation { summary.metadata.derivation = Some(crate::LoggedHistoryDerivation {
sources: vec![LoggedSessionHistoryEntryId("result".into())], 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 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.origin = LoggedSessionHistoryOrigin::CompactionSummary;
summary.metadata.derivation = derivation; summary.metadata.derivation = derivation;
let mut start = seed(session, vec![summary]); 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<dyn Store> = 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"));
}
}
}
+73 -21
View File
@@ -27,7 +27,7 @@ use thiserror::Error;
use crate::{ use crate::{
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
LoggedSessionHistoryOrigin, SegmentId, SegmentOrigin, SessionId, StoreError, SystemItem, LoggedSessionHistoryOrigin, SegmentId, SegmentOrigin, SessionId, StoreError,
WorkerAggregateStore, WorkerSessionStore, WorkerStoreError, WorkerAggregateStore, WorkerSessionStore, WorkerStoreError,
}; };
@@ -1165,7 +1165,7 @@ fn capture_tool_correction(
let LogEntry::ToolResultCorrected { entry, .. } = record else { let LogEntry::ToolResultCorrected { entry, .. } = record else {
return false; 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. // We read backwards: the first correction seen is the latest one.
corrections corrections
.entry(entry.metadata.entry_id.0.clone()) .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), } => extend_history(&mut entries, history, Some(segments), *ts),
LogEntry::AnnotatedAssistantItem { ts, entry } LogEntry::AnnotatedAssistantItem { ts, entry }
| LogEntry::AnnotatedToolResult { 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)); entries.push(history_entry(entry, *ts, data));
} }
} }
LogEntry::ToolResultCorrected { ts, entry } => { 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 if let Some(existing) = entries
.iter_mut() .iter_mut()
.find(|existing| existing.entry_id == entry.metadata.entry_id.0) .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( LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry(
&entry.item, &entry.public_item(),
entry.metadata.entry_id.0.clone(), entry.metadata.entry_id.0.clone(),
*ts, *ts,
provenance(&entry.metadata.origin), provenance(&entry.metadata.origin),
derivation_ids(entry), derivation_ids(entry),
entry
.metadata
.input_metadata
.as_ref()
.map(crate::HistoryInputMetadata::public_system_use_policy),
)), )),
LogEntry::RunYielded { LogEntry::RunYielded {
ts, ts,
@@ -1775,7 +1780,7 @@ fn extend_history(
segments: input_segments.cloned().unwrap_or_default(), segments: input_segments.cloned().unwrap_or_default(),
} }
} else { } else {
let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) else { let Some(data) = project_history_item(entry) else {
continue; continue;
}; };
data data
@@ -1790,6 +1795,19 @@ fn history_entry(
data: SessionSnapshotEntryData, data: SessionSnapshotEntryData,
) -> SessionSnapshotEntry { ) -> SessionSnapshotEntry {
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(), entry_id: entry.metadata.entry_id.0.clone(),
timestamp, timestamp,
provenance: provenance(&entry.metadata.origin), provenance: provenance(&entry.metadata.origin),
@@ -1833,6 +1851,7 @@ fn run_transition_entry(
data: SessionSnapshotEntryData, data: SessionSnapshotEntryData,
) -> SessionSnapshotEntry { ) -> SessionSnapshotEntry {
SessionSnapshotEntry { SessionSnapshotEntry {
use_policy: None,
entry_id: persisted_entry_id entry_id: persisted_entry_id
.map(|entry_id| entry_id.0.clone()) .map(|entry_id| entry_id.0.clone())
.unwrap_or_else(|| legacy_entry_id(session_key, segment_id, log_index, 0)), .unwrap_or_else(|| legacy_entry_id(session_key, segment_id, log_index, 0)),
@@ -1852,6 +1871,7 @@ fn legacy_entry(
data: SessionSnapshotEntryData, data: SessionSnapshotEntryData,
) -> SessionSnapshotEntry { ) -> SessionSnapshotEntry {
SessionSnapshotEntry { SessionSnapshotEntry {
use_policy: None,
entry_id: legacy_entry_id(session_key, segment_id, log_index, item_index), entry_id: legacy_entry_id(session_key, segment_id, log_index, item_index),
timestamp, timestamp,
provenance: SessionEntryProvenance::LegacyUnknown, provenance: SessionEntryProvenance::LegacyUnknown,
@@ -1884,9 +1904,8 @@ fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
LoggedSessionHistoryOrigin::FlowInstruction { .. } => { LoggedSessionHistoryOrigin::FlowInstruction { .. } => {
SessionEntryProvenance::FlowInstruction SessionEntryProvenance::FlowInstruction
} }
LoggedSessionHistoryOrigin::BackendInstruction { .. } => { LoggedSessionHistoryOrigin::BackendInstruction { .. }
SessionEntryProvenance::BackendInstruction | LoggedSessionHistoryOrigin::Provided { .. } => SessionEntryProvenance::BackendInstruction,
}
LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput, LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput, LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
LoggedSessionHistoryOrigin::CompactionSummary LoggedSessionHistoryOrigin::CompactionSummary
@@ -1914,6 +1933,35 @@ pub fn session_tool_attachment_id(
URL_SAFE_NO_PAD.encode(hash.finalize()) URL_SAFE_NO_PAD.encode(hash.finalize())
} }
fn project_history_item(entry: &LoggedHistoryEntry) -> Option<SessionSnapshotEntryData> {
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<SessionSnapshotEntryData> { fn project_item(entry_id: &str, item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
match item { match item {
LoggedItem::Message { role, content } => { LoggedItem::Message { role, content } => {
@@ -1986,30 +2034,26 @@ fn project_item(entry_id: &str, item: &LoggedItem) -> Option<SessionSnapshotEntr
} }
fn system_entry( fn system_entry(
item: &SystemItem, item: &crate::PublicSystemItem,
entry_id: String, entry_id: String,
timestamp: u64, timestamp: u64,
provenance: SessionEntryProvenance, provenance: SessionEntryProvenance,
derived_from: Vec<String>, derived_from: Vec<String>,
use_policy: Option<protocol::SessionEntryUsePolicy>,
) -> SessionSnapshotEntry { ) -> SessionSnapshotEntry {
let mut data = serde_json::to_value(item).ok(); let public = item.clone();
if let Some(serde_json::Value::Object(object)) = data.as_mut() { let data = Some(serde_json::to_value(&public).expect("public system input is serializable"));
object.remove("prompt_provenance"); let item_kind = public.kind;
} let content = public.body;
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();
SessionSnapshotEntry { SessionSnapshotEntry {
use_policy,
entry_id, entry_id,
timestamp, timestamp,
provenance, provenance,
derived_from, derived_from,
data: SessionSnapshotEntryData::SystemItem { data: SessionSnapshotEntryData::SystemItem {
item_kind, item_kind,
content: item.history_text(), content,
data, data,
}, },
} }
@@ -2291,6 +2335,7 @@ mod tests {
content: vec![LoggedContentPart::Text { text: text.into() }], content: vec![LoggedContentPart::Text { text: text.into() }],
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId(entry_id.into()), entry_id: LoggedSessionHistoryEntryId(entry_id.into()),
origin, origin,
derivation: None, derivation: None,
@@ -3589,6 +3634,7 @@ mod tests {
.map(|item| LoggedHistoryEntry { .map(|item| LoggedHistoryEntry {
item, item,
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId::new(), entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown, origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None, derivation: None,
@@ -3643,6 +3689,7 @@ mod tests {
}], }],
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: user_entry_id, entry_id: user_entry_id,
origin: LoggedSessionHistoryOrigin::HumanInput { origin: LoggedSessionHistoryOrigin::HumanInput {
account_id: "account-1".into(), account_id: "account-1".into(),
@@ -3712,6 +3759,7 @@ mod tests {
}], }],
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId::new(), entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::FlowInstruction { origin: LoggedSessionHistoryOrigin::FlowInstruction {
selector: "builtin:coder-review".into(), selector: "builtin:coder-review".into(),
@@ -3731,6 +3779,7 @@ mod tests {
}], }],
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: user_entry_id.clone(), entry_id: user_entry_id.clone(),
origin: origin.clone(), origin: origin.clone(),
derivation: Some(LoggedHistoryDerivation { derivation: Some(LoggedHistoryDerivation {
@@ -3761,6 +3810,7 @@ mod tests {
fn annotated_projection_preserves_identity_and_provenance() { fn annotated_projection_preserves_identity_and_provenance() {
let session_id = crate::new_session_id(); let session_id = crate::new_session_id();
let metadata = LoggedSessionHistoryMetadata { let metadata = LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId::new(), entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::ModelOutput { origin: LoggedSessionHistoryOrigin::ModelOutput {
worker: LoggedWorkerSubject { worker: LoggedWorkerSubject {
@@ -4323,6 +4373,7 @@ mod tests {
is_error: false, is_error: false,
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId("ancestor-image".into()), entry_id: LoggedSessionHistoryEntryId("ancestor-image".into()),
origin: LoggedSessionHistoryOrigin::LegacyUnknown, origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None, derivation: None,
@@ -4417,6 +4468,7 @@ mod tests {
is_error: false, is_error: false,
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId("tool-images".into()), entry_id: LoggedSessionHistoryEntryId("tool-images".into()),
origin: LoggedSessionHistoryOrigin::LegacyUnknown, origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None, derivation: None,
@@ -103,6 +103,7 @@ fn finish(
stats.insert( stats.insert(
anchor.clone(), anchor.clone(),
SessionSnapshotEntry { SessionSnapshotEntry {
use_policy: None,
entry_id: format!("run-stats:{anchor}"), entry_id: format!("run-stats:{anchor}"),
timestamp: ended_at, timestamp: ended_at,
provenance: SessionEntryProvenance::LegacyUnknown, provenance: SessionEntryProvenance::LegacyUnknown,
+18
View File
@@ -254,6 +254,23 @@ pub struct SegmentOrigin {
} }
impl LogEntry { 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 /// Allocate the durable public identity for a newly committed Run
/// transition. Calling this repeatedly is idempotent so the exact value /// transition. Calling this repeatedly is idempotent so the exact value
/// written to storage is also the value published to live subscribers. /// written to storage is also the value published to live subscribers.
@@ -537,6 +554,7 @@ mod tests {
LoggedHistoryEntry { LoggedHistoryEntry {
item: LoggedItem::from(item), item: LoggedItem::from(item),
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId::new(), entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown, origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None, derivation: None,
+3
View File
@@ -27,6 +27,9 @@ pub enum StoreError {
#[error("serialization error: {0}")] #[error("serialization error: {0}")]
Serde(#[from] serde_json::Error), Serde(#[from] serde_json::Error),
#[error("invalid history input metadata: {0}")]
InvalidInputMetadata(#[from] crate::InputMetadataError),
#[error("segment not found: {0}")] #[error("segment not found: {0}")]
NotFound(SegmentId), NotFound(SegmentId),
+90 -101
View File
@@ -86,20 +86,16 @@ pub struct PromptRenderProvenance {
/// `notify_wrapper` template) re-shape the live rendering on a later /// `notify_wrapper` template) re-shape the live rendering on a later
/// resume. /// resume.
/// ///
/// New variants get added here as fresh injection kinds come online /// Feature-owned inputs use `Input` plus explicit annotated metadata; adding a
/// (e.g. `TaskReminder`). The `kind` JSON tag is the snake_case form of /// Feature must not extend this enum or ordinary replay. Only core events have
/// the variant name. /// dedicated variants. Pre-v5 Feature kinds exist exclusively in the migration
/// decoder, not in this current wire contract.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum SystemItem { pub enum SystemItem {
/// One-time derived Journal reference context. The bounded Host snapshot is /// Generic System body; private metadata and public presentation are supplied
/// retained as JSON independently of its rendered body and prompt projection. /// independently by the producer in the same annotated history record.
JournalStartup { Input { body: String },
body: String,
snapshot: serde_json::Value,
snapshot_sha256: String,
prompt_provenance: PromptRenderProvenance,
},
/// Free-form notification sent in by an external caller via /// Free-form notification sent in by an external caller via
/// `Method::Notify`. `message` is the raw caller-supplied text; /// `Method::Notify`. `message` is the raw caller-supplied text;
/// `body` is the wrapped LLM-context form (Worker renders it via /// `body` is the wrapped LLM-context form (Worker renders it via
@@ -122,14 +118,6 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>, prompt_provenance: Option<PromptRenderProvenance>,
}, },
/// 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,
},
/// `@<path>` file reference resolution. `body` is the rendered /// `@<path>` file reference resolution. `body` is the rendered
/// LLM-context text (`[File: <path>]\n…` for regular files, /// LLM-context text (`[File: <path>]\n…` for regular files,
/// `[Dir: <path>]\n…` for directory listings, possibly with a /// `[Dir: <path>]\n…` for directory listings, possibly with a
@@ -137,10 +125,6 @@ pub enum SystemItem {
/// byte-identical to what was sent. /// byte-identical to what was sent.
FileAttachment { path: String, body: String }, 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 /// Historical persisted Knowledge reference resolution. Knowledge is no
/// longer active, so restored sessions ignore this item instead of replaying /// longer active, so restored sessions ignore this item instead of replaying
/// archived record text into model context. /// archived record text into model context.
@@ -164,29 +148,6 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>, prompt_provenance: Option<PromptRenderProvenance>,
}, },
/// 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<PromptRenderProvenance>,
},
/// 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<PromptRenderProvenance>,
},
/// Synthetic note inserted after an interrupted turn before the next /// Synthetic note inserted after an interrupted turn before the next
/// user input. `body` is the exact LLM-context text explaining that the /// user input. `body` is the exact LLM-context text explaining that the
/// previous turn was cut short. /// 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 { 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 /// Free-text body the LLM sees inside its `role:system` message
/// for this item. Returns the variant's stored `body` verbatim. /// for this item. Returns the variant's stored `body` verbatim.
pub fn history_text(&self) -> String { pub fn history_text(&self) -> String {
match self { match self {
SystemItem::JournalStartup { body, .. } => body.clone(), SystemItem::Input { body } => body.clone(),
SystemItem::Notification { body, .. } => body.clone(), SystemItem::Notification { body, .. } => body.clone(),
SystemItem::WorkerEvent { body, .. } => body.clone(), SystemItem::WorkerEvent { body, .. } => body.clone(),
SystemItem::FeatureInvocationResult { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(), SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::SkillActivation { body, .. } => body.clone(),
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(), SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
SystemItem::LegacyIgnored { slug } => { SystemItem::LegacyIgnored { slug } => {
format!("Ignored legacy procedure item: /{slug}") format!("Ignored legacy procedure item: /{slug}")
} }
SystemItem::TaskReminder { body, .. } => body.clone(), SystemItem::TaskReminder { body, .. } => body.clone(),
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(),
SystemItem::Interrupt { body, .. } => body.clone(), SystemItem::Interrupt { body, .. } => body.clone(),
} }
} }
@@ -229,17 +233,13 @@ impl SystemItem {
/// wire — keep flexible. /// wire — keep flexible.
pub fn kind_label(&self) -> &'static str { pub fn kind_label(&self) -> &'static str {
match self { match self {
SystemItem::JournalStartup { .. } => "journal_startup", SystemItem::Input { .. } => "input",
SystemItem::Notification { .. } => "notification", SystemItem::Notification { .. } => "notification",
SystemItem::WorkerEvent { .. } => "worker_event", SystemItem::WorkerEvent { .. } => "worker_event",
SystemItem::FeatureInvocationResult { .. } => "feature_invocation_result",
SystemItem::FileAttachment { .. } => "file_attachment", SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::SkillActivation { .. } => "skill_activation",
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored", SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
SystemItem::LegacyIgnored { .. } => "legacy_ignored", SystemItem::LegacyIgnored { .. } => "legacy_ignored",
SystemItem::TaskReminder { .. } => "task_reminder", SystemItem::TaskReminder { .. } => "task_reminder",
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh",
SystemItem::Interrupt { .. } => "interrupt", SystemItem::Interrupt { .. } => "interrupt",
} }
} }
@@ -297,33 +297,45 @@ mod tests {
} }
#[test] #[test]
fn subject_behavior_refresh_round_trips_with_exact_body() { fn current_system_item_rejects_frozen_feature_kinds() {
let item = SystemItem::SubjectBehaviorRefresh { // Even well-formed old payloads cannot enter normal v5 decode/replay.
body: "current Subject context".to_string(), for line in include_str!("legacy_session_log/tests/fixtures/v4-system-inputs.jsonl")
behavior_md: "exact behavior".to_string(), .lines()
prompt_provenance: None, .take(5)
}; {
let raw = serde_json::to_string(&item).unwrap(); let record: serde_json::Value = serde_json::from_str(line).unwrap();
let parsed: SystemItem = serde_json::from_str(&raw).unwrap(); assert!(serde_json::from_value::<SystemItem>(record["entry"]["item"].clone()).is_err());
assert_eq!(parsed.history_text(), "current Subject context");
assert!( assert!(
matches!(parsed, SystemItem::SubjectBehaviorRefresh { behavior_md, .. } if behavior_md == "exact behavior") serde_json::from_value::<crate::LoggedSystemHistoryEntry>(record["entry"].clone())
.is_err()
); );
assert!(serde_json::from_str::<crate::LogEntry>(line).is_err());
}
} }
#[test] #[test]
fn frozen_subject_behavior_log_reads_old_counter_without_republishing_it() { fn public_system_input_decoder_rejects_private_fields_and_unknown_presentations() {
let old = r#"{"kind":"subject_behavior_refresh","body":"exact archived body","behavior_revision":7}"#; let valid = serde_json::json!({
let item: SystemItem = serde_json::from_str(old).unwrap(); "kind": "new-feature-label",
assert_eq!(item.history_text(), "exact archived body"); "body": "explicit public content",
assert_eq!( "presentation": { "kind": "context" },
serde_json::to_value(item).unwrap(), });
serde_json::json!({ let public: PublicSystemItem = serde_json::from_value(valid.clone()).unwrap();
"kind": "subject_behavior_refresh", assert_eq!(public.body, "explicit public content");
"body": "exact archived body", for private_field in [
"behavior_md": "" "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::<PublicSystemItem>(malformed).is_err());
}
let mut unknown = valid;
unknown["presentation"] = serde_json::json!({ "kind": "execute_feature_payload" });
assert!(serde_json::from_value::<PublicSystemItem>(unknown).is_err());
} }
#[test] #[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] #[test]
fn round_trip_worker_event() { fn round_trip_worker_event() {
let item = SystemItem::WorkerEvent { let item = SystemItem::WorkerEvent {
+17 -39
View File
@@ -210,18 +210,6 @@ impl WorkerMetadata {
} }
} }
fn replace_active_session(metadata: &mut WorkerMetadata, active: Option<WorkerActiveSegmentRef>) {
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. /// Sync persistence backend for Worker metadata.
pub trait WorkerMetadataStore: Send + Sync { pub trait WorkerMetadataStore: Send + Sync {
/// Create or replace metadata for its `worker_name` key. /// Create or replace metadata for its `worker_name` key.
@@ -297,7 +285,7 @@ pub trait WorkerMetadataStore: Send + Sync {
workspace_root: Option<PathBuf>, workspace_root: Option<PathBuf>,
) -> Result<WorkerMetadata, WorkerStoreError> { ) -> Result<WorkerMetadata, WorkerStoreError> {
self.update_by_name(worker_name, |metadata| { self.update_by_name(worker_name, |metadata| {
replace_active_session(metadata, active); metadata.active = active;
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot; metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
if let Some(workspace_id) = workspace_id { if let Some(workspace_id) = workspace_id {
metadata.workspace_id = Some(workspace_id); metadata.workspace_id = Some(workspace_id);
@@ -317,7 +305,7 @@ pub trait WorkerMetadataStore: Send + Sync {
workspace_id: Option<String>, workspace_id: Option<String>,
) -> Result<WorkerMetadata, WorkerStoreError> { ) -> Result<WorkerMetadata, WorkerStoreError> {
self.update_by_name(worker_name, |metadata| { self.update_by_name(worker_name, |metadata| {
replace_active_session(metadata, active); metadata.active = active;
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot; metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
if let Some(workspace_id) = workspace_id { if let Some(workspace_id) = workspace_id {
metadata.workspace_id = Some(workspace_id); metadata.workspace_id = Some(workspace_id);
@@ -334,7 +322,7 @@ pub trait WorkerMetadataStore: Send + Sync {
workspace_root: Option<PathBuf>, workspace_root: Option<PathBuf>,
) -> Result<WorkerMetadata, WorkerStoreError> { ) -> Result<WorkerMetadata, WorkerStoreError> {
self.update_by_name(worker_name, |metadata| { self.update_by_name(worker_name, |metadata| {
replace_active_session(metadata, active); metadata.active = active;
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot; metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
if let Some(workspace_root) = workspace_root { if let Some(workspace_root) = workspace_root {
metadata.workspace_root = Some(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<WorkerMetadata, WorkerStoreError> {
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. /// Set spawned-child registry state while preserving active pointer and manifest snapshot.
fn set_spawned_children( fn set_spawned_children(
&self, &self,
@@ -1087,13 +1063,13 @@ mod tests {
)) ))
.unwrap(); .unwrap();
store store
.set_subjektiv_session_attribution( .update_by_name("subject-worker", |metadata| {
"subject-worker", metadata.subjektiv_session_attribution =
SubjektivSessionAttributionState::OutcomeUnknown { Some(SubjektivSessionAttributionState::OutcomeUnknown {
session_id, session_id,
diagnostic: "request interrupted".to_string(), diagnostic: "request interrupted".to_string(),
}, });
) })
.unwrap(); .unwrap();
store store
.set_active( .set_active(
@@ -1117,7 +1093,7 @@ mod tests {
} }
#[test] #[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 tmp = tempfile::TempDir::new().unwrap();
let store = FsWorkerStore::new(tmp.path()).unwrap(); let store = FsWorkerStore::new(tmp.path()).unwrap();
let old_session_id = crate::new_session_id(); let old_session_id = crate::new_session_id();
@@ -1129,13 +1105,13 @@ mod tests {
)) ))
.unwrap(); .unwrap();
store store
.set_subjektiv_session_attribution( .update_by_name("subject-worker", |metadata| {
"subject-worker", metadata.subjektiv_session_attribution =
SubjektivSessionAttributionState::Confirmed { Some(SubjektivSessionAttributionState::Confirmed {
session_id: old_session_id, session_id: old_session_id,
subject_id: "subject-1".to_string(), subject_id: "subject-1".to_string(),
}, });
) })
.unwrap(); .unwrap();
store store
@@ -1151,7 +1127,9 @@ mod tests {
restored.active, restored.active,
Some(WorkerActiveSegmentRef::pending_segment(new_session_id)) 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] #[test]
+198 -14
View File
@@ -36,7 +36,9 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; 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 ANNOTATED_SESSION_SCHEMA_VERSION: u32 = 3;
const PREVIOUS_SESSION_SCHEMA_VERSION: u32 = 2; const PREVIOUS_SESSION_SCHEMA_VERSION: u32 = 2;
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1; const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
@@ -204,7 +206,15 @@ impl WorkerSessionStore {
/// Open the Session store rooted at `<worker-aggregate>/session`. /// Open the Session store rooted at `<worker-aggregate>/session`.
pub fn new(root: impl Into<PathBuf>) -> Result<Self, StoreError> { pub fn new(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
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<Self, StoreError> {
fs::create_dir_all(root.join(SEGMENTS_DIR))?; fs::create_dir_all(root.join(SEGMENTS_DIR))?;
let session_id = match fs::read(root.join(SESSION_FILE)) { let session_id = match fs::read(root.join(SESSION_FILE)) {
Ok(bytes) => { Ok(bytes) => {
@@ -215,11 +225,14 @@ impl WorkerSessionStore {
} }
LEGACY_SESSION_SCHEMA_VERSION LEGACY_SESSION_SCHEMA_VERSION
| PREVIOUS_SESSION_SCHEMA_VERSION | PREVIOUS_SESSION_SCHEMA_VERSION
| ANNOTATED_SESSION_SCHEMA_VERSION => { | ANNOTATED_SESSION_SCHEMA_VERSION
migrate_segment_logs_to_v4( | COMPACTION_SESSION_SCHEMA_VERSION
| SYSTEM_INPUT_SESSION_SCHEMA_VERSION => {
migrate_segment_logs_to_current(
segment_log_paths(&root)?, segment_log_paths(&root)?,
manifest.session_id, manifest.session_id,
manifest.schema_version, manifest.schema_version,
write_segment,
)?; )?;
manifest.schema_version = SESSION_SCHEMA_VERSION; manifest.schema_version = SESSION_SCHEMA_VERSION;
atomic_write_json(&root.join(SESSION_FILE), &manifest)?; atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
@@ -742,6 +755,7 @@ impl Store for WorkerSessionStore {
segment_id: SegmentId, segment_id: SegmentId,
entry: &LogEntry, entry: &LogEntry,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
entry.validate_input_metadata()?;
let _retention = self.lock_publication(session_id)?; let _retention = self.lock_publication(session_id)?;
self.ensure_session(session_id, true)?; self.ensure_session(session_id, true)?;
self.append_log_entry(&self.log_path(segment_id), entry) self.append_log_entry(&self.log_path(segment_id), entry)
@@ -800,6 +814,9 @@ impl Store for WorkerSessionStore {
segment_id: SegmentId, segment_id: SegmentId,
entries: &[LogEntry], entries: &[LogEntry],
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
for entry in entries {
entry.validate_input_metadata()?;
}
let _retention = self.lock_publication(session_id)?; let _retention = self.lock_publication(session_id)?;
self.ensure_session(session_id, true)?; self.ensure_session(session_id, true)?;
let mut content = Vec::new(); let mut content = Vec::new();
@@ -1158,10 +1175,11 @@ fn segment_log_paths_in_directory(
Ok(paths) Ok(paths)
} }
fn migrate_segment_logs_to_v4( fn migrate_segment_logs_to_current(
paths: Vec<(SegmentId, PathBuf)>, paths: Vec<(SegmentId, PathBuf)>,
session_id: SessionId, session_id: SessionId,
source_schema_version: u32, source_schema_version: u32,
mut write_segment: impl FnMut(&Path, &[u8]) -> Result<(), StoreError>,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
struct MigrationPlan { struct MigrationPlan {
path: PathBuf, path: PathBuf,
@@ -1192,12 +1210,19 @@ fn migrate_segment_logs_to_v4(
} }
// Resolve trusted creation boundaries first, then update inherited copies by // Resolve trusted creation boundaries first, then update inherited copies by
// logical identity too (fork/rewind seeds retain the same entry id). // logical identity too (fork/rewind seeds retain the same entry id).
let summaries = crate::legacy_session_log::compaction_summary_ids( 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()), 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 { for plan in &mut plans {
crate::legacy_session_log::migrate_compaction_origins(&mut plan.entries, &summaries); 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)?; serde_json::to_writer(&mut plan.output, &entry)?;
plan.output.push(b'\n'); plan.output.push(b'\n');
} }
@@ -1219,7 +1244,7 @@ fn migrate_segment_logs_to_v4(
} }
for plan in plans { for plan in plans {
atomic_write_bytes(&plan.path, &plan.output)?; write_segment(&plan.path, &plan.output)?;
} }
Ok(()) Ok(())
} }
@@ -1236,9 +1261,20 @@ pub(crate) fn migrate_fs_session(root: &Path, session_id: SessionId) -> Result<(
Err(error) => return Err(error.into()), Err(error) => return Err(error.into()),
}; };
match version { match version {
SESSION_SCHEMA_VERSION => Ok(()), SESSION_SCHEMA_VERSION => {
ANNOTATED_SESSION_SCHEMA_VERSION => { validate_canonical_segment_paths(segment_log_paths_in_directory(root)?)
migrate_segment_logs_to_v4(segment_log_paths_in_directory(root)?, session_id, version)?; }
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) atomic_write_json(&marker, &SESSION_SCHEMA_VERSION)
} }
_ => Err(StoreError::Corrupt { _ => 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> { fn validate_canonical_segment_logs(root: &Path) -> Result<(), StoreError> {
for (_, path) in segment_log_paths(root)? { validate_canonical_segment_paths(segment_log_paths(root)?)
let _: Vec<LogEntry> = parse_jsonl(&fs::read(&path)?)?; }
fn validate_canonical_segment_paths(paths: Vec<(SegmentId, PathBuf)>) -> Result<(), StoreError> {
for (_, path) in paths {
let entries: Vec<LogEntry> = parse_jsonl(&fs::read(&path)?)?;
for entry in entries {
entry.validate_input_metadata()?;
}
} }
Ok(()) Ok(())
} }
@@ -1430,6 +1473,7 @@ mod tests {
LoggedHistoryEntry { LoggedHistoryEntry {
item: LoggedItem::from(item), item: LoggedItem::from(item),
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId::new(), entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown, origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None, 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<serde_json::Value> {
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<LogEntry> = 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] #[test]
fn current_jsonl_requires_annotations_across_append_rewrite_and_reopen() { fn current_jsonl_requires_annotations_across_append_rewrite_and_reopen() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
+175
View File
@@ -11,6 +11,7 @@ fn annotated(item: Item) -> LoggedHistoryEntry {
LoggedHistoryEntry { LoggedHistoryEntry {
item: LoggedItem::from(item), item: LoggedItem::from(item),
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId::new(), entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown, origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None, derivation: None,
@@ -421,3 +422,177 @@ fn lookup_session_of_finds_owning_session() {
assert_eq!(store.lookup_session_of(segid).unwrap(), Some(sid)); 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<dyn Store> = 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<dyn Store> = 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::<LogEntry>(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);
}
}
@@ -24,6 +24,7 @@ fn annotated(items: &[Item]) -> Vec<session_store::LoggedHistoryEntry> {
.map(|item| session_store::LoggedHistoryEntry { .map(|item| session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(item), item: session_store::LoggedItem::from(item),
metadata: session_store::LoggedSessionHistoryMetadata { metadata: session_store::LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: session_store::LoggedSessionHistoryEntryId::new(), entry_id: session_store::LoggedSessionHistoryEntryId::new(),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None, derivation: None,
+3 -13
View File
@@ -23,9 +23,7 @@ use session_store::{
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore, CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
}; };
use thiserror::Error; use thiserror::Error;
use worker::bootstrap::{ use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout};
WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, bash_output_dir_for_worker_id,
};
use worker::controller::WorkerControllerTransport; use worker::controller::WorkerControllerTransport;
use worker::ipc::protocol_session::{ use worker::ipc::protocol_session::{
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event, WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
@@ -216,7 +214,6 @@ impl StandaloneHost {
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone()); WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
let workspace_context = WorkerWorkspaceContext::local_filesystem(None); let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
let runtime_base = store.runtime_dir(worker_id); 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( let mut bootstrap = WorkerBootstrap::new(
bootstrap_manifest, bootstrap_manifest,
@@ -224,10 +221,7 @@ impl StandaloneHost {
launch.prompt_catalog, launch.prompt_catalog,
workspace_context, workspace_context,
filesystem_authority, filesystem_authority,
WorkerBootstrapLayout::Direct { WorkerBootstrapLayout::Direct { runtime_base },
runtime_base,
bash_output_dir,
},
WorkerControllerTransport::InProcess, WorkerControllerTransport::InProcess,
); );
if let Some(subject) = &subject { if let Some(subject) = &subject {
@@ -428,7 +422,6 @@ impl StandaloneHost {
); );
let workspace_context = WorkerWorkspaceContext::local_filesystem(None); let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
let runtime_base = store.runtime_dir(worker_id); 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( let mut bootstrap = WorkerBootstrap::new(
manifest, manifest,
@@ -436,10 +429,7 @@ impl StandaloneHost {
worker::PromptCatalogSource::builtins_only(), worker::PromptCatalogSource::builtins_only(),
workspace_context, workspace_context,
filesystem_authority, filesystem_authority,
WorkerBootstrapLayout::Direct { WorkerBootstrapLayout::Direct { runtime_base },
runtime_base,
bash_output_dir,
},
WorkerControllerTransport::InProcess, WorkerControllerTransport::InProcess,
); );
if let Some(subject) = &subject { if let Some(subject) = &subject {
@@ -212,6 +212,7 @@ fn message(id: &str, text: &str) -> session_store::LoggedHistoryEntry {
content: vec![session_store::LoggedContentPart::Text { text: text.into() }], content: vec![session_store::LoggedContentPart::Text { text: text.into() }],
}, },
metadata: session_store::LoggedSessionHistoryMetadata { metadata: session_store::LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: session_store::LoggedSessionHistoryEntryId(id.into()), entry_id: session_store::LoggedSessionHistoryEntryId(id.into()),
origin: session_store::LoggedSessionHistoryOrigin::LocalHumanInput, origin: session_store::LoggedSessionHistoryOrigin::LocalHumanInput,
derivation: None, derivation: None,
@@ -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::TextDelta { text } if text.contains(PARTIAL) => break,
protocol::Event::RunEnd { .. } => panic!("dialog ended before cancellation"), 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}"), 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() .iter()
.flat_map(|segment| source_store.read_all(session, *segment).unwrap()) .flat_map(|segment| source_store.read_all(session, *segment).unwrap())
.collect(); .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!( assert_eq!(
raw.iter() raw.iter()
.filter(|entry| matches!( .filter(|entry| matches!(
@@ -11,7 +11,7 @@ use agen::llm_client::{
types::Item, types::Item,
}; };
use futures::Stream; use futures::Stream;
use session_store::{CombinedStore, FsWorkerStore, LogEntry, LoggedSessionHistoryOrigin}; use session_store::{CombinedStore, FsWorkerStore, LogEntry};
use std::pin::Pin; use std::pin::Pin;
const BEFORE: &str = "FROZEN_POSITION: B partially implemented; tests not run; result unconfirmed"; 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::PromptCatalogSource::builtins_only(),
worker::WorkerWorkspaceContext::no_workspace(), worker::WorkerWorkspaceContext::no_workspace(),
worker::WorkerFilesystemAuthority::None, 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, worker::WorkerControllerTransport::InProcess,
).with_model_client(model.clone()).with_subjektiv_host(fixture.host().clone()).prepare_restored("journal-test").await.unwrap(); ).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); 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(); let worker = prepared.worker();
assert_ne!(worker.segment_id(), source_segment); 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 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); assert_eq!(journal_items.len(), 1);
let journal_item = Item::from(journal_items[0].item.clone()); let journal_item = Item::from(journal_items[0].item.clone());
let body = journal_item.as_text().unwrap(); 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(&expected_json), "durable seed contains the full selected JSON and processing checkpoint");
assert!(body.contains(BEFORE)); assert!(body.contains(BEFORE));
assert!(!body.contains(AFTER)); 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 requests = model.requests.lock().unwrap();
let input = requests[0].items.iter().find_map(|item| match item { 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")), Item::Message { role: agen::llm_client::types::Role::User, .. } => item.as_text().filter(|text| text.contains("## Conversation overview/index")),
@@ -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 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 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 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!(journal_items, 1);
assert_eq!(catalog.store.subject(&subject.id).unwrap().unwrap().behavior_md, "BEHAVIOR_ORIGINAL_UNCHANGED"); assert_eq!(catalog.store.subject(&subject.id).unwrap().unwrap().behavior_md, "BEHAVIOR_ORIGINAL_UNCHANGED");
second.shutdown().await.unwrap(); second.shutdown().await.unwrap();
@@ -1621,6 +1621,7 @@ allow = []
let history = |id: &str, item: LoggedItem| LoggedHistoryEntry { let history = |id: &str, item: LoggedItem| LoggedHistoryEntry {
item, item,
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId(id.into()), entry_id: LoggedSessionHistoryEntryId(id.into()),
origin: LoggedSessionHistoryOrigin::BackendInstruction { operation_id: None }, origin: LoggedSessionHistoryOrigin::BackendInstruction { operation_id: None },
derivation: None, derivation: None,
+8
View File
@@ -1299,6 +1299,14 @@ pub type OperationResult<T> = std::result::Result<T, OperationError>;
pub fn subjektiv_store_error(error: crate::SubjektivError) -> OperationError { pub fn subjektiv_store_error(error: crate::SubjektivError) -> OperationError {
match error { 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 { .. } => { crate::SubjektivError::SubjectScopeMismatch { .. } => {
OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}")) OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}"))
} }
+1 -1
View File
@@ -929,7 +929,7 @@ mod tests {
let (_manager, _workspace, store) = open_store(&root, "workspace-a"); let (_manager, _workspace, store) = open_store(&root, "workspace-a");
let migrated_subject = store.subject(&subject_id).unwrap().unwrap(); let migrated_subject = store.subject(&subject_id).unwrap().unwrap();
assert!(migrated_subject.behavior_md.is_empty()); 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( let lifecycle_attribution = attribution(
&subject_id, &subject_id,
"runtime-1", "runtime-1",
+16
View File
@@ -4,6 +4,7 @@ pub mod api;
pub mod job; pub mod job;
pub mod journal; pub mod journal;
mod legacy_migrations; mod legacy_migrations;
pub mod permissions;
mod schema; mod schema;
use legacy_migrations::{ use legacy_migrations::{
add_candidate_decision_receipts, add_subject_behavior, add_subject_session_attribution, 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")] #[error("subject `{subject_id}` behavior content changed")]
SubjectBehaviorConflict { subject_id: String }, 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}")] #[error("surface generation conflict: {0}")]
SurfaceGenerationConflict(String), SurfaceGenerationConflict(String),
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")] #[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
@@ -623,6 +634,11 @@ static MIGRATIONS: &[FeatureMigration] = &[
"add immutable candidate Journal context links", "add immutable candidate Journal context links",
journal::memory_links::migrate, journal::memory_links::migrate,
), ),
FeatureMigration::new(
12,
"add explicit Subject permission sources and immutable child execution grants",
permissions::migrate,
),
]; ];
pub const REGISTRATION: FeatureRegistration = pub const REGISTRATION: FeatureRegistration =
+288
View File
@@ -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<Option<SubjectPermissions>> {
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<SubjectPermissions> {
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<Vec<WorkerExecutionGrant>> {
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<Option<WorkerExecutionGrant>> {
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<Option<SubjectPermissions>> {
let raw: Option<String> = 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<Option<WorkerExecutionGrant>> {
let raw: Option<String> = 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<Option<WorkerExecutionGrant>> {
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;
+45
View File
@@ -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;
+656
View File
@@ -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<ScopedPermission>,
) -> 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::<std::result::Result<_, _>>()?)
}).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, &registration).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, &current.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, &registration).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();
}
+2 -1
View File
@@ -23,7 +23,8 @@ thiserror = { workspace = true }
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] } tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
tracing = { workspace = true } tracing = { workspace = true }
workdir = { workspace = true } workdir = { workspace = true }
uuid = { workspace = true, features = ["v7"] }
[dev-dependencies] [dev-dependencies]
filetime = "0.2.27" filetime = "0.2.27"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] }
+261 -200
View File
@@ -1,19 +1,19 @@
//! Bash Tool using WorkdirSession command capability. //! 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. //! 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 //! Per-execution handles and nonces prevent cancellation of another command. Dropping a future
//! does not confirm termination; active registrations remain until cleanup requests cancellation //! does not confirm termination. Registrations remain during bounded cancellation/drain cleanup;
//! and observes terminal output. //! an unconfirmed provider outcome leaves partial evidence, never a fabricated terminal fence.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crate::bash_output::{BashOutputMetadata, BashOutputView, BashOutputWriter};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait; use async_trait::async_trait;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use workdir::{ use workdir::{
CommandHandle, CommandOutputRequest, CommandRequest, WorkdirPath, WorkdirSessionHandle, CommandHandle, CommandOutputRequest, CommandRequest, WorkdirPath, WorkdirSessionHandle,
WorkdirSessionRouter, WorkdirSessionRouter,
@@ -35,9 +35,9 @@ struct BashParams {
timeout: Option<u64>, timeout: Option<u64>,
} }
pub(crate) struct BashTool { pub struct BashTool {
router: Arc<WorkdirSessionRouter>, router: Arc<WorkdirSessionRouter>,
output_dir: PathBuf, output: BashOutputView,
state: Arc<Mutex<BashExecutionState>>, state: Arc<Mutex<BashExecutionState>>,
} }
@@ -63,6 +63,8 @@ struct CommandGuard {
execution_id: String, execution_id: String,
execution_nonce: u64, execution_nonce: u64,
handle: Option<CommandHandle>, handle: Option<CommandHandle>,
cursor: u64,
retained: Option<BashOutputWriter>,
} }
impl Drop for CommandGuard { impl Drop for CommandGuard {
@@ -74,20 +76,66 @@ impl Drop for CommandGuard {
let state = Arc::clone(&self.state); let state = Arc::clone(&self.state);
let execution_id = self.execution_id.clone(); let execution_id = self.execution_id.clone();
let execution_nonce = self.execution_nonce; 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 // A dropped provider future is not terminal confirmation. Keep the live
// execution registered until cleanup has both requested cancellation and // execution registered until cleanup has both requested cancellation and
// observed terminal command output, so cancellation/session teardown // observed terminal command output, so cancellation/session teardown
// cannot race with an apparently empty registry. // cannot race with an apparently empty registry.
tokio::spawn(async move { tokio::spawn(async move {
let result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
let _ = workdir.cancel_command(handle.clone()).await; let _ = workdir.cancel_command(handle.clone()).await;
let _ = workdir let mut cursor = cursor;
.command_output(CommandOutputRequest { loop {
handle, let page = command_page(
cursor: 0, &workdir,
limit: INLINE_BYTE_BUDGET, CommandOutputRequest {
handle: handle.clone(),
cursor,
limit: workdir::MAX_COMMAND_READ_BYTES,
wait: true, 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; .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(); let mut state = state.lock().unwrap();
if state if state
.active .active
@@ -101,15 +149,49 @@ impl Drop for CommandGuard {
} }
} }
#[async_trait] // Output pages are replayable only with the exact same command/cursor/limit.
impl Tool for BashTool { // Retry read transport failures, never command start, and cap both attempts and
async fn execute( // backoff. The caller's deadline bounds a disconnected or unresponsive provider.
async fn command_page(
session: &workdir::WorkdirSessionHandle,
request: CommandOutputRequest,
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
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<WorkdirSessionRouter>, output: BashOutputView) -> Self {
Self {
router,
output,
state: Arc::new(Mutex::new(BashExecutionState::default())),
}
}
pub async fn execute_result(
&self, &self,
input_json: &str, input_json: &str,
ctx: agen::tool::ToolExecutionContext, ctx: agen::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<BashResult, ToolError> {
let params: BashParams = serde_json::from_str(input_json) let params: BashParams = serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?; .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( let selected = crate::routing::resolve_session(
&self.router, &self.router,
params.target_workdir.as_deref(), params.target_workdir.as_deref(),
@@ -134,14 +216,15 @@ impl Tool for BashTool {
execution_id: execution_id.clone(), execution_id: execution_id.clone(),
execution_nonce, execution_nonce,
handle: None, handle: None,
cursor: 0,
retained: Some(retained),
}; };
let handle = session let handle = session
.start_command(CommandRequest { .start_command(CommandRequest {
command: params.command, command: params.command,
timeout_secs, timeout_secs,
output_limit: INLINE_BYTE_BUDGET, transport_byte_budget: self.output.byte_budget(),
cwd: params.cwd, cwd: params.cwd,
spill_dir: Some(self.output_dir.clone()),
tool_call_id: Some(call_id.clone()), tool_call_id: Some(call_id.clone()),
}) })
.await .await
@@ -167,15 +250,101 @@ impl Tool for BashTool {
.await .await
.map_err(crate::ToolsError::from)?; .map_err(crate::ToolsError::from)?;
} }
let output = session let mut cursor = 0;
.command_output(CommandOutputRequest { let mut stdout_preview = Vec::new();
handle, let mut stderr_preview = Vec::new();
cursor: 0, let mut seen = [0_u64; 2];
limit: INLINE_BYTE_BUDGET, 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, wait: true,
}) },
),
)
.await .await
.map_err(|_| {
ToolError::ExecutionFailed(
"command byte transport deadline exceeded; terminal outcome unknown".into(),
)
})?
.map_err(crate::ToolsError::from)?; .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 cancellation_requested = {
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
let owns_registration = state let owns_registration = state
@@ -193,41 +362,78 @@ impl Tool for BashTool {
}; };
guard.handle = None; guard.handle = None;
let timed_out = output.timed_out; let timed_out = terminal.status == workdir::CommandStatus::TimedOut;
let summary = if cancellation_requested { let summary =
if cancellation_requested || terminal.status == workdir::CommandStatus::Cancelled {
format!("$ {cmd_summary} (cancelled)") format!("$ {cmd_summary} (cancelled)")
} else if output.timed_out { } else if timed_out {
format!("$ {cmd_summary} (timed out after {timeout_secs}s)") format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
} else { } else {
match output.exit_code { match terminal.exit_code {
Some(0) => format!("$ {cmd_summary}"), Some(0) => format!("$ {cmd_summary}"),
Some(code) => format!("$ {cmd_summary} (exit {code})"), Some(code) => format!("$ {cmd_summary} (exit {code})"),
None => format!("$ {cmd_summary} (terminated)"), None => format!("$ {cmd_summary} (terminated)"),
} }
}; };
let content = if output.content.is_empty() { Ok(BashResult {
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(),
};
Some(format!("{notice}\n{}", output.content))
} else {
Some(output.content)
};
let output = ToolOutput {
summary, summary,
content, stdout_preview: String::from_utf8_lossy(&stdout_preview).into_owned(),
attachments: Vec::new(), stderr_preview: String::from_utf8_lossy(&stderr_preview).into_owned(),
}; preview_omitted: terminal.stdout_bytes.saturating_add(terminal.stderr_bytes)
if cancellation_requested { > (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<ToolOutput, ToolError> {
let result = self.execute_result(input, ctx).await?;
let output = result.tool_output();
if result.cancelled {
Err(ToolError::Cancelled(output)) Err(ToolError::Cancelled(output))
} else if timed_out { } else if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) {
Err(ToolError::Interrupted(output)) Err(ToolError::Interrupted(output))
} else { } else {
Ok(output) Ok(output)
@@ -288,164 +494,19 @@ fn truncate_for_summary(command: &str) -> String {
summary summary
} }
pub fn bash_tool(session: WorkdirSessionHandle, output_dir: PathBuf) -> ToolDefinition { pub fn bash_tool(session: WorkdirSessionHandle, output: BashOutputView) -> ToolDefinition {
routed_bash_tool(crate::routing::singleton_router(session), output_dir) routed_bash_tool(crate::routing::singleton_router(session), output)
} }
pub(crate) fn routed_bash_tool( pub(crate) fn routed_bash_tool(
router: Arc<WorkdirSessionRouter>, router: Arc<WorkdirSessionRouter>,
output_dir: PathBuf, output: BashOutputView,
) -> ToolDefinition { ) -> ToolDefinition {
Arc::new(move || { Arc::new(move || {
let schema = schemars::schema_for!(BashParams);
let meta = ToolMeta::new("Bash") 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.") .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(serde_json::to_value(schema).expect("Bash schema serialization")); .input_schema(BashTool::input_schema());
let tool: Arc<dyn Tool> = Arc::new(BashTool { let tool: Arc<dyn Tool> = Arc::new(BashTool::new(router.clone(), output.clone()));
router: router.clone(),
output_dir: output_dir.clone(),
state: Arc::new(Mutex::new(BashExecutionState::default())),
});
(meta, tool) (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);
}
}
+605
View File
@@ -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<Mutex<Store>>,
lineage: Vec<String>,
active: Arc<AtomicBool>,
authority: Arc<dyn Fn() -> bool + Send + Sync>,
}
struct Store {
records: VecDeque<Arc<Mutex<Record>>>,
reserved: usize,
retention: Duration,
reaper_started: bool,
_shutdown: tokio::sync::watch::Sender<()>,
}
struct Record {
id: String,
owner: Vec<String>,
created: Instant,
stdout: Vec<u8>,
stderr: Vec<u8>,
order: Vec<OutputRange>,
stdout_seen: u64,
stderr_seen: u64,
truncated: bool,
limit: Option<RetentionLimit>,
transport_counts: Option<(u64, u64)>,
transport_truncation: Option<workdir::CommandOutputTruncation>,
terminal: Option<OutputTerminal>,
}
#[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<CommandStatus>,
pub exit_code: Option<i32>,
/// 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<RetentionLimit>,
pub transport_truncation: Option<workdir::CommandOutputTruncation>,
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<Mutex<Record>>,
}
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<dyn Fn() -> 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<BashOutputWriter, String> {
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<Arc<Mutex<Record>>> {
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<BashOutputMetadata> {
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<Vec<u8>> {
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<Result<(String, bool), String>> {
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<Vec<OutputRange>> {
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<Mutex<Store>>, 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<BashOutputMetadata, String> {
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::<Vec<_>>();
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)
);
}
}
+68
View File
@@ -403,3 +403,71 @@ fn check_pattern(pattern: &str) -> Result<(), ToolError> {
#[cfg(test)] #[cfg(test)]
#[path = "checkout_tests.rs"] #[path = "checkout_tests.rs"]
mod tests; 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<WorkdirSessionRouter>,
tracker: Tracker,
alias: &str,
generation: u64,
path: WorkdirPath,
validator: Vec<u8>,
offset: usize,
limit: usize,
max_bytes: usize,
) -> Result<(workdir::ReadResult, Option<Vec<u8>>, 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<WorkdirSessionRouter>,
alias: &str,
generation: u64,
path: WorkdirPath,
validator: Vec<u8>,
offset: u64,
max_bytes: usize,
) -> Result<(workdir::ReadBytesResult, Vec<u8>), 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")),
}
}
+10 -9
View File
@@ -5,6 +5,7 @@ pub mod error;
pub mod tracker; pub mod tracker;
mod bash; mod bash;
pub mod bash_output;
mod edit; mod edit;
mod file_target; mod file_target;
mod glob; mod glob;
@@ -16,7 +17,7 @@ pub mod view_image;
mod web; mod web;
mod write; mod write;
pub use bash::bash_tool; pub use bash::{BashResult, BashTool, bash_tool};
pub use checkout::{CheckoutToolOutput, execute_checkout_tool}; pub use checkout::{CheckoutToolOutput, execute_checkout_tool};
pub use edit::edit_tool; pub use edit::edit_tool;
pub use error::ToolsError; pub use error::ToolsError;
@@ -35,7 +36,7 @@ pub use write::write_tool;
pub fn core_builtin_tools( pub fn core_builtin_tools(
session: workdir::WorkdirSessionHandle, session: workdir::WorkdirSessionHandle,
tracker: Tracker, tracker: Tracker,
bash_output_dir: std::path::PathBuf, bash_output: bash_output::BashOutputView,
) -> Vec<agen::tool::ToolDefinition> { ) -> Vec<agen::tool::ToolDefinition> {
use workdir::WorkdirSessionCapability; use workdir::WorkdirSessionCapability;
@@ -58,7 +59,7 @@ pub fn core_builtin_tools(
tools.push(grep::routed_grep_tool(router.clone())); tools.push(grep::routed_grep_tool(router.clone()));
} }
if capabilities.supports(WorkdirSessionCapability::Command) { 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 tools
} }
@@ -71,7 +72,7 @@ pub fn core_builtin_tools(
pub fn routed_builtin_tools( pub fn routed_builtin_tools(
router: std::sync::Arc<workdir::WorkdirSessionRouter>, router: std::sync::Arc<workdir::WorkdirSessionRouter>,
tracker: Tracker, tracker: Tracker,
bash_output_dir: std::path::PathBuf, bash_output: bash_output::BashOutputView,
) -> Vec<agen::tool::ToolDefinition> { ) -> Vec<agen::tool::ToolDefinition> {
vec![ vec![
read::routed_read_tool(router.clone(), tracker.clone()), read::routed_read_tool(router.clone(), tracker.clone()),
@@ -79,14 +80,14 @@ pub fn routed_builtin_tools(
edit::routed_edit_tool(router.clone(), tracker), edit::routed_edit_tool(router.clone(), tracker),
glob::routed_glob_tool(router.clone()), glob::routed_glob_tool(router.clone()),
grep::routed_grep_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( pub fn routed_builtin_tools_for_current_capabilities(
router: std::sync::Arc<workdir::WorkdirSessionRouter>, router: std::sync::Arc<workdir::WorkdirSessionRouter>,
tracker: Tracker, tracker: Tracker,
bash_output_dir: std::path::PathBuf, bash_output: bash_output::BashOutputView,
) -> Vec<agen::tool::ToolDefinition> { ) -> Vec<agen::tool::ToolDefinition> {
use workdir::WorkdirSessionCapability; use workdir::WorkdirSessionCapability;
@@ -108,7 +109,7 @@ pub fn routed_builtin_tools_for_current_capabilities(
tools.push(grep::routed_grep_tool(router.clone())); tools.push(grep::routed_grep_tool(router.clone()));
} }
if capabilities.supports(WorkdirSessionCapability::Command) { 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 tools
} }
@@ -121,7 +122,7 @@ pub fn read_only_builtin_tools(
workdir::WorkdirSessionCapabilities::READ_ONLY, workdir::WorkdirSessionCapabilities::READ_ONLY,
"read-only tool projection requires a read-only Workdir session" "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( pub fn read_only_routed_builtin_tools(
@@ -173,7 +174,7 @@ mod workdir_tool_tests {
SharedScope::new(Scope::writable(dir.path()).unwrap()), SharedScope::new(Scope::writable(dir.path()).unwrap()),
WorkdirSessionCapabilities::READ_ONLY, 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() .into_iter()
.map(|definition| definition().0.name) .map(|definition| definition().0.name)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
+19 -27
View File
@@ -3,7 +3,7 @@
use std::sync::Arc; use std::sync::Arc;
use agen::tool::{Tool, ToolDefinition}; use agen::tool::{Tool, ToolDefinition};
use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; use manifest::Scope;
use serde_json::json; use serde_json::json;
use tempfile::TempDir; use tempfile::TempDir;
use tools::{Tracker, core_builtin_tools}; 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 dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap(); let scope = Scope::writable(dir.path()).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 fs: WorkdirSessionHandle = let fs: WorkdirSessionHandle =
std::sync::Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); std::sync::Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let tracker = Tracker::new(); let tracker = Tracker::new();
let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf())); let reg = Registry::new(core_builtin_tools(
(dir, spill, reg) fs,
tracker,
tools::bash_output::BashOutputView::new(),
));
(dir, reg)
} }
#[tokio::test] #[tokio::test]
async fn unicode_path_and_content() { async fn unicode_path_and_content() {
let (dir, _spill, reg) = setup(); let (dir, reg) = setup();
let file = dir.path().join("日本語ファイル.txt"); let file = dir.path().join("日本語ファイル.txt");
let content = "こんにちは 🦀 世界\nabc\n"; let content = "こんにちは 🦀 世界\nabc\n";
@@ -87,7 +79,7 @@ async fn unicode_path_and_content() {
async fn symlink_to_outside_scope_is_rejected_for_write() { async fn symlink_to_outside_scope_is_rejected_for_write() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let (dir, _spill, reg) = setup(); let (dir, reg) = setup();
let outside = TempDir::new().unwrap(); let outside = TempDir::new().unwrap();
let outside_target = outside.path().join("secret.txt"); let outside_target = outside.path().join("secret.txt");
std::fs::write(&outside_target, "secret").unwrap(); 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() { async fn broken_symlink_reports_target_and_repair_hint() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let (dir, _spill, reg) = setup(); let (dir, reg) = setup();
let link = dir.path().join("external-project"); let link = dir.path().join("external-project");
let target = dir.path().join("missing-target"); let target = dir.path().join("missing-target");
symlink(&target, &link).unwrap(); symlink(&target, &link).unwrap();
@@ -173,7 +165,7 @@ async fn broken_symlink_reports_target_and_repair_hint() {
#[tokio::test] #[tokio::test]
async fn empty_file_read_and_edit() { async fn empty_file_read_and_edit() {
let (dir, _spill, reg) = setup(); let (dir, reg) = setup();
let file = dir.path().join("empty.txt"); let file = dir.path().join("empty.txt");
std::fs::write(&file, "").unwrap(); std::fs::write(&file, "").unwrap();
@@ -207,7 +199,7 @@ async fn empty_file_read_and_edit() {
#[tokio::test] #[tokio::test]
async fn very_long_single_line() { async fn very_long_single_line() {
let (dir, _spill, reg) = setup(); let (dir, reg) = setup();
let file = dir.path().join("long.txt"); let file = dir.path().join("long.txt");
let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines
std::fs::write(&file, &big).unwrap(); std::fs::write(&file, &big).unwrap();
@@ -226,7 +218,7 @@ async fn very_long_single_line() {
#[tokio::test] #[tokio::test]
async fn absolute_path_requires_matching_read_scope() { async fn absolute_path_requires_matching_read_scope() {
let (_dir, _spill, reg) = setup(); let (_dir, reg) = setup();
let outside = tempfile::tempdir().unwrap(); let outside = tempfile::tempdir().unwrap();
let outside_file = outside.path().join("outside.txt"); let outside_file = outside.path().join("outside.txt");
std::fs::write(&outside_file, "secret").unwrap(); std::fs::write(&outside_file, "secret").unwrap();
@@ -247,7 +239,7 @@ async fn absolute_path_requires_matching_read_scope() {
#[tokio::test] #[tokio::test]
async fn directory_target_is_rejected_for_read() { async fn directory_target_is_rejected_for_read() {
let (_dir, _spill, reg) = setup(); let (_dir, reg) = setup();
let read = reg.get("Read"); let read = reg.get("Read");
let err = read let err = read
.execute(&json!({ "file_path": "." }).to_string(), Default::default()) .execute(&json!({ "file_path": "." }).to_string(), Default::default())
@@ -258,7 +250,7 @@ async fn directory_target_is_rejected_for_read() {
#[tokio::test] #[tokio::test]
async fn deeply_nested_new_file_is_created() { 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 deep = dir.path().join("a/b/c/d/e/deep.txt");
let write = reg.get("Write"); let write = reg.get("Write");
write write
@@ -277,7 +269,7 @@ async fn deeply_nested_new_file_is_created() {
#[tokio::test] #[tokio::test]
async fn replace_preserves_unicode() { async fn replace_preserves_unicode() {
let (dir, _spill, reg) = setup(); let (dir, reg) = setup();
let file = dir.path().join("u.txt"); let file = dir.path().join("u.txt");
std::fs::write(&file, "🦀 rust 🦀\n").unwrap(); std::fs::write(&file, "🦀 rust 🦀\n").unwrap();
@@ -306,7 +298,7 @@ async fn replace_preserves_unicode() {
#[tokio::test] #[tokio::test]
async fn grep_handles_unicode_pattern() { async fn grep_handles_unicode_pattern() {
let (dir, _spill, reg) = setup(); let (dir, reg) = setup();
let file = dir.path().join("u.txt"); let file = dir.path().join("u.txt");
std::fs::write(&file, "English\n日本語\nрусский\n").unwrap(); std::fs::write(&file, "English\n日本語\nрусский\n").unwrap();
+347 -151
View File
@@ -11,7 +11,7 @@ use agen::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle, Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle,
ToolExecutionTerminal, ToolMeta, ToolExecutionTerminal, ToolMeta,
}; };
use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; use manifest::Scope;
use serde_json::json; use serde_json::json;
use tempfile::TempDir; use tempfile::TempDir;
use tools::{ use tools::{
@@ -23,21 +23,6 @@ use workdir::{
WorkdirToolScopeRule, 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 { struct Registry {
entries: Vec<(ToolMeta, Arc<dyn Tool>)>, entries: Vec<(ToolMeta, Arc<dyn Tool>)>,
} }
@@ -61,39 +46,38 @@ impl Registry {
} }
} }
fn setup() -> (TempDir, TempDir, Registry) { fn setup() -> (TempDir, Registry) {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap(); let scope = Scope::writable(dir.path()).unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let fs: WorkdirSessionHandle = let fs: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let tracker = Tracker::new(); let tracker = Tracker::new();
let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf())); let reg = Registry::new(core_builtin_tools(
(dir, spill, reg) fs,
tracker,
tools::bash_output::BashOutputView::new(),
));
(dir, reg)
} }
fn setup_routed() -> ( fn setup_routed() -> (TempDir, TempDir, Arc<WorkdirSessionRouter>, Registry) {
TempDir,
TempDir,
TempDir,
Arc<WorkdirSessionRouter>,
Registry,
) {
let left = TempDir::new().unwrap(); let left = TempDir::new().unwrap();
let right = TempDir::new().unwrap(); let right = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let router = Arc::new(WorkdirSessionRouter::new()); let router = Arc::new(WorkdirSessionRouter::new());
for (alias, dir) in [("left", &left), ("right", &right)] { 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 = let session: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
router router
.attach(WorkdirAttachmentAlias::new(alias).unwrap(), session) .attach(WorkdirAttachmentAlias::new(alias).unwrap(), session)
.unwrap(); .unwrap();
} }
let definitions = let definitions = routed_builtin_tools(
routed_builtin_tools(router.clone(), Tracker::new(), spill.path().to_path_buf()); router.clone(),
(left, right, spill, router, Registry::new(definitions)) Tracker::new(),
tools::bash_output::BashOutputView::new(),
);
(left, right, router, Registry::new(definitions))
} }
fn valid_gif_with_len(target_len: usize) -> Vec<u8> { fn valid_gif_with_len(target_len: usize) -> Vec<u8> {
@@ -137,7 +121,7 @@ async fn call_err(tool: &Arc<dyn Tool>, input: serde_json::Value) -> agen::tool:
#[test] #[test]
fn core_builtin_tools_registers_full_set() { fn core_builtin_tools_registers_full_set() {
let (_dir, _spill, reg) = setup(); let (_dir, reg) = setup();
let mut names = reg.names(); let mut names = reg.names();
names.sort(); names.sort();
assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]); assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
@@ -145,7 +129,7 @@ fn core_builtin_tools_registers_full_set() {
#[test] #[test]
fn meta_has_description_and_schema() { fn meta_has_description_and_schema() {
let (_dir, _spill, reg) = setup(); let (_dir, reg) = setup();
for (meta, _) in &reg.entries { for (meta, _) in &reg.entries {
assert!( assert!(
!meta.description.is_empty(), !meta.description.is_empty(),
@@ -163,7 +147,7 @@ fn meta_has_description_and_schema() {
#[test] #[test]
fn routed_tool_schemas_share_optional_target_workdir() { 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)()); reg.entries.push(routed_view_image_tool(router)());
for (meta, _) in &reg.entries { for (meta, _) in &reg.entries {
let target = &meta.input_schema["properties"]["target_workdir"]; let target = &meta.input_schema["properties"]["target_workdir"];
@@ -174,7 +158,7 @@ fn routed_tool_schemas_share_optional_target_workdir() {
#[tokio::test] #[tokio::test]
async fn routed_tools_require_alias_only_when_attachment_set_is_ambiguous() { 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(&reg.get("Read"), json!({ "file_path": "same.txt" })).await; let error = call_err(&reg.get("Read"), json!({ "file_path": "same.txt" })).await;
let message = error.to_string(); let message = error.to_string();
assert!(message.contains("target_workdir_required"), "{message}"); 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( let reg = Registry::new(routed_builtin_tools(
router, router,
Tracker::new(), Tracker::new(),
Path::new("unused").to_path_buf(), tools::bash_output::BashOutputView::new(),
)); ));
let message = call_err(&reg.get("Read"), json!({ "file_path": "file.txt" })) let message = call_err(&reg.get("Read"), json!({ "file_path": "file.txt" }))
.await .await
@@ -209,7 +193,7 @@ async fn empty_routed_surface_returns_no_attachment_error() {
#[tokio::test] #[tokio::test]
async fn target_alias_routes_each_tool_and_isolates_read_before_edit() { 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(left.path().join("same.txt"), "left value\n").unwrap();
std::fs::write(right.path().join("same.txt"), "right 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(); 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; .await;
assert_eq!( assert_eq!(
std::fs::canonicalize(bash.content.unwrap().trim()).unwrap(), std::fs::canonicalize(serde_json::from_str::<serde_json::Value>(&bash.content.unwrap()).unwrap()["stdout_preview"].as_str().unwrap().trim()).unwrap(),
std::fs::canonicalize(right.path()).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() { async fn selected_attachment_capability_is_checked_without_fallback() {
let left = TempDir::new().unwrap(); let left = TempDir::new().unwrap();
let right = TempDir::new().unwrap(); let right = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let router = Arc::new(WorkdirSessionRouter::new()); let router = Arc::new(WorkdirSessionRouter::new());
router router
.attach( .attach(
WorkdirAttachmentAlias::new("writable").unwrap(), WorkdirAttachmentAlias::new("writable").unwrap(),
Arc::new(LocalWorkdirSession::new( Arc::new(LocalWorkdirSession::new(
scope_with_spill(left.path(), spill.path()), Scope::writable(left.path()).unwrap(),
left.path().to_path_buf(), left.path().to_path_buf(),
)), )),
) )
@@ -322,7 +305,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() {
workdir::Workdir::new("readonly"), workdir::Workdir::new("readonly"),
right.path().to_path_buf(), right.path().to_path_buf(),
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, workdir::WorkdirSessionCapabilities::READ_ONLY,
)), )),
) )
@@ -330,7 +313,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() {
let reg = Registry::new(routed_builtin_tools( let reg = Registry::new(routed_builtin_tools(
router.clone(), router.clone(),
Tracker::new(), Tracker::new(),
spill.path().to_path_buf(), tools::bash_output::BashOutputView::new(),
)); ));
let message = call_err( let message = call_err(
@@ -352,7 +335,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() {
#[tokio::test] #[tokio::test]
async fn routed_tools_observe_detach_after_registration() { 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(); let alias = WorkdirAttachmentAlias::new("left").unwrap();
router.detach(&alias).await.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() { async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
let first = TempDir::new().unwrap(); let first = TempDir::new().unwrap();
let second = 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(first.path().join("same.txt"), "same bytes\n").unwrap();
std::fs::write(second.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( .attach(
alias.clone(), alias.clone(),
Arc::new(LocalWorkdirSession::new( Arc::new(LocalWorkdirSession::new(
scope_with_spill(first.path(), spill.path()), Scope::writable(first.path()).unwrap(),
first.path().to_path_buf(), 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( let reg = Registry::new(routed_builtin_tools(
router.clone(), router.clone(),
Tracker::new(), Tracker::new(),
spill.path().to_path_buf(), tools::bash_output::BashOutputView::new(),
)); ));
call( call(
&reg.get("Read"), &reg.get("Read"),
@@ -403,7 +385,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
.attach( .attach(
alias.clone(), alias.clone(),
Arc::new(LocalWorkdirSession::new( Arc::new(LocalWorkdirSession::new(
scope_with_spill(second.path(), spill.path()), Scope::writable(second.path()).unwrap(),
second.path().to_path_buf(), second.path().to_path_buf(),
)), )),
) )
@@ -432,6 +414,8 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
struct ExternalValidatingSession { struct ExternalValidatingSession {
inner: LocalWorkdirSession, inner: LocalWorkdirSession,
truncate_first_binary_response: bool, truncate_first_binary_response: bool,
command_response_losses: std::sync::atomic::AtomicUsize,
lose_terminal_only: bool,
mutate_after_first_binary_response: Option<std::path::PathBuf>, mutate_after_first_binary_response: Option<std::path::PathBuf>,
} }
@@ -550,7 +534,23 @@ impl workdir::WorkdirSession for ExternalValidatingSession {
&self, &self,
request: workdir::CommandOutputRequest, request: workdir::CommandOutputRequest,
) -> Result<workdir::CommandOutput, workdir::WorkdirError> { ) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
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( async fn cancel_command(
@@ -568,11 +568,12 @@ impl workdir::WorkdirSession for ExternalValidatingSession {
#[tokio::test] #[tokio::test]
async fn external_view_image_uses_a_protocol_bounded_request() { async fn external_view_image_uses_a_protocol_bounded_request() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap(); let scope = Scope::writable(dir.path()).unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()), inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
truncate_first_binary_response: false, truncate_first_binary_response: false,
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
lose_terminal_only: false,
mutate_after_first_binary_response: None, mutate_after_first_binary_response: None,
}); });
let png = include_bytes!("fixtures/view-image/valid.png"); 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] #[tokio::test]
async fn external_view_image_rejects_truncated_and_cross_version_chunks() { async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let image_path = dir.path().join("image.png"); 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(); std::fs::write(&image_path, include_bytes!("fixtures/view-image/valid.png")).unwrap();
let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()), inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()),
truncate_first_binary_response: true, truncate_first_binary_response: true,
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
lose_terminal_only: false,
mutate_after_first_binary_response: None, mutate_after_first_binary_response: None,
}); });
let (_meta, tool) = view_image_tool(truncated)(); 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 { let changing: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()), inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
truncate_first_binary_response: false, 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), mutate_after_first_binary_response: Some(image_path),
}); });
let (_meta, tool) = view_image_tool(changing)(); 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() { async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() {
const LIMIT: usize = 10 * 1024 * 1024; const LIMIT: usize = 10 * 1024 * 1024;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap(); let scope = Scope::writable(dir.path()).unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle = let session: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let (_meta, tool) = view_image_tool(session)(); 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] #[tokio::test]
async fn view_image_attaches_valid_supported_image_fixtures() { async fn view_image_attaches_valid_supported_image_fixtures() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap(); let scope = Scope::writable(dir.path()).unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle = let session: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let (_meta, tool) = view_image_tool(session)(); let (_meta, tool) = view_image_tool(session)();
@@ -726,8 +728,7 @@ async fn delegated_view_image_preserves_caller_relative_path_identity() {
#[tokio::test] #[tokio::test]
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() { async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap(); let scope = Scope::writable(dir.path()).unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let session: WorkdirSessionHandle = let session: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let png = include_bytes!("fixtures/view-image/valid.png"); 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] #[tokio::test]
async fn read_then_edit_then_read_roundtrip() { async fn read_then_edit_then_read_roundtrip() {
let (dir, _spill, reg) = setup(); let (dir, reg) = setup();
let file = dir.path().join("a.txt"); let file = dir.path().join("a.txt");
std::fs::write(&file, "hello world\n").unwrap(); std::fs::write(&file, "hello world\n").unwrap();
let p = "a.txt"; let p = "a.txt";
@@ -784,7 +785,7 @@ async fn read_then_edit_then_read_roundtrip() {
#[tokio::test] #[tokio::test]
async fn write_then_grep_finds_content() { async fn write_then_grep_finds_content() {
let (dir, _spill, reg) = setup(); let (dir, reg) = setup();
let write = reg.get("Write"); let write = reg.get("Write");
let grep = reg.get("Grep"); let grep = reg.get("Grep");
@@ -813,7 +814,7 @@ async fn write_then_grep_finds_content() {
#[tokio::test] #[tokio::test]
async fn glob_finds_written_files() { async fn glob_finds_written_files() {
let (_dir, _spill, reg) = setup(); let (_dir, reg) = setup();
let write = reg.get("Write"); let write = reg.get("Write");
let glob = reg.get("Glob"); let glob = reg.get("Glob");
@@ -837,7 +838,7 @@ async fn glob_finds_written_files() {
#[tokio::test] #[tokio::test]
async fn absolute_path_is_rejected() { async fn absolute_path_is_rejected() {
let (_dir, _spill, reg) = setup(); let (_dir, reg) = setup();
let outside = TempDir::new().unwrap(); let outside = TempDir::new().unwrap();
let write = reg.get("Write"); let write = reg.get("Write");
@@ -859,7 +860,7 @@ async fn absolute_path_is_rejected() {
#[tokio::test] #[tokio::test]
async fn write_to_existing_without_read_fails() { async fn write_to_existing_without_read_fails() {
let (dir, _spill, reg) = setup(); let (dir, reg) = setup();
let file = dir.path().join("exists.txt"); let file = dir.path().join("exists.txt");
std::fs::write(&file, "preexisting").unwrap(); std::fs::write(&file, "preexisting").unwrap();
@@ -880,7 +881,7 @@ async fn write_to_existing_without_read_fails() {
async fn shared_workdir_across_tools() { async fn shared_workdir_across_tools() {
// The key invariant: all builtin tools share the same WorkdirSession instance, // The key invariant: all builtin tools share the same WorkdirSession instance,
// so read-history set by Read is visible to Edit and Write. // 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"); let file = dir.path().join("shared.txt");
std::fs::write(&file, "one\n").unwrap(); std::fs::write(&file, "one\n").unwrap();
@@ -907,7 +908,7 @@ async fn shared_workdir_across_tools() {
#[tokio::test] #[tokio::test]
async fn edit_requires_read_across_tools() { async fn edit_requires_read_across_tools() {
let (dir, _spill, reg) = setup(); let (dir, reg) = setup();
let file = dir.path().join("a.txt"); let file = dir.path().join("a.txt");
std::fs::write(&file, "foo\n").unwrap(); std::fs::write(&file, "foo\n").unwrap();
@@ -928,7 +929,7 @@ async fn edit_requires_read_across_tools() {
#[tokio::test] #[tokio::test]
async fn deterministic_tool_order_is_registration_order() { 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 // 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(); let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect();
assert_eq!( assert_eq!(
@@ -940,7 +941,7 @@ async fn deterministic_tool_order_is_registration_order() {
// Regression: tool name capitalization matches Claude Code reference // Regression: tool name capitalization matches Claude Code reference
#[test] #[test]
fn tool_names_match_reference_spec() { fn tool_names_match_reference_spec() {
let (_dir, _spill, reg) = setup(); let (_dir, reg) = setup();
for expected in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] { for expected in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
assert!( assert!(
reg.entries.iter().any(|(m, _)| m.name == expected), 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() { async fn tracker_recent_files_tracks_read_write_edit() {
// Build a fresh registry that shares a tracker we can query afterwards. // Build a fresh registry that shares a tracker we can query afterwards.
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap(); let scope = Scope::writable(dir.path()).unwrap();
let scope = scope_with_spill(dir.path(), spill.path());
let fs: WorkdirSessionHandle = let fs: WorkdirSessionHandle =
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
let tracker = Tracker::new(); let tracker = Tracker::new();
let reg = Registry::new(core_builtin_tools( let reg = Registry::new(core_builtin_tools(
fs, fs,
tracker.clone(), tracker.clone(),
spill.path().to_path_buf(), tools::bash_output::BashOutputView::new(),
)); ));
let a = dir.path().join("a.txt"); 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() { async fn bash_inherits_workdir_cwd() {
// The Bash tool starts at the WorkdirSession's pwd. Without any `cd`, its // The Bash tool starts at the WorkdirSession's pwd. Without any `cd`, its
// `pwd` should canonicalize to the workspace root we set up. // `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 bash = reg.get("Bash");
let out = call(&bash, json!({ "command": "pwd", "cwd": "." })).await; let out = call(&bash, json!({ "command": "pwd", "cwd": "." })).await;
let body = out.content.unwrap(); 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(); let expected = std::fs::canonicalize(dir.path()).unwrap();
assert_eq!(actual, expected); assert_eq!(actual, expected);
} }
fn output_fixture() -> (
TempDir,
WorkdirSessionHandle,
tools::bash_output::BashOutputView,
Arc<dyn Tool>,
) {
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] #[tokio::test]
async fn bash_provider_output_exposes_readable_retained_path() { async fn bash_output_is_retained_without_a_filesystem_grant_or_telemetry_subscriber() {
let (_dir, spill, reg) = setup(); let (root, _session, view, bash) = output_fixture();
let bash = reg.get("Bash");
let out = call( let out = call(
&bash, &bash,
json!({ "command": "printf 'x%.0s' {1..20480}", "cwd": "." }), json!({ "command": "printf 'x%.0s' {1..20480}; printf '\\377€' >&2; exit 7", "cwd": "." }),
) )
.await; .await;
let body = out.content.unwrap(); let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap();
assert!(body.contains("bounded WorkdirSession command output")); assert_eq!(body["output"]["terminal"]["exit_code"], 7);
assert!(body.contains("full output saved to")); assert_eq!(body["preview_omitted"], true);
assert!(body.contains(spill.path().to_str().unwrap())); assert_eq!(body["output"]["storage_truncated"], false);
let artifact = std::fs::read_dir(spill.path()) 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() .unwrap()
.next() .starts_with("/bash-output/")
.expect("retained output") );
.unwrap() assert!(!body.to_string().contains(root.path().to_str().unwrap()));
.path(); assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
assert_eq!(std::fs::metadata(artifact).unwrap().len(), 20_480); }
async fn await_command_output(
events: &mut tokio::sync::broadcast::Receiver<workdir::CommandEvent>,
) {
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<workdir::CommandEvent>,
) {
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] #[tokio::test]
async fn bash_cancellation_returns_bounded_progress_as_terminal_output() { async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
let (dir, _spill, reg) = setup(); let (_root, session, _view, bash) = output_fixture();
let marker = dir.path().join("must-not-run-after-cancel"); let mut events = session.subscribe_command_events().unwrap();
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 context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0); let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0);
let bash = reg.get("Bash");
let executing = bash.clone(); let executing = bash.clone();
let execution_context = context.clone(); let execution_context = context.clone();
let execution = tokio::spawn(async move { executing.execute(&input, execution_context).await }); let execution = tokio::spawn(async move {
executing.execute(r#"{"command":"printf 'before'; printf 'err-before' >&2; while :; do :; done","cwd":"."}"#, execution_context).await
tokio::time::sleep(std::time::Duration::from_millis(100)).await; });
bash.cancel_execution(&context) 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 .await
.expect("signal exact execution cancellation"); .unwrap()
let error = tokio::time::timeout(std::time::Duration::from_secs(2), execution) .unwrap()
.await .unwrap_err()
.expect("cancelled Bash should terminate inside the Engine grace budget") else {
.expect("Bash task join"); panic!("expected cancellation");
let ToolError::Cancelled(output) = error.expect_err("cancelled command is non-success") else {
panic!("expected typed cancellation result");
}; };
let content = output.content.expect("bounded progress output"); let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap();
assert!( assert_eq!(body["stdout_preview"], "before");
content.contains("before"), assert_eq!(body["stderr_preview"], "err-before");
"missing pre-cancel stdout: {content}" assert_eq!(body["output"]["terminal"]["status"], "cancelled");
); assert_eq!(body["output"]["terminal"]["transport_complete"], true);
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"
);
} }
#[tokio::test] #[tokio::test]
async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() { async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
let (dir, _spill, reg) = setup(); let (_root, session, _view, bash) = output_fixture();
let marker = dir.path().join("must-not-survive-force-close"); let mut events = session.subscribe_command_events().unwrap();
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 context = ToolExecutionContext::new("call-force", "attempt-force", 0); let context = ToolExecutionContext::new("call-force", "attempt-force", 0);
let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context); let (handle, terminal) = ToolExecutionHandle::start(
bash.clone(),
tokio::time::sleep(std::time::Duration::from_millis(100)).await; r#"{"command":"printf ready; while :; do :; done","cwd":"."}"#.into(),
context,
);
await_command_output(&mut events).await;
handle.force_close(); handle.force_close();
assert!(matches!( assert!(matches!(
terminal.await, terminal.await,
ToolExecutionTerminal::OutcomeUnknown ToolExecutionTerminal::OutcomeUnknown
)); ));
await_command_terminal(&mut events).await;
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; let output = call(&bash, json!({"command":"printf reused", "cwd":"."})).await;
assert!( let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap();
!marker.exists(), assert_eq!(body["stdout_preview"], "reused");
"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"));
} }
// Sanity: unused Path import guard // Sanity: unused Path import guard
const _: fn() -> &'static Path = || Path::new("/"); 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::<serde_json::Value>(&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();
}
}
+36 -20
View File
@@ -3013,7 +3013,8 @@ impl App {
/// `[File: …]` parsing path: each kind maps directly to a typed /// `[File: …]` parsing path: each kind maps directly to a typed
/// block (`Block::Notify`, `Block::WorkerEvent`, …). /// block (`Block::Notify`, `Block::WorkerEvent`, …).
fn apply_system_item(&mut self, value: &serde_json::Value) { fn apply_system_item(&mut self, value: &serde_json::Value) {
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else { let Ok(item) = serde_json::from_value::<session_store::PublicSystemItem>(value.clone())
else {
// Unknown / forward-compat shape: fall back to rendering the // Unknown / forward-compat shape: fall back to rendering the
// raw text payload (if any) as a generic system message. // raw text payload (if any) as a generic system message.
if let Some(text) = value.get("body").and_then(|b| b.as_str()) { if let Some(text) = value.get("body").and_then(|b| b.as_str()) {
@@ -3024,29 +3025,23 @@ impl App {
} }
return; return;
}; };
match item { let body = item.body;
session_store::SystemItem::Notification { message, .. } => { match item.presentation {
session_store::PublicSystemPresentation::Notification { message } => {
self.blocks.push(Block::Notify { message }); self.blocks.push(Block::Notify { message });
} }
session_store::SystemItem::WorkerEvent { event, .. } => { session_store::PublicSystemPresentation::WorkerEvent { event } => {
self.blocks.push(Block::WorkerEvent { event }); self.blocks.push(Block::WorkerEvent { event });
} }
session_store::SystemItem::FeatureInvocationResult { body, .. } session_store::PublicSystemPresentation::Context => {
| 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, .. } => {
self.task_store.apply_system_message_text(&body); self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::SystemMessage { 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.task_store.apply_system_message_text(&body);
self.blocks.push(Block::TaskReminder { text: body }); self.blocks.push(Block::TaskReminder { text: body });
} }
session_store::SystemItem::LegacyIgnored { .. } => {} session_store::PublicSystemPresentation::Ignored => {}
session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {}
} }
} }
@@ -3572,6 +3567,7 @@ mod completion_flow_tests {
session_store::LoggedHistoryEntry { session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(item), item: session_store::LoggedItem::from(item),
metadata: session_store::LoggedSessionHistoryMetadata { metadata: session_store::LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: session_store::LoggedSessionHistoryEntryId::new(), entry_id: session_store::LoggedSessionHistoryEntryId::new(),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown, origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None, derivation: None,
@@ -4329,6 +4325,7 @@ mod completion_flow_tests {
})); }));
app.handle_worker_event(Event::SessionEntryCommitted { app.handle_worker_event(Event::SessionEntryCommitted {
entry: protocol::SessionSnapshotEntry { entry: protocol::SessionSnapshotEntry {
use_policy: None,
entry_id: "run-failure".into(), entry_id: "run-failure".into(),
timestamp: 2, timestamp: 2,
provenance: protocol::SessionEntryProvenance::LegacyUnknown, provenance: protocol::SessionEntryProvenance::LegacyUnknown,
@@ -4450,12 +4447,12 @@ mod completion_flow_tests {
} }
#[test] #[test]
fn live_legacy_workflow_system_item_is_ignored() { fn retired_system_input_is_ignored() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let item = serde_json::json!({ let item = serde_json::json!({
"kind": "workflow", "kind": "retired_input",
"slug": "build", "body": "",
"body": "[Workflow /build]\nRun the build", "presentation": { "kind": "ignored" },
}); });
app.handle_worker_event(Event::SystemItem { app.handle_worker_event(Event::SystemItem {
entry_id: None, 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] #[test]
fn live_system_item_notification_appends_notify_block() { fn live_system_item_notification_appends_notify_block() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let item = serde_json::json!({ let item = serde_json::json!({
"kind": "notification", "kind": "notification",
"message": "hi", "presentation": { "kind": "notification", "message": "hi" },
"body": "[Notification] hi", "body": "[Notification] hi",
}); });
app.handle_worker_event(Event::SystemItem { app.handle_worker_event(Event::SystemItem {
@@ -4884,7 +4899,7 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let item = serde_json::json!({ let item = serde_json::json!({
"kind": "worker_event", "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", "body": "[Notification] worker `child` finished a turn",
}); });
app.handle_worker_event(Event::SystemItem { app.handle_worker_event(Event::SystemItem {
@@ -5285,6 +5300,7 @@ mod completion_flow_tests {
item: serde_json::json!({ item: serde_json::json!({
"kind": "task_reminder", "kind": "task_reminder",
"body": snapshot, "body": snapshot,
"presentation": { "kind": "task_reminder" },
}), }),
}); });
+1
View File
@@ -162,6 +162,7 @@ fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest {
ticket_assignment: None, ticket_assignment: None,
feature_connections: Default::default(), feature_connections: Default::default(),
control_operation_id: None, control_operation_id: None,
execution_grant: None,
} }
} }
+1 -1
View File
@@ -20,7 +20,7 @@ serde_json.workspace = true
sha2.workspace = true sha2.workspace = true
tempfile.workspace = true tempfile.workspace = true
thiserror.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] [dev-dependencies]
serde_json.workspace = true serde_json.workspace = true
+6 -1
View File
@@ -48,6 +48,10 @@ pub struct CheckoutRequest {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] #[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
pub enum CheckoutOperation { pub enum CheckoutOperation {
ReadBytes {
offset: u64,
max_bytes: usize,
},
Read { Read {
offset: usize, offset: usize,
limit: usize, limit: usize,
@@ -71,7 +75,7 @@ pub enum CheckoutOperation {
impl CheckoutOperation { impl CheckoutOperation {
pub fn capability(&self) -> WorkdirSessionCapability { pub fn capability(&self) -> WorkdirSessionCapability {
match self { match self {
Self::Read { .. } => WorkdirSessionCapability::Read, Self::Read { .. } | Self::ReadBytes { .. } => WorkdirSessionCapability::Read,
Self::Edit { .. } => WorkdirSessionCapability::Edit, Self::Edit { .. } => WorkdirSessionCapability::Edit,
Self::Write { .. } | Self::Create { .. } => WorkdirSessionCapability::Write, Self::Write { .. } | Self::Create { .. } => WorkdirSessionCapability::Write,
} }
@@ -86,6 +90,7 @@ impl CheckoutOperation {
)] )]
pub enum CheckoutOutput { pub enum CheckoutOutput {
Read(ReadResult), Read(ReadResult),
ReadBytes(crate::ReadBytesResult),
Write(WriteResult), Write(WriteResult),
Edit(EditResult), Edit(EditResult),
} }
@@ -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<i32>,
pub timed_out: bool,
}
#[async_trait::async_trait]
pub(crate) trait CollectCommand: WorkdirSession {
async fn collect_command_output(
&self,
mut request: CommandOutputRequest,
) -> Result<CollectedCommand, WorkdirError> {
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<T: WorkdirSession + ?Sized> CollectCommand for T {}
+145 -35
View File
@@ -9,7 +9,8 @@
//! COMMAND remains separate explicit authority. General session capability intersections may form //! COMMAND remains separate explicit authority. General session capability intersections may form
//! subsets independently of this grant hierarchy; the codec never implicitly expands nonhierarchical //! 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 //! 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}; use std::path::{Component, Path};
@@ -18,7 +19,7 @@ use serde::{Deserialize, Serialize};
use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError}; use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError};
use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities}; 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. /// Maximum serialized Backend-to-provider command frame size.
pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024; pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024;
/// Maximum serialized provider-to-Backend result frame size. Read bytes are /// 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; const MAX_EXTERNAL_SCOPE_RULES: usize = 64;
pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024; pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024;
pub const MAX_EXTERNAL_COMMAND_BYTES: usize = 64 * 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; 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 { fn valid_command_handle(handle: &crate::CommandHandle) -> bool {
!handle.0.is_empty() !handle.0.is_empty()
@@ -185,6 +186,11 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
&& !request.validator.is_empty() && !request.validator.is_empty()
&& request.validator.len() <= 256 && request.validator.len() <= 256
&& match &request.operation { && 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 { crate::CheckoutOperation::Read {
limit, max_bytes, .. limit, max_bytes, ..
} => { } => {
@@ -267,11 +273,10 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
} }
WorkdirSessionOperation::CommandStart(request) => { WorkdirSessionOperation::CommandStart(request) => {
!request.command.is_empty() !request.command.is_empty()
&& request.validate().is_ok()
&& request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES && request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES
&& (1..=MAX_EXTERNAL_COMMAND_TIMEOUT_SECS).contains(&request.timeout_secs) && (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) && is_root_relative(&request.cwd)
&& request.spill_dir.is_none()
&& request.tool_call_id.as_ref().is_none_or(|tool_call_id| { && request.tool_call_id.as_ref().is_none_or(|tool_call_id| {
!tool_call_id.is_empty() !tool_call_id.is_empty()
&& tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES && tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES
@@ -375,6 +380,13 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
WorkdirSessionOperationResult::CheckoutExecute(result) => { WorkdirSessionOperationResult::CheckoutExecute(result) => {
valid_checkout_observation(&result.observation) valid_checkout_observation(&result.observation)
&& match &result.output { && 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) => { crate::CheckoutOutput::Read(read) => {
read.path == result.observation.path read.path == result.observation.path
&& validate_external_result(&WorkdirSessionOperationResult::Read( && validate_external_result(&WorkdirSessionOperationResult::Read(
@@ -450,11 +462,19 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle), WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle),
WorkdirSessionOperationResult::CommandStatus(_) => true, WorkdirSessionOperationResult::CommandStatus(_) => true,
WorkdirSessionOperationResult::CommandOutput(output) => { WorkdirSessionOperationResult::CommandOutput(output) => {
output.content.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES output.transport_bounds_valid()
&& output.chunks.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES
&& output && output
.next_cursor .chunks
.is_none_or(|cursor| cursor <= MAX_EXTERNAL_COMMAND_CURSOR) .iter()
&& output.output_path.is_none() .map(|chunk| chunk.bytes.len())
.sum::<usize>()
<= 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, WorkdirSessionOperationResult::CommandCancel => true,
}; };
@@ -801,11 +821,16 @@ mod tests {
#[test] #[test]
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() { fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
let future = r#"{ for version in [
"version": 3, 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} "message": {"kind": "heartbeat", "generation": 1, "sequence": 1}
}"#; });
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(future).is_err()); assert!(serde_json::from_value::<ExternalWorkdirServerFrame>(unsupported).is_err());
}
let invalid_limits = r#"{ let invalid_limits = r#"{
"max_source_bytes": 1024, "max_source_bytes": 1024,
@@ -825,32 +850,35 @@ mod tests {
assert!(ExternalWorkdirOperation::try_from(absolute).is_err()); assert!(ExternalWorkdirOperation::try_from(absolute).is_err());
let command = WorkdirSessionOperation::CommandStart(CommandRequest { 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, timeout_secs: 1,
output_limit: 1024,
cwd: WorkdirPath::root(), cwd: serde_json::from_value(serde_json::json!("/absolute")).unwrap(),
spill_dir: Some("/home/operator/private/output".into()),
tool_call_id: None, tool_call_id: None,
}); });
assert!(ExternalWorkdirOperation::try_from(command).is_err()); assert!(ExternalWorkdirOperation::try_from(command).is_err());
let command = WorkdirSessionOperation::CommandStart(CommandRequest { let command = WorkdirSessionOperation::CommandStart(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "printf bounded".to_string(), command: "printf bounded".to_string(),
timeout_secs: 30, timeout_secs: 30,
output_limit: 12 * 1024,
cwd: WorkdirPath::root(), cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: Some("tool-call-1".to_string()), tool_call_id: Some("tool-call-1".to_string()),
}); });
assert!(ExternalWorkdirOperation::try_from(command).is_ok()); assert!(ExternalWorkdirOperation::try_from(command).is_ok());
assert!( assert!(
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart( ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart(
CommandRequest { CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1), command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1),
timeout_secs: 30, timeout_secs: 30,
output_limit: 12 * 1024,
cwd: WorkdirPath::root(), cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None, tool_call_id: None,
} }
)) ))
@@ -887,13 +915,17 @@ mod tests {
assert!( assert!(
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput( ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
crate::CommandOutput { crate::CommandOutput {
chunks: vec![],
next_cursor: 0,
terminal: Some(crate::CommandTerminal {
status: crate::CommandStatus::Completed, status: crate::CommandStatus::Completed,
exit_code: Some(0), exit_code: Some(0),
timed_out: false, stdout_bytes: 0,
content: "bounded".to_string(), stderr_bytes: 0,
next_cursor: None, stdout_delivered_bytes: 0,
truncated: false, stderr_delivered_bytes: 0,
output_path: None, truncation_reason: None,
})
} }
)) ))
.is_ok() .is_ok()
@@ -901,13 +933,14 @@ mod tests {
assert!( assert!(
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput( ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
crate::CommandOutput { crate::CommandOutput {
status: crate::CommandStatus::Completed, chunks: vec![crate::CommandByteChunk {
exit_code: Some(0), sequence: 0,
timed_out: false, stream: crate::CommandStream::Stdout,
content: "bounded".to_string(), offset: 0,
next_cursor: None, bytes: vec![0; MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + 1]
truncated: true, }],
output_path: Some("/provider/private/output".into()), next_cursor: 0,
terminal: None
} }
)) ))
.is_err() .is_err()
@@ -958,7 +991,7 @@ mod tests {
#[test] #[test]
fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() { fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() {
let unknown = r#"{ let unknown = r#"{
"version": 2, "version": 3,
"message": { "message": {
"kind": "heartbeat", "kind": "heartbeat",
"generation": 1, "generation": 1,
@@ -1012,4 +1045,81 @@ mod tests {
assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err()); assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err());
assert!(ExternalWorkdirOperationId::new("operation-1").is_ok()); 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::<ExternalWorkdirOperation>(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::<ExternalWorkdirOperationResult>(
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::<ExternalWorkdirOperationResult>(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()
);
}
} }
+13 -7
View File
@@ -1,6 +1,9 @@
#[cfg(test)]
use crate::command_test_support::CollectCommand as _;
use crate::http::{ use crate::http::{
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation, WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
}; };
use crate::{ use crate::{
BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot, BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot,
GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir, GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir,
@@ -59,11 +62,12 @@ async fn external_local_provider_is_strictly_read_only() {
let command = WorkdirSession::start_command( let command = WorkdirSession::start_command(
&session, &session,
CommandRequest { CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "echo changed > item.txt".to_string(), command: "echo changed > item.txt".to_string(),
timeout_secs: 1, timeout_secs: 1,
output_limit: 1024,
cwd: WorkdirPath::root(), cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
) )
@@ -145,11 +149,12 @@ async fn external_local_provider_read_write_is_filesystem_only() {
let command = WorkdirSession::start_command( let command = WorkdirSession::start_command(
&session, &session,
CommandRequest { CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "touch command-ran".to_string(), command: "touch command-ran".to_string(),
timeout_secs: 1, timeout_secs: 1,
output_limit: 1024,
cwd: WorkdirPath::root(), cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
) )
@@ -193,17 +198,18 @@ async fn external_command_permission_is_explicit_and_independent_from_file_acces
let handle = session let handle = session
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "printf command-ok".to_string(), command: "printf command-ok".to_string(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024,
cwd: WorkdirPath::root(), cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}) })
.await .await
.unwrap(); .unwrap();
let output = session let output = session
.command_output(CommandOutputRequest { .collect_command_output(CommandOutputRequest {
handle, handle,
cursor: 0, cursor: 0,
limit: 1024, limit: 1024,
+87 -12
View File
@@ -693,6 +693,12 @@ mod client {
let mutation = request.operation.capability() != crate::WorkdirSessionCapability::Read; let mutation = request.operation.capability() != crate::WorkdirSessionCapability::Read;
let expected_path = request.target.clone(); let expected_path = request.target.clone();
let expected_output = request.operation.capability(); 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 match self
.operate(WorkdirSessionOperation::CheckoutExecute(request)) .operate(WorkdirSessionOperation::CheckoutExecute(request))
.await .await
@@ -702,11 +708,30 @@ mod client {
&& !result.observation.validator.is_empty() && !result.observation.validator.is_empty()
&& result.observation.validator.len() <= 256 && result.observation.validator.len() <= 256
&& match (&result.output, expected_output) { && 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::CheckoutOutput::Read(read),
crate::WorkdirSessionCapability::Read, crate::WorkdirSessionCapability::Read,
) => { ) => {
read.path == expected_path byte_range.is_none()
&& read.path == expected_path
&& read.bytes.len() && read.bytes.len()
<= crate::BoundedReadLimits::EXTERNAL_DEFAULT <= crate::BoundedReadLimits::EXTERNAL_DEFAULT
.max_response_bytes .max_response_bytes
@@ -822,6 +847,7 @@ mod client {
&self, &self,
request: CommandRequest, request: CommandRequest,
) -> Result<CommandHandle, WorkdirError> { ) -> Result<CommandHandle, WorkdirError> {
request.validate()?;
match self match self
.operate(WorkdirSessionOperation::CommandStart(request)) .operate(WorkdirSessionOperation::CommandStart(request))
.await? .await?
@@ -848,17 +874,39 @@ mod client {
&self, &self,
request: CommandOutputRequest, request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> { ) -> Result<CommandOutput, WorkdirError> {
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; let wait = request.wait;
loop {
match self match self
.operate(WorkdirSessionOperation::CommandOutput(request.clone())) .operate(WorkdirSessionOperation::CommandOutput(request.clone()))
.await? .await?
{ {
WorkdirSessionOperationResult::CommandOutput(result) WorkdirSessionOperationResult::CommandOutput(result) => {
if wait && result.status == CommandStatus::Running => {} let size = result
WorkdirSessionOperationResult::CommandOutput(result) => return Ok(result), .chunks
_ => return Err(Self::mismatch("command_output")), .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, response: reqwest::Response,
) -> Result<T, WorkdirError> { ) -> Result<T, WorkdirError> {
if response.status().is_success() { 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 { } else {
Err(decode_error(response).await) Err(decode_error(response).await)
} }
} }
async fn bounded_response_bytes(
mut response: reqwest::Response,
) -> Result<Vec<u8>, 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 { async fn decode_error(response: reqwest::Response) -> WorkdirError {
response match bounded_response_bytes(response).await {
.json::<WorkdirTransportError>() Ok(bytes) => serde_json::from_slice::<WorkdirTransportError>(&bytes)
.await
.map(WorkdirTransportError::into_workdir_error) .map(WorkdirTransportError::into_workdir_error)
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
WorkdirError::Transport("Runtime Workdir error response was invalid".to_string()) WorkdirError::Transport("Runtime Workdir error response was invalid".into())
}) }),
Err(error) => error,
}
} }
fn http_unavailable(_error: reqwest::Error) -> WorkdirError { fn http_unavailable(_error: reqwest::Error) -> WorkdirError {
+3
View File
@@ -618,3 +618,6 @@ mod capability_tests {
assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63); assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63);
} }
} }
#[cfg(test)]
mod command_test_support;
File diff suppressed because it is too large Load Diff
@@ -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<bool>,
pub status: watch::Receiver<Option<crate::CommandTerminal>>,
pub transport: Arc<Mutex<Transport>>,
}
#[derive(Debug)]
pub(super) struct Transport {
receiver: mpsc::Receiver<Message>,
pending: Option<crate::CommandByteChunk>,
cursor: u64,
replay: Option<(u64, CommandOutput)>,
terminal: Option<crate::CommandTerminal>,
terminal_status: watch::Receiver<Option<crate::CommandTerminal>>,
}
impl Transport {
pub async fn read(
&mut self,
request: &CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> {
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::<usize>()
> 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<String, String>,
resources: Vec<Arc<dyn WorkdirSessionResource>>,
) -> 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<i32>) -> 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<R: AsyncRead + Unpin>(
mut stdout: R,
mut stderr: impl AsyncRead + Unpin,
sender: &mpsc::Sender<Message>,
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<String, String>,
mut cancel: watch::Receiver<bool>,
sender: &mpsc::Sender<Message>,
) -> 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;
@@ -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::CommandByteChunk>, 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::<usize>()
<= crate::MAX_COMMAND_READ_BYTES
);
cursor = page.next_cursor;
chunks.extend(page.chunks);
assert!(chunks.iter().map(|chunk| chunk.bytes.len()).sum::<usize>() <= 8 * 1024 * 1024);
if let Some(terminal) = page.terminal {
return (chunks, terminal);
}
}
}
}
fn stream_bytes(chunks: &[crate::CommandByteChunk], stream: CommandStream) -> Vec<u8> {
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::<String>()
.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);
}
}
+162 -19
View File
@@ -1,5 +1,3 @@
use std::path::PathBuf;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -8,18 +6,18 @@ use serde::{Deserialize, Serialize};
pub struct CommandHandle(pub String); pub struct CommandHandle(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CommandRequest { pub struct CommandRequest {
pub command: String, pub command: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub timeout_secs: u64, pub timeout_secs: u64,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] /// Aggregate unique-byte capture allowance, independent of consumer storage.
pub output_limit: usize, /// 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 /// Workdir-relative command directory. Providers validate it against the
/// active session before process start. /// active session before process start.
pub cwd: fs_operation::FsPath, pub cwd: fs_operation::FsPath,
/// Provider-local directory where complete output is retained when the
/// inline result exceeds `output_limit`.
pub spill_dir: Option<PathBuf>,
/// Optional caller-owned correlation id. Bash supplies its tool-call id so /// Optional caller-owned correlation id. Bash supplies its tool-call id so
/// user-facing command telemetry can update the corresponding Console row /// user-facing command telemetry can update the corresponding Console row
/// without exposing provider/session handles. /// without exposing provider/session handles.
@@ -27,12 +25,23 @@ pub struct CommandRequest {
pub tool_call_id: Option<String>, pub tool_call_id: Option<String>,
} }
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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CommandOutputRequest { pub struct CommandOutputRequest {
pub handle: CommandHandle, pub handle: CommandHandle,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] #[schemars(range(min = 0, max = 67_108_864_u64))]
pub cursor: usize, pub cursor: u64,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] #[schemars(range(min = 1, max = 65_536_usize))]
pub limit: usize, pub limit: usize,
pub wait: bool, 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)] #[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<u8>,
}
#[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, pub status: CommandStatus,
#[schemars(range(min = -2147483648_i32, max = 2147483647_i32))]
pub exit_code: Option<i32>, pub exit_code: Option<i32>,
pub timed_out: bool, pub stdout_bytes: u64,
pub content: String, pub stderr_bytes: u64,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub stdout_delivered_bytes: u64,
pub next_cursor: Option<usize>, pub stderr_delivered_bytes: u64,
pub truncated: bool, pub truncation_reason: Option<CommandOutputTruncation>,
/// Complete output retained by the provider when `truncated` is true. }
pub output_path: Option<PathBuf>,
/// 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<CommandByteChunk>,
pub next_cursor: u64,
pub terminal: Option<CommandTerminal>,
}
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::<CommandRequest>(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::<CommandRequest>(legacy).is_err());
}
let output = serde_json::json!({"chunks": [], "next_cursor": 0, "terminal": null, "output_path": "/host/private"});
assert!(serde_json::from_value::<CommandOutput>(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::<CommandRequest>(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::<CommandRequest>(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"))
);
}
} }
+12 -7
View File
@@ -341,7 +341,9 @@ impl WorkdirSession for RoutedWorkdirSession {
let _active = self.state.enter()?; let _active = self.state.enter()?;
let handle = request.handle.clone(); let handle = request.handle.clone();
let output = self.state.session.command_output(request).await?; 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) Ok(output)
} }
@@ -789,11 +791,12 @@ mod tests {
.unwrap(); .unwrap();
let handle = routed let handle = routed
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "printf done".to_string(), command: "printf done".to_string(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024,
cwd: crate::WorkdirPath::root(), cwd: crate::WorkdirPath::root(),
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}) })
.await .await
@@ -823,11 +826,12 @@ mod tests {
let routed = router.attach(alias, Arc::clone(&underlying)).unwrap(); let routed = router.attach(alias, Arc::clone(&underlying)).unwrap();
let handle = routed let handle = routed
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "sleep 30".to_string(), command: "sleep 30".to_string(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024,
cwd: crate::WorkdirPath::root(), cwd: crate::WorkdirPath::root(),
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}) })
.await .await
@@ -850,11 +854,12 @@ mod tests {
let routed = router.attach(alias.clone(), session("wd", &dir)).unwrap(); let routed = router.attach(alias.clone(), session("wd", &dir)).unwrap();
let handle = routed let handle = routed
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "sleep 30".to_string(), command: "sleep 30".to_string(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024,
cwd: crate::WorkdirPath::root(), cwd: crate::WorkdirPath::root(),
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}) })
.await .await
+289 -37
View File
@@ -10,6 +10,9 @@
//! provider-side before name enumeration, descent, or content reads. Returned coordinates are not //! provider-side before name enumeration, descent, or content reads. Returned coordinates are not
//! prefixed with cwd again. //! prefixed with cwd again.
#[cfg(test)]
use crate::command_test_support::CollectCommand as _;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path; use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -124,6 +127,7 @@ impl WorkdirToolBroker {
next_lease_id: AtomicU64::new(1), next_lease_id: AtomicU64::new(1),
close_lock: Arc::new(tokio::sync::Mutex::new(())), close_lock: Arc::new(tokio::sync::Mutex::new(())),
owned_commands: Arc::new(Mutex::new(HashSet::new())), owned_commands: Arc::new(Mutex::new(HashSet::new())),
command_reads: Mutex::new(HashMap::new()),
pending_command_events: Arc::new(Mutex::new(HashMap::new())), pending_command_events: Arc::new(Mutex::new(HashMap::new())),
starting_tool_calls: Arc::new(Mutex::new(HashSet::new())), starting_tool_calls: Arc::new(Mutex::new(HashSet::new())),
forwarded_starts: Arc::new(Mutex::new(HashSet::new())), forwarded_starts: Arc::new(Mutex::new(HashSet::new())),
@@ -230,25 +234,48 @@ impl WorkdirScopeLease {
.source .source
.cancel_command(handle.clone()) .cancel_command(handle.clone())
.await; .await;
let terminal = self let read_cursor = self
.broker
.authority
.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 .broker
.authority .authority
.source .source
.command_output(CommandOutputRequest { .command_output(CommandOutputRequest {
handle, handle: handle.clone(),
cursor: 0, cursor,
limit: 1, limit: crate::MAX_COMMAND_READ_BYTES,
wait: true, wait: true,
}) })
.await; .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) { match (cancel, terminal) {
(_, Ok(output)) => { (_, Ok(output)) => {
self.broker.authority.publish_terminal_if_missing( self.broker.authority.publish_terminal_if_missing(
&command_id, &command_id,
output.status, output.status,
output.exit_code, output.exit_code,
output.next_cursor.unwrap_or(output.content.len()) as u64, cursor,
&output.content, "",
); );
self.broker self.broker
.authority .authority
@@ -582,7 +609,12 @@ struct ScopedWorkdirSession {
scope_lock: tokio::sync::Mutex<()>, scope_lock: tokio::sync::Mutex<()>,
next_lease_id: AtomicU64, next_lease_id: AtomicU64,
close_lock: Arc<tokio::sync::Mutex<()>>, close_lock: Arc<tokio::sync::Mutex<()>>,
// Live cleanup responsibility ends at terminal delivery. Read ownership is
// retained separately so a lost terminal response can still be replayed.
owned_commands: Arc<Mutex<HashSet<String>>>, owned_commands: Arc<Mutex<HashSet<String>>>,
// Starting commands prunes completed entries at the bounded replay ceiling;
// current scope validity is still checked before every read or retry.
command_reads: Mutex<HashMap<String, Arc<tokio::sync::Mutex<u64>>>>,
pending_command_events: Arc<Mutex<HashMap<String, Vec<CommandEvent>>>>, pending_command_events: Arc<Mutex<HashMap<String, Vec<CommandEvent>>>>,
starting_tool_calls: Arc<Mutex<HashSet<String>>>, starting_tool_calls: Arc<Mutex<HashSet<String>>>,
forwarded_starts: Arc<Mutex<HashSet<String>>>, forwarded_starts: Arc<Mutex<HashSet<String>>>,
@@ -700,6 +732,11 @@ impl ScopedWorkdirSession {
.lock() .lock()
.expect("scoped command set mutex poisoned") .expect("scoped command set mutex poisoned")
.contains(&handle.0) .contains(&handle.0)
|| self
.command_reads
.lock()
.expect("command cursor mutex poisoned")
.contains_key(&handle.0)
{ {
Ok(()) Ok(())
} else { } else {
@@ -1013,6 +1050,7 @@ impl ScopedWorkdirSession {
next_lease_id: AtomicU64::new(1), next_lease_id: AtomicU64::new(1),
close_lock: close_lock.clone(), close_lock: close_lock.clone(),
owned_commands, owned_commands,
command_reads: Mutex::new(HashMap::new()),
pending_command_events, pending_command_events,
starting_tool_calls, starting_tool_calls,
forwarded_starts, forwarded_starts,
@@ -1222,6 +1260,9 @@ impl WorkdirSession for ScopedWorkdirSession {
.intersection(WorkdirSessionCapabilities::READ_ONLY); .intersection(WorkdirSessionCapabilities::READ_ONLY);
} }
if let crate::CheckoutOutput::Read(read) = &mut result.output { 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; read.path = result_path;
} }
Ok(result) Ok(result)
@@ -1407,6 +1448,16 @@ impl WorkdirSession for ScopedWorkdirSession {
.lock() .lock()
.expect("scoped command set mutex poisoned"); .expect("scoped command set mutex poisoned");
owned.insert(handle.0.clone()); 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 { if let Some(tool_call_id) = &tool_call_id {
self.starting_tool_calls self.starting_tool_calls
.lock() .lock()
@@ -1457,14 +1508,28 @@ impl WorkdirSession for ScopedWorkdirSession {
) -> Result<CommandOutput, WorkdirError> { ) -> Result<CommandOutput, WorkdirError> {
self.ensure_owned_command(&request.handle)?; self.ensure_owned_command(&request.handle)?;
let command_id = request.handle.0.clone(); 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?; 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( self.publish_terminal_if_missing(
&command_id, &command_id,
output.status, terminal.status,
output.exit_code, terminal.exit_code,
output.next_cursor.unwrap_or(output.content.len()) as u64, output.next_cursor,
&output.content, "",
); );
self.owned_commands self.owned_commands
.lock() .lock()
@@ -2080,20 +2145,21 @@ mod tests {
session: &WorkdirSessionHandle, session: &WorkdirSessionHandle,
command: impl Into<String>, command: impl Into<String>,
tool_call_id: impl Into<String>, tool_call_id: impl Into<String>,
) -> CommandOutput { ) -> crate::command_test_support::CollectedCommand {
let handle = session let handle = session
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: command.into(), command: command.into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024,
cwd: FsPath::root(), cwd: FsPath::root(),
spill_dir: None,
tool_call_id: Some(tool_call_id.into()), tool_call_id: Some(tool_call_id.into()),
}) })
.await .await
.unwrap(); .unwrap();
session session
.command_output(CommandOutputRequest { .collect_command_output(CommandOutputRequest {
handle, handle,
cursor: 0, cursor: 0,
limit: 1024, limit: 1024,
@@ -2112,11 +2178,12 @@ mod tests {
.expect("delegation wrapper must preserve command observation"); .expect("delegation wrapper must preserve command observation");
let handle = parent let handle = parent
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "printf ready; sleep 0.2; printf done".into(), command: "printf ready; sleep 0.2; printf done".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024,
cwd: FsPath::root(), cwd: FsPath::root(),
spill_dir: None,
tool_call_id: Some("tool-delegated".into()), tool_call_id: Some("tool-delegated".into()),
}) })
.await .await
@@ -2139,7 +2206,7 @@ mod tests {
assert_eq!(snapshots[0].stdout.content, "ready"); assert_eq!(snapshots[0].stdout.content, "ready");
let output = parent let output = parent
.command_output(CommandOutputRequest { .collect_command_output(CommandOutputRequest {
handle, handle,
cursor: 0, cursor: 0,
limit: 1024, limit: 1024,
@@ -2179,11 +2246,12 @@ mod tests {
); );
let error = child let error = child
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "pwd".into(), command: "pwd".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024,
cwd: FsPath::root(), cwd: FsPath::root(),
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}) })
.await .await
@@ -2211,11 +2279,12 @@ mod tests {
let handle = first let handle = first
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "pwd; sleep 0.2".into(), command: "pwd; sleep 0.2".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 4096,
cwd: FsPath::root(), cwd: FsPath::root(),
spill_dir: None,
tool_call_id: Some("first-command".into()), tool_call_id: Some("first-command".into()),
}) })
.await .await
@@ -2239,7 +2308,7 @@ mod tests {
)); ));
let output = first let output = first
.command_output(CommandOutputRequest { .collect_command_output(CommandOutputRequest {
handle, handle,
cursor: 0, cursor: 0,
limit: 4096, limit: 4096,
@@ -2316,11 +2385,12 @@ mod tests {
assert!(matches!( assert!(matches!(
child child
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "printf denied".into(), command: "printf denied".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024,
cwd: FsPath::root(), cwd: FsPath::root(),
spill_dir: None,
tool_call_id: Some("read-only-command".into()), tool_call_id: Some("read-only-command".into()),
}) })
.await, .await,
@@ -2622,11 +2692,12 @@ mod tests {
assert!(matches!( assert!(matches!(
child child
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "printf revoked".into(), command: "printf revoked".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024,
cwd: FsPath::root(), cwd: FsPath::root(),
spill_dir: None,
tool_call_id: Some("revoked-child-command".into()), tool_call_id: Some("revoked-child-command".into()),
}) })
.await, .await,
@@ -2724,11 +2795,12 @@ mod tests {
for index in 0..MAX_SCOPED_COMMANDS { for index in 0..MAX_SCOPED_COMMANDS {
child child
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "sleep 30".into(), command: "sleep 30".into(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024,
cwd: FsPath::root(), cwd: FsPath::root(),
spill_dir: None,
tool_call_id: Some(format!("command-{index}")), tool_call_id: Some(format!("command-{index}")),
}) })
.await .await
@@ -2737,11 +2809,12 @@ mod tests {
let error = child let error = child
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "sleep 30".into(), command: "sleep 30".into(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024,
cwd: FsPath::root(), cwd: FsPath::root(),
spill_dir: None,
tool_call_id: Some("command-over-limit".into()), tool_call_id: Some("command-over-limit".into()),
}) })
.await .await
@@ -2771,11 +2844,12 @@ mod tests {
let command = tokio::spawn(async move { let command = tokio::spawn(async move {
command_child command_child
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "sleep 30".into(), command: "sleep 30".into(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024,
cwd: FsPath::root(), cwd: FsPath::root(),
spill_dir: None,
tool_call_id: Some("racing-command".into()), tool_call_id: Some("racing-command".into()),
}) })
.await .await
@@ -2817,11 +2891,12 @@ mod tests {
let mut events = child.subscribe_command_events().unwrap(); let mut events = child.subscribe_command_events().unwrap();
let handle = child let handle = child
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "sleep 30; printf leaked > marker".into(), command: "sleep 30; printf leaked > marker".into(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024,
cwd: FsPath::root(), cwd: FsPath::root(),
spill_dir: None,
tool_call_id: Some("owned-command".into()), tool_call_id: Some("owned-command".into()),
}) })
.await .await
@@ -3012,11 +3087,12 @@ mod tests {
assert!(matches!( assert!(matches!(
parent parent
.start_command(CommandRequest { .start_command(CommandRequest {
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
command: "printf closed".into(), command: "printf closed".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024,
cwd: FsPath::root(), cwd: FsPath::root(),
spill_dir: None,
tool_call_id: Some("closed-parent-command".into()), tool_call_id: Some("closed-parent-command".into()),
}) })
.await, .await,
@@ -3028,4 +3104,180 @@ mod tests {
"child result after parent close: {child_result:?}" "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");
}
} }
@@ -103,11 +103,12 @@ impl WorkdirSession for CommandFailureSession {
} }
fn command() -> CommandRequest { fn command() -> CommandRequest {
CommandRequest { CommandRequest {
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
command: "fake command, never executed".into(), command: "fake command, never executed".into(),
timeout_secs: 1, timeout_secs: 1,
output_limit: 100,
cwd: WorkdirPath::root(), cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None, tool_call_id: None,
} }
} }
+3 -2
View File
@@ -167,11 +167,12 @@ async fn read_only_and_scoped_capability_refusals_do_not_collapse() {
assert_reason( assert_reason(
child child
.start_command(workdir::CommandRequest { .start_command(workdir::CommandRequest {
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
command: "must-not-run".into(), command: "must-not-run".into(),
timeout_secs: 1, timeout_secs: 1,
output_limit: 1,
cwd: WorkdirPath::root(), cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}) })
.await .await
+1
View File
@@ -56,6 +56,7 @@ server-api = { path = "../server-api" }
workdir.workspace = true workdir.workspace = true
[dev-dependencies] [dev-dependencies]
workdir = { workspace = true, features = ["http-client"] }
futures.workspace = true futures.workspace = true
agen.workspace = true agen.workspace = true
serial_test = "3.4.0" serial_test = "3.4.0"
+44 -11
View File
@@ -657,18 +657,32 @@ fn plan_runtime_store_migration(
)); ));
} }
if current_schema_version == SCHEMA_VERSION { 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 { let plan = FsRuntimeStoreMigrationPlan {
current_schema_version, current_schema_version,
target_schema_version: SCHEMA_VERSION, target_schema_version: SCHEMA_VERSION,
migration_required: false, migration_required,
worker_count: 0, worker_count: 0,
archived_orphan_worker_count: 0, archived_orphan_worker_count,
migrated_worker_aggregate_count: 0, migrated_worker_aggregate_count: 0,
migrated_diagnostic_worker_ref_count: 0, migrated_diagnostic_worker_ref_count: 0,
cleared_diagnostic_worker_ref_count: 0, cleared_diagnostic_worker_ref_count: 0,
mapping_digest: legacy_worker_identity_mapping_digest(&[]), mapping_digest: legacy_worker_identity_mapping_digest(&[]),
mappings: Vec::new(), mappings: Vec::new(),
excluded_ephemeral_paths: Vec::new(), excluded_ephemeral_paths,
}; };
return Ok((plan, Vec::new())); return Ok((plan, Vec::new()));
} }
@@ -1808,7 +1822,9 @@ fn migrate_runtime_store(
Ok(plan) Ok(plan)
} }
fn archive_orphaned_worker_directories(root: &Path) -> Result<usize, RuntimeError> { fn orphaned_worker_directories(
root: &Path,
) -> Result<Vec<(std::ffi::OsString, PathBuf)>, RuntimeError> {
let workers_dir = root.join(WORKERS_DIR); let workers_dir = root.join(WORKERS_DIR);
let mut orphaned = fs::read_dir(&workers_dir) let mut orphaned = fs::read_dir(&workers_dir)
.map_err(|source| runtime_io_error("read Worker records", &workers_dir, source))? .map_err(|source| runtime_io_error("read Worker records", &workers_dir, source))?
@@ -1821,6 +1837,11 @@ fn archive_orphaned_worker_directories(root: &Path) -> Result<usize, RuntimeErro
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
orphaned.sort_by(|(left, _), (right, _)| left.cmp(right)); orphaned.sort_by(|(left, _), (right, _)| left.cmp(right));
Ok(orphaned)
}
fn archive_orphaned_worker_directories(root: &Path) -> Result<usize, RuntimeError> {
let orphaned = orphaned_worker_directories(root)?;
if orphaned.is_empty() { if orphaned.is_empty() {
return Ok(0); return Ok(0);
} }
@@ -1855,6 +1876,21 @@ fn migrate_runtime_store_in_place(
if !plan.migration_required { if !plan.migration_required {
return Ok(plan); 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 runtime_path = root.join(RUNTIME_FILE);
let bytes = let bytes =
fs::read(&runtime_path).map_err(|error| runtime_io_error("read", &runtime_path, error))?; 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 // A Runtime schema bump says nothing about the retained Session schema.
// rewrite retained history or archive attestations. // Session Store owns versioned conversion, including v4 -> generic v5.
if plan.current_schema_version != SOURCE_SCHEMA_VERSION {
crate::retention::migrate_staged_sessions(root)?; crate::retention::migrate_staged_sessions(root)?;
}
let (document, _) = migrate_runtime_document( let (document, _) = migrate_runtime_document(
document, document,
plan.current_schema_version, plan.current_schema_version,
@@ -3011,9 +3045,8 @@ mod tests {
} else { } else {
drop(result.unwrap()); drop(result.unwrap());
assert_eq!(stored_schema_version(&root), u64::from(SCHEMA_VERSION)); assert_eq!(stored_schema_version(&root), u64::from(SCHEMA_VERSION));
let manifest: serde_json::Value = session_store::WorkerSessionStore::open_read_only(&session)
read_json(&session.join("session.json"), "test").unwrap(); .expect("startup migration must admit the current Session schema");
assert_eq!(manifest["schema_version"], 4);
let once = fs::read(session.join("session.json")).unwrap(); let once = fs::read(session.join("session.json")).unwrap();
drop(FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap()); drop(FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap());
assert_eq!(fs::read(session.join("session.json")).unwrap(), once); assert_eq!(fs::read(session.join("session.json")).unwrap(), once);
+5 -8
View File
@@ -67,7 +67,7 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use workdir::{ use workdir::{
CommandOutput, CommandStatus, WorkdirSessionHandle, dispatch_workdir_session_operation, CommandOutput, WorkdirSessionHandle, dispatch_workdir_session_operation,
http::{ http::{
OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, WorkdirSessionId, OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, WorkdirSessionId,
WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirSessionOperationResult, WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirSessionOperationResult,
@@ -914,13 +914,9 @@ async fn run_workdir_session_operation(
{ {
Ok(result) => result?, Ok(result) => result?,
Err(_) => CommandOutput { Err(_) => CommandOutput {
exit_code: None, chunks: Vec::new(),
timed_out: false, next_cursor: cursor,
status: CommandStatus::Running, terminal: None,
content: String::new(),
next_cursor: Some(cursor),
truncated: false,
output_path: None,
}, },
}; };
WorkdirSessionOperationResult::CommandOutput(output) WorkdirSessionOperationResult::CommandOutput(output)
@@ -2426,6 +2422,7 @@ pub enum RuntimeHttpServerError {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
mod command_transport_tests;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
mod session_material_tests; mod session_material_tests;
@@ -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<u8>,
) -> 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<SignedAuthorization>,
}
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::<usize>() <= 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::<Vec<_>>(),
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");
}
@@ -35,6 +35,7 @@ fn message(id: &str, role: LoggedRole, text: &str) -> LoggedHistoryEntry {
content: vec![LoggedContentPart::Text { text: text.into() }], content: vec![LoggedContentPart::Text { text: text.into() }],
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId(id.into()), entry_id: LoggedSessionHistoryEntryId(id.into()),
origin: LoggedSessionHistoryOrigin::LocalHumanInput, origin: LoggedSessionHistoryOrigin::LocalHumanInput,
derivation: None, derivation: None,
+47 -9
View File
@@ -1204,9 +1204,13 @@ fn archive_storage(
} }
} }
/// Called only on the private Runtime migration staging tree under its owner // Share discovery and erasure fencing between the read-only Runtime preflight
/// lock. Failure discards the whole tree, including manifests and attestations. // and its private staging pass. Session Store remains the authority for schema
pub(crate) fn migrate_staged_sessions(root: &Path) -> Result<(), RuntimeError> { // 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"] { for directory in ["workers", "orphaned-workers", "archives/workers"] {
let directory = root.join(directory); let directory = root.join(directory);
let entries = match fs::read_dir(&directory) { let entries = match fs::read_dir(&directory) {
@@ -1253,15 +1257,44 @@ pub(crate) fn migrate_staged_sessions(root: &Path) -> Result<(), RuntimeError> {
Err(session_store::StoreError::SessionErased(_)) => continue, Err(session_store::StoreError::SessionErased(_)) => continue,
Err(error) => return Err(retention_error(error)), Err(error) => return Err(retention_error(error)),
} }
let archive = if directory == root.join("archives/workers") { 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<bool, RuntimeError> {
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( let manifest: WorkerSessionArchiveManifest = read_json(
&aggregate.join("manifest.json"), &aggregate.join("manifest.json"),
"read archive before Session migration", "read archive before Session migration",
)?; )?;
Some(validate_existing_archive(&aggregate, &manifest, false)?) Some(validate_existing_archive(aggregate, &manifest, false)?)
} else { } else {
None 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| { session_store::WorkerSessionStore::new(&session).map_err(|error| {
RuntimeError::StoreCorrupt { RuntimeError::StoreCorrupt {
operation: "migrate Session schema", operation: "migrate Session schema",
@@ -1286,9 +1319,8 @@ pub(crate) fn migrate_staged_sessions(root: &Path) -> Result<(), RuntimeError> {
"commit migrated archive storage", "commit migrated archive storage",
)?; )?;
} }
}
}
Ok(()) Ok(())
})
} }
fn validate_existing_archive( fn validate_existing_archive(
@@ -1753,12 +1785,16 @@ fn temporary_path(path: &Path) -> PathBuf {
path.with_file_name(format!(".{name}.tmp-{}-{sequence}", std::process::id())) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::{Arc, Barrier}; 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::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, serde_json::to_vec_pretty(value).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(); fs::write(worker.join("runs/attempt-a/worker.sock"), b"not retained").unwrap();
} }
fn request( pub(super) fn request(
worker_id: WorkerId, worker_id: WorkerId,
disposition: SessionDisposition, disposition: SessionDisposition,
) -> WorkerRetentionExecutionRequest { ) -> WorkerRetentionExecutionRequest {
@@ -1831,6 +1867,7 @@ mod tests {
}], }],
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId("original".into()), entry_id: LoggedSessionHistoryEntryId("original".into()),
origin: LoggedSessionHistoryOrigin::LocalHumanInput, origin: LoggedSessionHistoryOrigin::LocalHumanInput,
derivation: None, derivation: None,
@@ -1844,6 +1881,7 @@ mod tests {
}], }],
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId("summary".into()), entry_id: LoggedSessionHistoryEntryId("summary".into()),
origin: LoggedSessionHistoryOrigin::DerivedSummary, origin: LoggedSessionHistoryOrigin::DerivedSummary,
derivation: Some(session_store::LoggedHistoryDerivation { derivation: Some(session_store::LoggedHistoryDerivation {
@@ -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"}}}}
@@ -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<PathBuf, Vec<u8>> {
fn walk(root: &Path, at: &Path, result: &mut BTreeMap<PathBuf, Vec<u8>>) {
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<Value> = 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::<Value>(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::<LogEntry>(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);
}
+1
View File
@@ -12422,6 +12422,7 @@ mod tests {
session: protocol::SessionSnapshot { session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(), pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![protocol::SessionSnapshotEntry { entries: vec![protocol::SessionSnapshotEntry {
use_policy: None,
entry_id: "restored-log-entry".to_owned(), entry_id: "restored-log-entry".to_owned(),
timestamp: 1, timestamp: 1,
provenance: protocol::SessionEntryProvenance::LegacyUnknown, provenance: protocol::SessionEntryProvenance::LegacyUnknown,
@@ -398,6 +398,7 @@ mod tests {
content: vec![LoggedContentPart::Text { text: text.into() }], content: vec![LoggedContentPart::Text { text: text.into() }],
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId(id.to_string()), entry_id: LoggedSessionHistoryEntryId(id.to_string()),
origin: LoggedSessionHistoryOrigin::HumanInput { origin: LoggedSessionHistoryOrigin::HumanInput {
account_id: "account-1".to_string(), account_id: "account-1".to_string(),
+211 -47
View File
@@ -147,7 +147,6 @@ use worker::{
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
bash_output_dir_for_worker_id,
}; };
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
@@ -1090,29 +1089,25 @@ fn runtime_local_workdir_session(
root: &Path, root: &Path,
cwd: &Path, cwd: &Path,
scope: manifest::SharedScope, scope: manifest::SharedScope,
command_output_scope: manifest::SharedScope,
capabilities: WorkdirSessionCapabilities, capabilities: WorkdirSessionCapabilities,
command_environment: std::collections::BTreeMap<String, String>, command_environment: std::collections::BTreeMap<String, String>,
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>, resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
) -> WorkdirSessionHandle { ) -> WorkdirSessionHandle {
Arc::new( Arc::new(LocalWorkdirSession::materialized_bound_with_environment(
LocalWorkdirSession::materialized_bound_with_environment_and_command_output_scope(
Workdir::new(workdir_id), Workdir::new(workdir_id),
root.to_path_buf(), root.to_path_buf(),
cwd.to_path_buf(), cwd.to_path_buf(),
scope, scope,
command_output_scope,
capabilities, capabilities,
command_environment, command_environment,
resources, resources,
), ))
)
} }
fn runtime_local_workdir_router( fn runtime_local_workdir_router(
attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>, attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
capabilities: &BTreeMap<WorkdirAttachmentAlias, WorkdirSessionCapabilities>, capabilities: &BTreeMap<WorkdirAttachmentAlias, WorkdirSessionCapabilities>,
command_output_scope: manifest::SharedScope, authorization_client: Option<Arc<dyn WorkspaceClient>>,
) -> Result<Arc<WorkdirSessionRouter>, String> { ) -> Result<Arc<WorkdirSessionRouter>, String> {
let router = Arc::new(WorkdirSessionRouter::new()); let router = Arc::new(WorkdirSessionRouter::new());
for (alias, binding) in attachments { for (alias, binding) in attachments {
@@ -1121,22 +1116,27 @@ fn runtime_local_workdir_router(
.copied() .copied()
.unwrap_or(WorkdirSessionCapabilities::READ_ONLY); .unwrap_or(WorkdirSessionCapabilities::READ_ONLY);
let scope = runtime_attachment_scope(binding.root(), capabilities)?; let scope = runtime_attachment_scope(binding.root(), capabilities)?;
router let source = runtime_local_workdir_session(
.attach(
alias.clone(),
runtime_local_workdir_session(
&binding.working_directory.id, &binding.working_directory.id,
binding.root(), binding.root(),
binding.cwd(), binding.cwd(),
scope, scope,
command_output_scope.clone(),
capabilities, capabilities,
binding.command_environment(), binding.command_environment(),
binding.session_resources().map_err(|error| { binding
format!("open Workdir attachment `{alias}` resources: {error}") .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(), source)
.map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?; .map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?;
} }
Ok(router) Ok(router)
@@ -1146,7 +1146,6 @@ fn restored_workdir_router(
local_attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>, local_attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
logical_attachments: &[crate::catalog::LogicalWorkdirAttachment], logical_attachments: &[crate::catalog::LogicalWorkdirAttachment],
claims: &[crate::catalog::WorkingDirectoryAttachmentClaim], claims: &[crate::catalog::WorkingDirectoryAttachmentClaim],
scope: manifest::SharedScope,
workspace_client: Arc<dyn WorkspaceClient>, workspace_client: Arc<dyn WorkspaceClient>,
) -> Result<Arc<WorkdirSessionRouter>, String> { ) -> Result<Arc<WorkdirSessionRouter>, String> {
let mut local_capabilities = local_attachments let mut local_capabilities = local_attachments
@@ -1163,7 +1162,11 @@ fn restored_workdir_router(
local_capabilities.insert(attachment.alias.clone(), attachment.capabilities); 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 { for attachment in logical_attachments {
if let Some(local) = local_attachments.get(&attachment.alias) { if let Some(local) = local_attachments.get(&attachment.alias) {
if local.working_directory.id != attachment.working_directory_id { 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 store = CombinedStore::new(session_store, worker_metadata_store);
let run_dir = self.worker_run_dir(&request.worker_ref)?; 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( let bootstrap = WorkerBootstrap::new(
manifest, manifest,
store, store,
@@ -1526,7 +1528,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
filesystem_authority, filesystem_authority,
WorkerBootstrapLayout::RuntimeManagedRun { WorkerBootstrapLayout::RuntimeManagedRun {
run_dir: run_dir.clone(), run_dir: run_dir.clone(),
bash_output_dir,
}, },
self.controller_transport, self.controller_transport,
); );
@@ -1571,7 +1572,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
worker.bind_workdir_sessions(runtime_local_workdir_router( worker.bind_workdir_sessions(runtime_local_workdir_router(
&request.workdir_attachments, &request.workdir_attachments,
&workdir_capabilities, &workdir_capabilities,
worker.scope().clone(), Some(worker.workspace_client_handle()),
)?); )?);
if let (Some(runtime_id), Some(workspace_id)) = if let (Some(runtime_id), Some(workspace_id)) =
(observation_runtime_id, observation_workspace_id.clone()) (observation_runtime_id, observation_workspace_id.clone())
@@ -1838,7 +1839,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
&request.workdir_attachments, &request.workdir_attachments,
&request.logical_workdir_attachments, &request.logical_workdir_attachments,
&request.request.workdir_attachments, &request.request.workdir_attachments,
worker.scope().clone(),
worker.workspace_client_handle(), worker.workspace_client_handle(),
)?; )?;
worker.bind_workdir_sessions(workdir_sessions); worker.bind_workdir_sessions(workdir_sessions);
@@ -1875,13 +1875,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
let workspace_client = worker.workspace_client_handle(); let workspace_client = worker.workspace_client_handle();
let run_dir = self.worker_restore_run_dir(&request.worker_ref, request.operation_id)?; 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 cleanup_sessions = worker.workdir_sessions();
let started = PreparedWorker::new( let started = PreparedWorker::new(
worker, worker,
WorkerBootstrapLayout::RuntimeManagedRun { WorkerBootstrapLayout::RuntimeManagedRun {
run_dir: run_dir.clone(), run_dir: run_dir.clone(),
bash_output_dir,
}, },
self.controller_transport, self.controller_transport,
) )
@@ -3755,7 +3753,6 @@ where
working_directory_id, working_directory_id,
binding.root(), binding.root(),
binding.cwd(), binding.cwd(),
scope.clone(),
scope, scope,
WorkdirSessionCapabilities::ALL, WorkdirSessionCapabilities::ALL,
binding.command_environment(), binding.command_environment(),
@@ -5033,6 +5030,7 @@ mod tests {
}], }],
}, },
metadata: LoggedSessionHistoryMetadata { metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id, entry_id,
origin: LoggedSessionHistoryOrigin::LegacyUnknown, origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None, derivation: None,
@@ -5050,6 +5048,65 @@ mod tests {
const SAVED_SUBJEKTIV_SYSTEM_PROMPT: &str = "Committed saved Subject Worker system prompt"; 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<server_api::authorization::OperationAuthorizationResponse, &'static str> {
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( fn saved_subjektiv_policy(
root: &Path, root: &Path,
worker_ref: &WorkerRef, 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 { tokio::spawn(async move {
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
}); });
@@ -6804,8 +6880,6 @@ mod tests {
#[test] #[test]
fn restored_router_uses_workspace_proxy_for_logical_only_attachment() { 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( let client = WorkerWorkspaceContext::local_filesystem(Some(
WorkspaceId::new("workspace-a").unwrap(), WorkspaceId::new("workspace-a").unwrap(),
)) ))
@@ -6820,7 +6894,6 @@ mod tests {
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
}], }],
&[], &[],
scope,
client, client,
) )
.unwrap(); .unwrap();
@@ -6865,21 +6938,18 @@ mod tests {
.unwrap(); .unwrap();
let alias = WorkdirAttachmentAlias::new("docs").unwrap(); let alias = WorkdirAttachmentAlias::new("docs").unwrap();
let workdir_id = binding.working_directory.id.clone(); 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 attachments = BTreeMap::from([(alias.clone(), binding)]);
let logical = [LogicalWorkdirAttachment { let logical = [LogicalWorkdirAttachment {
alias: alias.clone(), alias: alias.clone(),
working_directory_id: workdir_id, working_directory_id: workdir_id,
capabilities: WorkdirSessionCapabilities::READ_ONLY, capabilities: WorkdirSessionCapabilities::READ_ONLY,
}]; }];
let client = WorkerWorkspaceContext::local_filesystem(Some( // Direct local-router capability test: no Workspace Host transport is
WorkspaceId::new("workspace-a").unwrap(), // installed. Use an explicit standalone binding, not an unavailable
)) // Workspace-bound client pretending to authorize operations.
.client_handle(); let client = WorkerWorkspaceContext::no_workspace().client_handle();
let router = let router = restored_workdir_router(&attachments, &logical, &[], client).unwrap();
restored_workdir_router(&attachments, &logical, &[], command_output_scope, client)
.unwrap();
let session = router.session(&alias).unwrap(); let session = router.session(&alias).unwrap();
assert_eq!( 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<dyn WorkspaceClient>);
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<worker::WorkspaceResponse, worker::WorkspaceClientError> {
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 { struct MockFactory {
client: MockClient, client: MockClient,
runtime_base: PathBuf, runtime_base: PathBuf,
@@ -7547,6 +7702,22 @@ mod tests {
None, None,
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(); let workspace_client = workspace_context.client_handle();
self.observed_workspace_clients.lock().unwrap().push(( self.observed_workspace_clients.lock().unwrap().push((
workspace_client.kind().to_string(), workspace_client.kind().to_string(),
@@ -7566,12 +7737,8 @@ mod tests {
) )
.await .await
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
let bash_output_dir = self.runtime_base.join("bash-output"); let (handle, shutdown_rx) =
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed( WorkerController::spawn_runtime_managed(worker, &self.runtime_base)
worker,
&self.runtime_base,
&bash_output_dir,
)
.await .await
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
Ok(RuntimeWorkerController { Ok(RuntimeWorkerController {
@@ -8697,7 +8864,6 @@ mod tests {
"working-directory-42", "working-directory-42",
root.path(), root.path(),
root.path(), root.path(),
spawned_scope.clone(),
spawned_scope, spawned_scope,
WorkdirSessionCapabilities::READ_ONLY, WorkdirSessionCapabilities::READ_ONLY,
Default::default(), Default::default(),
@@ -8708,7 +8874,6 @@ mod tests {
"working-directory-42", "working-directory-42",
root.path(), root.path(),
root.path(), root.path(),
restored_scope.clone(),
restored_scope, restored_scope,
WorkdirSessionCapabilities::READ_ONLY, WorkdirSessionCapabilities::READ_ONLY,
Default::default(), Default::default(),
@@ -10233,8 +10398,7 @@ mod tests {
let alias = WorkdirAttachmentAlias::new("checkout").unwrap(); let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
let attachments = BTreeMap::from([(alias, binding.clone())]); 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(), None).unwrap();
let router = runtime_local_workdir_router(&attachments, &BTreeMap::new(), scope).unwrap();
session_clone.close().await.unwrap(); session_clone.close().await.unwrap();
assert_eq!( assert_eq!(
materializer.cleanup_working_directory(id).unwrap_err().code, materializer.cleanup_working_directory(id).unwrap_err().code,
+1 -3
View File
@@ -47,9 +47,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let worker = worker::Worker::from_manifest_toml(&toml, store).await?; let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
let runtime_tmp = tempfile::tempdir()?; 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()).await?;
let (handle, _shutdown_rx) =
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir).await?;
// Check initial status via shared state // Check initial status via shared state
println!("[shared_state] {}", handle.shared_state.status_json()); println!("[shared_state] {}", handle.shared_state.status_json());
File diff suppressed because it is too large Load Diff
+20 -59
View File
@@ -17,28 +17,9 @@ use manifest::WorkerManifest;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum WorkerBootstrapLayout { pub enum WorkerBootstrapLayout {
/// A direct Worker rooted below the supplied runtime base directory. /// A direct Worker rooted below the supplied runtime base directory.
Direct { Direct { runtime_base: PathBuf },
runtime_base: PathBuf,
bash_output_dir: PathBuf,
},
/// A runtime-managed Worker with an exact persisted run directory. /// A runtime-managed Worker with an exact persisted run directory.
RuntimeManagedRun { RuntimeManagedRun { run_dir: PathBuf },
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")
} }
/// Construction and controller inputs that are stable for one Worker launch. /// Construction and controller inputs that are stable for one Worker launch.
@@ -145,6 +126,13 @@ where
pub async fn prepare( pub async fn prepare(
self, self,
) -> Result<PreparedWorker<Box<dyn LlmClient>, St>, WorkerBootstrapError> { ) -> Result<PreparedWorker<Box<dyn LlmClient>, St>, WorkerBootstrapError> {
crate::feature::authorize_requested_manifest(
self.workspace_context.client_handle().as_ref(),
&self.manifest,
)
.map_err(|error| {
WorkerBootstrapError::Worker(WorkerError::FeatureLifecycle(error.to_string()))
})?;
let mut worker = Worker::from_manifest_with_context_and_model_client( let mut worker = Worker::from_manifest_with_context_and_model_client(
self.manifest, self.manifest,
self.store, self.store,
@@ -197,6 +185,13 @@ where
.await .await
.map_err(WorkerBootstrapError::Worker)?; .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 { if let Some(workdir_session) = self.workdir_session {
worker.bind_single_workdir_session(Some(workdir_session)); worker.bind_single_workdir_session(Some(workdir_session));
} }
@@ -272,27 +267,12 @@ where
{ {
let cleanup_sessions = worker.workdir_sessions(); let cleanup_sessions = worker.workdir_sessions();
let controller = match layout { let controller = match layout {
WorkerBootstrapLayout::Direct { WorkerBootstrapLayout::Direct { runtime_base } => {
runtime_base, WorkerController::spawn_with_transport_owned(worker, &runtime_base, transport).await
bash_output_dir,
} => {
WorkerController::spawn_with_transport_owned(
worker,
&runtime_base,
&bash_output_dir,
transport,
)
.await
} }
WorkerBootstrapLayout::RuntimeManagedRun { WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => {
run_dir,
bash_output_dir,
} => {
WorkerController::spawn_runtime_managed_run_with_transport_owned( WorkerController::spawn_runtime_managed_run_with_transport_owned(
worker, worker, &run_dir, transport,
&run_dir,
&bash_output_dir,
transport,
) )
.await .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")
);
}
}
+186 -183
View File
@@ -7,7 +7,7 @@
//! empty children mean "no indexable children", not "empty directory". List/Glob/Grep discover //! empty children mean "no indexable children", not "empty directory". List/Glob/Grep discover
//! content; Inspect checks selected coordinates. //! 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. //! 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 //! Publication also checks the generation recorded at acquisition. Possessing an Interface grants
//! no authority: route-reconstructed permission inputs and current provider capability/scope are //! 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 serde_json::{Value as Json, json};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use wip_protocol::{ use wip_protocol::{
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode, ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
ReturnDeclaration, TypeExpr, Value,
}; };
use workdir::{ use workdir::{
CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap, CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap,
@@ -117,13 +116,23 @@ impl Provider {
} }
Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?)) Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?))
} }
fn wrap_validator(&self, alias: &str, generation: u64, workdir: &str, raw: &[u8]) -> Vec<u8> { fn wrap_validator(
&self,
alias: &str,
generation: u64,
workdir: &str,
target: &WorkdirPath,
capabilities: workdir::WorkdirSessionCapabilities,
raw: &[u8],
) -> Vec<u8> {
let mut hash = Sha256::new(); let mut hash = Sha256::new();
for value in [ for value in [
self.incarnation.as_bytes(), self.incarnation.as_bytes(),
alias.as_bytes(), alias.as_bytes(),
&generation.to_be_bytes(), &generation.to_be_bytes(),
workdir.as_bytes(), workdir.as_bytes(),
target.as_str().as_bytes(),
format!("{capabilities:?}").as_bytes(),
raw, raw,
] { ] {
hash.update(value.len().to_be_bytes()); hash.update(value.len().to_be_bytes());
@@ -236,45 +245,17 @@ impl Provider {
return Ok(None); return Ok(None);
} }
let workdir = selected.session.workdir().id().as_str().to_owned(); let workdir = selected.session.workdir().id().as_str().to_owned();
let root = checkout_root(&alias); let projection = self.entry_projection(
// 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(
path, path,
&alias, &alias,
selected.generation, selected.generation,
&workdir, &workdir,
target, target,
observation, observation,
) else { );
return Ok(None); Ok(projection.map(|projection| WipPublication {
};
if path == root {
return WipPublication::self_scoped(projection).map(Some);
}
let Some(scope) = scope else {
return Ok(None);
};
Ok(Some(WipPublication {
projection, projection,
scope: Some(scope.object), scope: None,
})) }))
} }
fn entry_projection( fn entry_projection(
@@ -286,16 +267,44 @@ impl Provider {
target: WorkdirPath, target: WorkdirPath,
observation: CheckoutObservation, observation: CheckoutObservation,
) -> Option<WipProjection> { ) -> Option<WipProjection> {
let ops = self.operations(&observation); use crate::file_operation::Contract as C;
if ops.is_empty() { let has = |cap, name| observation.capabilities.supports(cap) && self.may_allow(name);
return None; let mut contracts = Vec::new();
}
let kind = if observation.kind == EntryKind::File { 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" "file"
} else { } 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" "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 { let handler = Arc::new(FileHandler {
provider: self.clone(), provider: self.clone(),
alias: alias.into(), alias: alias.into(),
@@ -304,86 +313,17 @@ impl Provider {
target, target,
observation, observation,
}); });
let family = format!("{kind}-g{generation}"); let mut projection = self.project(path, primary.descriptor(), validator, kind, handler);
let mut projection = self.project(path, descriptor(ops), validator, &family, handler); projection.interface = primary.reference();
// Keep path-qualified local names (permissions/capabilities may differ projection.interface_validator = Some(primary.validator());
// per target) while binding every content Interface to its live entrance. projection.object.interfaces = Vec::new();
projection.interface.scope = checkout_root(alias); crate::file_operation::expose(&mut projection, contracts);
projection.interface.name.push_str("#target/");
projection.interface.name.push_str(&encode_identity(path));
projection.object.interfaces = vec![projection.interface.clone()];
Some(projection) Some(projection)
} }
fn operations(&self, o: &CheckoutObservation) -> Vec<OperationDeclaration> {
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] #[async_trait]
impl WipSubtreeProvider for Arc<Provider> { impl WipSubtreeProvider for Arc<Provider> {
fn interface_target(&self, reference: &wip_protocol::InterfaceReference) -> Option<String> {
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<Option<WipPublication>, ProtocolError> { async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire()) let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
.await .await
@@ -588,8 +528,14 @@ impl FileHandler {
if let Some(raw) = result.validator { if let Some(raw) = result.validator {
Ok(WipOperationOutput::native_with_validator( Ok(WipOperationOutput::native_with_validator(
value, value,
self.provider self.provider.wrap_validator(
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw), &self.alias,
self.generation,
&self.workdir,
&self.target,
self.observation.capabilities,
&raw,
),
)) ))
} else { } else {
Ok(WipOperationOutput::native(value)) Ok(WipOperationOutput::native(value))
@@ -604,6 +550,127 @@ impl WipOperationHandler for FileHandler {
arguments: &BTreeMap<String, Value>, arguments: &BTreeMap<String, Value>,
context: WipCallContext, context: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> { ) -> Result<WipOperationOutput, WipOperationError> {
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 { let tool = match operation {
"list" => "List", "list" => "List",
"read" => "Read", "read" => "Read",
@@ -752,14 +819,6 @@ fn join_under(parent: &WorkdirPath, relative: &str) -> Result<String, WipOperati
format!("{}/{}", parent.as_str(), child.as_str()) format!("{}/{}", parent.as_str(), child.as_str())
}) })
} }
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
ParameterDeclaration {
name: name.into(),
required,
documentation: None,
r#type,
}
}
fn operation( fn operation(
name: &str, name: &str,
summary: &str, summary: &str,
@@ -774,53 +833,10 @@ fn operation(
parameters, parameters,
returns: ReturnDeclaration { returns: ReturnDeclaration {
documentation: None, 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 { fn kind_name(kind: EntryKind) -> &'static str {
match kind { match kind {
EntryKind::Directory => "directory", EntryKind::Directory => "directory",
@@ -841,19 +857,6 @@ fn cursor_value(alias: &str, cursor: &workdir::ListCursor) -> Value {
fn descriptor(operations: Vec<OperationDeclaration>) -> InterfaceDescriptor { fn descriptor(operations: Vec<OperationDeclaration>) -> 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} 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<ParameterDeclaration> {
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 { fn error(code: ProtocolErrorCode, message: &str) -> ProtocolError {
ProtocolError { ProtocolError {
code, code,
+208 -2
View File
@@ -31,11 +31,18 @@ enum WriteResponseFault {
LostBody, LostBody,
} }
#[derive(Clone, Copy)]
enum CommandResponseFault {
LostTerminal,
EmptyWaiting,
}
struct ProviderState { struct ProviderState {
session: WorkdirSessionHandle, session: WorkdirSessionHandle,
requests: Mutex<Vec<(String, String)>>, requests: Mutex<Vec<(String, String)>>,
operations: Mutex<Vec<WorkdirSessionOperation>>, operations: Mutex<Vec<WorkdirSessionOperation>>,
write_fault: Mutex<Option<WriteResponseFault>>, write_fault: Mutex<Option<WriteResponseFault>>,
command_fault: Mutex<Option<CommandResponseFault>>,
observe_refusal: Mutex<bool>, observe_refusal: Mutex<bool>,
} }
@@ -152,6 +159,21 @@ async fn operate(
source: Box::new(WorkdirError::OutOfScope("/private/provider/path".into())), 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 = let result =
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await; dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
match result { match result {
@@ -159,6 +181,15 @@ async fn operate(
Ok(result) => { Ok(result) => {
let fault = if is_write { let fault = if is_write {
state.write_fault.lock().unwrap().take() 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 { } else {
None None
}; };
@@ -237,6 +268,7 @@ impl HttpFixture {
requests: Mutex::new(Vec::new()), requests: Mutex::new(Vec::new()),
operations: Mutex::new(Vec::new()), operations: Mutex::new(Vec::new()),
write_fault: Mutex::new(None), write_fault: Mutex::new(None),
command_fault: Mutex::new(None),
observe_refusal: Mutex::new(false), observe_refusal: Mutex::new(false),
}); });
let app = Router::new() 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 { 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 runtime
.call( .call(
path.into(), path.into(),
@@ -620,7 +653,8 @@ async fn committed_write_unknown(fault: WriteResponseFault) {
let (runtime, router) = native_runtime(remote); let (runtime, router) = native_runtime(remote);
let file = "/checkouts/main/src/deep/a.txt"; let file = "/checkouts/main/src/deep/a.txt";
native_call(&runtime, file, "read", json!({})).await; 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); *fixture.state.write_fault.lock().unwrap() = Some(fault);
let error = runtime let error = runtime
.call( .call(
@@ -798,3 +832,175 @@ async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_wit
"refusal was retried" "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;
}
+6 -1
View File
@@ -683,7 +683,12 @@ async fn committed_checkout_mutation_losing_connection_is_unknown_not_rejected_o
let calls = Arc::new(AtomicUsize::new(0)); let calls = Arc::new(AtomicUsize::new(0));
let route = object_path("main", &file.target); let route = object_path("main", &file.target);
let mut projection = p.node(&route).await.unwrap().unwrap(); 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 { projection.handler = Arc::new(DetachBeforePublication {
file, file,
calls: calls.clone(), calls: calls.clone(),
+727 -28
View File
@@ -41,13 +41,28 @@ async fn interface(r: &WipRuntime, path: &str) -> wip_protocol::InterfaceReferen
.unwrap(); .unwrap();
p.interface 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( async fn call(
r: &WipRuntime, r: &WipRuntime,
path: &str, path: &str,
operation: &str, operation: &str,
args: Json, args: Json,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
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()) r.call(path.into(), i, operation.into(), args, Default::default())
.await .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(); let old = r.host.projection_live(path).await.unwrap().unwrap();
router.detach(&alias).await.unwrap(); router.detach(&alias).await.unwrap();
assert!(r.host.projection_live(path).await.unwrap().is_none()); 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(); router.attach(alias, session(&b, false)).unwrap();
let new = r.host.projection_live(path).await.unwrap().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_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!( assert!(
call( call(
&r, &r,
@@ -354,14 +383,16 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
); );
let restored = runtime(router, None); let restored = runtime(router, None);
let next = restored.host.projection_live(path).await.unwrap().unwrap(); 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_ne!(new.object.validator, next.object.validator);
assert!( assert_eq!(
restored restored
.host .host
.fetch_interface_live(&new.interface) .fetch_interface_live(&new.interface)
.await .await
.is_err() .unwrap()
.validator,
new.interface_validator
); );
assert_eq!( assert_eq!(
std::fs::read_to_string(b.path().join("file.txt")).unwrap(), 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(); let mut registry = WipMountRegistry::new();
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None) crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
.unwrap(); .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 (meta, tool) = def();
let p = compatibility_projection(meta, tool, None).unwrap(); let p = compatibility_projection(meta, tool, None).unwrap();
registry.mount(p).unwrap(); registry.mount(p).unwrap();
@@ -497,7 +532,7 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
.unwrap(); .unwrap();
let r = runtime(router, None); let r = runtime(router, None);
let path = "/checkouts/main/file.txt"; 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. // No rediscovery or Interface refresh between any of these operations.
for (op, args) in [ for (op, args) in [
("read", json!({})), ("read", json!({})),
@@ -505,7 +540,13 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
("write", json!({"content":"saved\n"})), ("write", json!({"content":"saved\n"})),
("read", json!({})), ("read", json!({})),
] { ] {
r.call(path.into(), i.clone(), op.into(), args, Default::default()) r.call(
path.into(),
contract(op).reference(),
op.into(),
args,
Default::default(),
)
.await .await
.unwrap(); .unwrap();
} }
@@ -514,7 +555,8 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
"saved\n" "saved\n"
); );
let parent = "/checkouts/main"; 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"] { for name in ["one.txt", "nested/two.txt"] {
r.call( r.call(
parent.into(), parent.into(),
@@ -617,11 +659,12 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
.is_none() .is_none()
); );
let file = "/checkouts/child/a.txt"; let file = "/checkouts/child/a.txt";
let i = interface(&child, file).await; interface(&child, file).await;
let i = contract("write").reference();
child child
.call( .call(
file.into(), file.into(),
i.clone(), contract("read").reference(),
"read".into(), "read".into(),
json!({}), json!({}),
Default::default(), Default::default(),
@@ -686,7 +729,21 @@ pub(super) async fn model_invoke(
operation: &str, operation: &str,
arguments: Json, arguments: Json,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
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] #[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 r = Arc::new(runtime(router, None));
let entrance = model_inspect(r.clone(), "/checkouts/main").await; let entrance = model_inspect(r.clone(), "/checkouts/main").await;
assert_eq!(entrance["path"], "/checkouts/main"); assert_eq!(entrance["path"], "/checkouts/main");
assert_eq!( assert_eq!(entrance["interfaces"][0]["reference"]["scope"], "/");
entrance["interfaces"][0]["reference"]["scope"],
"/checkouts/main"
);
let projection = r let projection = r
.host .host
.projection_live("/checkouts/main") .projection_live("/checkouts/main")
@@ -829,10 +883,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed
json!([]) json!([])
); );
let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await; let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await;
assert_eq!( assert_eq!(deep["interfaces"][0]["reference"]["scope"], "/");
deep["interfaces"][0]["reference"]["scope"],
"/checkouts/main"
);
for (operation, args) in [ for (operation, args) in [
("list", json!({})), ("list", json!({})),
("glob", json!({"pattern":"*.txt"})), ("glob", json!({"pattern":"*.txt"})),
@@ -926,7 +977,7 @@ async fn checkout_list_permission_cannot_be_bypassed_with_glob_or_command_identi
} }
#[tokio::test] #[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(); let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("a.txt"), "same\n").unwrap(); std::fs::write(dir.path().join("a.txt"), "same\n").unwrap();
let router = Arc::new(WorkdirSessionRouter::new()); 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 p = r.host.projection_live(path).await.unwrap().unwrap();
let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap(); let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap();
assert!( assert!(
fetched.scope_ref.is_none(), fetched.scope_ref.is_some(),
"ref-less publication is legitimate" "common namespace uses the Host root scope"
); );
// Ordinary content changes do not terminate the entrance's Interface lifetime. // Ordinary content changes do not terminate the entrance's Interface lifetime.
std::fs::write(dir.path().join("new.txt"), "fixture").unwrap(); 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 { let request = |reference, scope_ref| CallOperationRequest {
target: wip_protocol::Target { target: wip_protocol::Target {
path: path.into(), path: path.into(),
validator: None, validator: p.object.validator.clone(),
}, },
interface: wip_protocol::InterfaceTarget { interface: wip_protocol::InterfaceTarget {
reference, reference,
scope_ref, scope_ref,
validator: None, validator: fetched.validator.clone(),
}, },
operation: "read".into(), operation: "read".into(),
arguments: BTreeMap::new(), 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(); router.attach(main, session(&dir, true)).unwrap();
let forged = request(p.interface.clone(), None); let forged = request(p.interface.clone(), None);
assert!( 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<Vec<u8>>,
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<Vec<u8>>,
version: Arc<std::sync::atomic::AtomicU64>,
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<String, Value>,
_: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
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<Option<WipPublication>, 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<Vec<String>, 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;
}
+73 -78
View File
@@ -692,7 +692,6 @@ impl WorkerController {
pub async fn spawn<C, St>( pub async fn spawn<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
runtime_base: &Path, runtime_base: &Path,
bash_output_dir: &Path,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
@@ -701,7 +700,6 @@ impl WorkerController {
let (handle, shutdown, _task) = Self::spawn_inner( let (handle, shutdown, _task) = Self::spawn_inner(
worker, worker,
runtime_base, runtime_base,
bash_output_dir,
false, false,
None, None,
WorkerControllerTransport::UnixSocket, WorkerControllerTransport::UnixSocket,
@@ -715,7 +713,7 @@ impl WorkerController {
pub async fn spawn_with_transport<C, St>( pub async fn spawn_with_transport<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
runtime_base: &Path, runtime_base: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where where
@@ -723,42 +721,32 @@ impl WorkerController {
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{ {
let (handle, shutdown, _task) = let (handle, shutdown, _task) =
Self::spawn_with_transport_owned(worker, runtime_base, bash_output_dir, transport) Self::spawn_with_transport_owned(worker, runtime_base, transport).await?;
.await?;
Ok((handle, shutdown)) Ok((handle, shutdown))
} }
pub(crate) async fn spawn_with_transport_owned<C, St>( pub(crate) async fn spawn_with_transport_owned<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
runtime_base: &Path, runtime_base: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{ {
Self::spawn_inner( Self::spawn_inner(worker, runtime_base, false, None, transport).await
worker,
runtime_base,
bash_output_dir,
false,
None,
transport,
)
.await
} }
/// Spawn a Worker owned by `worker-runtime`. /// Spawn a Worker owned by `worker-runtime`.
/// ///
/// The controller uses an ephemeral directory for Unix sockets while tool /// The controller uses an ephemeral directory for Unix sockets. Bash output is
/// spill artifacts use the separately supplied Worker-owned temporary path. /// owned by its live Feature instance, independently of filesystem layout.
/// Runtime-managed Workers do not write legacy pid/status/manifest liveness /// Runtime-managed Workers do not write legacy pid/status/manifest liveness
/// projections. /// projections.
pub async fn spawn_runtime_managed<C, St>( pub async fn spawn_runtime_managed<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
runtime_base: &Path, runtime_base: &Path,
bash_output_dir: &Path,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
@@ -767,7 +755,6 @@ impl WorkerController {
let (handle, shutdown, _task) = Self::spawn_inner( let (handle, shutdown, _task) = Self::spawn_inner(
worker, worker,
runtime_base, runtime_base,
bash_output_dir,
true, true,
None, None,
WorkerControllerTransport::UnixSocket, WorkerControllerTransport::UnixSocket,
@@ -780,7 +767,6 @@ impl WorkerController {
pub async fn spawn_runtime_managed_run<C, St>( pub async fn spawn_runtime_managed_run<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
run_dir: &Path, run_dir: &Path,
bash_output_dir: &Path,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
@@ -789,7 +775,6 @@ impl WorkerController {
Self::spawn_runtime_managed_run_with_transport( Self::spawn_runtime_managed_run_with_transport(
worker, worker,
run_dir, run_dir,
bash_output_dir,
WorkerControllerTransport::UnixSocket, WorkerControllerTransport::UnixSocket,
) )
.await .await
@@ -800,19 +785,15 @@ impl WorkerController {
pub async fn spawn_runtime_managed_run_with_transport<C, St>( pub async fn spawn_runtime_managed_run_with_transport<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
run_dir: &Path, run_dir: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{ {
let (handle, shutdown, _task) = Self::spawn_runtime_managed_run_with_transport_owned( let (handle, shutdown, _task) =
worker, Self::spawn_runtime_managed_run_with_transport_owned(worker, run_dir, transport)
run_dir,
bash_output_dir,
transport,
)
.await?; .await?;
Ok((handle, shutdown)) Ok((handle, shutdown))
} }
@@ -820,7 +801,7 @@ impl WorkerController {
pub(crate) async fn spawn_runtime_managed_run_with_transport_owned<C, St>( pub(crate) async fn spawn_runtime_managed_run_with_transport_owned<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
run_dir: &Path, run_dir: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
where where
@@ -830,21 +811,13 @@ impl WorkerController {
let parent = run_dir let parent = run_dir
.parent() .parent()
.ok_or_else(|| std::io::Error::other("run path has no parent"))?; .ok_or_else(|| std::io::Error::other("run path has no parent"))?;
Self::spawn_inner( Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await
worker,
parent,
bash_output_dir,
true,
Some(run_dir),
transport,
)
.await
} }
async fn spawn_inner<C, St>( async fn spawn_inner<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
runtime_base: &Path, runtime_base: &Path,
bash_output_dir: &Path,
runtime_managed: bool, runtime_managed: bool,
runtime_run: Option<&Path>, runtime_run: Option<&Path>,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
@@ -857,7 +830,6 @@ impl WorkerController {
let result = Self::spawn_initialized( let result = Self::spawn_initialized(
worker, worker,
runtime_base, runtime_base,
bash_output_dir,
runtime_managed, runtime_managed,
runtime_run, runtime_run,
transport, transport,
@@ -874,7 +846,7 @@ impl WorkerController {
async fn spawn_initialized<C, St>( async fn spawn_initialized<C, St>(
mut worker: Worker<C, St>, mut worker: Worker<C, St>,
runtime_base: &Path, runtime_base: &Path,
bash_output_dir: &Path,
runtime_managed: bool, runtime_managed: bool,
runtime_run: Option<&Path>, runtime_run: Option<&Path>,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
@@ -888,8 +860,7 @@ impl WorkerController {
.map_err(std::io::Error::other)?; .map_err(std::io::Error::other)?;
// === 1. Initialization (channels / RuntimeDir / worker-immutable // === 1. Initialization (channels / RuntimeDir / worker-immutable
// snapshots / SpawnedWorkerRegistry / alerter attach / // snapshots / SpawnedWorkerRegistry / alerter attach) ===
// bash-output scope) ===
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let (method_tx, method_rx) = mpsc::channel::<Method>(32); let (method_tx, method_rx) = mpsc::channel::<Method>(32);
let (working_event_tx, _) = broadcast::channel::<Event>(256); let (working_event_tx, _) = broadcast::channel::<Event>(256);
@@ -897,8 +868,8 @@ impl WorkerController {
let in_flight = InFlightEvents::new(working_event_tx.clone()); let in_flight = InFlightEvents::new(working_event_tx.clone());
worker.attach_in_flight_events(in_flight.clone()); worker.attach_in_flight_events(in_flight.clone());
// Runtime directory is created before tool registration because it owns // Runtime directory is created before tool registration and before
// bounded tool artifacts, and before initial status/history writes consume // initial status/history writes consume
// the greeting we build after registration is complete. // the greeting we build after registration is complete.
let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run { let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run {
RuntimeDir::create_worker_run(run_dir).await? RuntimeDir::create_worker_run(run_dir).await?
@@ -935,26 +906,6 @@ impl WorkerController {
worker.attach_internal_worker_registry(spawned_registry.clone()); worker.attach_internal_worker_registry(spawned_registry.clone());
worker.attach_working_event_tx(working_event_tx.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 === // === 1.5. Direct writer wiring ===
// //
// Engine callbacks fire `on_history_append` for each assistant // Engine callbacks fire `on_history_append` for each assistant
@@ -974,7 +925,6 @@ impl WorkerController {
// === 3. Tool registration (builtin / memory / spawn-orchestration) === // === 3. Tool registration (builtin / memory / spawn-orchestration) ===
let fs_for_view = register_worker_tools( let fs_for_view = register_worker_tools(
&mut worker, &mut worker,
bash_output_dir,
runtime_base.to_path_buf(), runtime_base.to_path_buf(),
spawned_registry.clone(), spawned_registry.clone(),
Some(method_tx.downgrade()), Some(method_tx.downgrade()),
@@ -1440,7 +1390,7 @@ fn validate_memory_lifecycle_targets(
/// the shared state. /// the shared state.
pub(crate) async fn register_worker_tools<C, St>( pub(crate) async fn register_worker_tools<C, St>(
worker: &mut Worker<C, St>, worker: &mut Worker<C, St>,
bash_output_dir: PathBuf,
runtime_base: PathBuf, runtime_base: PathBuf,
spawned_registry: Arc<SpawnedWorkerRegistry>, spawned_registry: Arc<SpawnedWorkerRegistry>,
parent_method_tx: Option<mpsc::WeakSender<Method>>, parent_method_tx: Option<mpsc::WeakSender<Method>>,
@@ -1453,6 +1403,14 @@ where
// Worker-immutable snapshots taken before the mutable worker borrow // Worker-immutable snapshots taken before the mutable worker borrow
// below so the worker borrow doesn't conflict with reads on `worker`. // below so the worker borrow doesn't conflict with reads on `worker`.
let feature_config = worker.manifest().feature.clone(); 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 workdir_sessions = worker.workdir_sessions();
let restrict_workdir_tools_to_current_capabilities = inherited_workdir_tool_broker.is_some(); let restrict_workdir_tools_to_current_capabilities = inherited_workdir_tool_broker.is_some();
let workdir_tool_broker = Some( let workdir_tool_broker = Some(
@@ -1485,6 +1443,7 @@ where
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none(); let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
let prompts = worker.prompts().clone(); let prompts = worker.prompts().clone();
let tracker = tools::Tracker::new(); let tracker = tools::Tracker::new();
let bash_output = spawned_registry.bash_output();
{ {
let paste_store = worker.store().clone(); let paste_store = worker.store().clone();
let paste_session_id = worker.session_id(); let paste_session_id = worker.session_id();
@@ -1507,13 +1466,13 @@ where
tools::routed_builtin_tools_for_current_capabilities( tools::routed_builtin_tools_for_current_capabilities(
workdir_sessions.clone(), workdir_sessions.clone(),
tracker.clone(), tracker.clone(),
bash_output_dir.clone(), bash_output.clone(),
) )
} else { } else {
tools::routed_builtin_tools( tools::routed_builtin_tools(
workdir_sessions.clone(), workdir_sessions.clone(),
tracker.clone(), tracker.clone(),
bash_output_dir.clone(), bash_output.clone(),
) )
}; };
worker.engine_mut().register_tools(workdir_tools); worker.engine_mut().register_tools(workdir_tools);
@@ -1656,10 +1615,10 @@ where
if ordinary_subjektiv_features_enabled if ordinary_subjektiv_features_enabled
&& lifecycle_enabled && lifecycle_enabled
&& let Some(host) = subjektiv_host.as_ref() && 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( worker.install_session_context_source(Arc::new(
crate::feature::builtin::journal::JournalStartupContextSource::new( crate::feature::builtin::journal::JournalContextSource::new(
host.clone(), host.clone(),
worker.prompts(), 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 { if wip_mode && let Some(feature) = &workdir_wip_feature {
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip( crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
&mut wip_mount_registry, &mut wip_mount_registry,
@@ -2040,7 +2013,6 @@ where
spawner_workspace_context, spawner_workspace_context,
parent_notifications, parent_notifications,
runtime_base.clone(), runtime_base.clone(),
bash_output_dir.clone(),
spawner_workspace_root, spawner_workspace_root,
workdir_tool_broker, workdir_tool_broker,
spawned_registry.clone(), spawned_registry.clone(),
@@ -3326,6 +3298,8 @@ async fn controller_loop<C, St>(
} }
} }
spawned_registry.bash_output().revoke();
if let Some(observer) = command_observer.take() { if let Some(observer) = command_observer.take() {
observer.abort(); observer.abort();
let _ = observer.await; let _ = observer.await;
@@ -4139,7 +4113,21 @@ mod tests {
request: crate::worker::WorkspaceRequest, request: crate::worker::WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> ) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError>
{ {
// 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"); assert_eq!(request.path, "/api/w/workspace-1/drive/root");
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({ Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
"entry": {"workspace_id": "workspace-1", "node_id": "1"}, "entry": {"workspace_id": "workspace-1", "node_id": "1"},
@@ -4210,16 +4198,23 @@ mod tests {
worker.workdir_sessions(), worker.workdir_sessions(),
) )
.descriptor(); .descriptor();
register_worker_tools( let registered = register_worker_tools(
&mut worker, &mut worker,
root.path().join("bash-output"),
root.path().join("runtime"), root.path().join("runtime"),
SpawnedWorkerRegistry::new_for_internal_services(), SpawnedWorkerRegistry::new_for_internal_services(),
None, None,
None, None,
) )
.await .await;
.unwrap(); 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 configured = enabled && available && workspace.is_some();
let tools = worker.engine().tool_server_handle(); let tools = worker.engine().tool_server_handle();
tools.flush_pending(); tools.flush_pending();
@@ -64,7 +64,6 @@ async fn prepared(
WorkerFilesystemAuthority::None, WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct { WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"), runtime_base: temp.path().join("runtime"),
bash_output_dir: temp.path().join("bash"),
}, },
WorkerControllerTransport::InProcess, WorkerControllerTransport::InProcess,
) )
+1 -2
View File
@@ -635,12 +635,11 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
}; };
let bash_output_dir = crate::bash_output_dir_for_worker_id(&worker_name);
let started = match start_worker_controller( let started = match start_worker_controller(
worker, worker,
WorkerBootstrapLayout::Direct { WorkerBootstrapLayout::Direct {
runtime_base: runtime_base.clone(), runtime_base: runtime_base.clone(),
bash_output_dir,
}, },
WorkerControllerTransport::UnixSocket, WorkerControllerTransport::UnixSocket,
) )
+270
View File
@@ -1751,6 +1751,59 @@ pub enum FeaturePlanError {
/// Builder/installer for enabled feature modules. /// Builder/installer for enabled feature modules.
pub struct FeatureRegistryBuilder { pub struct FeatureRegistryBuilder {
modules: Vec<Arc<dyn FeatureModule>>, modules: Vec<Arc<dyn FeatureModule>>,
activation_guard: Option<Arc<dyn Fn() -> Result<(), String> + Send + Sync>>,
}
/// Profile switches request activation, not authority. Names are the actual
/// public config keys (not internal module ids such as `builtin:sub-worker-control`).
/// Check even switches whose runtime prerequisites will later make them inert.
/// No parent/child permission inheritance is performed here.
pub(crate) fn authorize_requested_features(
client: &dyn crate::worker::WorkspaceClient,
config: &manifest::FeatureConfig,
) -> Result<(), crate::worker::WorkspaceClientError> {
for (name, enabled) in [
("task", config.task.enabled),
("memory", config.memory.enabled()),
("subjektiv", config.subjektiv.enabled()),
("web", config.web.enabled),
("image", config.image.enabled),
("sub_worker", config.sub_worker.enabled),
("flow", config.flow.enabled),
("worker", config.worker.enabled),
(
"workspace_worker_discovery",
config.workspace_worker_discovery.enabled,
),
("objective", config.objective.enabled),
("manage_workdir", config.manage_workdir.enabled),
("workdir_catalog", config.workdir_catalog.enabled),
("workspace_config", config.workspace_config.enabled),
("drive", config.drive.enabled),
("ticket", config.ticket.enabled),
("merge_request", config.merge_request.any()),
("orchestration", config.orchestration.enabled),
] {
if enabled {
client.authorize_feature_activation(name).map_err(|error| {
crate::worker::WorkspaceClientError::Request(format!(
"Feature `{name}` activation rejected: {error}"
))
})?;
}
}
Ok(())
}
pub(crate) fn authorize_requested_manifest(
client: &dyn crate::worker::WorkspaceClient,
manifest: &manifest::WorkerManifest,
) -> Result<(), crate::worker::WorkspaceClientError> {
authorize_requested_features(client, &manifest.feature)?;
if !manifest.mcp.stdio_servers.is_empty() {
client.authorize_feature_activation("mcp")?;
}
Ok(())
} }
fn build_feature_install_plan( fn build_feature_install_plan(
@@ -1945,6 +1998,7 @@ impl Default for FeatureRegistryBuilder {
fn default() -> Self { fn default() -> Self {
Self { Self {
modules: Vec::new(), modules: Vec::new(),
activation_guard: None,
} }
} }
} }
@@ -1954,6 +2008,22 @@ impl FeatureRegistryBuilder {
Self::default() Self::default()
} }
/// Add a trusted, live preflight that runs before *any* module install. Worker
/// hosts use this in addition to the earlier pre-construction preflight.
pub fn with_activation_guard(
mut self,
guard: impl Fn() -> Result<(), String> + Send + Sync + 'static,
) -> Self {
let previous = self.activation_guard.take();
self.activation_guard = Some(Arc::new(move || {
if let Some(previous) = &previous {
previous()?;
}
guard()
}));
self
}
pub fn add_module<M>(&mut self, module: M) -> &mut Self pub fn add_module<M>(&mut self, module: M) -> &mut Self
where where
M: FeatureModule + 'static, M: FeatureModule + 'static,
@@ -2025,6 +2095,42 @@ impl FeatureRegistryBuilder {
hook_builder: &mut HookRegistryBuilder, hook_builder: &mut HookRegistryBuilder,
mut installed_tool_names: HashMap<String, FeatureId>, mut installed_tool_names: HashMap<String, FeatureId>,
) -> FeatureRegistryInstallReport { ) -> 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 let descriptors: Vec<_> = self
.modules .modules
.iter() .iter()
@@ -2300,6 +2406,170 @@ mod tests {
use serde_json::json; use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug)]
struct ActivationClient {
allowed: Vec<String>,
requested: std::sync::Mutex<Vec<String>>,
}
impl crate::worker::WorkspaceClient for ActivationClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
fn kind(&self) -> &str {
"activation-fixture"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: crate::worker::WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
use server_api::authorization::{OperationAuthorizationRequest, PermissionResource};
assert_eq!(request.path, "/api/w/workspace/authorization/check");
let input: OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert_eq!(input.resource, PermissionResource::Feature);
self.requested.lock().unwrap().push(input.operation.clone());
Ok(crate::worker::WorkspaceResponse {
status: if self.allowed.contains(&input.operation) {
200
} else {
403
},
body: "{}".into(),
})
}
}
#[test]
fn requested_activation_uses_exact_public_config_names_before_install() {
let mut config = manifest::FeatureConfig::default();
config.workdir_catalog.enabled = false;
// Enabling each config key independently also detects default-on flags.
for name in [
"task",
"memory",
"subjektiv",
"web",
"image",
"sub_worker",
"flow",
"worker",
"workspace_worker_discovery",
"objective",
"manage_workdir",
"workdir_catalog",
"workspace_config",
"drive",
"ticket",
"merge_request",
"orchestration",
] {
let mut value = serde_json::to_value(&config).unwrap();
if name == "memory" || name == "subjektiv" {
value[name]["profile"]["enabled"] = json!(true);
} else if name == "merge_request" {
value[name]["show"] = json!(true);
} else {
value[name]["enabled"] = json!(true);
}
let requested: manifest::FeatureConfig = serde_json::from_value(value).unwrap();
let client = ActivationClient {
allowed: vec![name.into()],
requested: Default::default(),
};
authorize_requested_features(&client, &requested).unwrap();
assert_eq!(*client.requested.lock().unwrap(), [name], "{name}");
}
}
#[test]
fn ticket_operation_permission_does_not_enable_feature_or_install_services() {
let mut config = manifest::FeatureConfig::default();
config.workdir_catalog.enabled = false;
config.ticket.enabled = true;
for allowed in [vec![], vec!["ticket.create"], vec!["ticket"]] {
let client = Arc::new(ActivationClient {
allowed: allowed.iter().map(|name| name.to_string()).collect(),
requested: Default::default(),
});
let module = PlannedServiceFeature::new(
FeatureDescriptor::builtin("ticket", "Ticket")
.with_provided_service(provided_service("ticket.test", "1")),
);
let calls = module.install_calls.clone();
let config = config.clone();
let registry = FeatureRegistryBuilder::new()
.with_module(module)
.with_activation_guard(move || {
authorize_requested_features(client.as_ref(), &config)
.map_err(|error| error.to_string())
});
let mut tools = Vec::new();
let report = registry.install_into_pending(&mut tools, &mut HookRegistryBuilder::new());
let permitted = allowed == ["ticket"];
assert_eq!(report.has_errors(), !permitted);
assert_eq!(calls.load(Ordering::SeqCst), usize::from(permitted));
assert!(tools.is_empty());
if !permitted {
assert!(report.error_message().contains("ticket"));
}
}
}
#[test]
fn local_compatibility_is_explicit_and_never_grants_workspace_operations() {
use crate::worker::{WorkspaceClient, unavailable_workspace_client};
use server_api::authorization::PermissionResource;
let local = unavailable_workspace_client(None, "standalone");
assert!(local.authorize_feature_activation("task").is_ok());
assert!(
local
.authorize_operation(
"ticket.create",
&PermissionResource::Workspace {
workspace_id: "workspace".into()
}
)
.is_err()
);
let unbound = ActivationClient {
allowed: vec!["ticket".into()],
requested: Default::default(),
};
assert!(unbound.authorize_feature_activation("ticket").is_ok());
let local_files = crate::worker::WorkerWorkspaceContext::local_filesystem(None);
assert!(
local_files
.client_handle()
.authorize_feature_activation("task")
.is_ok()
);
assert!(
local_files
.client_handle()
.authorize_operation(
"ticket.create",
&PermissionResource::Workspace {
workspace_id: "workspace".into()
}
)
.is_err()
);
let marker = crate::worker::marker_workspace_client(None, "local-filesystem");
assert!(
marker.authorize_feature_activation("task").is_err(),
"display kind is not authority"
);
let missing = unavailable_workspace_client(
Some(&crate::worker::WorkspaceId::new("workspace").unwrap()),
"Host unavailable",
);
assert!(missing.authorize_feature_activation("ticket").is_err());
}
#[test] #[test]
fn worker_feature_composition_has_no_dynamic_plugin_install_path() { fn worker_feature_composition_has_no_dynamic_plugin_install_path() {
let feature_source = include_str!("feature.rs") let feature_source = include_str!("feature.rs")
+11
View File
@@ -5,6 +5,17 @@
//! an external plugin-loading surface. //! an external plugin-loading surface.
pub mod backend_job_result; 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<std::sync::Arc<dyn crate::metadata_lifecycle::SessionMetadataLifecycle>> {
vec![std::sync::Arc::new(
crate::subjektiv_metadata::SessionAttributionMetadata,
)]
}
pub mod chat_invocation; pub mod chat_invocation;
pub mod drive; pub mod drive;
pub mod flow_transition; pub mod flow_transition;
+451
View File
@@ -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<WorkdirSessionRouter>,
view: BashOutputView,
permissions: Option<ToolPermissionConfig>,
) -> 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<ToolPermissionConfig>,
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<Self>) -> 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<CommandStream>)> {
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<Provider> {
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, 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<Vec<String>, ProtocolError> {
Ok(vec![])
}
}
struct Run {
provider: Arc<Provider>,
}
#[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<String, Value>,
ctx: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
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<Provider>,
id: String,
stream: Option<CommandStream>,
path: String,
validator: Vec<u8>,
}
impl File {
fn order_text(&self) -> Result<String, WipOperationError> {
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<Vec<u8>> {
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<String, Value>,
_: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
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,
}
}
@@ -138,6 +138,9 @@ impl DriveBackend {
) )
.await .await
} }
pub async fn capabilities(&self) -> Result<DriveCapabilities, DriveError> {
self.get("/capabilities", &serde_json::json!({})).await
}
pub async fn root(&self) -> Result<DriveEntry, DriveError> { pub async fn root(&self) -> Result<DriveEntry, DriveError> {
let entry: DriveEntry = self let entry: DriveEntry = self
.json( .json(
@@ -102,6 +102,26 @@ impl WorkspaceClient for InternalChildWorkspaceClient {
self.inner.is_available() self.inner.is_available()
} }
fn authorize_operation(
&self,
operation: &str,
resource: &server_api::authorization::PermissionResource,
) -> Result<(), WorkspaceClientError> {
self.inner.authorize_operation(operation, resource)
}
fn authorize_operation_scope(
&self,
operation: &str,
resource: &server_api::authorization::PermissionResource,
) -> Result<Option<server_api::authorization::PermissionResource>, WorkspaceClientError> {
self.inner.authorize_operation_scope(operation, resource)
}
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
self.inner.authorize_feature_activation(feature)
}
fn execute( fn execute(
&self, &self,
request: WorkspaceRequest, request: WorkspaceRequest,
@@ -7,12 +7,14 @@ use std::collections::VecDeque;
struct Client { struct Client {
responses: Mutex<VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>, responses: Mutex<VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
requests: Mutex<Vec<WorkspaceRequest>>, requests: Mutex<Vec<WorkspaceRequest>>,
capabilities: Mutex<WorkspaceResponse>,
} }
impl Client { impl Client {
fn new(responses: Vec<WorkspaceResponse>) -> Arc<Self> { fn new(responses: Vec<WorkspaceResponse>) -> Arc<Self> {
Arc::new(Self { Arc::new(Self {
responses: Mutex::new(responses.into_iter().map(Ok).collect()), responses: Mutex::new(responses.into_iter().map(Ok).collect()),
requests: Mutex::new(Vec::new()), requests: Mutex::new(Vec::new()),
capabilities: Mutex::new(response(DriveCapabilities { write: false })),
}) })
} }
} }
@@ -30,7 +32,15 @@ impl WorkspaceClient for Client {
&self, &self,
request: WorkspaceRequest, request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
let capabilities = request.path == "/api/w/workspace/drive/capabilities";
self.requests.lock().unwrap().push(request); 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 self.responses
.lock() .lock()
.unwrap() .unwrap()
@@ -479,7 +489,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
let feature = feature(client.clone()); let feature = feature(client.clone());
let mut mounts = crate::wip::WipMountRegistry::new(); let mut mounts = crate::wip::WipMountRegistry::new();
wip::mount_drive_wip(&mut mounts, &feature).unwrap(); 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 path = entry_path(&entry.entry);
let publication = provider.publication(&path).await.unwrap().unwrap(); let publication = provider.publication(&path).await.unwrap().unwrap();
assert_eq!(publication.projection.route, path); 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.scope.as_ref().unwrap().r#ref,
publication.projection.object.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.children("/drive").await.unwrap().is_empty());
assert!( assert!(
provider provider
@@ -504,7 +515,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
.flat_map(|op| &op.parameters) .flat_map(|op| &op.parameters)
.any(|parameter| parameter.name == "expected_mutation_id") .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)] #[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); 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<String, wip_protocol::Value> {
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<DriveMutationRequest>)>);
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<WorkspaceResponse, WorkspaceClientError> {
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<Option<wip_protocol::Object>>,
}
#[async_trait]
impl crate::wip::WipSubtreeProvider for RetainedObjectOnce {
async fn publication(
&self,
path: &str,
) -> Result<Option<crate::wip::WipPublication>, 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<Vec<String>, 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());
}
+187 -19
View File
@@ -40,7 +40,11 @@ pub fn mount_drive_wip(
registry.allocate_namespace("drive", "drive")?; registry.allocate_namespace("drive", "drive")?;
// Registration is only a namespace entrance. The subtree provides the // Registration is only a namespace entrance. The subtree provides the
// current target/scope/descriptor/handler in a single self-scoped snapshot. // 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); let descriptor = descriptor(&names, true);
registry.mount(WipProjection { registry.mount(WipProjection {
route: ROOT.into(), route: ROOT.into(),
@@ -63,7 +67,7 @@ pub fn mount_drive_wip(
})?; })?;
registry.mount_subtree(WipSubtreeMount { registry.mount_subtree(WipSubtreeMount {
root: ROOT.into(), 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))?; registry.replace_compatibility_tools(ROOT, SPECS.iter().map(|spec| spec.name))?;
Ok(()) Ok(())
@@ -80,7 +84,19 @@ impl WipOperationHandler for Unresolved {
Err(map_error(DriveError::Denied)) Err(map_error(DriveError::Denied))
} }
} }
pub(super) struct DriveProvider(pub(super) DriveFeature); #[derive(Clone)]
pub(super) struct DriveProvider {
feature: DriveFeature,
observation_nonce: Arc<str>,
}
impl DriveProvider {
pub(super) fn new(feature: DriveFeature) -> Self {
Self {
feature,
observation_nonce: uuid::Uuid::now_v7().to_string().into(),
}
}
}
#[async_trait] #[async_trait]
impl WipSubtreeProvider for DriveProvider { impl WipSubtreeProvider for DriveProvider {
// Arbitrary tree depth is safe: there are deliberately no indexable entries. // 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<Option<WipPublication>, ProtocolError> { async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
let result = if path == ROOT { let result = if path == ROOT {
self.0.backend.root().await self.feature.backend.root().await
} else { } 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); return Ok(None);
}; };
self.0.backend.metadata(&entry).await self.feature.backend.metadata(&entry).await
}; };
let entry = match result { let entry = match result {
Ok(entry) => entry, Ok(entry) => entry,
Err(DriveError::Denied | DriveError::NotFound) => return Ok(None), Err(DriveError::Denied | DriveError::NotFound) => return Ok(None),
Err(error) => return Err(protocol(error)), 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 root = path == ROOT;
let specs = if root { let specs: Vec<_> = if root {
root_specs() root_specs()
} else { } else {
entry_specs(&entry) 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 descriptor = descriptor(&specs, root);
let reference = contextual_reference(INTERFACE, path); 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 { let object = Object {
name: if root { name: if root {
"drive".into() "drive".into()
@@ -130,7 +169,8 @@ impl WipSubtreeProvider for DriveProvider {
)), )),
validator: Some(validator), validator: Some(validator),
}; };
let projection = WipProjection { let common_read = entry.kind == DriveEntryKind::File;
let mut projection = WipProjection {
route: path.into(), route: path.into(),
capability: "drive:content".into(), capability: "drive:content".into(),
kind: WipProjectionKind::Native, kind: WipProjectionKind::Native,
@@ -139,18 +179,80 @@ impl WipSubtreeProvider for DriveProvider {
interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()), interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()),
descriptor, descriptor,
handler: Arc::new(DriveHandler { handler: Arc::new(DriveHandler {
feature: self.0.clone(), feature: self.feature.clone(),
observation_nonce: self.observation_nonce.clone(),
entry, entry,
specs, specs,
root, 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) WipPublication::self_scoped(projection).map(Some)
} }
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> { async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
Ok(Vec::new()) Ok(Vec::new())
} }
} }
fn object_validator(
observation_nonce: &str,
entry: &DriveEntry,
mutation: &str,
specs: &[Spec],
) -> Vec<u8> {
// 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<Spec> { fn root_specs() -> Vec<Spec> {
SPECS SPECS
.iter() .iter()
@@ -194,11 +296,10 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati
} }
} }
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor { 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)], "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)], "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)], "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_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)], "create_folder"=>vec![parameter("name",true,TypeExpr::String)],
"relocate"=>vec![parameter("parent",true,TypeExpr::Entry),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 { struct DriveHandler {
feature: DriveFeature, feature: DriveFeature,
observation_nonce: Arc<str>,
entry: DriveEntry, entry: DriveEntry,
specs: Vec<Spec>, specs: Vec<Spec>,
root: bool, root: bool,
@@ -221,21 +323,34 @@ impl WipOperationHandler for DriveHandler {
arguments: &BTreeMap<String, Value>, arguments: &BTreeMap<String, Value>,
context: WipCallContext, context: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> { ) -> Result<WipOperationOutput, WipOperationError> {
let common_text = matches!(operation, "read_lines" | "search_text");
let backend_operation = if common_text { "read" } else { operation };
let spec = self let spec = self
.specs .specs
.iter() .iter()
.find(|spec| spec.operation == operation) .find(|spec| spec.operation == backend_operation)
.ok_or_else(|| { .ok_or_else(|| {
failure( failure(
ProtocolErrorCode::OperationNotFound, ProtocolErrorCode::OperationNotFound,
"Drive operation is not published", "Drive operation is not published",
) )
})?; })?;
let declaration = descriptor(&self.specs, self.root) 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 .operations
.into_iter() .into_iter()
.find(|op| op.name == operation) .find(|op| op.name == operation)
.unwrap(); .unwrap()
};
if arguments if arguments
.keys() .keys()
.any(|key| !declaration.parameters.iter().any(|p| p.name == *key)) .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(), "unrecognized arguments; committed-request observations are Client-managed".into(),
))); )));
} }
let mut input = wip_to_json(&Value::Record(arguments.clone())) let mut input = if common_text {
.map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?; // 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 let fields = input
.as_object_mut() .as_object_mut()
.ok_or_else(|| map_error(DriveError::Invalid("named arguments required".into())))?; .ok_or_else(|| map_error(DriveError::Invalid("named arguments required".into())))?;
@@ -298,6 +427,19 @@ impl WipOperationHandler for DriveHandler {
) )
.await .await
.map_err(map_error)?; .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(|_| { let value = json_to_wip(&output.value).map_err(|_| {
WipOperationError::OutcomeUnknown( WipOperationError::OutcomeUnknown(
"Completed Drive result unavailable; query request_status before another mutation" "Completed Drive result unavailable; query request_status before another mutation"
@@ -314,13 +456,39 @@ impl WipOperationHandler for DriveHandler {
"Committed Drive request identity unavailable".into(), "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 { } else {
WipOperationOutput::native(value) WipOperationOutput::native(value)
}; };
Ok(result.with_attachments(output.attachments)) 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<Option<DriveEntryRef>, DriveError> { fn parse_entry(feature: &DriveFeature, path: &str) -> Result<Option<DriveEntryRef>, DriveError> {
let workspace = feature.backend.workspace()?; let workspace = feature.backend.workspace()?;
let prefix = format!( let prefix = format!(
+139 -22
View File
@@ -23,7 +23,7 @@ use crate::hook::{
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError}; use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use async_trait::async_trait; use async_trait::async_trait;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::sync::Arc; use std::sync::Arc;
use subjektiv::journal::transport::*; use subjektiv::journal::transport::*;
@@ -32,12 +32,73 @@ use subjektiv::journal::{
MAX_JOURNAL_RESPONSE_BYTES, 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<subjektiv::journal::startup::JournalStartupSnapshot>,
snapshot_sha256: Option<String>,
prompt_provenance: session_store::PromptRenderProvenance,
}
fn journal_input(
body: String,
payload: session_store::FeatureInputPayload,
kind: &str,
) -> Result<session_store::SystemInputEnvelope, String> {
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. /// Subjektiv owns startup projection; Core only commits its typed initial items.
pub(crate) struct JournalStartupContextSource { pub(crate) struct JournalContextSource {
host: SubjektivHostConnection, host: SubjektivHostConnection,
prompts: Arc<arc_swap::ArcSwap<crate::prompt::catalog::PromptCatalog>>, prompts: Arc<arc_swap::ArcSwap<crate::prompt::catalog::PromptCatalog>>,
} }
impl JournalStartupContextSource { impl JournalContextSource {
pub(crate) fn new( pub(crate) fn new(
host: SubjektivHostConnection, host: SubjektivHostConnection,
prompts: Arc<arc_swap::ArcSwap<crate::prompt::catalog::PromptCatalog>>, prompts: Arc<arc_swap::ArcSwap<crate::prompt::catalog::PromptCatalog>>,
@@ -46,9 +107,12 @@ impl JournalStartupContextSource {
} }
} }
#[async_trait] #[async_trait]
impl crate::worker::InitialContextSource for JournalStartupContextSource { impl crate::worker::SessionContextSource for JournalContextSource {
async fn load(&self) -> Result<Vec<session_store::LoggedSystemHistoryEntry>, String> { async fn load(&self) -> Result<Vec<session_store::LoggedSystemHistoryEntry>, String> {
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
if !self.host.host.journal_startup_granted() {
return Ok(Vec::new());
}
let snapshot = self let snapshot = self
.host .host
.journal_startup_context() .journal_startup_context()
@@ -58,8 +122,8 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource {
let empty = snapshot.journals.is_empty() && !snapshot.omitted; let empty = snapshot.journals.is_empty() && !snapshot.omitted;
// Hash the same canonical JSON value retained by SystemItem, so replay // Hash the same canonical JSON value retained by SystemItem, so replay
// can verify provenance without depending on domain struct field order. // 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_value = 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_json = serde_json::to_string(&snapshot_value).map_err(|e| e.to_string())?;
if snapshot_json.len() > subjektiv::journal::startup::STARTUP_MAX_BYTES { if snapshot_json.len() > subjektiv::journal::startup::STARTUP_MAX_BYTES {
return Err("Host Journal startup snapshot exceeds independent byte budget".into()); 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()); return Err("Journal startup prompt exceeds rendered byte budget".into());
} }
let projection = prompts.projection(); let projection = prompts.projection();
Ok(vec![session_store::LoggedSystemHistoryEntry { let payload = session_store::FeatureInputPayload::encode(
item: session_store::SystemItem::JournalStartup { "journal:startup",
body, 1,
&JournalStartupInput {
snapshot, snapshot,
snapshot_sha256: Sha256::digest(snapshot_json.as_bytes()) snapshot_sha256: Sha256::digest(snapshot_json.as_bytes())
.iter() .iter()
@@ -89,11 +154,38 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource {
logical_name: "internal.subjektiv_journal_startup".into(), logical_name: "internal.subjektiv_journal_startup".into(),
}, },
}, },
metadata: crate::session_history::metadata( )
session_store::LoggedSessionHistoryOrigin::JournalContext, .map_err(|e| e.to_string())?;
Ok(vec![session_store::LoggedSystemHistoryEntry::from_input(
session_store::LoggedSessionHistoryOrigin::Provided {
producer: "subjektiv.journal".into(),
},
None, None,
), journal_input(body, payload, "journal_startup")?,
}]) )])
}
async fn load_compaction_context(
&self,
session: session_store::SessionId,
) -> Result<Vec<session_store::LoggedSystemHistoryEntry>, 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( pub(crate) fn freeze_compaction_journal(
connection: std::io::Result<Option<SubjektivHostConnection>>, connection: std::io::Result<Option<SubjektivHostConnection>>,
prompts: &crate::prompt::catalog::PromptCatalog, prompts: &crate::prompt::catalog::PromptCatalog,
) -> Result<String, crate::prompt::catalog::CatalogError> { ) -> Result<session_store::SystemInputEnvelope, String> {
use JournalReadStatus::*;
let workspace_id = connection.as_ref().ok().and_then(|connection| { let workspace_id = connection.as_ref().ok().and_then(|connection| {
connection connection
.as_ref() .as_ref()
@@ -116,24 +209,25 @@ pub(crate) fn freeze_compaction_journal(
Ok(_) => None, Ok(_) => None,
Err(_) => Some(Err(())), Err(_) => Some(Err(())),
}; };
let snapshot = read.as_ref().and_then(|read| read.as_ref().ok()).cloned();
let (status, json) = match read { let (status, json) = match read {
None => ("disabled", String::new()), None => (Disabled, String::new()),
Some(Ok(snapshot)) => { Some(Ok(snapshot)) => {
let status = if snapshot.journals.is_empty() { let status = if snapshot.journals.is_empty() {
"absent" Absent
} else { } else {
"available" Available
}; };
match serde_json::to_string(&snapshot) { match serde_json::to_string(&snapshot) {
Ok(json) if json.len() <= subjektiv::journal::startup::STARTUP_MAX_BYTES => { Ok(json) if json.len() <= subjektiv::journal::startup::STARTUP_MAX_BYTES => {
(status, json) (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"); tracing::warn!("Journal compaction snapshot unavailable; continuing without Journal");
} }
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -143,12 +237,35 @@ pub(crate) fn freeze_compaction_journal(
.map(|byte| format!("{byte:02x}")) .map(|byte| format!("{byte:02x}"))
.collect() .collect()
}); });
prompts.subjektiv_journal_compaction( let body = prompts
status, .subjektiv_journal_compaction(
status.as_str(),
&json, &json,
snapshot_sha256.as_deref(), snapshot_sha256.as_deref(),
workspace_id.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)] #[derive(Clone)]
@@ -505,13 +505,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
} }
} }
async fn start_command( async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
&self,
mut request: CommandRequest,
) -> Result<CommandHandle, WorkdirError> {
// `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;
match self.operate(WorkdirSessionOperation::CommandStart(request))? { match self.operate(WorkdirSessionOperation::CommandStart(request))? {
WorkdirSessionOperationResult::CommandStart(result) => Ok(result), WorkdirSessionOperationResult::CommandStart(result) => Ok(result),
_ => Err(Self::mismatch("command_start")), _ => Err(Self::mismatch("command_start")),
@@ -1660,9 +1654,9 @@ mod tests {
.start_command(CommandRequest { .start_command(CommandRequest {
command: "true".to_string(), command: "true".to_string(),
timeout_secs: 120, timeout_secs: 120,
output_limit: 1024,
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
cwd: workdir::WorkdirPath::root(), cwd: workdir::WorkdirPath::root(),
spill_dir: Some("/worker-local/bash-output".into()),
tool_call_id: Some("call-1".to_string()), tool_call_id: Some("call-1".to_string()),
}) })
.await .await
@@ -1675,7 +1669,7 @@ mod tests {
assert_eq!(body["operation"]["operation"], "command_start"); assert_eq!(body["operation"]["operation"], "command_start");
assert_eq!(body["operation"]["request"]["command"], "true"); assert_eq!(body["operation"]["request"]["command"], "true");
assert_eq!(body["operation"]["request"]["cwd"], ""); 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_eq!(body["operation"]["request"]["tool_call_id"], "call-1");
assert!( assert!(
!requests[0] !requests[0]
@@ -1942,9 +1936,9 @@ mod tests {
.start_command(CommandRequest { .start_command(CommandRequest {
command: "printf done".to_string(), command: "printf done".to_string(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024,
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
cwd: workdir::WorkdirPath::root(), cwd: workdir::WorkdirPath::root(),
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}) })
.await .await
@@ -6,7 +6,6 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta
use async_trait::async_trait; use async_trait::async_trait;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid;
use protocol::Segment; use protocol::Segment;
use server_api::{ use server_api::{
@@ -502,7 +501,10 @@ pub struct WorkerLifecycleSpawnRequest {
pub profile: String, pub profile: String,
pub singleton_key: Option<String>, pub singleton_key: Option<String>,
pub ticket_id: Option<String>, pub ticket_id: Option<String>,
/// Required for ordinary and Ticket spawn; exact retries reuse this ID and full input.
pub operation_id: Option<String>, pub operation_id: Option<String>,
/// Explicit requested authority. Profile selection alone grants nothing.
pub execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
pub display_name: String, pub display_name: String,
pub initial_submit: Vec<Segment>, pub initial_submit: Vec<Segment>,
} }
@@ -514,25 +516,27 @@ struct WorkspaceWorkerLifecycleService {
fn workspace_worker_create_request( fn workspace_worker_create_request(
request: WorkerLifecycleSpawnRequest, request: WorkerLifecycleSpawnRequest,
) -> Result<CreateWorkspaceWorkerRequest, WorkspaceClientError> { ) -> Result<CreateWorkspaceWorkerRequest, WorkspaceClientError> {
let ticket_assignment = match (request.ticket_id, request.operation_id) { let control_operation_id = request.operation_id.filter(|id| {
(Some(ticket_id), Some(operation_id)) => { !id.trim().is_empty() && id.len() <= 256 && !id.chars().any(char::is_control)
Some(CreateWorkspaceWorkerTicketAssignmentRequest { }).ok_or_else(|| WorkspaceClientError::Request(
orchestrator_assignment_id: request.orchestrator_assignment_id, "operation_id is required for every Worker spawn (reuse the same ID and full input after an unknown outcome)".into()
ticket_id, ))?;
operation_id, if request.ticket_id.is_none() && request.orchestrator_assignment_id.is_some() {
})
}
(None, None) => None,
_ => {
return Err(WorkspaceClientError::Request( return Err(WorkspaceClientError::Request(
"ticket_id and operation_id must be provided together".to_string(), "orchestrator_assignment_id requires ticket_id".into(),
)); ));
} }
}; if let Some(grant) = &request.execution_grant {
let control_operation_id = ticket_assignment grant.validate().map_err(WorkspaceClientError::Request)?;
.as_ref() }
.map(|assignment| assignment.operation_id.clone()) let ticket_assignment =
.unwrap_or_else(|| format!("worker-spawn-{}", Uuid::now_v7())); request
.ticket_id
.map(|ticket_id| CreateWorkspaceWorkerTicketAssignmentRequest {
orchestrator_assignment_id: request.orchestrator_assignment_id,
ticket_id,
operation_id: control_operation_id.clone(),
});
Ok(CreateWorkspaceWorkerRequest { Ok(CreateWorkspaceWorkerRequest {
runtime_id: request.runtime_id, runtime_id: request.runtime_id,
display_name: request.display_name, display_name: request.display_name,
@@ -551,6 +555,7 @@ fn workspace_worker_create_request(
.collect(), .collect(),
feature_connections: Default::default(), feature_connections: Default::default(),
control_operation_id: Some(control_operation_id), control_operation_id: Some(control_operation_id),
execution_grant: request.execution_grant,
}) })
} }
@@ -744,7 +749,13 @@ struct WorkerSpawnInput {
runtime_id: String, runtime_id: String,
/// Singular Workdir selection wrapped into the shared attachment collection /// Singular Workdir selection wrapped into the shared attachment collection
/// under the stable `workdir` alias. /// under the stable `workdir` alias.
working_directory_id: String, #[serde(default)]
working_directory_id: Option<String>,
/// Stable caller-chosen intent ID. Exact retries retain this ID and full input.
operation_id: String,
/// Explicit permission/resource request; selecting a Profile grants nothing.
#[serde(default)]
execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
profile: String, profile: String,
/// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted. /// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted.
#[serde(default)] #[serde(default)]
@@ -931,13 +942,23 @@ impl Tool for WorkspaceWorkerTool {
.ticket_id .ticket_id
.map(|ticket_id| authority_id(&ticket_id, "ticket_id")) .map(|ticket_id| authority_id(&ticket_id, "ticket_id"))
.transpose()?; .transpose()?;
let operation_id = ticket_id let operation_id = Some(authority_id(&input.operation_id, "operation_id")?);
.as_ref() let workdir_attachments = match input.working_directory_id {
.map(|ticket_id| { Some(id) => vec![WorkerLifecycleWorkdirAttachment {
let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?; alias: INITIAL_WORKDIR_ALIAS.to_string(),
Ok::<_, ToolError>(format!("worker-spawn:{ticket_id}:{call_id}")) working_directory_id: authority_id(&id, "working_directory_id")?,
}) relative_cwd: input
.transpose()?; .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 self.control
.spawn_worker(WorkerLifecycleSpawnRequest { .spawn_worker(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: input orchestrator_assignment_id: input
@@ -945,17 +966,8 @@ impl Tool for WorkspaceWorkerTool {
.map(|id| authority_id(&id, "orchestrator_assignment_id")) .map(|id| authority_id(&id, "orchestrator_assignment_id"))
.transpose()?, .transpose()?,
runtime_id: authority_id(&input.runtime_id, "runtime_id")?, runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
workdir_attachments: vec![WorkerLifecycleWorkdirAttachment { workdir_attachments,
alias: INITIAL_WORKDIR_ALIAS.to_string(), execution_grant: input.execution_grant,
working_directory_id: authority_id(
&input.working_directory_id,
"working_directory_id",
)?,
relative_cwd: input
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
}],
profile: non_empty(input.profile, "profile")?, profile: non_empty(input.profile, "profile")?,
singleton_key: input.singleton_key, singleton_key: input.singleton_key,
ticket_id, ticket_id,
@@ -1594,6 +1606,7 @@ mod tests {
"relative_cwd": "crates/worker", "relative_cwd": "crates/worker",
"profile": "builtin:coder", "profile": "builtin:coder",
"ticket_id": "00001KZ9E0DBS", "ticket_id": "00001KZ9E0DBS",
"operation_id": "stable-ticket-intent",
"initial_submit": [ "initial_submit": [
{ "kind": "flow", "selector": "builtin:coder-review" }, { "kind": "flow", "selector": "builtin:coder-review" },
{ "kind": "text", "content": "Implement Ticket 00001" } { "kind": "text", "content": "Implement Ticket 00001" }
@@ -1623,7 +1636,7 @@ mod tests {
body["ticket_assignment"], body["ticket_assignment"],
serde_json::json!({ serde_json::json!({
"ticket_id": "00001KZ9E0DBS", "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); 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] #[test]
fn worker_spawn_schema_exposes_normal_typed_segment_variants() { fn worker_spawn_schema_exposes_normal_typed_segment_variants() {
let schema = serde_json::to_value(schemars::schema_for!(WorkerSpawnInput)).unwrap(); let schema = serde_json::to_value(schemars::schema_for!(WorkerSpawnInput)).unwrap();
let text = serde_json::to_string(&schema).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("initial_submit"));
assert!(text.contains("ticket_id")); assert!(text.contains("ticket_id"));
assert!(text.contains("selector")); assert!(text.contains("selector"));
@@ -1941,7 +2024,8 @@ mod tests {
profile: "builtin:coder".to_string(), profile: "builtin:coder".to_string(),
singleton_key: Some("subjektiv:subject-1".to_string()), singleton_key: Some("subjektiv:subject-1".to_string()),
ticket_id: None, ticket_id: None,
operation_id: None, operation_id: Some("ordinary-spawn-intent-1".into()),
execution_grant: None,
display_name: "Coder".to_string(), display_name: "Coder".to_string(),
initial_submit: vec![ initial_submit: vec![
Segment::Flow { Segment::Flow {
@@ -1991,6 +2075,7 @@ mod tests {
singleton_key: None, singleton_key: None,
ticket_id: Some("T-719".to_string()), ticket_id: Some("T-719".to_string()),
operation_id: Some("spawn-ticket-worker:T-719:call-1".to_string()), operation_id: Some("spawn-ticket-worker:T-719:call-1".to_string()),
execution_grant: None,
display_name: "Worker · T-719".to_string(), display_name: "Worker · T-719".to_string(),
initial_submit: vec![Segment::text("Investigate Ticket T-719.")], initial_submit: vec![Segment::text("Investigate Ticket T-719.")],
}) })
+194 -45
View File
@@ -43,6 +43,80 @@ use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, 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<session_store::PromptRenderProvenance>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct ResidentSummaryInput {
prompt_provenance: Option<session_store::PromptRenderProvenance>,
}
fn resident_input(
body: String,
schema: &str,
payload: &impl serde::Serialize,
kind: &str,
) -> Result<session_store::LoggedSystemHistoryEntry, session_store::InputMetadataError> {
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<session_store::PromptRenderProvenance>,
) -> Result<session_store::LoggedSystemHistoryEntry, session_store::InputMetadataError> {
resident_input(
body,
"subject:behavior",
&SubjectBehaviorInput {
behavior_md,
prompt_provenance,
},
"subject_behavior_refresh",
)
}
pub(crate) fn resident_summary_input(
body: String,
prompt_provenance: Option<session_store::PromptRenderProvenance>,
) -> Result<session_store::LoggedSystemHistoryEntry, session_store::InputMetadataError> {
resident_input(
body,
"memory:resident-summary",
&ResidentSummaryInput { prompt_provenance },
"resident_summary_refresh",
)
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct WorkspaceHttpMemoryBackend { pub struct WorkspaceHttpMemoryBackend {
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
@@ -651,7 +725,7 @@ struct SubjectBehaviorRefreshState {
struct WorkspaceSubjektivResidentSummarySource { struct WorkspaceSubjektivResidentSummarySource {
host: SubjektivHostConnection, host: SubjektivHostConnection,
prompts: Arc<ArcSwap<PromptCatalog>>, prompts: Arc<ArcSwap<PromptCatalog>>,
behavior_state: Mutex<SubjectBehaviorRefreshState>, behavior_state: Arc<Mutex<SubjectBehaviorRefreshState>>,
} }
fn resident_summary_contribution( fn resident_summary_contribution(
@@ -691,6 +765,13 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
} }
impl WorkspaceSubjektivResidentSummarySource { 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( async fn fetch(
&self, &self,
) -> Result<server_api::SubjektivResidentContextOutput, SubjektivHostError> { ) -> Result<server_api::SubjektivResidentContextOutput, SubjektivHostError> {
@@ -707,7 +788,7 @@ impl WorkspaceSubjektivResidentSummarySource {
fn render( fn render(
&self, &self,
output: &server_api::SubjektivResidentContextOutput, output: &server_api::SubjektivResidentContextOutput,
) -> Result<String, String> { ) -> Result<(String, session_store::PromptRenderProvenance), String> {
use memory::backend::MemoryResidentSummaryAvailability as Availability; use memory::backend::MemoryResidentSummaryAvailability as Availability;
let availability = match output.memory_surface.availability { let availability = match output.memory_surface.availability {
Availability::Ready => "ready", Availability::Ready => "ready",
@@ -716,53 +797,68 @@ impl WorkspaceSubjektivResidentSummarySource {
Availability::Failed => "failed", Availability::Failed => "failed",
Availability::Unavailable => "unavailable", Availability::Unavailable => "unavailable",
}; };
self.prompts let prompts = self.prompts.load_full();
.load() let body = prompts
.subjektiv_resident_context( .subjektiv_resident_context(
&output.behavior_md, &output.behavior_md,
availability, availability,
output.memory_surface.content.as_deref(), output.memory_surface.content.as_deref(),
) )
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())?;
} let projection = prompts.projection();
Ok((
fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) { body,
let mut state = self session_store::PromptRenderProvenance {
.behavior_state workspace_id: self.host.host.workspace_id(),
.lock() source_digest: projection.source_digest.clone(),
.expect("subject behavior state poisoned"); projection_digest: projection.catalog_digest.clone(),
state.applied = Some(output.behavior_md.clone()); logical_name: "internal.subjektiv_resident_context".into(),
},
))
} }
} }
/// Detect behavior changes against the state reflected in history, not the fetched state. /// 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. /// Fetch/render never advances applied state. Initial preparation captures a
/// The interceptor confirms after durably appending a system item, allowing refetch if append fails. /// source-local completion, invoked only after its prompt and active pointer commit.
/// Invalidate applied before rewrite so even identical bodies can be presented in the new history. /// Later refreshes are acknowledged by the interceptor after durable append.
/// Invalidate applied before rewrite so identical bodies can be represented again.
#[async_trait] #[async_trait]
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource { impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
async fn load(&self) -> SystemPromptContribution { async fn load(&self) -> SystemPromptContribution {
self.load_initial().await.0
}
async fn load_initial(
&self,
) -> (
SystemPromptContribution,
Option<crate::worker::PromptContributionCompletion>,
) {
match self.fetch().await { match self.fetch().await {
Ok(output) => match self.render(&output) { Ok(output) => match self.render(&output) {
Ok(rendered) => { Ok((rendered, _)) => {
self.remember_behavior(&output); let state = self.behavior_state.clone();
SystemPromptContribution::Ready(rendered) let behavior = output.behavior_md;
(
SystemPromptContribution::Ready(rendered),
Some(Box::new(move || {
state
.lock()
.expect("subject behavior state poisoned")
.applied = Some(behavior);
})),
)
} }
Err(error) => { Err(error) => {
tracing::error!(%error, "subject resident context render failed"); tracing::error!(%error, "subject resident context render failed");
SystemPromptContribution::Ready( (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)
"The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
} }
}, },
Err(error) => { Err(error) => {
tracing::error!(%error, "subject resident context fetch failed"); tracing::error!(%error, "subject resident context fetch failed");
SystemPromptContribution::Ready( (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)
"The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
} }
} }
} }
@@ -781,18 +877,31 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource
if !changed { if !changed {
return Ok(None); return Ok(None);
} }
let rendered = self.render(&output)?; let (rendered, prompt_provenance) = self.render(&output)?;
Ok(Some(ResidentContextRefresh { Ok(Some(ResidentContextRefresh {
body: rendered, body: rendered,
behavior_md: output.behavior_md, behavior_md: output.behavior_md,
prompt_provenance: Some(prompt_provenance),
})) }))
} }
fn confirm_resident_context_behavior(&self, behavior_md: &str) { fn system_item_committed(
self.behavior_state &self,
.lock() _item: &session_store::SystemItem,
.expect("subject behavior state poisoned") metadata: &crate::session_history::SessionHistoryMetadata,
.applied = Some(behavior_md.to_owned()); ) {
if let Some(input) = metadata
.input_metadata
.as_ref()
.and_then(|input| input.feature_payload())
.and_then(|payload| {
payload
.decode::<SubjectBehaviorInput>("subject:behavior", 1)
.ok()
})
{
self.confirm_resident_context_behavior(&input.behavior_md);
}
} }
fn invalidate_resident_context_representation(&self) { fn invalidate_resident_context_representation(&self) {
@@ -944,7 +1053,13 @@ impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
"Subject behavior refresh requires durable session append authority", "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) Ok(HookPreRequestAction::Continue)
} }
@@ -969,16 +1084,18 @@ impl Hook<WorkerRestored> for SubjektivResidentRestoreRefreshHook {
.resident_memory_restore_section(surface_ready, summary) .resident_memory_restore_section(surface_ready, summary)
.map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?; .map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?;
let projection = prompts.projection(); let projection = prompts.projection();
context.system_items().append_resident_summary_refresh( let input = resident_summary_input(
body, body,
session_store::PromptRenderProvenance { Some(session_store::PromptRenderProvenance {
workspace_id: self.workspace_id.clone(), workspace_id: self.workspace_id.clone(),
source_digest: projection.source_digest.clone(), source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(), projection_digest: projection.catalog_digest.clone(),
logical_name: WorkerPrompt::ResidentMemoryRestoreSection.key().to_string(), logical_name: WorkerPrompt::ResidentMemoryRestoreSection.key().to_string(),
}, }),
); )
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
context.system_items().append_input(input);
Ok(()) Ok(())
} }
} }
@@ -1106,7 +1223,7 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource { Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
host: host.expect("checked explicit connection"), host: host.expect("checked explicit connection"),
prompts, prompts,
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()), behavior_state: Arc::new(Mutex::new(SubjectBehaviorRefreshState::default())),
}))) })))
} }
@@ -1723,8 +1840,16 @@ permission = "write"
.unwrap() .unwrap()
.unwrap(); .unwrap();
let initial = source.load().await; let (initial, completion) = source.load_initial().await;
assert!(matches!(initial, SystemPromptContribution::Ready(_))); assert!(matches!(initial, SystemPromptContribution::Ready(_)));
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_some()
);
completion.expect("successful initial contribution must provide completion")();
assert!( assert!(
source source
.load_changed_resident_context() .load_changed_resident_context()
@@ -1741,7 +1866,13 @@ permission = "write"
.expect("changed behavior must produce one refresh"); .expect("changed behavior must produce one refresh");
assert_eq!(refresh.behavior_md, "Ask when uncertain."); assert_eq!(refresh.behavior_md, "Ask when uncertain.");
assert!(refresh.body.contains("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!( assert!(
source source
.load_changed_resident_context() .load_changed_resident_context()
@@ -1758,7 +1889,13 @@ permission = "write"
.expect("cleared behavior must produce one refresh"); .expect("cleared behavior must produce one refresh");
assert_eq!(cleared.behavior_md, ""); assert_eq!(cleared.behavior_md, "");
assert!(cleared.body.contains("No user-managed behavior is set")); 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!( assert!(
source source
.load_changed_resident_context() .load_changed_resident_context()
@@ -1783,7 +1920,7 @@ permission = "write"
// target-side bounded resident request contract. // target-side bounded resident request contract.
assert_eq!( assert_eq!(
client.timeouts.lock().unwrap().as_slice(), client.timeouts.lock().unwrap().as_slice(),
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7] &[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 8]
); );
} }
@@ -1816,7 +1953,13 @@ permission = "write"
.await .await
.unwrap() .unwrap()
.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!( assert_eq!(
source source
.load_changed_resident_context() .load_changed_resident_context()
@@ -1825,7 +1968,13 @@ permission = "write"
.unwrap(), .unwrap(),
second 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!( assert!(
source source
.load_changed_resident_context() .load_changed_resident_context()

Some files were not shown because too many files have changed in this diff Show More