feat: add interactive read-write external workdir sharing
This commit is contained in:
@@ -697,10 +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 = 'read_only'),
|
||||
permissions TEXT NOT NULL CHECK (permissions IN ('read_only', 'read_write')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_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,
|
||||
|
||||
@@ -729,7 +729,7 @@ struct ExternalProviderConnection {
|
||||
workdir_id: String,
|
||||
provider_instance_id: String,
|
||||
generation: u64,
|
||||
expires_at: chrono::DateTime<Utc>,
|
||||
expires_at: Option<chrono::DateTime<Utc>>,
|
||||
capabilities: workdir::WorkdirSessionCapabilities,
|
||||
admission: Arc<tokio::sync::Semaphore>,
|
||||
sender: tokio::sync::mpsc::Sender<ExternalProviderCommand>,
|
||||
@@ -861,7 +861,11 @@ impl ExternalProviderWorkdirSession {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(workdir::WorkdirError::SessionClosed);
|
||||
}
|
||||
if Utc::now() >= self.connection.expires_at {
|
||||
if self
|
||||
.connection
|
||||
.expires_at
|
||||
.is_some_and(|expires_at| Utc::now() >= expires_at)
|
||||
{
|
||||
return Err(workdir::WorkdirError::Unavailable(
|
||||
"External Workdir grant has expired".to_string(),
|
||||
));
|
||||
@@ -877,7 +881,11 @@ impl ExternalProviderWorkdirSession {
|
||||
"External Workdir provider admission is closed".to_string(),
|
||||
)
|
||||
})?;
|
||||
if Utc::now() >= self.connection.expires_at {
|
||||
if self
|
||||
.connection
|
||||
.expires_at
|
||||
.is_some_and(|expires_at| Utc::now() >= expires_at)
|
||||
{
|
||||
return Err(workdir::WorkdirError::Unavailable(
|
||||
"External Workdir grant has expired".to_string(),
|
||||
));
|
||||
@@ -1020,19 +1028,42 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
}
|
||||
async fn write(
|
||||
&self,
|
||||
_request: workdir::WriteRequest,
|
||||
request: workdir::WriteRequest,
|
||||
) -> std::result::Result<workdir::WriteResult, workdir::WorkdirError> {
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Write,
|
||||
))
|
||||
if !self
|
||||
.connection
|
||||
.capabilities
|
||||
.supports(workdir::WorkdirSessionCapability::Write)
|
||||
{
|
||||
return Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Write,
|
||||
));
|
||||
}
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::Write(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::Write(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("write")),
|
||||
}
|
||||
}
|
||||
async fn edit(
|
||||
&self,
|
||||
_request: workdir::EditRequest,
|
||||
request: workdir::EditRequest,
|
||||
) -> std::result::Result<workdir::EditResult, workdir::WorkdirError> {
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Edit,
|
||||
))
|
||||
if !self
|
||||
.connection
|
||||
.capabilities
|
||||
.supports(workdir::WorkdirSessionCapability::Edit)
|
||||
{
|
||||
return Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Edit,
|
||||
));
|
||||
}
|
||||
match self.operate(WorkdirSessionOperation::Edit(request)).await? {
|
||||
WorkdirSessionOperationResult::Edit(value) => Ok(value),
|
||||
_ => Err(Self::mismatch("edit")),
|
||||
}
|
||||
}
|
||||
async fn list(
|
||||
&self,
|
||||
@@ -3415,9 +3446,7 @@ impl WorkspaceApi {
|
||||
code: "external_workdir_unavailable".to_string(),
|
||||
message: "External Workdir grant is unavailable".to_string(),
|
||||
})?;
|
||||
let grant_expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at)
|
||||
.map(|expires_at| expires_at.with_timezone(&Utc) <= Utc::now())
|
||||
.unwrap_or(true);
|
||||
let grant_expired = external_workdir_grant_is_expired(&grant)?;
|
||||
let connection = self
|
||||
.external_workdir_providers
|
||||
.lock()
|
||||
@@ -3429,7 +3458,9 @@ impl WorkspaceApi {
|
||||
connection.generation == grant.generation
|
||||
&& connection.provider_instance_id == grant.provider_instance_id
|
||||
&& connection.workdir_id == link.workdir_id
|
||||
&& connection.expires_at > Utc::now()
|
||||
&& connection
|
||||
.expires_at
|
||||
.is_none_or(|expires_at| expires_at > Utc::now())
|
||||
})
|
||||
{
|
||||
return Err(Error::RuntimeOperationFailed {
|
||||
@@ -3519,7 +3550,7 @@ impl WorkspaceApi {
|
||||
claim.working_directory_id
|
||||
)))
|
||||
})?;
|
||||
claim.capabilities = workdir_source_capabilities(&workdir);
|
||||
claim.capabilities = workdir_source_capabilities(self, &workdir)?;
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3607,7 +3638,7 @@ impl WorkspaceApi {
|
||||
}
|
||||
claim.capabilities = target
|
||||
.capabilities
|
||||
.intersection(workdir_source_capabilities(&workdir));
|
||||
.intersection(workdir_source_capabilities(self, &workdir)?);
|
||||
}
|
||||
|
||||
let missing = targets
|
||||
@@ -3643,14 +3674,27 @@ 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 workdir_source_capabilities(
|
||||
api: &WorkspaceApi,
|
||||
workdir: &WorkdirRegistryRecord,
|
||||
) -> workdir::WorkdirSessionCapabilities {
|
||||
) -> Result<workdir::WorkdirSessionCapabilities> {
|
||||
match &workdir.source {
|
||||
WorkdirRegistrySource::Repository { .. } => workdir::WorkdirSessionCapabilities::ALL,
|
||||
WorkdirRegistrySource::ExternalGrant { .. } => {
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
}
|
||||
WorkdirRegistrySource::Repository { .. } => Ok(workdir::WorkdirSessionCapabilities::ALL),
|
||||
WorkdirRegistrySource::ExternalGrant { grant_id } => api
|
||||
.store
|
||||
.get_external_workdir_grant(&api.config.workspace_id, grant_id)?
|
||||
.ok_or_else(|| Error::Store("External Workdir grant is unavailable".to_string()))
|
||||
.and_then(|grant| external_workdir_capabilities(&grant.permissions)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3750,7 +3794,7 @@ fn ticket_target_capabilities_for_workdir(
|
||||
worker: &RuntimeWorkerRef,
|
||||
workdir: &WorkdirRegistryRecord,
|
||||
) -> Result<workdir::WorkdirSessionCapabilities> {
|
||||
let source_capabilities = workdir_source_capabilities(workdir);
|
||||
let source_capabilities = workdir_source_capabilities(api, workdir)?;
|
||||
let Some(assignment) = api
|
||||
.store
|
||||
.get_current_ticket_role_assignment_for_worker(&api.config.workspace_id, worker)?
|
||||
@@ -3887,7 +3931,7 @@ fn validate_manual_ticket_coder_workdir_binding(
|
||||
}
|
||||
let capabilities = link
|
||||
.capabilities
|
||||
.intersection(workdir_source_capabilities(&workdir))
|
||||
.intersection(workdir_source_capabilities(api, &workdir)?)
|
||||
.intersection(target.capabilities);
|
||||
effective_links.push(WorkerWorkdirLinkRecord {
|
||||
capabilities,
|
||||
@@ -13815,7 +13859,7 @@ async fn open_current_worker_workdir_session_locked(
|
||||
})?;
|
||||
let effective_capabilities = link
|
||||
.capabilities
|
||||
.intersection(workdir_source_capabilities(&workdir))
|
||||
.intersection(workdir_source_capabilities(api, &workdir)?)
|
||||
.intersection(ticket_target_capabilities_for_workdir(
|
||||
api, worker, &workdir,
|
||||
)?);
|
||||
@@ -13829,9 +13873,7 @@ async fn open_current_worker_workdir_session_locked(
|
||||
code: "external_workdir_grant_not_found".to_string(),
|
||||
message: "External Workdir grant is unavailable".to_string(),
|
||||
})?;
|
||||
let expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at)
|
||||
.map(|expires_at| expires_at.with_timezone(&Utc) <= Utc::now())
|
||||
.unwrap_or(true);
|
||||
let expired = external_workdir_grant_is_expired(&grant)?;
|
||||
if expired {
|
||||
grant = fence_external_workdir_grant_expired(api, grant_id)
|
||||
.await
|
||||
@@ -14036,7 +14078,7 @@ fn sync_runtime_worker_workdir_attachments(
|
||||
})?;
|
||||
let capabilities = link
|
||||
.capabilities
|
||||
.intersection(workdir_source_capabilities(&workdir))
|
||||
.intersection(workdir_source_capabilities(api, &workdir)?)
|
||||
.intersection(ticket_target_capabilities_for_workdir(
|
||||
api, worker, &workdir,
|
||||
)?);
|
||||
@@ -14130,9 +14172,7 @@ async fn scoped_attach_current_worker_workdir(
|
||||
code: "external_workdir_unavailable".to_string(),
|
||||
message: "External Workdir grant is unavailable".to_string(),
|
||||
})?;
|
||||
let grant_expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at)
|
||||
.map(|expires_at| expires_at.with_timezone(&Utc) <= Utc::now())
|
||||
.unwrap_or(true);
|
||||
let grant_expired = external_workdir_grant_is_expired(&grant)?;
|
||||
let online = api
|
||||
.external_workdir_providers
|
||||
.lock()
|
||||
@@ -14142,7 +14182,9 @@ async fn scoped_attach_current_worker_workdir(
|
||||
connection.generation == grant.generation
|
||||
&& connection.provider_instance_id == grant.provider_instance_id
|
||||
&& connection.workdir_id == workdir.workdir_id
|
||||
&& connection.expires_at > Utc::now()
|
||||
&& connection
|
||||
.expires_at
|
||||
.is_none_or(|expires_at| expires_at > Utc::now())
|
||||
});
|
||||
if grant.status != "online" || grant_expired || !online {
|
||||
return Err(Error::RuntimeOperationFailed {
|
||||
@@ -16795,13 +16837,32 @@ async fn cleanup_runtime_working_directory(
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
|
||||
fn external_workdir_expires_at(
|
||||
grant: &ExternalWorkdirGrantRecord,
|
||||
) -> Result<Option<chrono::DateTime<Utc>>> {
|
||||
grant
|
||||
.expires_at
|
||||
.as_deref()
|
||||
.map(|value| {
|
||||
chrono::DateTime::parse_from_rfc3339(value)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.map_err(|_| Error::Store("External Workdir grant has invalid expiry".to_string()))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn external_workdir_grant_is_expired(grant: &ExternalWorkdirGrantRecord) -> Result<bool> {
|
||||
Ok(external_workdir_expires_at(grant)?.is_some_and(|expires_at| expires_at <= Utc::now()))
|
||||
}
|
||||
|
||||
fn schedule_external_workdir_expiry(
|
||||
api: &WorkspaceApi,
|
||||
grant: &ExternalWorkdirGrantRecord,
|
||||
) -> Result<()> {
|
||||
let expires_at = chrono::DateTime::parse_from_rfc3339(&grant.expires_at)
|
||||
.map_err(|_| Error::Store("External Workdir grant has invalid expiry".to_string()))?
|
||||
.with_timezone(&Utc);
|
||||
let Some(expires_at) = external_workdir_expires_at(grant)? else {
|
||||
cancel_external_workdir_expiry(api, &grant.grant_id);
|
||||
return Ok(());
|
||||
};
|
||||
let grant_id = grant.grant_id.clone();
|
||||
let task_api = api.clone();
|
||||
let task_grant_id = grant_id.clone();
|
||||
@@ -16882,9 +16943,9 @@ async fn fence_external_workdir_grant_expired(
|
||||
if matches!(grant.status.as_str(), "revoked" | "expired") {
|
||||
return Ok(grant);
|
||||
}
|
||||
let expires_at = chrono::DateTime::parse_from_rfc3339(&grant.expires_at)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.map_err(|_| Error::Store("External Workdir grant has invalid expiry".to_string()))?;
|
||||
let Some(expires_at) = external_workdir_expires_at(&grant)? else {
|
||||
return Ok(grant);
|
||||
};
|
||||
if expires_at > Utc::now() {
|
||||
return Ok(grant);
|
||||
}
|
||||
@@ -16959,15 +17020,12 @@ async fn scoped_create_external_workdir_grant(
|
||||
"External Workdir display name must be between 1 and 100 bytes and contain no control characters".to_string(),
|
||||
).into());
|
||||
}
|
||||
if !request.read_only {
|
||||
if request
|
||||
.ttl_seconds
|
||||
.is_some_and(|ttl_seconds| !(60..=86_400).contains(&ttl_seconds))
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"External Workdir grants currently require read_only=true".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if !(60..=86_400).contains(&request.ttl_seconds) {
|
||||
return Err(Error::InvalidInput(
|
||||
"External Workdir ttl_seconds must be between 60 and 86400".to_string(),
|
||||
"External Workdir ttl_seconds must be omitted or between 60 and 86400".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
@@ -16975,8 +17033,14 @@ async fn scoped_create_external_workdir_grant(
|
||||
.map_err(Error::InvalidInput)?;
|
||||
let now = Utc::now();
|
||||
let created_at = now.to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||
let expires_at = (now + Duration::seconds(request.ttl_seconds as i64))
|
||||
.to_rfc3339_opts(SecondsFormat::Nanos, true);
|
||||
let expires_at = request
|
||||
.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 grant_id = format!("ewg-{}", Uuid::now_v7());
|
||||
let workdir_id = format!("external-{}", Uuid::now_v7());
|
||||
let grant = ExternalWorkdirGrantRecord {
|
||||
@@ -16985,7 +17049,7 @@ async fn scoped_create_external_workdir_grant(
|
||||
workdir_id: workdir_id.clone(),
|
||||
provider_instance_id: request.provider_instance_id,
|
||||
display_name: display_name.to_string(),
|
||||
permissions: "read_only".to_string(),
|
||||
permissions: permissions.to_string(),
|
||||
created_by: actor.account_id,
|
||||
created_at: created_at.clone(),
|
||||
expires_at: expires_at.clone(),
|
||||
@@ -17029,7 +17093,10 @@ fn external_workdir_grant_response(
|
||||
working_directory_id: grant.workdir_id.clone(),
|
||||
provider_instance_id: grant.provider_instance_id.clone(),
|
||||
display_name: grant.display_name.clone(),
|
||||
permissions: grant.permissions.clone(),
|
||||
permission: match grant.permissions.as_str() {
|
||||
"read_write" => server_api::ExternalWorkdirPermission::ReadWrite,
|
||||
_ => server_api::ExternalWorkdirPermission::ReadOnly,
|
||||
},
|
||||
expires_at: grant.expires_at.clone(),
|
||||
generation: grant.generation,
|
||||
status: grant.status.clone(),
|
||||
@@ -17052,10 +17119,7 @@ async fn scoped_external_workdir_provider_ws(
|
||||
"External Workdir provider connection must use the grant creator's authenticated client".to_string(),
|
||||
).into());
|
||||
}
|
||||
let now = Utc::now();
|
||||
let expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at)
|
||||
.map(|expires_at| expires_at.with_timezone(&Utc) <= now)
|
||||
.unwrap_or(true);
|
||||
let expired = external_workdir_grant_is_expired(&grant)?;
|
||||
if expired {
|
||||
grant = fence_external_workdir_grant_expired(&api, &grant_id).await?;
|
||||
}
|
||||
@@ -17109,10 +17173,17 @@ async fn serve_external_workdir_provider(
|
||||
let _ = socket.close().await;
|
||||
return;
|
||||
};
|
||||
let expected_capabilities = match external_workdir_capabilities(&initial_grant.permissions) {
|
||||
Ok(capabilities) => capabilities,
|
||||
Err(_) => {
|
||||
let _ = socket.close().await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if registration.grant_id.as_str() != initial_grant.grant_id
|
||||
|| registration.workdir_id.as_str() != initial_grant.workdir_id
|
||||
|| registration.provider_instance_id.as_str() != initial_grant.provider_instance_id
|
||||
|| registration.capabilities != workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
|| registration.capabilities != expected_capabilities
|
||||
|| registration.read_limits != workdir::BoundedReadLimits::EXTERNAL_DEFAULT
|
||||
|| registration.generation == 0
|
||||
{
|
||||
@@ -17165,8 +17236,8 @@ async fn serve_external_workdir_provider(
|
||||
let _ = socket.close().await;
|
||||
return;
|
||||
}
|
||||
let expires_at = match chrono::DateTime::parse_from_rfc3339(&initial_grant.expires_at) {
|
||||
Ok(value) => value.with_timezone(&Utc),
|
||||
let expires_at = match external_workdir_expires_at(&initial_grant) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
let _ = socket.close().await;
|
||||
return;
|
||||
@@ -17419,11 +17490,8 @@ async fn scoped_get_external_workdir_grant(
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let now = Utc::now();
|
||||
let expired = !matches!(grant.status.as_str(), "revoked" | "expired")
|
||||
&& chrono::DateTime::parse_from_rfc3339(&grant.expires_at)
|
||||
.map(|expires_at| expires_at.with_timezone(&Utc) <= now)
|
||||
.unwrap_or(true);
|
||||
&& external_workdir_grant_is_expired(&grant)?;
|
||||
if expired {
|
||||
grant = fence_external_workdir_grant_expired(&api, &grant_id).await?;
|
||||
}
|
||||
@@ -21856,7 +21924,7 @@ fn finalize_spawned_worker_workdir_attachments(
|
||||
worker_record,
|
||||
attachment.alias.as_str(),
|
||||
&summary.working_directory_id,
|
||||
workdir_source_capabilities(&workdir_record),
|
||||
workdir_source_capabilities(api, &workdir_record)?,
|
||||
None,
|
||||
),
|
||||
)?;
|
||||
@@ -26074,7 +26142,7 @@ mod tests {
|
||||
workdir_id: "external-a".to_string(),
|
||||
provider_instance_id: "provider-a".to_string(),
|
||||
generation: 4,
|
||||
expires_at: Utc::now() + Duration::minutes(1),
|
||||
expires_at: Some(Utc::now() + Duration::minutes(1)),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
admission: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||
sender,
|
||||
@@ -26130,6 +26198,70 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_provider_read_write_session_routes_files_but_never_commands() {
|
||||
let (sender, mut commands) = tokio::sync::mpsc::channel(1);
|
||||
let connection = Arc::new(ExternalProviderConnection {
|
||||
grant_id: "grant-rw".to_string(),
|
||||
workdir_id: "external-rw".to_string(),
|
||||
provider_instance_id: "provider-rw".to_string(),
|
||||
generation: 2,
|
||||
expires_at: None,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_WRITE,
|
||||
admission: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||
sender,
|
||||
});
|
||||
let session = Arc::new(ExternalProviderWorkdirSession::new(connection, None));
|
||||
let writer = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
session
|
||||
.write(workdir::WriteRequest {
|
||||
path: workdir::WorkdirPath::new("created.txt").unwrap(),
|
||||
content: b"created".to_vec(),
|
||||
expected_hash: None,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let Some(ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
}) = commands.recv().await
|
||||
else {
|
||||
panic!("expected provider write operation");
|
||||
};
|
||||
assert!(matches!(operation, WorkdirSessionOperation::Write(_)));
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::Write(
|
||||
workdir::WriteResult {
|
||||
bytes_written: 7,
|
||||
created: true,
|
||||
},
|
||||
)))
|
||||
.unwrap();
|
||||
assert!(writer.await.unwrap().unwrap().created);
|
||||
|
||||
let command = 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,
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(
|
||||
command,
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Command
|
||||
))
|
||||
));
|
||||
assert!(commands.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_expiry_fences_offline_grant_and_persists_cleanup_completion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -26146,8 +26278,10 @@ mod tests {
|
||||
permissions: "read_only".to_string(),
|
||||
created_by: test_browser_request_actor().account_id,
|
||||
created_at: created_at.clone(),
|
||||
expires_at: (now + Duration::milliseconds(50))
|
||||
.to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
expires_at: Some(
|
||||
(now + Duration::milliseconds(50))
|
||||
.to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
),
|
||||
generation: 1,
|
||||
status: "offline".to_string(),
|
||||
updated_at: created_at.clone(),
|
||||
@@ -26246,7 +26380,9 @@ mod tests {
|
||||
permissions: "read_only".to_string(),
|
||||
created_by: test_browser_request_actor().account_id,
|
||||
created_at: created_at.clone(),
|
||||
expires_at: (now + Duration::minutes(5)).to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
expires_at: Some(
|
||||
(now + Duration::minutes(5)).to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
),
|
||||
generation: 1,
|
||||
status: "online".to_string(),
|
||||
updated_at: created_at.clone(),
|
||||
@@ -26451,7 +26587,7 @@ mod tests {
|
||||
permissions: "read_only".to_string(),
|
||||
created_by: test_browser_request_actor().account_id,
|
||||
created_at: now.clone(),
|
||||
expires_at: expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true),
|
||||
expires_at: Some(expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true)),
|
||||
generation: 1,
|
||||
status: "pending".to_string(),
|
||||
updated_at: now.clone(),
|
||||
@@ -26498,7 +26634,7 @@ mod tests {
|
||||
workdir_id: grant.workdir_id.clone(),
|
||||
provider_instance_id: grant.provider_instance_id.clone(),
|
||||
generation: 1,
|
||||
expires_at,
|
||||
expires_at: Some(expires_at),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
admission: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||
sender,
|
||||
@@ -26590,8 +26726,8 @@ mod tests {
|
||||
Json(ExternalWorkdirGrantCreateRequest {
|
||||
provider_instance_id: "provider-a".to_string(),
|
||||
display_name: "Session logs".to_string(),
|
||||
ttl_seconds: 600,
|
||||
read_only: true,
|
||||
ttl_seconds: Some(600),
|
||||
permission: server_api::ExternalWorkdirPermission::ReadWrite,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -26620,7 +26756,7 @@ mod tests {
|
||||
.id()
|
||||
.clone(),
|
||||
generation,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_WRITE,
|
||||
read_limits: workdir::BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
},
|
||||
})
|
||||
@@ -26716,14 +26852,27 @@ mod tests {
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
|
||||
.unwrap();
|
||||
assert_eq!(links.len(), 2);
|
||||
assert!(
|
||||
links.iter().all(|link| {
|
||||
link.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
})
|
||||
let repository_capabilities = links
|
||||
.iter()
|
||||
.find(|link| link.alias == "repository")
|
||||
.unwrap()
|
||||
.capabilities;
|
||||
let external_capabilities = links
|
||||
.iter()
|
||||
.find(|link| link.alias == "session-logs")
|
||||
.unwrap()
|
||||
.capabilities;
|
||||
assert_eq!(
|
||||
repository_capabilities,
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
assert_eq!(
|
||||
external_capabilities,
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE
|
||||
);
|
||||
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_only(
|
||||
let local_session = workdir::LocalWorkdirSession::external_read_write(
|
||||
workdir::Workdir::new(&grant.working_directory_id),
|
||||
provider_root.path(),
|
||||
workdir::BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
@@ -26737,7 +26886,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
broker_session.capabilities(),
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE
|
||||
);
|
||||
drop(session_guard);
|
||||
let read = {
|
||||
@@ -26787,7 +26936,137 @@ mod tests {
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(read.await.unwrap().unwrap().bytes, b"one\ntwo\n");
|
||||
let read_result = read.await.unwrap().unwrap();
|
||||
assert_eq!(read_result.bytes, b"one\ntwo\n");
|
||||
|
||||
let edit = {
|
||||
let broker_session = broker_session.clone();
|
||||
let expected_hash = read_result.content_hash;
|
||||
tokio::spawn(async move {
|
||||
broker_session
|
||||
.edit(workdir::EditRequest {
|
||||
path: workdir::WorkdirPath::new("events.jsonl").unwrap(),
|
||||
old_string: "two".to_string(),
|
||||
new_string: "changed".to_string(),
|
||||
replace_all: false,
|
||||
expected_hash,
|
||||
})
|
||||
.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::Edit(_)));
|
||||
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();
|
||||
assert_eq!(edit.await.unwrap().unwrap().replacements, 1);
|
||||
|
||||
let write = {
|
||||
let broker_session = broker_session.clone();
|
||||
tokio::spawn(async move {
|
||||
broker_session
|
||||
.write(workdir::WriteRequest {
|
||||
path: workdir::WorkdirPath::new("created.txt").unwrap(),
|
||||
content: b"created".to_vec(),
|
||||
expected_hash: None,
|
||||
})
|
||||
.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::Write(_)));
|
||||
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();
|
||||
assert!(write.await.unwrap().unwrap().created);
|
||||
assert_eq!(
|
||||
fs::read_to_string(provider_root.path().join("events.jsonl")).unwrap(),
|
||||
"one\nchanged\n"
|
||||
);
|
||||
assert_eq!(
|
||||
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,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
command_error,
|
||||
workdir::WorkdirError::Unsupported(workdir::WorkdirSessionCapability::Command)
|
||||
| workdir::WorkdirError::UnsupportedOperation(_)
|
||||
| workdir::WorkdirError::Denied(_)
|
||||
),
|
||||
"unexpected command error: {command_error:?}"
|
||||
);
|
||||
|
||||
let Json(revoked) = scoped_revoke_external_workdir_grant(
|
||||
State(api.clone()),
|
||||
@@ -26829,7 +27108,7 @@ mod tests {
|
||||
workdir_id: "external-a".to_string(),
|
||||
provider_instance_id: "provider-a".to_string(),
|
||||
generation: 2,
|
||||
expires_at: Utc::now() + Duration::minutes(1),
|
||||
expires_at: Some(Utc::now() + Duration::minutes(1)),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
admission: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||
sender,
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 66;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 67;
|
||||
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";
|
||||
@@ -48,6 +48,8 @@ const EXTERNAL_WORKDIR_CLEANUP_MIGRATION_NAME: &str =
|
||||
"durable External Workdir expiry cleanup state";
|
||||
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 TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
||||
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
||||
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
||||
@@ -175,6 +177,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME,
|
||||
apply: migrate_ticket_targets_and_workdir_capabilities_v65_to_v66,
|
||||
},
|
||||
Migration {
|
||||
version: 67,
|
||||
name: EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME,
|
||||
apply: migrate_external_workdir_access_and_optional_expiry_v66_to_v67,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -870,7 +877,7 @@ pub struct ExternalWorkdirGrantRecord {
|
||||
pub permissions: String,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub expires_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
pub generation: u64,
|
||||
pub status: String,
|
||||
pub updated_at: String,
|
||||
@@ -7754,7 +7761,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
WHERE workspace_id = ?1 AND grant_id = ?2 AND provider_instance_id = ?3
|
||||
AND ((status = 'pending' AND generation = ?4)
|
||||
OR (status = 'offline' AND generation + 1 = ?4))
|
||||
AND expires_at > ?5"#,
|
||||
AND (expires_at IS NULL OR expires_at > ?5)"#,
|
||||
params![
|
||||
workspace_id,
|
||||
grant_id,
|
||||
@@ -7827,12 +7834,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
SET status = 'expired', updated_at = ?2, cleanup_state = 'pending',
|
||||
cleanup_error = NULL, cleanup_updated_at = ?2
|
||||
WHERE workspace_id = ?1 AND status IN ('pending', 'online', 'offline')
|
||||
AND expires_at <= ?2",
|
||||
AND expires_at IS NOT NULL AND expires_at <= ?2",
|
||||
params![workspace_id, now],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE external_workdir_grants SET status = 'offline', updated_at = ?2
|
||||
WHERE workspace_id = ?1 AND status = 'online' AND expires_at > ?2",
|
||||
WHERE workspace_id = ?1 AND status = 'online'
|
||||
AND (expires_at IS NULL OR expires_at > ?2)",
|
||||
params![workspace_id, now],
|
||||
)?;
|
||||
tx.execute(
|
||||
@@ -9130,9 +9138,12 @@ fn encode_workdir_link_capabilities(
|
||||
Ok("all")
|
||||
} else if capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY {
|
||||
Ok("read_only")
|
||||
} else if capabilities == workdir::WorkdirSessionCapabilities::READ_WRITE {
|
||||
Ok("read_write")
|
||||
} else {
|
||||
Err(Error::InvalidInput(
|
||||
"Workdir attachment capabilities must be exactly all or read_only".to_string(),
|
||||
"Workdir attachment capabilities must be exactly all, read_only, or read_write"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -9144,6 +9155,7 @@ fn decode_workdir_link_capabilities(
|
||||
match value {
|
||||
"all" => Ok(workdir::WorkdirSessionCapabilities::ALL),
|
||||
"read_only" => Ok(workdir::WorkdirSessionCapabilities::READ_ONLY),
|
||||
"read_write" => Ok(workdir::WorkdirSessionCapabilities::READ_WRITE),
|
||||
_ => Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
column,
|
||||
rusqlite::types::Type::Text,
|
||||
@@ -11957,6 +11969,122 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_external_workdir_access_and_optional_expiry_v66_to_v67(
|
||||
conn: &Connection,
|
||||
) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if current != 66 {
|
||||
return Err(Error::Store(format!(
|
||||
"expected schema version 66 before {EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_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_v67 (
|
||||
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')),
|
||||
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_v67 (
|
||||
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_v67 RENAME TO external_workdir_grants;
|
||||
CREATE INDEX idx_external_workdir_grants_status_expiry
|
||||
ON external_workdir_grants(workspace_id, status, expires_at);
|
||||
CREATE TABLE worker_workdir_links_v67 (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
workdir_id TEXT NOT NULL,
|
||||
alias TEXT NOT NULL,
|
||||
capabilities TEXT NOT NULL CHECK (capabilities IN ('all', 'read_only', 'read_write')),
|
||||
linked_at TEXT NOT NULL,
|
||||
unlinked_at TEXT,
|
||||
PRIMARY KEY (workspace_id, worker_id, workdir_id, alias),
|
||||
FOREIGN KEY (workspace_id, worker_id)
|
||||
REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, workdir_id)
|
||||
REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO worker_workdir_links_v67 (
|
||||
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
|
||||
linked_at, unlinked_at
|
||||
) SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
|
||||
linked_at, unlinked_at
|
||||
FROM worker_workdir_links;
|
||||
DROP TABLE worker_workdir_links;
|
||||
ALTER TABLE worker_workdir_links_v67 RENAME TO worker_workdir_links;
|
||||
CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique
|
||||
ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL;
|
||||
CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique
|
||||
ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL;
|
||||
CREATE INDEX worker_workdir_links_workdir
|
||||
ON worker_workdir_links(workspace_id, workdir_id);
|
||||
"#,
|
||||
)?;
|
||||
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-67 migration left {violations} foreign-key violation(s)"
|
||||
)));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![
|
||||
67_i64,
|
||||
EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_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"))?;
|
||||
@@ -12019,7 +12147,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
ON typed_ticket_targets(workspace_id, repository_key, ticket_id);
|
||||
ALTER TABLE worker_workdir_links
|
||||
ADD COLUMN capabilities TEXT NOT NULL
|
||||
CHECK (capabilities IN ('all', 'read_only'));
|
||||
CHECK (capabilities IN ('all', 'read_only', 'read_write'));
|
||||
"#,
|
||||
)?;
|
||||
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
|
||||
@@ -12745,10 +12873,10 @@ mod tests {
|
||||
workdir_id: "external-a".to_string(),
|
||||
provider_instance_id: "provider-a".to_string(),
|
||||
display_name: "Session logs".to_string(),
|
||||
permissions: "read_only".to_string(),
|
||||
permissions: "read_write".to_string(),
|
||||
created_by: "owner".to_string(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
expires_at: "2099-01-01T00:00:00Z".to_string(),
|
||||
expires_at: None,
|
||||
generation: 1,
|
||||
status: "pending".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
@@ -12775,6 +12903,12 @@ mod tests {
|
||||
store
|
||||
.create_external_workdir_grant(&grant, &workdir)
|
||||
.unwrap();
|
||||
let persisted_grant = store
|
||||
.get_external_workdir_grant("workspace-a", "grant-a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(persisted_grant.permissions, "read_write");
|
||||
assert_eq!(persisted_grant.expires_at, None);
|
||||
store
|
||||
.record_external_workdir_audit(
|
||||
"workspace-a",
|
||||
@@ -12858,6 +12992,14 @@ mod tests {
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.get_external_workdir_grant("workspace-a", "grant-a")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.status,
|
||||
"offline"
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.activate_external_workdir_grant(
|
||||
|
||||
Reference in New Issue
Block a user