feat: add external workdir command permissions
This commit is contained in:
Generated
+1
@@ -6181,6 +6181,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fs-operation",
|
||||
"libc",
|
||||
"manifest",
|
||||
"reqwest",
|
||||
"schemars",
|
||||
|
||||
@@ -5241,13 +5241,28 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExternalWorkdirPermission {
|
||||
#[default]
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExternalWorkdirPermissions {
|
||||
/// Read files and use read-only discovery operations such as Glob and Grep.
|
||||
pub read: bool,
|
||||
/// Create or update files with Write and Edit. Existing-file mutation also
|
||||
/// requires `read` so callers can satisfy the read-before-write contract.
|
||||
pub write: bool,
|
||||
/// Execute commands as the sharing CLI user on the provider host. This is
|
||||
/// explicit authority and does not imply either filesystem category.
|
||||
pub command: bool,
|
||||
}
|
||||
|
||||
impl Default for ExternalWorkdirPermissions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
read: true,
|
||||
write: false,
|
||||
command: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -5261,7 +5276,7 @@ pub struct ExternalWorkdirGrantCreateRequest {
|
||||
#[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub ttl_seconds: Option<u64>,
|
||||
pub permission: ExternalWorkdirPermission,
|
||||
pub permissions: ExternalWorkdirPermissions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -5274,7 +5289,7 @@ pub struct ExternalWorkdirGrantResponse {
|
||||
pub working_directory_id: String,
|
||||
pub provider_instance_id: String,
|
||||
pub display_name: String,
|
||||
pub permission: ExternalWorkdirPermission,
|
||||
pub permissions: ExternalWorkdirPermissions,
|
||||
/// `None` means the grant has no automatic expiry. It remains bounded by
|
||||
/// explicit revoke, provider disconnect, and provider shutdown cleanup.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -11,6 +11,7 @@ http-client = ["dep:reqwest"]
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
fs-operation.workspace = true
|
||||
libc.workspace = true
|
||||
manifest.workspace = true
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
|
||||
schemars.workspace = true
|
||||
|
||||
+102
-21
@@ -26,6 +26,17 @@ pub const MAX_EXTERNAL_RESULT_ITEMS: usize = 10_000;
|
||||
const MAX_EXTERNAL_GREP_CONTEXT: usize = 100;
|
||||
const MAX_EXTERNAL_SCOPE_RULES: usize = 64;
|
||||
pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = 512 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_TIMEOUT_SECS: u64 = 600;
|
||||
const MAX_EXTERNAL_COMMAND_CURSOR: usize = 1_000_000;
|
||||
|
||||
fn valid_command_handle(handle: &crate::CommandHandle) -> bool {
|
||||
!handle.0.is_empty()
|
||||
&& handle.0.len() <= MAX_PROTOCOL_ID_BYTES
|
||||
&& handle.0.trim() == handle.0
|
||||
&& !handle.0.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Fail-closed wire version for the External Workdir provider protocol.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
@@ -123,10 +134,9 @@ fn is_root_relative(path: &WorkdirPath) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate the filesystem-only subset shared by read-only and read-write
|
||||
/// External Workdirs. Capability enforcement remains a separate grant/session
|
||||
/// boundary; command operations never enter the provider protocol.
|
||||
fn validate_filesystem_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
|
||||
/// Validate the bounded operation subset available to External Workdirs.
|
||||
/// Capability enforcement remains a separate grant/session boundary.
|
||||
fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
|
||||
let path_is_valid = match operation {
|
||||
WorkdirSessionOperation::AuthorizeScope(request) => {
|
||||
is_root_relative(&request.path)
|
||||
@@ -182,17 +192,34 @@ fn validate_filesystem_operation(operation: &WorkdirSessionOperation) -> Result<
|
||||
.checked_add(request.new_string.len())
|
||||
.is_some_and(|size| size <= MAX_EXTERNAL_WRITE_BYTES)
|
||||
}
|
||||
WorkdirSessionOperation::CommandStart(_)
|
||||
| WorkdirSessionOperation::CommandStatus(_)
|
||||
| WorkdirSessionOperation::CommandOutput(_)
|
||||
| WorkdirSessionOperation::CommandCancel(_) => {
|
||||
return Err("command operations are not available to an External Workdir".to_string());
|
||||
WorkdirSessionOperation::CommandStart(request) => {
|
||||
!request.command.is_empty()
|
||||
&& request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES
|
||||
&& (1..=MAX_EXTERNAL_COMMAND_TIMEOUT_SECS).contains(&request.timeout_secs)
|
||||
&& (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.output_limit)
|
||||
&& is_root_relative(&request.cwd)
|
||||
&& request.spill_dir.is_none()
|
||||
&& request.tool_call_id.as_ref().is_none_or(|tool_call_id| {
|
||||
!tool_call_id.is_empty()
|
||||
&& tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES
|
||||
&& !tool_call_id.chars().any(char::is_control)
|
||||
})
|
||||
}
|
||||
WorkdirSessionOperation::CommandStatus(handle)
|
||||
| WorkdirSessionOperation::CommandCancel(handle) => valid_command_handle(handle),
|
||||
WorkdirSessionOperation::CommandOutput(request) => {
|
||||
valid_command_handle(&request.handle)
|
||||
&& request.cursor <= MAX_EXTERNAL_COMMAND_CURSOR
|
||||
&& (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.limit)
|
||||
}
|
||||
};
|
||||
if path_is_valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("External Workdir operation paths must be root-relative".to_string())
|
||||
Err(
|
||||
"External Workdir operation exceeds protocol bounds or uses an invalid path"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +242,7 @@ impl TryFrom<WorkdirSessionOperation> for ExternalWorkdirOperation {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(operation: WorkdirSessionOperation) -> Result<Self, Self::Error> {
|
||||
validate_filesystem_operation(&operation)?;
|
||||
validate_external_operation(&operation)?;
|
||||
Ok(Self(operation))
|
||||
}
|
||||
}
|
||||
@@ -239,7 +266,7 @@ fn result_paths_fit<'a>(paths: impl IntoIterator<Item = &'a WorkdirPath>) -> boo
|
||||
.is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES)
|
||||
}
|
||||
|
||||
fn validate_filesystem_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||
fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||
let result_is_valid = match result {
|
||||
WorkdirSessionOperationResult::AuthorizeScope
|
||||
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
|
||||
@@ -273,14 +300,16 @@ fn validate_filesystem_result(result: &WorkdirSessionOperationResult) -> Result<
|
||||
result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES
|
||||
&& result.replacements <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
}
|
||||
WorkdirSessionOperationResult::CommandStart(_)
|
||||
| WorkdirSessionOperationResult::CommandStatus(_)
|
||||
| WorkdirSessionOperationResult::CommandOutput(_)
|
||||
| WorkdirSessionOperationResult::CommandCancel => {
|
||||
return Err(
|
||||
"command operation results are not available to an External Workdir".to_string(),
|
||||
);
|
||||
WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle),
|
||||
WorkdirSessionOperationResult::CommandStatus(_) => true,
|
||||
WorkdirSessionOperationResult::CommandOutput(output) => {
|
||||
output.content.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES
|
||||
&& output
|
||||
.next_cursor
|
||||
.is_none_or(|cursor| cursor <= MAX_EXTERNAL_COMMAND_CURSOR)
|
||||
&& output.output_path.is_none()
|
||||
}
|
||||
WorkdirSessionOperationResult::CommandCancel => true,
|
||||
};
|
||||
if result_is_valid {
|
||||
Ok(())
|
||||
@@ -308,7 +337,7 @@ impl TryFrom<WorkdirSessionOperationResult> for ExternalWorkdirOperationResult {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(result: WorkdirSessionOperationResult) -> Result<Self, Self::Error> {
|
||||
validate_filesystem_result(&result)?;
|
||||
validate_external_result(&result)?;
|
||||
Ok(Self(result))
|
||||
}
|
||||
}
|
||||
@@ -544,7 +573,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filesystem_protocol_rejects_host_paths_and_commands_but_allows_bounded_mutations() {
|
||||
fn protocol_rejects_host_paths_and_unbounded_commands_but_allows_bounded_operations() {
|
||||
let absolute = WorkdirSessionOperation::Read(ReadRequest {
|
||||
path: WorkdirPath::new_scoped("/home/operator/private/session.log").unwrap(),
|
||||
offset: 0,
|
||||
@@ -563,6 +592,29 @@ mod tests {
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(command).is_err());
|
||||
|
||||
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||
command: "printf bounded".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 12 * 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: Some("tool-call-1".to_string()),
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(command).is_ok());
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart(
|
||||
CommandRequest {
|
||||
command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1),
|
||||
timeout_secs: 30,
|
||||
output_limit: 12 * 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
}
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::Write(WriteRequest {
|
||||
path: WorkdirPath::new("created.txt").unwrap(),
|
||||
@@ -589,6 +641,35 @@ mod tests {
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(
|
||||
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
||||
crate::CommandOutput {
|
||||
status: crate::CommandStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
content: "bounded".to_string(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
output_path: None,
|
||||
}
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
||||
crate::CommandOutput {
|
||||
status: crate::CommandStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
content: "bounded".to_string(),
|
||||
next_cursor: None,
|
||||
truncated: true,
|
||||
output_path: Some("/provider/private/output".into()),
|
||||
}
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,10 +2,10 @@ use crate::http::{
|
||||
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
|
||||
};
|
||||
use crate::{
|
||||
BoundedReadLimits, CommandRequest, EditRequest, ExternalWorkdirRoot, GlobRequest,
|
||||
GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir, WorkdirError,
|
||||
WorkdirPath, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability,
|
||||
WriteRequest,
|
||||
BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot,
|
||||
GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir,
|
||||
WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
|
||||
WorkdirSessionCapability, WriteRequest,
|
||||
};
|
||||
|
||||
fn external_session(root: &tempfile::TempDir, limits: BoundedReadLimits) -> LocalWorkdirSession {
|
||||
@@ -162,6 +162,73 @@ async fn external_local_provider_read_write_is_filesystem_only() {
|
||||
assert!(!root.path().join("command-ran").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_command_permission_is_explicit_and_independent_from_file_access() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join("item.txt"), "private").unwrap();
|
||||
let pinned = ExternalWorkdirRoot::pin(root.path()).unwrap();
|
||||
let session = LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
Workdir::new("external-workdir-command"),
|
||||
pinned,
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
WorkdirSessionCapabilities::COMMAND_ONLY,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
session.capabilities(),
|
||||
WorkdirSessionCapabilities::COMMAND_ONLY
|
||||
);
|
||||
assert!(matches!(
|
||||
session
|
||||
.read(ReadRequest {
|
||||
path: WorkdirPath::new("item.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
})
|
||||
.await,
|
||||
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Read))
|
||||
));
|
||||
|
||||
let handle = session
|
||||
.start_command(CommandRequest {
|
||||
command: "printf command-ok".to_string(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let output = session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.content, "command-ok");
|
||||
assert_eq!(output.exit_code, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_write_category_never_implicitly_enables_read() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let pinned = ExternalWorkdirRoot::pin(root.path()).unwrap();
|
||||
let error = LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
Workdir::new("external-workdir-invalid-write"),
|
||||
pinned,
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
WorkdirSessionCapabilities::WRITE_ONLY,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("WRITE requires READ"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_dispatcher_preserves_operation_result_pairing_and_capabilities() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -134,6 +134,16 @@ impl WorkdirSessionCapabilities {
|
||||
bits: Self::READ | Self::GLOB | Self::GREP,
|
||||
};
|
||||
|
||||
/// Operator-facing WRITE category without implicitly granting READ or COMMAND.
|
||||
pub const WRITE_ONLY: Self = Self {
|
||||
bits: Self::WRITE | Self::EDIT,
|
||||
};
|
||||
|
||||
/// Operator-facing COMMAND category without implicitly granting file access.
|
||||
pub const COMMAND_ONLY: Self = Self {
|
||||
bits: Self::COMMAND,
|
||||
};
|
||||
|
||||
/// Filesystem read/write authority without process execution.
|
||||
///
|
||||
/// External Workdirs use this capability set so operator-approved file
|
||||
@@ -391,3 +401,23 @@ impl From<fs_operation::FsError> for WorkdirError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod capability_tests {
|
||||
use super::*;
|
||||
|
||||
fn bits(capabilities: WorkdirSessionCapabilities) -> u8 {
|
||||
serde_json::to_value(capabilities).unwrap()["bits"]
|
||||
.as_u64()
|
||||
.unwrap() as u8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_categories_preserve_operation_bit_values() {
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::READ_ONLY), 25);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::WRITE_ONLY), 6);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::READ_WRITE), 31);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::COMMAND_ONLY), 32);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63);
|
||||
}
|
||||
}
|
||||
|
||||
+138
-8
@@ -11,6 +11,8 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt::Debug;
|
||||
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -762,6 +764,42 @@ impl LocalWorkdirSession {
|
||||
Self::external_read_write_pinned(workdir, root, read_limits)
|
||||
}
|
||||
|
||||
/// Construct an External provider session with an exact operator-approved
|
||||
/// READ/WRITE/COMMAND category set. WRITE requires READ so callers can
|
||||
/// satisfy the read-before-write contract; COMMAND remains independent.
|
||||
pub fn external_with_capabilities_pinned(
|
||||
workdir: Workdir,
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
let read = WorkdirSessionCapabilities::READ_ONLY;
|
||||
let read_write = WorkdirSessionCapabilities::READ_WRITE;
|
||||
let command = WorkdirSessionCapabilities::COMMAND_ONLY;
|
||||
if ![
|
||||
read,
|
||||
read_write,
|
||||
command,
|
||||
read.union(command),
|
||||
read_write.union(command),
|
||||
]
|
||||
.contains(&capabilities)
|
||||
{
|
||||
return Err(WorkdirError::InvalidArgument(
|
||||
"External Workdir capabilities must contain at least one of READ or COMMAND, and WRITE requires READ"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let permission = if capabilities.supports(WorkdirSessionCapability::Write) {
|
||||
Permission::Write
|
||||
} else {
|
||||
// Command execution still needs provider-internal read access to
|
||||
// validate and enter its cwd. This scope is not an exposed READ grant.
|
||||
Permission::Read
|
||||
};
|
||||
Self::external_pinned(workdir, pinned, read_limits, permission, capabilities)
|
||||
}
|
||||
|
||||
/// Construct a read-only provider session from a root pinned before remote
|
||||
/// grant creation.
|
||||
pub fn external_read_only_pinned(
|
||||
@@ -769,11 +807,10 @@ impl LocalWorkdirSession {
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
Self::external_pinned(
|
||||
Self::external_with_capabilities_pinned(
|
||||
workdir,
|
||||
pinned,
|
||||
read_limits,
|
||||
Permission::Read,
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
)
|
||||
}
|
||||
@@ -785,11 +822,10 @@ impl LocalWorkdirSession {
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
Self::external_pinned(
|
||||
Self::external_with_capabilities_pinned(
|
||||
workdir,
|
||||
pinned,
|
||||
read_limits,
|
||||
Permission::Write,
|
||||
WorkdirSessionCapabilities::READ_WRITE,
|
||||
)
|
||||
}
|
||||
@@ -1556,6 +1592,45 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CommandProcessGroupGuard {
|
||||
process_group_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl CommandProcessGroupGuard {
|
||||
fn new(process_group_id: Option<u32>) -> Self {
|
||||
Self { process_group_id }
|
||||
}
|
||||
|
||||
fn terminate(&mut self) {
|
||||
let Some(process_group_id) = self.process_group_id.take() else {
|
||||
return;
|
||||
};
|
||||
terminate_process_group(process_group_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CommandProcessGroupGuard {
|
||||
fn drop(&mut self) {
|
||||
self.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn terminate_process_group(process_group_id: u32) {
|
||||
let Ok(process_group_id) = i32::try_from(process_group_id) else {
|
||||
return;
|
||||
};
|
||||
// The command is spawned as its own process-group leader below. A negative
|
||||
// pid addresses that exact group, including descendants that outlive bash.
|
||||
unsafe {
|
||||
libc::kill(-process_group_id, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn terminate_process_group(_process_group_id: u32) {}
|
||||
|
||||
async fn run_command(
|
||||
cwd: PathBuf,
|
||||
request: CommandRequest,
|
||||
@@ -1574,7 +1649,8 @@ async fn run_command(
|
||||
.map_err(|error| WorkdirError::io(&stderr_path, error))?;
|
||||
|
||||
telemetry.started(&command_id, request.tool_call_id.clone());
|
||||
let mut child = match Command::new("bash")
|
||||
let mut command = Command::new("bash");
|
||||
command
|
||||
.arg("-c")
|
||||
.arg(&request.command)
|
||||
.current_dir(&cwd)
|
||||
@@ -1582,15 +1658,17 @@ async fn run_command(
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout_file))
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
{
|
||||
.kill_on_drop(true);
|
||||
#[cfg(unix)]
|
||||
command.as_std_mut().process_group(0);
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
telemetry.terminal(&command_id, CommandStatus::Failed, None);
|
||||
return Err(WorkdirError::io(&cwd, error));
|
||||
}
|
||||
};
|
||||
let mut process_group = CommandProcessGroupGuard::new(child.id());
|
||||
|
||||
let mut stdout_reader =
|
||||
std::fs::File::open(&stdout_path).map_err(|error| WorkdirError::io(&stdout_path, error))?;
|
||||
@@ -1609,18 +1687,21 @@ async fn run_command(
|
||||
tokio::select! {
|
||||
exit = child.wait() => {
|
||||
let exit = exit.map_err(|error| WorkdirError::io(&cwd, error))?;
|
||||
process_group.terminate();
|
||||
break (
|
||||
if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed },
|
||||
exit.code(),
|
||||
);
|
||||
}
|
||||
_ = &mut timeout => {
|
||||
process_group.terminate();
|
||||
let _ = child.start_kill();
|
||||
let exit_code = child.wait().await.ok().and_then(|status| status.code());
|
||||
break (CommandStatus::TimedOut, exit_code);
|
||||
}
|
||||
changed = cancel.changed() => {
|
||||
if changed.is_err() || *cancel.borrow() {
|
||||
process_group.terminate();
|
||||
let _ = child.start_kill();
|
||||
let exit_code = child.wait().await.ok().and_then(|status| status.code());
|
||||
break (CommandStatus::Cancelled, exit_code);
|
||||
@@ -3473,6 +3554,55 @@ mod tests {
|
||||
assert!(released.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn cancellation_kills_command_process_group_descendants() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let workdir = make_fs(&dir);
|
||||
let handle = WorkdirSession::start_command(
|
||||
&workdir,
|
||||
CommandRequest {
|
||||
command: "(sleep 0.4; echo leaked > child-marker) & echo ready > child-ready; wait"
|
||||
.into(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..100 {
|
||||
if dir.path().join("child-ready").exists() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
assert!(dir.path().join("child-ready").exists());
|
||||
|
||||
WorkdirSession::cancel_command(&workdir, handle.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let output = WorkdirSession::command_output(
|
||||
&workdir,
|
||||
CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.status, CommandStatus::Cancelled);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
assert!(
|
||||
!dir.path().join("child-marker").exists(),
|
||||
"cancelled command descendant survived its process group"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_cancels_active_command() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -697,7 +697,10 @@ CREATE TABLE external_workdir_grants (
|
||||
workdir_id TEXT NOT NULL,
|
||||
provider_instance_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL CHECK (permissions IN ('read_only', 'read_write')),
|
||||
permissions TEXT NOT NULL CHECK (permissions IN (
|
||||
'read_only', 'read_write', 'command_only',
|
||||
'read_command', 'read_write_command'
|
||||
)),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
|
||||
@@ -711,14 +711,40 @@ impl ExternalOperationCancelGuard {
|
||||
fn disarm(&mut self) {
|
||||
self.operation_id = None;
|
||||
}
|
||||
|
||||
fn cancel(&mut self) {
|
||||
let Some(operation_id) = self.operation_id.take() else {
|
||||
return;
|
||||
};
|
||||
enqueue_external_operation_cancel(self.sender.clone(), operation_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ExternalOperationCancelGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(operation_id) = self.operation_id.take() {
|
||||
let _ = self
|
||||
.sender
|
||||
.try_send(ExternalProviderCommand::Cancel { operation_id });
|
||||
self.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_external_operation_cancel(
|
||||
sender: tokio::sync::mpsc::Sender<ExternalProviderCommand>,
|
||||
operation_id: ExternalWorkdirOperationId,
|
||||
) {
|
||||
let command = ExternalProviderCommand::Cancel { operation_id };
|
||||
match sender.try_send(command) {
|
||||
Ok(()) | Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {}
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Full(command)) => {
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
runtime.spawn(async move {
|
||||
// Never delay the failed operation's return on a saturated provider
|
||||
// queue, but give the provider a bounded opportunity to accept the
|
||||
// cancellation as it drains already queued messages.
|
||||
let _ =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), sender.send(command))
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -792,6 +818,8 @@ struct ExternalProviderWorkdirSession {
|
||||
connection: Arc<ExternalProviderConnection>,
|
||||
audit: Option<ExternalProviderAuditContext>,
|
||||
closed: std::sync::atomic::AtomicBool,
|
||||
close_lock: tokio::sync::Mutex<()>,
|
||||
active_commands: tokio::sync::Mutex<HashMap<String, workdir::CommandHandle>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ExternalProviderWorkdirSession {
|
||||
@@ -815,6 +843,8 @@ impl ExternalProviderWorkdirSession {
|
||||
connection,
|
||||
audit,
|
||||
closed: std::sync::atomic::AtomicBool::new(false),
|
||||
close_lock: tokio::sync::Mutex::new(()),
|
||||
active_commands: tokio::sync::Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,6 +888,7 @@ impl ExternalProviderWorkdirSession {
|
||||
operation: WorkdirSessionOperation,
|
||||
) -> std::result::Result<WorkdirSessionOperationResult, workdir::WorkdirError> {
|
||||
let operation_kind = external_workdir_operation_kind(&operation);
|
||||
let operation_timeout = external_workdir_operation_timeout(&operation);
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(workdir::WorkdirError::SessionClosed);
|
||||
}
|
||||
@@ -914,7 +945,7 @@ impl ExternalProviderWorkdirSession {
|
||||
"External Workdir provider disconnected".to_string(),
|
||||
));
|
||||
}
|
||||
let result = match tokio::time::timeout(std::time::Duration::from_secs(30), receive).await {
|
||||
let result = match tokio::time::timeout(operation_timeout, receive).await {
|
||||
Ok(result) => {
|
||||
cancel_guard.disarm();
|
||||
match result {
|
||||
@@ -925,14 +956,7 @@ impl ExternalProviderWorkdirSession {
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if let Some(cancel_operation_id) = cancel_guard.operation_id.take() {
|
||||
let _ = cancel_guard
|
||||
.sender
|
||||
.send(ExternalProviderCommand::Cancel {
|
||||
operation_id: cancel_operation_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
cancel_guard.cancel();
|
||||
Err(workdir::WorkdirError::Unavailable(
|
||||
"External Workdir provider operation timed out".to_string(),
|
||||
))
|
||||
@@ -950,6 +974,17 @@ impl ExternalProviderWorkdirSession {
|
||||
result
|
||||
}
|
||||
|
||||
fn ensure_capability(
|
||||
&self,
|
||||
capability: workdir::WorkdirSessionCapability,
|
||||
) -> std::result::Result<(), workdir::WorkdirError> {
|
||||
if self.connection.capabilities.supports(capability) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(workdir::WorkdirError::Unsupported(capability))
|
||||
}
|
||||
}
|
||||
|
||||
fn mismatch(expected: &str) -> workdir::WorkdirError {
|
||||
workdir::WorkdirError::Unavailable(format!(
|
||||
"External Workdir provider returned a mismatched result; expected {expected}"
|
||||
@@ -957,6 +992,17 @@ impl ExternalProviderWorkdirSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn external_workdir_operation_timeout(operation: &WorkdirSessionOperation) -> std::time::Duration {
|
||||
if matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandOutput(request) if request.wait
|
||||
) {
|
||||
std::time::Duration::from_secs(workdir::external::MAX_EXTERNAL_COMMAND_TIMEOUT_SECS + 10)
|
||||
} else {
|
||||
std::time::Duration::from_secs(30)
|
||||
}
|
||||
}
|
||||
|
||||
fn external_workdir_operation_kind(operation: &WorkdirSessionOperation) -> &'static str {
|
||||
match operation {
|
||||
WorkdirSessionOperation::AuthorizeScope(_) => "authorize_scope",
|
||||
@@ -988,6 +1034,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::WorkdirScopeAuthorizationRequest,
|
||||
) -> std::result::Result<(), workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::AuthorizeScope(request))
|
||||
.await?
|
||||
@@ -1000,6 +1047,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::WorkdirScopeOverlapRequest,
|
||||
) -> std::result::Result<bool, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::ScopeRulesOverlap(request))
|
||||
.await?
|
||||
@@ -1012,6 +1060,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::StatRequest,
|
||||
) -> std::result::Result<workdir::StatResult, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
||||
WorkdirSessionOperationResult::Stat(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("stat")),
|
||||
@@ -1021,6 +1070,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::ReadRequest,
|
||||
) -> std::result::Result<workdir::ReadResult, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||
match self.operate(WorkdirSessionOperation::Read(request)).await? {
|
||||
WorkdirSessionOperationResult::Read(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("read")),
|
||||
@@ -1030,15 +1080,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::WriteRequest,
|
||||
) -> std::result::Result<workdir::WriteResult, workdir::WorkdirError> {
|
||||
if !self
|
||||
.connection
|
||||
.capabilities
|
||||
.supports(workdir::WorkdirSessionCapability::Write)
|
||||
{
|
||||
return Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Write,
|
||||
));
|
||||
}
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Write)?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::Write(request))
|
||||
.await?
|
||||
@@ -1051,15 +1093,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::EditRequest,
|
||||
) -> std::result::Result<workdir::EditResult, workdir::WorkdirError> {
|
||||
if !self
|
||||
.connection
|
||||
.capabilities
|
||||
.supports(workdir::WorkdirSessionCapability::Edit)
|
||||
{
|
||||
return Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Edit,
|
||||
));
|
||||
}
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Edit)?;
|
||||
match self.operate(WorkdirSessionOperation::Edit(request)).await? {
|
||||
WorkdirSessionOperationResult::Edit(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("edit")),
|
||||
@@ -1069,6 +1103,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::ListRequest,
|
||||
) -> std::result::Result<workdir::ListResult, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||
match self.operate(WorkdirSessionOperation::List(request)).await? {
|
||||
WorkdirSessionOperationResult::List(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("list")),
|
||||
@@ -1078,6 +1113,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::GlobRequest,
|
||||
) -> std::result::Result<workdir::GlobResult, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Glob)?;
|
||||
match self.operate(WorkdirSessionOperation::Glob(request)).await? {
|
||||
WorkdirSessionOperationResult::Glob(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("glob")),
|
||||
@@ -1087,6 +1123,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::GrepRequest,
|
||||
) -> std::result::Result<workdir::GrepResult, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Grep)?;
|
||||
match self.operate(WorkdirSessionOperation::Grep(request)).await? {
|
||||
WorkdirSessionOperationResult::Grep(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("grep")),
|
||||
@@ -1094,37 +1131,107 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
}
|
||||
async fn start_command(
|
||||
&self,
|
||||
_request: workdir::CommandRequest,
|
||||
mut request: workdir::CommandRequest,
|
||||
) -> std::result::Result<workdir::CommandHandle, workdir::WorkdirError> {
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Command,
|
||||
))
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||
let _close_guard = self.close_lock.lock().await;
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(workdir::WorkdirError::SessionClosed);
|
||||
}
|
||||
// Bash supplies a Worker-host spill directory. It is neither meaningful
|
||||
// nor safe to send to a client-hosted provider, so External commands use
|
||||
// bounded inline output and report truncation without a provider path.
|
||||
request.spill_dir = None;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandStart(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandStart(handle) => {
|
||||
self.active_commands
|
||||
.lock()
|
||||
.await
|
||||
.insert(handle.0.clone(), handle.clone());
|
||||
Ok(handle)
|
||||
}
|
||||
_ => Err(Self::mismatch("command_start")),
|
||||
}
|
||||
}
|
||||
async fn command_status(
|
||||
&self,
|
||||
_handle: workdir::CommandHandle,
|
||||
handle: workdir::CommandHandle,
|
||||
) -> std::result::Result<workdir::CommandStatus, workdir::WorkdirError> {
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Command,
|
||||
))
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandStatus(handle))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandStatus(status) => Ok(status),
|
||||
_ => Err(Self::mismatch("command_status")),
|
||||
}
|
||||
}
|
||||
async fn command_output(
|
||||
&self,
|
||||
_request: workdir::CommandOutputRequest,
|
||||
request: workdir::CommandOutputRequest,
|
||||
) -> std::result::Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Command,
|
||||
))
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||
let command_id = request.handle.0.clone();
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandOutput(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandOutput(output) => {
|
||||
if output.status != workdir::CommandStatus::Running && output.next_cursor.is_none()
|
||||
{
|
||||
self.active_commands.lock().await.remove(&command_id);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
_ => Err(Self::mismatch("command_output")),
|
||||
}
|
||||
}
|
||||
async fn cancel_command(
|
||||
&self,
|
||||
_handle: workdir::CommandHandle,
|
||||
handle: workdir::CommandHandle,
|
||||
) -> std::result::Result<(), workdir::WorkdirError> {
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Command,
|
||||
))
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandCancel(handle))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandCancel => Ok(()),
|
||||
_ => Err(Self::mismatch("command_cancel")),
|
||||
}
|
||||
}
|
||||
async fn close(&self) -> std::result::Result<(), workdir::WorkdirError> {
|
||||
let _close_guard = self.close_lock.lock().await;
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
let commands = self
|
||||
.active_commands
|
||||
.lock()
|
||||
.await
|
||||
.drain()
|
||||
.map(|(_, handle)| handle)
|
||||
.collect::<Vec<_>>();
|
||||
for handle in commands {
|
||||
// Detach/session replacement is terminal authority for commands
|
||||
// started through this exact provider generation. Request cancel,
|
||||
// then wait for provider-owned process reaping before closing.
|
||||
let _ = self
|
||||
.operate(WorkdirSessionOperation::CommandCancel(handle.clone()))
|
||||
.await;
|
||||
let _ = self
|
||||
.operate(WorkdirSessionOperation::CommandOutput(
|
||||
workdir::CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: workdir::external::MAX_EXTERNAL_COMMAND_OUTPUT_BYTES,
|
||||
wait: true,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
}
|
||||
self.closed.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
@@ -3674,16 +3781,75 @@ struct ValidatedTicketImplementationTarget {
|
||||
capabilities: workdir::WorkdirSessionCapabilities,
|
||||
}
|
||||
|
||||
fn external_workdir_capabilities(permissions: &str) -> Result<workdir::WorkdirSessionCapabilities> {
|
||||
match permissions {
|
||||
"read_only" => Ok(workdir::WorkdirSessionCapabilities::READ_ONLY),
|
||||
"read_write" => Ok(workdir::WorkdirSessionCapabilities::READ_WRITE),
|
||||
_ => Err(Error::Store(
|
||||
fn external_workdir_permissions(persisted: &str) -> Result<server_api::ExternalWorkdirPermissions> {
|
||||
let permissions = match persisted {
|
||||
"read_only" => server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: false,
|
||||
command: false,
|
||||
},
|
||||
"read_write" => server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: true,
|
||||
command: false,
|
||||
},
|
||||
"command_only" => server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: true,
|
||||
},
|
||||
"read_command" => server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: false,
|
||||
command: true,
|
||||
},
|
||||
"read_write_command" => server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: true,
|
||||
command: true,
|
||||
},
|
||||
_ => {
|
||||
return Err(Error::Store(
|
||||
"External Workdir grant has invalid persisted permissions".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(permissions)
|
||||
}
|
||||
|
||||
fn persist_external_workdir_permissions(
|
||||
permissions: server_api::ExternalWorkdirPermissions,
|
||||
) -> Result<&'static str> {
|
||||
match (permissions.read, permissions.write, permissions.command) {
|
||||
(true, false, false) => Ok("read_only"),
|
||||
(true, true, false) => Ok("read_write"),
|
||||
(false, false, true) => Ok("command_only"),
|
||||
(true, false, true) => Ok("read_command"),
|
||||
(true, true, true) => Ok("read_write_command"),
|
||||
(false, true, _) => Err(Error::InvalidInput(
|
||||
"External Workdir WRITE permission requires READ for read-before-write".to_string(),
|
||||
)),
|
||||
(false, false, false) => Err(Error::InvalidInput(
|
||||
"External Workdir sharing requires at least one of READ or COMMAND".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn external_workdir_capabilities(permissions: &str) -> Result<workdir::WorkdirSessionCapabilities> {
|
||||
let permissions = external_workdir_permissions(permissions)?;
|
||||
let mut capabilities = workdir::WorkdirSessionCapabilities::EMPTY;
|
||||
if permissions.read {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::READ_ONLY);
|
||||
}
|
||||
if permissions.write {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::WRITE_ONLY);
|
||||
}
|
||||
if permissions.command {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY);
|
||||
}
|
||||
Ok(capabilities)
|
||||
}
|
||||
|
||||
fn workdir_source_capabilities(
|
||||
api: &WorkspaceApi,
|
||||
workdir: &WorkdirRegistryRecord,
|
||||
@@ -17037,10 +17203,7 @@ async fn scoped_create_external_workdir_grant(
|
||||
.ttl_seconds
|
||||
.map(|ttl_seconds| now + Duration::seconds(ttl_seconds as i64))
|
||||
.map(|expires_at| expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true));
|
||||
let permissions = match request.permission {
|
||||
server_api::ExternalWorkdirPermission::ReadOnly => "read_only",
|
||||
server_api::ExternalWorkdirPermission::ReadWrite => "read_write",
|
||||
};
|
||||
let permissions = persist_external_workdir_permissions(request.permissions)?;
|
||||
let grant_id = format!("ewg-{}", Uuid::now_v7());
|
||||
let workdir_id = format!("external-{}", Uuid::now_v7());
|
||||
let grant = ExternalWorkdirGrantRecord {
|
||||
@@ -17080,27 +17243,24 @@ async fn scoped_create_external_workdir_grant(
|
||||
schedule_external_workdir_expiry(&api, &grant)?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(external_workdir_grant_response(&grant)),
|
||||
Json(external_workdir_grant_response(&grant)?),
|
||||
))
|
||||
}
|
||||
|
||||
fn external_workdir_grant_response(
|
||||
grant: &ExternalWorkdirGrantRecord,
|
||||
) -> ExternalWorkdirGrantResponse {
|
||||
ExternalWorkdirGrantResponse {
|
||||
) -> Result<ExternalWorkdirGrantResponse> {
|
||||
Ok(ExternalWorkdirGrantResponse {
|
||||
grant_id: grant.grant_id.clone(),
|
||||
workspace_id: grant.workspace_id.clone(),
|
||||
working_directory_id: grant.workdir_id.clone(),
|
||||
provider_instance_id: grant.provider_instance_id.clone(),
|
||||
display_name: grant.display_name.clone(),
|
||||
permission: match grant.permissions.as_str() {
|
||||
"read_write" => server_api::ExternalWorkdirPermission::ReadWrite,
|
||||
_ => server_api::ExternalWorkdirPermission::ReadOnly,
|
||||
},
|
||||
permissions: external_workdir_permissions(&grant.permissions)?,
|
||||
expires_at: grant.expires_at.clone(),
|
||||
generation: grant.generation,
|
||||
status: grant.status.clone(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn scoped_external_workdir_provider_ws(
|
||||
@@ -17501,7 +17661,7 @@ async fn scoped_get_external_workdir_grant(
|
||||
cleanup_external_workdir_attachments(&api, &grant.workdir_id).await?;
|
||||
cancel_external_workdir_expiry(&api, &grant_id);
|
||||
}
|
||||
Ok(Json(external_workdir_grant_response(&grant)))
|
||||
Ok(Json(external_workdir_grant_response(&grant)?))
|
||||
}
|
||||
|
||||
async fn scoped_revoke_external_workdir_grant(
|
||||
@@ -17565,7 +17725,7 @@ async fn scoped_revoke_external_workdir_grant(
|
||||
// have committed its fence before a Runtime/session projection failure.
|
||||
cleanup_external_workdir_attachments(&api, &grant.workdir_id).await?;
|
||||
cancel_external_workdir_expiry(&api, &grant_id);
|
||||
Ok(Json(external_workdir_grant_response(&grant)))
|
||||
Ok(Json(external_workdir_grant_response(&grant)?))
|
||||
}
|
||||
|
||||
async fn cleanup_external_workdir_attachments(
|
||||
@@ -26068,6 +26228,39 @@ mod tests {
|
||||
WorkspaceRuntimeBinding,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn external_permission_categories_map_to_existing_capability_bits_without_escalation() {
|
||||
assert_eq!(
|
||||
external_workdir_capabilities("read_only").unwrap(),
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
assert_eq!(
|
||||
external_workdir_capabilities("read_write").unwrap(),
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE
|
||||
);
|
||||
assert_eq!(
|
||||
external_workdir_capabilities("command_only").unwrap(),
|
||||
workdir::WorkdirSessionCapabilities::COMMAND_ONLY
|
||||
);
|
||||
assert_eq!(
|
||||
external_workdir_capabilities("read_command").unwrap(),
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY)
|
||||
);
|
||||
assert_eq!(
|
||||
external_workdir_capabilities("read_write_command").unwrap(),
|
||||
workdir::WorkdirSessionCapabilities::ALL
|
||||
);
|
||||
assert!(
|
||||
persist_external_workdir_permissions(server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: true,
|
||||
command: false,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_projection_does_not_expose_unavailable_snapshot_as_current() {
|
||||
let availability = protocol::subscription::SubscriptionWorkerAvailability::Unavailable;
|
||||
@@ -26262,6 +26455,156 @@ mod tests {
|
||||
assert!(commands.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_operation_cancel_retries_without_blocking_a_saturated_provider_queue() {
|
||||
let (sender, mut commands) = tokio::sync::mpsc::channel(1);
|
||||
let queued_id = ExternalWorkdirOperationId::new("op-queued").unwrap();
|
||||
sender
|
||||
.send(ExternalProviderCommand::Cancel {
|
||||
operation_id: queued_id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let retried_id = ExternalWorkdirOperationId::new("op-retried").unwrap();
|
||||
enqueue_external_operation_cancel(sender, retried_id.clone());
|
||||
|
||||
let Some(ExternalProviderCommand::Cancel { operation_id }) = commands.recv().await else {
|
||||
panic!("expected pre-existing queued cancellation");
|
||||
};
|
||||
assert_eq!(operation_id, queued_id);
|
||||
let retried = tokio::time::timeout(std::time::Duration::from_secs(1), commands.recv())
|
||||
.await
|
||||
.expect("cancellation retry should not be lost");
|
||||
let Some(ExternalProviderCommand::Cancel { operation_id }) = retried else {
|
||||
panic!("expected retried cancellation");
|
||||
};
|
||||
assert_eq!(operation_id, retried_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_provider_command_only_session_denies_read_and_routes_bounded_lifecycle() {
|
||||
let (sender, mut commands) = tokio::sync::mpsc::channel(4);
|
||||
let capabilities = workdir::WorkdirSessionCapabilities::COMMAND_ONLY;
|
||||
let connection = Arc::new(ExternalProviderConnection {
|
||||
grant_id: "grant-command".to_string(),
|
||||
workdir_id: "external-command".to_string(),
|
||||
provider_instance_id: "provider-command".to_string(),
|
||||
generation: 8,
|
||||
expires_at: None,
|
||||
capabilities,
|
||||
admission: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||
sender,
|
||||
});
|
||||
let session = Arc::new(ExternalProviderWorkdirSession::new(connection, None));
|
||||
let denied = session
|
||||
.read(workdir::ReadRequest {
|
||||
path: workdir::WorkdirPath::new("secret.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
max_bytes: 1,
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(
|
||||
denied,
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Read
|
||||
))
|
||||
));
|
||||
assert!(commands.try_recv().is_err());
|
||||
|
||||
let starter = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
session
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "printf routed".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: Some("/worker/private/spill".into()),
|
||||
tool_call_id: Some("call-1".to_string()),
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let Some(ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
}) = commands.recv().await
|
||||
else {
|
||||
panic!("expected command start operation");
|
||||
};
|
||||
assert!(matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandStart(workdir::CommandRequest {
|
||||
spill_dir: None,
|
||||
..
|
||||
})
|
||||
));
|
||||
let handle = workdir::CommandHandle("command-1".to_string());
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandStart(
|
||||
handle.clone(),
|
||||
)))
|
||||
.unwrap();
|
||||
assert_eq!(starter.await.unwrap().unwrap(), handle);
|
||||
|
||||
let closer = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move { session.close().await })
|
||||
};
|
||||
let Some(ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
}) = commands.recv().await
|
||||
else {
|
||||
panic!("expected command cancel during close");
|
||||
};
|
||||
assert!(matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandCancel(ref current) if current == &handle
|
||||
));
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandCancel))
|
||||
.unwrap();
|
||||
let Some(ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
}) = commands.recv().await
|
||||
else {
|
||||
panic!("expected terminal command output during close");
|
||||
};
|
||||
assert!(matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandOutput(workdir::CommandOutputRequest {
|
||||
wait: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(
|
||||
workdir::CommandOutput {
|
||||
status: workdir::CommandStatus::Cancelled,
|
||||
exit_code: None,
|
||||
timed_out: false,
|
||||
content: String::new(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
output_path: None,
|
||||
},
|
||||
)))
|
||||
.unwrap();
|
||||
closer.await.unwrap().unwrap();
|
||||
assert!(matches!(
|
||||
session.command_status(handle).await,
|
||||
Err(workdir::WorkdirError::SessionClosed)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_expiry_fences_offline_grant_and_persists_cleanup_completion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -26696,7 +27039,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_provider_websocket_reconnects_with_fresh_generation_and_revokes() {
|
||||
async fn external_provider_websocket_routes_files_and_commands_then_reconnects_and_revokes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let api = test_api(dir.path()).await;
|
||||
@@ -26726,7 +27069,11 @@ mod tests {
|
||||
provider_instance_id: "provider-a".to_string(),
|
||||
display_name: "Session logs".to_string(),
|
||||
ttl_seconds: Some(600),
|
||||
permission: server_api::ExternalWorkdirPermission::ReadWrite,
|
||||
permissions: server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: true,
|
||||
command: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -26755,7 +27102,7 @@ mod tests {
|
||||
.id()
|
||||
.clone(),
|
||||
generation,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_WRITE,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||
read_limits: workdir::BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
},
|
||||
})
|
||||
@@ -26867,14 +27214,16 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
external_capabilities,
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE
|
||||
workdir::WorkdirSessionCapabilities::ALL
|
||||
);
|
||||
let provider_root = tempfile::tempdir().unwrap();
|
||||
fs::write(provider_root.path().join("events.jsonl"), b"one\ntwo\n").unwrap();
|
||||
let local_session = workdir::LocalWorkdirSession::external_read_write(
|
||||
let pinned_provider_root = workdir::ExternalWorkdirRoot::pin(provider_root.path()).unwrap();
|
||||
let local_session = workdir::LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
workdir::Workdir::new(&grant.working_directory_id),
|
||||
provider_root.path(),
|
||||
pinned_provider_root,
|
||||
workdir::BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
workdir::WorkdirSessionCapabilities::ALL,
|
||||
)
|
||||
.unwrap();
|
||||
let session_lock = current_worker_session_lock(&api, &worker);
|
||||
@@ -26885,7 +27234,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
broker_session.capabilities(),
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE
|
||||
workdir::WorkdirSessionCapabilities::ALL
|
||||
);
|
||||
drop(session_guard);
|
||||
let read = {
|
||||
@@ -27052,26 +27401,120 @@ mod tests {
|
||||
fs::read_to_string(provider_root.path().join("created.txt")).unwrap(),
|
||||
"created"
|
||||
);
|
||||
let command_error = broker_session
|
||||
let command_start = {
|
||||
let broker_session = broker_session.clone();
|
||||
tokio::spawn(async move {
|
||||
broker_session
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "touch forbidden".to_string(),
|
||||
timeout_secs: 1,
|
||||
command: "printf command-ok".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
spill_dir: Some("/worker/private/spill".into()),
|
||||
tool_call_id: Some("call-e2e".to_string()),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
command_error,
|
||||
workdir::WorkdirError::Unsupported(workdir::WorkdirSessionCapability::Command)
|
||||
| workdir::WorkdirError::UnsupportedOperation(_)
|
||||
| workdir::WorkdirError::Denied(_)
|
||||
),
|
||||
"unexpected command error: {command_error:?}"
|
||||
);
|
||||
})
|
||||
};
|
||||
let (operation_id, operation) = loop {
|
||||
let text = second.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if let ExternalWorkdirServerMessage::Operation {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
operation,
|
||||
} = frame.message
|
||||
{
|
||||
break (operation_id, operation);
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
operation.as_inner(),
|
||||
WorkdirSessionOperation::CommandStart(workdir::CommandRequest {
|
||||
spill_dir: None,
|
||||
..
|
||||
})
|
||||
));
|
||||
let result = workdir::http::dispatch_workdir_session_operation(
|
||||
&local_session,
|
||||
operation.into_inner(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
outcome: ExternalWorkdirOperationOutcome::Completed { result },
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let command_handle = command_start.await.unwrap().unwrap();
|
||||
|
||||
let command_output = {
|
||||
let broker_session = broker_session.clone();
|
||||
let handle = command_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
broker_session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let (operation_id, operation) = loop {
|
||||
let text = second.next().await.unwrap().unwrap().into_text().unwrap();
|
||||
let frame = serde_json::from_str::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if let ExternalWorkdirServerMessage::Operation {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
operation,
|
||||
} = frame.message
|
||||
{
|
||||
break (operation_id, operation);
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
operation.as_inner(),
|
||||
WorkdirSessionOperation::CommandOutput(_)
|
||||
));
|
||||
let result = workdir::http::dispatch_workdir_session_operation(
|
||||
&local_session,
|
||||
operation.into_inner(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
outcome: ExternalWorkdirOperationOutcome::Completed { result },
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let command_output = command_output.await.unwrap().unwrap();
|
||||
assert_eq!(command_output.content, "command-ok");
|
||||
assert_eq!(command_output.exit_code, Some(0));
|
||||
assert_eq!(command_output.output_path, None);
|
||||
|
||||
let Json(revoked) = scoped_revoke_external_workdir_grant(
|
||||
State(api.clone()),
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 67;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 68;
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||
@@ -50,6 +50,8 @@ const TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME: &str =
|
||||
"Ticket target collection and Workdir attachment capabilities";
|
||||
const EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME: &str =
|
||||
"External Workdir read-write access and optional expiry";
|
||||
const EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME: &str =
|
||||
"External Workdir explicit command permission";
|
||||
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
||||
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
||||
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
||||
@@ -182,6 +184,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME,
|
||||
apply: migrate_external_workdir_access_and_optional_expiry_v66_to_v67,
|
||||
},
|
||||
Migration {
|
||||
version: 68,
|
||||
name: EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME,
|
||||
apply: migrate_external_workdir_command_permission_v67_to_v68,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -12098,6 +12105,90 @@ fn migrate_external_workdir_access_and_optional_expiry_v66_to_v67(conn: &Connect
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_external_workdir_command_permission_v67_to_v68(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if current != 67 {
|
||||
return Err(Error::Store(format!(
|
||||
"expected schema version 67 before {EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME} migration, found {current}"
|
||||
)));
|
||||
}
|
||||
let foreign_keys_enabled =
|
||||
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0;
|
||||
if foreign_keys_enabled {
|
||||
conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
|
||||
}
|
||||
let result = (|| {
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE external_workdir_grants_v68 (
|
||||
grant_id TEXT NOT NULL,
|
||||
workspace_id TEXT NOT NULL,
|
||||
workdir_id TEXT NOT NULL,
|
||||
provider_instance_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL CHECK (permissions IN (
|
||||
'read_only', 'read_write', 'command_only',
|
||||
'read_command', 'read_write_command'
|
||||
)),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
generation INTEGER NOT NULL CHECK (generation > 0),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')),
|
||||
updated_at TEXT NOT NULL,
|
||||
cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed')),
|
||||
cleanup_error TEXT,
|
||||
cleanup_updated_at TEXT,
|
||||
PRIMARY KEY (workspace_id, grant_id),
|
||||
UNIQUE (workspace_id, workdir_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, workdir_id)
|
||||
REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
INSERT INTO external_workdir_grants_v68 (
|
||||
grant_id, workspace_id, workdir_id, provider_instance_id, display_name,
|
||||
permissions, created_by, created_at, expires_at, generation, status, updated_at,
|
||||
cleanup_state, cleanup_error, cleanup_updated_at
|
||||
) SELECT grant_id, workspace_id, workdir_id, provider_instance_id, display_name,
|
||||
permissions, created_by, created_at, expires_at, generation, status, updated_at,
|
||||
cleanup_state, cleanup_error, cleanup_updated_at
|
||||
FROM external_workdir_grants;
|
||||
DROP TABLE external_workdir_grants;
|
||||
ALTER TABLE external_workdir_grants_v68 RENAME TO external_workdir_grants;
|
||||
CREATE INDEX idx_external_workdir_grants_status_expiry
|
||||
ON external_workdir_grants(workspace_id, status, expires_at);
|
||||
"#,
|
||||
)?;
|
||||
let violations =
|
||||
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})?;
|
||||
if violations != 0 {
|
||||
return Err(Error::Store(format!(
|
||||
"schema-68 migration left {violations} foreign-key violation(s)"
|
||||
)));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![68_i64, EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
let restore = if foreign_keys_enabled {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
match (result, restore) {
|
||||
(Err(error), _) => Err(error),
|
||||
(Ok(()), Err(error)) => Err(error),
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
||||
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
||||
@@ -14233,6 +14324,10 @@ mod tests {
|
||||
version: 67,
|
||||
name: EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 68,
|
||||
name: EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -14295,6 +14390,10 @@ mod tests {
|
||||
67,
|
||||
EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
(
|
||||
68,
|
||||
EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -14348,6 +14447,68 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v68_preserves_legacy_external_grants_without_command_authority() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("server.db");
|
||||
prepare_schema_v50(&path, Some("workspace-a"));
|
||||
let mut conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
for migration in MIGRATIONS
|
||||
.iter()
|
||||
.filter(|migration| migration.version <= 67)
|
||||
{
|
||||
(migration.apply)(&conn).unwrap();
|
||||
}
|
||||
let tx = conn.transaction().unwrap();
|
||||
tx.execute_batch(
|
||||
"INSERT INTO external_workdir_grants (
|
||||
grant_id, workspace_id, workdir_id, provider_instance_id, display_name,
|
||||
permissions, created_by, created_at, expires_at, generation, status, updated_at
|
||||
) VALUES
|
||||
('grant-ro', 'workspace-a', 'external-ro', 'provider-ro', 'Read only',
|
||||
'read_only', 'owner', '1', NULL, 1, 'pending', '1'),
|
||||
('grant-rw', 'workspace-a', 'external-rw', 'provider-rw', 'Read write',
|
||||
'read_write', 'owner', '1', NULL, 1, 'pending', '1');
|
||||
INSERT INTO workdir_registry (
|
||||
workspace_id, workdir_id, display_name, source_kind, runtime_id,
|
||||
repository_id, external_grant_id, creation_selector, creation_ref,
|
||||
materialization_status, cleanliness, created_at, updated_at
|
||||
) VALUES
|
||||
('workspace-a', 'external-ro', 'Read only', 'external_grant', NULL,
|
||||
NULL, 'grant-ro', NULL, NULL, 'pending', 'unknown', '1', '1'),
|
||||
('workspace-a', 'external-rw', 'Read write', 'external_grant', NULL,
|
||||
NULL, 'grant-rw', NULL, NULL, 'pending', 'unknown', '1', '1');",
|
||||
)
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
|
||||
migrate_external_workdir_command_permission_v67_to_v68(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 68);
|
||||
let permissions = conn
|
||||
.prepare("SELECT permissions FROM external_workdir_grants ORDER BY grant_id")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
assert_eq!(permissions, vec!["read_only", "read_write"]);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE external_workdir_grants SET permissions = 'read_command' WHERE grant_id = 'grant-ro'",
|
||||
[],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE external_workdir_grants SET permissions = 'write_only' WHERE grant_id = 'grant-rw'",
|
||||
[],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_resumes_from_a_valid_partially_applied_chain() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -14366,7 +14527,7 @@ mod tests {
|
||||
.map(|migration| migration.version)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68
|
||||
]
|
||||
);
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
@@ -14375,7 +14536,7 @@ mod tests {
|
||||
current_schema_version(&conn).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 18);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 19);
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||
|
||||
+107
-11
@@ -623,6 +623,7 @@ fn parse_workdir_args(
|
||||
let mut ttl = None;
|
||||
let mut read_only = false;
|
||||
let mut read_write = false;
|
||||
let mut explicit_permissions: Option<server_api::ExternalWorkdirPermissions> = None;
|
||||
let mut non_interactive = false;
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
@@ -666,6 +667,32 @@ fn parse_workdir_args(
|
||||
);
|
||||
index += 2;
|
||||
}
|
||||
"--permission" => {
|
||||
let category = required_option_value(args, index, "--permission")?;
|
||||
let permissions =
|
||||
explicit_permissions.get_or_insert(server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: false,
|
||||
});
|
||||
let enabled = match category.to_ascii_lowercase().as_str() {
|
||||
"read" => &mut permissions.read,
|
||||
"write" => &mut permissions.write,
|
||||
"command" => &mut permissions.command,
|
||||
_ => {
|
||||
return Err(ParseError(format!(
|
||||
"unknown --permission category `{category}`; expected read, write, or command"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if *enabled {
|
||||
return Err(ParseError(format!(
|
||||
"--permission {category} may only be provided once"
|
||||
)));
|
||||
}
|
||||
*enabled = true;
|
||||
index += 2;
|
||||
}
|
||||
"--read-only" => {
|
||||
if read_only {
|
||||
return Err(ParseError(
|
||||
@@ -715,6 +742,21 @@ fn parse_workdir_args(
|
||||
"--read-only conflicts with --read-write".to_string(),
|
||||
));
|
||||
}
|
||||
if explicit_permissions.is_some() && (read_only || read_write) {
|
||||
return Err(ParseError(
|
||||
"--permission conflicts with legacy --read-only/--read-write".to_string(),
|
||||
));
|
||||
}
|
||||
let permissions = explicit_permissions.unwrap_or(server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: read_write,
|
||||
command: false,
|
||||
});
|
||||
if permissions.write && !permissions.read {
|
||||
return Err(ParseError(
|
||||
"--permission write requires --permission read for read-before-write".to_string(),
|
||||
));
|
||||
}
|
||||
if non_interactive && workspace_id.is_none() {
|
||||
return Err(ParseError(
|
||||
"--non-interactive requires --workspace-id".to_string(),
|
||||
@@ -728,11 +770,7 @@ fn parse_workdir_args(
|
||||
backend_url,
|
||||
display_name: display_name.unwrap_or_else(|| "External Workdir".to_string()),
|
||||
ttl,
|
||||
permission: if read_write {
|
||||
server_api::ExternalWorkdirPermission::ReadWrite
|
||||
} else {
|
||||
server_api::ExternalWorkdirPermission::ReadOnly
|
||||
},
|
||||
permissions,
|
||||
non_interactive,
|
||||
}))
|
||||
}
|
||||
@@ -1862,7 +1900,7 @@ Usage:
|
||||
yoi --backend <URL> [--workspace-id <ID>] panel
|
||||
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
|
||||
yoi [--backend <URL>] login [--no-wait]
|
||||
yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive]
|
||||
yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--permission <read|write|command>]... [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive]
|
||||
yoi <HOST_COMMAND> [OPTIONS]
|
||||
|
||||
Target selection:
|
||||
@@ -1908,7 +1946,7 @@ Options:
|
||||
|
||||
fn print_workdir_help() {
|
||||
println!(
|
||||
"yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, permission, and TTL, then explicitly confirm before any grant is created.\nRead-only is the default permission. --read-write enables the existing filesystem Write/Edit operations only; Bash, command, and exec remain disabled.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id <ID> Preselect a Workspace; required with --non-interactive\n --read-only Explicitly select the default read-only permission\n --read-write Allow filesystem Write/Edit operations (never commands)\n --ttl <TTL> Automatic expiry; omission is unlimited\n --display-name <NAME> Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n"
|
||||
"yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, READ/WRITE/COMMAND categories, and TTL, then explicitly confirm before any grant is created.\nREAD enables Read/Glob/Grep and related observation operations. WRITE enables Write/Edit and requires READ for read-before-write. COMMAND is independent, disabled by default, and runs with the sharing CLI user's host authority; it is not sandboxed.\nRepeat --permission to choose explicit categories in automation. Legacy --read-only and --read-write remain file-only aliases and conflict with --permission.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id <ID> Preselect a Workspace; required with --non-interactive\n --permission <CATEGORY> Enable read, write, or command; repeat per category\n --read-only Legacy alias for --permission read\n --read-write Legacy alias for read + write (commands remain disabled)\n --ttl <TTL> Automatic expiry; omission is unlimited\n --display-name <NAME> Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1989,12 +2027,43 @@ mod tests {
|
||||
assert_eq!(options.display_name, "session-analysis");
|
||||
assert_eq!(options.ttl, Some(Duration::from_secs(5_400)));
|
||||
assert_eq!(
|
||||
options.permission,
|
||||
server_api::ExternalWorkdirPermission::ReadWrite
|
||||
options.permissions,
|
||||
server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: true,
|
||||
command: false,
|
||||
}
|
||||
);
|
||||
assert!(options.non_interactive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_share_parses_independent_command_permission() {
|
||||
let args = vec![
|
||||
"share".to_string(),
|
||||
".".to_string(),
|
||||
"--permission".to_string(),
|
||||
"command".to_string(),
|
||||
"--non-interactive".to_string(),
|
||||
];
|
||||
let target = TargetSelection {
|
||||
backend_url: Some("https://backend.example".to_string()),
|
||||
workspace_id: Some("workspace-a".to_string()),
|
||||
..TargetSelection::default()
|
||||
};
|
||||
let Mode::WorkdirShare(options) = parse_workdir_args(&args, &target).unwrap() else {
|
||||
panic!("expected Workdir share mode");
|
||||
};
|
||||
assert_eq!(
|
||||
options.permissions,
|
||||
server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_share_defaults_to_interactive_read_only_and_unlimited() {
|
||||
let args = vec!["share".to_string(), ".".to_string()];
|
||||
@@ -2009,8 +2078,8 @@ mod tests {
|
||||
assert_eq!(options.display_name, "External Workdir");
|
||||
assert_eq!(options.ttl, None);
|
||||
assert_eq!(
|
||||
options.permission,
|
||||
server_api::ExternalWorkdirPermission::ReadOnly
|
||||
options.permissions,
|
||||
server_api::ExternalWorkdirPermissions::default()
|
||||
);
|
||||
assert!(!options.non_interactive);
|
||||
}
|
||||
@@ -2048,6 +2117,33 @@ mod tests {
|
||||
.contains("conflicts")
|
||||
);
|
||||
|
||||
let legacy_conflict = vec![
|
||||
"share".to_string(),
|
||||
".".to_string(),
|
||||
"--read-write".to_string(),
|
||||
"--permission".to_string(),
|
||||
"command".to_string(),
|
||||
];
|
||||
assert!(
|
||||
parse_workdir_args(&legacy_conflict, &target)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("conflicts")
|
||||
);
|
||||
|
||||
let write_without_read = vec![
|
||||
"share".to_string(),
|
||||
".".to_string(),
|
||||
"--permission".to_string(),
|
||||
"write".to_string(),
|
||||
];
|
||||
assert!(
|
||||
parse_workdir_args(&write_without_read, &target)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("requires --permission read")
|
||||
);
|
||||
|
||||
let missing_workspace = TargetSelection {
|
||||
backend_url: Some("https://backend.example".to_string()),
|
||||
..TargetSelection::default()
|
||||
|
||||
+188
-67
@@ -30,12 +30,23 @@ pub(crate) struct WorkdirShareOptions {
|
||||
pub backend_url: String,
|
||||
pub display_name: String,
|
||||
pub ttl: Option<Duration>,
|
||||
pub permission: server_api::ExternalWorkdirPermission,
|
||||
pub permissions: server_api::ExternalWorkdirPermissions,
|
||||
pub non_interactive: bool,
|
||||
}
|
||||
|
||||
const PROVIDER_OPERATION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation) -> Duration {
|
||||
if matches!(
|
||||
operation,
|
||||
workdir::http::WorkdirSessionOperation::CommandOutput(request) if request.wait
|
||||
) {
|
||||
Duration::from_secs(workdir::external::MAX_EXTERNAL_COMMAND_TIMEOUT_SECS + 10)
|
||||
} else {
|
||||
PROVIDER_OPERATION_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProviderEnd {
|
||||
Disconnected,
|
||||
@@ -128,13 +139,47 @@ fn authenticated_server_api_client(
|
||||
Ok(ServerApiClient::with_client(base_url, http_client))
|
||||
}
|
||||
|
||||
fn permission_label(permission: server_api::ExternalWorkdirPermission) -> &'static str {
|
||||
match permission {
|
||||
server_api::ExternalWorkdirPermission::ReadOnly => "read-only",
|
||||
server_api::ExternalWorkdirPermission::ReadWrite => {
|
||||
"read-write (files only; commands disabled)"
|
||||
fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> String {
|
||||
let mut enabled = Vec::new();
|
||||
if permissions.read {
|
||||
enabled.push("READ");
|
||||
}
|
||||
if permissions.write {
|
||||
enabled.push("WRITE");
|
||||
}
|
||||
if permissions.command {
|
||||
enabled.push("COMMAND");
|
||||
}
|
||||
enabled.join(", ")
|
||||
}
|
||||
|
||||
fn validate_permissions(permissions: server_api::ExternalWorkdirPermissions) -> Result<(), String> {
|
||||
if permissions.write && !permissions.read {
|
||||
return Err(
|
||||
"WRITE requires READ so existing files can satisfy read-before-write; select both categories"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if !permissions.read && !permissions.command {
|
||||
return Err("select at least one of READ or COMMAND".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workdir_capabilities(
|
||||
permissions: server_api::ExternalWorkdirPermissions,
|
||||
) -> workdir::WorkdirSessionCapabilities {
|
||||
let mut capabilities = workdir::WorkdirSessionCapabilities::EMPTY;
|
||||
if permissions.read {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::READ_ONLY);
|
||||
}
|
||||
if permissions.write {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::WRITE_ONLY);
|
||||
}
|
||||
if permissions.command {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY);
|
||||
}
|
||||
capabilities
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -144,7 +189,7 @@ struct ResolvedWorkdirShareOptions {
|
||||
backend_url: String,
|
||||
display_name: String,
|
||||
ttl: Option<Duration>,
|
||||
permission: server_api::ExternalWorkdirPermission,
|
||||
permissions: server_api::ExternalWorkdirPermissions,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_ttl(value: &str) -> Result<Duration, String> {
|
||||
@@ -254,30 +299,49 @@ fn configure_interactively(
|
||||
};
|
||||
options.workspace_id = Some(workspaces[selected_index].workspace_id.clone());
|
||||
|
||||
let current_permission = match options.permission {
|
||||
server_api::ExternalWorkdirPermission::ReadOnly => "r",
|
||||
server_api::ExternalWorkdirPermission::ReadWrite => "w",
|
||||
};
|
||||
writeln!(
|
||||
output,
|
||||
"Permission: read-only or read-write files. Read-write never enables Bash/command/exec."
|
||||
"Permissions are independent categories: READ (Read/Glob/Grep), WRITE (Write/Edit), and COMMAND (host command lifecycle). WRITE requires READ for read-before-write. COMMAND runs with this CLI user's host authority and is not sandboxed."
|
||||
)
|
||||
.map_err(|error| format!("failed to write prompt: {error}"))?;
|
||||
let Some(permission) = read_prompt_line(
|
||||
let Some(permission_input) = read_prompt_line(
|
||||
input,
|
||||
output,
|
||||
&format!("Permission [r/w; current {current_permission}; q to cancel]: "),
|
||||
&format!(
|
||||
"Permissions [comma-separated READ/WRITE/COMMAND; current {}; q to cancel]: ",
|
||||
permissions_label(options.permissions)
|
||||
),
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
options.permission = match permission.as_str() {
|
||||
"" => options.permission,
|
||||
"r" | "read-only" | "read_only" => server_api::ExternalWorkdirPermission::ReadOnly,
|
||||
"w" | "read-write" | "read_write" => server_api::ExternalWorkdirPermission::ReadWrite,
|
||||
"q" | "quit" | "cancel" => return Ok(None),
|
||||
_ => return Err("Permission must be read-only (`r`) or read-write (`w`)".to_string()),
|
||||
if matches!(permission_input.as_str(), "q" | "quit" | "cancel") {
|
||||
return Ok(None);
|
||||
}
|
||||
if !permission_input.is_empty() {
|
||||
let mut permissions = server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: false,
|
||||
};
|
||||
for category in permission_input
|
||||
.split([',', ' '])
|
||||
.filter(|category| !category.is_empty())
|
||||
{
|
||||
match category.to_ascii_lowercase().as_str() {
|
||||
"r" | "read" | "read-only" | "read_only" => permissions.read = true,
|
||||
"w" | "write" | "read-write" | "read_write" => permissions.write = true,
|
||||
"c" | "command" => permissions.command = true,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"unknown permission category `{category}`; use READ, WRITE, or COMMAND"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
validate_permissions(permissions)?;
|
||||
options.permissions = permissions;
|
||||
}
|
||||
|
||||
writeln!(output, "TTL: {}", ttl_label(options.ttl))
|
||||
.map_err(|error| format!("failed to write prompt: {error}"))?;
|
||||
@@ -305,8 +369,8 @@ fn configure_interactively(
|
||||
.and_then(|()| {
|
||||
writeln!(
|
||||
output,
|
||||
" Permission: {}",
|
||||
permission_label(options.permission)
|
||||
" Permissions: {}",
|
||||
permissions_label(options.permissions)
|
||||
)
|
||||
})
|
||||
.and_then(|()| writeln!(output, " TTL: {}", ttl_label(options.ttl)))
|
||||
@@ -324,7 +388,7 @@ fn configure_interactively(
|
||||
backend_url: options.backend_url,
|
||||
display_name: options.display_name,
|
||||
ttl: options.ttl,
|
||||
permission: options.permission,
|
||||
permissions: options.permissions,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -342,6 +406,7 @@ fn ensure_interactive_tty(stdin_is_tty: bool, stderr_is_tty: bool) -> Result<(),
|
||||
async fn resolve_share_options(
|
||||
options: WorkdirShareOptions,
|
||||
) -> Result<Option<ResolvedWorkdirShareOptions>, String> {
|
||||
validate_permissions(options.permissions)?;
|
||||
if options.non_interactive {
|
||||
let workspace_id = options.workspace_id.clone().ok_or_else(|| {
|
||||
"non-interactive sharing requires --workspace-id; no Workspace is inferred from cwd or path"
|
||||
@@ -353,7 +418,7 @@ async fn resolve_share_options(
|
||||
backend_url: options.backend_url,
|
||||
display_name: options.display_name,
|
||||
ttl: options.ttl,
|
||||
permission: options.permission,
|
||||
permissions: options.permissions,
|
||||
}));
|
||||
}
|
||||
ensure_interactive_tty(
|
||||
@@ -392,35 +457,29 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
|
||||
provider_instance_id: provider_instance_id.clone(),
|
||||
display_name: options.display_name.clone(),
|
||||
ttl_seconds: options.ttl.map(|ttl| ttl.as_secs()),
|
||||
permission: options.permission,
|
||||
permissions: options.permissions,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("External Workdir grant request failed: {error}"))?;
|
||||
|
||||
if grant.permissions != options.permissions {
|
||||
return Err(
|
||||
"Backend returned permissions that differ from the requested grant".to_string(),
|
||||
);
|
||||
}
|
||||
let capabilities = workdir_capabilities(grant.permissions);
|
||||
let limits = BoundedReadLimits::EXTERNAL_DEFAULT;
|
||||
let session = match options.permission {
|
||||
server_api::ExternalWorkdirPermission::ReadOnly => {
|
||||
LocalWorkdirSession::external_read_only_pinned(
|
||||
Workdir::new(&grant.working_directory_id),
|
||||
pinned_root,
|
||||
limits,
|
||||
)
|
||||
}
|
||||
server_api::ExternalWorkdirPermission::ReadWrite => {
|
||||
LocalWorkdirSession::external_read_write_pinned(
|
||||
Workdir::new(&grant.working_directory_id),
|
||||
pinned_root,
|
||||
limits,
|
||||
)
|
||||
}
|
||||
}
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
println!("External Workdir grant: {}", grant.grant_id);
|
||||
println!("Logical Workdir: {}", grant.working_directory_id);
|
||||
println!("Display name: {}", grant.display_name);
|
||||
println!("Permission: {}", permission_label(grant.permission));
|
||||
println!("Permissions: {}", permissions_label(grant.permissions));
|
||||
if grant.permissions.command {
|
||||
println!(
|
||||
"COMMAND executes with this sharing CLI user's host authority; it is not sandboxed."
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"Expires at: {}",
|
||||
grant
|
||||
@@ -440,6 +499,16 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
|
||||
.transpose()?;
|
||||
let mut generation = grant.generation;
|
||||
loop {
|
||||
// Command handles and results are generation-owned. A reconnect always
|
||||
// gets a fresh LocalWorkdirSession, and closing the old one kills and
|
||||
// reaps every process before the next generation can register.
|
||||
let session = LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
Workdir::new(&grant.working_directory_id),
|
||||
pinned_root.clone(),
|
||||
limits,
|
||||
capabilities,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let outcome = serve_provider_connection(
|
||||
&backend_client,
|
||||
&server_api_client,
|
||||
@@ -450,6 +519,17 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
|
||||
generation,
|
||||
)
|
||||
.await;
|
||||
let close_result = session.close().await.map_err(|error| error.to_string());
|
||||
let outcome = match (outcome, close_result) {
|
||||
(Ok(outcome), Ok(())) => Ok(outcome),
|
||||
(Err(error), Ok(())) => Err(error),
|
||||
(Ok(_), Err(error)) => Err(format!(
|
||||
"External Workdir provider session cleanup failed: {error}"
|
||||
)),
|
||||
(Err(connection_error), Err(close_error)) => Err(format!(
|
||||
"{connection_error}; provider session cleanup failed: {close_error}"
|
||||
)),
|
||||
};
|
||||
let outcome = match outcome {
|
||||
Ok(outcome) => outcome,
|
||||
Err(connection_error) => {
|
||||
@@ -625,7 +705,7 @@ async fn serve_provider_connection(
|
||||
tokio::select! {
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
|
||||
return Ok(ProviderEnd::Interrupted);
|
||||
}
|
||||
@@ -640,11 +720,12 @@ async fn serve_provider_connection(
|
||||
let cancelled = match operation.join() {
|
||||
Ok(cancelled) => cancelled,
|
||||
Err(error) => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let outcome = if cancelled {
|
||||
cancel_completed_command(session, &completion.outcome).await;
|
||||
ExternalWorkdirOperationOutcome::Cancelled
|
||||
} else {
|
||||
completion.outcome
|
||||
@@ -657,19 +738,19 @@ async fn serve_provider_connection(
|
||||
},
|
||||
);
|
||||
if send_provider_frame(&mut socket, &result).await.is_err() {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
}
|
||||
}
|
||||
incoming = socket.next() => {
|
||||
let Some(incoming) = incoming else {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
};
|
||||
let incoming = match incoming {
|
||||
Ok(incoming) => incoming,
|
||||
Err(_) => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
}
|
||||
};
|
||||
@@ -678,7 +759,7 @@ async fn serve_provider_connection(
|
||||
let frame = match serde_json::from_str::<ExternalWorkdirServerFrame>(&text) {
|
||||
Ok(frame) => frame,
|
||||
Err(error) => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Err(format!("Backend returned an invalid External Workdir frame: {error}"));
|
||||
}
|
||||
};
|
||||
@@ -689,24 +770,26 @@ async fn serve_provider_connection(
|
||||
operation,
|
||||
} if frame_generation == generation => {
|
||||
if operations.len() >= 16 || operations.contains_key(operation_id.as_str()) {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Err("Backend exceeded External Workdir operation bounds".to_string());
|
||||
}
|
||||
let key = operation_id.as_str().to_string();
|
||||
let completion_sender = completion_sender.clone();
|
||||
let task_operation_id = operation_id.clone();
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let operation_timeout = provider_operation_timeout(operation.as_inner());
|
||||
let guarded_session = session.with_operation_guard(
|
||||
cancelled.clone(),
|
||||
Instant::now() + PROVIDER_OPERATION_TIMEOUT,
|
||||
Instant::now() + operation_timeout,
|
||||
);
|
||||
let operation = operation.into_inner();
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("external-workdir-operation".to_string())
|
||||
.spawn(move || {
|
||||
let outcome = std::panic::catch_unwind(
|
||||
std::panic::AssertUnwindSafe(|| {
|
||||
futures::executor::block_on(execute_operation(
|
||||
runtime.block_on(execute_operation(
|
||||
&guarded_session,
|
||||
operation,
|
||||
))
|
||||
@@ -726,7 +809,7 @@ async fn serve_provider_connection(
|
||||
let thread = match spawned {
|
||||
Ok(thread) => thread,
|
||||
Err(error) => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Err(format!("failed to start External Workdir operation: {error}"));
|
||||
}
|
||||
};
|
||||
@@ -746,7 +829,7 @@ async fn serve_provider_connection(
|
||||
ExternalWorkdirProviderMessage::Heartbeat { generation, sequence },
|
||||
);
|
||||
if send_provider_frame(&mut socket, &heartbeat).await.is_err() {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
}
|
||||
}
|
||||
@@ -764,7 +847,7 @@ async fn serve_provider_connection(
|
||||
generation: frame_generation,
|
||||
..
|
||||
} if frame_generation == generation => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
let acknowledged = ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::RevokeAcknowledged { generation },
|
||||
);
|
||||
@@ -772,7 +855,7 @@ async fn serve_provider_connection(
|
||||
return Ok(ProviderEnd::Revoked);
|
||||
}
|
||||
_ => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Err("stale or unexpected External Workdir provider frame".to_string());
|
||||
}
|
||||
}
|
||||
@@ -784,12 +867,12 @@ async fn serve_provider_connection(
|
||||
)
|
||||
.await;
|
||||
if !matches!(pong, Ok(Ok(()))) {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
}
|
||||
}
|
||||
Message::Close(_) => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
}
|
||||
_ => {}
|
||||
@@ -799,6 +882,28 @@ async fn serve_provider_connection(
|
||||
}
|
||||
}
|
||||
|
||||
async fn cancel_completed_command(
|
||||
session: &LocalWorkdirSession,
|
||||
outcome: &ExternalWorkdirOperationOutcome,
|
||||
) {
|
||||
let ExternalWorkdirOperationOutcome::Completed { result } = outcome else {
|
||||
return;
|
||||
};
|
||||
let workdir::http::WorkdirSessionOperationResult::CommandStart(handle) = result.as_inner()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = session.cancel_command(handle.clone()).await;
|
||||
let _ = session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor: 0,
|
||||
limit: workdir::external::MAX_EXTERNAL_COMMAND_OUTPUT_BYTES,
|
||||
wait: true,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn execute_operation(
|
||||
session: &LocalWorkdirSession,
|
||||
operation: workdir::http::WorkdirSessionOperation,
|
||||
@@ -825,6 +930,15 @@ fn cancel_operations(operations: &HashMap<String, OperationTask>) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_provider_operations(
|
||||
session: &LocalWorkdirSession,
|
||||
operations: &mut HashMap<String, OperationTask>,
|
||||
completions: &mut tokio::sync::mpsc::Receiver<OperationCompletion>,
|
||||
) -> Result<(), String> {
|
||||
session.close().await.map_err(|error| error.to_string())?;
|
||||
stop_operations(operations, completions).await
|
||||
}
|
||||
|
||||
async fn stop_operations(
|
||||
operations: &mut HashMap<String, OperationTask>,
|
||||
completions: &mut tokio::sync::mpsc::Receiver<OperationCompletion>,
|
||||
@@ -925,7 +1039,7 @@ mod tests {
|
||||
backend_url: "https://backend.example".to_string(),
|
||||
display_name: "External Workdir".to_string(),
|
||||
ttl: None,
|
||||
permission: server_api::ExternalWorkdirPermission::ReadOnly,
|
||||
permissions: server_api::ExternalWorkdirPermissions::default(),
|
||||
non_interactive: false,
|
||||
}
|
||||
}
|
||||
@@ -954,8 +1068,8 @@ mod tests {
|
||||
|
||||
assert_eq!(selected.workspace_id, "workspace-a");
|
||||
assert_eq!(
|
||||
selected.permission,
|
||||
server_api::ExternalWorkdirPermission::ReadOnly
|
||||
selected.permissions,
|
||||
server_api::ExternalWorkdirPermissions::default()
|
||||
);
|
||||
assert_eq!(selected.ttl, None);
|
||||
assert!(
|
||||
@@ -966,12 +1080,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_share_requires_explicit_read_write_selection_and_confirmation() {
|
||||
fn interactive_share_requires_explicit_write_and_command_selection_and_confirmation() {
|
||||
let workspaces = [
|
||||
workspace("workspace-a", "Alpha"),
|
||||
workspace("workspace-b", "Beta"),
|
||||
];
|
||||
let mut input = std::io::Cursor::new(b"2\nw\n15m\ny\n".to_vec());
|
||||
let mut input = std::io::Cursor::new(b"2\nREAD,WRITE,COMMAND\n15m\ny\n".to_vec());
|
||||
let mut output = Vec::new();
|
||||
|
||||
let selected =
|
||||
@@ -981,10 +1095,17 @@ mod tests {
|
||||
|
||||
assert_eq!(selected.workspace_id, "workspace-b");
|
||||
assert_eq!(
|
||||
selected.permission,
|
||||
server_api::ExternalWorkdirPermission::ReadWrite
|
||||
selected.permissions,
|
||||
server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: true,
|
||||
command: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(selected.ttl, Some(Duration::from_secs(900)));
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("not sandboxed"));
|
||||
assert!(output.contains("Permissions: READ, WRITE, COMMAND"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1101,7 +1222,7 @@ mod tests {
|
||||
"working_directory_id": "workdir-a",
|
||||
"provider_instance_id": "provider-a",
|
||||
"display_name": "Shared files",
|
||||
"permission": "read_only",
|
||||
"permissions": {"read": true, "write": false, "command": false},
|
||||
"expires_at": "2026-09-23T03:00:00Z",
|
||||
"generation": 7,
|
||||
"status": "pending"
|
||||
@@ -1121,7 +1242,7 @@ mod tests {
|
||||
provider_instance_id: "provider-a".to_string(),
|
||||
display_name: "Shared files".to_string(),
|
||||
ttl_seconds: Some(600),
|
||||
permission: server_api::ExternalWorkdirPermission::ReadOnly,
|
||||
permissions: server_api::ExternalWorkdirPermissions::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# External Workdir sharing permissions
|
||||
|
||||
`yoi workdir share` exposes a client-hosted directory through an authenticated,
|
||||
outbound Backend connection. The operator grants three independent categories:
|
||||
|
||||
- **READ**: file reads and read-only discovery operations such as Stat, List,
|
||||
ViewImage, Glob, and Grep.
|
||||
- **WRITE**: Write and Edit. WRITE requires READ because updating an existing
|
||||
file uses the read-before-write content-hash contract. Selecting WRITE never
|
||||
enables COMMAND.
|
||||
- **COMMAND**: command start, status, bounded output, and cancellation. COMMAND
|
||||
does not enable READ or WRITE. It executes on the sharing host with the OS
|
||||
authority of the user running the CLI; External Workdir command execution is
|
||||
**not a sandbox** and does not confine the process to the shared directory.
|
||||
|
||||
READ is enabled by default and COMMAND is disabled by default. Interactive mode
|
||||
asks for a comma-separated category list and shows the exact grant before it is
|
||||
created. For automation, repeat `--permission`:
|
||||
|
||||
```sh
|
||||
yoi --backend https://example.invalid workdir share ./project \
|
||||
--workspace-id workspace-a \
|
||||
--permission read \
|
||||
--permission write \
|
||||
--permission command \
|
||||
--non-interactive
|
||||
```
|
||||
|
||||
`--read-only` remains an alias for READ. `--read-write` remains an alias for
|
||||
READ+WRITE and never enables COMMAND. These legacy flags conflict with explicit
|
||||
`--permission` arguments so ambiguous automation fails closed.
|
||||
|
||||
Omitting `--ttl` creates no automatic expiry. An explicit TTL must be between
|
||||
60 seconds and 24 hours. Ctrl-C, revoke, expiry, provider disconnect, attachment
|
||||
detach, and provider-generation replacement terminate and reap active commands.
|
||||
A reconnect creates a fresh command namespace; old handles and results are not
|
||||
accepted by the new generation.
|
||||
|
||||
Command output uses the existing bounded inline contract. Worker-local spill
|
||||
paths are not sent to the sharing provider, and provider-local paths are never
|
||||
returned to the Backend or model. Truncated External command output is therefore
|
||||
reported without a full-output host path.
|
||||
+28
-12
@@ -2015,8 +2015,8 @@
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"permission": {
|
||||
"$ref": "#/components/schemas/ExternalWorkdirPermission"
|
||||
"permissions": {
|
||||
"$ref": "#/components/schemas/ExternalWorkdirPermissions"
|
||||
},
|
||||
"provider_instance_id": {
|
||||
"type": "string"
|
||||
@@ -2034,7 +2034,7 @@
|
||||
"required": [
|
||||
"provider_instance_id",
|
||||
"display_name",
|
||||
"permission"
|
||||
"permissions"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -2060,8 +2060,8 @@
|
||||
"grant_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"permission": {
|
||||
"$ref": "#/components/schemas/ExternalWorkdirPermission"
|
||||
"permissions": {
|
||||
"$ref": "#/components/schemas/ExternalWorkdirPermissions"
|
||||
},
|
||||
"provider_instance_id": {
|
||||
"type": "string"
|
||||
@@ -2082,18 +2082,34 @@
|
||||
"working_directory_id",
|
||||
"provider_instance_id",
|
||||
"display_name",
|
||||
"permission",
|
||||
"permissions",
|
||||
"generation",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ExternalWorkdirPermission": {
|
||||
"enum": [
|
||||
"read_only",
|
||||
"read_write"
|
||||
"ExternalWorkdirPermissions": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "Execute commands as the sharing CLI user on the provider host. This is\nexplicit authority and does not imply either filesystem category.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"read": {
|
||||
"description": "Read files and use read-only discovery operations such as Glob and Grep.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"write": {
|
||||
"description": "Create or update files with Write and Edit. Existing-file mutation also\nrequires `read` so callers can satisfy the read-before-write contract.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"read",
|
||||
"write",
|
||||
"command"
|
||||
],
|
||||
"type": "string"
|
||||
"type": "object"
|
||||
},
|
||||
"ExtractAudit": {
|
||||
"properties": {
|
||||
@@ -13994,7 +14010,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a"
|
||||
"x-yoi-source-digest": "sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
@@ -9,11 +9,11 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type ExternalWorkdirGrantCreateRequest = { display_name: string; permission: ExternalWorkdirPermission; provider_instance_id: string; ttl_seconds?: number | null; };
|
||||
export type ExternalWorkdirGrantCreateRequest = { display_name: string; permissions: ExternalWorkdirPermissions; provider_instance_id: string; ttl_seconds?: number | null; };
|
||||
|
||||
export type ExternalWorkdirGrantResponse = { display_name: string; expires_at?: string | null; generation: number; grant_id: string; permission: ExternalWorkdirPermission; provider_instance_id: string; status: string; working_directory_id: string; workspace_id: string; };
|
||||
export type ExternalWorkdirGrantResponse = { display_name: string; expires_at?: string | null; generation: number; grant_id: string; permissions: ExternalWorkdirPermissions; provider_instance_id: string; status: string; working_directory_id: string; workspace_id: string; };
|
||||
|
||||
export type ExternalWorkdirPermission = "read_only" | "read_write";
|
||||
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
|
||||
|
||||
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
Reference in New Issue
Block a user