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
+4
View File
@@ -7514,6 +7514,9 @@ pub struct CurrentWorkerWorkdirAttachmentResponse {
pub alias: String,
pub working_directory_id: String,
pub capabilities: workdir::WorkdirSessionCapabilities,
/// Present for attachment responses, absent after detach.
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<workdir::WorkdirSessionEnvironment>,
pub attached: bool,
}
@@ -13286,6 +13289,7 @@ mod tests {
alias: "checkout".to_string(),
working_directory_id: "workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
environment: None,
attached: false,
};
let value = serde_json::to_value(&response).unwrap();
+3 -1
View File
@@ -30,7 +30,9 @@ struct BashParams {
/// Worker-local alias of the Workdir attachment to use.
#[serde(default)]
target_workdir: Option<String>,
/// Workdir-relative directory in which to execute the command.
/// Directory in which to execute the command.
// Keep provider path-coordinate documentation out of the model-facing schema.
#[schemars(with = "String")]
cwd: WorkdirPath,
command: String,
#[serde(default)]
+1 -1
View File
@@ -21,7 +21,7 @@ pub(crate) struct EditParams {
/// Worker-local alias of the Workdir attachment to use.
#[serde(default)]
pub target_workdir: Option<String>,
/// Logical path relative to the bound Workdir root.
/// Existing file in which to replace text.
pub file_path: String,
#[serde(flatten)]
pub edit: fs_operation::text::EditArgs,
+2 -2
View File
@@ -17,7 +17,7 @@ pub(crate) struct GlobParams {
pub target_workdir: Option<String>,
/// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`.
pub pattern: String,
/// Logical Workdir-relative directory. Defaults to the Workdir root.
/// Directory to search. Defaults to `.`.
#[serde(default)]
pub path: Option<String>,
}
@@ -106,7 +106,7 @@ pub(crate) fn routed_glob_tool(router: Arc<WorkdirSessionRouter>) -> ToolDefinit
Arc::new(move || {
let schema = schemars::schema_for!(GlobParams);
let meta = ToolMeta::new("Glob")
.description("Find files matching a glob pattern inside the selected Workdir. Results are sorted and capped at 1000 entries. Paths are Workdir-relative.")
.description("Find files matching a glob pattern inside the selected Workdir. Results are sorted and capped at 1000 entries.")
.input_schema(serde_json::to_value(schema).expect("Glob schema serialization"));
let tool: Arc<dyn Tool> = Arc::new(GlobTool {
router: router.clone(),
+2 -2
View File
@@ -27,7 +27,7 @@ pub(crate) struct GrepParams {
#[serde(default)]
target_workdir: Option<String>,
pattern: String,
/// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root.
/// File or directory to search. Defaults to `.`.
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
@@ -160,7 +160,7 @@ pub(crate) fn routed_grep_tool(router: Arc<WorkdirSessionRouter>) -> ToolDefinit
Arc::new(move || {
let schema = schemars::schema_for!(GrepParams);
let meta = ToolMeta::new("Grep")
.description("Search a file or directory in the selected Workdir with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Directory traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
.description("Search a file or directory in the selected Workdir with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Directory traversal executes inside the WorkdirSession provider. Results are bounded.")
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
router: router.clone(),
+2 -2
View File
@@ -13,7 +13,7 @@ use workdir::{WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
const DESCRIPTION: &str = "Read a text file from a Workdir attachment selected by its Worker-local alias. \
Supports offset/limit for large files. Returns line-numbered output (1-based). \
Directories cannot be read. The file must be read before Write or Edit can \
modify it. Paths are Workdir-relative unless an absolute path is explicitly readable. \
modify it. \
When exactly one Workdir is attached, target_workdir may be omitted.";
const DEFAULT_LIMIT: usize = 2000;
@@ -24,7 +24,7 @@ pub(crate) struct ReadParams {
/// Worker-local alias of the Workdir attachment to use.
#[serde(default)]
pub target_workdir: Option<String>,
/// Workdir-relative path, or an absolute path covered by readable scope.
/// Text file to read; directories are not supported.
pub file_path: String,
#[serde(flatten)]
pub read: fs_operation::text::LineReadArgs,
+2 -2
View File
@@ -20,7 +20,7 @@ pub const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
const IMAGE_READ_CHUNK_BYTES: usize = 1024 * 1024;
const DESCRIPTION: &str = "Attach an image from the selected Workdir attachment to the next model request. \
The path must be logical and Workdir-relative. Supported formats: PNG, JPEG, GIF, and WebP. \
Supported formats: PNG, JPEG, GIF, and WebP. \
When exactly one Workdir is attached, target_workdir may be omitted.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
@@ -28,7 +28,7 @@ struct ViewImageParams {
/// Worker-local alias of the Workdir attachment to use.
#[serde(default)]
target_workdir: Option<String>,
/// Logical path relative to the bound Workdir root.
/// Image file to attach to the next model request.
path: String,
}
+1 -1
View File
@@ -20,7 +20,7 @@ pub(crate) struct WriteParams {
/// Worker-local alias of the Workdir attachment to use.
#[serde(default)]
pub target_workdir: Option<String>,
/// Logical path relative to the bound Workdir root.
/// File to create or overwrite.
pub file_path: String,
#[serde(flatten)]
pub write: fs_operation::text::WriteArgs,
+586
View File
@@ -3,6 +3,11 @@
//! `ToolServerHandle::register_tool` / `flush_pending` are `pub(crate)` in
//! agen, so from here we exercise the factories directly — the same
//! code path that `flush_pending()` runs at production time.
//!
//! Scoped routing cases connect real LocalWorkdirSession, WorkdirToolBroker::scope,
//! attachment router and Tool factories without replacing their permission or path checks.
//! They use RAII temporary roots and existing image fixtures; command cases require the
//! same local shell as the Bash suite and run in the normal `tools --test integration` target.
use std::path::Path;
use std::sync::Arc;
@@ -80,6 +85,101 @@ fn setup_routed() -> (TempDir, TempDir, Arc<WorkdirSessionRouter>, Registry) {
(left, right, router, Registry::new(definitions))
}
const READ_SAME_FILE: &str = "IFS= read -r line < same.txt && printf '%s\\n' \"$line\"";
struct ScopedRegistry {
roots: Vec<TempDir>,
directories: Vec<std::path::PathBuf>,
brokers: Vec<WorkdirToolBroker>,
leases: Vec<workdir::WorkdirScopeLease>,
router: Arc<WorkdirSessionRouter>,
reg: Registry,
}
impl ScopedRegistry {
// Cwd and scope targets here are delegation declarations in provider coordinates.
async fn new(attachments: &[(&str, &str, bool)]) -> Self {
let router = Arc::new(WorkdirSessionRouter::new());
let mut roots = Vec::new();
let mut directories = Vec::new();
let mut brokers = Vec::new();
let mut leases = Vec::new();
for &(alias, cwd, command) in attachments {
let root = TempDir::new().unwrap();
let directory = root.path().join(cwd);
std::fs::create_dir_all(directory.join("subdir")).unwrap();
std::fs::write(directory.join("same.txt"), format!("{alias} original\n")).unwrap();
std::fs::write(
directory.join("subdir/same.txt"),
format!("{alias} nested needle\n"),
)
.unwrap();
std::fs::write(
directory.join("image.png"),
include_bytes!("fixtures/view-image/valid.png"),
)
.unwrap();
if cwd != "." {
// Decoys make accidental root-based resolution observable.
std::fs::write(root.path().join("same.txt"), "root decoy\n").unwrap();
std::fs::write(root.path().join("image.png"), "not an image").unwrap();
}
let source: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
));
let broker = WorkdirToolBroker::new(source);
let lease = broker
.scope(WorkdirToolScope {
rules: vec![WorkdirToolScopeRule {
target: WorkdirPath::new(cwd).unwrap(),
permission: WorkdirToolScopePermission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
cwd: WorkdirPath::new(cwd).unwrap(),
command,
})
.await
.unwrap();
router
.attach(
WorkdirAttachmentAlias::new(alias).unwrap(),
lease.tool_session(),
)
.unwrap();
roots.push(root);
directories.push(directory);
brokers.push(broker);
leases.push(lease);
}
let mut definitions = routed_builtin_tools(
router.clone(),
Tracker::new(),
tools::bash_output::BashOutputView::new(),
);
definitions.push(routed_view_image_tool(router.clone()));
Self {
roots,
directories,
brokers,
leases,
router,
reg: Registry::new(definitions),
}
}
async fn close(self) {
self.router.close_all().await.unwrap();
for lease in &self.leases {
lease.close().await.unwrap();
}
for broker in &self.brokers {
broker.close().await.unwrap();
}
}
}
fn valid_gif_with_len(target_len: usize) -> Vec<u8> {
let fixture = include_bytes!("fixtures/view-image/valid.gif");
assert_eq!(
@@ -156,6 +256,71 @@ fn routed_tool_schemas_share_optional_target_workdir() {
}
}
#[test]
fn routed_tool_help_does_not_override_session_path_coordinates() {
fn assert_no_root_coordinates(tool: &str, description: &str) {
let normalized = description
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
for obsolete in ["workdir-relative", "workdir root", "root-relative"] {
assert!(
!normalized.contains(obsolete),
"{tool} help overrides session path coordinates: {description}"
);
}
}
fn check_schema_descriptions(tool: &str, schema: &serde_json::Value) {
match schema {
serde_json::Value::Object(object) => {
if let Some(description) = object.get("description").and_then(|v| v.as_str()) {
assert_no_root_coordinates(tool, description);
}
for value in object.values() {
check_schema_descriptions(tool, value);
}
}
serde_json::Value::Array(array) => {
for value in array {
check_schema_descriptions(tool, value);
}
}
_ => {}
}
}
let router = Arc::new(WorkdirSessionRouter::new());
let mut definitions = routed_builtin_tools(
router.clone(),
Tracker::new(),
tools::bash_output::BashOutputView::new(),
);
definitions.push(routed_view_image_tool(router));
for definition in definitions {
let (meta, _) = definition();
assert_no_root_coordinates(&meta.name, &meta.description);
// Referenced type definitions are model-visible too, not just properties.
check_schema_descriptions(&meta.name, &meta.input_schema);
let path_parameter = match meta.name.as_str() {
"Read" | "Write" | "Edit" => "file_path",
"Glob" | "Grep" | "ViewImage" => "path",
"Bash" => "cwd",
other => panic!("missing path parameter contract for {other}"),
};
let help = meta.input_schema["properties"][path_parameter]["description"]
.as_str()
.unwrap_or_default();
assert!(
!help.trim().is_empty(),
"{}.{path_parameter} must retain argument-specific help",
meta.name
);
}
}
#[tokio::test]
async fn routed_tools_require_alias_only_when_attachment_set_is_ambiguous() {
let (_left, _right, router, reg) = setup_routed();
@@ -284,6 +449,427 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() {
router.close_all().await.unwrap();
}
#[tokio::test]
async fn scoped_cwd_file_search_and_image_tools_share_reusable_paths() {
for cwd in [".", "work"] {
let fixture = ScopedRegistry::new(&[("checkout", cwd, false)]).await;
let read = fixture.reg.get("Read");
let original = call(&read, json!({ "file_path": "same.txt" })).await;
assert!(original.content.unwrap().contains("checkout original"));
call(
&fixture.reg.get("Write"),
json!({ "file_path": "same.txt", "content": "written marker\n" }),
)
.await;
call(
&fixture.reg.get("Edit"),
json!({ "file_path": "same.txt", "old_string": "written", "new_string": "edited" }),
)
.await;
let edited = call(&read, json!({ "file_path": "same.txt" })).await;
assert!(edited.content.unwrap().contains("edited marker"));
assert_eq!(
std::fs::read_to_string(fixture.directories[0].join("same.txt")).unwrap(),
"edited marker\n"
);
// Omitted search paths start at cwd, not at the underlying provider root.
for (name, input) in [
("Glob", json!({ "pattern": "same.txt" })),
(
"Grep",
json!({ "pattern": "edited marker", "output_mode": "files_with_matches" }),
),
] {
let result = call(&fixture.reg.get(name), input).await;
assert_eq!(
result.content.unwrap().trim(),
"same.txt",
"{name}, cwd={cwd}"
);
}
// A search rooted below cwd still returns cwd-relative reusable paths.
for (name, input) in [
("Glob", json!({ "pattern": "*.txt", "path": "subdir" })),
(
"Grep",
json!({ "pattern": "needle", "path": "subdir", "output_mode": "files_with_matches" }),
),
] {
let result = call(&fixture.reg.get(name), input).await;
let body = result.content.unwrap();
let paths: Vec<_> = body.lines().collect();
assert_eq!(paths, ["subdir/same.txt"], "{name}, cwd={cwd}");
let path = paths[0];
let found = call(&read, json!({ "file_path": path })).await;
assert!(found.content.unwrap().contains("checkout nested needle"));
call(
&fixture.reg.get("Edit"),
json!({ "file_path": path, "old_string": "needle", "new_string": format!("needle-{name}") }),
)
.await;
}
assert_eq!(
std::fs::read_to_string(fixture.directories[0].join("subdir/same.txt")).unwrap(),
"checkout nested needle-Grep-Glob\n"
);
let image = call(
&fixture.reg.get("ViewImage"),
json!({ "path": "image.png" }),
)
.await;
assert_eq!(image.attachments.len(), 1);
let agen::tool::Attachment::Image(image) = &image.attachments[0];
assert_eq!(image.mime_type(), "image/png");
assert_eq!(
image.data(),
include_bytes!("fixtures/view-image/valid.png")
);
if cwd != "." {
assert_eq!(
std::fs::read_to_string(fixture.roots[0].path().join("same.txt")).unwrap(),
"root decoy\n"
);
}
fixture.close().await;
}
}
#[tokio::test]
async fn scoped_bash_relative_cwd_and_cd_do_not_change_later_tool_cwd() {
for cwd in [".", "work"] {
let fixture = ScopedRegistry::new(&[("checkout", cwd, true)]).await;
for (command_cwd, command, expected) in [
(".", READ_SAME_FILE, "checkout original\n"),
("subdir", READ_SAME_FILE, "checkout nested needle\n"),
("./subdir//.", READ_SAME_FILE, "checkout nested needle\n"),
(
".",
"cd subdir && IFS= read -r line < same.txt && printf '%s\\n' \"$line\"",
"checkout nested needle\n",
),
(".", READ_SAME_FILE, "checkout original\n"),
] {
let output = call(
&fixture.reg.get("Bash"),
json!({ "cwd": command_cwd, "command": command, "timeout": 5 }),
)
.await;
let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap();
assert_eq!(body["output"]["terminal"]["exit_code"], 0, "{body}");
assert_eq!(body["stdout_preview"], expected, "cwd={cwd}, {command}");
let subsequent =
call(&fixture.reg.get("Read"), json!({ "file_path": "same.txt" })).await;
assert!(subsequent.content.unwrap().contains("checkout original"));
}
fixture.close().await;
}
}
#[tokio::test]
async fn scoped_aliases_with_different_cwds_keep_paths_and_observations_separate() {
let fixture =
ScopedRegistry::new(&[("left", "work", true), ("right", "other/deep", true)]).await;
let read = fixture.reg.get("Read");
let edit = fixture.reg.get("Edit");
call(
&read,
json!({ "target_workdir": "left", "file_path": "same.txt" }),
)
.await;
let unread = call_err(
&edit,
json!({ "target_workdir": "right", "file_path": "same.txt", "old_string": "original", "new_string": "wrong" }),
)
.await;
assert!(unread.to_string().contains("has not been read"), "{unread}");
call(
&read,
json!({ "target_workdir": "right", "file_path": "same.txt" }),
)
.await;
call(
&fixture.reg.get("Write"),
json!({ "target_workdir": "right", "file_path": "same.txt", "content": "right written\n" }),
)
.await;
call(
&edit,
json!({ "target_workdir": "right", "file_path": "same.txt", "old_string": "written", "new_string": "edited" }),
)
.await;
assert_eq!(
std::fs::read_to_string(fixture.directories[0].join("same.txt")).unwrap(),
"left original\n"
);
assert_eq!(
std::fs::read_to_string(fixture.directories[1].join("same.txt")).unwrap(),
"right edited\n"
);
// Different valid bytes make cross-alias image reads observable too.
let png: &[u8] = include_bytes!("fixtures/view-image/valid.png");
let gif: &[u8] = include_bytes!("fixtures/view-image/valid.gif");
std::fs::write(fixture.directories[1].join("image.png"), gif).unwrap();
for (alias, expected, image_bytes, mime) in [
("left", "left original", png, "image/png"),
("right", "right edited", gif, "image/gif"),
] {
let output = call(
&read,
json!({ "target_workdir": alias, "file_path": "same.txt" }),
)
.await;
assert!(output.content.unwrap().contains(expected));
for (name, input) in [
(
"Glob",
json!({ "target_workdir": alias, "pattern": "*.txt", "path": "subdir" }),
),
(
"Grep",
json!({ "target_workdir": alias, "pattern": alias, "path": "subdir", "output_mode": "files_with_matches" }),
),
] {
let output = call(&fixture.reg.get(name), input).await;
let path = output.content.unwrap();
let path = path.trim();
assert_eq!(path, "subdir/same.txt", "{alias}: {name}");
let found = call(&read, json!({ "target_workdir": alias, "file_path": path })).await;
assert!(
found
.content
.unwrap()
.contains(&format!("{alias} nested needle"))
);
}
let image = call(
&fixture.reg.get("ViewImage"),
json!({ "target_workdir": alias, "path": "image.png" }),
)
.await;
let agen::tool::Attachment::Image(image) = &image.attachments[0];
assert_eq!(image.mime_type(), mime);
assert_eq!(image.data(), image_bytes);
let output = call(
&fixture.reg.get("Bash"),
json!({ "target_workdir": alias, "cwd": ".", "command": READ_SAME_FILE, "timeout": 5 }),
)
.await;
let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap();
assert_eq!(body["output"]["terminal"]["exit_code"], 0, "{body}");
assert_eq!(body["stdout_preview"], format!("{expected}\n"));
}
fixture.close().await;
}
#[tokio::test]
async fn scoped_command_false_rejects_bash_without_fallback_to_another_alias() {
let fixture =
ScopedRegistry::new(&[("enabled", "work", true), ("disabled", "other", false)]).await;
for cwd in [".", "subdir"] {
let error = call_err(
&fixture.reg.get("Bash"),
json!({ "target_workdir": "disabled", "cwd": cwd, "command": "printf forbidden > blocked.txt", "timeout": 5 }),
)
.await;
assert!(
error.to_string().contains("does not support Command"),
"{error}"
);
}
// Write permission remains usable, but does not imply command permission.
call(
&fixture.reg.get("Write"),
json!({ "target_workdir": "disabled", "file_path": "created.txt", "content": "allowed write\n" }),
)
.await;
assert_eq!(
std::fs::read_to_string(fixture.directories[1].join("created.txt")).unwrap(),
"allowed write\n"
);
for (root, directory) in fixture.roots.iter().zip(&fixture.directories) {
for path in [
root.path().to_path_buf(),
directory.clone(),
directory.join("subdir"),
] {
assert!(!path.join("blocked.txt").exists());
}
}
fixture.close().await;
}
#[tokio::test]
async fn scoped_normalized_paths_share_read_before_mutation_history() {
let fixture = ScopedRegistry::new(&[("checkout", "work", false)]).await;
let read = call(
&fixture.reg.get("Read"),
json!({ "file_path": "./subdir//./same.txt" }),
)
.await;
assert!(read.content.unwrap().contains("checkout nested needle"));
call(
&fixture.reg.get("Write"),
json!({ "file_path": "subdir/same.txt", "content": "normalized write\n" }),
)
.await;
call(
&fixture.reg.get("Edit"),
json!({ "file_path": "./subdir/same.txt", "old_string": "write", "new_string": "edit" }),
)
.await;
assert_eq!(
std::fs::read_to_string(fixture.directories[0].join("subdir/same.txt")).unwrap(),
"normalized edit\n"
);
for (name, input) in [
("Glob", json!({ "pattern": "*.txt", "path": "./subdir//." })),
(
"Grep",
json!({ "pattern": "normalized edit", "path": "./subdir//.", "output_mode": "files_with_matches" }),
),
] {
let result = call(&fixture.reg.get(name), input).await;
assert_eq!(result.content.unwrap().trim(), "subdir/same.txt", "{name}");
}
let image = call(
&fixture.reg.get("ViewImage"),
json!({ "path": "././image.png" }),
)
.await;
let agen::tool::Attachment::Image(image) = &image.attachments[0];
assert_eq!(
image.data(),
include_bytes!("fixtures/view-image/valid.png")
);
fixture.close().await;
}
#[tokio::test]
async fn scoped_tools_reject_parent_traversal_and_paths_outside_the_grant() {
let fixture = ScopedRegistry::new(&[("checkout", "work", true)]).await;
let outside = fixture.roots[0].path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("secret.txt"), "private needle\n").unwrap();
for invalid in [
"../outside/secret.txt",
"subdir/../../outside/secret.txt",
"subdir\\same.txt",
] {
for (name, input) in [
("Read", json!({ "file_path": invalid })),
(
"Write",
json!({ "file_path": invalid, "content": "forbidden" }),
),
(
"Edit",
json!({ "file_path": invalid, "old_string": "needle", "new_string": "forbidden" }),
),
("Glob", json!({ "path": invalid, "pattern": "*" })),
("Grep", json!({ "path": invalid, "pattern": "needle" })),
("ViewImage", json!({ "path": invalid })),
(
"Bash",
json!({ "cwd": invalid, "command": "printf forbidden > blocked.txt", "timeout": 5 }),
),
] {
let error = call_err(&fixture.reg.get(name), input).await;
assert!(
error.to_string().contains("path"),
"{name}: {invalid}: {error}"
);
}
}
// These paths are inside the provider's grant, but outside the child's grant.
// Absolute readable paths must not bypass the delegated scope after cwd rebasing.
for (name, input) in [
("Read", json!({ "file_path": outside.join("secret.txt") })),
("Grep", json!({ "path": outside, "pattern": "needle" })),
(
"Bash",
json!({ "cwd": outside, "command": "printf forbidden > blocked.txt", "timeout": 5 }),
),
] {
let error = call_err(&fixture.reg.get(name), input).await;
let message = error.to_string();
assert!(
matches!(error, ToolError::InvalidArgument(_)),
"{name}: {message}"
);
assert!(
message.contains("scope") || message.contains("path"),
"{name}: {message}"
);
}
assert_eq!(
std::fs::read_to_string(outside.join("secret.txt")).unwrap(),
"private needle\n"
);
assert!(!outside.join("blocked.txt").exists());
assert!(!fixture.directories[0].join("blocked.txt").exists());
assert!(!fixture.roots[0].path().join("blocked.txt").exists());
fixture.close().await;
}
#[cfg(unix)]
#[tokio::test]
async fn scoped_cwd_tools_reject_resolved_symlink_escape_after_a_read() {
use std::os::unix::fs::symlink;
let fixture = ScopedRegistry::new(&[("checkout", "work", true)]).await;
let outside = fixture.roots[0].path().join("outside");
std::fs::create_dir(&outside).unwrap();
// Equal bytes avoid disguising an authority failure as a stale-hash rejection.
std::fs::write(outside.join("secret.txt"), "checkout original\n").unwrap();
let png = include_bytes!("fixtures/view-image/valid.png");
std::fs::write(outside.join("image.png"), png).unwrap();
call(&fixture.reg.get("Read"), json!({ "file_path": "same.txt" })).await;
std::fs::remove_file(fixture.directories[0].join("same.txt")).unwrap();
symlink(
"../outside/secret.txt",
fixture.directories[0].join("same.txt"),
)
.unwrap();
symlink("../outside", fixture.directories[0].join("escape")).unwrap();
for (name, input) in [
("Read", json!({ "file_path": "same.txt" })),
(
"Write",
json!({ "file_path": "escape/created.txt", "content": "forbidden" }),
),
(
"Edit",
json!({ "file_path": "same.txt", "old_string": "original", "new_string": "forbidden" }),
),
("Glob", json!({ "path": "escape", "pattern": "*" })),
("Grep", json!({ "path": "escape", "pattern": "original" })),
("ViewImage", json!({ "path": "escape/image.png" })),
(
"Bash",
json!({ "cwd": "escape", "command": "printf forbidden > blocked.txt", "timeout": 5 }),
),
] {
let error = call_err(&fixture.reg.get(name), input).await;
let message = error.to_string();
assert!(
message.contains("scope") || message.contains("symlink"),
"{name}: {message}"
);
}
assert_eq!(
std::fs::read_to_string(outside.join("secret.txt")).unwrap(),
"checkout original\n"
);
assert_eq!(std::fs::read(outside.join("image.png")).unwrap(), png);
assert!(!outside.join("created.txt").exists());
assert!(!outside.join("blocked.txt").exists());
fixture.close().await;
}
#[tokio::test]
async fn selected_attachment_capability_is_checked_without_fallback() {
let left = TempDir::new().unwrap();
+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" {
+1
View File
@@ -869,6 +869,7 @@ async fn open_workdir_session(
session_id: session_id.clone(),
workdir_id: session.workdir().id().clone(),
capabilities: session.capabilities(),
environment: session.environment(),
};
state
.workdir_sessions
@@ -111,6 +111,9 @@ impl Harness {
session_id: WorkdirSessionId::new("session-transport").unwrap(),
workdir_id: Workdir::new("wd-transport").id().clone(),
capabilities: WorkdirSessionCapabilities::ALL,
environment: workdir::WorkdirSessionEnvironment::root(
WorkdirSessionCapabilities::ALL,
),
})
}),
)
@@ -55,6 +55,10 @@ impl WorkdirSession for RetainedWorkdirSession {
self.inner.capabilities()
}
fn environment(&self) -> workdir::WorkdirSessionEnvironment {
self.inner.environment()
}
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
+64 -1
View File
@@ -1127,8 +1127,17 @@ fn runtime_local_workdir_router(
),
None => source,
};
let cwd = binding
.cwd()
.strip_prefix(binding.root())
.map_err(|_| format!("Workdir attachment `{alias}` cwd is outside its logical root"))?;
let cwd = workdir::WorkdirPath::new(
cwd.to_str()
.ok_or_else(|| format!("Workdir attachment `{alias}` cwd is not UTF-8"))?,
)
.map_err(|error| error.to_string())?;
router
.attach(alias.clone(), source)
.attach_at_cwd(alias.clone(), source, cwd)
.map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?;
}
Ok(router)
@@ -10550,6 +10559,60 @@ mod tests {
assert!(!binding.working_directory_root().exists());
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn local_attachment_router_keeps_each_binding_cwd_and_capability_ceiling() {
let runtime_base = tempfile::tempdir().unwrap();
let repo = create_clean_repo();
let materializer = RuntimeGitMaterializer::new(runtime_base.path());
let root_binding = materializer
.create(&working_directory_request(repo.path()))
.unwrap();
std::fs::create_dir(root_binding.root().join("nested")).unwrap();
std::fs::write(root_binding.root().join("marker"), "root").unwrap();
std::fs::write(root_binding.root().join("nested/marker"), "nested").unwrap();
let mut nested_binding = root_binding.clone();
nested_binding.cwd = root_binding.root().join("nested");
let root_alias = WorkdirAttachmentAlias::new("root").unwrap();
let nested_alias = WorkdirAttachmentAlias::new("nested").unwrap();
let attachments = BTreeMap::from([
(root_alias.clone(), root_binding),
(nested_alias.clone(), nested_binding),
]);
let ceilings = BTreeMap::from([
(root_alias, WorkdirSessionCapabilities::ALL),
(nested_alias, WorkdirSessionCapabilities::READ_ONLY),
]);
let router = runtime_local_workdir_router(&attachments, &ceilings, None).unwrap();
assert!(router.resolve(None).is_err());
for (alias, cwd, content, command) in [
("root", "", "root", true),
("nested", "nested", "nested", false),
] {
let selected = router.resolve(Some(alias)).unwrap().session;
let environment = selected.environment();
assert_eq!(environment.cwd.as_str(), cwd);
assert_eq!(
environment
.capabilities
.supports(workdir::WorkdirSessionCapability::Command),
command
);
let read = selected
.read(workdir::ReadRequest {
path: workdir::WorkdirPath::new("marker").unwrap(),
offset: 0,
limit: 10,
max_bytes: 100,
})
.await
.unwrap();
assert_eq!(read.bytes, content.as_bytes());
assert_eq!(read.path.as_str(), "marker");
}
router.close_all().await.unwrap();
}
#[test]
fn spawn_failure_with_existing_working_directory_preserves_workdir() {
let runtime_base = tempfile::tempdir().unwrap();
@@ -124,6 +124,18 @@ macro_rules! impl_deferred_session {
self.capabilities
}
fn environment(&self) -> WorkdirSessionEnvironment {
let mut environment = self.state.try_lock().ok().and_then(|state| {
state.session.as_ref().map(|session| session.environment())
}).unwrap_or_else(|| {
let mut environment = WorkdirSessionEnvironment::root(self.capabilities);
environment.restrictions.push("Deferred provider: scope is checked on connection and every operation".into());
environment
});
environment.capabilities = self.capabilities;
environment
}
$(
async fn $method(&self, $request: $request_type) -> Result<$result, WorkdirError> {
self.session().await?.$method($request).await
+42 -30
View File
@@ -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();
}
+37
View File
@@ -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
+9 -4
View File
@@ -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/") =>
+1 -2
View File
@@ -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,
+85 -10
View File
@@ -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(),
+2
View File
@@ -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
View File
@@ -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:?}"),
}
+3
View File
@@ -8827,6 +8827,9 @@ mod tests {
session_id: workdir::http::WorkdirSessionId::new("session-1").unwrap(),
workdir_id: Workdir::new("wd-1").id().clone(),
capabilities: workdir::WorkdirSessionCapabilities::ALL,
environment: workdir::WorkdirSessionEnvironment::root(
workdir::WorkdirSessionCapabilities::ALL,
),
};
let stat = workdir::http::WorkdirSessionOperationResult::Stat(StatResult {
path: WorkdirPath::new("hello.txt").unwrap(),
+89 -21
View File
@@ -20656,6 +20656,55 @@ async fn import_current_worker_workdir_observation(
Ok(())
}
// The Backend proxy has root-coordinate operations even before its provider is
// opened. Multiple attachments must remain attachable without opening every
// provider: a pending/offline provider is checked when an operation selects it.
async fn current_worker_attachment_environment_locked(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
link: &WorkerWorkdirLinkRecord,
capabilities: workdir::WorkdirSessionCapabilities,
open_if_single: bool,
session_guard: &Arc<tokio::sync::OwnedMutexGuard<()>>,
) -> Result<workdir::WorkdirSessionEnvironment> {
if open_if_single {
let stage_api = api.clone();
let stage_worker = worker.clone();
let count = api
.store
.db_executor()
.run(move || {
active_filesystem_workdir_links(&stage_api, &stage_worker).map(|links| links.len())
})
.await?;
if count == 1 {
return Ok(open_current_worker_workdir_session_locked(
api,
worker,
link,
session_guard,
)
.await?
.environment());
}
}
if let Some(mut environment) = api
.workdir_sessions
.lock()
.expect("Workdir session registry lock poisoned")
.attachments
.get(worker)
.filter(|source| source.workdir().id().as_str() == link.workdir_id)
.map(|source| source.environment())
{
environment.capabilities = environment.capabilities.intersection(capabilities);
return Ok(environment);
}
let mut environment = workdir::WorkdirSessionEnvironment::root(capabilities);
environment.restrictions.push("Backend proxy: provider scope and availability are checked when each operation selects this attachment".into());
Ok(environment)
}
async fn scoped_attach_current_worker_workdir(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -20782,17 +20831,17 @@ async fn scoped_attach_current_worker_workdir(
}
let effective_capabilities = existing.capabilities.intersection(capabilities);
if effective_capabilities != existing.capabilities {
let mut attenuated = existing;
let mut attenuated = existing.clone();
attenuated.capabilities = effective_capabilities;
api.store.attach_worker_workdir(&attenuated)?;
}
Ok(Some(effective_capabilities))
Ok(Some((existing, effective_capabilities)))
} else {
Ok(None)
}
})
.await?;
if let Some(effective_capabilities) = existing {
if let Some((link, effective_capabilities)) = existing {
sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard).await?;
api.worker_projection
.refresh_async(&worker)
@@ -20803,6 +20852,17 @@ async fn scoped_attach_current_worker_workdir(
alias: alias.to_string(),
working_directory_id: workdir_id.to_string(),
capabilities: effective_capabilities,
environment: Some(
current_worker_attachment_environment_locked(
&api,
&worker,
&link,
effective_capabilities,
false,
&_session_guard,
)
.await?,
),
attached: true,
}));
}
@@ -20828,25 +20888,25 @@ async fn scoped_attach_current_worker_workdir(
detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard).await;
return Err(error);
}
let stage_api = api.clone();
let stage_worker = worker.clone();
let active_count = api
.store
.db_executor()
.run(move || {
active_filesystem_workdir_links(&stage_api, &stage_worker).map(|links| links.len())
})
.await?;
if active_count == 1
&& let Err(error) =
open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await
let environment = match current_worker_attachment_environment_locked(
&api,
&worker,
&link,
capabilities,
true,
&_session_guard,
)
.await
{
let _ =
detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard).await;
let _ =
sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard).await;
return Err(error.into());
}
Ok(environment) => environment,
Err(error) => {
let _ = detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard)
.await;
let _ = sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard)
.await;
return Err(error.into());
}
};
if let Err(error) = api
.worker_projection
.refresh_async(&worker)
@@ -20863,6 +20923,7 @@ async fn scoped_attach_current_worker_workdir(
alias: alias.to_string(),
working_directory_id: workdir_id.to_string(),
capabilities,
environment: Some(environment),
attached: true,
}))
}
@@ -21105,6 +21166,7 @@ async fn detach_current_worker_workdir(
alias: alias.to_string(),
working_directory_id: link.workdir_id,
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
environment: None,
attached: false,
})
}
@@ -60835,6 +60897,12 @@ mod tests {
serde_json::from_slice(&attached_body).unwrap();
assert!(attached.attached);
assert_eq!(attached.working_directory_id, fixture.docs_workdir_id);
let environment = attached
.environment
.expect("attached proxy has an execution environment");
assert!(environment.cwd.is_root());
assert_eq!(environment.capabilities, attached.capabilities);
assert!(!environment.restrictions.is_empty());
assert_eq!(
api.store
.get_workdir_registry(TEST_WORKSPACE_ID, &fixture.docs_workdir_id)
@@ -200,6 +200,7 @@ async fn backend_delegation_reaches_remote_repository_resolution() {
session_id: WorkdirSessionId::new("delegation-session").unwrap(),
workdir_id,
capabilities: Caps::ALL,
environment: workdir::WorkdirSessionEnvironment::root(Caps::ALL),
})
}
}),