From 8b951d53e6ced7a3616d0c090520c83c5b2c68f0 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 29 Sep 2026 11:26:25 +0900 Subject: [PATCH] feat: add external workdir command permissions --- Cargo.lock | 1 + crates/server-api/src/lib.rs | 31 +- crates/workdir/Cargo.toml | 1 + crates/workdir/src/external.rs | 123 +++- crates/workdir/src/external_local_tests.rs | 75 ++- crates/workdir/src/lib.rs | 30 + crates/workdir/src/local.rs | 146 +++- crates/workspace-server/src/latest_schema.sql | 5 +- crates/workspace-server/src/server.rs | 627 +++++++++++++++--- crates/workspace-server/src/store.rs | 167 ++++- crates/yoi/src/main.rs | 118 +++- crates/yoi/src/workdir_share.rs | 259 ++++++-- docs/development/external-workdir-sharing.md | 42 ++ openapi/server-api.json | 40 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/workdir-api.ts | 8 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 20 files changed, 1445 insertions(+), 238 deletions(-) create mode 100644 docs/development/external-workdir-sharing.md diff --git a/Cargo.lock b/Cargo.lock index 6b8e51672..8053c9987 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6181,6 +6181,7 @@ version = "0.1.0" dependencies = [ "async-trait", "fs-operation", + "libc", "manifest", "reqwest", "schemars", diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 4790c3f4e..825616bf3 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -5241,13 +5241,28 @@ impl From 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, - 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")] diff --git a/crates/workdir/Cargo.toml b/crates/workdir/Cargo.toml index b5b7a99c8..0afb851e7 100644 --- a/crates/workdir/Cargo.toml +++ b/crates/workdir/Cargo.toml @@ -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 diff --git a/crates/workdir/src/external.rs b/crates/workdir/src/external.rs index 22ff45d83..a1b12ec06 100644 --- a/crates/workdir/src/external.rs +++ b/crates/workdir/src/external.rs @@ -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 for ExternalWorkdirOperation { type Error = String; fn try_from(operation: WorkdirSessionOperation) -> Result { - validate_filesystem_operation(&operation)?; + validate_external_operation(&operation)?; Ok(Self(operation)) } } @@ -239,7 +266,7 @@ fn result_paths_fit<'a>(paths: impl IntoIterator) -> 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 for ExternalWorkdirOperationResult { type Error = String; fn try_from(result: WorkdirSessionOperationResult) -> Result { - 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] diff --git a/crates/workdir/src/external_local_tests.rs b/crates/workdir/src/external_local_tests.rs index 943eb3dd5..664f8eca3 100644 --- a/crates/workdir/src/external_local_tests.rs +++ b/crates/workdir/src/external_local_tests.rs @@ -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(); diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index 70a0b5a62..806a1431a 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -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 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); + } +} diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index 2d67a3a12..4a4a3b61e 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -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 { + 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::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::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, +} + +impl CommandProcessGroupGuard { + fn new(process_group_id: Option) -> 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(); diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 674bee27d..0accd1d9a 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -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, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 69cfef1d0..57f68cb53 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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, + 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, audit: Option, closed: std::sync::atomic::AtomicBool, + close_lock: tokio::sync::Mutex<()>, + active_commands: tokio::sync::Mutex>, } 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 { 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 { + 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 { + 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 { + 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 { - 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 { - 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 { + 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 { + 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 { + 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 { - 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 { - 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 { - 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::>(); + 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 { - match permissions { - "read_only" => Ok(workdir::WorkdirSessionCapabilities::READ_ONLY), - "read_write" => Ok(workdir::WorkdirSessionCapabilities::READ_WRITE), - _ => Err(Error::Store( - "External Workdir grant has invalid persisted permissions".to_string(), +fn external_workdir_permissions(persisted: &str) -> Result { + 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 { + 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 { + 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 - .start_command(workdir::CommandRequest { - command: "touch forbidden".to_string(), - timeout_secs: 1, - output_limit: 1024, - cwd: workdir::WorkdirPath::root(), - spill_dir: None, - tool_call_id: None, + let command_start = { + let broker_session = broker_session.clone(); + tokio::spawn(async move { + broker_session + .start_command(workdir::CommandRequest { + command: "printf command-ok".to_string(), + timeout_secs: 30, + output_limit: 1024, + cwd: workdir::WorkdirPath::root(), + spill_dir: Some("/worker/private/spill".into()), + tool_call_id: Some("call-e2e".to_string()), + }) + .await }) + }; + let (operation_id, operation) = loop { + let text = second.next().await.unwrap().unwrap().into_text().unwrap(); + let frame = serde_json::from_str::(&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_err(); - assert!( - matches!( - command_error, - workdir::WorkdirError::Unsupported(workdir::WorkdirSessionCapability::Command) - | workdir::WorkdirError::UnsupportedOperation(_) - | workdir::WorkdirError::Denied(_) - ), - "unexpected command error: {command_error:?}" - ); + .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::(&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()), diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index da4eaaa37..9c902f5b3 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -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::>>() + .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![ - 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()); diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index e0c0ce435..7a3577091 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -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 = 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 [--workspace-id ] panel yoi [--backend ] init --display-name --repository-key [--repository ] [--default-ref ] yoi [--backend ] login [--no-wait] - yoi [--backend ] workdir share [--workspace-id ] [--read-only|--read-write] [--ttl ] [--display-name ] [--non-interactive] + yoi [--backend ] workdir share [--workspace-id ] [--permission ]... [--read-only|--read-write] [--ttl ] [--display-name ] [--non-interactive] yoi [OPTIONS] Target selection: @@ -1908,7 +1946,7 @@ Options: fn print_workdir_help() { println!( - "yoi workdir share\n\nUsage:\n yoi [--backend ] workdir share [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 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 Automatic expiry; omission is unlimited\n --display-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 ] workdir share [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 Preselect a Workspace; required with --non-interactive\n --permission 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 Automatic expiry; omission is unlimited\n --display-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() diff --git a/crates/yoi/src/workdir_share.rs b/crates/yoi/src/workdir_share.rs index 44d104abf..82d74ec8b 100644 --- a/crates/yoi/src/workdir_share.rs +++ b/crates/yoi/src/workdir_share.rs @@ -30,12 +30,23 @@ pub(crate) struct WorkdirShareOptions { pub backend_url: String, pub display_name: String, pub ttl: Option, - 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, - permission: server_api::ExternalWorkdirPermission, + permissions: server_api::ExternalWorkdirPermissions, } pub(crate) fn parse_ttl(value: &str) -> Result { @@ -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, 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}"))?; - 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, - ) - } + if grant.permissions != options.permissions { + return Err( + "Backend returned permissions that differ from the requested grant".to_string(), + ); } - .map_err(|error| error.to_string())?; + let capabilities = workdir_capabilities(grant.permissions); + let limits = BoundedReadLimits::EXTERNAL_DEFAULT; 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::(&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) { } } +async fn stop_provider_operations( + session: &LocalWorkdirSession, + operations: &mut HashMap, + completions: &mut tokio::sync::mpsc::Receiver, +) -> Result<(), String> { + session.close().await.map_err(|error| error.to_string())?; + stop_operations(operations, completions).await +} + async fn stop_operations( operations: &mut HashMap, completions: &mut tokio::sync::mpsc::Receiver, @@ -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 diff --git a/docs/development/external-workdir-sharing.md b/docs/development/external-workdir-sharing.md new file mode 100644 index 000000000..dbe980168 --- /dev/null +++ b/docs/development/external-workdir-sharing.md @@ -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. diff --git a/openapi/server-api.json b/openapi/server-api.json index 78d8960f5..f04307fe8 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -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": { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index def8cb001..2020f3a12 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index f7baeefdf..cb5c39c72 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index fe0ab354c..65d6017f2 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index b45fe6e0a..2747cd40e 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index feaf762c5..ed5c7392e 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -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; error: string; message: string; }; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 59423f838..2e2c07ed4 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -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