Compare commits

..
Author SHA1 Message Date
Nick Pismenkov b01bfecb48 remove CLAUDE.md changes 2026-03-09 17:13:46 -07:00
Nick Pismenkov 940c182fea fix: remove accidentally committed binary 2026-03-09 17:13:04 -07:00
Nick Pismenkov 654bda9239 Merge main into fix/libsql-crash
Resolves conflict in CLAUDE.md by keeping our detailed Configuration section.

[skip-regression-check]
2026-03-09 16:40:16 -07:00
Nick Pismenkov 13cde37722 fix
[skip-regression-check]
2026-03-09 16:40:11 -07:00
Nick PismenkovandClaude Haiku 4.5 f5123055b5 docs: document libSQL CLI connection crash issue
Add detailed documentation for the libSQL CLI crash that blocks interactive
setup flows on libSQL deployments (ironclaw tool setup, ironclaw secret set,
ironclaw mcp auth).

- Add known issue comment to src/db/mod.rs create_secrets_store() documenting
  the "invalid connection string" crash on libSQL CLI subcommands
- Add related comments to src/cli/tool.rs init_secrets_store() and
  src/cli/mcp.rs get_secrets_store() functions
- Add to CLAUDE.md Current Limitations section as priority item #1
- Include workaround: manually read master key from /proc/PID/environ,
  encrypt secrets with AES-256-GCM, write directly to secrets table
- Root cause unknown: libSQL works fine in main process but fails when
  called from CLI subcommands (possibly path resolution, file permissions,
  or WAL mode conflicts)
- Related: #655 (libSQL backend gaps)

Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-03-09 16:01:07 -07:00
23 changed files with 71 additions and 492 deletions
-2
View File
@@ -115,8 +115,6 @@ AGENT_NAME=ironclaw
AGENT_MAX_PARALLEL_JOBS=5
AGENT_JOB_TIMEOUT_SECS=3600
AGENT_STUCK_THRESHOLD_SECS=300
# Maximum tokens per job (0 = unlimited, also settable via settings.json agent.max_tokens_per_job)
# AGENT_MAX_TOKENS_PER_JOB=0
# Enable planning phase before tool execution (default: true)
AGENT_USE_PLANNING=true
+1 -2
View File
@@ -2,7 +2,7 @@ name: Claude Code Review
on:
pull_request:
types: [labeled]
types: [opened, labeled]
permissions:
contents: read
@@ -28,7 +28,6 @@ jobs:
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_bots: "ironclaw-ci[bot]"
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
prompt: |
Code review this pull request. Follow these steps precisely:
+1 -7
View File
@@ -44,7 +44,6 @@ jobs:
clippy-windows:
name: Clippy Windows (${{ matrix.name }})
if: github.base_ref == 'main'
runs-on: windows-latest
strategy:
fail-fast: false
@@ -77,12 +76,7 @@ jobs:
needs: [format, clippy, clippy-windows]
steps:
- run: |
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.clippy-windows.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
# clippy-windows only runs on main PRs, so skip/success are both acceptable
if [[ "${{ needs.clippy-windows.result }}" == "failure" ]]; then
echo "Windows clippy failed"
exit 1
fi
+2
View File
@@ -115,6 +115,7 @@ jobs:
- name: Generate GitHub App token
id: app-token
if: ${{ secrets.GH_RELEASES_MANAGER_APP_ID != '' }}
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
@@ -229,6 +230,7 @@ jobs:
- name: Generate GitHub App token
id: app-token
if: ${{ secrets.GH_RELEASES_MANAGER_APP_ID != '' }}
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
-6
View File
@@ -2,8 +2,6 @@ name: Run Tests
on:
workflow_call:
pull_request:
branches:
- main
push:
branches:
- main
@@ -43,7 +41,6 @@ jobs:
name: Telegram Channel Tests
if: >
github.event_name == 'push' ||
github.event_name == 'workflow_call' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: ubuntu-latest
steps:
@@ -59,7 +56,6 @@ jobs:
name: Windows Build (${{ matrix.name }})
if: >
github.event_name == 'push' ||
github.event_name == 'workflow_call' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: windows-latest
strategy:
@@ -87,7 +83,6 @@ jobs:
name: WASM WIT Compatibility
if: >
github.event_name == 'push' ||
github.event_name == 'workflow_call' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: ubuntu-latest
steps:
@@ -111,7 +106,6 @@ jobs:
name: Docker Build
if: >
github.event_name == 'push' ||
github.event_name == 'workflow_call' ||
(github.event_name == 'pull_request' && github.base_ref != 'staging')
runs-on: ubuntu-latest
steps:
+1
View File
@@ -28,3 +28,4 @@ trace_*.json
# Local Claude Code settings (machine-specific, should not be committed)
.claude/settings.local.json
rust_out
-93
View File
@@ -1205,7 +1205,6 @@ mod tests {
max_tool_iterations: 50,
auto_approve_tools: false,
default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
},
deps,
Arc::new(ChannelManager::new()),
@@ -1264,96 +1263,6 @@ mod tests {
}
}
#[test]
fn test_always_approval_requirement_bypasses_session_auto_approve() {
// Regression test: even if tool is auto-approved in session,
// ApprovalRequirement::Always must still trigger approval.
use crate::tools::ApprovalRequirement;
let mut session = Session::new("user-1");
let tool_name = "tool_remove";
// Manually auto-approve tool_remove in this session
session.auto_approve_tool(tool_name);
assert!(
session.is_tool_auto_approved(tool_name),
"tool should be auto-approved"
);
// However, ApprovalRequirement::Always should always require approval
// This is verified by the dispatcher logic: Always => true (ignores session state)
let always_req = ApprovalRequirement::Always;
let requires_approval = match always_req {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
ApprovalRequirement::Always => true,
};
assert!(
requires_approval,
"ApprovalRequirement::Always must require approval even when tool is auto-approved"
);
}
#[test]
fn test_always_approval_requirement_vs_unless_auto_approved() {
// Verify the two requirements behave differently
use crate::tools::ApprovalRequirement;
let mut session = Session::new("user-2");
let tool_name = "http";
// Scenario 1: Tool is auto-approved
session.auto_approve_tool(tool_name);
// UnlessAutoApproved → doesn't require approval if auto-approved
let unless_req = ApprovalRequirement::UnlessAutoApproved;
let unless_needs = match unless_req {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
ApprovalRequirement::Always => true,
};
assert!(
!unless_needs,
"UnlessAutoApproved should not need approval when auto-approved"
);
// Always → always requires approval
let always_req = ApprovalRequirement::Always;
let always_needs = match always_req {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
ApprovalRequirement::Always => true,
};
assert!(
always_needs,
"Always must always require approval, even when auto-approved"
);
// Scenario 2: Tool is NOT auto-approved
let new_tool = "new_tool";
assert!(!session.is_tool_auto_approved(new_tool));
// UnlessAutoApproved → requires approval
let unless_needs = match unless_req {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(new_tool),
ApprovalRequirement::Always => true,
};
assert!(
unless_needs,
"UnlessAutoApproved should need approval when not auto-approved"
);
// Always → always requires approval
let always_needs = match always_req {
ApprovalRequirement::Never => false,
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(new_tool),
ApprovalRequirement::Always => true,
};
assert!(always_needs, "Always must always require approval");
}
#[test]
fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
// PendingApproval from before the deferred_tool_calls field was added
@@ -2044,7 +1953,6 @@ mod tests {
max_tool_iterations,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
},
deps,
Arc::new(ChannelManager::new()),
@@ -2161,7 +2069,6 @@ mod tests {
max_tool_iterations: max_iter,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
},
deps,
Arc::new(ChannelManager::new()),
-16
View File
@@ -160,13 +160,6 @@ impl Scheduler {
.create_job_for_user(user_id, title, description)
.await?;
// Apply token budget from config, allowing per-job metadata override.
let max_tokens = metadata
.as_ref()
.and_then(|m| m.get("max_tokens"))
.and_then(|v| v.as_u64())
.unwrap_or(self.config.max_tokens_per_job);
// Apply metadata if provided
if let Some(meta) = metadata {
self.context_manager
@@ -176,15 +169,6 @@ impl Scheduler {
.await?;
}
// Set token budget (separate update to avoid overwriting metadata)
if max_tokens > 0 {
self.context_manager
.update_context(job_id, |ctx| {
ctx.max_tokens = max_tokens;
})
.await?;
}
// Persist to DB before scheduling so the worker's FK references are valid
if let Some(ref store) = self.store {
let ctx = self.context_manager.get_context(job_id).await?;
+3 -100
View File
@@ -417,8 +417,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
iteration += 1;
if iteration > max_iterations {
self.mark_failed("Maximum iterations exceeded: job hit the iteration cap")
.await?;
self.mark_stuck("Maximum iterations exceeded").await?;
return Ok(());
}
@@ -438,8 +437,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
"LLM rate limited during tool selection, backing off"
);
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
self.mark_failed("Persistent rate limiting: exceeded retry limit")
.await?;
self.mark_stuck("Persistent rate limiting").await?;
return Ok(());
}
self.log_event(
@@ -469,8 +467,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
"LLM rate limited during respond_with_tools, backing off"
);
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
self.mark_failed("Persistent rate limiting: exceeded retry limit")
.await?;
self.mark_stuck("Persistent rate limiting").await?;
return Ok(());
}
self.log_event(
@@ -486,20 +483,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
Err(e) => return Err(e.into()),
};
// Track token usage from LLM call against the job budget.
// NOTE: select_tools() also makes LLM calls but doesn't expose
// TokenUsage; only respond_with_tools() usage is tracked here.
let total_tokens = respond_output.usage.total() as u64;
if total_tokens > 0
&& let Err(msg) = self
.context_manager()
.update_context(self.job_id, |ctx| ctx.add_tokens(total_tokens))
.await?
{
self.mark_failed(&msg).await?;
return Ok(());
}
match respond_output.result {
RespondResult::Text(response) => {
// Check for explicit completion phrases. Use word-boundary
@@ -1779,84 +1762,4 @@ mod tests {
"Always tool should be allowed with permission"
);
}
#[tokio::test]
async fn test_token_budget_exceeded_fails_job() {
let worker = make_worker(vec![]).await;
// Transition to InProgress (required for mark_failed)
worker
.context_manager()
.update_context(worker.job_id, |ctx| {
ctx.transition_to(JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
// Set a token budget
worker
.context_manager()
.update_context(worker.job_id, |ctx| {
ctx.max_tokens = 100;
})
.await
.unwrap();
// Simulate adding tokens that exceed the budget
let budget_result = worker
.context_manager()
.update_context(worker.job_id, |ctx| ctx.add_tokens(200))
.await
.unwrap();
assert!(
budget_result.is_err(),
"Should return error when token budget exceeded"
);
// Verify that mark_failed transitions job to Failed
worker
.mark_failed(&budget_result.unwrap_err())
.await
.unwrap();
let ctx = worker
.context_manager()
.get_context(worker.job_id)
.await
.unwrap();
assert_eq!(ctx.state, JobState::Failed);
}
#[tokio::test]
async fn test_iteration_cap_marks_failed_not_stuck() {
let worker = make_worker(vec![]).await;
// Transition to InProgress (required for mark_failed)
worker
.context_manager()
.update_context(worker.job_id, |ctx| {
ctx.transition_to(JobState::InProgress, None)
})
.await
.unwrap()
.unwrap();
// Simulate what the execution loop does when max_iterations is exceeded
worker
.mark_failed("Maximum iterations exceeded: job hit the iteration cap")
.await
.unwrap();
let ctx = worker
.context_manager()
.get_context(worker.job_id)
.await
.unwrap();
assert_eq!(
ctx.state,
JobState::Failed,
"Iteration cap should transition to Failed, not Stuck"
);
}
}
+1 -15
View File
@@ -276,25 +276,11 @@ pub async fn jobs_cancel_handler(
})));
}
// Fall back to agent job cancellation: stop the worker via the scheduler
// (which updates the in-memory ContextManager AND aborts the task handle),
// then persist the status to the DB as a fallback.
// Fall back to agent job cancellation via DB status update.
if let Some(ref store) = state.store
&& let Ok(Some(job)) = store.get_job(job_id).await
{
if job.state.is_active() {
// Try to stop via scheduler (aborts the worker task + updates
// in-memory ContextManager). This is best-effort — the job may
// not be in the scheduler map if it already finished.
if let Some(ref slot) = state.scheduler
&& let Some(ref scheduler) = *slot.read().await
{
let _ = scheduler.stop(job_id).await;
}
// Always persist cancellation to the DB so the state is
// consistent even if the scheduler wasn't available or the
// job wasn't in its in-memory map.
store
.update_job_status(
job_id,
-2
View File
@@ -108,7 +108,6 @@ pub async fn routines_detail_handler(
status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
})
.collect();
@@ -253,7 +252,6 @@ pub async fn routines_runs_handler(
status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
})
.collect();
-2
View File
@@ -2017,7 +2017,6 @@ async fn routines_detail_handler(
status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
})
.collect();
@@ -2170,7 +2169,6 @@ async fn routines_runs_handler(
status: format!("{:?}", run.status),
result_summary: run.result_summary.clone(),
tokens_used: run.tokens_used,
job_id: run.job_id,
})
.collect();
-1
View File
@@ -776,7 +776,6 @@ pub struct RoutineRunInfo {
pub status: String,
pub result_summary: Option<String>,
pub tokens_used: Option<i32>,
pub job_id: Option<Uuid>,
}
// --- Settings ---
+1 -1
View File
@@ -626,7 +626,7 @@ async fn save_servers(
}
}
/// Initialize and return the secrets store.
/// Get the secrets store for MCP authentication operations.
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
let config = Config::from_env().await?;
-7
View File
@@ -29,8 +29,6 @@ pub struct AgentConfig {
pub auto_approve_tools: bool,
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
pub default_timezone: String,
/// Maximum tokens per job (0 = unlimited).
pub max_tokens_per_job: u64,
}
impl AgentConfig {
@@ -52,7 +50,6 @@ impl AgentConfig {
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
}
}
@@ -108,10 +105,6 @@ impl AgentConfig {
}
tz
},
max_tokens_per_job: parse_optional_env(
"AGENT_MAX_TOKENS_PER_JOB",
settings.agent.max_tokens_per_job,
)?,
})
}
}
+1 -4
View File
@@ -28,16 +28,14 @@ impl JobStore for LibSqlBackend {
r#"
INSERT INTO agent_jobs (
id, conversation_id, title, description, category, status, source,
user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, created_at, started_at, completed_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
ON CONFLICT (id) DO UPDATE SET
title = excluded.title,
description = excluded.description,
category = excluded.category,
status = excluded.status,
user_id = excluded.user_id,
estimated_cost = excluded.estimated_cost,
estimated_time_secs = excluded.estimated_time_secs,
actual_cost = excluded.actual_cost,
@@ -53,7 +51,6 @@ impl JobStore for LibSqlBackend {
opt_text(ctx.category.as_deref()),
status,
"direct",
ctx.user_id.as_str(),
opt_text_owned(ctx.budget.map(|d| d.to_string())),
opt_text(ctx.budget_token.as_deref()),
opt_text_owned(ctx.bid_amount.map(|d| d.to_string())),
-18
View File
@@ -482,24 +482,6 @@ mod tests {
assert_eq!(timeout, 5000);
}
/// Regression test: save_job must persist user_id and get_job must return it.
#[tokio::test]
async fn test_save_job_persists_user_id() {
use crate::context::JobContext;
use crate::db::JobStore;
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test_user_id.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();
let ctx = JobContext::with_user("test-user-42", "Test Job", "A test job");
backend.save_job(&ctx).await.unwrap();
let loaded = backend.get_job(ctx.job_id).await.unwrap().unwrap();
assert_eq!(loaded.user_id, "test-user-42");
}
#[tokio::test]
async fn test_concurrent_writes_succeed() {
// Use a temp file so connections share state (in-memory DBs are connection-local)
+20 -7
View File
@@ -77,7 +77,7 @@ pub async fn connect_from_config(
Ok(Arc::new(backend))
}
#[cfg(feature = "postgres")]
_ => {
crate::config::DatabaseBackend::Postgres => {
let pg = postgres::PgBackend::new(config)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
@@ -85,9 +85,16 @@ pub async fn connect_from_config(
Ok(Arc::new(pg))
}
#[cfg(not(feature = "postgres"))]
_ => Err(DatabaseError::Pool(
"No database backend available. Enable 'postgres' or 'libsql' feature.".to_string(),
crate::config::DatabaseBackend::Postgres => Err(DatabaseError::Pool(
"No postgres backend available. Rebuild with --features postgres.".to_string(),
)),
// Catches LibSql in postgres-only builds (libsql arm compiled out)
#[allow(unreachable_patterns)]
_ => Err(DatabaseError::Pool(format!(
"Database backend {:?} not available in this build. \
Set DATABASE_BACKEND to a compiled-in backend, or rebuild with the matching feature flag.",
config.backend
))),
}
}
@@ -130,7 +137,7 @@ pub async fn create_secrets_store(
)))
}
#[cfg(feature = "postgres")]
_ => {
crate::config::DatabaseBackend::Postgres => {
let pg = postgres::PgBackend::new(config)
.await
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
@@ -142,10 +149,16 @@ pub async fn create_secrets_store(
)))
}
#[cfg(not(feature = "postgres"))]
_ => Err(DatabaseError::Pool(
"No database backend available for secrets. Enable 'postgres' or 'libsql' feature."
.to_string(),
crate::config::DatabaseBackend::Postgres => Err(DatabaseError::Pool(
"No postgres backend available. Rebuild with --features postgres.".to_string(),
)),
// Catches LibSql in postgres-only builds (libsql arm compiled out)
#[allow(unreachable_patterns)]
_ => Err(DatabaseError::Pool(format!(
"Database backend {:?} not available in this build. \
Set DATABASE_BACKEND to a compiled-in backend, or rebuild with the matching feature flag.",
config.backend
))),
}
}
+1 -36
View File
@@ -149,16 +149,14 @@ impl Store {
r#"
INSERT INTO agent_jobs (
id, conversation_id, title, description, category, status, source,
user_id,
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
actual_cost, repair_attempts, created_at, started_at, completed_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
category = EXCLUDED.category,
status = EXCLUDED.status,
user_id = EXCLUDED.user_id,
estimated_cost = EXCLUDED.estimated_cost,
estimated_time_secs = EXCLUDED.estimated_time_secs,
actual_cost = EXCLUDED.actual_cost,
@@ -174,7 +172,6 @@ impl Store {
&ctx.category,
&status,
&"direct", // source
&ctx.user_id,
&ctx.budget,
&ctx.budget_token,
&ctx.bid_amount,
@@ -2136,36 +2133,4 @@ mod tests {
assert_eq!(summary.channel, ch);
}
}
/// Regression test: save_job must persist user_id and get_job must return it.
/// Requires a running PostgreSQL instance (integration tier).
#[cfg(feature = "postgres")]
#[tokio::test]
#[ignore]
async fn test_save_job_persists_user_id() {
use crate::config::Config;
use crate::context::JobContext;
let _ = dotenvy::dotenv();
let config = Config::from_env().await.expect("Failed to load config");
let store = Store::new(&config.database)
.await
.expect("Failed to connect to database");
store
.run_migrations()
.await
.expect("Failed to run migrations");
let ctx = JobContext::with_user("test-user-42", "PG user_id test", "regression test");
store.save_job(&ctx).await.unwrap();
let loaded = store.get_job(ctx.job_id).await.unwrap().unwrap();
assert_eq!(loaded.user_id, "test-user-42");
// Clean up
let conn = store.conn().await.unwrap();
conn.execute("DELETE FROM agent_jobs WHERE id = $1", &[&ctx.job_id])
.await
.unwrap();
}
}
-5
View File
@@ -386,10 +386,6 @@ pub struct AgentSettings {
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
#[serde(default = "default_timezone")]
pub default_timezone: String,
/// Maximum tokens per job (0 = unlimited).
#[serde(default)]
pub max_tokens_per_job: u64,
}
fn default_agent_name() -> String {
@@ -446,7 +442,6 @@ impl Default for AgentSettings {
max_tool_iterations: default_max_tool_iterations(),
auto_approve_tools: false,
default_timezone: default_timezone(),
max_tokens_per_job: 0,
}
}
}
+32 -103
View File
@@ -1573,18 +1573,46 @@ impl SetupWizard {
}
/// Fetch available models from the NEAR AI API.
///
/// Uses [`build_nearai_model_fetch_config`] to construct the provider config,
/// which reads `NEARAI_API_KEY` from the environment when present.
async fn fetch_nearai_models(&self) -> Vec<String> {
let session = match self.session_manager {
Some(ref s) => Arc::clone(s),
None => return vec![],
};
use crate::config::LlmConfig;
use crate::llm::create_llm_provider;
let config = build_nearai_model_fetch_config();
let base_url = std::env::var("NEARAI_BASE_URL")
.unwrap_or_else(|_| "https://private.near.ai".to_string());
let auth_base_url = std::env::var("NEARAI_AUTH_URL")
.unwrap_or_else(|_| "https://private.near.ai".to_string());
let config = LlmConfig {
backend: "nearai".to_string(),
session: crate::llm::session::SessionConfig {
auth_base_url,
session_path: crate::config::llm::default_session_path(),
},
nearai: crate::config::NearAiConfig {
model: "dummy".to_string(),
cheap_model: None,
base_url,
api_key: None,
fallback_model: None,
max_retries: 3,
circuit_breaker_threshold: None,
circuit_breaker_recovery_secs: 30,
response_cache_enabled: false,
response_cache_ttl_secs: 3600,
response_cache_max_entries: 1000,
failover_cooldown_secs: 300,
failover_cooldown_threshold: 3,
smart_routing_cascade: true,
},
provider: None,
bedrock: None,
request_timeout_secs: 120,
};
match create_llm_provider(&config, session).await {
Ok(provider) => match provider.list_models().await {
@@ -3212,52 +3240,6 @@ async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCa
/// Mask an API key for display: show first 6 + last 4 chars.
///
/// Uses char-based indexing to avoid panicking on multi-byte UTF-8.
/// Build the `LlmConfig` used by `fetch_nearai_models` to list available models.
///
/// Reads `NEARAI_API_KEY` from the environment so that users who authenticated
/// via Cloud API key (option 4) don't get re-prompted during model selection.
fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
let base_url =
std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
let auth_base_url =
std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
// If the user authenticated via API key (option 4), the key is stored
// as an env var. Pass it through so `resolve_bearer_token()` doesn't
// re-trigger the interactive auth prompt.
let api_key = std::env::var("NEARAI_API_KEY")
.ok()
.filter(|k| !k.is_empty())
.map(secrecy::SecretString::from);
crate::config::LlmConfig {
backend: "nearai".to_string(),
session: crate::llm::session::SessionConfig {
auth_base_url,
session_path: crate::config::llm::default_session_path(),
},
nearai: crate::config::NearAiConfig {
model: "dummy".to_string(),
cheap_model: None,
base_url,
api_key,
fallback_model: None,
max_retries: 3,
circuit_breaker_threshold: None,
circuit_breaker_recovery_secs: 30,
response_cache_enabled: false,
response_cache_ttl_secs: 3600,
response_cache_max_entries: 1000,
failover_cooldown_secs: 300,
failover_cooldown_threshold: 3,
smart_routing_cascade: true,
},
provider: None,
bedrock: None,
request_timeout_secs: 120,
}
}
fn mask_api_key(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
if chars.len() < 12 {
@@ -3658,14 +3640,6 @@ mod tests {
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let original = std::env::var(key).ok();
unsafe {
std::env::set_var(key, value);
}
Self { key, original }
}
fn clear(key: &'static str) -> Self {
let original = std::env::var(key).ok();
unsafe {
@@ -3852,49 +3826,4 @@ mod tests {
};
assert!(settings.secrets_master_key_hex.is_some());
}
/// Regression test for #799: `fetch_nearai_models` hardcoded `api_key: None`,
/// causing the auth prompt to re-appear during model selection when the user
/// had authenticated via NEAR AI Cloud API key (option 4).
#[test]
fn test_build_nearai_model_fetch_config_picks_up_api_key_env() {
use secrecy::ExposeSecret;
let _guard = EnvGuard::set("NEARAI_API_KEY", "test-cloud-api-key-12345");
let config = build_nearai_model_fetch_config();
assert!(
config.nearai.api_key.is_some(),
"config should include NEARAI_API_KEY from env"
);
assert_eq!(
config.nearai.api_key.as_ref().unwrap().expose_secret(),
"test-cloud-api-key-12345"
);
}
/// Regression test for #799: when NEARAI_API_KEY is absent or empty,
/// the config should have `api_key: None` (session token path).
#[test]
fn test_build_nearai_model_fetch_config_none_when_no_api_key() {
let _guard = EnvGuard::clear("NEARAI_API_KEY");
let config = build_nearai_model_fetch_config();
assert!(
config.nearai.api_key.is_none(),
"config should have no api_key when env var is absent"
);
}
/// Regression test for #799: empty NEARAI_API_KEY should be treated as absent.
#[test]
fn test_build_nearai_model_fetch_config_none_when_empty_api_key() {
let _guard = EnvGuard::set("NEARAI_API_KEY", "");
let config = build_nearai_model_fetch_config();
assert!(
config.nearai.api_key.is_none(),
"config should have no api_key when env var is empty"
);
}
}
+4 -32
View File
@@ -451,8 +451,8 @@ impl Tool for ToolRemoveTool {
}
fn description(&self) -> &str {
"Permanently remove an installed extension (channel, tool, or MCP server) from disk. \
This action cannot be undone the WASM binary and configuration files will be deleted."
"Remove an installed extension (channel, tool, or MCP server). \
Unregisters tools and deletes configuration."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -492,7 +492,7 @@ impl Tool for ToolRemoveTool {
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::Always
ApprovalRequirement::UnlessAutoApproved
}
}
@@ -701,38 +701,10 @@ mod tests {
assert_eq!(tool.name(), "tool_remove");
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::Always
ApprovalRequirement::UnlessAutoApproved
);
}
#[test]
fn tool_remove_always_requires_approval_regardless_of_params() {
use crate::tools::tool::ApprovalRequirement;
let tool = ToolRemoveTool {
manager: test_manager_stub(),
};
let test_cases = vec![
("no params", serde_json::json!({})),
("empty name", serde_json::json!({"name": ""})),
("slack", serde_json::json!({"name": "slack"})),
("github-cli", serde_json::json!({"name": "github-cli"})),
(
"with extra fields",
serde_json::json!({"name": "tool", "extra": "field"}),
),
];
for (case_name, params) in test_cases {
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::Always,
"tool_remove must always require approval for case: {}",
case_name
);
}
}
#[test]
fn test_tool_upgrade_schema() {
use crate::tools::tool::ApprovalRequirement;
+3 -33
View File
@@ -709,8 +709,7 @@ impl Tool for SkillRemoveTool {
}
fn description(&self) -> &str {
"Permanently remove an installed skill from disk. This action cannot be undone — \
the skill files will be deleted."
"Remove an installed skill by name. Only user-installed skills can be removed."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -771,7 +770,7 @@ impl Tool for SkillRemoveTool {
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::Always
ApprovalRequirement::UnlessAutoApproved
}
}
@@ -838,41 +837,12 @@ mod tests {
assert_eq!(tool.name(), "skill_remove");
assert_eq!(
tool.requires_approval(&serde_json::json!({})),
ApprovalRequirement::Always
ApprovalRequirement::UnlessAutoApproved
);
let schema = tool.parameters_schema();
assert!(schema["properties"].get("name").is_some());
}
#[test]
fn skill_remove_always_requires_approval_regardless_of_params() {
use crate::tools::tool::ApprovalRequirement;
let tool = SkillRemoveTool::new(test_registry());
let test_cases = vec![
("no params", serde_json::json!({})),
("empty name", serde_json::json!({"name": ""})),
(
"deployment skill",
serde_json::json!({"name": "deployment"}),
),
("custom skill", serde_json::json!({"name": "custom-skill"})),
(
"with extra fields",
serde_json::json!({"name": "skill", "extra": "field"}),
),
];
for (case_name, params) in test_cases {
assert_eq!(
tool.requires_approval(&params),
ApprovalRequirement::Always,
"skill_remove must always require approval for case: {}",
case_name
);
}
}
#[test]
fn test_validate_fetch_url_allows_https() {
assert!(super::validate_fetch_url("https://clawhub.ai/api/v1/download?slug=foo").is_ok());