diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 825616bf3..e68a33cf2 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -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 for RuntimeWorking } } +impl From for ExternalWorkdirPermissions { + fn from(value: workdir::workspace::WorkdirPermissionSummary) -> Self { + Self { + read: value.read, + write: value.write, + command: value.command, + } + } +} + impl From for WorkingDirectorySummary { fn from(value: workdir::workspace::WorkingDirectorySummary) -> Self { Self { @@ -5221,9 +5238,13 @@ impl From 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::(missing).is_err()); + } + #[test] fn workdir_response_rejects_stale_occupancy_shape() { let stale = serde_json::json!({ diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index 4472ea407..8fa950f51 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -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( diff --git a/crates/workdir/src/workspace.rs b/crates/workdir/src/workspace.rs index 2e7c57582..8bb21fe06 100644 --- a/crates/workdir/src/workspace.rs +++ b/crates/workdir/src/workspace.rs @@ -100,11 +100,24 @@ pub struct RuntimeWorkingDirectorySummary { pub occupied_by: Option, } +#[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)] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3021be462..2bc9c314c 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3973,6 +3973,21 @@ fn external_workdir_capabilities(permissions: &str) -> Result 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 { + 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, @@ -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 { + 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, ) -> 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 { - 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::>(); 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::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(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 9c902f5b3..1d8769f11 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -21,7 +21,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 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 { - 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::>>() + .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![ - 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()); diff --git a/openapi/server-api.json b/openapi/server-api.json index f04307fe8..85a0e6c25 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -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": { diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 05284f684..8836db08e 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -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", diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index cb5c39c72..04e182360 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256: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; refs: Array; 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; 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; workdir_attachments?: Array; 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"; diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 65d6017f2..83d4c91a0 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256: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; expected_plan_digest: string; expected_plan_revision: string; workdir_target_ids?: Array; worker_target_ids?: Array; }; +export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; }; + export type RemoveRuntimeRequest = { expected_binding_revision: number; operation_id: string; }; export type RepositoryApiError = { diagnostics?: Array; 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; 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; workdir_attachments?: Array; 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"; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index ed5c7392e..c784459f9 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256: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"; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 2e2c07ed4..ce3cb7aa0 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256: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; 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; workdir_attachments?: Array; 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"; diff --git a/web/workspace/src/lib/workspace/api/workdirs.ts b/web/workspace/src/lib/workspace/api/workdirs.ts index 870c425fd..f22724f07 100644 --- a/web/workspace/src/lib/workspace/api/workdirs.ts +++ b/web/workspace/src/lib/workspace/api/workdirs.ts @@ -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, key: string): string { return value; } +function booleanField( + record: Record, + 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, key: string): unknown[] { const value = record[key]; if (!Array.isArray(value)) throw new Error(`${key} must be an array`); diff --git a/web/workspace/src/lib/workspace/api/workers.test.ts b/web/workspace/src/lib/workspace/api/workers.test.ts index 2667a0e63..f6b703159 100644 --- a/web/workspace/src/lib/workspace/api/workers.test.ts +++ b/web/workspace/src/lib/workspace/api/workers.test.ts @@ -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", diff --git a/web/workspace/src/lib/workspace/api/workers.ts b/web/workspace/src/lib/workspace/api/workers.ts index 26dcefe8c..96ac01d23 100644 --- a/web/workspace/src/lib/workspace/api/workers.ts +++ b/web/workspace/src/lib/workspace/api/workers.ts @@ -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([ "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), }; } diff --git a/web/workspace/src/lib/workspace/settings/workdir-permissions.ts b/web/workspace/src/lib/workspace/settings/workdir-permissions.ts new file mode 100644 index 000000000..7942e324d --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/workdir-permissions.ts @@ -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"; +} diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts index 9fea0695a..6cd8d19fd 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -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", diff --git a/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts index 31e320ce2..cd5988819 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts @@ -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", }), diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte index 0d2c61796..0c84506a3 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte @@ -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(', '); } diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index db55d1e10..3c2e82aa1 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -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)}`; } diff --git a/web/workspace/tests/workdir-api.test.ts b/web/workspace/tests/workdir-api.test.ts index f4c125484..abb9d8963 100644 --- a/web/workspace/tests/workdir-api.test.ts +++ b/web/workspace/tests/workdir-api.test.ts @@ -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", () => { diff --git a/web/workspace/tests/workdir-permissions.test.ts b/web/workspace/tests/workdir-permissions.test.ts new file mode 100644 index 000000000..244cb4e47 --- /dev/null +++ b/web/workspace/tests/workdir-permissions.test.ts @@ -0,0 +1,22 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): 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}`); + } + } +});