fix: preserve external command attachment authority
This commit is contained in:
@@ -3973,6 +3973,21 @@ fn external_workdir_capabilities(permissions: &str) -> Result<workdir::WorkdirSe
|
||||
Ok(capabilities)
|
||||
}
|
||||
|
||||
fn workdir_permission_summary(
|
||||
capabilities: workdir::WorkdirSessionCapabilities,
|
||||
) -> server_api::ExternalWorkdirPermissions {
|
||||
let read = capabilities.supports(workdir::WorkdirSessionCapability::Read)
|
||||
&& capabilities.supports(workdir::WorkdirSessionCapability::Glob)
|
||||
&& capabilities.supports(workdir::WorkdirSessionCapability::Grep);
|
||||
server_api::ExternalWorkdirPermissions {
|
||||
read,
|
||||
write: read
|
||||
&& capabilities.supports(workdir::WorkdirSessionCapability::Write)
|
||||
&& capabilities.supports(workdir::WorkdirSessionCapability::Edit),
|
||||
command: capabilities.supports(workdir::WorkdirSessionCapability::Command),
|
||||
}
|
||||
}
|
||||
|
||||
fn workdir_source_capabilities(
|
||||
api: &WorkspaceApi,
|
||||
workdir: &WorkdirRegistryRecord,
|
||||
@@ -4117,6 +4132,19 @@ fn ticket_target_capabilities_for_workdir(
|
||||
Ok(target.capabilities.intersection(source_capabilities))
|
||||
}
|
||||
|
||||
fn effective_worker_workdir_capabilities(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
workdir: &WorkdirRegistryRecord,
|
||||
link_capabilities: workdir::WorkdirSessionCapabilities,
|
||||
) -> Result<workdir::WorkdirSessionCapabilities> {
|
||||
Ok(link_capabilities
|
||||
.intersection(workdir_source_capabilities(api, workdir)?)
|
||||
.intersection(ticket_target_capabilities_for_workdir(
|
||||
api, worker, workdir,
|
||||
)?))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ManualTicketCoderWorkdirBinding {
|
||||
original_links: Vec<WorkerWorkdirLinkRecord>,
|
||||
@@ -14146,12 +14174,8 @@ async fn open_current_worker_workdir_session_locked(
|
||||
link.workdir_id
|
||||
),
|
||||
})?;
|
||||
let effective_capabilities = link
|
||||
.capabilities
|
||||
.intersection(workdir_source_capabilities(api, &workdir)?)
|
||||
.intersection(ticket_target_capabilities_for_workdir(
|
||||
api, worker, &workdir,
|
||||
)?);
|
||||
let effective_capabilities =
|
||||
effective_worker_workdir_capabilities(api, worker, &workdir, link.capabilities)?;
|
||||
if let WorkdirRegistrySource::ExternalGrant { grant_id } = &workdir.source {
|
||||
close_current_worker_attachment_session_locked(api, worker).await?;
|
||||
let mut grant = api
|
||||
@@ -23752,6 +23776,9 @@ fn project_workspace_worker(
|
||||
api: &WorkspaceApi,
|
||||
summary: InternalWorkerSummary,
|
||||
) -> ApiResult<server_api::WorkerSummary> {
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(&api.config.workspace_id, &summary.worker)?;
|
||||
let workdir_attachments = summary
|
||||
.workdir_attachments
|
||||
.iter()
|
||||
@@ -23761,8 +23788,29 @@ fn project_workspace_worker(
|
||||
&summary.worker.runtime_id,
|
||||
&attachment.working_directory.summary,
|
||||
);
|
||||
let link = links
|
||||
.iter()
|
||||
.find(|link| {
|
||||
link.unlinked_at.is_none()
|
||||
&& link.alias == attachment.alias.as_str()
|
||||
&& link.workdir_id == record.workdir_id
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
Error::RegistryInconsistency(format!(
|
||||
"Worker {}:{} attachment `{}` has no active durable Workdir link",
|
||||
summary.worker.runtime_id, summary.worker.worker_id, attachment.alias
|
||||
))
|
||||
})?;
|
||||
Ok(server_api::WorkerWorkdirAttachmentSummary {
|
||||
alias: attachment.alias.to_string(),
|
||||
effective_permissions: workdir_permission_summary(
|
||||
effective_worker_workdir_capabilities(
|
||||
api,
|
||||
&summary.worker,
|
||||
&record,
|
||||
link.capabilities,
|
||||
)?,
|
||||
),
|
||||
working_directory: projected_workdir_summary_from_record(api, &record)?.into(),
|
||||
})
|
||||
})
|
||||
@@ -24720,10 +24768,14 @@ fn project_worker_registry_projection(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let effective_permissions = workdir_permission_summary(
|
||||
effective_worker_workdir_capabilities(api, &record.worker, workdir, link.capabilities)?,
|
||||
);
|
||||
workdir_attachments.push(server_api::WorkerWorkdirAttachmentSummary {
|
||||
alias: workdir::WorkdirAttachmentAlias::new(link.alias)
|
||||
.expect("persisted attachment aliases are validated on write")
|
||||
.to_string(),
|
||||
effective_permissions,
|
||||
working_directory: projected_workdir_summary_from_record(api, workdir)?.into(),
|
||||
});
|
||||
}
|
||||
@@ -25106,6 +25158,7 @@ fn runtime_workdir_summary_from_record(
|
||||
fn workdir_summary_from_record(
|
||||
record: &WorkdirRegistryRecord,
|
||||
repository_key: Option<&str>,
|
||||
external_grant_permissions: Option<workdir::workspace::WorkdirPermissionSummary>,
|
||||
) -> WorkingDirectorySummary {
|
||||
let status = match record.materialization_status.as_str() {
|
||||
"present" => WorkingDirectoryStatusKind::Active,
|
||||
@@ -25134,6 +25187,8 @@ fn workdir_summary_from_record(
|
||||
WorkdirRegistrySource::ExternalGrant { grant_id } => (
|
||||
WorkingDirectorySource::ExternalGrant {
|
||||
grant_id: grant_id.clone(),
|
||||
grant_permissions: external_grant_permissions
|
||||
.expect("External Workdir projection requires grant permissions"),
|
||||
},
|
||||
MaterializerKind::ClientHostedExternal,
|
||||
None,
|
||||
@@ -25192,16 +25247,39 @@ fn projected_workdir_summary_from_record(
|
||||
api: &WorkspaceApi,
|
||||
record: &WorkdirRegistryRecord,
|
||||
) -> Result<WorkingDirectorySummary> {
|
||||
let repository_key = match &record.source {
|
||||
WorkdirRegistrySource::Repository { repository_id, .. } => Some(
|
||||
api.store
|
||||
.get_repository(&record.workspace_id, repository_id)?
|
||||
.ok_or_else(|| Error::UnknownRepository(repository_id.clone()))?
|
||||
.repository_key,
|
||||
let (repository_key, external_grant_permissions) = match &record.source {
|
||||
WorkdirRegistrySource::Repository { repository_id, .. } => (
|
||||
Some(
|
||||
api.store
|
||||
.get_repository(&record.workspace_id, repository_id)?
|
||||
.ok_or_else(|| Error::UnknownRepository(repository_id.clone()))?
|
||||
.repository_key,
|
||||
),
|
||||
None,
|
||||
),
|
||||
WorkdirRegistrySource::ExternalGrant { grant_id } => (
|
||||
None,
|
||||
Some({
|
||||
let permissions = api
|
||||
.store
|
||||
.get_external_workdir_grant(&record.workspace_id, grant_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::Store("External Workdir grant is unavailable".to_string())
|
||||
})
|
||||
.and_then(|grant| external_workdir_permissions(&grant.permissions))?;
|
||||
workdir::workspace::WorkdirPermissionSummary {
|
||||
read: permissions.read,
|
||||
write: permissions.write,
|
||||
command: permissions.command,
|
||||
}
|
||||
}),
|
||||
),
|
||||
WorkdirRegistrySource::ExternalGrant { .. } => None,
|
||||
};
|
||||
let mut summary = workdir_summary_from_record(record, repository_key.as_deref());
|
||||
let mut summary = workdir_summary_from_record(
|
||||
record,
|
||||
repository_key.as_deref(),
|
||||
external_grant_permissions,
|
||||
);
|
||||
apply_workdir_occupancy_projection(api, &mut summary)?;
|
||||
Ok(summary)
|
||||
}
|
||||
@@ -27375,13 +27453,25 @@ mod tests {
|
||||
let mut workdir_ids = Vec::new();
|
||||
|
||||
for index in 1..=2 {
|
||||
let (permissions, capabilities) = if index == 1 {
|
||||
(
|
||||
"command_only",
|
||||
workdir::WorkdirSessionCapabilities::COMMAND_ONLY,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"read_command",
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY),
|
||||
)
|
||||
};
|
||||
let grant = ExternalWorkdirGrantRecord {
|
||||
grant_id: format!("launch-grant-{index}"),
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
workdir_id: format!("launch-external-{index}"),
|
||||
provider_instance_id: format!("launch-provider-{index}"),
|
||||
display_name: format!("External launch {index}"),
|
||||
permissions: "read_only".to_string(),
|
||||
permissions: permissions.to_string(),
|
||||
created_by: test_browser_request_actor().account_id,
|
||||
created_at: now.clone(),
|
||||
expires_at: Some(expires_at.to_rfc3339_opts(SecondsFormat::Nanos, true)),
|
||||
@@ -27432,7 +27522,7 @@ mod tests {
|
||||
provider_instance_id: grant.provider_instance_id.clone(),
|
||||
generation: 1,
|
||||
expires_at: Some(expires_at),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
capabilities,
|
||||
admission: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||
shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
sender,
|
||||
@@ -27454,6 +27544,33 @@ mod tests {
|
||||
.map(|summary| summary.working_directory_id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
assert!(workdir_ids.iter().all(|id| available.contains(id.as_str())));
|
||||
for (index, workdir_id) in workdir_ids.iter().enumerate() {
|
||||
let summary = options
|
||||
.working_directories
|
||||
.iter()
|
||||
.find(|summary| summary.working_directory_id == *workdir_id)
|
||||
.unwrap();
|
||||
let expected_permissions = if index == 0 {
|
||||
server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: true,
|
||||
}
|
||||
} else {
|
||||
server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: false,
|
||||
command: true,
|
||||
}
|
||||
};
|
||||
assert!(matches!(
|
||||
&summary.source,
|
||||
server_api::WorkingDirectorySource::ExternalGrant {
|
||||
grant_permissions,
|
||||
..
|
||||
} if *grant_permissions == expected_permissions
|
||||
));
|
||||
}
|
||||
|
||||
let Json(worker) = create_workspace_worker(
|
||||
State(api.clone()),
|
||||
@@ -27491,7 +27608,194 @@ mod tests {
|
||||
.collect::<HashSet<_>>(),
|
||||
HashSet::from(["primary", "reference"]),
|
||||
);
|
||||
drop(receivers);
|
||||
|
||||
let worker_ref = RuntimeWorkerRef::new(&worker.runtime_id, &worker.worker_id);
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker_ref)
|
||||
.unwrap();
|
||||
let projected_worker = workers_response(api.clone())
|
||||
.unwrap()
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.worker_id == worker.worker_id)
|
||||
.unwrap();
|
||||
for (index, receiver) in receivers.iter_mut().enumerate() {
|
||||
let expected_capabilities = if index == 0 {
|
||||
workdir::WorkdirSessionCapabilities::COMMAND_ONLY
|
||||
} else {
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY)
|
||||
};
|
||||
let link = links
|
||||
.iter()
|
||||
.find(|link| link.workdir_id == workdir_ids[index])
|
||||
.unwrap();
|
||||
assert_eq!(link.capabilities, expected_capabilities);
|
||||
let projected_attachment = projected_worker
|
||||
.workdir_attachments
|
||||
.iter()
|
||||
.find(|attachment| {
|
||||
attachment.working_directory.working_directory_id == workdir_ids[index]
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
projected_attachment.effective_permissions,
|
||||
workdir_permission_summary(expected_capabilities)
|
||||
);
|
||||
|
||||
let session_lock = current_worker_session_lock(&api, &worker_ref);
|
||||
let session_guard = session_lock.lock().await;
|
||||
let session = open_current_worker_workdir_session_locked(&api, &worker_ref, link)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(session_guard);
|
||||
assert_eq!(session.capabilities(), expected_capabilities);
|
||||
if index == 0 {
|
||||
let denied = session
|
||||
.read(workdir::ReadRequest {
|
||||
path: workdir::WorkdirPath::new("forbidden.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
max_bytes: 1,
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(
|
||||
denied,
|
||||
Err(workdir::WorkdirError::Unsupported(
|
||||
workdir::WorkdirSessionCapability::Read
|
||||
)) | Err(workdir::WorkdirError::Denied(_))
|
||||
));
|
||||
}
|
||||
|
||||
let mut starter = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
session
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "printf attached".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let provider_handle = workdir::CommandHandle(format!("provider-command-{index}"));
|
||||
let provider_operation = tokio::select! {
|
||||
result = &mut starter => panic!("attached command ended before provider routing: {result:?}"),
|
||||
operation = receiver.recv() => operation,
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {
|
||||
panic!("attached command was not routed to the provider")
|
||||
}
|
||||
};
|
||||
let Some(ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
}) = provider_operation
|
||||
else {
|
||||
panic!("expected attached command start operation");
|
||||
};
|
||||
assert!(matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandStart(_)
|
||||
));
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandStart(
|
||||
provider_handle.clone(),
|
||||
)))
|
||||
.unwrap();
|
||||
let external_handle = tokio::time::timeout(std::time::Duration::from_secs(2), starter)
|
||||
.await
|
||||
.expect("attached command start response timed out")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let output_handle = external_handle.clone();
|
||||
let mut canceler = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move { session.cancel_command(external_handle).await })
|
||||
};
|
||||
let provider_cancellation = tokio::select! {
|
||||
result = &mut canceler => panic!("attached cancellation ended before provider routing: {result:?}"),
|
||||
operation = receiver.recv() => operation,
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {
|
||||
panic!("attached cancellation was not routed to the provider")
|
||||
}
|
||||
};
|
||||
let Some(ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
}) = provider_cancellation
|
||||
else {
|
||||
panic!("expected attached command cancellation");
|
||||
};
|
||||
assert!(matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandCancel(ref handle) if handle == &provider_handle
|
||||
));
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandCancel))
|
||||
.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), canceler)
|
||||
.await
|
||||
.expect("attached command cancellation response timed out")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let output = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: output_handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let Some(ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
}) = tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv())
|
||||
.await
|
||||
.expect("attached terminal output was not routed to the provider")
|
||||
else {
|
||||
panic!("expected attached terminal command output");
|
||||
};
|
||||
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();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), output)
|
||||
.await
|
||||
.expect("attached terminal output response timed out")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -29751,7 +30055,7 @@ mod tests {
|
||||
assert_eq!(attachment.alias, "attachment");
|
||||
assert!(matches!(
|
||||
&attachment.working_directory.source,
|
||||
server_api::WorkingDirectorySource::ExternalGrant { grant_id }
|
||||
server_api::WorkingDirectorySource::ExternalGrant { grant_id, .. }
|
||||
if grant_id == "external-grant"
|
||||
));
|
||||
assert_eq!(
|
||||
@@ -32031,7 +32335,7 @@ mod tests {
|
||||
updated_at: "2".to_string(),
|
||||
};
|
||||
|
||||
let projected = workdir_summary_from_record(&workdir, Some("main"));
|
||||
let projected = workdir_summary_from_record(&workdir, Some("main"), None);
|
||||
|
||||
assert_eq!(projected.status, WorkingDirectoryStatusKind::Active);
|
||||
let serialized = serde_json::to_string(&projected).unwrap();
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 68;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 69;
|
||||
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";
|
||||
@@ -52,6 +52,8 @@ 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 WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME: &str =
|
||||
"External Workdir command attachment capabilities";
|
||||
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
||||
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
||||
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
||||
@@ -189,6 +191,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME,
|
||||
apply: migrate_external_workdir_command_permission_v67_to_v68,
|
||||
},
|
||||
Migration {
|
||||
version: 69,
|
||||
name: WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME,
|
||||
apply: migrate_workdir_command_attachment_capabilities_v68_to_v69,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -9138,37 +9145,52 @@ fn read_device_login_flow_record(
|
||||
})
|
||||
}
|
||||
|
||||
const WORKDIR_LINK_CAPABILITY_ENCODINGS: &[(workdir::WorkdirSessionCapabilities, &str)] = &[
|
||||
(workdir::WorkdirSessionCapabilities::ALL, "all"),
|
||||
(workdir::WorkdirSessionCapabilities::READ_ONLY, "read_only"),
|
||||
(
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE,
|
||||
"read_write",
|
||||
),
|
||||
(
|
||||
workdir::WorkdirSessionCapabilities::COMMAND_ONLY,
|
||||
"command_only",
|
||||
),
|
||||
(
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY),
|
||||
"read_command",
|
||||
),
|
||||
];
|
||||
|
||||
fn encode_workdir_link_capabilities(
|
||||
capabilities: workdir::WorkdirSessionCapabilities,
|
||||
) -> Result<&'static str> {
|
||||
if capabilities == workdir::WorkdirSessionCapabilities::ALL {
|
||||
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, read_only, or read_write"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
WORKDIR_LINK_CAPABILITY_ENCODINGS
|
||||
.iter()
|
||||
.find_map(|(candidate, encoded)| (*candidate == capabilities).then_some(*encoded))
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidInput(
|
||||
"Workdir attachment capabilities must be exactly all, read_only, read_write, command_only, or read_command"
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_workdir_link_capabilities(
|
||||
value: &str,
|
||||
column: usize,
|
||||
) -> rusqlite::Result<workdir::WorkdirSessionCapabilities> {
|
||||
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,
|
||||
format!("invalid Worker Workdir link capabilities {value:?}").into(),
|
||||
)),
|
||||
}
|
||||
WORKDIR_LINK_CAPABILITY_ENCODINGS
|
||||
.iter()
|
||||
.find_map(|(capabilities, encoded)| (*encoded == value).then_some(*capabilities))
|
||||
.ok_or_else(|| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
column,
|
||||
rusqlite::types::Type::Text,
|
||||
format!("invalid Worker Workdir link capabilities {value:?}").into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_worker_workdir_link_record(
|
||||
@@ -12189,6 +12211,103 @@ fn migrate_external_workdir_command_permission_v67_to_v68(conn: &Connection) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_workdir_command_attachment_capabilities_v68_to_v69(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if current != 68 {
|
||||
return Err(Error::Store(format!(
|
||||
"expected schema version 68 before {WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_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 worker_workdir_links_v69 (
|
||||
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', 'command_only', 'read_command'
|
||||
)),
|
||||
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_v69 (
|
||||
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_v69 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);
|
||||
CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal
|
||||
BEFORE INSERT ON worker_workdir_links FOR EACH ROW
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM runtime_removal_operations operation
|
||||
WHERE operation.runtime_id = NEW.runtime_id
|
||||
AND operation.state IN ('pending', 'cleanup_pending')
|
||||
)
|
||||
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||
CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal
|
||||
BEFORE UPDATE ON worker_workdir_links FOR EACH ROW
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM runtime_removal_operations operation
|
||||
WHERE operation.runtime_id = NEW.runtime_id
|
||||
AND operation.state IN ('pending', 'cleanup_pending')
|
||||
)
|
||||
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||
"#,
|
||||
)?;
|
||||
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-69 migration left {violations} foreign-key violation(s)"
|
||||
)));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![
|
||||
69_i64,
|
||||
WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_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"))?;
|
||||
@@ -12251,7 +12370,9 @@ 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', 'read_write'));
|
||||
CHECK (capabilities IN (
|
||||
'all', 'read_only', 'read_write', 'command_only', 'read_command'
|
||||
));
|
||||
"#,
|
||||
)?;
|
||||
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
|
||||
@@ -12947,6 +13068,29 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_workdir_link_capability_codec_round_trips_only_canonical_sets() {
|
||||
for (capabilities, encoded) in WORKDIR_LINK_CAPABILITY_ENCODINGS {
|
||||
assert_eq!(
|
||||
encode_workdir_link_capabilities(*capabilities).unwrap(),
|
||||
*encoded
|
||||
);
|
||||
assert_eq!(
|
||||
decode_workdir_link_capabilities(encoded, 0).unwrap(),
|
||||
*capabilities
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
encode_workdir_link_capabilities(workdir::WorkdirSessionCapabilities::EMPTY).is_err()
|
||||
);
|
||||
assert!(
|
||||
encode_workdir_link_capabilities(workdir::WorkdirSessionCapabilities::WRITE_ONLY)
|
||||
.is_err()
|
||||
);
|
||||
assert!(decode_workdir_link_capabilities("write_only", 0).is_err());
|
||||
assert!(decode_workdir_link_capabilities("future_authority", 0).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_workdir_grant_persists_typed_source_and_fences_generations() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
@@ -14328,6 +14472,10 @@ mod tests {
|
||||
version: 68,
|
||||
name: EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 69,
|
||||
name: WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -14394,6 +14542,10 @@ mod tests {
|
||||
68,
|
||||
EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
(
|
||||
69,
|
||||
WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -14509,6 +14661,89 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v69_preserves_legacy_attachment_capabilities_and_allows_command_subsets() {
|
||||
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 <= 68)
|
||||
{
|
||||
(migration.apply)(&conn).unwrap();
|
||||
}
|
||||
let tx = conn.transaction().unwrap();
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
INSERT INTO worker_registry (
|
||||
workspace_id, runtime_id, worker_id, display_name, retention_state,
|
||||
created_at, updated_at
|
||||
) VALUES ('workspace-a', 'runtime-a', 'worker-a', 'Worker A', 'normal', '1', '1');
|
||||
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');
|
||||
INSERT INTO worker_workdir_links (
|
||||
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
|
||||
linked_at, unlinked_at
|
||||
) VALUES
|
||||
('workspace-a', 'runtime-a', 'worker-a', 'external-ro', 'readonly',
|
||||
'read_only', '1', '2'),
|
||||
('workspace-a', 'runtime-a', 'worker-a', 'external-rw', 'readwrite',
|
||||
'read_write', '1', '2');
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
|
||||
migrate_workdir_command_attachment_capabilities_v68_to_v69(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 69);
|
||||
let capabilities = conn
|
||||
.prepare("SELECT capabilities FROM worker_workdir_links ORDER BY alias")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
assert_eq!(capabilities, vec!["read_only", "read_write"]);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE worker_workdir_links SET capabilities = 'command_only' WHERE alias = 'readonly'",
|
||||
[],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE worker_workdir_links SET capabilities = 'read_command' WHERE alias = 'readwrite'",
|
||||
[],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE worker_workdir_links SET capabilities = 'write_only' WHERE alias = 'readonly'",
|
||||
[],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_resumes_from_a_valid_partially_applied_chain() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -14527,7 +14762,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, 68
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69
|
||||
]
|
||||
);
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
@@ -14536,7 +14771,7 @@ mod tests {
|
||||
current_schema_version(&conn).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 19);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 20);
|
||||
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