Merge pull request #1196 from nearai/staging-promote/e74214dc-23104855330

chore: promote staging to staging-promote/97b11ffd-23104193988 (2026-03-15 06:18 UTC)
This commit is contained in:
Henry Park
2026-03-16 13:28:17 -07:00
committed by GitHub
27 changed files with 2164 additions and 90 deletions
+32 -9
View File
@@ -855,19 +855,42 @@ impl Agent {
};
if let Some(pending) = pending_auth {
match &submission {
Submission::UserInput { content } => {
return self
.process_auth_token(message, &pending, content, session, thread_id)
.await;
}
_ => {
// Any control submission (interrupt, undo, etc.) cancels auth mode
if pending.is_expired() {
// TTL exceeded — clear stale auth mode
tracing::warn!(
extension = %pending.extension_name,
"Auth mode expired after TTL, clearing"
);
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.pending_auth = None;
}
// Fall through to normal handling
}
// If this was a user message (possibly a pasted token), return an
// explicit error instead of forwarding it to the LLM/history.
if matches!(submission, Submission::UserInput { .. }) {
return Ok(Some(format!(
"Authentication for **{}** expired. Please try again.",
pending.extension_name
)));
}
// Control submissions (interrupt, undo, etc.) fall through to normal handling
} else {
match &submission {
Submission::UserInput { content } => {
return self
.process_auth_token(message, &pending, content, session, thread_id)
.await;
}
_ => {
// Any control submission (interrupt, undo, etc.) cancels auth mode
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.pending_auth = None;
}
// Fall through to normal handling
}
}
}
}
+45 -9
View File
@@ -12,7 +12,7 @@
use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -135,6 +135,12 @@ pub enum ThreadState {
/// Pending auth token request.
///
/// Auth mode TTL — must stay in sync with
/// `crate::cli::oauth_defaults::OAUTH_FLOW_EXPIRY` (5 minutes / 300 s).
/// Defined separately to avoid a session→cli module dependency.
const AUTH_MODE_TTL_SECS: i64 = 300;
const AUTH_MODE_TTL: TimeDelta = TimeDelta::seconds(AUTH_MODE_TTL_SECS);
/// When `tool_auth` returns `awaiting_token`, the thread enters auth mode.
/// The next user message is intercepted before entering the normal pipeline
/// (no logging, no turn creation, no history) and routed directly to the
@@ -143,6 +149,16 @@ pub enum ThreadState {
pub struct PendingAuth {
/// Extension name to authenticate.
pub extension_name: String,
/// When this auth mode was entered. Used for TTL expiry.
#[serde(default = "Utc::now")]
pub created_at: DateTime<Utc>,
}
impl PendingAuth {
/// Returns `true` if this auth mode has exceeded the TTL.
pub fn is_expired(&self) -> bool {
Utc::now() - self.created_at > AUTH_MODE_TTL
}
}
/// Pending tool approval request stored on a thread.
@@ -298,7 +314,10 @@ impl Thread {
/// Enter auth mode: next user message will be routed directly to
/// the credential store, bypassing the normal pipeline entirely.
pub fn enter_auth_mode(&mut self, extension_name: String) {
self.pending_auth = Some(PendingAuth { extension_name });
self.pending_auth = Some(PendingAuth {
extension_name,
created_at: Utc::now(),
});
self.updated_at = Utc::now();
}
@@ -687,15 +706,16 @@ mod tests {
#[test]
fn test_enter_auth_mode() {
let before = Utc::now();
let mut thread = Thread::new(Uuid::new_v4());
assert!(thread.pending_auth.is_none());
thread.enter_auth_mode("telegram".to_string());
assert!(thread.pending_auth.is_some());
assert_eq!(
thread.pending_auth.as_ref().unwrap().extension_name,
"telegram"
);
let pending = thread.pending_auth.as_ref().unwrap();
assert_eq!(pending.extension_name, "telegram");
assert!(pending.created_at >= before);
assert!(!pending.is_expired());
}
#[test]
@@ -705,8 +725,9 @@ mod tests {
let pending = thread.take_pending_auth();
assert!(pending.is_some());
assert_eq!(pending.unwrap().extension_name, "notion");
let pending = pending.unwrap();
assert_eq!(pending.extension_name, "notion");
assert!(!pending.is_expired());
// Should be cleared after take
assert!(thread.pending_auth.is_none());
assert!(thread.take_pending_auth().is_none());
@@ -720,10 +741,25 @@ mod tests {
let json = serde_json::to_string(&thread).expect("should serialize");
assert!(json.contains("pending_auth"));
assert!(json.contains("openai"));
assert!(json.contains("created_at"));
let restored: Thread = serde_json::from_str(&json).expect("should deserialize");
assert!(restored.pending_auth.is_some());
assert_eq!(restored.pending_auth.unwrap().extension_name, "openai");
let pending = restored.pending_auth.unwrap();
assert_eq!(pending.extension_name, "openai");
assert!(!pending.is_expired());
}
#[test]
fn test_pending_auth_expiry() {
let mut pending = PendingAuth {
extension_name: "test".to_string(),
created_at: Utc::now(),
};
assert!(!pending.is_expired());
// Backdate beyond the TTL
pending.created_at = Utc::now() - AUTH_MODE_TTL - TimeDelta::seconds(1);
assert!(pending.is_expired());
}
#[test]
+23 -3
View File
@@ -526,23 +526,33 @@ async fn oauth_callback_handler(
.get("error_description")
.cloned()
.unwrap_or_else(|| error.clone());
clear_auth_mode(&state).await;
return oauth_error_page(&description);
}
let state_param = match params.get("state") {
Some(s) if !s.is_empty() => s.clone(),
_ => return oauth_error_page("IronClaw"),
_ => {
clear_auth_mode(&state).await;
return oauth_error_page("IronClaw");
}
};
let code = match params.get("code") {
Some(c) if !c.is_empty() => c.clone(),
_ => return oauth_error_page("IronClaw"),
_ => {
clear_auth_mode(&state).await;
return oauth_error_page("IronClaw");
}
};
// Look up the pending flow by CSRF state (atomic remove prevents replay)
let ext_mgr = match state.extension_manager.as_ref() {
Some(mgr) => mgr,
None => return oauth_error_page("IronClaw"),
None => {
clear_auth_mode(&state).await;
return oauth_error_page("IronClaw");
}
};
// Strip instance prefix from state for registry lookup.
@@ -563,6 +573,7 @@ async fn oauth_callback_handler(
lookup_key = %lookup_key,
"OAuth callback received with unknown or expired state"
);
clear_auth_mode(&state).await;
return oauth_error_page("IronClaw");
}
};
@@ -581,6 +592,7 @@ async fn oauth_callback_handler(
message: "OAuth flow expired. Please try again.".to_string(),
});
}
clear_auth_mode(&state).await;
return oauth_error_page(&flow.display_name);
}
@@ -690,6 +702,10 @@ async fn oauth_callback_handler(
}
}
// Clear auth mode regardless of outcome so the next user message goes
// through to the LLM instead of being intercepted as a token.
clear_auth_mode(&state).await;
// After successful OAuth, auto-activate the extension so it moves
// from "Installed (Authenticate)" → "Active" without a second click.
// OAuth success is independent of activation — tokens are already stored.
@@ -2182,6 +2198,10 @@ async fn extensions_setup_submit_handler(
"Extension manager not available (secrets store required)".to_string(),
))?;
// Clear auth mode regardless of outcome so the next user message goes
// through to the LLM instead of being intercepted as a token.
clear_auth_mode(&state).await;
match ext_mgr.configure(&name, &req.secrets).await {
Ok(result) => {
// Broadcast auth_completed so the chat UI can dismiss any in-progress
+20 -1
View File
@@ -600,6 +600,22 @@ document.getElementById('chat-input').addEventListener('paste', (e) => {
}
});
const chatMessagesEl = document.getElementById('chat-messages');
chatMessagesEl.addEventListener('copy', (e) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
if (!anchorNode || !focusNode) return;
if (!chatMessagesEl.contains(anchorNode) || !chatMessagesEl.contains(focusNode)) return;
const text = selection.toString();
if (!text || !e.clipboardData) return;
// Force plain-text clipboard output so dark-theme styling never leaks on paste.
e.preventDefault();
e.clipboardData.clearData();
e.clipboardData.setData('text/plain', text);
});
function addGeneratedImage(dataUrl, path) {
const container = document.getElementById('chat-messages');
const card = document.createElement('div');
@@ -1759,7 +1775,10 @@ chatInput.addEventListener('keydown', (e) => {
}
}
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
// Safari fires compositionend before keydown, so e.isComposing is already false
// when Enter confirms IME input. keyCode 229 (VK_PROCESS) catches this case.
// See https://bugs.webkit.org/show_bug.cgi?id=165004
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
e.preventDefault();
hideSlashAutocomplete();
sendMessage();
+587
View File
@@ -0,0 +1,587 @@
//! CLI command for viewing and managing gateway logs.
//!
//! Provides access to gateway logs through three mechanisms:
//! - Reading the gateway log file (`~/.ironclaw/gateway.log`)
//! - Streaming live logs via the gateway's SSE endpoint (`/api/logs/events`)
//! - Getting/setting the runtime log level via `/api/logs/level`
use std::io::{Seek, SeekFrom};
use std::path::Path;
use clap::Args;
/// View and manage gateway logs.
#[derive(Args, Debug, Clone)]
#[command(
about = "View and manage gateway logs",
long_about = "Tail gateway logs, stream live output, or adjust log level.\nExamples:\n ironclaw logs # Show last 200 lines\n ironclaw logs --follow # Stream live logs via SSE\n ironclaw logs --limit 50 --json # Last 50 lines as JSON\n ironclaw logs --level # Show current log level\n ironclaw logs --level debug # Set log level to debug"
)]
pub struct LogsCommand {
/// Stream live logs from the running gateway via SSE.
/// Replays recent history then streams new entries in real time.
#[arg(short, long)]
pub follow: bool,
/// Maximum number of lines to show (default: 200)
#[arg(short, long, default_value = "200")]
pub limit: usize,
/// Output log entries as JSON (one object per line)
#[arg(long)]
pub json: bool,
/// Display timestamps in local timezone
#[arg(long)]
pub local_time: bool,
/// Plain text output (no ANSI styling)
#[arg(long)]
pub plain: bool,
/// Gateway URL (default: http://{GATEWAY_HOST}:{GATEWAY_PORT})
#[arg(long)]
pub url: Option<String>,
/// Gateway auth token (reads GATEWAY_AUTH_TOKEN env if not set)
#[arg(long)]
pub token: Option<String>,
/// Connection timeout in milliseconds (default: 5000)
#[arg(long, default_value = "5000")]
pub timeout: u64,
/// Get or set runtime log level. Without a value, shows current level.
/// With a value (trace|debug|info|warn|error), sets the level.
#[arg(long, num_args = 0..=1, default_missing_value = "")]
pub level: Option<String>,
}
/// Resolved gateway connection parameters.
struct GatewayParams {
base_url: String,
token: String,
}
/// Run the logs CLI command.
pub async fn run_logs_command(cmd: LogsCommand, config_path: Option<&Path>) -> anyhow::Result<()> {
// --level takes priority: it's a control-plane operation, not log viewing.
if let Some(level_arg) = &cmd.level {
let params = resolve_gateway_params(&cmd, config_path).await?;
if level_arg.is_empty() {
return cmd_get_level(&cmd, &params).await;
} else {
return cmd_set_level(&cmd, level_arg, &params).await;
}
}
if cmd.follow {
let params = resolve_gateway_params(&cmd, config_path).await?;
cmd_follow(&cmd, &params).await
} else {
cmd_show(&cmd)
}
}
// ── Show log file ────────────────────────────────────────────────────────
/// Read the last N lines from `~/.ironclaw/gateway.log`.
///
/// Uses a reverse-scan strategy: seeks to the end of the file and reads
/// backwards in chunks to find the last `limit` newlines, so memory usage
/// is proportional to the output size, not the file size.
fn cmd_show(cmd: &LogsCommand) -> anyhow::Result<()> {
let log_path = crate::bootstrap::ironclaw_base_dir().join("gateway.log");
if !log_path.exists() {
anyhow::bail!(
"No gateway log file found at {}.\n\
The log file is created when the gateway runs in background mode \
(e.g. `ironclaw gateway start`).",
log_path.display()
);
}
let lines = tail_file(&log_path, cmd.limit)?;
if lines.is_empty() {
println!("(log file is empty)");
return Ok(());
}
if cmd.json {
for line in &lines {
let obj = serde_json::json!({ "line": line });
println!("{}", obj);
}
} else {
for line in &lines {
println!("{}", line);
}
}
Ok(())
}
/// Read the last `n` lines from a file by scanning backwards from EOF.
///
/// Reads in 8 KiB chunks from the end, counting newlines until enough
/// are found or the beginning of the file is reached.
fn tail_file(path: &Path, n: usize) -> anyhow::Result<Vec<String>> {
let mut file = std::fs::File::open(path)
.map_err(|e| anyhow::anyhow!("Failed to open {}: {}", path.display(), e))?;
let file_len = file
.seek(SeekFrom::End(0))
.map_err(|e| anyhow::anyhow!("Failed to seek {}: {}", path.display(), e))?;
if file_len == 0 {
return Ok(Vec::new());
}
// Read backwards in chunks to find enough newlines.
const CHUNK_SIZE: u64 = 8192;
let mut tail_bytes = Vec::new();
let mut newline_count = 0;
let mut remaining = file_len;
while remaining > 0 && newline_count <= n {
let read_size = std::cmp::min(CHUNK_SIZE, remaining);
remaining -= read_size;
file.seek(SeekFrom::Start(remaining))
.map_err(|e| anyhow::anyhow!("Seek failed: {e}"))?;
let mut chunk = vec![0u8; read_size as usize];
std::io::Read::read_exact(&mut file, &mut chunk)
.map_err(|e| anyhow::anyhow!("Read failed: {e}"))?;
// Count newlines in this chunk (backwards).
for &byte in chunk.iter().rev() {
if byte == b'\n' {
newline_count += 1;
}
}
// Prepend chunk to collected bytes.
chunk.append(&mut tail_bytes);
tail_bytes = chunk;
}
// Convert to string and take last N lines.
let text = String::from_utf8_lossy(&tail_bytes);
let all_lines: Vec<&str> = text.lines().collect();
let start = all_lines.len().saturating_sub(n);
Ok(all_lines[start..].iter().map(|s| s.to_string()).collect())
}
// ── Follow (live SSE stream) ─────────────────────────────────────────────
/// Connect to the gateway's `/api/logs/events` SSE endpoint and stream logs.
async fn cmd_follow(cmd: &LogsCommand, params: &GatewayParams) -> anyhow::Result<()> {
let timeout_dur = std::time::Duration::from_millis(cmd.timeout);
let client = reqwest::Client::builder()
.connect_timeout(timeout_dur)
.build()
.map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?;
let url = format!("{}/api/logs/events", params.base_url);
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", params.token))
.header("Accept", "text/event-stream")
// No per-request timeout: SSE streams are long-lived.
.timeout(std::time::Duration::from_secs(u64::MAX / 2))
.send()
.await
.map_err(|e| {
anyhow::anyhow!(
"Failed to connect to gateway at {url}: {e}\n\
Is the gateway running? Try `ironclaw gateway status`."
)
})?;
if !resp.status().is_success() {
anyhow::bail!(
"Gateway returned HTTP {}: {}",
resp.status(),
resp.text().await.unwrap_or_default()
);
}
eprintln!("Connected to {} — streaming logs (Ctrl-C to stop)", url);
// Parse SSE stream line by line.
let mut bytes_stream = resp.bytes_stream();
let mut buffer = String::new();
let mut lines_shown: usize = 0;
use futures::StreamExt;
while let Some(chunk) = bytes_stream.next().await {
let chunk = chunk.map_err(|e| anyhow::anyhow!("Stream error: {e}"))?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
// Process complete lines from the buffer.
while let Some(newline_pos) = buffer.find('\n') {
let line = buffer[..newline_pos].to_string();
buffer = buffer[newline_pos + 1..].to_string();
// SSE format: "data: {...}" lines carry the payload.
if let Some(data) = line.strip_prefix("data: ")
&& let Ok(entry) = serde_json::from_str::<serde_json::Value>(data)
{
print_log_entry(&entry, cmd);
lines_shown += 1;
}
// Skip "event:", "id:", "retry:", and empty keepalive lines.
}
}
if lines_shown == 0 {
eprintln!("(no log entries received)");
}
Ok(())
}
// ── Log level get/set ────────────────────────────────────────────────────
/// GET /api/logs/level — show the current log level.
async fn cmd_get_level(cmd: &LogsCommand, params: &GatewayParams) -> anyhow::Result<()> {
let timeout_dur = std::time::Duration::from_millis(cmd.timeout);
let client = reqwest::Client::builder()
.timeout(timeout_dur)
.build()
.map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?;
let url = format!("{}/api/logs/level", params.base_url);
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", params.token))
.send()
.await
.map_err(|e| {
anyhow::anyhow!(
"Failed to connect to gateway at {url}: {e}\n\
Is the gateway running? Try `ironclaw gateway status`."
)
})?;
if !resp.status().is_success() {
anyhow::bail!(
"Gateway returned HTTP {}: {}",
resp.status(),
resp.text().await.unwrap_or_default()
);
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| anyhow::anyhow!("Invalid response: {e}"))?;
if cmd.json {
println!(
"{}",
serde_json::to_string_pretty(&body).unwrap_or_default()
);
} else {
let level = body
.get("level")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
println!("Current log level: {}", level);
}
Ok(())
}
/// PUT /api/logs/level — change the runtime log level.
async fn cmd_set_level(
cmd: &LogsCommand,
level: &str,
params: &GatewayParams,
) -> anyhow::Result<()> {
const VALID: &[&str] = &["trace", "debug", "info", "warn", "error"];
let level_lower = level.to_lowercase();
if !VALID.contains(&level_lower.as_str()) {
anyhow::bail!(
"Invalid log level '{}'. Must be one of: {}",
level,
VALID.join(", ")
);
}
let timeout_dur = std::time::Duration::from_millis(cmd.timeout);
let client = reqwest::Client::builder()
.timeout(timeout_dur)
.build()
.map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?;
let url = format!("{}/api/logs/level", params.base_url);
let resp = client
.put(&url)
.header("Authorization", format!("Bearer {}", params.token))
.json(&serde_json::json!({ "level": level_lower }))
.send()
.await
.map_err(|e| {
anyhow::anyhow!(
"Failed to connect to gateway at {url}: {e}\n\
Is the gateway running? Try `ironclaw gateway status`."
)
})?;
if !resp.status().is_success() {
anyhow::bail!(
"Gateway returned HTTP {}: {}",
resp.status(),
resp.text().await.unwrap_or_default()
);
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| anyhow::anyhow!("Invalid response: {e}"))?;
if cmd.json {
println!(
"{}",
serde_json::to_string_pretty(&body).unwrap_or_default()
);
} else {
let new_level = body
.get("level")
.and_then(|v| v.as_str())
.unwrap_or(&level_lower);
println!("Log level set to: {}", new_level);
}
Ok(())
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// Resolve gateway connection params from CLI flags, config file, or env.
///
/// Priority: --url/--token flags > config TOML > env vars > defaults.
async fn resolve_gateway_params(
cmd: &LogsCommand,
config_path: Option<&Path>,
) -> anyhow::Result<GatewayParams> {
// Load gateway config. Errors propagate when --config is explicit.
let gw_config = load_gateway_config(config_path).await?;
// URL: --url flag > config TOML > env vars > defaults.
let base_url = if let Some(url) = &cmd.url {
url.trim_end_matches('/').to_string()
} else if let Some(cfg) = &gw_config {
format!("http://{}:{}", cfg.host, cfg.port)
} else {
let host = std::env::var("GATEWAY_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("GATEWAY_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000);
format!("http://{}:{}", host, port)
};
// Token: --token flag > config TOML > env var.
let token = if let Some(token) = &cmd.token {
token.clone()
} else if let Some(t) = gw_config.as_ref().and_then(|c| c.auth_token.clone()) {
t
} else {
std::env::var("GATEWAY_AUTH_TOKEN").map_err(|_| {
anyhow::anyhow!(
"No auth token provided. Use --token <TOKEN> or set GATEWAY_AUTH_TOKEN.\n\
The token is printed when the gateway starts."
)
})?
};
Ok(GatewayParams { base_url, token })
}
/// Try to load gateway config from the TOML config file.
///
/// If `config_path` was explicitly provided (via `--config`), errors are
/// propagated — the user asked for a specific file and deserves a clear
/// failure when it is missing, unreadable, or malformed. When no path
/// was given we fall back to env-only resolution and silently return
/// `None` on failure so that `ironclaw logs` works without any config.
async fn load_gateway_config(
config_path: Option<&Path>,
) -> anyhow::Result<Option<crate::config::GatewayConfig>> {
if config_path.is_some() {
// Explicit --config: propagate errors.
let config = crate::config::Config::from_env_with_toml(config_path)
.await
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
Ok(config.channels.gateway)
} else {
// No explicit config: best-effort, swallow errors.
let config = crate::config::Config::from_env_with_toml(None).await.ok();
Ok(config.and_then(|c| c.channels.gateway))
}
}
/// Print a single log entry to stdout.
fn print_log_entry(entry: &serde_json::Value, cmd: &LogsCommand) {
if cmd.json {
println!("{}", serde_json::to_string(entry).unwrap_or_default());
return;
}
let level = entry.get("level").and_then(|v| v.as_str()).unwrap_or("?");
let target = entry.get("target").and_then(|v| v.as_str()).unwrap_or("");
let message = entry.get("message").and_then(|v| v.as_str()).unwrap_or("");
let timestamp = entry
.get("timestamp")
.and_then(|v| v.as_str())
.unwrap_or("");
let display_ts = if cmd.local_time {
convert_to_local_time(timestamp)
} else {
timestamp.to_string()
};
if cmd.plain {
println!("{} {} [{}] {}", display_ts, level, target, message);
} else {
let level_colored = colorize_level(level);
println!("{} {} [{}] {}", display_ts, level_colored, target, message);
}
}
/// Convert an RFC 3339 timestamp to local time display.
fn convert_to_local_time(ts: &str) -> String {
chrono::DateTime::parse_from_rfc3339(ts)
.map(|dt| {
dt.with_timezone(&chrono::Local)
.format("%Y-%m-%dT%H:%M:%S%.3f")
.to_string()
})
.unwrap_or_else(|_| ts.to_string())
}
/// Apply ANSI color to log level for terminal display.
fn colorize_level(level: &str) -> String {
match level {
"ERROR" => format!("\x1b[31m{}\x1b[0m", level), // red
"WARN" => format!("\x1b[33m{}\x1b[0m", level), // yellow
"INFO" => format!("\x1b[32m{}\x1b[0m", level), // green
"DEBUG" => format!("\x1b[36m{}\x1b[0m", level), // cyan
"TRACE" => format!("\x1b[90m{}\x1b[0m", level), // gray
_ => level.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_colorize_level() {
assert!(colorize_level("ERROR").contains("\x1b[31m"));
assert!(colorize_level("WARN").contains("\x1b[33m"));
assert!(colorize_level("INFO").contains("\x1b[32m"));
assert!(colorize_level("DEBUG").contains("\x1b[36m"));
assert!(colorize_level("TRACE").contains("\x1b[90m"));
assert_eq!(colorize_level("UNKNOWN"), "UNKNOWN");
}
#[test]
fn test_convert_to_local_time_valid() {
let ts = "2024-01-15T10:30:00.000Z";
let result = convert_to_local_time(ts);
assert!(result.contains("2024-01-15"));
}
#[test]
fn test_convert_to_local_time_invalid() {
let ts = "not-a-timestamp";
assert_eq!(convert_to_local_time(ts), "not-a-timestamp");
}
#[test]
fn test_print_log_entry_json() {
let entry = serde_json::json!({
"level": "INFO",
"target": "ironclaw::agent",
"message": "test message",
"timestamp": "2024-01-15T10:30:00.000Z"
});
let cmd = LogsCommand {
follow: false,
limit: 200,
json: true,
local_time: false,
plain: false,
url: None,
token: None,
timeout: 5000,
level: None,
};
// Should not panic
print_log_entry(&entry, &cmd);
}
#[test]
fn test_tail_file_small() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.log");
std::fs::write(&path, "line1\nline2\nline3\nline4\nline5\n").unwrap();
let result = tail_file(&path, 3).unwrap();
assert_eq!(result, vec!["line3", "line4", "line5"]);
}
#[test]
fn test_tail_file_fewer_lines_than_limit() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.log");
std::fs::write(&path, "a\nb\n").unwrap();
let result = tail_file(&path, 200).unwrap();
assert_eq!(result, vec!["a", "b"]);
}
#[test]
fn test_tail_file_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.log");
std::fs::write(&path, "").unwrap();
let result = tail_file(&path, 10).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_tail_file_large() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("big.log");
// Write 10000 lines to test chunked reading.
let content: String = (0..10000).map(|i| format!("line {}\n", i)).collect();
std::fs::write(&path, &content).unwrap();
let result = tail_file(&path, 5).unwrap();
assert_eq!(result.len(), 5);
assert_eq!(result[0], "line 9995");
assert_eq!(result[4], "line 9999");
}
#[test]
fn test_tail_file_no_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.log");
std::fs::write(&path, "line1\nline2\nline3").unwrap();
let result = tail_file(&path, 2).unwrap();
assert_eq!(result, vec!["line2", "line3"]);
}
}
+10
View File
@@ -11,6 +11,7 @@
//! - Managing OS service (`service install`, `service start`, `service stop`)
//! - Listing configured channels (`channels list`)
//! - Active health diagnostics (`doctor`)
//! - Viewing gateway logs (`logs`)
//! - Checking system health (`status`)
mod channels;
@@ -19,6 +20,7 @@ mod config;
mod doctor;
#[cfg(feature = "import")]
pub mod import;
mod logs;
mod mcp;
pub mod memory;
pub mod oauth_defaults;
@@ -36,6 +38,7 @@ pub use config::{ConfigCommand, run_config_command};
pub use doctor::run_doctor_command;
#[cfg(feature = "import")]
pub use import::{ImportCommand, run_import_command};
pub use logs::{LogsCommand, run_logs_command};
pub use mcp::{McpCommand, run_mcp_command};
pub use memory::MemoryCommand;
pub use memory::run_memory_command_with_db;
@@ -206,6 +209,13 @@ pub enum Command {
)]
Doctor,
/// View and manage gateway logs
#[command(
about = "View and manage gateway logs",
long_about = "Tail gateway logs, stream live output, or adjust log level.\nExamples:\n ironclaw logs # Show last 200 lines from gateway.log\n ironclaw logs --follow # Stream live logs via SSE\n ironclaw logs --level # Show current log level\n ironclaw logs --level debug # Set log level to debug"
)]
Logs(LogsCommand),
/// Show system health and diagnostics
#[command(
about = "Show system status",
@@ -0,0 +1,36 @@
---
source: src/cli/mod.rs
expression: help
---
Secure personal AI assistant that protects your data and expands its capabilities
Usage: ironclaw [OPTIONS] [COMMAND]
Commands:
run Run the AI agent
onboard Run interactive setup wizard
config Manage app configs
tool Manage WASM tools
registry Browse/install extensions
channels Manage channels
routines Manage routines
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
service Manage OS service
skills Manage skills
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
completion Generate completions
import Import from other AI systems
help Print this message or the help of the given subcommand(s)
Options:
--cli-only Run in interactive CLI mode only (disable other channels)
--no-db Skip database connection (for testing)
-m, --message <MESSAGE> Single message mode - send one message and exit
-c, --config <CONFIG> Configuration file path (optional, uses env vars by default)
--no-onboard Skip first-run onboarding check
-h, --help Print help (see more with '--help')
-V, --version Print version
@@ -20,6 +20,7 @@ Commands:
service Manage OS service
skills Manage skills
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
completion Generate completions
help Print this message or the help of the given subcommand(s)
@@ -0,0 +1,52 @@
---
source: src/cli/mod.rs
expression: help
---
IronClaw is a secure AI assistant. Use 'ironclaw <subcommand> --help' for details.
Examples:
ironclaw run # Start the agent
ironclaw config list # List configs
Usage: ironclaw [OPTIONS] [COMMAND]
Commands:
run Run the AI agent
onboard Run interactive setup wizard
config Manage app configs
tool Manage WASM tools
registry Browse/install extensions
channels Manage channels
routines Manage routines
mcp Manage MCP servers
memory Manage workspace memory
pairing Manage DM pairing
service Manage OS service
skills Manage skills
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
completion Generate completions
import Import from other AI systems
help Print this message or the help of the given subcommand(s)
Options:
--cli-only
Run in interactive CLI mode only (disable other channels)
--no-db
Skip database connection (for testing)
-m, --message <MESSAGE>
Single message mode - send one message and exit
-c, --config <CONFIG>
Configuration file path (optional, uses env vars by default)
--no-onboard
Skip first-run onboarding check
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
@@ -23,6 +23,7 @@ Commands:
service Manage OS service
skills Manage skills
doctor Run diagnostics
logs View and manage gateway logs
status Show system status
completion Generate completions
help Print this message or the help of the given subcommand(s)
+309 -33
View File
@@ -91,11 +91,20 @@ pub struct SignalConfig {
}
impl ChannelsConfig {
/// Resolve channels config following `env > settings > default` for every field.
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() {
let cs = &settings.channels;
// --- HTTP webhook ---
// HTTP is enabled when env vars are set OR settings has it enabled.
let http_enabled_by_env =
optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some();
let http = if http_enabled_by_env || cs.http_enabled {
Some(HttpConfig {
host: optional_env("HTTP_HOST")?.unwrap_or_else(|| "0.0.0.0".to_string()),
port: parse_optional_env("HTTP_PORT", 8080)?,
host: optional_env("HTTP_HOST")?
.or_else(|| cs.http_host.clone())
.unwrap_or_else(|| "0.0.0.0".to_string()),
port: parse_optional_env("HTTP_PORT", cs.http_port.unwrap_or(8080))?,
webhook_secret: optional_env("HTTP_WEBHOOK_SECRET")?.map(SecretString::from),
user_id: optional_env("HTTP_USER_ID")?.unwrap_or_else(|| "http".to_string()),
})
@@ -103,42 +112,58 @@ impl ChannelsConfig {
None
};
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", true)?;
// --- Web gateway ---
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
let gateway = if gateway_enabled {
Some(GatewayConfig {
host: optional_env("GATEWAY_HOST")?.unwrap_or_else(|| "127.0.0.1".to_string()),
port: parse_optional_env("GATEWAY_PORT", 3000)?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?,
user_id: optional_env("GATEWAY_USER_ID")?.unwrap_or_else(|| "default".to_string()),
host: optional_env("GATEWAY_HOST")?
.or_else(|| cs.gateway_host.clone())
.unwrap_or_else(|| "127.0.0.1".to_string()),
port: parse_optional_env(
"GATEWAY_PORT",
cs.gateway_port.unwrap_or(DEFAULT_GATEWAY_PORT),
)?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
.or_else(|| cs.gateway_auth_token.clone()),
user_id: optional_env("GATEWAY_USER_ID")?
.or_else(|| cs.gateway_user_id.clone())
.unwrap_or_else(|| "default".to_string()),
})
} else {
None
};
let signal = if let Some(http_url) = optional_env("SIGNAL_HTTP_URL")? {
let account = optional_env("SIGNAL_ACCOUNT")?.ok_or(ConfigError::InvalidValue {
key: "SIGNAL_ACCOUNT".to_string(),
message: "SIGNAL_ACCOUNT is required when SIGNAL_HTTP_URL is set".to_string(),
})?;
let allow_from = match std::env::var_os("SIGNAL_ALLOW_FROM") {
// --- Signal ---
let signal_url = optional_env("SIGNAL_HTTP_URL")?.or_else(|| cs.signal_http_url.clone());
let signal = if let Some(http_url) = signal_url {
let account = optional_env("SIGNAL_ACCOUNT")?
.or_else(|| cs.signal_account.clone())
.ok_or(ConfigError::InvalidValue {
key: "SIGNAL_ACCOUNT".to_string(),
message: "SIGNAL_ACCOUNT is required when Signal is enabled".to_string(),
})?;
let allow_from_str =
optional_env("SIGNAL_ALLOW_FROM")?.or_else(|| cs.signal_allow_from.clone());
let allow_from = match allow_from_str {
None => vec![account.clone()],
Some(val) => {
let s = val.to_string_lossy();
s.split(',')
.map(|e| e.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
Some(s) => s
.split(',')
.map(|e| e.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
};
let dm_policy =
optional_env("SIGNAL_DM_POLICY")?.unwrap_or_else(|| "pairing".to_string());
let group_policy =
optional_env("SIGNAL_GROUP_POLICY")?.unwrap_or_else(|| "allowlist".to_string());
let dm_policy = optional_env("SIGNAL_DM_POLICY")?
.or_else(|| cs.signal_dm_policy.clone())
.unwrap_or_else(|| "pairing".to_string());
let group_policy = optional_env("SIGNAL_GROUP_POLICY")?
.or_else(|| cs.signal_group_policy.clone())
.unwrap_or_else(|| "allowlist".to_string());
Some(SignalConfig {
http_url,
account,
allow_from,
allow_from_groups: optional_env("SIGNAL_ALLOW_FROM_GROUPS")?
.or_else(|| cs.signal_allow_from_groups.clone())
.map(|s| {
s.split(',')
.map(|e| e.trim().to_string())
@@ -149,6 +174,7 @@ impl ChannelsConfig {
dm_policy,
group_policy,
group_allow_from: optional_env("SIGNAL_GROUP_ALLOW_FROM")?
.or_else(|| cs.signal_group_allow_from.clone())
.map(|s| {
s.split(',')
.map(|e| e.trim().to_string())
@@ -167,9 +193,17 @@ impl ChannelsConfig {
None
};
let cli_enabled = optional_env("CLI_ENABLED")?
.map(|s| s.to_lowercase() != "false" && s != "0")
.unwrap_or(true);
// --- CLI ---
let cli_enabled = parse_bool_env("CLI_ENABLED", cs.cli_enabled)?;
// --- WASM channels ---
let wasm_channels_dir = optional_env("WASM_CHANNELS_DIR")?
.map(PathBuf::from)
.or_else(|| cs.wasm_channels_dir.clone())
.unwrap_or_else(default_channels_dir);
let wasm_channels_enabled =
parse_bool_env("WASM_CHANNELS_ENABLED", cs.wasm_channels_enabled)?;
Ok(Self {
cli: CliConfig {
@@ -178,12 +212,10 @@ impl ChannelsConfig {
http,
gateway,
signal,
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
.map(PathBuf::from)
.unwrap_or_else(default_channels_dir),
wasm_channels_enabled: parse_bool_env("WASM_CHANNELS_ENABLED", true)?,
wasm_channels_dir,
wasm_channels_enabled,
wasm_channel_owner_ids: {
let mut ids = settings.channels.wasm_channel_owner_ids.clone();
let mut ids = cs.wasm_channel_owner_ids.clone();
// Backwards compat: TELEGRAM_OWNER_ID env var
if let Some(id_str) = optional_env("TELEGRAM_OWNER_ID")? {
let id: i64 = id_str.parse().map_err(|e: std::num::ParseIntError| {
@@ -200,6 +232,10 @@ impl ChannelsConfig {
}
}
/// Default gateway port — used both in `resolve()` and as the fallback in
/// other modules that need to construct a gateway URL.
pub const DEFAULT_GATEWAY_PORT: u16 = 3000;
/// Get the default channels directory (~/.ironclaw/channels/).
fn default_channels_dir() -> PathBuf {
ironclaw_base_dir().join("channels")
@@ -362,4 +398,244 @@ mod tests {
"expected path ending in 'channels', got: {dir:?}"
);
}
#[test]
fn default_gateway_port_constant() {
assert_eq!(DEFAULT_GATEWAY_PORT, 3000);
}
/// With default settings and no env vars, gateway should use defaults.
#[test]
fn resolve_gateway_defaults_from_settings() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
// Clear env vars that would interfere
unsafe {
std::env::remove_var("GATEWAY_ENABLED");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let settings = crate::settings::Settings::default();
let cfg = ChannelsConfig::resolve(&settings).unwrap();
let gw = cfg.gateway.expect("gateway should be enabled by default");
assert_eq!(gw.host, "127.0.0.1");
assert_eq!(gw.port, DEFAULT_GATEWAY_PORT);
assert!(gw.auth_token.is_none());
assert_eq!(gw.user_id, "default");
}
/// Settings values should be used when no env vars are set.
#[test]
fn resolve_gateway_from_settings() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
unsafe {
std::env::remove_var("GATEWAY_ENABLED");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let mut settings = crate::settings::Settings::default();
settings.channels.gateway_port = Some(4000);
settings.channels.gateway_host = Some("0.0.0.0".to_string());
settings.channels.gateway_auth_token = Some("db-token-123".to_string());
settings.channels.gateway_user_id = Some("myuser".to_string());
let cfg = ChannelsConfig::resolve(&settings).unwrap();
let gw = cfg.gateway.expect("gateway should be enabled");
assert_eq!(gw.port, 4000);
assert_eq!(gw.host, "0.0.0.0");
assert_eq!(gw.auth_token.as_deref(), Some("db-token-123"));
assert_eq!(gw.user_id, "myuser");
}
/// Env vars should override settings values.
#[test]
fn resolve_env_overrides_settings() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
unsafe {
std::env::set_var("GATEWAY_PORT", "5000");
std::env::set_var("GATEWAY_HOST", "10.0.0.1");
std::env::set_var("GATEWAY_AUTH_TOKEN", "env-token");
std::env::remove_var("GATEWAY_ENABLED");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let mut settings = crate::settings::Settings::default();
settings.channels.gateway_port = Some(4000);
settings.channels.gateway_host = Some("0.0.0.0".to_string());
settings.channels.gateway_auth_token = Some("db-token".to_string());
let cfg = ChannelsConfig::resolve(&settings).unwrap();
let gw = cfg.gateway.expect("gateway should be enabled");
assert_eq!(gw.port, 5000, "env should override settings");
assert_eq!(gw.host, "10.0.0.1", "env should override settings");
assert_eq!(
gw.auth_token.as_deref(),
Some("env-token"),
"env should override settings"
);
// Cleanup
unsafe {
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
}
}
/// CLI enabled should fall back to settings.
#[test]
fn resolve_cli_enabled_from_settings() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
unsafe {
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("GATEWAY_ENABLED");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let mut settings = crate::settings::Settings::default();
settings.channels.cli_enabled = false;
let cfg = ChannelsConfig::resolve(&settings).unwrap();
assert!(!cfg.cli.enabled, "settings should disable CLI");
}
/// HTTP channel should activate when settings has it enabled.
#[test]
fn resolve_http_from_settings() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
unsafe {
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("HTTP_WEBHOOK_SECRET");
std::env::remove_var("HTTP_USER_ID");
std::env::remove_var("GATEWAY_ENABLED");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let mut settings = crate::settings::Settings::default();
settings.channels.http_enabled = true;
settings.channels.http_port = Some(9090);
settings.channels.http_host = Some("10.0.0.1".to_string());
let cfg = ChannelsConfig::resolve(&settings).unwrap();
let http = cfg.http.expect("HTTP should be enabled from settings");
assert_eq!(http.port, 9090);
assert_eq!(http.host, "10.0.0.1");
}
/// Settings round-trip through DB map for new gateway fields.
#[test]
fn settings_gateway_fields_db_roundtrip() {
let mut settings = crate::settings::Settings::default();
settings.channels.gateway_port = Some(4000);
settings.channels.gateway_host = Some("0.0.0.0".to_string());
settings.channels.gateway_auth_token = Some("tok-abc".to_string());
settings.channels.gateway_user_id = Some("myuser".to_string());
settings.channels.cli_enabled = false;
let map = settings.to_db_map();
let restored = crate::settings::Settings::from_db_map(&map);
assert_eq!(restored.channels.gateway_port, Some(4000));
assert_eq!(restored.channels.gateway_host.as_deref(), Some("0.0.0.0"));
assert_eq!(
restored.channels.gateway_auth_token.as_deref(),
Some("tok-abc")
);
assert_eq!(restored.channels.gateway_user_id.as_deref(), Some("myuser"));
assert!(!restored.channels.cli_enabled);
}
/// Invalid boolean env values must produce errors, not silently degrade.
#[test]
fn resolve_rejects_invalid_bool_env() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
let settings = crate::settings::Settings::default();
// GATEWAY_ENABLED=maybe should error
unsafe {
std::env::set_var("GATEWAY_ENABLED", "maybe");
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let result = ChannelsConfig::resolve(&settings);
assert!(result.is_err(), "GATEWAY_ENABLED=maybe should be rejected");
// CLI_ENABLED=on should error
unsafe {
std::env::remove_var("GATEWAY_ENABLED");
std::env::set_var("CLI_ENABLED", "on");
}
let result = ChannelsConfig::resolve(&settings);
assert!(result.is_err(), "CLI_ENABLED=on should be rejected");
// WASM_CHANNELS_ENABLED=yes should error
unsafe {
std::env::remove_var("CLI_ENABLED");
std::env::set_var("WASM_CHANNELS_ENABLED", "yes");
}
let result = ChannelsConfig::resolve(&settings);
assert!(
result.is_err(),
"WASM_CHANNELS_ENABLED=yes should be rejected"
);
// Cleanup
unsafe {
std::env::remove_var("WASM_CHANNELS_ENABLED");
}
}
}
+3 -1
View File
@@ -34,7 +34,9 @@ use crate::settings::Settings;
// Re-export all public types so `crate::config::FooConfig` continues to work.
pub use self::agent::AgentConfig;
pub use self::builder::BuilderModeConfig;
pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig, SignalConfig};
pub use self::channels::{
ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig,
};
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
pub use self::embeddings::EmbeddingsConfig;
pub use self::heartbeat::HeartbeatConfig;
+13 -4
View File
@@ -2864,9 +2864,16 @@ impl ExtensionManager {
// Try to list and create tools.
// A 401/auth error means the server requires OAuth — surface as
// AuthRequired so the activate handler triggers the OAuth flow.
// Some servers (e.g. GitHub MCP) return 400 with "Authorization header
// is badly formatted" instead of 401 when auth is missing or invalid.
let mcp_tools = client.list_tools().await.map_err(|e| {
let msg = e.to_string();
if msg.contains("requires authentication") || msg.contains("401") {
let msg_lower = msg.to_ascii_lowercase();
if msg_lower.contains("requires authentication")
|| msg.contains("401")
|| (msg.contains("400")
&& (msg_lower.contains("authorization") || msg_lower.contains("authenticate")))
{
ExtensionError::AuthRequired
} else {
ExtensionError::ActivationFailed(msg)
@@ -3444,7 +3451,8 @@ impl ExtensionManager {
.or_else(|| relay_config.callback_url.clone())
.unwrap_or_else(|| {
let host = std::env::var("GATEWAY_HOST").unwrap_or_else(|_| "127.0.0.1".into());
let port = std::env::var("GATEWAY_PORT").unwrap_or_else(|_| "3001".into());
let port = std::env::var("GATEWAY_PORT")
.unwrap_or_else(|_| crate::config::DEFAULT_GATEWAY_PORT.to_string());
format!("http://{}:{}", host, port)
});
@@ -3843,11 +3851,12 @@ impl ExtensionManager {
secret_name, name
)));
}
if secret_value.trim().is_empty() {
let trimmed_value = secret_value.trim();
if trimmed_value.is_empty() {
continue;
}
let params =
CreateSecretParams::new(secret_name, secret_value).with_provider(name.to_string());
CreateSecretParams::new(secret_name, trimmed_value).with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
+4
View File
@@ -92,6 +92,10 @@ async fn async_main() -> anyhow::Result<()> {
return ironclaw::cli::run_skills_command(skills_cmd.clone(), cli.config.as_deref())
.await;
}
Some(Command::Logs(logs_cmd)) => {
init_cli_tracing();
return ironclaw::cli::run_logs_command(logs_cmd.clone(), cli.config.as_deref()).await;
}
Some(Command::Doctor) => {
init_cli_tracing();
return ironclaw::cli::run_doctor_command().await;
+53 -1
View File
@@ -220,7 +220,7 @@ pub struct TunnelSettings {
}
/// Channel-specific settings.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelSettings {
/// Whether HTTP webhook channel is enabled.
#[serde(default)]
@@ -234,6 +234,30 @@ pub struct ChannelSettings {
#[serde(default)]
pub http_host: Option<String>,
/// Whether the web gateway is enabled.
#[serde(default = "default_true")]
pub gateway_enabled: bool,
/// Web gateway listen host.
#[serde(default)]
pub gateway_host: Option<String>,
/// Web gateway listen port.
#[serde(default)]
pub gateway_port: Option<u16>,
/// Web gateway bearer auth token. Auto-generated at gateway startup if unset.
#[serde(default)]
pub gateway_auth_token: Option<String>,
/// Web gateway user ID.
#[serde(default)]
pub gateway_user_id: Option<String>,
/// Whether the CLI channel is enabled.
#[serde(default = "default_true")]
pub cli_enabled: bool,
/// Whether Signal channel is enabled.
#[serde(default)]
pub signal_enabled: bool,
@@ -289,6 +313,34 @@ pub struct ChannelSettings {
pub wasm_channels_dir: Option<PathBuf>,
}
impl Default for ChannelSettings {
fn default() -> Self {
Self {
http_enabled: false,
http_port: None,
http_host: None,
gateway_enabled: true,
gateway_host: None,
gateway_port: None,
gateway_auth_token: None,
gateway_user_id: None,
cli_enabled: true,
signal_enabled: false,
signal_http_url: None,
signal_account: None,
signal_allow_from: None,
signal_allow_from_groups: None,
signal_dm_policy: None,
signal_group_policy: None,
signal_group_allow_from: None,
wasm_channel_owner_ids: std::collections::HashMap::new(),
wasm_channels: Vec::new(),
wasm_channels_enabled: true,
wasm_channels_dir: None,
}
}
}
/// Heartbeat configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatSettings {
+24 -1
View File
@@ -301,7 +301,11 @@ impl Tool for SkillInstallTool {
let content = if let Some(raw) = params.get("content").and_then(|v| v.as_str()) {
// Direct content provided
raw.to_string()
} else if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
} else if let Some(url) = params
.get("url")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
// Fetch from explicit URL
fetch_skill_content(url).await?
} else {
@@ -1297,4 +1301,23 @@ mod tests {
);
}
}
#[test]
fn test_empty_url_param_is_treated_as_absent() {
// LLMs sometimes pass "" for optional parameters instead of omitting them.
// Before the fix, url: "" would match Some("") and attempt to fetch from an
// empty URL (failing with an invalid URL error) instead of falling through to
// the catalog lookup. The full execute path cannot be tested here without a
// real catalog and database, so this test verifies the parameter filtering
// behaviour directly.
let params = serde_json::json!({"name": "my-skill", "url": ""});
let url = params
.get("url")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
assert!(
url.is_none(),
"empty url string should be treated as absent"
);
}
}
+27 -7
View File
@@ -24,7 +24,7 @@ use crate::tools::mcp::config::McpServerConfig;
/// Per-request timeouts can override the default via `.timeout()` on
/// the request builder.
fn oauth_http_client() -> Result<&'static reqwest::Client, AuthError> {
static CLIENT: std::sync::OnceLock<Result<reqwest::Client, String>> =
static CLIENT: std::sync::OnceLock<Result<reqwest::Client, AuthError>> =
std::sync::OnceLock::new();
CLIENT
.get_or_init(|| {
@@ -32,10 +32,10 @@ fn oauth_http_client() -> Result<&'static reqwest::Client, AuthError> {
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| e.to_string())
.map_err(|e| AuthError::Http(e.to_string()))
})
.as_ref()
.map_err(|e| AuthError::Http(e.clone()))
.map_err(Clone::clone)
}
/// Log a debug message when a discovery/auth response is a redirect.
@@ -57,7 +57,7 @@ fn log_redirect_if_applicable(url: &str, response: &reqwest::Response) {
}
/// OAuth authorization error.
#[derive(Debug, thiserror::Error)]
#[derive(Debug, Clone, thiserror::Error)]
pub enum AuthError {
#[error("Server does not support OAuth authorization")]
NotSupported,
@@ -443,6 +443,11 @@ async fn fetch_resource_metadata(url: &str) -> Result<ProtectedResourceMetadata,
}
/// Try to discover OAuth metadata via 401 challenge response.
///
/// Also accepts 400 responses, since some servers return 400 for
/// unauthenticated requests. In practice the 400 path rarely yields a
/// `WWW-Authenticate` header (GitHub's MCP does not), so discovery
/// typically falls through to strategy 2 (RFC 9728) or 3 (direct).
async fn discover_via_401(server_url: &str) -> Result<AuthorizationServerMetadata, AuthError> {
validate_url_safe(server_url).await?;
@@ -459,9 +464,13 @@ async fn discover_via_401(server_url: &str) -> Result<AuthorizationServerMetadat
log_redirect_if_applicable(server_url, &response);
if response.status().as_u16() != 401 {
let status = response.status().as_u16();
// Accept 401 (standard) and 400 (some servers like GitHub MCP use this).
// In both cases, look for WWW-Authenticate header with discovery metadata.
if status != 401 && status != 400 {
return Err(AuthError::DiscoveryFailed(format!(
"Expected 401, got {}",
"Expected 401 or 400, got {}",
response.status()
)));
}
@@ -471,7 +480,7 @@ async fn discover_via_401(server_url: &str) -> Result<AuthorizationServerMetadat
.get("WWW-Authenticate")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
AuthError::DiscoveryFailed("No WWW-Authenticate header in 401 response".to_string())
AuthError::DiscoveryFailed(format!("No WWW-Authenticate header in {} response", status))
})?;
let resource_metadata_url = parse_resource_metadata_url(www_auth).ok_or_else(|| {
@@ -1511,6 +1520,17 @@ mod tests {
}
}
#[test]
fn test_auth_error_clone_preserves_http_variant_and_payload() {
let original = AuthError::Http("builder failed".to_string());
let cloned = original.clone();
match cloned {
AuthError::Http(message) => assert_eq!(message, "builder failed"), // safety: test assertion in #[cfg(test)] module; not production panic path
other => panic!("expected AuthError::Http variant, got {other:?}"),
}
}
// --- New tests for well-known URI construction ---
#[test]
+142 -2
View File
@@ -275,7 +275,10 @@ impl McpClient {
.keys()
.any(|k| k.eq_ignore_ascii_case("authorization"));
if !has_custom_auth && let Some(token) = self.get_access_token().await? {
headers.insert("Authorization".to_string(), format!("Bearer {}", token));
let trimmed = token.trim();
if !trimmed.is_empty() {
headers.insert("Authorization".to_string(), format!("Bearer {}", trimmed));
}
}
if let Some(ref session_manager) = self.session_manager
&& let Some(session_id) = session_manager.get_session_id(&self.server_name).await
@@ -302,7 +305,12 @@ impl McpClient {
match result {
Ok(response) => return Ok(response),
Err(ToolError::ExternalService(ref msg))
if msg.contains("401") || msg.contains("Unauthorized") =>
if msg.contains("401")
|| msg.contains("Unauthorized")
|| (msg.contains("400") && {
let lower = msg.to_ascii_lowercase();
lower.contains("authorization") || lower.contains("authenticate")
}) =>
{
if attempt == 0
&& let Some(ref secrets) = self.secrets
@@ -1113,4 +1121,136 @@ mod tests {
let approval = wrapper.requires_approval(&serde_json::json!({}));
assert_eq!(approval, ApprovalRequirement::Never);
}
// Regression test: empty/whitespace-only tokens must not produce a
// malformed `Authorization: Bearer ` header (GitHub MCP returns 400
// "Authorization header is badly formatted" in this case).
#[tokio::test]
async fn test_build_headers_skips_empty_token() {
use crate::secrets::{CreateSecretParams, DecryptedSecret, Secret, SecretError, SecretRef};
use uuid::Uuid;
// In-memory secrets store that returns a whitespace-only string for the token.
struct EmptyTokenStore;
#[async_trait]
impl crate::secrets::SecretsStore for EmptyTokenStore {
async fn create(
&self,
_user_id: &str,
_params: CreateSecretParams,
) -> Result<Secret, SecretError> {
unimplemented!()
}
async fn get(&self, _user_id: &str, _name: &str) -> Result<Secret, SecretError> {
unimplemented!()
}
async fn get_decrypted(
&self,
_user_id: &str,
_name: &str,
) -> Result<DecryptedSecret, SecretError> {
DecryptedSecret::from_bytes(b" ".to_vec())
}
async fn exists(&self, _user_id: &str, _name: &str) -> Result<bool, SecretError> {
Ok(true)
}
async fn delete(&self, _user_id: &str, _name: &str) -> Result<bool, SecretError> {
Ok(true)
}
async fn list(&self, _user_id: &str) -> Result<Vec<SecretRef>, SecretError> {
Ok(Vec::new())
}
async fn record_usage(&self, _secret_id: Uuid) -> Result<(), SecretError> {
Ok(())
}
async fn is_accessible(
&self,
_user_id: &str,
_secret_name: &str,
_allowed_secrets: &[String],
) -> Result<bool, SecretError> {
Ok(true)
}
}
let config = McpServerConfig::new("github", "https://api.githubcopilot.com/mcp/");
let session_manager = Arc::new(McpSessionManager::new());
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(EmptyTokenStore);
let client = McpClient::new_authenticated(config, session_manager, secrets, "test-user");
let headers = client.build_request_headers().await.unwrap(); // safety: test
assert!(
// safety: test
!headers.contains_key("Authorization"),
"Empty/whitespace token must not produce an Authorization header, got: {:?}",
headers.get("Authorization")
);
}
// Regression test: tokens with leading/trailing whitespace must be trimmed
// before being used in the Authorization header.
#[tokio::test]
async fn test_build_headers_trims_token() {
use crate::secrets::{CreateSecretParams, DecryptedSecret, Secret, SecretError, SecretRef};
use uuid::Uuid;
struct PaddedTokenStore;
#[async_trait]
impl crate::secrets::SecretsStore for PaddedTokenStore {
async fn create(
&self,
_user_id: &str,
_params: CreateSecretParams,
) -> Result<Secret, SecretError> {
unimplemented!()
}
async fn get(&self, _user_id: &str, _name: &str) -> Result<Secret, SecretError> {
unimplemented!()
}
async fn get_decrypted(
&self,
_user_id: &str,
_name: &str,
) -> Result<DecryptedSecret, SecretError> {
DecryptedSecret::from_bytes(b" gho_abc123 \n".to_vec())
}
async fn exists(&self, _user_id: &str, _name: &str) -> Result<bool, SecretError> {
Ok(true)
}
async fn delete(&self, _user_id: &str, _name: &str) -> Result<bool, SecretError> {
Ok(true)
}
async fn list(&self, _user_id: &str) -> Result<Vec<SecretRef>, SecretError> {
Ok(Vec::new())
}
async fn record_usage(&self, _secret_id: Uuid) -> Result<(), SecretError> {
Ok(())
}
async fn is_accessible(
&self,
_user_id: &str,
_secret_name: &str,
_allowed_secrets: &[String],
) -> Result<bool, SecretError> {
Ok(true)
}
}
let config = McpServerConfig::new("github", "https://api.githubcopilot.com/mcp/");
let session_manager = Arc::new(McpSessionManager::new());
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(PaddedTokenStore);
let client = McpClient::new_authenticated(config, session_manager, secrets, "test-user");
let headers = client.build_request_headers().await.unwrap(); // safety: test
assert_eq!(
// safety: test
headers.get("Authorization").unwrap(), // safety: test
"Bearer gho_abc123",
"Token must be trimmed before use in Authorization header"
);
}
}