From a5b56e9c30748aa07c6c3270fedef80af9056385 Mon Sep 17 00:00:00 2001 From: Yuval Kohavi Date: Tue, 10 Nov 2026 15:56:37 -0500 Subject: [PATCH 1/4] feat(controller): serve the gateway JWKS off the management listener LLM gateways need the controller's JWKS to verify the JWTs it issues. Until now the management router also served /.well-known/jwks.json, so a gateway configured against it had to reach the management API, which can read the device inventory and delete devices. - The management router no longer serves the JWKS. The fleet listener still does, so existing gateway configurations keep working. - New optional `gatewayJwt.jwksListener` (`listen`, `tls`) serves only the JWKS, over plain HTTP or over HTTPS with the controller certificate. Point gateways at it so a NetworkPolicy or firewall can allow them that one port. Gateways that export usage still need the fleet port. - Helm: a `-jwks` ClusterIP Service, container port and `jwksListener` config, controlled by `gatewayJwks` values. - examples/claude fetches the JWKS from the new listener on 8081. Hardening plan item A4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 9 +- crates/controller/src/application.rs | 29 ++- crates/controller/src/gateway_jwt.rs | 166 +++++++++++++++++- crates/controller/src/management.rs | 18 +- .../controller/src/management/auth/tests.rs | 27 ++- crates/core/src/config.rs | 69 ++++++++ .../templates/NOTES.txt | 5 + .../templates/_helpers.tpl | 12 ++ .../templates/configmap.yaml | 7 +- .../templates/deployment.yaml | 5 + .../templates/service-jwks.yaml | 17 ++ .../helm/agentdesktop-controller/values.yaml | 17 ++ docs/device-identity.md | 4 +- examples/claude/agentgateway.yaml | 2 +- examples/claude/controller.yaml | 2 + schema/controller-config.json | 30 ++++ schema/controller-config.md | 3 + 17 files changed, 383 insertions(+), 39 deletions(-) create mode 100644 deploy/helm/agentdesktop-controller/templates/service-jwks.yaml diff --git a/README.md b/README.md index b5062843..0510bcd8 100644 --- a/README.md +++ b/README.md @@ -697,7 +697,7 @@ routes: issuer: agentdesktop-controller audiences: [agentgateway] jwks: - url: https:///.well-known/jwks.json + url: https:///.well-known/jwks.json # fleet listener or gatewayJwt.jwksListener urlRewrite: authority: full: api.business.githubcopilot.com @@ -815,6 +815,13 @@ cross-site browser mutations and non-loopback `Host` headers. Reach it remotely through an SSH tunnel. Remote management (`adminAccess.mode: authenticated`) requires a controller build with a compiled-in authentication provider; the standard executable rejects it at startup. Device enrollment is configured separately. +LLM gateways need the controller's JWKS (`/.well-known/jwks.json`) to verify +the JWTs it issues. The management listener does not serve it. Gateways fetch +it from the fleet listener, or from an optional `gatewayJwt.jwksListener` that +serves nothing else. With the dedicated listener, a NetworkPolicy or firewall +can allow the gateway that one port and block the management API, so a +compromised gateway cannot read the device inventory or delete devices. A +gateway that exports usage to the controller also needs the fleet port. See the [controller configuration schema](schema/controller-config.md). ### Configuration status diff --git a/crates/controller/src/application.rs b/crates/controller/src/application.rs index 6620999a..b7545c81 100644 --- a/crates/controller/src/application.rs +++ b/crates/controller/src/application.rs @@ -65,7 +65,18 @@ pub async fn run( tracing::info!("LLM gateway JWT issuance enabled"); } let gateway_jwks = gateway_jwt_issuer.as_ref().map(GatewayJwtIssuer::jwks); - let admin_gateway_jwks = gateway_jwks.clone(); + let jwks_listener = config + .gateway_jwt + .as_ref() + .and_then(|gateway| gateway.jwks_listener.as_ref()) + .zip(gateway_jwks.clone()) + .map(|(listener, jwks)| { + let tls = listener.tls.then(|| gateway_jwt::JwksTls { + certificate: tls.certificate.clone(), + key: tls.key.clone(), + }); + gateway_jwt::serve(listener.listen, jwks, tls) + }); let usage_token = config .usage .as_ref() @@ -145,13 +156,13 @@ pub async fn run( .await .context("serve fleet gRPC API") }; - let admin = management::serve( - admin_listen, - admin_state, - admin_gateway_jwks, - config.admin_access, - extensions, - ); - tokio::try_join!(fleet, admin)?; + let admin = management::serve(admin_listen, admin_state, config.admin_access, extensions); + let jwks = async move { + match jwks_listener { + Some(serve) => serve.await, + None => std::future::pending().await, + } + }; + tokio::try_join!(fleet, admin, jwks)?; Ok(()) } diff --git a/crates/controller/src/gateway_jwt.rs b/crates/controller/src/gateway_jwt.rs index 86ea4f96..1ad5177c 100644 --- a/crates/controller/src/gateway_jwt.rs +++ b/crates/controller/src/gateway_jwt.rs @@ -1,7 +1,8 @@ use std::{ collections::BTreeMap, fs, - path::Path, + net::SocketAddr, + path::{Path, PathBuf}, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -10,11 +11,13 @@ use anyhow::Context; use aws_lc_rs::{rsa::KeyPair as RsaKeyPair, signature::KeyPair as _}; use axum::{ Json, Router, - extract::State, + extract::{Request, State}, http::{StatusCode, header}, + middleware::{self, Next}, response::{IntoResponse, Response}, routing::get, }; +use axum_server::tls_rustls::RustlsConfig; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use serde::Serialize; @@ -34,13 +37,71 @@ pub struct GatewayJwks { keys: Vec, } -/// HTTP routes published on the controller's device-facing TLS listener. +/// The JWKS route, mounted on the fleet listener and the dedicated JWKS listener. pub fn routes(jwks: Option) -> Router { Router::>::new() .route("/.well-known/jwks.json", get(serve_jwks)) .with_state(jwks) } +const JWKS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +/// PEM certificate and key for an HTTPS JWKS listener. +pub struct JwksTls { + pub certificate: PathBuf, + pub key: PathBuf, +} + +/// Serves only the gateway JWKS, over HTTPS when `tls` is set. +pub async fn serve( + address: SocketAddr, + jwks: GatewayJwks, + tls: Option, +) -> anyhow::Result<()> { + let listener = tokio::net::TcpListener::bind(address) + .await + .with_context(|| format!("bind gateway JWKS listener on {address}"))?; + serve_listener(listener, jwks, tls).await +} + +pub async fn serve_listener( + listener: tokio::net::TcpListener, + jwks: GatewayJwks, + tls: Option, +) -> anyhow::Result<()> { + let address = listener.local_addr()?; + let app = routes(Some(jwks)).layer(middleware::from_fn(safeguard_request)); + match tls { + Some(tls) => { + let config = RustlsConfig::from_pem_file(&tls.certificate, &tls.key) + .await + .context("load gateway JWKS TLS identity")?; + tracing::info!(listen = %address, tls = true, "gateway JWKS listening"); + axum_server::from_tcp_rustls(listener.into_std()?, config)? + .serve(app.into_make_service()) + .await + .context("serve gateway JWKS over HTTPS") + } + None => { + tracing::info!(listen = %address, tls = false, "gateway JWKS listening"); + axum::serve(listener, app) + .await + .context("serve gateway JWKS") + } + } +} + +async fn safeguard_request(request: Request, next: Next) -> Response { + let mut response = tokio::time::timeout(JWKS_REQUEST_TIMEOUT, next.run(request)) + .await + .unwrap_or_else(|_| StatusCode::GATEWAY_TIMEOUT.into_response()); + response.headers_mut().insert( + header::X_CONTENT_TYPE_OPTIONS, + header::HeaderValue::from_static("nosniff"), + ); + response +} + async fn serve_jwks(State(jwks): State>) -> Result { jwks.map(|jwks| { ( @@ -195,8 +256,7 @@ mod tests { use super::{Actor, Claims, GatewayJwtIssuer}; - #[test] - fn published_jwks_verifies_issued_credentials() { + fn test_issuer() -> GatewayJwtIssuer { let key_pair = KeyPair::generate(KeySize::Rsa2048).expect("generate RSA key"); let der: Pkcs8V1Der<'static> = key_pair.as_der().expect("encode private key"); let pem = pem_rfc7468::encode_string("PRIVATE KEY", LineEnding::LF, der.as_ref()) @@ -207,7 +267,6 @@ mod tests { rand::random::() )); fs::write(&path, pem).expect("write test private key"); - let issuer = GatewayJwtIssuer::from_rsa_pem( &path, "agentdesktop-controller".to_owned(), @@ -215,6 +274,99 @@ mod tests { std::time::Duration::from_secs(300), ) .expect("create issuer"); + let _ = fs::remove_file(path); + issuer + } + + async fn get_with_retry(client: &reqwest::Client, url: &str) -> reqwest::Response { + for _ in 0..50 { + match client.get(url).send().await { + Ok(response) => return response, + Err(error) if error.is_connect() => { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + Err(error) => panic!("JWKS probe failed: {error}"), + } + } + panic!("JWKS listener did not start"); + } + + async fn assert_serves_jwks(client: &reqwest::Client, origin: &str) { + let response = get_with_retry(client, &format!("{origin}/.well-known/jwks.json")).await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response.headers()["content-type"], + "application/jwk-set+json" + ); + assert_eq!(response.headers()["cache-control"], "public, max-age=300"); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + let jwks: JwkSet = response.json().await.expect("decode JWKS"); + assert!(jwks.find("agentdesktop").is_some()); + + let other = client + .get(format!("{origin}/api/v1/devices")) + .send() + .await + .expect("probe other path"); + assert_eq!(other.status(), reqwest::StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn dedicated_listener_serves_only_jwks_over_http() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(super::serve_listener(listener, test_issuer().jwks(), None)); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + assert_serves_jwks(&client, &format!("http://{address}")).await; + server.abort(); + } + + #[tokio::test] + async fn dedicated_listener_serves_jwks_over_https_when_configured() { + let directory = std::env::temp_dir().join(format!( + "agentdesktop-jwks-tls-{}-{}", + std::process::id(), + rand::random::() + )); + fs::create_dir(&directory).unwrap(); + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec!["127.0.0.1".into()]).unwrap(); + let certificate = directory.join("controller.pem"); + let key = directory.join("controller-key.pem"); + fs::write(&certificate, cert.pem()).unwrap(); + fs::write(&key, signing_key.serialize_pem()).unwrap(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(super::serve_listener( + listener, + test_issuer().jwks(), + Some(super::JwksTls { certificate, key }), + )); + let client = reqwest::Client::builder() + .add_root_certificate(reqwest::Certificate::from_pem(cert.pem().as_bytes()).unwrap()) + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + assert_serves_jwks(&client, &format!("https://{address}")).await; + assert!( + client + .get(format!("http://{address}/.well-known/jwks.json")) + .send() + .await + .map_or(true, |response| !response.status().is_success()), + "plaintext must not reach the TLS listener" + ); + server.abort(); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn published_jwks_verifies_issued_credentials() { + let issuer = test_issuer(); let (credential, _) = issuer .issue("user", "device", "codex", "agentgateway", None, false) .expect("issue credential"); @@ -231,8 +383,6 @@ mod tests { validation.set_audience(&["agentgateway"]); decode::(&credential, &decoding_key, &validation) .expect("verify credential with published JWKS"); - - let _ = fs::remove_file(path); } #[test] diff --git a/crates/controller/src/management.rs b/crates/controller/src/management.rs index 0c47cf3a..833ee584 100644 --- a/crates/controller/src/management.rs +++ b/crates/controller/src/management.rs @@ -17,11 +17,7 @@ use tracing::info; use agentdesktop_core::config::{ControllerAdminAccess, ControllerAdminTransport}; -use crate::{ - daemon_config::DaemonConfigStore, - database::Database, - gateway_jwt::{self, GatewayJwks}, -}; +use crate::{daemon_config::DaemonConfigStore, database::Database}; pub mod auth; mod handlers; @@ -45,7 +41,7 @@ pub struct Extensions { /// Required for authenticated access, and rejected for local access, which always /// uses the built-in loopback provider. pub authentication: Option>, - /// Extension-owned APIs, protected separately from public auth/JWKS routes. + /// Extension-owned APIs, protected separately from public auth routes. pub routes: Vec, /// Optional complete UI bundle that replaces the built-in one, e.g. to add pages. pub assets: Option, @@ -112,11 +108,10 @@ pub fn auth(access: &ControllerAdminAccess, extensions: &Extensions) -> anyhow:: pub async fn serve( address: SocketAddr, state: State, - gateway_jwks: Option, access: ControllerAdminAccess, extensions: Extensions, ) -> anyhow::Result<()> { - let app = router(state, gateway_jwks, &access, extensions)?; + let app = router(state, &access, extensions)?; let listener = tokio::net::TcpListener::bind(address).await?; serve_router(listener, app, access).await } @@ -171,12 +166,11 @@ fn admin_tls_config(certificate: &Path, key: &Path) -> anyhow::Result, access: &ControllerAdminAccess, extensions: Extensions, ) -> anyhow::Result { @@ -224,9 +218,7 @@ pub fn router( for routes in extensions.routes { app = app.route(routes.path, auth.authenticate_api(routes.methods)); } - // Private routes default to no-store; public JWKS keeps its own caching. Common - // HTTP safeguards still wrap all routes. - Ok(policy.apply(http::private_routes(app).merge(gateway_jwt::routes(gateway_jwks)))) + Ok(policy.apply(http::private_routes(app))) } fn asset(assets: AssetLookup, uri: &Uri) -> Response { diff --git a/crates/controller/src/management/auth/tests.rs b/crates/controller/src/management/auth/tests.rs index 84c36d04..d5bd2848 100644 --- a/crates/controller/src/management/auth/tests.rs +++ b/crates/controller/src/management/auth/tests.rs @@ -190,6 +190,28 @@ async fn request(app: Router, path: &str, method: Method, headers: HeaderMap) -> app.oneshot(request).await.unwrap() } +#[tokio::test] +async fn management_router_does_not_publish_the_gateway_jwks() { + let (database, path) = database().await; + let router = super::super::router( + state(database), + &ControllerAdminAccess::Local, + super::super::Extensions::default(), + ) + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, "127.0.0.1:8080".parse().unwrap()); + let response = request(router, "/.well-known/jwks.json", Method::GET, headers).await; + assert_ne!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/jwk-set+json") + ); + std::fs::remove_file(path).unwrap(); +} + #[tokio::test] async fn local_mode_keeps_ui_api_and_bootstrap_without_session_tables() { let (database, path) = database().await; @@ -348,7 +370,6 @@ async fn authenticated_configuration_is_rejected_without_a_provider() { let error = match super::super::serve( "127.0.0.1:0".parse().unwrap(), state(database), - None, authenticated_access(), super::super::Extensions::default(), ) @@ -432,7 +453,6 @@ async fn management_routes_check_precise_operations() { let calls = Arc::new(std::sync::Mutex::new(Vec::new())); let router = super::super::router( state(database), - None, &authenticated_access(), super::super::Extensions { authentication: Some(Arc::new(RecordingProvider(calls.clone()))), @@ -475,7 +495,6 @@ async fn local_configuration_is_rejected_with_a_provider() { let (database, path) = database().await; let error = match super::super::router( state(database), - None, &ControllerAdminAccess::Local, super::super::Extensions { authentication: Some(Arc::new(TokenProvider)), @@ -639,7 +658,6 @@ async fn extension_providers_authenticate_and_authorize_the_management_router() let (database, path) = database().await; let router = super::super::router( state(database), - None, &authenticated_access(), super::super::Extensions { authentication: Some(Arc::new(TokenProvider)), @@ -757,7 +775,6 @@ async fn direct_tls_transport_serves_the_authenticated_router_over_https_only() let access = ControllerAdminAccess::Authenticated(admin); let router = super::super::router( state(database), - None, &access, super::super::Extensions { authentication: Some(Arc::new(TokenProvider)), diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 84ad790b..5a85da43 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -620,6 +620,24 @@ pub struct ControllerGatewayJwtConfig { #[serde(default = "default_gateway_jwt_lifetime", with = "humantime_serde")] #[cfg_attr(feature = "schema", schemars(with = "String"))] pub lifetime: Duration, + /// Listener that serves only `/.well-known/jwks.json`, which the fleet listener also serves. + /// + /// LLM gateways need the JWKS to verify controller-issued JWTs. Point them here so a NetworkPolicy or firewall can allow them this port and block the management API, which can read the device inventory and delete devices. Gateways that export usage also need the fleet port. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jwks_listener: Option, +} + +/// Dedicated listener for the LLM gateway JWKS. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ControllerGatewayJwksListener { + /// Address on which the JWKS listener accepts connections. + #[cfg_attr(feature = "schema", schemars(with = "String"))] + pub listen: SocketAddr, + /// Serve HTTPS with the controller certificate from `tls` instead of plain HTTP. + #[serde(default)] + pub tls: bool, } /// Collection of LLM token and cost records that agentgateway exports to the @@ -1414,6 +1432,15 @@ pub fn parse_controller(contents: &str) -> anyhow::Result { if gateway.lifetime.is_zero() { anyhow::bail!("gatewayJwt.lifetime must be greater than zero"); } + if let Some(jwks) = &gateway.jwks_listener + && [config.fleet_listen, config.admin_listen] + .iter() + .any(|other| other.port() == jwks.listen.port()) + { + anyhow::bail!( + "gatewayJwt.jwksListener.listen must use a port other than fleetListen and adminListen" + ); + } } Ok(config) } @@ -2222,6 +2249,48 @@ oidc: ); } + #[test] + fn controller_gateway_jwks_listener_is_optional_and_separate_from_other_listeners() { + let base = r#" +tls: /etc/agentdesktop/tls +oidc: + issuer: https://idp.example.com + clientId: agentdesktop +gatewayJwt: + privateKey: /gateway-jwt-key.pem +"#; + let controller = parse_controller(base).expect("gateway JWT without JWKS listener"); + assert!(controller.gateway_jwt.unwrap().jwks_listener.is_none()); + + let controller = parse_controller(&format!( + "{base} jwksListener:\n listen: 0.0.0.0:8081\n" + )) + .expect("plain HTTP JWKS listener"); + let listener = controller.gateway_jwt.unwrap().jwks_listener.unwrap(); + assert_eq!(listener.listen, "0.0.0.0:8081".parse().unwrap()); + assert!(!listener.tls); + + let controller = parse_controller(&format!( + "{base} jwksListener:\n listen: 127.0.0.1:8444\n tls: true\n" + )) + .expect("HTTPS JWKS listener"); + assert!(controller.gateway_jwt.unwrap().jwks_listener.unwrap().tls); + + for listen in ["0.0.0.0:8443", "127.0.0.1:8080"] { + assert!( + parse_controller(&format!("{base} jwksListener:\n listen: {listen}\n")) + .is_err(), + "{listen}" + ); + } + assert!( + parse_controller(&format!( + "{base} jwksListener:\n listen: 127.0.0.1:8081\n path: /jwks\n" + )) + .is_err() + ); + } + #[test] fn controller_usage_token_file_is_relative_to_the_configuration() { let unique = std::time::SystemTime::now() diff --git a/deploy/helm/agentdesktop-controller/templates/NOTES.txt b/deploy/helm/agentdesktop-controller/templates/NOTES.txt index 5de77bac..6ed0f917 100644 --- a/deploy/helm/agentdesktop-controller/templates/NOTES.txt +++ b/deploy/helm/agentdesktop-controller/templates/NOTES.txt @@ -1,6 +1,11 @@ The Agentdesktop fleet API is available through Service: {{ include "agentdesktop-controller.fullname" . }}:{{ .Values.service.port }} +{{ if include "agentdesktop-controller.gatewayJwksEnabled" . -}} +LLM gateways fetch the gateway JWKS from Service: + {{ ternary "https" "http" .Values.gatewayJwks.tls }}://{{ include "agentdesktop-controller.gatewayJwksServiceName" . }}.{{ .Release.Namespace }}.svc:{{ include "agentdesktop-controller.gatewayJwksServicePort" . }}/.well-known/jwks.json + +{{ end -}} The management UI binds to loopback and is not exposed by the Service. Open it with: kubectl port-forward deployment/{{ include "agentdesktop-controller.fullname" . }} 8080:8080 diff --git a/deploy/helm/agentdesktop-controller/templates/_helpers.tpl b/deploy/helm/agentdesktop-controller/templates/_helpers.tpl index 38a16c1e..02dd90c6 100644 --- a/deploy/helm/agentdesktop-controller/templates/_helpers.tpl +++ b/deploy/helm/agentdesktop-controller/templates/_helpers.tpl @@ -18,6 +18,18 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} {{- end }} +{{- define "agentdesktop-controller.gatewayJwksEnabled" -}} +{{- if and .Values.gatewayJwks.enabled (or .Values.gatewayJwt .Values.existingConfigSecret) }}true{{- end }} +{{- end }} + +{{- define "agentdesktop-controller.gatewayJwksServiceName" -}} +{{- printf "%s-jwks" (include "agentdesktop-controller.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "agentdesktop-controller.gatewayJwksServicePort" -}} +{{- .Values.gatewayJwks.service.port | default (ternary 443 80 .Values.gatewayJwks.tls) }} +{{- end }} + {{- define "agentdesktop-controller.selectorLabels" -}} app.kubernetes.io/name: {{ include "agentdesktop-controller.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/deploy/helm/agentdesktop-controller/templates/configmap.yaml b/deploy/helm/agentdesktop-controller/templates/configmap.yaml index 1a522b9e..ea802137 100644 --- a/deploy/helm/agentdesktop-controller/templates/configmap.yaml +++ b/deploy/helm/agentdesktop-controller/templates/configmap.yaml @@ -18,7 +18,12 @@ data: redirectUri: {{ .Values.oidc.redirectUri | quote }} {{- with .Values.gatewayJwt }} gatewayJwt: -{{ toYaml . | indent 6 }} +{{ toYaml (ternary (omit . "jwksListener") . $.Values.gatewayJwks.enabled) | indent 6 }} + {{- if $.Values.gatewayJwks.enabled }} + jwksListener: + listen: 0.0.0.0:{{ $.Values.gatewayJwks.port }} + tls: {{ $.Values.gatewayJwks.tls }} + {{- end }} {{- end }} {{- with .Values.daemonConfig }} daemonConfig: diff --git a/deploy/helm/agentdesktop-controller/templates/deployment.yaml b/deploy/helm/agentdesktop-controller/templates/deployment.yaml index f5d47b4c..1766844b 100644 --- a/deploy/helm/agentdesktop-controller/templates/deployment.yaml +++ b/deploy/helm/agentdesktop-controller/templates/deployment.yaml @@ -39,6 +39,11 @@ spec: - name: admin containerPort: 8080 protocol: TCP + {{- if include "agentdesktop-controller.gatewayJwksEnabled" . }} + - name: jwks + containerPort: {{ .Values.gatewayJwks.port }} + protocol: TCP + {{- end }} securityContext: allowPrivilegeEscalation: false capabilities: diff --git a/deploy/helm/agentdesktop-controller/templates/service-jwks.yaml b/deploy/helm/agentdesktop-controller/templates/service-jwks.yaml new file mode 100644 index 00000000..b6438e24 --- /dev/null +++ b/deploy/helm/agentdesktop-controller/templates/service-jwks.yaml @@ -0,0 +1,17 @@ +{{- if include "agentdesktop-controller.gatewayJwksEnabled" . }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "agentdesktop-controller.gatewayJwksServiceName" . }} + labels: + {{- include "agentdesktop-controller.labels" . | nindent 4 }} +spec: + type: {{ .Values.gatewayJwks.service.type }} + selector: + {{- include "agentdesktop-controller.selectorLabels" . | nindent 4 }} + ports: + - name: {{ ternary "https" "http" .Values.gatewayJwks.tls }} + port: {{ include "agentdesktop-controller.gatewayJwksServicePort" . }} + targetPort: jwks + protocol: TCP +{{- end }} diff --git a/deploy/helm/agentdesktop-controller/values.yaml b/deploy/helm/agentdesktop-controller/values.yaml index 230c7918..8b6a03d5 100644 --- a/deploy/helm/agentdesktop-controller/values.yaml +++ b/deploy/helm/agentdesktop-controller/values.yaml @@ -22,6 +22,23 @@ allowInsecureDev: false # Optional short-lived JWT issuer for authenticated LLM gateways. gatewayJwt: {} +# Listener and Service that serve only the gateway JWKS (/.well-known/jwks.json). +# Point LLM gateways at this Service so a NetworkPolicy can allow them this port +# and nothing else on the controller (usage export also needs the fleet port). +# The chart creates them when gatewayJwt or existingConfigSecret is set. With +# existingConfigSecret, that configuration must set gatewayJwt.jwksListener to +# listen on 0.0.0.0:, with the same tls setting. +gatewayJwks: + enabled: true + port: 8081 + # Serve HTTPS with the controller certificate from tlsSecretName. The + # certificate must then include the JWKS Service name. + tls: false + service: + type: ClusterIP + # Defaults to 443 with tls, otherwise 80. + port: "" + # Daemon configuration distributed to enrolled devices. Leave empty to # disable managed configuration. daemonConfig: {} diff --git a/docs/device-identity.md b/docs/device-identity.md index d1f3587d..13d0055e 100644 --- a/docs/device-identity.md +++ b/docs/device-identity.md @@ -340,7 +340,9 @@ managed tools obtain short-lived gateway credentials through the daemon: 4. The daemon caches the JWT for at most 60 seconds per client ID, never within 30 seconds of its expiry, and never across logout or re-enrollment. 5. The gateway verifies the JWT with the controller's public keys, served at - `/.well-known/jwks.json`. + `/.well-known/jwks.json` on the fleet listener and, when configured, on the + dedicated `gatewayJwt.jwksListener` (plain HTTP, or HTTPS with the + controller certificate). The management listener does not serve them. With `authentication.type: oidc`, the controller is not involved. The daemon signs the user in to the gateway's own OIDC client, using the browser or diff --git a/examples/claude/agentgateway.yaml b/examples/claude/agentgateway.yaml index 48e07321..b9605a76 100644 --- a/examples/claude/agentgateway.yaml +++ b/examples/claude/agentgateway.yaml @@ -43,7 +43,7 @@ llm: issuer: agentdesktop-controller audiences: [agentgateway] jwks: - url: http://127.0.0.1:8080/.well-known/jwks.json + url: http://127.0.0.1:8081/.well-known/jwks.json models: - name: "*" provider: anthropic diff --git a/examples/claude/controller.yaml b/examples/claude/controller.yaml index 98d9ae85..1ba489c5 100644 --- a/examples/claude/controller.yaml +++ b/examples/claude/controller.yaml @@ -13,6 +13,8 @@ daemonConfig: gatewayJwt: privateKey: /tmp/agentdesktop-keys/gateway-jwt-key.pem + jwksListener: + listen: 127.0.0.1:8081 usage: tokenFile: /tmp/agentdesktop-keys/usage-token diff --git a/schema/controller-config.json b/schema/controller-config.json index 774c2998..eefde999 100644 --- a/schema/controller-config.json +++ b/schema/controller-config.json @@ -191,6 +191,25 @@ "path" ] }, + "ControllerGatewayJwksListener": { + "description": "Dedicated listener for the LLM gateway JWKS.", + "type": "object", + "properties": { + "listen": { + "description": "Address on which the JWKS listener accepts connections.", + "type": "string" + }, + "tls": { + "description": "Serve HTTPS with the controller certificate from `tls` instead of plain HTTP.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "required": [ + "listen" + ] + }, "ControllerGatewayJwtConfig": { "description": "Settings for issuing short-lived LLM gateway JWTs.", "type": "object", @@ -200,6 +219,17 @@ "type": "string", "default": "agentdesktop-controller" }, + "jwksListener": { + "description": "Listener that serves only `/.well-known/jwks.json`, which the fleet listener also serves.\n\nLLM gateways need the JWKS to verify controller-issued JWTs. Point them here so a NetworkPolicy or firewall can allow them this port and block the management API, which can read the device inventory and delete devices. Gateways that export usage also need the fleet port.", + "anyOf": [ + { + "$ref": "#/$defs/ControllerGatewayJwksListener" + }, + { + "type": "null" + } + ] + }, "keyId": { "description": "Key identifier placed in generated JWT headers.", "type": "string", diff --git a/schema/controller-config.md b/schema/controller-config.md index 9461c745..5e385bc1 100644 --- a/schema/controller-config.md +++ b/schema/controller-config.md @@ -21,6 +21,9 @@ |`fleetListen`|string|Address on which the device-facing gRPC fleet API listens.| |`gatewayJwt`|object|LLM gateway JWT signing settings.| |`gatewayJwt.issuer`|string|Issuer claim placed in generated JWTs.| +|`gatewayJwt.jwksListener`|object|Listener that serves only `/.well-known/jwks.json`, which the fleet listener also serves.

LLM gateways need the JWKS to verify controller-issued JWTs. Point them here so a NetworkPolicy or firewall can allow them this port and block the management API, which can read the device inventory and delete devices. Gateways that export usage also need the fleet port.| +|`gatewayJwt.jwksListener.listen`|string|Address on which the JWKS listener accepts connections.| +|`gatewayJwt.jwksListener.tls`|boolean|Serve HTTPS with the controller certificate from `tls` instead of plain HTTP.| |`gatewayJwt.keyId`|string|Key identifier placed in generated JWT headers.| |`gatewayJwt.lifetime`|string|Lifetime of generated JWTs. Defaults to `5m`.| |`gatewayJwt.privateKey`|string|Path to the PEM-encoded RSA private signing key.

Relative paths are resolved from the controller configuration directory.| From 3f4616d5dca6fd2720a08f4005731af4a9568f7a Mon Sep 17 00:00:00 2001 From: Yuval Kohavi Date: Wed, 11 Nov 2026 16:02:22 -0500 Subject: [PATCH 2/4] fix(controller): use an explicit rustls provider for the JWKS listener `RustlsConfig::from_pem_file` relies on the process-default rustls CryptoProvider. Workspace builds enable both the aws-lc-rs and ring features, so rustls cannot pick one and the HTTPS JWKS listener panicked at startup (and in its test under `cargo test --workspace`). Reuse the management listener's explicit aws-lc-rs config, renamed to `tls_server_config` with a label for its error messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/controller/src/gateway_jwt.rs | 8 +++++--- crates/controller/src/management.rs | 21 +++++++++++++-------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/controller/src/gateway_jwt.rs b/crates/controller/src/gateway_jwt.rs index 1ad5177c..adbf59e2 100644 --- a/crates/controller/src/gateway_jwt.rs +++ b/crates/controller/src/gateway_jwt.rs @@ -73,9 +73,11 @@ pub async fn serve_listener( let app = routes(Some(jwks)).layer(middleware::from_fn(safeguard_request)); match tls { Some(tls) => { - let config = RustlsConfig::from_pem_file(&tls.certificate, &tls.key) - .await - .context("load gateway JWKS TLS identity")?; + let config = RustlsConfig::from_config(Arc::new(crate::management::tls_server_config( + &tls.certificate, + &tls.key, + "gateway JWKS", + )?)); tracing::info!(listen = %address, tls = true, "gateway JWKS listening"); axum_server::from_tcp_rustls(listener.into_std()?, config)? .serve(app.into_make_service()) diff --git a/crates/controller/src/management.rs b/crates/controller/src/management.rs index 833ee584..8eb848c8 100644 --- a/crates/controller/src/management.rs +++ b/crates/controller/src/management.rs @@ -125,9 +125,10 @@ pub async fn serve_router( if let ControllerAdminAccess::Authenticated(config) = &access && let ControllerAdminTransport::Tls { certificate, key } = &config.transport { - let tls = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(admin_tls_config( + let tls = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(tls_server_config( certificate, key, + "admin", )?)); info!(listen = %address, "controller authenticated admin HTTPS listening"); axum_server::from_tcp_rustls(listener.into_std()?, tls)? @@ -141,27 +142,31 @@ pub async fn serve_router( Ok(()) } -/// Admin TLS identity with an explicit crypto provider, so the listener does not -/// depend on which rustls backends the final binary happens to enable. -fn admin_tls_config(certificate: &Path, key: &Path) -> anyhow::Result { +/// Server TLS identity with an explicit crypto provider, so a listener does not depend on +/// which rustls backends the final binary happens to enable. `purpose` labels errors. +pub(crate) fn tls_server_config( + certificate: &Path, + key: &Path, + purpose: &str, +) -> anyhow::Result { use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; let certificates = CertificateDer::pem_file_iter(certificate) .and_then(Iterator::collect::, _>>) - .with_context(|| format!("read admin TLS certificate {}", certificate.display()))?; + .with_context(|| format!("read {purpose} TLS certificate {}", certificate.display()))?; anyhow::ensure!( !certificates.is_empty(), - "admin TLS certificate {} contains no certificates", + "{purpose} TLS certificate {} contains no certificates", certificate.display() ); let key = PrivateKeyDer::from_pem_file(key) - .with_context(|| format!("read admin TLS key {}", key.display()))?; + .with_context(|| format!("read {purpose} TLS key {}", key.display()))?; let mut config = rustls::ServerConfig::builder_with_provider(Arc::new( rustls::crypto::aws_lc_rs::default_provider(), )) .with_safe_default_protocol_versions()? .with_no_client_auth() .with_single_cert(certificates, key) - .context("load admin TLS identity")?; + .with_context(|| format!("load {purpose} TLS identity"))?; config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; Ok(config) } From 96f587ae3cc5f5b0b08f264ed9bc24fc6f6c6fc0 Mon Sep 17 00:00:00 2001 From: Yuval Kohavi Date: Wed, 11 Nov 2026 16:08:06 -0500 Subject: [PATCH 3/4] refactor(core): share the explicit-provider TLS server config Move `tls_server_config` from the controller's management module to `agentdesktop_core::tls::server_config` so any crate can build a rustls server config without relying on the process-default CryptoProvider. Core already depends on rustls with aws-lc-rs through reqwest, so the direct dependency adds nothing to the build; the controller no longer needs its own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 3 +- crates/controller/Cargo.toml | 1 - crates/controller/src/gateway_jwt.rs | 8 ++- crates/controller/src/management.rs | 39 ++------------ crates/core/Cargo.toml | 4 ++ crates/core/src/lib.rs | 1 + crates/core/src/tls.rs | 77 ++++++++++++++++++++++++++++ 7 files changed, 91 insertions(+), 42 deletions(-) create mode 100644 crates/core/src/tls.rs diff --git a/Cargo.lock b/Cargo.lock index 2a52f9ed..fdcd37a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,7 +136,6 @@ dependencies = [ "rcgen", "reqwest", "rust-embed", - "rustls", "serde", "serde_json", "sha2 0.11.1", @@ -158,7 +157,9 @@ version = "0.1.1" dependencies = [ "anyhow", "humantime-serde", + "rcgen", "reqwest", + "rustls", "schemars 1.2.3", "serde", "serde-transcode", diff --git a/crates/controller/Cargo.toml b/crates/controller/Cargo.toml index 5cb495a4..eef56d7e 100644 --- a/crates/controller/Cargo.toml +++ b/crates/controller/Cargo.toml @@ -27,7 +27,6 @@ rand.workspace = true rcgen.workspace = true reqwest.workspace = true rust-embed = "8" -rustls.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/crates/controller/src/gateway_jwt.rs b/crates/controller/src/gateway_jwt.rs index adbf59e2..c2dba7c7 100644 --- a/crates/controller/src/gateway_jwt.rs +++ b/crates/controller/src/gateway_jwt.rs @@ -73,11 +73,9 @@ pub async fn serve_listener( let app = routes(Some(jwks)).layer(middleware::from_fn(safeguard_request)); match tls { Some(tls) => { - let config = RustlsConfig::from_config(Arc::new(crate::management::tls_server_config( - &tls.certificate, - &tls.key, - "gateway JWKS", - )?)); + let config = RustlsConfig::from_config(Arc::new( + agentdesktop_core::tls::server_config(&tls.certificate, &tls.key, "gateway JWKS")?, + )); tracing::info!(listen = %address, tls = true, "gateway JWKS listening"); axum_server::from_tcp_rustls(listener.into_std()?, config)? .serve(app.into_make_service()) diff --git a/crates/controller/src/management.rs b/crates/controller/src/management.rs index 8eb848c8..cedbf6b3 100644 --- a/crates/controller/src/management.rs +++ b/crates/controller/src/management.rs @@ -2,7 +2,7 @@ //! Handler authorization is explicit; authentication and policy implementations are //! supplied through `auth`, independently of the fleet enrollment API. -use std::{borrow::Cow, net::SocketAddr, path::Path, sync::Arc}; +use std::{borrow::Cow, net::SocketAddr, sync::Arc}; use anyhow::Context as _; use axum::{ @@ -125,11 +125,9 @@ pub async fn serve_router( if let ControllerAdminAccess::Authenticated(config) = &access && let ControllerAdminTransport::Tls { certificate, key } = &config.transport { - let tls = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(tls_server_config( - certificate, - key, - "admin", - )?)); + let tls = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new( + agentdesktop_core::tls::server_config(certificate, key, "admin")?, + )); info!(listen = %address, "controller authenticated admin HTTPS listening"); axum_server::from_tcp_rustls(listener.into_std()?, tls)? .serve(app.into_make_service()) @@ -142,35 +140,6 @@ pub async fn serve_router( Ok(()) } -/// Server TLS identity with an explicit crypto provider, so a listener does not depend on -/// which rustls backends the final binary happens to enable. `purpose` labels errors. -pub(crate) fn tls_server_config( - certificate: &Path, - key: &Path, - purpose: &str, -) -> anyhow::Result { - use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; - let certificates = CertificateDer::pem_file_iter(certificate) - .and_then(Iterator::collect::, _>>) - .with_context(|| format!("read {purpose} TLS certificate {}", certificate.display()))?; - anyhow::ensure!( - !certificates.is_empty(), - "{purpose} TLS certificate {} contains no certificates", - certificate.display() - ); - let key = PrivateKeyDer::from_pem_file(key) - .with_context(|| format!("read {purpose} TLS key {}", key.display()))?; - let mut config = rustls::ServerConfig::builder_with_provider(Arc::new( - rustls::crypto::aws_lc_rs::default_provider(), - )) - .with_safe_default_protocol_versions()? - .with_no_client_auth() - .with_single_cert(certificates, key) - .with_context(|| format!("load {purpose} TLS identity"))?; - config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - Ok(config) -} - /// Compose authenticated APIs/UI and deliberately public auth endpoints. /// The provider and HTTP safeguards both derive from `access`, so they cannot disagree. /// Permissions are checked by handlers; providers must not infer them from paths. diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e285de5f..3e5f38c0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -12,6 +12,7 @@ schema = ["dep:schemars"] anyhow.workspace = true humantime-serde.workspace = true reqwest.workspace = true +rustls.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true @@ -22,3 +23,6 @@ tracing-appender.workspace = true tracing-subscriber.workspace = true url.workspace = true + +[dev-dependencies] +rcgen.workspace = true diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 7adf78c0..71ca3b1a 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -3,6 +3,7 @@ pub mod http; pub mod model; pub mod serdes; pub mod telemetry; +pub mod tls; use std::{env, path::PathBuf}; diff --git a/crates/core/src/tls.rs b/crates/core/src/tls.rs new file mode 100644 index 00000000..c95adf42 --- /dev/null +++ b/crates/core/src/tls.rs @@ -0,0 +1,77 @@ +use std::{path::Path, sync::Arc}; + +use anyhow::Context; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; + +/// Server TLS identity with an explicit crypto provider, so a listener does not depend on +/// which rustls backends the final binary happens to enable. `purpose` labels errors. +pub fn server_config( + certificate: &Path, + key: &Path, + purpose: &str, +) -> anyhow::Result { + let certificates = CertificateDer::pem_file_iter(certificate) + .and_then(Iterator::collect::, _>>) + .with_context(|| format!("read {purpose} TLS certificate {}", certificate.display()))?; + anyhow::ensure!( + !certificates.is_empty(), + "{purpose} TLS certificate {} contains no certificates", + certificate.display() + ); + let key = PrivateKeyDer::from_pem_file(key) + .with_context(|| format!("read {purpose} TLS key {}", key.display()))?; + let mut config = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_no_client_auth() + .with_single_cert(certificates, key) + .with_context(|| format!("load {purpose} TLS identity"))?; + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::server_config; + + #[test] + fn loads_a_pem_identity_and_labels_errors_with_the_purpose() { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let directory = + std::env::temp_dir().join(format!("agentdesktop-tls-{}-{unique}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec!["127.0.0.1".into()]).unwrap(); + let certificate = directory.join("certificate.pem"); + let key = directory.join("key.pem"); + let empty = directory.join("empty.pem"); + std::fs::write(&certificate, cert.pem()).unwrap(); + std::fs::write(&key, signing_key.serialize_pem()).unwrap(); + std::fs::write(&empty, "").unwrap(); + + let config = server_config(&certificate, &key, "test").expect("load identity"); + assert_eq!( + config.alpn_protocols, + [b"h2".to_vec(), b"http/1.1".to_vec()] + ); + + let error = server_config(&empty, &key, "test").unwrap_err(); + assert!( + error.to_string().starts_with("test TLS certificate") + && error.to_string().ends_with("contains no certificates"), + "{error}" + ); + let error = + server_config(&certificate, &directory.join("missing.pem"), "test").unwrap_err(); + assert!( + error.to_string().starts_with("read test TLS key"), + "{error}" + ); + + std::fs::remove_dir_all(directory).unwrap(); + } +} From c4fd3a5818d65080ef18ac91bb29cd371c726b77 Mon Sep 17 00:00:00 2001 From: Yuval Kohavi Date: Wed, 11 Nov 2026 16:34:35 -0500 Subject: [PATCH 4/4] fix(ui): align shared UI peer versions with the apps #226 bumped react and lucide-react in the controller and desktop apps but not in the shared @agentdesktop/ui package, so pnpm installed a second copy (react 19.2.8, lucide-react 1.31.0) for it. With a cold Vite cache, the controller storybook tests loaded both copies and 89 of 92 failed with "Invalid hook call"; runs that reused a warm cache passed. Raise the peer ranges to react ^19.3.1 and lucide-react ^1.67.0 so the lockfile resolves a single copy of each. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontend/pnpm-lock.yaml | 23 ++++------------------- frontend/ui/package.json | 4 ++-- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f3ff8bbe..08aa7b07 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -133,11 +133,11 @@ importers: ui: dependencies: lucide-react: - specifier: ^1.31.0 - version: 1.31.0(react@19.2.8) + specifier: ^1.67.0 + version: 1.67.0(@types/react@19.3.0)(react@19.3.1) react: - specifier: ^19.2.8 - version: 19.2.8 + specifier: ^19.3.1 + version: 19.3.1 devDependencies: '@types/react': specifier: ^19.3.0 @@ -1640,11 +1640,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.31.0: - resolution: {integrity: sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lucide-react@1.67.0: resolution: {integrity: sha512-z7kd21YNm/Gw3E7mTnq5dTuvV+yc+OEwA//oDENSG1SbZ/NVgKdjxALJFNdGoC0Ji7HaJR2Dha9ev7VUPxy9CQ==} peerDependencies: @@ -1767,10 +1762,6 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react@19.2.8: - resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} - engines: {node: '>=0.10.0'} - react@19.3.1: resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} engines: {node: '>=0.10.0'} @@ -3158,10 +3149,6 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.31.0(react@19.2.8): - dependencies: - react: 19.2.8 - lucide-react@1.67.0(@types/react@19.3.0)(react@19.3.1): dependencies: react: 19.3.1 @@ -3309,8 +3296,6 @@ snapshots: react-is@17.0.2: {} - react@19.2.8: {} - react@19.3.1: {} recast@0.23.21: diff --git a/frontend/ui/package.json b/frontend/ui/package.json index 12edfce5..b665e546 100644 --- a/frontend/ui/package.json +++ b/frontend/ui/package.json @@ -8,8 +8,8 @@ "./styles.css": "./src/styles.css" }, "peerDependencies": { - "lucide-react": "^1.31.0", - "react": "^19.2.8" + "lucide-react": "^1.67.0", + "react": "^19.3.1" }, "devDependencies": { "@types/react": "^19.3.0"