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