fix: align Workdir session cwd and model environment

Project alias, cwd, scope and command ceilings from actual sessions. Bind initial attachment cwd once in the shared broker/router while keeping root-relative delegation and detach generations. Preserve caller coordinates across reads, searches, checked checkout and commands; keep provider authorization and symlink boundaries.

Connect real SubWorkers, all filesystem Tools, scoped aliases, HTTP providers and limited Host grants in permanent regressions. Validated related normal suites (workdir http-client, tools, worker, worker-runtime, server-api typescript, fs-operation), workspace-server normal suite (1272 passed, 3 existing ignores), root check, product binary builds, rustdoc/doctests, generated contract checks, format and state-contract names. Test roots are checkout-local; Git parent discovery requires GIT_CEILING_DIRECTORIES at TMPDIR, confirmed against exact b860 baseline.
This commit is contained in:
2026-10-22 20:46:10 +09:00
parent b860c67ea2
commit 8b6e3070dd
39 changed files with 2562 additions and 160 deletions
+3 -2
View File
@@ -6,7 +6,7 @@
//! hashes alone do not accept same-content inode replacements. Callers own Read line numbering
//! and per-attachment read history.
//!
//! Create paths are checkout-root-relative and must be strict descendants of the observed target
//! Create paths use the target's session coordinates and must be strict descendants of the observed target
//! directory. Existing destinations are never overwritten; results return the updated observation
//! of the retained target directory, not the created file. Read results also return the validator
//! of the state actually read, never a replacement from a later independent stat.
@@ -18,7 +18,8 @@
//!
//! Search delegates to shared List/Glob/Grep engines. Scope rules form unions within layers and
//! intersections across layers, provider authority, and output roots. Wrappers transform coordinates
//! once; returned paths and Grep content are relative to the final session's logical root.
//! once; returned paths and Grep content are relative to the composed output root (the caller's
//! cwd by default).
//! Paths are not reverse-parsed from rendered filenames; non-UTF-8 names are not lossily aliased.
//! Unsupported providers reject search rather than fall back to broader trait searches. Search is
//! live traversal, not an atomic snapshot of all descendants or exclusion of external editors.
+68
View File
@@ -0,0 +1,68 @@
//! Model-facing session coordinates, separate from provider paths and authority.
use crate::{WorkdirPath, WorkdirSessionCapabilities, WorkdirToolScopeRule};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// A snapshot of the environment actually used by a session's operations.
///
/// Cwd is a logical Workdir-root coordinate. Scope targets use that root or
/// explicit absolute provider paths. Scope is an additional allow ceiling, not
/// a replacement for provider checks. Providers
/// may retain further restrictions (including deny rules); this projection
/// never authorizes an operation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkdirSessionEnvironment {
#[serde(deserialize_with = "deserialize_cwd")]
pub cwd: WorkdirPath,
pub scope: Option<Vec<WorkdirToolScopeRule>>,
pub capabilities: WorkdirSessionCapabilities,
pub restrictions: Vec<String>,
}
fn deserialize_cwd<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<WorkdirPath, D::Error> {
let path = String::deserialize(deserializer)?;
WorkdirPath::new(path).map_err(serde::de::Error::custom)
}
impl WorkdirSessionEnvironment {
pub fn root(capabilities: WorkdirSessionCapabilities) -> Self {
Self {
cwd: WorkdirPath::root(),
scope: None,
capabilities,
restrictions: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_cwd_is_normalized_and_cannot_claim_an_absolute_or_parent_base() {
let mut wire = serde_json::to_value(WorkdirSessionEnvironment::root(
WorkdirSessionCapabilities::READ_ONLY,
))
.unwrap();
for invalid in [
"/absolute",
"../parent",
"nested/../parent",
"nested\\other",
] {
wire["cwd"] = invalid.into();
assert!(
serde_json::from_value::<WorkdirSessionEnvironment>(wire.clone()).is_err(),
"{invalid}"
);
}
wire["cwd"] = "./nested//child".into();
let decoded: WorkdirSessionEnvironment = serde_json::from_value(wire).unwrap();
assert_eq!(decoded.cwd.as_str(), "nested/child");
}
}
+175
View File
@@ -0,0 +1,175 @@
//! Real Local/scoped/read-only/router sessions across loopback HTTP. Only the
//! HTTP authentication boundary is a fixture; coordinate and scope checks are
//! production implementations. Runs in the normal http-client library suite.
#![cfg(all(feature = "http-client", target_os = "linux"))]
use crate::http::*;
use crate::*;
use axum::{
Json, Router,
extract::State,
routing::{delete, post},
};
use std::sync::Arc;
struct Server(tokio::task::JoinHandle<()>);
impl Drop for Server {
fn drop(&mut self) {
self.0.abort();
}
}
async fn open(State(session): State<WorkdirSessionHandle>) -> Json<OpenWorkdirSessionResponse> {
Json(OpenWorkdirSessionResponse {
session_id: WorkdirSessionId::new("environment-session").unwrap(),
workdir_id: session.workdir().id().clone(),
capabilities: session.capabilities(),
environment: session.environment(),
})
}
async fn operation(
State(session): State<WorkdirSessionHandle>,
Json(request): Json<WorkdirSessionOperationRequest>,
) -> Result<
Json<WorkdirSessionOperationResult>,
(axum::http::StatusCode, Json<WorkdirTransportError>),
> {
dispatch_workdir_session_operation(session.as_ref(), request.operation)
.await
.map(Json)
.map_err(|error| {
(
axum::http::StatusCode::FORBIDDEN,
Json(WorkdirTransportError::from_workdir_error(&error)),
)
})
}
#[tokio::test]
async fn remote_environment_matches_scoped_readonly_operations_without_rebasing_twice() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("nested")).unwrap();
std::fs::write(root.path().join("nested/file.txt"), "nested marker").unwrap();
std::fs::write(root.path().join("file.txt"), "root decoy").unwrap();
let broker = WorkdirToolBroker::new(Arc::new(LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
)));
let lease = broker
.scope(WorkdirToolScope {
cwd: WorkdirPath::new("nested").unwrap(),
rules: vec![WorkdirToolScopeRule {
target: WorkdirPath::new("nested").unwrap(),
permission: WorkdirToolScopePermission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
command: true,
})
.await
.unwrap();
let session: WorkdirSessionHandle = Arc::new(ReadOnlyWorkdirSession::new(lease.tool_session()));
let expected = session.environment();
let app = Router::new()
.route("/v1/working-directories/{id}/sessions", post(open))
.route("/v1/workdir-sessions/{id}/operations", post(operation))
.route(
"/v1/workdir-sessions/{id}",
delete(|| async { axum::http::StatusCode::NO_CONTENT }),
)
.with_state(session.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let _server = Server(tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
}));
let remote: WorkdirSessionHandle = Arc::new(
RemoteWorkdirSession::open(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{address}").parse().unwrap(),
"fixture",
session.workdir().id().clone(),
OpenWorkdirSessionRequest::default(),
)
.await
.unwrap(),
);
let router = WorkdirSessionRouter::new();
router
.attach(WorkdirAttachmentAlias::new("remote").unwrap(), remote)
.unwrap();
let selected = router.resolve(Some("remote")).unwrap().session;
assert_eq!(selected.environment(), expected);
assert_eq!(expected.cwd, WorkdirPath::new("nested").unwrap());
assert!(
!expected
.capabilities
.supports(WorkdirSessionCapability::Command)
);
assert!(
expected
.scope
.unwrap()
.iter()
.all(|rule| rule.permission == WorkdirToolScopePermission::Read)
);
let found = selected
.glob(GlobRequest {
path: WorkdirPath::root(),
pattern: "*.txt".into(),
limit: 10,
})
.await
.unwrap();
assert_eq!(found.paths, vec![WorkdirPath::new("file.txt").unwrap()]);
let path = found.paths[0].clone();
let read = selected
.read(ReadRequest {
path: path.clone(),
offset: 0,
limit: 10,
max_bytes: 100,
})
.await
.unwrap();
assert_eq!(read.path, path);
assert_eq!(read.bytes, b"nested marker");
assert!(
selected
.write(WriteRequest {
path,
content: b"denied".to_vec(),
expected_hash: Some(read.content_hash)
})
.await
.is_err()
);
assert!(
selected
.read(ReadRequest {
path: WorkdirPath::new_scoped(root.path().join("file.txt").to_str().unwrap())
.unwrap(),
offset: 0,
limit: 10,
max_bytes: 100
})
.await
.is_err()
);
assert!(
selected
.start_command(CommandRequest {
command: "touch escaped".into(),
cwd: WorkdirPath::root(),
timeout_secs: 5,
transport_byte_budget: 100,
tool_call_id: None
})
.await
.is_err()
);
assert!(!root.path().join("nested/escaped").exists());
router.close_all().await.unwrap();
lease.close().await.unwrap();
broker.close().await.unwrap();
}
+12
View File
@@ -54,6 +54,7 @@ pub struct OpenWorkdirSessionResponse {
pub session_id: WorkdirSessionId,
pub workdir_id: WorkdirId,
pub capabilities: WorkdirSessionCapabilities,
pub environment: crate::WorkdirSessionEnvironment,
}
/// One provider-side Workdir operation.
@@ -553,6 +554,7 @@ mod client {
workdir: Workdir,
session_id: WorkdirSessionId,
capabilities: WorkdirSessionCapabilities,
environment: crate::WorkdirSessionEnvironment,
closed: AtomicBool,
}
@@ -612,6 +614,7 @@ mod client {
workdir: Workdir::new(opened.workdir_id.as_str()),
session_id: opened.session_id,
capabilities: opened.capabilities,
environment: opened.environment,
closed: AtomicBool::new(false),
})
}
@@ -676,6 +679,12 @@ mod client {
self.capabilities
}
fn environment(&self) -> crate::WorkdirSessionEnvironment {
let mut environment = self.environment.clone();
environment.capabilities = self.capabilities();
environment
}
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
@@ -1209,6 +1218,9 @@ mod client {
workdir,
session_id: WorkdirSessionId::new("s").unwrap(),
capabilities: WorkdirSessionCapabilities::EMPTY,
environment: crate::WorkdirSessionEnvironment::root(
WorkdirSessionCapabilities::EMPTY,
),
closed: AtomicBool::new(false),
};
let stat = StatRequest {
+14
View File
@@ -5,6 +5,8 @@ pub use checkout::{
CheckoutObservation, CheckoutOperation, CheckoutOutput, CheckoutRequest, CheckoutResult,
CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult,
};
mod environment;
pub use environment::WorkdirSessionEnvironment;
pub mod external;
pub mod http;
mod local;
@@ -16,6 +18,8 @@ pub mod workspace;
#[cfg(test)]
mod checkout_search_tests;
#[cfg(test)]
mod environment_tests;
#[cfg(test)]
mod external_local_tests;
use std::path::{Path, PathBuf};
@@ -185,6 +189,9 @@ pub type WriteOutcome = WriteResult;
/// Worker-bound operation session for a persistent [`Workdir`].
///
/// Filesystem search and commands run on the host owning materialization.
/// Relative operation paths and reusable search results use the session's cwd;
/// root-coordinate providers are adapted once at attachment, not by each Tool.
/// Scope declarations retain their own root-coordinate authority contract.
/// Session closure is terminal but does not delete the Workdir or files.
/// Unsupported checkout/search implementations explicitly reject requests, with no ambient
/// filesystem fallback. Providers own identity validators; callers own read hash history
@@ -194,6 +201,13 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
fn workdir(&self) -> &Workdir;
fn capabilities(&self) -> WorkdirSessionCapabilities;
/// Describe the coordinates used by this session, not a caller's Profile
/// or filesystem authority. Root-coordinate providers use the default;
/// coordinate and authority wrappers must project their effective view.
fn environment(&self) -> WorkdirSessionEnvironment {
WorkdirSessionEnvironment::root(self.capabilities())
}
/// Validate an attenuated filesystem rule at the provider boundary without
/// exposing the resolved host path. Providers that cannot resolve symbolic
/// links must reject resolved-policy checks rather than downgrade them.
+46
View File
@@ -1340,6 +1340,52 @@ impl WorkdirSession for LocalWorkdirSession {
self.inner.capabilities
}
fn environment(&self) -> crate::WorkdirSessionEnvironment {
let scope = self.inner.scope.snapshot();
let project = |rule: manifest::ScopeRule| -> Option<crate::WorkdirToolScopeRule> {
let target = if self.inner.root.starts_with(&rule.target) && rule.recursive {
WorkdirPath::root()
} else {
let path = rule
.target
.strip_prefix(&self.inner.root)
.unwrap_or(&rule.target);
WorkdirPath::new_scoped(path.to_str()?).ok()?
};
Some(crate::WorkdirToolScopeRule {
target,
permission: match rule.permission {
Permission::Read => WorkdirToolScopePermission::Read,
Permission::Write => WorkdirToolScopePermission::Write,
},
recursive: rule.recursive,
symlink_policy: rule.symlink_policy,
})
};
crate::WorkdirSessionEnvironment {
cwd: WorkdirPath::root(),
scope: Some(
scope
.allow_rules()
.into_iter()
.filter_map(project)
.collect(),
),
capabilities: self.capabilities(),
restrictions: scope
.deny_rules()
.into_iter()
.map(|rule| {
project(rule)
.map(|rule| format!("Deny: {rule:?}"))
.unwrap_or_else(|| {
"Unrepresentable provider deny rule remains enforced".into()
})
})
.collect(),
}
}
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
@@ -134,6 +134,9 @@ async fn pipe_and_wait_faults_cross_real_http_with_prefix_and_terminal_replay()
session_id: WorkdirSessionId::new("fault-session").unwrap(),
workdir_id: crate::Workdir::new("fault-workdir").id().clone(),
capabilities: crate::WorkdirSessionCapabilities::ALL,
environment: crate::WorkdirSessionEnvironment::root(
crate::WorkdirSessionCapabilities::ALL,
),
})
}),
)
+236 -31
View File
@@ -225,16 +225,27 @@ impl Drop for ActiveOperation<'_> {
#[derive(Debug, Clone)]
pub struct RoutedWorkdirSession {
state: Arc<RoutedSessionState>,
view: Option<WorkdirSessionHandle>,
}
impl RoutedWorkdirSession {
fn inner(&self) -> &WorkdirSessionHandle {
self.view.as_ref().unwrap_or(&self.state.session)
}
}
#[async_trait]
impl WorkdirSession for RoutedWorkdirSession {
fn workdir(&self) -> &Workdir {
self.state.session.workdir()
self.inner().workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.state.session.capabilities()
self.inner().capabilities()
}
fn environment(&self) -> crate::WorkdirSessionEnvironment {
self.inner().environment()
}
async fn authorize_scope_path(
@@ -242,7 +253,7 @@ impl WorkdirSession for RoutedWorkdirSession {
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
let _active = self.state.enter()?;
self.state.session.authorize_scope_path(request).await
self.inner().authorize_scope_path(request).await
}
async fn scope_rules_overlap(
@@ -250,7 +261,7 @@ impl WorkdirSession for RoutedWorkdirSession {
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.scope_rules_overlap(request).await
self.inner().scope_rules_overlap(request).await
}
async fn checkout_search(
@@ -258,7 +269,7 @@ impl WorkdirSession for RoutedWorkdirSession {
request: crate::CheckoutSearchRequest,
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.checkout_search(request).await
self.inner().checkout_search(request).await
}
async fn checkout_observe(
@@ -266,59 +277,59 @@ impl WorkdirSession for RoutedWorkdirSession {
path: crate::WorkdirPath,
) -> Result<crate::CheckoutObservation, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.checkout_observe(path).await
self.inner().checkout_observe(path).await
}
async fn checkout_execute(
&self,
request: crate::CheckoutRequest,
) -> Result<crate::CheckoutResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.checkout_execute(request).await
self.inner().checkout_execute(request).await
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.stat(request).await
self.inner().stat(request).await
}
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.read(request).await
self.inner().read(request).await
}
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.read_bytes(request).await
self.inner().read_bytes(request).await
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.write(request).await
self.inner().write(request).await
}
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.edit(request).await
self.inner().edit(request).await
}
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.list(request).await
self.inner().list(request).await
}
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.glob(request).await
self.inner().glob(request).await
}
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.grep(request).await
self.inner().grep(request).await
}
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
let _active = self.state.enter()?;
let handle = self.state.session.start_command(request).await?;
let handle = self.inner().start_command(request).await?;
self.state
.active_commands
.lock()
@@ -329,7 +340,7 @@ impl WorkdirSession for RoutedWorkdirSession {
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
let _active = self.state.enter()?;
let status = self.state.session.command_status(handle.clone()).await?;
let status = self.inner().command_status(handle.clone()).await?;
self.state.mark_terminal(&handle, status);
Ok(status)
}
@@ -340,7 +351,7 @@ impl WorkdirSession for RoutedWorkdirSession {
) -> Result<CommandOutput, WorkdirError> {
let _active = self.state.enter()?;
let handle = request.handle.clone();
let output = self.state.session.command_output(request).await?;
let output = self.inner().command_output(request).await?;
if let Some(terminal) = &output.terminal {
self.state.mark_terminal(&handle, terminal.status);
}
@@ -349,7 +360,7 @@ impl WorkdirSession for RoutedWorkdirSession {
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
let _active = self.state.enter()?;
self.state.session.cancel_command(handle.clone()).await?;
self.inner().cancel_command(handle.clone()).await?;
self.state
.active_commands
.lock()
@@ -359,11 +370,11 @@ impl WorkdirSession for RoutedWorkdirSession {
}
fn subscribe_command_events(&self) -> Option<broadcast::Receiver<CommandEvent>> {
self.state.session.subscribe_command_events()
self.inner().subscribe_command_events()
}
fn command_snapshot(&self) -> Vec<CommandSnapshot> {
let snapshots = self.state.session.command_snapshot();
let snapshots = self.inner().command_snapshot();
self.state.reconcile_terminal_commands(&snapshots);
snapshots
}
@@ -374,7 +385,7 @@ impl WorkdirSession for RoutedWorkdirSession {
"Workdir attachment has active operations or commands".to_string(),
));
}
self.state.session.close().await
self.inner().close().await
}
}
@@ -387,6 +398,7 @@ impl WorkdirSession for RoutedWorkdirSession {
pub struct WorkdirSessionRouter {
sessions: RwLock<BTreeMap<WorkdirAttachmentAlias, Arc<RoutedSessionState>>>,
next_generation: AtomicU64,
coordinate_brokers: RwLock<BTreeMap<WorkdirAttachmentAlias, (u64, crate::WorkdirToolBroker)>>,
}
impl WorkdirSessionRouter {
@@ -473,9 +485,9 @@ impl WorkdirSessionRouter {
}
};
Ok(ResolvedWorkdirSession {
session: self.routed(&selected.0, selected.1.clone()),
alias: selected.0,
generation: selected.1.generation,
session: Arc::new(RoutedWorkdirSession { state: selected.1 }),
})
}
@@ -483,13 +495,22 @@ impl WorkdirSessionRouter {
&self,
alias: WorkdirAttachmentAlias,
session: WorkdirSessionHandle,
) -> Result<WorkdirSessionHandle, WorkdirError> {
self.attach_with_cwd(alias, session, None)
}
fn attach_with_cwd(
&self,
alias: WorkdirAttachmentAlias,
session: WorkdirSessionHandle,
cwd: Option<crate::WorkdirPath>,
) -> Result<WorkdirSessionHandle, WorkdirError> {
let mut sessions = self.sessions.write().map_err(|_| {
WorkdirError::Unavailable("Workdir attachment router is poisoned".to_string())
WorkdirError::Unavailable("Workdir attachment router is poisoned".into())
})?;
if sessions.contains_key(&alias) {
return Err(WorkdirError::Conflict(format!(
"Workdir attachment alias `{alias}` already exists"
"Workdir attachment alias \u{0060}{alias}\u{0060} already exists"
)));
}
let state = Arc::new(RoutedSessionState {
@@ -499,8 +520,61 @@ impl WorkdirSessionRouter {
active_operations: AtomicUsize::new(0),
active_commands: Mutex::new(HashSet::new()),
});
let view = if let Some(cwd) = cwd {
let source = Arc::new(RoutedWorkdirSession {
state: state.clone(),
view: None,
});
let broker = crate::WorkdirToolBroker::at_cwd(source, cwd)?;
let view = broker.tool_session();
self.coordinate_brokers
.write()
.map_err(|_| {
WorkdirError::Unavailable("Workdir coordinate broker map is poisoned".into())
})?
.insert(alias.clone(), (state.generation, broker));
Some(view)
} else {
None
};
sessions.insert(alias, state.clone());
Ok(Arc::new(RoutedWorkdirSession { state }))
Ok(Arc::new(RoutedWorkdirSession { state, view }))
}
fn routed(
&self,
alias: &WorkdirAttachmentAlias,
state: Arc<RoutedSessionState>,
) -> Arc<RoutedWorkdirSession> {
let view = self
.coordinate_broker(alias, state.generation)
.map(|broker| broker.tool_session());
Arc::new(RoutedWorkdirSession { state, view })
}
pub(crate) fn coordinate_broker(
&self,
alias: &WorkdirAttachmentAlias,
generation: u64,
) -> Option<crate::WorkdirToolBroker> {
self.coordinate_brokers
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(alias)
.filter(|(current, _)| *current == generation)
.map(|(_, broker)| broker.clone())
}
/// Bind tool coordinates once while retaining root-coordinate delegation
/// and provider authorization. Both views share the route's detach fence.
pub fn attach_at_cwd(
&self,
alias: WorkdirAttachmentAlias,
source: WorkdirSessionHandle,
cwd: crate::WorkdirPath,
) -> Result<WorkdirSessionHandle, WorkdirError> {
let cwd = crate::WorkdirPath::new(cwd.as_str())?;
self.attach_with_cwd(alias, source, Some(cwd))
}
pub fn session(&self, alias: &WorkdirAttachmentAlias) -> Option<WorkdirSessionHandle> {
@@ -509,7 +583,7 @@ impl WorkdirSessionRouter {
.ok()?
.get(alias)
.cloned()
.map(|state| Arc::new(RoutedWorkdirSession { state }) as WorkdirSessionHandle)
.map(|state| self.routed(alias, state) as WorkdirSessionHandle)
}
/// Compatibility projection used only while filesystem tool schemas have no
@@ -521,10 +595,8 @@ impl WorkdirSessionRouter {
return None;
}
sessions
.values()
.next()
.cloned()
.map(|state| Arc::new(RoutedWorkdirSession { state }) as WorkdirSessionHandle)
.first_key_value()
.map(|(alias, state)| self.routed(alias, state.clone()) as WorkdirSessionHandle)
}
pub fn can_detach(&self, alias: &WorkdirAttachmentAlias) -> Result<(), WorkdirError> {
@@ -620,6 +692,10 @@ impl WorkdirSessionRouter {
.is_some_and(|current| Arc::ptr_eq(current, &state))
{
sessions.remove(alias);
self.coordinate_brokers
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(alias);
}
Ok(state.session.clone())
}
@@ -661,6 +737,10 @@ impl WorkdirSessionRouter {
.is_some_and(|current| Arc::ptr_eq(current, &state))
{
sessions.remove(&alias);
self.coordinate_brokers
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&alias);
}
}
Ok(())
@@ -684,6 +764,131 @@ mod tests {
))
}
#[tokio::test]
async fn cwd_views_share_root_delegation_leases_and_attachment_fences() {
use crate::{
WorkdirPath as P, WorkdirToolBrokerRouter, WorkdirToolScope,
WorkdirToolScopePermission as Permission, WorkdirToolScopeRule,
};
let root = TempDir::new().unwrap();
for directory in ["work", "other", "other/inside"] {
std::fs::create_dir_all(root.path().join(directory)).unwrap();
std::fs::write(root.path().join(directory).join("marker"), directory).unwrap();
}
let router = Arc::new(WorkdirSessionRouter::new());
let alias = WorkdirAttachmentAlias::new("source").unwrap();
let parent = router
.attach_at_cwd(alias.clone(), session("wd", &root), P::new("work").unwrap())
.unwrap();
assert_eq!(parent.environment().cwd, P::new("work").unwrap());
let request = |cwd: &str| WorkdirToolScope {
rules: vec![WorkdirToolScopeRule {
target: P::new(cwd).unwrap(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
cwd: P::new(cwd).unwrap(),
command: false,
};
let brokers = WorkdirToolBrokerRouter::new(router.clone());
// Declarations stay root-relative, not work/other.
let child = brokers
.scope(vec![(alias.clone(), request("other"))])
.await
.unwrap();
let grandchild = child
.broker_router()
.scope(vec![(alias.clone(), request("other/inside"))])
.await
.unwrap();
for (session, cwd) in [
(parent.clone(), "work"),
(
child.session_router().resolve(None).unwrap().session,
"other",
),
(
grandchild.session_router().resolve(None).unwrap().session,
"other/inside",
),
] {
assert_eq!(session.environment().cwd, P::new(cwd).unwrap());
let result = session
.read(ReadRequest {
path: P::new("marker").unwrap(),
offset: 0,
limit: 10,
max_bytes: 100,
})
.await
.unwrap();
assert_eq!(result.bytes, cwd.as_bytes());
assert_eq!(result.path, P::new("marker").unwrap());
assert_eq!(
session
.stat(StatRequest {
path: P::new("marker").unwrap()
})
.await
.unwrap()
.path,
P::new("marker").unwrap()
);
}
// The model's tool view and delegation view share write-lease exclusion.
let absolute = P::new_scoped(root.path().join("other/marker").to_str().unwrap()).unwrap();
let denied = parent
.write(WriteRequest {
path: absolute,
content: b"forbidden".to_vec(),
expected_hash: None,
})
.await
.unwrap_err();
assert_eq!(
denied.denial_reason(),
Some(crate::WorkdirDenialReason::ChildWriteLeaseConflict)
);
let old_child = child.session_router().resolve(None).unwrap().session;
router.begin_detach(&alias).await.unwrap();
assert!(
old_child
.read(ReadRequest {
path: P::new("marker").unwrap(),
offset: 0,
limit: 10,
max_bytes: 100
})
.await
.is_err()
);
router.cancel_detach(&alias);
grandchild.close().await.unwrap();
child.close().await.unwrap();
router.detach(&alias).await.unwrap();
router
.attach_at_cwd(
alias,
session("replacement", &root),
P::new("other").unwrap(),
)
.unwrap();
assert_eq!(
router.resolve(None).unwrap().session.environment().cwd,
P::new("other").unwrap()
);
assert!(
old_child
.stat(StatRequest {
path: P::new("marker").unwrap()
})
.await
.is_err()
);
router.close_all().await.unwrap();
}
#[tokio::test]
async fn checkout_routes_use_active_operation_detach_fence_and_old_handles_stay_closed() {
let root = TempDir::new().unwrap();
+85 -8
View File
@@ -108,17 +108,34 @@ impl WorkdirToolBroker {
Self::with_capabilities(source, capabilities)
}
/// Set the tool coordinate base without changing provider authority.
/// The input is relative to the source session's coordinates; delegation
/// declarations retain their existing coordinate contract.
pub fn at_cwd(source: WorkdirSessionHandle, cwd: FsPath) -> Result<Self, WorkdirError> {
let cwd = FsPath::new(cwd.as_str())?;
let capabilities = source.capabilities();
Ok(Self::with_capabilities_and_cwd(source, capabilities, cwd))
}
/// Own a session behind an additional Backend-authored capability ceiling.
/// The ceiling can only attenuate provider capabilities; it can never add one.
pub fn with_capabilities(
source: WorkdirSessionHandle,
capabilities: WorkdirSessionCapabilities,
) -> Self {
Self::with_capabilities_and_cwd(source, capabilities, FsPath::root())
}
fn with_capabilities_and_cwd(
source: WorkdirSessionHandle,
capabilities: WorkdirSessionCapabilities,
cwd: FsPath,
) -> Self {
let capabilities = source.capabilities().intersection(capabilities);
let (command_events, _) = broadcast::channel(64);
let authority = Arc::new(ScopedWorkdirSession {
source,
cwd: FsPath::new("").expect("empty Workdir path is valid"),
cwd,
scope: None,
capabilities,
validity: SessionValidity::root(),
@@ -416,6 +433,9 @@ impl WorkdirToolBrokerRouter {
.sessions
.resolve(Some(alias.as_str()))
.map_err(|error| WorkdirError::InvalidArgument(error.to_string()))?;
if let Some(broker) = self.sessions.coordinate_broker(alias, selected.generation) {
return Ok(broker);
}
let mut brokers = self.brokers.lock().map_err(|_| {
WorkdirError::Unavailable("Workdir tool broker router is poisoned".to_string())
})?;
@@ -684,7 +704,7 @@ impl ScopedWorkdirSession {
}
fn resolve_path(&self, path: &FsPath) -> Result<FsPath, WorkdirError> {
if self.cwd.as_str().is_empty() {
if self.cwd.as_str().is_empty() || Path::new(path.as_str()).is_absolute() {
return Ok(path.clone());
}
let joined = Path::new(self.cwd.as_str()).join(path.as_str());
@@ -1085,6 +1105,43 @@ impl WorkdirSession for ScopedWorkdirSession {
self.capabilities
}
fn environment(&self) -> crate::WorkdirSessionEnvironment {
let mut environment = self.source.environment();
let source_cwd = environment.cwd.clone();
environment.cwd = FsPath::new(
Path::new(source_cwd.as_str())
.join(self.cwd.as_str())
.to_str()
.expect("validated UTF-8 paths"),
)
.expect("validated session cwd");
if let Some(scope) = &self.scope {
if let Some(inherited) = environment.scope.take() {
environment
.restrictions
.push(format!("Provider allow ceiling: {inherited:?}"));
}
environment.scope = Some(
scope
.iter()
.cloned()
.map(|mut rule| {
rule.target = FsPath::new(
Path::new(source_cwd.as_str())
.join(rule.target.as_str())
.to_str()
.expect("validated UTF-8 paths"),
)
.expect("validated scope target");
rule
})
.collect(),
);
}
environment.capabilities = self.capabilities();
environment
}
async fn authorize_scope_path(
&self,
mut request: WorkdirScopeAuthorizationRequest,
@@ -1273,8 +1330,11 @@ impl WorkdirSession for ScopedWorkdirSession {
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
let caller_path = request.path.clone();
request.path = path;
self.source.stat(request).await
let mut result = self.source.stat(request).await?;
result.path = caller_path;
Ok(result)
}
async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> {
@@ -1282,8 +1342,11 @@ impl WorkdirSession for ScopedWorkdirSession {
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
let caller_path = request.path.clone();
request.path = path;
self.source.read(request).await
let mut result = self.source.read(request).await?;
result.path = caller_path;
Ok(result)
}
async fn read_bytes(
@@ -1422,9 +1485,10 @@ impl WorkdirSession for ScopedWorkdirSession {
)));
}
let tool_call_id = request.tool_call_id.clone();
if self.scope.is_some() {
request.cwd = self.resolve_path(&request.cwd)?;
}
request.cwd = self
.resolve_operation_path(&request.cwd, WorkdirToolScopePermission::Read)
.await?;
self.ensure_path(&request.cwd, WorkdirToolScopePermission::Read)?;
if let Some(tool_call_id) = &tool_call_id {
self.starting_tool_calls
.lock()
@@ -1601,7 +1665,20 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
WorkdirSessionCapabilities::READ_ONLY
self.inner
.capabilities()
.intersection(WorkdirSessionCapabilities::READ_ONLY)
}
fn environment(&self) -> crate::WorkdirSessionEnvironment {
let mut environment = self.inner.environment();
environment.capabilities = self.capabilities();
if let Some(scope) = &mut environment.scope {
for rule in scope {
rule.permission = WorkdirToolScopePermission::Read;
}
}
environment
}
async fn authorize_scope_path(
+18 -14
View File
@@ -14,6 +14,18 @@ use workdir::{
},
};
fn opened(workdir_id: &str) -> String {
serde_json::to_string(&workdir::http::OpenWorkdirSessionResponse {
session_id: workdir::http::WorkdirSessionId::new("s").unwrap(),
workdir_id: Workdir::new(workdir_id).id().clone(),
capabilities: workdir::WorkdirSessionCapabilities::ALL,
environment: workdir::WorkdirSessionEnvironment::root(
workdir::WorkdirSessionCapabilities::ALL,
),
})
.unwrap()
}
fn reply(listener: &TcpListener, status: &str, body: &str) {
listener.set_nonblocking(true).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
@@ -73,11 +85,7 @@ async fn remote_http_denials_forward_only_typed_reasons_and_old_missing_reason()
error["denial_reason"] = serde_json::to_value(reason).unwrap();
}
let server = thread::spawn(move || {
reply(
&listener,
"200 OK",
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
);
reply(&listener, "200 OK", &opened("w"));
reply(&listener, "403 Forbidden", &error.to_string());
});
let remote = RemoteWorkdirSession::open(
@@ -163,11 +171,7 @@ async fn remote_checkout_diagnostics_preserve_mutation_outcome_and_read_failures
let address = listener.local_addr().unwrap();
let error = serde_json::json!({"code":code, "message":"/secret/host provider-secret", "denial_reason":reason});
let server = thread::spawn(move || {
reply(
&listener,
"200 OK",
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
);
reply(&listener, "200 OK", &opened("w"));
reply(
&listener,
&format!("{} Fixture failure", code.http_status()),
@@ -230,11 +234,11 @@ async fn mismatched_http_results_keep_cause_and_mutation_uncertainty() {
reply(
&listener,
"200 OK",
if case == "identity" {
r#"{"session_id":"s","workdir_id":"wrong-secret","capabilities":{"bits":63}}"#
&opened(if case == "identity" {
"wrong-secret"
} else {
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#
},
"w"
}),
);
if case != "identity" {
let result = if case == "bytes" {