From cdfb4256d05fe26a4dc4594f4f6cbe712101b7b0 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:08 +0000 Subject: [PATCH 01/37] Harden CI checkout credentials and local commit hooks --- .githooks/pre-commit | 0 .githooks/pre-commit-user | 7 +++++-- .github/workflows/ci.yml | 4 ++++ scripts/audit-dependencies.ts | 5 ++++- 4 files changed, 13 insertions(+), 3 deletions(-) mode change 100644 => 100755 .githooks/pre-commit mode change 100644 => 100755 .githooks/pre-commit-user diff --git a/.githooks/pre-commit b/.githooks/pre-commit old mode 100644 new mode 100755 diff --git a/.githooks/pre-commit-user b/.githooks/pre-commit-user old mode 100644 new mode 100755 index 0745b91a..21cb2f99 --- a/.githooks/pre-commit-user +++ b/.githooks/pre-commit-user @@ -11,7 +11,8 @@ fi staged_files=$(git diff --cached --name-only --diff-filter=ACMR) if [ -n "$staged_files" ]; then - for file in $staged_files; do + # Read newline-separated paths so filenames with spaces are scanned. + while IFS= read -r file; do case "$file" in .githooks/pre-commit|.githooks/pre-commit-user) continue @@ -36,7 +37,9 @@ if [ -n "$staged_files" ]; then echo "Move credentials out of the repo or add a narrow exception after review." exit 1 fi - done + done < { ]); if (stderr) process.stderr.write(stderr); - if (!stdout.trim()) process.exit(exitCode); + if (!stdout.trim()) { + if (exitCode !== 0) process.exit(exitCode); + return {}; + } try { // SAFETY: `bun audit --json` owns this versioned report payload. return JSON.parse(stdout) as AuditReport; From e70667ab181c64f4b74525b353cb091462ad08d6 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:10 +0000 Subject: [PATCH 02/37] Redact unparsed Argo comparison state that may hold Secrets --- src-tauri/src/commands/argo/connected.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/argo/connected.rs b/src-tauri/src/commands/argo/connected.rs index 2e29e407..6b9c5ec3 100644 --- a/src-tauri/src/commands/argo/connected.rs +++ b/src-tauri/src/commands/argo/connected.rs @@ -751,7 +751,19 @@ pub(crate) fn state(value: Option<&Value>, redact: bool) -> Option { .and_then(|state| match state { Value::String(text) => serde_json::from_str(text) .ok() - .or_else(|| Some(Value::String(text.clone()))), + .or_else(|| { + serde_yaml::from_str::(text) + .ok() + .filter(|parsed| parsed.is_object() || parsed.is_array()) + }) + .or_else(|| { + // Unparseable text cannot be redacted structurally; withhold it if it may be a Secret. + Some(if redact && text.contains("Secret") { + Value::String("[REDACTED]".into()) + } else { + Value::String(text.clone()) + }) + }), _ => Some(state.clone()), }) .map(|mut state| { @@ -1717,6 +1729,15 @@ mod tests { assert!(!value.to_string().contains("plaintext")); } + #[test] + fn non_json_secret_state_is_redacted() { + let yaml = serde_json::json!("kind: Secret\ndata:\n password: plaintext\n"); + let redacted = state(Some(&yaml), true).unwrap(); + assert_eq!(redacted["data"]["password"], "[REDACTED]"); + let broken = serde_json::json!("kind: Secret\n\tdata: [plaintext"); + assert_eq!(state(Some(&broken), true).unwrap(), "[REDACTED]"); + } + #[test] fn kubernetes_comparisons_do_not_imply_desired_state() { let comparison = kubernetes_comparison(ArgoManagedResource { From 2ae9485bf4545f9bdc56cb5894646888ae01143f Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:12 +0000 Subject: [PATCH 03/37] Reject guarded operations on non-built-in API groups --- src-tauri/src/commands/operations.rs | 10 ++++++++++ src-tauri/src/models/operations.rs | 2 ++ src/features/incidents/incident-actions.ts | 4 +++- .../resource-detail/OperationsTab.svelte | 2 +- .../resource-detail/operations-model.ts | 19 +++++++++++++++++++ src/lib/types.ts | 6 ++---- 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands/operations.rs b/src-tauri/src/commands/operations.rs index 1d619e88..bec64a90 100644 --- a/src-tauri/src/commands/operations.rs +++ b/src-tauri/src/commands/operations.rs @@ -193,6 +193,15 @@ fn validate_target(target: &ClusterOperationTarget, supported: &[&str]) -> Resul AppErrorKind::UnsupportedOperation, )); } + if let Some(api_version) = target.api_version.as_deref() { + let expected = builtin_kind(&target.kind).map(|kind| kind.api_version()); + if expected.as_deref() != Some(api_version) { + return Err(AppError::new( + format!("{} is not the built-in {} resource", api_version, target.kind), + AppErrorKind::UnsupportedOperation, + )); + } + } Ok(()) } @@ -274,6 +283,7 @@ mod tests { namespace: Some("default".to_string()), kind: kind.to_string(), name: "api".to_string(), + api_version: None, } } diff --git a/src-tauri/src/models/operations.rs b/src-tauri/src/models/operations.rs index ff9c968d..58b4313a 100644 --- a/src-tauri/src/models/operations.rs +++ b/src-tauri/src/models/operations.rs @@ -7,6 +7,8 @@ pub struct ClusterOperationTarget { pub kind: String, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub namespace: Option, } diff --git a/src/features/incidents/incident-actions.ts b/src/features/incidents/incident-actions.ts index 14da50a5..a5c0fab6 100644 --- a/src/features/incidents/incident-actions.ts +++ b/src/features/incidents/incident-actions.ts @@ -5,6 +5,7 @@ import type { } from "@/lib/types"; import { findResourceIndex, looseResourceKey } from "@/lib/resource-identity"; import { incidentState } from "./model"; +import { isBuiltinOperationTarget } from "../resource-detail/operations-model"; export type IncidentEnrichmentState = "idle" | "loading" | "ready" | "error"; @@ -82,7 +83,7 @@ export function resolveIncidentOwner( const chain = [...current.chain, ownerNode.summary]; directOwner ??= ownerNode.summary; if (firstChain.length === 0) firstChain = chain; - if (ACTIONABLE_WORKLOAD_KINDS.has(ownerNode.summary.kind)) { + if (ACTIONABLE_WORKLOAD_KINDS.has(ownerNode.summary.kind) && isBuiltinOperationTarget(ownerNode.summary)) { return { directOwner, workloadOwner: ownerNode.summary, @@ -110,6 +111,7 @@ function workloadActions( ): IncidentAvailableAction[] { const subject = prefix === "owner" ? `owning ${resource.kind}` : `this ${resource.kind}`; const actions: IncidentAvailableAction[] = []; + if (!isBuiltinOperationTarget(resource)) return actions; if (resource.kind === "Deployment" || resource.kind === "StatefulSet") { actions.push( { diff --git a/src/features/resource-detail/OperationsTab.svelte b/src/features/resource-detail/OperationsTab.svelte index b035ef01..b93b83c5 100644 --- a/src/features/resource-detail/OperationsTab.svelte +++ b/src/features/resource-detail/OperationsTab.svelte @@ -26,7 +26,7 @@ let result = $state(""); let error = $state(null); let busy = $state(false); - const target = $derived({ clusterContext: resource.cluster, namespace: resource.namespace, kind: resource.kind, name: resource.name }); + const target = $derived({ clusterContext: resource.cluster, namespace: resource.namespace, kind: resource.kind, name: resource.name, apiVersion: resource.apiVersion }); const operations = $derived(guardedOperations(resource)); const errorBlocker = $derived(error ? guardedOperationBlocker(error) : null); diff --git a/src/features/resource-detail/operations-model.ts b/src/features/resource-detail/operations-model.ts index 5dda4042..7fbeec41 100644 --- a/src/features/resource-detail/operations-model.ts +++ b/src/features/resource-detail/operations-model.ts @@ -32,9 +32,28 @@ export function guardedOperationBlocker( return "operation support"; } +const BUILTIN_API_VERSION: Record = { + Deployment: "apps/v1", + StatefulSet: "apps/v1", + DaemonSet: "apps/v1", + Pod: "v1", + ConfigMap: "v1", +}; + +/** True only for the built-in resource the backend operates on, not a CRD sharing its kind. */ +export function isBuiltinOperationTarget(resource: ResourceSummary): boolean { + return ( + !resource.dynamic && + (resource.apiVersion === undefined || resource.apiVersion === BUILTIN_API_VERSION[resource.kind]) + ); +} + export function guardedOperations(resource: ResourceSummary): GuardedOperations { const scope = (action: string) => `${action} this exact selected ${resource.kind} resource only.`; const available: GuardedOperation[] = []; + if (!isBuiltinOperationTarget(resource)) { + return { available, blocker: `Blocker: ${resource.kind} resource is not a built-in Kubernetes workload or core resource.` }; + } if (resource.kind === "Deployment" || resource.kind === "StatefulSet") { available.push( { diff --git a/src/lib/types.ts b/src/lib/types.ts index 772fd277..adfb59ac 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -153,6 +153,7 @@ export interface ClusterOperationTarget { clusterContext: string; kind: string; name: string; + apiVersion?: string; namespace?: string | null; } @@ -177,10 +178,7 @@ export interface ClusterOperationPreview { effect: string; } -export interface ClusterOperationResult { - target: ClusterOperationTarget; - effect: string; -} +export type ClusterOperationResult = ClusterOperationPreview; export type ResourceHealth = | "healthy" From 929baa599788de852587dcb4e06a94e6abfa10f8 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:15 +0000 Subject: [PATCH 04/37] Clear exec confirmation when the target container changes --- src/features/resource-detail/ExecTab.svelte | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/features/resource-detail/ExecTab.svelte b/src/features/resource-detail/ExecTab.svelte index b254062f..c50d3132 100644 --- a/src/features/resource-detail/ExecTab.svelte +++ b/src/features/resource-detail/ExecTab.svelte @@ -147,6 +147,12 @@ }; }); + // Container or Pod changes from the Logs tab or detail loading must not reuse an earlier acknowledgement. + $effect(() => { + void [selectedContainer, resource.cluster, resource.namespace, resource.name, kubeconfigSourceKey]; + confirmed = false; + }); + $effect(() => { if (!active) return; const frame = window.requestAnimationFrame(() => fitTerminal()); From 9d5f6baa30cdb6c85691d4986fee7fcd750de442 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:17 +0000 Subject: [PATCH 05/37] Redact full Helm release Secrets and narrow inspection lists --- src-tauri/src/commands/helm/redaction.rs | 7 ++----- src-tauri/src/commands/helm/storage.rs | 2 +- src-tauri/src/commands/incidents.rs | 2 +- src-tauri/src/commands/rbac_inventory.rs | 14 +++++++++++-- src-tauri/src/commands/resources/revisions.rs | 20 ++++++++++++++++++- 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/commands/helm/redaction.rs b/src-tauri/src/commands/helm/redaction.rs index 761b8c22..52580849 100644 --- a/src-tauri/src/commands/helm/redaction.rs +++ b/src-tauri/src/commands/helm/redaction.rs @@ -1,11 +1,8 @@ +use crate::commands::helpers::redact_secret; use k8s_openapi::api::core::v1::{ConfigMap, Secret}; pub(super) fn redact_secret_release(secret: &mut Secret) { - if let Some(data) = secret.data.as_mut() { - if let Some(release) = data.get_mut("release") { - release.0 = b"REDACTED".to_vec(); - } - } + redact_secret(secret); } pub(super) fn redact_configmap_release(configmap: &mut ConfigMap) { diff --git a/src-tauri/src/commands/helm/storage.rs b/src-tauri/src/commands/helm/storage.rs index 7d640417..28c4dc75 100644 --- a/src-tauri/src/commands/helm/storage.rs +++ b/src-tauri/src/commands/helm/storage.rs @@ -230,10 +230,10 @@ async fn release_storage_object( )); } let record = secret_record(cluster_context, &mut secret)?; + redact_secret_release(&mut secret); let metadata = serde_json::to_value(&secret.metadata).map_err(|e| { AppError::new(e.to_string(), AppErrorKind::Serialization).with_source(e) })?; - redact_secret_release(&mut secret); let yaml = serialize_resource_document(&secret, yaml_view_mode, yaml_encoding)?; Ok((record, metadata, yaml)) } diff --git a/src-tauri/src/commands/incidents.rs b/src-tauri/src/commands/incidents.rs index 8ac5a2c4..4d22d2ae 100644 --- a/src-tauri/src/commands/incidents.rs +++ b/src-tauri/src/commands/incidents.rs @@ -179,7 +179,7 @@ async fn list_warning_events( } else { Api::all(client) }; - (namespace, api.list(&list_params()).await) + (namespace, api.list(&list_params().fields("type=Warning")).await) } }); for (namespace, result) in join_all(fetches).await { diff --git a/src-tauri/src/commands/rbac_inventory.rs b/src-tauri/src/commands/rbac_inventory.rs index 6535070e..da367503 100644 --- a/src-tauri/src/commands/rbac_inventory.rs +++ b/src-tauri/src/commands/rbac_inventory.rs @@ -1,7 +1,7 @@ use super::{binding_summary, role_summary, service_account_summary}; use crate::{ commands::helpers::list_params, - models::{AppError, RbacBindingSummary, RbacRoleSummary, ServiceAccountSummary}, + models::{AppError, AppErrorKind, RbacBindingSummary, RbacRoleSummary, ServiceAccountSummary}, }; use k8s_openapi::api::{ core::v1::ServiceAccount, @@ -49,8 +49,18 @@ where ); match api.list(¶ms).await { Ok(page) => { - token = page.metadata.continue_; + let next = page.metadata.continue_; items.extend(page.items); + if next.is_some() && next == token { + return InventoryLoad::partial( + items, + AppError::new( + "Kubernetes API returned a repeated continue token", + AppErrorKind::Cluster, + ), + ); + } + token = next; } Err(error) => return InventoryLoad::partial(items, AppError::from(error)), } diff --git a/src-tauri/src/commands/resources/revisions.rs b/src-tauri/src/commands/resources/revisions.rs index 65403a57..2751031c 100644 --- a/src-tauri/src/commands/resources/revisions.rs +++ b/src-tauri/src/commands/resources/revisions.rs @@ -39,9 +39,13 @@ async fn deployment_revisions_from( AppErrorKind::Cluster, ) })?; + let mut params = ListParams::default(); + if let Some(selector) = deployment_selector(&deployment) { + params = params.labels(&selector); + } let replica_sets: Api = Api::namespaced(client, namespace); let replica_sets = replica_sets - .list(&ListParams::default()) + .list(¶ms) .await .map_err(AppError::from)?; @@ -51,6 +55,20 @@ async fn deployment_revisions_from( )) } +fn deployment_selector(deployment: &Deployment) -> Option { + let labels = deployment.spec.as_ref()?.selector.match_labels.as_ref()?; + if labels.is_empty() { + return None; + } + Some( + labels + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join(","), + ) +} + fn deployment_revisions_from_replica_sets( replica_sets: Vec, deployment_uid: &str, From 0b61d19b0ddccdce63c2bdfef37429c13a1d7c94 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:20 +0000 Subject: [PATCH 06/37] Fetch topology metadata only and watch it only while the map is open --- .../commands/resources/topology_collection.rs | 69 +++++++++++++++++-- .../commands/resources/topology_dynamic.rs | 43 ++++-------- src/features/resources/ResourceBrowser.svelte | 4 +- src/features/resources/topology-layout.ts | 5 +- 4 files changed, 81 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/commands/resources/topology_collection.rs b/src-tauri/src/commands/resources/topology_collection.rs index 82005638..9cd87bd6 100644 --- a/src-tauri/src/commands/resources/topology_collection.rs +++ b/src-tauri/src/commands/resources/topology_collection.rs @@ -21,7 +21,7 @@ use k8s_openapi::api::{ }; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use k8s_openapi::{ClusterResourceScope, NamespaceResourceScope}; -use kube::{Api, Client, Error as KubeError}; +use kube::{core::PartialObjectMeta, Api, Client, Error as KubeError}; const MAX_TOPOLOGY_LIST_CONCURRENCY: usize = 16; const DEPLOYMENT_REVISION_ANNOTATION: &str = "deployment.kubernetes.io/revision"; @@ -155,6 +155,65 @@ where Ok((rows, warnings)) } +/// Lists only object metadata so Secret/ConfigMap payloads never reach the backend. +async fn list_namespaced_metadata_with_warnings( + client: Client, + namespaces: &[String], +) -> Result<(Vec>, Vec), AppError> +where + T: Clone + + std::fmt::Debug + + serde::de::DeserializeOwned + + kube::Resource + + k8s_openapi::Resource + + Send + + Sync + + 'static, +{ + let scopes: Vec> = if namespaces.is_empty() { + vec![None] + } else { + namespaces.iter().cloned().map(Some).collect() + }; + let outcomes = stream::iter(scopes) + .map(|namespace| { + let api: Api = match &namespace { + Some(namespace) => Api::namespaced(client.clone(), namespace), + None => Api::all(client.clone()), + }; + async move { + let rows = api.list_metadata(&list_params()).await; + (namespace, rows.map(|rows| rows.items)) + } + }) + .buffered(MAX_TOPOLOGY_LIST_CONCURRENCY) + .collect::>() + .await; + + let mut out = Vec::new(); + let mut warnings = Vec::new(); + for (namespace, outcome) in outcomes { + match outcome { + Ok(rows) => out.extend(rows), + Err(error) if is_optional_topology_list_error(&error) => { + push_topology_list_warning::(&mut warnings, namespace.as_deref(), &error); + } + Err(error) => return Err(AppError::from(error)), + } + } + Ok((out, warnings)) +} + +pub(super) fn inputs_from_partial_metadata( + cluster_context: &str, + items: Vec>, +) -> Vec { + items + .iter() + .map(|item| input_from_metadata(cluster_context, T::KIND, T::API_VERSION, &item.metadata)) + .collect() +} + async fn list_cluster_with_warnings(client: Client) -> Result<(Vec, Vec), AppError> where T: Clone @@ -390,8 +449,8 @@ async fn collect_support_topology_inputs( list_namespaced_with_warnings::(client.clone(), namespaces), list_namespaced_with_warnings::(client.clone(), namespaces), list_namespaced_with_warnings::(client.clone(), namespaces), - list_namespaced_with_warnings::(client.clone(), namespaces), - list_namespaced_with_warnings::(client.clone(), namespaces), + list_namespaced_metadata_with_warnings::(client.clone(), namespaces), + list_namespaced_metadata_with_warnings::(client.clone(), namespaces), list_cluster_with_warnings::(client), )?; @@ -407,8 +466,8 @@ async fn collect_support_topology_inputs( inputs.extend(inputs_from_metadata(cluster_context, pvcs)); inputs.extend(inputs_from_metadata(cluster_context, services)); inputs.extend(inputs_from_metadata(cluster_context, ingresses)); - inputs.extend(inputs_from_metadata(cluster_context, configmaps)); - inputs.extend(inputs_from_metadata(cluster_context, secrets)); + inputs.extend(inputs_from_partial_metadata(cluster_context, configmaps)); + inputs.extend(inputs_from_partial_metadata(cluster_context, secrets)); inputs.extend(inputs_from_metadata(cluster_context, storageclasses)); Ok(TopologyInputCollection { diff --git a/src-tauri/src/commands/resources/topology_dynamic.rs b/src-tauri/src/commands/resources/topology_dynamic.rs index df11feb2..be33d6d4 100644 --- a/src-tauri/src/commands/resources/topology_dynamic.rs +++ b/src-tauri/src/commands/resources/topology_dynamic.rs @@ -1,13 +1,12 @@ use super::{ api_resource_from_discovered, dynamic_resource_summary, - topology::{input_from_metadata, TopologyInputResource}, + topology::TopologyInputResource, }; use crate::commands::helpers::{extract_owner_ref_summary, list_params}; use crate::models::AppErrorKind; use crate::models::{AppError, DiscoveredResourceKind}; use futures_util::{stream, StreamExt}; use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use kube::{ api::{Api, DynamicObject}, Client, Error as KubeError, @@ -20,23 +19,6 @@ const MAX_DYNAMIC_TOPOLOGY_KIND_CONCURRENCY: usize = 48; const MAX_DYNAMIC_TOPOLOGY_LIST_CONCURRENCY: usize = 32; type DynamicListLimiter = Arc; -fn inputs_from_metadata(cluster_context: &str, items: Vec) -> Vec -where - T: k8s_openapi::Metadata, -{ - items - .iter() - .map(|item| { - input_from_metadata( - cluster_context, - ::KIND, - ::API_VERSION, - item.metadata(), - ) - }) - .collect() -} - fn is_optional_app_error(error: &AppError) -> bool { matches!(error.kind, AppErrorKind::Forbidden | AppErrorKind::NotFound) } @@ -81,16 +63,19 @@ pub(super) async fn list_crd_definition_inputs( warnings: &mut Vec, ) -> Result, AppError> { let api: Api = Api::all(client); - match api.list(&list_params()).await { - Ok(rows) => Ok(inputs_from_metadata(cluster_context, rows.items) - .into_iter() - .filter(|input| { - namespaces.is_empty() - || input.summary.git_ops_owner.is_some() - || input.summary.helm_release.is_some() - || input.owner.is_some() - }) - .collect()), + match api.list_metadata(&list_params()).await { + Ok(rows) => Ok(super::topology_collection::inputs_from_partial_metadata( + cluster_context, + rows.items, + ) + .into_iter() + .filter(|input| { + namespaces.is_empty() + || input.summary.git_ops_owner.is_some() + || input.summary.helm_release.is_some() + || input.owner.is_some() + }) + .collect()), Err(error) if is_optional_topology_list_error(&error) => { warnings.push(format!( "Skipped CustomResourceDefinition across namespaces in topology: {error}" diff --git a/src/features/resources/ResourceBrowser.svelte b/src/features/resources/ResourceBrowser.svelte index 81ed58fb..3874222b 100644 --- a/src/features/resources/ResourceBrowser.svelte +++ b/src/features/resources/ResourceBrowser.svelte @@ -828,14 +828,14 @@ async function refreshView() { await refreshCurrentView({ client, queryClient, clusterContext, kubeconfigEnvVar: kubeconfigSourceKey, - keys: mergeWatchKeys(watchKeysFromFetchKeys(fetchKeys), topologyWatchKeys(topologyNamespaces), focusedArgoWatchKeys), + keys: mergeWatchKeys(watchKeysFromFetchKeys(fetchKeys), mapPanelOpen ? topologyWatchKeys(topologyNamespaces) : [], focusedArgoWatchKeys), namespaces: selectedNamespaces, }); } $effect(() => { if (!sourceReady || !clusterContext || fetchKeys.length === 0) return; - const watchKeys = mergeWatchKeys(watchKeysFromFetchKeys(fetchKeys), topologyWatchKeys(topologyNamespaces), focusedArgoWatchKeys); + const watchKeys = mergeWatchKeys(watchKeysFromFetchKeys(fetchKeys), mapPanelOpen ? topologyWatchKeys(topologyNamespaces) : [], focusedArgoWatchKeys); const resourceKey = resourceQueryKey; const topologyKey = topologyQueryKey; const argoScope = focusedArgoApplicationScope; diff --git a/src/features/resources/topology-layout.ts b/src/features/resources/topology-layout.ts index 7d7530c0..f6303c8b 100644 --- a/src/features/resources/topology-layout.ts +++ b/src/features/resources/topology-layout.ts @@ -218,10 +218,7 @@ function buildPrimaryChildren( .sort(compareNodes); const primaryParent = parentNodes[0]; if (!primaryParent) continue; - primaryChildren.set(primaryParent.id, [ - ...(primaryChildren.get(primaryParent.id) ?? []), - node.id, - ]); + pushMapValue(primaryChildren, primaryParent.id, node.id); } for (const [parentId, childIds] of primaryChildren.entries()) { From 3a04b08c0658e00706105cdce9711e6eecc8513f Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:22 +0000 Subject: [PATCH 07/37] Back off watch renewal after early stream EOF --- src-tauri/src/commands/streams/watch.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/streams/watch.rs b/src-tauri/src/commands/streams/watch.rs index 55cadf3c..e97a4b6a 100644 --- a/src-tauri/src/commands/streams/watch.rs +++ b/src-tauri/src/commands/streams/watch.rs @@ -14,7 +14,9 @@ use kube::{ core::Status, Client, }; -use std::time::Duration; +use std::time::{Duration, Instant}; + +const WATCH_TIMEOUT_SECS: u32 = 30; fn event_action(event: &WatchEvent) -> String { match event { @@ -181,7 +183,8 @@ async fn run_resource_watch_with_client( } }; let api = scoped_dynamic_api(client, &key, namespaced, &api_resource); - let params = WatchParams::default().timeout(30); + let params = WatchParams::default().timeout(WATCH_TIMEOUT_SECS); + let watch_started = Instant::now(); match api.watch(¶ms, &resource_version).await { Ok(stream) => { @@ -192,7 +195,13 @@ async fn run_resource_watch_with_client( loop { let Some(event) = stream.next().await else { // Kubernetes ends watches normally at timeoutSeconds. Resume from - // the last version without reporting an outage or delaying renewal. + // the last version without reporting an outage. An earlier EOF keeps + // the reconnect delay so a misbehaving server cannot cause a request loop. + if watch_started.elapsed() + < Duration::from_secs(u64::from(WATCH_TIMEOUT_SECS)) + { + tokio::time::sleep(Duration::from_secs(2)).await; + } continue 'watch; }; match event { From 53f0599f1765a0c89d50245b2b8cbc5e0a5bf930 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:25 +0000 Subject: [PATCH 08/37] Treat empty Ingress load balancer addresses as pending --- .../src/commands/resources/ingress_status.rs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/resources/ingress_status.rs b/src-tauri/src/commands/resources/ingress_status.rs index efcc7734..3a453824 100644 --- a/src-tauri/src/commands/resources/ingress_status.rs +++ b/src-tauri/src/commands/resources/ingress_status.rs @@ -23,7 +23,12 @@ fn has_load_balancer_address(status: &IngressStatus) -> bool { .is_some_and(|ingress| { ingress .iter() - .any(|entry| entry.ip.is_some() || entry.hostname.is_some()) + .any(|entry| { + [&entry.ip, &entry.hostname] + .into_iter() + .flatten() + .any(|address| !address.trim().is_empty()) + }) }) } @@ -107,4 +112,23 @@ mod tests { assert_eq!(summary.status.as_deref(), Some("Pending")); assert_eq!(summary.ready.as_deref(), Some("false")); } + + #[test] + fn ingress_with_blank_load_balancer_address_is_pending() { + let status = IngressStatus { + load_balancer: Some(IngressLoadBalancerStatus { + ingress: Some(vec![IngressLoadBalancerIngress { + ip: Some(String::new()), + hostname: Some(" ".to_string()), + ..Default::default() + }]), + }), + }; + let mut summary = summary(); + + apply_ingress_status(&mut summary, Some(&status)); + + assert_eq!(summary.status.as_deref(), Some("Pending")); + assert_eq!(summary.ready.as_deref(), Some("false")); + } } From 4b726b0f1bcfef668e115dc7cba9bfa596f06966 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:27 +0000 Subject: [PATCH 09/37] Invalidate cached clients when the fallback kubeconfig changes --- src-tauri/src/commands/kubeconfig.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/kubeconfig.rs b/src-tauri/src/commands/kubeconfig.rs index 2ba9b922..300a9fa8 100644 --- a/src-tauri/src/commands/kubeconfig.rs +++ b/src-tauri/src/commands/kubeconfig.rs @@ -213,10 +213,21 @@ impl KubeconfigSource { // discovery when that selection is empty, followed by app-added paths. pub(super) fn effective_kubeconfig_paths(&self) -> Result, AppError> { if let Some(paths) = self.configured_paths()? { - return Ok(paths + let mut paths = paths .into_iter() .map(|configured| configured.path) - .collect()); + .collect::>(); + // read_configured_kubeconfig falls back to the default kubeconfig + // when no configured path loads, so changes to it must invalidate + // cached clients too. + if self.read_env { + if let Some(default) = default_kubeconfig_path() { + if !paths.contains(&default) { + paths.push(default); + } + } + } + return Ok(paths); } Ok(standard_kubeconfig_paths() .into_iter() From d287165a7d744351b48c2a2b3d8cbf9e19760dc1 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:30 +0000 Subject: [PATCH 10/37] Drop imported port forwards outside the workspace scope --- src/features/workspaces/workspace-sharing.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/features/workspaces/workspace-sharing.ts b/src/features/workspaces/workspace-sharing.ts index 31534aa4..b93aa869 100644 --- a/src/features/workspaces/workspace-sharing.ts +++ b/src/features/workspaces/workspace-sharing.ts @@ -1,6 +1,7 @@ import { createSavedPortForward, createWorkspaceRecord, + reconcileSavedPortForwardsForScope, type SavedWorkspace, type SavePortForwardInput, workspaceScopeContexts, @@ -176,8 +177,13 @@ function sharedToSavedWorkspace( rbacReviews: (replace?.rbacReviews ?? []).filter((review) => clusterContexts.includes(review.clusterContext), ), - portForwards: workspace.portForwards.map((forward) => - createSavedPortForward(forward satisfies SavePortForwardInput, now), + portForwards: reconcileSavedPortForwardsForScope( + workspace.portForwards + .filter( + ({ namespace }) => scope.namespaces.length === 0 || scope.namespaces.includes(namespace), + ) + .map((forward) => createSavedPortForward(forward satisfies SavePortForwardInput, now)), + scope, ), }; } From 127e586d8f23c275b995d8b0fe37717aaf3b6f9a Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:32 +0000 Subject: [PATCH 11/37] Reset YAML apply state when the draft changes mid-apply --- src/features/resource-detail/ResourceYamlPane.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/resource-detail/ResourceYamlPane.svelte b/src/features/resource-detail/ResourceYamlPane.svelte index 8346fd37..1cf3a061 100644 --- a/src/features/resource-detail/ResourceYamlPane.svelte +++ b/src/features/resource-detail/ResourceYamlPane.svelte @@ -390,6 +390,7 @@ function clearYamlDraftFeedback() { yamlApplyRevision += 1; yamlPreparing = false; + yamlApplying = false; yamlLintDiagnostics = []; yamlLintNotes = []; yamlLintError = ""; From 6c6580b534ced80176d77da669d62650fb52997f Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:35 +0000 Subject: [PATCH 12/37] Preserve existing query defaults when retention is applied --- src/lib/finite-read-lifecycle.ts | 5 ++++- src/lib/query-retention.test.ts | 17 +++++++++++++++++ src/lib/query-retention.ts | 5 ++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/lib/finite-read-lifecycle.ts b/src/lib/finite-read-lifecycle.ts index edbbfb60..af905329 100644 --- a/src/lib/finite-read-lifecycle.ts +++ b/src/lib/finite-read-lifecycle.ts @@ -69,7 +69,10 @@ export function finiteReadMeta( export function configureFiniteReadQueryDefaults(queryClient: QueryClient): void { for (const root of FINITE_READ_QUERY_ROOTS) { - queryClient.setQueryDefaults([root], { meta: finiteReadMeta() }); + queryClient.setQueryDefaults([root], { + ...queryClient.getQueryDefaults([root]), + meta: finiteReadMeta(), + }); } } diff --git a/src/lib/query-retention.test.ts b/src/lib/query-retention.test.ts index f4b937e7..eab6c741 100644 --- a/src/lib/query-retention.test.ts +++ b/src/lib/query-retention.test.ts @@ -1,4 +1,5 @@ import { QueryClient, QueryObserver } from "@tanstack/svelte-query"; +import { configureFiniteReadQueryDefaults, isFiniteReadQuery } from "./finite-read-lifecycle"; import { configureLargeQueryRetention, LARGE_QUERY_GC_TIME_MS, @@ -102,3 +103,19 @@ describe("large query retention", () => { queryClient.clear(); }); }); + +describe("large query retention with finite-read defaults", () => { + // Failure mode: setting gcTime replaces the finiteRead meta set earlier for the same root. + test("keeps finite-read metadata on large roots", () => { + const queryClient = new QueryClient(); + configureFiniteReadQueryDefaults(queryClient); + configureLargeQueryRetention(queryClient); + + for (const root of LARGE_QUERY_ROOTS) { + queryClient.setQueryData([root, "k"], "v"); + const query = queryClient.getQueryCache().find({ queryKey: [root, "k"] }); + expect(query ? isFiniteReadQuery(query) : false).toBe(true); + } + queryClient.clear(); + }); +}); diff --git a/src/lib/query-retention.ts b/src/lib/query-retention.ts index d24307bf..593a5bcb 100644 --- a/src/lib/query-retention.ts +++ b/src/lib/query-retention.ts @@ -10,6 +10,9 @@ export const LARGE_QUERY_GC_TIME_MS = 300_000; export function configureLargeQueryRetention(queryClient: QueryClient): void { for (const root of LARGE_QUERY_ROOTS) { - queryClient.setQueryDefaults([root], { gcTime: LARGE_QUERY_GC_TIME_MS }); + queryClient.setQueryDefaults([root], { + ...queryClient.getQueryDefaults([root]), + gcTime: LARGE_QUERY_GC_TIME_MS, + }); } } From b0bcbd4272fb637d90ad6f953b87dac7acbae278 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:23:37 +0000 Subject: [PATCH 13/37] Replace ES2023 toSorted with copied sorts --- src/components/sidebar-tree-helpers.ts | 8 ++++---- src/features/live-sessions/helpers.ts | 2 +- src/features/live-sessions/portForwardForms.ts | 2 +- src/features/resources/resourceBrowserModel.ts | 4 ++-- src/features/resources/resourceBrowserReadSpecs.ts | 2 +- src/features/resources/resourceTableModel.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/sidebar-tree-helpers.ts b/src/components/sidebar-tree-helpers.ts index cbdadcb1..140c310d 100644 --- a/src/components/sidebar-tree-helpers.ts +++ b/src/components/sidebar-tree-helpers.ts @@ -16,8 +16,8 @@ export function buildShallowNamespaceTreeNode(namespace: string): TreeNode { export function extraDiscoveredKinds( resourceKinds: DiscoveredResourceKind[], ): DiscoveredResourceKind[] { - return resourceKinds - .toSorted((left, right) => { + return [...resourceKinds] + .sort((left, right) => { return ( left.kind.localeCompare(right.kind) || left.apiVersion.localeCompare(right.apiVersion) || @@ -59,13 +59,13 @@ export function buildCustomResourceGroupNodes({ kindsByGroup.set(resourceKind.group, groupKinds); } return [...kindsByGroup.entries()] - .toSorted(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => left.localeCompare(right)) .map(([group, kinds]): TreeNode => ({ id: { type: "group", section, namespace, group }, label: group, selectable: false, children: kinds - .toSorted((left, right) => left.kind.localeCompare(right.kind)) + .sort((left, right) => left.kind.localeCompare(right.kind)) .map((resourceKind): TreeNode => { const shortNames = resourceKind.shortNames ?? []; return { diff --git a/src/features/live-sessions/helpers.ts b/src/features/live-sessions/helpers.ts index 32516928..63a72583 100644 --- a/src/features/live-sessions/helpers.ts +++ b/src/features/live-sessions/helpers.ts @@ -52,7 +52,7 @@ export function portForwardSessionKey( export function sortPortForwardSessions( sessions: PortForwardSessionSummary[], ): PortForwardSessionSummary[] { - return sessions.toSorted((a, b) => { + return [...sessions].sort((a, b) => { const targetCompare = portForwardSessionKey(a).localeCompare( portForwardSessionKey(b), ); diff --git a/src/features/live-sessions/portForwardForms.ts b/src/features/live-sessions/portForwardForms.ts index dd562703..6843ecf3 100644 --- a/src/features/live-sessions/portForwardForms.ts +++ b/src/features/live-sessions/portForwardForms.ts @@ -109,7 +109,7 @@ export function extractServicePortOptions(yaml: string | undefined): ServicePort const protocol = port.protocol?.toUpperCase() ?? "TCP"; return isNumber(port.port) && port.port > 0 && protocol === "TCP"; }) - .toSorted((a, b) => a.port - b.port || (a.name ?? "").localeCompare(b.name ?? "")); + .sort((a, b) => a.port - b.port || (a.name ?? "").localeCompare(b.name ?? "")); } export function parsePortForwardForm( diff --git a/src/features/resources/resourceBrowserModel.ts b/src/features/resources/resourceBrowserModel.ts index afb3ffa3..9026d5b4 100644 --- a/src/features/resources/resourceBrowserModel.ts +++ b/src/features/resources/resourceBrowserModel.ts @@ -65,8 +65,8 @@ export function nextNamespaceSelection( export function allKindOptions( discoveredKinds: DiscoveredResourceKind[], ): ResourceKindSelection[] { - const discovered = discoveredKinds - .toSorted((left, right) => left.kind.localeCompare(right.kind)) + const discovered = [...discoveredKinds] + .sort((left, right) => left.kind.localeCompare(right.kind)) return [...SUPPORTED_KINDS, ...CLUSTER_SCOPED_KINDS, ...discovered]; } diff --git a/src/features/resources/resourceBrowserReadSpecs.ts b/src/features/resources/resourceBrowserReadSpecs.ts index 1f2e01b3..00ab4970 100644 --- a/src/features/resources/resourceBrowserReadSpecs.ts +++ b/src/features/resources/resourceBrowserReadSpecs.ts @@ -31,7 +31,7 @@ export function buildResourceBrowserReadSpecs({ .filter((key) => key.kind instanceof Object) .map((key) => resourceKindFetchKey(key.kind)), )] - .toSorted() + .sort() .join(","); const resourceQueryKey = queryKeys.resources( clusterContext, diff --git a/src/features/resources/resourceTableModel.ts b/src/features/resources/resourceTableModel.ts index 897b50bc..cf3cb6c6 100644 --- a/src/features/resources/resourceTableModel.ts +++ b/src/features/resources/resourceTableModel.ts @@ -109,7 +109,7 @@ function gitOpsGroupedRows( rows: ResourceSummary[], preferredGitOpsResourceKeys?: ReadonlySet, ): ResourceSummary[] { - const preferredRows = rows.toSorted((left, right) => { + const preferredRows = [...rows].sort((left, right) => { const preferredPriority = Number(isPreferredGitOpsResource(left, preferredGitOpsResourceKeys)) - Number(isPreferredGitOpsResource(right, preferredGitOpsResourceKeys)); From c0a2d0383fb65caa85636063692c510ae460cbf7 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:30:07 +0000 Subject: [PATCH 14/37] Turn off YAML apply force-conflicts by default --- docs/wiki/Edit-and-Apply-YAML.md | 2 +- docs/wiki/Settings-Updates-and-Diagnostics.md | 2 +- src/lib/settings.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/wiki/Edit-and-Apply-YAML.md b/docs/wiki/Edit-and-Apply-YAML.md index a7f3f8f5..2ec08fdf 100644 --- a/docs/wiki/Edit-and-Apply-YAML.md +++ b/docs/wiki/Edit-and-Apply-YAML.md @@ -49,7 +49,7 @@ The **Allow YAML force-conflicts** setting controls whether dry-run and Apply ma - Leave it off for normal edits. A field-manager conflict stops the dry run. - After that conflict, **Allow force-conflicts for this resource** appears. Selecting it reruns the dry run with force enabled; review the new diff before selecting **Apply**. -- When the global setting is on, both dry run and Apply use force-conflicts. Turn it off before editing resources whose existing manager should remain authoritative. +- The setting is off by default. When you turn it on, both dry run and Apply use force-conflicts. Turn it off again before editing resources whose existing manager should remain authoritative. Force-conflicts can replace another manager's field ownership. It is not a way to bypass RBAC, admission, immutable fields, or invalid manifests. diff --git a/docs/wiki/Settings-Updates-and-Diagnostics.md b/docs/wiki/Settings-Updates-and-Diagnostics.md index 2d70eb09..7f41796b 100644 --- a/docs/wiki/Settings-Updates-and-Diagnostics.md +++ b/docs/wiki/Settings-Updates-and-Diagnostics.md @@ -31,7 +31,7 @@ Open **Settings** from app navigation. Most settings are global preferences. Whe | YAML encoding | YAML | Select YAML or KYAML in YAML panels. | | YAML diff appearance | Clean | Select clean or Git-style rendering for selected-resource dry-run diffs. | | YAML error lens | On | Shows editor diagnostics below YAML lines. | -| Allow YAML force-conflicts | On | Allows guarded YAML operations to take server-side field ownership. Review dry-run diff and confirmation target before force-applying. | +| Allow YAML force-conflicts | Off | Allows guarded YAML operations to take server-side field ownership. Review dry-run diff and confirmation target before force-applying. | ## Kubeconfig diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 94e1fb1d..bfdd80e7 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -303,7 +303,7 @@ export const useSettingsState = createStore()( debugModeEnabled: false, autoStartSavedPortForwards: false, keepLiveSessionsOnWorkspaceSwitch: false, - allowYamlForceConflicts: true, + allowYamlForceConflicts: false, timestampTimezone: "local", yamlViewModeDefault: "kubectl", yamlEncodingDefault: "yaml", From a920cd633d622ba524083ddf3f4d276963e675ca Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:31:00 +0000 Subject: [PATCH 15/37] Make WebView2 remote debugging opt-in for Windows dev --- docs/development-workflow.md | 2 +- scripts/tauri.ts | 2 ++ tests/tauri-launcher.test.ts | 11 ++++++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/development-workflow.md b/docs/development-workflow.md index 4b167a6f..4e341754 100644 --- a/docs/development-workflow.md +++ b/docs/development-workflow.md @@ -43,7 +43,7 @@ bun run lint:anti-slop `bun run tauri dev` starts Vite on Bun at `http://localhost:1430`. Opening it in a normal browser runs Svelte with browser-only mock Tauri responses for frontend inspection and automation. -Tauri webview uses real IPC and Rust Kubernetes commands. On Windows, development exposes Chrome DevTools Protocol at `http://127.0.0.1:9222`; set `KUBECOVE_DEVTOOLS_PORT` before launch to change it. Packaged apps do not expose this endpoint. +Tauri webview uses real IPC and Rust Kubernetes commands. On Windows, development does not expose Chrome DevTools Protocol by default. Set `KUBECOVE_DEVTOOLS=1` before `tauri dev` to expose it at `http://127.0.0.1:9222`, and set `KUBECOVE_DEVTOOLS_PORT` to change the port. Packaged apps do not expose this endpoint. Browser mock mode never receives kubeconfig contents, calls a local Rust bridge, or accesses a real cluster. Treat all browser data as fake. diff --git a/scripts/tauri.ts b/scripts/tauri.ts index a2cacd64..bc2dd01c 100644 --- a/scripts/tauri.ts +++ b/scripts/tauri.ts @@ -21,6 +21,8 @@ export function tauriEnvironment( ); } + if (environment.KUBECOVE_DEVTOOLS !== "1") return environment; + const currentArguments = environment[WEBVIEW2_ARGUMENTS]?.trim() ?? ""; if (/(?:^|\s)--remote-debugging-port(?:=|\s)/.test(currentArguments)) { return environment; diff --git a/tests/tauri-launcher.test.ts b/tests/tauri-launcher.test.ts index 46e70ba7..56fa3e46 100644 --- a/tests/tauri-launcher.test.ts +++ b/tests/tauri-launcher.test.ts @@ -5,18 +5,23 @@ const webviewArguments = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"; const cargoTargetDirectory = "CARGO_TARGET_DIR"; describe("Tauri development launcher", () => { - test("enables a localhost WebView2 debug endpoint for Windows development", () => { - const environment = tauriEnvironment(["dev"], {}, "win32"); + test("enables a localhost WebView2 debug endpoint only when KUBECOVE_DEVTOOLS=1", () => { + const environment = tauriEnvironment(["dev"], { KUBECOVE_DEVTOOLS: "1" }, "win32"); expect(environment[webviewArguments]).toBe( "--remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --remote-allow-origins=http://127.0.0.1:9222,http://localhost:9222", ); }); + test("does not enable WebView2 debugging by default", () => { + expect(tauriEnvironment(["dev"], {}, "win32")[webviewArguments]).toBeUndefined(); + }); + test("keeps existing WebView2 arguments and supports a custom port", () => { const environment = tauriEnvironment( ["dev"], { + KUBECOVE_DEVTOOLS: "1", KUBECOVE_DEVTOOLS_PORT: "9333", [webviewArguments]: "--disable-features=ExampleFeature", }, @@ -35,7 +40,7 @@ describe("Tauri development launcher", () => { const existing = "--remote-debugging-port=9444 --disable-gpu"; const environment = tauriEnvironment( ["dev"], - { [webviewArguments]: existing }, + { KUBECOVE_DEVTOOLS: "1", [webviewArguments]: existing }, "win32", ); From 25d73218cfdca6a98961f694c84596d1c3c7a2a1 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:33:57 +0000 Subject: [PATCH 16/37] Explain that Service forwards may use a different Pod per connection --- src/features/live-sessions/LiveSessionsSurface.svelte | 2 +- src/features/resource-detail/PortForwardTab.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/live-sessions/LiveSessionsSurface.svelte b/src/features/live-sessions/LiveSessionsSurface.svelte index c9367fc7..9fa76e16 100644 --- a/src/features/live-sessions/LiveSessionsSurface.svelte +++ b/src/features/live-sessions/LiveSessionsSurface.svelte @@ -89,7 +89,7 @@ function portForwardSessionResolution(session: PortForwardSessionSummary): string { if (session.targetKind === "Service") { - return `Resolved Pod: ${session.resolvedPodName}:${session.resolvedPodPort}`; + return `Latest Pod used: ${session.resolvedPodName}:${session.resolvedPodPort} (each connection may use a different ready Pod)`; } return `Pod: ${session.resolvedPodName}:${session.resolvedPodPort}`; } diff --git a/src/features/resource-detail/PortForwardTab.svelte b/src/features/resource-detail/PortForwardTab.svelte index 8da4e477..51be0b6a 100644 --- a/src/features/resource-detail/PortForwardTab.svelte +++ b/src/features/resource-detail/PortForwardTab.svelte @@ -214,7 +214,7 @@ function sessionResolution(session: PortForwardSessionSummary): string { if (session.targetKind === "Service") { - return `Resolved to Pod/${session.resolvedPodName}:${session.resolvedPodPort}`; + return `Latest Pod used: Pod/${session.resolvedPodName}:${session.resolvedPodPort} (each connection may use a different ready Pod)`; } return `Pod/${session.resolvedPodName}:${session.resolvedPodPort}`; } From 44d3385aedba81a37652e57786238970fd04a7d2 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:34:04 +0000 Subject: [PATCH 17/37] Match resource Events by UID and trim health and log payloads --- src-tauri/src/commands/events.rs | 56 +++++++++++++++++-- src-tauri/src/commands/resources/dynamic.rs | 9 +-- src-tauri/src/commands/streams.rs | 8 +++ .../src/commands/streams/aggregate_logs.rs | 2 +- src-tauri/src/commands/streams/logs.rs | 2 +- src-tauri/src/models/health.rs | 13 ++++- src-tauri/src/models/mod.rs | 12 ++-- src/features/incidents/guidance.ts | 2 +- .../ResourceDetailPanel.svelte | 12 +++- src/features/resource-detail/helpers.ts | 3 +- src/lib/tauri.ts | 2 + tests/tauri.test.ts | 2 +- 12 files changed, 101 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/commands/events.rs b/src-tauri/src/commands/events.rs index e525a9ae..a5b3270e 100644 --- a/src-tauri/src/commands/events.rs +++ b/src-tauri/src/commands/events.rs @@ -56,6 +56,7 @@ fn event_matches_resource( kind: &str, name: &str, namespace: Option<&str>, + uid: Option<&str>, ) -> bool { event.involved_object.kind.as_deref() == Some(kind) && event.involved_object.name.as_deref() == Some(name) @@ -63,6 +64,10 @@ fn event_matches_resource( Some(ns) => event.involved_object.namespace.as_deref() == Some(ns), None => true, } + && match (uid, event.involved_object.uid.as_deref()) { + (Some(expected), Some(actual)) => expected == actual, + _ => true, + } } fn summarize_event(event: &k8s_openapi::api::core::v1::Event) -> ResourceEventSummary { @@ -83,6 +88,7 @@ pub async fn resource_events_from( kind: String, name: String, namespace: Option, + uid: Option, kubeconfig_env_var: Option, ) -> Result, AppError> { let source = KubeconfigSource::new(kubeconfig_env_var)?; @@ -104,7 +110,9 @@ pub async fn resource_events_from( .await .map_err(AppError::from)? .into_iter() - .filter(|event| event_matches_resource(event, &kind, &name, namespace.as_deref())) + .filter(|event| { + event_matches_resource(event, &kind, &name, namespace.as_deref(), uid.as_deref()) + }) .collect(); events.sort_by_key(event_timestamp); @@ -119,6 +127,7 @@ pub async fn list_resource_events( kind: String, name: String, namespace: Option, + uid: Option, kubeconfig_env_var: Option, request_id: Option, cancel_scope: Option, @@ -138,6 +147,7 @@ pub async fn list_resource_events( kind.clone(), name.clone(), namespace.clone(), + uid, kubeconfig_env_var, ), ) @@ -222,13 +232,15 @@ mod tests { &event, "Pod", "api-0", - Some("payments") + Some("payments"), + None )); assert!(!event_matches_resource( &event, "Pod", "api-1", - Some("payments") + Some("payments"), + None )); assert_eq!(event_source(&event), "deployment-controller"); assert_eq!(event_timestamp(&event).unwrap().timestamp(), 1_700_000_100); @@ -268,13 +280,49 @@ mod tests { ..Default::default() }; - assert!(event_matches_resource(&event, "Node", "kind-worker", None)); + assert!(event_matches_resource( + &event, + "Node", + "kind-worker", + None, + None + )); assert!(!event_matches_resource( &event, "Node", "kind-worker", Some("default"), + None, + )); + } + + #[test] + fn event_matching_requires_matching_uid_when_both_present() { + let event = Event { + involved_object: ObjectReference { + kind: Some("Pod".to_string()), + name: Some("api-0".to_string()), + uid: Some("real".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + assert!(event_matches_resource( + &event, + "Pod", + "api-0", + None, + Some("real") + )); + assert!(!event_matches_resource( + &event, + "Pod", + "api-0", + None, + Some("forged") )); + assert!(event_matches_resource(&event, "Pod", "api-0", None, None)); } #[test] diff --git a/src-tauri/src/commands/resources/dynamic.rs b/src-tauri/src/commands/resources/dynamic.rs index 3b0c9b46..6271c93f 100644 --- a/src-tauri/src/commands/resources/dynamic.rs +++ b/src-tauri/src/commands/resources/dynamic.rs @@ -11,9 +11,10 @@ use crate::commands::{ }; use crate::models::AppErrorKind; use crate::models::{ - argo_health_assessment, evaluate_health, AppError, DiscoveredResourceKind, HealthAssessment, - HealthAssessmentEvidence, HealthAssessmentInput, HealthAssessmentSource, HealthAssessmentState, - ResourceDetailsFull, ResourceHealth, ResourceSummary, YamlEncoding, YamlViewMode, + argo_health_assessment, condition_evidence, evaluate_health, AppError, DiscoveredResourceKind, + HealthAssessment, HealthAssessmentEvidence, HealthAssessmentInput, HealthAssessmentSource, + HealthAssessmentState, ResourceDetailsFull, ResourceHealth, ResourceSummary, YamlEncoding, + YamlViewMode, }; use chrono::{TimeZone, Utc}; use kube::{ @@ -149,7 +150,7 @@ fn dynamic_health_assessment(data: &Value) -> HealthAssessment { matches!(kind, "Ready" | "Healthy" | "Reconciling" | "Stalled").then(|| { HealthAssessmentEvidence { source: HealthAssessmentSource::Kubernetes, - raw: condition.clone(), + raw: condition_evidence(condition), state: Some(state), current: true, reason: format!("Kubernetes {kind} condition is {status}"), diff --git a/src-tauri/src/commands/streams.rs b/src-tauri/src/commands/streams.rs index 2eff6f87..689a6eba 100644 --- a/src-tauri/src/commands/streams.rs +++ b/src-tauri/src/commands/streams.rs @@ -59,6 +59,14 @@ fn validate_event_watch_target( Ok(()) } +/// Mirrors `MAX_RETAINED_LOG_LINES` in `src/features/resource-detail/log-helpers.ts`; +/// the frontend never keeps more lines than this, so the backend never requests more. +const MAX_LOG_TAIL_LINES: i64 = 1_000; + +fn clamp_tail_lines(tail_lines: Option) -> i64 { + tail_lines.unwrap_or(200).min(MAX_LOG_TAIL_LINES) +} + fn validate_pod_log_stream_request(request: &PodLogStreamRequest) -> Result<(), AppError> { if request.cluster_context.trim().is_empty() || request.namespace.trim().is_empty() diff --git a/src-tauri/src/commands/streams/aggregate_logs.rs b/src-tauri/src/commands/streams/aggregate_logs.rs index 91a9444c..1cb58c94 100644 --- a/src-tauri/src/commands/streams/aggregate_logs.rs +++ b/src-tauri/src/commands/streams/aggregate_logs.rs @@ -76,7 +76,7 @@ pub(super) async fn run_aggregated_log_stream( }; let pods: Api = Api::namespaced(client.clone(), &request.namespace); let options = LogStreamOptions { - tail_lines: Some(request.tail_lines.unwrap_or(200)), + tail_lines: Some(super::clamp_tail_lines(request.tail_lines)), since_seconds: request.since_seconds, }; let mut source_streams = SourceStreams::default(); diff --git a/src-tauri/src/commands/streams/logs.rs b/src-tauri/src/commands/streams/logs.rs index bbc33471..c8d0a569 100644 --- a/src-tauri/src/commands/streams/logs.rs +++ b/src-tauri/src/commands/streams/logs.rs @@ -32,7 +32,7 @@ pub(super) async fn run_pod_log_stream( let params = LogParams { container: request.container.clone(), follow: true, - tail_lines: Some(request.tail_lines.unwrap_or(200)), + tail_lines: Some(super::clamp_tail_lines(request.tail_lines)), since_seconds: request.since_seconds, timestamps: true, ..LogParams::default() diff --git a/src-tauri/src/models/health.rs b/src-tauri/src/models/health.rs index f0d641c6..3a23d993 100644 --- a/src-tauri/src/models/health.rs +++ b/src-tauri/src/models/health.rs @@ -81,6 +81,17 @@ pub struct HealthAssessmentInput { pub evidence: Vec, } +/// Keeps only the fields health evidence needs from a status condition. +pub fn condition_evidence(condition: &Value) -> Value { + let mut kept = serde_json::Map::new(); + for key in ["type", "status", "reason", "message"] { + if let Some(value) = condition.get(key) { + kept.insert(key.to_string(), value.clone()); + } + } + Value::Object(kept) +} + pub fn evaluate_health(mut input: HealthAssessmentInput) -> HealthAssessment { let completeness = if input.provider_available { HealthAssessmentCompleteness::Complete @@ -253,7 +264,7 @@ pub fn argo_application_set_health_assessment( let condition_status = condition.get("status").and_then(Value::as_str); evidence.push(HealthAssessmentEvidence { source: HealthAssessmentSource::ArgoHealth, - raw: condition.clone(), + raw: condition_evidence(condition), state: if condition_status == Some("True") { match condition_type { Some("ErrorOccurred") => Some(HealthAssessmentState::Degraded), diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index c0ef55b6..d23eef8d 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -23,10 +23,10 @@ pub use argo::{ ArgoAppProjectDetails, ArgoAppProjectSummary, ArgoApplicationDetails, ArgoApplicationHistory, ArgoApplicationInspector, ArgoApplicationRef, ArgoApplicationSetDetails, ArgoApplicationSetSummary, ArgoApplicationSourceSummary, ArgoApplicationSummary, - ArgoConnectedFallback, ArgoConnectionProfile, ArgoConnectionStatus, ArgoInspectionFailure, - ArgoManagedResource, ArgoOperationConfirmation, ArgoOperationPreflight, ArgoOperationRequest, - ArgoOperationResult, ArgoResourceComparison, ArgoServerCapability, ArgoServerEndpoint, - ArgoServiceTunnelUnavailableReason, + ArgoConfirmedTarget, ArgoConnectedFallback, ArgoConnectionProfile, ArgoConnectionStatus, + ArgoInspectionFailure, ArgoManagedResource, ArgoOperationConfirmation, ArgoOperationPreflight, + ArgoOperationRequest, ArgoOperationResult, ArgoResourceComparison, ArgoServerCapability, + ArgoServerEndpoint, ArgoServiceTunnelUnavailableReason, }; pub use cancellation::{CancelBackendRequestsResult, CancelWorkspaceRequestsResult}; pub use cluster::ClusterContext; @@ -43,8 +43,8 @@ pub use flux::{ FluxResourceSummary, }; pub use health::{ - argo_application_set_health_assessment, argo_health_assessment, evaluate_health, - HealthAssessment, HealthAssessmentCompleteness, HealthAssessmentEvidence, + argo_application_set_health_assessment, argo_health_assessment, condition_evidence, + evaluate_health, HealthAssessment, HealthAssessmentCompleteness, HealthAssessmentEvidence, HealthAssessmentInput, HealthAssessmentSource, HealthAssessmentState, }; pub use helm::{ diff --git a/src/features/incidents/guidance.ts b/src/features/incidents/guidance.ts index b99a5443..38fbfbbd 100644 --- a/src/features/incidents/guidance.ts +++ b/src/features/incidents/guidance.ts @@ -111,7 +111,7 @@ function detailedEvidence( ): IncidentEvidence[] { if (!details) return []; const conditions = getConditionRows(details.status); - const containers = getContainerStatusRows(details.status); + const containers = getContainerStatusRows(details.status, details.summary.kind); return buildIncidentSignals(details.summary, conditions, [], containers).map((signal) => ({ id: `detail:${signal.id}`, label: signal.label, diff --git a/src/features/resource-detail/ResourceDetailPanel.svelte b/src/features/resource-detail/ResourceDetailPanel.svelte index f66eac52..63dabbd0 100644 --- a/src/features/resource-detail/ResourceDetailPanel.svelte +++ b/src/features/resource-detail/ResourceDetailPanel.svelte @@ -279,6 +279,13 @@ retry: false, staleTime: 30_000, })); + const eventsUid = $derived( + typeof detailsQuery.data?.metadata?.uid === "string" ? detailsQuery.data.metadata.uid : undefined, + ); + // Events are matched by UID, so wait for the details read that supplies it. + const eventsReady = $derived( + !detailsEnabled || detailsQuery.isSuccess || detailsQuery.isError, + ); const eventsQuery = createQuery(() => ({ queryKey: eventsQueryKey, queryFn: async () => { @@ -292,6 +299,7 @@ resource.namespace ?? undefined, kubeconfigSourceKey, createFiniteReadRequest(eventsCancelScope, "events"), + eventsUid, ), ); } catch (error) { @@ -301,14 +309,14 @@ throw error; } }, - enabled: eventsEnabled, + enabled: eventsEnabled && eventsReady, retry: false, staleTime: 30_000, })); const detailResource = $derived(detailsQuery.data?.summary ?? resource); const conditionRows = $derived(getConditionRows(detailsQuery.data?.status)); - const containerRows = $derived(getContainerStatusRows(detailsQuery.data?.status)); + const containerRows = $derived(getContainerStatusRows(detailsQuery.data?.status, detailResource.kind)); const containerOptions = $derived.by(() => { const regularContainers = containerRows.filter((container) => container.type !== "init"); return (regularContainers.length > 0 ? regularContainers : containerRows).map( diff --git a/src/features/resource-detail/helpers.ts b/src/features/resource-detail/helpers.ts index 3fe70577..67ed0f1d 100644 --- a/src/features/resource-detail/helpers.ts +++ b/src/features/resource-detail/helpers.ts @@ -142,8 +142,9 @@ function getStatusList(status: JsonObject, key: string): JsonObject[] { export function getContainerStatusRows( status: JsonObject | undefined, + kind: string, ): ContainerStatusRow[] { - if (!status) return []; + if (!status || kind !== "Pod") return []; const containers = [ ...getStatusList(status, "initContainerStatuses").map((container) => ({ container, diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 216459bd..70ba3039 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -502,12 +502,14 @@ export async function listResourceEvents( namespace?: string, kubeconfigEnvVar?: string, cancellable?: CancellableRequest, + uid?: string, ): Promise { return client.invoke("list_resource_events", { clusterContext, kind, name, namespace, + uid, ...kubeconfigArg(kubeconfigEnvVar), ...cancellableArg(cancellable), }); diff --git a/tests/tauri.test.ts b/tests/tauri.test.ts index 9b6ba2ec..473e5f7c 100644 --- a/tests/tauri.test.ts +++ b/tests/tauri.test.ts @@ -303,7 +303,7 @@ describe("getContainerStatusRows", () => { }, }, ], - }), + }, "Pod"), ).toEqual([ { name: "api", From 3c1d8626fcecbc7048cac4470ccc2e464933de23 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:34:06 +0000 Subject: [PATCH 18/37] Require confirmed tunnel targets before sending Argo credentials --- .../0017-private-argocd-service-tunnels.md | 11 +++ src-tauri/src/commands/argo/connected.rs | 54 +++++++++---- src-tauri/src/commands/argo/tunnel.rs | 71 ++++++++++++++++- src-tauri/src/models/argo.rs | 16 ++++ src/app/svelte/ArgoConnectionSettings.svelte | 76 +++++++++++++++++-- src/lib/gitops-types.ts | 8 ++ src/lib/tauri-argo.ts | 2 + src/lib/tauri-dev-mocks.ts | 8 +- 8 files changed, 223 insertions(+), 23 deletions(-) diff --git a/docs/decisions/0017-private-argocd-service-tunnels.md b/docs/decisions/0017-private-argocd-service-tunnels.md index 07aa99bd..2da042b0 100644 --- a/docs/decisions/0017-private-argocd-service-tunnels.md +++ b/docs/decisions/0017-private-argocd-service-tunnels.md @@ -13,3 +13,14 @@ Discovery reports only eligible TCP Service ports and a bounded unavailable reas ## Consequences Manual external HTTPS profiles remain available. Saved profiles persist endpoint identity and scope but no credentials, TLS override, custom CA material, or tunnel address. Tunnel access is unavailable when discovery cannot prove the required Service target. + +## Amendment 2026-10-05: user-confirmed tunnel target + +Discovery matches Services by name (`argocd-server`, `argo-cd-argocd-server`) in any readable namespace, so a workload in any namespace can imitate Argo CD and receive a token or local login sent through a tunnel. Service names and labels are attacker-controllable and are not identity. + +Trust rule: + +- Discovery reports the Pod it resolved for each Service (`targetPod`) and whether the Service carries `app.kubernetes.io/part-of=argocd` (`argoLabeled`). The label is a hint only. Unlabelled Services stay selectable but are flagged in the selector and in a stronger warning. +- Before credentials are sent through a Service tunnel, including reconnects of saved profiles that use a remembered credential, the UI shows the exact namespace, Service name, and Pod with a warning and requires explicit confirmation. The user confirmation is the control. +- `connect_argo_server` takes the confirmed target (namespace, Service name, Pod). For a Service tunnel the backend starts the tunnel, resolves the target, and refuses to send any credential unless the confirmed target matches the resolved namespace, Service, and Pod. A missing confirmation or a target that changed between display and send is rejected, and the user must refresh discovery and confirm again. +- The Pod is confirmed at connect time only. The tunnel continues to resolve Service endpoints per connection as before. diff --git a/src-tauri/src/commands/argo/connected.rs b/src-tauri/src/commands/argo/connected.rs index 6b9c5ec3..d5c014e7 100644 --- a/src-tauri/src/commands/argo/connected.rs +++ b/src-tauri/src/commands/argo/connected.rs @@ -2,7 +2,10 @@ pub(crate) use super::transport::{api_delete, api_get, api_post, redact_secret_f use super::transport::{ argo_url, http_client, normalize_endpoint, response_json, safe_http_error, url, }; -use super::{scope::scoped_connection, tunnel::ArgoServiceTunnel}; +use super::{ + scope::scoped_connection, + tunnel::{verify_confirmed_target, ArgoServiceTunnel}, +}; use crate::commands::{ gitops_crd::{client_for_context, find_api_resource, get_crd_object}, kubeconfig::KubeconfigSource, @@ -11,8 +14,9 @@ use crate::commands::{ use crate::models::AppErrorKind; use crate::models::{ AppError, ArgoApplicationHistory, ArgoApplicationInspector, ArgoApplicationRef, - ArgoConnectionProfile, ArgoConnectionStatus, ArgoManagedResource, ArgoResourceComparison, - ArgoServerCapability, ArgoServerEndpoint, ArgoServiceTunnelUnavailableReason, + ArgoConfirmedTarget, ArgoConnectionProfile, ArgoConnectionStatus, ArgoManagedResource, + ArgoResourceComparison, ArgoServerCapability, ArgoServerEndpoint, + ArgoServiceTunnelUnavailableReason, }; use k8s_openapi::api::core::v1::Service; use kube::{ @@ -365,6 +369,8 @@ fn unavailable_capability( endpoint: None, unavailable_reason: Some(message.into()), unavailable: Some(reason), + target_pod: None, + argo_labeled: false, } } @@ -446,11 +452,22 @@ fn servicetunnel_capabilities(service: &Service) -> Vec { }), unavailable_reason: None, unavailable: None, + target_pod: None, + argo_labeled: false, } }) .collect() } +fn is_argo_labeled(service: &Service) -> bool { + service + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("app.kubernetes.io/part-of")) + .is_some_and(|value| value == "argocd") +} + fn tunnel_target_unavailable(error: &AppError) -> ArgoServiceTunnelUnavailableReason { match error.kind { AppErrorKind::LiveSessionTargetUnavailable => { @@ -482,6 +499,7 @@ pub async fn discover_argo_servers( let mut capabilities = Vec::new(); for service in list.items { for mut capability in servicetunnel_capabilities(&service) { + capability.argo_labeled = is_argo_labeled(&service); if let Some(ArgoServerEndpoint::ServiceTunnel { namespace, service_name, @@ -489,17 +507,20 @@ pub async fn discover_argo_servers( .. }) = capability.endpoint.as_ref() { - if let Err(error) = - crate::commands::sessions::service::resolve_service_target( - client.clone(), - namespace, - service_name, - *service_port, - ) - .await + match crate::commands::sessions::service::resolve_service_target( + client.clone(), + namespace, + service_name, + *service_port, + ) + .await { - capability.unavailable = Some(tunnel_target_unavailable(&error)); - capability.unavailable_reason = Some(error.message); + Ok(target) => capability.target_pod = Some(target.pod_name), + Err(error) => { + capability.unavailable = + Some(tunnel_target_unavailable(&error)); + capability.unavailable_reason = Some(error.message); + } } } capabilities.push(capability); @@ -526,6 +547,7 @@ pub async fn connect_argo_server( cluster_context: Option, kubeconfig_env_var: Option, workspace_id: Option, + confirmed_target: Option, ) -> Result { let connection_epoch = store.connection_epoch(); let kubeconfig_source_key = kubeconfig_source_key(kubeconfig_env_var.as_deref())?; @@ -564,6 +586,12 @@ pub async fn connect_argo_server( *service_port, ) .await?; + verify_confirmed_target( + confirmed_target.as_ref(), + namespace, + service_name, + started.pod_name(), + )?; let host = argo_url(&profile.url)? .host_str() .expect("normalized service endpoint has a host") diff --git a/src-tauri/src/commands/argo/tunnel.rs b/src-tauri/src/commands/argo/tunnel.rs index cf5c6c0b..74eac761 100644 --- a/src-tauri/src/commands/argo/tunnel.rs +++ b/src-tauri/src/commands/argo/tunnel.rs @@ -5,7 +5,7 @@ use crate::{ service::resolve_service_target, target::client_for_context, }, - models::AppError, + models::{AppError, ArgoConfirmedTarget}, }; use k8s_openapi::api::core::v1::Pod; use kube::{api::Api, Client}; @@ -21,6 +21,7 @@ use tokio::{ pub(crate) struct ArgoServiceTunnel { local_port: u16, + pod_name: String, state: Arc, } @@ -55,6 +56,7 @@ impl ArgoServiceTunnel { let client = client_for_context(cluster_context, kubeconfig_env_var).await?; let target = resolve_service_target(client.clone(), &namespace, &service_name, service_port).await?; + let pod_name = target.pod_name.clone(); verify_port_forward( client.clone(), &target.namespace, @@ -94,6 +96,7 @@ impl ArgoServiceTunnel { )); Ok(Self { local_port, + pod_name, state: Arc::new(TunnelState { shutdown: std::sync::Mutex::new(Some(shutdown)), task: std::sync::Mutex::new(Some(task)), @@ -105,6 +108,10 @@ impl ArgoServiceTunnel { self.local_port } + pub(crate) fn pod_name(&self) -> &str { + &self.pod_name + } + pub(crate) fn close(&self) { self.state.close(); } @@ -113,6 +120,7 @@ impl ArgoServiceTunnel { pub(crate) fn test_tunnel(shutdown: oneshot::Sender<()>) -> Self { Self { local_port: 0, + pod_name: String::new(), state: Arc::new(TunnelState { shutdown: std::sync::Mutex::new(Some(shutdown)), task: std::sync::Mutex::new(None), @@ -127,6 +135,32 @@ impl Drop for ArgoServiceTunnel { } } +/// Credentials may only cross a tunnel whose resolved target is exactly what the user confirmed. +pub(crate) fn verify_confirmed_target( + confirmed: Option<&ArgoConfirmedTarget>, + namespace: &str, + service_name: &str, + pod_name: &str, +) -> Result<(), AppError> { + let confirmed = confirmed.ok_or_else(|| { + AppError::new( + "confirm the Argo CD Service tunnel target before sending credentials", + AppErrorKind::ArgoConnection, + ) + })?; + let pod_matches = confirmed + .pod_name + .as_deref() + .is_some_and(|confirmed_pod| confirmed_pod == pod_name); + if confirmed.namespace == namespace && confirmed.service_name == service_name && pod_matches { + return Ok(()); + } + Err(AppError::new( + "Argo CD Service tunnel target changed since it was confirmed; refresh discovery and confirm again", + AppErrorKind::ArgoConnection, + )) +} + #[derive(Clone)] struct ServiceRoute { namespace: String, @@ -235,6 +269,7 @@ mod tests { let (shutdown, receiver) = oneshot::channel(); let tunnel = ArgoServiceTunnel { local_port: 0, + pod_name: String::new(), state: Arc::new(TunnelState { shutdown: std::sync::Mutex::new(Some(shutdown)), task: std::sync::Mutex::new(None), @@ -247,6 +282,40 @@ mod tests { state.close(); } + #[test] + fn credentials_require_the_exact_confirmed_target() { + let confirmed = |namespace: &str, service: &str, pod: Option<&str>| ArgoConfirmedTarget { + namespace: namespace.into(), + service_name: service.into(), + pod_name: pod.map(Into::into), + }; + let check = |target: Option| { + verify_confirmed_target(target.as_ref(), "argocd", "argocd-server", "pod-a").is_ok() + }; + assert!(check(Some(confirmed( + "argocd", + "argocd-server", + Some("pod-a") + )))); + assert!(!check(None)); + assert!(!check(Some(confirmed( + "evil", + "argocd-server", + Some("pod-a") + )))); + assert!(!check(Some(confirmed( + "argocd", + "argo-cd-argocd-server", + Some("pod-a") + )))); + assert!(!check(Some(confirmed( + "argocd", + "argocd-server", + Some("pod-b") + )))); + assert!(!check(Some(confirmed("argocd", "argocd-server", None)))); + } + #[test] fn forbidden_port_forward_is_actionable_and_redacted() { let error = port_forward_error(kube::Error::Api(Box::new(kube::core::Status { diff --git a/src-tauri/src/models/argo.rs b/src-tauri/src/models/argo.rs index 18582549..350f30c2 100644 --- a/src-tauri/src/models/argo.rs +++ b/src-tauri/src/models/argo.rs @@ -383,6 +383,22 @@ pub struct ArgoServerCapability { pub unavailable_reason: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub unavailable: Option, + /// Pod discovery resolved for the Service; shown to the user for target confirmation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_pod: Option, + /// Whether the Service carries `app.kubernetes.io/part-of=argocd`. A hint, not identity. + #[serde(default)] + pub argo_labeled: bool, +} + +/// Exact private tunnel target the user confirmed before credentials are sent. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArgoConfirmedTarget { + pub namespace: String, + pub service_name: String, + #[serde(default)] + pub pod_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src/app/svelte/ArgoConnectionSettings.svelte b/src/app/svelte/ArgoConnectionSettings.svelte index 23d50556..bd62da0b 100644 --- a/src/app/svelte/ArgoConnectionSettings.svelte +++ b/src/app/svelte/ArgoConnectionSettings.svelte @@ -35,7 +35,7 @@ discoverArgoServers, forgetArgoCredential, } from "@/lib/tauri"; - import type { ArgoServerEndpoint } from "@/lib/types"; + import type { ArgoConfirmedTarget, ArgoServerEndpoint } from "@/lib/types"; let { clusterContext, workspaceId, kubeconfigEnvVar }: { clusterContext?: string; @@ -64,6 +64,11 @@ let busy = $state(false); let error = $state(null); let connected = $state(null); + let pendingConfirmation = $state<{ + saved?: (typeof settings.argoProfiles)[number]; + target: ArgoConfirmedTarget; + argoLabeled: boolean; + } | null>(null); const matchingProfiles = $derived( clusterContext && workspaceId ? eligibleArgoProfiles( @@ -145,6 +150,11 @@ return () => finiteReadCleanup.schedule(cancelScope, queryKey); }); + $effect(() => { + selectedCapabilityId; + pendingConfirmation = null; + }); + $effect(() => { connected = connectionStatuses.data?.find(([, status]) => status.connected)?.[0] ?? null; }); @@ -165,9 +175,42 @@ return `argo:${workspaceId ?? "global"}:${clusterContext ?? "global"}:${kubeconfigEnvVar ?? "global"}:${argoEndpointIdentity(endpoint)}`; } - async function connect(saved?: (typeof settings.argoProfiles)[number]) { + // Credentials never cross a private tunnel until the user confirms the exact discovered target. + function requestConnect(saved?: (typeof settings.argoProfiles)[number]) { + const endpoint = saved?.endpoint ?? draftEndpoint; + if (!endpoint) return; + if (endpoint.kind !== "serviceTunnel") return void connect(saved); + const capability = discovered.data?.find( + (server) => + server.endpoint?.kind === "serviceTunnel" && + server.endpoint.namespace === endpoint.namespace && + server.endpoint.serviceName === endpoint.serviceName && + server.endpoint.servicePort === endpoint.servicePort && + !server.unavailableReason, + ); + if (!capability?.targetPod) { + error = "This Service tunnel target was not discovered. Refresh discovery and try again."; + return; + } + error = null; + pendingConfirmation = { + saved, + target: { + namespace: endpoint.namespace, + serviceName: endpoint.serviceName, + podName: capability.targetPod, + }, + argoLabeled: capability.argoLabeled, + }; + } + + async function connect( + saved?: (typeof settings.argoProfiles)[number], + confirmedTarget?: ArgoConfirmedTarget, + ) { const endpoint = saved?.endpoint ?? draftEndpoint; if (!endpoint) return; + pendingConfirmation = null; busy = true; error = null; try { @@ -185,6 +228,7 @@ clusterContext, kubeconfigEnvVar, workspaceId, + confirmedTarget, }); if (result.profile) { const profile = result.profile; @@ -272,7 +316,7 @@ Only discovered selector-backed TCP Services can be connected. @@ -303,8 +347,30 @@ {#if insecureTls} Insecure sessionCertificate validation is disabled and never saved. {/if} + {#if pendingConfirmation} + {@const pending = pendingConfirmation} + + + Confirm tunnel target + +

Your Argo CD credentials will be sent to this cluster workload. Any workload with this name can capture them.

+
+
Namespace
{pending.target.namespace}
+
Service
{pending.target.serviceName}
+
Pod
{pending.target.podName}
+
+ {#if !pending.argoLabeled} +

This Service does not carry the standard Argo CD label (app.kubernetes.io/part-of=argocd). Only continue if you recognise it as your Argo CD server.

+ {/if} +
+ + +
+
+
+ {/if} {#if error}Connection failed{error}{/if} - + {:else}

Open Settings from a workspace to discover or connect an Argo CD server.

@@ -312,7 +378,7 @@ {#if matchingProfiles.length > 0}

Saved server profiles

{#each matchingProfiles as profile} -
{endpointLabel(profile.endpoint)}
{#if clusterContext}{/if}
+
{endpointLabel(profile.endpoint)}
{#if clusterContext}{/if}
{/each}
{/if} diff --git a/src/lib/gitops-types.ts b/src/lib/gitops-types.ts index 4fe7311f..621762bc 100644 --- a/src/lib/gitops-types.ts +++ b/src/lib/gitops-types.ts @@ -88,6 +88,14 @@ export interface ArgoServerCapability { endpoint: ArgoServerEndpoint | null; unavailableReason: string | null; unavailable?: ArgoServiceTunnelUnavailableReason | null; + targetPod?: string | null; + argoLabeled: boolean; +} + +export interface ArgoConfirmedTarget { + namespace: string; + serviceName: string; + podName: string; } export interface ArgoApplicationRef { diff --git a/src/lib/tauri-argo.ts b/src/lib/tauri-argo.ts index 0142c7a8..07d28ac0 100644 --- a/src/lib/tauri-argo.ts +++ b/src/lib/tauri-argo.ts @@ -5,6 +5,7 @@ import type { ArgoApplicationInspector, ArgoApplicationRef, ArgoConnectionProfile, + ArgoConfirmedTarget, ArgoConnectionStatus, ArgoManagedResource, ArgoOperationConfirmation, @@ -45,6 +46,7 @@ export async function connectArgoServer( clusterContext?: string; kubeconfigEnvVar?: string; workspaceId?: string; + confirmedTarget?: ArgoConfirmedTarget; }, ): Promise { const { kubeconfigEnvVar, ...args } = request; diff --git a/src/lib/tauri-dev-mocks.ts b/src/lib/tauri-dev-mocks.ts index c306478d..28595287 100644 --- a/src/lib/tauri-dev-mocks.ts +++ b/src/lib/tauri-dev-mocks.ts @@ -164,16 +164,16 @@ const handlers = { list_resource_metrics: () => metrics(), detect_argocd: () => true, discover_argo_servers: () => [ - { id: "service:argocd:argocd-server:443", name: "argocd-server", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: { kind: "serviceTunnel", namespace: "argocd", serviceName: "argocd-server", servicePort: 443, scheme: "https" }, unavailableReason: null }, - { id: "service:argocd:argocd-server:80", name: "argocd-server", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: { kind: "serviceTunnel", namespace: "argocd", serviceName: "argocd-server", servicePort: 80, scheme: "https" }, unavailableReason: null }, - { id: "service:argocd:external", name: "argocd-external", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: null, unavailableReason: "ExternalName Services cannot be port-forwarded" }, + { id: "service:argocd:argocd-server:443", name: "argocd-server", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: { kind: "serviceTunnel", namespace: "argocd", serviceName: "argocd-server", servicePort: 443, scheme: "https" }, unavailableReason: null, targetPod: "argocd-server-7d9f8b6c5-x2k4q", argoLabeled: true }, + { id: "service:argocd:argocd-server:80", name: "argocd-server", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: { kind: "serviceTunnel", namespace: "argocd", serviceName: "argocd-server", servicePort: 80, scheme: "https" }, unavailableReason: null, targetPod: "argocd-server-7d9f8b6c5-x2k4q", argoLabeled: true }, + { id: "service:argocd:external", name: "argocd-external", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: null, unavailableReason: "ExternalName Services cannot be port-forwarded", argoLabeled: false }, ], connect_argo_server: (args) => { const serverUrl = args?.serverUrl ?? "https://argocd.example.test"; const endpoint = args?.endpoint ?? { kind: "externalHttps", url: serverUrl }; const profile: ArgoConnectionProfile = { id: args?.id ?? "mock-profile", endpoint, url: serverUrl, clusterContext: args?.clusterContext ?? null, workspaceId: args?.workspaceId ?? null, transport: "connected", rememberCredential: Boolean(args?.rememberCredential) }; argoConnections.set(profile.id, profile); - return { profile, connected: true, username: args?.username ?? "mock-user", unavailableReason: null }; + return { profile, connected: true, username: args?.username ?? "mock-user", unavailableReason: null, targetPod: "argocd-server-7d9f8b6c5-x2k4q", argoLabeled: true }; }, get_argo_connection_status: (args) => { const profile = args?.id ? argoConnections.get(args.id) ?? null : null; From 363db9f66e89c85f2939503f0d2cec7c31894443 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:34:09 +0000 Subject: [PATCH 19/37] Fail closed on unloadable kubeconfig sources for cluster-changing commands --- src-tauri/src/commands/argo/operations.rs | 12 ++++--- .../argo/operations_execution_tests.rs | 6 ++-- src-tauri/src/commands/gitops_crd.rs | 10 ++++++ src-tauri/src/commands/kubeconfig.rs | 34 ++++++++++++++++++ src-tauri/src/commands/kubeconfig_clients.rs | 3 ++ src-tauri/src/commands/kubeconfig_tests.rs | 36 +++++++++++++++++++ src-tauri/src/commands/operations.rs | 9 +++-- src-tauri/src/commands/pod_exec/validation.rs | 2 +- src-tauri/src/commands/resources/apply.rs | 4 +-- src-tauri/src/commands/sessions/target.rs | 2 +- 10 files changed, 104 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/commands/argo/operations.rs b/src-tauri/src/commands/argo/operations.rs index 2e653fb6..96fe1b72 100644 --- a/src-tauri/src/commands/argo/operations.rs +++ b/src-tauri/src/commands/argo/operations.rs @@ -1,7 +1,7 @@ use super::connected::{api_delete, api_get, api_post, ConnectedArgo}; use super::scope::{acquire_connection_lease, scoped_connection, ConnectionLease}; use super::session::{consume, issue, peek, OperationSession, SessionSnapshot}; -use crate::commands::gitops_crd::{client_for_context, find_api_resource}; +use crate::commands::gitops_crd::{fail_closed_client_for_context, find_api_resource}; use crate::commands::kubeconfig::KubeconfigSource; use crate::models::AppErrorKind; use crate::models::{ @@ -171,7 +171,8 @@ async fn fallback_allowed(request: &ArgoOperationRequest) -> Result<(), AppError AppErrorKind::ArgoOperationUnavailable, )); } - let client = client_for_context(context, request.kubeconfig_env_var.clone()).await?; + let client = + fail_closed_client_for_context(context, request.kubeconfig_env_var.clone()).await?; let review = SelfSubjectAccessReview { metadata: ObjectMeta::default(), spec: SelfSubjectAccessReviewSpec { @@ -372,7 +373,8 @@ async fn revalidate_session( } fallback_allowed(request).await?; let context = request.cluster_context.as_deref().expect("validated"); - let client = client_for_context(context, request.kubeconfig_env_var.clone()).await?; + let client = + fail_closed_client_for_context(context, request.kubeconfig_env_var.clone()).await?; let resource = find_api_resource(&client, "argoproj.io", "Application") .await? .ok_or_else(|| AppError::new("Application CRD not found", AppErrorKind::Cluster))?; @@ -623,6 +625,7 @@ async fn kubernetes_operation( // ADR 0009: a confirmed write must not be cancelled by workspace client // rotation after the API server may already have applied it. let client = KubeconfigSource::new(request.kubeconfig_env_var.clone())? + .fail_closed() .operation_client_for_context(request.cluster_context.as_deref().expect("validated")) .await?; let api = Api::::namespaced_with( @@ -767,7 +770,8 @@ async fn resolve_request( AppErrorKind::ArgoOperationUnavailable, ) })?; - let client = client_for_context(context, request.kubeconfig_env_var.clone()).await?; + let client = + fail_closed_client_for_context(context, request.kubeconfig_env_var.clone()).await?; let ar = find_api_resource(&client, "argoproj.io", "Application") .await? .ok_or_else(|| AppError::new("Application CRD not found", AppErrorKind::Cluster))?; diff --git a/src-tauri/src/commands/argo/operations_execution_tests.rs b/src-tauri/src/commands/argo/operations_execution_tests.rs index 2ebe2987..c230cf75 100644 --- a/src-tauri/src/commands/argo/operations_execution_tests.rs +++ b/src-tauri/src/commands/argo/operations_execution_tests.rs @@ -148,10 +148,8 @@ async fn confirmed_kubernetes_operation_survives_workspace_client_rotation() { env::remove_var(&env_var); let _ = fs::remove_file(&kubeconfig); - assert!( - received - .starts_with(b"PATCH /apis/argoproj.io/v1alpha1/namespaces/argocd/applications/demo") - ); + assert!(received + .starts_with(b"PATCH /apis/argoproj.io/v1alpha1/namespaces/argocd/applications/demo")); assert!( result .expect("workspace rotation must not cancel a confirmed write") diff --git a/src-tauri/src/commands/gitops_crd.rs b/src-tauri/src/commands/gitops_crd.rs index 34c1ff99..dc1db00b 100644 --- a/src-tauri/src/commands/gitops_crd.rs +++ b/src-tauri/src/commands/gitops_crd.rs @@ -18,6 +18,16 @@ pub(crate) async fn client_for_context( source.client_for_context(cluster_context).await } +/// Client for cluster-changing flows: errors instead of falling back to the +/// default kubeconfig when configured sources do not load. +pub(crate) async fn fail_closed_client_for_context( + cluster_context: &str, + kubeconfig_env_var: Option, +) -> Result { + let source = KubeconfigSource::new(kubeconfig_env_var)?.fail_closed(); + source.client_for_context(cluster_context).await +} + pub(crate) async fn discover_api_resources(client: &Client) -> Result, AppError> { let discovery = Discovery::new(client.clone()) .run_aggregated() diff --git a/src-tauri/src/commands/kubeconfig.rs b/src-tauri/src/commands/kubeconfig.rs index 300a9fa8..ea5f827d 100644 --- a/src-tauri/src/commands/kubeconfig.rs +++ b/src-tauri/src/commands/kubeconfig.rs @@ -80,6 +80,7 @@ pub struct KubeconfigSource { app_paths: Vec, show_source_labels: bool, read_env: bool, + fail_closed: bool, } impl KubeconfigSource { @@ -115,6 +116,7 @@ impl KubeconfigSource { .collect(), show_source_labels, read_env: true, + fail_closed: false, }) } @@ -125,9 +127,27 @@ impl KubeconfigSource { app_paths: vec![path], show_source_labels: false, read_env: false, + fail_closed: false, } } + /// Cluster-changing paths use this: when configured sources exist but none + /// load, they error instead of falling back to the default kubeconfig. + #[must_use] + pub fn fail_closed(mut self) -> Self { + self.fail_closed = true; + self + } + + // Checked before client-cache lookups so a client cached from the default + // fallback is never reused by a fail-closed source. + pub(super) fn ensure_configured_sources_load(&self) -> Result<(), AppError> { + if self.fail_closed && self.configured_paths()?.is_some() { + self.read_configured_kubeconfig()?; + } + Ok(()) + } + pub fn key(&self) -> String { let mut hasher = DefaultHasher::new(); self.env_var.hash(&mut hasher); @@ -315,6 +335,20 @@ impl KubeconfigSource { )); } + if self.fail_closed { + return Err(AppError::new( + format!( + "none of the configured kubeconfig sources could be loaded; refusing to fall back to the default kubeconfig for a cluster-changing action: {}", + warnings + .iter() + .map(|warning| warning.message.as_str()) + .collect::>() + .join("; ") + ), + AppErrorKind::Kubeconfig, + )); + } + read_default_kubeconfig_without_env() .map(|kubeconfig| (kubeconfig, warnings)) .map_err(|err| { diff --git a/src-tauri/src/commands/kubeconfig_clients.rs b/src-tauri/src/commands/kubeconfig_clients.rs index c08d6944..397cddc7 100644 --- a/src-tauri/src/commands/kubeconfig_clients.rs +++ b/src-tauri/src/commands/kubeconfig_clients.rs @@ -31,6 +31,7 @@ impl KubeconfigSource { &self, cluster_context: &str, ) -> Result<(Client, String), AppError> { + self.ensure_configured_sources_load()?; let fingerprint = client_cache::fingerprint_files(&self.effective_kubeconfig_paths()?); let source_key = self.key(); let generation = client_cache::finite_client_generation(); @@ -65,6 +66,7 @@ impl KubeconfigSource { &self, cluster_context: &str, ) -> Result { + self.ensure_configured_sources_load()?; let fingerprint = client_cache::fingerprint_files(&self.effective_kubeconfig_paths()?); let source_key = self.key(); if let Some(client) = @@ -95,6 +97,7 @@ impl KubeconfigSource { &self, cluster_context: &str, ) -> Result<(Client, String), AppError> { + self.ensure_configured_sources_load()?; let fingerprint = client_cache::fingerprint_files(&self.effective_kubeconfig_paths()?); let source_key = self.key(); if let Some(cached) = diff --git a/src-tauri/src/commands/kubeconfig_tests.rs b/src-tauri/src/commands/kubeconfig_tests.rs index 47e943fb..e93eb1c4 100644 --- a/src-tauri/src/commands/kubeconfig_tests.rs +++ b/src-tauri/src/commands/kubeconfig_tests.rs @@ -307,3 +307,39 @@ fn e2e_source_ignores_inherited_override() { let _ = fs::remove_file(override_path); let _ = fs::remove_file(standard_path); } + +#[test] +fn missing_app_path_fails_closed_for_cluster_changing_sources() { + let _env_lock = ENV_LOCK.lock().expect("environment lock"); + let home = env::temp_dir().join(unique_env_var("FAIL_CLOSED_HOME")); + let default_path = home.join(".kube").join("config"); + fs::create_dir_all(default_path.parent().expect("default kubeconfig parent")) + .expect("create default kubeconfig directory"); + let default_source = write_kubeconfig("default-context"); + fs::copy(&default_source, &default_path).expect("copy default kubeconfig"); + let missing = env::temp_dir().join(unique_env_var("FAIL_CLOSED_MISSING")); + let env_var = unique_env_var("FAIL_CLOSED_ENV"); + let _kubeconfig = EnvVarGuard::set(&env_var, &missing); + let _home = EnvVarGuard::set("HOME", &home); + let source = KubeconfigSource::from_settings(Some(&env_var), Vec::new(), true).expect("source"); + + let (kubeconfig, _) = source + .read_configured_kubeconfig() + .expect("read-only paths fall back to the default kubeconfig"); + assert_eq!( + kubeconfig.current_context.as_deref(), + Some("default-context") + ); + + let source = source.fail_closed(); + + let error = source + .read_configured_kubeconfig() + .expect_err("must not fall back to the default kubeconfig"); + assert_eq!(error.kind, AppErrorKind::Kubeconfig); + assert!(error.message.contains("refusing to fall back")); + assert!(source.ensure_configured_sources_load().is_err()); + + let _ = fs::remove_dir_all(home); + let _ = fs::remove_file(default_source); +} diff --git a/src-tauri/src/commands/operations.rs b/src-tauri/src/commands/operations.rs index bec64a90..4a06856d 100644 --- a/src-tauri/src/commands/operations.rs +++ b/src-tauri/src/commands/operations.rs @@ -141,6 +141,7 @@ async fn operation_client_for( kubeconfig_env_var: Option, ) -> Result { KubeconfigSource::new(kubeconfig_env_var)? + .fail_closed() .operation_client_for_context(&target.cluster_context) .await } @@ -150,6 +151,7 @@ async fn client_for( kubeconfig_env_var: Option, ) -> Result { KubeconfigSource::new(kubeconfig_env_var)? + .fail_closed() .client_for_context(&target.cluster_context) .await } @@ -194,10 +196,13 @@ fn validate_target(target: &ClusterOperationTarget, supported: &[&str]) -> Resul )); } if let Some(api_version) = target.api_version.as_deref() { - let expected = builtin_kind(&target.kind).map(|kind| kind.api_version()); + let expected = builtin_kind(&target.kind).map(BuiltinKind::api_version); if expected.as_deref() != Some(api_version) { return Err(AppError::new( - format!("{} is not the built-in {} resource", api_version, target.kind), + format!( + "{} is not the built-in {} resource", + api_version, target.kind + ), AppErrorKind::UnsupportedOperation, )); } diff --git a/src-tauri/src/commands/pod_exec/validation.rs b/src-tauri/src/commands/pod_exec/validation.rs index 1c4b1f71..bf21df63 100644 --- a/src-tauri/src/commands/pod_exec/validation.rs +++ b/src-tauri/src/commands/pod_exec/validation.rs @@ -135,6 +135,6 @@ pub(super) async fn client_for_context( cluster_context: &str, kubeconfig_env_var: Option, ) -> Result { - let source = KubeconfigSource::new(kubeconfig_env_var)?; + let source = KubeconfigSource::new(kubeconfig_env_var)?.fail_closed(); source.live_client_for_context(cluster_context).await } diff --git a/src-tauri/src/commands/resources/apply.rs b/src-tauri/src/commands/resources/apply.rs index 83ebcf4d..1dc7ec20 100644 --- a/src-tauri/src/commands/resources/apply.rs +++ b/src-tauri/src/commands/resources/apply.rs @@ -14,7 +14,7 @@ use validation::validate_yaml_apply; #[tauri::command] pub async fn prepare_yaml_apply(request: YamlApplyRequest) -> Result { let validated = validate_yaml_apply(request)?; - let source = KubeconfigSource::new(validated.request.kubeconfig_env_var.clone())?; + let source = KubeconfigSource::new(validated.request.kubeconfig_env_var.clone())?.fail_closed(); let client = source .client_for_context(&validated.request.cluster_context) .await?; @@ -24,7 +24,7 @@ pub async fn prepare_yaml_apply(request: YamlApplyRequest) -> Result Result { let validated = validate_yaml_apply(request)?; - let source = KubeconfigSource::new(validated.request.kubeconfig_env_var.clone())?; + let source = KubeconfigSource::new(validated.request.kubeconfig_env_var.clone())?.fail_closed(); let client = source .operation_client_for_context(&validated.request.cluster_context) .await?; diff --git a/src-tauri/src/commands/sessions/target.rs b/src-tauri/src/commands/sessions/target.rs index 32d4d5be..9f1b09dc 100644 --- a/src-tauri/src/commands/sessions/target.rs +++ b/src-tauri/src/commands/sessions/target.rs @@ -146,6 +146,6 @@ pub(crate) async fn client_for_context( cluster_context: &str, kubeconfig_env_var: Option, ) -> Result { - let source = KubeconfigSource::new(kubeconfig_env_var)?; + let source = KubeconfigSource::new(kubeconfig_env_var)?.fail_closed(); source.live_client_for_context(cluster_context).await } From f7f95d240d6a11619c4ef747bf97de0107750581 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:34:11 +0000 Subject: [PATCH 20/37] Format security fix code with rustfmt --- src-tauri/src/commands/incidents.rs | 5 ++++- src-tauri/src/commands/resources/ingress_status.rs | 4 +--- src-tauri/src/commands/resources/revisions.rs | 5 +---- src-tauri/src/commands/resources/topology_dynamic.rs | 3 +-- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/commands/incidents.rs b/src-tauri/src/commands/incidents.rs index 4d22d2ae..acef0ab9 100644 --- a/src-tauri/src/commands/incidents.rs +++ b/src-tauri/src/commands/incidents.rs @@ -179,7 +179,10 @@ async fn list_warning_events( } else { Api::all(client) }; - (namespace, api.list(&list_params().fields("type=Warning")).await) + ( + namespace, + api.list(&list_params().fields("type=Warning")).await, + ) } }); for (namespace, result) in join_all(fetches).await { diff --git a/src-tauri/src/commands/resources/ingress_status.rs b/src-tauri/src/commands/resources/ingress_status.rs index 3a453824..e3b79b6b 100644 --- a/src-tauri/src/commands/resources/ingress_status.rs +++ b/src-tauri/src/commands/resources/ingress_status.rs @@ -21,9 +21,7 @@ fn has_load_balancer_address(status: &IngressStatus) -> bool { .as_ref() .and_then(|load_balancer| load_balancer.ingress.as_ref()) .is_some_and(|ingress| { - ingress - .iter() - .any(|entry| { + ingress.iter().any(|entry| { [&entry.ip, &entry.hostname] .into_iter() .flatten() diff --git a/src-tauri/src/commands/resources/revisions.rs b/src-tauri/src/commands/resources/revisions.rs index 2751031c..d596a017 100644 --- a/src-tauri/src/commands/resources/revisions.rs +++ b/src-tauri/src/commands/resources/revisions.rs @@ -44,10 +44,7 @@ async fn deployment_revisions_from( params = params.labels(&selector); } let replica_sets: Api = Api::namespaced(client, namespace); - let replica_sets = replica_sets - .list(¶ms) - .await - .map_err(AppError::from)?; + let replica_sets = replica_sets.list(¶ms).await.map_err(AppError::from)?; Ok(deployment_revisions_from_replica_sets( replica_sets.items, diff --git a/src-tauri/src/commands/resources/topology_dynamic.rs b/src-tauri/src/commands/resources/topology_dynamic.rs index be33d6d4..12597b0b 100644 --- a/src-tauri/src/commands/resources/topology_dynamic.rs +++ b/src-tauri/src/commands/resources/topology_dynamic.rs @@ -1,6 +1,5 @@ use super::{ - api_resource_from_discovered, dynamic_resource_summary, - topology::TopologyInputResource, + api_resource_from_discovered, dynamic_resource_summary, topology::TopologyInputResource, }; use crate::commands::helpers::{extract_owner_ref_summary, list_params}; use crate::models::AppErrorKind; From 2482eb20bce2e2522d2aa7969dbc32e568472021 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:34:14 +0000 Subject: [PATCH 21/37] Update force-conflicts default test to the new off default --- tests/yaml-encoding.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/yaml-encoding.test.ts b/tests/yaml-encoding.test.ts index 57cec151..8962d01f 100644 --- a/tests/yaml-encoding.test.ts +++ b/tests/yaml-encoding.test.ts @@ -148,11 +148,11 @@ describe("YAML encoding", () => { ]); }); - test("settings allow YAML force-conflicts by default and can disable them", () => { - expect(useSettingsState.getState().allowYamlForceConflicts).toBe(true); + test("settings disallow YAML force-conflicts by default and can enable them", () => { + expect(useSettingsState.getState().allowYamlForceConflicts).toBe(false); - useSettingsState.getState().setAllowYamlForceConflicts(false); + useSettingsState.getState().setAllowYamlForceConflicts(true); - expect(useSettingsState.getState().allowYamlForceConflicts).toBe(false); + expect(useSettingsState.getState().allowYamlForceConflicts).toBe(true); }); }); From a3ad84216689e61f1bd599c288f3084d3211a0f3 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:23:12 +0000 Subject: [PATCH 22/37] Ignore dev-only braces advisory with no patched release --- scripts/audit-dependencies.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/audit-dependencies.ts b/scripts/audit-dependencies.ts index 62ed7c36..00698377 100644 --- a/scripts/audit-dependencies.ts +++ b/scripts/audit-dependencies.ts @@ -11,6 +11,8 @@ const ignoredAdvisories = new Map([ ["GHSA-jmr9-qjv8-65gv", "extract-zip"], // TODO(Timpan4): Remove once WebdriverIO accepts brace-expansion 5.0.8+. ["GHSA-mh99-v99m-4gvg", "brace-expansion"], + // TODO(Timpan4): Remove once braces ships a patch or WebdriverIO's mocha drops chokidar 3. + ["GHSA-vfj7-8cjw-p6xm", "braces"], ]); function advisoryId(advisory: AuditAdvisory): string { From d5728eee7fc2c1500796f09305ebcc454ccdf936 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:23:18 +0000 Subject: [PATCH 23/37] Redact Argo state sent as a JSON string literal --- src-tauri/src/commands/argo/connected.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/argo/connected.rs b/src-tauri/src/commands/argo/connected.rs index d5c014e7..7cb138b8 100644 --- a/src-tauri/src/commands/argo/connected.rs +++ b/src-tauri/src/commands/argo/connected.rs @@ -777,8 +777,10 @@ pub(crate) fn managed_resource(value: &Value) -> ArgoManagedResource { pub(crate) fn state(value: Option<&Value>, redact: bool) -> Option { value .and_then(|state| match state { - Value::String(text) => serde_json::from_str(text) + Value::String(text) => serde_json::from_str::(text) .ok() + // A JSON string literal is still unstructured text, so it takes the text path below. + .filter(|parsed| !parsed.is_string()) .or_else(|| { serde_yaml::from_str::(text) .ok() From d2dd5cea538f6b0d780f6b895d8fa96fe8477522 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:23:21 +0000 Subject: [PATCH 24/37] Pass the confirmed tunnel target in the real-cluster Argo E2E --- e2e/specs/real/inspection.e2e.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/specs/real/inspection.e2e.ts b/e2e/specs/real/inspection.e2e.ts index 4b512420..7bcfc2b6 100644 --- a/e2e/specs/real/inspection.e2e.ts +++ b/e2e/specs/real/inspection.e2e.ts @@ -7,6 +7,7 @@ import type { ArgoApplicationInspector, ArgoApplicationRef, ArgoApplicationSummary, + ArgoConfirmedTarget, ArgoConnectionStatus, ArgoOperationConfirmation, ArgoOperationPreflight, @@ -51,7 +52,7 @@ type CommandMap = { stop_stream: { args: { streamId: string }; result: boolean }; list_argocd_applications: { args: { clusterContext: string }; result: ArgoApplicationSummary[] }; discover_argo_servers: { args: { clusterContext: string; kubeconfigEnvVar?: string }; result: ArgoServerCapability[] }; - connect_argo_server: { args: { id: string; serverUrl: string; endpoint: ArgoServerEndpoint; username?: string; password?: string; insecureTls: boolean; rememberCredential: boolean; clusterContext: string; kubeconfigEnvVar?: string; workspaceId: string }; result: ArgoConnectionStatus }; + connect_argo_server: { args: { id: string; serverUrl: string; endpoint: ArgoServerEndpoint; username?: string; password?: string; insecureTls: boolean; rememberCredential: boolean; clusterContext: string; kubeconfigEnvVar?: string; workspaceId: string; confirmedTarget?: ArgoConfirmedTarget }; result: ArgoConnectionStatus }; get_argo_connection_status: { args: { id: string }; result: ArgoConnectionStatus }; disconnect_argo_server: { args: { id: string }; result: undefined }; get_argo_application_inspector: { args: { clusterContext: string; kubeconfigEnvVar?: string; connectionId: string; transport: "connected"; application: ArgoApplicationRef; redactSecrets: boolean }; result: ArgoApplicationInspector }; @@ -271,6 +272,7 @@ describe("native Kind command boundary", () => { expect(server.url).toBeNull(); expect(server.unavailableReason).toBeNull(); expect(server.endpoint.servicePort).toBeGreaterThan(0); + if (!server.targetPod) throw new Error("argocd-server Service tunnel has no discovered target Pod"); const password = Buffer.from(await runKubectl(["get", "secret", "argocd-initial-admin-secret", "-n", "argocd", "-o", "jsonpath={.data.password}"]), "base64").toString("utf8"); if (!password) throw new Error("Argo CD initial admin password is empty"); @@ -288,6 +290,7 @@ describe("native Kind command boundary", () => { clusterContext: cluster, kubeconfigEnvVar: e2eKubeconfigSource, workspaceId, + confirmedTarget: { namespace: server.endpoint.namespace, serviceName: server.endpoint.serviceName, podName: server.targetPod }, }); argoConnections.push(connectionId); expect(connected).toMatchObject({ connected: true, profile: { rememberCredential: false, endpoint: { kind: "serviceTunnel", serviceName: "argocd-server", servicePort: server.endpoint.servicePort, scheme: "http" } } }); From 880dcd742ff0602dfde5db3aab54eb4e3f720672 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:23:23 +0000 Subject: [PATCH 25/37] Refetch Argo discovery after a rejected tunnel target --- src/app/svelte/ArgoConnectionSettings.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/svelte/ArgoConnectionSettings.svelte b/src/app/svelte/ArgoConnectionSettings.svelte index bd62da0b..b5a528e6 100644 --- a/src/app/svelte/ArgoConnectionSettings.svelte +++ b/src/app/svelte/ArgoConnectionSettings.svelte @@ -251,6 +251,8 @@ void queryClient.invalidateQueries({ queryKey: ["argo-connection-status"] }); } catch (caught) { error = caught instanceof Error ? caught.message : String(caught); + // A rejected tunnel target is usually a replaced Pod; refetch so the next confirmation shows the current one. + if (confirmedTarget) void queryClient.invalidateQueries({ queryKey: discoveryQueryKey }); } finally { // Credentials never persist in component state after submit. token = ""; From 64551d798978a6f7af470af569a8eeaca327bb32 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:27:52 +0000 Subject: [PATCH 26/37] Pin Argo tunnel connections to the confirmed Pod --- docs/decisions/0017-private-argocd-service-tunnels.md | 2 +- src-tauri/src/commands/argo/tunnel.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/decisions/0017-private-argocd-service-tunnels.md b/docs/decisions/0017-private-argocd-service-tunnels.md index 2da042b0..6191236c 100644 --- a/docs/decisions/0017-private-argocd-service-tunnels.md +++ b/docs/decisions/0017-private-argocd-service-tunnels.md @@ -23,4 +23,4 @@ Trust rule: - Discovery reports the Pod it resolved for each Service (`targetPod`) and whether the Service carries `app.kubernetes.io/part-of=argocd` (`argoLabeled`). The label is a hint only. Unlabelled Services stay selectable but are flagged in the selector and in a stronger warning. - Before credentials are sent through a Service tunnel, including reconnects of saved profiles that use a remembered credential, the UI shows the exact namespace, Service name, and Pod with a warning and requires explicit confirmation. The user confirmation is the control. - `connect_argo_server` takes the confirmed target (namespace, Service name, Pod). For a Service tunnel the backend starts the tunnel, resolves the target, and refuses to send any credential unless the confirmed target matches the resolved namespace, Service, and Pod. A missing confirmation or a target that changed between display and send is rejected, and the user must refresh discovery and confirm again. -- The Pod is confirmed at connect time only. The tunnel continues to resolve Service endpoints per connection as before. +- The tunnel stays pinned to the confirmed Pod. Each connection still resolves the Service, and a connection that resolves to a different Pod is refused, so a rollout or selector change requires reconnecting and confirming the new target. Pod and Service port-forward sessions (ADR 0003) keep per-connection re-resolution. diff --git a/src-tauri/src/commands/argo/tunnel.rs b/src-tauri/src/commands/argo/tunnel.rs index 74eac761..e348e40f 100644 --- a/src-tauri/src/commands/argo/tunnel.rs +++ b/src-tauri/src/commands/argo/tunnel.rs @@ -92,6 +92,7 @@ impl ArgoServiceTunnel { namespace, service_name, service_port, + pod_name: pod_name.clone(), }), )); Ok(Self { @@ -166,6 +167,8 @@ struct ServiceRoute { namespace: String, service_name: String, service_port: u16, + /// The Pod the user confirmed; credentials never reach any other Pod. + pod_name: String, } async fn verify_port_forward( @@ -223,6 +226,12 @@ async fn run_tunnel( route.service_port, ) .await?; + if target.pod_name != route.pod_name { + return Err(AppError::new( + "Argo CD Service now resolves to a different Pod than the confirmed one; reconnect and confirm the new target", + AppErrorKind::ArgoTunnel, + )); + } forward_pod_connection( client, target.namespace, From 885f2c1bf155c0b320e7096fbf077d6974c0abf0 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:27:58 +0000 Subject: [PATCH 27/37] Withhold unstructured Argo state for Secret resources --- src-tauri/src/commands/argo/connected.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/argo/connected.rs b/src-tauri/src/commands/argo/connected.rs index 7cb138b8..ca14063a 100644 --- a/src-tauri/src/commands/argo/connected.rs +++ b/src-tauri/src/commands/argo/connected.rs @@ -902,12 +902,18 @@ fn kubernetes_comparison(resource: ArgoManagedResource) -> ArgoResourceCompariso } fn connected_comparison(value: &Value) -> ArgoResourceComparison { + let is_secret = value.get("kind").and_then(Value::as_str) == Some("Secret"); + // Unstructured Secret state cannot be redacted field by field, so it is withheld whole. + let redacted_state = |key: &str| match state(value.get(key), true) { + Some(Value::String(_)) if is_secret => Some(Value::String("[REDACTED]".into())), + other => other, + }; ArgoResourceComparison { resource: managed_resource(value), - target_state: state(value.get("targetState"), true), - live_state: state(value.get("liveState"), true), - normalized_live_state: state(value.get("normalizedLiveState"), true), - predicted_live_state: state(value.get("predictedLiveState"), true), + target_state: redacted_state("targetState"), + live_state: redacted_state("liveState"), + normalized_live_state: redacted_state("normalizedLiveState"), + predicted_live_state: redacted_state("predictedLiveState"), modified: value.get("modified").and_then(Value::as_bool), exact: Some(true), provenance: Some("argocd-managed-resource".into()), From 7146ad31e384195ced19a9c0b63ff8332bc1e910 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:28:01 +0000 Subject: [PATCH 28/37] Connect only to the endpoint shown in the tunnel confirmation --- src/app/svelte/ArgoConnectionSettings.svelte | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/svelte/ArgoConnectionSettings.svelte b/src/app/svelte/ArgoConnectionSettings.svelte index b5a528e6..6ed6b4e4 100644 --- a/src/app/svelte/ArgoConnectionSettings.svelte +++ b/src/app/svelte/ArgoConnectionSettings.svelte @@ -66,6 +66,7 @@ let connected = $state(null); let pendingConfirmation = $state<{ saved?: (typeof settings.argoProfiles)[number]; + endpoint: ArgoServerEndpoint; target: ArgoConfirmedTarget; argoLabeled: boolean; } | null>(null); @@ -195,6 +196,7 @@ error = null; pendingConfirmation = { saved, + endpoint, target: { namespace: endpoint.namespace, serviceName: endpoint.serviceName, @@ -207,8 +209,10 @@ async function connect( saved?: (typeof settings.argoProfiles)[number], confirmedTarget?: ArgoConfirmedTarget, + confirmedEndpoint?: ArgoServerEndpoint, ) { - const endpoint = saved?.endpoint ?? draftEndpoint; + // A confirmed tunnel connects only to the endpoint shown in the confirmation, even if the form changed since. + const endpoint = confirmedEndpoint ?? saved?.endpoint ?? draftEndpoint; if (!endpoint) return; pendingConfirmation = null; busy = true; @@ -365,7 +369,7 @@

This Service does not carry the standard Argo CD label (app.kubernetes.io/part-of=argocd). Only continue if you recognise it as your Argo CD server.

{/if}
- +
From ba7566ed5c0283779c8142149b5205c98d96e874 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:28:03 +0000 Subject: [PATCH 29/37] Key resource Events cache by resource UID --- src/features/resource-detail/ResourceDetailPanel.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/resource-detail/ResourceDetailPanel.svelte b/src/features/resource-detail/ResourceDetailPanel.svelte index 63dabbd0..9418bd5e 100644 --- a/src/features/resource-detail/ResourceDetailPanel.svelte +++ b/src/features/resource-detail/ResourceDetailPanel.svelte @@ -234,7 +234,7 @@ const currentDetailsCancelScope = detailsCancelScope; const currentDetailsQueryKey = detailsQueryKey; const currentEventsCancelScope = eventsCancelScope; - const currentEventsQueryKey = eventsQueryKey; + const currentEventsQueryKey = scopedEventsQueryKey; finiteReadCleanup.cancelPending(currentDetailsCancelScope); finiteReadCleanup.cancelPending(currentEventsCancelScope); return () => { @@ -282,12 +282,14 @@ const eventsUid = $derived( typeof detailsQuery.data?.metadata?.uid === "string" ? detailsQuery.data.metadata.uid : undefined, ); + // Keyed by UID so results fetched without one (or for a replaced resource) are never reused. + const scopedEventsQueryKey = $derived([...eventsQueryKey, eventsUid ?? ""] as const); // Events are matched by UID, so wait for the details read that supplies it. const eventsReady = $derived( !detailsEnabled || detailsQuery.isSuccess || detailsQuery.isError, ); const eventsQuery = createQuery(() => ({ - queryKey: eventsQueryKey, + queryKey: scopedEventsQueryKey, queryFn: async () => { try { return await runDetailFetch("events", "resource-events", () => From b7607558dc13e349d3f09702b22dddd56638a3c1 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:28:06 +0000 Subject: [PATCH 30/37] Hold the YAML apply lock until the request settles --- src/features/resource-detail/ResourceYamlPane.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/resource-detail/ResourceYamlPane.svelte b/src/features/resource-detail/ResourceYamlPane.svelte index 1cf3a061..73988308 100644 --- a/src/features/resource-detail/ResourceYamlPane.svelte +++ b/src/features/resource-detail/ResourceYamlPane.svelte @@ -390,7 +390,6 @@ function clearYamlDraftFeedback() { yamlApplyRevision += 1; yamlPreparing = false; - yamlApplying = false; yamlLintDiagnostics = []; yamlLintNotes = []; yamlLintError = ""; @@ -440,7 +439,8 @@ yamlApplyRawError = error; yamlApplyError = getErrorMessage(error); } finally { - if (applyRevision === yamlApplyRevision) yamlApplying = false; + // Always release the lock so a draft edited mid-apply cannot leave the pane stuck applying. + yamlApplying = false; } } From 21a20ba93cc5c66b797c9f997b26af1a82e5b2ec Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:28:08 +0000 Subject: [PATCH 31/37] Scan staged paths verbatim in the credential hook --- .githooks/pre-commit-user | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.githooks/pre-commit-user b/.githooks/pre-commit-user index 21cb2f99..41a6d6e8 100755 --- a/.githooks/pre-commit-user +++ b/.githooks/pre-commit-user @@ -8,7 +8,13 @@ if ! git diff --cached --check; then exit 1 fi -staged_files=$(git diff --cached --name-only --diff-filter=ACMR) +# -z disables path quoting so `git show` gets the exact index path. +staged_files=$(git diff --cached --name-only -z --diff-filter=ACMR | tr '\0' '\n') +staged_count=$(git diff --cached --name-only -z --diff-filter=ACMR | tr -cd '\0' | wc -c) +if [ -n "$staged_files" ] && [ "$(printf '%s\n' "$staged_files" | wc -l)" -ne "$staged_count" ]; then + echo "pre-commit: a staged path contains a newline; rename it so the credential scan can read it." + exit 1 +fi if [ -n "$staged_files" ]; then # Read newline-separated paths so filenames with spaces are scanned. From d0e80758f04d2148d5f9a84713705367e032412b Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:24:01 +0000 Subject: [PATCH 32/37] Satisfy anti-slop lint in review fixes --- src/app/svelte/ArgoConnectionSettings.svelte | 2 +- .../resource-detail/ResourceDetailPanel.svelte | 7 ++++--- src/features/resource-detail/operations-model.ts | 16 ++++++++-------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/app/svelte/ArgoConnectionSettings.svelte b/src/app/svelte/ArgoConnectionSettings.svelte index 6ed6b4e4..956ed00a 100644 --- a/src/app/svelte/ArgoConnectionSettings.svelte +++ b/src/app/svelte/ArgoConnectionSettings.svelte @@ -152,7 +152,7 @@ }); $effect(() => { - selectedCapabilityId; + void selectedCapabilityId; pendingConfirmation = null; }); diff --git a/src/features/resource-detail/ResourceDetailPanel.svelte b/src/features/resource-detail/ResourceDetailPanel.svelte index 9418bd5e..9c8b62d3 100644 --- a/src/features/resource-detail/ResourceDetailPanel.svelte +++ b/src/features/resource-detail/ResourceDetailPanel.svelte @@ -279,9 +279,10 @@ retry: false, staleTime: 30_000, })); - const eventsUid = $derived( - typeof detailsQuery.data?.metadata?.uid === "string" ? detailsQuery.data.metadata.uid : undefined, - ); + const eventsUid = $derived.by(() => { + const uid = detailsQuery.data?.metadata?.uid; + return String(uid) === uid ? uid : undefined; + }); // Keyed by UID so results fetched without one (or for a replaced resource) are never reused. const scopedEventsQueryKey = $derived([...eventsQueryKey, eventsUid ?? ""] as const); // Events are matched by UID, so wait for the details read that supplies it. diff --git a/src/features/resource-detail/operations-model.ts b/src/features/resource-detail/operations-model.ts index 7fbeec41..7d90fcce 100644 --- a/src/features/resource-detail/operations-model.ts +++ b/src/features/resource-detail/operations-model.ts @@ -32,19 +32,19 @@ export function guardedOperationBlocker( return "operation support"; } -const BUILTIN_API_VERSION: Record = { - Deployment: "apps/v1", - StatefulSet: "apps/v1", - DaemonSet: "apps/v1", - Pod: "v1", - ConfigMap: "v1", -}; +const BUILTIN_API_VERSION = new Map([ + ["Deployment", "apps/v1"], + ["StatefulSet", "apps/v1"], + ["DaemonSet", "apps/v1"], + ["Pod", "v1"], + ["ConfigMap", "v1"], +]); /** True only for the built-in resource the backend operates on, not a CRD sharing its kind. */ export function isBuiltinOperationTarget(resource: ResourceSummary): boolean { return ( !resource.dynamic && - (resource.apiVersion === undefined || resource.apiVersion === BUILTIN_API_VERSION[resource.kind]) + (resource.apiVersion === undefined || resource.apiVersion === BUILTIN_API_VERSION.get(resource.kind)) ); } From 6e3393eb6ce2cf3a3a0374dd2567b627a06bae34 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:24:08 +0000 Subject: [PATCH 33/37] Replace empty assertions flagged by Rust 1.99 clippy --- src-tauri/src/commands/argo/connected.rs | 2 +- src-tauri/src/commands/diagnostics.rs | 4 ++-- src-tauri/src/commands/helm/storage_tests.rs | 2 +- src-tauri/src/commands/pod_exec/runner.rs | 8 ++++---- src-tauri/src/commands/resources/revisions.rs | 2 +- src-tauri/src/commands/resources/scope.rs | 2 +- src-tauri/src/commands/resources/topology_tests.rs | 14 +++++++------- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/commands/argo/connected.rs b/src-tauri/src/commands/argo/connected.rs index ca14063a..beb7af9a 100644 --- a/src-tauri/src/commands/argo/connected.rs +++ b/src-tauri/src/commands/argo/connected.rs @@ -1789,7 +1789,7 @@ mod tests { ); assert!(comparison.target_state.is_none()); assert!(comparison.live_state.is_none()); - assert!(comparison.available_actions.is_empty()); + assert_eq!(comparison.available_actions.len(), 0); } #[test] diff --git a/src-tauri/src/commands/diagnostics.rs b/src-tauri/src/commands/diagnostics.rs index d72d375b..9486e566 100644 --- a/src-tauri/src/commands/diagnostics.rs +++ b/src-tauri/src/commands/diagnostics.rs @@ -173,7 +173,7 @@ mod tests { 12, vec![diagnostic_field("rows", 5)], ); - assert!(stored_backend_diagnostics().is_empty()); + assert_eq!(stored_backend_diagnostics().len(), 0); set_backend_diagnostics_enabled(true); for index in 0..505 { @@ -194,7 +194,7 @@ mod tests { ); clear_stored_diagnostics(); - assert!(stored_backend_diagnostics().is_empty()); + assert_eq!(stored_backend_diagnostics().len(), 0); record_backend_result( "tracked_success", diff --git a/src-tauri/src/commands/helm/storage_tests.rs b/src-tauri/src/commands/helm/storage_tests.rs index 30fd931e..676ec697 100644 --- a/src-tauri/src/commands/helm/storage_tests.rs +++ b/src-tauri/src/commands/helm/storage_tests.rs @@ -31,7 +31,7 @@ fn values_summary_treats_explicit_null_as_empty() { assert!(!summary.has_values); assert_eq!(summary.value_count, 0); - assert!(summary.top_level_keys.is_empty()); + assert_eq!(summary.top_level_keys.len(), 0); } #[test] diff --git a/src-tauri/src/commands/pod_exec/runner.rs b/src-tauri/src/commands/pod_exec/runner.rs index 9c69b9c1..54597a22 100644 --- a/src-tauri/src/commands/pod_exec/runner.rs +++ b/src-tauri/src/commands/pod_exec/runner.rs @@ -376,11 +376,11 @@ mod tests { let first = take_utf8_prefix(&mut pending).expect("first chunk"); assert_eq!(first, "h"); - assert!(!pending.is_empty()); + assert_ne!(pending.len(), 0); pending.append(&mut remainder); assert_eq!(take_utf8_prefix(&mut pending).expect("rest"), "éllo"); - assert!(pending.is_empty()); + assert_eq!(pending.len(), 0); } #[test] @@ -389,7 +389,7 @@ mod tests { assert_eq!(take_utf8_prefix(&mut pending).expect("chunk"), "a\u{FFFD}"); assert_eq!(take_utf8_prefix(&mut pending).expect("rest"), "b"); - assert!(pending.is_empty()); + assert_eq!(pending.len(), 0); } #[test] @@ -401,6 +401,6 @@ mod tests { pending.push(0xA9); assert_eq!(take_utf8_prefix(&mut pending).expect("complete"), "é"); - assert!(pending.is_empty()); + assert_eq!(pending.len(), 0); } } diff --git a/src-tauri/src/commands/resources/revisions.rs b/src-tauri/src/commands/resources/revisions.rs index d596a017..2cba700d 100644 --- a/src-tauri/src/commands/resources/revisions.rs +++ b/src-tauri/src/commands/resources/revisions.rs @@ -173,6 +173,6 @@ mod tests { assert_eq!(revisions[2].revision, None); let serialized = serde_json::to_value(&revisions[2]).unwrap(); assert!(serialized.get("revision").is_none()); - assert!(!revisions[0].pod_template_yaml.is_empty()); + assert_ne!(revisions[0].pod_template_yaml.len(), 0); } } diff --git a/src-tauri/src/commands/resources/scope.rs b/src-tauri/src/commands/resources/scope.rs index 1fb9bc81..a7db03d7 100644 --- a/src-tauri/src/commands/resources/scope.rs +++ b/src-tauri/src/commands/resources/scope.rs @@ -382,7 +382,7 @@ mod tests { let groups = group_requests(vec![pod_request(None), pod_request(Some("default"))]).expect("groups"); - assert!(ownership_namespaces(&groups).is_empty()); + assert_eq!(ownership_namespaces(&groups).len(), 0); } #[test] diff --git a/src-tauri/src/commands/resources/topology_tests.rs b/src-tauri/src/commands/resources/topology_tests.rs index f49119d0..55d21fe7 100644 --- a/src-tauri/src/commands/resources/topology_tests.rs +++ b/src-tauri/src/commands/resources/topology_tests.rs @@ -368,7 +368,7 @@ fn builds_deployment_to_replicaset_to_pod_edges_from_owner_uids() { assert!(topology.edges.iter().any(|edge| { edge.source == rs_id && edge.target == pod_id && edge.relation == TopologyRelation::Owns })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -412,7 +412,7 @@ fn builds_custom_resource_to_workload_edges_from_owner_uids() { && edge.target == statefulset_id && edge.relation == TopologyRelation::Owns })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -452,7 +452,7 @@ fn builds_daemonset_to_pod_edges_from_owner_uids() { && edge.target == pod_id && edge.relation == TopologyRelation::Owns })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -503,7 +503,7 @@ fn builds_cronjob_to_job_to_pod_edges_from_owner_uids() { assert!(topology.edges.iter().any(|edge| { edge.source == job_id && edge.target == pod_id && edge.relation == TopologyRelation::Owns })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -638,7 +638,7 @@ fn builds_network_flow_from_ingress_to_service_slice_and_pod() { && edge.target == pod_id && edge.relation == TopologyRelation::Targets })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -699,7 +699,7 @@ fn network_flow_skips_missing_endpoint_warning_for_external_name_service() { assert_eq!(topology.nodes.len(), 1); assert_eq!(topology.nodes[0].kind, "Service"); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -739,5 +739,5 @@ fn network_flow_skips_missing_pod_warning_for_selectorless_endpoint_slices() { && edge.target == slice_id && edge.relation == TopologyRelation::Targets })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } From 8108c29291a5547e848ac47f22c36ce895c46712 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:24:10 +0000 Subject: [PATCH 34/37] Release the YAML apply lock only from the apply that took it --- src/features/resource-detail/ResourceYamlPane.svelte | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/features/resource-detail/ResourceYamlPane.svelte b/src/features/resource-detail/ResourceYamlPane.svelte index 73988308..7fae6e06 100644 --- a/src/features/resource-detail/ResourceYamlPane.svelte +++ b/src/features/resource-detail/ResourceYamlPane.svelte @@ -93,6 +93,8 @@ let yamlLintError = $state(""); let yamlPreparing = $state(false); let yamlApplying = $state(false); + // Only the apply that took the lock may release it, even after Cancel lets a newer apply start. + let yamlApplyLockOwner: object | null = null; let yamlFormatError = $state(""); let yamlPrepareRawError = $state(null); let yamlPrepareError = $state(""); @@ -419,6 +421,8 @@ return; } const applyRevision = yamlApplyRevision; + const lockOwner = {}; + yamlApplyLockOwner = lockOwner; yamlApplying = true; yamlApplyRawError = null; yamlApplyError = ""; @@ -439,8 +443,11 @@ yamlApplyRawError = error; yamlApplyError = getErrorMessage(error); } finally { - // Always release the lock so a draft edited mid-apply cannot leave the pane stuck applying. - yamlApplying = false; + // Released even when a mid-apply draft edit discarded the result, so the pane cannot stay stuck applying. + if (yamlApplyLockOwner === lockOwner) { + yamlApplyLockOwner = null; + yamlApplying = false; + } } } From 5c98b14770865a7616dda42d2911082bcf4da5d0 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:24:13 +0000 Subject: [PATCH 35/37] Reject staged paths with any newline before scanning --- .githooks/pre-commit-user | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.githooks/pre-commit-user b/.githooks/pre-commit-user index 41a6d6e8..d8ce58e6 100755 --- a/.githooks/pre-commit-user +++ b/.githooks/pre-commit-user @@ -9,12 +9,12 @@ if ! git diff --cached --check; then fi # -z disables path quoting so `git show` gets the exact index path. -staged_files=$(git diff --cached --name-only -z --diff-filter=ACMR | tr '\0' '\n') -staged_count=$(git diff --cached --name-only -z --diff-filter=ACMR | tr -cd '\0' | wc -c) -if [ -n "$staged_files" ] && [ "$(printf '%s\n' "$staged_files" | wc -l)" -ne "$staged_count" ]; then +newline_count=$(git diff --cached --name-only -z --diff-filter=ACMR | tr -cd '\n' | wc -c) +if [ "$newline_count" -ne 0 ]; then echo "pre-commit: a staged path contains a newline; rename it so the credential scan can read it." exit 1 fi +staged_files=$(git diff --cached --name-only -z --diff-filter=ACMR | tr '\0' '\n') if [ -n "$staged_files" ]; then # Read newline-separated paths so filenames with spaces are scanned. From 92a7ec194475c1c00a1f08f2255c939db3e3ad1e Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:34:15 +0000 Subject: [PATCH 36/37] Stop RBAC paging when any continue token recurs --- src-tauri/src/commands/rbac_inventory.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/rbac_inventory.rs b/src-tauri/src/commands/rbac_inventory.rs index da367503..ba7a391a 100644 --- a/src-tauri/src/commands/rbac_inventory.rs +++ b/src-tauri/src/commands/rbac_inventory.rs @@ -42,6 +42,8 @@ where { let mut items = Vec::new(); let mut token = None; + // Any recurring token means the server is cycling, not just repeating the last one. + let mut seen_tokens = std::collections::HashSet::new(); loop { let params = token.as_deref().map_or_else( || list_params().limit(500), @@ -51,7 +53,10 @@ where Ok(page) => { let next = page.metadata.continue_; items.extend(page.items); - if next.is_some() && next == token { + if next + .as_deref() + .is_some_and(|value| !value.is_empty() && !seen_tokens.insert(value.to_owned())) + { return InventoryLoad::partial( items, AppError::new( From c59beb80bdac34c77a6ac01433e113036febb9d2 Mon Sep 17 00:00:00 2001 From: Tim Aronsson <70634109+Timpan4@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:34:21 +0000 Subject: [PATCH 37/37] Discard Argo tunnel confirmation when the cluster scope changes --- src/app/svelte/ArgoConnectionSettings.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/svelte/ArgoConnectionSettings.svelte b/src/app/svelte/ArgoConnectionSettings.svelte index 956ed00a..7d6d43ce 100644 --- a/src/app/svelte/ArgoConnectionSettings.svelte +++ b/src/app/svelte/ArgoConnectionSettings.svelte @@ -151,8 +151,12 @@ return () => finiteReadCleanup.schedule(cancelScope, queryKey); }); + // A confirmation belongs to one cluster scope; any scope or selection change discards it. $effect(() => { void selectedCapabilityId; + void clusterContext; + void kubeconfigEnvVar; + void workspaceId; pendingConfirmation = null; });