diff --git a/crates/oxmux/src/errors.rs b/crates/oxmux/src/errors.rs index ea211ef..0ba0753 100644 --- a/crates/oxmux/src/errors.rs +++ b/crates/oxmux/src/errors.rs @@ -146,8 +146,8 @@ impl ConfigurationSourceMetadata { } use crate::{ - MinimalProxyErrorCode, ProtocolFamily, ProtocolTranslationDirection, ProviderExecutionFailure, - RoutingFailure, StreamingFailure, + LocalClientAuthorizationFailure, MinimalProxyErrorCode, ProtocolFamily, + ProtocolTranslationDirection, ProviderExecutionFailure, RoutingFailure, StreamingFailure, }; #[derive(Clone, Debug, Eq, PartialEq)] @@ -204,6 +204,11 @@ pub enum CoreError { /// Human-readable diagnostic message. message: String, }, + /// Local client authorization failed for a protected route. + LocalClientAuthorization { + /// Structured authorization failure associated with this state. + failure: LocalClientAuthorizationFailure, + }, /// Provider account summary construction failed. ProviderAccountSummary { /// Human-readable diagnostic message. @@ -315,6 +320,7 @@ impl fmt::Display for CoreError { Self::LocalRuntimeShutdown { message } => { write!(formatter, "local runtime shutdown failed: {message}") } + Self::LocalClientAuthorization { failure } => write!(formatter, "{failure}"), Self::ProviderAccountSummary { message } => { write!(formatter, "provider account summary failed: {message}") } diff --git a/crates/oxmux/src/local_client_auth.rs b/crates/oxmux/src/local_client_auth.rs new file mode 100644 index 0000000..5157b80 --- /dev/null +++ b/crates/oxmux/src/local_client_auth.rs @@ -0,0 +1,412 @@ +//! Local client authorization contracts for loopback proxy access. +//! +//! These values describe caller-owned authorization to the local proxy. They do +//! not represent provider credentials, OAuth tokens, desktop credential storage, +//! or upstream provider account state. + +use core::fmt; + +/// Route scope protected by local client authorization. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LocalClientRouteScope { + /// OpenAI-compatible inference route access. + Inference, + /// Local management, status, and control route access. + Management, +} + +impl LocalClientRouteScope { + /// Returns a stable machine-readable scope label. + pub fn as_str(self) -> &'static str { + match self { + Self::Inference => "inference", + Self::Management => "management", + } + } +} + +/// Secret-bearing local client credential. +#[derive(Clone, Eq, PartialEq)] +pub struct LocalClientCredential { + secret: String, +} + +impl LocalClientCredential { + /// Creates a local client credential used only for loopback proxy authorization. + pub fn new(secret: impl Into) -> Result { + let secret = secret.into(); + if secret.trim().is_empty() { + return Err(LocalClientCredentialError::EmptySecret); + } + + Ok(Self { secret }) + } + + /// Returns redacted metadata safe for status and management surfaces. + pub fn redacted_metadata(&self) -> RedactedLocalClientCredentialMetadata { + RedactedLocalClientCredentialMetadata { + configured: true, + display: "", + } + } + + pub(crate) fn matches(&self, presented: &str) -> bool { + fixed_secret_time_eq(self.secret.as_bytes(), presented.as_bytes()) + } +} + +fn fixed_secret_time_eq(expected: &[u8], presented: &[u8]) -> bool { + let mut difference = expected.len() ^ presented.len(); + for (index, expected_byte) in expected.iter().enumerate() { + let presented_byte = presented.get(index).copied().unwrap_or(0); + difference |= usize::from(expected_byte ^ presented_byte); + } + difference == 0 +} + +/// Local client credential construction error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LocalClientCredentialError { + /// Credential secret was empty or whitespace-only. + EmptySecret, +} + +impl fmt::Display for LocalClientCredentialError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptySecret => formatter.write_str("local client credential secret is empty"), + } + } +} + +impl std::error::Error for LocalClientCredentialError {} + +impl fmt::Debug for LocalClientCredential { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LocalClientCredential") + .field("secret", &"") + .finish() + } +} + +/// Redacted credential metadata safe for logs, debug output, and management snapshots. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RedactedLocalClientCredentialMetadata { + /// Whether a local client credential is configured. + pub configured: bool, + /// Stable redacted display label that never contains the raw credential. + pub display: &'static str, +} + +impl RedactedLocalClientCredentialMetadata { + /// Metadata for a required credential that is not configured. + pub fn missing() -> Self { + Self { + configured: false, + display: "", + } + } +} + +/// Local client authorization protection policy for a route scope. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LocalClientAuthorizationPolicy { + /// Route scope is classified but does not require local client authorization. + Disabled, + /// Route scope requires a matching configured local client credential. + Required { + /// Expected local client credential, when configured. + credential: Option, + }, +} + +impl LocalClientAuthorizationPolicy { + /// Creates a disabled local client authorization policy. + pub fn disabled() -> Self { + Self::Disabled + } + + /// Creates a required policy with a configured local client credential. + pub fn required(credential: LocalClientCredential) -> Self { + Self::Required { + credential: Some(credential), + } + } + + /// Creates a required policy with no configured credential, which fails closed. + pub fn required_without_credential() -> Self { + Self::Required { credential: None } + } + + /// Returns redacted status metadata for this policy. + pub fn metadata(&self) -> LocalClientAuthorizationPolicyMetadata { + match self { + Self::Disabled => LocalClientAuthorizationPolicyMetadata::Disabled, + Self::Required { credential } => LocalClientAuthorizationPolicyMetadata::Required { + credential: credential + .as_ref() + .map(LocalClientCredential::redacted_metadata) + .unwrap_or_else(RedactedLocalClientCredentialMetadata::missing), + }, + } + } + + /// Authorizes a local client attempt for a route scope. + pub fn authorize( + &self, + scope: LocalClientRouteScope, + attempt: &LocalClientAuthorizationAttempt, + ) -> LocalClientAuthorizationOutcome { + match self { + Self::Disabled => LocalClientAuthorizationOutcome::Disabled { scope }, + Self::Required { credential: None } => { + LocalClientAuthorizationOutcome::Denied(LocalClientAuthorizationFailure::new( + scope, + LocalClientAuthorizationFailureReason::MissingConfiguredCredential, + )) + } + Self::Required { + credential: Some(credential), + } => match attempt { + LocalClientAuthorizationAttempt::Missing => { + LocalClientAuthorizationOutcome::Denied(LocalClientAuthorizationFailure::new( + scope, + LocalClientAuthorizationFailureReason::MissingCredential, + )) + } + LocalClientAuthorizationAttempt::Malformed => { + LocalClientAuthorizationOutcome::Denied(LocalClientAuthorizationFailure::new( + scope, + LocalClientAuthorizationFailureReason::MalformedCredential, + )) + } + LocalClientAuthorizationAttempt::UnsupportedScheme => { + LocalClientAuthorizationOutcome::Denied(LocalClientAuthorizationFailure::new( + scope, + LocalClientAuthorizationFailureReason::UnsupportedScheme, + )) + } + LocalClientAuthorizationAttempt::Bearer { token } if credential.matches(token) => { + LocalClientAuthorizationOutcome::Authorized { scope } + } + LocalClientAuthorizationAttempt::Bearer { .. } => { + LocalClientAuthorizationOutcome::Denied(LocalClientAuthorizationFailure::new( + scope, + LocalClientAuthorizationFailureReason::InvalidCredential, + )) + } + }, + } + } +} + +/// Management-safe metadata for a local client authorization policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LocalClientAuthorizationPolicyMetadata { + /// Protection is disabled for the route scope. + Disabled, + /// Protection is required for the route scope. + Required { + /// Redacted credential metadata for the required policy. + credential: RedactedLocalClientCredentialMetadata, + }, +} + +/// Independent protection policies for local route categories. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalRouteProtection { + /// Inference route protection policy. + pub inference: LocalClientAuthorizationPolicy, + /// Management/status/control route protection policy. + pub management: LocalClientAuthorizationPolicy, +} + +impl LocalRouteProtection { + /// Creates route protection with all scopes disabled. + pub fn disabled() -> Self { + Self { + inference: LocalClientAuthorizationPolicy::Disabled, + management: LocalClientAuthorizationPolicy::Disabled, + } + } + + /// Returns management-safe metadata for both route scopes. + pub fn metadata(&self) -> LocalRouteProtectionMetadata { + LocalRouteProtectionMetadata { + inference: self.inference.metadata(), + management: self.management.metadata(), + } + } +} + +impl Default for LocalRouteProtection { + fn default() -> Self { + Self::disabled() + } +} + +/// Management-safe route protection metadata. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LocalRouteProtectionMetadata { + /// Inference route authorization metadata. + pub inference: LocalClientAuthorizationPolicyMetadata, + /// Management/status/control route authorization metadata. + pub management: LocalClientAuthorizationPolicyMetadata, +} + +impl LocalRouteProtectionMetadata { + /// Metadata for route protection with all scopes disabled. + pub fn disabled() -> Self { + LocalRouteProtection::disabled().metadata() + } +} + +/// Local client authorization presented by a request adapter. +#[derive(Clone, Eq, PartialEq)] +pub enum LocalClientAuthorizationAttempt { + /// No local client authorization credential was presented. + Missing, + /// A bearer token was presented. + Bearer { + /// Presented bearer token. Debug output redacts this value. + token: String, + }, + /// Authorization shape was malformed. + Malformed, + /// Authorization scheme was unsupported. + UnsupportedScheme, +} + +impl LocalClientAuthorizationAttempt { + /// Creates a bearer-token authorization attempt. + pub fn bearer(token: impl Into) -> Self { + Self::Bearer { + token: token.into(), + } + } +} + +impl fmt::Debug for LocalClientAuthorizationAttempt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing => formatter.write_str("Missing"), + Self::Bearer { .. } => formatter + .debug_struct("Bearer") + .field("token", &"") + .finish(), + Self::Malformed => formatter.write_str("Malformed"), + Self::UnsupportedScheme => formatter.write_str("UnsupportedScheme"), + } + } +} + +/// Structured local client authorization outcome. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LocalClientAuthorizationOutcome { + /// Route scope authorized the presented local client credential. + Authorized { + /// Authorized route scope. + scope: LocalClientRouteScope, + }, + /// Route scope did not require authorization. + Disabled { + /// Unprotected route scope. + scope: LocalClientRouteScope, + }, + /// Route scope denied the request. + Denied(LocalClientAuthorizationFailure), +} + +impl LocalClientAuthorizationOutcome { + /// Converts the outcome into a result suitable for route dispatch. + pub fn into_result(self) -> Result<(), LocalClientAuthorizationFailure> { + match self { + Self::Authorized { .. } | Self::Disabled { .. } => Ok(()), + Self::Denied(failure) => Err(failure), + } + } +} + +/// Structured local client authorization failure. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LocalClientAuthorizationFailure { + /// Route scope that rejected the request. + pub scope: LocalClientRouteScope, + /// Stable failure reason. + pub reason: LocalClientAuthorizationFailureReason, +} + +impl LocalClientAuthorizationFailure { + /// Creates a local client authorization failure. + pub fn new( + scope: LocalClientRouteScope, + reason: LocalClientAuthorizationFailureReason, + ) -> Self { + Self { scope, reason } + } +} + +impl fmt::Display for LocalClientAuthorizationFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "local client authorization failed for {}: {}", + self.scope.as_str(), + self.reason.as_str() + ) + } +} + +impl std::error::Error for LocalClientAuthorizationFailure {} + +/// Stable local client authorization failure reason. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LocalClientAuthorizationFailureReason { + /// Request omitted required local client authorization. + MissingCredential, + /// Request authorization header was malformed. + MalformedCredential, + /// Request used an unsupported authorization scheme. + UnsupportedScheme, + /// Request credential did not match the configured credential. + InvalidCredential, + /// Route required authorization but no credential was configured. + MissingConfiguredCredential, +} + +impl LocalClientAuthorizationFailureReason { + /// Returns a stable serialized failure code. + pub fn as_str(self) -> &'static str { + match self { + Self::MissingCredential => "missing_credential", + Self::MalformedCredential => "malformed_credential", + Self::UnsupportedScheme => "unsupported_scheme", + Self::InvalidCredential => "invalid_credential", + Self::MissingConfiguredCredential => "missing_configured_credential", + } + } +} + +#[cfg(test)] +mod tests { + use super::{LocalClientCredential, fixed_secret_time_eq}; + + #[test] + fn local_client_credential_matches_exact_secret_only() { + let credential = LocalClientCredential::new("expected-token").expect("valid credential"); + + assert!(credential.matches("expected-token")); + assert!(!credential.matches("wrong-token")); + assert!(!credential.matches("expected-token-extra")); + assert!(!credential.matches("expected")); + } + + #[test] + fn fixed_secret_time_comparison_rejects_length_mismatches() { + assert!(fixed_secret_time_eq(b"secret", b"secret")); + assert!(!fixed_secret_time_eq(b"secret", b"secret-extra")); + assert!(!fixed_secret_time_eq(b"secret", b"sec")); + assert!(!fixed_secret_time_eq(b"secret", b"secreu")); + } +} diff --git a/crates/oxmux/src/local_proxy_runtime.rs b/crates/oxmux/src/local_proxy_runtime.rs index a5216cd..c940283 100644 --- a/crates/oxmux/src/local_proxy_runtime.rs +++ b/crates/oxmux/src/local_proxy_runtime.rs @@ -17,6 +17,10 @@ use crate::{ ProviderExecutor, ProxyLifecycleState, QuotaSummary, RoutingAvailabilitySnapshot, RoutingPolicy, UptimeMetadata, UsageSummary, core_identity, }; +use crate::{ + LocalClientAuthorizationAttempt, LocalClientRouteScope, LocalRouteProtection, + LocalRouteProtectionMetadata, +}; /// HTTP path used by the local health runtime to serve health responses. pub const LOCAL_HEALTH_PATH: &str = "/health"; @@ -26,6 +30,8 @@ pub const LOCAL_HEALTH_RESPONSE_BODY: &str = "oxmux local health runtime: health const MAX_LOCAL_HEALTH_REQUEST_BYTES: usize = 8 * 1024; const MAX_LOCAL_PROXY_REQUEST_BYTES: usize = 64 * 1024; const LOCAL_CHAT_COMPLETIONS_PATH: &str = crate::MINIMAL_CHAT_COMPLETIONS_PATH; +const LOCAL_MANAGEMENT_PREFIX: &str = "/v0/management/"; +const LOCAL_CLIENT_AUTHENTICATE_HEADER: &str = "WWW-Authenticate: Bearer realm=\"oxmux\"\r\n"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] /// Loopback listen configuration for the local health runtime. @@ -82,6 +88,8 @@ pub struct LocalProxyRouteConfig { pub availability: RoutingAvailabilitySnapshot, /// Provider executor used by the minimal proxy route. pub provider_executor: Arc, + /// Local route protection policies for inference and management scopes. + pub route_protection: LocalRouteProtection, } impl LocalProxyRouteConfig { @@ -95,8 +103,15 @@ impl LocalProxyRouteConfig { routing_policy, availability, provider_executor, + route_protection: LocalRouteProtection::disabled(), } } + + /// Returns this route configuration with local route protection policies. + pub fn with_route_protection(mut self, route_protection: LocalRouteProtection) -> Self { + self.route_protection = route_protection; + self + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -108,6 +123,8 @@ pub struct LocalHealthRuntimeStatus { pub health: CoreHealthState, /// Bound endpoint associated with this lifecycle state. pub endpoint: Option, + /// Redacted local route protection metadata. + pub local_route_protection: LocalRouteProtectionMetadata, } impl LocalHealthRuntimeStatus { @@ -117,6 +134,7 @@ impl LocalHealthRuntimeStatus { lifecycle: ProxyLifecycleState::Starting, health: CoreHealthState::Healthy, endpoint: None, + local_route_protection: LocalRouteProtectionMetadata::disabled(), } } @@ -128,6 +146,7 @@ impl LocalHealthRuntimeStatus { }, health: CoreHealthState::Failed { error }, endpoint: None, + local_route_protection: LocalRouteProtectionMetadata::disabled(), } } @@ -137,6 +156,7 @@ impl LocalHealthRuntimeStatus { lifecycle: ProxyLifecycleState::Stopped, health: CoreHealthState::Healthy, endpoint, + local_route_protection: LocalRouteProtectionMetadata::disabled(), } } @@ -159,6 +179,7 @@ impl LocalHealthRuntimeStatus { providers: Vec::new(), usage: UsageSummary::zero(), quota: QuotaSummary::unknown(), + local_route_protection: self.local_route_protection, warnings: Vec::new(), errors, } @@ -174,6 +195,7 @@ pub struct LocalHealthRuntime { shutdown_requested: Arc, worker: Option>>, status: LocalHealthRuntimeStatus, + local_route_protection: LocalRouteProtectionMetadata, } impl std::fmt::Debug for LocalHealthRuntime { @@ -229,6 +251,10 @@ impl LocalHealthRuntime { let endpoint = BoundEndpoint { socket_addr }; let shutdown_requested = Arc::new(AtomicBool::new(false)); let worker_shutdown_requested = shutdown_requested.clone(); + let local_route_protection = proxy_route + .as_ref() + .map(|route| route.route_protection.metadata()) + .unwrap_or_else(LocalRouteProtectionMetadata::disabled); let worker = thread::spawn(move || { serve_health_requests(listener, worker_shutdown_requested, proxy_route) }); @@ -246,6 +272,7 @@ impl LocalHealthRuntime { }, health: CoreHealthState::Healthy, endpoint: Some(endpoint), + local_route_protection, }; Ok(Self { @@ -256,6 +283,7 @@ impl LocalHealthRuntime { shutdown_requested, worker: Some(worker), status, + local_route_protection, }) } @@ -287,6 +315,7 @@ impl LocalHealthRuntime { }, health: CoreHealthState::Healthy, endpoint: Some(self.endpoint), + local_route_protection: self.local_route_protection, }, None => self.status.clone(), } @@ -312,7 +341,10 @@ impl LocalHealthRuntime { if let Some(worker) = self.worker.take() { match worker.join() { Ok(Ok(())) => { - self.status = LocalHealthRuntimeStatus::stopped(Some(self.endpoint)); + self.status = LocalHealthRuntimeStatus { + local_route_protection: self.local_route_protection, + ..LocalHealthRuntimeStatus::stopped(Some(self.endpoint)) + }; Ok(self.status.clone()) } Ok(Err(error)) => { @@ -403,15 +435,22 @@ fn handle_connection( } }; - match (request.method.as_str(), request.path.as_str()) { - ("GET", LOCAL_HEALTH_PATH) => { - write_response(&mut stream, "200 OK", LOCAL_HEALTH_RESPONSE_BODY) - } - ("POST", LOCAL_CHAT_COMPLETIONS_PATH) => { + match classify_local_route(&request) { + LocalRoute::Health => write_response(&mut stream, "200 OK", LOCAL_HEALTH_RESPONSE_BODY), + LocalRoute::Inference => { let Some(proxy_route) = proxy_route else { let response = MinimalProxyResponse::unsupported_path(); return write_json_response(&mut stream, response.status_code, &response.body); }; + if let Err(failure) = proxy_route + .route_protection + .inference + .authorize(LocalClientRouteScope::Inference, &request.authorization) + .into_result() + { + let response = MinimalProxyResponse::local_client_unauthorized(&failure); + return write_json_response(&mut stream, response.status_code, &response.body); + } let proxy_request = match MinimalProxyRequest::open_ai_chat_completions(request.body) { Ok(request) => request, Err(error) => { @@ -429,17 +468,55 @@ fn handle_connection( ); write_json_response(&mut stream, response.status_code, &response.body) } - _ => { + LocalRoute::Management => { + let Some(proxy_route) = proxy_route else { + let response = MinimalProxyResponse::unsupported_path(); + return write_json_response(&mut stream, response.status_code, &response.body); + }; + let authorization_outcome = proxy_route + .route_protection + .management + .authorize(LocalClientRouteScope::Management, &request.authorization); + if let Err(failure) = authorization_outcome.into_result() { + let response = MinimalProxyResponse::local_client_unauthorized(&failure); + return write_json_response(&mut stream, response.status_code, &response.body); + } + let response = MinimalProxyResponse::management_boundary(); + write_json_response(&mut stream, response.status_code, &response.body) + } + LocalRoute::Unsupported => { let response = MinimalProxyResponse::unsupported_path(); write_json_response(&mut stream, response.status_code, &response.body) } } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LocalRoute { + Health, + Inference, + Management, + Unsupported, +} + +fn classify_local_route(request: &LocalHttpRequest) -> LocalRoute { + match (request.method.as_str(), request.path.as_str()) { + ("GET", LOCAL_HEALTH_PATH) => LocalRoute::Health, + ("POST", LOCAL_CHAT_COMPLETIONS_PATH) => LocalRoute::Inference, + (method, path) + if path.starts_with(LOCAL_MANAGEMENT_PREFIX) && matches!(method, "GET" | "POST") => + { + LocalRoute::Management + } + _ => LocalRoute::Unsupported, + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct LocalHttpRequest { method: String, path: String, + authorization: LocalClientAuthorizationAttempt, body: Vec, } @@ -514,15 +591,17 @@ fn read_local_request(stream: &mut TcpStream) -> Result MAX_LOCAL_PROXY_REQUEST_BYTES { return Err(invalid_local_request( "body", @@ -556,10 +635,17 @@ fn read_local_request(stream: &mut TcpStream) -> Result Result { let mut request = Vec::new(); @@ -602,10 +688,11 @@ fn find_header_end(request: &[u8]) -> Option { request.windows(4).position(|window| window == b"\r\n\r\n") } -fn parse_content_length<'a>( +fn parse_bounded_headers<'a>( header_lines: impl Iterator, -) -> Result { +) -> Result { let mut content_length = None; + let mut authorization = None; for line in header_lines { let Some((name, value)) = line.split_once(':') else { continue; @@ -628,10 +715,35 @@ fn parse_content_length<'a>( } content_length = Some(parsed_content_length); + } else if name.eq_ignore_ascii_case("authorization") { + if authorization.is_some() { + authorization = Some(LocalClientAuthorizationAttempt::Malformed); + } else { + authorization = Some(parse_authorization_header(value)); + } } } - Ok(content_length.unwrap_or(0)) + Ok(BoundedLocalHeaders { + content_length: content_length.unwrap_or(0), + authorization: authorization.unwrap_or(LocalClientAuthorizationAttempt::Missing), + }) +} + +fn parse_authorization_header(value: &str) -> LocalClientAuthorizationAttempt { + let trimmed = value.trim(); + let Some((scheme, token)) = trimmed.split_once(char::is_whitespace) else { + return LocalClientAuthorizationAttempt::Malformed; + }; + if !scheme.eq_ignore_ascii_case("Bearer") { + return LocalClientAuthorizationAttempt::UnsupportedScheme; + } + let token = token.trim(); + if token.is_empty() || token.split_whitespace().count() != 1 { + return LocalClientAuthorizationAttempt::Malformed; + } + + LocalClientAuthorizationAttempt::bearer(token) } fn invalid_local_request( @@ -687,14 +799,20 @@ fn write_json_response( let reason = match status_code { 200 => "OK", 400 => "Bad Request", + 401 => "Unauthorized", 404 => "Not Found", 408 => "Request Timeout", 500 => "Internal Server Error", 502 => "Bad Gateway", _ => "Internal Server Error", }; + let authenticate_header = if status_code == 401 { + LOCAL_CLIENT_AUTHENTICATE_HEADER + } else { + "" + }; let response = format!( - "HTTP/1.1 {status_code} {reason}\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{body}", + "HTTP/1.1 {status_code} {reason}\r\nContent-Length: {}\r\nContent-Type: application/json\r\n{authenticate_header}Connection: close\r\n\r\n{body}", body.len() ); stream diff --git a/crates/oxmux/src/management.rs b/crates/oxmux/src/management.rs index c14e2a2..272d814 100644 --- a/crates/oxmux/src/management.rs +++ b/crates/oxmux/src/management.rs @@ -18,7 +18,7 @@ use crate::configuration::{ }; use crate::provider::{DegradedReason, ProviderSummary}; use crate::usage::{QuotaSummary, UsageSummary}; -use crate::{CoreError, CoreIdentity, core_identity}; +use crate::{CoreError, CoreIdentity, LocalRouteProtectionMetadata, core_identity}; #[derive(Clone, Debug, Eq, PartialEq)] /// Aggregate management view of identity, lifecycle, health, configuration, provider, usage, quota, and error state. @@ -45,6 +45,8 @@ pub struct ManagementSnapshot { pub usage: UsageSummary, /// Quota summary visible in management state. pub quota: QuotaSummary, + /// Local route protection metadata visible in management state. + pub local_route_protection: LocalRouteProtectionMetadata, /// Non-fatal warnings visible to management consumers. pub warnings: Vec, /// Structured errors associated with this state. @@ -66,6 +68,7 @@ impl ManagementSnapshot { providers: Vec::new(), usage: UsageSummary::zero(), quota: QuotaSummary::unknown(), + local_route_protection: LocalRouteProtectionMetadata::disabled(), warnings: Vec::new(), errors: Vec::new(), } diff --git a/crates/oxmux/src/minimal_proxy.rs b/crates/oxmux/src/minimal_proxy.rs index 22f9b84..96fd1e6 100644 --- a/crates/oxmux/src/minimal_proxy.rs +++ b/crates/oxmux/src/minimal_proxy.rs @@ -5,9 +5,9 @@ //! responses for local runtime tests and bootstrap behavior. use crate::{ - CanonicalProtocolRequest, CoreError, ProtocolMetadata, ProtocolPayload, ProtocolPayloadBody, - ProviderExecutionRequest, ProviderExecutor, ResponseMode, RoutingAvailabilitySnapshot, - RoutingBoundary, RoutingPolicy, RoutingSelectionRequest, + CanonicalProtocolRequest, CoreError, LocalClientAuthorizationFailure, ProtocolMetadata, + ProtocolPayload, ProtocolPayloadBody, ProviderExecutionRequest, ProviderExecutor, ResponseMode, + RoutingAvailabilitySnapshot, RoutingBoundary, RoutingPolicy, RoutingSelectionRequest, }; /// OpenAI-compatible chat completions path served by the minimal proxy engine. @@ -86,10 +86,45 @@ impl MinimalProxyResponse { ) } + /// Creates a minimal proxy response for local client authorization failures. + pub fn local_client_unauthorized(failure: &LocalClientAuthorizationFailure) -> Self { + let body = serde_json::json!({ + "error": { + "code": MinimalProxyErrorCode::LocalClientUnauthorized.as_str(), + "message": failure.to_string(), + "reason": failure.reason.as_str(), + "scope": failure.scope.as_str(), + "type": "oxmux_proxy_error" + } + }) + .to_string(); + + Self { + status_code: 401, + content_type: MINIMAL_PROXY_JSON_CONTENT_TYPE, + body, + } + } + + /// Creates a deterministic protected management boundary response. + pub fn management_boundary() -> Self { + let body = serde_json::json!({ + "object": "oxmux.management.boundary", + "status": "authorized", + "message": "local management boundary reserved" + }) + .to_string(); + + Self::success(body) + } + /// Maps a core error into a minimal proxy response. pub fn from_core_error(error: &CoreError) -> Self { match error { CoreError::MinimalProxyRequestValidation { .. } => Self::invalid_request(error), + CoreError::LocalClientAuthorization { failure } => { + Self::local_client_unauthorized(failure) + } _ => Self::proxy_failure(error), } } @@ -139,6 +174,8 @@ pub enum MinimalProxyErrorCode { ResponseSerializationFailed, /// Local request path is not supported by the minimal runtime. UnsupportedPath, + /// Local client authorization failed for a protected route. + LocalClientUnauthorized, } impl MinimalProxyErrorCode { @@ -155,12 +192,14 @@ impl MinimalProxyErrorCode { Self::UnsupportedResponseMode => "unsupported_response_mode", Self::ResponseSerializationFailed => "response_serialization_failed", Self::UnsupportedPath => "unsupported_path", + Self::LocalClientUnauthorized => "local_client_unauthorized", } } fn from_core_error(error: &CoreError) -> Self { match error { CoreError::MinimalProxyRequestValidation { code, .. } => *code, + CoreError::LocalClientAuthorization { .. } => Self::LocalClientUnauthorized, CoreError::Routing { .. } => Self::RoutingFailed, CoreError::ProviderExecution { .. } => Self::ProviderExecutionFailed, CoreError::MinimalProxyUnsupportedResponseMode { .. } => Self::UnsupportedResponseMode, diff --git a/crates/oxmux/src/oxmux.rs b/crates/oxmux/src/oxmux.rs index b00b9e1..59ff48f 100644 --- a/crates/oxmux/src/oxmux.rs +++ b/crates/oxmux/src/oxmux.rs @@ -13,6 +13,8 @@ pub mod configuration; /// Structured error types shared by headless core boundaries. pub mod errors; +/// Local client authorization contracts for loopback proxy access. +pub mod local_client_auth; /// Local loopback runtime contracts for health and minimal proxy serving. pub mod local_proxy_runtime; /// Management snapshot and lifecycle state contracts. @@ -44,6 +46,13 @@ pub use errors::{ ConfigurationError, ConfigurationErrorKind, ConfigurationSourceMetadata, CoreError, InvalidConfigurationValue, }; +pub use local_client_auth::{ + LocalClientAuthorizationAttempt, LocalClientAuthorizationFailure, + LocalClientAuthorizationFailureReason, LocalClientAuthorizationOutcome, + LocalClientAuthorizationPolicy, LocalClientAuthorizationPolicyMetadata, LocalClientCredential, + LocalClientCredentialError, LocalClientRouteScope, LocalRouteProtection, + LocalRouteProtectionMetadata, RedactedLocalClientCredentialMetadata, +}; pub use local_proxy_runtime::{ LOCAL_HEALTH_PATH, LOCAL_HEALTH_RESPONSE_BODY, LocalHealthRuntime, LocalHealthRuntimeConfig, LocalHealthRuntimeStatus, LocalProxyRouteConfig, diff --git a/crates/oxmux/tests/direct_use.rs b/crates/oxmux/tests/direct_use.rs index c32df72..d67a640 100644 --- a/crates/oxmux/tests/direct_use.rs +++ b/crates/oxmux/tests/direct_use.rs @@ -6,10 +6,14 @@ use std::time::Duration; use oxmux::{ AccountSummary, AuthMethodCategory, AuthState, BoundEndpoint, ConfigurationSnapshot, ConfigurationUpdateIntent, CoreError, CoreHealthState, DegradedReason, LastCheckedMetadata, - LifecycleControlIntent, ManagementSnapshot, MeteredValue, ProtocolFamily, ProtocolMetadata, - ProtocolPayload, ProviderCapability, ProviderSummary, ProxyLifecycleState, QuotaState, - QuotaSummary, ResponseMode, RoutingDefault, StreamEvent, StreamMetadata, StreamTerminalState, - StreamingResponse, UptimeMetadata, UsageSummary, core_identity, + LifecycleControlIntent, LocalClientAuthorizationAttempt, LocalClientAuthorizationFailureReason, + LocalClientAuthorizationOutcome, LocalClientAuthorizationPolicy, + LocalClientAuthorizationPolicyMetadata, LocalClientCredential, LocalClientRouteScope, + LocalRouteProtection, LocalRouteProtectionMetadata, ManagementSnapshot, MeteredValue, + ProtocolFamily, ProtocolMetadata, ProtocolPayload, ProviderCapability, ProviderSummary, + ProxyLifecycleState, QuotaState, QuotaSummary, ResponseMode, RoutingDefault, StreamEvent, + StreamMetadata, StreamTerminalState, StreamingResponse, UptimeMetadata, UsageSummary, + core_identity, }; #[test] @@ -36,6 +40,54 @@ fn streaming_primitives_are_usable_through_public_facade() -> Result<(), CoreErr Ok(()) } +#[test] +fn local_client_authorization_primitives_are_public_and_redacted() +-> Result<(), Box> { + let credential = LocalClientCredential::new("local-secret")?; + let policy = LocalClientAuthorizationPolicy::required(credential.clone()); + + let authorized = policy.authorize( + LocalClientRouteScope::Inference, + &LocalClientAuthorizationAttempt::bearer("local-secret"), + ); + assert!(matches!( + authorized, + LocalClientAuthorizationOutcome::Authorized { + scope: LocalClientRouteScope::Inference + } + )); + + let denied = policy.authorize( + LocalClientRouteScope::Management, + &LocalClientAuthorizationAttempt::bearer("wrong-secret"), + ); + assert!(matches!( + denied, + LocalClientAuthorizationOutcome::Denied(failure) + if failure.reason == LocalClientAuthorizationFailureReason::InvalidCredential + && failure.scope == LocalClientRouteScope::Management + )); + + assert!(!format!("{credential:?}").contains("local-secret")); + assert!(matches!( + policy.metadata(), + LocalClientAuthorizationPolicyMetadata::Required { credential } + if credential.configured && credential.display.contains("redacted") + )); + + let route_protection = LocalRouteProtection { + inference: policy, + management: LocalClientAuthorizationPolicy::required_without_credential(), + }; + assert!(matches!( + route_protection.metadata().management, + LocalClientAuthorizationPolicyMetadata::Required { credential } + if !credential.configured && credential.display.contains("missing") + )); + + Ok(()) +} + #[test] fn management_snapshot_can_be_constructed_from_in_memory_values() { let endpoint = BoundEndpoint { @@ -116,6 +168,7 @@ fn management_snapshot_can_be_constructed_from_in_memory_values() { reason: "provider quota endpoint is not implemented".to_string(), }, }, + local_route_protection: LocalRouteProtectionMetadata::disabled(), warnings: vec!["quota data is placeholder-only".to_string()], errors: vec![CoreError::UsageQuotaSummary { message: "quota fetch deferred".to_string(), diff --git a/crates/oxmux/tests/local_proxy_runtime.rs b/crates/oxmux/tests/local_proxy_runtime.rs index 644ee98..215ccdb 100644 --- a/crates/oxmux/tests/local_proxy_runtime.rs +++ b/crates/oxmux/tests/local_proxy_runtime.rs @@ -3,17 +3,20 @@ use std::io::{Read, Write}; use std::net::{IpAddr, Ipv4Addr, Shutdown, SocketAddr, TcpListener, TcpStream}; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; use std::time::Duration; use oxmux::{ AuthMethodCategory, CanonicalProtocolResponse, CoreError, CoreHealthState, - LOCAL_HEALTH_RESPONSE_BODY, LocalHealthRuntime, LocalHealthRuntimeConfig, - LocalHealthRuntimeStatus, LocalProxyRouteConfig, MockProviderAccount, MockProviderHarness, - MockProviderOutcome, ModelRoute, ProtocolFamily, ProtocolMetadata, ProtocolPayload, - ProtocolResponseStatus, ProxyLifecycleState, RoutingAvailabilitySnapshot, - RoutingAvailabilityState, RoutingCandidate, RoutingPolicy, RoutingTarget, - RoutingTargetAvailability, + LOCAL_HEALTH_RESPONSE_BODY, LocalClientAuthorizationPolicy, + LocalClientAuthorizationPolicyMetadata, LocalClientCredential, LocalHealthRuntime, + LocalHealthRuntimeConfig, LocalHealthRuntimeStatus, LocalProxyRouteConfig, + LocalRouteProtection, MockProviderAccount, MockProviderHarness, MockProviderOutcome, + ModelRoute, ProtocolFamily, ProtocolMetadata, ProtocolPayload, ProtocolResponseStatus, + ProviderExecutionRequest, ProviderExecutionResult, ProviderExecutor, ProxyLifecycleState, + RoutingAvailabilitySnapshot, RoutingAvailabilityState, RoutingCandidate, RoutingPolicy, + RoutingTarget, RoutingTargetAvailability, }; #[test] @@ -128,6 +131,161 @@ fn chat_completion_route_returns_deterministic_json_response() Ok(()) } +#[test] +fn protected_chat_completion_requires_valid_inference_authorization() +-> Result<(), Box> { + let counter = Arc::new(AtomicUsize::new(0)); + let mut runtime = LocalHealthRuntime::start_with_proxy_route( + LocalHealthRuntimeConfig::loopback(0), + proxy_route_config_with_executor( + Arc::new(CountingProviderExecutor { + inner: success_provider()?, + calls: counter.clone(), + }), + LocalRouteProtection { + inference: LocalClientAuthorizationPolicy::required(LocalClientCredential::new( + "inference-token", + )?), + management: LocalClientAuthorizationPolicy::disabled(), + }, + )?, + )?; + let socket_addr = runtime.bound_endpoint().socket_addr; + let body = r#"{"model":"smoke-model","messages":[{"role":"user","content":"hi"}]}"#; + + let missing_response = post_chat_completion(socket_addr, body)?; + assert!(missing_response.starts_with("HTTP/1.1 401 Unauthorized\r\n")); + assert!(missing_response.contains("WWW-Authenticate: Bearer realm=\"oxmux\"\r\n")); + assert!(missing_response.contains(r#""code":"local_client_unauthorized""#)); + assert!(missing_response.contains(r#""scope":"inference""#)); + assert!(!missing_response.contains("inference-token")); + assert_eq!(counter.load(Ordering::SeqCst), 0); + + let valid_response = + post_chat_completion_with_authorization(socket_addr, body, Some("Bearer inference-token"))?; + assert!(valid_response.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(valid_response.contains("runtime provider response")); + assert_eq!(counter.load(Ordering::SeqCst), 1); + + runtime.shutdown()?; + Ok(()) +} + +#[test] +fn management_boundary_uses_distinct_authorization_scope() -> Result<(), Box> +{ + let mut runtime = LocalHealthRuntime::start_with_proxy_route( + LocalHealthRuntimeConfig::loopback(0), + proxy_route_config_with_executor( + Arc::new(success_provider()?), + LocalRouteProtection { + inference: LocalClientAuthorizationPolicy::required(LocalClientCredential::new( + "inference-token", + )?), + management: LocalClientAuthorizationPolicy::required(LocalClientCredential::new( + "management-token", + )?), + }, + )?, + )?; + let socket_addr = runtime.bound_endpoint().socket_addr; + + let inference_only = management_request(socket_addr, Some("Bearer inference-token"))?; + assert!(inference_only.starts_with("HTTP/1.1 401 Unauthorized\r\n")); + assert!(inference_only.contains("WWW-Authenticate: Bearer realm=\"oxmux\"\r\n")); + assert!(inference_only.contains(r#""scope":"management""#)); + assert!(inference_only.contains(r#""reason":"invalid_credential""#)); + assert!(!inference_only.contains("management-token")); + + let authorized = management_request(socket_addr, Some("Bearer management-token"))?; + assert!(authorized.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(authorized.contains(r#""object":"oxmux.management.boundary""#)); + assert!(!authorized.contains(r#""object":"chat.completion""#)); + + let body = r#"{"model":"smoke-model","messages":[{"role":"user","content":"hi"}]}"#; + let management_only = post_chat_completion_with_authorization( + socket_addr, + body, + Some("Bearer management-token"), + )?; + assert!(management_only.starts_with("HTTP/1.1 401 Unauthorized\r\n")); + assert!(management_only.contains("WWW-Authenticate: Bearer realm=\"oxmux\"\r\n")); + assert!(management_only.contains(r#""scope":"inference""#)); + + runtime.shutdown()?; + Ok(()) +} + +#[test] +fn management_boundary_is_unsupported_without_proxy_route() -> Result<(), Box> +{ + let mut runtime = LocalHealthRuntime::start(LocalHealthRuntimeConfig::loopback(0))?; + let response = management_request( + runtime.bound_endpoint().socket_addr, + Some("Bearer management-token"), + )?; + + assert!(response.starts_with("HTTP/1.1 404 Not Found\r\n")); + assert!(response.contains(r#""code":"unsupported_path""#)); + assert!(!response.contains(r#""object":"oxmux.management.boundary""#)); + + runtime.shutdown()?; + Ok(()) +} + +#[test] +fn bearer_parsing_returns_structured_unauthorized_reasons() -> Result<(), Box> +{ + let mut runtime = LocalHealthRuntime::start_with_proxy_route( + LocalHealthRuntimeConfig::loopback(0), + proxy_route_config_with_executor( + Arc::new(success_provider()?), + LocalRouteProtection { + inference: LocalClientAuthorizationPolicy::required(LocalClientCredential::new( + "expected-token", + )?), + management: LocalClientAuthorizationPolicy::required_without_credential(), + }, + )?, + )?; + let socket_addr = runtime.bound_endpoint().socket_addr; + let body = r#"{"model":"smoke-model","messages":[{"role":"user","content":"hi"}]}"#; + + for (authorization, reason) in [ + (None, "missing_credential"), + (Some("Bearer"), "malformed_credential"), + (Some("Basic expected-token"), "unsupported_scheme"), + (Some("Bearer wrong-token"), "invalid_credential"), + (Some("Bearer too many parts"), "malformed_credential"), + ] { + let response = post_chat_completion_with_authorization(socket_addr, body, authorization)?; + assert!(response.starts_with("HTTP/1.1 401 Unauthorized\r\n")); + assert!(response.contains("WWW-Authenticate: Bearer realm=\"oxmux\"\r\n")); + assert!(response.contains(&format!(r#""reason":"{reason}""#))); + assert!(!response.contains("expected-token")); + } + + let duplicate_authorization = raw_request( + socket_addr, + &format!( + "POST /v1/chat/completions HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAuthorization: Bearer wrong-token\r\nAuthorization: Bearer expected-token\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ), + )?; + assert!(duplicate_authorization.starts_with("HTTP/1.1 401 Unauthorized\r\n")); + assert!(duplicate_authorization.contains("WWW-Authenticate: Bearer realm=\"oxmux\"\r\n")); + assert!(duplicate_authorization.contains(r#""reason":"malformed_credential""#)); + + let missing_configured = management_request(socket_addr, Some("Bearer expected-token"))?; + assert!(missing_configured.starts_with("HTTP/1.1 401 Unauthorized\r\n")); + assert!(missing_configured.contains("WWW-Authenticate: Bearer realm=\"oxmux\"\r\n")); + assert!(missing_configured.contains(r#""reason":"missing_configured_credential""#)); + + runtime.shutdown()?; + Ok(()) +} + #[test] fn malformed_chat_request_returns_400_and_runtime_keeps_serving() -> Result<(), Box> { @@ -224,6 +382,28 @@ fn unsupported_method_and_path_return_json_404() -> Result<(), Box Result<(), Box> { + let mut runtime = LocalHealthRuntime::start_with_proxy_route( + LocalHealthRuntimeConfig::loopback(0), + proxy_route_config()?, + )?; + let response = raw_request( + runtime.bound_endpoint().socket_addr, + "DELETE /v0/management/status HTTP/1.1\r\nHost: localhost\r\n\r\n", + )?; + + assert!( + response.starts_with("HTTP/1.1 404 Not Found\r\n"), + "unexpected response: {response:?}" + ); + assert!(response.contains("Content-Type: application/json\r\n")); + assert!(response.contains(r#""code":"unsupported_path""#)); + + runtime.shutdown()?; + Ok(()) +} + #[test] fn bind_failure_produces_structured_failed_status() -> Result<(), Box> { let occupied_listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?; @@ -297,7 +477,18 @@ fn shutdown_releases_listener_without_external_dependencies() #[test] fn management_snapshot_reflects_runtime_status() -> Result<(), Box> { - let mut runtime = LocalHealthRuntime::start(LocalHealthRuntimeConfig::loopback(0))?; + let mut runtime = LocalHealthRuntime::start_with_proxy_route( + LocalHealthRuntimeConfig::loopback(0), + proxy_route_config_with_executor( + Arc::new(success_provider()?), + LocalRouteProtection { + inference: LocalClientAuthorizationPolicy::required(LocalClientCredential::new( + "snapshot-secret", + )?), + management: LocalClientAuthorizationPolicy::required_without_credential(), + }, + )?, + )?; let snapshot = runtime.management_snapshot(); assert!(matches!( @@ -309,6 +500,13 @@ fn management_snapshot_reflects_runtime_status() -> Result<(), Box")); + assert!(!format!("{snapshot:?}").contains("snapshot-secret")); runtime.shutdown()?; Ok(()) @@ -338,17 +536,51 @@ fn client_io_failure_does_not_stop_health_runtime() -> Result<(), Box std::io::Result { + post_chat_completion_with_authorization(socket_addr, body, None) +} + +fn post_chat_completion_with_authorization( + socket_addr: SocketAddr, + body: &str, + authorization: Option<&str>, +) -> std::io::Result { + let authorization = authorization + .map(|authorization| format!("Authorization: {authorization}\r\n")) + .unwrap_or_default(); raw_request( socket_addr, &format!( - "POST /v1/chat/completions HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + "POST /v1/chat/completions HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\n{authorization}Content-Length: {}\r\n\r\n{}", body.len(), body ), ) } +fn management_request( + socket_addr: SocketAddr, + authorization: Option<&str>, +) -> std::io::Result { + let authorization = authorization + .map(|authorization| format!("Authorization: {authorization}\r\n")) + .unwrap_or_default(); + raw_request( + socket_addr, + &format!("GET /v0/management/status HTTP/1.1\r\nHost: localhost\r\n{authorization}\r\n"), + ) +} + fn proxy_route_config() -> Result { + proxy_route_config_with_executor( + Arc::new(success_provider()?), + LocalRouteProtection::disabled(), + ) +} + +fn proxy_route_config_with_executor( + executor: Arc, + route_protection: LocalRouteProtection, +) -> Result { let target = RoutingTarget::provider_account("mock-openai", "acct-primary"); let policy = RoutingPolicy::new(vec![ModelRoute::new( "smoke-model", @@ -358,7 +590,13 @@ fn proxy_route_config() -> Result { target, RoutingAvailabilityState::Available, )]); - let executor = MockProviderHarness::new( + + Ok(LocalProxyRouteConfig::new(policy, availability, executor) + .with_route_protection(route_protection)) +} + +fn success_provider() -> Result { + Ok(MockProviderHarness::new( "mock-openai", "Mock OpenAI", ProtocolFamily::OpenAi, @@ -369,13 +607,22 @@ fn proxy_route_config() -> Result { ProtocolPayload::opaque("application/json", b"runtime provider response".to_vec()), )?), )? - .with_account(MockProviderAccount::new("acct-primary", "Primary account")); + .with_account(MockProviderAccount::new("acct-primary", "Primary account"))) +} - Ok(LocalProxyRouteConfig::new( - policy, - availability, - Arc::new(executor), - )) +struct CountingProviderExecutor { + inner: MockProviderHarness, + calls: Arc, +} + +impl ProviderExecutor for CountingProviderExecutor { + fn execute( + &self, + request: ProviderExecutionRequest, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + self.inner.execute(request) + } } fn raw_request(socket_addr: SocketAddr, request: &str) -> std::io::Result { diff --git a/crates/oxmux/tests/provider_execution.rs b/crates/oxmux/tests/provider_execution.rs index a44ea95..4141a94 100644 --- a/crates/oxmux/tests/provider_execution.rs +++ b/crates/oxmux/tests/provider_execution.rs @@ -501,6 +501,7 @@ fn management_snapshot_can_include_mock_provider_health() -> Result<(), CoreErro providers: vec![provider], usage: UsageSummary::zero(), quota: QuotaSummary::unknown(), + local_route_protection: oxmux::LocalRouteProtectionMetadata::disabled(), warnings: vec!["mock provider is degraded".to_string()], errors: Vec::new(), }; diff --git a/openspec/changes/adopt-production-readiness-workflow/.openspec.yaml b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/.openspec.yaml similarity index 100% rename from openspec/changes/adopt-production-readiness-workflow/.openspec.yaml rename to openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/.openspec.yaml diff --git a/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/design.md b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/design.md new file mode 100644 index 0000000..0e1ed29 --- /dev/null +++ b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/design.md @@ -0,0 +1,77 @@ +## Context + +`oxmux` currently exposes a loopback-only local runtime with `GET /health` and a minimal `POST /v1/chat/completions` inference smoke route. The runtime parser is bounded and local-only, but its request representation only retains method, path, and body; local client authorization headers are not yet modeled. Management state exists as typed Rust snapshots, while management HTTP endpoints beyond `/health` were intentionally deferred. + +Issue #18 adds the security and route-boundary contract needed before real provider adapters, CLI/IDE clients, and app-facing management controls depend on the local runtime. The boundary must authorize local clients without reusing or exposing provider credentials, and it must keep inference routes separate from management/status/control routes. + +## Goals / Non-Goals + +**Goals:** + +- Represent local client authorization in `oxmux` as caller-owned access to the local proxy, not as provider authentication. +- Classify local runtime routes as health, inference, management/status/control, or unsupported before dispatch, with `/v0/management/*` reserved as the protected management namespace. +- Allow inference and management/status/control routes to use distinct authorization policies so future CLI/IDE clients can be granted only the access they need. +- Preserve loopback-only binding, bounded request parsing, stable `/health`, deterministic unsupported-path responses, and headless `oxmux` ownership. +- Add deterministic tests for valid, missing, and invalid authorization on inference and management/status/control paths. + +**Non-Goals:** + +- No remote management web panel or public network exposure by default. +- No OAuth login flow, token refresh, provider credential resolution, or platform secret storage. +- No Amp-specific URL rewriting, provider fallback, or new OpenAI-compatible endpoints beyond the existing minimal smoke route. +- No `oxidemux` GPUI, tray, notification, packaging, or desktop lifecycle work. +- No requirement to introduce Axum, Tower, or another HTTP framework for this change. + +## Decisions + +1. **Use explicit local route categories before dispatch.** + - Decision: classify `GET /health` as health, `POST /v1/chat/completions` as inference, `/v0/management/*` as the protected management/status/control namespace, and all other paths as unsupported before invoking route behavior. + - Rationale: route classification makes authorization decisions testable and prevents future management paths from being accidentally handled as inference or health requests. + - Alternative considered: match method/path directly in `handle_connection` and add ad hoc checks. That keeps the current implementation small but makes future management route authorization harder to audit. + +2. **Model local client authorization separately from provider credentials.** + - Decision: add `oxmux` primitives for local client credentials, authorization policies, redacted credential metadata, and authorization outcomes without storing or displaying raw secrets. + - Rationale: local API keys authorize access to the local proxy. Provider API keys authorize upstream provider calls. Mixing them would risk leaking provider credentials through local status surfaces or forwarding local client keys upstream. + - Alternative considered: reuse provider `AuthState` or provider credential references. That would blur the product boundary and make management snapshots ambiguous. + +3. **Prefer standard bearer-token semantics for local clients while keeping the core representation transport-neutral.** + - Decision: parse `Authorization: Bearer ` for the initial HTTP runtime, reject missing, malformed, wrong-scheme, or wrong-token headers deterministically when a route policy requires authorization, but keep public primitives named around local client authorization rather than HTTP-only bearer auth. + - Rationale: bearer tokens match OpenAI-compatible clients and common Rust proxy examples, while neutral naming leaves room for future Unix-socket, IPC, or desktop-mediated authorization. + - Alternative considered: custom `x-api-key` only. It is common, but less compatible with OpenAI-style local clients. + +4. **Keep `/health` stable and unauthenticated unless a later change explicitly reclassifies it.** + - Decision: `/health` remains the smoke-test endpoint and does not become a protected management endpoint in this change. + - Rationale: existing specs and tests rely on `/health` as a low-friction local runtime check. Future richer management/status/control routes can be protected without breaking smoke checks. + - Alternative considered: protect all non-unsupported routes. That would be stricter, but it would change the established health contract unnecessarily. + +5. **Use deterministic local tests instead of real network or provider calls.** + - Decision: tests should exercise the current local runtime and mock provider execution path, including ensuring local client authorization is not exposed through provider credentials or status output. + - Rationale: this preserves the headless core boundary and keeps CI independent from secrets, provider SDKs, OAuth, and external services. + +6. **Make protection policy states explicit and fail-safe.** + - Decision: model each protected scope as disabled or required. Disabled means the route does not require local client authorization. Required means a configured local credential must exist and the request must present a matching bearer token; if the credential is missing from configuration, the protected route fails closed with a deterministic unauthorized/configuration outcome rather than allowing access. + - Rationale: explicit states avoid accidental open access when a maintainer enables protection but omits the credential. + - Alternative considered: infer defaults from the presence or absence of a token. That is simpler but makes misconfiguration indistinguishable from intentionally disabled protection. + +7. **Reserve a deterministic management boundary without creating a remote management API.** + - Decision: `/v0/management/*` is classified and authorized in this change, but a valid authorized request returns a deterministic placeholder/boundary response unless a later OpenSpec change defines concrete management operations. + - Rationale: issue #18 needs a testable management authorization boundary now, but the project explicitly does not want a remote management web panel or broad mutable API in this change. + - Alternative considered: implement a real management status endpoint immediately. That would exceed the issue scope and could create API commitments before management operations are designed. + +## Risks / Trade-offs + +- **Risk: Local auth tokens could appear in debug output or errors.** → Mitigation: make secret-bearing values non-secret by design where possible, expose redacted metadata only, and add tests for debug/display/status surfaces. +- **Risk: Route categories overfit the current minimal runtime.** → Mitigation: define categories broadly enough for future CLI/IDE management clients while implementing only minimal route behavior now. +- **Risk: Management route tests require a route before real management HTTP APIs exist.** → Mitigation: add a deterministic placeholder management/status/control route classification and authorization response without claiming a full remote management API. +- **Risk: Bearer-only HTTP parsing could limit future clients.** → Mitigation: public primitives remain transport-neutral; bearer parsing is only the current local HTTP adapter behavior. +- **Risk: Required protection with missing configuration could accidentally permit access.** → Mitigation: fail closed and expose a structured configuration/unauthorized outcome without including secret values. + +## Migration Plan + +This is additive. Existing `/health` behavior and unsupported-path behavior remain stable. Existing `POST /v1/chat/completions` tests should be updated to include the configured valid local client authorization when inference auth is enabled, while tests can also cover disabled authorization for compatibility where useful. + +Rollback is straightforward because the change should not introduce external services or persisted migrations: remove the local authorization configuration/primitives, restore the direct route dispatch behavior, and retain existing health/minimal proxy tests. + +## Open Questions + +- Should a later compatibility change add `x-api-key` parsing in addition to bearer authorization after the initial bearer-only HTTP adapter behavior lands? diff --git a/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/proposal.md b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/proposal.md new file mode 100644 index 0000000..88b141e --- /dev/null +++ b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/proposal.md @@ -0,0 +1,29 @@ +## Why + +The local proxy now has a loopback health endpoint and a minimal OpenAI-compatible inference route, but it does not yet define which local clients are allowed to call inference or future management/status/control routes. This change establishes the `oxmux` security and route-boundary contract before real provider adapters, account controls, and desktop/CLI management clients build on the runtime. + +## What Changes + +- Add local client authorization primitives to `oxmux` for representing a caller-owned local API key or equivalent authorization requirement without exposing provider credentials. +- Split local runtime route policy into inference and management/status/control categories so each category can require independent authorization decisions. +- Keep `GET /health` available as a stable local smoke endpoint while documenting how protected management/status/control routes differ from unauthenticated health checks. +- Add deterministic authorized and unauthorized request tests for inference and management/status/control paths. +- Preserve loopback-only defaults and avoid Amp-specific URL rewriting, remote management panels, OAuth flows, and provider fallback behavior. + +## Capabilities + +### New Capabilities + +- `oxmux-local-client-auth`: Local client authorization primitives, redaction rules, and request authorization outcomes for loopback clients. + +### Modified Capabilities + +- `oxmux-core`: The public facade exposes local client authorization and route-boundary primitives as headless `oxmux` core concerns. +- `oxmux-local-proxy-runtime`: The local runtime distinguishes health, inference, and management/status/control route categories and applies configured authorization decisions. +- `oxmux-management-lifecycle`: Management/status/control access is represented separately from inference access so app and future CLI/IDE clients can reason about protected management surfaces. + +## Impact + +- Affected code: `crates/oxmux/src/local_proxy_runtime.rs`, `crates/oxmux/src/oxmux.rs`, likely a focused `oxmux` auth module, runtime tests, and public API documentation. +- Affected specs: `oxmux-core`, `oxmux-local-proxy-runtime`, `oxmux-management-lifecycle`, plus new `oxmux-local-client-auth` capability. +- No app-shell, GPUI, OAuth UI, platform credential storage, remote management web panel, provider fallback, or provider credential resolution work is included. diff --git a/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-core/spec.md b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-core/spec.md new file mode 100644 index 0000000..b620b2e --- /dev/null +++ b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-core/spec.md @@ -0,0 +1,32 @@ +## MODIFIED Requirements + +### Requirement: Minimal public facade for future core domains +The `oxmux` crate SHALL expose a small public facade that establishes ownership of proxy lifecycle, local health runtime, local client authorization, provider/auth, provider execution, routing, protocol translation, configuration, streaming, management/status, usage/quota, domain error primitives, and a minimal concrete proxy request smoke path without implementing full provider SDK integration, outbound provider calls, credential storage, full proxy request handling, remote management panels, or real streaming transport adapters in this change. + +#### Scenario: Provider auth ownership is visible but not implemented +- **WHEN** maintainers inspect the `oxmux` public API or documentation +- **THEN** provider authentication and token refresh are identified as future core concerns without requiring OAuth UI, platform credential storage, or concrete provider clients in this phase + +#### Scenario: Local client authorization ownership is visible +- **WHEN** maintainers inspect the `oxmux` public API or documentation after adding local client authorization boundaries +- **THEN** local proxy client authorization, inference access, management/status/control access, redacted local client credential metadata, and structured unauthorized outcomes are represented as headless core concerns without requiring GPUI, desktop credential storage, OAuth UI, provider SDKs, or app-shell state + +#### Scenario: Provider execution ownership exposes deterministic mock boundaries +- **WHEN** maintainers inspect the `oxmux` public API or documentation after adding provider execution primitives +- **THEN** provider execution is represented by trait, request, result, mock harness, and structured outcome primitives that can be used in deterministic tests without requiring real provider SDKs, HTTP clients, OAuth, platform credential storage, GPUI, or app-shell state + +#### Scenario: Routing ownership exposes typed policy primitives +- **WHEN** maintainers inspect the `oxmux` public API or documentation after adding routing policy primitives +- **THEN** model aliases, account targeting, priority, fallback, exhausted states, degraded states, selection outcomes, skipped candidate metadata, and routing failure details are represented by typed public primitives and exercised by the minimal smoke route without requiring full proxy routing behavior, provider SDKs, outbound provider calls, GPUI, or app-shell state + +#### Scenario: Protocol ownership exposes typed skeleton boundaries +- **WHEN** maintainers inspect the `oxmux` public API or documentation +- **THEN** OpenAI, Gemini, Claude, Codex, and provider-specific protocol translation are represented by typed request/response boundaries, typed protocol metadata, and deferred translation results while the minimal smoke route may construct an OpenAI canonical request without requiring full request translators, response translators, or outbound provider calls in this phase + +#### Scenario: Streaming ownership exposes typed response primitives +- **WHEN** maintainers inspect the `oxmux` public API or documentation after adding streaming response primitives +- **THEN** response mode, complete responses, ordered stream events, in-sequence terminal events, stream completion, stream cancellation, stream errors, streaming failure details, and deterministic mock stream outcomes are represented by typed public primitives without requiring network transports, provider stream adapters, provider SDKs, outbound provider calls, GPUI, or app-shell state + +#### Scenario: Management ownership includes local health runtime status +- **WHEN** maintainers inspect the `oxmux` public API or documentation +- **THEN** proxy lifecycle state, local health runtime status, provider listing, account health, usage, quota, degraded service status, and protected management/status/control route boundaries are identified as core concerns while full remote management panels remain deferred diff --git a/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-local-client-auth/spec.md b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-local-client-auth/spec.md new file mode 100644 index 0000000..ae1c1f4 --- /dev/null +++ b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-local-client-auth/spec.md @@ -0,0 +1,58 @@ +## ADDED Requirements + +### Requirement: Core represents local client authorization +The `oxmux` crate SHALL define local client authorization primitives for loopback clients that are separate from provider credentials and safe to expose through redacted metadata, structured outcomes, and tests. + +#### Scenario: Local client credential is not a provider credential +- **WHEN** a local client credential is configured for access to the local proxy runtime +- **THEN** `oxmux` represents it as local client authorization state rather than as a provider API key, OAuth token, provider credential reference, or platform credential storage handle + +#### Scenario: Authorization metadata is redacted +- **WHEN** local client authorization configuration, status, errors, debug output, or display text is inspected +- **THEN** raw local client secrets are not exposed and only redacted metadata or structured authorization state is visible + +#### Scenario: Bearer authorization is accepted for protected HTTP routes +- **WHEN** a protected local HTTP route receives an `Authorization: Bearer ` header whose token matches the configured local client credential for that route scope +- **THEN** `oxmux` treats the request as locally authorized for that scope without representing the token as a provider credential + +#### Scenario: Missing local authorization is structured +- **WHEN** a protected local route receives no local client authorization credential +- **THEN** `oxmux` returns a deterministic unauthorized outcome that callers can inspect without parsing display text + +#### Scenario: Malformed local authorization is structured +- **WHEN** a protected local HTTP route receives an authorization header with a missing token, unsupported scheme, malformed bearer value, or otherwise invalid header shape +- **THEN** `oxmux` returns a deterministic unauthorized outcome that does not reveal the expected credential value + +#### Scenario: Invalid local authorization is structured +- **WHEN** a protected local route receives an invalid local client authorization credential +- **THEN** `oxmux` returns a deterministic unauthorized outcome that does not reveal the expected credential value + +### Requirement: Core defines fail-safe authorization policies +The `oxmux` local client authorization model SHALL define explicit policy states for disabled and required route protection, and required protection SHALL fail closed when no expected local credential is configured. + +#### Scenario: Disabled protection does not require local authorization +- **WHEN** local authorization policy is disabled for a route scope +- **THEN** `oxmux` does not require an `Authorization` header for that scope while still preserving route classification and loopback-only runtime behavior + +#### Scenario: Required protection accepts only matching credentials +- **WHEN** local authorization policy is required for a route scope +- **THEN** `oxmux` authorizes only requests with a matching local client credential for that scope and rejects missing, malformed, or mismatched credentials deterministically + +#### Scenario: Missing configured credential fails closed +- **WHEN** local authorization policy is required for a route scope but no expected local credential is configured +- **THEN** `oxmux` rejects protected requests for that scope with a deterministic unauthorized or configuration outcome rather than allowing access + +### Requirement: Core distinguishes route authorization scopes +The `oxmux` local client authorization model SHALL distinguish inference access from management/status/control access so future CLI, IDE, and app-shell clients can be authorized without Amp-specific coupling. + +#### Scenario: Inference access can be authorized independently +- **WHEN** a local client is authorized for inference access but not management/status/control access +- **THEN** `oxmux` can allow protected inference routes while rejecting protected management/status/control routes with structured unauthorized responses + +#### Scenario: Management access can be authorized independently +- **WHEN** a local client is authorized for management/status/control access but not inference access +- **THEN** `oxmux` can allow protected management/status/control routes while rejecting protected inference routes with structured unauthorized responses + +#### Scenario: Authorization scopes remain client-generic +- **WHEN** maintainers inspect the local client authorization API +- **THEN** scope names and outcomes are generic to local proxy clients and do not mention Amp-specific URL rewriting, provider fallback, GPUI views, or desktop-only concepts diff --git a/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-local-proxy-runtime/spec.md b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-local-proxy-runtime/spec.md new file mode 100644 index 0000000..3b05b0b --- /dev/null +++ b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-local-proxy-runtime/spec.md @@ -0,0 +1,70 @@ +## MODIFIED Requirements + +### Requirement: Runtime dispatches minimal proxy route +The `oxmux` local runtime SHALL preserve the stable health endpoint while also classifying loopback requests by route category and dispatching a bounded loopback `POST /v1/chat/completions` request to the minimal proxy engine path when configured with deterministic core proxy inputs and valid local client authorization when that route is protected. + +#### Scenario: Runtime classifies local route categories explicitly +- **WHEN** the runtime receives a loopback request +- **THEN** it classifies `GET /health` as health, `POST /v1/chat/completions` as inference, `/v0/management/*` as management/status/control, and any other method/path as unsupported before applying route behavior + +#### Scenario: Health endpoint remains stable +- **WHEN** a client sends `GET /health` to a running local runtime after local client authorization boundaries are added +- **THEN** the runtime still returns the stable health response defined for local health smoke testing without requiring provider, OAuth, quota, credential, GPUI, app-shell, or local client authorization state + +#### Scenario: Authorized chat-completion route is dispatched on loopback runtime +- **WHEN** a client sends a valid minimal `POST /v1/chat/completions` request with valid configured local inference authorization to a running loopback runtime configured for mock-backed proxy execution +- **THEN** the runtime dispatches the request to the `oxmux` minimal proxy engine and returns the serialized engine response over the local HTTP connection + +#### Scenario: Unauthorized chat-completion route is rejected before proxy execution +- **WHEN** a client sends `POST /v1/chat/completions` without valid local inference authorization and the route is configured as protected +- **THEN** the runtime returns a deterministic unauthorized response without invoking routing or provider execution and without exposing expected credential values + +#### Scenario: Management route authorization is distinct from inference authorization +- **WHEN** a client sends a `/v0/management/*` request with only inference authorization +- **THEN** the runtime rejects the request with a deterministic unauthorized response rather than treating it as an inference request or a health check + +#### Scenario: Authorized management boundary returns deterministic placeholder response +- **WHEN** a client sends a `/v0/management/*` request with valid configured management/status/control authorization before a concrete management operation is defined +- **THEN** the runtime returns a deterministic protected-boundary response that proves authorization and classification succeeded without invoking inference routing, provider execution, OAuth, platform credential storage, or a remote management panel + +#### Scenario: Unauthorized management boundary is rejected +- **WHEN** a client sends a `/v0/management/*` request without valid configured management/status/control authorization +- **THEN** the runtime returns a deterministic unauthorized response without exposing expected credential values and without invoking inference routing or provider execution + +#### Scenario: Runtime rejects unsupported local requests deterministically +- **WHEN** a client sends a local request whose method or path is neither `GET /health`, `POST /v1/chat/completions`, nor `/v0/management/*` +- **THEN** the runtime returns a deterministic unsupported-path response without reporting health success, authorization success, management success, or proxy execution success + +### Requirement: Runtime request parsing is bounded and local-only +The `oxmux` local runtime SHALL parse only the bounded local HTTP request data needed for the health endpoint, local client authorization, route classification, minimal chat-completion smoke route, and `/v0/management/*` route boundaries, and SHALL reject malformed or oversized requests with deterministic failures instead of panicking or reading unbounded input. + +#### Scenario: Malformed local proxy request is rejected +- **WHEN** a loopback client sends malformed request-line, header, body, local authorization, or content data for a local runtime route +- **THEN** the runtime returns a deterministic invalid-request response and keeps the listener usable for later valid requests + +#### Scenario: Runtime parses local authorization without retaining unrelated headers +- **WHEN** a loopback client sends a local request with authorization and other headers +- **THEN** the runtime retains only the bounded header data needed for content length and local client authorization decisions and does not expose raw authorization values through status, debug, display, or provider execution surfaces + +#### Scenario: Runtime remains loopback-only +- **WHEN** runtime configuration requests a non-loopback listen address after local client authorization boundaries are added +- **THEN** `oxmux` still returns a structured local runtime configuration error instead of binding a public network interface + +#### Scenario: Runtime avoids desktop and provider-network dependencies +- **WHEN** maintainers inspect or run local runtime tests for local client authorization and route boundaries +- **THEN** the runtime remains independent from GPUI, tray/background lifecycle, updater, packaging, OAuth UI, token refresh, raw credential storage, provider SDKs, real provider accounts, and outbound provider network calls + +#### Scenario: Management boundary remains local and side-effect-free +- **WHEN** maintainers inspect or exercise `/v0/management/*` behavior in this change +- **THEN** the runtime keeps the boundary loopback-only, non-HTML, non-provider-credential-bearing, side-effect-free, and independent from remote management panels until a later OpenSpec change defines concrete management operations + +### Requirement: Runtime exposes stable health endpoint +The system SHALL expose a stable health endpoint suitable for smoke testing the minimal local runtime, and adding local client authorization boundaries SHALL NOT change the health response contract or make health checks depend on provider, routing, OAuth, quota, credential, local client authorization, GPUI, or app-shell state. + +#### Scenario: Health request succeeds +- **WHEN** a client sends `GET /health` to a running local runtime after local client authorization boundaries are added +- **THEN** the runtime returns the same successful HTTP response with stable content indicating the runtime is healthy + +#### Scenario: Unknown path does not masquerade as health +- **WHEN** a client requests a path other than `GET /health`, `POST /v1/chat/completions`, or `/v0/management/*` +- **THEN** the runtime returns a deterministic non-health response that does not report a healthy smoke-test result, authorization success, management success, or proxy execution success diff --git a/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-management-lifecycle/spec.md b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-management-lifecycle/spec.md new file mode 100644 index 0000000..4a6de06 --- /dev/null +++ b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-management-lifecycle/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Core exposes management snapshot +The system SHALL provide an `oxmux` management snapshot that represents app-visible core state and can reflect the minimal local health runtime, deterministic mock provider execution health, and protected local management/status/control route boundary metadata without requiring a running desktop app, GPUI window, IPC process, external provider call, OAuth flow, routing engine, network-backed quota fetch, or platform credential storage. + +#### Scenario: Snapshot can be constructed directly +- **WHEN** Rust code depends on `oxmux` and constructs the management snapshot from in-memory values +- **THEN** it can inspect core identity, lifecycle state, health state, configuration summary, provider/account summaries, usage/quota summaries, local management boundary metadata, warnings, and errors without launching `oxidemux` + +#### Scenario: Snapshot reports degraded state +- **WHEN** one or more provider accounts, mock provider outcomes, configuration entries, local authorization checks, or lifecycle checks are degraded +- **THEN** the management snapshot exposes structured degraded reasons that the app shell can display without reimplementing degradation logic + +#### Scenario: Snapshot reflects failed mock provider state +- **WHEN** a deterministic mock provider execution outcome is failed +- **THEN** provider/account summaries and snapshot health data can expose the failed state through existing `ProviderSummary`, `AccountSummary`, `CoreHealthState`, warnings, and structured `CoreError` values without app-shell-specific copies + +#### Scenario: Snapshot reflects quota-limited mock provider state +- **WHEN** a deterministic mock provider execution outcome is quota-limited +- **THEN** provider/account summaries and snapshot quota data can expose that state through existing `QuotaState` and `QuotaSummary` values without adding a mock-only quota model + +#### Scenario: Snapshot reflects local runtime status +- **WHEN** the minimal local health runtime starts, fails to bind, runs, or shuts down +- **THEN** the management snapshot can expose the corresponding lifecycle state, bound endpoint metadata when available, and structured error data when startup fails + +#### Scenario: Snapshot does not expose local client secrets +- **WHEN** local client authorization is configured for inference or management/status/control access +- **THEN** the management snapshot can expose whether local route protection is configured and healthy without exposing raw local client authorization secrets diff --git a/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/tasks.md b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/tasks.md new file mode 100644 index 0000000..01faf18 --- /dev/null +++ b/openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/tasks.md @@ -0,0 +1,29 @@ +## 1. Local Client Authorization Primitives + +- [x] 1.1 Add `oxmux` local client authorization types for disabled and required protection policies, protected route scopes, configured credentials, redacted metadata, and structured authorization outcomes. +- [x] 1.2 Ensure local client authorization secrets are not exposed through `Debug`, `Display`, status values, management snapshots, or structured errors. +- [x] 1.3 Re-export the local client authorization primitives through the public `oxmux` facade without adding GPUI, platform credential storage, OAuth, provider SDK, or app-shell dependencies. + +## 2. Runtime Route Classification and Authorization + +- [x] 2.1 Add explicit local route classification for `GET /health`, `POST /v1/chat/completions`, `/v0/management/*`, and unsupported requests before route dispatch. +- [x] 2.2 Extend bounded local HTTP parsing to retain only the header data needed for content length and `Authorization: Bearer ` local client authorization decisions. +- [x] 2.3 Apply inference authorization before `POST /v1/chat/completions` invokes routing or provider execution when inference protection is configured. +- [x] 2.4 Add deterministic protected `/v0/management/*` boundary responses for authorized and unauthorized management/status/control requests without implementing a full remote management API. +- [x] 2.5 Preserve stable unauthenticated `GET /health`, loopback-only binding, bounded request limits, and deterministic unsupported-path behavior. + +## 3. Management and Error Surfaces + +- [x] 3.1 Add management/status metadata that can report local route protection configuration and authorization health without exposing local client secrets. +- [x] 3.2 Add structured unauthorized local route errors or response codes that callers can match without parsing display strings. +- [x] 3.3 Verify local client authorization is never forwarded to mock provider execution or represented as a provider credential reference. + +## 4. Tests and Verification + +- [x] 4.1 Add runtime tests for authorized and unauthorized `POST /v1/chat/completions` requests. +- [x] 4.2 Add runtime tests for authorized and unauthorized `/v0/management/*` boundary requests. +- [x] 4.3 Add runtime tests proving inference authorization does not grant management/status/control access, and management authorization does not grant inference access. +- [x] 4.4 Add bearer parsing tests for missing, malformed, wrong-scheme, wrong-token, and missing-configured-credential cases. +- [x] 4.5 Add redaction tests for local client authorization configuration, errors, and management/status surfaces. +- [x] 4.6 Update public API documentation tests as needed for newly exported `oxmux` types. +- [x] 4.7 Run `openspec validate --changes add-local-client-auth-management-api-boundary`, `cargo test -p oxmux`, and `mise run ci`. diff --git a/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/.openspec.yaml b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/.openspec.yaml new file mode 100644 index 0000000..5f23b85 --- /dev/null +++ b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-29 diff --git a/openspec/changes/adopt-production-readiness-workflow/design.md b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/design.md similarity index 100% rename from openspec/changes/adopt-production-readiness-workflow/design.md rename to openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/design.md diff --git a/openspec/changes/adopt-production-readiness-workflow/proposal.md b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/proposal.md similarity index 100% rename from openspec/changes/adopt-production-readiness-workflow/proposal.md rename to openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/proposal.md diff --git a/openspec/changes/adopt-production-readiness-workflow/specs/development-workflow/spec.md b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/specs/development-workflow/spec.md similarity index 100% rename from openspec/changes/adopt-production-readiness-workflow/specs/development-workflow/spec.md rename to openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/specs/development-workflow/spec.md diff --git a/openspec/changes/adopt-production-readiness-workflow/tasks.md b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/tasks.md similarity index 100% rename from openspec/changes/adopt-production-readiness-workflow/tasks.md rename to openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/tasks.md diff --git a/openspec/specs/oxmux-core/spec.md b/openspec/specs/oxmux-core/spec.md index d425862..e6fbd75 100644 --- a/openspec/specs/oxmux-core/spec.md +++ b/openspec/specs/oxmux-core/spec.md @@ -10,7 +10,6 @@ provider/account state, management snapshots, usage/quota state, and structured errors. Desktop shells and platform adapters provide UI and OS integrations, but they must not redefine those core semantics. ## Requirements - ### Requirement: Core facade exposes minimal proxy engine path The `oxmux` public facade SHALL expose the minimal proxy engine primitives needed for Rust consumers and tests to exercise a local OpenAI-compatible chat-completion smoke path through protocol, routing, provider execution, response mode handling, and structured errors without importing `oxidemux` or desktop-specific code. @@ -45,12 +44,16 @@ The system SHALL provide an `oxmux` Rust library crate that is usable without GP - **THEN** it can construct the public core facade and start, query, and shut down the minimal local health runtime without launching the `oxidemux` binary, opening a window, starting IPC, or contacting an external provider ### Requirement: Minimal public facade for future core domains -The `oxmux` crate SHALL expose a small public facade that establishes ownership of proxy lifecycle, local health runtime, provider/auth, provider execution, routing, protocol translation, configuration, streaming, management/status, usage/quota, domain error primitives, and a minimal concrete proxy request smoke path without implementing full provider SDK integration, outbound provider calls, credential storage, full proxy request handling, or real streaming transport adapters in this change. +The `oxmux` crate SHALL expose a small public facade that establishes ownership of proxy lifecycle, local health runtime, local client authorization, provider/auth, provider execution, routing, protocol translation, configuration, streaming, management/status, usage/quota, domain error primitives, and a minimal concrete proxy request smoke path without implementing full provider SDK integration, outbound provider calls, credential storage, full proxy request handling, remote management panels, or real streaming transport adapters in this change. #### Scenario: Provider auth ownership is visible but not implemented - **WHEN** maintainers inspect the `oxmux` public API or documentation - **THEN** provider authentication and token refresh are identified as future core concerns without requiring OAuth UI, platform credential storage, or concrete provider clients in this phase +#### Scenario: Local client authorization ownership is visible +- **WHEN** maintainers inspect the `oxmux` public API or documentation after adding local client authorization boundaries +- **THEN** local proxy client authorization, inference access, management/status/control access, redacted local client credential metadata, and structured unauthorized outcomes are represented as headless core concerns without requiring GPUI, desktop credential storage, OAuth UI, provider SDKs, or app-shell state + #### Scenario: Provider execution ownership exposes deterministic mock boundaries - **WHEN** maintainers inspect the `oxmux` public API or documentation after adding provider execution primitives - **THEN** provider execution is represented by trait, request, result, mock harness, and structured outcome primitives that can be used in deterministic tests without requiring real provider SDKs, HTTP clients, OAuth, platform credential storage, GPUI, or app-shell state @@ -69,7 +72,7 @@ The `oxmux` crate SHALL expose a small public facade that establishes ownership #### Scenario: Management ownership includes local health runtime status - **WHEN** maintainers inspect the `oxmux` public API or documentation -- **THEN** proxy lifecycle state, local health runtime status, provider listing, account health, usage, quota, and degraded service status are identified as core concerns while management endpoints beyond `/health` remain deferred +- **THEN** proxy lifecycle state, local health runtime status, provider listing, account health, usage, quota, degraded service status, and protected management/status/control route boundaries are identified as core concerns while full remote management panels remain deferred ### Requirement: Core owns subscription proxy semantics The `oxmux` crate SHALL own the reusable subscription-aware proxy semantics needed to normalize local AI requests, represent model aliases, expose reasoning/thinking request compatibility primitives, accept app-supplied provider/account availability, route requests through deterministic policy, and return structured outcomes without depending on GPUI, tray/menu libraries, OAuth UI, platform credential storage, provider SDKs, or the `oxidemux` app shell. @@ -182,3 +185,4 @@ Reload outcomes SHALL distinguish at least unchanged, replaced, and rejected can #### Scenario: Consumer reports rejected outcome - **WHEN** a layered reload hook returns a rejected outcome - **THEN** a Rust, CLI, or app-shell consumer can display structured candidate diagnostics while keeping the previous active runtime state visible + diff --git a/openspec/specs/oxmux-local-client-auth/spec.md b/openspec/specs/oxmux-local-client-auth/spec.md new file mode 100644 index 0000000..c0b6dc9 --- /dev/null +++ b/openspec/specs/oxmux-local-client-auth/spec.md @@ -0,0 +1,62 @@ +# oxmux-local-client-auth Specification + +## Purpose +TBD - created by archiving change add-local-client-auth-management-api-boundary. Update Purpose after archive. +## Requirements +### Requirement: Core represents local client authorization +The `oxmux` crate SHALL define local client authorization primitives for loopback clients that are separate from provider credentials and safe to expose through redacted metadata, structured outcomes, and tests. + +#### Scenario: Local client credential is not a provider credential +- **WHEN** a local client credential is configured for access to the local proxy runtime +- **THEN** `oxmux` represents it as local client authorization state rather than as a provider API key, OAuth token, provider credential reference, or platform credential storage handle + +#### Scenario: Authorization metadata is redacted +- **WHEN** local client authorization configuration, status, errors, debug output, or display text is inspected +- **THEN** raw local client secrets are not exposed and only redacted metadata or structured authorization state is visible + +#### Scenario: Bearer authorization is accepted for protected HTTP routes +- **WHEN** a protected local HTTP route receives an `Authorization: Bearer ` header whose token matches the configured local client credential for that route scope +- **THEN** `oxmux` treats the request as locally authorized for that scope without representing the token as a provider credential + +#### Scenario: Missing local authorization is structured +- **WHEN** a protected local route receives no local client authorization credential +- **THEN** `oxmux` returns a deterministic unauthorized outcome that callers can inspect without parsing display text + +#### Scenario: Malformed local authorization is structured +- **WHEN** a protected local HTTP route receives an authorization header with a missing token, unsupported scheme, malformed bearer value, or otherwise invalid header shape +- **THEN** `oxmux` returns a deterministic unauthorized outcome that does not reveal the expected credential value + +#### Scenario: Invalid local authorization is structured +- **WHEN** a protected local route receives an invalid local client authorization credential +- **THEN** `oxmux` returns a deterministic unauthorized outcome that does not reveal the expected credential value + +### Requirement: Core defines fail-safe authorization policies +The `oxmux` local client authorization model SHALL define explicit policy states for disabled and required route protection, and required protection SHALL fail closed when no expected local credential is configured. + +#### Scenario: Disabled protection does not require local authorization +- **WHEN** local authorization policy is disabled for a route scope +- **THEN** `oxmux` does not require an `Authorization` header for that scope while still preserving route classification and loopback-only runtime behavior + +#### Scenario: Required protection accepts only matching credentials +- **WHEN** local authorization policy is required for a route scope +- **THEN** `oxmux` authorizes only requests with a matching local client credential for that scope and rejects missing, malformed, or mismatched credentials deterministically + +#### Scenario: Missing configured credential fails closed +- **WHEN** local authorization policy is required for a route scope but no expected local credential is configured +- **THEN** `oxmux` rejects protected requests for that scope with a deterministic unauthorized or configuration outcome rather than allowing access + +### Requirement: Core distinguishes route authorization scopes +The `oxmux` local client authorization model SHALL distinguish inference access from management/status/control access so future CLI, IDE, and app-shell clients can be authorized without Amp-specific coupling. + +#### Scenario: Inference access can be authorized independently +- **WHEN** a local client is authorized for inference access but not management/status/control access +- **THEN** `oxmux` can allow protected inference routes while rejecting protected management/status/control routes with structured unauthorized responses + +#### Scenario: Management access can be authorized independently +- **WHEN** a local client is authorized for management/status/control access but not inference access +- **THEN** `oxmux` can allow protected management/status/control routes while rejecting protected inference routes with structured unauthorized responses + +#### Scenario: Authorization scopes remain client-generic +- **WHEN** maintainers inspect the local client authorization API +- **THEN** scope names and outcomes are generic to local proxy clients and do not mention Amp-specific URL rewriting, provider fallback, GPUI views, or desktop-only concepts + diff --git a/openspec/specs/oxmux-local-proxy-runtime/spec.md b/openspec/specs/oxmux-local-proxy-runtime/spec.md index 2d9df72..fc5b943 100644 --- a/openspec/specs/oxmux-local-proxy-runtime/spec.md +++ b/openspec/specs/oxmux-local-proxy-runtime/spec.md @@ -1,39 +1,64 @@ ## Purpose Define the `oxmux` local proxy health runtime used for loopback-only smoke testing of the reusable core without desktop, provider, routing, or app-shell dependencies. - ## Requirements - ### Requirement: Runtime dispatches minimal proxy route -The `oxmux` local runtime SHALL preserve the stable health endpoint while also dispatching a bounded loopback `POST /v1/chat/completions` request to the minimal proxy engine path when configured with deterministic core proxy inputs. +The `oxmux` local runtime SHALL preserve the stable health endpoint while also classifying loopback requests by route category and dispatching a bounded loopback `POST /v1/chat/completions` request to the minimal proxy engine path when configured with deterministic core proxy inputs and valid local client authorization when that route is protected. + +#### Scenario: Runtime classifies local route categories explicitly +- **WHEN** the runtime receives a loopback request +- **THEN** it classifies `GET /health` as health, `POST /v1/chat/completions` as inference, `/v0/management/*` as management/status/control, and any other method/path as unsupported before applying route behavior #### Scenario: Health endpoint remains stable -- **WHEN** a client sends `GET /health` to a running local runtime after the minimal proxy route is added -- **THEN** the runtime still returns the stable health response defined for local health smoke testing +- **WHEN** a client sends `GET /health` to a running local runtime after local client authorization boundaries are added +- **THEN** the runtime still returns the stable health response defined for local health smoke testing without requiring provider, OAuth, quota, credential, GPUI, app-shell, or local client authorization state -#### Scenario: Chat-completion route is dispatched on loopback runtime -- **WHEN** a client sends a valid minimal `POST /v1/chat/completions` request to a running loopback runtime configured for mock-backed proxy execution +#### Scenario: Authorized chat-completion route is dispatched on loopback runtime +- **WHEN** a client sends a valid minimal `POST /v1/chat/completions` request with valid configured local inference authorization to a running loopback runtime configured for mock-backed proxy execution - **THEN** the runtime dispatches the request to the `oxmux` minimal proxy engine and returns the serialized engine response over the local HTTP connection +#### Scenario: Unauthorized chat-completion route is rejected before proxy execution +- **WHEN** a client sends `POST /v1/chat/completions` without valid local inference authorization and the route is configured as protected +- **THEN** the runtime returns a deterministic unauthorized response without invoking routing or provider execution and without exposing expected credential values + +#### Scenario: Management route authorization is distinct from inference authorization +- **WHEN** a client sends a `/v0/management/*` request with only inference authorization +- **THEN** the runtime rejects the request with a deterministic unauthorized response rather than treating it as an inference request or a health check + +#### Scenario: Authorized management boundary returns deterministic placeholder response +- **WHEN** a client sends a `/v0/management/*` request with valid configured management/status/control authorization before a concrete management operation is defined +- **THEN** the runtime returns a deterministic protected-boundary response that proves authorization and classification succeeded without invoking inference routing, provider execution, OAuth, platform credential storage, or a remote management panel + +#### Scenario: Unauthorized management boundary is rejected +- **WHEN** a client sends a `/v0/management/*` request without valid configured management/status/control authorization +- **THEN** the runtime returns a deterministic unauthorized response without exposing expected credential values and without invoking inference routing or provider execution + #### Scenario: Runtime rejects unsupported local requests deterministically -- **WHEN** a client sends a local request whose method or path is neither the health endpoint nor the supported minimal chat-completion route -- **THEN** the runtime returns a deterministic unsupported-path response without reporting health success or proxy execution success +- **WHEN** a client sends a local request whose method or path is neither `GET /health`, `POST /v1/chat/completions`, nor `/v0/management/*` +- **THEN** the runtime returns a deterministic unsupported-path response without reporting health success, authorization success, management success, or proxy execution success ### Requirement: Runtime request parsing is bounded and local-only -The `oxmux` local runtime SHALL parse only the bounded local HTTP request data needed for the health endpoint and minimal chat-completion smoke route, and SHALL reject malformed or oversized requests with deterministic failures instead of panicking or reading unbounded input. +The `oxmux` local runtime SHALL parse only the bounded local HTTP request data needed for the health endpoint, local client authorization, route classification, minimal chat-completion smoke route, and `/v0/management/*` route boundaries, and SHALL reject malformed or oversized requests with deterministic failures instead of panicking or reading unbounded input. #### Scenario: Malformed local proxy request is rejected -- **WHEN** a loopback client sends malformed request-line, header, body, or content data for the minimal chat-completion route +- **WHEN** a loopback client sends malformed request-line, header, body, local authorization, or content data for a local runtime route - **THEN** the runtime returns a deterministic invalid-request response and keeps the listener usable for later valid requests +#### Scenario: Runtime parses local authorization without retaining unrelated headers +- **WHEN** a loopback client sends a local request with authorization and other headers +- **THEN** the runtime retains only the bounded header data needed for content length and local client authorization decisions and does not expose raw authorization values through status, debug, display, or provider execution surfaces + #### Scenario: Runtime remains loopback-only -- **WHEN** runtime configuration requests a non-loopback listen address after the minimal proxy route is added +- **WHEN** runtime configuration requests a non-loopback listen address after local client authorization boundaries are added - **THEN** `oxmux` still returns a structured local runtime configuration error instead of binding a public network interface #### Scenario: Runtime avoids desktop and provider-network dependencies -- **WHEN** maintainers inspect or run local runtime tests for the minimal proxy route +- **WHEN** maintainers inspect or run local runtime tests for local client authorization and route boundaries - **THEN** the runtime remains independent from GPUI, tray/background lifecycle, updater, packaging, OAuth UI, token refresh, raw credential storage, provider SDKs, real provider accounts, and outbound provider network calls +#### Scenario: Management boundary remains local and side-effect-free +- **WHEN** maintainers inspect or exercise `/v0/management/*` behavior in this change +- **THEN** the runtime keeps the boundary loopback-only, non-HTML, non-provider-credential-bearing, side-effect-free, and independent from remote management panels until a later OpenSpec change defines concrete management operations ### Requirement: Core starts local health runtime The system SHALL provide an `oxmux` local proxy health runtime that binds a configurable loopback HTTP endpoint from deterministic configuration without launching `oxidemux` or requiring external providers. @@ -47,15 +72,15 @@ The system SHALL provide an `oxmux` local proxy health runtime that binds a conf - **THEN** `oxmux` returns a structured configuration or lifecycle error instead of binding a public network interface ### Requirement: Runtime exposes stable health endpoint -The system SHALL expose a stable health endpoint suitable for smoke testing the minimal local runtime, and adding the minimal proxy route SHALL NOT change the health response contract or make health checks depend on provider, routing, OAuth, quota, credential, GPUI, or app-shell state. +The system SHALL expose a stable health endpoint suitable for smoke testing the minimal local runtime, and adding local client authorization boundaries SHALL NOT change the health response contract or make health checks depend on provider, routing, OAuth, quota, credential, local client authorization, GPUI, or app-shell state. #### Scenario: Health request succeeds -- **WHEN** a client sends `GET /health` to a running local runtime after the minimal proxy route is added +- **WHEN** a client sends `GET /health` to a running local runtime after local client authorization boundaries are added - **THEN** the runtime returns the same successful HTTP response with stable content indicating the runtime is healthy #### Scenario: Unknown path does not masquerade as health -- **WHEN** a client requests a path other than the supported health endpoint or minimal chat-completion smoke route -- **THEN** the runtime returns a deterministic non-health response that does not report a healthy smoke-test result or proxy execution success +- **WHEN** a client requests a path other than `GET /health`, `POST /v1/chat/completions`, or `/v0/management/*` +- **THEN** the runtime returns a deterministic non-health response that does not report a healthy smoke-test result, authorization success, management success, or proxy execution success ### Requirement: Runtime reports lifecycle transitions The system SHALL report local runtime startup, running, failure, shutdown, and stopped status through typed `oxmux` lifecycle facade states. @@ -82,3 +107,4 @@ The system SHALL keep the local runtime independent from real provider transport #### Scenario: Core dependency boundary remains intact - **WHEN** maintainers inspect `crates/oxmux/Cargo.toml` after adding the health runtime - **THEN** `oxmux` still has no dependency on `oxidemux`, GPUI, gpui-component, tray libraries, updater libraries, packaging tools, provider SDKs, OAuth UI, platform credential storage libraries, or outbound provider HTTP client stacks required by real provider transports + diff --git a/openspec/specs/oxmux-management-lifecycle/spec.md b/openspec/specs/oxmux-management-lifecycle/spec.md index e5ac76a..6f5596e 100644 --- a/openspec/specs/oxmux-management-lifecycle/spec.md +++ b/openspec/specs/oxmux-management-lifecycle/spec.md @@ -3,14 +3,14 @@ Define the app-facing `oxmux` management, lifecycle, configuration, provider/account, and usage/quota facade that can be consumed without launching the desktop app or starting provider-backed proxy routing behavior. ## Requirements ### Requirement: Core exposes management snapshot -The system SHALL provide an `oxmux` management snapshot that represents app-visible core state and can reflect the minimal local health runtime and deterministic mock provider execution health without requiring a running desktop app, GPUI window, IPC process, external provider call, OAuth flow, routing engine, network-backed quota fetch, or platform credential storage. +The system SHALL provide an `oxmux` management snapshot that represents app-visible core state and can reflect the minimal local health runtime, deterministic mock provider execution health, and protected local management/status/control route boundary metadata without requiring a running desktop app, GPUI window, IPC process, external provider call, OAuth flow, routing engine, network-backed quota fetch, or platform credential storage. #### Scenario: Snapshot can be constructed directly - **WHEN** Rust code depends on `oxmux` and constructs the management snapshot from in-memory values -- **THEN** it can inspect core identity, lifecycle state, health state, configuration summary, provider/account summaries, usage/quota summaries, warnings, and errors without launching `oxidemux` +- **THEN** it can inspect core identity, lifecycle state, health state, configuration summary, provider/account summaries, usage/quota summaries, local management boundary metadata, warnings, and errors without launching `oxidemux` #### Scenario: Snapshot reports degraded state -- **WHEN** one or more provider accounts, mock provider outcomes, configuration entries, or lifecycle checks are degraded +- **WHEN** one or more provider accounts, mock provider outcomes, configuration entries, local authorization checks, or lifecycle checks are degraded - **THEN** the management snapshot exposes structured degraded reasons that the app shell can display without reimplementing degradation logic #### Scenario: Snapshot reflects failed mock provider state @@ -25,6 +25,10 @@ The system SHALL provide an `oxmux` management snapshot that represents app-visi - **WHEN** the minimal local health runtime starts, fails to bind, runs, or shuts down - **THEN** the management snapshot can expose the corresponding lifecycle state, bound endpoint metadata when available, and structured error data when startup fails +#### Scenario: Snapshot does not expose local client secrets +- **WHEN** local client authorization is configured for inference or management/status/control access +- **THEN** the management snapshot can expose whether local route protection is configured and healthy without exposing raw local client authorization secrets + ### Requirement: Core exposes proxy lifecycle state and control intents The system SHALL define typed proxy lifecycle states and control intents for start, stop, restart, and status refresh operations, and SHALL use those states to report the minimal local health runtime lifecycle without implementing provider-backed proxy routing in this change. @@ -158,3 +162,4 @@ Rejected layered candidates SHALL record candidate source metadata, candidate fi #### Scenario: Successful layered reload clears previous failure - **WHEN** a rejected layered reload is followed by a valid changed layered reload - **THEN** the management snapshot exposes the new active configuration and fingerprint and clears the previous failed layered reload diagnostics +