diff --git a/docs/api.md b/docs/api.md index fab7087cf..efa383ca5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -357,6 +357,22 @@ Success response: } ``` +### `PATCH /admin/logs/state` + +Updates the in-process logging state used by admin log controls. This is runtime-only; persistent configuration changes still go through `PATCH /admin/settings`. + +Request: + +```json +{ + "enabled": true, + "capacity": 2000, + "captureBody": false +} +``` + +Success response uses the same data shape as `GET /admin/logs/state`. + ### `GET /admin/logs/{id}` Returns a single stored event log by id using the same event shape as list items. Missing ids return HTTP `404` with body code `40401`. diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 4308b79f7..f30ac4729 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -11,7 +11,7 @@ Update this file before each feature commit. | SQLite storage | Completed | `ffec8b5` + `b0c10f2` | `cargo test sqlite_schema_creates_accounts_and_event_tables`; `cargo test connect_sqlite_creates_parent_directory_for_file_database`; full `fmt/test/clippy` before commit | Creates SQLite WAL connector, parent directory bootstrap, sqlx-managed schema for accounts/API keys/sessions/cookies/fingerprints/events, and event/account indexes. | | Admin auth and client API keys | Partial | `24d5799` + `4e0d1e5` + `e9fdea0` + `8af350d` + `960bbb2` | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; `cargo test --test admin_login_route_test`; `cargo test --test admin_api_keys_route_test`; `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `960bbb2`; current local key import/export targeted `cargo test --test admin_api_keys_route_test` | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper; `/admin/login` issues HttpOnly sessions and rejects client API keys as admin credentials. `/admin/api-keys` can create/list local client keys, status-enable/status-disable local client keys, label local client keys, delete local client keys, batch-delete local client keys, export local client key metadata, import metadata by rotating to new local `cpr_` keys, and `/v1/*` verifies only enabled keys against SQLite instead of accepting any `cpr_` string. These routes are Rust-local client auth utilities, not TypeScript third-party provider API-key parity. Provider API key pools and provider key import/export remain out of scope; local `cpr_` key plaintext and key hashes are not exportable after HMAC storage, and import returns newly generated plaintext values only once. | | Secret encryption | Completed | `105aa58` + `228febd` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; `cargo test secret_box_load_or_create_should_reuse_existing_master_key_file`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1::` without plaintext and can load/create the runtime master key file used by repositories. | -| Logging and pagination | Completed | `eb0341e` + `fc16c48` + `b0c10f2` + `ee0ed90` + `fdb9374` + `5bf012c` + `pending` | `cargo test event_logs_are_cursor_paginated`; `cargo test admin_logs_are_cursor_paginated_and_include_request_id`; `cargo test --test admin_logs_route_test admin_logs_`; `cargo test rotating_writer_splits_files_when_size_limit_is_reached`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page` camelCase serialization, admin `/admin/logs` query/detail/state/clear APIs, admin `/admin/usage-stats` and `/admin/usage-stats/summary`, event log repository, runtime tracing initialization, HTTP trace layer, date/size-based file rotation, v1 response lifecycle events with request/account/route/model/status/latency/non-secret metadata, and non-secret `account.refresh` events for upstream-401 refresh failures. | +| Logging and pagination | Completed | `eb0341e` + `fc16c48` + `b0c10f2` + `ee0ed90` + `fdb9374` + `5bf012c` + `pending` | `cargo test event_logs_are_cursor_paginated`; `cargo test admin_logs_are_cursor_paginated_and_include_request_id`; `cargo test --test admin_logs_route_test admin_logs_state_patch`; `cargo test --test admin_logs_route_test admin_logs_`; `cargo test rotating_writer_splits_files_when_size_limit_is_reached`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page` camelCase serialization, admin `/admin/logs` query/detail/state/update/clear APIs, admin `/admin/usage-stats` and `/admin/usage-stats/summary`, event log repository, runtime tracing initialization, HTTP trace layer, date/size-based file rotation, v1 response lifecycle events with request/account/route/model/status/latency/non-secret metadata, and non-secret `account.refresh` events for upstream-401 refresh failures. Runtime log state updates are in-process only; persistent config changes remain under `/admin/settings`. | | Diagnostics | Completed | `pending` | `cargo test --test diagnostics_route_test admin_diagnostics`; `cargo test --test diagnostics_route_test debug_upstream`; `cargo test --test diagnostics_route_test` | Adds admin-session-gated `GET /admin/diagnostics` plus local-only `GET /debug/diagnostics`, `GET /debug/fingerprint`, and `GET /debug/upstream` with runtime package metadata, config/database/log/security paths, Codex backend transport settings, static Desktop fingerprint summary and standalone fingerprint debug output, account repository/pool state, runtime capacity summary, retained settings summary, and a Codex models endpoint connectivity probe without tokens or upstream bodies. Remote forwarded debug requests are rejected with `403`. | | TLS headers and fingerprint | Completed | `1bdd08c` | `cargo test codex_headers_include_desktop_identity_and_turn_state`; `cargo tree \| rg 'reqwest\|rustls'`; full `fmt/test/clippy` before commit | Adds Codex Desktop fingerprint model, exact identity/request headers, and pinned reqwest/rustls client builder. | | Cookie persistence | Completed | `4710926` + `5d3cc6e` + `203270f` | `cargo test cookie_jar_captures_and_replays_account_scoped_cookies`; `cargo test cookie_repository_encrypts_and_replays_account_scoped_cookies`; full `fmt/test/clippy` before `203270f` | Adds account-scoped in-memory Cookie jar and encrypted SQLite Cookie repository; Cookies are replayed only for the same account/domain. Admin-pasted browser Cookie headers are stored encrypted and can be cleared per account. | diff --git a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md index f31e5a0d9..ad5befe3d 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -2977,7 +2977,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav - [x] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Create/list/status/delete/batch-delete/label/export/import and `/v1/*` SQLite verification are complete. Remove provider/model binding semantics from the TypeScript version; Rust client API keys authorize only local `/v1/*` access. - [x] **Settings:** implement admin settings for server/runtime fields that remain in scope: default model, reasoning effort, service tier, model aliases, refresh enabled/margin/concurrency, max concurrent per account, request interval, rotation strategy, tier priority, quota refresh/thresholds/skip exhausted, logs state/capacity/body capture, and usage history retention. GET and PATCH `/admin/settings` are admin-session gated, PATCH validates retained fields, writes a root `local.yaml` overlay, and rejects proxy URL, Ollama, third-party provider, OpenAI official key, proxy API key, and Electron/self-update settings. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task. - [x] **Diagnostics:** expand health/admin diagnostics to include authenticated state, pool summary, capacity summary, transport/fingerprint status, paths, runtime metadata, and test-connection checks. `/admin/diagnostics` returns the non-secret runtime summary behind admin-session auth. Local-only `/debug/diagnostics` returns runtime metadata, paths, account repository/pool state, capacity summary, transport config, and static fingerprint status without secrets. Local-only `/debug/fingerprint` returns the runtime Desktop fingerprint summary. Local-only `/debug/upstream` probes the Codex models endpoint with runtime TLS/fingerprint headers and returns only status metadata. Keep production-local gating for sensitive debug endpoints. -- [ ] **Logs and usage stats:** add `/admin/logs/state`, clear/detail, error-log equivalents if retained, and richer bounded history queries if needed. `/admin/logs/state`, `/admin/logs/{id}`, `DELETE /admin/logs`, log file rotation, SQLite event pagination, `/admin/usage-stats` pagination, and `/admin/usage-stats/summary` are complete. Remaining: decide whether to retain old error-log grouping/raw/count/seen/report APIs and richer bounded body-history queries. +- [ ] **Logs and usage stats:** add `/admin/logs/state`, clear/detail, error-log equivalents if retained, and richer bounded history queries if needed. `GET/PATCH /admin/logs/state`, `/admin/logs/{id}`, `DELETE /admin/logs`, log file rotation, SQLite event pagination, `/admin/usage-stats` pagination, and `/admin/usage-stats/summary` are complete. Remaining: decide whether to retain old error-log grouping/raw/count/seen/report APIs and richer bounded body-history queries. - [x] **Model catalog:** replace the hard-coded `/v1/models` list with a model store that supports static defaults, configured aliases, `-low/-medium/-high/-xhigh/-fast/-flex` suffix parsing, backend model fetching per plan, cache persistence, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/admin/refresh-models`, and `/debug/models`. Static defaults, aliases, suffix parsing, cached backend snapshots, plan allowlists, admin refresh, read-only model routes, and debug summary are complete. - [ ] **Account scheduling parity:** extend account acquisition beyond `active + least last_used`. Completed in the in-memory pool: max concurrent slots per account, stale slot cleanup, least-used/round-robin/sticky strategies, quota-cache skip, tier priority, model-plan filtering, exclude IDs, preferred account from session affinity, Cloudflare cooldown skip, request-staggering metadata, capacity summary, settings-backed construction, import-to-pool sync, startup pool snapshots from SQLite, HTTP fallback acquisition across alternate accounts, WebSocket fallback for non-history requests, previous-response account affinity, and runtime pool clearing on logout. Remaining: durable release-time slot state and durable quota-cache/cooldown restore. - [ ] **Upstream Codex lifecycle:** implement account acquire -> Codex request -> SSE/collect-or-stream -> rate-limit capture -> usage release -> retry/fallback. HTTP SSE collection, streaming passthrough, 401-triggered refresh retry, Codex request context fields, SSE `error`/`response.failed` mapping, 429/402/403 account-state classification, successful WebSocket rate-limit capture, and fallback across alternate accounts for non-streaming Responses, HTTP SSE setup, non-history WebSocket setup, and Chat Completions are complete. Remaining: durable 429 quota cooldown persistence, path-block Cookie clearing, and richer OpenAI-compatible error mapping. @@ -2995,7 +2995,7 @@ This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/ - [ ] **Admin account management:** Rust has paginated sanitized list, native/Sub2API import, native/Sub2API export, manual token add with upstream refresh-token exchange and JWT metadata/expires extraction, device-code token import, label/status/delete, batch delete/status, Cookie get/set/delete, per-account refresh/probe, reset usage, quota fetch/update, quota warnings, auth status, and logout. Missing persistence-health reporting and durable quota-window restore. - [x] **Client API key management:** Rust creates/lists/status-toggles/deletes/batch-deletes/labels/exports/imports local `cpr_` keys and verifies them for `/v1/*`. Do not port the TypeScript provider/model binding or provider model-fetch APIs. - [x] **Settings:** Rust exposes admin-session-gated GET/PATCH `/admin/settings` for retained fields. PATCH validates default model, default reasoning effort, service tier, aliases, refresh settings, max concurrent accounts, request interval, rotation strategy, tier priority, quota refresh/threshold/skip settings, log state/capacity/body capture, and usage retention, writes root `local.yaml`, and does not re-add proxy URL, provider, Ollama, proxy API key, or Electron/self-update settings. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task. -- [ ] **Logs and error diagnostics:** Rust has cursor-paginated SQLite event logs, rotating files, `/admin/logs/state`, detail-by-id, and clear. Missing pause/enable mutation, direction/search filters, bounded in-memory request/response body capture, and the original local error-log grouping/raw/count/seen/report/clear APIs if retained for troubleshooting. +- [ ] **Logs and error diagnostics:** Rust has cursor-paginated SQLite event logs, rotating files, `GET/PATCH /admin/logs/state`, detail-by-id, and clear. Missing direction/search filters, bounded in-memory request/response body capture, and the original local error-log grouping/raw/count/seen/report/clear APIs if retained for troubleshooting. - [ ] **Usage and quota background jobs:** Rust records cumulative account usage from `/v1/*` and computes admin quota warnings from cached SQLite quota snapshots. Missing usage history snapshots (`raw`, `five_min`, `hourly`, `daily`), retention cleanup, passive quota-window recovery, active quota refresher for dirty/limit-reached accounts, and startup background scheduling. - [ ] **Model catalog:** Rust has a static default catalog, aliases, suffix parsing, read routes, debug summary, backend Codex model fetch/probe, per-plan snapshots, model-plan index feeding account scheduling, `/admin/refresh-models`, and runtime cache persistence. Missing richer metadata fields and optional external model-cache loading if retained. - [ ] **Account scheduling and retry:** Rust has in-memory slots, stale cleanup, strategies, tier priority, preferred/exclude IDs, SQLite startup restore, request-staggering metadata, 429/402/403 fallback across alternate accounts, Cloudflare cooldown skip, successful WebSocket rate-limit header application, non-history WebSocket fallback, previous-response history affinity, and status/token/logout mutation sync for the runtime pool. Missing strict request-interval sleep enforcement, durable quota/cache restore, Cloudflare path-block tracker, cooldown clearing, and richer capacity/health endpoints. diff --git a/src/http/admin/logs.rs b/src/http/admin/logs.rs index 84d1b0499..06a580f83 100644 --- a/src/http/admin/logs.rs +++ b/src/http/admin/logs.rs @@ -2,14 +2,14 @@ use axum::{ extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, response::IntoResponse, - Extension, + Extension, Json, }; use serde::{Deserialize, Serialize}; use crate::{ app::state::AppState, http::middleware::RequestId, - service::log::{ClearLogs, LogServiceError, LogState}, + service::log::{ClearLogs, LogServiceError, LogState, LogStateUpdate}, utils::pagination::clamp_limit, }; @@ -37,6 +37,14 @@ pub struct ClearLogsData { pub cleared: u64, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateLogStateRequest { + pub enabled: Option, + pub capacity: Option, + pub capture_body: Option, +} + pub async fn logs( State(state): State, Extension(request_id): Extension, @@ -73,6 +81,24 @@ pub async fn logs_state( } } +pub async fn update_logs_state( + State(state): State, + Extension(request_id): Extension, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + let request_id = request_id.as_str().to_string(); + require_admin_session(&state, &headers, &request_id).await?; + + match state.services.logs.update_state(payload.into()).await { + Ok(state) => Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(LogStateData::from(state), request_id), + )), + Err(error) => Err(log_service_error(error, request_id)), + } +} + pub async fn log_detail( State(state): State, Extension(request_id): Extension, @@ -133,6 +159,16 @@ impl From for ClearLogsData { } } +impl From for LogStateUpdate { + fn from(request: UpdateLogStateRequest) -> Self { + Self { + enabled: request.enabled, + capacity: request.capacity, + capture_body: request.capture_body, + } + } +} + fn log_service_error(error: LogServiceError, request_id: String) -> AdminError { match error { LogServiceError::RepositoryUnavailable => AdminError::new( @@ -165,5 +201,11 @@ fn log_service_error(error: LogServiceError, request_id: String) -> AdminError { "Failed to clear event logs", request_id, ), + LogServiceError::InvalidCapacity => AdminError::new( + StatusCode::UNPROCESSABLE_ENTITY, + 42201, + "logsCapacity must be greater than 0", + request_id, + ), } } diff --git a/src/http/admin/router.rs b/src/http/admin/router.rs index 1c4e5397b..6551fff24 100644 --- a/src/http/admin/router.rs +++ b/src/http/admin/router.rs @@ -22,7 +22,7 @@ use super::{ auth_logout, auth_status, login, }, diagnostics::diagnostics, - logs::{clear_logs, log_detail, logs, logs_state}, + logs::{clear_logs, log_detail, logs, logs_state, update_logs_state}, models::refresh_models, settings::{settings, update_settings}, usage::{usage_stats, usage_stats_summary}, @@ -45,7 +45,10 @@ pub fn router() -> Router { ) .route("/admin/diagnostics", get(diagnostics)) .route("/admin/logs", get(logs).delete(clear_logs)) - .route("/admin/logs/state", get(logs_state)) + .route( + "/admin/logs/state", + get(logs_state).patch(update_logs_state), + ) .route("/admin/logs/{log_id}", get(log_detail)) .route("/admin/settings", get(settings).patch(update_settings)) .route("/admin/refresh-models", post(refresh_models)) diff --git a/src/service/log.rs b/src/service/log.rs index de224d120..d1221b848 100644 --- a/src/service/log.rs +++ b/src/service/log.rs @@ -1,3 +1,7 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + use crate::{ config::LoggingConfig, logs::{event::EventLog, repository::EventLogRepository}, @@ -6,7 +10,7 @@ use crate::{ #[derive(Clone)] pub struct LogService { - config: LoggingConfig, + config: Arc>, repository: Option, } @@ -17,6 +21,7 @@ pub enum LogServiceError { Get, Count, Clear, + InvalidCapacity, } #[derive(Debug)] @@ -32,9 +37,19 @@ pub struct ClearLogs { pub cleared: u64, } +#[derive(Debug, Default)] +pub struct LogStateUpdate { + pub enabled: Option, + pub capacity: Option, + pub capture_body: Option, +} + impl LogService { pub fn new(config: LoggingConfig, repository: Option) -> Self { - Self { config, repository } + Self { + config: Arc::new(RwLock::new(config)), + repository, + } } pub async fn list( @@ -54,14 +69,36 @@ impl LogService { .count() .await .map_err(|_| LogServiceError::Count)?; + let config = self.config.read().await; Ok(LogState { - enabled: self.config.enabled, - capacity: self.config.capacity, - capture_body: self.config.capture_body, + enabled: config.enabled, + capacity: config.capacity, + capture_body: config.capture_body, stored_count, }) } + pub async fn update_state(&self, update: LogStateUpdate) -> Result { + if matches!(update.capacity, Some(0)) { + return Err(LogServiceError::InvalidCapacity); + } + + { + let mut config = self.config.write().await; + if let Some(enabled) = update.enabled { + config.enabled = enabled; + } + if let Some(capacity) = update.capacity { + config.capacity = capacity; + } + if let Some(capture_body) = update.capture_body { + config.capture_body = capture_body; + } + } + + self.state().await + } + pub async fn get(&self, id: &str) -> Result, LogServiceError> { self.repository()? .get(id) diff --git a/tests/admin_logs_route_test.rs b/tests/admin_logs_route_test.rs index 0356c22d1..d685cc146 100644 --- a/tests/admin_logs_route_test.rs +++ b/tests/admin_logs_route_test.rs @@ -185,6 +185,92 @@ async fn admin_logs_state_should_return_runtime_logging_state_and_stored_count() assert_eq!(body["data"]["storedCount"], 2); } +#[tokio::test] +async fn admin_logs_state_patch_should_require_admin_session_cookie() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-logs-state-patch-auth.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + let app = build_router(AppState::with_pool(test_config(url), pool)); + + let response = app + .oneshot( + Request::builder() + .method("PATCH") + .uri("/admin/logs/state") + .header("content-type", "application/json") + .header("x-request-id", "req_logs_state_patch") + .body(Body::from( + serde_json::json!({ "enabled": true }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = response_json(response).await; + assert_eq!(body["code"], 40101); + assert_eq!(body["requestId"], "req_logs_state_patch"); +} + +#[tokio::test] +async fn admin_logs_state_patch_should_update_runtime_logging_state() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-logs-state-patch.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let app = build_router(AppState::with_pool(test_config(url), pool)); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/admin/logs/state") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_logs_state_patch") + .body(Body::from( + serde_json::json!({ + "enabled": true, + "captureBody": true, + "capacity": 512 + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["code"], 200); + assert_eq!(body["requestId"], "req_logs_state_patch"); + assert_eq!(body["data"]["enabled"], true); + assert_eq!(body["data"]["captureBody"], true); + assert_eq!(body["data"]["capacity"], 512); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/admin/logs/state") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response_json(response).await; + assert_eq!(body["data"]["enabled"], true); + assert_eq!(body["data"]["captureBody"], true); + assert_eq!(body["data"]["capacity"], 512); +} + #[tokio::test] async fn admin_logs_detail_should_return_one_event_by_id() { let dir = tempfile::tempdir().unwrap();