fix: adapt T-769 recovery onto current restore lane checkpoint

UNCOMPILED TRANSPORT / PRESERVATION CHECKPOINT, NOT A VALIDATED CANDIDATE. One-time recovery authorization permits publication before validation so the parent can validate this exact source in a separate Workdir. Prior compilation attempts were interrupted by Workdir-session unavailability; cause and test outcome remain unknown. No Cargo build/check/test was started in this bounded checkpoint turn.

Merge current lane 4ef7b03651 into existing source b7a22146a0. Selectively adapt typed factory failure and same-operation cleanup/replay while preserving current saved Feature restore and operation-time authorization, logical/deferred Workdirs, T765 Host/schema and T768 replay-safe Subject grant. No T770/T771 implementation.

All 19 target-relative paths and added test bodies were source-audited. Workspace fmt check, scoped rustfmt, whitespace/diff checks passed; conflict markers zero. Excluded unrelated formatter changes and untracked compiler artifacts. Local audit: target/t769-checkpoint/AUDIT.md.

Handoff to parent: compile and run focused and package/API regressions on this exact source, fix as necessary, then obtain fresh independent review. Existing MR 01a15606-a6ba-7d23-8d60-0874607399e7 only; no new review requested here. No acceptance, approval, adoption, target/develop push, integration, Ticket closure or service update is asserted.
This commit is contained in:
2026-10-21 00:20:54 +09:00
238 changed files with 29879 additions and 5011 deletions
+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 runtime_tmp = tempfile::tempdir()?;
let bash_output_dir = runtime_tmp.path().join("bash-output");
let (handle, _shutdown_rx) =
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir).await?;
let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?;
// Check initial status via shared state
println!("[shared_state] {}", handle.shared_state.status_json());
File diff suppressed because it is too large Load Diff
+13 -59
View File
@@ -17,28 +17,9 @@ use manifest::WorkerManifest;
#[derive(Debug, Clone)]
pub enum WorkerBootstrapLayout {
/// A direct Worker rooted below the supplied runtime base directory.
Direct {
runtime_base: PathBuf,
bash_output_dir: PathBuf,
},
Direct { runtime_base: PathBuf },
/// A runtime-managed Worker with an exact persisted run directory.
RuntimeManagedRun {
run_dir: PathBuf,
bash_output_dir: PathBuf,
},
}
/// Return the temporary Bash spill directory owned by a stable Worker identity.
///
/// The directory deliberately lives outside session/run-generation storage so a
/// restarted controller for the same Worker keeps the same readable artifact
/// boundary.
pub fn bash_output_dir_for_worker_id(worker_id: impl std::fmt::Display) -> PathBuf {
std::env::temp_dir()
.join("yoi")
.join("workers")
.join(worker_id.to_string())
.join("bash-output")
RuntimeManagedRun { run_dir: PathBuf },
}
/// Construction and controller inputs that are stable for one Worker launch.
@@ -145,6 +126,13 @@ where
pub async fn prepare(
self,
) -> Result<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(
self.manifest,
self.store,
@@ -273,27 +261,12 @@ where
let cleanup_sessions = worker.workdir_sessions();
let factory_owned_cleanup = worker.startup_feature_cleanup_handle().is_some();
let controller = match layout {
WorkerBootstrapLayout::Direct {
runtime_base,
bash_output_dir,
} => {
WorkerController::spawn_with_transport_owned(
worker,
&runtime_base,
&bash_output_dir,
transport,
)
.await
WorkerBootstrapLayout::Direct { runtime_base } => {
WorkerController::spawn_with_transport_owned(worker, &runtime_base, transport).await
}
WorkerBootstrapLayout::RuntimeManagedRun {
run_dir,
bash_output_dir,
} => {
WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => {
WorkerController::spawn_runtime_managed_run_with_transport_owned(
worker,
&run_dir,
&bash_output_dir,
transport,
worker, &run_dir, transport,
)
.await
}
@@ -317,22 +290,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
//! content; Inspect checks selected coordinates.
//!
//! Interfaces/validators bind the projection incarnation, attachment generation, and provider state
//! Object validators bind the projection incarnation, attachment generation, and provider state
//! so alias reuse after restore or detach cannot transfer old observations to a new connection.
//! Publication also checks the generation recorded at acquisition. Possessing an Interface grants
//! no authority: route-reconstructed permission inputs and current provider capability/scope are
@@ -28,9 +28,8 @@ use manifest::{ToolPermissionAction, ToolPermissionConfig};
use serde_json::{Value as Json, json};
use sha2::{Digest, Sha256};
use wip_protocol::{
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
ReturnDeclaration, TypeExpr, Value,
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
};
use workdir::{
CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap,
@@ -117,13 +116,23 @@ impl Provider {
}
Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?))
}
fn wrap_validator(&self, alias: &str, generation: u64, workdir: &str, raw: &[u8]) -> Vec<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();
for value in [
self.incarnation.as_bytes(),
alias.as_bytes(),
&generation.to_be_bytes(),
workdir.as_bytes(),
target.as_str().as_bytes(),
format!("{capabilities:?}").as_bytes(),
raw,
] {
hash.update(value.len().to_be_bytes());
@@ -236,45 +245,17 @@ impl Provider {
return Ok(None);
}
let workdir = selected.session.workdir().id().as_str().to_owned();
let root = checkout_root(&alias);
// The router guard pins this connection across both observations. Root
// lifetime is attachment lifetime, not directory-content validator state.
let scope = if target.is_root() {
None
} else {
let root_observation =
match selected.session.checkout_observe(WorkdirPath::root()).await {
Ok(o) => o,
Err(_) => return Ok(None),
};
self.entry_projection(
&root,
&alias,
selected.generation,
&workdir,
WorkdirPath::root(),
root_observation,
)
};
let Some(projection) = self.entry_projection(
let projection = self.entry_projection(
path,
&alias,
selected.generation,
&workdir,
target,
observation,
) else {
return Ok(None);
};
if path == root {
return WipPublication::self_scoped(projection).map(Some);
}
let Some(scope) = scope else {
return Ok(None);
};
Ok(Some(WipPublication {
);
Ok(projection.map(|projection| WipPublication {
projection,
scope: Some(scope.object),
scope: None,
}))
}
fn entry_projection(
@@ -286,16 +267,44 @@ impl Provider {
target: WorkdirPath,
observation: CheckoutObservation,
) -> Option<WipProjection> {
let ops = self.operations(&observation);
if ops.is_empty() {
return None;
}
use crate::file_operation::Contract as C;
let has = |cap, name| observation.capabilities.supports(cap) && self.may_allow(name);
let mut contracts = Vec::new();
let kind = if observation.kind == EntryKind::File {
if has(Cap::Read, "Read") {
contracts.extend([C::ReadLines, C::SearchText, C::ReadBytes]);
}
if has(Cap::Edit, "Edit") {
contracts.push(C::Edit);
}
if has(Cap::Write, "Write") {
contracts.push(C::Write);
}
"file"
} else {
if has(Cap::Read, "List") {
contracts.push(C::List);
}
if has(Cap::Glob, "Glob") {
contracts.push(C::Glob);
}
if has(Cap::Grep, "Grep") {
contracts.push(C::Grep);
}
if has(Cap::Write, "Write") {
contracts.push(C::CreateFile);
}
"directory"
};
let validator = self.wrap_validator(alias, generation, workdir, &observation.validator);
let primary = *contracts.first()?;
let validator = self.wrap_validator(
alias,
generation,
workdir,
&target,
observation.capabilities,
&observation.validator,
);
let handler = Arc::new(FileHandler {
provider: self.clone(),
alias: alias.into(),
@@ -304,86 +313,17 @@ impl Provider {
target,
observation,
});
let family = format!("{kind}-g{generation}");
let mut projection = self.project(path, descriptor(ops), validator, &family, handler);
// Keep path-qualified local names (permissions/capabilities may differ
// per target) while binding every content Interface to its live entrance.
projection.interface.scope = checkout_root(alias);
projection.interface.name.push_str("#target/");
projection.interface.name.push_str(&encode_identity(path));
projection.object.interfaces = vec![projection.interface.clone()];
let mut projection = self.project(path, primary.descriptor(), validator, kind, handler);
projection.interface = primary.reference();
projection.interface_validator = Some(primary.validator());
projection.object.interfaces = Vec::new();
crate::file_operation::expose(&mut projection, contracts);
Some(projection)
}
fn operations(&self, o: &CheckoutObservation) -> Vec<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]
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> {
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
.await
@@ -588,8 +528,14 @@ impl FileHandler {
if let Some(raw) = result.validator {
Ok(WipOperationOutput::native_with_validator(
value,
self.provider
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
self.provider.wrap_validator(
&self.alias,
self.generation,
&self.workdir,
&self.target,
self.observation.capabilities,
&raw,
),
))
} else {
Ok(WipOperationOutput::native(value))
@@ -604,6 +550,127 @@ impl WipOperationHandler for FileHandler {
arguments: &BTreeMap<String, Value>,
context: WipCallContext,
) -> 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 {
"list" => "List",
"read" => "Read",
@@ -752,14 +819,6 @@ fn join_under(parent: &WorkdirPath, relative: &str) -> Result<String, WipOperati
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(
name: &str,
summary: &str,
@@ -774,53 +833,10 @@ fn operation(
parameters,
returns: ReturnDeclaration {
documentation: None,
r#type: result_type(name == "list"),
r#type: TypeExpr::Json,
},
}
}
fn field(name: &str, required: bool, r#type: TypeExpr) -> FieldDeclaration {
FieldDeclaration {
name: name.into(),
required,
documentation: None,
r#type,
}
}
fn cursor_type() -> TypeExpr {
TypeExpr::Record {
fields: vec![
field("kind", true, TypeExpr::String),
field("entry", true, TypeExpr::Entry),
],
}
}
fn result_type(list: bool) -> TypeExpr {
let mut item = vec![field("entry", true, TypeExpr::Entry)];
if list {
item.extend([
field("kind", true, TypeExpr::String),
field("size", true, TypeExpr::Integer),
]);
}
let mut fields = vec![
field("summary", true, TypeExpr::String),
field("content", false, TypeExpr::String),
field(
"items",
true,
TypeExpr::List {
items: Box::new(TypeExpr::Record { fields: item }),
},
),
];
if list {
fields.extend([
field("truncated", true, TypeExpr::Boolean),
field("after", false, cursor_type()),
]);
}
TypeExpr::Record { fields }
}
fn kind_name(kind: EntryKind) -> &'static str {
match kind {
EntryKind::Directory => "directory",
@@ -841,19 +857,6 @@ fn cursor_value(alias: &str, cursor: &workdir::ListCursor) -> Value {
fn descriptor(operations: Vec<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}
}
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 {
ProtocolError {
code,
+208 -2
View File
@@ -31,11 +31,18 @@ enum WriteResponseFault {
LostBody,
}
#[derive(Clone, Copy)]
enum CommandResponseFault {
LostTerminal,
EmptyWaiting,
}
struct ProviderState {
session: WorkdirSessionHandle,
requests: Mutex<Vec<(String, String)>>,
operations: Mutex<Vec<WorkdirSessionOperation>>,
write_fault: Mutex<Option<WriteResponseFault>>,
command_fault: Mutex<Option<CommandResponseFault>>,
observe_refusal: Mutex<bool>,
}
@@ -152,6 +159,21 @@ async fn operate(
source: Box::new(WorkdirError::OutOfScope("/private/provider/path".into())),
});
}
if let WorkdirSessionOperation::CommandOutput(output) = &request.operation {
if matches!(
*state.command_fault.lock().unwrap(),
Some(CommandResponseFault::EmptyWaiting)
) {
return AxumJson(workdir::http::WorkdirSessionOperationResult::CommandOutput(
workdir::CommandOutput {
chunks: vec![],
next_cursor: output.cursor,
terminal: None,
},
))
.into_response();
}
}
let result =
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
match result {
@@ -159,6 +181,15 @@ async fn operate(
Ok(result) => {
let fault = if is_write {
state.write_fault.lock().unwrap().take()
} else if matches!(&result, workdir::http::WorkdirSessionOperationResult::CommandOutput(output) if output.terminal.is_some())
{
let mut command_fault = state.command_fault.lock().unwrap();
if matches!(*command_fault, Some(CommandResponseFault::LostTerminal)) {
command_fault.take();
Some(WriteResponseFault::LostBody)
} else {
None
}
} else {
None
};
@@ -237,6 +268,7 @@ impl HttpFixture {
requests: Mutex::new(Vec::new()),
operations: Mutex::new(Vec::new()),
write_fault: Mutex::new(None),
command_fault: Mutex::new(None),
observe_refusal: Mutex::new(false),
});
let app = Router::new()
@@ -358,7 +390,8 @@ async fn observe_interface(runtime: &WipRuntime, path: &str) -> wip_protocol::In
}
async fn native_call(runtime: &WipRuntime, path: &str, operation: &str, args: Json) -> Value {
let interface = observe_interface(runtime, path).await;
observe_interface(runtime, path).await;
let interface = super::checkout_wip_tests::contract(operation).reference();
runtime
.call(
path.into(),
@@ -620,7 +653,8 @@ async fn committed_write_unknown(fault: WriteResponseFault) {
let (runtime, router) = native_runtime(remote);
let file = "/checkouts/main/src/deep/a.txt";
native_call(&runtime, file, "read", json!({})).await;
let interface = observe_interface(&runtime, file).await;
observe_interface(&runtime, file).await;
let interface = super::checkout_wip_tests::contract("write").reference();
*fixture.state.write_fault.lock().unwrap() = Some(fault);
let error = runtime
.call(
@@ -798,3 +832,175 @@ async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_wit
"refusal was retried"
);
}
// Full collection/projection integration: production HTTP client, local process pipes,
// Bash collector/store, WIP codec and common file dispatch. Only HTTP response loss is injected.
#[tokio::test]
async fn remote_native_bash_retains_streams_quota_and_replays_lost_terminal_through_common_files() {
watchdog(async {
let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await;
let remote = Arc::new(f.open(TOKEN).await.unwrap());
let router = Arc::new(WorkdirSessionRouter::new());
router.attach(WorkdirAttachmentAlias::new("main").unwrap(), remote.clone()).unwrap();
let mut registry = WipMountRegistry::new();
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None).unwrap();
let view = tools::bash_output::BashOutputView::new();
crate::feature::builtin::bash::mount(&mut registry, router.clone(), view, None).unwrap();
let runtime = WipRuntime::new(WipHost::new(registry), SecurityContext::new("remote-bash"), 0).unwrap();
*f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::LostTerminal);
let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":r"printf 'first\nneedle\n'; printf '%200000s' ''; printf '\377€' >&2; exit 7","cwd":"."}}), Default::default()).await.unwrap();
f.public_output(run.content.as_ref().unwrap());
let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap();
assert_eq!(result["metadata"]["stdout_saved"], 200013);
assert_eq!(result["metadata"]["stderr_saved"], 4);
assert_eq!(result["metadata"]["terminal"]["exit_code"], 7);
assert_eq!(result["metadata"]["terminal"]["transport_complete"], true);
assert_eq!(result["metadata"]["storage_truncated"], false);
assert_eq!(result["preview_omitted"], true);
for name in ["stdout_entry", "stderr_entry", "order_entry"] {
runtime.inspect(result[name].as_str().unwrap().into(), false).await.unwrap();
}
let stdout = result["stdout_entry"].as_str().unwrap();
let search = runtime.invoke(stdout.into(), crate::file_operation::Contract::SearchText.reference(), "search".into(), json!({"pattern":"needle","limit":2}), Default::default()).await.unwrap();
assert!(search.content.unwrap().contains("needle"));
let stderr = runtime.invoke(result["stderr_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"limit":4}), Default::default()).await.unwrap();
let stderr: Json = serde_json::from_str(&stderr.content.unwrap()).unwrap();
use base64::Engine as _;
assert_eq!(base64::engine::general_purpose::STANDARD.decode(stderr["data"].as_str().unwrap()).unwrap(), [vec![255], "€".as_bytes().to_vec()].concat());
let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf '%4194303s' ''; printf '€'","cwd":"."}}), Default::default()).await.unwrap();
let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap();
assert_eq!(result["metadata"]["stdout_saved"], 4194304);
assert_eq!(result["metadata"]["stdout_seen"], 4194306);
assert_eq!(result["metadata"]["transport_truncation"], "byte_budget");
assert_eq!(result["metadata"]["storage_truncated"], true);
let tail = runtime.invoke(result["stdout_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"offset":4194303,"limit":4}), Default::default()).await.unwrap();
let tail: Json = serde_json::from_str(&tail.content.unwrap()).unwrap();
assert_eq!(base64::engine::general_purpose::STANDARD.decode(tail["data"].as_str().unwrap()).unwrap(), vec![0xe2]);
assert_eq!(tail["eof"], true);
assert_eq!(f.state.operations.lock().unwrap().iter().filter(|op| matches!(op, WorkdirSessionOperation::CommandStart(_))).count(), 2);
assert!(f.state.command_fault.lock().unwrap().is_none());
router.close_all().await.unwrap();
f.assert_endpoints();
}).await;
}
#[tokio::test]
async fn waiting_remote_command_rejects_empty_response_without_http_busy_loop() {
watchdog(async {
let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await;
let remote = f.open(TOKEN).await.unwrap();
*f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::EmptyWaiting);
let error = remote
.command_output(workdir::CommandOutputRequest {
handle: workdir::CommandHandle("unused".into()),
cursor: 0,
limit: 1024,
wait: true,
})
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::Unavailable(_)));
assert_eq!(
f.state
.operations
.lock()
.unwrap()
.iter()
.filter(|op| matches!(op, WorkdirSessionOperation::CommandOutput(_)))
.count(),
1
);
remote.close().await.unwrap();
})
.await;
}
#[tokio::test]
async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundaries() {
use base64::Engine as _;
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
let (runtime, _) = native_runtime(remote);
let path = "/checkouts/main/src/deep/a.txt";
observe_interface(&runtime, path).await;
let output = runtime
.invoke(
path.into(),
crate::file_operation::Contract::ReadBytes.reference(),
"read".into(),
json!({"offset":1,"limit":3}),
Default::default(),
)
.await
.unwrap();
let json: Json = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(json["encoding"], "base64");
assert_eq!(json["offset"], 1);
assert_eq!(json["next_offset"], 4);
let source = fixture
.state
.session
.read_bytes(workdir::ReadBytesRequest {
path: WorkdirPath::new("src/deep/a.txt").unwrap(),
offset: 1,
max_bytes: 3,
expected_hash: None,
})
.await
.unwrap();
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(json["data"].as_str().unwrap())
.unwrap(),
source.bytes
);
}
#[tokio::test]
async fn http_failed_text_read_does_not_authorize_mutations() {
for operation in ["write", "edit"] {
for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] {
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
let remote = fixture.open(TOKEN).await.unwrap();
checkout_wip_tests::failed_text_read_cannot_authorize_mutation(
Arc::new(remote),
&fixture.dir.path().join("text"),
&oversized,
operation,
)
.await;
let mutations = fixture
.state
.operations
.lock()
.unwrap()
.iter()
.filter(|op| {
matches!(
op,
WorkdirSessionOperation::CheckoutExecute(CheckoutRequest {
operation: CheckoutOperation::Write { .. }
| CheckoutOperation::Edit { .. },
..
})
)
})
.count();
assert_eq!(
mutations, 1,
"only the mutation after a successful Read may reach the provider"
);
}
}
}
#[tokio::test]
async fn http_long_utf8_search_refuses_incomplete_line_and_preserves_line_pagination() {
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
let remote = fixture.open(TOKEN).await.unwrap();
checkout_wip_tests::assert_long_utf8_search_refuses_incomplete_line(
Arc::new(remote),
fixture.dir.path(),
)
.await;
}
+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 route = object_path("main", &file.target);
let mut projection = p.node(&route).await.unwrap().unwrap();
let interface = projection.interface.clone();
let interface = match operation {
"write" => crate::file_operation::Contract::Write,
"edit" => crate::file_operation::Contract::Edit,
_ => crate::file_operation::Contract::CreateFile,
}
.reference();
projection.handler = Arc::new(DetachBeforePublication {
file,
calls: calls.clone(),
+729 -30
View File
@@ -41,13 +41,28 @@ async fn interface(r: &WipRuntime, path: &str) -> wip_protocol::InterfaceReferen
.unwrap();
p.interface
}
pub(super) fn contract(operation: &str) -> crate::file_operation::Contract {
use crate::file_operation::Contract as C;
match operation {
"read" => C::ReadLines,
"search" => C::SearchText,
"edit" => C::Edit,
"write" => C::Write,
"list" => C::List,
"glob" => C::Glob,
"grep" => C::Grep,
"create_file" => C::CreateFile,
_ => panic!("unknown file operation {operation}"),
}
}
async fn call(
r: &WipRuntime,
path: &str,
operation: &str,
args: Json,
) -> Result<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())
.await
}
@@ -336,12 +351,26 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
let old = r.host.projection_live(path).await.unwrap().unwrap();
router.detach(&alias).await.unwrap();
assert!(r.host.projection_live(path).await.unwrap().is_none());
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
assert_eq!(
r.host
.fetch_interface_live(&old.interface)
.await
.unwrap()
.validator,
old.interface_validator
);
router.attach(alias, session(&b, false)).unwrap();
let new = r.host.projection_live(path).await.unwrap().unwrap();
assert_ne!(old.interface, new.interface);
assert_eq!(old.interface, new.interface);
assert_ne!(old.object.validator, new.object.validator);
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
assert_eq!(
r.host
.fetch_interface_live(&old.interface)
.await
.unwrap()
.validator,
old.interface_validator
);
assert!(
call(
&r,
@@ -354,14 +383,16 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
);
let restored = runtime(router, None);
let next = restored.host.projection_live(path).await.unwrap().unwrap();
assert_ne!(new.interface, next.interface);
assert_eq!(new.interface, next.interface);
assert_ne!(new.object.validator, next.object.validator);
assert!(
assert_eq!(
restored
.host
.fetch_interface_live(&new.interface)
.await
.is_err()
.unwrap()
.validator,
new.interface_validator
);
assert_eq!(
std::fs::read_to_string(b.path().join("file.txt")).unwrap(),
@@ -446,7 +477,11 @@ async fn checkout_wip_policy_is_not_feature_authority_and_compatibility_is_repla
let mut registry = WipMountRegistry::new();
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
.unwrap();
for def in tools::routed_builtin_tools(router, tools::Tracker::new(), dir.path().join("bash")) {
for def in tools::routed_builtin_tools(
router,
tools::Tracker::new(),
tools::bash_output::BashOutputView::new(),
) {
let (meta, tool) = def();
let p = compatibility_projection(meta, tool, None).unwrap();
registry.mount(p).unwrap();
@@ -497,7 +532,7 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
.unwrap();
let r = runtime(router, None);
let path = "/checkouts/main/file.txt";
let i = interface(&r, path).await;
interface(&r, path).await;
// No rediscovery or Interface refresh between any of these operations.
for (op, args) in [
("read", json!({})),
@@ -505,16 +540,23 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
("write", json!({"content":"saved\n"})),
("read", json!({})),
] {
r.call(path.into(), i.clone(), op.into(), args, Default::default())
.await
.unwrap();
r.call(
path.into(),
contract(op).reference(),
op.into(),
args,
Default::default(),
)
.await
.unwrap();
}
assert_eq!(
std::fs::read_to_string(dir.path().join("file.txt")).unwrap(),
"saved\n"
);
let parent = "/checkouts/main";
let i = interface(&r, parent).await;
interface(&r, parent).await;
let i = contract("create_file").reference();
for name in ["one.txt", "nested/two.txt"] {
r.call(
parent.into(),
@@ -617,11 +659,12 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
.is_none()
);
let file = "/checkouts/child/a.txt";
let i = interface(&child, file).await;
interface(&child, file).await;
let i = contract("write").reference();
child
.call(
file.into(),
i.clone(),
contract("read").reference(),
"read".into(),
json!({}),
Default::default(),
@@ -686,7 +729,21 @@ pub(super) async fn model_invoke(
operation: &str,
arguments: Json,
) -> Result<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]
@@ -752,10 +809,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed
let r = Arc::new(runtime(router, None));
let entrance = model_inspect(r.clone(), "/checkouts/main").await;
assert_eq!(entrance["path"], "/checkouts/main");
assert_eq!(
entrance["interfaces"][0]["reference"]["scope"],
"/checkouts/main"
);
assert_eq!(entrance["interfaces"][0]["reference"]["scope"], "/");
let projection = r
.host
.projection_live("/checkouts/main")
@@ -829,10 +883,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed
json!([])
);
let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await;
assert_eq!(
deep["interfaces"][0]["reference"]["scope"],
"/checkouts/main"
);
assert_eq!(deep["interfaces"][0]["reference"]["scope"], "/");
for (operation, args) in [
("list", json!({})),
("glob", json!({"pattern":"*.txt"})),
@@ -926,7 +977,7 @@ async fn checkout_list_permission_cannot_be_bypassed_with_glob_or_command_identi
}
#[tokio::test]
async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connection_before_read() {
async fn common_scope_rejects_wrong_namespace_scope_ref_and_old_target_authority() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("a.txt"), "same\n").unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
@@ -943,8 +994,8 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
let p = r.host.projection_live(path).await.unwrap().unwrap();
let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap();
assert!(
fetched.scope_ref.is_none(),
"ref-less publication is legitimate"
fetched.scope_ref.is_some(),
"common namespace uses the Host root scope"
);
// Ordinary content changes do not terminate the entrance's Interface lifetime.
std::fs::write(dir.path().join("new.txt"), "fixture").unwrap();
@@ -959,12 +1010,12 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
let request = |reference, scope_ref| CallOperationRequest {
target: wip_protocol::Target {
path: path.into(),
validator: None,
validator: p.object.validator.clone(),
},
interface: wip_protocol::InterfaceTarget {
reference,
scope_ref,
validator: None,
validator: fetched.validator.clone(),
},
operation: "read".into(),
arguments: BTreeMap::new(),
@@ -996,6 +1047,654 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
router.attach(main, session(&dir, true)).unwrap();
let forged = request(p.interface.clone(), None);
assert!(
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::ValidatorMismatch)
);
}
fn observed_common_request(
path: &str,
validator: Option<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;
}
+86 -79
View File
@@ -692,7 +692,6 @@ impl WorkerController {
pub async fn spawn<C, St>(
worker: Worker<C, St>,
runtime_base: &Path,
bash_output_dir: &Path,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + Clone + 'static,
@@ -701,7 +700,6 @@ impl WorkerController {
let (handle, shutdown, _task) = Self::spawn_inner(
worker,
runtime_base,
bash_output_dir,
false,
None,
WorkerControllerTransport::UnixSocket,
@@ -715,7 +713,7 @@ impl WorkerController {
pub async fn spawn_with_transport<C, St>(
worker: Worker<C, St>,
runtime_base: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
@@ -723,42 +721,32 @@ impl WorkerController {
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let (handle, shutdown, _task) =
Self::spawn_with_transport_owned(worker, runtime_base, bash_output_dir, transport)
.await?;
Self::spawn_with_transport_owned(worker, runtime_base, transport).await?;
Ok((handle, shutdown))
}
pub(crate) async fn spawn_with_transport_owned<C, St>(
worker: Worker<C, St>,
runtime_base: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
Self::spawn_inner(
worker,
runtime_base,
bash_output_dir,
false,
None,
transport,
)
.await
Self::spawn_inner(worker, runtime_base, false, None, transport).await
}
/// Spawn a Worker owned by `worker-runtime`.
///
/// The controller uses an ephemeral directory for Unix sockets while tool
/// spill artifacts use the separately supplied Worker-owned temporary path.
/// The controller uses an ephemeral directory for Unix sockets. Bash output is
/// owned by its live Feature instance, independently of filesystem layout.
/// Runtime-managed Workers do not write legacy pid/status/manifest liveness
/// projections.
pub async fn spawn_runtime_managed<C, St>(
worker: Worker<C, St>,
runtime_base: &Path,
bash_output_dir: &Path,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + Clone + 'static,
@@ -767,7 +755,6 @@ impl WorkerController {
let (handle, shutdown, _task) = Self::spawn_inner(
worker,
runtime_base,
bash_output_dir,
true,
None,
WorkerControllerTransport::UnixSocket,
@@ -780,7 +767,6 @@ impl WorkerController {
pub async fn spawn_runtime_managed_run<C, St>(
worker: Worker<C, St>,
run_dir: &Path,
bash_output_dir: &Path,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + Clone + 'static,
@@ -789,7 +775,6 @@ impl WorkerController {
Self::spawn_runtime_managed_run_with_transport(
worker,
run_dir,
bash_output_dir,
WorkerControllerTransport::UnixSocket,
)
.await
@@ -800,27 +785,23 @@ impl WorkerController {
pub async fn spawn_runtime_managed_run_with_transport<C, St>(
worker: Worker<C, St>,
run_dir: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let (handle, shutdown, _task) = Self::spawn_runtime_managed_run_with_transport_owned(
worker,
run_dir,
bash_output_dir,
transport,
)
.await?;
let (handle, shutdown, _task) =
Self::spawn_runtime_managed_run_with_transport_owned(worker, run_dir, transport)
.await?;
Ok((handle, shutdown))
}
pub(crate) async fn spawn_runtime_managed_run_with_transport_owned<C, St>(
worker: Worker<C, St>,
run_dir: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
where
@@ -830,21 +811,13 @@ impl WorkerController {
let parent = run_dir
.parent()
.ok_or_else(|| std::io::Error::other("run path has no parent"))?;
Self::spawn_inner(
worker,
parent,
bash_output_dir,
true,
Some(run_dir),
transport,
)
.await
Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await
}
async fn spawn_inner<C, St>(
worker: Worker<C, St>,
runtime_base: &Path,
bash_output_dir: &Path,
runtime_managed: bool,
runtime_run: Option<&Path>,
transport: WorkerControllerTransport,
@@ -858,7 +831,6 @@ impl WorkerController {
let result = Self::spawn_initialized(
worker,
runtime_base,
bash_output_dir,
runtime_managed,
runtime_run,
transport,
@@ -878,7 +850,7 @@ impl WorkerController {
async fn spawn_initialized<C, St>(
mut worker: Worker<C, St>,
runtime_base: &Path,
bash_output_dir: &Path,
runtime_managed: bool,
runtime_run: Option<&Path>,
transport: WorkerControllerTransport,
@@ -892,8 +864,7 @@ impl WorkerController {
.map_err(std::io::Error::other)?;
// === 1. Initialization (channels / RuntimeDir / worker-immutable
// snapshots / SpawnedWorkerRegistry / alerter attach /
// bash-output scope) ===
// snapshots / SpawnedWorkerRegistry / alerter attach) ===
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let (method_tx, method_rx) = mpsc::channel::<Method>(32);
let (working_event_tx, _) = broadcast::channel::<Event>(256);
@@ -901,8 +872,8 @@ impl WorkerController {
let in_flight = InFlightEvents::new(working_event_tx.clone());
worker.attach_in_flight_events(in_flight.clone());
// Runtime directory is created before tool registration because it owns
// bounded tool artifacts, and before initial status/history writes consume
// Runtime directory is created before tool registration and before
// initial status/history writes consume
// the greeting we build after registration is complete.
let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run {
RuntimeDir::create_worker_run(run_dir).await?
@@ -939,26 +910,6 @@ impl WorkerController {
worker.attach_internal_worker_registry(spawned_registry.clone());
worker.attach_working_event_tx(working_event_tx.clone());
// Bash spill artifacts are owned by the stable Worker identity rather
// than a controller session/run generation. Push a recursive
// `allow(Read)` for the exact tool output path into the Worker's shared
// runtime scope so the Workdir session and system prompt stay aligned.
let bash_output_dir = bash_output_dir.to_path_buf();
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
std::io::Error::other(format!(
"create bash output dir {}: {e}",
bash_output_dir.display()
))
})?;
worker
.add_scope_rules([manifest::ScopeRule {
target: bash_output_dir.clone(),
permission: manifest::Permission::Read,
recursive: true,
symlink_policy: Default::default(),
}])
.map_err(std::io::Error::other)?;
// === 1.5. Direct writer wiring ===
//
// Engine callbacks fire `on_history_append` for each assistant
@@ -978,7 +929,6 @@ impl WorkerController {
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
let fs_for_view = register_worker_tools(
&mut worker,
bash_output_dir,
runtime_base.to_path_buf(),
spawned_registry.clone(),
Some(method_tx.downgrade()),
@@ -1445,7 +1395,7 @@ fn validate_memory_lifecycle_targets(
/// the shared state.
pub(crate) async fn register_worker_tools<C, St>(
worker: &mut Worker<C, St>,
bash_output_dir: PathBuf,
runtime_base: PathBuf,
spawned_registry: Arc<SpawnedWorkerRegistry>,
parent_method_tx: Option<mpsc::WeakSender<Method>>,
@@ -1458,6 +1408,11 @@ where
// Worker-immutable snapshots taken before the mutable worker borrow
// below so the worker borrow doesn't conflict with reads on `worker`.
let feature_config = worker.manifest().feature.clone();
// New activation requires admission before any initialization side effect.
// Restore reconstructs saved composition; operation authority stays live.
worker
.authorize_feature_configuration()
.map_err(|error| std::io::Error::new(std::io::ErrorKind::PermissionDenied, error))?;
let workdir_sessions = worker.workdir_sessions();
let restrict_workdir_tools_to_current_capabilities = inherited_workdir_tool_broker.is_some();
let workdir_tool_broker = Some(
@@ -1490,6 +1445,7 @@ where
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
let prompts = worker.prompts().clone();
let tracker = tools::Tracker::new();
let bash_output = spawned_registry.bash_output();
{
let paste_store = worker.store().clone();
let paste_session_id = worker.session_id();
@@ -1512,13 +1468,13 @@ where
tools::routed_builtin_tools_for_current_capabilities(
workdir_sessions.clone(),
tracker.clone(),
bash_output_dir.clone(),
bash_output.clone(),
)
} else {
tools::routed_builtin_tools(
workdir_sessions.clone(),
tracker.clone(),
bash_output_dir.clone(),
bash_output.clone(),
)
};
worker.engine_mut().register_tools(workdir_tools);
@@ -1661,10 +1617,10 @@ where
if ordinary_subjektiv_features_enabled
&& lifecycle_enabled
&& let Some(host) = subjektiv_host.as_ref()
&& host.host.journal_startup_granted()
&& (host.host.journal_startup_granted() || host.host.journal_compaction_granted())
{
worker.install_initial_context_source(Arc::new(
crate::feature::builtin::journal::JournalStartupContextSource::new(
worker.install_session_context_source(Arc::new(
crate::feature::builtin::journal::JournalContextSource::new(
host.clone(),
worker.prompts(),
),
@@ -1915,6 +1871,20 @@ where
)
})?;
}
if wip_mode {
crate::feature::builtin::bash::mount(
&mut wip_mount_registry,
worker.workdir_sessions(),
bash_output.clone(),
wip_permissions.clone(),
)
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("mount Bash output WIP projection: {error}"),
)
})?;
}
if wip_mode && let Some(feature) = &workdir_wip_feature {
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
&mut wip_mount_registry,
@@ -2045,7 +2015,6 @@ where
spawner_workspace_context,
parent_notifications,
runtime_base.clone(),
bash_output_dir.clone(),
spawner_workspace_root,
workdir_tool_broker,
spawned_registry.clone(),
@@ -3331,6 +3300,8 @@ async fn controller_loop<C, St>(
}
}
spawned_registry.bash_output().revoke();
if let Some(observer) = command_observer.take() {
observer.abort();
let _ = observer.await;
@@ -4144,7 +4115,21 @@ mod tests {
request: crate::worker::WorkspaceRequest,
) -> 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");
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
"entry": {"workspace_id": "workspace-1", "node_id": "1"},
@@ -4215,16 +4200,23 @@ mod tests {
worker.workdir_sessions(),
)
.descriptor();
register_worker_tools(
let registered = register_worker_tools(
&mut worker,
root.path().join("bash-output"),
root.path().join("runtime"),
SpawnedWorkerRegistry::new_for_internal_services(),
None,
None,
)
.await
.unwrap();
.await;
if enabled && (!available || workspace.is_none()) {
assert_eq!(
registered.unwrap_err().kind(),
std::io::ErrorKind::PermissionDenied
);
assert!(requests.lock().unwrap().is_empty());
continue;
}
registered.unwrap();
let configured = enabled && available && workspace.is_some();
let tools = worker.engine().tool_server_handle();
tools.flush_pending();
@@ -4429,6 +4421,21 @@ mod tests {
assert!(feature_install < materialize_gate);
assert!(materialize_gate < materialize);
assert!(materialize < exposure);
let observer = startup.find("let command_observer = fs_for_view").unwrap();
let recover = startup.find(".recover_unfinished_compaction()").unwrap();
let files = startup
.find("runtime_dir.write_status(&shared_state).await?")
.unwrap();
let loop_start = startup.find("tokio::spawn(controller_loop(").unwrap();
assert!(recover < observer && files < observer && observer < loop_start);
let socket_startup = &startup[observer..loop_start];
let abort = socket_startup.find("observer.abort()").unwrap();
let join = socket_startup.find("observer.await").unwrap();
let failure = socket_startup.find("return Err(error)").unwrap();
assert!(
abort < join && join < failure,
"startup cannot detach its observer on error"
);
assert!(
startup[materialize_gate..materialize]
.contains("needs_initial_session_head_materialization")
@@ -64,7 +64,6 @@ async fn prepared(
WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"),
bash_output_dir: temp.path().join("bash"),
},
WorkerControllerTransport::InProcess,
)
+1 -2
View File
@@ -635,12 +635,11 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
return ExitCode::FAILURE;
}
};
let bash_output_dir = crate::bash_output_dir_for_worker_id(&worker_name);
let started = match start_worker_controller(
worker,
WorkerBootstrapLayout::Direct {
runtime_base: runtime_base.clone(),
bash_output_dir,
},
WorkerControllerTransport::UnixSocket,
)
+294
View File
@@ -1534,8 +1534,18 @@ impl ProtocolProviderRegistrar<'_> {
}
}
/// Restore preserves the saved Feature composition; only new activation asks
/// for admission. Neither mode grants authority for protected operations.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum FeatureInstallation {
#[default]
Activate,
Restore,
}
/// Install-time context provided to a feature module.
pub struct FeatureInstallContext<'a> {
installation: FeatureInstallation,
feature_id: &'a FeatureId,
declarations: &'a FeatureContributionDeclarations,
pending_tools: &'a mut Vec<ToolDefinition>,
@@ -1548,6 +1558,10 @@ pub struct FeatureInstallContext<'a> {
}
impl FeatureInstallContext<'_> {
pub(crate) fn is_restoring(&self) -> bool {
self.installation == FeatureInstallation::Restore
}
pub fn feature_id(&self) -> &FeatureId {
self.feature_id
}
@@ -1751,6 +1765,60 @@ pub enum FeaturePlanError {
/// Builder/installer for enabled feature modules.
pub struct FeatureRegistryBuilder {
modules: Vec<Arc<dyn FeatureModule>>,
installation: FeatureInstallation,
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(
@@ -1945,6 +2013,8 @@ impl Default for FeatureRegistryBuilder {
fn default() -> Self {
Self {
modules: Vec::new(),
installation: FeatureInstallation::Activate,
activation_guard: None,
}
}
}
@@ -1954,6 +2024,28 @@ impl FeatureRegistryBuilder {
Self::default()
}
pub(crate) fn with_installation(mut self, installation: FeatureInstallation) -> Self {
self.installation = installation;
self
}
/// Add a trusted, live preflight that runs before *any* new module activation.
/// Restoring saved composition does not repeat admission checks. 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
where
M: FeatureModule + 'static,
@@ -2025,6 +2117,43 @@ impl FeatureRegistryBuilder {
hook_builder: &mut HookRegistryBuilder,
mut installed_tool_names: HashMap<String, FeatureId>,
) -> FeatureRegistryInstallReport {
if self.installation == FeatureInstallation::Activate
&& let Some(error) = self
.activation_guard
.as_ref()
.and_then(|guard| guard().err())
{
return FeatureRegistryInstallReport {
reports: {
let mut descriptors = self.descriptors();
if descriptors.is_empty() {
descriptors.push(FeatureDescriptor::builtin(
"activation",
"Feature activation",
));
}
descriptors
.iter()
.map(|descriptor| {
let mut report = FeatureInstallReport::new(descriptor);
report
.diagnostics
.push(FeatureDiagnostic::error(error.clone()));
report.mark_skipped(
FeatureContributionKind::Diagnostic,
descriptor.id.to_string(),
"Feature activation rejected before installation",
);
report
})
.collect()
},
services: FeatureServiceRegistry::default(),
background_tasks: FeatureBackgroundTaskRegistry::default(),
chat_invocations: FeatureInvocationRegistry::default(),
plan_error: None,
};
}
let descriptors: Vec<_> = self
.modules
.iter()
@@ -2125,6 +2254,7 @@ impl FeatureRegistryBuilder {
let install_result = {
let mut context = FeatureInstallContext {
installation: self.installation,
feature_id: &descriptor.id,
declarations: &declarations,
pending_tools,
@@ -2300,6 +2430,170 @@ mod tests {
use serde_json::json;
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]
fn worker_feature_composition_has_no_dynamic_plugin_install_path() {
let feature_source = include_str!("feature.rs")
+11
View File
@@ -5,6 +5,17 @@
//! an external plugin-loading surface.
pub mod backend_job_result;
pub(crate) mod bash;
/// Host metadata participants are independent of model-facing Tool enablement.
/// Disabling a Feature must not preserve stale Session-bound authority evidence.
pub(crate) fn session_metadata_lifecycles()
-> Vec<std::sync::Arc<dyn crate::metadata_lifecycle::SessionMetadataLifecycle>> {
vec![std::sync::Arc::new(
crate::subjektiv_metadata::SessionAttributionMetadata,
)]
}
pub mod chat_invocation;
pub mod drive;
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
}
pub async fn capabilities(&self) -> Result<DriveCapabilities, DriveError> {
self.get("/capabilities", &serde_json::json!({})).await
}
pub async fn root(&self) -> Result<DriveEntry, DriveError> {
let entry: DriveEntry = self
.json(
@@ -102,6 +102,26 @@ impl WorkspaceClient for InternalChildWorkspaceClient {
self.inner.is_available()
}
fn authorize_operation(
&self,
operation: &str,
resource: &server_api::authorization::PermissionResource,
) -> Result<(), WorkspaceClientError> {
self.inner.authorize_operation(operation, resource)
}
fn authorize_operation_scope(
&self,
operation: &str,
resource: &server_api::authorization::PermissionResource,
) -> Result<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(
&self,
request: WorkspaceRequest,
@@ -7,12 +7,14 @@ use std::collections::VecDeque;
struct Client {
responses: Mutex<VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
requests: Mutex<Vec<WorkspaceRequest>>,
capabilities: Mutex<WorkspaceResponse>,
}
impl Client {
fn new(responses: Vec<WorkspaceResponse>) -> Arc<Self> {
Arc::new(Self {
responses: Mutex::new(responses.into_iter().map(Ok).collect()),
requests: Mutex::new(Vec::new()),
capabilities: Mutex::new(response(DriveCapabilities { write: false })),
})
}
}
@@ -30,7 +32,15 @@ impl WorkspaceClient for Client {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let capabilities = request.path == "/api/w/workspace/drive/capabilities";
self.requests.lock().unwrap().push(request);
if capabilities {
let response = self.capabilities.lock().unwrap();
return Ok(WorkspaceResponse {
status: response.status,
body: response.body.clone(),
});
}
self.responses
.lock()
.unwrap()
@@ -479,7 +489,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
let feature = feature(client.clone());
let mut mounts = crate::wip::WipMountRegistry::new();
wip::mount_drive_wip(&mut mounts, &feature).unwrap();
let provider = wip::DriveProvider(feature);
let provider = wip::DriveProvider::new(feature);
let path = entry_path(&entry.entry);
let publication = provider.publication(&path).await.unwrap().unwrap();
assert_eq!(publication.projection.route, path);
@@ -487,7 +497,8 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
publication.scope.as_ref().unwrap().r#ref,
publication.projection.object.r#ref
);
assert_eq!(publication.projection.object.validator, Some(b"3".to_vec()));
assert!(publication.projection.object.validator.is_some());
assert_ne!(publication.projection.object.validator, Some(b"3".to_vec()));
assert!(provider.children("/drive").await.unwrap().is_empty());
assert!(
provider
@@ -504,7 +515,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
.flat_map(|op| &op.parameters)
.any(|parameter| parameter.name == "expected_mutation_id")
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
assert_eq!(client.requests.lock().unwrap().len(), 2);
}
#[derive(Debug)]
@@ -656,3 +667,662 @@ async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() {
);
assert_eq!(client.json.requests.lock().unwrap().len(), 1);
}
fn native_context() -> crate::wip::WipCallContext {
crate::wip::WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "drive-test".into(),
}
}
fn native_args(value: Value) -> std::collections::BTreeMap<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());
}
+190 -22
View File
@@ -40,7 +40,11 @@ pub fn mount_drive_wip(
registry.allocate_namespace("drive", "drive")?;
// Registration is only a namespace entrance. The subtree provides the
// current target/scope/descriptor/handler in a single self-scoped snapshot.
let names = root_specs();
// This unresolved entrance has no live capability evidence.
let names: Vec<_> = root_specs()
.into_iter()
.filter(|spec| !mutation(spec))
.collect();
let descriptor = descriptor(&names, true);
registry.mount(WipProjection {
route: ROOT.into(),
@@ -63,7 +67,7 @@ pub fn mount_drive_wip(
})?;
registry.mount_subtree(WipSubtreeMount {
root: ROOT.into(),
provider: Arc::new(DriveProvider(feature.clone())),
provider: Arc::new(DriveProvider::new(feature.clone())),
})?;
registry.replace_compatibility_tools(ROOT, SPECS.iter().map(|spec| spec.name))?;
Ok(())
@@ -80,7 +84,19 @@ impl WipOperationHandler for Unresolved {
Err(map_error(DriveError::Denied))
}
}
pub(super) struct DriveProvider(pub(super) DriveFeature);
#[derive(Clone)]
pub(super) struct DriveProvider {
feature: DriveFeature,
observation_nonce: Arc<str>,
}
impl DriveProvider {
pub(super) fn new(feature: DriveFeature) -> Self {
Self {
feature,
observation_nonce: uuid::Uuid::now_v7().to_string().into(),
}
}
}
#[async_trait]
impl WipSubtreeProvider for DriveProvider {
// Arbitrary tree depth is safe: there are deliberately no indexable entries.
@@ -92,27 +108,50 @@ impl WipSubtreeProvider for DriveProvider {
}
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
let result = if path == ROOT {
self.0.backend.root().await
self.feature.backend.root().await
} else {
let Some(entry) = parse_entry(&self.0, path).map_err(protocol)? else {
let Some(entry) = parse_entry(&self.feature, path).map_err(protocol)? else {
return Ok(None);
};
self.0.backend.metadata(&entry).await
self.feature.backend.metadata(&entry).await
};
let entry = match result {
Ok(entry) => entry,
Err(DriveError::Denied | DriveError::NotFound) => return Ok(None),
Err(error) => return Err(protocol(error)),
};
let capabilities = match self.feature.backend.capabilities().await {
Ok(capabilities) => capabilities,
Err(DriveError::Denied | DriveError::NotFound) => return Ok(None),
Err(error) => return Err(protocol(error)),
};
let root = path == ROOT;
let specs = if root {
let specs: Vec<_> = if root {
root_specs()
} else {
entry_specs(&entry)
};
}
.into_iter()
.filter(|spec| {
!mutation(spec) || (capabilities.write && publication_may_allow(&self.feature, spec))
})
.collect();
let common_mutations: Vec<_> = specs
.iter()
.filter_map(|spec| match spec.operation {
"edit" => Some(crate::file_operation::Contract::Edit),
"write" => Some(crate::file_operation::Contract::Write),
_ => None,
})
.collect();
let descriptor = descriptor(&specs, root);
let reference = contextual_reference(INTERFACE, path);
let validator = entry.last_mutation_id.as_bytes().to_vec();
let validator = object_validator(
&self.observation_nonce,
&entry,
&entry.last_mutation_id,
&specs,
);
let object = Object {
name: if root {
"drive".into()
@@ -130,7 +169,8 @@ impl WipSubtreeProvider for DriveProvider {
)),
validator: Some(validator),
};
let projection = WipProjection {
let common_read = entry.kind == DriveEntryKind::File;
let mut projection = WipProjection {
route: path.into(),
capability: "drive:content".into(),
kind: WipProjectionKind::Native,
@@ -139,18 +179,80 @@ impl WipSubtreeProvider for DriveProvider {
interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()),
descriptor,
handler: Arc::new(DriveHandler {
feature: self.0.clone(),
feature: self.feature.clone(),
observation_nonce: self.observation_nonce.clone(),
entry,
specs,
root,
}),
};
// This is publication evidence only. Backend execution rechecks current
// authority and the captured commit precondition on every mutation.
crate::file_operation::expose(&mut projection, common_mutations);
if common_read {
crate::file_operation::expose(
&mut projection,
[
crate::file_operation::Contract::ReadLines,
crate::file_operation::Contract::SearchText,
],
);
}
WipPublication::self_scoped(projection).map(Some)
}
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
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> {
SPECS
.iter()
@@ -194,11 +296,10 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati
}
}
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().filter(|spec| !matches!(spec.operation,"edit"|"write")).map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
"list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
"search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
"read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)],
"write"|"edit"=>crate::file_operation::parameters(spec.operation,false).expect("shared text arguments"),
"create_text"=>vec![parameter("name",true,TypeExpr::String),parameter("content",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)],
"create_folder"=>vec![parameter("name",true,TypeExpr::String)],
"relocate"=>vec![parameter("parent",true,TypeExpr::Entry),parameter("name",true,TypeExpr::String)],
@@ -209,6 +310,7 @@ fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
}
struct DriveHandler {
feature: DriveFeature,
observation_nonce: Arc<str>,
entry: DriveEntry,
specs: Vec<Spec>,
root: bool,
@@ -221,21 +323,34 @@ impl WipOperationHandler for DriveHandler {
arguments: &BTreeMap<String, Value>,
context: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
let common_text = matches!(operation, "read_lines" | "search_text");
let backend_operation = if common_text { "read" } else { operation };
let spec = self
.specs
.iter()
.find(|spec| spec.operation == operation)
.find(|spec| spec.operation == backend_operation)
.ok_or_else(|| {
failure(
ProtocolErrorCode::OperationNotFound,
"Drive operation is not published",
)
})?;
let declaration = descriptor(&self.specs, self.root)
.operations
.into_iter()
.find(|op| op.name == operation)
.unwrap();
let contract = match operation {
"read_lines" => Some(crate::file_operation::Contract::ReadLines),
"search_text" => Some(crate::file_operation::Contract::SearchText),
"edit" => Some(crate::file_operation::Contract::Edit),
"write" => Some(crate::file_operation::Contract::Write),
_ => None,
};
let declaration = if let Some(contract) = contract {
contract.descriptor().operations.into_iter().next().unwrap()
} else {
descriptor(&self.specs, self.root)
.operations
.into_iter()
.find(|op| op.name == operation)
.unwrap()
};
if arguments
.keys()
.any(|key| !declaration.parameters.iter().any(|p| p.name == *key))
@@ -244,8 +359,22 @@ impl WipOperationHandler for DriveHandler {
"unrecognized arguments; committed-request observations are Client-managed".into(),
)));
}
let mut input = wip_to_json(&Value::Record(arguments.clone()))
.map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?;
let mut input = if common_text {
// Validate the common input before performing any source read. Its
// line window is evaluated only after the complete bounded read.
crate::file_operation::text_result(operation, arguments, "")?;
if self
.entry
.size
.is_some_and(|size| size > server_api::DRIVE_CHUNK_MAX_BYTES)
{
return Err(map_error(DriveError::Limit));
}
json!({"entry": self.entry.entry, "max_bytes": server_api::DRIVE_CHUNK_MAX_BYTES})
} else {
wip_to_json(&Value::Record(arguments.clone()))
.map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?
};
let fields = input
.as_object_mut()
.ok_or_else(|| map_error(DriveError::Invalid("named arguments required".into())))?;
@@ -298,6 +427,19 @@ impl WipOperationHandler for DriveHandler {
)
.await
.map_err(map_error)?;
if common_text {
let content = complete_text(&output.value)?;
let value = crate::file_operation::text_result(operation, arguments, content)?;
return Ok(WipOperationOutput::native_with_validator(
value,
object_validator(
&self.observation_nonce,
&self.entry,
&self.entry.last_mutation_id,
&self.specs,
),
));
}
let value = json_to_wip(&output.value).map_err(|_| {
WipOperationError::OutcomeUnknown(
"Completed Drive result unavailable; query request_status before another mutation"
@@ -314,13 +456,39 @@ impl WipOperationHandler for DriveHandler {
"Committed Drive request identity unavailable".into(),
)
})?;
WipOperationOutput::native_with_validator(value, last_mutation_id.as_bytes().to_vec())
WipOperationOutput::native_with_validator(
value,
object_validator(
&self.observation_nonce,
&self.entry,
last_mutation_id,
&self.specs,
),
)
} else {
WipOperationOutput::native(value)
};
Ok(result.with_attachments(output.attachments))
}
}
/// A bounded line/search contract cannot claim that a partial blob is the full
/// source. Retain Drive's separate whole-text read with its truncation flag.
fn complete_text(value: &serde_json::Value) -> Result<&str, WipOperationError> {
let content = value["text"]
.as_str()
.ok_or_else(|| map_error(DriveError::Unavailable))?;
let size = value["entry"]["metadata"]["size"]
.as_u64()
.ok_or_else(|| map_error(DriveError::Unavailable))?;
if value["truncated"].as_bool() != Some(false)
|| size != content.len() as u64
|| content.len() > server_api::DRIVE_CHUNK_MAX_BYTES as usize
{
return Err(map_error(DriveError::Limit));
}
Ok(content)
}
fn parse_entry(feature: &DriveFeature, path: &str) -> Result<Option<DriveEntryRef>, DriveError> {
let workspace = feature.backend.workspace()?;
let prefix = format!(
+143 -26
View File
@@ -23,7 +23,7 @@ use crate::hook::{
use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use async_trait::async_trait;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::Arc;
use subjektiv::journal::transport::*;
@@ -32,12 +32,73 @@ use subjektiv::journal::{
MAX_JOURNAL_RESPONSE_BYTES,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct JournalStartupInput {
pub snapshot: subjektiv::journal::startup::JournalStartupSnapshot,
pub snapshot_sha256: String,
pub prompt_provenance: session_store::PromptRenderProvenance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum JournalReadStatus {
Available,
Absent,
Disabled,
Unavailable,
}
impl JournalReadStatus {
fn as_str(self) -> &'static str {
match self {
Self::Available => "available",
Self::Absent => "absent",
Self::Disabled => "disabled",
Self::Unavailable => "unavailable",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct JournalCompactionInput {
status: JournalReadStatus,
snapshot: Option<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.
pub(crate) struct JournalStartupContextSource {
pub(crate) struct JournalContextSource {
host: SubjektivHostConnection,
prompts: Arc<arc_swap::ArcSwap<crate::prompt::catalog::PromptCatalog>>,
}
impl JournalStartupContextSource {
impl JournalContextSource {
pub(crate) fn new(
host: SubjektivHostConnection,
prompts: Arc<arc_swap::ArcSwap<crate::prompt::catalog::PromptCatalog>>,
@@ -46,9 +107,12 @@ impl JournalStartupContextSource {
}
}
#[async_trait]
impl crate::worker::InitialContextSource for JournalStartupContextSource {
impl crate::worker::SessionContextSource for JournalContextSource {
async fn load(&self) -> Result<Vec<session_store::LoggedSystemHistoryEntry>, String> {
use sha2::{Digest, Sha256};
if !self.host.host.journal_startup_granted() {
return Ok(Vec::new());
}
let snapshot = self
.host
.journal_startup_context()
@@ -58,8 +122,8 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource {
let empty = snapshot.journals.is_empty() && !snapshot.omitted;
// Hash the same canonical JSON value retained by SystemItem, so replay
// can verify provenance without depending on domain struct field order.
let snapshot = serde_json::to_value(snapshot).map_err(|e| e.to_string())?;
let snapshot_json = serde_json::to_string(&snapshot).map_err(|e| e.to_string())?;
let snapshot_value = serde_json::to_value(&snapshot).map_err(|e| e.to_string())?;
let snapshot_json = serde_json::to_string(&snapshot_value).map_err(|e| e.to_string())?;
if snapshot_json.len() > subjektiv::journal::startup::STARTUP_MAX_BYTES {
return Err("Host Journal startup snapshot exceeds independent byte budget".into());
}
@@ -74,9 +138,10 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource {
return Err("Journal startup prompt exceeds rendered byte budget".into());
}
let projection = prompts.projection();
Ok(vec![session_store::LoggedSystemHistoryEntry {
item: session_store::SystemItem::JournalStartup {
body,
let payload = session_store::FeatureInputPayload::encode(
"journal:startup",
1,
&JournalStartupInput {
snapshot,
snapshot_sha256: Sha256::digest(snapshot_json.as_bytes())
.iter()
@@ -89,11 +154,38 @@ impl crate::worker::InitialContextSource for JournalStartupContextSource {
logical_name: "internal.subjektiv_journal_startup".into(),
},
},
metadata: crate::session_history::metadata(
session_store::LoggedSessionHistoryOrigin::JournalContext,
None,
),
}])
)
.map_err(|e| e.to_string())?;
Ok(vec![session_store::LoggedSystemHistoryEntry::from_input(
session_store::LoggedSessionHistoryOrigin::Provided {
producer: "subjektiv.journal".into(),
},
None,
journal_input(body, payload, "journal_startup")?,
)])
}
async fn load_compaction_context(
&self,
session: session_store::SessionId,
) -> Result<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(
connection: std::io::Result<Option<SubjektivHostConnection>>,
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| {
connection
.as_ref()
@@ -116,24 +209,25 @@ pub(crate) fn freeze_compaction_journal(
Ok(_) => None,
Err(_) => Some(Err(())),
};
let snapshot = read.as_ref().and_then(|read| read.as_ref().ok()).cloned();
let (status, json) = match read {
None => ("disabled", String::new()),
None => (Disabled, String::new()),
Some(Ok(snapshot)) => {
let status = if snapshot.journals.is_empty() {
"absent"
Absent
} else {
"available"
Available
};
match serde_json::to_string(&snapshot) {
Ok(json) if json.len() <= subjektiv::journal::startup::STARTUP_MAX_BYTES => {
(status, json)
}
_ => ("unavailable", String::new()),
_ => (Unavailable, String::new()),
}
}
Some(Err(())) => ("unavailable", String::new()),
Some(Err(())) => (Unavailable, String::new()),
};
if status == "unavailable" {
if status == Unavailable {
tracing::warn!("Journal compaction snapshot unavailable; continuing without Journal");
}
use sha2::{Digest, Sha256};
@@ -143,12 +237,35 @@ pub(crate) fn freeze_compaction_journal(
.map(|byte| format!("{byte:02x}"))
.collect()
});
prompts.subjektiv_journal_compaction(
status,
&json,
snapshot_sha256.as_deref(),
workspace_id.as_deref(),
let body = prompts
.subjektiv_journal_compaction(
status.as_str(),
&json,
snapshot_sha256.as_deref(),
workspace_id.as_deref(),
)
.map_err(|e| e.to_string())?;
let payload = session_store::FeatureInputPayload::encode(
"journal:compaction",
1,
&JournalCompactionInput {
status,
prompt_provenance: session_store::PromptRenderProvenance {
workspace_id,
source_digest: prompts.projection().source_digest.clone(),
projection_digest: prompts.projection().catalog_digest.clone(),
logical_name: "internal.subjektiv_journal_compaction".into(),
},
snapshot: if status == Unavailable {
None
} else {
snapshot
},
snapshot_sha256,
},
)
.map_err(|e| e.to_string())?;
journal_input(body, payload, "journal_compaction")
}
#[derive(Clone)]
@@ -505,13 +505,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
}
}
async fn start_command(
&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;
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
match self.operate(WorkdirSessionOperation::CommandStart(request))? {
WorkdirSessionOperationResult::CommandStart(result) => Ok(result),
_ => Err(Self::mismatch("command_start")),
@@ -1660,9 +1654,9 @@ mod tests {
.start_command(CommandRequest {
command: "true".to_string(),
timeout_secs: 120,
output_limit: 1024,
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
cwd: workdir::WorkdirPath::root(),
spill_dir: Some("/worker-local/bash-output".into()),
tool_call_id: Some("call-1".to_string()),
})
.await
@@ -1675,7 +1669,7 @@ mod tests {
assert_eq!(body["operation"]["operation"], "command_start");
assert_eq!(body["operation"]["request"]["command"], "true");
assert_eq!(body["operation"]["request"]["cwd"], "");
assert!(body["operation"]["request"]["spill_dir"].is_null());
assert!(body["operation"]["request"].get("spill_dir").is_none());
assert_eq!(body["operation"]["request"]["tool_call_id"], "call-1");
assert!(
!requests[0]
@@ -1942,9 +1936,9 @@ mod tests {
.start_command(CommandRequest {
command: "printf done".to_string(),
timeout_secs: 5,
output_limit: 1024,
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
cwd: workdir::WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
})
.await
@@ -6,7 +6,6 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::Deserialize;
use uuid::Uuid;
use protocol::Segment;
use server_api::{
@@ -502,7 +501,10 @@ pub struct WorkerLifecycleSpawnRequest {
pub profile: String,
pub singleton_key: Option<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>,
/// Explicit requested authority. Profile selection alone grants nothing.
pub execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
pub display_name: String,
pub initial_submit: Vec<Segment>,
}
@@ -514,25 +516,27 @@ struct WorkspaceWorkerLifecycleService {
fn workspace_worker_create_request(
request: WorkerLifecycleSpawnRequest,
) -> Result<CreateWorkspaceWorkerRequest, WorkspaceClientError> {
let ticket_assignment = match (request.ticket_id, request.operation_id) {
(Some(ticket_id), Some(operation_id)) => {
Some(CreateWorkspaceWorkerTicketAssignmentRequest {
let control_operation_id = request.operation_id.filter(|id| {
!id.trim().is_empty() && id.len() <= 256 && !id.chars().any(char::is_control)
}).ok_or_else(|| WorkspaceClientError::Request(
"operation_id is required for every Worker spawn (reuse the same ID and full input after an unknown outcome)".into()
))?;
if request.ticket_id.is_none() && request.orchestrator_assignment_id.is_some() {
return Err(WorkspaceClientError::Request(
"orchestrator_assignment_id requires ticket_id".into(),
));
}
if let Some(grant) = &request.execution_grant {
grant.validate().map_err(WorkspaceClientError::Request)?;
}
let ticket_assignment =
request
.ticket_id
.map(|ticket_id| CreateWorkspaceWorkerTicketAssignmentRequest {
orchestrator_assignment_id: request.orchestrator_assignment_id,
ticket_id,
operation_id,
})
}
(None, None) => None,
_ => {
return Err(WorkspaceClientError::Request(
"ticket_id and operation_id must be provided together".to_string(),
));
}
};
let control_operation_id = ticket_assignment
.as_ref()
.map(|assignment| assignment.operation_id.clone())
.unwrap_or_else(|| format!("worker-spawn-{}", Uuid::now_v7()));
operation_id: control_operation_id.clone(),
});
Ok(CreateWorkspaceWorkerRequest {
runtime_id: request.runtime_id,
display_name: request.display_name,
@@ -551,6 +555,7 @@ fn workspace_worker_create_request(
.collect(),
feature_connections: Default::default(),
control_operation_id: Some(control_operation_id),
execution_grant: request.execution_grant,
})
}
@@ -744,7 +749,13 @@ struct WorkerSpawnInput {
runtime_id: String,
/// Singular Workdir selection wrapped into the shared attachment collection
/// under the stable `workdir` alias.
working_directory_id: String,
#[serde(default)]
working_directory_id: Option<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,
/// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted.
#[serde(default)]
@@ -931,13 +942,23 @@ impl Tool for WorkspaceWorkerTool {
.ticket_id
.map(|ticket_id| authority_id(&ticket_id, "ticket_id"))
.transpose()?;
let operation_id = ticket_id
.as_ref()
.map(|ticket_id| {
let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?;
Ok::<_, ToolError>(format!("worker-spawn:{ticket_id}:{call_id}"))
})
.transpose()?;
let operation_id = Some(authority_id(&input.operation_id, "operation_id")?);
let workdir_attachments = match input.working_directory_id {
Some(id) => vec![WorkerLifecycleWorkdirAttachment {
alias: INITIAL_WORKDIR_ALIAS.to_string(),
working_directory_id: authority_id(&id, "working_directory_id")?,
relative_cwd: input
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
}],
None if input.relative_cwd.is_none() => Vec::new(),
None => {
return Err(ToolError::ExecutionFailed(
"relative_cwd requires working_directory_id".into(),
));
}
};
self.control
.spawn_worker(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: input
@@ -945,17 +966,8 @@ impl Tool for WorkspaceWorkerTool {
.map(|id| authority_id(&id, "orchestrator_assignment_id"))
.transpose()?,
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
workdir_attachments: vec![WorkerLifecycleWorkdirAttachment {
alias: INITIAL_WORKDIR_ALIAS.to_string(),
working_directory_id: authority_id(
&input.working_directory_id,
"working_directory_id",
)?,
relative_cwd: input
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
}],
workdir_attachments,
execution_grant: input.execution_grant,
profile: non_empty(input.profile, "profile")?,
singleton_key: input.singleton_key,
ticket_id,
@@ -1594,6 +1606,7 @@ mod tests {
"relative_cwd": "crates/worker",
"profile": "builtin:coder",
"ticket_id": "00001KZ9E0DBS",
"operation_id": "stable-ticket-intent",
"initial_submit": [
{ "kind": "flow", "selector": "builtin:coder-review" },
{ "kind": "text", "content": "Implement Ticket 00001" }
@@ -1623,7 +1636,7 @@ mod tests {
body["ticket_assignment"],
serde_json::json!({
"ticket_id": "00001KZ9E0DBS",
"operation_id": "worker-spawn:00001KZ9E0DBS:call-1"
"operation_id": "stable-ticket-intent"
})
);
assert_eq!(body["workdir_attachments"].as_array().unwrap().len(), 1);
@@ -1910,10 +1923,80 @@ mod tests {
);
}
#[tokio::test]
async fn ordinary_spawn_requires_stable_id_and_forwards_grant_without_ticket_or_workdir() {
let client = Arc::new(RecordingWorkspaceClient::default());
let tool = WorkspaceWorkerTool {
operation: WorkerOperation::Spawn,
control: test_control(client.clone()),
runtime_worker_control: true,
};
let mut input = serde_json::json!({"runtime_id":"runtime-1", "profile":"builtin:coder"});
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert!(client.requests.lock().unwrap().is_empty());
input["operation_id"] = serde_json::json!("ordinary-intent-1");
input["execution_grant"] = serde_json::json!({"permissions":[{"operation":"ticket", "resource":{"kind":"feature"}}], "redelegation":[]});
for call_id in ["call-1", "retry-another-call"] {
tool.execute(
&input.to_string(),
ToolExecutionContext::new(call_id, "batch", 0),
)
.await
.unwrap();
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].body, requests[1].body);
let body: serde_json::Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["control_operation_id"], "ordinary-intent-1");
assert_eq!(body["execution_grant"], input["execution_grant"]);
assert_eq!(body["workdir_attachments"], serde_json::json!([]));
assert!(
body.get("ticket_assignment")
.is_none_or(|value| value.is_null())
);
}
#[test]
fn lifecycle_spawn_rejects_missing_or_blank_id_before_backend() {
for operation_id in [
None,
Some("".into()),
Some(" ".into()),
Some("bad\nidentity".into()),
] {
let result = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: None,
runtime_id: "runtime-1".into(),
workdir_attachments: Vec::new(),
profile: "builtin:coder".into(),
singleton_key: None,
ticket_id: None,
operation_id,
execution_grant: None,
display_name: "Worker".into(),
initial_submit: Vec::new(),
});
assert!(result.unwrap_err().to_string().contains("operation_id"));
}
}
#[test]
fn worker_spawn_schema_exposes_normal_typed_segment_variants() {
let schema = serde_json::to_value(schemars::schema_for!(WorkerSpawnInput)).unwrap();
let text = serde_json::to_string(&schema).unwrap();
assert!(
schema["required"]
.as_array()
.unwrap()
.contains(&serde_json::json!("operation_id"))
);
assert!(text.contains("execution_grant"));
assert!(text.contains("initial_submit"));
assert!(text.contains("ticket_id"));
assert!(text.contains("selector"));
@@ -1941,7 +2024,8 @@ mod tests {
profile: "builtin:coder".to_string(),
singleton_key: Some("subjektiv:subject-1".to_string()),
ticket_id: None,
operation_id: None,
operation_id: Some("ordinary-spawn-intent-1".into()),
execution_grant: None,
display_name: "Coder".to_string(),
initial_submit: vec![
Segment::Flow {
@@ -1991,6 +2075,7 @@ mod tests {
singleton_key: None,
ticket_id: Some("T-719".to_string()),
operation_id: Some("spawn-ticket-worker:T-719:call-1".to_string()),
execution_grant: None,
display_name: "Worker · T-719".to_string(),
initial_submit: vec![Segment::text("Investigate Ticket T-719.")],
})
+194 -45
View File
@@ -43,6 +43,80 @@ use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SubjectBehaviorInput {
pub behavior_md: String,
pub prompt_provenance: Option<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)]
pub struct WorkspaceHttpMemoryBackend {
client: Arc<dyn WorkspaceClient>,
@@ -651,7 +725,7 @@ struct SubjectBehaviorRefreshState {
struct WorkspaceSubjektivResidentSummarySource {
host: SubjektivHostConnection,
prompts: Arc<ArcSwap<PromptCatalog>>,
behavior_state: Mutex<SubjectBehaviorRefreshState>,
behavior_state: Arc<Mutex<SubjectBehaviorRefreshState>>,
}
fn resident_summary_contribution(
@@ -691,6 +765,13 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
}
impl WorkspaceSubjektivResidentSummarySource {
fn confirm_resident_context_behavior(&self, behavior_md: &str) {
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.applied = Some(behavior_md.to_owned());
}
async fn fetch(
&self,
) -> Result<server_api::SubjektivResidentContextOutput, SubjektivHostError> {
@@ -707,7 +788,7 @@ impl WorkspaceSubjektivResidentSummarySource {
fn render(
&self,
output: &server_api::SubjektivResidentContextOutput,
) -> Result<String, String> {
) -> Result<(String, session_store::PromptRenderProvenance), String> {
use memory::backend::MemoryResidentSummaryAvailability as Availability;
let availability = match output.memory_surface.availability {
Availability::Ready => "ready",
@@ -716,53 +797,68 @@ impl WorkspaceSubjektivResidentSummarySource {
Availability::Failed => "failed",
Availability::Unavailable => "unavailable",
};
self.prompts
.load()
let prompts = self.prompts.load_full();
let body = prompts
.subjektiv_resident_context(
&output.behavior_md,
availability,
output.memory_surface.content.as_deref(),
)
.map_err(|error| error.to_string())
}
fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) {
let mut state = self
.behavior_state
.lock()
.expect("subject behavior state poisoned");
state.applied = Some(output.behavior_md.clone());
.map_err(|error| error.to_string())?;
let projection = prompts.projection();
Ok((
body,
session_store::PromptRenderProvenance {
workspace_id: self.host.host.workspace_id(),
source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(),
logical_name: "internal.subjektiv_resident_context".into(),
},
))
}
}
/// Detect behavior changes against the state reflected in history, not the fetched state.
///
/// Initial load builds a prompt contribution; change refresh does not advance applied on fetch/render alone.
/// The interceptor confirms after durably appending a system item, allowing refetch if append fails.
/// Invalidate applied before rewrite so even identical bodies can be presented in the new history.
/// Fetch/render never advances applied state. Initial preparation captures a
/// source-local completion, invoked only after its prompt and active pointer commit.
/// Later refreshes are acknowledged by the interceptor after durable append.
/// Invalidate applied before rewrite so identical bodies can be represented again.
#[async_trait]
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
async fn load(&self) -> SystemPromptContribution {
self.load_initial().await.0
}
async fn load_initial(
&self,
) -> (
SystemPromptContribution,
Option<crate::worker::PromptContributionCompletion>,
) {
match self.fetch().await {
Ok(output) => match self.render(&output) {
Ok(rendered) => {
self.remember_behavior(&output);
SystemPromptContribution::Ready(rendered)
Ok((rendered, _)) => {
let state = self.behavior_state.clone();
let behavior = output.behavior_md;
(
SystemPromptContribution::Ready(rendered),
Some(Box::new(move || {
state
.lock()
.expect("subject behavior state poisoned")
.applied = Some(behavior);
})),
)
}
Err(error) => {
tracing::error!(%error, "subject resident context render failed");
SystemPromptContribution::Ready(
"The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
(SystemPromptContribution::Ready("The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory.".into()), None)
}
},
Err(error) => {
tracing::error!(%error, "subject resident context fetch failed");
SystemPromptContribution::Ready(
"The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
(SystemPromptContribution::Ready("The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory.".into()), None)
}
}
}
@@ -781,18 +877,31 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource
if !changed {
return Ok(None);
}
let rendered = self.render(&output)?;
let (rendered, prompt_provenance) = self.render(&output)?;
Ok(Some(ResidentContextRefresh {
body: rendered,
behavior_md: output.behavior_md,
prompt_provenance: Some(prompt_provenance),
}))
}
fn confirm_resident_context_behavior(&self, behavior_md: &str) {
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.applied = Some(behavior_md.to_owned());
fn system_item_committed(
&self,
_item: &session_store::SystemItem,
metadata: &crate::session_history::SessionHistoryMetadata,
) {
if let Some(input) = metadata
.input_metadata
.as_ref()
.and_then(|input| input.feature_payload())
.and_then(|payload| {
payload
.decode::<SubjectBehaviorInput>("subject:behavior", 1)
.ok()
})
{
self.confirm_resident_context_behavior(&input.behavior_md);
}
}
fn invalidate_resident_context_representation(&self) {
@@ -944,7 +1053,13 @@ impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
"Subject behavior refresh requires durable session append authority",
)
})?;
system_items.append_subject_behavior_refresh(refresh.body, refresh.behavior_md);
let input = subject_behavior_input(
refresh.body,
refresh.behavior_md,
refresh.prompt_provenance,
)
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
system_items.append_input(input);
}
Ok(HookPreRequestAction::Continue)
}
@@ -969,16 +1084,18 @@ impl Hook<WorkerRestored> for SubjektivResidentRestoreRefreshHook {
.resident_memory_restore_section(surface_ready, summary)
.map_err(|error| HookError::new(HookErrorCategory::Internal, error.to_string()))?;
let projection = prompts.projection();
context.system_items().append_resident_summary_refresh(
let input = resident_summary_input(
body,
session_store::PromptRenderProvenance {
Some(session_store::PromptRenderProvenance {
workspace_id: self.workspace_id.clone(),
source_digest: projection.source_digest.clone(),
projection_digest: projection.catalog_digest.clone(),
logical_name: WorkerPrompt::ResidentMemoryRestoreSection.key().to_string(),
},
);
}),
)
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
context.system_items().append_input(input);
Ok(())
}
}
@@ -1106,7 +1223,7 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
host: host.expect("checked explicit connection"),
prompts,
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
behavior_state: Arc::new(Mutex::new(SubjectBehaviorRefreshState::default())),
})))
}
@@ -1723,8 +1840,16 @@ permission = "write"
.unwrap()
.unwrap();
let initial = source.load().await;
let (initial, completion) = source.load_initial().await;
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_some()
);
completion.expect("successful initial contribution must provide completion")();
assert!(
source
.load_changed_resident_context()
@@ -1741,7 +1866,13 @@ permission = "write"
.expect("changed behavior must produce one refresh");
assert_eq!(refresh.behavior_md, "Ask when uncertain.");
assert!(refresh.body.contains("Ask when uncertain."));
source.confirm_resident_context_behavior(&refresh.behavior_md);
let input = subject_behavior_input(
refresh.body.clone(),
refresh.behavior_md.clone(),
refresh.prompt_provenance.clone(),
)
.unwrap();
source.system_item_committed(&input.item, &input.metadata);
assert!(
source
.load_changed_resident_context()
@@ -1758,7 +1889,13 @@ permission = "write"
.expect("cleared behavior must produce one refresh");
assert_eq!(cleared.behavior_md, "");
assert!(cleared.body.contains("No user-managed behavior is set"));
source.confirm_resident_context_behavior(&cleared.behavior_md);
let input = subject_behavior_input(
cleared.body.clone(),
cleared.behavior_md.clone(),
cleared.prompt_provenance.clone(),
)
.unwrap();
source.system_item_committed(&input.item, &input.metadata);
assert!(
source
.load_changed_resident_context()
@@ -1783,7 +1920,7 @@ permission = "write"
// target-side bounded resident request contract.
assert_eq!(
client.timeouts.lock().unwrap().as_slice(),
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7]
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 8]
);
}
@@ -1816,7 +1953,13 @@ permission = "write"
.await
.unwrap()
.unwrap();
source.confirm_resident_context_behavior(&first.behavior_md);
let input = subject_behavior_input(
first.body.clone(),
first.behavior_md.clone(),
first.prompt_provenance.clone(),
)
.unwrap();
source.system_item_committed(&input.item, &input.metadata);
assert_eq!(
source
.load_changed_resident_context()
@@ -1825,7 +1968,13 @@ permission = "write"
.unwrap(),
second
);
source.confirm_resident_context_behavior(&second.behavior_md);
let input = subject_behavior_input(
second.body.clone(),
second.behavior_md.clone(),
second.prompt_provenance.clone(),
)
.unwrap();
source.system_item_committed(&input.item, &input.metadata);
assert!(
source
.load_changed_resident_context()
@@ -21,7 +21,7 @@ use crate::feature::{
const FEATURE_ID: &str = "orchestration";
const TOOL_NAME: &str = "SpawnTicketWorker";
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Orchestrator starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); repository/Workdir attachments are optional. For builtin:coder (or coder), omitted flow defaults to builtin:coder-review so the Coder owns the Reviewer SubWorker cycle and MR verdict. Explicit flow: null disables Flow; a string selects another Flow. Other profiles get no default Flow. Results and return choices follow user intent, not a universal approval or MR gate. Orchestrator starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
#[derive(Debug, Default)]
pub struct OrchestrationFeature;
@@ -88,15 +88,26 @@ struct SpawnTicketWorkerInput {
profile: Option<String>,
/// Initial user request delivered through the normal typed submission path.
initial_request: String,
/// Optional Flow selector, delivered as a typed Flow segment before the request.
#[serde(default)]
flow: Option<String>,
/// Flow selector, delivered before the request. Omission defaults to
/// builtin:coder-review for builtin:coder (or coder); other profiles get no Flow.
/// Explicit null disables Flow; an explicit string selects that Flow instead.
#[serde(default, deserialize_with = "deserialize_present_flow")]
flow: Option<Option<String>>,
/// Alias-keyed Workdir selections; omit or leave empty for a Worker without
/// Workdirs. Backend derives effective capabilities; callers cannot request them.
#[serde(default)]
workdir_attachments: Vec<SpawnTicketWorkerWorkdirInput>,
}
// Serde's ordinary Option would collapse explicit null and an absent field.
// Only an absent field selects the profile-specific default.
fn deserialize_present_flow<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer).map(Some)
}
struct SpawnTicketWorkerTool {
ticket_service: Arc<dyn TicketService>,
worker_service: Arc<dyn WorkerLifecycleService>,
@@ -125,7 +136,13 @@ impl Tool for SpawnTicketWorkerTool {
.transpose()?
.unwrap_or_else(|| "builtin:ticket-worker".into());
let initial_request = non_empty(input.initial_request, "initial_request")?;
let flow = input.flow.map(|flow| non_empty(flow, "flow")).transpose()?;
let flow = match input.flow {
Some(flow) => flow.map(|flow| non_empty(flow, "flow")).transpose()?,
None if matches!(profile.as_str(), "builtin:coder" | "coder") => {
Some("builtin:coder-review".to_string())
}
None => None,
};
let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?;
let mut initial_submit = Vec::new();
if let Some(selector) = flow {
@@ -147,6 +164,7 @@ impl Tool for SpawnTicketWorkerTool {
operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)),
display_name: format!("Worker · {}", ticket.resource_key),
initial_submit,
execution_grant: None,
})
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
@@ -445,6 +463,107 @@ mod tests {
}
}
#[tokio::test]
async fn spawn_ticket_worker_defaults_coder_flow_but_preserves_explicit_selection() {
// Exercise the LLM JSON boundary and the typed request passed to lifecycle.
// Lifecycle/network execution is recorded, not launched by this test.
let cases = [
("builtin:coder", None, Some("builtin:coder-review")),
("coder", None, Some("builtin:coder-review")),
(" builtin:coder ", None, Some("builtin:coder-review")),
("builtin:coder", Some(serde_json::Value::Null), None),
("coder", Some(serde_json::Value::Null), None),
(
"builtin:coder",
Some(serde_json::json!(" project:investigation ")),
Some("project:investigation"),
),
(
"builtin:coder",
Some(serde_json::json!("builtin:coder-review")),
Some("builtin:coder-review"),
),
("builtin:ticket-worker", None, None),
("builtin:intake", None, None),
("project:coder", None, None),
(
"project:coder",
Some(serde_json::json!("builtin:coder-review")),
Some("builtin:coder-review"),
),
];
for (profile, flow, expected_flow) in cases {
let service = Arc::new(RecordingService::default());
let (meta, tool) = definition(
Arc::new(FixedTicketService(TicketState::Open)),
service.clone(),
)();
let mut input = input();
input["profile"] = serde_json::json!(profile);
input["orchestrator_assignment_id"] = serde_json::json!("delegation-742");
if let Some(flow) = flow {
input["flow"] = flow;
}
assert!(jsonschema::is_valid(&meta.input_schema, &input));
tool.execute(
&input.to_string(),
ToolExecutionContext::new("call-coder-flow", "batch-1", 0),
)
.await
.unwrap();
let requests = service.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
let request = &requests[0];
let mut expected = Vec::new();
if let Some(selector) = expected_flow {
expected.push(Segment::Flow {
selector: selector.into(),
});
}
expected.push(Segment::text("Investigate Ticket T-719."));
assert_eq!(
request.initial_submit, expected,
"profile={profile}, input={input}"
);
assert_eq!(request.profile, profile.trim());
assert_eq!(
request.orchestrator_assignment_id.as_deref(),
Some("delegation-742")
);
}
}
#[tokio::test]
async fn spawn_ticket_worker_rejects_invalid_coder_flow_without_falling_back() {
let service = Arc::new(RecordingService::default());
let (meta, tool) = definition(
Arc::new(FixedTicketService(TicketState::Open)),
service.clone(),
)();
for flow in [
serde_json::json!(" "),
serde_json::json!(false),
serde_json::json!({"selector": "builtin:coder-review"}),
serde_json::json!([]),
] {
let mut input = input();
input["profile"] = serde_json::json!("builtin:coder");
input["flow"] = flow.clone();
if !flow.is_string() {
assert!(!jsonschema::is_valid(&meta.input_schema, &input));
}
let error = tool
.execute(
&input.to_string(),
ToolExecutionContext::new("call-invalid-flow", "batch-1", 0),
)
.await
.unwrap_err();
assert!(matches!(error, ToolError::InvalidArgument(_)), "{error}");
}
assert!(service.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend_not_case_state() {
for state in [TicketState::Open, TicketState::Closed] {
@@ -371,7 +371,7 @@ fn parse_entry_ref(value: &str) -> Result<SessionEntryRef, ToolError> {
fn parse_kind(value: &str) -> Result<ReferenceKind, ToolError> {
ReferenceKind::parse(value).ok_or_else(|| {
ToolError::InvalidArgument(format!(
"invalid kind {value:?}; expected user, assistant, or tool"
"invalid kind {value:?}; expected user, assistant, system, or tool"
))
})
}
@@ -276,7 +276,9 @@ mod tests {
use super::*;
use crate::hook::{PreRequestInfo, SystemItemAppendHandle};
fn pre_request_context(pending: Arc<Mutex<Vec<SystemItem>>>) -> PreRequestContext {
fn pre_request_context(
pending: Arc<Mutex<Vec<session_store::LoggedSystemHistoryEntry>>>,
) -> PreRequestContext {
PreRequestContext::new(
PreRequestInfo {
item_count: 1,
@@ -315,7 +317,7 @@ mod tests {
let queued = pending.lock().expect("pending queue poisoned");
assert_eq!(queued.len(), 1);
let SystemItem::TaskReminder { body, .. } = &queued[0] else {
let SystemItem::TaskReminder { body, .. } = &queued[0].item else {
panic!("unexpected system item: {:?}", queued[0]);
};
assert!(body.starts_with("Current session steps are listed below."));
@@ -339,7 +341,7 @@ mod tests {
}
let queued = pending.lock().expect("pending queue poisoned");
let SystemItem::TaskReminder { source, body, .. } = &queued[0] else {
let SystemItem::TaskReminder { source, body, .. } = &queued[0].item else {
panic!("unexpected system item: {:?}", queued[0]);
};
assert_eq!(*source, SystemReminderSource::TaskInactivity);
@@ -452,7 +454,7 @@ mod tests {
}
let queued = pending.lock().expect("pending queue poisoned");
assert_eq!(queued.len(), 1);
let SystemItem::TaskReminder { body, .. } = &queued[0] else {
let SystemItem::TaskReminder { body, .. } = &queued[0].item else {
panic!("unexpected system item: {:?}", queued[0]);
};
assert!(body.contains("taskid 1 (completed) done"));
+402 -3
View File
@@ -509,6 +509,15 @@ const TICKET_SERVICE_VERSION: &str = "1";
pub trait TicketService: Send + Sync {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
/// Typed creation without a synthetic Tool context. The default keeps legacy
/// handoff-only fixture implementations fail-closed; production installs
/// provide creation through the shared authorized backend dispatch.
fn create(&self, _input: NewTicket) -> TicketResult<TicketRef> {
Err(TicketError::Conflict(
"Ticket creation service is unavailable".into(),
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -526,6 +535,7 @@ fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
struct WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend,
access: TicketFeatureAccess,
}
fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketError> {
@@ -545,6 +555,17 @@ impl TicketService for WorkspaceTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
}
fn create(&self, input: NewTicket) -> TicketResult<TicketRef> {
// Services retain the same Profile ceiling as Tools and WIP. Host
// permission cannot turn a read-only Feature surface into an author.
if !self.access.authoring {
return Err(TicketError::Conflict(
"Ticket creation is disabled by the Profile authoring policy".into(),
));
}
self.backend.create(input)
}
}
fn ticket_workflow_instruction() -> FeatureInstructionDeclaration {
@@ -801,8 +822,18 @@ impl FeatureModule for TicketFeature {
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let workspace_client = self.workspace_client();
// Direct new registry installs may not have a matching manifest switch.
// Restore retains saved composition; operations still require live authority.
if !context.is_restoring() {
workspace_client
.authorize_feature_activation(FEATURE_ID)
.map_err(|error| {
FeatureInstallError::Install(format!("Ticket activation rejected: {error}"))
})?;
}
let ticket_service: Arc<dyn TicketService> = Arc::new(WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(workspace_client.clone()),
access: self.access,
});
context.services().provide(
ServiceDeclaration::new(
@@ -1104,6 +1135,22 @@ impl WorkspaceHttpTicketBackend {
"Tickets must be created open; closing requires a case judgment".into(),
));
}
// Shared trusted boundary for Tool, WIP and Ticket service callers.
// Activation (`ticket`) is not `ticket.create`. Other Ticket operations
// are not yet adapted to this seam; missing checks there are not
// supplied automatically by a universal Tool executor.
client
.authorize_operation(
"ticket.create",
&server_api::authorization::PermissionResource::Workspace {
workspace_id: workspace_id.clone(),
},
)
.map_err(|error| {
TicketError::Conflict(format!(
"Ticket create authorization failed: {error}"
))
})?;
let ticket = Self::request(
client,
WorkspaceRequestMethod::Post,
@@ -3541,9 +3588,13 @@ mod tests {
}
fn workspace_feature(access: TicketFeatureAccess) -> TicketFeature {
let client: Arc<dyn WorkspaceClient> = Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
);
let client: Arc<dyn WorkspaceClient> = Arc::new(CreateAuthorizationClient {
workspace: Some("workspace"),
activation_allowed: true,
allowed: true,
backend_status: 200,
requests: Default::default(),
});
ticket_tools_feature(client, access)
}
@@ -4075,6 +4126,7 @@ mod tests {
});
let service = WorkspaceTicketService {
access: TicketFeatureAccess::read_only(),
backend: WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
)),
@@ -4095,11 +4147,358 @@ mod tests {
}
}
// This suite keeps the real Feature/Tool/WIP/backend-dispatch code; only the
// Host transport is replaced. Host grant/storage behavior is tested at Server.
#[derive(Debug)]
struct CreateAuthorizationClient {
workspace: Option<&'static str>,
activation_allowed: bool,
allowed: bool,
backend_status: u16,
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl WorkspaceClient for CreateAuthorizationClient {
fn workspace_id(&self) -> Option<&str> {
self.workspace
}
fn kind(&self) -> &str {
"create-authorization-fixture"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
let authorization = request.path.ends_with("/authorization/check");
let mut permitted = self.allowed;
if authorization {
assert_eq!(request.method, WorkspaceRequestMethod::Post);
let body: Value = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
if body["operation"] == "ticket" {
assert_eq!(
body,
json!({"operation":"ticket", "resource":{"kind":"feature"}})
);
permitted = self.activation_allowed;
} else {
assert_eq!(
body,
json!({"operation":"ticket.create", "resource":{"kind":"workspace","workspace_id":"workspace"}})
);
}
} else {
assert_eq!(request.path, "/api/w/workspace/tickets");
}
self.requests.lock().unwrap().push(request);
Ok(crate::worker::WorkspaceResponse {
status: if authorization {
if permitted { 200 } else { 403 }
} else {
self.backend_status
},
body: if authorization {
if permitted {
"{}".into()
} else {
json!({"message":"Ticket permission revoked"}).to_string()
}
} else if self.backend_status != 200 {
json!({"error":"Forbidden", "message":"backend create denied", "diagnostics":[]}).to_string()
} else {
serde_json::to_string(&TicketRef {
id: "created-1".into(),
resource_key: Some("T-1".into()),
slug: "created".into(),
status: ticket::TicketStatus::Open,
})
.unwrap()
},
})
}
}
#[tokio::test]
async fn create_authorization_guards_tool_wip_installed_service_and_backend_without_local_root()
{
for (allowed, backend_status) in [(false, 200), (true, 200), (true, 403)] {
for surface in ["tool", "wip", "service", "backend"] {
let client = Arc::new(CreateAuthorizationClient {
workspace: Some("workspace"),
activation_allowed: true,
allowed,
backend_status,
requests: Default::default(),
});
let result = match surface {
"backend" => WorkspaceHttpTicketBackend::new(client.clone())
.create(NewTicket::new("Create"))
.map(|_| ())
.map_err(|error| error.to_string()),
"service" => {
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(
client.clone(),
TicketFeatureAccess::workspace_authoring(),
))
.install_into_pending(
&mut Vec::new(),
&mut HookRegistryBuilder::default(),
);
assert!(!report.has_errors(), "{}", report.error_message());
let service = report
.services
.service::<dyn TicketService>(&ServiceId::builtin(TICKET_SERVICE_ID))
.unwrap();
service
.create(NewTicket::new("Create"))
.map(|_| ())
.map_err(|error| error.to_string())
}
"tool" => {
let tools = enabled_ticket_definitions(
client.clone(),
TicketFeatureAccess::workspace_authoring(),
);
let (_, tool) = tools
.into_iter()
.find(|definition| definition().0.name == "TicketCreate")
.unwrap()();
tool.execute(r#"{"title":"Create"}"#, ToolExecutionContext::direct())
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
_ => {
let tools = native_ticket_tools(
client.clone(),
TicketFeatureAccess::workspace_authoring(),
)
.unwrap();
let handler = TicketCollectionWipHandler {
tools: operation_map(tools),
permissions: None,
collection_route: "/tickets".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
handler
.call(
"create",
&BTreeMap::from([(
"title".into(),
WipValue::String("Create".into()),
)]),
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "test".into(),
},
)
.await
.map(|_| ())
.map_err(|error| match error {
WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
message,
..
}) => message,
_ => panic!("a definitive denial is not outcome-unknown"),
})
}
};
assert_eq!(
result.is_ok(),
allowed && backend_status == 200,
"{surface}: {result:?}"
);
if allowed && backend_status == 403 {
let error = result.unwrap_err();
assert!(error.contains("HTTP status 403"), "{surface}: {error}");
assert!(
error.contains("backend create denied"),
"{surface}: {error}"
);
}
let requests = client.requests.lock().unwrap();
assert_eq!(
requests.len(),
1 + usize::from(allowed) + usize::from(surface == "service"),
"{surface}"
);
assert!(requests[0].path.ends_with("/authorization/check"));
assert_eq!(
requests
.iter()
.filter(|request| request.path.ends_with("/tickets"))
.count(),
usize::from(allowed)
);
}
}
}
#[test]
fn direct_ticket_install_requires_activation_even_without_manifest_switch() {
assert!(!manifest::FeatureConfig::default().ticket.enabled);
for create_allowed in [false, true] {
let client = Arc::new(CreateAuthorizationClient {
workspace: Some("workspace"),
activation_allowed: false,
allowed: create_allowed,
backend_status: 200,
requests: Default::default(),
});
let mut tools = Vec::new();
// No manifest-config guard: this is the explicit module install path.
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(
client.clone(),
TicketFeatureAccess::workspace_authoring(),
))
.install_into_pending(&mut tools, &mut HookRegistryBuilder::default());
assert!(report.has_errors());
assert!(
report
.error_message()
.contains("Ticket activation rejected")
);
assert!(report.error_message().contains("HTTP status 403"));
assert!(tools.is_empty());
assert!(report.installed_instruction_contributions().is_empty());
assert!(report.services.providers().is_empty());
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
}
#[test]
fn restored_ticket_keeps_tools_and_services_but_rejects_revoked_operation() {
let client = Arc::new(CreateAuthorizationClient {
workspace: Some("workspace"),
activation_allowed: false,
allowed: false,
backend_status: 200,
requests: Default::default(),
});
let mut tools = Vec::new();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(
client.clone(),
TicketFeatureAccess::workspace_authoring(),
))
.with_installation(crate::feature::FeatureInstallation::Restore)
.install_into_pending(&mut tools, &mut HookRegistryBuilder::default());
assert!(!report.has_errors(), "{}", report.error_message());
assert!(tools.iter().any(|tool| tool().0.name == "TicketCreate"));
assert!(!report.installed_instruction_contributions().is_empty());
assert!(client.requests.lock().unwrap().is_empty());
let service = report
.services
.service::<dyn TicketService>(&ServiceId::builtin(TICKET_SERVICE_ID))
.unwrap();
let error = service
.create(NewTicket::new("Revoked operation"))
.unwrap_err();
assert!(error.to_string().contains("HTTP status 403"), "{error}");
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1, "denial must precede the backend effect");
assert!(requests[0].path.ends_with("/authorization/check"));
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["operation"], "ticket.create");
}
#[test]
fn installed_ticket_service_readonly_profile_cannot_create_with_host_permission() {
let client = Arc::new(CreateAuthorizationClient {
workspace: Some("workspace"),
activation_allowed: true,
allowed: true,
backend_status: 200,
requests: Default::default(),
});
let mut tools = Vec::new();
let report = FeatureRegistryBuilder::new()
.with_module(ticket_tools_feature(
client.clone(),
TicketFeatureAccess::read_only(),
))
.install_into_pending(&mut tools, &mut HookRegistryBuilder::default());
assert!(!report.has_errors(), "{}", report.error_message());
assert!(!tools.iter().any(|tool| tool().0.name == "TicketCreate"));
let service = report
.services
.service::<dyn TicketService>(&ServiceId::builtin(TICKET_SERVICE_ID))
.unwrap();
assert!(
service
.create(NewTicket::new("No profile bypass"))
.unwrap_err()
.to_string()
.contains("Profile authoring policy")
);
let requests = client.requests.lock().unwrap();
assert_eq!(
requests.len(),
1,
"only activation; no create check or backend effect"
);
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["operation"], "ticket");
}
#[test]
fn legacy_handoff_only_service_does_not_gain_creation() {
struct LegacyService;
impl TicketService for LegacyService {
fn ticket_handoff(&self, _: &str) -> Result<TicketHandoff, TicketError> {
unreachable!("creation must not call handoff")
}
}
assert!(
LegacyService
.create(NewTicket::new("No implicit creation"))
.unwrap_err()
.to_string()
.contains("unavailable")
);
}
#[test]
fn create_without_workspace_identity_fails_before_transport_and_backend_denial_is_preserved() {
for (workspace, allowed, backend_status, expected_calls) in
[(None, true, 200, 0), (Some("workspace"), true, 403, 2)]
{
let client = Arc::new(CreateAuthorizationClient {
workspace,
activation_allowed: true,
allowed,
backend_status,
requests: Default::default(),
});
let result =
WorkspaceHttpTicketBackend::new(client.clone()).create(NewTicket::new("Create"));
assert!(result.is_err());
assert_eq!(client.requests.lock().unwrap().len(), expected_calls);
}
}
#[test]
fn workspace_http_backend_executes_ticket_create_operation() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
let (mut authorization, _) = listener.accept().unwrap();
authorization
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
let mut buffer = [0_u8; 8192];
let len = authorization.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with("POST /api/w/workspace-a/authorization/check HTTP/1.1"));
assert!(request.contains("\"operation\":\"ticket.create\""));
assert!(request.contains("\"workspace_id\":\"workspace-a\""));
write!(authorization, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}").unwrap();
drop(authorization);
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
@@ -813,6 +813,7 @@ mod tests {
_ => return None,
};
Some(protocol::SessionSnapshotEntry {
use_policy: None,
entry_id: format!("fake-{index:08}"),
timestamp: index as u64,
provenance: protocol::SessionEntryProvenance::LegacyUnknown,
@@ -47,6 +47,12 @@ use crate::wip::{
pub(super) struct ConfigProvider {
pub feature: WorkspaceConfigFeature,
pub backend: Arc<WorkspaceConfigBackend>,
// A fresh mount after restore invalidates observations from its predecessor.
pub observation_nonce: Arc<str>,
}
fn object_validator(nonce: &str, connection: &str, validator: &str) -> Vec<u8> {
Sha256::digest(format!("{nonce}:{connection}:{validator}")).to_vec()
}
/// Backend tree root is empty; source names are canonical relative config paths.
@@ -240,7 +246,7 @@ impl ConfigProvider {
let descriptor = descriptor(&operations);
let interface = contextual_reference("yoi.workspace-config/node/v1", path);
let interface_validator = Some(Sha256::digest(format!("{descriptor:?}")).to_vec());
Ok(Some(WipProjection {
let mut projection = WipProjection {
route: path.into(),
capability: "workspace-config:content".into(),
kind: WipProjectionKind::Native,
@@ -252,7 +258,11 @@ impl ConfigProvider {
)),
interfaces: vec![interface.clone()],
r#ref: None,
validator: Some(node.validator.as_bytes().to_vec()),
validator: Some(object_validator(
&self.observation_nonce,
&attachment.connection_id,
&node.validator,
)),
},
interface,
descriptor,
@@ -263,9 +273,12 @@ impl ConfigProvider {
attachment,
snapshot,
node,
operations,
operations: operations.clone(),
observation_nonce: self.observation_nonce.clone(),
}),
}))
};
crate::file_operation::expose(&mut projection, common_contracts(&operations));
Ok(Some(projection))
}
}
@@ -335,12 +348,28 @@ fn operations(
names.push(name.into());
}
}
if names.iter().any(|name| name == "read") {
names.extend(["read_lines".into(), "search_text".into()]);
}
if root {
names.push("attach".into());
}
names
}
fn common_contracts(names: &[String]) -> Vec<crate::file_operation::Contract> {
use crate::file_operation::Contract;
[
("read_lines", Contract::ReadLines),
("search_text", Contract::SearchText),
("edit", Contract::Edit),
("write", Contract::Write),
]
.into_iter()
.filter_map(|(name, contract)| names.iter().any(|n| n == name).then_some(contract))
.collect()
}
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
ParameterDeclaration {
name: name.into(),
@@ -356,11 +385,11 @@ fn descriptor(names: &[String]) -> InterfaceDescriptor {
summary: "Logical Workspace configuration file operations".into(),
details: Some("Backend grants, semantic validation, atomic whole-tree CAS and activation apply. Validators and change digests are managed automatically. apply_changes atomically accepts create/update/delete/rename changes without validators or expected_digest fields. Saving updates configuration for future operation boundaries, not already-running Worker manifests. No OS or command access.".into()),
}),
operations: names.iter().map(|name| OperationDeclaration {
operations: names.iter().filter(|name| !matches!(name.as_str(), "read_lines" | "search_text" | "edit" | "write")).map(|name| OperationDeclaration {
name: name.clone(), documentation: None,
parameters: match name.as_str() {
"attach" => vec![parameter("access", false, TypeExpr::String)],
"read" | "write" | "edit" => crate::file_operation::parameters(name, false).unwrap(),
"read" => crate::file_operation::parameters(name, false).unwrap(),
"delete" => vec![],
"create" => vec![parameter("path", false, TypeExpr::String), parameter("content", true, TypeExpr::String), parameter("content_type", false, TypeExpr::String)],
"apply_changes" => vec![parameter("changes", true, TypeExpr::Json)],
@@ -378,6 +407,7 @@ struct ConfigHandler {
snapshot: WorkspaceConfigObserveResponse,
node: WorkspaceConfigNode,
operations: Vec<String>,
observation_nonce: Arc<str>,
}
#[derive(Debug, Deserialize)]
@@ -476,13 +506,18 @@ impl ConfigHandler {
|| response.validator != self.node.validator
|| Some(&response.digest) != self.node.digest.as_ref()
|| Some(&response.content_type) != self.node.content_type.as_ref()
|| response.content.len() > MAX_TEXT_BYTES
{
return Err(failure(
ProtocolErrorCode::Internal,
"Invalid Workspace config read response",
));
}
if response.content.len() > MAX_TEXT_BYTES {
return Err(failure(
ProtocolErrorCode::ResourceLimitExceeded,
"Workspace config source exceeds text limit",
));
}
let mut response = response;
response.content = fs_operation::text::read(response.content, text_limits())
.map_err(text_error)?
@@ -628,11 +663,33 @@ impl ConfigHandler {
));
}
// Direct handler calls also enforce parameter shape; no model validators accepted.
let declaration = descriptor(&self.operations)
.operations
.into_iter()
.find(|op| op.name == operation)
.ok_or_else(invalid)?;
let declaration = match operation {
"read_lines" => crate::file_operation::Contract::ReadLines
.descriptor()
.operations
.into_iter()
.next(),
"search_text" => crate::file_operation::Contract::SearchText
.descriptor()
.operations
.into_iter()
.next(),
"edit" => crate::file_operation::Contract::Edit
.descriptor()
.operations
.into_iter()
.next(),
"write" => crate::file_operation::Contract::Write
.descriptor()
.operations
.into_iter()
.next(),
_ => descriptor(&self.operations)
.operations
.into_iter()
.find(|op| op.name == operation),
}
.ok_or_else(invalid)?;
if arguments
.keys()
.any(|name| !declaration.parameters.iter().any(|p| p.name == *name))
@@ -646,6 +703,19 @@ impl ConfigHandler {
.call(operation, arguments, context)
.await;
}
if matches!(operation, "read_lines" | "search_text") {
crate::file_operation::text_result(operation, arguments, "")?;
let result = self.read().await?;
let value = crate::file_operation::text_result(operation, arguments, &result.content)?;
return Ok(WipOperationOutput::native_with_validator(
value,
object_validator(
&self.observation_nonce,
&self.attachment.connection_id,
&result.validator,
),
));
}
if operation == "read" {
let _: fs_operation::text::ReadArgs = decode_text(arguments)?;
let result = self.read().await?;
@@ -657,7 +727,11 @@ impl ConfigHandler {
.map_err(|_| invalid())?;
return Ok(WipOperationOutput::native_with_validator(
value,
self.node.validator.as_bytes().to_vec(),
object_validator(
&self.observation_nonce,
&self.attachment.connection_id,
&self.node.validator,
),
));
}
if self.attachment.access != WorkspaceConfigAccess::ReadWrite {
@@ -762,7 +836,11 @@ impl ConfigHandler {
"activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?;
Ok(WipOperationOutput::native_with_validator(
value,
node.validator.as_bytes().to_vec(),
object_validator(
&self.observation_nonce,
&self.attachment.connection_id,
&node.validator,
),
))
}
}
@@ -466,8 +466,19 @@ async fn call(
runtime
.call(
path.into(),
interface(path, true),
op.into(),
match op {
"edit" => crate::file_operation::Contract::Edit.reference(),
"write" => crate::file_operation::Contract::Write.reference(),
"read_lines" => crate::file_operation::Contract::ReadLines.reference(),
"search_text" => crate::file_operation::Contract::SearchText.reference(),
_ => interface(path, true),
},
match op {
"read_lines" => "read",
"search_text" => "search",
_ => op,
}
.into(),
args,
ToolExecutionContext::direct(),
)
@@ -825,11 +836,11 @@ async fn workspace_config_production_reattach_new_lifetime_rejects_old_same_byte
client.state.lock().unwrap().active = None;
let second = feature.attach("second", None).await.unwrap();
assert_ne!(first.connection_id, second.connection_id);
assert!(
call(&runtime, path, "write", json!({"content":"bad"}))
.await
.is_err()
);
let error = call(&runtime, path, "write", json!({"content":"bad"}))
.await
.unwrap_err()
.to_string();
assert!(error.contains("ValidatorMismatch"), "{error}");
assert!(client.state.lock().unwrap().commits.is_empty());
observe_interface(&runtime, path, true).await;
call(&runtime, path, "write", json!({"content":"new-lifetime"}))
@@ -958,3 +969,189 @@ async fn workspace_config_production_ungranted_root_does_not_poison_worldspace_d
.all(|r| r.method == WorkspaceRequestMethod::Get)
);
}
#[tokio::test]
async fn common_read_only_text_windows_search_and_rejections_preserve_backend_authority() {
let client = Router::new();
{
let mut state = client.state.lock().unwrap();
state.read_only = true;
state.entries.get_mut("main.dcdl").unwrap().1 = "α\r\nneedle.*\nNeedle.*\nlast\n".into();
}
let (feature, runtime) = runtime(client.clone());
feature.attach("read-only", None).await.unwrap();
let path = "/workspace-config/main.dcdl";
observe_interface(&runtime, path, true).await;
let object = runtime
.inspect(path.into(), true)
.await
.unwrap()
.content
.unwrap();
assert!(object.contains(&crate::file_operation::Contract::ReadLines.reference().name));
assert!(object.contains(&crate::file_operation::Contract::SearchText.reference().name));
assert!(!object.contains(&crate::file_operation::Contract::Write.reference().name));
assert!(!object.contains(&crate::file_operation::Contract::Edit.reference().name));
let read = call(&runtime, path, "read_lines", json!({"offset":1,"limit":2}))
.await
.unwrap();
let read: Json = serde_json::from_str(read.content.as_deref().unwrap()).unwrap();
assert!(read["content"].as_str().unwrap().contains("needle.*"));
assert!(read["content"].as_str().unwrap().contains("Needle.*"));
assert!(!read["content"].as_str().unwrap().contains('α'));
assert_eq!(read["next_offset"], 3);
assert_eq!(read["truncated"], true);
let search = call(
&runtime,
path,
"search_text",
json!({"pattern":"needle.*","limit":3}),
)
.await
.unwrap();
let search: Json = serde_json::from_str(search.content.as_deref().unwrap()).unwrap();
assert_eq!(search["matches"], json!([{"line":2,"text":"needle.*"}]));
assert_eq!(search["next_offset"], 3);
assert_eq!(search["truncated"], true);
for (op, arguments) in [
("read_lines", json!({"limit":0})),
("read_lines", json!({"limit":2001})),
("search_text", json!({"pattern":""})),
(
"search_text",
json!({"pattern":"x","case_insensitive":true}),
),
] {
let before = client
.state
.lock()
.unwrap()
.requests
.iter()
.filter(|r| r.path.ends_with("/read"))
.count();
assert!(call(&runtime, path, op, arguments).await.is_err());
let after = client
.state
.lock()
.unwrap()
.requests
.iter()
.filter(|r| r.path.ends_with("/read"))
.count();
assert_eq!(before, after);
}
assert!(
call(&runtime, path, "write", json!({"content":"denied"}))
.await
.is_err()
);
client.state.lock().unwrap().granted = false;
assert!(call(&runtime, path, "read_lines", json!({})).await.is_err());
assert!(client.state.lock().unwrap().commits.is_empty());
}
#[tokio::test]
async fn common_text_read_refuses_oversized_rendered_window() {
let client = Router::new();
let (feature, runtime) = runtime(client.clone());
feature.attach("bounded", None).await.unwrap();
let path = "/workspace-config/main.dcdl";
client
.state
.lock()
.unwrap()
.entries
.get_mut("main.dcdl")
.unwrap()
.1 = "x".repeat(64 * 1024);
observe_interface(&runtime, path, true).await;
let error = call(&runtime, path, "read_lines", json!({}))
.await
.unwrap_err()
.to_string();
assert!(error.contains("ResourceLimitExceeded"), "{error}");
assert!(client.state.lock().unwrap().commits.is_empty());
}
#[tokio::test]
async fn workspace_config_backend_validators_reject_path_and_connection_transplant_even_for_identical_sources()
{
use crate::wip::WipOperationError;
use wip_protocol::ProtocolErrorCode;
let client = Router::new();
let path = "profiles/team/deep/model.dcdl";
client
.state
.lock()
.unwrap()
.entries
.get_mut(path)
.unwrap()
.1 = "model = old".into();
let (feature, runtime) = runtime(client.clone());
let first = feature.attach("path-binding", None).await.unwrap();
let backend = backend::WorkspaceConfigBackend::new(client.clone());
let (main_validator, deep_validator) = {
let state = client.state.lock().unwrap();
(state.validator("main.dcdl"), state.validator(path))
};
assert_ne!(main_validator, deep_validator);
let read = |connection_id: String, validator: String| WorkspaceConfigReadRequest {
connection_id,
path: path.into(),
validator,
};
let result = backend
.post::<WorkspaceConfigReadResponse, _>(
"/read",
&read(first.connection_id.clone(), main_validator),
false,
)
.await;
assert!(
matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch)
);
let result: WorkspaceConfigReadResponse = backend
.post(
"/read",
&read(first.connection_id.clone(), deep_validator.clone()),
false,
)
.await
.unwrap_or_else(|_| panic!("correct path validator rejected"));
assert_eq!(result.content, "model = old");
assert_eq!(result.validator, deep_validator);
client.state.lock().unwrap().active = None;
let second = feature.attach("connection-binding", None).await.unwrap();
assert_ne!(first.connection_id, second.connection_id);
for connection in [first.connection_id, second.connection_id.clone()] {
let result = backend
.post::<WorkspaceConfigReadResponse, _>(
"/read",
&read(connection, deep_validator.clone()),
false,
)
.await;
assert!(
matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch)
);
}
// Fresh path/connection observation restores native common mutation access,
// and the returned exact post-operation validator also supports common reads.
let route = format!("/workspace-config/{path}");
observe_interface(&runtime, &route, true).await;
call(
&runtime,
&route,
"write",
json!({"content":"model = updated"}),
)
.await
.unwrap();
let result = call(&runtime, &route, "read_lines", json!({}))
.await
.unwrap();
assert!(result.content.unwrap().contains("model = updated"));
assert_eq!(client.state.lock().unwrap().commits.len(), 1);
}
@@ -45,6 +45,7 @@ pub fn mount_workspace_config_wip(
provider: Arc::new(super::content::ConfigProvider {
feature: feature.clone(),
backend: backend.clone(),
observation_nonce: uuid::Uuid::now_v7().to_string().into(),
}),
})?;
}
+39
View File
@@ -39,6 +39,45 @@ const MAX_INVOCATION_DESCRIPTOR_BYTES: usize =
protocol::invocation::MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES;
const MAX_COMPLETION_RESPONSE_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct InvocationResultInput {
pub result: FeatureInvocationResult,
}
pub(crate) fn result_input(
result: FeatureInvocationResult,
body: String,
) -> Result<session_store::LoggedSystemHistoryEntry, session_store::InputMetadataError> {
use session_store::*;
let payload = FeatureInputPayload::encode(
"feature:invocation-result",
1,
&InvocationResultInput { result },
)?;
let metadata = HistoryInputMetadata::new(
HistoryInputUsePolicy {
recording_material: InputEvidenceUse::Exclude,
memory_evidence: InputEvidenceUse::Exclude,
observation: InputObservationUse::Hidden,
compaction: InputCompactionUse::Retain,
},
Some(payload),
Some(PublicSystemItem {
kind: "feature_invocation_result".into(),
body: body.clone(),
presentation: PublicSystemPresentation::Context,
}),
)?;
Ok(LoggedSystemHistoryEntry::from_input(
LoggedSessionHistoryOrigin::Provided {
producer: "feature.invocation".into(),
},
None,
SystemInputEnvelope::new(body, metadata)?,
))
}
pub(crate) fn invocation_result_is_bounded(result: &FeatureInvocationResult) -> bool {
result.message.len() <= MAX_INVOCATION_MESSAGE_BYTES
&& result
+660 -2
View File
@@ -1,5 +1,15 @@
//! WIP presentation of the provider-neutral file argument contracts. Providers
//! choose which read surface to publish; routing and preconditions are not args.
//! Stable root file contracts, independent of provider and target lifetimes.
//!
//! Each capability has one versioned reference and complete descriptor. Providers publish only
//! supported combinations. Root scope is a namespace, never a file grant. Current Object
//! membership selects the provider handler; target observations, checked operations, prior Read,
//! CAS and grants remain provider-owned. Contract validators contain no target or connection state.
//!
//! Line windows, byte ranges and whole-text domain reads are different meanings, not aliases.
//! Settings atomic commits and Drive management/cursor pagination remain domain extensions.
//! Common mutations return one metadata envelope while providers retain their commit details.
//! Old contextual references in history are neither rewritten nor accepted as aliases; callers
//! reobserve Objects and common definitions after migration or restore.
use fs_operation::text::TextOperation;
use wip_protocol::{ParameterDeclaration, TypeExpr};
@@ -41,3 +51,651 @@ fn schema_parameters(schema: &schemars::Schema) -> Vec<ParameterDeclaration> {
})
.collect()
}
use crate::wip::{
WipCallContext, WipOperationError, WipOperationHandler, WipOperationOutput, WipProjection,
};
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, sync::Arc};
use wip_protocol::{
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceReference,
OperationDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, Value,
};
pub(crate) const MAX_LINES: usize = 2000;
pub(crate) const MAX_BYTES: usize = 64 * 1024;
/// Contracts have independent capability membership and no target-dependent descriptor fields.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Contract {
ReadBytes,
ReadLines,
SearchText,
Edit,
Write,
List,
Glob,
Grep,
CreateFile,
}
impl Contract {
pub(crate) fn reference(self) -> InterfaceReference {
crate::wip::root_reference(match self {
Self::ReadLines => "yoi.file/read-lines/v1",
Self::ReadBytes => "yoi.file/read-bytes/v1",
Self::SearchText => "yoi.file/search-text/v1",
Self::Edit => "yoi.file/edit-text/v1",
Self::Write => "yoi.file/write-text/v1",
Self::List => "yoi.directory/list/v1",
Self::Glob => "yoi.directory/glob/v1",
Self::Grep => "yoi.directory/grep/v1",
Self::CreateFile => "yoi.directory/create-file/v1",
})
}
pub(crate) fn from_reference(r: &InterfaceReference) -> Option<Self> {
[
Self::ReadLines,
Self::ReadBytes,
Self::SearchText,
Self::Edit,
Self::Write,
Self::List,
Self::Glob,
Self::Grep,
Self::CreateFile,
]
.into_iter()
.find(|c| c.reference() == *r)
}
pub(crate) fn operation(self) -> &'static str {
match self {
Self::ReadLines | Self::ReadBytes => "read",
Self::SearchText => "search",
Self::Edit => "edit",
Self::Write => "write",
Self::List => "list",
Self::Glob => "glob",
Self::Grep => "grep",
Self::CreateFile => "create_file",
}
}
pub(crate) fn handler_operation(self) -> &'static str {
match self {
Self::ReadLines => "read_lines",
Self::ReadBytes => "read_bytes",
Self::SearchText => "search_text",
_ => self.operation(),
}
}
pub(crate) fn descriptor(self) -> InterfaceDescriptor {
let (summary, parameters, result) = match self {
Self::ReadBytes => (
"Read a bounded binary byte range",
vec![
parameter("offset", false, TypeExpr::Integer),
parameter("limit", false, TypeExpr::Integer),
],
TypeExpr::Record {
fields: vec![
field("encoding", true, TypeExpr::String),
field("data", true, TypeExpr::String),
field("offset", true, TypeExpr::Integer),
field("next_offset", true, TypeExpr::Integer),
field("eof", true, TypeExpr::Boolean),
],
},
),
Self::ReadLines => (
"Read a bounded text line window",
parameters("read", true).unwrap(),
page_type(false),
),
Self::SearchText => {
let mut p = parameters("read", true).unwrap();
p.push(parameter("pattern", true, TypeExpr::String));
(
"Search a bounded text line window for a literal",
p,
page_type(true),
)
}
Self::Edit | Self::Write => (
"Change existing text under provider preconditions",
parameters(self.operation(), true).unwrap(),
mutation_type(),
),
Self::List => (
"List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; limit defaults to 100, maximum 1000; after continues by kind/entry. Includes hidden/ignored names under provider List policy, unlike Glob/Grep.",
vec![
parameter("limit", false, TypeExpr::Integer),
parameter("after", false, cursor_type()),
],
result_type(true),
),
Self::Glob => (
"Glob within this directory",
vec![
parameter("pattern", true, TypeExpr::String),
parameter("path", false, TypeExpr::String),
],
result_type(false),
),
Self::Grep => (
"Regex search within this directory",
grep_parameters(),
result_type(false),
),
Self::CreateFile => (
"Create a new relative file and permitted missing parents; never overwrite",
vec![
parameter("path", true, TypeExpr::String),
parameter("content", true, TypeExpr::String),
],
mutation_type(),
),
};
let details = match self {
Self::ReadBytes => {
"Binary-safe byte range, independent of UTF-8 and line boundaries. Offset defaults to 0; limit defaults to 16384 and must be 1..=32768 bytes. Offset must be <=9007199254708223. Returns encoding=base64 with RFC4648 standard padded data, offset and next_offset in bytes, eof when no later bytes remain. At EOF returns empty data, eof=true and next_offset=offset; offsets beyond EOF are InvalidArguments. Encoded pretty JSON result is bounded to 64 KiB. Does not establish text prior-Read for mutation. InvalidArguments rejects ranges/unknown fields, ResourceLimitExceeded bounds, ValidatorMismatch stale source, PermissionDenied authority. Provider source bounds apply; no automatic retry."
}
Self::ReadLines | Self::SearchText => {
"UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry."
}
Self::Edit | Self::Write | Self::CreateFile => {
"UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry."
}
_ => {
"Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry."
}
};
InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
types: vec![],
documentation: Some(Documentation {
summary: summary.into(),
details: Some(details.into()),
}),
operations: vec![OperationDeclaration {
name: self.operation().into(),
documentation: None,
parameters,
returns: ReturnDeclaration {
documentation: None,
r#type: result,
},
}],
}
}
pub(crate) fn validator(self) -> Vec<u8> {
Sha256::digest(format!("{:?}", self.descriptor())).to_vec()
}
}
pub(crate) fn expose(p: &mut WipProjection, contracts: impl IntoIterator<Item = Contract>) {
for c in contracts {
let r = c.reference();
if !p.object.interfaces.contains(&r) {
p.object.interfaces.push(r);
}
}
}
/// Select only from current provider membership; the selected handler still owns all authority.
pub(crate) fn select(p: &mut WipProjection, r: &InterfaceReference) -> bool {
let Some(c) = Contract::from_reference(r) else {
return false;
};
if !p.object.interfaces.contains(r) {
return false;
}
p.interface = r.clone();
p.descriptor = c.descriptor();
p.interface_validator = Some(c.validator());
p.handler = Arc::new(Selected {
contract: c,
inner: p.handler.clone(),
});
true
}
struct Selected {
contract: Contract,
inner: Arc<dyn WipOperationHandler>,
}
#[async_trait]
impl WipOperationHandler for Selected {
async fn call(
&self,
_: &str,
args: &BTreeMap<String, Value>,
context: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
let mut out = self
.inner
.call(self.contract.handler_operation(), args, context)
.await?;
if matches!(
self.contract,
Contract::Edit | Contract::Write | Contract::CreateFile
) {
out.value = Value::Record(BTreeMap::from([("metadata".into(), out.value)]));
}
Ok(out)
}
async fn cancel_operation(
&self,
_: &str,
ctx: &agen::tool::ToolExecutionContext,
) -> Result<(), agen::tool::ToolError> {
self.inner
.cancel_operation(self.contract.handler_operation(), ctx)
.await
}
}
fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError {
WipOperationError::Protocol(ProtocolError {
code,
message: message.into(),
})
}
pub(crate) fn window(
args: &BTreeMap<String, Value>,
search: bool,
) -> Result<(usize, usize), WipOperationError> {
if args
.keys()
.any(|k| !["offset", "limit"].contains(&k.as_str()) && !(search && k == "pattern"))
{
return Err(failure(
ProtocolErrorCode::InvalidArguments,
"unknown text argument",
));
}
let integer = |name: &str, default: usize| match args.get(name) {
None => Ok(default),
Some(Value::Integer(i)) => usize::try_from(*i)
.map_err(|_| failure(ProtocolErrorCode::InvalidArguments, "negative text range")),
_ => Err(failure(
ProtocolErrorCode::InvalidArguments,
"invalid text range",
)),
};
let offset = integer("offset", 0)?;
let limit = integer("limit", MAX_LINES)?;
if !(1..=MAX_LINES).contains(&limit)
|| (offset as u64) > 9_007_199_254_740_991u64 - MAX_LINES as u64
{
return Err(failure(
ProtocolErrorCode::InvalidArguments,
"text range exceeds bounds",
));
}
if search {
match args.get("pattern") {
Some(Value::String(p)) if !p.is_empty() && p.len() <= 4096 => {}
_ => {
return Err(failure(
ProtocolErrorCode::InvalidArguments,
"search requires a nonempty literal pattern up to 4096 bytes",
));
}
}
}
Ok((offset, limit))
}
pub(crate) fn text_result(
op: &str,
args: &BTreeMap<String, Value>,
content: &str,
) -> Result<Value, WipOperationError> {
let (offset, limit) = window(args, op == "search_text")?;
let mut lines = content.lines().skip(offset);
let mut text = String::new();
for line in lines.by_ref().take(limit) {
if text.len().saturating_add(line.len()).saturating_add(1) >= MAX_BYTES {
return Err(failure(
ProtocolErrorCode::ResourceLimitExceeded,
"text window exceeds source byte bound",
));
}
text.push_str(line);
text.push('\n');
}
page_result(op, args, &text, offset, lines.next().is_some())
}
/// A checked provider can supply only the selected window, without copying whole source files.
pub(crate) fn page_result(
op: &str,
args: &BTreeMap<String, Value>,
text: &str,
offset: usize,
more: bool,
) -> Result<Value, WipOperationError> {
let (_, limit) = window(args, op == "search_text")?;
if text.len() >= MAX_BYTES {
return Err(failure(
ProtocolErrorCode::ResourceLimitExceeded,
"text window exceeds source byte bound",
));
}
let count = text.lines().count();
if count > limit {
return Err(failure(
ProtocolErrorCode::Internal,
"provider returned an invalid line window",
));
}
let mut fields = BTreeMap::from([
(
"next_offset".into(),
Value::Integer((offset + count) as i64),
),
("truncated".into(), Value::Boolean(more)),
]);
if op == "search_text" {
let Some(Value::String(pattern)) = args.get("pattern") else {
unreachable!("window checked pattern")
};
let matches = text
.lines()
.enumerate()
.filter(|(_, l)| l.contains(pattern))
.map(|(i, l)| {
Value::Record(BTreeMap::from([
("line".into(), Value::Integer((offset + i + 1) as i64)),
("text".into(), Value::String(l.into())),
]))
})
.collect();
fields.insert("matches".into(), Value::List(matches));
} else {
let full_len = text
.lines()
.enumerate()
.map(|(i, l)| format!("{:>6}\t", offset + i + 1).len() + l.len() + 1)
.sum::<usize>();
if full_len > MAX_BYTES.saturating_sub(64) {
return Err(failure(
ProtocolErrorCode::ResourceLimitExceeded,
"numbered window exceeds output byte bound",
));
}
let rendered =
fs_operation::text::render_numbered(text, offset, offset + count, false, MAX_BYTES);
fields.insert("content".into(), Value::String(rendered.body));
}
let value = Value::Record(fields);
let json = crate::wip::wip_to_json(&value)
.map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?;
// The model receives pretty JSON, so budget escapes and record/indentation overhead too.
let encoded = serde_json::to_vec_pretty(&json)
.map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?;
if encoded.len() > MAX_BYTES {
return Err(failure(
ProtocolErrorCode::ResourceLimitExceeded,
"serialized text window exceeds output byte bound",
));
}
Ok(value)
}
fn parameter(n: &str, r: bool, t: TypeExpr) -> ParameterDeclaration {
ParameterDeclaration {
name: n.into(),
required: r,
documentation: None,
r#type: t,
}
}
fn field(n: &str, r: bool, t: TypeExpr) -> FieldDeclaration {
FieldDeclaration {
name: n.into(),
required: r,
documentation: None,
r#type: t,
}
}
fn mutation_type() -> TypeExpr {
TypeExpr::Record {
fields: vec![field("metadata", true, TypeExpr::Json)],
}
}
fn page_type(search: bool) -> TypeExpr {
let mut fields = vec![
field("next_offset", true, TypeExpr::Integer),
field("truncated", true, TypeExpr::Boolean),
];
fields.push(if search {
field(
"matches",
true,
TypeExpr::List {
items: Box::new(TypeExpr::Record {
fields: vec![
field("line", true, TypeExpr::Integer),
field("text", true, TypeExpr::String),
],
}),
},
)
} else {
field("content", true, TypeExpr::String)
});
TypeExpr::Record { fields }
}
fn cursor_type() -> TypeExpr {
TypeExpr::Record {
fields: vec![
field("kind", true, TypeExpr::String),
field("entry", true, TypeExpr::Entry),
],
}
}
fn result_type(list: bool) -> TypeExpr {
let mut item = vec![field("entry", true, TypeExpr::Entry)];
if list {
item.extend([
field("kind", true, TypeExpr::String),
field("size", true, TypeExpr::Integer),
]);
}
let mut fields = vec![
field("summary", true, TypeExpr::String),
field("content", false, TypeExpr::String),
field(
"items",
true,
TypeExpr::List {
items: Box::new(TypeExpr::Record { fields: item }),
},
),
];
if list {
fields.extend([
field("truncated", true, TypeExpr::Boolean),
field("after", false, cursor_type()),
]);
}
TypeExpr::Record { fields }
}
fn grep_parameters() -> Vec<ParameterDeclaration> {
let mut p = vec![parameter("pattern", true, TypeExpr::String)];
for n in ["path", "glob", "type", "output_mode"] {
p.push(parameter(n, false, TypeExpr::String));
}
for n in ["-A", "-B", "-C", "head_limit", "offset"] {
p.push(parameter(n, false, TypeExpr::Integer));
}
for n in ["case_insensitive", "multiline"] {
p.push(parameter(n, false, TypeExpr::Boolean));
}
p
}
#[cfg(test)]
mod tests {
use super::*;
fn args(value: serde_json::Value) -> BTreeMap<String, Value> {
let Value::Record(args) = crate::wip::json_to_wip(&value).unwrap() else {
panic!("record")
};
args
}
#[test]
fn text_window_preserves_unicode_crlf_offsets_and_literal_search_continuation() {
let input = "first\r\n雪.*\r\nnot a match\n雪.*\n";
let arguments = args(serde_json::json!({"offset":1,"limit":2,"pattern":".*"}));
let whole = text_result("search_text", &arguments, input)
.unwrap_or_else(|_| panic!("text result refused"));
let partial = page_result("search_text", &arguments, "雪.*\r\nnot a match\n", 1, true)
.unwrap_or_else(|_| panic!("page refused"));
assert_eq!(whole, partial);
let result = crate::wip::wip_to_json(&whole).unwrap();
assert_eq!(
result,
serde_json::json!({"matches":[{"line":2,"text":"雪.*"}],"next_offset":3,"truncated":true})
);
let read = text_result(
"read_lines",
&args(serde_json::json!({"offset":1,"limit":1})),
input,
)
.unwrap_or_else(|_| panic!("read refused"));
assert_eq!(
crate::wip::wip_to_json(&read).unwrap(),
serde_json::json!({"content":" 2\t雪.*\n","next_offset":2,"truncated":true})
);
}
#[test]
fn text_window_refuses_invalid_ranges_patterns_source_and_encoded_output_bounds() {
for value in [
serde_json::json!({"limit":0}),
serde_json::json!({"limit":2001}),
serde_json::json!({"offset":-1}),
serde_json::json!({"offset":9_007_199_254_740_991i64}),
serde_json::json!({"file_path":"elsewhere"}),
] {
assert!(
matches!(text_result("read_lines",&args(value),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments)
);
}
for pattern in [String::new(), "x".repeat(4097)] {
assert!(
matches!(text_result("search_text",&args(serde_json::json!({"pattern":pattern})),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments)
);
}
for (operation, arguments, source) in [
(
"read_lines",
serde_json::json!({}),
"雪".repeat(MAX_BYTES / 3 + 1),
),
("read_lines", serde_json::json!({}), "\u{1}".repeat(16000)),
(
"search_text",
serde_json::json!({"pattern":"x"}),
"x\n".repeat(MAX_LINES),
),
(
"search_text",
serde_json::json!({"pattern":"\""}),
format!("{}\n", "\"".repeat(40000)),
),
] {
assert!(
matches!(text_result(operation,&args(arguments),&source),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ResourceLimitExceeded),
"{operation}: {} source bytes",
source.len()
);
}
}
}
/// Bounds reserve room for base64 expansion, JSON encoding, and exact lossless integer offsets.
pub(crate) fn byte_range(
args: &BTreeMap<String, Value>,
) -> Result<(u64, usize), WipOperationError> {
if args
.keys()
.any(|k| !["offset", "limit"].contains(&k.as_str()))
{
return Err(failure(
ProtocolErrorCode::InvalidArguments,
"unknown byte argument",
));
}
let integer = |name: &str, default: u64| match args.get(name) {
None => Ok(default),
Some(Value::Integer(i)) => u64::try_from(*i)
.map_err(|_| failure(ProtocolErrorCode::InvalidArguments, "negative byte range")),
_ => Err(failure(
ProtocolErrorCode::InvalidArguments,
"invalid byte range",
)),
};
let offset = integer("offset", 0)?;
let limit = integer("limit", 16384)?;
if offset > 9_007_199_254_708_223 || !(1..=32768).contains(&limit) {
return Err(failure(
ProtocolErrorCode::InvalidArguments,
"byte range exceeds bounds",
));
}
Ok((offset, limit as usize))
}
pub(crate) fn bytes_result(
args: &BTreeMap<String, Value>,
bytes: &[u8],
eof: bool,
) -> Result<Value, WipOperationError> {
use base64::Engine as _;
let (offset, limit) = byte_range(args)?;
if bytes.len() > limit || (bytes.is_empty() && !eof) {
return Err(failure(
ProtocolErrorCode::Internal,
"provider returned an invalid byte range",
));
}
let value = Value::Record(BTreeMap::from([
("encoding".into(), Value::String("base64".into())),
(
"data".into(),
Value::String(base64::engine::general_purpose::STANDARD.encode(bytes)),
),
("offset".into(), Value::Integer(offset as i64)),
(
"next_offset".into(),
Value::Integer((offset + bytes.len() as u64) as i64),
),
("eof".into(), Value::Boolean(eof)),
]));
let json = crate::wip::wip_to_json(&value)
.map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))?;
if serde_json::to_vec_pretty(&json)
.map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))?
.len()
> MAX_BYTES
{
return Err(failure(
ProtocolErrorCode::ResourceLimitExceeded,
"encoded byte result exceeds bound",
));
}
Ok(value)
}
#[cfg(test)]
mod byte_tests {
use super::*;
#[test]
fn maximum_raw_byte_page_reserves_base64_and_complete_json_budget() {
let args = BTreeMap::from([("limit".into(), Value::Integer(32768))]);
let result = bytes_result(&args, &vec![0xff; 32768], false)
.unwrap_or_else(|_| panic!("valid bounded bytes"));
let json = crate::wip::wip_to_json(&result).unwrap();
assert!(serde_json::to_vec_pretty(&json).unwrap().len() <= MAX_BYTES);
assert_eq!(json["next_offset"], 32768);
assert_eq!(json["eof"], false);
assert!(
matches!(bytes_result(&args,&vec![0;32769],false),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::Internal)
);
}
}
+37 -43
View File
@@ -25,7 +25,9 @@ use agen::tool::{ToolOutput, ToolResult};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use session_store::{SystemItem, SystemReminder};
#[cfg(test)]
use session_store::SystemItem;
use session_store::SystemReminder;
use thiserror::Error;
use crate::SessionEntryRef;
@@ -254,7 +256,7 @@ impl From<HookTurnEndAction> for TurnEndAction {
// Hook context handles
// =============================================================================
/// Host-created handle for appending approved durable [`SystemItem`] requests.
/// Host-created handle for appending approved durable [`session_store::SystemItem`] requests.
///
/// Hook code can use this handle only when the Worker host includes it in an
/// event-specific context. The handle queues typed requests; the host drains the
@@ -263,11 +265,11 @@ impl From<HookTurnEndAction> for TurnEndAction {
/// raw `agen::Item`, history writer, event sender, `Worker`, `Engine`, or
/// notification buffer.
pub struct SystemItemAppendHandle {
pending: Arc<Mutex<Vec<SystemItem>>>,
pending: Arc<Mutex<Vec<session_store::LoggedSystemHistoryEntry>>>,
}
impl SystemItemAppendHandle {
pub(crate) fn new(pending: Arc<Mutex<Vec<SystemItem>>>) -> Self {
pub(crate) fn new(pending: Arc<Mutex<Vec<session_store::LoggedSystemHistoryEntry>>>) -> Self {
Self { pending }
}
@@ -279,53 +281,44 @@ impl SystemItemAppendHandle {
self.pending
.lock()
.expect("system-item append queue poisoned")
.push(item);
.push(session_store::LoggedSystemHistoryEntry {
item,
metadata: crate::session_history::metadata(
session_store::LoggedSessionHistoryOrigin::BackendInstruction {
operation_id: None,
},
None,
),
});
}
/// Queue a Subject behavior refresh with its exact represented behavior. The caller receives this
/// narrow operation only at a pre-request boundary, and the host commits it
/// before exposing it to the model.
pub fn append_subject_behavior_refresh(
&self,
body: impl Into<String>,
behavior_md: impl Into<String>,
) {
/// Queue producer-validated System context. Metadata and body are committed together.
/// The Host exposes this capability only at a pre-request boundary.
pub fn append_input(&self, input: session_store::LoggedSystemHistoryEntry) {
self.pending
.lock()
.expect("system-item append queue poisoned")
.push(SystemItem::SubjectBehaviorRefresh {
body: body.into(),
behavior_md: behavior_md.into(),
prompt_provenance: None,
});
.push(input);
}
}
/// Host-created narrow append authority exposed only at a restore lifecycle
/// boundary. It cannot emit arbitrary system items.
/// boundary. No model-facing tool receives this capability.
pub struct RestoreSystemItemAppendHandle {
pending: Arc<Mutex<Vec<SystemItem>>>,
pending: Arc<Mutex<Vec<session_store::LoggedSystemHistoryEntry>>>,
}
impl RestoreSystemItemAppendHandle {
pub(crate) fn new(pending: Arc<Mutex<Vec<SystemItem>>>) -> Self {
pub(crate) fn new(pending: Arc<Mutex<Vec<session_store::LoggedSystemHistoryEntry>>>) -> Self {
Self { pending }
}
/// Queue a Feature-rendered resident-summary refresh for durable append at
/// the restore lifecycle boundary.
pub fn append_resident_summary_refresh(
&self,
body: impl Into<String>,
prompt_provenance: session_store::PromptRenderProvenance,
) {
/// Queue producer-validated System context at the Host restore boundary.
pub fn append_input(&self, input: session_store::LoggedSystemHistoryEntry) {
self.pending
.lock()
.expect("restore system-item append queue poisoned")
.push(SystemItem::ResidentSummaryRefresh {
body: body.into(),
prompt_provenance: Some(prompt_provenance),
});
.push(input);
}
}
@@ -983,7 +976,7 @@ mod tests {
let queued = pending.lock().expect("pending queue poisoned");
assert_eq!(queued.len(), 1);
match &queued[0] {
match &queued[0].item {
SystemItem::TaskReminder { body, .. } => {
assert_eq!(body, "remember tasks");
}
@@ -992,7 +985,7 @@ mod tests {
}
#[test]
fn restore_append_handle_queues_only_resident_refresh_items() {
fn restore_append_handle_preserves_producer_metadata() {
let pending = Arc::new(Mutex::new(Vec::new()));
let handle = RestoreSystemItemAppendHandle::new(Arc::clone(&pending));
let provenance = session_store::PromptRenderProvenance {
@@ -1003,15 +996,16 @@ mod tests {
logical_name: "internal.resident_memory_restore_section".into(),
};
handle.append_resident_summary_refresh("current surface", provenance.clone());
assert!(matches!(
pending.lock().unwrap().as_slice(),
[SystemItem::ResidentSummaryRefresh {
body,
prompt_provenance: Some(actual),
}] if body == "current surface" && actual == &provenance
));
let input = crate::feature::builtin::memory::resident_summary_input(
"current surface".into(),
Some(provenance),
)
.unwrap();
let expected = serde_json::to_value(&input).unwrap();
handle.append_input(input);
let queued = pending.lock().unwrap();
assert_eq!(queued.len(), 1);
assert_eq!(serde_json::to_value(&queued[0]).unwrap(), expected);
}
#[test]
+1
View File
@@ -1499,6 +1499,7 @@ permission = "write"
is_error: false,
},
metadata: session_store::LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: session_store::LoggedSessionHistoryEntryId(entry_id.into()),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
+156 -38
View File
@@ -216,15 +216,6 @@ impl WorkerInterceptor {
self
}
fn confirm_resident_context_item(&self, item: &SystemItem) {
let Some(source) = self.resident_context_source.as_ref() else {
return;
};
if let SystemItem::SubjectBehaviorRefresh { behavior_md, .. } = item {
source.confirm_resident_context_behavior(behavior_md);
}
}
/// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
/// entry through the attached writer (no-op when no writer is
/// wired). Sync — writes complete before the matching
@@ -243,16 +234,20 @@ impl WorkerInterceptor {
return Ok(());
};
for (item, extensions, history_provenance) in items {
let entry = writer.commit_system_item_with_extensions(
writer.commit_system_item_with_extensions_then(
item.clone(),
extensions.clone(),
history_provenance.clone(),
&mut |entry| {
self.pending_committed_history
.lock()
.expect("pending committed history poisoned")
.push_back(entry.clone());
if let Some(source) = self.resident_context_source.as_ref() {
source.system_item_committed(item, &entry.annotation);
}
},
)?;
self.pending_committed_history
.lock()
.expect("pending committed history poisoned")
.push_back(entry);
self.confirm_resident_context_item(item);
}
Ok(())
}
@@ -267,6 +262,31 @@ impl WorkerInterceptor {
)
}
fn commit_system_entries(
&self,
entries: &[session_store::LoggedSystemHistoryEntry],
) -> Result<(), session_store::StoreError> {
let Some(writer) = &self.log_writer else {
return Ok(());
};
for entry in entries {
writer.commit_annotated_system_item_then(
entry.clone(),
Vec::new(),
&mut |history| {
self.pending_committed_history
.lock()
.expect("pending committed history poisoned")
.push_back(history.clone());
if let Some(source) = &self.resident_context_source {
source.system_item_committed(&entry.item, &history.annotation);
}
},
)?;
}
Ok(())
}
fn current_turn_index(&self) -> usize {
self.next_turn_index
.load(Ordering::Relaxed)
@@ -392,11 +412,6 @@ impl WorkerInterceptor {
} if prompt_provenance.is_none() => {
*prompt_provenance = Some(provenance("internal.task_reminder"));
}
SystemItem::SubjectBehaviorRefresh {
prompt_provenance, ..
} if prompt_provenance.is_none() => {
*prompt_provenance = Some(provenance("internal.subjektiv_resident_context"));
}
SystemItem::Interrupt {
prompt_provenance, ..
} if prompt_provenance.is_none() => {
@@ -622,15 +637,17 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
return Ok(PreRequestAction::Yield);
}
let mut system_items: Vec<SystemItem> = std::mem::take(
let mut system_items: Vec<session_store::LoggedSystemHistoryEntry> = std::mem::take(
&mut *pending_hook_system_items
.lock()
.expect("pending hook system-item queue poisoned"),
);
self.attach_prompt_provenance(&mut system_items);
for entry in &mut system_items {
self.attach_prompt_provenance(std::slice::from_mut(&mut entry.item));
}
let mut appended_items: Vec<Item> = system_items
.iter()
.map(SystemItem::to_history_item)
.map(|entry| entry.item.to_history_item())
.collect();
let effective_context = if appended_items.is_empty() {
Cow::Borrowed(context.as_slice())
@@ -644,7 +661,7 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
let compact_decision =
self.request_compact_decision(current_tokens, effective_context.as_ref());
if !matches!(compact_decision, AutomaticCompactDecision::Continue) {
if let Err(error) = self.commit_system_items(&system_items) {
if let Err(error) = self.commit_system_entries(&system_items) {
return Ok(PreRequestAction::Cancel(format!(
"session persistence failed: {error}"
)));
@@ -688,7 +705,7 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
if appended_items.is_empty() {
return Ok(PreRequestAction::Continue);
}
Ok(match self.commit_system_items(&system_items) {
Ok(match self.commit_system_entries(&system_items) {
Ok(()) => PreRequestAction::ContinueWith(appended_items),
Err(error) => PreRequestAction::Cancel(format!("session persistence failed: {error}")),
})
@@ -988,7 +1005,14 @@ mod tests {
input
.system_items()
.expect("test interceptor has durable append authority")
.append_subject_behavior_refresh("current Subject context", "Ask when uncertain.");
.append_input(
crate::feature::builtin::memory::subject_behavior_input(
"current Subject context".into(),
"Ask when uncertain.".into(),
None,
)
.unwrap(),
);
Ok(HookPreRequestAction::Continue)
}
}
@@ -1003,8 +1027,26 @@ mod tests {
SystemPromptContribution::Unavailable
}
fn confirm_resident_context_behavior(&self, behavior_md: &str) {
self.confirmed.lock().unwrap().push(behavior_md.to_owned());
fn system_item_committed(
&self,
_item: &SystemItem,
metadata: &crate::session_history::SessionHistoryMetadata,
) {
if let Some(input) = metadata
.input_metadata
.as_ref()
.and_then(|input| input.feature_payload())
.and_then(|payload| {
payload
.decode::<crate::feature::builtin::memory::SubjectBehaviorInput>(
"subject:behavior",
1,
)
.ok()
})
{
self.confirmed.lock().unwrap().push(input.behavior_md);
}
}
}
@@ -1548,14 +1590,87 @@ mod tests {
assert_eq!(*confirmed.lock().unwrap(), ["Ask when uncertain."]);
assert!(matches!(
committed.lock().unwrap().as_slice(),
[SystemItem::SubjectBehaviorRefresh {
behavior_md,
prompt_provenance: Some(_),
..
}] if behavior_md == "Ask when uncertain."
[SystemItem::Input { body }] if body == "current Subject context"
));
}
#[test]
fn system_input_completion_reports_only_durable_batch_prefix() {
struct PrefixCommitter(Arc<Mutex<Vec<session_store::LogEntry>>>);
impl SystemItemCommitter for PrefixCommitter {
fn commit_log_entry(
&self,
entry: session_store::LogEntry,
) -> Result<(), session_store::StoreError> {
let mut committed = self.0.lock().unwrap();
if !committed.is_empty() {
return Err(std::io::Error::other("injected second append failure").into());
}
committed.push(entry);
Ok(())
}
}
struct Observer {
saved: Arc<Mutex<Vec<session_store::LogEntry>>>,
receipts: Arc<Mutex<Vec<crate::session_history::SessionHistoryMetadata>>>,
}
#[async_trait]
impl SystemPromptContributionSource for Observer {
async fn load(&self) -> SystemPromptContribution {
SystemPromptContribution::Unavailable
}
fn system_item_committed(
&self,
item: &SystemItem,
metadata: &crate::session_history::SessionHistoryMetadata,
) {
let saved = self.saved.lock().unwrap();
let Some(session_store::LogEntry::AnnotatedSystemItem { entry, .. }) = saved.last()
else {
panic!("completion must follow persistence");
};
assert_eq!(&entry.metadata, metadata);
assert_eq!(entry.item.history_text(), item.history_text());
self.receipts.lock().unwrap().push(metadata.clone());
}
}
let saved = Arc::new(Mutex::new(Vec::new()));
let receipts = Arc::new(Mutex::new(Vec::new()));
let pending = Arc::new(Mutex::new(Vec::new()));
let interceptor = WorkerInterceptor::new(
Arc::new(HookRegistryBuilder::new().build()),
None,
None,
NotifyBuffer::new(),
pending.clone(),
test_prompts(),
Some(Arc::new(PrefixCommitter(saved.clone()))),
)
.with_resident_context_source(Some(Arc::new(Observer {
saved: saved.clone(),
receipts: receipts.clone(),
})));
let items = [
SystemItem::Notification {
message: "first".into(),
body: "first saved input".into(),
prompt_provenance: None,
},
SystemItem::FileAttachment {
path: "test.rs".into(),
body: "second unsaved input".into(),
},
];
assert!(interceptor.commit_system_items(&items).is_err());
assert_eq!(saved.lock().unwrap().len(), 1);
let receipts = receipts.lock().unwrap();
assert_eq!(receipts.len(), 1);
let pending = interceptor.pending_committed_history.lock().unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].annotation, receipts[0]);
assert_eq!(pending[0].item.as_text(), Some("first saved input"));
}
#[test]
fn behavior_ack_uses_raw_content_including_clear_not_rendered_context() {
let committed = Arc::new(Mutex::new(Vec::new()));
@@ -1576,12 +1691,15 @@ mod tests {
})),
)
.with_resident_context_source(Some(source));
let items = ["raw behavior", ""].map(|behavior_md| SystemItem::SubjectBehaviorRefresh {
body: "same rendered context".into(),
behavior_md: behavior_md.into(),
prompt_provenance: None,
let items = ["raw behavior", ""].map(|behavior_md| {
crate::feature::builtin::memory::subject_behavior_input(
"same rendered context".into(),
behavior_md.into(),
None,
)
.unwrap()
});
interceptor.commit_system_items(&items).unwrap();
interceptor.commit_system_entries(&items).unwrap();
assert_eq!(*confirmed.lock().unwrap(), ["raw behavior", ""]);
assert_eq!(committed.lock().unwrap().len(), 2);
}
+72 -14
View File
@@ -90,7 +90,8 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
let entry_id = Some(entry.metadata.entry_id.0.clone());
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
let value = serde_json::to_value(entry.public_item())
.expect("public system input is serializable");
Some(Event::SystemItem {
entry_id,
item: value,
@@ -339,21 +340,13 @@ mod tests {
#[test]
fn system_item_log_entry_keeps_identity_in_live_event() {
let entry_id = session_store::LoggedSessionHistoryEntryId::new();
let mut input =
crate::feature::builtin::memory::resident_summary_input("refresh".into(), None)
.unwrap();
input.metadata.entry_id = entry_id.clone();
let event = live_log_entry_event(LogEntry::AnnotatedSystemItem {
ts: session_store::segment_log::now_millis(),
entry: session_store::LoggedSystemHistoryEntry {
item: session_store::SystemItem::ResidentSummaryRefresh {
body: "refresh".to_string(),
prompt_provenance: None,
},
metadata: session_store::LoggedSessionHistoryMetadata {
entry_id: entry_id.clone(),
origin: session_store::LoggedSessionHistoryOrigin::BackendInstruction {
operation_id: None,
},
derivation: None,
},
},
entry: input,
extensions: Vec::new(),
})
.expect("SystemItem must be live-relevant");
@@ -370,6 +363,71 @@ mod tests {
}
}
#[test]
fn live_and_snapshot_system_projection_exclude_private_feature_fields() {
let provenance = session_store::PromptRenderProvenance {
workspace_id: Some("private-workspace".into()),
source_digest: "private-source".into(),
projection_digest: "private-projection".into(),
logical_name: "private-template".into(),
};
let items = [
crate::feature::builtin::memory::subject_behavior_input(
"displayed context".into(),
"private-raw-behavior".into(),
Some(provenance.clone()),
)
.unwrap(),
crate::feature::builtin::memory::resident_summary_input(
"displayed reference".into(),
Some(provenance),
)
.unwrap(),
];
for input in items {
let entry = LogEntry::AnnotatedSystemItem {
ts: 7,
entry: input,
extensions: vec![],
};
let persisted: LogEntry =
serde_json::from_slice(&serde_json::to_vec(&entry).unwrap()).unwrap();
let snapshot = session_store::public_snapshot::project_current_session_snapshot(&[
persisted.clone(),
]);
let Event::SystemItem {
entry_id,
item: live,
} = live_log_entry_event(persisted).unwrap()
else {
panic!("expected live system input");
};
let snapshot_entry = &snapshot.entries[0];
assert_eq!(entry_id.as_deref(), Some(snapshot_entry.entry_id.as_str()));
let protocol::SessionSnapshotEntryData::SystemItem {
data: Some(saved),
content,
..
} = &snapshot_entry.data
else {
panic!("expected canonical public system input");
};
assert_eq!(&live, saved);
let public: session_store::PublicSystemItem =
serde_json::from_value(live.clone()).unwrap();
assert_eq!(&public.body, content);
assert!(matches!(
public.presentation,
session_store::PublicSystemPresentation::Context
));
let encoded = serde_json::to_string(&snapshot).unwrap();
assert!(!encoded.contains("private-"));
assert!(live.get("prompt_provenance").is_none());
assert!(live.get("snapshot").is_none());
assert!(live.get("behavior_md").is_none());
}
}
#[test]
fn user_input_log_entry_maps_to_user_message_event() {
let segments = vec![protocol::Segment::text("hello from log")];
+4 -1
View File
@@ -1,5 +1,6 @@
#![doc = include_str!("../README.md")]
pub mod authorized_workdir;
mod backend_job;
pub use backend_job::BackendJobExecutionBinding;
pub mod bootstrap;
@@ -15,6 +16,7 @@ pub mod hook;
pub(crate) mod in_flight;
pub mod ipc;
pub mod job;
mod metadata_lifecycle;
pub mod model_client;
mod paste_artifact_tool;
pub mod prompt;
@@ -26,6 +28,7 @@ mod session_history;
pub mod shared_state;
mod shutdown_after_idle;
mod subject_release;
mod subjektiv_metadata;
pub use subject_release::SubjectReleaseReservation;
pub mod skill;
pub mod spawn;
@@ -39,7 +42,7 @@ mod worker;
pub use bootstrap::{
BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError,
WorkerBootstrapLayout, bash_output_dir_for_worker_id, start_worker_controller,
WorkerBootstrapLayout, start_worker_controller,
};
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle};
+69
View File
@@ -0,0 +1,69 @@
//! Atomic metadata transitions with Host-owned Session lifecycle participants.
//!
//! Participants inspect domain evidence inside the metadata update, not after the
//! active pointer has been published. The generic writer never interprets it.
use session_store::worker_metadata::{
WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore, WorkerStoreError,
};
use std::sync::Arc;
pub(crate) trait SessionMetadataLifecycle: Send + Sync {
/// Runs under the metadata update lock. Only compare/transform the supplied
/// value: no I/O, reentrant store calls, lock waits, or Feature refresh.
fn active_changing(&self, metadata: &mut WorkerMetadata, next: Option<&WorkerActiveSegmentRef>);
}
pub(crate) type WorkerMetadataWriter =
Arc<dyn Fn(WorkerMetadata) -> Result<(), WorkerStoreError> + Send + Sync>;
pub(crate) type WorkerMetadataSegmentCas = Arc<
dyn Fn(&str, &WorkerActiveSegmentRef, WorkerActiveSegmentRef) -> Result<bool, WorkerStoreError>
+ Send
+ Sync,
>;
pub(crate) fn worker_metadata_writer_for_store<St>(store: &St) -> WorkerMetadataWriter
where
St: WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let store = store.clone();
let participants = crate::feature::builtin::session_metadata_lifecycles();
Arc::new(move |next| {
store
.update_by_name(&next.worker_name, |metadata| {
for participant in &participants {
participant.active_changing(metadata, next.active.as_ref());
}
metadata.active = next.active;
metadata.resolved_manifest_snapshot = next.resolved_manifest_snapshot;
if next.workspace_id.is_some() {
metadata.workspace_id = next.workspace_id;
}
if next.workspace_root.is_some() {
metadata.workspace_root = next.workspace_root;
}
})
.map(|_| ())
})
}
pub(crate) fn worker_metadata_segment_cas_for_store<St>(store: &St) -> WorkerMetadataSegmentCas
where
St: WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let store = store.clone();
let participants = crate::feature::builtin::session_metadata_lifecycles();
Arc::new(move |worker_name, expected, replacement| {
let mut matched = false;
store.update_by_name(worker_name, |metadata| {
if metadata.active.as_ref() == Some(expected) {
for participant in &participants {
participant.active_changing(metadata, Some(&replacement));
}
metadata.active = Some(replacement);
matched = true;
}
})?;
Ok(matched)
})
}
-1
View File
@@ -1049,7 +1049,6 @@ mod tests {
);
}
assert!(orchestration.contains("requires an Idle Worker and never queues input"));
assert!(orchestration.contains("Direct SubWorkers also require Idle"));
assert!(orchestration.contains("use `WorkerNotify` instead"));
assert!(orchestration.contains("do not create a queued Submit"));
for alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] {
+25 -2
View File
@@ -326,9 +326,32 @@ mod tests {
use crate::feature::builtin::merge_request::MergeRequestFeature;
use crate::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess};
use crate::hook::HookRegistryBuilder;
use crate::worker::TestWorkspaceHttpClient;
use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
};
use manifest::MergeRequestFeatureConfig;
#[derive(Debug)]
struct MetadataOnlyHost;
impl WorkspaceClient for MetadataOnlyHost {
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
fn kind(&self) -> &str {
"metadata-fixture"
}
fn is_available(&self) -> bool {
true
}
fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> {
assert_eq!(feature, "ticket");
Ok(())
}
fn execute(&self, _: WorkspaceRequest) -> Result<WorkspaceResponse, WorkspaceClientError> {
panic!("metadata-only prompt fixture must not perform protected operations")
}
}
// These builtin resources are the model-visible workflow compatibility contract.
// Fixtures prove composition and tool descriptions, not an agent's real judgment.
fn ticket_workflow_fixture(
@@ -351,7 +374,7 @@ mod tests {
mr_config: MergeRequestFeatureConfig,
access: TicketFeatureAccess,
) -> (String, Vec<agen::tool::ToolMeta>) {
let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
let client = Arc::new(MetadataOnlyHost);
let mut tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
+390 -28
View File
@@ -74,6 +74,7 @@ impl std::fmt::Display for SessionEntryRef {
pub(crate) enum ReferenceKind {
User,
Assistant,
System,
Tool,
}
@@ -82,6 +83,7 @@ impl ReferenceKind {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
Self::Tool => "tool",
}
}
@@ -90,6 +92,7 @@ impl ReferenceKind {
match value {
"user" => Some(Self::User),
"assistant" | "agent" => Some(Self::Assistant),
"system" => Some(Self::System),
"tool" => Some(Self::Tool),
_ => None,
}
@@ -131,6 +134,7 @@ pub(crate) struct OverviewItem {
#[derive(Debug, Clone)]
pub(crate) struct ReferenceEntry {
use_policy: Option<protocol::SessionEntryUsePolicy>,
pub id: SessionEntryRef,
pub origin: SessionEntryProvenance,
pub entry_range: [u64; 2],
@@ -242,6 +246,7 @@ pub(crate) struct SessionEntryEvidence {
#[derive(Debug, Clone)]
struct CapturedHistoryEntry {
use_policy: Option<protocol::SessionEntryUsePolicy>,
item: Item,
entry_id: session_store::LoggedSessionHistoryEntryId,
origin: SessionEntryProvenance,
@@ -302,10 +307,12 @@ impl SessionCapture {
is_error,
attachments: _,
} => Item::tool_result_item(call_id, summary, content, is_error),
// Observation deliberately excludes system items and
// controller errors from model-visible session evidence.
SessionSnapshotEntryData::SystemItem { .. }
| SessionSnapshotEntryData::Invoke { .. }
SessionSnapshotEntryData::SystemItem { content, .. } => {
entry.use_policy?;
Item::system_message(content)
}
// Controller errors are not model-visible evidence.
SessionSnapshotEntryData::Invoke { .. }
| SessionSnapshotEntryData::Usage { .. }
| SessionSnapshotEntryData::RunStats { .. }
| SessionSnapshotEntryData::RunYielded { .. }
@@ -314,6 +321,7 @@ impl SessionCapture {
| SessionSnapshotEntryData::RunError { .. } => return None,
};
Some(CapturedHistoryEntry {
use_policy: entry.use_policy,
item,
entry_id: session_store::LoggedSessionHistoryEntryId(entry.entry_id),
origin: entry.provenance,
@@ -328,6 +336,7 @@ impl SessionCapture {
.into_iter()
.enumerate()
.map(|(index, item)| CapturedHistoryEntry {
use_policy: None,
item,
entry_id: session_store::LoggedSessionHistoryEntryId(format!("{index:08}")),
origin: SessionEntryProvenance::LegacyUnknown,
@@ -342,10 +351,32 @@ impl SessionCapture {
) -> Self {
let entries = entries
.into_iter()
.map(|entry| CapturedHistoryEntry {
item: entry.item,
entry_id: entry.annotation.entry_id,
origin: public_provenance(&entry.annotation.origin),
.map(|entry| {
let (item, use_policy) = if let Some(input) = &entry.annotation.input_metadata {
let is_system = matches!(
entry.item,
Item::Message {
role: Role::System,
..
}
);
if is_system {
(
Item::system_message(input.public_projection().body),
Some(input.public_system_use_policy()),
)
} else {
(entry.item, Some(input.public_use_policy()))
}
} else {
(entry.item, None)
};
CapturedHistoryEntry {
item,
use_policy,
entry_id: entry.annotation.entry_id,
origin: public_provenance(&entry.annotation.origin),
}
})
.collect();
Self::from_captured_entries(segment_id, entries)
@@ -364,7 +395,8 @@ impl SessionCapture {
let entry_range = [idx as u64, idx as u64];
match item {
Item::Message { role, content, .. } => {
let Some(kind) = message_reference_kind(&entry.origin, role) else {
let Some(kind) = message_reference_kind(&entry.origin, role, entry.use_policy)
else {
continue;
};
let text = content
@@ -376,6 +408,7 @@ impl SessionCapture {
let summary = truncate_chars(&text, 240);
let id = SessionEntryRef::from_history_entry_id(&entry.entry_id);
index.push(ReferenceEntry {
use_policy: entry.use_policy,
id: id.clone(),
origin: entry.origin.clone(),
entry_range,
@@ -386,7 +419,9 @@ impl SessionCapture {
summary: summary.clone(),
search_text: text.clone(),
});
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
if entry.use_policy.is_none_or(|p| p.observation_readable)
&& matches!(kind, ReferenceKind::User | ReferenceKind::Assistant)
{
overview.push(OverviewItem {
id: id.clone(),
origin: entry.origin.clone(),
@@ -403,6 +438,7 @@ impl SessionCapture {
} => {
let text = format!("{name}\n{arguments}");
index.push(ReferenceEntry {
use_policy: entry.use_policy,
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
origin: entry.origin.clone(),
entry_range,
@@ -430,6 +466,7 @@ impl SessionCapture {
content.as_deref().unwrap_or_default(),
);
index.push(ReferenceEntry {
use_policy: entry.use_policy,
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
origin: entry.origin.clone(),
entry_range,
@@ -510,6 +547,9 @@ impl SessionCapture {
let mut hits = Vec::new();
for entry in &self.index {
if entry.use_policy.is_some_and(|p| !p.observation_readable) {
continue;
}
if entry.entry_range[0] < min_entry_index || entry.entry_range[0] > max_entry_index {
continue;
}
@@ -581,6 +621,9 @@ impl SessionCapture {
};
for entry in selected {
if entry.use_policy.is_some_and(|p| !p.observation_readable) {
continue;
}
if entries.len() >= max_items {
truncated = true;
break;
@@ -695,21 +738,11 @@ impl SessionCapture {
pub(crate) fn evidence_for(&self, id: &str) -> Option<SessionEntryEvidence> {
let entry = self.index.iter().find(|entry| entry.id.as_str() == id)?;
let excerpt = self
.read(
ReadSelector::Id(id),
ReadOptions {
include_tools: true,
tool_part: ToolPart::Both,
detail: ReadDetail::Compact,
max_items: 1,
max_bytes: 2 * 1024,
},
)
.entries
.first()
.map(|entry| entry.text.clone())
.unwrap_or_else(|| entry.summary.clone());
if entry.use_policy.is_some_and(|p| !p.memory_evidence) {
return None;
}
let item = &self.entries.get(entry.entry_range[0] as usize)?.item;
let excerpt = render_item(item, entry, ReadDetail::Compact, 2 * 1024);
Some(SessionEntryEvidence {
segment_id: self.segment_id.clone(),
entry_ref: entry.id.clone(),
@@ -731,9 +764,8 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance
}
WorkerHistoryProvenance::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
WorkerHistoryProvenance::FlowInstruction { .. } => SessionEntryProvenance::FlowInstruction,
WorkerHistoryProvenance::BackendInstruction { .. } => {
SessionEntryProvenance::BackendInstruction
}
WorkerHistoryProvenance::BackendInstruction { .. }
| WorkerHistoryProvenance::Provided { .. } => SessionEntryProvenance::BackendInstruction,
WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
WorkerHistoryProvenance::CompactionSummary
@@ -746,7 +778,15 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance
fn message_reference_kind(
origin: &SessionEntryProvenance,
provider_role: &Role,
use_policy: Option<protocol::SessionEntryUsePolicy>,
) -> Option<ReferenceKind> {
if use_policy.is_some() {
return match provider_role {
Role::System => Some(ReferenceKind::System),
Role::User => Some(ReferenceKind::User),
Role::Assistant => Some(ReferenceKind::Assistant),
};
}
match origin {
SessionEntryProvenance::HumanInput | SessionEntryProvenance::WorkerInput => {
Some(ReferenceKind::User)
@@ -950,6 +990,328 @@ mod tests {
}
}
#[test]
fn generic_public_input_observation_and_memory_policies_survive_store_restore_independently() {
use session_store::{
FeatureInputPayload, FsStore, HistoryInputMetadata, HistoryInputUsePolicy,
InputCompactionUse, InputEvidenceUse, InputObservationUse, LogEntry,
LoggedSystemHistoryEntry, PublicSystemItem, PublicSystemPresentation, Store,
SystemInputEnvelope,
};
let root = tempfile::tempdir().unwrap();
let store = FsStore::new(root.path()).unwrap();
let session = session_store::new_session_id();
let segment = session_store::new_segment_id();
store
.create_segment(
session,
segment,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id: session,
system_prompt: None,
config: Default::default(),
history: vec![],
forked_from: None,
compacted_from: None,
}],
)
.unwrap();
let mut inputs = Vec::new();
for (producer, observation, memory_evidence, public) in [
(
"same",
InputObservationUse::Readable,
InputEvidenceUse::Exclude,
true,
),
(
"same",
InputObservationUse::Hidden,
InputEvidenceUse::Include,
true,
),
(
"renamed",
InputObservationUse::Readable,
InputEvidenceUse::Include,
true,
),
(
"renamed",
InputObservationUse::Hidden,
InputEvidenceUse::Exclude,
true,
),
(
"private",
InputObservationUse::Readable,
InputEvidenceUse::Include,
false,
),
] {
let entry = LoggedSystemHistoryEntry::from_input(
WorkerHistoryProvenance::Provided {
producer: producer.into(),
},
None,
SystemInputEnvelope::new(
"private model text",
HistoryInputMetadata::new(
HistoryInputUsePolicy {
recording_material: InputEvidenceUse::Exclude,
memory_evidence,
observation,
compaction: InputCompactionUse::ReferenceInput,
},
Some(
FeatureInputPayload::encode(
"future:owner",
99,
&serde_json::json!({ "secret": "private metadata" }),
)
.unwrap(),
),
public.then(|| PublicSystemItem {
kind: "supplied-context".into(),
body: "public context: do not extract this".into(),
presentation: PublicSystemPresentation::Context,
}),
)
.unwrap(),
)
.unwrap(),
);
store
.append(
session,
segment,
&LogEntry::AnnotatedSystemItem {
ts: 2,
entry: entry.clone(),
extensions: vec![],
},
)
.unwrap();
inputs.push(entry);
}
let saved = store.read_all(session, segment).unwrap();
let restored =
crate::session_history::restore_history_entries(session, segment, &saved).unwrap();
let snapshot = session_store::public_snapshot::project_session_snapshot(session, &saved);
let snapshot: SessionSnapshot =
serde_json::from_value(serde_json::to_value(snapshot).unwrap()).unwrap();
for capture in [
SessionCapture::from_history_entries(segment.to_string(), restored),
SessionCapture::from_session_snapshot(segment.to_string(), snapshot),
] {
let hits = capture.search(&SearchOptions {
kind: Some(ReferenceKind::System),
..Default::default()
});
assert_eq!(hits.len(), 2);
for (index, input) in inputs.iter().enumerate() {
let reference = SessionEntryRef::from_history_entry_id(&input.metadata.entry_id);
let read =
capture.read(ReadSelector::Id(reference.as_str()), ReadOptions::default());
assert_eq!(!read.entries.is_empty(), matches!(index, 0 | 2));
let evidence = capture.evidence_for(reference.as_str());
assert_eq!(evidence.is_some(), matches!(index, 1 | 2));
for text in read
.entries
.iter()
.map(|entry| entry.text.as_str())
.chain(evidence.iter().map(|entry| entry.excerpt.as_str()))
{
assert!(text.contains("public context: do not extract this"));
assert!(!text.contains("private model text"));
assert!(!text.contains("private metadata"));
assert!(!text.contains("future:owner"));
}
}
}
}
// Real FsStore seed/fork/reopen -> ordinary history replay, public snapshot,
// capture and both evidence consumers; no Feature or Tool execution occurs.
#[test]
fn tool_policy_axes_survive_restore_and_fork_without_exposing_derived_context() {
use session_store::public_material::{
PublicMaterialLimits, PublicMaterialUse, SessionPublicMaterialReader,
};
use session_store::*;
for recording in [InputEvidenceUse::Include, InputEvidenceUse::Exclude] {
for memory in [InputEvidenceUse::Include, InputEvidenceUse::Exclude] {
for observation in [InputObservationUse::Readable, InputObservationUse::Hidden] {
let root = tempfile::tempdir().unwrap();
let store = FsStore::new(root.path()).unwrap();
let session = new_session_id();
let first = new_segment_id();
let fork = new_segment_id();
let items = vec![
Item::tool_call("original", "ArbitraryTool", "{}"),
Item::tool_result("original", "original result"),
Item::tool_call("derived", "ArbitraryTool", "PRIVATE_SYNTHETIC_CONTEXT"),
Item::tool_result("derived", "PRIVATE_SYNTHETIC_RESULT"),
];
let mut history: Vec<_> = items
.iter()
.cloned()
.map(|item| LoggedHistoryEntry {
item: LoggedItem::from(item),
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
})
.collect();
let sources = history[..2]
.iter()
.map(|entry| entry.metadata.entry_id.clone())
.collect::<Vec<_>>();
for entry in &mut history[..2] {
entry.metadata.input_metadata = Some(
HistoryInputMetadata::new(
HistoryInputUsePolicy {
recording_material: recording,
memory_evidence: memory,
observation,
compaction: InputCompactionUse::Retain,
},
None,
None,
)
.unwrap(),
);
}
for entry in &mut history[2..] {
entry.metadata.derivation = Some(LoggedHistoryDerivation {
sources: sources.clone(),
});
entry.metadata.input_metadata = Some(
HistoryInputMetadata::new(
HistoryInputUsePolicy {
recording_material: InputEvidenceUse::Exclude,
memory_evidence: InputEvidenceUse::Exclude,
observation: InputObservationUse::Hidden,
compaction: InputCompactionUse::Retain,
},
None,
None,
)
.unwrap(),
);
}
for segment in [first, fork] {
store
.create_segment(
session,
segment,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id: session,
system_prompt: None,
config: Default::default(),
history: history.clone(),
forked_from: (segment == fork).then_some(SegmentOrigin {
segment_id: first,
at_turn_index: 0,
}),
compacted_from: None,
}],
)
.unwrap();
}
let reopened = FsStore::new(root.path()).unwrap();
let saved = reopened.read_all(session, fork).unwrap();
let restored =
crate::session_history::restore_history_entries(session, fork, &saved)
.unwrap();
for (index, entry) in restored.iter().enumerate() {
assert_eq!(entry.item, items[index]);
assert_eq!(entry.annotation, history[index].metadata);
}
let capture = SessionCapture::from_history_entries(fork.to_string(), restored);
assert!(
matches!(&capture.entries[2].item, Item::ToolCall { call_id, .. } if call_id == "derived")
);
assert!(
matches!(&capture.entries[3].item, Item::ToolResult { call_id, .. } if call_id == "derived")
);
let snapshot = public_snapshot::project_session_snapshot(session, &saved);
assert!(
!serde_json::to_string(&snapshot)
.unwrap()
.contains("PRIVATE_SYNTHETIC")
);
for capture in [
capture,
SessionCapture::from_session_snapshot(fork.to_string(), snapshot),
] {
assert_eq!(
capture
.search(&SearchOptions {
kind: Some(ReferenceKind::Tool),
..Default::default()
})
.len(),
if observation == InputObservationUse::Readable {
2
} else {
0
}
);
for (index, entry) in history.iter().enumerate() {
let reference =
SessionEntryRef::from_history_entry_id(&entry.metadata.entry_id);
assert_eq!(
!capture
.read(
ReadSelector::Id(reference.as_str()),
ReadOptions::default()
)
.entries
.is_empty(),
index < 2 && observation == InputObservationUse::Readable
);
assert_eq!(
capture.evidence_for(reference.as_str()).is_some(),
index < 2 && memory == InputEvidenceUse::Include
);
}
}
for purpose in [
PublicMaterialUse::Recording,
PublicMaterialUse::MemoryEvidence,
] {
let reader = SessionPublicMaterialReader::open_fs_for_use(
root.path(),
session,
fork,
PublicMaterialLimits::default(),
reopened.retention(),
purpose,
)
.unwrap();
let ids = reader
.descriptors()
.map(|entry| entry.identity.entry_ref.clone())
.collect::<Vec<_>>();
let included = match purpose {
PublicMaterialUse::Recording => recording,
PublicMaterialUse::MemoryEvidence => memory,
} == InputEvidenceUse::Include;
assert_eq!(
ids,
sources
.iter()
.filter(|_| included)
.map(|id| format!("E{}", id.0))
.collect::<Vec<_>>()
);
}
}
}
}
}
#[test]
fn overview_contains_user_and_assistant_only() {
let view = SessionCapture::new(
+18 -16
View File
@@ -24,6 +24,7 @@ pub(crate) fn test_logged_history_entry(item: impl Into<Item>) -> LoggedHistoryE
LoggedHistoryEntry {
item: session_store::LoggedItem::from(item.into()),
metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
@@ -38,6 +39,7 @@ pub(crate) fn test_logged_system_entry(
session_store::LoggedSystemHistoryEntry {
item,
metadata: LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
@@ -58,12 +60,23 @@ pub(crate) fn metadata(
derivation: Option<SessionHistoryDerivation>,
) -> SessionHistoryMetadata {
SessionHistoryMetadata {
input_metadata: None,
entry_id: SessionHistoryEntryId::new(),
origin,
derivation,
}
}
pub(crate) fn compaction_use(
metadata: &SessionHistoryMetadata,
) -> session_store::InputCompactionUse {
metadata
.input_metadata
.as_ref()
.map(|input| input.usage().compaction)
.unwrap_or(session_store::InputCompactionUse::Retain)
}
#[cfg(test)]
pub(crate) fn history_entry(
item: Item,
@@ -80,6 +93,7 @@ pub(crate) fn history_entry_with_id(
HistoryEntry::new(
item,
SessionHistoryMetadata {
input_metadata: None,
entry_id,
origin,
derivation: None,
@@ -130,22 +144,10 @@ pub(crate) fn restore_history_entries(
}
*target = from_logged(entry);
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
history.push(HistoryEntry::new(entry.item.to_history_item(), {
let mut metadata = entry.metadata.clone();
// Old typed startup records used generic placeholder origins.
// Preserve explicitly supplied metadata on current contributions.
if matches!(
metadata.origin,
WorkerHistoryProvenance::DerivedSummary
| WorkerHistoryProvenance::LegacyUnknown
) && matches!(entry.item, session_store::SystemItem::JournalStartup { .. })
{
metadata.origin = WorkerHistoryProvenance::JournalContext;
}
metadata
}))
}
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
entry.item.to_history_item(),
entry.metadata.clone(),
)),
_ => {}
}
}
+44
View File
@@ -8,6 +8,50 @@ pub use server_api::{
SkillProjectionStatus, SkillProvenance, SkillResourceRef, SkillSourceKind,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SkillActivationInput {
pub name: String,
}
pub(crate) fn activation_input(
activation: &SkillActivationResponse,
) -> Result<session_store::LoggedSystemHistoryEntry, session_store::InputMetadataError> {
use session_store::*;
let body = format!(
"Agent Skill `{}` activated from {}.\n\n{}",
activation.name, activation.provenance.id, activation.body
);
let payload = FeatureInputPayload::encode(
"skill:activation",
1,
&SkillActivationInput {
name: activation.name.clone(),
},
)?;
let metadata = HistoryInputMetadata::new(
HistoryInputUsePolicy {
recording_material: InputEvidenceUse::Exclude,
memory_evidence: InputEvidenceUse::Exclude,
observation: InputObservationUse::Hidden,
compaction: InputCompactionUse::Retain,
},
Some(payload),
Some(PublicSystemItem {
kind: "skill_activation".into(),
body: body.clone(),
presentation: PublicSystemPresentation::Context,
}),
)?;
Ok(LoggedSystemHistoryEntry::from_input(
LoggedSessionHistoryOrigin::Provided {
producer: "skill.activation".into(),
},
None,
SystemInputEnvelope::new(body, metadata)?,
))
}
#[derive(Debug, thiserror::Error)]
pub enum SkillClientError {
#[error("workspace client is unavailable: {0}")]
+1
View File
@@ -77,6 +77,7 @@ mod tests {
.into_iter()
.enumerate()
.map(|(index, value)| protocol::SessionSnapshotEntry {
use_policy: None,
entry_id: format!("test-{index}"),
timestamp: index as u64,
provenance: protocol::SessionEntryProvenance::LegacyUnknown,
+28 -7
View File
@@ -88,7 +88,7 @@ impl InternalSpawnedWorkerRecord {
pub(crate) fn new(
worker_name: String,
scope_delegated: Vec<ScopeRule>,
workdir_tool_scope: WorkdirScopeLeaseSet,
workdir_tool_scope: Arc<WorkdirScopeLeaseSet>,
#[cfg(test)] installed_tools: Vec<String>,
session: InternalWorkerSessionHandle,
child_registry: Arc<SpawnedWorkerRegistry>,
@@ -97,7 +97,7 @@ impl InternalSpawnedWorkerRecord {
Self {
worker_name: worker_name.clone(),
scope_delegated,
workdir_tool_scope: Arc::new(workdir_tool_scope),
workdir_tool_scope,
#[cfg(test)]
installed_tools: installed_tools.into(),
published: Arc::new(super::published::PublishedProtocol::new(
@@ -329,6 +329,7 @@ pub struct SpawnedWorkerRegistry {
pending_internal_notify: Notify,
internal_spawn_cleanup_failed: AtomicBool,
parent_scope: Option<SharedScope>,
bash_output: Mutex<tools::bash_output::BashOutputView>,
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
protocol_publish_lock: Arc<Mutex<()>>,
}
@@ -340,6 +341,14 @@ pub struct SpawnedWorkerRegistryLoad {
}
impl SpawnedWorkerRegistry {
pub(crate) fn bash_output(&self) -> tools::bash_output::BashOutputView {
self.bash_output.lock().unwrap().clone()
}
pub(crate) fn inherit_bash_output(&self, view: tools::bash_output::BashOutputView) {
*self.bash_output.lock().unwrap() = view;
}
pub(crate) fn new_for_internal_services() -> Arc<Self> {
Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()),
@@ -352,6 +361,7 @@ impl SpawnedWorkerRegistry {
pending_internal_notify: Notify::new(),
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope: None,
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
parent_protocol: Mutex::new(None),
protocol_publish_lock: Arc::new(Mutex::new(())),
})
@@ -370,6 +380,7 @@ impl SpawnedWorkerRegistry {
pending_internal_notify: Notify::new(),
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope: None,
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
parent_protocol: Mutex::new(None),
protocol_publish_lock: Arc::new(Mutex::new(())),
})
@@ -387,6 +398,7 @@ impl SpawnedWorkerRegistry {
pending_internal_notify: Notify::new(),
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope: Some(parent_scope),
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
parent_protocol: Mutex::new(None),
protocol_publish_lock: Arc::new(Mutex::new(())),
})
@@ -473,6 +485,7 @@ impl SpawnedWorkerRegistry {
pending_internal_notify: Notify::new(),
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope,
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
parent_protocol: Mutex::new(None),
protocol_publish_lock: Arc::new(Mutex::new(())),
}),
@@ -966,6 +979,10 @@ impl SpawnedWorkerRegistry {
"an in-flight Internal SubWorker failed cleanup during shutdown",
));
}
let output = self.bash_output();
if !output.is_root() {
output.revoke();
}
first_error.map_or(Ok(()), Err)
}
@@ -1111,6 +1128,7 @@ fn internal_worker_live_snapshot(snapshot: InternalWorkerSnapshot) -> Option<Eve
impl Drop for SpawnedWorkerRegistry {
fn drop(&mut self) {
self.bash_output.get_mut().unwrap().revoke();
let Some(parent_scope) = &self.parent_scope else {
return;
};
@@ -1297,11 +1315,13 @@ mod tests {
InternalSpawnedWorkerRecord::new(
name.into(),
Vec::new(),
workdir::WorkdirScopeLeaseSet::from_single(
workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
delegation,
)
.unwrap(),
Arc::new(
workdir::WorkdirScopeLeaseSet::from_single(
workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
delegation,
)
.unwrap(),
),
Vec::new(),
session,
registry(),
@@ -1338,6 +1358,7 @@ mod tests {
is_error: false,
},
metadata: session_store::LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: session_store::LoggedSessionHistoryEntryId(entry_id.into()),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
+43 -38
View File
@@ -310,7 +310,7 @@ pub struct SubWorkerSpawnTool {
/// Runtime-owned root used for Internal Worker controller state.
runtime_base: PathBuf,
/// Parent Worker-owned temporary root used for bounded Bash spill output.
bash_output_dir: PathBuf,
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
workspace_root: PathBuf,
@@ -345,7 +345,7 @@ impl SubWorkerSpawnTool {
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifications: ParentNotificationTarget,
runtime_base: PathBuf,
bash_output_dir: PathBuf,
workspace_root: PathBuf,
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
registry: Arc<SpawnedWorkerRegistry>,
@@ -358,7 +358,7 @@ impl SubWorkerSpawnTool {
workspace_context,
parent_notifications,
runtime_base,
bash_output_dir,
workspace_root,
workdir_tool_broker,
registry,
@@ -430,15 +430,6 @@ impl Tool for SubWorkerSpawnTool {
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let workdir_rules = parse_workdir_scope(&input.scope)?;
let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name);
tokio::fs::create_dir_all(&child_bash_output_dir)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"create Internal Worker Bash output directory {}: {error}",
child_bash_output_dir.display()
))
})?;
let workdir_tool_broker = require_workdir_tool_broker(self.workdir_tool_broker.as_ref())?;
let tool_scopes = routed_workdir_tool_scopes(
workdir_tool_broker,
@@ -452,6 +443,7 @@ impl Tool for SubWorkerSpawnTool {
.map_err(|error| {
ToolError::InvalidArgument(format!("scope parent-owned Workdir tools: {error}"))
})?;
let workdir_scope = Arc::new(workdir_scope);
let child_workdir_tool_broker = workdir_scope.broker_router();
let spawn_selector =
@@ -552,24 +544,17 @@ impl Tool for SubWorkerSpawnTool {
)
.await
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
child
.add_scope_rules([ScopeRule {
target: child_bash_output_dir.clone(),
permission: manifest::Permission::Read,
recursive: true,
symlink_policy: Default::default(),
}])
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"grant Internal Worker Bash output scope: {error}"
))
})?;
let child_scope = child.scope().clone();
child.bind_workdir_sessions(workdir_scope.session_router());
let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope);
let output_lease = workdir_scope.clone();
child_registry.inherit_bash_output(
self.registry
.bash_output()
.child(Arc::new(move || output_lease.is_active())),
);
register_worker_tools(
&mut child,
child_bash_output_dir,
self.runtime_base.clone(),
child_registry.clone(),
None,
@@ -1052,7 +1037,7 @@ pub(crate) fn sub_worker_spawn_tool(
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifications: ParentNotificationTarget,
runtime_base: PathBuf,
bash_output_dir: PathBuf,
workspace_root: PathBuf,
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
registry: Arc<SpawnedWorkerRegistry>,
@@ -1064,7 +1049,6 @@ pub(crate) fn sub_worker_spawn_tool(
workspace_context,
parent_notifications,
runtime_base,
bash_output_dir,
workspace_root,
workdir_tool_broker,
registry,
@@ -1078,7 +1062,7 @@ fn sub_worker_spawn_tool_impl(
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifications: ParentNotificationTarget,
runtime_base: PathBuf,
bash_output_dir: PathBuf,
workspace_root: PathBuf,
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
registry: Arc<SpawnedWorkerRegistry>,
@@ -1110,7 +1094,6 @@ fn sub_worker_spawn_tool_impl(
workspace_context.clone(),
parent_notifications.clone(),
runtime_base.clone(),
bash_output_dir.clone(),
workspace_root.clone(),
workdir_tool_broker.clone(),
registry.clone(),
@@ -1374,7 +1357,6 @@ enabled = false
ParentNotificationTarget::Durable(Arc::new(|_| {})),
),
runtime.path().to_path_buf(),
bash_output_dir.clone(),
workspace_root.clone(),
Some(workdir_tool_broker),
registry.clone(),
@@ -1425,12 +1407,7 @@ enabled = false
.await
.expect("spawn project reviewer as Internal Worker");
assert!(output.summary.contains("internal worker `reviewer-child`"));
assert!(
bash_output_dir
.join("sub-workers")
.join("reviewer-child")
.is_dir()
);
assert!(!bash_output_dir.join("sub-workers").exists());
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
let record = registry
.get_internal("reviewer-child")
@@ -1460,6 +1437,27 @@ enabled = false
record.session.wait_until_idle().await,
crate::internal_worker::InternalWorkerSessionStatus::Idle
);
let child_output_view = record.child_registry.bash_output();
let child_bash = tools::BashTool::new(
record.workdir_tool_scope.session_router(),
child_output_view.clone(),
);
let child_output = child_bash
.execute_result(
r#"{"command":"printf retained-child-evidence","cwd":"."}"#,
Default::default(),
)
.await
.unwrap();
let output_id = child_output.metadata.id.clone();
assert!(child_output_view.metadata(&output_id).is_some());
assert_eq!(
registry
.bash_output()
.read(&output_id, workdir::CommandStream::Stdout, 0, 65536)
.unwrap(),
b"retained-child-evidence"
);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(!observed_parent_write_revoked.load(Ordering::SeqCst));
assert!(observed_instruction_override.load(Ordering::SeqCst));
@@ -1563,6 +1561,8 @@ enabled = false
!record.workdir_tool_scope.is_active(),
"stopped child must revoke cloned scoped sessions"
);
assert!(child_output_view.metadata(&output_id).is_none());
assert!(registry.bash_output().metadata(&output_id).is_some());
assert!(registry.get_internal("reviewer-child").is_some());
assert!(
@@ -1573,6 +1573,7 @@ enabled = false
.is_some()
);
assert!(registry.get_internal("reviewer-child").is_none());
assert!(registry.bash_output().metadata(&output_id).is_some());
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
fail_requests.store(false, Ordering::SeqCst);
@@ -1672,7 +1673,6 @@ enabled = false
),
ParentNotificationTarget::Durable(Arc::new(|_| {})),
runtime.path().to_path_buf(),
bash_output_dir,
workspace_root.clone(),
Some(workdir_tool_broker),
registry.clone(),
@@ -1805,7 +1805,6 @@ enabled = false
ParentNotificationTarget::Durable(Arc::new(|_| {})),
),
runtime.path().to_path_buf(),
bash_output_dir.clone(),
workspace_root.clone(),
Some(workdir_tool_broker),
registry.clone(),
@@ -1987,6 +1986,12 @@ enabled = false
"test"
}
fn authorize_feature_activation(&self, _feature: &str) -> Result<(), WorkspaceClientError> {
// This fixture represents a non-Subject Host admitting ordinary
// Internal Worker configuration, not an operation permission grant.
Ok(())
}
fn is_available(&self) -> bool {
true
}
+53 -17
View File
@@ -26,6 +26,30 @@ use server_api::*;
use crate::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceServerOperation};
impl<C: agen::llm_client::client::LlmClient + 'static, St: session_store::Store>
crate::Worker<C, St>
{
/// Bind live Host capability and its context provider, not authority inherited
/// from a previous binding. Ordinary Feature/lifecycle gates also apply to embedders.
pub fn bind_subjektiv_host(&mut self, host: Option<Arc<dyn SubjektivHost>>) {
self.set_subjektiv_host(host);
if self.manifest_lifecycle_features_enabled()
&& self.backend_job().is_none()
&& crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(self.manifest())
&& let Ok(Some(connection)) = self.subjektiv_host_connection()
&& (connection.host.journal_startup_granted()
|| connection.host.journal_compaction_granted())
{
self.install_session_context_source(Arc::new(
crate::feature::builtin::journal::JournalContextSource::new(
connection,
self.prompts(),
),
));
}
}
}
/// Host-issued execution context, never deserialized from model arguments.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubjektivHostContext {
@@ -919,7 +943,6 @@ enabled = true
WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"),
bash_output_dir: temp.path().join("bash-output"),
},
WorkerControllerTransport::InProcess,
)
@@ -1113,7 +1136,6 @@ enabled = true
WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"),
bash_output_dir: temp.path().join("bash"),
},
WorkerControllerTransport::InProcess,
)
@@ -1130,7 +1152,12 @@ enabled = true
let initial = store.read_all(session, segment).unwrap();
let journal = initial.iter().find_map(|entry| match entry {
LogEntry::AnnotatedSystemItem { entry, .. }
if matches!(entry.item, SystemItem::JournalStartup { .. }) =>
if entry
.metadata
.input_metadata
.as_ref()
.and_then(|input| input.feature_payload())
.is_some_and(|payload| payload.schema() == "journal:startup") =>
{
Some(entry)
}
@@ -1140,22 +1167,33 @@ enabled = true
let body = journal.map(|journal| {
assert_eq!(
journal.metadata.origin,
LoggedSessionHistoryOrigin::JournalContext
LoggedSessionHistoryOrigin::Provided {
producer: "subjektiv.journal".into()
}
);
let SystemItem::JournalStartup {
body,
snapshot: saved,
snapshot_sha256,
prompt_provenance,
} = &journal.item
else {
let SystemItem::Input { body } = &journal.item else {
unreachable!()
};
assert_eq!(*saved, serde_json::to_value(&snapshot).unwrap());
let payload = journal
.metadata
.input_metadata
.as_ref()
.unwrap()
.feature_payload()
.unwrap()
.decode::<crate::feature::builtin::journal::JournalStartupInput>(
"journal:startup",
1,
)
.unwrap();
let saved = serde_json::to_value(&payload.snapshot).unwrap();
let snapshot_sha256 = &payload.snapshot_sha256;
let prompt_provenance = &payload.prompt_provenance;
assert_eq!(saved, serde_json::to_value(&snapshot).unwrap());
use sha2::{Digest, Sha256};
assert_eq!(
*snapshot_sha256,
Sha256::digest(serde_json::to_vec(saved).unwrap())
Sha256::digest(serde_json::to_vec(&saved).unwrap())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
@@ -1168,7 +1206,7 @@ enabled = true
body.trim_end(),
format!(
"## Prior Journal context\n\n```json\n{}\n```",
serde_json::to_string(saved).unwrap()
serde_json::to_string(&saved).unwrap()
)
);
body.as_str()
@@ -1237,7 +1275,7 @@ enabled = true
}
assert_eq!(host.startup_calls.load(Ordering::SeqCst), 1);
let logs = store.read_all(session, segment).unwrap();
assert_eq!(logs.iter().filter(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if matches!(entry.item, SystemItem::JournalStartup { .. }))).count(), usize::from(contributes_context));
assert_eq!(logs.iter().filter(|entry| matches!(entry, LogEntry::AnnotatedSystemItem { entry, .. } if entry.metadata.input_metadata.as_ref().and_then(|input| input.feature_payload()).is_some_and(|payload| payload.schema() == "journal:startup"))).count(), usize::from(contributes_context));
controller
.handle
.send(Method::Shutdown {
@@ -1279,7 +1317,6 @@ enabled = true
WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"),
bash_output_dir: temp.path().join("bash"),
},
WorkerControllerTransport::InProcess,
)
@@ -1339,7 +1376,6 @@ enabled = true
WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"),
bash_output_dir: temp.path().join("bash-output"),
},
WorkerControllerTransport::InProcess,
)
+120
View File
@@ -0,0 +1,120 @@
//! Host-owned attribution transitions. Metadata callbacks are synchronous, local
//! comparisons only: no network I/O, Feature lookup, or nested store access.
use crate::metadata_lifecycle::SessionMetadataLifecycle;
use session_store::{
SessionId, SubjektivSessionAttributionState, WorkerActiveSegmentRef, WorkerMetadata,
WorkerMetadataStore,
};
pub(crate) struct SessionAttributionMetadata;
impl SessionMetadataLifecycle for SessionAttributionMetadata {
fn active_changing(
&self,
metadata: &mut WorkerMetadata,
next: Option<&WorkerActiveSegmentRef>,
) {
if metadata.active.as_ref().map(|active| active.session_id)
!= next.map(|active| active.session_id)
{
metadata.subjektiv_session_attribution = None;
}
}
}
pub(crate) fn compare_and_swap_attribution<St: WorkerMetadataStore>(
store: &St,
worker_name: &str,
session_id: SessionId,
expected: Option<&SubjektivSessionAttributionState>,
replacement: SubjektivSessionAttributionState,
) -> Result<(), crate::WorkerError> {
let mut matched = false;
store.update_by_name(worker_name, |metadata| {
if metadata.active.as_ref().map(|active| active.session_id) == Some(session_id)
&& metadata.subjektiv_session_attribution.as_ref() == expected
&& replacement.session_id() == session_id
{
metadata.subjektiv_session_attribution = Some(replacement);
matched = true;
}
})?;
if !matched {
return Err(crate::WorkerError::SubjektivSessionAttribution {
state: "attribution_conflict",
message: "active Session or observed attribution changed during Host confirmation"
.into(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
//! Connect the production metadata writer/CAS and Host lifecycle to a real
//! filesystem store; no metadata or persistence boundary is substituted.
use super::*;
use crate::metadata_lifecycle::{
worker_metadata_segment_cas_for_store, worker_metadata_writer_for_store,
};
#[test]
fn host_transition_preserves_same_session_and_invalidates_new_session_atomically() {
let dir = tempfile::tempdir().unwrap();
let store = session_store::FsWorkerStore::new(dir.path()).unwrap();
let session = session_store::new_session_id();
let mut metadata = WorkerMetadata::new(
"body",
Some(WorkerActiveSegmentRef::pending_segment(session)),
);
metadata.workspace_id = Some("workspace".into());
metadata.subjektiv_session_attribution =
Some(SubjektivSessionAttributionState::Confirmed {
session_id: session,
subject_id: "subject".into(),
});
store.write(&metadata).unwrap();
let writer = worker_metadata_writer_for_store(&store);
let cas = worker_metadata_segment_cas_for_store(&store);
let segment =
WorkerActiveSegmentRef::active_segment(session, session_store::new_segment_id());
let mut next = WorkerMetadata::new("body", Some(segment.clone()));
writer(next.clone()).unwrap();
let materialized = store.read_by_name("body").unwrap().unwrap();
assert_eq!(
materialized.subjektiv_session_attribution,
metadata.subjektiv_session_attribution
);
assert_eq!(materialized.workspace_id, metadata.workspace_id);
let compacted =
WorkerActiveSegmentRef::active_segment(session, session_store::new_segment_id());
assert!(cas("body", &segment, compacted.clone()).unwrap());
assert_eq!(
store
.read_by_name("body")
.unwrap()
.unwrap()
.subjektiv_session_attribution,
metadata.subjektiv_session_attribution
);
let new_session = WorkerActiveSegmentRef::pending_segment(session_store::new_session_id());
assert!(!cas("body", &segment, new_session.clone()).unwrap());
assert!(cas("body", &compacted, new_session.clone()).unwrap());
let switched = store.read_by_name("body").unwrap().unwrap();
assert_eq!(switched.active, Some(new_session));
assert!(switched.subjektiv_session_attribution.is_none());
assert_eq!(switched.workspace_id, metadata.workspace_id);
// Returning to the old Session cannot recover the invalidated evidence.
next.active = metadata.active;
writer(next).unwrap();
assert!(
store
.read_by_name("body")
.unwrap()
.unwrap()
.subjektiv_session_attribution
.is_none()
);
}
}
+84 -11
View File
@@ -418,13 +418,22 @@ struct MountedProjection {
impl MountedProjection {
fn resolved(&self) -> WipProjection {
let mut projection = self.projection.clone();
let mut handlers: BTreeMap<_, _> = self
.operation_handlers
.iter()
.map(|(op, (_, h))| (op.clone(), h.clone()))
.collect();
for reference in &projection.object.interfaces {
if let Some(contract) = crate::file_operation::Contract::from_reference(reference) {
handlers.insert(
contract.handler_operation().into(),
self.projection.handler.clone(),
);
}
}
projection.handler = Arc::new(OperationDispatchHandler {
owner: Arc::clone(&self.projection.handler),
handlers: self
.operation_handlers
.iter()
.map(|(operation, (_, handler))| (operation.clone(), Arc::clone(handler)))
.collect(),
handlers,
});
projection
}
@@ -1017,6 +1026,10 @@ impl WipMountRegistry {
descriptor: &InterfaceDescriptor,
validator: Option<&[u8]>,
) -> bool {
if let Some(contract) = crate::file_operation::Contract::from_reference(interface) {
return *descriptor != contract.descriptor()
|| validator != Some(contract.validator().as_slice());
}
self.mounts.values().any(|mounted| {
mounted.projection.interface == *interface
&& (mounted.projection.descriptor != *descriptor
@@ -1082,6 +1095,10 @@ impl WipMountRegistry {
validator: Option<&[u8]>,
replacing_route: Option<&str>,
) -> bool {
if let Some(contract) = crate::file_operation::Contract::from_reference(interface) {
return *descriptor != contract.descriptor()
|| validator != Some(contract.validator().as_slice());
}
self.additional_interfaces.iter().any(|(route, all)| {
Some(route.as_str()) != replacing_route
&& all.iter().any(|p| {
@@ -1188,10 +1205,24 @@ fn validate_projection(projection: &WipProjection) -> Result<(), WipMountError>
route: projection.route.clone(),
message: error.to_string(),
})?;
if projection.object.interfaces != [projection.interface.clone()] {
if !projection.object.interfaces.contains(&projection.interface)
|| projection.object.interfaces.iter().any(|reference| {
*reference != projection.interface
&& crate::file_operation::Contract::from_reference(reference).is_none()
})
{
return Err(WipMountError::InvalidProjection {
route: projection.route.clone(),
message: "projected object must expose exactly its declared interface".into(),
message: "projected object must expose its declared interface and only known common contracts".into(),
});
}
if let Some(contract) = crate::file_operation::Contract::from_reference(&projection.interface)
&& (projection.descriptor != contract.descriptor()
|| projection.interface_validator.as_deref() != Some(contract.validator().as_slice()))
{
return Err(WipMountError::InvalidProjection {
route: projection.route.clone(),
message: "common contract descriptor/validator differs from its definition".into(),
});
}
projection
@@ -1304,9 +1335,13 @@ impl WipHost {
path: &str,
reference: &InterfaceReference,
) -> Result<Option<WipPublication>, ProtocolError> {
let Some(owner) = self.publication_live(path).await? else {
let Some(mut owner) = self.publication_live(path).await? else {
return Ok(None);
};
if crate::file_operation::select(&mut owner.projection, reference) {
owner.scope = self.object_at(&reference.scope);
return Ok(Some(owner));
}
// A live provider owns all its publication metadata; static additional
// Interface registrations must not survive its scope lifetime changes.
if owner.projection.interface == *reference || self.subtree(path).is_some() {
@@ -1333,6 +1368,20 @@ impl WipHost {
reference
.validate()
.map_err(|e| protocol_error(ProtocolErrorCode::InvalidRequest, e.to_string()))?;
if let Some(contract) = crate::file_operation::Contract::from_reference(reference) {
let scope = self.object_at("/").ok_or_else(|| {
protocol_error(
ProtocolErrorCode::InterfaceNotFound,
"root scope unavailable",
)
})?;
return Ok(FetchInterfaceResponse {
interface: reference.clone(),
scope_ref: scope.r#ref,
descriptor: contract.descriptor(),
validator: Some(contract.validator()),
});
}
let target = if let Some(mount) = self.subtree(&reference.scope) {
let target = mount.provider.interface_target(reference).ok_or_else(|| {
protocol_error(
@@ -1496,6 +1545,9 @@ impl WipHost {
&self,
reference: &InterfaceReference,
) -> Option<(InterfaceDescriptor, Option<Vec<u8>>)> {
if let Some(contract) = crate::file_operation::Contract::from_reference(reference) {
return Some((contract.descriptor(), Some(contract.validator())));
}
for (path, all) in &self.registry.additional_interfaces {
if self.projection(path).is_none() {
continue;
@@ -1560,6 +1612,18 @@ impl WipHost {
if let Some(validator) = projection.handler.object_validator() {
projection.object.validator = Some(validator);
}
if let Some(contract) =
crate::file_operation::Contract::from_reference(&projection.interface)
{
if !projection
.handler
.operation_available(contract.handler_operation())
{
return None;
}
// Common descriptor identity never varies with subject or capability filtering.
return Some(projection);
}
let contextual = contextual_family || projection.handler.contextual_interface();
let original_len = projection.descriptor.operations.len();
projection
@@ -5502,6 +5566,7 @@ mod tests {
entry: session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(saved),
metadata: session_store::LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
@@ -6044,6 +6109,10 @@ mod tests {
};
let authoring_client = Arc::new(ScriptedWorkspaceClient::new([
query_response,
crate::worker::WorkspaceResponse {
status: 200,
body: json!({"scope": null}).to_string(),
},
create_response,
comment_response,
]));
@@ -6125,11 +6194,12 @@ mod tests {
.await
.unwrap();
let authoring_requests = authoring_client.requests();
assert_eq!(authoring_requests.len(), 3);
assert_eq!(authoring_requests.len(), 4);
assert!(authoring_requests[0].path.ends_with("/tickets/query"));
assert!(authoring_requests[1].path.ends_with("/tickets"));
assert!(authoring_requests[1].path.ends_with("/authorization/check"));
assert!(authoring_requests[2].path.ends_with("/tickets"));
assert!(
authoring_requests[2]
authoring_requests[3]
.path
.ends_with("/tickets/T-9/thread-events")
);
@@ -7057,3 +7127,6 @@ mod tests {
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
}
#[cfg(test)]
mod bash_output_tests;
+510
View File
@@ -0,0 +1,510 @@
//! Native Bash output integration through real WIP Client/Host codec and local process pipes.
//! No telemetry/storage fake: only the model client is absent. Runtime HTTP transport is tested
//! by worker-runtime's command_transport_tests. Each fixture owns its Workdir and ephemeral view.
use super::*;
use crate::file_operation::Contract;
use tempfile::TempDir;
use tools::bash_output::BashOutputView;
use workdir::{
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter,
};
struct Fixture {
_dir: TempDir,
router: Arc<WorkdirSessionRouter>,
view: BashOutputView,
runtime: WipRuntime,
}
impl Fixture {
fn new() -> Self {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("real.txt"), "first\n雪 needle\nlast\n").unwrap();
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
manifest::Scope::writable(dir.path()).unwrap(),
dir.path().into(),
));
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(WorkdirAttachmentAlias::new("main").unwrap(), session)
.unwrap();
let view = BashOutputView::new();
let runtime = runtime(router.clone(), view.clone());
Self {
_dir: dir,
router,
view,
runtime,
}
}
async fn run(&self, command: &str) -> Json {
value(
self.runtime
.invoke(
"/bash-output".into(),
contextual_reference("yoi.bash/command/v1", "/bash-output"),
"run".into(),
json!({"input":{"command":command,"cwd":"."}}),
Default::default(),
)
.await
.unwrap(),
)
}
}
fn runtime(router: Arc<WorkdirSessionRouter>, view: BashOutputView) -> WipRuntime {
let mut registry = WipMountRegistry::new();
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
.unwrap();
crate::feature::builtin::bash::mount(&mut registry, router, view, None).unwrap();
WipRuntime::new(WipHost::new(registry), SecurityContext::new("bash-test"), 0).unwrap()
}
fn value(output: ToolOutput) -> Json {
serde_json::from_str(&output.content.unwrap()).unwrap()
}
async fn invoke(
runtime: &WipRuntime,
path: &str,
contract: Contract,
operation: &str,
args: Json,
) -> Result<Json, ToolError> {
runtime
.invoke(
path.into(),
contract.reference(),
operation.into(),
args,
Default::default(),
)
.await
.map(value)
}
#[tokio::test]
async fn native_bash_entries_reuse_checkout_contract_for_direct_inspect_read_search() {
let f = Fixture::new();
let result = f
.run("printf 'first\n雪 needle\nlast\n'; printf error >&2; exit 3")
.await;
assert_eq!(result["metadata"]["terminal"]["exit_code"], 3);
let stdout = result["stdout_entry"].as_str().unwrap();
assert!(stdout.starts_with("/bash-output/"));
// The native result Descriptor gives these coordinates TypeExpr::Entry; Object inspection
// contains capabilities and metadata, not the process output body.
let projection = f
.runtime
.host
.projection_live("/bash-output")
.await
.unwrap()
.unwrap();
let TypeExpr::Record { fields } = &projection.descriptor.operations[0].returns.r#type else {
panic!("native record");
};
assert_eq!(
fields
.iter()
.find(|v| v.name == "stdout_entry")
.unwrap()
.r#type,
TypeExpr::Entry
);
let inspect = f.runtime.inspect(stdout.into(), false).await.unwrap();
assert!(!inspect.content.unwrap().contains("雪 needle"));
for path in ["/checkouts/main/real.txt", stdout] {
let read = invoke(
&f.runtime,
path,
Contract::ReadLines,
"read",
json!({"offset":1,"limit":1}),
)
.await
.unwrap();
assert!(read["content"].as_str().unwrap().contains("雪 needle"));
assert_eq!(read["next_offset"], 2);
let search = invoke(
&f.runtime,
path,
Contract::SearchText,
"search",
json!({"offset":0,"limit":2,"pattern":"needle"}),
)
.await
.unwrap();
assert_eq!(search["matches"][0]["line"], 2);
assert_eq!(search["next_offset"], 2);
assert_eq!(search["truncated"], true);
}
for name in ["stdout_entry", "stderr_entry", "order_entry"] {
assert_eq!(
fields.iter().find(|v| v.name == name).unwrap().r#type,
TypeExpr::Entry
);
f.runtime
.inspect(result[name].as_str().unwrap().into(), false)
.await
.unwrap();
}
let stderr = invoke(
&f.runtime,
result["stderr_entry"].as_str().unwrap(),
Contract::ReadLines,
"read",
json!({}),
)
.await
.unwrap();
assert!(stderr["content"].as_str().unwrap().contains("error"));
let order = invoke(
&f.runtime,
result["order_entry"].as_str().unwrap(),
Contract::ReadLines,
"read",
json!({}),
)
.await
.unwrap();
assert!(order["content"].as_str().unwrap().contains("Stdout"));
assert!(order["content"].as_str().unwrap().contains("Stderr"));
assert_eq!(result["metadata"]["ordering"], "provider_read_observation");
let tree = value(
f.runtime
.tree("/bash-output".into(), 8, true)
.await
.unwrap(),
);
assert!(!tree.to_string().contains(stdout));
assert!(
invoke(
&f.runtime,
stdout,
Contract::Write,
"write",
json!({"content":"no"})
)
.await
.is_err()
);
assert!(
invoke(
&f.runtime,
stdout,
Contract::Edit,
"edit",
json!({"old_string":"first","new_string":"no"})
)
.await
.is_err()
);
}
#[tokio::test]
async fn common_byte_reader_handles_long_lines_raw_invalid_utf8_boundaries_and_eof() {
let f = Fixture::new();
let result = f.run("printf '%100000s' ''; printf '€\\377'").await;
let path = result["stdout_entry"].as_str().unwrap();
assert!(
invoke(
&f.runtime,
path,
Contract::ReadLines,
"read",
json!({"limit":1})
)
.await
.is_err()
);
for (offset, expected) in [
(99999, vec![b' ', 0xe2]),
(100001, vec![0x82, 0xac]),
(100003, vec![0xff]),
] {
let read = invoke(
&f.runtime,
path,
Contract::ReadBytes,
"read",
json!({"offset":offset,"limit":2}),
)
.await
.unwrap();
let bytes = base64::engine::general_purpose::STANDARD
.decode(read["data"].as_str().unwrap())
.unwrap();
assert_eq!(bytes, expected);
assert_eq!(read["next_offset"], offset + expected.len() as u64);
}
let eof = invoke(
&f.runtime,
path,
Contract::ReadBytes,
"read",
json!({"offset":100004,"limit":2}),
)
.await
.unwrap();
assert_eq!(eof["data"], "");
assert_eq!(eof["eof"], true);
assert!(
invoke(
&f.runtime,
path,
Contract::ReadBytes,
"read",
json!({"offset":100005})
)
.await
.is_err()
);
assert!(
invoke(
&f.runtime,
path,
Contract::ReadBytes,
"read",
json!({"limit":32769})
)
.await
.is_err()
);
}
#[tokio::test]
async fn publication_and_invoke_recheck_child_authority_and_root_teardown_invalidates_old_refs() {
let f = Fixture::new();
let lease = Arc::new(std::sync::atomic::AtomicBool::new(true));
let child = f.view.child({
let lease = lease.clone();
Arc::new(move || lease.load(std::sync::atomic::Ordering::Acquire))
});
let child_runtime = runtime(f.router.clone(), child.clone());
let result = value(
child_runtime
.invoke(
"/bash-output".into(),
contextual_reference("yoi.bash/command/v1", "/bash-output"),
"run".into(),
json!({"input":{"command":"printf child-evidence","cwd":"."}}),
Default::default(),
)
.await
.unwrap(),
);
let path = result["stdout_entry"].as_str().unwrap();
child_runtime.inspect(path.into(), false).await.unwrap();
let sibling = runtime(f.router.clone(), f.view.child(Arc::new(|| true)));
let unrelated = runtime(f.router.clone(), BashOutputView::new());
assert!(sibling.inspect(path.into(), false).await.is_err());
assert!(unrelated.inspect(path.into(), false).await.is_err());
lease.store(false, std::sync::atomic::Ordering::Release);
assert!(
invoke(&child_runtime, path, Contract::ReadBytes, "read", json!({}))
.await
.is_err()
);
assert!(
invoke(
&child_runtime,
path,
Contract::SearchText,
"search",
json!({"pattern":"child"})
)
.await
.is_err()
);
assert!(
invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({}))
.await
.is_ok()
);
f.view.revoke();
assert!(
invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({}))
.await
.is_err()
);
let restored = runtime(f.router.clone(), BashOutputView::new());
assert!(restored.inspect(path.into(), false).await.is_err());
}
#[tokio::test]
async fn native_timeout_and_cancellation_keep_terminal_evidence_instead_of_success() {
let f = Fixture::new();
let session = f.router.only_session().unwrap();
let mut events = session.subscribe_command_events().unwrap();
let runtime = Arc::new(f.runtime);
let context = agen::tool::ToolExecutionContext::new("native-cancel", "native-attempt", 0);
let running = runtime.clone();
let executing_context = context.clone();
let execution = tokio::spawn(async move {
running
.invoke(
"/bash-output".into(),
contextual_reference("yoi.bash/command/v1", "/bash-output"),
"run".into(),
json!({"input":{"command":"printf before; while :; do :; done","cwd":"."}}),
executing_context,
)
.await
});
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
if matches!(
events.recv().await.unwrap(),
workdir::CommandEvent::Output { .. }
) {
break;
}
}
})
.await
.unwrap();
runtime.cancel(&context).await.unwrap();
let ToolError::Cancelled(output) =
tokio::time::timeout(std::time::Duration::from_secs(5), execution)
.await
.unwrap()
.unwrap()
.unwrap_err()
else {
panic!("expected cancelled terminal output")
};
let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap();
let id = body["output"]["id"].as_str().unwrap();
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
assert_eq!(body["output"]["terminal"]["status"], "cancelled");
assert_eq!(
f.view
.read(id, workdir::CommandStream::Stdout, 0, 20)
.unwrap(),
b"before"
);
assert!(
invoke(
&runtime,
body["stdout_entry"].as_str().unwrap(),
Contract::ReadBytes,
"read",
json!({})
)
.await
.is_ok()
);
let error = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf timeout; while :; do :; done","cwd":".","timeout":1}}), Default::default()).await.unwrap_err();
let ToolError::Interrupted(output) = error else {
panic!("expected timeout evidence")
};
let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap();
assert_eq!(body["output"]["terminal"]["status"], "timed_out");
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
assert!(
f.view
.metadata(body["output"]["id"].as_str().unwrap())
.is_some()
);
session.close().await.unwrap();
}
#[tokio::test]
async fn common_read_and_search_permissions_are_independent_from_bash_execution() {
let f = Fixture::new();
let result = f.run("printf needle").await;
let path = result["stdout_entry"].as_str().unwrap();
let mut registry = WipMountRegistry::new();
crate::feature::builtin::bash::mount(
&mut registry,
f.router.clone(),
f.view.clone(),
Some(ToolPermissionConfig {
default_action: ToolPermissionAction::Deny,
rules: vec![manifest::ToolPermissionRule {
tool: "Grep".into(),
pattern: "*".into(),
action: ToolPermissionAction::Allow,
}],
}),
)
.unwrap();
let restricted = WipRuntime::new(
WipHost::new(registry),
SecurityContext::new("restricted"),
0,
)
.unwrap();
restricted.inspect(path.into(), false).await.unwrap();
assert!(
invoke(&restricted, path, Contract::ReadBytes, "read", json!({}))
.await
.is_err()
);
assert!(
invoke(&restricted, path, Contract::ReadLines, "read", json!({}))
.await
.is_err()
);
let found = invoke(
&restricted,
path,
Contract::SearchText,
"search",
json!({"pattern":"needle"}),
)
.await
.unwrap();
assert!(found["matches"].as_array().unwrap().len() == 1);
assert!(
restricted
.invoke(
"/bash-output".into(),
contextual_reference("yoi.bash/command/v1", "/bash-output"),
"run".into(),
json!({"input":{"command":"touch denied","cwd":"."}}),
Default::default()
)
.await
.is_err()
);
assert!(!f._dir.path().join("denied").exists());
}
#[tokio::test]
async fn multibyte_long_line_search_refuses_instead_of_skipping_an_unread_suffix() {
let f = Fixture::new();
let result = f
.run("printf '雪%.0s' {1..22000}; printf 'needle\nnext\n'")
.await;
let path = result["stdout_entry"].as_str().unwrap();
let error = invoke(
&f.runtime,
path,
Contract::SearchText,
"search",
json!({"pattern":"needle","offset":0,"limit":1}),
)
.await
.unwrap_err();
assert!(
error.to_string().contains("ResourceLimitExceeded"),
"{error}"
);
// No successful page claims to advance past a partial line. Raw bytes can still
// retrieve its unread suffix using the same common file contract.
let suffix = invoke(
&f.runtime,
path,
Contract::ReadBytes,
"read",
json!({"offset":66000,"limit":7}),
)
.await
.unwrap();
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(suffix["data"].as_str().unwrap())
.unwrap(),
b"needle\n"
);
assert_eq!(suffix["next_offset"], 66007);
}
+1326 -313
View File
File diff suppressed because it is too large Load Diff
+277 -60
View File
@@ -119,6 +119,7 @@ fn annotated(item: Item) -> session_store::LoggedHistoryEntry {
session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(item),
metadata: session_store::LoggedSessionHistoryMetadata {
input_metadata: None,
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
@@ -1495,11 +1496,9 @@ async fn post_compact_pause_bills_partial_without_rearming_or_consuming_correlat
let session_id = worker.session_id();
let runtime_tmp = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_tmp.path().join("bash-output");
let (handle, shutdown_receiver) =
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
.await
.unwrap();
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
.await
.unwrap();
let mut rx = handle.subscribe();
handle
.send(Method::submit_text(
@@ -1705,11 +1704,9 @@ async fn controller_does_not_repeat_a_persisted_mid_run_compaction_failure() {
let (worker, store) = make_worker_with_manifest_and_store(MID_TURN_MANIFEST_TOML, client).await;
let session_id = worker.session_id();
let runtime_tmp = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_tmp.path().join("bash-output");
let (handle, shutdown_receiver) =
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
.await
.unwrap();
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
.await
.unwrap();
let mut rx = handle.subscribe();
handle
@@ -1830,11 +1827,9 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
let source_session = worker.session_id();
let source_segment = worker.segment_id();
let runtime_tmp = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_tmp.path().join("bash-output");
let (handle, shutdown_receiver) =
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
.await
.unwrap();
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
.await
.unwrap();
let mut rx = handle.subscribe();
handle
@@ -1996,8 +1991,7 @@ async fn controller_compact_method_publishes_progress_and_clear() {
]);
let worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
let runtime_tmp = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_tmp.path().join("bash-output");
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path())
.await
.unwrap();
let mut rx = handle.subscribe();
@@ -2219,10 +2213,48 @@ fn summary_entry(
origin: session_store::LoggedSessionHistoryOrigin,
) -> session_store::LoggedHistoryEntry {
let mut entry = annotated(item);
entry.metadata.input_metadata = matches!(
&entry.item,
session_store::LoggedItem::Message {
role: session_store::LoggedRole::System,
..
}
)
.then(|| {
session_store::HistoryInputMetadata::new(
session_store::HistoryInputUsePolicy {
recording_material: session_store::InputEvidenceUse::Exclude,
memory_evidence: session_store::InputEvidenceUse::Exclude,
observation: session_store::InputObservationUse::Hidden,
compaction: match origin {
session_store::LoggedSessionHistoryOrigin::CompactionSummary => {
session_store::InputCompactionUse::OpeningSummary
}
session_store::LoggedSessionHistoryOrigin::JournalContext => {
session_store::InputCompactionUse::ReferenceInput
}
_ => session_store::InputCompactionUse::Retain,
},
},
None,
None,
)
.unwrap()
});
entry.metadata.origin = origin;
entry
}
fn compaction_use(
metadata: &session_store::LoggedSessionHistoryMetadata,
) -> session_store::InputCompactionUse {
metadata
.input_metadata
.as_ref()
.map(|input| input.usage().compaction)
.unwrap_or(session_store::InputCompactionUse::Retain)
}
fn long_summary(label: &str) -> String {
format!(
"## Completed Tasks\n{label}: investigated the transport boundary.\n\n\
@@ -2358,7 +2390,8 @@ async fn full_initial_summary_survives_small_overview_budgets_and_both_retention
assert_eq!(
replacement
.iter()
.filter(|entry| entry.metadata.origin == CompactionSummary)
.filter(|entry| compaction_use(&entry.metadata)
== session_store::InputCompactionUse::OpeningSummary)
.count(),
1
);
@@ -2603,7 +2636,9 @@ async fn three_chained_compactions_transport_each_complete_predecessor_without_r
let seed = replacement_seed(&fixture.worker);
let inherited = seed
.iter()
.filter(|entry| entry.metadata.origin == CompactionSummary)
.filter(|entry| {
compaction_use(&entry.metadata) == session_store::InputCompactionUse::OpeningSummary
})
.collect::<Vec<_>>();
assert_eq!(inherited.len(), 1);
assert_eq!(
@@ -2891,7 +2926,7 @@ impl worker::subjektiv::SubjektivHost for CompactionJournalHost {
}
fn journal_startup_granted(&self) -> bool {
true
false
}
fn journal_startup_context(
@@ -2995,8 +3030,8 @@ fn compaction_snapshot(generation: usize) -> subjektiv::journal::startup::Journa
fn journal_body(worker: &Worker<MockClient, TestStore>) -> String {
let seed = replacement_seed(worker);
assert_eq!(
seed[1].metadata.origin,
session_store::LoggedSessionHistoryOrigin::JournalContext
compaction_use(&seed[1].metadata),
session_store::InputCompactionUse::ReferenceInput
);
let item = Item::from(seed[1].item.clone());
assert!(matches!(
@@ -3008,8 +3043,8 @@ fn journal_body(worker: &Worker<MockClient, TestStore>) -> String {
));
assert_eq!(
seed.iter()
.filter(|entry| entry.metadata.origin
== session_store::LoggedSessionHistoryOrigin::JournalContext)
.filter(|entry| compaction_use(&entry.metadata)
== session_store::InputCompactionUse::ReferenceInput)
.count(),
1
);
@@ -3118,20 +3153,34 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai
projection.source_digest = "a".repeat(64);
let initial_catalog = Arc::new(worker::PromptCatalog::from_projection(projection).unwrap());
fixture.worker.prompts().store(initial_catalog.clone());
let legacy_startup = session_store::LoggedSystemHistoryEntry {
item: session_store::SystemItem::JournalStartup {
body: "OLD_TYPED_STARTUP_JOURNAL".into(),
snapshot: serde_json::json!({"journals": []}),
snapshot_sha256: "legacy-startup-digest".into(),
prompt_provenance: session_store::PromptRenderProvenance {
workspace_id: None,
source_digest: "legacy-source".into(),
projection_digest: "legacy-projection".into(),
logical_name: "internal.subjektiv_journal_startup".into(),
},
},
metadata: summary_entry(Item::system_message(""), DerivedSummary).metadata,
};
// Already migrated generic input with unrelated provenance/payload; Core
// must select it by explicit policy, not identify a Journal Feature.
let legacy_startup = session_store::LoggedSystemHistoryEntry::from_input(
DerivedSummary,
None,
session_store::SystemInputEnvelope::new(
"OLD_TYPED_STARTUP_JOURNAL",
session_store::HistoryInputMetadata::new(
session_store::HistoryInputUsePolicy {
recording_material: session_store::InputEvidenceUse::Exclude,
memory_evidence: session_store::InputEvidenceUse::Exclude,
observation: session_store::InputObservationUse::Hidden,
compaction: session_store::InputCompactionUse::ReferenceInput,
},
Some(
session_store::FeatureInputPayload::encode(
"renamed-owner:reference",
8,
&serde_json::json!({"private": "retained opaque context"}),
)
.unwrap(),
),
None,
)
.unwrap(),
)
.unwrap(),
);
let legacy_id = legacy_startup.metadata.entry_id.clone();
fixture
.restore_seed_with_system(
@@ -3165,7 +3214,7 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai
.unwrap()
.annotation
.origin,
JournalContext
DerivedSummary
);
let session = fixture.worker.session_id();
let original_segment = fixture.worker.segment_id();
@@ -3190,8 +3239,8 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai
.unwrap()
.annotation
.origin,
JournalContext,
"legacy startup classification survives a normal turn"
DerivedSummary,
"generic source provenance survives a normal turn unchanged"
);
}
assert_eq!(
@@ -3242,7 +3291,7 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai
assert_eq!(host.reads.lock().unwrap().len(), index + 1);
let history = fixture.worker.history();
let overview = input
.split("## Previous Journal reference (historical, not automatically retained)")
.split("## Previous reference input (historical, not automatically retained)")
.next()
.unwrap();
for old in &old_bodies {
@@ -3335,6 +3384,17 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai
.run_text("normal post-compaction turn")
.await
.unwrap();
let frozen_seed = replacement_seed(&fixture.worker)
.into_iter()
.filter(|entry| {
matches!(
compaction_use(&entry.metadata),
session_store::InputCompactionUse::ReferenceInput
| session_store::InputCompactionUse::OpeningSummary
)
})
.collect::<Vec<_>>();
assert_eq!(frozen_seed.len(), 2);
let records = fixture
.worker
.store()
@@ -3374,6 +3434,20 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai
.run_text("normal turn after typed history restore")
.await
.unwrap();
for frozen in frozen_seed {
let replayed = fixture
.worker
.session_history()
.entries()
.iter()
.find(|entry| entry.annotation.entry_id == frozen.metadata.entry_id)
.expect("frozen entry identity survives re-restore");
assert_eq!(replayed.item, Item::from(frozen.item));
assert_eq!(
replayed.annotation, frozen.metadata,
"origin, derivation, public allowlist, policy and private payload survive atomic seed/restore"
);
}
assert_eq!(
host.reads.lock().unwrap().len(),
3,
@@ -3444,27 +3518,41 @@ async fn journal_absent_disabled_unavailable_and_over_byte_budget_keep_conversat
)
.await;
fixture.worker.compact(0).await.unwrap();
let body = journal_body(&fixture.worker);
let expected = match case {
"no-host" | "disabled" => "disabled",
"absent" => "absent",
_ => "unavailable",
};
assert!(
body.contains(&format!("Journal read status: {expected}.")),
"{case}"
);
let input = first_compactor_input(&observed.requests()[1]);
assert_frozen_journal_transport(&input, &body);
assert_journal_provenance(
&body,
expected_json.as_deref(),
&fixture.worker.prompts().load_full(),
(case != "no-host").then_some(COMPACTION_HOST_WORKSPACE_ID),
);
let body = if matches!(case, "disabled" | "no-host") {
assert_eq!(
replacement_seed(&fixture.worker)
.iter()
.filter(|entry| compaction_use(&entry.metadata)
== session_store::InputCompactionUse::ReferenceInput)
.count(),
0
);
assert!(!input.contains("Journal reference frozen at this compaction boundary"));
String::new()
} else {
let body = journal_body(&fixture.worker);
let expected = if case == "absent" {
"absent"
} else {
"unavailable"
};
assert!(
body.contains(&format!("Journal read status: {expected}.")),
"{case}"
);
assert_frozen_journal_transport(&input, &body);
assert_journal_provenance(
&body,
expected_json.as_deref(),
&fixture.worker.prompts().load_full(),
Some(COMPACTION_HOST_WORKSPACE_ID),
);
body
};
assert_full_initial_summary(&input, &inherited);
assert!(input.contains("UNRECORDED_FALLBACK_GOAL"), "{case}");
assert!(input.contains("## Previous Journal reference (historical, not automatically retained)\nPREVIOUS_JOURNAL_DELEGATED_CONTEXT"));
assert!(input.contains("## Previous reference input (historical, not automatically retained)\nPREVIOUS_JOURNAL_DELEGATED_CONTEXT"));
assert!(
fixture.worker.history().iter().all(|item| !item
.as_text()
@@ -3567,3 +3655,132 @@ async fn journal_snapshot_counts_against_compactor_hard_input_cap_without_mutati
);
}
}
#[tokio::test]
async fn identical_reference_and_retained_inputs_preserve_distinct_metadata_after_compaction() {
use session_store::*;
let client = MockClient::new(vec![
single_text_events("fixture"),
single_text_events("fork checkpoint"),
write_summary_tool_use_events("review-summary", "replacement summary"),
single_text_events("done"),
single_text_events("restore checkpoint"),
]);
let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await;
let make = |producer: &str, compaction| {
LoggedSystemHistoryEntry::from_input(
LoggedSessionHistoryOrigin::Provided {
producer: producer.into(),
},
None,
SystemInputEnvelope::new(
"identical model context",
HistoryInputMetadata::new(
HistoryInputUsePolicy {
recording_material: InputEvidenceUse::Exclude,
memory_evidence: InputEvidenceUse::Exclude,
observation: InputObservationUse::Hidden,
compaction,
},
Some(
FeatureInputPayload::encode(
"review:owner",
1,
&serde_json::json!({"producer": producer}),
)
.unwrap(),
),
None,
)
.unwrap(),
)
.unwrap(),
)
.to_history_entry()
};
let historical = make("old-reference", InputCompactionUse::ReferenceInput);
let retained = make("retained-context", InputCompactionUse::Retain);
let repeated = make("second-retained-context", InputCompactionUse::Retain);
assert_ne!(historical.metadata.entry_id, retained.metadata.entry_id);
fixture
.restore_seed(
vec![historical.clone(), retained.clone(), repeated.clone()],
false,
)
.await;
let session = fixture.worker.session_id();
let original = fixture.worker.segment_id();
fixture
.worker
.store()
.append(
session,
original,
&LogEntry::Extension {
ts: 2,
domain: "test.fork_drift".into(),
payload: serde_json::json!({}),
},
)
.unwrap();
fixture.worker.run_text("fork checkpoint").await.unwrap();
let fork = fixture.worker.segment_id();
assert_ne!(fork, original);
let forked = replacement_seed(&fixture.worker);
for expected in [&historical, &retained, &repeated] {
assert!(forked.contains(expected));
}
fixture.worker.compact(1_000_000).await.unwrap();
let seed = replacement_seed(&fixture.worker);
let matching: Vec<_> = seed.iter().filter(|e| e.item == retained.item).collect();
assert_eq!(matching.len(), 2);
assert_eq!(
matching[0].metadata, retained.metadata,
"a retained logical entry must not inherit identity/policy/payload from an equal-body reference that was removed"
);
assert_eq!(matching[1].metadata, repeated.metadata);
let compacted = fixture.worker.segment_id();
let reopened = FsStore::new(fixture._store_dir.path()).unwrap();
let records = reopened.read_all(session, compacted).unwrap();
assert!(
matches!(&records[0], LogEntry::AnnotatedSegmentStart { compacted_from: Some(source), .. } if source.segment_id == fork)
);
fixture.worker.run_text("restore checkpoint").await.unwrap();
let records = fixture.worker.store().read_all(session, compacted).unwrap();
let snapshot = public_snapshot::project_current_session_snapshot(&records);
let checkpoint = snapshot
.entries
.iter()
.rev()
.find(|entry| {
matches!(
entry.data,
protocol::SessionSnapshotEntryData::UserInput { .. }
)
})
.unwrap();
fixture
.worker
.rewind_to(checkpoint.entry_id.clone())
.await
.unwrap();
for expected in [&retained, &repeated] {
assert!(
fixture
.worker
.session_history()
.entries()
.iter()
.any(|entry| entry.annotation == expected.metadata
&& entry.item == Item::from(expected.item.clone()))
);
}
assert!(
!fixture
.worker
.session_history()
.entries()
.iter()
.any(|entry| entry.annotation.entry_id == historical.metadata.entry_id)
);
}
+57 -58
View File
@@ -225,8 +225,14 @@ impl WorkspaceClient for AvailableWorkspaceClient {
fn execute(
&self,
_request: WorkspaceRequest,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
return Ok(WorkspaceResponse {
status: 200,
body: "{}".into(),
});
}
Err(WorkspaceClientError::Unavailable("test".to_string()))
}
}
@@ -249,8 +255,14 @@ impl WorkspaceClient for NoopWorkspaceClient {
fn execute(
&self,
_request: WorkspaceRequest,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
return Ok(WorkspaceResponse {
status: 200,
body: "{}".into(),
});
}
Err(WorkspaceClientError::Unavailable(
"test client does not execute requests".to_string(),
))
@@ -311,8 +323,7 @@ async fn spawn_controller(worker: Worker<MockClient, TestStore>) -> WorkerHandle
let tmp = tempfile::tempdir().unwrap();
let runtime_base = tmp.path().to_owned();
std::mem::forget(tmp);
let bash_output_dir = runtime_base.join("bash-output");
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base, &bash_output_dir)
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base)
.await
.unwrap();
handle
@@ -342,13 +353,9 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() {
)
.await
.unwrap();
let (handle, shutdown_rx) = WorkerController::spawn(
worker,
&root.path().join("runtime"),
&root.path().join("bash-output"),
)
.await
.unwrap();
let (handle, shutdown_rx) = WorkerController::spawn(worker, &root.path().join("runtime"))
.await
.unwrap();
let mut rx = handle.subscribe();
let file = handle
.upload_file_with_context(
@@ -408,24 +415,20 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() {
}
#[tokio::test]
async fn controller_grants_read_scope_for_exact_bash_output_directory() {
async fn controller_does_not_grant_or_create_a_model_visible_bash_output_directory() {
let worker = make_worker(MockClient::new(simple_text_events())).await;
let shared_scope = worker.scope().clone();
let runtime_base = tempfile::tempdir().unwrap();
let worker_tmp = tempfile::tempdir().unwrap();
let bash_output_dir = worker_tmp.path().join("worker-1").join("bash-output");
let original_rules = shared_scope.snapshot().allow_rules().to_vec();
let (handle, shutdown_rx) =
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
.await
.unwrap();
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
.await
.unwrap();
assert!(bash_output_dir.is_dir());
assert!(shared_scope.snapshot().allow_rules().iter().any(|rule| {
rule.target == bash_output_dir
&& rule.permission == manifest::Permission::Read
&& rule.recursive
}));
assert_eq!(
shared_scope.snapshot().allow_rules(),
original_rules.as_slice()
);
assert!(!handle.runtime_dir.path().join("bash-output").exists());
handle
@@ -451,9 +454,9 @@ async fn shutdown_closes_bound_workdir_session() {
.start_command(CommandRequest {
command: "sleep 30".to_owned(),
timeout_secs: 60,
output_limit: 1024,
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
cwd: workdir::WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
})
.await
@@ -461,11 +464,9 @@ async fn shutdown_closes_bound_workdir_session() {
worker.bind_single_workdir_session(Some(Arc::clone(&session)));
let runtime_base = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_base.path().join("bash-output");
let (handle, shutdown_rx) =
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
.await
.unwrap();
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
.await
.unwrap();
handle
.send(Method::Shutdown {
command: worker_command(&handle),
@@ -501,9 +502,9 @@ async fn controller_filters_workdir_command_not_owned_by_worker() {
.start_command(CommandRequest {
command: "sleep 0.2".to_owned(),
timeout_secs: 5,
output_limit: 1024,
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
cwd: workdir::WorkdirPath::root(),
spill_dir: None,
tool_call_id: Some("foreign-tool-call".into()),
})
.await
@@ -529,7 +530,10 @@ async fn controller_filters_workdir_command_not_owned_by_worker() {
})
.await
.unwrap();
assert_eq!(output.status, workdir::CommandStatus::Completed);
assert_eq!(
output.terminal.unwrap().status,
workdir::CommandStatus::Completed
);
handle
.send(Method::Shutdown {
command: worker_command(&handle),
@@ -738,9 +742,8 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
std::fs::write(&invalid_runtime_base, "file").unwrap();
let bash_output_dir = runtime_base.path().join("bash-output");
assert!(
WorkerController::spawn(worker, &invalid_runtime_base, &bash_output_dir)
WorkerController::spawn(worker, &invalid_runtime_base)
.await
.is_err()
);
@@ -749,9 +752,9 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
.start_command(CommandRequest {
command: "printf unreachable".to_owned(),
timeout_secs: 5,
output_limit: 1024,
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
cwd: workdir::WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
})
.await,
@@ -926,11 +929,7 @@ mod socket_startup_failure {
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
WorkerController::spawn_runtime_managed_run(
worker,
&run_dir,
&root.path().join("bash-output"),
),
WorkerController::spawn_runtime_managed_run(worker, &run_dir),
)
.await
.expect("socket startup and rollback must settle");
@@ -953,9 +952,8 @@ mod socket_startup_failure {
.start_command(CommandRequest {
command: "printf unreachable".to_owned(),
timeout_secs: 5,
output_limit: 1024,
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
cwd: workdir::WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
})
.await,
@@ -1117,6 +1115,12 @@ impl WorkspaceClient for JournalWorkspaceClient {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
return Ok(WorkspaceResponse {
status: 200,
body: "{}".into(),
});
}
self.requests.lock().unwrap().push(request.clone());
let (status, body) = if request.path.ends_with("/subjektiv/journal/job/finish") {
(
@@ -1758,8 +1762,7 @@ permission = "write"
let client = MockClient::new(simple_text_events());
let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0;
let tmp = tempfile::tempdir().unwrap();
let bash_output_dir = tmp.path().join("bash-output");
let result = WorkerController::spawn(worker, tmp.path(), &bash_output_dir).await;
let result = WorkerController::spawn(worker, tmp.path()).await;
assert!(
result.is_ok(),
"feature exposure must not imply delegation authority"
@@ -3132,11 +3135,9 @@ async fn status_json_reflects_worker_name() {
async fn shutdown_closes_method_admission_before_terminal_confirmation() {
let worker = make_worker(MockClient::new(simple_text_events())).await;
let runtime_base = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_base.path().join("bash-output");
let (handle, mut shutdown_rx) =
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
.await
.unwrap();
let (handle, mut shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
.await
.unwrap();
handle
.send(Method::Shutdown {
command: worker_command(&handle),
@@ -3171,11 +3172,9 @@ async fn shutdown_joins_socket_server_with_active_connection() {
let worker = make_worker(MockClient::new(simple_text_events())).await;
let runtime_base = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_base.path().join("bash-output");
let (handle, shutdown_rx) =
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
.await
.unwrap();
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
.await
.unwrap();
let socket_path = handle.runtime_dir.socket_path();
let _connection = UnixStream::connect(&socket_path).await.unwrap();
@@ -3292,7 +3291,7 @@ async fn socket_worker_event_turn_ended_while_idle_auto_starts_turn() {
Ok(Some(Event::SystemItem { ref item, .. }))
if item.get("kind").and_then(|k| k.as_str()) == Some("worker_event")
&& item
.pointer("/event/worker_name")
.pointer("/presentation/event/worker_name")
.and_then(|v| v.as_str()) == Some("child") =>
{
saw_worker_event_echo = true;
+15 -5
View File
@@ -14,7 +14,7 @@ use protocol::{
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
InvocationCompletion, InvocationValue, Segment,
};
use session_store::{CombinedStore, FsStore, FsWorkerStore, LogEntry, SystemItem};
use session_store::{CombinedStore, FsStore, FsWorkerStore, LogEntry};
use worker::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext,
FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule, FeatureRegistryBuilder,
@@ -224,10 +224,20 @@ fn results(worker: &Worker<Trace, TestStore>) -> Vec<FeatureInvocationResult> {
.0
.into_iter()
.filter_map(|entry| match entry {
LogEntry::AnnotatedSystemItem { entry, .. } => match entry.item {
SystemItem::FeatureInvocationResult { result, .. } => Some(result),
_ => None,
},
LogEntry::AnnotatedSystemItem { entry, .. } => {
#[derive(serde::Deserialize)]
struct SavedResult {
result: FeatureInvocationResult,
}
entry
.metadata
.input_metadata
.as_ref()?
.feature_payload()?
.decode::<SavedResult>("feature:invocation-result", 1)
.ok()
.map(|input| input.result)
}
_ => None,
})
.collect()