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:
@@ -206,26 +206,36 @@ impl AuthorizedWorkdirSession {
|
||||
.map(CheckoutSearchResult::Grep),
|
||||
};
|
||||
};
|
||||
scoped
|
||||
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||
rules: vec![WorkdirToolScopeRule {
|
||||
target: WorkdirPath::root(),
|
||||
..rule.clone()
|
||||
}],
|
||||
path: relative_path(&root, operation.path())?,
|
||||
permission: WorkdirToolScopePermission::Read,
|
||||
})
|
||||
.await?;
|
||||
// Lease cwd rebases paths. Use the same provider scoped-traversal contract
|
||||
// with attachment output_root instead, retaining typed paths, cursors and
|
||||
// Grep's rendered filenames without parsing or post-filtering output.
|
||||
let mut request = CheckoutSearchRequest::new(operation);
|
||||
request.scope_layers.push(vec![rule]);
|
||||
self.session.checkout_search(request).await
|
||||
self.search_with_scope(scoped, root, rule, CheckoutSearchRequest::new(operation))
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn search_with_scope(
|
||||
&self,
|
||||
scoped: WorkdirSessionHandle,
|
||||
root: WorkdirPath,
|
||||
rule: WorkdirToolScopeRule,
|
||||
mut request: CheckoutSearchRequest,
|
||||
) -> Result<CheckoutSearchResult, WorkdirError> {
|
||||
scoped
|
||||
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||
rules: vec![WorkdirToolScopeRule {
|
||||
target: WorkdirPath::root(),
|
||||
..rule.clone()
|
||||
}],
|
||||
path: relative_path(&root, request.operation.path())?,
|
||||
permission: WorkdirToolScopePermission::Read,
|
||||
})
|
||||
.await?;
|
||||
// Preserve the caller's coordinate root, cursors and all existing
|
||||
// attenuation layers. The provider applies the Host ceiling during
|
||||
// traversal, not by filtering already-rendered filenames afterward.
|
||||
request.scope_layers.push(vec![rule]);
|
||||
self.session.checkout_search(request).await
|
||||
}
|
||||
|
||||
fn authorize_command(&self) -> Result<(), WorkdirError> {
|
||||
// cwd is not a sandbox: arbitrary shell can access the entire attachment.
|
||||
self.authorize(COMMAND, &WorkdirPath::root(), true)
|
||||
@@ -261,6 +271,12 @@ impl WorkdirSession for AuthorizedWorkdirSession {
|
||||
self.session.capabilities()
|
||||
}
|
||||
|
||||
fn environment(&self) -> workdir::WorkdirSessionEnvironment {
|
||||
let mut environment = self.session.environment();
|
||||
environment.capabilities = environment.capabilities.intersection(self.capabilities());
|
||||
environment
|
||||
}
|
||||
|
||||
async fn authorize_scope_path(
|
||||
&self,
|
||||
mut request: WorkdirScopeAuthorizationRequest,
|
||||
@@ -296,8 +312,14 @@ impl WorkdirSession for AuthorizedWorkdirSession {
|
||||
&self,
|
||||
request: CheckoutSearchRequest,
|
||||
) -> Result<CheckoutSearchResult, WorkdirError> {
|
||||
self.require_legacy(READ)?;
|
||||
self.session.checkout_search(request).await
|
||||
let path = request.operation.path().clone();
|
||||
self.with_authorized(READ, &path, true, |scoped, root, rule| async move {
|
||||
match rule {
|
||||
Some(rule) => self.search_with_scope(scoped, root, rule, request).await,
|
||||
None => self.session.checkout_search(request).await,
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
@@ -490,6 +512,8 @@ mod tests {
|
||||
//! Component tests connect this adapter to the real LocalWorkdirSession.
|
||||
//! Only the Host authorization transport/policy is replaced; these are not E2E tests.
|
||||
|
||||
mod environment;
|
||||
|
||||
use super::*;
|
||||
use crate::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
||||
use server_api::authorization::ScopedPermission;
|
||||
@@ -842,18 +866,6 @@ mod tests {
|
||||
.await,
|
||||
Err(WorkdirError::Denied(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||
ListRequest {
|
||||
path: WorkdirPath::root(),
|
||||
after: None,
|
||||
limit: 10,
|
||||
}
|
||||
)))
|
||||
.await,
|
||||
Err(WorkdirError::Denied(_))
|
||||
));
|
||||
let rule = WorkdirToolScopeRule {
|
||||
target: WorkdirPath::root(),
|
||||
permission: WorkdirToolScopePermission::Read,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Normal lib tests connecting live Host-grant checks to real provider traversal
|
||||
//! and router cwd projection. Only the Host transport is replaced by the parent
|
||||
//! module's grant fixture; no commands, network or ambient Workspace state are used.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorization_wrapper_preserves_inner_environment_and_capability_ceiling() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join("src")).unwrap();
|
||||
let provider = Arc::new(LocalWorkdirSession::materialized(
|
||||
root.path().to_path_buf(),
|
||||
root.path().to_path_buf(),
|
||||
manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap()),
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
));
|
||||
let broker = WorkdirToolBroker::at_cwd(provider, WorkdirPath::new("src").unwrap()).unwrap();
|
||||
let inner = broker.tool_session();
|
||||
let expected = inner.environment();
|
||||
let client = AuthorizationClient::new(vec![permission(READ, "src", true)]);
|
||||
let wrapped = authorized_workdir_session(inner, client.clone(), "attachment-id".into());
|
||||
assert_eq!(wrapped.environment(), expected);
|
||||
assert_eq!(wrapped.environment().cwd.as_str(), "src");
|
||||
assert_eq!(
|
||||
wrapped.environment().capabilities,
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
assert!(
|
||||
client.requests.lock().unwrap().is_empty(),
|
||||
"environment is metadata, not a cached grant decision"
|
||||
);
|
||||
wrapped.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nonroot_routed_search_retains_host_grants_output_coordinates_and_cursors() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join("src")).unwrap();
|
||||
std::fs::create_dir(root.path().join("private")).unwrap();
|
||||
for name in ["a.txt", "b.txt"] {
|
||||
std::fs::write(root.path().join("src").join(name), "needle public marker").unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
root.path().join("private/secret.txt"),
|
||||
"needle private marker",
|
||||
)
|
||||
.unwrap();
|
||||
let client = AuthorizationClient::new(vec![permission(READ, "src", true)]);
|
||||
let authorized = session(&root, client.clone());
|
||||
let router = workdir::WorkdirSessionRouter::new();
|
||||
let alias = workdir::WorkdirAttachmentAlias::new("checkout").unwrap();
|
||||
router
|
||||
.attach_at_cwd(alias.clone(), authorized, WorkdirPath::new("src").unwrap())
|
||||
.unwrap();
|
||||
let routed = router.resolve(Some("checkout")).unwrap().session;
|
||||
assert_eq!(routed.environment().cwd.as_str(), "src");
|
||||
let paths = routed
|
||||
.glob(GlobRequest {
|
||||
path: WorkdirPath::root(),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.paths;
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
WorkdirPath::new("a.txt").unwrap(),
|
||||
WorkdirPath::new("b.txt").unwrap()
|
||||
]
|
||||
);
|
||||
// Search output is reusable as a path without adding src/ at the caller.
|
||||
assert_eq!(
|
||||
routed.read(read(paths[0].as_str())).await.unwrap().bytes,
|
||||
b"needle public marker"
|
||||
);
|
||||
for output_mode in [
|
||||
workdir::GrepOutputMode::Content,
|
||||
workdir::GrepOutputMode::FilesWithMatches,
|
||||
workdir::GrepOutputMode::Count,
|
||||
] {
|
||||
let result = routed
|
||||
.grep(GrepRequest {
|
||||
path: WorkdirPath::root(),
|
||||
pattern: "needle".into(),
|
||||
glob: None,
|
||||
file_type: None,
|
||||
case_insensitive: false,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
multiline: false,
|
||||
output_mode,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut grep_paths = result.paths;
|
||||
grep_paths.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||
assert_eq!(grep_paths, paths);
|
||||
assert!(result.output.contains("a.txt"));
|
||||
assert!(!result.output.contains("src/"));
|
||||
assert!(!result.output.contains("secret"));
|
||||
}
|
||||
let first = routed
|
||||
.list(ListRequest {
|
||||
path: WorkdirPath::root(),
|
||||
after: None,
|
||||
limit: 1,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.entries[0].path.as_str(), "a.txt");
|
||||
assert_eq!(first.next_after.as_ref().unwrap().path.as_str(), "a.txt");
|
||||
let second = routed
|
||||
.list(ListRequest {
|
||||
path: WorkdirPath::root(),
|
||||
after: first.next_after,
|
||||
limit: 1,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.entries[0].path.as_str(), "b.txt");
|
||||
assert!(second.next_after.is_none());
|
||||
|
||||
// An existing caller ceiling must survive the extra Host layer. An empty
|
||||
// layer is invalid and cannot silently turn into the broader Host grant.
|
||||
let mut denied = CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(GlobRequest {
|
||||
path: WorkdirPath::root(),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
}));
|
||||
denied.scope_layers.push(Vec::new());
|
||||
assert!(routed.checkout_search(denied).await.is_err());
|
||||
assert!(client.requests.lock().unwrap().iter().all(|request| {
|
||||
matches!(&request.resource, PermissionResource::Workdir { path, .. } if path == "src" || path == "src/a.txt")
|
||||
}));
|
||||
client.revoked.store(true, Ordering::SeqCst);
|
||||
assert!(
|
||||
routed
|
||||
.glob(GlobRequest {
|
||||
path: WorkdirPath::root(),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
router.detach(&alias).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn scoped_checkout_search_preserves_caller_layer_and_rejects_resolved_escape() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(root.path().join("src/nested")).unwrap();
|
||||
std::fs::create_dir(root.path().join("private")).unwrap();
|
||||
std::fs::write(root.path().join("src/public.txt"), "needle public").unwrap();
|
||||
std::fs::write(root.path().join("src/nested/hidden.txt"), "needle hidden").unwrap();
|
||||
std::fs::write(root.path().join("private/secret.txt"), "needle secret").unwrap();
|
||||
std::os::unix::fs::symlink("../private", root.path().join("src/escape")).unwrap();
|
||||
std::os::unix::fs::symlink("../private/secret.txt", root.path().join("src/alias.txt")).unwrap();
|
||||
let client = AuthorizationClient::new(vec![permission(READ, "src", true)]);
|
||||
let authorized = session(&root, client);
|
||||
let request = CheckoutSearchRequest {
|
||||
operation: CheckoutSearchOperation::Glob(GlobRequest {
|
||||
path: WorkdirPath::new("src").unwrap(),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
}),
|
||||
output_root: WorkdirPath::new("src").unwrap(),
|
||||
scope_layers: vec![vec![WorkdirToolScopeRule {
|
||||
target: WorkdirPath::new("src").unwrap(),
|
||||
recursive: false,
|
||||
permission: WorkdirToolScopePermission::Read,
|
||||
symlink_policy: manifest::SymlinkPolicy::Resolved,
|
||||
}]],
|
||||
};
|
||||
let result = authorized.checkout_search(request).await.unwrap();
|
||||
let CheckoutSearchResult::Glob(result) = result else {
|
||||
panic!("expected Glob result")
|
||||
};
|
||||
assert_eq!(result.paths, vec![WorkdirPath::new("public.txt").unwrap()]);
|
||||
assert!(
|
||||
authorized
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(
|
||||
GlobRequest {
|
||||
path: WorkdirPath::new("private").unwrap(),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
}
|
||||
)))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
authorized.close().await.unwrap();
|
||||
}
|
||||
@@ -121,6 +121,7 @@ async fn open_session(
|
||||
session_id: WorkdirSessionId::new(SESSION).unwrap(),
|
||||
workdir_id: state.session.workdir().id().clone(),
|
||||
capabilities: state.session.capabilities(),
|
||||
environment: state.session.environment(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
@@ -251,6 +252,10 @@ impl Drop for HttpFixture {
|
||||
|
||||
impl HttpFixture {
|
||||
async fn new(capabilities: WorkdirSessionCapabilities) -> Self {
|
||||
Self::at_cwd(capabilities, "").await
|
||||
}
|
||||
|
||||
async fn at_cwd(capabilities: WorkdirSessionCapabilities, cwd: &str) -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("src/deep")).unwrap();
|
||||
std::fs::write(
|
||||
@@ -264,6 +269,13 @@ impl HttpFixture {
|
||||
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
|
||||
capabilities,
|
||||
));
|
||||
let session: WorkdirSessionHandle = if cwd.is_empty() {
|
||||
session
|
||||
} else {
|
||||
workdir::WorkdirToolBroker::at_cwd(session, WorkdirPath::new(cwd).unwrap())
|
||||
.unwrap()
|
||||
.tool_session()
|
||||
};
|
||||
let state = Arc::new(ProviderState {
|
||||
session,
|
||||
requests: Mutex::new(Vec::new()),
|
||||
@@ -349,6 +361,31 @@ impl HttpFixture {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_open_keeps_provider_environment_and_nonroot_operation_coordinates() {
|
||||
let fixture = HttpFixture::at_cwd(WorkdirSessionCapabilities::READ_ONLY, "src/deep").await;
|
||||
let remote = fixture.open(TOKEN).await.unwrap();
|
||||
assert_eq!(remote.environment(), fixture.state.session.environment());
|
||||
assert_eq!(remote.environment().cwd.as_str(), "src/deep");
|
||||
assert_eq!(
|
||||
remote.environment().capabilities,
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
let result = remote
|
||||
.read(workdir::ReadRequest {
|
||||
path: WorkdirPath::new("a.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 4096,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.bytes, b"first\nneedle needle\nlast\n");
|
||||
fixture.public_output(&serde_json::to_string(&remote.environment()).unwrap());
|
||||
remote.close().await.unwrap();
|
||||
fixture.assert_endpoints();
|
||||
}
|
||||
|
||||
fn native_runtime(remote: Arc<RemoteWorkdirSession>) -> (WipRuntime, Arc<WorkdirSessionRouter>) {
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
|
||||
@@ -78,6 +78,12 @@ impl WorkdirSession for HookedSession {
|
||||
self.local.capabilities()
|
||||
}
|
||||
|
||||
fn environment(&self) -> workdir::WorkdirSessionEnvironment {
|
||||
let mut environment = self.local.environment();
|
||||
environment.capabilities = environment.capabilities.intersection(self.capabilities());
|
||||
environment
|
||||
}
|
||||
|
||||
async fn authorize_scope_path(
|
||||
&self,
|
||||
request: WorkdirScopeAuthorizationRequest,
|
||||
@@ -201,11 +207,10 @@ async fn checkout_wip_file_operations_share_core_and_keep_prior_read_boundary()
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("text.txt"), "旧 旧\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
let hooked = HookedSession::new(&dir);
|
||||
assert_eq!(hooked.environment(), hooked.local.environment());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
HookedSession::new(&dir),
|
||||
)
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), hooked)
|
||||
.unwrap();
|
||||
let provider = provider(router);
|
||||
let route = format!("{}/text.txt", checkout_root("main"));
|
||||
|
||||
@@ -226,6 +226,7 @@ pub struct WorkspaceAttachedWorkdirSession {
|
||||
target_workdir: String,
|
||||
workdir: Workdir,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
environment: workdir::WorkdirSessionEnvironment,
|
||||
}
|
||||
|
||||
impl WorkspaceAttachedWorkdirSession {
|
||||
@@ -249,17 +250,38 @@ impl WorkspaceAttachedWorkdirSession {
|
||||
)
|
||||
}
|
||||
|
||||
/// Bind a known root-coordinate attachment. Opened provider sessions must
|
||||
/// instead pass their metadata to [`Self::handle_for_workdir_with_environment`].
|
||||
pub fn handle_for_workdir_with_capabilities(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
target_workdir: impl Into<String>,
|
||||
workdir_id: impl Into<String>,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
) -> WorkdirSessionHandle {
|
||||
Self::handle_for_workdir_with_environment(
|
||||
client,
|
||||
target_workdir,
|
||||
workdir_id,
|
||||
capabilities,
|
||||
workdir::WorkdirSessionEnvironment::root(capabilities),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bind provider-authored coordinates without allowing metadata to expand
|
||||
/// the Backend attachment capability ceiling.
|
||||
pub fn handle_for_workdir_with_environment(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
target_workdir: impl Into<String>,
|
||||
workdir_id: impl Into<String>,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
environment: workdir::WorkdirSessionEnvironment,
|
||||
) -> WorkdirSessionHandle {
|
||||
Arc::new(Self {
|
||||
client,
|
||||
target_workdir: target_workdir.into(),
|
||||
workdir: Workdir::new(workdir_id),
|
||||
capabilities,
|
||||
capabilities: capabilities.intersection(environment.capabilities),
|
||||
environment,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -362,6 +384,12 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
||||
self.capabilities
|
||||
}
|
||||
|
||||
fn environment(&self) -> workdir::WorkdirSessionEnvironment {
|
||||
let mut environment = self.environment.clone();
|
||||
environment.capabilities = environment.capabilities.intersection(self.capabilities());
|
||||
environment
|
||||
}
|
||||
|
||||
async fn authorize_scope_path(
|
||||
&self,
|
||||
request: WorkdirScopeAuthorizationRequest,
|
||||
@@ -684,11 +712,12 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
let response = self.attach_response(alias, working_directory_id)?;
|
||||
if let Err(error) = self.session_router.attach(
|
||||
alias_key.clone(),
|
||||
WorkspaceAttachedWorkdirSession::handle_for_workdir_with_capabilities(
|
||||
WorkspaceAttachedWorkdirSession::handle_for_workdir_with_environment(
|
||||
self.client.clone(),
|
||||
alias,
|
||||
working_directory_id,
|
||||
response.capabilities,
|
||||
response.attachment.capabilities,
|
||||
response.environment.clone(),
|
||||
),
|
||||
) {
|
||||
let _ = self.detach(alias);
|
||||
@@ -706,9 +735,9 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
&self,
|
||||
alias: &str,
|
||||
working_directory_id: &str,
|
||||
) -> Result<WorkdirAttachmentResponse, ToolError> {
|
||||
) -> Result<WorkdirSessionAttachmentResponse, ToolError> {
|
||||
let workspace_id = encode_path_segment(self.workspace_id()?);
|
||||
self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest::json(
|
||||
self.execute_json::<WorkdirSessionAttachmentResponse>(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/workers/self/workdir-attachments"),
|
||||
serde_json::to_string(&WorkdirAttachRequest {
|
||||
@@ -1192,6 +1221,13 @@ struct WorkdirAttachmentResponse {
|
||||
attached: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct WorkdirSessionAttachmentResponse {
|
||||
#[serde(flatten)]
|
||||
attachment: WorkdirAttachmentResponse,
|
||||
environment: workdir::WorkdirSessionEnvironment,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkdirDeleteInput {
|
||||
@@ -1502,6 +1538,7 @@ mod tests {
|
||||
"alias": "checkout",
|
||||
"working_directory_id": "wd-created",
|
||||
"capabilities": {"bits": 63},
|
||||
"environment": workdir::WorkdirSessionEnvironment::root(WorkdirSessionCapabilities::ALL),
|
||||
"attached": true
|
||||
})),
|
||||
response(json!({
|
||||
@@ -2004,6 +2041,7 @@ mod tests {
|
||||
"alias": "checkout",
|
||||
"working_directory_id": "wd-attached",
|
||||
"capabilities": {"bits": 63},
|
||||
"environment": workdir::WorkdirSessionEnvironment::root(WorkdirSessionCapabilities::ALL),
|
||||
"attached": true
|
||||
})),
|
||||
response(json!({
|
||||
@@ -2063,16 +2101,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_propagates_provider_read_only_capabilities_to_the_alias_session() {
|
||||
#[tokio::test]
|
||||
async fn attach_projects_provider_environment_without_expanding_capability_ceiling() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join("src")).unwrap();
|
||||
let provider = Arc::new(workdir::LocalWorkdirSession::materialized(
|
||||
dir.path().to_path_buf(),
|
||||
dir.path().to_path_buf(),
|
||||
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
));
|
||||
let broker =
|
||||
workdir::WorkdirToolBroker::at_cwd(provider, workdir::WorkdirPath::new("src").unwrap())
|
||||
.unwrap();
|
||||
let mut environment = broker.tool_session().environment();
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||
"workspace_id": "workspace/test",
|
||||
"alias": "external",
|
||||
"working_directory_id": "external-workdir",
|
||||
"capabilities": {"bits": 25},
|
||||
"environment": environment,
|
||||
"attached": true
|
||||
}))]));
|
||||
let backend = WorkspaceHttpWorkdirBackend::new(client);
|
||||
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
|
||||
backend
|
||||
.attach(WorkdirAttachInput {
|
||||
alias: "external".to_string(),
|
||||
@@ -2084,10 +2135,33 @@ mod tests {
|
||||
.session_router
|
||||
.session(&workdir::WorkdirAttachmentAlias::new("external").unwrap())
|
||||
.unwrap();
|
||||
environment.capabilities = WorkdirSessionCapabilities::READ_ONLY;
|
||||
assert_eq!(session.environment(), environment);
|
||||
assert_eq!(
|
||||
session.capabilities(),
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
assert!(matches!(
|
||||
session
|
||||
.checkout_execute(workdir::CheckoutRequest {
|
||||
target: workdir::WorkdirPath::root(),
|
||||
validator: vec![1],
|
||||
operation: workdir::CheckoutOperation::Create {
|
||||
path: workdir::WorkdirPath::new("new.txt").unwrap(),
|
||||
content: b"denied".to_vec(),
|
||||
},
|
||||
})
|
||||
.await,
|
||||
Err(WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Write
|
||||
))
|
||||
));
|
||||
assert_eq!(
|
||||
client.requests().len(),
|
||||
1,
|
||||
"denied mutation must not reach the Backend"
|
||||
);
|
||||
broker.tool_session().close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2097,6 +2171,7 @@ mod tests {
|
||||
"alias": "checkout",
|
||||
"working_directory_id": "wd-attached",
|
||||
"capabilities": {"bits": 63},
|
||||
"environment": workdir::WorkdirSessionEnvironment::root(WorkdirSessionCapabilities::ALL),
|
||||
"attached": true
|
||||
}))]));
|
||||
let reopen_calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
@@ -1253,7 +1253,7 @@ pub(crate) mod tests {
|
||||
state.generation += 1;
|
||||
let raw = json!({"alias":body["alias"], "working_directory_id":body["working_directory_id"], "connection_id":format!("connection-{}", state.generation), "capabilities":{"bits":63}});
|
||||
state.attachments.push(raw);
|
||||
json!({"workspace_id":"test-workspace", "alias":body["alias"], "working_directory_id":body["working_directory_id"], "capabilities":{"bits":63}, "attached":true})
|
||||
json!({"workspace_id":"test-workspace", "alias":body["alias"], "working_directory_id":body["working_directory_id"], "capabilities":{"bits":63}, "environment":workdir::WorkdirSessionEnvironment::root(workdir::WorkdirSessionCapabilities::ALL), "attached":true})
|
||||
}
|
||||
(WorkspaceRequestMethod::Delete, p)
|
||||
if p.starts_with("workers/self/workdir-attachments/") =>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
mod result;
|
||||
pub use result::{JobResultFeature, JobResultFinalizer, JobResultSink, SUBMIT_JOB_RESULT_TOOL};
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -154,7 +153,7 @@ fn prepare_job_from_registry_with_grant(
|
||||
)?;
|
||||
let mut system_prompt = template.render(&SystemPromptContext {
|
||||
now: chrono::Utc::now(),
|
||||
cwd: Cow::Borrowed("(no filesystem)"),
|
||||
workdir_sessions: &workdir::WorkdirSessionRouter::new(),
|
||||
language: &manifest.engine.language,
|
||||
scope: &scope,
|
||||
tool_names,
|
||||
|
||||
@@ -7,13 +7,12 @@
|
||||
//! eagerly syntax-checks it at Worker construction. The final system
|
||||
//! prompt is materialised exactly once just before the first LLM turn:
|
||||
//! the rendered body is appended with a fixed trailing section carrying
|
||||
//! the Worker's `Scope` summary, (if present) the project's `AGENTS.md`
|
||||
//! contents, resident memory sections, and conditional Worker-orchestration
|
||||
//! guidance, then the whole string is handed to the Engine via
|
||||
//! `set_system_prompt`. Subsequent turns and compactions reuse that
|
||||
//! the Worker's `Scope` summary, actual Workdir session environments, (if
|
||||
//! present) the project's `AGENTS.md` contents, resident memory sections, and
|
||||
//! conditional Worker-orchestration guidance, then the whole string is handed
|
||||
//! to the Engine via `set_system_prompt`. Subsequent turns and compactions reuse that
|
||||
//! materialised string verbatim.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -105,7 +104,8 @@ impl std::fmt::Debug for SystemPromptTemplate {
|
||||
/// templates cannot drop them on the floor.
|
||||
pub struct SystemPromptContext<'a> {
|
||||
pub now: DateTime<Utc>,
|
||||
pub cwd: Cow<'a, str>,
|
||||
/// The same session router used by the Worker's filesystem tools.
|
||||
pub workdir_sessions: &'a workdir::WorkdirSessionRouter,
|
||||
/// Language policy exposed to instruction templates as `{{ language }}`.
|
||||
pub language: &'a str,
|
||||
pub scope: &'a Scope,
|
||||
@@ -140,7 +140,14 @@ impl<'a> SystemPromptContext<'a> {
|
||||
"datetime".into(),
|
||||
Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)),
|
||||
);
|
||||
root.insert("cwd".into(), Value::from(self.cwd.as_ref()));
|
||||
// A singular template cwd is meaningful only with one attachment. Never
|
||||
// substitute the local Profile/Memory discovery directory for a session cwd.
|
||||
let cwd = self
|
||||
.workdir_sessions
|
||||
.only_session()
|
||||
.map(|session| display_workdir_path(&session.environment().cwd).to_owned())
|
||||
.unwrap_or_else(|| "no single Workdir session cwd; see execution environment".into());
|
||||
root.insert("cwd".into(), Value::from(cwd));
|
||||
root.insert("language".into(), Value::from(self.language));
|
||||
root.insert(
|
||||
"tools".into(),
|
||||
@@ -255,6 +262,70 @@ fn exact_prompt_name(reference: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn display_workdir_path(path: &workdir::WorkdirPath) -> &str {
|
||||
if path.as_str().is_empty() {
|
||||
"."
|
||||
} else {
|
||||
path.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
/// Project provider-owned session metadata, rather than reconstructing authority
|
||||
/// from a manifest Scope or the directory used to discover Profiles and Memory.
|
||||
pub(crate) fn workdir_environment_section(
|
||||
router: &workdir::WorkdirSessionRouter,
|
||||
) -> Result<String, SystemPromptError> {
|
||||
use workdir::WorkdirSessionCapability::{Command, Edit, Glob, Grep, Read, Write};
|
||||
|
||||
let mut environments = Vec::new();
|
||||
for alias in router.aliases() {
|
||||
let resolved = router
|
||||
.resolve(Some(alias.as_str()))
|
||||
.map_err(|error| SystemPromptError::Render(error.to_string()))?;
|
||||
let environment = resolved.session.environment();
|
||||
let capabilities: Vec<_> = [
|
||||
(Read, "read"),
|
||||
(Write, "write"),
|
||||
(Edit, "edit"),
|
||||
(Glob, "glob"),
|
||||
(Grep, "grep"),
|
||||
(Command, "command"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(capability, name)| {
|
||||
environment
|
||||
.capabilities
|
||||
.supports(capability)
|
||||
.then_some(name)
|
||||
})
|
||||
.collect();
|
||||
environments.push(serde_json::json!({
|
||||
"alias": alias.as_str(),
|
||||
"cwd": display_workdir_path(&environment.cwd),
|
||||
"scope": environment.scope,
|
||||
"restrictions": environment.restrictions,
|
||||
"capabilities": capabilities,
|
||||
"command": environment.capabilities.supports(Command),
|
||||
}));
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&environments)
|
||||
.map_err(|error| SystemPromptError::Render(error.to_string()))?;
|
||||
Ok(format!(
|
||||
"## Workdir execution environment\n\n\
|
||||
Select a Workdir by its alias (`target_workdir`); omission is allowed only with one attachment. \
|
||||
Relative filesystem operation paths are resolved from that selected session's cwd, not its Workdir root. \
|
||||
Paths returned by Glob/Grep use the same cwd-relative coordinates and can be passed unchanged to other filesystem tools. \
|
||||
Bash starts at the session cwd; an explicit relative cwd is resolved from it, and shell cd does not change the session cwd.\n\n\
|
||||
In the metadata below, cwd and relative scope targets are Workdir-root-relative (`.` or an empty path denotes the root); explicit absolute provider scope targets stay absolute. \
|
||||
A null scope means the provider's full available scope, not unrestricted host access; an empty scope grants no paths. \
|
||||
Restrictions retain additional provider or ancestor ceilings, including deny rules. \
|
||||
Capabilities and command availability are session ceilings, not new grants. \
|
||||
Resolved targets remain subject to scope, symlink and command authorization. \
|
||||
SubWorker scope declarations remain Workdir-root-relative; other Scope declarations and Profile/Memory roots retain their own meaning and are not rebased by session cwd.\n\n\
|
||||
```json\n{json}\n```"
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the final system prompt by appending the fixed trailing
|
||||
/// section to `body`. The Rust side owns the layout (blank-line
|
||||
/// separators, trailing-whitespace trim); each section's header + body
|
||||
@@ -276,8 +347,12 @@ fn append_trailing_section(
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
let boundaries = prompts.working_boundaries_section(&scope.summary())?;
|
||||
out.push_str(boundaries.trim_end_matches(&['\n', ' '][..]));
|
||||
if ctx.workdir_sessions.is_empty() {
|
||||
let boundaries = prompts.working_boundaries_section(&scope.summary())?;
|
||||
out.push_str(boundaries.trim_end_matches(&['\n', ' '][..]));
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&workdir_environment_section(ctx.workdir_sessions)?);
|
||||
out.push('\n');
|
||||
if let Some(agents) = agents_md {
|
||||
out.push('\n');
|
||||
@@ -392,7 +467,7 @@ mod tests {
|
||||
now: DateTime::parse_from_rfc3339("2026-10-08T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
cwd: Cow::Borrowed("."),
|
||||
workdir_sessions: &workdir::WorkdirSessionRouter::new(),
|
||||
language: "en",
|
||||
scope: &scope,
|
||||
tool_names: metas.iter().map(|meta| meta.name.clone()).collect(),
|
||||
|
||||
@@ -1107,6 +1107,8 @@ fn sub_worker_spawn_tool_impl(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod environment;
|
||||
|
||||
use super::*;
|
||||
use manifest::{DelegationScope, Permission, Scope, SharedScope};
|
||||
use std::pin::Pin;
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
//! Connection tests for model-visible environments and actual routed file operations.
|
||||
//! Runs the production SubWorker spawn/broker/Worker/prompt/tool path with temporary
|
||||
//! LocalWorkdirSession providers; only the external LLM is scripted. Selected by
|
||||
//! the normal worker lib suite, without network credentials or live Workspace data.
|
||||
|
||||
use super::*;
|
||||
use agen::Item;
|
||||
use serde_json::{Value, json};
|
||||
use workdir::{WorkdirSessionCapability, WorkdirSessionRouter};
|
||||
|
||||
const TASK: &str = "Read marker.txt from each attached Workdir.";
|
||||
|
||||
fn prompt_environments(prompt: &str) -> Vec<Value> {
|
||||
let section = prompt
|
||||
.split_once("## Workdir execution environment\n")
|
||||
.expect("Worker must supply the execution environment independently of task text")
|
||||
.1;
|
||||
let json = section.split_once("```json\n").unwrap().1;
|
||||
serde_json::from_str(json.split_once("\n```").unwrap().0).unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct EnvironmentClient {
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
read_issued: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for EnvironmentClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
||||
{
|
||||
let environments = prompt_environments(request.system_prompt.as_deref().unwrap());
|
||||
self.requests.lock().unwrap().push(request);
|
||||
let mut events = Vec::new();
|
||||
if !self.read_issued.swap(true, Ordering::SeqCst) {
|
||||
// Use only the model-visible aliases and the same relative path in
|
||||
// every session. No test-side cwd prefix is passed to the Read tool.
|
||||
for (index, environment) in environments.iter().enumerate() {
|
||||
let alias = environment["alias"].as_str().unwrap();
|
||||
events.extend([
|
||||
LlmEvent::tool_use_start(index, format!("read-{alias}"), "Read"),
|
||||
LlmEvent::tool_input_delta(
|
||||
index,
|
||||
json!({"target_workdir": alias, "file_path": "marker.txt"}).to_string(),
|
||||
),
|
||||
LlmEvent::tool_use_stop(index),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
events.extend([
|
||||
LlmEvent::text_block_start(0),
|
||||
LlmEvent::text_delta(0, "read complete"),
|
||||
LlmEvent::text_block_stop(0, None),
|
||||
]);
|
||||
}
|
||||
events.push(LlmEvent::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}));
|
||||
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvironmentFixture {
|
||||
runtime: TempDir,
|
||||
root: PathBuf,
|
||||
manifest: WorkerManifest,
|
||||
profiles: AvailableProfiles,
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
sessions: Arc<WorkdirSessionRouter>,
|
||||
}
|
||||
|
||||
impl EnvironmentFixture {
|
||||
fn new(aliases: &[(&str, &str)]) -> Self {
|
||||
let runtime = TempDir::new().unwrap();
|
||||
let root = runtime.path().join("profile-root");
|
||||
// Profile discovery stays at this root, deliberately outside every
|
||||
// attached Workdir. Session cwd must never redirect it into work/nested.
|
||||
let profiles = write_project_profile_registry(&root, None, &[]);
|
||||
let mut manifest = parent_manifest(&root, None);
|
||||
manifest.feature.sub_worker.enabled = true;
|
||||
let registry = SpawnedWorkerRegistry::new_internal(
|
||||
"parent".into(),
|
||||
SharedScope::new(Scope::from_config(&manifest.scope).unwrap()),
|
||||
);
|
||||
let sessions = Arc::new(WorkdirSessionRouter::new());
|
||||
for (alias, cwd) in aliases {
|
||||
let checkout = runtime.path().join(alias);
|
||||
for path in ["", "work", "work/nested", "work/nested/deep", "other"] {
|
||||
std::fs::create_dir_all(checkout.join(path)).unwrap();
|
||||
std::fs::write(checkout.join(path).join("marker.txt"), marker(alias, path))
|
||||
.unwrap();
|
||||
}
|
||||
let scope = SharedScope::new(Scope::writable(&checkout).unwrap());
|
||||
let session = Arc::new(workdir::LocalWorkdirSession::materialized_bound(
|
||||
workdir::Workdir::new(format!("fixture-{alias}")),
|
||||
checkout.clone(),
|
||||
checkout.join(cwd),
|
||||
scope,
|
||||
workdir::WorkdirSessionCapabilities::ALL,
|
||||
));
|
||||
sessions
|
||||
.attach_at_cwd(
|
||||
workdir::WorkdirAttachmentAlias::new(*alias).unwrap(),
|
||||
session,
|
||||
workdir::WorkdirPath::new(cwd).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Self {
|
||||
runtime,
|
||||
root,
|
||||
manifest,
|
||||
profiles,
|
||||
registry,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn(
|
||||
&self,
|
||||
spawner: &str,
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
broker: workdir::WorkdirToolBrokerRouter,
|
||||
input: Value,
|
||||
) -> (
|
||||
crate::spawn::registry::InternalSpawnedWorkerRecord,
|
||||
EnvironmentClient,
|
||||
) {
|
||||
assert_eq!(input["task"], TASK);
|
||||
let client = EnvironmentClient::default();
|
||||
let mut manifest = self.manifest.clone();
|
||||
manifest.worker.name = spawner.into();
|
||||
let tool = SubWorkerSpawnTool::new(
|
||||
spawner.into(),
|
||||
crate::worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
||||
self.runtime.path().to_path_buf(),
|
||||
self.root.clone(),
|
||||
Some(broker),
|
||||
registry.clone(),
|
||||
manifest,
|
||||
PromptCatalogSource::builtins_only(),
|
||||
self.profiles.clone(),
|
||||
)
|
||||
.with_internal_client(Box::new(client.clone()));
|
||||
tool.execute(
|
||||
&input.to_string(),
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.expect("spawn through production delegation and Worker construction");
|
||||
let record = registry
|
||||
.get_internal(input["name"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), record.session.wait_until_idle())
|
||||
.await
|
||||
.expect("scripted read turn must terminate"),
|
||||
InternalWorkerSessionStatus::Idle,
|
||||
);
|
||||
(record, client)
|
||||
}
|
||||
}
|
||||
|
||||
fn marker(alias: &str, cwd: &str) -> String {
|
||||
format!(
|
||||
"{alias}:{}:session-marker",
|
||||
if cwd.is_empty() { "." } else { cwd }
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_environment_and_reads(
|
||||
router: &WorkdirSessionRouter,
|
||||
client: &EnvironmentClient,
|
||||
expected: &[(&str, &str, bool)],
|
||||
) -> String {
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert!(
|
||||
requests.len() >= 2,
|
||||
"Read must execute and return to the model"
|
||||
);
|
||||
let prompt = requests[0].system_prompt.as_deref().unwrap();
|
||||
assert!(
|
||||
!prompt.contains("## Working boundaries"),
|
||||
"legacy Scope must not override the connected session environment"
|
||||
);
|
||||
let environments = prompt_environments(prompt);
|
||||
assert_eq!(environments.len(), expected.len());
|
||||
assert_eq!(router.len(), expected.len());
|
||||
for (alias, cwd, command) in expected {
|
||||
let environment = router.resolve(Some(alias)).unwrap().session.environment();
|
||||
assert_eq!(environment.cwd.as_str(), *cwd);
|
||||
assert_eq!(
|
||||
environment
|
||||
.capabilities
|
||||
.supports(WorkdirSessionCapability::Command),
|
||||
*command
|
||||
);
|
||||
let projected = environments
|
||||
.iter()
|
||||
.find(|env| env["alias"] == *alias)
|
||||
.unwrap();
|
||||
assert_eq!(projected["cwd"], if cwd.is_empty() { "." } else { *cwd });
|
||||
assert_eq!(
|
||||
projected["scope"],
|
||||
serde_json::to_value(&environment.scope).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
projected["restrictions"],
|
||||
serde_json::to_value(&environment.restrictions).unwrap()
|
||||
);
|
||||
assert_eq!(projected["command"], *command);
|
||||
for (capability, name) in [
|
||||
(WorkdirSessionCapability::Read, "read"),
|
||||
(WorkdirSessionCapability::Write, "write"),
|
||||
(WorkdirSessionCapability::Edit, "edit"),
|
||||
(WorkdirSessionCapability::Glob, "glob"),
|
||||
(WorkdirSessionCapability::Grep, "grep"),
|
||||
(WorkdirSessionCapability::Command, "command"),
|
||||
] {
|
||||
assert_eq!(
|
||||
projected["capabilities"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|value| value == name),
|
||||
environment.capabilities.supports(capability),
|
||||
);
|
||||
}
|
||||
let expected_marker = marker(alias, cwd);
|
||||
let observed_read = requests
|
||||
.iter()
|
||||
.flat_map(|request| &request.items)
|
||||
.any(|item| {
|
||||
matches!(item, Item::ToolResult { call_id, content: Some(content), .. }
|
||||
if call_id == &format!("read-{alias}") && content.contains(&expected_marker))
|
||||
});
|
||||
assert!(
|
||||
observed_read,
|
||||
"{alias}: relative Read did not return {expected_marker}"
|
||||
);
|
||||
}
|
||||
prompt.to_owned()
|
||||
}
|
||||
|
||||
fn assert_retained_environment(
|
||||
record: &crate::spawn::registry::InternalSpawnedWorkerRecord,
|
||||
prompt: &str,
|
||||
) {
|
||||
let retained = record.session.entries().iter().any(|entry| {
|
||||
matches!(entry, session_store::LogEntry::AnnotatedSegmentStart { system_prompt: Some(saved), .. }
|
||||
if saved == prompt)
|
||||
});
|
||||
assert!(
|
||||
retained,
|
||||
"the model-visible environment must also be retained in session history"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_root_and_nonroot_cwd_are_projected_without_task_annotations() {
|
||||
for (name, cwd, command, permission) in [
|
||||
("root-child", "", false, "read"),
|
||||
("cwd-child", "work", true, "write"),
|
||||
] {
|
||||
let fixture = EnvironmentFixture::new(&[("checkout", "")]);
|
||||
let (record, client) = fixture
|
||||
.spawn(
|
||||
"parent",
|
||||
fixture.registry.clone(),
|
||||
workdir::WorkdirToolBrokerRouter::new(fixture.sessions.clone()),
|
||||
json!({
|
||||
"name": name, "task": TASK, "cwd": if cwd.is_empty() { "." } else { cwd },
|
||||
"scope": [{"target": ".", "permission": permission}],
|
||||
"command": command,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let router = record.workdir_tool_scope.session_router();
|
||||
let prompt = assert_environment_and_reads(&router, &client, &[("checkout", cwd, command)]);
|
||||
assert_retained_environment(&record, &prompt);
|
||||
assert_eq!(
|
||||
record.installed_tools.iter().any(|tool| tool == "Bash"),
|
||||
command
|
||||
);
|
||||
fixture.registry.remove_internal(name).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_multiple_aliases_and_nested_child_keep_real_session_environment() {
|
||||
let fixture = EnvironmentFixture::new(&[("alpha", ""), ("beta", "")]);
|
||||
let (child, child_client) = fixture
|
||||
.spawn(
|
||||
"parent",
|
||||
fixture.registry.clone(),
|
||||
workdir::WorkdirToolBrokerRouter::new(fixture.sessions.clone()),
|
||||
json!({
|
||||
"name": "child", "task": TASK, "cwd": "work", "command": true,
|
||||
"scope": [
|
||||
{"target_workdir": "alpha", "target": ".", "permission": "write"},
|
||||
{"target_workdir": "beta", "target": ".", "permission": "write"},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let child_router = child.workdir_tool_scope.session_router();
|
||||
let child_prompt = assert_environment_and_reads(
|
||||
&child_router,
|
||||
&child_client,
|
||||
&[("alpha", "work", true), ("beta", "work", true)],
|
||||
);
|
||||
assert_retained_environment(&child, &child_prompt);
|
||||
assert!(
|
||||
child
|
||||
.installed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "SubWorkerSpawn")
|
||||
);
|
||||
|
||||
// Reuse the actual child's registry and attenuated broker. The nested tool
|
||||
// receives the scripted LLM at the same boundary as the first spawn; session
|
||||
// construction, routing, delegation and prompt materialization remain real.
|
||||
let (nested, nested_client) = fixture
|
||||
.spawn(
|
||||
"child",
|
||||
child.child_registry.clone(),
|
||||
child.workdir_tool_scope.broker_router(),
|
||||
json!({
|
||||
"name": "nested", "task": TASK, "cwd": "work/nested",
|
||||
"scope": [
|
||||
{"target_workdir": "alpha", "target": "work/nested", "permission": "read"},
|
||||
{"target_workdir": "beta", "target": "work/nested", "permission": "read"},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let nested_router = nested.workdir_tool_scope.session_router();
|
||||
let nested_prompt = assert_environment_and_reads(
|
||||
&nested_router,
|
||||
&nested_client,
|
||||
&[
|
||||
("alpha", "work/nested", false),
|
||||
("beta", "work/nested", false),
|
||||
],
|
||||
);
|
||||
assert_retained_environment(&nested, &nested_prompt);
|
||||
assert!(!nested.installed_tools.iter().any(|tool| tool == "Bash"));
|
||||
for alias in ["alpha", "beta"] {
|
||||
let session = nested_router.resolve(Some(alias)).unwrap().session;
|
||||
let environment = session.environment();
|
||||
let scope = environment
|
||||
.scope
|
||||
.expect("nested session must remain scoped");
|
||||
assert_eq!(scope.len(), 1);
|
||||
assert_eq!(scope[0].target.as_str(), "work/nested");
|
||||
assert_eq!(
|
||||
scope[0].permission,
|
||||
workdir::WorkdirToolScopePermission::Read
|
||||
);
|
||||
assert!(
|
||||
session
|
||||
.read(workdir::ReadRequest {
|
||||
path: workdir::WorkdirPath::new_scoped(
|
||||
fixture
|
||||
.runtime
|
||||
.path()
|
||||
.join(alias)
|
||||
.join("marker.txt")
|
||||
.display()
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
// Grandchild cwd and reduced command authority must not rewrite its parent.
|
||||
assert_environment_and_reads(
|
||||
&child_router,
|
||||
&child_client,
|
||||
&[("alpha", "work", true), ("beta", "work", true)],
|
||||
);
|
||||
child
|
||||
.child_registry
|
||||
.remove_internal("nested")
|
||||
.await
|
||||
.unwrap();
|
||||
fixture.registry.remove_internal("child").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_multiple_aliases_keep_root_scope_independent_of_parent_session_cwds() {
|
||||
let fixture = EnvironmentFixture::new(&[("alpha", "work"), ("beta", "other")]);
|
||||
let (record, client) = fixture
|
||||
.spawn(
|
||||
"parent",
|
||||
fixture.registry.clone(),
|
||||
workdir::WorkdirToolBrokerRouter::new(fixture.sessions.clone()),
|
||||
json!({
|
||||
"name": "child", "task": TASK, "cwd": "work/nested",
|
||||
"scope": [
|
||||
{"target_workdir": "alpha", "target": "work/nested", "permission": "read"},
|
||||
{"target_workdir": "beta", "target": "work/nested", "permission": "read"},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let prompt = assert_environment_and_reads(
|
||||
&record.workdir_tool_scope.session_router(),
|
||||
&client,
|
||||
&[
|
||||
("alpha", "work/nested", false),
|
||||
("beta", "work/nested", false),
|
||||
],
|
||||
);
|
||||
assert_retained_environment(&record, &prompt);
|
||||
for (alias, cwd) in [("alpha", "work"), ("beta", "other")] {
|
||||
assert_eq!(
|
||||
fixture
|
||||
.sessions
|
||||
.resolve(Some(alias))
|
||||
.unwrap()
|
||||
.session
|
||||
.environment()
|
||||
.cwd
|
||||
.as_str(),
|
||||
cwd
|
||||
);
|
||||
}
|
||||
fixture.registry.remove_internal("child").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nonroot_startup_then_spawn_and_nested_child_keep_root_relative_scope() {
|
||||
let fixture = EnvironmentFixture::new(&[("workdir", "")]);
|
||||
let checkout = fixture.runtime.path().join("workdir");
|
||||
let client = EnvironmentClient::default();
|
||||
let mut manifest = fixture.manifest.clone();
|
||||
manifest.scope.allow = vec![abs_rule(&checkout, Permission::Write)];
|
||||
let mut worker =
|
||||
Worker::<Box<dyn LlmClient>, EphemeralSessionStore>::from_internal_manifest_with_context(
|
||||
manifest,
|
||||
EphemeralSessionStore::default(),
|
||||
PromptCatalogSource::builtins_only(),
|
||||
crate::worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||
WorkerFilesystemAuthority::local(checkout.clone(), checkout.join("work")),
|
||||
Some(Box::new(client.clone())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Do not bind a test router: exercise workdir_sessions_from_authority via
|
||||
// the real constructor and the normal controller's broker selection.
|
||||
let startup = worker.workdir_sessions();
|
||||
register_worker_tools(
|
||||
&mut worker,
|
||||
fixture.runtime.path().to_path_buf(),
|
||||
fixture.registry.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), worker.run_text(TASK))
|
||||
.await
|
||||
.expect("startup read turn must terminate")
|
||||
.unwrap();
|
||||
assert_environment_and_reads(&startup, &client, &[("workdir", "work", true)]);
|
||||
|
||||
let (child, child_client) = fixture
|
||||
.spawn(
|
||||
"parent",
|
||||
fixture.registry.clone(),
|
||||
workdir::WorkdirToolBrokerRouter::new(startup.clone()),
|
||||
json!({
|
||||
"name": "child", "task": TASK, "cwd": "work/nested",
|
||||
"scope": [{"target": "work/nested", "permission": "read"}],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let child_router = child.workdir_tool_scope.session_router();
|
||||
let child_prompt = assert_environment_and_reads(
|
||||
&child_router,
|
||||
&child_client,
|
||||
&[("workdir", "work/nested", false)],
|
||||
);
|
||||
assert_retained_environment(&child, &child_prompt);
|
||||
let (nested, nested_client) = fixture
|
||||
.spawn(
|
||||
"child",
|
||||
child.child_registry.clone(),
|
||||
child.workdir_tool_scope.broker_router(),
|
||||
json!({
|
||||
"name": "nested", "task": TASK, "cwd": "work/nested/deep",
|
||||
"scope": [{"target": "work/nested/deep", "permission": "read"}],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let nested_router = nested.workdir_tool_scope.session_router();
|
||||
let nested_prompt = assert_environment_and_reads(
|
||||
&nested_router,
|
||||
&nested_client,
|
||||
&[("workdir", "work/nested/deep", false)],
|
||||
);
|
||||
assert_retained_environment(&nested, &nested_prompt);
|
||||
for (router, target) in [
|
||||
(&child_router, "work/nested"),
|
||||
(&nested_router, "work/nested/deep"),
|
||||
] {
|
||||
let environment = router.resolve(None).unwrap().session.environment();
|
||||
let rules = environment.scope.unwrap();
|
||||
assert_eq!(rules.len(), 1);
|
||||
assert_eq!(rules[0].target.as_str(), target);
|
||||
}
|
||||
assert_eq!(
|
||||
startup
|
||||
.resolve(None)
|
||||
.unwrap()
|
||||
.session
|
||||
.environment()
|
||||
.cwd
|
||||
.as_str(),
|
||||
"work"
|
||||
);
|
||||
let alias = workdir::WorkdirAttachmentAlias::new("workdir").unwrap();
|
||||
startup.begin_detach(&alias).await.unwrap();
|
||||
// Both the parent tool view and descendant root-coordinate broker source
|
||||
// must share this fence, rather than bypassing it through a cached broker.
|
||||
for router in [&startup, &child_router, &nested_router] {
|
||||
let session = router.resolve(None).unwrap().session;
|
||||
assert!(
|
||||
session
|
||||
.read(workdir::ReadRequest {
|
||||
path: workdir::WorkdirPath::new("marker.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
startup.cancel_detach(&alias);
|
||||
child
|
||||
.child_registry
|
||||
.remove_internal("nested")
|
||||
.await
|
||||
.unwrap();
|
||||
fixture.registry.remove_internal("child").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_rejects_cwd_outside_workdir_root_without_running_the_model() {
|
||||
let fixture = EnvironmentFixture::new(&[("workdir", "")]);
|
||||
let client = EnvironmentClient::default();
|
||||
let mut manifest = fixture.manifest.clone();
|
||||
manifest.scope.allow = vec![abs_rule(fixture.runtime.path(), Permission::Write)];
|
||||
let error =
|
||||
Worker::<Box<dyn LlmClient>, EphemeralSessionStore>::from_internal_manifest_with_context(
|
||||
manifest,
|
||||
EphemeralSessionStore::default(),
|
||||
PromptCatalogSource::builtins_only(),
|
||||
crate::worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||
WorkerFilesystemAuthority::local(
|
||||
fixture.runtime.path().join("workdir"),
|
||||
fixture.root.clone(),
|
||||
),
|
||||
Some(Box::new(client.clone())),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("a session cwd must be representable within its Workdir root");
|
||||
assert!(matches!(error, crate::WorkerError::InvalidCwd { .. }));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_multiple_aliases_project_distinct_session_cwds_not_discovery_root() {
|
||||
for prompt_override in [None, Some("Feature-owned instruction body")] {
|
||||
let fixture = EnvironmentFixture::new(&[("alpha", "work"), ("beta", "other")]);
|
||||
let client = EnvironmentClient::default();
|
||||
let mut worker = Worker::<Box<dyn LlmClient>, EphemeralSessionStore>::from_internal_manifest_with_context(
|
||||
fixture.manifest.clone(),
|
||||
EphemeralSessionStore::default(),
|
||||
PromptCatalogSource::builtins_only(),
|
||||
crate::worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||
WorkerFilesystemAuthority::None,
|
||||
Some(Box::new(client.clone())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worker.bind_workdir_sessions(fixture.sessions.clone());
|
||||
register_worker_tools(
|
||||
&mut worker,
|
||||
fixture.runtime.path().to_path_buf(),
|
||||
fixture.registry.clone(),
|
||||
None,
|
||||
Some(workdir::WorkdirToolBrokerRouter::new(
|
||||
fixture.sessions.clone(),
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worker.install_system_prompt_contribution(None, prompt_override.map(str::to_owned));
|
||||
tokio::time::timeout(Duration::from_secs(5), worker.run_text(TASK))
|
||||
.await
|
||||
.expect("scripted worker turn must terminate")
|
||||
.unwrap();
|
||||
let prompt = assert_environment_and_reads(
|
||||
&fixture.sessions,
|
||||
&client,
|
||||
&[("alpha", "work", true), ("beta", "other", true)],
|
||||
);
|
||||
if let Some(body) = prompt_override {
|
||||
assert!(prompt.starts_with(body));
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-15
@@ -3700,7 +3700,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
let prompts = Arc::new(ArcSwap::from(PromptCatalog::builtins_only()?));
|
||||
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
||||
let scope = SharedScope::new(scope);
|
||||
let workdir_sessions = workdir_sessions_from_authority(&filesystem_authority, &scope);
|
||||
let workdir_sessions = workdir_sessions_from_authority(&filesystem_authority, &scope)?;
|
||||
let mut worker = Self {
|
||||
backend_job: std::sync::OnceLock::new(),
|
||||
manifest,
|
||||
@@ -4980,11 +4980,14 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
let Some(template) = self.system_prompt_template.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(rendered) = self.feature_system_prompt_override.as_ref() {
|
||||
if let Some(body) = self.feature_system_prompt_override.as_ref() {
|
||||
let environment =
|
||||
crate::prompt::system::workdir_environment_section(&self.workdir_sessions)
|
||||
.map_err(|source| WorkerError::SystemPromptRender { source })?;
|
||||
self.engine
|
||||
.as_mut()
|
||||
.expect("worker present")
|
||||
.set_system_prompt(rendered.clone());
|
||||
.set_system_prompt(format!("{body}\n\n{environment}"));
|
||||
return Ok(());
|
||||
}
|
||||
let alerter = self.alerter.clone();
|
||||
@@ -5029,14 +5032,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
};
|
||||
let worker_language = worker_language(&self.manifest.engine);
|
||||
let scope_snapshot = self.scope.snapshot();
|
||||
let cwd_for_prompt = self
|
||||
.local_working_directory()
|
||||
.map(|local| local.cwd.display().to_string())
|
||||
.unwrap_or_else(|| "no local working directory".to_string());
|
||||
let prompt_catalog = self.prompts.load_full();
|
||||
let ctx = SystemPromptContext {
|
||||
now: chrono::Utc::now(),
|
||||
cwd: cwd_for_prompt.into(),
|
||||
workdir_sessions: &self.workdir_sessions,
|
||||
language: worker_language,
|
||||
scope: &scope_snapshot,
|
||||
tool_names,
|
||||
@@ -8306,7 +8305,7 @@ where
|
||||
let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store));
|
||||
let scope = SharedScope::new(common.scope);
|
||||
let workdir_sessions =
|
||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
|
||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope)?;
|
||||
|
||||
let mut worker = Self {
|
||||
backend_job: std::sync::OnceLock::new(),
|
||||
@@ -8707,7 +8706,7 @@ where
|
||||
engine.set_cache_key(Some(segment_id.to_string()));
|
||||
let scope = SharedScope::new(common.scope);
|
||||
let workdir_sessions =
|
||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
|
||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope)?;
|
||||
let mut worker = Self {
|
||||
backend_job: std::sync::OnceLock::new(),
|
||||
manifest,
|
||||
@@ -9109,7 +9108,7 @@ where
|
||||
let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store));
|
||||
let scope = SharedScope::new(common.scope);
|
||||
let workdir_sessions =
|
||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
|
||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope)?;
|
||||
|
||||
let mut worker = Self {
|
||||
backend_job: std::sync::OnceLock::new(),
|
||||
@@ -10220,9 +10219,25 @@ pub enum WorkerError {
|
||||
fn workdir_sessions_from_authority(
|
||||
authority: &WorkerFilesystemAuthority,
|
||||
scope: &SharedScope,
|
||||
) -> Arc<WorkdirSessionRouter> {
|
||||
) -> Result<Arc<WorkdirSessionRouter>, WorkerError> {
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
if let Some(local) = authority.as_local() {
|
||||
let invalid_cwd = |error: String| WorkerError::InvalidCwd {
|
||||
cwd: local.cwd.clone(),
|
||||
source: std::io::Error::new(std::io::ErrorKind::InvalidInput, error),
|
||||
};
|
||||
let relative_cwd = local.cwd.strip_prefix(&local.root).map_err(|error| {
|
||||
invalid_cwd(format!(
|
||||
"session cwd must be within its Workdir root: {error}"
|
||||
))
|
||||
})?;
|
||||
let relative_cwd = relative_cwd
|
||||
.to_str()
|
||||
.ok_or_else(|| invalid_cwd("session cwd must be UTF-8".into()))?;
|
||||
let relative_cwd = workdir::WorkdirPath::new(relative_cwd)
|
||||
.map_err(|error| invalid_cwd(error.to_string()))?;
|
||||
// The provider keeps root-coordinate operations. Only the routed tool
|
||||
// view uses cwd; delegation reuses the same root-coordinate broker.
|
||||
let session = Arc::new(LocalWorkdirSession::materialized(
|
||||
local.root.clone(),
|
||||
local.cwd.clone(),
|
||||
@@ -10230,13 +10245,14 @@ fn workdir_sessions_from_authority(
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
)) as WorkdirSessionHandle;
|
||||
router
|
||||
.attach(
|
||||
.attach_at_cwd(
|
||||
WorkdirAttachmentAlias::new("workdir").expect("static alias is valid"),
|
||||
session,
|
||||
relative_cwd,
|
||||
)
|
||||
.expect("fresh Workdir router accepts its only attachment");
|
||||
.map_err(|error| invalid_cwd(error.to_string()))?;
|
||||
}
|
||||
router
|
||||
Ok(router)
|
||||
}
|
||||
|
||||
/// Bundle of resources that every high-level Worker constructor needs:
|
||||
|
||||
@@ -187,7 +187,8 @@ async fn materialise_on_first_turn_populates_worker() {
|
||||
.expect("system prompt materialised")
|
||||
.to_string();
|
||||
assert!(rendered.contains("date="));
|
||||
assert!(rendered.contains(&pwd.display().to_string()));
|
||||
assert!(rendered.contains("## Workdir execution environment"));
|
||||
assert!(!rendered.contains(&pwd.display().to_string()));
|
||||
assert!(rendered.starts_with("date="));
|
||||
}
|
||||
|
||||
@@ -206,7 +207,8 @@ async fn session_start_state_captures_rendered_prompt() {
|
||||
LogEntry::AnnotatedSegmentStart { system_prompt, .. } => {
|
||||
let sp = system_prompt.as_deref().expect("system prompt set");
|
||||
assert!(sp.starts_with("hello"));
|
||||
assert!(sp.contains(&pwd.display().to_string()));
|
||||
assert!(sp.contains("## Workdir execution environment"));
|
||||
assert!(!sp.contains(&pwd.display().to_string()));
|
||||
}
|
||||
other => panic!("expected AnnotatedSegmentStart as first entry, got {other:?}"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user