Skip to content

fix(api): harden admin config and secret handling#371

Open
PyRo1121 wants to merge 7 commits intospacedriveapp:mainfrom
PyRo1121:codex/admin-hardening-secret-store
Open

fix(api): harden admin config and secret handling#371
PyRo1121 wants to merge 7 commits intospacedriveapp:mainfrom
PyRo1121:codex/admin-hardening-secret-store

Conversation

@PyRo1121
Copy link
Contributor

@PyRo1121 PyRo1121 commented Mar 8, 2026

What?

This PR hardens the admin plane so API-initiated configuration changes no longer write plaintext credentials into config.toml, narrows access to the highest-risk admin endpoints, and replaces a few documented-but-unimplemented maintenance paths with real behavior.

At a high level, this change does five things:

  1. Moves provider, messaging, and binding-supplied credentials into SecretsStore and writes secret: references into config instead of plaintext values.
  2. Tightens the HTTP API security posture by removing mirrored CORS, requiring configured auth for raw-config and secret-export surfaces, and refusing to start on a non-loopback bind without api.auth_token.
  3. Makes the API auth token live-reloadable so config reloads update middleware behavior instead of leaving the server in its startup auth state forever.
  4. Deduplicates Anthropic/OpenAI OAuth credential file persistence behind one shared helper.
  5. Replaces the memory maintenance merge stub with a real duplicate merge path that rewrites graph edges before deleting duplicates.

Why?

Before this change, the admin API had a few concrete weaknesses:

  • update_provider, create_messaging_instance, and create_binding could persist sensitive credentials directly into config.toml.
  • config/raw and secrets/export were only protected by the optional global API token. If api.auth_token was unset, these very sensitive admin surfaces were effectively open.
  • The HTTP server mirrored arbitrary Origin headers and would happily start on non-loopback binds without auth, which is too easy to misconfigure for a control-plane API.
  • The auth token in ApiState was fixed at startup. Updating config later did not change the middleware behavior.
  • Named messaging adapter permissions could be hot-started, but existing named adapters did not have a reload path for their permission ArcSwaps.
  • Memory maintenance reported a merge step but the core merge function was still a stub.

This PR fixes those issues without changing the intended local-dev workflow:

  • local loopback + no auth still works for general development,
  • externally reachable no-auth API startup no longer works,
  • the most sensitive admin endpoints now require configured auth even in otherwise local/dev-friendly setups.

How?

1. Shared admin config / secret plumbing

New helper module: src/api/admin.rs

This module centralizes the repeated admin-plane logic for:

  • loading and locking config.toml edits,
  • writing config changes safely,
  • reloading runtime configs after writes,
  • resolving the shared SecretsStore,
  • deriving secret: references from SystemSecrets metadata.

This removes the prior pattern where each admin handler open-coded its own read/parse/write/reload sequence and independently decided how to persist credentials.

2. Stop writing plaintext credentials into config

Changed handlers:

  • src/api/providers.rs
  • src/api/messaging.rs
  • src/api/bindings.rs

Behavior changes:

  • Provider updates now store provider secrets in SecretsStore and write secret:... references into [llm].
  • Messaging instance creation now stores secret-bearing fields (tokens, passwords, client secrets, webhook auth token, etc.) in SecretsStore and persists only references into config.toml.
  • Binding-driven platform bootstrap does the same, so the alternate admin path cannot bypass the secret store.

Important detail:

  • ollama_base_url stays plaintext because it is configuration, not a secret.
  • WebhookConfig now participates in SystemSecrets, so webhook auth tokens follow the same secret-store path and auto-categorization as the other admin-managed credentials.

3. Harden the HTTP API surface

Changed handlers / server wiring:

  • src/api/server.rs
  • src/api/settings.rs
  • src/api/secrets.rs
  • src/api/state.rs

Behavior changes:

  • CORS no longer mirrors arbitrary origins. It now only allows localhost / loopback origins for cross-origin browser access.
  • GET /api/config/raw, PUT /api/config/raw, and POST /api/secrets/export now require a configured api.auth_token. If the server is running without one, those endpoints return 403 instead of exposing sensitive content.
  • The server now refuses to start on a non-loopback bind without api.auth_token.
  • ApiState.auth_token is now live and reloadable. Config reloads update the middleware token instead of leaving the server on whatever auth state existed at startup.

4. Shared OAuth credential persistence

New helper module: src/oauth_storage.rs

Changed modules:

  • src/auth.rs
  • src/openai_auth.rs

This extracts the duplicated JSON credential save/load logic used by Anthropic and OpenAI OAuth persistence into one shared helper with tests. That gives us one place to maintain the on-disk policy and removes duplicate serialization / permission code.

5. Hot-reload permissions for named messaging adapters

Changed modules:

  • src/config/watcher.rs
  • src/main.rs

This introduces registries of the named adapter permission ArcSwaps for Discord, Slack, Telegram, and Twitch.

Before:

  • named adapters could be hot-started,
  • but existing named adapters had no external handle for permission reload,
  • so config edits changed permissions only after a restart.

After:

  • the watcher updates the existing ArcSwap for each named runtime key on config reload,
  • and newly hot-started named adapters register their permission handles into the same registry.

6. Replace the memory merge stub

Changed module:

  • src/memory/maintenance.rs

merge_similar_memories() is now a real implementation instead of returning Ok(0) unconditionally.

Implementation details:

  • it groups non-forgotten memories by type + normalized content,
  • picks a canonical memory using importance / access / recency ordering,
  • rewrites associations from the duplicate to the canonical memory,
  • skips self-loops,
  • deletes the duplicate memory after edges are preserved.

This is intentionally conservative. It does not attempt semantic embedding-based merge yet; it implements a real exact/normalized duplicate merge that is safe and testable with the current maintenance inputs.

Testing

Added targeted regression tests for:

  • provider secret persistence (update_provider writes secret: refs + stores value),
  • messaging instance secret persistence,
  • binding bootstrap secret persistence,
  • raw-config auth gating,
  • secret export auth gating,
  • OAuth JSON persistence round-trip,
  • local-only CORS predicate / bind protection helper,
  • duplicate memory merge edge-rewrite behavior.

Validation run:

  • cargo fmt
  • cargo fmt --check

Validation caveat

Full Rust build validation is currently blocked in this environment because lance-encoding's build script requires protoc, and protoc is not installed here.

The current cargo check failure is:

Error: Could not find `protoc`

So the PR includes formatting validation and targeted test additions, but I could not complete a full compile/test run from this machine.

Reviewer guide

Suggested review order:

  1. src/api/admin.rs
  2. src/api/providers.rs
  3. src/api/messaging.rs
  4. src/api/bindings.rs
  5. src/api/server.rs + src/api/settings.rs + src/api/secrets.rs
  6. src/config/watcher.rs + src/main.rs
  7. src/oauth_storage.rs, src/auth.rs, src/openai_auth.rs
  8. src/memory/maintenance.rs

Screenshots

N/A. This PR is backend / config-plane / maintenance work only and does not change the UI.

Note

This PR consolidates security hardening across the admin API surface. The core change moves sensitive credentials from plaintext config.toml writes into an encrypted SecretsStore, with secret: references persisted to config instead. Additionally, the HTTP API now gates raw config and secret export endpoints with mandatory authentication, refuses to bind on non-loopback addresses without configured auth tokens, and restricts CORS to localhost. The API auth token is now live-reloadable on config changes, named adapter permissions hot-reload via registries, and the memory maintenance merge function transitions from a stub to a real duplicate-merging implementation.

Written by Tembo for commit ac586cdd. This will update automatically on new commits.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 8, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds an admin helpers module and OAuth JSON helpers; migrates config mutations to use secret-backed references with persistence to a SecretsStore and hot-reload; enforces API auth on sensitive endpoints; threads per-runtime named permission registries through startup and watcher; implements memory deduplication; and updates many API/messaging/provider flows and tests.

Changes

Cohort / File(s) Summary
Admin helpers & OAuth storage
src/api/admin.rs, src/oauth_storage.rs, src/lib.rs
New admin-plane helpers to load/mutate/write TOML config, resolve/persist secret references, obtain SecretsStore, require API auth token, and reload runtime configs; new OAuth JSON credential helpers and pub mod oauth_storage.
Bindings & Messaging
src/api/bindings.rs, src/api/messaging.rs
Replaced direct config IO with load_config_doc/write_config_doc, persist platform credentials as secret references via secret_reference, reload runtime configs after writes, update in-memory bindings/permissions and start adapters conditionally; tests added.
Providers & Settings
src/api/providers.rs, src/api/settings.rs
Provider add/update/delete and settings endpoints now use admin helpers and secret references for keys; writes use centralized write+reload flow; get/update raw config endpoints enforce API auth and use config write mutex.
API state & Server
src/api/state.rs, src/api/server.rs, src/main.rs
ApiState.auth_token -> RwLock<Option<String>>, added set_api_auth_token and api_bind; server validates protected binds and local-origin CORS; added should_warn_unprotected_bind; threaded named-permission registries through startup (initialize_agents) and file-watcher wiring.
Secrets, Registry & Config types
src/api/secrets.rs, src/secrets/store.rs, src/config/types.rs
export_secrets now requires API auth; WebhookConfig implements SystemSecrets; secret registry extended for webhook fields; added named_twitch_token_file_name.
Config watcher & Permissions propagation
src/config/watcher.rs
Added per-runtime named_* permission registries and updated watcher/hot-start to update per-instance permission ArcSwap targets and use named_twitch_token_file_name for Twitch tokens; spawn_file_watcher signature extended.
Credential IO refactors
src/auth.rs, src/openai_auth.rs
Delegated credentials path/load/save to oauth_storage helpers, removing ad-hoc JSON IO logic.
Memory maintenance & types
src/memory/maintenance.rs, src/memory/types.rs
Implemented merge_similar_memories (normalization, clustering, canonical selection, association rewrites, deletion) with tests; MemoryType now derives Hash.
Tests & miscellaneous imports
src/api/..., src/main.rs, various imports
Added/updated unit tests for secret persistence, API auth guards, server helpers; adjusted imports to use admin helpers and pass named permission maps through initialization paths.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • jamiepine
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'fix(api): harden admin config and secret handling' accurately reflects the main objective of hardening the admin API surface and improving secret management practices.
Description check ✅ Passed The PR description provides comprehensive context detailing what changes were made, why they were necessary, and how they address security and implementation gaps in the admin API and credential handling.
Docstring Coverage ✅ Passed Docstring coverage is 89.53% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
require_api_auth_token(&state).await?;
let (_config_guard, config_path, _doc) = load_config_doc(&state).await?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load_config_doc() parses the existing file; if config.toml is already malformed, this blocks the raw editor from writing a fixed (validated) payload. You can keep the write mutex but skip parsing here.

Suggested change
let (_config_guard, config_path, _doc) = load_config_doc(&state).await?;
let _config_guard = state.config_write_mutex.lock().await;
let config_path = state.config_path.read().await.clone();
if config_path.as_os_str().is_empty() {
tracing::error!("config_path not set in ApiState");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}

Comment on lines +109 to +114
let auth_token = state.auth_token.read().await;
if auth_token.is_some() {
Ok(())
} else {
Err(StatusCode::FORBIDDEN)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This treats Some("") / whitespace as configured, which makes the auth-gated endpoints + non-loopback startup checks effectively no-op if someone sets api.auth_token = "". I’d treat empty/whitespace as missing.

Suggested change
let auth_token = state.auth_token.read().await;
if auth_token.is_some() {
Ok(())
} else {
Err(StatusCode::FORBIDDEN)
}
let auth_token = state.auth_token.read().await;
if auth_token
.as_deref()
.is_some_and(|token| !token.trim().is_empty())
{
Ok(())
} else {
Err(StatusCode::FORBIDDEN)
}

src/api/admin.rs Outdated
state: &Arc<ApiState>,
config_path: &Path,
) -> Result<crate::config::Config, StatusCode> {
let new_config = crate::config::Config::load_from_path(config_path).map_err(|error| {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor perf nit: Config::load_from_path() does sync fs IO (std::fs::read_to_string). Since this runs in async handlers, wrapping it in tokio::task::spawn_blocking would avoid blocking the runtime under load.

Comment on lines +204 to +205
.then_with(|| right.updated_at.cmp(&left.updated_at))
.then_with(|| right.created_at.cmp(&left.created_at))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these comparisons are flipped for max_by: right.updated_at.cmp(&left.updated_at) makes older timestamps "greater", so it can pick the least-recently-updated memory as canonical.

Suggested change
.then_with(|| right.updated_at.cmp(&left.updated_at))
.then_with(|| right.created_at.cmp(&left.created_at))
.then_with(|| left.updated_at.cmp(&right.updated_at))
.then_with(|| left.created_at.cmp(&right.created_at))

Comment on lines +22 to +29
std::fs::write(path, &data).with_context(|| format!("failed to write {}", path.display()))?;

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("failed to set permissions on {}", path.display()))?;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Unix there’s a small window where the file is written with default perms before set_permissions(0o600) runs. If you want to avoid that for newly-created files, open with mode(0o600) and write through the handle.

Suggested change
std::fs::write(path, &data).with_context(|| format!("failed to write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("failed to set permissions on {}", path.display()))?;
}
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)
.with_context(|| format!("failed to open {}", path.display()))?;
file.write_all(data.as_bytes())
.with_context(|| format!("failed to write {}", path.display()))?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("failed to set permissions on {}", path.display()))?;
}
#[cfg(not(unix))]
{
std::fs::write(path, &data)
.with_context(|| format!("failed to write {}", path.display()))?;
}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/config/watcher.rs (1)

223-243: ⚠️ Potential issue | 🟠 Major

Refresh named-instance permissions even when the default adapter is absent.

Each block nests the named-instance reload under Some(<platform>_permissions). In a named-only setup those global Options can be None, so Lines 231-242, 252-261, 272-283, and 293-302 never run and the per-instance ArcSwaps stop hot-reloading entirely.

Also applies to: 245-262, 264-284, 286-303

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/watcher.rs` around lines 223 - 243, The per-instance permission
refresh is currently nested inside the conditional that requires a default
discord_permissions (the `if let Some(ref perms) = discord_permissions && let
Some(discord_config) = &config.messaging.discord` block), which prevents
named-instance `ArcSwap`s from being updated when the default adapter is absent;
relocate the loop that reads `named_discord_permissions` and updates each
registry entry using `binding_runtime_adapter_key("discord",
Some(instance.name.as_str()))` and
`DiscordPermissions::from_instance_config(instance, &config.bindings)` so it
runs whenever `discord_config` is present (i.e., outside the
`Some(discord_permissions)` guard), keeping the existing update of the global
`perms.store(Arc::new(DiscordPermissions::from_config(...)))` where applicable.
src/api/messaging.rs (2)

1548-1556: ⚠️ Potential issue | 🟠 Major

Don't report success if the post-write reload failed.

If reload_runtime_configs errors here, the instance is already persisted but the handler still returns success and leaves state.bindings stale. This should propagate the reload failure instead of treating it as best-effort. As per coding guidelines, "Don't silently discard errors. No let _ = on Results. Handle them, log them, or propagate them."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/messaging.rs` around lines 1548 - 1556, The current post-write block
treats reload_runtime_configs(&state, &config_path).await as best-effort and
silently ignores failures; change it so reload_runtime_configs errors are
propagated (or explicitly handled) and only update state.bindings after a
successful reload. Specifically, replace the if let Ok(new_config) =
reload_runtime_configs(...) pattern with a propagation (using ? on the Result)
or explicit error handling, and move the
bindings_swap.store(std::sync::Arc::new(new_config.bindings.clone())) call so it
only runs when reload_runtime_configs returns Ok; ensure no Result is discarded
and the function returns the reload failure to the caller instead of reporting
success.

1558-1560: ⚠️ Potential issue | 🟠 Major

Named Twitch instances now use two different token-file names.

This path now relies on the watcher to start new adapters, and src/config/watcher.rs builds named Twitch token files as twitch_token_<safe>_<hash>.json. The manual start/delete paths in this file still use twitch_token_<safe>.json, so re-enable/delete won't reuse or clean up the same credential file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/messaging.rs` around lines 1558 - 1560, The manual start/re-enable
and delete credential paths in src/api/messaging.rs still construct token
filenames as twitch_token_<safe>.json while the watcher in src/config/watcher.rs
uses twitch_token_<safe>_<hash>.json; update the token-file naming logic used by
the manual start/re_enable_adapter and delete_adapter (or equivalent
start/delete handlers) to generate and look for twitch_token_{safe}_{hash}.json
(matching the watcher’s safe+hash scheme) so re-enable/delete will find and
remove the same credential files the watcher creates. Ensure any token-file
lookup, creation, and removal code in messaging.rs uses the same safe+hash
naming helper/function as watcher.rs.
🧹 Nitpick comments (3)
src/api/secrets.rs (1)

799-804: Exercise the configured-token branch too.

This only proves the FORBIDDEN path. If you also call state.set_api_auth_token(Some("test".into())).await and assert the handler falls through to the next guard (SERVICE_UNAVAILABLE in this harness), you'll verify that export_secrets() is actually consulting the live auth token.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/secrets.rs` around lines 799 - 804, The test
export_secrets_requires_configured_api_auth only checks the FORBIDDEN branch;
update it to also exercise the configured-token branch by calling
state.set_api_auth_token(Some("test".into())).await before invoking
export_secrets(State(state)), then assert the response status matches the next
guard path (SERVICE_UNAVAILABLE in this test harness). This verifies
export_secrets actually consults the live auth token; keep the existing
FORBIDDEN check and add the additional set_api_auth_token + response assertion
in the same test (or a small follow-up test) referencing export_secrets and
State(state).
src/secrets/store.rs (1)

1103-1119: Add a webhook regression assertion.

This registry change affects secret classification, but the nearby tests do not lock in WEBHOOK_AUTH_TOKEN. A small assertion in auto_categorize_known_patterns() and/or system_secret_registry_contains_all_sections() would keep a future removal from silently reclassifying webhook tokens as tool secrets.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/secrets/store.rs` around lines 1103 - 1119, Add a regression assertion
that ensures the webhook auth token remains classified as a webhook secret:
update the tests (preferably in auto_categorize_known_patterns() or
system_secret_registry_contains_all_sections()) to assert that
"WEBHOOK_AUTH_TOKEN" (or the constant used by WebhookConfig::secret_fields()) is
present in the resulting registry/fields after calling the classification code;
this locks in the behavior so future changes to DefaultsConfig/LlmConfig/other
secret_fields() cannot silently reclassify the webhook token as a tool/internal
secret.
src/main.rs (1)

1509-1516: Consider using the type aliases from watcher.rs for readability.

The type aliases NamedDiscordPermissions, NamedSlackPermissions, etc. are defined in src/config/watcher.rs (lines 23-29) and match these exact types. Reusing them here would reduce verbosity and maintain consistency across the codebase.

♻️ Proposed refactor using type aliases

At the top of the file, add imports:

use spacebot::config::{
    NamedDiscordPermissions, NamedSlackPermissions, 
    NamedTelegramPermissions, NamedTwitchPermissions
};

Or if the aliases aren't exported, define local type aliases to match watcher.rs:

+type NamedDiscordPermissions =
+    Arc<std::sync::RwLock<HashMap<String, Arc<ArcSwap<spacebot::config::DiscordPermissions>>>>>;
+// ... similar for other types
+
 let named_discord_permissions =
-    Arc::new(std::sync::RwLock::new(std::collections::HashMap::new()));
+    NamedDiscordPermissions::default();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main.rs` around lines 1509 - 1516, Replace the verbose
Arc<RwLock<HashMap<...>>> declarations with the existing type aliases to improve
readability: import or reference NamedDiscordPermissions, NamedSlackPermissions,
NamedTelegramPermissions, and NamedTwitchPermissions (from config::watcher or
re-exported module) and initialize named_discord_permissions,
named_slack_permissions, named_telegram_permissions, and
named_twitch_permissions using Arc::new(RwLock::new(<alias>::default() or
HashMap::new() wrapped appropriately)) so the variables use the aliases instead
of explicit Arc<RwLock<HashMap<...>>> types.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/admin.rs`:
- Around line 58-95: Multiple call sites are currently discarding the Result
from reload_runtime_configs; update each call to either handle errors locally or
propagate them: replace bare .await calls with either if let Err(e) =
reload_runtime_configs(...).await { tracing::warn!(%e, "reload_runtime_configs
failed for context: {context_info}"); } using relevant context (agent_id,
provider id, or config_path) for the log, or if the caller is an HTTP handler
that returns Result<..., StatusCode> propagate with ? so the error flows to the
client; ensure you reference the reload_runtime_configs(...) invocation and add
a tracing::warn!(%e, ...) or ? as appropriate at each site.

In `@src/api/bindings.rs`:
- Around line 495-503: The handler currently ignores failures from
reload_runtime_configs after write_config_doc (using if let Ok(...)), which can
leave on-disk state inconsistent with in-memory state; change the logic to
propagate the error instead of treating reload as best-effort — call
reload_runtime_configs(&state, &config_path).await and propagate any Err (via ?
or explicit match returning the error) so the handler returns a failure if
reload fails, ensuring write_config_doc, reload_runtime_configs, and any use of
new_config are handled correctly.

In `@src/api/providers.rs`:
- Around line 327-328: The handlers finalize_openai_oauth, update_provider, and
delete_provider currently discard the Config returned by reload_runtime_configs
and either re-read disk or never refresh defaults_config; change each to capture
the returned new_config from reload_runtime_configs(state, &config_path).await
and immediately call
state.set_defaults_config(new_config.defaults.clone()).await instead of doing a
second disk read or skipping the update; apply the same change to the other
occurrences noted (the blocks around the other reload_runtime_configs calls) so
defaults_config is updated from the returned Config in all places.

In `@src/api/server.rs`:
- Around line 40-45: Normalize empty/blank auth tokens to None wherever auth is
read and when configs are reloaded so a blank string doesn't count as
configured; specifically update reload_runtime_configs to convert
Some("")/all-whitespace to None before writing into state.auth_token, and update
the places that read state.auth_token (the auth_token clone used with
should_warn_unprotected_bind and the runtime per-request check) to treat blank
tokens as None (e.g., map/normalize before as_deref()) so
should_warn_unprotected_bind(bind, auth_token.as_deref()) and the per-request
authorization logic never inadvertently accept an empty token and the startup
public-bind guard remains effective if auth is cleared.

In `@src/api/settings.rs`:
- Around line 323-325: The current write_config_doc call is followed by
discarding the Result from reload_runtime_configs (let _ =
reload_runtime_configs(&state, &config_path).await;) which both ignores errors
and fails to update the in-memory defaults cache; change this to capture the
returned Config, propagate any error instead of silencing it, and refresh the
in-memory defaults by calling
state.set_defaults_config(new_config.defaults.clone()).await using the returned
new_config from reload_runtime_configs (refer to reload_runtime_configs,
state.set_defaults_config, and write_config_doc to locate the change).

In `@src/config/types.rs`:
- Around line 2331-2350: The SystemSecrets impl for WebhookConfig was added but
migrate_secrets() still uses a hard-coded list of sections and never invokes
migrate_section_secrets::<WebhookConfig>(), leaving messaging.webhook.auth_token
un-migrated; update migrate_secrets() to include the webhook section by calling
migrate_section_secrets::<WebhookConfig>() (or add "messaging.webhook" to the
existing hard-coded migration flow) so the WebhookConfig secret_fields
(auth_token / WEBHOOK_AUTH_TOKEN) are processed during migration.

In `@src/memory/maintenance.rs`:
- Around line 151-163: The grouping key uses MemoryType in a HashMap key but
MemoryType isn't Hash, so update the MemoryType type's derive list (the
enum/struct that currently derives Debug, Clone, Copy, Serialize, Deserialize,
PartialEq, Eq) to also derive Hash so HashMap<(MemoryType, String), Vec<Memory>>
compiles; locate the MemoryType definition (the type that currently lists those
derives) and add Hash to its derive attributes.
- Around line 197-205: The selector select_canonical_memory prefers older
memories on ties because the updated_at and created_at comparisons are reversed;
change those comparisons so newer timestamps win by comparing
left.updated_at.cmp(&right.updated_at) and
left.created_at.cmp(&right.created_at) (keeping the existing
importance.total_cmp and access_count.cmp ordering) so ties pick the most
recently updated/created memory as canonical.

In `@src/oauth_storage.rs`:
- Around line 19-29: save_json_credentials currently writes the serialized JSON
via std::fs::write and then calls set_permissions, which can leave the file
world-readable if permissions change fails; instead open the file atomically
with restrictive permissions using std::fs::OpenOptions in
save_json_credentials: create an OpenOptions builder, set
write(true).create(true).truncate(true) and on Unix call .mode(0o600) before
opening, then write the serialized data into that file handle (replacing
std::fs::write) so the credential file is created with 0o600 from the start and
avoids the race/failure window where set_permissions might fail.

---

Outside diff comments:
In `@src/api/messaging.rs`:
- Around line 1548-1556: The current post-write block treats
reload_runtime_configs(&state, &config_path).await as best-effort and silently
ignores failures; change it so reload_runtime_configs errors are propagated (or
explicitly handled) and only update state.bindings after a successful reload.
Specifically, replace the if let Ok(new_config) = reload_runtime_configs(...)
pattern with a propagation (using ? on the Result) or explicit error handling,
and move the
bindings_swap.store(std::sync::Arc::new(new_config.bindings.clone())) call so it
only runs when reload_runtime_configs returns Ok; ensure no Result is discarded
and the function returns the reload failure to the caller instead of reporting
success.
- Around line 1558-1560: The manual start/re-enable and delete credential paths
in src/api/messaging.rs still construct token filenames as
twitch_token_<safe>.json while the watcher in src/config/watcher.rs uses
twitch_token_<safe>_<hash>.json; update the token-file naming logic used by the
manual start/re_enable_adapter and delete_adapter (or equivalent start/delete
handlers) to generate and look for twitch_token_{safe}_{hash}.json (matching the
watcher’s safe+hash scheme) so re-enable/delete will find and remove the same
credential files the watcher creates. Ensure any token-file lookup, creation,
and removal code in messaging.rs uses the same safe+hash naming helper/function
as watcher.rs.

In `@src/config/watcher.rs`:
- Around line 223-243: The per-instance permission refresh is currently nested
inside the conditional that requires a default discord_permissions (the `if let
Some(ref perms) = discord_permissions && let Some(discord_config) =
&config.messaging.discord` block), which prevents named-instance `ArcSwap`s from
being updated when the default adapter is absent; relocate the loop that reads
`named_discord_permissions` and updates each registry entry using
`binding_runtime_adapter_key("discord", Some(instance.name.as_str()))` and
`DiscordPermissions::from_instance_config(instance, &config.bindings)` so it
runs whenever `discord_config` is present (i.e., outside the
`Some(discord_permissions)` guard), keeping the existing update of the global
`perms.store(Arc::new(DiscordPermissions::from_config(...)))` where applicable.

---

Nitpick comments:
In `@src/api/secrets.rs`:
- Around line 799-804: The test export_secrets_requires_configured_api_auth only
checks the FORBIDDEN branch; update it to also exercise the configured-token
branch by calling state.set_api_auth_token(Some("test".into())).await before
invoking export_secrets(State(state)), then assert the response status matches
the next guard path (SERVICE_UNAVAILABLE in this test harness). This verifies
export_secrets actually consults the live auth token; keep the existing
FORBIDDEN check and add the additional set_api_auth_token + response assertion
in the same test (or a small follow-up test) referencing export_secrets and
State(state).

In `@src/main.rs`:
- Around line 1509-1516: Replace the verbose Arc<RwLock<HashMap<...>>>
declarations with the existing type aliases to improve readability: import or
reference NamedDiscordPermissions, NamedSlackPermissions,
NamedTelegramPermissions, and NamedTwitchPermissions (from config::watcher or
re-exported module) and initialize named_discord_permissions,
named_slack_permissions, named_telegram_permissions, and
named_twitch_permissions using Arc::new(RwLock::new(<alias>::default() or
HashMap::new() wrapped appropriately)) so the variables use the aliases instead
of explicit Arc<RwLock<HashMap<...>>> types.

In `@src/secrets/store.rs`:
- Around line 1103-1119: Add a regression assertion that ensures the webhook
auth token remains classified as a webhook secret: update the tests (preferably
in auto_categorize_known_patterns() or
system_secret_registry_contains_all_sections()) to assert that
"WEBHOOK_AUTH_TOKEN" (or the constant used by WebhookConfig::secret_fields()) is
present in the resulting registry/fields after calling the classification code;
this locks in the behavior so future changes to DefaultsConfig/LlmConfig/other
secret_fields() cannot silently reclassify the webhook token as a tool/internal
secret.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1dbc3aba-8b94-47d7-a9ed-3d0f6fa74f19

📥 Commits

Reviewing files that changed from the base of the PR and between 203c9d9 and ac586cd.

📒 Files selected for processing (18)
  • src/api.rs
  • src/api/admin.rs
  • src/api/bindings.rs
  • src/api/messaging.rs
  • src/api/providers.rs
  • src/api/secrets.rs
  • src/api/server.rs
  • src/api/settings.rs
  • src/api/state.rs
  • src/auth.rs
  • src/config/types.rs
  • src/config/watcher.rs
  • src/lib.rs
  • src/main.rs
  • src/memory/maintenance.rs
  • src/oauth_storage.rs
  • src/openai_auth.rs
  • src/secrets/store.rs

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (2)
src/api/settings.rs (2)

323-324: ⚠️ Potential issue | 🟡 Minor

Silently discarding reload_runtime_configs result violates coding guidelines.

The Result from reload_runtime_configs is discarded with let _ =. Per coding guidelines, errors must not be silently discarded. Additionally, the returned Config could be used to refresh state.defaults_config.

🛡️ Proposed fix to handle errors and use returned config
     write_config_doc(&config_path, &doc).await?;
-    let _ = reload_runtime_configs(&state, &config_path).await;
+    match reload_runtime_configs(&state, &config_path).await {
+        Ok(new_config) => {
+            state.set_defaults_config(new_config.defaults.clone()).await;
+        }
+        Err(status) => {
+            tracing::warn!(?status, "config reload failed after settings update");
+        }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/settings.rs` around lines 323 - 324, The call to
reload_runtime_configs currently discards its Result with "let _ =", which
violates error-handling rules and misses the returned Config; change the call to
properly handle the Result from reload_runtime_configs(&state,
&config_path).await by matching or using ?/map_err to propagate or log errors
instead of ignoring them, and when it succeeds use the returned Config to update
state.defaults_config (or otherwise refresh state) so the in-memory defaults
reflect the written doc; ensure you reference reload_runtime_configs,
state.defaults_config, write_config_doc, and config_path when making the change.

432-432: ⚠️ Potential issue | 🟡 Minor

Same issue: silently discarding reload_runtime_configs result.

Same fix pattern should be applied here.

🛡️ Proposed fix
-    let _ = reload_runtime_configs(&state, &config_path).await;
+    match reload_runtime_configs(&state, &config_path).await {
+        Ok(new_config) => {
+            state.set_defaults_config(new_config.defaults.clone()).await;
+        }
+        Err(status) => {
+            tracing::warn!(?status, "config reload failed after raw config update");
+        }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/settings.rs` at line 432, The call to reload_runtime_configs(&state,
&config_path).await is currently ignoring its Result; update the call in
settings.rs (where reload_runtime_configs is invoked with variables state and
config_path) to handle the Result instead of discarding it — for example, match
or use if let Err(err) = reload_runtime_configs(&state, &config_path).await { /*
log error via your logger (e.g., error!("...: {:?}", err)) or handle failure
appropriately */ } so failures are surfaced and not silently dropped.
🧹 Nitpick comments (2)
src/oauth_storage.rs (1)

37-38: Redundant set_permissions call after atomic creation with mode(0o600).

Since the file is already created with mode(0o600) via OpenOptions at line 32, the subsequent set_permissions call is unnecessary. The atomic creation ensures the file never exists with broader permissions.

♻️ Proposed removal of redundant call
         file.write_all(data.as_bytes())
             .with_context(|| format!("failed to write {}", path.display()))?;
-        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
-            .with_context(|| format!("failed to set permissions on {}", path.display()))?;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/oauth_storage.rs` around lines 37 - 38, Remove the redundant
std::fs::set_permissions(...) call in src/oauth_storage.rs that follows atomic
creation via OpenOptions::mode(0o600); specifically delete the
std::fs::set_permissions(path,
std::fs::Permissions::from_mode(0o600)).with_context(|| format!("failed to set
permissions on {}", path.display()))?; line(s) and keep the existing OpenOptions
creation/error handling intact so no additional permission-setting or context
wrapping remains.
src/main.rs (1)

2382-2401: Consider a type alias to reduce signature complexity.

The initialize_agents function signature is quite verbose with four nearly-identical permission registry types. A type alias could improve readability without changing behavior.

♻️ Example type alias
// At module level:
type NamedPermissionsMap<T> = Arc<std::sync::RwLock<std::collections::HashMap<String, Arc<ArcSwap<T>>>>>;

// Then in the signature:
named_discord_permissions: NamedPermissionsMap<spacebot::config::DiscordPermissions>,
named_slack_permissions: NamedPermissionsMap<spacebot::config::SlackPermissions>,
// etc.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main.rs` around lines 2382 - 2401, The function signature for
initialize_agents contains four repeated verbose permission registry types;
define a module-level generic type alias (e.g., NamedPermissionsMap<T>) that
maps T to Arc<RwLock<HashMap<String, Arc<ArcSwap<T>>>>>, then replace the
explicit types for named_discord_permissions, named_slack_permissions,
named_telegram_permissions, and named_twitch_permissions in initialize_agents
with NamedPermissionsMap<spacebot::config::DiscordPermissions> (and the
corresponding SlackPermissions, TelegramPermissions, TwitchPermissions) and
update any matching parameter usages to use the alias; ensure the alias is
declared in the same module so ArcSwap and the std types resolve without
additional changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/memory/maintenance.rs`:
- Around line 233-235: The current upsert via
memory_store.create_association(Association::new(...).with_weight(...)) can
overwrite a stronger existing canonical edge with a weaker rewritten one; change
the logic to first read the existing association for the same (source_id,
target_id, relation_type), compute a deterministic combined weight (e.g.
max(existing.weight, association.weight)), set the new Association's weight to
that max via with_weight, and then call memory_store.create_association with the
combined-weight Association (or, if the store supports an upsert/combine API,
call that with a combine function that returns the max weight) so the canonical
stronger edge is preserved instead of last-writer-wins.
- Around line 241-268: The parser currently coerces unknown memory_type strings
to MemoryType::Fact; change parse_memory_type to return Result<MemoryType,
anyhow::Error> (or a suitable error type) and return Err when the input doesn't
match a known variant, then change row_to_memory to return Result<Memory,
anyhow::Error> and propagate that error (including the offending string and row
id) instead of constructing a Memory with a default; update all callers of
row_to_memory to propagate or abort the run (bubble the error up) so the process
stops on unknown/corrupt enum values rather than silently converting them.
- Around line 130-136: The function merge_similar_memories currently ignores the
similarity_threshold and only deduplicates exact normalized-string matches;
update the logic in merge_similar_memories (and the same pattern in the other
similar blocks) to compute a real similarity score for each candidate pair
(e.g., cosine similarity of stored embeddings or normalized string similarity
like Jaro-Winkler/Levenshtein) and compare that score to similarity_threshold
before merging, or if embeddings/similarity code is not available, rename the
function/parameter to indicate exact-match deduplication (e.g.,
merge_exact_matches and exact_match_only) and remove the misleading threshold
parameter; ensure you reference MemoryStore methods used to fetch
text/embeddings and adjust the merge path to use the computed score for the
comparison.
- Around line 151-163: The grouping currently collapses different channels
because groups is keyed only by (MemoryType, normalized) — change the grouping
key to include each Memory's channel identifier (e.g., use (memory.memory_type,
memory.channel_id.clone(), normalized) as the HashMap key), update the HashMap
type declaration accordingly, and adjust the .entry(...) call to use that
three-tuple so memories from different channels are kept in separate groups;
reference normalize_memory_content, groups, and the Memory struct's channel_id
when making these edits.

---

Duplicate comments:
In `@src/api/settings.rs`:
- Around line 323-324: The call to reload_runtime_configs currently discards its
Result with "let _ =", which violates error-handling rules and misses the
returned Config; change the call to properly handle the Result from
reload_runtime_configs(&state, &config_path).await by matching or using
?/map_err to propagate or log errors instead of ignoring them, and when it
succeeds use the returned Config to update state.defaults_config (or otherwise
refresh state) so the in-memory defaults reflect the written doc; ensure you
reference reload_runtime_configs, state.defaults_config, write_config_doc, and
config_path when making the change.
- Line 432: The call to reload_runtime_configs(&state, &config_path).await is
currently ignoring its Result; update the call in settings.rs (where
reload_runtime_configs is invoked with variables state and config_path) to
handle the Result instead of discarding it — for example, match or use if let
Err(err) = reload_runtime_configs(&state, &config_path).await { /* log error via
your logger (e.g., error!("...: {:?}", err)) or handle failure appropriately */
} so failures are surfaced and not silently dropped.

---

Nitpick comments:
In `@src/main.rs`:
- Around line 2382-2401: The function signature for initialize_agents contains
four repeated verbose permission registry types; define a module-level generic
type alias (e.g., NamedPermissionsMap<T>) that maps T to
Arc<RwLock<HashMap<String, Arc<ArcSwap<T>>>>>, then replace the explicit types
for named_discord_permissions, named_slack_permissions,
named_telegram_permissions, and named_twitch_permissions in initialize_agents
with NamedPermissionsMap<spacebot::config::DiscordPermissions> (and the
corresponding SlackPermissions, TelegramPermissions, TwitchPermissions) and
update any matching parameter usages to use the alias; ensure the alias is
declared in the same module so ArcSwap and the std types resolve without
additional changes.

In `@src/oauth_storage.rs`:
- Around line 37-38: Remove the redundant std::fs::set_permissions(...) call in
src/oauth_storage.rs that follows atomic creation via OpenOptions::mode(0o600);
specifically delete the std::fs::set_permissions(path,
std::fs::Permissions::from_mode(0o600)).with_context(|| format!("failed to set
permissions on {}", path.display()))?; line(s) and keep the existing OpenOptions
creation/error handling intact so no additional permission-setting or context
wrapping remains.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 332933c8-a80e-4f0c-88a1-4ccc1dea4bc0

📥 Commits

Reviewing files that changed from the base of the PR and between ac586cd and e717bca.

📒 Files selected for processing (7)
  • src/api/admin.rs
  • src/api/server.rs
  • src/api/settings.rs
  • src/main.rs
  • src/memory/maintenance.rs
  • src/memory/types.rs
  • src/oauth_storage.rs

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
src/api/server.rs (1)

334-347: ⚠️ Potential issue | 🟠 Major

Normalize blank auth tokens in the middleware path too.

Line 335 still treats Some("") / whitespace as configured, so any direct write to state.auth_token can make Authorization: Bearer pass even though the startup guard already treats blank tokens as missing. Trim here as well instead of relying on setter-only invariants.

Suggested hardening
-    let expected_token = state.auth_token.read().await.clone();
-    let Some(expected_token) = expected_token.as_deref() else {
+    let expected_token = state.auth_token.read().await.clone().and_then(|token| {
+        let trimmed = token.trim();
+        (!trimmed.is_empty()).then(|| trimmed.to_owned())
+    });
+    let Some(expected_token) = expected_token.as_deref() else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/server.rs` around lines 334 - 347, The middleware currently treats
Some("") or whitespace-only auth tokens as configured; update the logic around
state.auth_token/read() and the local expected_token handling so blank or
all-whitespace tokens are treated as missing—i.e., after reading/cloning
expected_token (the variable named expected_token created from
state.auth_token.read().await.clone()), trim it and treat empty results as None
before the early public-listener check that uses state.api_bind and before
calling next.run(request). Replace the current Some(...) path with a
trimmed-and-empty-filtered check so Authorization: Bearer (empty) no longer
bypasses auth while keeping the existing api_bind and next.run(request)
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/admin.rs`:
- Around line 155-176: The code currently trims the input value into
trimmed_value and then persists trimmed_value, which can alter valid secrets;
change the logic in the handler so trim() is used only to check emptiness (use
trimmed_value.is_empty()) but pass the original unmodified value to store.set;
locate the variable/value handling around trimmed_value and the call
store.set(&secret_name, trimmed_value, category) and replace that argument with
the original value (or its owned String) while keeping the emptiness check on
trimmed_value, leaving secret_name, category deduction, and error handling
unchanged.

In `@src/api/bindings.rs`:
- Around line 553-599: The failure branches for Slack adapter construction/start
currently only log errors (tracing::error!) and let the handler continue
returning a success payload; update the SlackAdapter::new error branch and the
manager.register_and_start(adapter).await Err branch (and the analogous blocks
around lines 620-670) to surface the failure in the HTTP response: either return
an Err(StatusCode::INTERNAL_SERVER_ERROR) immediately or mark the response
payload's restart_required/warning field to true and include a descriptive
message so callers can distinguish "saved" vs "running"; ensure you use the same
response type the handler returns and keep the tracing::error! log but also set
the response accordingly so failed Slack adapter creation/start is not reported
as active.
- Line 207: load_config_doc(&state).await? returns a guard that holds
config_write_mutex, so keep holding it only for the write+reload work and
release it before calling register_and_start(...) (which runs adapter login).
After obtaining (_config_guard, config_path, mut doc) from load_config_doc,
explicitly drop the guard (or let it go out of scope by moving the write/reload
into a narrow block) before any loop or calls to register_and_start; update the
remaining occurrences in this file (also around the 503-673 region) to ensure
the config guard is released prior to starting adapters.

In `@src/api/server.rs`:
- Around line 367-381: The CORS predicate in allow_local_origin only checks for
"http://" prefixes and exact matches; update it to also accept the equivalent
"https://" origins (e.g., add starts_with checks for "https://127.0.0.1:",
"https://localhost:", "https://[::1]:" and equality checks for
"https://127.0.0.1", "https://localhost", "https://[::1]") so loopback HTTPS dev
setups are allowed; apply the same change to the other identical predicate
referenced in the same file (the second allow_local_origin-style check around
the later block).

---

Duplicate comments:
In `@src/api/server.rs`:
- Around line 334-347: The middleware currently treats Some("") or
whitespace-only auth tokens as configured; update the logic around
state.auth_token/read() and the local expected_token handling so blank or
all-whitespace tokens are treated as missing—i.e., after reading/cloning
expected_token (the variable named expected_token created from
state.auth_token.read().await.clone()), trim it and treat empty results as None
before the early public-listener check that uses state.api_bind and before
calling next.run(request). Replace the current Some(...) path with a
trimmed-and-empty-filtered check so Authorization: Bearer (empty) no longer
bypasses auth while keeping the existing api_bind and next.run(request)
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7703d7e6-3922-453b-a99d-f206455caf25

📥 Commits

Reviewing files that changed from the base of the PR and between e717bca and dffb898.

📒 Files selected for processing (8)
  • src/api/admin.rs
  • src/api/bindings.rs
  • src/api/messaging.rs
  • src/api/providers.rs
  • src/api/secrets.rs
  • src/api/server.rs
  • src/api/settings.rs
  • src/api/state.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/api/secrets.rs

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/memory/maintenance.rs`:
- Around line 182-195: The current seed-only clustering in the loop that uses
normalized_similarity and similarity_threshold can miss transitive duplicates;
change the clustering in the function (the while let Some((seed_memory,
seed_normalized)) { ... } block) to perform BFS/expansion: initialize a queue
with the seed_normalized (and seed_memory), iterate popping items from the queue
and compare each remaining (memory, normalized) to the popped normalized, and
when similarity >= similarity_threshold push that memory into duplicates and
also enqueue its normalized for further comparisons; only after the expansion
finishes set remaining to the next_remaining of those not matched. This builds
connected components (or at least expands clusters) instead of only comparing to
the original seed.
- Around line 139-141: The code currently returns Ok(0) when
similarity_threshold is outside 0.0..=1.0 which hides misconfiguration; replace
that silent no-op with a proper error return. In the block testing
similarity_threshold (related to merge_similarity_threshold), return an Err with
a clear message including the invalid value (e.g., "invalid
merge_similarity_threshold: {value}, must be in 0.0..=1.0") using the function's
existing error type (e.g., anyhow::bail! or return Err(...) so the caller fails
fast) instead of Ok(0).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fba9e2e8-8886-4937-ba6e-bfd567b183b3

📥 Commits

Reviewing files that changed from the base of the PR and between dffb898 and 62504d8.

📒 Files selected for processing (1)
  • src/memory/maintenance.rs

@PyRo1121
Copy link
Contributor Author

PyRo1121 commented Mar 8, 2026

Follow-up for commit 8809788

This pass addressed the remaining active bot/review notes that were still actionable on the branch.

What changed:

  • Preserved secret values exactly as submitted in src/api/admin.rs instead of trimming before persistence. Trimming is now only used for emptiness validation.
  • Hardened the API auth middleware in src/api/server.rs so blank/whitespace tokens are treated as missing on read, not just at the setter boundary.
  • Expanded loopback CORS allowances in src/api/server.rs to include HTTPS loopback origins (https://localhost, https://127.0.0.1, https://[::1]) and added regression coverage for that path.
  • Added a shared named_twitch_token_file_name() helper in src/config/types.rs and switched the watcher, main initialization, and messaging runtime paths to the same hashed Twitch token filename scheme.
  • Updated src/config/watcher.rs so named-instance permission registries are refreshed even when the default adapter is absent, which fixes named-only setups.
  • Updated src/api/bindings.rs to release the config mutex before adapter login/start work and to surface adapter activation failures back through the HTTP response instead of always reporting success.
  • Added the missing configured-auth branch coverage for export_secrets() in src/api/secrets.rs and locked in webhook token classification in src/secrets/store.rs.
  • Introduced an async-aware shared test lock for the API tests that mutate the process-wide secret resolver so the full cargo test --lib run stays deterministic.

Local validation on this commit passed with a local protoc binary:

  • cargo check --all-targets
  • cargo clippy --all-targets -- -D warnings
  • cargo test --lib -q

Result: 505 passed; 0 failed locally before push.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/api/messaging.rs (1)

1501-1533: ⚠️ Potential issue | 🟠 Major

Reject named webhook instances until the runtime supports them.

This writes [[messaging.webhook.instances]], but WebhookConfig has no instances field and is_named_adapter_platform() excludes "webhook". The API can therefore return success for an adapter that bindings, status, and runtime startup never actually use. Reject request.name for webhook until multi-instance support exists end-to-end.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/messaging.rs` around lines 1501 - 1533, The code currently writes
[[messaging.webhook.instances]] and accepts request.name for "webhook" even
though WebhookConfig lacks instances and is_named_adapter_platform() excludes
"webhook"; update the request validation to reject named webhook instances by
checking if the platform equals "webhook" and request.name.is_some() (return an
appropriate validation/error result), and prevent creating or appending an
"instances" table for webhook in the block that handles the "webhook" match arm
and the subsequent platform_table.instances insertion; reference the "webhook"
match arm, the instance-table insertion logic
(platform_table.insert/get_mut/...as_array_of_tables_mut/instances.push),
WebhookConfig, is_named_adapter_platform(), and request.name to locate where to
add this check.
🧹 Nitpick comments (1)
src/config/watcher.rs (1)

356-368: Minor: std::sync::RwLock::write() in async context.

Using std::sync::RwLock inside rt.spawn(async move { ... }) blocks the async runtime while holding the lock. The critical section (a single insert) is tiny so this is unlikely to cause issues in practice, but if contention grows, consider switching to tokio::sync::RwLock or wrapping the write in spawn_blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/watcher.rs` around lines 356 - 368, The code is holding a
std::sync::RwLock write guard inside async work (the
named_discord_permissions.write().expect("lock
poisoned").insert(runtime_key.clone(), permissions.clone()) call inside the
async hot-start path); replace this blocking lock with an async-aware lock or
offload the write: either change named_discord_permissions to a
tokio::sync::RwLock and use .write().await before .insert, or move the insert
into a blocking task via tokio::task::spawn_blocking, ensuring runtime_key and
permissions are cloned into that closure; keep the rest of the flow (creating
DiscordAdapter and calling manager.register_and_start) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/server.rs`:
- Around line 370-390: The allow_local_origin function currently matches only
three literal hosts; change it to parse the origin into a URL (e.g., using
url::Url::parse) and validate scheme is http or https, then extract
url.host_str() and accept if the host is "localhost" or if
host.parse::<std::net::IpAddr>().map_or(false, |ip| ip.is_loopback()); keep the
existing port-agnostic behavior and return false on parse errors—update
allow_local_origin to use this host/is_loopback logic instead of the current
starts_with / equality checks.

In `@src/config/types.rs`:
- Around line 1557-1569: The function named_twitch_token_file_name currently
uses std::collections::hash_map::DefaultHasher which is unstable across Rust
versions; replace that with a stable cryptographic digest (e.g., blake3 or
SHA-256) to produce a deterministic filename. Keep the safe_name sanitization,
compute a stable hex digest from name.as_bytes() using a chosen crate (for
example blake3::hash or sha2::Sha256), optionally truncate to a fixed length for
readability, and use that hex digest in the existing
format!("twitch_token_{safe_name}_{name_hash:016x}.json") (adjust formatting
token to a hex string). Add the digest crate to Cargo.toml and update imports;
ensure the function name named_twitch_token_file_name remains unchanged.

In `@src/oauth_storage.rs`:
- Around line 19-43: save_json_credentials currently truncates the target file
before the new JSON is fully written, risking corruption; change it to write
atomically by creating a same-directory temporary file (e.g., path with a
random/suffix tmp name), write the pretty JSON to that temp file, call
file.sync_all() and set permissions (on unix set mode 0o600 via OpenOptionsExt)
before renaming, then atomically replace the original with
std::fs::rename(temp_path, path); after rename, sync the parent directory
(std::fs::File::open(dir)?.sync_all()) to ensure durability; keep this behavior
for both cfg(unix) and cfg(not(unix)) branches and ensure errors are propagated
from the functions used in save_json_credentials.

---

Outside diff comments:
In `@src/api/messaging.rs`:
- Around line 1501-1533: The code currently writes
[[messaging.webhook.instances]] and accepts request.name for "webhook" even
though WebhookConfig lacks instances and is_named_adapter_platform() excludes
"webhook"; update the request validation to reject named webhook instances by
checking if the platform equals "webhook" and request.name.is_some() (return an
appropriate validation/error result), and prevent creating or appending an
"instances" table for webhook in the block that handles the "webhook" match arm
and the subsequent platform_table.instances insertion; reference the "webhook"
match arm, the instance-table insertion logic
(platform_table.insert/get_mut/...as_array_of_tables_mut/instances.push),
WebhookConfig, is_named_adapter_platform(), and request.name to locate where to
add this check.

---

Nitpick comments:
In `@src/config/watcher.rs`:
- Around line 356-368: The code is holding a std::sync::RwLock write guard
inside async work (the named_discord_permissions.write().expect("lock
poisoned").insert(runtime_key.clone(), permissions.clone()) call inside the
async hot-start path); replace this blocking lock with an async-aware lock or
offload the write: either change named_discord_permissions to a
tokio::sync::RwLock and use .write().await before .insert, or move the insert
into a blocking task via tokio::task::spawn_blocking, ensuring runtime_key and
permissions are cloned into that closure; keep the rest of the flow (creating
DiscordAdapter and calling manager.register_and_start) unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c0964ca3-e26b-4776-9a1a-c347e6259a91

📥 Commits

Reviewing files that changed from the base of the PR and between 62504d8 and 8809788.

📒 Files selected for processing (10)
  • src/api/admin.rs
  • src/api/bindings.rs
  • src/api/messaging.rs
  • src/api/secrets.rs
  • src/api/server.rs
  • src/config/types.rs
  • src/config/watcher.rs
  • src/main.rs
  • src/oauth_storage.rs
  • src/secrets/store.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/secrets/store.rs

Comment on lines +370 to +390
fn allow_local_origin(
origin: &header::HeaderValue,
_request_parts: &axum::http::request::Parts,
) -> bool {
let Ok(origin) = origin.to_str() else {
return false;
};

origin.starts_with("http://127.0.0.1:")
|| origin.starts_with("https://127.0.0.1:")
|| origin.starts_with("http://localhost:")
|| origin.starts_with("https://localhost:")
|| origin.starts_with("http://[::1]:")
|| origin.starts_with("https://[::1]:")
|| origin == "http://127.0.0.1"
|| origin == "https://127.0.0.1"
|| origin == "http://localhost"
|| origin == "https://localhost"
|| origin == "http://[::1]"
|| origin == "https://[::1]"
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle all loopback origins, not just three literals.

The helper says "localhost/loopback", but it only admits 127.0.0.1, localhost, and [::1]. Valid local origins like http://127.0.0.2:5173 or http://127.1:5173 are still rejected. Parsing the origin host and checking is_loopback() would match the bind policy and avoid surprising local-dev CORS failures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/server.rs` around lines 370 - 390, The allow_local_origin function
currently matches only three literal hosts; change it to parse the origin into a
URL (e.g., using url::Url::parse) and validate scheme is http or https, then
extract url.host_str() and accept if the host is "localhost" or if
host.parse::<std::net::IpAddr>().map_or(false, |ip| ip.is_loopback()); keep the
existing port-agnostic behavior and return false on parse errors—update
allow_local_origin to use this host/is_loopback logic instead of the current
starts_with / equality checks.

Comment on lines +1557 to +1569
/// Build the persisted token filename for a named Twitch adapter instance.
pub fn named_twitch_token_file_name(name: &str) -> String {
use std::hash::{Hash, Hasher};

let safe_name: String = name
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
.collect();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
name.hash(&mut hasher);
let name_hash = hasher.finish();

format!("twitch_token_{safe_name}_{name_hash:016x}.json")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Do the Rust standard library docs guarantee stable output for std::collections::hash_map::DefaultHasher across compiler or standard-library releases?

💡 Result:

No. The standard library docs explicitly say std::collections::hash_map::DefaultHasher’s internal algorithm is not specified, and therefore “it and its hashes should not be relied upon over releases” (i.e., they are not guaranteed stable across compiler/stdlib releases). (doc.rust-lang.org)

Also, because DefaultHasher is the hasher used by RandomState, and different RandomState instances are designed to produce different hash results for the same input, you shouldn’t expect stable/reproducible hash outputs in general. (doc.rust-lang.org)

Citations:


Use a stable digest for persisted Twitch token filenames.

DefaultHasher is not suitable for on-disk identifiers. The Rust standard library explicitly states that DefaultHasher's algorithm is unspecified and that "it and its hashes should not be relied upon over releases." A compiler or standard-library upgrade can change the hash output, invalidating existing token filenames and forcing named Twitch adapters to re-authenticate. Use a stable digest algorithm (e.g., SHA-256 or Blake3) instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 1557 - 1569, The function
named_twitch_token_file_name currently uses
std::collections::hash_map::DefaultHasher which is unstable across Rust
versions; replace that with a stable cryptographic digest (e.g., blake3 or
SHA-256) to produce a deterministic filename. Keep the safe_name sanitization,
compute a stable hex digest from name.as_bytes() using a chosen crate (for
example blake3::hash or sha2::Sha256), optionally truncate to a fixed length for
readability, and use that hex digest in the existing
format!("twitch_token_{safe_name}_{name_hash:016x}.json") (adjust formatting
token to a hex string). Add the digest crate to Cargo.toml and update imports;
ensure the function name named_twitch_token_file_name remains unchanged.

Comment on lines +19 to +43
pub fn save_json_credentials<T: Serialize>(path: &Path, credentials: &T) -> Result<()> {
let data = serde_json::to_string_pretty(credentials)
.with_context(|| format!("failed to serialize {}", path.display()))?;

#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt;

let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)
.with_context(|| format!("failed to open {}", path.display()))?;
file.write_all(data.as_bytes())
.with_context(|| format!("failed to write {}", path.display()))?;
}

#[cfg(not(unix))]
{
std::fs::write(path, &data)
.with_context(|| format!("failed to write {}", path.display()))?;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Write OAuth credential updates atomically.

This truncates the live JSON before the replacement bytes are safely in place. A crash or concurrent save in that window leaves unreadable credentials on disk, and the next load_json_credentials() forces a fresh OAuth login. Persist to a same-directory temp file and rename it into place after the write succeeds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/oauth_storage.rs` around lines 19 - 43, save_json_credentials currently
truncates the target file before the new JSON is fully written, risking
corruption; change it to write atomically by creating a same-directory temporary
file (e.g., path with a random/suffix tmp name), write the pretty JSON to that
temp file, call file.sync_all() and set permissions (on unix set mode 0o600 via
OpenOptionsExt) before renaming, then atomically replace the original with
std::fs::rename(temp_path, path); after rename, sync the parent directory
(std::fs::File::open(dir)?.sync_all()) to ensure durability; keep this behavior
for both cfg(unix) and cfg(not(unix)) branches and ensure errors are propagated
from the functions used in save_json_credentials.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant