fix: preserve external command attachment authority

This commit is contained in:
2026-09-29 14:41:36 +09:00
parent 4ab2a2b88a
commit e9e0368db4
21 changed files with 955 additions and 80 deletions
+83 -5
View File
@@ -5083,8 +5083,15 @@ pub struct RuntimeWorkingDirectorySummary {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum WorkingDirectorySource {
Repository { repository_key: String },
ExternalGrant { grant_id: String },
Repository {
repository_key: String,
},
ExternalGrant {
grant_id: String,
/// Operator-approved ceiling for this External Workdir grant. Worker
/// attachment authority is projected separately after attenuation.
grant_permissions: ExternalWorkdirPermissions,
},
}
/// Public, provider-neutral Workdir inventory projection.
@@ -5212,6 +5219,16 @@ impl From<workdir::workspace::RuntimeWorkingDirectorySummary> for RuntimeWorking
}
}
impl From<workdir::workspace::WorkdirPermissionSummary> for ExternalWorkdirPermissions {
fn from(value: workdir::workspace::WorkdirPermissionSummary) -> Self {
Self {
read: value.read,
write: value.write,
command: value.command,
}
}
}
impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySummary {
fn from(value: workdir::workspace::WorkingDirectorySummary) -> Self {
Self {
@@ -5221,9 +5238,13 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
workdir::workspace::WorkingDirectorySource::Repository { repository_key } => {
WorkingDirectorySource::Repository { repository_key }
}
workdir::workspace::WorkingDirectorySource::ExternalGrant { grant_id } => {
WorkingDirectorySource::ExternalGrant { grant_id }
}
workdir::workspace::WorkingDirectorySource::ExternalGrant {
grant_id,
grant_permissions,
} => WorkingDirectorySource::ExternalGrant {
grant_id,
grant_permissions: grant_permissions.into(),
},
},
creation_selector: value.creation_selector,
creation_ref: value.creation_ref,
@@ -7977,6 +7998,10 @@ pub struct WorkspaceWorkerDiscoveryPage {
#[serde(deny_unknown_fields)]
pub struct WorkerWorkdirAttachmentSummary {
pub alias: String,
/// Current effective category authority after intersecting the durable link,
/// source grant, and Ticket target ceilings. This is independent from
/// provider liveness and may be narrower than the grant ceiling.
pub effective_permissions: ExternalWorkdirPermissions,
pub working_directory: WorkingDirectorySummary,
}
@@ -11269,6 +11294,59 @@ mod tests {
}
}
#[test]
fn external_workdir_summary_separates_grant_and_attachment_effective_permissions() {
let summary = WorkingDirectorySummary {
working_directory_id: "external-1".into(),
display_name: Some("Session data".into()),
source: WorkingDirectorySource::ExternalGrant {
grant_id: "grant-1".into(),
grant_permissions: ExternalWorkdirPermissions {
read: true,
write: false,
command: true,
},
},
creation_selector: None,
creation_ref: None,
creation_tree: None,
current_selector: None,
current_ref: None,
current_tree: None,
observed_at_epoch_seconds: None,
materializer_kind: WorkingDirectoryMaterializerKind::ClientHostedExternal,
cleanup_target: None,
status: WorkingDirectoryStatusKind::Active,
cleanliness: Some("unknown".into()),
occupied_by: None,
};
let value = serde_json::to_value(WorkerWorkdirAttachmentSummary {
alias: "sessions".into(),
effective_permissions: ExternalWorkdirPermissions {
read: false,
write: false,
command: true,
},
working_directory: summary,
})
.unwrap();
assert_eq!(
value["working_directory"]["source"]["grant_permissions"],
serde_json::json!({"read": true, "write": false, "command": true})
);
assert_eq!(
value["effective_permissions"],
serde_json::json!({"read": false, "write": false, "command": true})
);
let mut missing = value;
missing["working_directory"]["source"]
.as_object_mut()
.unwrap()
.remove("grant_permissions");
assert!(serde_json::from_value::<WorkerWorkdirAttachmentSummary>(missing).is_err());
}
#[test]
fn workdir_response_rejects_stale_occupancy_shape() {
let stale = serde_json::json!({
+50 -5
View File
@@ -522,14 +522,29 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
}
let attachment = &worker.workdir_attachments[0];
let directory = &attachment.working_directory;
let source_label = match &directory.source {
BackendWorkingDirectorySource::Repository { repository_key } => repository_key.as_str(),
BackendWorkingDirectorySource::ExternalGrant { .. } => "external",
let (source_label, permission_label) = match &directory.source {
BackendWorkingDirectorySource::Repository { repository_key } => {
(repository_key.as_str(), String::new())
}
BackendWorkingDirectorySource::ExternalGrant { .. } => {
let permissions = &attachment.effective_permissions;
let mut categories = Vec::new();
if permissions.read {
categories.push("READ");
}
if permissions.write {
categories.push("WRITE");
}
if permissions.command {
categories.push("COMMAND");
}
("external", format!("・{}", categories.join("・")))
}
};
let label = directory.display_name.as_deref().unwrap_or(source_label);
format!(
"wd:{}:{}・{}",
attachment.alias, label, directory.working_directory_id
"wd:{}:{}{}・{}",
attachment.alias, label, permission_label, directory.working_directory_id
)
}
@@ -826,6 +841,7 @@ mod tests {
worker.workdir_attachments = vec![
serde_json::from_value(serde_json::json!({
"alias": "checkout",
"effective_permissions": {"read": true, "write": true, "command": true},
"working_directory": {
"working_directory_id": "001a06a9f0202000000",
"display_name": "Checkout",
@@ -871,6 +887,7 @@ mod tests {
worker.workdir_attachments = vec![
serde_json::from_value(serde_json::json!({
"alias": "checkout",
"effective_permissions": {"read": true, "write": true, "command": true},
"working_directory": {
"working_directory_id": "workdir-1",
"source": {"kind": "repository", "repository_key": "main"},
@@ -901,6 +918,34 @@ mod tests {
);
}
#[test]
fn worker_row_shows_external_attachment_effective_permissions() {
let mut worker = worker("runtime-a", "worker-a", None);
worker.workdir_attachments = vec![
serde_json::from_value(serde_json::json!({
"alias": "sessions",
"effective_permissions": {"read": false, "write": false, "command": true},
"working_directory": {
"working_directory_id": "external-1",
"display_name": "Session data",
"source": {
"kind": "external_grant",
"grant_id": "grant-1",
"grant_permissions": {"read": true, "write": false, "command": true}
},
"materializer_kind": "client_hosted_external",
"status": "active"
}
}))
.unwrap(),
];
assert_eq!(
working_directory_text(&worker),
"wd:sessions:Session data・COMMAND・external-1"
);
}
#[test]
fn picker_title_uses_backend_worker_wording() {
let target = BackendRuntimeListTarget::new(
+15 -2
View File
@@ -100,11 +100,24 @@ pub struct RuntimeWorkingDirectorySummary {
pub occupied_by: Option<WorkingDirectoryOccupancy>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkdirPermissionSummary {
pub read: bool,
pub write: bool,
pub command: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum WorkingDirectorySource {
Repository { repository_key: String },
ExternalGrant { grant_id: String },
Repository {
repository_key: String,
},
ExternalGrant {
grant_id: String,
grant_permissions: WorkdirPermissionSummary,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+323 -19
View File
@@ -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();
+261 -26
View File
@@ -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());
+12 -2
View File
@@ -11940,12 +11940,17 @@
"alias": {
"type": "string"
},
"effective_permissions": {
"$ref": "#/components/schemas/ExternalWorkdirPermissions",
"description": "Current effective category authority after intersecting the durable link,\nsource grant, and Ticket target ceilings. This is independent from\nprovider liveness and may be narrower than the grant ceiling."
},
"working_directory": {
"$ref": "#/components/schemas/WorkingDirectorySummary"
}
},
"required": [
"alias",
"effective_permissions",
"working_directory"
],
"type": "object"
@@ -12235,6 +12240,10 @@
"grant_id": {
"type": "string"
},
"grant_permissions": {
"$ref": "#/components/schemas/ExternalWorkdirPermissions",
"description": "Operator-approved ceiling for this External Workdir grant. Worker\nattachment authority is projected separately after attenuation."
},
"kind": {
"const": "external_grant",
"type": "string"
@@ -12242,7 +12251,8 @@
},
"required": [
"kind",
"grant_id"
"grant_id",
"grant_permissions"
],
"type": "object"
}
@@ -14010,7 +14020,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be"
"x-yoi-source-digest": "sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c"
},
"openapi": "3.1.0",
"paths": {
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview",
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be)
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -15,6 +15,8 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe
export type DiagnosticSeverity = "info" | "warning" | "error";
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
export type GitCommitSummary = { author_date: string; author_email: string; author_name: string; hash: string; parents: Array<string>; refs: Array<string>; short_hash: string; summary: string; };
export type GitRemoteSummary = { fetch_url: string; name: string; };
@@ -73,7 +75,7 @@ export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState;
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; capabilities: WorkerCapabilitySummary; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; working_directory: WorkingDirectorySummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
export type WorkerWorkspaceSummary = { identity: string; visibility: string; workspace_id?: string | null; };
@@ -83,7 +85,7 @@ export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hos
export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be)
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -27,6 +27,8 @@ export type DiagnosticSeverity = "info" | "warning" | "error";
export type ExecuteRuntimeCleanupRequest = { confirm_dirty_discard_target_ids?: Array<string>; expected_plan_digest: string; expected_plan_revision: string; workdir_target_ids?: Array<string>; worker_target_ids?: Array<string>; };
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
export type RemoveRuntimeRequest = { expected_binding_revision: number; operation_id: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
@@ -163,7 +165,7 @@ export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState;
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; capabilities: WorkerCapabilitySummary; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; working_directory: WorkingDirectorySummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
export type WorkerWorkspaceSummary = { identity: string; visibility: string; workspace_id?: string | null; };
@@ -173,7 +175,7 @@ export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hos
export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be)
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -37,7 +37,7 @@ export type WorkingDirectoryRemovalRequest = { reason: string; };
export type WorkingDirectoryRemovalResponse = { disposition: WorkingDirectoryRemovalDisposition; failure_category?: string | null; retryable: boolean; working_directory_id: string; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:e0407f2ae3f1d454d05543627b87eff8eb001b5a90a900ca5af91d7bb492c3be)
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -19,6 +19,8 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe
export type DiagnosticSeverity = "info" | "warning" | "error";
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed";
export type PasteArtifactMediaType = "text_plain_utf8";
@@ -67,7 +69,7 @@ export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState;
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; capabilities: WorkerCapabilitySummary; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; working_directory: WorkingDirectorySummary; };
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
export type WorkerWorkspaceSummary = { identity: string; visibility: string; workspace_id?: string | null; };
@@ -79,7 +81,7 @@ export type WorkingDirectoryOccupancy = { display_name: string; linked_at: strin
export type WorkingDirectoryRepositoryOption = { default_selector?: string | null; repository_key: string; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; };
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
@@ -1,5 +1,6 @@
import type {
Diagnostic,
ExternalWorkdirPermissions,
WorkingDirectoryCleanupTarget,
WorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse,
@@ -176,13 +177,15 @@ export function parseWorkingDirectorySummary(
function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
const source = exactRecord(
value,
new Set(["kind", "repository_key", "grant_id"]),
new Set(["kind", "repository_key", "grant_id", "grant_permissions"]),
"Workdir source",
);
const kind = stringField(source, "kind");
if (kind === "repository") {
if (source.grant_id !== undefined) {
throw new Error("Repository Workdir source must not contain grant_id");
if (source.grant_id !== undefined || source.grant_permissions !== undefined) {
throw new Error(
"Repository Workdir source must not contain External grant authority",
);
}
return {
kind,
@@ -198,11 +201,31 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
return {
kind,
grant_id: stringField(source, "grant_id"),
grant_permissions: parseExternalWorkdirPermissions(
source.grant_permissions,
"External Workdir grant permissions",
),
};
}
throw new Error("Workdir source.kind has an unsupported value");
}
export function parseExternalWorkdirPermissions(
value: unknown,
label = "External Workdir permissions",
): ExternalWorkdirPermissions {
const permissions = exactRecord(
value,
new Set(["read", "write", "command"]),
label,
);
return {
read: booleanField(permissions, "read", label),
write: booleanField(permissions, "write", label),
command: booleanField(permissions, "command", label),
};
}
function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
const record = exactRecord(
value,
@@ -260,6 +283,18 @@ function stringField(record: Record<string, unknown>, key: string): string {
return value;
}
function booleanField(
record: Record<string, unknown>,
key: string,
label: string,
): boolean {
const value = record[key];
if (typeof value !== "boolean") {
throw new Error(`${label}.${key} must be a boolean`);
}
return value;
}
function arrayField(record: Record<string, unknown>, key: string): unknown[] {
const value = record[key];
if (!Array.isArray(value)) throw new Error(`${key} must be an array`);
@@ -96,6 +96,48 @@ Deno.test("Worker summary parser enforces observation freshness", () => {
assertEquals(observedStopped.state, "stopped");
});
Deno.test("Worker summary parser preserves attachment-effective External permissions", () => {
const attachment = {
alias: "sessions",
effective_permissions: { read: false, write: false, command: true },
working_directory: {
working_directory_id: "external-1",
display_name: "Sessions",
source: {
kind: "external_grant",
grant_id: "grant-1",
grant_permissions: { read: true, write: false, command: true },
},
materializer_kind: "client_hosted_external",
status: "active",
},
};
const parsed = parseWorkerSummary({
...worker,
resource_key: "W-1",
availability: "observed",
workdir_attachments: [attachment],
});
assertEquals(
parsed.workdir_attachments?.[0]?.effective_permissions,
attachment.effective_permissions,
);
assertThrows(
() =>
parseWorkerSummary({
...worker,
resource_key: "W-1",
availability: "observed",
workdir_attachments: [{
alias: attachment.alias,
working_directory: attachment.working_directory,
}],
}),
Error,
"effective_permissions",
);
});
Deno.test("Worker launch options parser accepts the generated wire shape", () => {
const parsed = parseWorkerLaunchOptionsResponse({
workspace_id: "workspace-a",
@@ -24,7 +24,10 @@ import type {
WorkingDirectoryRepositoryOption,
} from "$lib/generated/worker-launch-api";
import type { Segment } from "$lib/generated/protocol";
import { parseWorkingDirectorySummary } from "$lib/workspace/api/workdirs";
import {
parseExternalWorkdirPermissions,
parseWorkingDirectorySummary,
} from "$lib/workspace/api/workdirs";
const DIAGNOSTIC_SEVERITIES = new Set<DiagnosticSeverity>([
"info",
@@ -316,9 +319,13 @@ function workerWorkdirAttachment(
label: string,
): WorkerWorkdirAttachmentSummary {
const item = record(value, label);
exact(item, ["alias", "working_directory"], label);
exact(item, ["alias", "effective_permissions", "working_directory"], label);
return {
alias: string(item.alias, `${label}.alias`),
effective_permissions: parseExternalWorkdirPermissions(
item.effective_permissions,
`${label}.effective_permissions`,
),
working_directory: parseWorkingDirectorySummary(item.working_directory),
};
}
@@ -0,0 +1,12 @@
import type { ExternalWorkdirPermissions } from "$lib/generated/workdir-api";
/** Render independent Workdir authority categories in a stable, non-conflating order. */
export function formatWorkdirPermissions(
permissions: ExternalWorkdirPermissions,
): string {
const categories: string[] = [];
if (permissions.read) categories.push("READ");
if (permissions.write) categories.push("WRITE");
if (permissions.command) categories.push("COMMAND");
return categories.length > 0 ? categories.join(" · ") : "NONE";
}
@@ -32,7 +32,11 @@ Deno.test("defaultWorkerLaunchForm preserves an available read-only External Wor
const external = {
working_directory_id: "external-1",
display_name: "Session analysis",
source: { kind: "external_grant" as const, grant_id: "grant-1" },
source: {
kind: "external_grant" as const,
grant_id: "grant-1",
grant_permissions: { read: true, write: false, command: false },
},
materializer_kind: "client_hosted_external" as const,
status: "active" as const,
cleanliness: "unknown",
@@ -111,7 +111,11 @@ Deno.test("Worker sidebar Workdir label uses an External Workdir display name wi
const meta = sidebarWorkdirMeta([
attachment("sessions", {
display_name: "Session analysis",
source: { kind: "external_grant", grant_id: "grant-1" },
source: {
kind: "external_grant",
grant_id: "grant-1",
grant_permissions: { read: true, write: false, command: false },
},
materializer_kind: "client_hosted_external",
cleanliness: "unknown",
}),
@@ -7,6 +7,7 @@
parseWorkerRetentionResponse,
} from '$lib/workspace/api/runtime-workers';
import { workerHref } from '$lib/workspace/resource-links';
import { formatWorkdirPermissions } from '$lib/workspace/settings/workdir-permissions';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { canOpenWorkerConsole } from '$lib/workspace/sidebar/workers';
import { liveWorkerState } from '$lib/workspace/sidebar/worker-state';
@@ -149,7 +150,7 @@
function workerDirectory(worker: Worker): string {
const attachments = worker.workdir_attachments ?? [];
if (attachments.length === 0) return '—';
return attachments.map(({ alias, working_directory: directory }) => {
return attachments.map(({ alias, effective_permissions, working_directory: directory }) => {
const repositoryKey = directory.source.kind === 'repository'
? directory.source.repository_key
: null;
@@ -157,6 +158,9 @@
? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider
: null;
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
if (directory.source.kind === 'external_grant') {
return `${alias}: ${label} · ${formatWorkdirPermissions(effective_permissions)}`;
}
return `${alias}: ${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
}).join(', ');
}
@@ -12,6 +12,7 @@
parseWorkerLaunchOptionsResponse,
} from '$lib/workspace/api/workers';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { formatWorkdirPermissions } from '$lib/workspace/settings/workdir-permissions';
import {
buildCreateWorkspaceWorkerRequest,
defaultWorkerLaunchForm,
@@ -38,6 +39,9 @@
? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider
: null;
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
if (directory.source.kind === 'external_grant') {
return `${label} · External · ${formatWorkdirPermissions(directory.source.grant_permissions)}`;
}
return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
}
+51 -1
View File
@@ -53,7 +53,11 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
item: {
working_directory_id: "external-1",
display_name: "Session analysis",
source: { kind: "external_grant", grant_id: "grant-1" },
source: {
kind: "external_grant",
grant_id: "grant-1",
grant_permissions: { read: true, write: false, command: true },
},
materializer_kind: "client_hosted_external",
status: "active",
cleanliness: "unknown",
@@ -66,6 +70,52 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
if (detail.item.source.kind !== "external_grant") {
throw new Error("External Workdir source was not preserved");
}
if (
!detail.item.source.grant_permissions.read ||
detail.item.source.grant_permissions.write ||
!detail.item.source.grant_permissions.command
) {
throw new Error("External Workdir grant permissions were not preserved");
}
});
Deno.test("Workdir REST validation rejects missing or misplaced External grant permissions", () => {
for (
const source of [
{ kind: "external_grant", grant_id: "grant-1" },
{
kind: "external_grant",
grant_id: "grant-1",
grant_permissions: { read: true, write: false, command: "yes" },
},
{
kind: "repository",
repository_key: "main",
grant_permissions: { read: true, write: false, command: false },
},
]
) {
let rejected = false;
try {
parseWorkingDirectoryDetailResponse({
workspace_id: "workspace-a",
item: {
working_directory_id: "invalid-1",
source,
materializer_kind: source.kind === "repository"
? "runtime_git_clone"
: "client_hosted_external",
status: "active",
},
diagnostics: [],
});
} catch {
rejected = true;
}
if (!rejected) {
throw new Error(`invalid External permission projection was accepted: ${JSON.stringify(source)}`);
}
}
});
Deno.test("Workdir REST validation rejects stale response JSON", () => {
@@ -0,0 +1,22 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import { formatWorkdirPermissions } from "../src/lib/workspace/settings/workdir-permissions.ts";
Deno.test("Workdir permissions render independent categories in stable order", () => {
const cases = [
[{ read: true, write: false, command: false }, "READ"],
[{ read: true, write: true, command: false }, "READ · WRITE"],
[{ read: false, write: false, command: true }, "COMMAND"],
[{ read: true, write: false, command: true }, "READ · COMMAND"],
[{ read: true, write: true, command: true }, "READ · WRITE · COMMAND"],
] as const;
for (const [permissions, expected] of cases) {
const actual = formatWorkdirPermissions(permissions);
if (actual !== expected) {
throw new Error(`expected ${expected}, got ${actual}`);
}
}
});