feat: add external workdir command permissions
This commit is contained in:
@@ -697,7 +697,10 @@ CREATE TABLE external_workdir_grants (
|
||||
workdir_id TEXT NOT NULL,
|
||||
provider_instance_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL CHECK (permissions IN ('read_only', 'read_write')),
|
||||
permissions TEXT NOT NULL CHECK (permissions IN (
|
||||
'read_only', 'read_write', 'command_only',
|
||||
'read_command', 'read_write_command'
|
||||
)),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
|
||||
@@ -711,14 +711,40 @@ impl ExternalOperationCancelGuard {
|
||||
fn disarm(&mut self) {
|
||||
self.operation_id = None;
|
||||
}
|
||||
|
||||
fn cancel(&mut self) {
|
||||
let Some(operation_id) = self.operation_id.take() else {
|
||||
return;
|
||||
};
|
||||
enqueue_external_operation_cancel(self.sender.clone(), operation_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ExternalOperationCancelGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(operation_id) = self.operation_id.take() {
|
||||
let _ = self
|
||||
.sender
|
||||
.try_send(ExternalProviderCommand::Cancel { operation_id });
|
||||
self.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_external_operation_cancel(
|
||||
sender: tokio::sync::mpsc::Sender<ExternalProviderCommand>,
|
||||
operation_id: ExternalWorkdirOperationId,
|
||||
) {
|
||||
let command = ExternalProviderCommand::Cancel { operation_id };
|
||||
match sender.try_send(command) {
|
||||
Ok(()) | Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {}
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Full(command)) => {
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
runtime.spawn(async move {
|
||||
// Never delay the failed operation's return on a saturated provider
|
||||
// queue, but give the provider a bounded opportunity to accept the
|
||||
// cancellation as it drains already queued messages.
|
||||
let _ =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), sender.send(command))
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -792,6 +818,8 @@ struct ExternalProviderWorkdirSession {
|
||||
connection: Arc<ExternalProviderConnection>,
|
||||
audit: Option<ExternalProviderAuditContext>,
|
||||
closed: std::sync::atomic::AtomicBool,
|
||||
close_lock: tokio::sync::Mutex<()>,
|
||||
active_commands: tokio::sync::Mutex<HashMap<String, workdir::CommandHandle>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ExternalProviderWorkdirSession {
|
||||
@@ -815,6 +843,8 @@ impl ExternalProviderWorkdirSession {
|
||||
connection,
|
||||
audit,
|
||||
closed: std::sync::atomic::AtomicBool::new(false),
|
||||
close_lock: tokio::sync::Mutex::new(()),
|
||||
active_commands: tokio::sync::Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,6 +888,7 @@ impl ExternalProviderWorkdirSession {
|
||||
operation: WorkdirSessionOperation,
|
||||
) -> std::result::Result<WorkdirSessionOperationResult, workdir::WorkdirError> {
|
||||
let operation_kind = external_workdir_operation_kind(&operation);
|
||||
let operation_timeout = external_workdir_operation_timeout(&operation);
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(workdir::WorkdirError::SessionClosed);
|
||||
}
|
||||
@@ -914,7 +945,7 @@ impl ExternalProviderWorkdirSession {
|
||||
"External Workdir provider disconnected".to_string(),
|
||||
));
|
||||
}
|
||||
let result = match tokio::time::timeout(std::time::Duration::from_secs(30), receive).await {
|
||||
let result = match tokio::time::timeout(operation_timeout, receive).await {
|
||||
Ok(result) => {
|
||||
cancel_guard.disarm();
|
||||
match result {
|
||||
@@ -925,14 +956,7 @@ impl ExternalProviderWorkdirSession {
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if let Some(cancel_operation_id) = cancel_guard.operation_id.take() {
|
||||
let _ = cancel_guard
|
||||
.sender
|
||||
.send(ExternalProviderCommand::Cancel {
|
||||
operation_id: cancel_operation_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
cancel_guard.cancel();
|
||||
Err(workdir::WorkdirError::Unavailable(
|
||||
"External Workdir provider operation timed out".to_string(),
|
||||
))
|
||||
@@ -950,6 +974,17 @@ impl ExternalProviderWorkdirSession {
|
||||
result
|
||||
}
|
||||
|
||||
fn ensure_capability(
|
||||
&self,
|
||||
capability: workdir::WorkdirSessionCapability,
|
||||
) -> std::result::Result<(), workdir::WorkdirError> {
|
||||
if self.connection.capabilities.supports(capability) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(workdir::WorkdirError::Unsupported(capability))
|
||||
}
|
||||
}
|
||||
|
||||
fn mismatch(expected: &str) -> workdir::WorkdirError {
|
||||
workdir::WorkdirError::Unavailable(format!(
|
||||
"External Workdir provider returned a mismatched result; expected {expected}"
|
||||
@@ -957,6 +992,17 @@ impl ExternalProviderWorkdirSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn external_workdir_operation_timeout(operation: &WorkdirSessionOperation) -> std::time::Duration {
|
||||
if matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandOutput(request) if request.wait
|
||||
) {
|
||||
std::time::Duration::from_secs(workdir::external::MAX_EXTERNAL_COMMAND_TIMEOUT_SECS + 10)
|
||||
} else {
|
||||
std::time::Duration::from_secs(30)
|
||||
}
|
||||
}
|
||||
|
||||
fn external_workdir_operation_kind(operation: &WorkdirSessionOperation) -> &'static str {
|
||||
match operation {
|
||||
WorkdirSessionOperation::AuthorizeScope(_) => "authorize_scope",
|
||||
@@ -988,6 +1034,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::WorkdirScopeAuthorizationRequest,
|
||||
) -> std::result::Result<(), workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::AuthorizeScope(request))
|
||||
.await?
|
||||
@@ -1000,6 +1047,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::WorkdirScopeOverlapRequest,
|
||||
) -> std::result::Result<bool, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::ScopeRulesOverlap(request))
|
||||
.await?
|
||||
@@ -1012,6 +1060,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::StatRequest,
|
||||
) -> std::result::Result<workdir::StatResult, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
||||
WorkdirSessionOperationResult::Stat(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("stat")),
|
||||
@@ -1021,6 +1070,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::ReadRequest,
|
||||
) -> std::result::Result<workdir::ReadResult, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||
match self.operate(WorkdirSessionOperation::Read(request)).await? {
|
||||
WorkdirSessionOperationResult::Read(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("read")),
|
||||
@@ -1030,15 +1080,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::WriteRequest,
|
||||
) -> std::result::Result<workdir::WriteResult, workdir::WorkdirError> {
|
||||
if !self
|
||||
.connection
|
||||
.capabilities
|
||||
.supports(workdir::WorkdirSessionCapability::Write)
|
||||
{
|
||||
return Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Write,
|
||||
));
|
||||
}
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Write)?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::Write(request))
|
||||
.await?
|
||||
@@ -1051,15 +1093,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::EditRequest,
|
||||
) -> std::result::Result<workdir::EditResult, workdir::WorkdirError> {
|
||||
if !self
|
||||
.connection
|
||||
.capabilities
|
||||
.supports(workdir::WorkdirSessionCapability::Edit)
|
||||
{
|
||||
return Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Edit,
|
||||
));
|
||||
}
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Edit)?;
|
||||
match self.operate(WorkdirSessionOperation::Edit(request)).await? {
|
||||
WorkdirSessionOperationResult::Edit(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("edit")),
|
||||
@@ -1069,6 +1103,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::ListRequest,
|
||||
) -> std::result::Result<workdir::ListResult, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Read)?;
|
||||
match self.operate(WorkdirSessionOperation::List(request)).await? {
|
||||
WorkdirSessionOperationResult::List(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("list")),
|
||||
@@ -1078,6 +1113,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::GlobRequest,
|
||||
) -> std::result::Result<workdir::GlobResult, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Glob)?;
|
||||
match self.operate(WorkdirSessionOperation::Glob(request)).await? {
|
||||
WorkdirSessionOperationResult::Glob(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("glob")),
|
||||
@@ -1087,6 +1123,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
&self,
|
||||
request: workdir::GrepRequest,
|
||||
) -> std::result::Result<workdir::GrepResult, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Grep)?;
|
||||
match self.operate(WorkdirSessionOperation::Grep(request)).await? {
|
||||
WorkdirSessionOperationResult::Grep(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("grep")),
|
||||
@@ -1094,37 +1131,107 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
}
|
||||
async fn start_command(
|
||||
&self,
|
||||
_request: workdir::CommandRequest,
|
||||
mut request: workdir::CommandRequest,
|
||||
) -> std::result::Result<workdir::CommandHandle, workdir::WorkdirError> {
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Command,
|
||||
))
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||
let _close_guard = self.close_lock.lock().await;
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(workdir::WorkdirError::SessionClosed);
|
||||
}
|
||||
// Bash supplies a Worker-host spill directory. It is neither meaningful
|
||||
// nor safe to send to a client-hosted provider, so External commands use
|
||||
// bounded inline output and report truncation without a provider path.
|
||||
request.spill_dir = None;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandStart(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandStart(handle) => {
|
||||
self.active_commands
|
||||
.lock()
|
||||
.await
|
||||
.insert(handle.0.clone(), handle.clone());
|
||||
Ok(handle)
|
||||
}
|
||||
_ => Err(Self::mismatch("command_start")),
|
||||
}
|
||||
}
|
||||
async fn command_status(
|
||||
&self,
|
||||
_handle: workdir::CommandHandle,
|
||||
handle: workdir::CommandHandle,
|
||||
) -> std::result::Result<workdir::CommandStatus, workdir::WorkdirError> {
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Command,
|
||||
))
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandStatus(handle))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandStatus(status) => Ok(status),
|
||||
_ => Err(Self::mismatch("command_status")),
|
||||
}
|
||||
}
|
||||
async fn command_output(
|
||||
&self,
|
||||
_request: workdir::CommandOutputRequest,
|
||||
request: workdir::CommandOutputRequest,
|
||||
) -> std::result::Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Command,
|
||||
))
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||
let command_id = request.handle.0.clone();
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandOutput(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandOutput(output) => {
|
||||
if output.status != workdir::CommandStatus::Running && output.next_cursor.is_none()
|
||||
{
|
||||
self.active_commands.lock().await.remove(&command_id);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
_ => Err(Self::mismatch("command_output")),
|
||||
}
|
||||
}
|
||||
async fn cancel_command(
|
||||
&self,
|
||||
_handle: workdir::CommandHandle,
|
||||
handle: workdir::CommandHandle,
|
||||
) -> std::result::Result<(), workdir::WorkdirError> {
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Command,
|
||||
))
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandCancel(handle))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandCancel => Ok(()),
|
||||
_ => Err(Self::mismatch("command_cancel")),
|
||||
}
|
||||
}
|
||||
async fn close(&self) -> std::result::Result<(), workdir::WorkdirError> {
|
||||
let _close_guard = self.close_lock.lock().await;
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
let commands = self
|
||||
.active_commands
|
||||
.lock()
|
||||
.await
|
||||
.drain()
|
||||
.map(|(_, handle)| handle)
|
||||
.collect::<Vec<_>>();
|
||||
for handle in commands {
|
||||
// Detach/session replacement is terminal authority for commands
|
||||
// started through this exact provider generation. Request cancel,
|
||||
// then wait for provider-owned process reaping before closing.
|
||||
let _ = self
|
||||
.operate(WorkdirSessionOperation::CommandCancel(handle.clone()))
|
||||
.await;
|
||||
let _ = self
|
||||
.operate(WorkdirSessionOperation::CommandOutput(
|
||||
workdir::CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: workdir::external::MAX_EXTERNAL_COMMAND_OUTPUT_BYTES,
|
||||
wait: true,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
}
|
||||
self.closed.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
@@ -3674,16 +3781,75 @@ struct ValidatedTicketImplementationTarget {
|
||||
capabilities: workdir::WorkdirSessionCapabilities,
|
||||
}
|
||||
|
||||
fn external_workdir_capabilities(permissions: &str) -> Result<workdir::WorkdirSessionCapabilities> {
|
||||
match permissions {
|
||||
"read_only" => Ok(workdir::WorkdirSessionCapabilities::READ_ONLY),
|
||||
"read_write" => Ok(workdir::WorkdirSessionCapabilities::READ_WRITE),
|
||||
_ => Err(Error::Store(
|
||||
"External Workdir grant has invalid persisted permissions".to_string(),
|
||||
fn external_workdir_permissions(persisted: &str) -> Result<server_api::ExternalWorkdirPermissions> {
|
||||
let permissions = match persisted {
|
||||
"read_only" => server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: false,
|
||||
command: false,
|
||||
},
|
||||
"read_write" => server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: true,
|
||||
command: false,
|
||||
},
|
||||
"command_only" => server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: true,
|
||||
},
|
||||
"read_command" => server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: false,
|
||||
command: true,
|
||||
},
|
||||
"read_write_command" => server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: true,
|
||||
command: true,
|
||||
},
|
||||
_ => {
|
||||
return Err(Error::Store(
|
||||
"External Workdir grant has invalid persisted permissions".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(permissions)
|
||||
}
|
||||
|
||||
fn persist_external_workdir_permissions(
|
||||
permissions: server_api::ExternalWorkdirPermissions,
|
||||
) -> Result<&'static str> {
|
||||
match (permissions.read, permissions.write, permissions.command) {
|
||||
(true, false, false) => Ok("read_only"),
|
||||
(true, true, false) => Ok("read_write"),
|
||||
(false, false, true) => Ok("command_only"),
|
||||
(true, false, true) => Ok("read_command"),
|
||||
(true, true, true) => Ok("read_write_command"),
|
||||
(false, true, _) => Err(Error::InvalidInput(
|
||||
"External Workdir WRITE permission requires READ for read-before-write".to_string(),
|
||||
)),
|
||||
(false, false, false) => Err(Error::InvalidInput(
|
||||
"External Workdir sharing requires at least one of READ or COMMAND".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn external_workdir_capabilities(permissions: &str) -> Result<workdir::WorkdirSessionCapabilities> {
|
||||
let permissions = external_workdir_permissions(permissions)?;
|
||||
let mut capabilities = workdir::WorkdirSessionCapabilities::EMPTY;
|
||||
if permissions.read {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::READ_ONLY);
|
||||
}
|
||||
if permissions.write {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::WRITE_ONLY);
|
||||
}
|
||||
if permissions.command {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY);
|
||||
}
|
||||
Ok(capabilities)
|
||||
}
|
||||
|
||||
fn workdir_source_capabilities(
|
||||
api: &WorkspaceApi,
|
||||
workdir: &WorkdirRegistryRecord,
|
||||
@@ -17037,10 +17203,7 @@ async fn scoped_create_external_workdir_grant(
|
||||
.ttl_seconds
|
||||
.map(|ttl_seconds| now + Duration::seconds(ttl_seconds as i64))
|
||||
.map(|expires_at| expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true));
|
||||
let permissions = match request.permission {
|
||||
server_api::ExternalWorkdirPermission::ReadOnly => "read_only",
|
||||
server_api::ExternalWorkdirPermission::ReadWrite => "read_write",
|
||||
};
|
||||
let permissions = persist_external_workdir_permissions(request.permissions)?;
|
||||
let grant_id = format!("ewg-{}", Uuid::now_v7());
|
||||
let workdir_id = format!("external-{}", Uuid::now_v7());
|
||||
let grant = ExternalWorkdirGrantRecord {
|
||||
@@ -17080,27 +17243,24 @@ async fn scoped_create_external_workdir_grant(
|
||||
schedule_external_workdir_expiry(&api, &grant)?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(external_workdir_grant_response(&grant)),
|
||||
Json(external_workdir_grant_response(&grant)?),
|
||||
))
|
||||
}
|
||||
|
||||
fn external_workdir_grant_response(
|
||||
grant: &ExternalWorkdirGrantRecord,
|
||||
) -> ExternalWorkdirGrantResponse {
|
||||
ExternalWorkdirGrantResponse {
|
||||
) -> Result<ExternalWorkdirGrantResponse> {
|
||||
Ok(ExternalWorkdirGrantResponse {
|
||||
grant_id: grant.grant_id.clone(),
|
||||
workspace_id: grant.workspace_id.clone(),
|
||||
working_directory_id: grant.workdir_id.clone(),
|
||||
provider_instance_id: grant.provider_instance_id.clone(),
|
||||
display_name: grant.display_name.clone(),
|
||||
permission: match grant.permissions.as_str() {
|
||||
"read_write" => server_api::ExternalWorkdirPermission::ReadWrite,
|
||||
_ => server_api::ExternalWorkdirPermission::ReadOnly,
|
||||
},
|
||||
permissions: external_workdir_permissions(&grant.permissions)?,
|
||||
expires_at: grant.expires_at.clone(),
|
||||
generation: grant.generation,
|
||||
status: grant.status.clone(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn scoped_external_workdir_provider_ws(
|
||||
@@ -17501,7 +17661,7 @@ async fn scoped_get_external_workdir_grant(
|
||||
cleanup_external_workdir_attachments(&api, &grant.workdir_id).await?;
|
||||
cancel_external_workdir_expiry(&api, &grant_id);
|
||||
}
|
||||
Ok(Json(external_workdir_grant_response(&grant)))
|
||||
Ok(Json(external_workdir_grant_response(&grant)?))
|
||||
}
|
||||
|
||||
async fn scoped_revoke_external_workdir_grant(
|
||||
@@ -17565,7 +17725,7 @@ async fn scoped_revoke_external_workdir_grant(
|
||||
// have committed its fence before a Runtime/session projection failure.
|
||||
cleanup_external_workdir_attachments(&api, &grant.workdir_id).await?;
|
||||
cancel_external_workdir_expiry(&api, &grant_id);
|
||||
Ok(Json(external_workdir_grant_response(&grant)))
|
||||
Ok(Json(external_workdir_grant_response(&grant)?))
|
||||
}
|
||||
|
||||
async fn cleanup_external_workdir_attachments(
|
||||
@@ -26068,6 +26228,39 @@ mod tests {
|
||||
WorkspaceRuntimeBinding,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn external_permission_categories_map_to_existing_capability_bits_without_escalation() {
|
||||
assert_eq!(
|
||||
external_workdir_capabilities("read_only").unwrap(),
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
assert_eq!(
|
||||
external_workdir_capabilities("read_write").unwrap(),
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE
|
||||
);
|
||||
assert_eq!(
|
||||
external_workdir_capabilities("command_only").unwrap(),
|
||||
workdir::WorkdirSessionCapabilities::COMMAND_ONLY
|
||||
);
|
||||
assert_eq!(
|
||||
external_workdir_capabilities("read_command").unwrap(),
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY)
|
||||
);
|
||||
assert_eq!(
|
||||
external_workdir_capabilities("read_write_command").unwrap(),
|
||||
workdir::WorkdirSessionCapabilities::ALL
|
||||
);
|
||||
assert!(
|
||||
persist_external_workdir_permissions(server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: true,
|
||||
command: false,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_projection_does_not_expose_unavailable_snapshot_as_current() {
|
||||
let availability = protocol::subscription::SubscriptionWorkerAvailability::Unavailable;
|
||||
@@ -26262,6 +26455,156 @@ mod tests {
|
||||
assert!(commands.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_operation_cancel_retries_without_blocking_a_saturated_provider_queue() {
|
||||
let (sender, mut commands) = tokio::sync::mpsc::channel(1);
|
||||
let queued_id = ExternalWorkdirOperationId::new("op-queued").unwrap();
|
||||
sender
|
||||
.send(ExternalProviderCommand::Cancel {
|
||||
operation_id: queued_id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let retried_id = ExternalWorkdirOperationId::new("op-retried").unwrap();
|
||||
enqueue_external_operation_cancel(sender, retried_id.clone());
|
||||
|
||||
let Some(ExternalProviderCommand::Cancel { operation_id }) = commands.recv().await else {
|
||||
panic!("expected pre-existing queued cancellation");
|
||||
};
|
||||
assert_eq!(operation_id, queued_id);
|
||||
let retried = tokio::time::timeout(std::time::Duration::from_secs(1), commands.recv())
|
||||
.await
|
||||
.expect("cancellation retry should not be lost");
|
||||
let Some(ExternalProviderCommand::Cancel { operation_id }) = retried else {
|
||||
panic!("expected retried cancellation");
|
||||
};
|
||||
assert_eq!(operation_id, retried_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_provider_command_only_session_denies_read_and_routes_bounded_lifecycle() {
|
||||
let (sender, mut commands) = tokio::sync::mpsc::channel(4);
|
||||
let capabilities = workdir::WorkdirSessionCapabilities::COMMAND_ONLY;
|
||||
let connection = Arc::new(ExternalProviderConnection {
|
||||
grant_id: "grant-command".to_string(),
|
||||
workdir_id: "external-command".to_string(),
|
||||
provider_instance_id: "provider-command".to_string(),
|
||||
generation: 8,
|
||||
expires_at: None,
|
||||
capabilities,
|
||||
admission: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||
sender,
|
||||
});
|
||||
let session = Arc::new(ExternalProviderWorkdirSession::new(connection, None));
|
||||
let denied = session
|
||||
.read(workdir::ReadRequest {
|
||||
path: workdir::WorkdirPath::new("secret.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
max_bytes: 1,
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(
|
||||
denied,
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Read
|
||||
))
|
||||
));
|
||||
assert!(commands.try_recv().is_err());
|
||||
|
||||
let starter = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
session
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "printf routed".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: Some("/worker/private/spill".into()),
|
||||
tool_call_id: Some("call-1".to_string()),
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let Some(ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
}) = commands.recv().await
|
||||
else {
|
||||
panic!("expected command start operation");
|
||||
};
|
||||
assert!(matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandStart(workdir::CommandRequest {
|
||||
spill_dir: None,
|
||||
..
|
||||
})
|
||||
));
|
||||
let handle = workdir::CommandHandle("command-1".to_string());
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandStart(
|
||||
handle.clone(),
|
||||
)))
|
||||
.unwrap();
|
||||
assert_eq!(starter.await.unwrap().unwrap(), handle);
|
||||
|
||||
let closer = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move { session.close().await })
|
||||
};
|
||||
let Some(ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
}) = commands.recv().await
|
||||
else {
|
||||
panic!("expected command cancel during close");
|
||||
};
|
||||
assert!(matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandCancel(ref current) if current == &handle
|
||||
));
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandCancel))
|
||||
.unwrap();
|
||||
let Some(ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
}) = commands.recv().await
|
||||
else {
|
||||
panic!("expected terminal command output during close");
|
||||
};
|
||||
assert!(matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandOutput(workdir::CommandOutputRequest {
|
||||
wait: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(
|
||||
workdir::CommandOutput {
|
||||
status: workdir::CommandStatus::Cancelled,
|
||||
exit_code: None,
|
||||
timed_out: false,
|
||||
content: String::new(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
output_path: None,
|
||||
},
|
||||
)))
|
||||
.unwrap();
|
||||
closer.await.unwrap().unwrap();
|
||||
assert!(matches!(
|
||||
session.command_status(handle).await,
|
||||
Err(workdir::WorkdirError::SessionClosed)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_expiry_fences_offline_grant_and_persists_cleanup_completion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -26696,7 +27039,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_provider_websocket_reconnects_with_fresh_generation_and_revokes() {
|
||||
async fn external_provider_websocket_routes_files_and_commands_then_reconnects_and_revokes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let api = test_api(dir.path()).await;
|
||||
@@ -26726,7 +27069,11 @@ mod tests {
|
||||
provider_instance_id: "provider-a".to_string(),
|
||||
display_name: "Session logs".to_string(),
|
||||
ttl_seconds: Some(600),
|
||||
permission: server_api::ExternalWorkdirPermission::ReadWrite,
|
||||
permissions: server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: true,
|
||||
command: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -26755,7 +27102,7 @@ mod tests {
|
||||
.id()
|
||||
.clone(),
|
||||
generation,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_WRITE,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||
read_limits: workdir::BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
},
|
||||
})
|
||||
@@ -26867,14 +27214,16 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
external_capabilities,
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE
|
||||
workdir::WorkdirSessionCapabilities::ALL
|
||||
);
|
||||
let provider_root = tempfile::tempdir().unwrap();
|
||||
fs::write(provider_root.path().join("events.jsonl"), b"one\ntwo\n").unwrap();
|
||||
let local_session = workdir::LocalWorkdirSession::external_read_write(
|
||||
let pinned_provider_root = workdir::ExternalWorkdirRoot::pin(provider_root.path()).unwrap();
|
||||
let local_session = workdir::LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
workdir::Workdir::new(&grant.working_directory_id),
|
||||
provider_root.path(),
|
||||
pinned_provider_root,
|
||||
workdir::BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
workdir::WorkdirSessionCapabilities::ALL,
|
||||
)
|
||||
.unwrap();
|
||||
let session_lock = current_worker_session_lock(&api, &worker);
|
||||
@@ -26885,7 +27234,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
broker_session.capabilities(),
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE
|
||||
workdir::WorkdirSessionCapabilities::ALL
|
||||
);
|
||||
drop(session_guard);
|
||||
let read = {
|
||||
@@ -27052,26 +27401,120 @@ mod tests {
|
||||
fs::read_to_string(provider_root.path().join("created.txt")).unwrap(),
|
||||
"created"
|
||||
);
|
||||
let command_error = broker_session
|
||||
.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::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if let ExternalWorkdirServerMessage::Operation {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
operation,
|
||||
} = frame.message
|
||||
{
|
||||
break (operation_id, operation);
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
operation.as_inner(),
|
||||
WorkdirSessionOperation::CommandStart(workdir::CommandRequest {
|
||||
spill_dir: None,
|
||||
..
|
||||
})
|
||||
));
|
||||
let result = workdir::http::dispatch_workdir_session_operation(
|
||||
&local_session,
|
||||
operation.into_inner(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
outcome: ExternalWorkdirOperationOutcome::Completed { result },
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap_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::<ExternalWorkdirServerFrame>(&text).unwrap();
|
||||
if let ExternalWorkdirServerMessage::Operation {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
operation,
|
||||
} = frame.message
|
||||
{
|
||||
break (operation_id, operation);
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
operation.as_inner(),
|
||||
WorkdirSessionOperation::CommandOutput(_)
|
||||
));
|
||||
let result = workdir::http::dispatch_workdir_session_operation(
|
||||
&local_session,
|
||||
operation.into_inner(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 2,
|
||||
operation_id,
|
||||
outcome: ExternalWorkdirOperationOutcome::Completed { result },
|
||||
},
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let command_output = command_output.await.unwrap().unwrap();
|
||||
assert_eq!(command_output.content, "command-ok");
|
||||
assert_eq!(command_output.exit_code, Some(0));
|
||||
assert_eq!(command_output.output_path, None);
|
||||
|
||||
let Json(revoked) = scoped_revoke_external_workdir_grant(
|
||||
State(api.clone()),
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 67;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 68;
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||
@@ -50,6 +50,8 @@ const TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME: &str =
|
||||
"Ticket target collection and Workdir attachment capabilities";
|
||||
const EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME: &str =
|
||||
"External Workdir read-write access and optional expiry";
|
||||
const EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME: &str =
|
||||
"External Workdir explicit command permission";
|
||||
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
||||
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
||||
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
||||
@@ -182,6 +184,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME,
|
||||
apply: migrate_external_workdir_access_and_optional_expiry_v66_to_v67,
|
||||
},
|
||||
Migration {
|
||||
version: 68,
|
||||
name: EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME,
|
||||
apply: migrate_external_workdir_command_permission_v67_to_v68,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -12098,6 +12105,90 @@ fn migrate_external_workdir_access_and_optional_expiry_v66_to_v67(conn: &Connect
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_external_workdir_command_permission_v67_to_v68(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if current != 67 {
|
||||
return Err(Error::Store(format!(
|
||||
"expected schema version 67 before {EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME} migration, found {current}"
|
||||
)));
|
||||
}
|
||||
let foreign_keys_enabled =
|
||||
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0;
|
||||
if foreign_keys_enabled {
|
||||
conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
|
||||
}
|
||||
let result = (|| {
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE external_workdir_grants_v68 (
|
||||
grant_id TEXT NOT NULL,
|
||||
workspace_id TEXT NOT NULL,
|
||||
workdir_id TEXT NOT NULL,
|
||||
provider_instance_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL CHECK (permissions IN (
|
||||
'read_only', 'read_write', 'command_only',
|
||||
'read_command', 'read_write_command'
|
||||
)),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
generation INTEGER NOT NULL CHECK (generation > 0),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')),
|
||||
updated_at TEXT NOT NULL,
|
||||
cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed')),
|
||||
cleanup_error TEXT,
|
||||
cleanup_updated_at TEXT,
|
||||
PRIMARY KEY (workspace_id, grant_id),
|
||||
UNIQUE (workspace_id, workdir_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, workdir_id)
|
||||
REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
INSERT INTO external_workdir_grants_v68 (
|
||||
grant_id, workspace_id, workdir_id, provider_instance_id, display_name,
|
||||
permissions, created_by, created_at, expires_at, generation, status, updated_at,
|
||||
cleanup_state, cleanup_error, cleanup_updated_at
|
||||
) SELECT grant_id, workspace_id, workdir_id, provider_instance_id, display_name,
|
||||
permissions, created_by, created_at, expires_at, generation, status, updated_at,
|
||||
cleanup_state, cleanup_error, cleanup_updated_at
|
||||
FROM external_workdir_grants;
|
||||
DROP TABLE external_workdir_grants;
|
||||
ALTER TABLE external_workdir_grants_v68 RENAME TO external_workdir_grants;
|
||||
CREATE INDEX idx_external_workdir_grants_status_expiry
|
||||
ON external_workdir_grants(workspace_id, status, expires_at);
|
||||
"#,
|
||||
)?;
|
||||
let violations =
|
||||
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})?;
|
||||
if violations != 0 {
|
||||
return Err(Error::Store(format!(
|
||||
"schema-68 migration left {violations} foreign-key violation(s)"
|
||||
)));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![68_i64, EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
let restore = if foreign_keys_enabled {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
match (result, restore) {
|
||||
(Err(error), _) => Err(error),
|
||||
(Ok(()), Err(error)) => Err(error),
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
||||
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
||||
@@ -14233,6 +14324,10 @@ mod tests {
|
||||
version: 67,
|
||||
name: EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 68,
|
||||
name: EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -14295,6 +14390,10 @@ mod tests {
|
||||
67,
|
||||
EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
(
|
||||
68,
|
||||
EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -14348,6 +14447,68 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v68_preserves_legacy_external_grants_without_command_authority() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("server.db");
|
||||
prepare_schema_v50(&path, Some("workspace-a"));
|
||||
let mut conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
for migration in MIGRATIONS
|
||||
.iter()
|
||||
.filter(|migration| migration.version <= 67)
|
||||
{
|
||||
(migration.apply)(&conn).unwrap();
|
||||
}
|
||||
let tx = conn.transaction().unwrap();
|
||||
tx.execute_batch(
|
||||
"INSERT INTO external_workdir_grants (
|
||||
grant_id, workspace_id, workdir_id, provider_instance_id, display_name,
|
||||
permissions, created_by, created_at, expires_at, generation, status, updated_at
|
||||
) VALUES
|
||||
('grant-ro', 'workspace-a', 'external-ro', 'provider-ro', 'Read only',
|
||||
'read_only', 'owner', '1', NULL, 1, 'pending', '1'),
|
||||
('grant-rw', 'workspace-a', 'external-rw', 'provider-rw', 'Read write',
|
||||
'read_write', 'owner', '1', NULL, 1, 'pending', '1');
|
||||
INSERT INTO workdir_registry (
|
||||
workspace_id, workdir_id, display_name, source_kind, runtime_id,
|
||||
repository_id, external_grant_id, creation_selector, creation_ref,
|
||||
materialization_status, cleanliness, created_at, updated_at
|
||||
) VALUES
|
||||
('workspace-a', 'external-ro', 'Read only', 'external_grant', NULL,
|
||||
NULL, 'grant-ro', NULL, NULL, 'pending', 'unknown', '1', '1'),
|
||||
('workspace-a', 'external-rw', 'Read write', 'external_grant', NULL,
|
||||
NULL, 'grant-rw', NULL, NULL, 'pending', 'unknown', '1', '1');",
|
||||
)
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
|
||||
migrate_external_workdir_command_permission_v67_to_v68(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 68);
|
||||
let permissions = conn
|
||||
.prepare("SELECT permissions FROM external_workdir_grants ORDER BY grant_id")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
assert_eq!(permissions, vec!["read_only", "read_write"]);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE external_workdir_grants SET permissions = 'read_command' WHERE grant_id = 'grant-ro'",
|
||||
[],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE external_workdir_grants SET permissions = 'write_only' WHERE grant_id = 'grant-rw'",
|
||||
[],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_resumes_from_a_valid_partially_applied_chain() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -14366,7 +14527,7 @@ mod tests {
|
||||
.map(|migration| migration.version)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68
|
||||
]
|
||||
);
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
@@ -14375,7 +14536,7 @@ mod tests {
|
||||
current_schema_version(&conn).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 18);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 19);
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||
|
||||
Reference in New Issue
Block a user