From c80e2cb8501144a4449b31f79cd1ddb8799ecbaa Mon Sep 17 00:00:00 2001 From: Brandon Martin Date: Wed, 29 Apr 2026 17:42:40 -0500 Subject: [PATCH 01/11] Add local client auth OpenSpec change --- .../design.md | 77 +++++++++++++++++++ .../proposal.md | 29 +++++++ .../specs/oxmux-core/spec.md | 32 ++++++++ .../specs/oxmux-local-client-auth/spec.md | 58 ++++++++++++++ .../specs/oxmux-local-proxy-runtime/spec.md | 70 +++++++++++++++++ .../specs/oxmux-management-lifecycle/spec.md | 28 +++++++ .../tasks.md | 29 +++++++ 7 files changed, 323 insertions(+) create mode 100644 openspec/changes/add-local-client-auth-management-api-boundary/design.md create mode 100644 openspec/changes/add-local-client-auth-management-api-boundary/proposal.md create mode 100644 openspec/changes/add-local-client-auth-management-api-boundary/specs/oxmux-core/spec.md create mode 100644 openspec/changes/add-local-client-auth-management-api-boundary/specs/oxmux-local-client-auth/spec.md create mode 100644 openspec/changes/add-local-client-auth-management-api-boundary/specs/oxmux-local-proxy-runtime/spec.md create mode 100644 openspec/changes/add-local-client-auth-management-api-boundary/specs/oxmux-management-lifecycle/spec.md create mode 100644 openspec/changes/add-local-client-auth-management-api-boundary/tasks.md diff --git a/openspec/changes/add-local-client-auth-management-api-boundary/design.md b/openspec/changes/add-local-client-auth-management-api-boundary/design.md new file mode 100644 index 0000000..0e1ed29 --- /dev/null +++ b/openspec/changes/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/add-local-client-auth-management-api-boundary/proposal.md b/openspec/changes/add-local-client-auth-management-api-boundary/proposal.md new file mode 100644 index 0000000..88b141e --- /dev/null +++ b/openspec/changes/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/add-local-client-auth-management-api-boundary/specs/oxmux-core/spec.md b/openspec/changes/add-local-client-auth-management-api-boundary/specs/oxmux-core/spec.md new file mode 100644 index 0000000..b620b2e --- /dev/null +++ b/openspec/changes/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/add-local-client-auth-management-api-boundary/specs/oxmux-local-client-auth/spec.md b/openspec/changes/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/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/add-local-client-auth-management-api-boundary/specs/oxmux-local-proxy-runtime/spec.md b/openspec/changes/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/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/add-local-client-auth-management-api-boundary/specs/oxmux-management-lifecycle/spec.md b/openspec/changes/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/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/add-local-client-auth-management-api-boundary/tasks.md b/openspec/changes/add-local-client-auth-management-api-boundary/tasks.md new file mode 100644 index 0000000..01faf18 --- /dev/null +++ b/openspec/changes/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`. From 2b7e3acbdc9b9c994ef9c010dd416192e5de04a3 Mon Sep 17 00:00:00 2001 From: Brandon Martin Date: Wed, 29 Apr 2026 17:42:41 -0500 Subject: [PATCH 02/11] oxmux: Add local client auth primitives --- crates/oxmux/src/local_client_auth.rs | 380 ++++++++++++++++++++++++++ crates/oxmux/src/oxmux.rs | 9 + 2 files changed, 389 insertions(+) create mode 100644 crates/oxmux/src/local_client_auth.rs diff --git a/crates/oxmux/src/local_client_auth.rs b/crates/oxmux/src/local_client_auth.rs new file mode 100644 index 0000000..014ea12 --- /dev/null +++ b/crates/oxmux/src/local_client_auth.rs @@ -0,0 +1,380 @@ +//! 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 { + self.secret == presented + } +} + +/// 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", + } + } +} 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, From 495e583438606dc62fd2a7190fe09a2b82c105ad Mon Sep 17 00:00:00 2001 From: Brandon Martin Date: Wed, 29 Apr 2026 17:43:09 -0500 Subject: [PATCH 03/11] oxmux: Apply local client auth boundary --- crates/oxmux/src/errors.rs | 10 +- crates/oxmux/src/local_proxy_runtime.rs | 144 ++++++++++++++++++++--- crates/oxmux/src/management.rs | 5 +- crates/oxmux/src/minimal_proxy.rs | 45 ++++++- crates/oxmux/tests/direct_use.rs | 61 +++++++++- crates/oxmux/tests/provider_execution.rs | 1 + 6 files changed, 238 insertions(+), 28 deletions(-) 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_proxy_runtime.rs b/crates/oxmux/src/local_proxy_runtime.rs index a5216cd..bebc1c2 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,7 @@ 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/"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] /// Loopback listen configuration for the local health runtime. @@ -82,6 +87,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 +102,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 +122,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 +133,7 @@ impl LocalHealthRuntimeStatus { lifecycle: ProxyLifecycleState::Starting, health: CoreHealthState::Healthy, endpoint: None, + local_route_protection: LocalRouteProtectionMetadata::disabled(), } } @@ -128,6 +145,7 @@ impl LocalHealthRuntimeStatus { }, health: CoreHealthState::Failed { error }, endpoint: None, + local_route_protection: LocalRouteProtectionMetadata::disabled(), } } @@ -137,6 +155,7 @@ impl LocalHealthRuntimeStatus { lifecycle: ProxyLifecycleState::Stopped, health: CoreHealthState::Healthy, endpoint, + local_route_protection: LocalRouteProtectionMetadata::disabled(), } } @@ -159,6 +178,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 +194,7 @@ pub struct LocalHealthRuntime { shutdown_requested: Arc, worker: Option>>, status: LocalHealthRuntimeStatus, + local_route_protection: LocalRouteProtectionMetadata, } impl std::fmt::Debug for LocalHealthRuntime { @@ -229,6 +250,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 +271,7 @@ impl LocalHealthRuntime { }, health: CoreHealthState::Healthy, endpoint: Some(endpoint), + local_route_protection, }; Ok(Self { @@ -256,6 +282,7 @@ impl LocalHealthRuntime { shutdown_requested, worker: Some(worker), status, + local_route_protection, }) } @@ -287,6 +314,7 @@ impl LocalHealthRuntime { }, health: CoreHealthState::Healthy, endpoint: Some(self.endpoint), + local_route_protection: self.local_route_protection, }, None => self.status.clone(), } @@ -312,7 +340,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 +434,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 +467,51 @@ 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, + (_, path) if path.starts_with(LOCAL_MANAGEMENT_PREFIX) => LocalRoute::Management, + _ => LocalRoute::Unsupported, + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct LocalHttpRequest { method: String, path: String, + authorization: LocalClientAuthorizationAttempt, body: Vec, } @@ -514,15 +586,17 @@ fn read_local_request(stream: &mut TcpStream) -> Result MAX_LOCAL_PROXY_REQUEST_BYTES { return Err(invalid_local_request( "body", @@ -556,10 +630,17 @@ fn read_local_request(stream: &mut TcpStream) -> Result Result { let mut request = Vec::new(); @@ -602,10 +683,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 +710,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,6 +794,7 @@ 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", 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/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/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(), }; From 716bba6cc4c06f042987b60ba3144e629cdb6bfb Mon Sep 17 00:00:00 2001 From: Brandon Martin Date: Wed, 29 Apr 2026 17:43:12 -0500 Subject: [PATCH 04/11] Test local route authorization boundaries --- crates/oxmux/tests/local_proxy_runtime.rs | 249 ++++++++++++++++++++-- 1 file changed, 234 insertions(+), 15 deletions(-) diff --git a/crates/oxmux/tests/local_proxy_runtime.rs b/crates/oxmux/tests/local_proxy_runtime.rs index 644ee98..aed0455 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,155 @@ 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(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(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(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(&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(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(r#""reason":"missing_configured_credential""#)); + + runtime.shutdown()?; + Ok(()) +} + #[test] fn malformed_chat_request_returns_400_and_runtime_keeps_serving() -> Result<(), Box> { @@ -297,7 +449,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 +472,13 @@ fn management_snapshot_reflects_runtime_status() -> Result<(), Box")); + assert!(!format!("{snapshot:?}").contains("snapshot-secret")); runtime.shutdown()?; Ok(()) @@ -338,17 +508,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 +562,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 +579,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 { From 5c53861709c7957e1765b7eceda6c395b983bedf Mon Sep 17 00:00:00 2001 From: Brandon Martin Date: Wed, 29 Apr 2026 17:43:14 -0500 Subject: [PATCH 05/11] Add local client auth OpenSpec metadata --- .../.openspec.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 openspec/changes/add-local-client-auth-management-api-boundary/.openspec.yaml diff --git a/openspec/changes/add-local-client-auth-management-api-boundary/.openspec.yaml b/openspec/changes/add-local-client-auth-management-api-boundary/.openspec.yaml new file mode 100644 index 0000000..5f23b85 --- /dev/null +++ b/openspec/changes/add-local-client-auth-management-api-boundary/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-29 From 69852b960fafbaa8a9d2c04ef67d016344a8afd6 Mon Sep 17 00:00:00 2001 From: Brandon Martin Date: Wed, 29 Apr 2026 20:03:39 -0500 Subject: [PATCH 06/11] Harden local client auth comparison --- crates/oxmux/src/local_client_auth.rs | 34 ++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/oxmux/src/local_client_auth.rs b/crates/oxmux/src/local_client_auth.rs index 014ea12..5157b80 100644 --- a/crates/oxmux/src/local_client_auth.rs +++ b/crates/oxmux/src/local_client_auth.rs @@ -51,10 +51,19 @@ impl LocalClientCredential { } pub(crate) fn matches(&self, presented: &str) -> bool { - self.secret == presented + 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 { @@ -378,3 +387,26 @@ impl LocalClientAuthorizationFailureReason { } } } + +#[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")); + } +} From f1c3134a82776bab5859180e4ecfdbe5bd774f72 Mon Sep 17 00:00:00 2001 From: Brandon Martin Date: Wed, 29 Apr 2026 20:03:41 -0500 Subject: [PATCH 07/11] Restrict management route methods --- crates/oxmux/src/local_proxy_runtime.rs | 6 +++++- crates/oxmux/tests/local_proxy_runtime.rs | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/oxmux/src/local_proxy_runtime.rs b/crates/oxmux/src/local_proxy_runtime.rs index bebc1c2..c841960 100644 --- a/crates/oxmux/src/local_proxy_runtime.rs +++ b/crates/oxmux/src/local_proxy_runtime.rs @@ -502,7 +502,11 @@ 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, - (_, path) if path.starts_with(LOCAL_MANAGEMENT_PREFIX) => LocalRoute::Management, + (method, path) + if path.starts_with(LOCAL_MANAGEMENT_PREFIX) && matches!(method, "GET" | "POST") => + { + LocalRoute::Management + } _ => LocalRoute::Unsupported, } } diff --git a/crates/oxmux/tests/local_proxy_runtime.rs b/crates/oxmux/tests/local_proxy_runtime.rs index aed0455..acdbddc 100644 --- a/crates/oxmux/tests/local_proxy_runtime.rs +++ b/crates/oxmux/tests/local_proxy_runtime.rs @@ -376,6 +376,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)))?; From d2f69f3fc00b0ad53f82478cc275f476e65dbc4f Mon Sep 17 00:00:00 2001 From: Brandon Martin Date: Wed, 29 Apr 2026 20:08:32 -0500 Subject: [PATCH 08/11] Add bearer challenge to local auth failures --- crates/oxmux/src/local_proxy_runtime.rs | 8 +++++++- crates/oxmux/tests/local_proxy_runtime.rs | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/oxmux/src/local_proxy_runtime.rs b/crates/oxmux/src/local_proxy_runtime.rs index c841960..c940283 100644 --- a/crates/oxmux/src/local_proxy_runtime.rs +++ b/crates/oxmux/src/local_proxy_runtime.rs @@ -31,6 +31,7 @@ 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. @@ -805,8 +806,13 @@ fn write_json_response( 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/tests/local_proxy_runtime.rs b/crates/oxmux/tests/local_proxy_runtime.rs index acdbddc..215ccdb 100644 --- a/crates/oxmux/tests/local_proxy_runtime.rs +++ b/crates/oxmux/tests/local_proxy_runtime.rs @@ -155,6 +155,7 @@ fn protected_chat_completion_requires_valid_inference_authorization() 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")); @@ -191,6 +192,7 @@ fn management_boundary_uses_distinct_authorization_scope() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Date: Wed, 29 Apr 2026 20:32:45 -0500 Subject: [PATCH 09/11] Archive local client auth OpenSpec change --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/oxmux-core/spec.md | 0 .../specs/oxmux-local-client-auth/spec.md | 0 .../specs/oxmux-local-proxy-runtime/spec.md | 0 .../specs/oxmux-management-lifecycle/spec.md | 0 .../tasks.md | 0 openspec/specs/oxmux-core/spec.md | 10 ++- .../specs/oxmux-local-client-auth/spec.md | 62 +++++++++++++++++++ .../specs/oxmux-local-proxy-runtime/spec.md | 60 +++++++++++++----- .../specs/oxmux-management-lifecycle/spec.md | 11 +++- 12 files changed, 120 insertions(+), 23 deletions(-) rename openspec/changes/{add-local-client-auth-management-api-boundary => archive/2026-04-30-add-local-client-auth-management-api-boundary}/.openspec.yaml (100%) rename openspec/changes/{add-local-client-auth-management-api-boundary => archive/2026-04-30-add-local-client-auth-management-api-boundary}/design.md (100%) rename openspec/changes/{add-local-client-auth-management-api-boundary => archive/2026-04-30-add-local-client-auth-management-api-boundary}/proposal.md (100%) rename openspec/changes/{add-local-client-auth-management-api-boundary => archive/2026-04-30-add-local-client-auth-management-api-boundary}/specs/oxmux-core/spec.md (100%) rename openspec/changes/{add-local-client-auth-management-api-boundary => archive/2026-04-30-add-local-client-auth-management-api-boundary}/specs/oxmux-local-client-auth/spec.md (100%) rename openspec/changes/{add-local-client-auth-management-api-boundary => archive/2026-04-30-add-local-client-auth-management-api-boundary}/specs/oxmux-local-proxy-runtime/spec.md (100%) rename openspec/changes/{add-local-client-auth-management-api-boundary => archive/2026-04-30-add-local-client-auth-management-api-boundary}/specs/oxmux-management-lifecycle/spec.md (100%) rename openspec/changes/{add-local-client-auth-management-api-boundary => archive/2026-04-30-add-local-client-auth-management-api-boundary}/tasks.md (100%) create mode 100644 openspec/specs/oxmux-local-client-auth/spec.md diff --git a/openspec/changes/add-local-client-auth-management-api-boundary/.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/add-local-client-auth-management-api-boundary/.openspec.yaml rename to openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/.openspec.yaml diff --git a/openspec/changes/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 similarity index 100% rename from openspec/changes/add-local-client-auth-management-api-boundary/design.md rename to openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/design.md diff --git a/openspec/changes/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 similarity index 100% rename from openspec/changes/add-local-client-auth-management-api-boundary/proposal.md rename to openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/proposal.md diff --git a/openspec/changes/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 similarity index 100% rename from openspec/changes/add-local-client-auth-management-api-boundary/specs/oxmux-core/spec.md rename to openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-core/spec.md diff --git a/openspec/changes/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 similarity index 100% rename from openspec/changes/add-local-client-auth-management-api-boundary/specs/oxmux-local-client-auth/spec.md rename to openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-local-client-auth/spec.md diff --git a/openspec/changes/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 similarity index 100% rename from openspec/changes/add-local-client-auth-management-api-boundary/specs/oxmux-local-proxy-runtime/spec.md rename to openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-local-proxy-runtime/spec.md diff --git a/openspec/changes/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 similarity index 100% rename from openspec/changes/add-local-client-auth-management-api-boundary/specs/oxmux-management-lifecycle/spec.md rename to openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/specs/oxmux-management-lifecycle/spec.md diff --git a/openspec/changes/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 similarity index 100% rename from openspec/changes/add-local-client-auth-management-api-boundary/tasks.md rename to openspec/changes/archive/2026-04-30-add-local-client-auth-management-api-boundary/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 + From f70cf2ca6b74abee6d27f2edb80e662efcb3f4eb Mon Sep 17 00:00:00 2001 From: Brandon Martin Date: Wed, 29 Apr 2026 20:32:45 -0500 Subject: [PATCH 10/11] Archive production readiness OpenSpec change --- .../.openspec.yaml | 2 + .../design.md | 83 ++++++++++++++++ .../proposal.md | 31 ++++++ .../specs/development-workflow/spec.md | 95 +++++++++++++++++++ .../tasks.md | 52 ++++++++++ 5 files changed, 263 insertions(+) create mode 100644 openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/design.md create mode 100644 openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/proposal.md create mode 100644 openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/specs/development-workflow/spec.md create mode 100644 openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/tasks.md 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/archive/2026-04-30-adopt-production-readiness-workflow/design.md b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/design.md new file mode 100644 index 0000000..c1f1f72 --- /dev/null +++ b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/design.md @@ -0,0 +1,83 @@ +## Context + +OxideMux already treats `mise.toml` as the canonical local and CI task graph. The current workflow runs formatting, checking, clippy, tests, rustdoc, hk checks, and OpenSpec PR evidence validation through mise-backed GitHub Actions. Issue #35 expands the same repository workflow layer to cover production-readiness patterns from `langfuse-rs`: security checks, supply-chain policy, and package metadata. + +The important constraint is that `langfuse-rs` is a reference, not a template. Its security and release workflows prove the desired shape, but OxideMux must keep tool installation and command selection centralized in mise. Release publishing and release workflow scaffolding are intentionally deferred until OxideMux has usable product behavior and a clear distribution milestone. + +## Goals / Non-Goals + +**Goals:** + +- Add a mise-defined security workflow that can run locally and in GitHub Actions. +- Add initial `cargo-deny` policy and `cargo-audit` vulnerability checks. +- Keep `cargo-vet` evaluated but non-blocking until audits and exemptions are maintainable. +- Centralize shared Cargo package metadata without changing package behavior. +- Prepare crate manifests for future distribution metadata review. +- Add explicit Rust tool behavior configuration only when it reduces drift from mise-managed checks. +- Document the new security workflow for contributors. + +**Non-Goals:** + +- No release publishing workflow. +- No crates.io publishing, token handling, or release automation. +- No changelog-derived GitHub release creation. +- No changes to `oxmux` runtime semantics, `oxidemux` app-shell behavior, provider execution, routing, protocol translation, subscription UX, or GPUI behavior. +- No blocking `cargo-vet` gate unless a later proposal defines audit ownership and exemption maintenance. + +## Decisions + +### Decision: Make mise the security command boundary + +Security tools should be installed or pinned through `mise.toml` where practical, and GitHub Actions should call mise tasks such as `mise run security`, `mise run audit`, or `mise run deny`. Workflow YAML must not install `cargo-deny` or `cargo-audit` directly with raw cargo commands, and it must not run the underlying cargo security tools directly when a mise task exists. + +Alternative considered: copy `langfuse-rs` workflow steps that install cargo tools directly in Actions. Rejected because OxideMux already specifies mise as the workflow source of truth, and duplicated setup would drift from local verification. + +### Decision: Add `cargo-deny` and `cargo-audit` first + +`cargo-deny` gives an immediate policy surface for licenses, duplicate crates, advisories, registries, and git sources. `cargo-audit` gives a focused vulnerability check that is easy for contributors to understand and run locally. + +Alternative considered: start with `cargo-vet` as the primary supply-chain gate. Rejected for this change because `cargo-vet` requires an audit/exemption maintenance model that is premature for the current small dependency graph. + +### Decision: Treat `cargo-vet` as deferred evaluation + +This change may add documentation or a task placeholder for evaluating `cargo-vet`, but it must not make vetting a required CI gate unless the dependency graph, exemption policy, and ownership model are established. + +Alternative considered: add a generated `supply-chain/config.toml` immediately. Rejected unless the implementation can prove it is maintainable and non-blocking; otherwise it creates security theater and future churn. + +### Decision: Centralize shared metadata with workspace inheritance + +The root workspace manifest should own shared fields such as edition, rust-version, license, repository, homepage, and authors where applicable. Crate manifests should inherit shared metadata and keep crate-specific metadata local. + +Alternative considered: leave metadata duplicated in crate manifests. Rejected because duplicated package metadata drifts before distribution and makes future publishing review harder. + +### Decision: Defer release automation entirely + +Issue #35 originally mentioned release workflow scaffolding, but OxideMux is not usable yet. Release automation would introduce publishing assumptions before crate ownership, binary packaging, versioning, and product readiness are settled. + +Alternative considered: add a no-publish release workflow that only runs CI and extracts changelog notes. Rejected for this proposal because even scaffolded release automation invites premature maintenance and should be revisited at a usable-product milestone. + +## Risks / Trade-offs + +- `cargo-deny` license policy could block acceptable transitive dependencies. Mitigation: start with an explicit, reviewable allow list aligned with current dependencies and document how exceptions are reviewed. +- Duplicate dependency checks can be noisy while dependencies are still evolving. Mitigation: use warning levels where appropriate and reserve hard failures for policy violations that are clearly actionable. +- `cargo-audit` can fail on advisories without available fixes. Mitigation: document how ignored advisories must be justified in policy rather than silently bypassed. +- Workspace metadata inheritance can accidentally change package metadata. Mitigation: verify `cargo metadata`/manifest behavior and keep crate-specific fields local when inheritance would alter package identity. +- Adding tool config files can create another source of truth. Mitigation: only add `clippy.toml` or `rustfmt.toml` when they express stable behavior not already captured by mise commands. + +## Migration Plan + +1. Add the development-workflow spec updates for security verification, supply-chain policy, metadata readiness, and release automation deferral. +2. Add mise tool pins and security tasks. +3. Add `deny.toml` and wire `cargo-deny`/`cargo-audit` through mise. +4. Add a dedicated GitHub security workflow that sets up mise and runs the mise security task. +5. Centralize workspace package metadata and update crate manifests. +6. Update README/CONTRIBUTING/PR guidance for the new security checks and release deferral. +7. Verify `mise run ci`, `mise run hk-check`, and the new security task. + +Rollback is straightforward because this change is repository tooling and metadata only: remove the new workflow/config/tasks and restore manifest metadata if any check creates unacceptable churn. + +## Open Questions + +- Which exact `cargo-deny` license allow list should be used for the current dependency graph? +- Should duplicate dependencies be warnings or hard failures at first adoption? +- Which mise backend is most reliable for pinning `cargo-deny` and `cargo-audit` in local and CI environments while still keeping workflow YAML limited to mise setup plus `mise run ...` invocations? diff --git a/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/proposal.md b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/proposal.md new file mode 100644 index 0000000..68568c2 --- /dev/null +++ b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/proposal.md @@ -0,0 +1,31 @@ +## Why + +OxideMux has a mise-first development workflow, but it does not yet expose repository-level security checks, supply-chain policy, or distribution-ready Cargo metadata through that workflow. Issue #35 asks us to adopt the production-readiness patterns proven in `langfuse-rs` while preserving `mise.toml` as the local and CI source of truth. + +## What Changes + +- Add repository security verification as a first-class mise-defined workflow, including local and GitHub Actions entrypoints. +- Add initial supply-chain policy for `cargo-deny`, covering vulnerability advisories, license allowances, duplicate dependencies, registries, and git sources. +- Add `cargo-audit` as a mise-managed vulnerability check. +- Defer blocking `cargo-vet` adoption until the dependency graph and audit/exemption policy are stable enough to maintain. +- Centralize shared Cargo package metadata in the workspace manifest with `[workspace.package]` and use workspace inheritance where appropriate. +- Add crate/package metadata needed before future distribution, such as repository, readme, documentation, keywords, categories, and package exclusions. +- Add explicit `clippy.toml` and `rustfmt.toml` only where they reduce environment drift and complement existing mise tasks. +- Update contributor documentation so local security checks are run through `mise run ...`, not raw cargo tool invocations. +- Exclude release publishing, release workflow scaffolding, crates.io token handling, and changelog-derived GitHub releases from this change until OxideMux is usable. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `development-workflow`: Extend the repository workflow contract to include mise-defined security checks, supply-chain policy, Cargo metadata readiness, and explicit release automation deferral. + +## Impact + +- Affects repository workflow files such as `mise.toml`, `.github/workflows/`, `deny.toml`, optional tool config files, `Cargo.toml`, crate manifests, README, and contributor documentation. +- Does not change `oxmux` runtime behavior, `oxidemux` app behavior, public Rust API semantics, provider execution, routing, protocol translation, subscription UX, or GPUI behavior. +- Keeps `mise run ci` as the existing quality gate and adds security verification beside it rather than expanding release automation prematurely. diff --git a/openspec/changes/archive/2026-04-30-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 new file mode 100644 index 0000000..cea2975 --- /dev/null +++ b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/specs/development-workflow/spec.md @@ -0,0 +1,95 @@ +## ADDED Requirements + +### Requirement: Mise defines repository security verification +The repository SHALL expose supply-chain and vulnerability checks through mise-defined tasks that can be run locally and from GitHub Actions. + +#### Scenario: Local security verification uses mise +- **WHEN** a contributor reads the development workflow documentation +- **THEN** it tells them to run repository security verification through `mise run security` or documented granular mise tasks + +#### Scenario: Security CI invokes mise verification +- **WHEN** GitHub Actions runs repository security checks +- **THEN** the workflow sets up mise tooling and invokes mise-defined security tasks instead of installing or running cargo security tools directly in workflow YAML + +#### Scenario: Security workflow covers routine triggers +- **WHEN** maintainers inspect the security workflow triggers +- **THEN** the workflow runs for pull requests, pushes to `main`, and scheduled checks + +#### Scenario: Security task preserves standard CI +- **WHEN** maintainers add security verification tasks +- **THEN** the existing `mise run ci` quality contract continues to run formatting, checking, clippy, tests, and documentation checks without being replaced by security-only checks + +### Requirement: Supply-chain policy is explicit and reviewable +The repository SHALL include an initial supply-chain policy for Rust dependencies that covers vulnerability advisories, license allowances, duplicate dependency handling, registries, and git sources. + +#### Scenario: Cargo deny policy is present +- **WHEN** a contributor runs the documented supply-chain policy check +- **THEN** the check evaluates a repository `deny.toml` policy for advisories, licenses, duplicate crates, registries, and git sources + +#### Scenario: Unknown dependency sources are rejected +- **WHEN** dependency resolution includes an unapproved registry or git source +- **THEN** the supply-chain policy check fails with a clear source policy violation + +#### Scenario: Policy exceptions stay visible +- **WHEN** a dependency requires a license, advisory, duplicate, registry, or git-source exception +- **THEN** the exception is recorded in the repository policy instead of being hidden in ad hoc CI commands + +### Requirement: Cargo vet remains deferred until maintainable +The repository SHALL NOT require `cargo-vet` as a blocking local or CI gate until a later change defines audit ownership, exemption policy, and maintenance expectations. + +#### Scenario: Cargo vet is not required by initial security verification +- **WHEN** a contributor runs the initial documented security verification for this change +- **THEN** it does not fail solely because `cargo-vet` audits or exemptions have not been initialized + +#### Scenario: Cargo vet is non-blocking in CI +- **WHEN** GitHub Actions runs the initial security workflow for this change +- **THEN** `cargo-vet` is absent from the required path or is explicitly configured as non-blocking + +#### Scenario: Future cargo vet adoption requires policy +- **WHEN** maintainers decide to make `cargo-vet` blocking +- **THEN** they first define the audit ownership and exemption policy in OpenSpec or contributor documentation + +### Requirement: Cargo workspace metadata is centralized for future distribution +The repository SHALL centralize shared Cargo package metadata in the workspace manifest and use workspace inheritance where it does not change crate behavior. + +#### Scenario: Shared package metadata is inherited +- **WHEN** maintainers inspect workspace and crate manifests +- **THEN** shared metadata such as edition, rust-version, license, repository, homepage, and authors is defined once in `[workspace.package]` where appropriate and inherited by workspace crates + +#### Scenario: Crate-specific metadata remains local +- **WHEN** a crate needs package-specific metadata such as name, description, readme, documentation, keywords, categories, or exclusions +- **THEN** that metadata remains in the crate manifest or explicitly inherits only fields that preserve the crate's package identity + +#### Scenario: Metadata changes do not alter runtime behavior +- **WHEN** workspace package metadata is centralized +- **THEN** `oxmux` and `oxidemux` runtime behavior, public API semantics, and crate boundary responsibilities remain unchanged + +#### Scenario: Metadata changes preserve package identity +- **WHEN** workspace package metadata and crate metadata are updated +- **THEN** manifest validation confirms package names, crate targets, readme/documentation links, included files, and package-specific descriptions remain intentional without requiring release publishing + +### Requirement: Rust tool configuration remains subordinate to mise tasks +The repository SHALL add explicit Rust tool configuration files only when they complement mise-defined checks and reduce environment drift. + +#### Scenario: Clippy configuration complements mise +- **WHEN** maintainers add or update `clippy.toml` +- **THEN** the configuration captures stable project-wide lint behavior without replacing `mise run clippy` as the contributor and CI entrypoint + +#### Scenario: Rustfmt configuration complements mise +- **WHEN** maintainers add or update `rustfmt.toml` +- **THEN** the configuration captures stable project-wide formatting behavior without replacing `mise run fmt` as the contributor and CI entrypoint + +#### Scenario: Tool configuration is omitted when redundant +- **WHEN** explicit Rust tool configuration would only duplicate existing defaults or mise task commands +- **THEN** the implementation omits that configuration and documents why no file was added + +### Requirement: Release automation is deferred until usable product readiness +The repository SHALL defer release publishing workflows, crates.io token handling, and automated GitHub release creation until OxideMux has a usable product milestone and a separate release proposal. + +#### Scenario: Production-readiness workflow excludes publishing +- **WHEN** this change is implemented +- **THEN** it does not add a release publishing workflow, crates.io publishing steps, or required publishing credentials + +#### Scenario: Release readiness is documented as deferred +- **WHEN** contributors read workflow documentation after this change +- **THEN** it explains that release automation is intentionally deferred until a later usable-product milestone diff --git a/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/tasks.md b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/tasks.md new file mode 100644 index 0000000..4c4e5ad --- /dev/null +++ b/openspec/changes/archive/2026-04-30-adopt-production-readiness-workflow/tasks.md @@ -0,0 +1,52 @@ +## 1. Mise-managed security tooling + +- [x] 1.1 Add mise-managed tool pins or installation strategy for `cargo-deny` and `cargo-audit`. +- [x] 1.2 Add granular mise tasks for dependency policy and vulnerability checks. +- [x] 1.3 Add a top-level `mise run security` task that runs the granular security checks. +- [x] 1.4 Verify the new security tasks do not replace or weaken the existing `mise run ci` task. +- [x] 1.5 Verify GitHub workflow YAML does not install or run `cargo-deny`, `cargo-audit`, or other cargo security tools directly when a mise task exists. + +## 2. Supply-chain policy + +- [x] 2.1 Add an initial `deny.toml` covering advisories, license allow list, duplicate dependency handling, registries, and git sources. +- [x] 2.2 Run the cargo-deny task and tune only explicit, reviewable policy exceptions required by the current dependency graph. +- [x] 2.3 Run the cargo-audit task and document any unavoidable advisory exceptions in policy rather than CI shell commands. +- [x] 2.4 Document that blocking `cargo-vet` adoption is deferred pending audit ownership and exemption policy. +- [x] 2.5 Verify `cargo-vet` is absent from required local and CI security paths or explicitly non-blocking. + +## 3. GitHub security workflow + +- [x] 3.1 Add a dedicated GitHub Actions security workflow for pull requests, pushes to `main`, and scheduled checks. +- [x] 3.2 Configure the workflow to set up mise and invoke `mise run security` instead of duplicating raw cargo security commands. +- [x] 3.3 Keep workflow permissions minimal and read-only unless a later change requires elevated permissions. + +## 4. Cargo metadata readiness + +- [x] 4.1 Add shared `[workspace.package]` metadata to the root `Cargo.toml` where inheritance preserves current package behavior. +- [x] 4.2 Update `crates/oxmux/Cargo.toml` to inherit shared metadata and keep crate-specific package metadata local. +- [x] 4.3 Update `crates/oxidemux/Cargo.toml` to inherit shared metadata and keep crate-specific package metadata local. +- [x] 4.4 Add distribution-readiness metadata such as repository, readme, documentation, keywords, categories, and package exclusions where appropriate. +- [x] 4.5 Verify manifest metadata changes preserve package names, crate targets, readme/documentation links, included files, package-specific descriptions, runtime behavior, and crate boundary responsibilities without requiring release publishing. + +## 5. Tool behavior configuration + +- [x] 5.1 Evaluate whether `clippy.toml` reduces drift beyond the existing `mise run clippy` command. +- [x] 5.2 Add `clippy.toml` only if it captures stable project-wide lint behavior. +- [x] 5.3 Evaluate whether `rustfmt.toml` reduces drift beyond the existing `mise run fmt` command. +- [x] 5.4 Add `rustfmt.toml` only if it captures stable project-wide formatting behavior. + +## 6. Documentation and scope guardrails + +- [x] 6.1 Update README development instructions to include the new mise security task. +- [x] 6.2 Update `CONTRIBUTING.md` to explain security verification, supply-chain policy exceptions, and deferred release automation. +- [x] 6.3 Update the pull request template if needed so contributors can report security verification results. +- [x] 6.4 Ensure documentation states that release publishing workflows, crates.io token handling, and automated GitHub releases remain out of scope until a later usable-product milestone. + +## 7. Verification + +- [x] 7.1 Run `openspec validate adopt-production-readiness-workflow --strict`. +- [x] 7.2 Run `mise run security` or the new granular security task set. +- [x] 7.3 Run `mise run ci`. +- [x] 7.4 Run `mise run hk-check`. +- [x] 7.5 Confirm no release workflow or publishing credential requirement was introduced. +- [x] 7.6 Confirm `cargo-vet` is not required for local or CI success in this change. From 487c3c3cc52dbdf54af77a9b8f6cf22f854635c1 Mon Sep 17 00:00:00 2001 From: Brandon Martin Date: Wed, 29 Apr 2026 20:58:48 -0500 Subject: [PATCH 11/11] Remove archived production readiness change --- .../.openspec.yaml | 2 - .../design.md | 83 ---------------- .../proposal.md | 31 ------ .../specs/development-workflow/spec.md | 95 ------------------- .../tasks.md | 52 ---------- 5 files changed, 263 deletions(-) delete mode 100644 openspec/changes/adopt-production-readiness-workflow/.openspec.yaml delete mode 100644 openspec/changes/adopt-production-readiness-workflow/design.md delete mode 100644 openspec/changes/adopt-production-readiness-workflow/proposal.md delete mode 100644 openspec/changes/adopt-production-readiness-workflow/specs/development-workflow/spec.md delete mode 100644 openspec/changes/adopt-production-readiness-workflow/tasks.md diff --git a/openspec/changes/adopt-production-readiness-workflow/.openspec.yaml b/openspec/changes/adopt-production-readiness-workflow/.openspec.yaml deleted file mode 100644 index 5f23b85..0000000 --- a/openspec/changes/adopt-production-readiness-workflow/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-29 diff --git a/openspec/changes/adopt-production-readiness-workflow/design.md b/openspec/changes/adopt-production-readiness-workflow/design.md deleted file mode 100644 index c1f1f72..0000000 --- a/openspec/changes/adopt-production-readiness-workflow/design.md +++ /dev/null @@ -1,83 +0,0 @@ -## Context - -OxideMux already treats `mise.toml` as the canonical local and CI task graph. The current workflow runs formatting, checking, clippy, tests, rustdoc, hk checks, and OpenSpec PR evidence validation through mise-backed GitHub Actions. Issue #35 expands the same repository workflow layer to cover production-readiness patterns from `langfuse-rs`: security checks, supply-chain policy, and package metadata. - -The important constraint is that `langfuse-rs` is a reference, not a template. Its security and release workflows prove the desired shape, but OxideMux must keep tool installation and command selection centralized in mise. Release publishing and release workflow scaffolding are intentionally deferred until OxideMux has usable product behavior and a clear distribution milestone. - -## Goals / Non-Goals - -**Goals:** - -- Add a mise-defined security workflow that can run locally and in GitHub Actions. -- Add initial `cargo-deny` policy and `cargo-audit` vulnerability checks. -- Keep `cargo-vet` evaluated but non-blocking until audits and exemptions are maintainable. -- Centralize shared Cargo package metadata without changing package behavior. -- Prepare crate manifests for future distribution metadata review. -- Add explicit Rust tool behavior configuration only when it reduces drift from mise-managed checks. -- Document the new security workflow for contributors. - -**Non-Goals:** - -- No release publishing workflow. -- No crates.io publishing, token handling, or release automation. -- No changelog-derived GitHub release creation. -- No changes to `oxmux` runtime semantics, `oxidemux` app-shell behavior, provider execution, routing, protocol translation, subscription UX, or GPUI behavior. -- No blocking `cargo-vet` gate unless a later proposal defines audit ownership and exemption maintenance. - -## Decisions - -### Decision: Make mise the security command boundary - -Security tools should be installed or pinned through `mise.toml` where practical, and GitHub Actions should call mise tasks such as `mise run security`, `mise run audit`, or `mise run deny`. Workflow YAML must not install `cargo-deny` or `cargo-audit` directly with raw cargo commands, and it must not run the underlying cargo security tools directly when a mise task exists. - -Alternative considered: copy `langfuse-rs` workflow steps that install cargo tools directly in Actions. Rejected because OxideMux already specifies mise as the workflow source of truth, and duplicated setup would drift from local verification. - -### Decision: Add `cargo-deny` and `cargo-audit` first - -`cargo-deny` gives an immediate policy surface for licenses, duplicate crates, advisories, registries, and git sources. `cargo-audit` gives a focused vulnerability check that is easy for contributors to understand and run locally. - -Alternative considered: start with `cargo-vet` as the primary supply-chain gate. Rejected for this change because `cargo-vet` requires an audit/exemption maintenance model that is premature for the current small dependency graph. - -### Decision: Treat `cargo-vet` as deferred evaluation - -This change may add documentation or a task placeholder for evaluating `cargo-vet`, but it must not make vetting a required CI gate unless the dependency graph, exemption policy, and ownership model are established. - -Alternative considered: add a generated `supply-chain/config.toml` immediately. Rejected unless the implementation can prove it is maintainable and non-blocking; otherwise it creates security theater and future churn. - -### Decision: Centralize shared metadata with workspace inheritance - -The root workspace manifest should own shared fields such as edition, rust-version, license, repository, homepage, and authors where applicable. Crate manifests should inherit shared metadata and keep crate-specific metadata local. - -Alternative considered: leave metadata duplicated in crate manifests. Rejected because duplicated package metadata drifts before distribution and makes future publishing review harder. - -### Decision: Defer release automation entirely - -Issue #35 originally mentioned release workflow scaffolding, but OxideMux is not usable yet. Release automation would introduce publishing assumptions before crate ownership, binary packaging, versioning, and product readiness are settled. - -Alternative considered: add a no-publish release workflow that only runs CI and extracts changelog notes. Rejected for this proposal because even scaffolded release automation invites premature maintenance and should be revisited at a usable-product milestone. - -## Risks / Trade-offs - -- `cargo-deny` license policy could block acceptable transitive dependencies. Mitigation: start with an explicit, reviewable allow list aligned with current dependencies and document how exceptions are reviewed. -- Duplicate dependency checks can be noisy while dependencies are still evolving. Mitigation: use warning levels where appropriate and reserve hard failures for policy violations that are clearly actionable. -- `cargo-audit` can fail on advisories without available fixes. Mitigation: document how ignored advisories must be justified in policy rather than silently bypassed. -- Workspace metadata inheritance can accidentally change package metadata. Mitigation: verify `cargo metadata`/manifest behavior and keep crate-specific fields local when inheritance would alter package identity. -- Adding tool config files can create another source of truth. Mitigation: only add `clippy.toml` or `rustfmt.toml` when they express stable behavior not already captured by mise commands. - -## Migration Plan - -1. Add the development-workflow spec updates for security verification, supply-chain policy, metadata readiness, and release automation deferral. -2. Add mise tool pins and security tasks. -3. Add `deny.toml` and wire `cargo-deny`/`cargo-audit` through mise. -4. Add a dedicated GitHub security workflow that sets up mise and runs the mise security task. -5. Centralize workspace package metadata and update crate manifests. -6. Update README/CONTRIBUTING/PR guidance for the new security checks and release deferral. -7. Verify `mise run ci`, `mise run hk-check`, and the new security task. - -Rollback is straightforward because this change is repository tooling and metadata only: remove the new workflow/config/tasks and restore manifest metadata if any check creates unacceptable churn. - -## Open Questions - -- Which exact `cargo-deny` license allow list should be used for the current dependency graph? -- Should duplicate dependencies be warnings or hard failures at first adoption? -- Which mise backend is most reliable for pinning `cargo-deny` and `cargo-audit` in local and CI environments while still keeping workflow YAML limited to mise setup plus `mise run ...` invocations? diff --git a/openspec/changes/adopt-production-readiness-workflow/proposal.md b/openspec/changes/adopt-production-readiness-workflow/proposal.md deleted file mode 100644 index 68568c2..0000000 --- a/openspec/changes/adopt-production-readiness-workflow/proposal.md +++ /dev/null @@ -1,31 +0,0 @@ -## Why - -OxideMux has a mise-first development workflow, but it does not yet expose repository-level security checks, supply-chain policy, or distribution-ready Cargo metadata through that workflow. Issue #35 asks us to adopt the production-readiness patterns proven in `langfuse-rs` while preserving `mise.toml` as the local and CI source of truth. - -## What Changes - -- Add repository security verification as a first-class mise-defined workflow, including local and GitHub Actions entrypoints. -- Add initial supply-chain policy for `cargo-deny`, covering vulnerability advisories, license allowances, duplicate dependencies, registries, and git sources. -- Add `cargo-audit` as a mise-managed vulnerability check. -- Defer blocking `cargo-vet` adoption until the dependency graph and audit/exemption policy are stable enough to maintain. -- Centralize shared Cargo package metadata in the workspace manifest with `[workspace.package]` and use workspace inheritance where appropriate. -- Add crate/package metadata needed before future distribution, such as repository, readme, documentation, keywords, categories, and package exclusions. -- Add explicit `clippy.toml` and `rustfmt.toml` only where they reduce environment drift and complement existing mise tasks. -- Update contributor documentation so local security checks are run through `mise run ...`, not raw cargo tool invocations. -- Exclude release publishing, release workflow scaffolding, crates.io token handling, and changelog-derived GitHub releases from this change until OxideMux is usable. - -## Capabilities - -### New Capabilities - - - -### Modified Capabilities - -- `development-workflow`: Extend the repository workflow contract to include mise-defined security checks, supply-chain policy, Cargo metadata readiness, and explicit release automation deferral. - -## Impact - -- Affects repository workflow files such as `mise.toml`, `.github/workflows/`, `deny.toml`, optional tool config files, `Cargo.toml`, crate manifests, README, and contributor documentation. -- Does not change `oxmux` runtime behavior, `oxidemux` app behavior, public Rust API semantics, provider execution, routing, protocol translation, subscription UX, or GPUI behavior. -- Keeps `mise run ci` as the existing quality gate and adds security verification beside it rather than expanding release automation prematurely. diff --git a/openspec/changes/adopt-production-readiness-workflow/specs/development-workflow/spec.md b/openspec/changes/adopt-production-readiness-workflow/specs/development-workflow/spec.md deleted file mode 100644 index cea2975..0000000 --- a/openspec/changes/adopt-production-readiness-workflow/specs/development-workflow/spec.md +++ /dev/null @@ -1,95 +0,0 @@ -## ADDED Requirements - -### Requirement: Mise defines repository security verification -The repository SHALL expose supply-chain and vulnerability checks through mise-defined tasks that can be run locally and from GitHub Actions. - -#### Scenario: Local security verification uses mise -- **WHEN** a contributor reads the development workflow documentation -- **THEN** it tells them to run repository security verification through `mise run security` or documented granular mise tasks - -#### Scenario: Security CI invokes mise verification -- **WHEN** GitHub Actions runs repository security checks -- **THEN** the workflow sets up mise tooling and invokes mise-defined security tasks instead of installing or running cargo security tools directly in workflow YAML - -#### Scenario: Security workflow covers routine triggers -- **WHEN** maintainers inspect the security workflow triggers -- **THEN** the workflow runs for pull requests, pushes to `main`, and scheduled checks - -#### Scenario: Security task preserves standard CI -- **WHEN** maintainers add security verification tasks -- **THEN** the existing `mise run ci` quality contract continues to run formatting, checking, clippy, tests, and documentation checks without being replaced by security-only checks - -### Requirement: Supply-chain policy is explicit and reviewable -The repository SHALL include an initial supply-chain policy for Rust dependencies that covers vulnerability advisories, license allowances, duplicate dependency handling, registries, and git sources. - -#### Scenario: Cargo deny policy is present -- **WHEN** a contributor runs the documented supply-chain policy check -- **THEN** the check evaluates a repository `deny.toml` policy for advisories, licenses, duplicate crates, registries, and git sources - -#### Scenario: Unknown dependency sources are rejected -- **WHEN** dependency resolution includes an unapproved registry or git source -- **THEN** the supply-chain policy check fails with a clear source policy violation - -#### Scenario: Policy exceptions stay visible -- **WHEN** a dependency requires a license, advisory, duplicate, registry, or git-source exception -- **THEN** the exception is recorded in the repository policy instead of being hidden in ad hoc CI commands - -### Requirement: Cargo vet remains deferred until maintainable -The repository SHALL NOT require `cargo-vet` as a blocking local or CI gate until a later change defines audit ownership, exemption policy, and maintenance expectations. - -#### Scenario: Cargo vet is not required by initial security verification -- **WHEN** a contributor runs the initial documented security verification for this change -- **THEN** it does not fail solely because `cargo-vet` audits or exemptions have not been initialized - -#### Scenario: Cargo vet is non-blocking in CI -- **WHEN** GitHub Actions runs the initial security workflow for this change -- **THEN** `cargo-vet` is absent from the required path or is explicitly configured as non-blocking - -#### Scenario: Future cargo vet adoption requires policy -- **WHEN** maintainers decide to make `cargo-vet` blocking -- **THEN** they first define the audit ownership and exemption policy in OpenSpec or contributor documentation - -### Requirement: Cargo workspace metadata is centralized for future distribution -The repository SHALL centralize shared Cargo package metadata in the workspace manifest and use workspace inheritance where it does not change crate behavior. - -#### Scenario: Shared package metadata is inherited -- **WHEN** maintainers inspect workspace and crate manifests -- **THEN** shared metadata such as edition, rust-version, license, repository, homepage, and authors is defined once in `[workspace.package]` where appropriate and inherited by workspace crates - -#### Scenario: Crate-specific metadata remains local -- **WHEN** a crate needs package-specific metadata such as name, description, readme, documentation, keywords, categories, or exclusions -- **THEN** that metadata remains in the crate manifest or explicitly inherits only fields that preserve the crate's package identity - -#### Scenario: Metadata changes do not alter runtime behavior -- **WHEN** workspace package metadata is centralized -- **THEN** `oxmux` and `oxidemux` runtime behavior, public API semantics, and crate boundary responsibilities remain unchanged - -#### Scenario: Metadata changes preserve package identity -- **WHEN** workspace package metadata and crate metadata are updated -- **THEN** manifest validation confirms package names, crate targets, readme/documentation links, included files, and package-specific descriptions remain intentional without requiring release publishing - -### Requirement: Rust tool configuration remains subordinate to mise tasks -The repository SHALL add explicit Rust tool configuration files only when they complement mise-defined checks and reduce environment drift. - -#### Scenario: Clippy configuration complements mise -- **WHEN** maintainers add or update `clippy.toml` -- **THEN** the configuration captures stable project-wide lint behavior without replacing `mise run clippy` as the contributor and CI entrypoint - -#### Scenario: Rustfmt configuration complements mise -- **WHEN** maintainers add or update `rustfmt.toml` -- **THEN** the configuration captures stable project-wide formatting behavior without replacing `mise run fmt` as the contributor and CI entrypoint - -#### Scenario: Tool configuration is omitted when redundant -- **WHEN** explicit Rust tool configuration would only duplicate existing defaults or mise task commands -- **THEN** the implementation omits that configuration and documents why no file was added - -### Requirement: Release automation is deferred until usable product readiness -The repository SHALL defer release publishing workflows, crates.io token handling, and automated GitHub release creation until OxideMux has a usable product milestone and a separate release proposal. - -#### Scenario: Production-readiness workflow excludes publishing -- **WHEN** this change is implemented -- **THEN** it does not add a release publishing workflow, crates.io publishing steps, or required publishing credentials - -#### Scenario: Release readiness is documented as deferred -- **WHEN** contributors read workflow documentation after this change -- **THEN** it explains that release automation is intentionally deferred until a later usable-product milestone diff --git a/openspec/changes/adopt-production-readiness-workflow/tasks.md b/openspec/changes/adopt-production-readiness-workflow/tasks.md deleted file mode 100644 index 4c4e5ad..0000000 --- a/openspec/changes/adopt-production-readiness-workflow/tasks.md +++ /dev/null @@ -1,52 +0,0 @@ -## 1. Mise-managed security tooling - -- [x] 1.1 Add mise-managed tool pins or installation strategy for `cargo-deny` and `cargo-audit`. -- [x] 1.2 Add granular mise tasks for dependency policy and vulnerability checks. -- [x] 1.3 Add a top-level `mise run security` task that runs the granular security checks. -- [x] 1.4 Verify the new security tasks do not replace or weaken the existing `mise run ci` task. -- [x] 1.5 Verify GitHub workflow YAML does not install or run `cargo-deny`, `cargo-audit`, or other cargo security tools directly when a mise task exists. - -## 2. Supply-chain policy - -- [x] 2.1 Add an initial `deny.toml` covering advisories, license allow list, duplicate dependency handling, registries, and git sources. -- [x] 2.2 Run the cargo-deny task and tune only explicit, reviewable policy exceptions required by the current dependency graph. -- [x] 2.3 Run the cargo-audit task and document any unavoidable advisory exceptions in policy rather than CI shell commands. -- [x] 2.4 Document that blocking `cargo-vet` adoption is deferred pending audit ownership and exemption policy. -- [x] 2.5 Verify `cargo-vet` is absent from required local and CI security paths or explicitly non-blocking. - -## 3. GitHub security workflow - -- [x] 3.1 Add a dedicated GitHub Actions security workflow for pull requests, pushes to `main`, and scheduled checks. -- [x] 3.2 Configure the workflow to set up mise and invoke `mise run security` instead of duplicating raw cargo security commands. -- [x] 3.3 Keep workflow permissions minimal and read-only unless a later change requires elevated permissions. - -## 4. Cargo metadata readiness - -- [x] 4.1 Add shared `[workspace.package]` metadata to the root `Cargo.toml` where inheritance preserves current package behavior. -- [x] 4.2 Update `crates/oxmux/Cargo.toml` to inherit shared metadata and keep crate-specific package metadata local. -- [x] 4.3 Update `crates/oxidemux/Cargo.toml` to inherit shared metadata and keep crate-specific package metadata local. -- [x] 4.4 Add distribution-readiness metadata such as repository, readme, documentation, keywords, categories, and package exclusions where appropriate. -- [x] 4.5 Verify manifest metadata changes preserve package names, crate targets, readme/documentation links, included files, package-specific descriptions, runtime behavior, and crate boundary responsibilities without requiring release publishing. - -## 5. Tool behavior configuration - -- [x] 5.1 Evaluate whether `clippy.toml` reduces drift beyond the existing `mise run clippy` command. -- [x] 5.2 Add `clippy.toml` only if it captures stable project-wide lint behavior. -- [x] 5.3 Evaluate whether `rustfmt.toml` reduces drift beyond the existing `mise run fmt` command. -- [x] 5.4 Add `rustfmt.toml` only if it captures stable project-wide formatting behavior. - -## 6. Documentation and scope guardrails - -- [x] 6.1 Update README development instructions to include the new mise security task. -- [x] 6.2 Update `CONTRIBUTING.md` to explain security verification, supply-chain policy exceptions, and deferred release automation. -- [x] 6.3 Update the pull request template if needed so contributors can report security verification results. -- [x] 6.4 Ensure documentation states that release publishing workflows, crates.io token handling, and automated GitHub releases remain out of scope until a later usable-product milestone. - -## 7. Verification - -- [x] 7.1 Run `openspec validate adopt-production-readiness-workflow --strict`. -- [x] 7.2 Run `mise run security` or the new granular security task set. -- [x] 7.3 Run `mise run ci`. -- [x] 7.4 Run `mise run hk-check`. -- [x] 7.5 Confirm no release workflow or publishing credential requirement was introduced. -- [x] 7.6 Confirm `cargo-vet` is not required for local or CI success in this change.