mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-02 17:49:20 +00:00
* feat(ux): complete UX overhaul — design system, boot screen, onboarding, web polish Shared design system: CSS custom properties for spacing, typography, transitions, and color tokens used across web UI and boot screen. Boot screen: compact feature-tags line showing enabled subsystems (db, tools, routines, heartbeat, skills, sandbox, embeddings) at a glance. Downgrade startup info logs (libSQL, webhook, workspace seed) to debug level since the boot screen now covers this. Onboarding wizard: model picker with live API fetch, provider-aware auth flow, improved error recovery and progress display. Web UI: ARIA attributes, welcome card, streaming debounce, connection status banner, skeleton loaders, send cooldown. CLI: doctor command enhancements, status command cleanup, REPL banner consolidation, shared fmt module. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(ux): Apple-level design refinements — spring physics, glass morphism, chat polish Merge staging theme support (dark/light/system toggle) and layer UX polish on top: spring-physics motion, glass morphism depth, chat experience improvements, and responsive mobile refinements. Design system: - Restore and extend design token system (spacing, typography, timing, easing) with legacy aliases for theme compatibility - Add shadow tiers, accent glow, glass morphism, spring easing tokens - Tokens defined in both dark (:root) and light ([data-theme="light"]) Micro-interactions (Phase 2): - Spring-overshoot message entry animation (slideUp) - Spring-scale button press on all interactive buttons - Tab crossfade animation, tool card smooth accordion (max-height) - Modal scale(0.95) + blur(8px) entry, toast spring slide - Sidebar width crossfade, card hover lift Visual depth (Phase 3): - Tab bar glass morphism + surface highlight + sliding indicator - Active tab accent background pill - Assistant message accent left border, user message bubble tail - Floating input area (rounded + shadow + margin) Chat polish (Phase 4): - Smooth streaming cursor (cursorPulse), message hover timestamps - Time separators (Today/Yesterday/date) - Textarea smooth auto-expand, send button glow Settings & forms (Phase 5): - iOS-style toggle switches for boolean settings - Input focus glow, save feedback spring animation - Welcome card with gradient background + proper spacing - Sticky settings group headers with glass backdrop Accessibility & mobile (Phase 6): - Animated focus ring, prefers-reduced-motion global kill-switch - Touch target audit (44px min), mobile bottom-sheet modals - Mobile bottom tab bar, toast redesign (icon + border + countdown) - Thread hover translateX, badge in_progress pulse Bug fixes: - Gateway/TEE popover z-index (tab-bar z-index: 200, popovers 500) - Connection lost banner as fixed top bar instead of flex child - Sidebar collapse keeps toggle + new thread buttons visible - Downgrade noisy startup logs (db, webhook, vector) to debug - Remove green dot pulse animation on connected status - Deduplicate confirm-modal in HTML, add tab-indicator div Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(web): mobile layout improvements — sidebar toggle, settings drill-down, tab bar polish - Fix mobile sidebar toggle: use expanded-mobile class instead of collapsed, add backdrop overlay, auto-close on thread select, outside-click dismiss - Settings: replace cramped horizontal tabs with drill-down navigation (category list → detail view → back button) - Bottom tab bar: add glass morphism, hide theme toggle, flip tab indicator to top edge - Keep thread toggle button visible in collapsed 36px sidebar strip Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): interactive approval selector and transient status lines - Replace ASCII-art approval box with clean horizontal rule card - Add inquire-based interactive selector for tool approvals (↑↓ + Enter) - Selector runs directly from send_status via spawn_blocking, with stdin_locked flag to prevent readline from competing for stdin - Transient thinking/tool-started lines: each replaces the previous, all erased before final output (no clutter left in scrollback) - Esc in selector sends denial so agent never gets stuck Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: widen TurnCost token fields to u64 and remove unused variable - Change input_tokens/output_tokens from u32 to u64 in StatusUpdate::TurnCost, SseEvent::TurnCost, and the thread_ops emit site to avoid truncation on large conversations - Remove unused _routine_engine_for_loop binding in agent_loop.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: reduce startup log noise — demote info to debug Demote routine startup messages (builder, WASM tools, tunnel, WASM channels) from info to debug so the default log output stays clean. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): allow CDN scripts in CSP connect-src directive Add cdn.jsdelivr.net and cdnjs.cloudflare.com to connect-src so the browser can fetch marked.js and DOMPurify without CSP violations. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: fix cargo fmt in repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): gate turn_cost SSE handler on current thread Prevents cost badge from attaching to the wrong message when switching threads or receiving events from background threads. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * ci: retrigger CI * fix: add missing extension_manager to webhook EngineContext The webhook trigger path added in #736 was missing the extension_manager field introduced by #1453. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: ignore RUSTSEC-2026-0049 rustls-webpki CRL advisory Low impact — requires compromised CA to exploit. Tracked for upstream rustls-webpki upgrade. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(routines): use fields.join for cron normalization Use split_whitespace fields instead of re-trimming the original string to avoid preserving extra internal whitespace in cron expressions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): Apple-style approval card — clean vertical flow - Drop verbose tool description (the command IS the decision surface) - Unified vertical pipe layout: ◆ header → │ params → │ selector - Selector options show keyboard shortcuts inline: Approve (y) - Compact help message, answered state uses └ to close the flow - No horizontal rules, no blank-line padding — just breathing room Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(repl): replace inquire with crossterm for approval selector Drop the inquire dependency (which pulled in crossterm 0.25, duplicating the existing 0.28). The 3-option approval selector is now built directly with crossterm raw mode — same UX, zero new dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore(deps): upgrade crossterm 0.28 → 0.29, eliminate duplication termimad (via crokey) uses crossterm 0.29. Upgrading our direct dependency from 0.28 to 0.29 collapses to a single crossterm version in the dependency tree. Also migrated termimad::crossterm:: references to the direct crossterm import. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments — box_top off-by-one, smart_truncate overflow, mobile theme toggle - Fix box_top() fill calculation: was off-by-one, producing boxes 1 char too wide (fmt.rs) - Fix smart_truncate(): account for "..." in the budget so output never exceeds max_chars (repl.rs) - Move theme toggle to settings sidebar on mobile instead of display:none, so mobile users can still switch themes (style.css, index.html, app.js) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: cargo fmt repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review — retry duplication, CSP connect-src, deny color - Remove failed message before retry to prevent duplicate user messages - Revert connect-src to 'self' — CDN hosts only need script-src - Use red for Deny confirmation in REPL approval selector Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
445 lines
15 KiB
Rust
445 lines
15 KiB
Rust
//! WASM channel setup and credential injection.
|
|
//!
|
|
//! Encapsulates the logic for loading WASM channels, registering their
|
|
//! webhook routes, and injecting credentials from the secrets store.
|
|
|
|
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
|
|
use crate::channels::wasm::{
|
|
LoadedChannel, RegisteredEndpoint, SharedWasmChannel, TELEGRAM_CHANNEL_NAME, WasmChannel,
|
|
WasmChannelLoader, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
|
bot_username_setting_key, create_wasm_channel_router,
|
|
};
|
|
use crate::config::Config;
|
|
use crate::db::Database;
|
|
use crate::extensions::ExtensionManager;
|
|
use crate::pairing::PairingStore;
|
|
use crate::secrets::SecretsStore;
|
|
|
|
/// Result of WASM channel setup.
|
|
pub struct WasmChannelSetup {
|
|
pub channels: Vec<(String, Box<dyn crate::channels::Channel>)>,
|
|
pub channel_names: Vec<String>,
|
|
pub webhook_routes: Option<axum::Router>,
|
|
/// Runtime objects needed for hot-activation via ExtensionManager.
|
|
pub wasm_channel_runtime: Arc<WasmChannelRuntime>,
|
|
pub pairing_store: Arc<PairingStore>,
|
|
pub wasm_channel_router: Arc<WasmChannelRouter>,
|
|
}
|
|
|
|
/// Load WASM channels and register their webhook routes.
|
|
pub async fn setup_wasm_channels(
|
|
config: &Config,
|
|
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
|
extension_manager: Option<&Arc<ExtensionManager>>,
|
|
database: Option<&Arc<dyn Database>>,
|
|
) -> Option<WasmChannelSetup> {
|
|
let runtime = match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
|
|
Ok(r) => Arc::new(r),
|
|
Err(e) => {
|
|
tracing::warn!("Failed to initialize WASM channel runtime: {}", e);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let pairing_store = Arc::new(PairingStore::new());
|
|
let settings_store: Option<Arc<dyn crate::db::SettingsStore>> =
|
|
database.map(|db| Arc::clone(db) as Arc<dyn crate::db::SettingsStore>);
|
|
let mut loader = WasmChannelLoader::new(
|
|
Arc::clone(&runtime),
|
|
Arc::clone(&pairing_store),
|
|
settings_store.clone(),
|
|
config.owner_id.clone(),
|
|
);
|
|
if let Some(secrets) = secrets_store {
|
|
loader = loader.with_secrets_store(Arc::clone(secrets));
|
|
}
|
|
|
|
let results = match loader
|
|
.load_from_dir(&config.channels.wasm_channels_dir)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
tracing::warn!("Failed to scan WASM channels directory: {}", e);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let wasm_router = Arc::new(WasmChannelRouter::new());
|
|
let mut channels: Vec<(String, Box<dyn crate::channels::Channel>)> = Vec::new();
|
|
let mut channel_names: Vec<String> = Vec::new();
|
|
|
|
for loaded in results.loaded {
|
|
let (name, channel) = register_channel(
|
|
loaded,
|
|
config,
|
|
secrets_store,
|
|
settings_store.as_ref(),
|
|
&wasm_router,
|
|
)
|
|
.await;
|
|
channel_names.push(name.clone());
|
|
channels.push((name, channel));
|
|
}
|
|
|
|
for (path, err) in &results.errors {
|
|
tracing::warn!("Failed to load WASM channel {}: {}", path.display(), err);
|
|
}
|
|
|
|
// Always create webhook routes (even with no channels loaded) so that
|
|
// channels hot-added at runtime can receive webhooks without a restart.
|
|
let webhook_routes = {
|
|
Some(create_wasm_channel_router(
|
|
Arc::clone(&wasm_router),
|
|
extension_manager.map(Arc::clone),
|
|
))
|
|
};
|
|
|
|
Some(WasmChannelSetup {
|
|
channels,
|
|
channel_names,
|
|
webhook_routes,
|
|
wasm_channel_runtime: runtime,
|
|
pairing_store,
|
|
wasm_channel_router: wasm_router,
|
|
})
|
|
}
|
|
|
|
/// Process a single loaded WASM channel: retrieve secrets, inject config,
|
|
/// register with the router, and set up signing keys and credentials.
|
|
async fn register_channel(
|
|
loaded: LoadedChannel,
|
|
config: &Config,
|
|
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
|
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
|
|
wasm_router: &Arc<WasmChannelRouter>,
|
|
) -> (String, Box<dyn crate::channels::Channel>) {
|
|
let channel_name = loaded.name().to_string();
|
|
tracing::debug!("Loaded WASM channel: {}", channel_name);
|
|
let owner_actor_id = config
|
|
.channels
|
|
.wasm_channel_owner_ids
|
|
.get(channel_name.as_str())
|
|
.map(ToString::to_string);
|
|
|
|
let secret_name = loaded.webhook_secret_name();
|
|
let sig_key_secret_name = loaded.signature_key_secret_name();
|
|
let hmac_secret_name = loaded.hmac_secret_name();
|
|
|
|
let webhook_secret = if let Some(secrets) = secrets_store {
|
|
secrets
|
|
.get_decrypted(&config.owner_id, &secret_name)
|
|
.await
|
|
.ok()
|
|
.map(|s| s.expose().to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
|
|
|
|
let webhook_path = format!("/webhook/{}", channel_name);
|
|
let endpoints = vec![RegisteredEndpoint {
|
|
channel_name: channel_name.clone(),
|
|
path: webhook_path,
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: webhook_secret.is_some(),
|
|
}];
|
|
|
|
let channel_arc = Arc::new(loaded.channel.with_owner_actor_id(owner_actor_id.clone()));
|
|
|
|
// Inject runtime config (tunnel URL, webhook secret, owner_id).
|
|
{
|
|
let mut config_updates = std::collections::HashMap::new();
|
|
|
|
if let Some(ref tunnel_url) = config.tunnel.public_url {
|
|
config_updates.insert(
|
|
"tunnel_url".to_string(),
|
|
serde_json::Value::String(tunnel_url.clone()),
|
|
);
|
|
}
|
|
|
|
if let Some(ref secret) = webhook_secret {
|
|
config_updates.insert(
|
|
"webhook_secret".to_string(),
|
|
serde_json::Value::String(secret.clone()),
|
|
);
|
|
}
|
|
|
|
if let Some(&owner_id) = config
|
|
.channels
|
|
.wasm_channel_owner_ids
|
|
.get(channel_name.as_str())
|
|
{
|
|
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
|
|
}
|
|
|
|
if channel_name == TELEGRAM_CHANNEL_NAME
|
|
&& let Some(store) = settings_store
|
|
&& let Ok(Some(serde_json::Value::String(username))) = store
|
|
.get_setting("default", &bot_username_setting_key(&channel_name))
|
|
.await
|
|
&& !username.trim().is_empty()
|
|
{
|
|
config_updates.insert("bot_username".to_string(), serde_json::json!(username));
|
|
}
|
|
// Inject channel-specific secrets into config for channels that need
|
|
// credentials in API request bodies (e.g., Feishu token exchange).
|
|
// The credential injection system only replaces placeholders in URLs
|
|
// and headers, so channels like Feishu that exchange app_id + app_secret
|
|
// for a tenant token need the raw values in their config.
|
|
inject_channel_secrets_into_config(&channel_name, secrets_store, &mut config_updates).await;
|
|
|
|
if !config_updates.is_empty() {
|
|
channel_arc.update_config(config_updates).await;
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
has_tunnel = config.tunnel.public_url.is_some(),
|
|
has_webhook_secret = webhook_secret.is_some(),
|
|
"Injected runtime config into channel"
|
|
);
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
has_webhook_secret = webhook_secret.is_some(),
|
|
secret_header = ?secret_header,
|
|
"Registering channel with router"
|
|
);
|
|
|
|
wasm_router
|
|
.register(
|
|
Arc::clone(&channel_arc),
|
|
endpoints,
|
|
webhook_secret.clone(),
|
|
secret_header,
|
|
)
|
|
.await;
|
|
|
|
// Register Ed25519 signature key if declared in capabilities.
|
|
if let Some(ref sig_key_name) = sig_key_secret_name
|
|
&& let Some(secrets) = secrets_store
|
|
&& let Ok(key_secret) = secrets.get_decrypted(&config.owner_id, sig_key_name).await
|
|
{
|
|
match wasm_router
|
|
.register_signature_key(&channel_name, key_secret.expose())
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
tracing::info!(channel = %channel_name, "Registered Ed25519 signature key")
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(channel = %channel_name, error = %e, "Invalid signature key in secrets store")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register HMAC signing secret if declared in capabilities.
|
|
if let Some(ref hmac_secret_name) = hmac_secret_name
|
|
&& let Some(secrets) = secrets_store
|
|
&& let Ok(secret) = secrets
|
|
.get_decrypted(&config.owner_id, hmac_secret_name)
|
|
.await
|
|
{
|
|
wasm_router
|
|
.register_hmac_secret(&channel_name, secret.expose())
|
|
.await;
|
|
tracing::info!(channel = %channel_name, "Registered HMAC signing secret");
|
|
}
|
|
|
|
// Inject credentials from secrets store / environment.
|
|
match inject_channel_credentials(
|
|
&channel_arc,
|
|
secrets_store
|
|
.as_ref()
|
|
.map(|s| s.as_ref() as &dyn SecretsStore),
|
|
&channel_name,
|
|
&config.owner_id,
|
|
)
|
|
.await
|
|
{
|
|
Ok(count) => {
|
|
if count > 0 {
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
credentials_injected = count,
|
|
"Channel credentials injected"
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
channel = %channel_name,
|
|
error = %e,
|
|
"Failed to inject channel credentials"
|
|
);
|
|
}
|
|
}
|
|
|
|
(channel_name, Box::new(SharedWasmChannel::new(channel_arc)))
|
|
}
|
|
|
|
/// Inject credentials for a channel based on naming convention.
|
|
///
|
|
/// Looks for secrets matching the pattern `{channel_name}_*` and injects them
|
|
/// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`).
|
|
///
|
|
/// Falls back to environment variables starting with the uppercase channel name
|
|
/// prefix (e.g., `TELEGRAM_` for channel `telegram`) for missing credentials.
|
|
///
|
|
/// Returns the number of credentials injected.
|
|
pub async fn inject_channel_credentials(
|
|
channel: &Arc<WasmChannel>,
|
|
secrets: Option<&dyn SecretsStore>,
|
|
channel_name: &str,
|
|
owner_id: &str,
|
|
) -> anyhow::Result<usize> {
|
|
if channel_name.trim().is_empty() {
|
|
return Ok(0);
|
|
}
|
|
|
|
let mut count = 0;
|
|
let mut injected_placeholders = HashSet::new();
|
|
|
|
// 1. Try injecting from persistent secrets store if available
|
|
if let Some(secrets) = secrets {
|
|
let all_secrets = secrets
|
|
.list(owner_id)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
|
|
|
|
let prefix = format!("{}_", channel_name.to_ascii_lowercase());
|
|
|
|
for secret_meta in all_secrets {
|
|
if !secret_meta.name.to_ascii_lowercase().starts_with(&prefix) {
|
|
continue;
|
|
}
|
|
|
|
let decrypted = match secrets.get_decrypted(owner_id, &secret_meta.name).await {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
secret = %secret_meta.name,
|
|
error = %e,
|
|
"Failed to decrypt secret for channel credential injection"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let placeholder = secret_meta.name.to_uppercase();
|
|
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
secret = %secret_meta.name,
|
|
placeholder = %placeholder,
|
|
"Injecting credential"
|
|
);
|
|
|
|
channel
|
|
.set_credential(&placeholder, decrypted.expose().to_string())
|
|
.await;
|
|
injected_placeholders.insert(placeholder);
|
|
count += 1;
|
|
}
|
|
}
|
|
|
|
// 2. Fall back to environment variables for credentials not in the secrets store.
|
|
// Only env vars starting with the channel's uppercase prefix are allowed
|
|
// (e.g., TELEGRAM_ for channel "telegram") to prevent reading unrelated host
|
|
// credentials like AWS_SECRET_ACCESS_KEY.
|
|
let prefix = format!("{}_", channel_name.to_ascii_uppercase());
|
|
let caps = channel.capabilities();
|
|
if let Some(ref http_cap) = caps.tool_capabilities.http {
|
|
for cred_mapping in http_cap.credentials.values() {
|
|
let placeholder = cred_mapping.secret_name.to_uppercase();
|
|
if injected_placeholders.contains(&placeholder) {
|
|
continue;
|
|
}
|
|
if !placeholder.starts_with(&prefix) {
|
|
tracing::warn!(
|
|
channel = %channel_name,
|
|
placeholder = %placeholder,
|
|
"Ignoring non-prefixed credential placeholder in environment fallback"
|
|
);
|
|
continue;
|
|
}
|
|
if let Ok(env_value) = std::env::var(&placeholder)
|
|
&& !env_value.is_empty()
|
|
{
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
placeholder = %placeholder,
|
|
"Injecting credential from environment variable"
|
|
);
|
|
channel.set_credential(&placeholder, env_value).await;
|
|
count += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(count)
|
|
}
|
|
|
|
/// Inject channel-specific secrets into the config JSON.
|
|
///
|
|
/// Some channels (e.g., Feishu) need raw credential values in their config
|
|
/// because they perform token exchanges that require secrets in the HTTP
|
|
/// request body. The standard credential injection system only replaces
|
|
/// placeholders in URLs and headers, so this function fills config fields
|
|
/// that map to secret names.
|
|
///
|
|
/// Mapping: for a channel named "feishu", secrets `feishu_app_id` and
|
|
/// `feishu_app_secret` are injected as config keys `app_id` and `app_secret`.
|
|
async fn inject_channel_secrets_into_config(
|
|
channel_name: &str,
|
|
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
|
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
|
|
) {
|
|
// Map of (config_key, secret_name) pairs per channel.
|
|
let secret_config_mappings: &[(&str, &str)] = match channel_name {
|
|
"feishu" => &[
|
|
("app_id", "feishu_app_id"),
|
|
("app_secret", "feishu_app_secret"),
|
|
],
|
|
_ => return,
|
|
};
|
|
|
|
let Some(secrets) = secrets_store else {
|
|
return;
|
|
};
|
|
|
|
for &(config_key, secret_name) in secret_config_mappings {
|
|
match secrets.get_decrypted("default", secret_name).await {
|
|
Ok(decrypted) => {
|
|
config_updates.insert(
|
|
config_key.to_string(),
|
|
serde_json::Value::String(decrypted.expose().to_string()),
|
|
);
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
config_key = %config_key,
|
|
"Injected secret into channel config"
|
|
);
|
|
}
|
|
Err(_) => {
|
|
// Also try environment variable fallback.
|
|
let env_name = secret_name.to_uppercase();
|
|
if let Ok(val) = std::env::var(&env_name)
|
|
&& !val.is_empty()
|
|
{
|
|
config_updates.insert(config_key.to_string(), serde_json::Value::String(val));
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
config_key = %config_key,
|
|
"Injected secret from env into channel config"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|