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 alias: String,
|
||||||
pub working_directory_id: String,
|
pub working_directory_id: String,
|
||||||
pub capabilities: workdir::WorkdirSessionCapabilities,
|
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,
|
pub attached: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13286,6 +13289,7 @@ mod tests {
|
|||||||
alias: "checkout".to_string(),
|
alias: "checkout".to_string(),
|
||||||
working_directory_id: "workdir".to_string(),
|
working_directory_id: "workdir".to_string(),
|
||||||
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
|
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
|
||||||
|
environment: None,
|
||||||
attached: false,
|
attached: false,
|
||||||
};
|
};
|
||||||
let value = serde_json::to_value(&response).unwrap();
|
let value = serde_json::to_value(&response).unwrap();
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ struct BashParams {
|
|||||||
/// Worker-local alias of the Workdir attachment to use.
|
/// Worker-local alias of the Workdir attachment to use.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
target_workdir: Option<String>,
|
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,
|
cwd: WorkdirPath,
|
||||||
command: String,
|
command: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub(crate) struct EditParams {
|
|||||||
/// Worker-local alias of the Workdir attachment to use.
|
/// Worker-local alias of the Workdir attachment to use.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub target_workdir: Option<String>,
|
pub target_workdir: Option<String>,
|
||||||
/// Logical path relative to the bound Workdir root.
|
/// Existing file in which to replace text.
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub edit: fs_operation::text::EditArgs,
|
pub edit: fs_operation::text::EditArgs,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ pub(crate) struct GlobParams {
|
|||||||
pub target_workdir: Option<String>,
|
pub target_workdir: Option<String>,
|
||||||
/// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`.
|
/// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`.
|
||||||
pub pattern: String,
|
pub pattern: String,
|
||||||
/// Logical Workdir-relative directory. Defaults to the Workdir root.
|
/// Directory to search. Defaults to `.`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ pub(crate) fn routed_glob_tool(router: Arc<WorkdirSessionRouter>) -> ToolDefinit
|
|||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(GlobParams);
|
let schema = schemars::schema_for!(GlobParams);
|
||||||
let meta = ToolMeta::new("Glob")
|
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"));
|
.input_schema(serde_json::to_value(schema).expect("Glob schema serialization"));
|
||||||
let tool: Arc<dyn Tool> = Arc::new(GlobTool {
|
let tool: Arc<dyn Tool> = Arc::new(GlobTool {
|
||||||
router: router.clone(),
|
router: router.clone(),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ pub(crate) struct GrepParams {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
target_workdir: Option<String>,
|
target_workdir: Option<String>,
|
||||||
pattern: 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)]
|
#[serde(default)]
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -160,7 +160,7 @@ pub(crate) fn routed_grep_tool(router: Arc<WorkdirSessionRouter>) -> ToolDefinit
|
|||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(GrepParams);
|
let schema = schemars::schema_for!(GrepParams);
|
||||||
let meta = ToolMeta::new("Grep")
|
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"));
|
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
||||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
||||||
router: router.clone(),
|
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. \
|
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). \
|
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 \
|
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.";
|
When exactly one Workdir is attached, target_workdir may be omitted.";
|
||||||
|
|
||||||
const DEFAULT_LIMIT: usize = 2000;
|
const DEFAULT_LIMIT: usize = 2000;
|
||||||
@@ -24,7 +24,7 @@ pub(crate) struct ReadParams {
|
|||||||
/// Worker-local alias of the Workdir attachment to use.
|
/// Worker-local alias of the Workdir attachment to use.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub target_workdir: Option<String>,
|
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,
|
pub file_path: String,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub read: fs_operation::text::LineReadArgs,
|
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 IMAGE_READ_CHUNK_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Attach an image from the selected Workdir attachment to the next model request. \
|
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.";
|
When exactly one Workdir is attached, target_workdir may be omitted.";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
@@ -28,7 +28,7 @@ struct ViewImageParams {
|
|||||||
/// Worker-local alias of the Workdir attachment to use.
|
/// Worker-local alias of the Workdir attachment to use.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
target_workdir: Option<String>,
|
target_workdir: Option<String>,
|
||||||
/// Logical path relative to the bound Workdir root.
|
/// Image file to attach to the next model request.
|
||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub(crate) struct WriteParams {
|
|||||||
/// Worker-local alias of the Workdir attachment to use.
|
/// Worker-local alias of the Workdir attachment to use.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub target_workdir: Option<String>,
|
pub target_workdir: Option<String>,
|
||||||
/// Logical path relative to the bound Workdir root.
|
/// File to create or overwrite.
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub write: fs_operation::text::WriteArgs,
|
pub write: fs_operation::text::WriteArgs,
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
//! `ToolServerHandle::register_tool` / `flush_pending` are `pub(crate)` in
|
//! `ToolServerHandle::register_tool` / `flush_pending` are `pub(crate)` in
|
||||||
//! agen, so from here we exercise the factories directly — the same
|
//! agen, so from here we exercise the factories directly — the same
|
||||||
//! code path that `flush_pending()` runs at production time.
|
//! 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::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -80,6 +85,101 @@ fn setup_routed() -> (TempDir, TempDir, Arc<WorkdirSessionRouter>, Registry) {
|
|||||||
(left, right, router, Registry::new(definitions))
|
(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> {
|
fn valid_gif_with_len(target_len: usize) -> Vec<u8> {
|
||||||
let fixture = include_bytes!("fixtures/view-image/valid.gif");
|
let fixture = include_bytes!("fixtures/view-image/valid.gif");
|
||||||
assert_eq!(
|
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]
|
#[tokio::test]
|
||||||
async fn routed_tools_require_alias_only_when_attachment_set_is_ambiguous() {
|
async fn routed_tools_require_alias_only_when_attachment_set_is_ambiguous() {
|
||||||
let (_left, _right, router, reg) = setup_routed();
|
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();
|
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]
|
#[tokio::test]
|
||||||
async fn selected_attachment_capability_is_checked_without_fallback() {
|
async fn selected_attachment_capability_is_checked_without_fallback() {
|
||||||
let left = TempDir::new().unwrap();
|
let left = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! hashes alone do not accept same-content inode replacements. Callers own Read line numbering
|
//! hashes alone do not accept same-content inode replacements. Callers own Read line numbering
|
||||||
//! and per-attachment read history.
|
//! 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
|
//! 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 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.
|
//! 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
|
//! 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
|
//! 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.
|
//! 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
|
//! 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.
|
//! 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 session_id: WorkdirSessionId,
|
||||||
pub workdir_id: WorkdirId,
|
pub workdir_id: WorkdirId,
|
||||||
pub capabilities: WorkdirSessionCapabilities,
|
pub capabilities: WorkdirSessionCapabilities,
|
||||||
|
pub environment: crate::WorkdirSessionEnvironment,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One provider-side Workdir operation.
|
/// One provider-side Workdir operation.
|
||||||
@@ -553,6 +554,7 @@ mod client {
|
|||||||
workdir: Workdir,
|
workdir: Workdir,
|
||||||
session_id: WorkdirSessionId,
|
session_id: WorkdirSessionId,
|
||||||
capabilities: WorkdirSessionCapabilities,
|
capabilities: WorkdirSessionCapabilities,
|
||||||
|
environment: crate::WorkdirSessionEnvironment,
|
||||||
closed: AtomicBool,
|
closed: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,6 +614,7 @@ mod client {
|
|||||||
workdir: Workdir::new(opened.workdir_id.as_str()),
|
workdir: Workdir::new(opened.workdir_id.as_str()),
|
||||||
session_id: opened.session_id,
|
session_id: opened.session_id,
|
||||||
capabilities: opened.capabilities,
|
capabilities: opened.capabilities,
|
||||||
|
environment: opened.environment,
|
||||||
closed: AtomicBool::new(false),
|
closed: AtomicBool::new(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -676,6 +679,12 @@ mod client {
|
|||||||
self.capabilities
|
self.capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn environment(&self) -> crate::WorkdirSessionEnvironment {
|
||||||
|
let mut environment = self.environment.clone();
|
||||||
|
environment.capabilities = self.capabilities();
|
||||||
|
environment
|
||||||
|
}
|
||||||
|
|
||||||
async fn authorize_scope_path(
|
async fn authorize_scope_path(
|
||||||
&self,
|
&self,
|
||||||
request: WorkdirScopeAuthorizationRequest,
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
@@ -1209,6 +1218,9 @@ mod client {
|
|||||||
workdir,
|
workdir,
|
||||||
session_id: WorkdirSessionId::new("s").unwrap(),
|
session_id: WorkdirSessionId::new("s").unwrap(),
|
||||||
capabilities: WorkdirSessionCapabilities::EMPTY,
|
capabilities: WorkdirSessionCapabilities::EMPTY,
|
||||||
|
environment: crate::WorkdirSessionEnvironment::root(
|
||||||
|
WorkdirSessionCapabilities::EMPTY,
|
||||||
|
),
|
||||||
closed: AtomicBool::new(false),
|
closed: AtomicBool::new(false),
|
||||||
};
|
};
|
||||||
let stat = StatRequest {
|
let stat = StatRequest {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ pub use checkout::{
|
|||||||
CheckoutObservation, CheckoutOperation, CheckoutOutput, CheckoutRequest, CheckoutResult,
|
CheckoutObservation, CheckoutOperation, CheckoutOutput, CheckoutRequest, CheckoutResult,
|
||||||
CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult,
|
CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult,
|
||||||
};
|
};
|
||||||
|
mod environment;
|
||||||
|
pub use environment::WorkdirSessionEnvironment;
|
||||||
pub mod external;
|
pub mod external;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
mod local;
|
mod local;
|
||||||
@@ -16,6 +18,8 @@ pub mod workspace;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod checkout_search_tests;
|
mod checkout_search_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod environment_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod external_local_tests;
|
mod external_local_tests;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -185,6 +189,9 @@ pub type WriteOutcome = WriteResult;
|
|||||||
/// Worker-bound operation session for a persistent [`Workdir`].
|
/// Worker-bound operation session for a persistent [`Workdir`].
|
||||||
///
|
///
|
||||||
/// Filesystem search and commands run on the host owning materialization.
|
/// 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.
|
/// Session closure is terminal but does not delete the Workdir or files.
|
||||||
/// Unsupported checkout/search implementations explicitly reject requests, with no ambient
|
/// Unsupported checkout/search implementations explicitly reject requests, with no ambient
|
||||||
/// filesystem fallback. Providers own identity validators; callers own read hash history
|
/// 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 workdir(&self) -> &Workdir;
|
||||||
fn capabilities(&self) -> WorkdirSessionCapabilities;
|
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
|
/// Validate an attenuated filesystem rule at the provider boundary without
|
||||||
/// exposing the resolved host path. Providers that cannot resolve symbolic
|
/// exposing the resolved host path. Providers that cannot resolve symbolic
|
||||||
/// links must reject resolved-policy checks rather than downgrade them.
|
/// links must reject resolved-policy checks rather than downgrade them.
|
||||||
|
|||||||
@@ -1340,6 +1340,52 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
self.inner.capabilities
|
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(
|
async fn authorize_scope_path(
|
||||||
&self,
|
&self,
|
||||||
request: WorkdirScopeAuthorizationRequest,
|
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(),
|
session_id: WorkdirSessionId::new("fault-session").unwrap(),
|
||||||
workdir_id: crate::Workdir::new("fault-workdir").id().clone(),
|
workdir_id: crate::Workdir::new("fault-workdir").id().clone(),
|
||||||
capabilities: crate::WorkdirSessionCapabilities::ALL,
|
capabilities: crate::WorkdirSessionCapabilities::ALL,
|
||||||
|
environment: crate::WorkdirSessionEnvironment::root(
|
||||||
|
crate::WorkdirSessionCapabilities::ALL,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
+236
-31
@@ -225,16 +225,27 @@ impl Drop for ActiveOperation<'_> {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RoutedWorkdirSession {
|
pub struct RoutedWorkdirSession {
|
||||||
state: Arc<RoutedSessionState>,
|
state: Arc<RoutedSessionState>,
|
||||||
|
view: Option<WorkdirSessionHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoutedWorkdirSession {
|
||||||
|
fn inner(&self) -> &WorkdirSessionHandle {
|
||||||
|
self.view.as_ref().unwrap_or(&self.state.session)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl WorkdirSession for RoutedWorkdirSession {
|
impl WorkdirSession for RoutedWorkdirSession {
|
||||||
fn workdir(&self) -> &Workdir {
|
fn workdir(&self) -> &Workdir {
|
||||||
self.state.session.workdir()
|
self.inner().workdir()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
||||||
self.state.session.capabilities()
|
self.inner().capabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn environment(&self) -> crate::WorkdirSessionEnvironment {
|
||||||
|
self.inner().environment()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn authorize_scope_path(
|
async fn authorize_scope_path(
|
||||||
@@ -242,7 +253,7 @@ impl WorkdirSession for RoutedWorkdirSession {
|
|||||||
request: WorkdirScopeAuthorizationRequest,
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
) -> Result<(), WorkdirError> {
|
) -> Result<(), WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
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(
|
async fn scope_rules_overlap(
|
||||||
@@ -250,7 +261,7 @@ impl WorkdirSession for RoutedWorkdirSession {
|
|||||||
request: WorkdirScopeOverlapRequest,
|
request: WorkdirScopeOverlapRequest,
|
||||||
) -> Result<bool, WorkdirError> {
|
) -> Result<bool, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
let _active = self.state.enter()?;
|
||||||
self.state.session.scope_rules_overlap(request).await
|
self.inner().scope_rules_overlap(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn checkout_search(
|
async fn checkout_search(
|
||||||
@@ -258,7 +269,7 @@ impl WorkdirSession for RoutedWorkdirSession {
|
|||||||
request: crate::CheckoutSearchRequest,
|
request: crate::CheckoutSearchRequest,
|
||||||
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
|
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
let _active = self.state.enter()?;
|
||||||
self.state.session.checkout_search(request).await
|
self.inner().checkout_search(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn checkout_observe(
|
async fn checkout_observe(
|
||||||
@@ -266,59 +277,59 @@ impl WorkdirSession for RoutedWorkdirSession {
|
|||||||
path: crate::WorkdirPath,
|
path: crate::WorkdirPath,
|
||||||
) -> Result<crate::CheckoutObservation, WorkdirError> {
|
) -> Result<crate::CheckoutObservation, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
let _active = self.state.enter()?;
|
||||||
self.state.session.checkout_observe(path).await
|
self.inner().checkout_observe(path).await
|
||||||
}
|
}
|
||||||
async fn checkout_execute(
|
async fn checkout_execute(
|
||||||
&self,
|
&self,
|
||||||
request: crate::CheckoutRequest,
|
request: crate::CheckoutRequest,
|
||||||
) -> Result<crate::CheckoutResult, WorkdirError> {
|
) -> Result<crate::CheckoutResult, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
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> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
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> {
|
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
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> {
|
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
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> {
|
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
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> {
|
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
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> {
|
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
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> {
|
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
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> {
|
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
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> {
|
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
let _active = self.state.enter()?;
|
||||||
let handle = self.state.session.start_command(request).await?;
|
let handle = self.inner().start_command(request).await?;
|
||||||
self.state
|
self.state
|
||||||
.active_commands
|
.active_commands
|
||||||
.lock()
|
.lock()
|
||||||
@@ -329,7 +340,7 @@ impl WorkdirSession for RoutedWorkdirSession {
|
|||||||
|
|
||||||
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
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);
|
self.state.mark_terminal(&handle, status);
|
||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
@@ -340,7 +351,7 @@ impl WorkdirSession for RoutedWorkdirSession {
|
|||||||
) -> Result<CommandOutput, WorkdirError> {
|
) -> Result<CommandOutput, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
let _active = self.state.enter()?;
|
||||||
let handle = request.handle.clone();
|
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 {
|
if let Some(terminal) = &output.terminal {
|
||||||
self.state.mark_terminal(&handle, terminal.status);
|
self.state.mark_terminal(&handle, terminal.status);
|
||||||
}
|
}
|
||||||
@@ -349,7 +360,7 @@ impl WorkdirSession for RoutedWorkdirSession {
|
|||||||
|
|
||||||
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
let _active = self.state.enter()?;
|
||||||
self.state.session.cancel_command(handle.clone()).await?;
|
self.inner().cancel_command(handle.clone()).await?;
|
||||||
self.state
|
self.state
|
||||||
.active_commands
|
.active_commands
|
||||||
.lock()
|
.lock()
|
||||||
@@ -359,11 +370,11 @@ impl WorkdirSession for RoutedWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn subscribe_command_events(&self) -> Option<broadcast::Receiver<CommandEvent>> {
|
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> {
|
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);
|
self.state.reconcile_terminal_commands(&snapshots);
|
||||||
snapshots
|
snapshots
|
||||||
}
|
}
|
||||||
@@ -374,7 +385,7 @@ impl WorkdirSession for RoutedWorkdirSession {
|
|||||||
"Workdir attachment has active operations or commands".to_string(),
|
"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 {
|
pub struct WorkdirSessionRouter {
|
||||||
sessions: RwLock<BTreeMap<WorkdirAttachmentAlias, Arc<RoutedSessionState>>>,
|
sessions: RwLock<BTreeMap<WorkdirAttachmentAlias, Arc<RoutedSessionState>>>,
|
||||||
next_generation: AtomicU64,
|
next_generation: AtomicU64,
|
||||||
|
coordinate_brokers: RwLock<BTreeMap<WorkdirAttachmentAlias, (u64, crate::WorkdirToolBroker)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkdirSessionRouter {
|
impl WorkdirSessionRouter {
|
||||||
@@ -473,9 +485,9 @@ impl WorkdirSessionRouter {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(ResolvedWorkdirSession {
|
Ok(ResolvedWorkdirSession {
|
||||||
|
session: self.routed(&selected.0, selected.1.clone()),
|
||||||
alias: selected.0,
|
alias: selected.0,
|
||||||
generation: selected.1.generation,
|
generation: selected.1.generation,
|
||||||
session: Arc::new(RoutedWorkdirSession { state: selected.1 }),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,13 +495,22 @@ impl WorkdirSessionRouter {
|
|||||||
&self,
|
&self,
|
||||||
alias: WorkdirAttachmentAlias,
|
alias: WorkdirAttachmentAlias,
|
||||||
session: WorkdirSessionHandle,
|
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> {
|
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||||
let mut sessions = self.sessions.write().map_err(|_| {
|
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) {
|
if sessions.contains_key(&alias) {
|
||||||
return Err(WorkdirError::Conflict(format!(
|
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 {
|
let state = Arc::new(RoutedSessionState {
|
||||||
@@ -499,8 +520,61 @@ impl WorkdirSessionRouter {
|
|||||||
active_operations: AtomicUsize::new(0),
|
active_operations: AtomicUsize::new(0),
|
||||||
active_commands: Mutex::new(HashSet::new()),
|
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());
|
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> {
|
pub fn session(&self, alias: &WorkdirAttachmentAlias) -> Option<WorkdirSessionHandle> {
|
||||||
@@ -509,7 +583,7 @@ impl WorkdirSessionRouter {
|
|||||||
.ok()?
|
.ok()?
|
||||||
.get(alias)
|
.get(alias)
|
||||||
.cloned()
|
.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
|
/// Compatibility projection used only while filesystem tool schemas have no
|
||||||
@@ -521,10 +595,8 @@ impl WorkdirSessionRouter {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
sessions
|
sessions
|
||||||
.values()
|
.first_key_value()
|
||||||
.next()
|
.map(|(alias, state)| self.routed(alias, state.clone()) as WorkdirSessionHandle)
|
||||||
.cloned()
|
|
||||||
.map(|state| Arc::new(RoutedWorkdirSession { state }) as WorkdirSessionHandle)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_detach(&self, alias: &WorkdirAttachmentAlias) -> Result<(), WorkdirError> {
|
pub fn can_detach(&self, alias: &WorkdirAttachmentAlias) -> Result<(), WorkdirError> {
|
||||||
@@ -620,6 +692,10 @@ impl WorkdirSessionRouter {
|
|||||||
.is_some_and(|current| Arc::ptr_eq(current, &state))
|
.is_some_and(|current| Arc::ptr_eq(current, &state))
|
||||||
{
|
{
|
||||||
sessions.remove(alias);
|
sessions.remove(alias);
|
||||||
|
self.coordinate_brokers
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.remove(alias);
|
||||||
}
|
}
|
||||||
Ok(state.session.clone())
|
Ok(state.session.clone())
|
||||||
}
|
}
|
||||||
@@ -661,6 +737,10 @@ impl WorkdirSessionRouter {
|
|||||||
.is_some_and(|current| Arc::ptr_eq(current, &state))
|
.is_some_and(|current| Arc::ptr_eq(current, &state))
|
||||||
{
|
{
|
||||||
sessions.remove(&alias);
|
sessions.remove(&alias);
|
||||||
|
self.coordinate_brokers
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.remove(&alias);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
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]
|
#[tokio::test]
|
||||||
async fn checkout_routes_use_active_operation_detach_fence_and_old_handles_stay_closed() {
|
async fn checkout_routes_use_active_operation_detach_fence_and_old_handles_stay_closed() {
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -108,17 +108,34 @@ impl WorkdirToolBroker {
|
|||||||
Self::with_capabilities(source, capabilities)
|
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.
|
/// Own a session behind an additional Backend-authored capability ceiling.
|
||||||
/// The ceiling can only attenuate provider capabilities; it can never add one.
|
/// The ceiling can only attenuate provider capabilities; it can never add one.
|
||||||
pub fn with_capabilities(
|
pub fn with_capabilities(
|
||||||
source: WorkdirSessionHandle,
|
source: WorkdirSessionHandle,
|
||||||
capabilities: WorkdirSessionCapabilities,
|
capabilities: WorkdirSessionCapabilities,
|
||||||
|
) -> Self {
|
||||||
|
Self::with_capabilities_and_cwd(source, capabilities, FsPath::root())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_capabilities_and_cwd(
|
||||||
|
source: WorkdirSessionHandle,
|
||||||
|
capabilities: WorkdirSessionCapabilities,
|
||||||
|
cwd: FsPath,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let capabilities = source.capabilities().intersection(capabilities);
|
let capabilities = source.capabilities().intersection(capabilities);
|
||||||
let (command_events, _) = broadcast::channel(64);
|
let (command_events, _) = broadcast::channel(64);
|
||||||
let authority = Arc::new(ScopedWorkdirSession {
|
let authority = Arc::new(ScopedWorkdirSession {
|
||||||
source,
|
source,
|
||||||
cwd: FsPath::new("").expect("empty Workdir path is valid"),
|
cwd,
|
||||||
scope: None,
|
scope: None,
|
||||||
capabilities,
|
capabilities,
|
||||||
validity: SessionValidity::root(),
|
validity: SessionValidity::root(),
|
||||||
@@ -416,6 +433,9 @@ impl WorkdirToolBrokerRouter {
|
|||||||
.sessions
|
.sessions
|
||||||
.resolve(Some(alias.as_str()))
|
.resolve(Some(alias.as_str()))
|
||||||
.map_err(|error| WorkdirError::InvalidArgument(error.to_string()))?;
|
.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(|_| {
|
let mut brokers = self.brokers.lock().map_err(|_| {
|
||||||
WorkdirError::Unavailable("Workdir tool broker router is poisoned".to_string())
|
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> {
|
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());
|
return Ok(path.clone());
|
||||||
}
|
}
|
||||||
let joined = Path::new(self.cwd.as_str()).join(path.as_str());
|
let joined = Path::new(self.cwd.as_str()).join(path.as_str());
|
||||||
@@ -1085,6 +1105,43 @@ impl WorkdirSession for ScopedWorkdirSession {
|
|||||||
self.capabilities
|
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(
|
async fn authorize_scope_path(
|
||||||
&self,
|
&self,
|
||||||
mut request: WorkdirScopeAuthorizationRequest,
|
mut request: WorkdirScopeAuthorizationRequest,
|
||||||
@@ -1273,8 +1330,11 @@ impl WorkdirSession for ScopedWorkdirSession {
|
|||||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||||
.await?;
|
.await?;
|
||||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||||
|
let caller_path = request.path.clone();
|
||||||
request.path = path;
|
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> {
|
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)
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||||
.await?;
|
.await?;
|
||||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||||
|
let caller_path = request.path.clone();
|
||||||
request.path = path;
|
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(
|
async fn read_bytes(
|
||||||
@@ -1422,9 +1485,10 @@ impl WorkdirSession for ScopedWorkdirSession {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let tool_call_id = request.tool_call_id.clone();
|
let tool_call_id = request.tool_call_id.clone();
|
||||||
if self.scope.is_some() {
|
request.cwd = self
|
||||||
request.cwd = self.resolve_path(&request.cwd)?;
|
.resolve_operation_path(&request.cwd, WorkdirToolScopePermission::Read)
|
||||||
}
|
.await?;
|
||||||
|
self.ensure_path(&request.cwd, WorkdirToolScopePermission::Read)?;
|
||||||
if let Some(tool_call_id) = &tool_call_id {
|
if let Some(tool_call_id) = &tool_call_id {
|
||||||
self.starting_tool_calls
|
self.starting_tool_calls
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1601,7 +1665,20 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
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(
|
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) {
|
fn reply(listener: &TcpListener, status: &str, body: &str) {
|
||||||
listener.set_nonblocking(true).unwrap();
|
listener.set_nonblocking(true).unwrap();
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
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();
|
error["denial_reason"] = serde_json::to_value(reason).unwrap();
|
||||||
}
|
}
|
||||||
let server = thread::spawn(move || {
|
let server = thread::spawn(move || {
|
||||||
reply(
|
reply(&listener, "200 OK", &opened("w"));
|
||||||
&listener,
|
|
||||||
"200 OK",
|
|
||||||
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
|
|
||||||
);
|
|
||||||
reply(&listener, "403 Forbidden", &error.to_string());
|
reply(&listener, "403 Forbidden", &error.to_string());
|
||||||
});
|
});
|
||||||
let remote = RemoteWorkdirSession::open(
|
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 address = listener.local_addr().unwrap();
|
||||||
let error = serde_json::json!({"code":code, "message":"/secret/host provider-secret", "denial_reason":reason});
|
let error = serde_json::json!({"code":code, "message":"/secret/host provider-secret", "denial_reason":reason});
|
||||||
let server = thread::spawn(move || {
|
let server = thread::spawn(move || {
|
||||||
reply(
|
reply(&listener, "200 OK", &opened("w"));
|
||||||
&listener,
|
|
||||||
"200 OK",
|
|
||||||
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
|
|
||||||
);
|
|
||||||
reply(
|
reply(
|
||||||
&listener,
|
&listener,
|
||||||
&format!("{} Fixture failure", code.http_status()),
|
&format!("{} Fixture failure", code.http_status()),
|
||||||
@@ -230,11 +234,11 @@ async fn mismatched_http_results_keep_cause_and_mutation_uncertainty() {
|
|||||||
reply(
|
reply(
|
||||||
&listener,
|
&listener,
|
||||||
"200 OK",
|
"200 OK",
|
||||||
if case == "identity" {
|
&opened(if case == "identity" {
|
||||||
r#"{"session_id":"s","workdir_id":"wrong-secret","capabilities":{"bits":63}}"#
|
"wrong-secret"
|
||||||
} else {
|
} else {
|
||||||
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#
|
"w"
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
if case != "identity" {
|
if case != "identity" {
|
||||||
let result = if case == "bytes" {
|
let result = if case == "bytes" {
|
||||||
|
|||||||
@@ -869,6 +869,7 @@ async fn open_workdir_session(
|
|||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
workdir_id: session.workdir().id().clone(),
|
workdir_id: session.workdir().id().clone(),
|
||||||
capabilities: session.capabilities(),
|
capabilities: session.capabilities(),
|
||||||
|
environment: session.environment(),
|
||||||
};
|
};
|
||||||
state
|
state
|
||||||
.workdir_sessions
|
.workdir_sessions
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ impl Harness {
|
|||||||
session_id: WorkdirSessionId::new("session-transport").unwrap(),
|
session_id: WorkdirSessionId::new("session-transport").unwrap(),
|
||||||
workdir_id: Workdir::new("wd-transport").id().clone(),
|
workdir_id: Workdir::new("wd-transport").id().clone(),
|
||||||
capabilities: WorkdirSessionCapabilities::ALL,
|
capabilities: WorkdirSessionCapabilities::ALL,
|
||||||
|
environment: workdir::WorkdirSessionEnvironment::root(
|
||||||
|
WorkdirSessionCapabilities::ALL,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ impl WorkdirSession for RetainedWorkdirSession {
|
|||||||
self.inner.capabilities()
|
self.inner.capabilities()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn environment(&self) -> workdir::WorkdirSessionEnvironment {
|
||||||
|
self.inner.environment()
|
||||||
|
}
|
||||||
|
|
||||||
async fn authorize_scope_path(
|
async fn authorize_scope_path(
|
||||||
&self,
|
&self,
|
||||||
request: WorkdirScopeAuthorizationRequest,
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
|
|||||||
@@ -1127,8 +1127,17 @@ fn runtime_local_workdir_router(
|
|||||||
),
|
),
|
||||||
None => source,
|
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
|
router
|
||||||
.attach(alias.clone(), source)
|
.attach_at_cwd(alias.clone(), source, cwd)
|
||||||
.map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?;
|
.map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?;
|
||||||
}
|
}
|
||||||
Ok(router)
|
Ok(router)
|
||||||
@@ -10550,6 +10559,60 @@ mod tests {
|
|||||||
assert!(!binding.working_directory_root().exists());
|
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]
|
#[test]
|
||||||
fn spawn_failure_with_existing_working_directory_preserves_workdir() {
|
fn spawn_failure_with_existing_working_directory_preserves_workdir() {
|
||||||
let runtime_base = tempfile::tempdir().unwrap();
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -124,6 +124,18 @@ macro_rules! impl_deferred_session {
|
|||||||
self.capabilities
|
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> {
|
async fn $method(&self, $request: $request_type) -> Result<$result, WorkdirError> {
|
||||||
self.session().await?.$method($request).await
|
self.session().await?.$method($request).await
|
||||||
|
|||||||
@@ -206,26 +206,36 @@ impl AuthorizedWorkdirSession {
|
|||||||
.map(CheckoutSearchResult::Grep),
|
.map(CheckoutSearchResult::Grep),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
scoped
|
self.search_with_scope(scoped, root, rule, CheckoutSearchRequest::new(operation))
|
||||||
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
.await
|
||||||
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
|
|
||||||
})
|
})
|
||||||
.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> {
|
fn authorize_command(&self) -> Result<(), WorkdirError> {
|
||||||
// cwd is not a sandbox: arbitrary shell can access the entire attachment.
|
// cwd is not a sandbox: arbitrary shell can access the entire attachment.
|
||||||
self.authorize(COMMAND, &WorkdirPath::root(), true)
|
self.authorize(COMMAND, &WorkdirPath::root(), true)
|
||||||
@@ -261,6 +271,12 @@ impl WorkdirSession for AuthorizedWorkdirSession {
|
|||||||
self.session.capabilities()
|
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(
|
async fn authorize_scope_path(
|
||||||
&self,
|
&self,
|
||||||
mut request: WorkdirScopeAuthorizationRequest,
|
mut request: WorkdirScopeAuthorizationRequest,
|
||||||
@@ -296,8 +312,14 @@ impl WorkdirSession for AuthorizedWorkdirSession {
|
|||||||
&self,
|
&self,
|
||||||
request: CheckoutSearchRequest,
|
request: CheckoutSearchRequest,
|
||||||
) -> Result<CheckoutSearchResult, WorkdirError> {
|
) -> Result<CheckoutSearchResult, WorkdirError> {
|
||||||
self.require_legacy(READ)?;
|
let path = request.operation.path().clone();
|
||||||
self.session.checkout_search(request).await
|
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(
|
async fn checkout_observe(
|
||||||
@@ -490,6 +512,8 @@ mod tests {
|
|||||||
//! Component tests connect this adapter to the real LocalWorkdirSession.
|
//! Component tests connect this adapter to the real LocalWorkdirSession.
|
||||||
//! Only the Host authorization transport/policy is replaced; these are not E2E tests.
|
//! Only the Host authorization transport/policy is replaced; these are not E2E tests.
|
||||||
|
|
||||||
|
mod environment;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
use crate::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
||||||
use server_api::authorization::ScopedPermission;
|
use server_api::authorization::ScopedPermission;
|
||||||
@@ -842,18 +866,6 @@ mod tests {
|
|||||||
.await,
|
.await,
|
||||||
Err(WorkdirError::Denied(_))
|
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 {
|
let rule = WorkdirToolScopeRule {
|
||||||
target: WorkdirPath::root(),
|
target: WorkdirPath::root(),
|
||||||
permission: WorkdirToolScopePermission::Read,
|
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(),
|
session_id: WorkdirSessionId::new(SESSION).unwrap(),
|
||||||
workdir_id: state.session.workdir().id().clone(),
|
workdir_id: state.session.workdir().id().clone(),
|
||||||
capabilities: state.session.capabilities(),
|
capabilities: state.session.capabilities(),
|
||||||
|
environment: state.session.environment(),
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -251,6 +252,10 @@ impl Drop for HttpFixture {
|
|||||||
|
|
||||||
impl HttpFixture {
|
impl HttpFixture {
|
||||||
async fn new(capabilities: WorkdirSessionCapabilities) -> Self {
|
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();
|
let dir = TempDir::new().unwrap();
|
||||||
std::fs::create_dir_all(dir.path().join("src/deep")).unwrap();
|
std::fs::create_dir_all(dir.path().join("src/deep")).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
@@ -264,6 +269,13 @@ impl HttpFixture {
|
|||||||
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
|
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
|
||||||
capabilities,
|
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 {
|
let state = Arc::new(ProviderState {
|
||||||
session,
|
session,
|
||||||
requests: Mutex::new(Vec::new()),
|
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>) {
|
fn native_runtime(remote: Arc<RemoteWorkdirSession>) -> (WipRuntime, Arc<WorkdirSessionRouter>) {
|
||||||
let router = Arc::new(WorkdirSessionRouter::new());
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
router
|
router
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ impl WorkdirSession for HookedSession {
|
|||||||
self.local.capabilities()
|
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(
|
async fn authorize_scope_path(
|
||||||
&self,
|
&self,
|
||||||
request: WorkdirScopeAuthorizationRequest,
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
@@ -201,11 +207,10 @@ async fn checkout_wip_file_operations_share_core_and_keep_prior_read_boundary()
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
std::fs::write(dir.path().join("text.txt"), "旧 旧\n").unwrap();
|
std::fs::write(dir.path().join("text.txt"), "旧 旧\n").unwrap();
|
||||||
let router = Arc::new(WorkdirSessionRouter::new());
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
let hooked = HookedSession::new(&dir);
|
||||||
|
assert_eq!(hooked.environment(), hooked.local.environment());
|
||||||
router
|
router
|
||||||
.attach(
|
.attach(WorkdirAttachmentAlias::new("main").unwrap(), hooked)
|
||||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
|
||||||
HookedSession::new(&dir),
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let provider = provider(router);
|
let provider = provider(router);
|
||||||
let route = format!("{}/text.txt", checkout_root("main"));
|
let route = format!("{}/text.txt", checkout_root("main"));
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ pub struct WorkspaceAttachedWorkdirSession {
|
|||||||
target_workdir: String,
|
target_workdir: String,
|
||||||
workdir: Workdir,
|
workdir: Workdir,
|
||||||
capabilities: WorkdirSessionCapabilities,
|
capabilities: WorkdirSessionCapabilities,
|
||||||
|
environment: workdir::WorkdirSessionEnvironment,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceAttachedWorkdirSession {
|
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(
|
pub fn handle_for_workdir_with_capabilities(
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
target_workdir: impl Into<String>,
|
target_workdir: impl Into<String>,
|
||||||
workdir_id: impl Into<String>,
|
workdir_id: impl Into<String>,
|
||||||
capabilities: WorkdirSessionCapabilities,
|
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 {
|
) -> WorkdirSessionHandle {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
client,
|
client,
|
||||||
target_workdir: target_workdir.into(),
|
target_workdir: target_workdir.into(),
|
||||||
workdir: Workdir::new(workdir_id),
|
workdir: Workdir::new(workdir_id),
|
||||||
capabilities,
|
capabilities: capabilities.intersection(environment.capabilities),
|
||||||
|
environment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +384,12 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
|||||||
self.capabilities
|
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(
|
async fn authorize_scope_path(
|
||||||
&self,
|
&self,
|
||||||
request: WorkdirScopeAuthorizationRequest,
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
@@ -684,11 +712,12 @@ impl WorkspaceHttpWorkdirBackend {
|
|||||||
let response = self.attach_response(alias, working_directory_id)?;
|
let response = self.attach_response(alias, working_directory_id)?;
|
||||||
if let Err(error) = self.session_router.attach(
|
if let Err(error) = self.session_router.attach(
|
||||||
alias_key.clone(),
|
alias_key.clone(),
|
||||||
WorkspaceAttachedWorkdirSession::handle_for_workdir_with_capabilities(
|
WorkspaceAttachedWorkdirSession::handle_for_workdir_with_environment(
|
||||||
self.client.clone(),
|
self.client.clone(),
|
||||||
alias,
|
alias,
|
||||||
working_directory_id,
|
working_directory_id,
|
||||||
response.capabilities,
|
response.attachment.capabilities,
|
||||||
|
response.environment.clone(),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
let _ = self.detach(alias);
|
let _ = self.detach(alias);
|
||||||
@@ -706,9 +735,9 @@ impl WorkspaceHttpWorkdirBackend {
|
|||||||
&self,
|
&self,
|
||||||
alias: &str,
|
alias: &str,
|
||||||
working_directory_id: &str,
|
working_directory_id: &str,
|
||||||
) -> Result<WorkdirAttachmentResponse, ToolError> {
|
) -> Result<WorkdirSessionAttachmentResponse, ToolError> {
|
||||||
let workspace_id = encode_path_segment(self.workspace_id()?);
|
let workspace_id = encode_path_segment(self.workspace_id()?);
|
||||||
self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest::json(
|
self.execute_json::<WorkdirSessionAttachmentResponse>(WorkspaceRequest::json(
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
format!("/api/w/{workspace_id}/workers/self/workdir-attachments"),
|
format!("/api/w/{workspace_id}/workers/self/workdir-attachments"),
|
||||||
serde_json::to_string(&WorkdirAttachRequest {
|
serde_json::to_string(&WorkdirAttachRequest {
|
||||||
@@ -1192,6 +1221,13 @@ struct WorkdirAttachmentResponse {
|
|||||||
attached: bool,
|
attached: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
struct WorkdirSessionAttachmentResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
attachment: WorkdirAttachmentResponse,
|
||||||
|
environment: workdir::WorkdirSessionEnvironment,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct WorkdirDeleteInput {
|
struct WorkdirDeleteInput {
|
||||||
@@ -1502,6 +1538,7 @@ mod tests {
|
|||||||
"alias": "checkout",
|
"alias": "checkout",
|
||||||
"working_directory_id": "wd-created",
|
"working_directory_id": "wd-created",
|
||||||
"capabilities": {"bits": 63},
|
"capabilities": {"bits": 63},
|
||||||
|
"environment": workdir::WorkdirSessionEnvironment::root(WorkdirSessionCapabilities::ALL),
|
||||||
"attached": true
|
"attached": true
|
||||||
})),
|
})),
|
||||||
response(json!({
|
response(json!({
|
||||||
@@ -2004,6 +2041,7 @@ mod tests {
|
|||||||
"alias": "checkout",
|
"alias": "checkout",
|
||||||
"working_directory_id": "wd-attached",
|
"working_directory_id": "wd-attached",
|
||||||
"capabilities": {"bits": 63},
|
"capabilities": {"bits": 63},
|
||||||
|
"environment": workdir::WorkdirSessionEnvironment::root(WorkdirSessionCapabilities::ALL),
|
||||||
"attached": true
|
"attached": true
|
||||||
})),
|
})),
|
||||||
response(json!({
|
response(json!({
|
||||||
@@ -2063,16 +2101,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn attach_propagates_provider_read_only_capabilities_to_the_alias_session() {
|
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!({
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||||
"workspace_id": "workspace/test",
|
"workspace_id": "workspace/test",
|
||||||
"alias": "external",
|
"alias": "external",
|
||||||
"working_directory_id": "external-workdir",
|
"working_directory_id": "external-workdir",
|
||||||
"capabilities": {"bits": 25},
|
"capabilities": {"bits": 25},
|
||||||
|
"environment": environment,
|
||||||
"attached": true
|
"attached": true
|
||||||
}))]));
|
}))]));
|
||||||
let backend = WorkspaceHttpWorkdirBackend::new(client);
|
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
|
||||||
backend
|
backend
|
||||||
.attach(WorkdirAttachInput {
|
.attach(WorkdirAttachInput {
|
||||||
alias: "external".to_string(),
|
alias: "external".to_string(),
|
||||||
@@ -2084,10 +2135,33 @@ mod tests {
|
|||||||
.session_router
|
.session_router
|
||||||
.session(&workdir::WorkdirAttachmentAlias::new("external").unwrap())
|
.session(&workdir::WorkdirAttachmentAlias::new("external").unwrap())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
environment.capabilities = WorkdirSessionCapabilities::READ_ONLY;
|
||||||
|
assert_eq!(session.environment(), environment);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
session.capabilities(),
|
session.capabilities(),
|
||||||
WorkdirSessionCapabilities::READ_ONLY
|
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]
|
#[tokio::test]
|
||||||
@@ -2097,6 +2171,7 @@ mod tests {
|
|||||||
"alias": "checkout",
|
"alias": "checkout",
|
||||||
"working_directory_id": "wd-attached",
|
"working_directory_id": "wd-attached",
|
||||||
"capabilities": {"bits": 63},
|
"capabilities": {"bits": 63},
|
||||||
|
"environment": workdir::WorkdirSessionEnvironment::root(WorkdirSessionCapabilities::ALL),
|
||||||
"attached": true
|
"attached": true
|
||||||
}))]));
|
}))]));
|
||||||
let reopen_calls = Arc::new(AtomicUsize::new(0));
|
let reopen_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
|||||||
@@ -1253,7 +1253,7 @@ pub(crate) mod tests {
|
|||||||
state.generation += 1;
|
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}});
|
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);
|
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)
|
(WorkspaceRequestMethod::Delete, p)
|
||||||
if p.starts_with("workers/self/workdir-attachments/") =>
|
if p.starts_with("workers/self/workdir-attachments/") =>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
mod result;
|
mod result;
|
||||||
pub use result::{JobResultFeature, JobResultFinalizer, JobResultSink, SUBMIT_JOB_RESULT_TOOL};
|
pub use result::{JobResultFeature, JobResultFinalizer, JobResultSink, SUBMIT_JOB_RESULT_TOOL};
|
||||||
|
|
||||||
use std::borrow::Cow;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -154,7 +153,7 @@ fn prepare_job_from_registry_with_grant(
|
|||||||
)?;
|
)?;
|
||||||
let mut system_prompt = template.render(&SystemPromptContext {
|
let mut system_prompt = template.render(&SystemPromptContext {
|
||||||
now: chrono::Utc::now(),
|
now: chrono::Utc::now(),
|
||||||
cwd: Cow::Borrowed("(no filesystem)"),
|
workdir_sessions: &workdir::WorkdirSessionRouter::new(),
|
||||||
language: &manifest.engine.language,
|
language: &manifest.engine.language,
|
||||||
scope: &scope,
|
scope: &scope,
|
||||||
tool_names,
|
tool_names,
|
||||||
|
|||||||
@@ -7,13 +7,12 @@
|
|||||||
//! eagerly syntax-checks it at Worker construction. The final system
|
//! eagerly syntax-checks it at Worker construction. The final system
|
||||||
//! prompt is materialised exactly once just before the first LLM turn:
|
//! prompt is materialised exactly once just before the first LLM turn:
|
||||||
//! the rendered body is appended with a fixed trailing section carrying
|
//! the rendered body is appended with a fixed trailing section carrying
|
||||||
//! the Worker's `Scope` summary, (if present) the project's `AGENTS.md`
|
//! the Worker's `Scope` summary, actual Workdir session environments, (if
|
||||||
//! contents, resident memory sections, and conditional Worker-orchestration
|
//! present) the project's `AGENTS.md` contents, resident memory sections, and
|
||||||
//! guidance, then the whole string is handed to the Engine via
|
//! conditional Worker-orchestration guidance, then the whole string is handed
|
||||||
//! `set_system_prompt`. Subsequent turns and compactions reuse that
|
//! to the Engine via `set_system_prompt`. Subsequent turns and compactions reuse that
|
||||||
//! materialised string verbatim.
|
//! materialised string verbatim.
|
||||||
|
|
||||||
use std::borrow::Cow;
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -105,7 +104,8 @@ impl std::fmt::Debug for SystemPromptTemplate {
|
|||||||
/// templates cannot drop them on the floor.
|
/// templates cannot drop them on the floor.
|
||||||
pub struct SystemPromptContext<'a> {
|
pub struct SystemPromptContext<'a> {
|
||||||
pub now: DateTime<Utc>,
|
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 }}`.
|
/// Language policy exposed to instruction templates as `{{ language }}`.
|
||||||
pub language: &'a str,
|
pub language: &'a str,
|
||||||
pub scope: &'a Scope,
|
pub scope: &'a Scope,
|
||||||
@@ -140,7 +140,14 @@ impl<'a> SystemPromptContext<'a> {
|
|||||||
"datetime".into(),
|
"datetime".into(),
|
||||||
Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)),
|
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("language".into(), Value::from(self.language));
|
||||||
root.insert(
|
root.insert(
|
||||||
"tools".into(),
|
"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
|
/// Build the final system prompt by appending the fixed trailing
|
||||||
/// section to `body`. The Rust side owns the layout (blank-line
|
/// section to `body`. The Rust side owns the layout (blank-line
|
||||||
/// separators, trailing-whitespace trim); each section's header + body
|
/// separators, trailing-whitespace trim); each section's header + body
|
||||||
@@ -276,8 +347,12 @@ fn append_trailing_section(
|
|||||||
}
|
}
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
|
|
||||||
let boundaries = prompts.working_boundaries_section(&scope.summary())?;
|
if ctx.workdir_sessions.is_empty() {
|
||||||
out.push_str(boundaries.trim_end_matches(&['\n', ' '][..]));
|
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');
|
out.push('\n');
|
||||||
if let Some(agents) = agents_md {
|
if let Some(agents) = agents_md {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
@@ -392,7 +467,7 @@ mod tests {
|
|||||||
now: DateTime::parse_from_rfc3339("2026-10-08T00:00:00Z")
|
now: DateTime::parse_from_rfc3339("2026-10-08T00:00:00Z")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_timezone(&Utc),
|
.with_timezone(&Utc),
|
||||||
cwd: Cow::Borrowed("."),
|
workdir_sessions: &workdir::WorkdirSessionRouter::new(),
|
||||||
language: "en",
|
language: "en",
|
||||||
scope: &scope,
|
scope: &scope,
|
||||||
tool_names: metas.iter().map(|meta| meta.name.clone()).collect(),
|
tool_names: metas.iter().map(|meta| meta.name.clone()).collect(),
|
||||||
|
|||||||
@@ -1107,6 +1107,8 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
mod environment;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use manifest::{DelegationScope, Permission, Scope, SharedScope};
|
use manifest::{DelegationScope, Permission, Scope, SharedScope};
|
||||||
use std::pin::Pin;
|
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()?));
|
let prompts = Arc::new(ArcSwap::from(PromptCatalog::builtins_only()?));
|
||||||
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
||||||
let scope = SharedScope::new(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 {
|
let mut worker = Self {
|
||||||
backend_job: std::sync::OnceLock::new(),
|
backend_job: std::sync::OnceLock::new(),
|
||||||
manifest,
|
manifest,
|
||||||
@@ -4980,11 +4980,14 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
let Some(template) = self.system_prompt_template.as_ref() else {
|
let Some(template) = self.system_prompt_template.as_ref() else {
|
||||||
return Ok(());
|
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
|
self.engine
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.expect("worker present")
|
.expect("worker present")
|
||||||
.set_system_prompt(rendered.clone());
|
.set_system_prompt(format!("{body}\n\n{environment}"));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let alerter = self.alerter.clone();
|
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 worker_language = worker_language(&self.manifest.engine);
|
||||||
let scope_snapshot = self.scope.snapshot();
|
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 prompt_catalog = self.prompts.load_full();
|
||||||
let ctx = SystemPromptContext {
|
let ctx = SystemPromptContext {
|
||||||
now: chrono::Utc::now(),
|
now: chrono::Utc::now(),
|
||||||
cwd: cwd_for_prompt.into(),
|
workdir_sessions: &self.workdir_sessions,
|
||||||
language: worker_language,
|
language: worker_language,
|
||||||
scope: &scope_snapshot,
|
scope: &scope_snapshot,
|
||||||
tool_names,
|
tool_names,
|
||||||
@@ -8306,7 +8305,7 @@ where
|
|||||||
let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store));
|
let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store));
|
||||||
let scope = SharedScope::new(common.scope);
|
let scope = SharedScope::new(common.scope);
|
||||||
let workdir_sessions =
|
let workdir_sessions =
|
||||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
|
workdir_sessions_from_authority(&common.filesystem_authority, &scope)?;
|
||||||
|
|
||||||
let mut worker = Self {
|
let mut worker = Self {
|
||||||
backend_job: std::sync::OnceLock::new(),
|
backend_job: std::sync::OnceLock::new(),
|
||||||
@@ -8707,7 +8706,7 @@ where
|
|||||||
engine.set_cache_key(Some(segment_id.to_string()));
|
engine.set_cache_key(Some(segment_id.to_string()));
|
||||||
let scope = SharedScope::new(common.scope);
|
let scope = SharedScope::new(common.scope);
|
||||||
let workdir_sessions =
|
let workdir_sessions =
|
||||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
|
workdir_sessions_from_authority(&common.filesystem_authority, &scope)?;
|
||||||
let mut worker = Self {
|
let mut worker = Self {
|
||||||
backend_job: std::sync::OnceLock::new(),
|
backend_job: std::sync::OnceLock::new(),
|
||||||
manifest,
|
manifest,
|
||||||
@@ -9109,7 +9108,7 @@ where
|
|||||||
let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store));
|
let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store));
|
||||||
let scope = SharedScope::new(common.scope);
|
let scope = SharedScope::new(common.scope);
|
||||||
let workdir_sessions =
|
let workdir_sessions =
|
||||||
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
|
workdir_sessions_from_authority(&common.filesystem_authority, &scope)?;
|
||||||
|
|
||||||
let mut worker = Self {
|
let mut worker = Self {
|
||||||
backend_job: std::sync::OnceLock::new(),
|
backend_job: std::sync::OnceLock::new(),
|
||||||
@@ -10220,9 +10219,25 @@ pub enum WorkerError {
|
|||||||
fn workdir_sessions_from_authority(
|
fn workdir_sessions_from_authority(
|
||||||
authority: &WorkerFilesystemAuthority,
|
authority: &WorkerFilesystemAuthority,
|
||||||
scope: &SharedScope,
|
scope: &SharedScope,
|
||||||
) -> Arc<WorkdirSessionRouter> {
|
) -> Result<Arc<WorkdirSessionRouter>, WorkerError> {
|
||||||
let router = Arc::new(WorkdirSessionRouter::new());
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
if let Some(local) = authority.as_local() {
|
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(
|
let session = Arc::new(LocalWorkdirSession::materialized(
|
||||||
local.root.clone(),
|
local.root.clone(),
|
||||||
local.cwd.clone(),
|
local.cwd.clone(),
|
||||||
@@ -10230,13 +10245,14 @@ fn workdir_sessions_from_authority(
|
|||||||
WorkdirSessionCapabilities::ALL,
|
WorkdirSessionCapabilities::ALL,
|
||||||
)) as WorkdirSessionHandle;
|
)) as WorkdirSessionHandle;
|
||||||
router
|
router
|
||||||
.attach(
|
.attach_at_cwd(
|
||||||
WorkdirAttachmentAlias::new("workdir").expect("static alias is valid"),
|
WorkdirAttachmentAlias::new("workdir").expect("static alias is valid"),
|
||||||
session,
|
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:
|
/// 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")
|
.expect("system prompt materialised")
|
||||||
.to_string();
|
.to_string();
|
||||||
assert!(rendered.contains("date="));
|
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="));
|
assert!(rendered.starts_with("date="));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +207,8 @@ async fn session_start_state_captures_rendered_prompt() {
|
|||||||
LogEntry::AnnotatedSegmentStart { system_prompt, .. } => {
|
LogEntry::AnnotatedSegmentStart { system_prompt, .. } => {
|
||||||
let sp = system_prompt.as_deref().expect("system prompt set");
|
let sp = system_prompt.as_deref().expect("system prompt set");
|
||||||
assert!(sp.starts_with("hello"));
|
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:?}"),
|
other => panic!("expected AnnotatedSegmentStart as first entry, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8827,6 +8827,9 @@ mod tests {
|
|||||||
session_id: workdir::http::WorkdirSessionId::new("session-1").unwrap(),
|
session_id: workdir::http::WorkdirSessionId::new("session-1").unwrap(),
|
||||||
workdir_id: Workdir::new("wd-1").id().clone(),
|
workdir_id: Workdir::new("wd-1").id().clone(),
|
||||||
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||||
|
environment: workdir::WorkdirSessionEnvironment::root(
|
||||||
|
workdir::WorkdirSessionCapabilities::ALL,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
let stat = workdir::http::WorkdirSessionOperationResult::Stat(StatResult {
|
let stat = workdir::http::WorkdirSessionOperationResult::Stat(StatResult {
|
||||||
path: WorkdirPath::new("hello.txt").unwrap(),
|
path: WorkdirPath::new("hello.txt").unwrap(),
|
||||||
|
|||||||
@@ -20656,6 +20656,55 @@ async fn import_current_worker_workdir_observation(
|
|||||||
Ok(())
|
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(
|
async fn scoped_attach_current_worker_workdir(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
@@ -20782,17 +20831,17 @@ async fn scoped_attach_current_worker_workdir(
|
|||||||
}
|
}
|
||||||
let effective_capabilities = existing.capabilities.intersection(capabilities);
|
let effective_capabilities = existing.capabilities.intersection(capabilities);
|
||||||
if effective_capabilities != existing.capabilities {
|
if effective_capabilities != existing.capabilities {
|
||||||
let mut attenuated = existing;
|
let mut attenuated = existing.clone();
|
||||||
attenuated.capabilities = effective_capabilities;
|
attenuated.capabilities = effective_capabilities;
|
||||||
api.store.attach_worker_workdir(&attenuated)?;
|
api.store.attach_worker_workdir(&attenuated)?;
|
||||||
}
|
}
|
||||||
Ok(Some(effective_capabilities))
|
Ok(Some((existing, effective_capabilities)))
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.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?;
|
sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard).await?;
|
||||||
api.worker_projection
|
api.worker_projection
|
||||||
.refresh_async(&worker)
|
.refresh_async(&worker)
|
||||||
@@ -20803,6 +20852,17 @@ async fn scoped_attach_current_worker_workdir(
|
|||||||
alias: alias.to_string(),
|
alias: alias.to_string(),
|
||||||
working_directory_id: workdir_id.to_string(),
|
working_directory_id: workdir_id.to_string(),
|
||||||
capabilities: effective_capabilities,
|
capabilities: effective_capabilities,
|
||||||
|
environment: Some(
|
||||||
|
current_worker_attachment_environment_locked(
|
||||||
|
&api,
|
||||||
|
&worker,
|
||||||
|
&link,
|
||||||
|
effective_capabilities,
|
||||||
|
false,
|
||||||
|
&_session_guard,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
),
|
||||||
attached: true,
|
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;
|
detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard).await;
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
let stage_api = api.clone();
|
let environment = match current_worker_attachment_environment_locked(
|
||||||
let stage_worker = worker.clone();
|
&api,
|
||||||
let active_count = api
|
&worker,
|
||||||
.store
|
&link,
|
||||||
.db_executor()
|
capabilities,
|
||||||
.run(move || {
|
true,
|
||||||
active_filesystem_workdir_links(&stage_api, &stage_worker).map(|links| links.len())
|
&_session_guard,
|
||||||
})
|
)
|
||||||
.await?;
|
.await
|
||||||
if active_count == 1
|
|
||||||
&& let Err(error) =
|
|
||||||
open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await
|
|
||||||
{
|
{
|
||||||
let _ =
|
Ok(environment) => environment,
|
||||||
detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard).await;
|
Err(error) => {
|
||||||
let _ =
|
let _ = detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard)
|
||||||
sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard).await;
|
.await;
|
||||||
return Err(error.into());
|
let _ = sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard)
|
||||||
}
|
.await;
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Err(error) = api
|
if let Err(error) = api
|
||||||
.worker_projection
|
.worker_projection
|
||||||
.refresh_async(&worker)
|
.refresh_async(&worker)
|
||||||
@@ -20863,6 +20923,7 @@ async fn scoped_attach_current_worker_workdir(
|
|||||||
alias: alias.to_string(),
|
alias: alias.to_string(),
|
||||||
working_directory_id: workdir_id.to_string(),
|
working_directory_id: workdir_id.to_string(),
|
||||||
capabilities,
|
capabilities,
|
||||||
|
environment: Some(environment),
|
||||||
attached: true,
|
attached: true,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -21105,6 +21166,7 @@ async fn detach_current_worker_workdir(
|
|||||||
alias: alias.to_string(),
|
alias: alias.to_string(),
|
||||||
working_directory_id: link.workdir_id,
|
working_directory_id: link.workdir_id,
|
||||||
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
|
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
|
||||||
|
environment: None,
|
||||||
attached: false,
|
attached: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -60835,6 +60897,12 @@ mod tests {
|
|||||||
serde_json::from_slice(&attached_body).unwrap();
|
serde_json::from_slice(&attached_body).unwrap();
|
||||||
assert!(attached.attached);
|
assert!(attached.attached);
|
||||||
assert_eq!(attached.working_directory_id, fixture.docs_workdir_id);
|
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!(
|
assert_eq!(
|
||||||
api.store
|
api.store
|
||||||
.get_workdir_registry(TEST_WORKSPACE_ID, &fixture.docs_workdir_id)
|
.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(),
|
session_id: WorkdirSessionId::new("delegation-session").unwrap(),
|
||||||
workdir_id,
|
workdir_id,
|
||||||
capabilities: Caps::ALL,
|
capabilities: Caps::ALL,
|
||||||
|
environment: workdir::WorkdirSessionEnvironment::root(Caps::ALL),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user