Bump MSRV to 1.92, add GCP deployment files (#40)

* Bump MSRV to 1.92 and add GCP deployment files

rig-core 0.30 uses let_chains (stabilized post-1.87), which breaks
builds on Rust 1.85. Bump rust-version in Cargo.toml and both
Dockerfiles to 1.92 (verified working).

Add cloud deployment scaffolding:
- Dockerfile: multi-stage build for the main agent container
- deploy/cloud-sql-proxy.service: systemd unit for Cloud SQL Auth Proxy
- deploy/ironclaw.service: systemd unit for the IronClaw container
- deploy/setup.sh: VM bootstrap script (Docker, proxy, services)
- deploy/env.example: reference environment configuration

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Address review feedback: harden deploy scaffolding

- Add comment explaining GATEWAY_HOST=0.0.0.0 and when to use 127.0.0.1
- Document /opt/ironclaw ownership model (root-owned, Docker reads as root)
- Switch cloud-sql-proxy service from User=root to DynamicUser=yes

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Resolve clippy lints (Rust 1.93) and fix CI test workflow

- Fix 97 collapsible_if warnings using let-chains syntax (auto-fixed)
- Fix ptr_arg: change &PathBuf to &Path in pairing store functions
- Fix suspicious_open_options: add .truncate(false) to OpenOptions
- Fix too_many_arguments: add clippy allow on execute_status
- Fix unnecessary_unwrap: use if-let in repository.rs hybrid_search
- Gate unused EchoTool with #[cfg(test)]
- Add PairingStore argument to ChannelStoreData::new() test call sites
- Add skip guard for bundled channel test when WASM artifacts unavailable
- Split CI test workflow to exclude PostgreSQL-dependent integration tests

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Address review feedback from ilblackdragon

- Add root check to setup.sh (exits with error if not root)
- Add warning comment to env.example about placeholder passwords
- Dockerfile.worker already uses rust:1.92 (no change needed)
- PR #41 overlap noted; will rebase after #41 merges

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: resolve 47 collapsible_if clippy warnings

Collapse nested if statements across the codebase to satisfy
clippy::collapsible_if on Rust 1.93.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Zaki Manian
2026-02-13 22:21:50 +04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent bbb68f7490
commit 5df0d13b59
53 changed files with 1055 additions and 909 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
name = "ironclaw" name = "ironclaw"
version = "0.1.3" version = "0.1.3"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
authors = ["NEAR AI <[email protected]>"] authors = ["NEAR AI <[email protected]>"]
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+46
View File
@@ -0,0 +1,46 @@
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
#
# Build:
# docker build --platform linux/amd64 -t ironclaw:latest .
#
# Run:
# docker run --env-file .env -p 3000:3000 ironclaw:latest
# Stage 1: Build
FROM rust:1.92-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev cmake gcc g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy manifests first for layer caching
COPY Cargo.toml Cargo.lock ./
# Copy source and build artifacts
COPY src/ src/
COPY migrations/ migrations/
COPY wit/ wit/
RUN cargo build --release --bin ironclaw
# Stage 2: Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
COPY --from=builder /app/migrations /app/migrations
# Non-root user
RUN useradd -m -u 1000 -s /bin/bash ironclaw
USER ironclaw
EXPOSE 3000
ENV RUST_LOG=ironclaw=info
ENTRYPOINT ["ironclaw"]
+2 -2
View File
@@ -9,7 +9,7 @@
# The image includes common development tools so workers can build software, # The image includes common development tools so workers can build software,
# run tests, and execute shell commands. # run tests, and execute shell commands.
FROM rust:1.85-bookworm AS builder FROM rust:1.92-bookworm AS builder
WORKDIR /build WORKDIR /build
COPY . . COPY . .
@@ -40,7 +40,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ENV RUSTUP_HOME=/usr/local/rustup \ ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \ CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH PATH=/usr/local/cargo/bin:$PATH
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.85.0 \ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.92.0 \
&& chmod -R a+r /usr/local/rustup /usr/local/cargo && chmod -R a+r /usr/local/rustup /usr/local/cargo
# Install Claude Code CLI (for claude-bridge mode) # Install Claude Code CLI (for claude-bridge mode)
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Cloud SQL Auth Proxy
After=network.target
[Service]
Type=simple
DynamicUser=yes
ExecStart=/usr/local/bin/cloud-sql-proxy ironclaw-prod:us-central1:ironclaw-db --port=5432
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+27
View File
@@ -0,0 +1,27 @@
# WARNING: Replace all CHANGE_ME values before deploying.
# Do not use placeholder passwords in production.
DATABASE_URL=postgres://ironclaw:CHANGE_ME@localhost:5432/ironclaw
# NEAR AI
NEARAI_SESSION_TOKEN=CHANGE_ME
NEARAI_MODEL=claude-3-5-sonnet-20241022
NEARAI_BASE_URL=https://cloud-api.near.ai
NEARAI_AUTH_URL=https://private.near.ai
NEARAI_API_MODE=chat_completions
# Agent
AGENT_NAME=ironclaw
CLI_ENABLED=false
# Web Gateway
GATEWAY_ENABLED=true
# 0.0.0.0 binds to all interfaces (required for Docker --network=host).
# Use 127.0.0.1 if running outside Docker or for local-only access.
GATEWAY_HOST=0.0.0.0
GATEWAY_PORT=3000
GATEWAY_AUTH_TOKEN=CHANGE_ME
# Disabled for initial deploy
SANDBOX_ENABLED=false
HEARTBEAT_ENABLED=false
EMBEDDING_ENABLED=false
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=IronClaw AI Assistant
After=cloud-sql-proxy.service docker.service
Requires=cloud-sql-proxy.service
[Service]
Type=simple
ExecStartPre=/usr/bin/docker pull us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest
ExecStart=/usr/bin/docker run --rm \
--name ironclaw \
--env-file /opt/ironclaw/.env \
--network=host \
us-central1-docker.pkg.dev/ironclaw-prod/ironclaw/agent:latest \
--no-onboard
ExecStop=/usr/bin/docker stop ironclaw
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# VM bootstrap script for IronClaw on GCP Compute Engine.
#
# Run on a fresh Debian 12 VM after SSH:
# sudo bash setup.sh
#
# Prerequisites:
# - VM has the ironclaw-vm service account attached
# - Cloud SQL Auth Proxy accessible via IAM
# - Artifact Registry image pushed
set -euo pipefail
# Must run as root
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: This script must be run as root (sudo bash setup.sh)"
exit 1
fi
echo "==> Installing Docker"
apt-get update
apt-get install -y docker.io
systemctl enable docker
systemctl start docker
echo "==> Installing Cloud SQL Auth Proxy"
curl -fsSL -o /usr/local/bin/cloud-sql-proxy \
https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.14.3/cloud-sql-proxy.linux.amd64
chmod +x /usr/local/bin/cloud-sql-proxy
echo "==> Installing systemd services"
cp /tmp/deploy/cloud-sql-proxy.service /etc/systemd/system/
cp /tmp/deploy/ironclaw.service /etc/systemd/system/
systemctl daemon-reload
echo "==> Starting Cloud SQL Auth Proxy"
systemctl enable cloud-sql-proxy
systemctl start cloud-sql-proxy
echo "==> Configuring Docker registry auth"
# The VM service account provides Artifact Registry access
gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
echo "==> Creating config directory"
# Owned by root, readable only by root. Docker reads --env-file as root
# before dropping to uid 1000 (ironclaw) inside the container.
mkdir -p /opt/ironclaw
chmod 700 /opt/ironclaw
if [ ! -f /opt/ironclaw/.env ]; then
echo "WARNING: /opt/ironclaw/.env does not exist."
echo "Create it with your configuration before starting IronClaw."
echo "See deploy/env.example for the required variables."
echo ""
echo "Then run: systemctl enable ironclaw && systemctl start ironclaw"
else
chmod 600 /opt/ironclaw/.env
echo "==> Starting IronClaw"
systemctl enable ironclaw
systemctl start ironclaw
fi
echo "==> Setup complete"
echo ""
echo "Verify with:"
echo " systemctl status cloud-sql-proxy"
echo " systemctl status ironclaw"
echo " docker logs ironclaw"
+136 -145
View File
@@ -654,19 +654,17 @@ impl Agent {
} }
// Restore response chain from conversation metadata // Restore response chain from conversation metadata
if let Some(store) = self.store() { if let Some(store) = self.store()
if let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await { && let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await
if let Some(rid) = metadata && let Some(rid) = metadata
.get("last_response_id") .get("last_response_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from) .map(String::from)
{ {
thread.last_response_id = Some(rid.clone()); thread.last_response_id = Some(rid.clone());
self.llm() self.llm()
.seed_response_chain(&thread_uuid.to_string(), rid); .seed_response_chain(&thread_uuid.to_string(), rid);
tracing::debug!("Restored response chain for thread {}", thread_uuid); tracing::debug!("Restored response chain for thread {}", thread_uuid);
}
}
} }
// Insert into session and register with session manager // Insert into session and register with session manager
@@ -954,13 +952,12 @@ impl Agent {
return; return;
} }
if let Some(ref resp) = response { if let Some(ref resp) = response
if let Err(e) = store && let Err(e) = store
.add_conversation_message(thread_id, "assistant", resp) .add_conversation_message(thread_id, "assistant", resp)
.await .await
{ {
tracing::warn!("Failed to persist assistant message: {}", e); tracing::warn!("Failed to persist assistant message: {}", e);
}
} }
}); });
} }
@@ -1058,14 +1055,14 @@ impl Agent {
// Check if interrupted // Check if interrupted
{ {
let sess = session.lock().await; let sess = session.lock().await;
if let Some(thread) = sess.threads.get(&thread_id) { if let Some(thread) = sess.threads.get(&thread_id)
if thread.state == ThreadState::Interrupted { && thread.state == ThreadState::Interrupted
return Err(crate::error::JobError::ContextError { {
id: thread_id, return Err(crate::error::JobError::ContextError {
reason: "Interrupted".to_string(), id: thread_id,
} reason: "Interrupted".to_string(),
.into());
} }
.into());
} }
} }
@@ -1140,11 +1137,11 @@ impl Agent {
// Record tool calls in the thread // Record tool calls in the thread
{ {
let mut sess = session.lock().await; let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) { if let Some(thread) = sess.threads.get_mut(&thread_id)
if let Some(turn) = thread.last_turn_mut() { && let Some(turn) = thread.last_turn_mut()
for tc in &tool_calls { {
turn.record_tool_call(&tc.name, tc.arguments.clone()); for tc in &tool_calls {
} turn.record_tool_call(&tc.name, tc.arguments.clone());
} }
} }
} }
@@ -1152,54 +1149,48 @@ impl Agent {
// Execute each tool (with approval checking) // Execute each tool (with approval checking)
for tc in tool_calls { for tc in tool_calls {
// Check if tool requires approval // Check if tool requires approval
if let Some(tool) = self.tools().get(&tc.name).await { if let Some(tool) = self.tools().get(&tc.name).await
if tool.requires_approval() { && tool.requires_approval()
// Check if auto-approved for this session {
let mut is_auto_approved = { // Check if auto-approved for this session
let sess = session.lock().await; let mut is_auto_approved = {
sess.is_tool_auto_approved(&tc.name) let sess = session.lock().await;
sess.is_tool_auto_approved(&tc.name)
};
// For shell commands, override auto-approval for
// destructive patterns that should always require
// explicit per-invocation approval.
if is_auto_approved
&& tc.name == "shell"
&& let Some(cmd) = tc
.arguments
.as_str()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| {
v.get("command").and_then(|c| c.as_str().map(String::from))
})
&& crate::tools::builtin::shell::requires_explicit_approval(&cmd)
{
tracing::info!(
"Shell command '{}' requires explicit approval despite auto-approve",
cmd.chars().take(80).collect::<String>()
);
is_auto_approved = false;
}
if !is_auto_approved {
// Need approval - store pending request and return
let pending = PendingApproval {
request_id: Uuid::new_v4(),
tool_name: tc.name.clone(),
parameters: tc.arguments.clone(),
description: tool.description().to_string(),
tool_call_id: tc.id.clone(),
context_messages: context_messages.clone(),
}; };
// For shell commands, override auto-approval for return Ok(AgenticLoopResult::NeedApproval { pending });
// destructive patterns that should always require
// explicit per-invocation approval.
if is_auto_approved && tc.name == "shell" {
if let Some(cmd) = tc
.arguments
.as_str()
.and_then(|s| {
serde_json::from_str::<serde_json::Value>(s).ok()
})
.and_then(|v| {
v.get("command")
.and_then(|c| c.as_str().map(String::from))
})
{
if crate::tools::builtin::shell::requires_explicit_approval(
&cmd,
) {
tracing::info!(
"Shell command '{}' requires explicit approval despite auto-approve",
cmd.chars().take(80).collect::<String>()
);
is_auto_approved = false;
}
}
}
if !is_auto_approved {
// Need approval - store pending request and return
let pending = PendingApproval {
request_id: Uuid::new_v4(),
tool_name: tc.name.clone(),
parameters: tc.arguments.clone(),
description: tool.description().to_string(),
tool_call_id: tc.id.clone(),
context_messages: context_messages.clone(),
};
return Ok(AgenticLoopResult::NeedApproval { pending });
}
} }
} }
@@ -1230,34 +1221,34 @@ impl Agent {
) )
.await; .await;
if let Ok(ref output) = tool_result { if let Ok(ref output) = tool_result
if !output.is_empty() { && !output.is_empty()
let _ = self {
.channels let _ = self
.send_status( .channels
&message.channel, .send_status(
StatusUpdate::ToolResult { &message.channel,
name: tc.name.clone(), StatusUpdate::ToolResult {
preview: truncate_for_preview(output, 200), name: tc.name.clone(),
}, preview: truncate_for_preview(output, 200),
&message.metadata, },
) &message.metadata,
.await; )
} .await;
} }
// Record result in thread // Record result in thread
{ {
let mut sess = session.lock().await; let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) { if let Some(thread) = sess.threads.get_mut(&thread_id)
if let Some(turn) = thread.last_turn_mut() { && let Some(turn) = thread.last_turn_mut()
match &tool_result { {
Ok(output) => { match &tool_result {
turn.record_tool_result(serde_json::json!(output)); Ok(output) => {
} turn.record_tool_result(serde_json::json!(output));
Err(e) => { }
turn.record_tool_error(e.to_string()); Err(e) => {
} turn.record_tool_error(e.to_string());
} }
} }
} }
@@ -1640,17 +1631,17 @@ impl Agent {
}; };
// Verify request ID if provided // Verify request ID if provided
if let Some(req_id) = request_id { if let Some(req_id) = request_id
if req_id != pending.request_id { && req_id != pending.request_id
// Put it back and return error {
let mut sess = session.lock().await; // Put it back and return error
if let Some(thread) = sess.threads.get_mut(&thread_id) { let mut sess = session.lock().await;
thread.await_approval(pending); if let Some(thread) = sess.threads.get_mut(&thread_id) {
} thread.await_approval(pending);
return Ok(SubmissionResult::error(
"Request ID mismatch. Use the correct request ID.",
));
} }
return Ok(SubmissionResult::error(
"Request ID mismatch. Use the correct request ID.",
));
} }
if approved { if approved {
@@ -1704,20 +1695,20 @@ impl Agent {
) )
.await; .await;
if let Ok(ref output) = tool_result { if let Ok(ref output) = tool_result
if !output.is_empty() { && !output.is_empty()
let _ = self {
.channels let _ = self
.send_status( .channels
&message.channel, .send_status(
StatusUpdate::ToolResult { &message.channel,
name: pending.tool_name.clone(), StatusUpdate::ToolResult {
preview: truncate_for_preview(output, 200), name: pending.tool_name.clone(),
}, preview: truncate_for_preview(output, 200),
&message.metadata, },
) &message.metadata,
.await; )
} .await;
} }
// Build context including the tool result // Build context including the tool result
@@ -1726,15 +1717,15 @@ impl Agent {
// Record result in thread // Record result in thread
{ {
let mut sess = session.lock().await; let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) { if let Some(thread) = sess.threads.get_mut(&thread_id)
if let Some(turn) = thread.last_turn_mut() { && let Some(turn) = thread.last_turn_mut()
match &tool_result { {
Ok(output) => { match &tool_result {
turn.record_tool_result(serde_json::json!(output)); Ok(output) => {
} turn.record_tool_result(serde_json::json!(output));
Err(e) => { }
turn.record_tool_error(e.to_string()); Err(e) => {
} turn.record_tool_error(e.to_string());
} }
} }
} }
@@ -2094,15 +2085,15 @@ impl Agent {
} }
// Persist new job to database (fire-and-forget) // Persist new job to database (fire-and-forget)
if let Some(store) = self.store() { if let Some(store) = self.store()
if let Ok(ctx) = self.context_manager.get_context(job_id).await { && let Ok(ctx) = self.context_manager.get_context(job_id).await
let store = store.clone(); {
tokio::spawn(async move { let store = store.clone();
if let Err(e) = store.save_job(&ctx).await { tokio::spawn(async move {
tracing::warn!("Failed to persist new job {}: {}", job_id, e); if let Err(e) = store.save_job(&ctx).await {
} tracing::warn!("Failed to persist new job {}: {}", job_id, e);
}); }
} });
} }
// Schedule for execution // Schedule for execution
@@ -2182,10 +2173,10 @@ impl Agent {
let mut output = String::from("Jobs:\n"); let mut output = String::from("Jobs:\n");
for job_id in jobs { for job_id in jobs {
if let Ok(ctx) = self.context_manager.get_context(job_id).await { if let Ok(ctx) = self.context_manager.get_context(job_id).await
if ctx.user_id == user_id { && ctx.user_id == user_id
output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state)); {
} output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
} }
} }
+2 -3
View File
@@ -103,10 +103,9 @@ impl RoutineEngine {
if let Trigger::Event { if let Trigger::Event {
channel: Some(ch), .. channel: Some(ch), ..
} = &routine.trigger } = &routine.trigger
&& ch != &message.channel
{ {
if ch != &message.channel { continue;
continue;
}
} }
// Regex match // Regex match
+18 -18
View File
@@ -119,25 +119,25 @@ impl SelfRepair for DefaultSelfRepair {
let mut stuck_jobs = Vec::new(); let mut stuck_jobs = Vec::new();
for job_id in stuck_ids { for job_id in stuck_ids {
if let Ok(ctx) = self.context_manager.get_context(job_id).await { if let Ok(ctx) = self.context_manager.get_context(job_id).await
if ctx.state == JobState::Stuck { && ctx.state == JobState::Stuck
let stuck_duration = ctx {
.started_at let stuck_duration = ctx
.map(|start| { .started_at
let now = Utc::now(); .map(|start| {
let duration = now.signed_duration_since(start); let now = Utc::now();
Duration::from_secs(duration.num_seconds().max(0) as u64) let duration = now.signed_duration_since(start);
}) Duration::from_secs(duration.num_seconds().max(0) as u64)
.unwrap_or_default(); })
.unwrap_or_default();
stuck_jobs.push(StuckJob { stuck_jobs.push(StuckJob {
job_id, job_id,
last_activity: ctx.started_at.unwrap_or(ctx.created_at), last_activity: ctx.started_at.unwrap_or(ctx.created_at),
stuck_duration, stuck_duration,
last_error: None, last_error: None,
repair_attempts: ctx.repair_attempts, repair_attempts: ctx.repair_attempts,
}); });
}
} }
} }
+5 -5
View File
@@ -346,11 +346,11 @@ impl Thread {
let mut turn = Turn::new(turn_number, &msg.content); let mut turn = Turn::new(turn_number, &msg.content);
// Check if next is assistant response // Check if next is assistant response
if let Some(next) = iter.peek() { if let Some(next) = iter.peek()
if next.role == crate::llm::Role::Assistant { && next.role == crate::llm::Role::Assistant
let response = iter.next().expect("peeked"); {
turn.complete(&response.content); let response = iter.next().expect("peeked");
} turn.complete(&response.content);
} }
self.turns.push(turn); self.turns.push(turn);
+4 -4
View File
@@ -199,10 +199,10 @@ impl SessionManager {
{ {
let sessions = self.sessions.read().await; let sessions = self.sessions.read().await;
for user_id in &stale_users { for user_id in &stale_users {
if let Some(session) = sessions.get(user_id) { if let Some(session) = sessions.get(user_id)
if let Ok(sess) = session.try_lock() { && let Ok(sess) = session.try_lock()
stale_thread_ids.extend(sess.threads.keys()); {
} stale_thread_ids.extend(sess.threads.keys());
} }
} }
} }
+13 -14
View File
@@ -93,27 +93,26 @@ impl SubmissionParser {
// /thread <uuid> - switch thread // /thread <uuid> - switch thread
if let Some(rest) = lower.strip_prefix("/thread ") { if let Some(rest) = lower.strip_prefix("/thread ") {
let rest = rest.trim(); let rest = rest.trim();
if rest != "new" { if rest != "new"
if let Ok(id) = Uuid::parse_str(rest) { && let Ok(id) = Uuid::parse_str(rest)
return Submission::SwitchThread { thread_id: id }; {
} return Submission::SwitchThread { thread_id: id };
} }
} }
// /resume <uuid> - resume from checkpoint // /resume <uuid> - resume from checkpoint
if let Some(rest) = lower.strip_prefix("/resume ") { if let Some(rest) = lower.strip_prefix("/resume ")
if let Ok(id) = Uuid::parse_str(rest.trim()) { && let Ok(id) = Uuid::parse_str(rest.trim())
return Submission::Resume { checkpoint_id: id }; {
} return Submission::Resume { checkpoint_id: id };
} }
// Try structured JSON approval (from web gateway's /api/chat/approval endpoint) // Try structured JSON approval (from web gateway's /api/chat/approval endpoint)
if trimmed.starts_with('{') { if trimmed.starts_with('{')
if let Ok(submission) = serde_json::from_str::<Submission>(trimmed) { && let Ok(submission) = serde_json::from_str::<Submission>(trimmed)
if matches!(submission, Submission::ExecApproval { .. }) { && matches!(submission, Submission::ExecApproval { .. })
return submission; {
} return submission;
}
} }
// Approval responses (simple yes/no/always for pending approvals) // Approval responses (simple yes/no/always for pending approvals)
+5 -5
View File
@@ -227,11 +227,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
} }
// Check for cancellation // Check for cancellation
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await { if let Ok(ctx) = self.context_manager().get_context(self.job_id).await
if ctx.state == JobState::Cancelled { && ctx.state == JobState::Cancelled
tracing::info!("Worker for job {} detected cancellation", self.job_id); {
return Ok(()); tracing::info!("Worker for job {} detected cancellation", self.job_id);
} return Ok(());
} }
iteration += 1; iteration += 1;
+30 -31
View File
@@ -134,13 +134,13 @@ impl ChannelStoreData {
if result.contains('{') && result.contains('}') { if result.contains('{') && result.contains('}') {
// Only warn if it looks like an unresolved placeholder (not JSON braces) // Only warn if it looks like an unresolved placeholder (not JSON braces)
let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok(); let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
if let Some(re) = brace_pattern { if let Some(re) = brace_pattern
if re.is_match(&result) { && re.is_match(&result)
tracing::warn!( {
context = %context, tracing::warn!(
"String may contain unresolved credential placeholders" context = %context,
); "String may contain unresolved credential placeholders"
} );
} }
} }
@@ -338,13 +338,13 @@ impl near::agent::channel_host::Host for ChannelStoreData {
// Enforce max response body size to prevent memory exhaustion. // Enforce max response body size to prevent memory exhaustion.
let max_response = max_response_bytes; let max_response = max_response_bytes;
if let Some(cl) = response.content_length() { if let Some(cl) = response.content_length()
if cl as usize > max_response { && cl as usize > max_response
return Err(format!( {
"Response body too large: {} bytes exceeds limit of {} bytes", return Err(format!(
cl, max_response "Response body too large: {} bytes exceeds limit of {} bytes",
)); cl, max_response
} ));
} }
let body = response let body = response
.bytes() .bytes()
@@ -1495,8 +1495,8 @@ impl WasmChannel {
match result { match result {
Ok(emitted_messages) => { Ok(emitted_messages) => {
// Process any emitted messages // Process any emitted messages
if !emitted_messages.is_empty() { if !emitted_messages.is_empty()
if let Err(e) = Self::dispatch_emitted_messages( && let Err(e) = Self::dispatch_emitted_messages(
&channel_name, &channel_name,
emitted_messages, emitted_messages,
&message_tx, &message_tx,
@@ -1508,7 +1508,6 @@ impl WasmChannel {
"Failed to dispatch emitted messages from poll" "Failed to dispatch emitted messages from poll"
); );
} }
}
} }
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
@@ -1738,22 +1737,22 @@ impl Channel for WasmChannel {
*self.endpoints.write().await = endpoints; *self.endpoints.write().await = endpoints;
// Start polling if configured // Start polling if configured
if let Some(poll_config) = &config.poll { if let Some(poll_config) = &config.poll
if poll_config.enabled { && poll_config.enabled
let interval = self {
.capabilities let interval = self
.validate_poll_interval(poll_config.interval_ms) .capabilities
.map_err(|e| ChannelError::StartupFailed { .validate_poll_interval(poll_config.interval_ms)
name: self.name.clone(), .map_err(|e| ChannelError::StartupFailed {
reason: e, name: self.name.clone(),
})?; reason: e,
})?;
// Create shutdown channel for polling and store the sender to keep it alive // Create shutdown channel for polling and store the sender to keep it alive
let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel(); let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
*self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx); *self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx);
self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx); self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx);
}
} }
tracing::info!( tracing::info!(
+10 -12
View File
@@ -25,23 +25,21 @@ pub async fn auth_middleware(
next: Next, next: Next,
) -> Response { ) -> Response {
// Try Authorization header first (constant-time comparison) // Try Authorization header first (constant-time comparison)
if let Some(auth_header) = headers.get("authorization") { if let Some(auth_header) = headers.get("authorization")
if let Ok(value) = auth_header.to_str() { && let Ok(value) = auth_header.to_str()
if let Some(token) = value.strip_prefix("Bearer ") { && let Some(token) = value.strip_prefix("Bearer ")
if bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) { && bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
return next.run(request).await; {
} return next.run(request).await;
}
}
} }
// Fall back to query parameter for SSE EventSource (constant-time comparison) // Fall back to query parameter for SSE EventSource (constant-time comparison)
if let Some(query) = request.uri().query() { if let Some(query) = request.uri().query() {
for pair in query.split('&') { for pair in query.split('&') {
if let Some(token) = pair.strip_prefix("token=") { if let Some(token) = pair.strip_prefix("token=")
if bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) { && bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
return next.run(request).await; {
} return next.run(request).await;
} }
} }
} }
+8 -8
View File
@@ -473,10 +473,10 @@ pub async fn chat_completions_handler(
if let Some(mt) = req.max_tokens { if let Some(mt) = req.max_tokens {
tool_req = tool_req.with_max_tokens(mt); tool_req = tool_req.with_max_tokens(mt);
} }
if let Some(ref tc) = req.tool_choice { if let Some(ref tc) = req.tool_choice
if let Some(choice) = normalize_tool_choice(tc) { && let Some(choice) = normalize_tool_choice(tc)
tool_req = tool_req.with_tool_choice(choice); {
} tool_req = tool_req.with_tool_choice(choice);
} }
let resp = llm let resp = llm
@@ -591,10 +591,10 @@ async fn handle_streaming(
if let Some(mt) = req.max_tokens { if let Some(mt) = req.max_tokens {
tool_req = tool_req.with_max_tokens(mt); tool_req = tool_req.with_max_tokens(mt);
} }
if let Some(ref tc) = req.tool_choice { if let Some(ref tc) = req.tool_choice
if let Some(choice) = normalize_tool_choice(tc) { && let Some(choice) = normalize_tool_choice(tc)
tool_req = tool_req.with_tool_choice(choice); {
} tool_req = tool_req.with_tool_choice(choice);
} }
LlmResult::WithTools( LlmResult::WithTools(
llm.complete_with_tools(tool_req) llm.complete_with_tools(tool_req)
+154 -155
View File
@@ -525,10 +525,10 @@ pub async fn clear_auth_mode(state: &GatewayState) {
if let Some(ref sm) = state.session_manager { if let Some(ref sm) = state.session_manager {
let session = sm.get_or_create_session(&state.user_id).await; let session = sm.get_or_create_session(&state.user_id).await;
let mut sess = session.lock().await; let mut sess = session.lock().await;
if let Some(thread_id) = sess.active_thread { if let Some(thread_id) = sess.active_thread
if let Some(thread) = sess.threads.get_mut(&thread_id) { && let Some(thread) = sess.threads.get_mut(&thread_id)
thread.pending_auth = None; {
} thread.pending_auth = None;
} }
} }
} }
@@ -626,69 +626,69 @@ async fn chat_history_handler(
// Verify the thread belongs to the authenticated user before returning any data. // Verify the thread belongs to the authenticated user before returning any data.
// In-memory threads are already scoped by user via session_manager, but DB // In-memory threads are already scoped by user via session_manager, but DB
// lookups could expose another user's conversation if the UUID is guessed. // lookups could expose another user's conversation if the UUID is guessed.
if query.thread_id.is_some() { if query.thread_id.is_some()
if let Some(ref store) = state.store { && let Some(ref store) = state.store
let owned = store {
.conversation_belongs_to_user(thread_id, &state.user_id) let owned = store
.await .conversation_belongs_to_user(thread_id, &state.user_id)
.unwrap_or(false); .await
if !owned && !sess.threads.contains_key(&thread_id) { .unwrap_or(false);
return Err((StatusCode::NOT_FOUND, "Thread not found".to_string())); if !owned && !sess.threads.contains_key(&thread_id) {
} return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
} }
} }
// For paginated requests (before cursor set), always go to DB // For paginated requests (before cursor set), always go to DB
if before_cursor.is_some() { if before_cursor.is_some()
if let Some(ref store) = state.store { && let Some(ref store) = state.store
let (messages, has_more) = store {
.list_conversation_messages_paginated(thread_id, before_cursor, limit as i64) let (messages, has_more) = store
.await .list_conversation_messages_paginated(thread_id, before_cursor, limit as i64)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339()); let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339());
let turns = build_turns_from_db_messages(&messages); let turns = build_turns_from_db_messages(&messages);
return Ok(Json(HistoryResponse { return Ok(Json(HistoryResponse {
thread_id, thread_id,
turns, turns,
has_more, has_more,
oldest_timestamp, oldest_timestamp,
})); }));
}
} }
// Try in-memory first (freshest data for active threads) // Try in-memory first (freshest data for active threads)
if let Some(thread) = sess.threads.get(&thread_id) { if let Some(thread) = sess.threads.get(&thread_id)
if !thread.turns.is_empty() { && !thread.turns.is_empty()
let turns: Vec<TurnInfo> = thread {
.turns let turns: Vec<TurnInfo> = thread
.iter() .turns
.map(|t| TurnInfo { .iter()
turn_number: t.turn_number, .map(|t| TurnInfo {
user_input: t.user_input.clone(), turn_number: t.turn_number,
response: t.response.clone(), user_input: t.user_input.clone(),
state: format!("{:?}", t.state), response: t.response.clone(),
started_at: t.started_at.to_rfc3339(), state: format!("{:?}", t.state),
completed_at: t.completed_at.map(|dt| dt.to_rfc3339()), started_at: t.started_at.to_rfc3339(),
tool_calls: t completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
.tool_calls tool_calls: t
.iter() .tool_calls
.map(|tc| ToolCallInfo { .iter()
name: tc.name.clone(), .map(|tc| ToolCallInfo {
has_result: tc.result.is_some(), name: tc.name.clone(),
has_error: tc.error.is_some(), has_result: tc.result.is_some(),
}) has_error: tc.error.is_some(),
.collect(), })
}) .collect(),
.collect(); })
.collect();
return Ok(Json(HistoryResponse { return Ok(Json(HistoryResponse {
thread_id, thread_id,
turns, turns,
has_more: false, has_more: false,
oldest_timestamp: None, oldest_timestamp: None,
})); }));
}
} }
// Fall back to DB for historical threads not in memory (paginated) // Fall back to DB for historical threads not in memory (paginated)
@@ -738,12 +738,12 @@ fn build_turns_from_db_messages(messages: &[crate::history::ConversationMessage]
}; };
// Check if next message is an assistant response // Check if next message is an assistant response
if let Some(next) = iter.peek() { if let Some(next) = iter.peek()
if next.role == "assistant" { && next.role == "assistant"
let assistant_msg = iter.next().expect("peeked"); {
turn.response = Some(assistant_msg.content.clone()); let assistant_msg = iter.next().expect("peeked");
turn.completed_at = Some(assistant_msg.created_at.to_rfc3339()); turn.response = Some(assistant_msg.content.clone());
} turn.completed_at = Some(assistant_msg.created_at.to_rfc3339());
} }
// Incomplete turn (user message without response) // Incomplete turn (user message without response)
@@ -1126,65 +1126,65 @@ async fn jobs_detail_handler(
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
// Try sandbox job from DB first, scoped to the authenticated user. // Try sandbox job from DB first, scoped to the authenticated user.
if let Some(ref store) = state.store { if let Some(ref store) = state.store
if let Ok(Some(job)) = store.get_sandbox_job(job_id).await { && let Ok(Some(job)) = store.get_sandbox_job(job_id).await
if job.user_id != state.user_id { {
return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); if job.user_id != state.user_id {
} return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
let browse_id = std::path::Path::new(&job.project_dir)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| job.id.to_string());
let ui_state = match job.status.as_str() {
"creating" => "pending",
"running" => "in_progress",
s => s,
};
let elapsed_secs = job.started_at.map(|start| {
let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
(end - start).num_seconds().max(0) as u64
});
// Synthesize transitions from timestamps.
let mut transitions = Vec::new();
if let Some(started) = job.started_at {
transitions.push(TransitionInfo {
from: "creating".to_string(),
to: "running".to_string(),
timestamp: started.to_rfc3339(),
reason: None,
});
}
if let Some(completed) = job.completed_at {
transitions.push(TransitionInfo {
from: "running".to_string(),
to: job.status.clone(),
timestamp: completed.to_rfc3339(),
reason: job.failure_reason.clone(),
});
}
return Ok(Json(JobDetailResponse {
id: job.id,
title: job.task.clone(),
description: String::new(),
state: ui_state.to_string(),
user_id: job.user_id.clone(),
created_at: job.created_at.to_rfc3339(),
started_at: job.started_at.map(|dt| dt.to_rfc3339()),
completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
elapsed_secs,
project_dir: Some(job.project_dir.clone()),
browse_url: Some(format!("/projects/{}/", browse_id)),
job_mode: {
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
mode.filter(|m| m != "worker")
},
transitions,
}));
} }
let browse_id = std::path::Path::new(&job.project_dir)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| job.id.to_string());
let ui_state = match job.status.as_str() {
"creating" => "pending",
"running" => "in_progress",
s => s,
};
let elapsed_secs = job.started_at.map(|start| {
let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
(end - start).num_seconds().max(0) as u64
});
// Synthesize transitions from timestamps.
let mut transitions = Vec::new();
if let Some(started) = job.started_at {
transitions.push(TransitionInfo {
from: "creating".to_string(),
to: "running".to_string(),
timestamp: started.to_rfc3339(),
reason: None,
});
}
if let Some(completed) = job.completed_at {
transitions.push(TransitionInfo {
from: "running".to_string(),
to: job.status.clone(),
timestamp: completed.to_rfc3339(),
reason: job.failure_reason.clone(),
});
}
return Ok(Json(JobDetailResponse {
id: job.id,
title: job.task.clone(),
description: String::new(),
state: ui_state.to_string(),
user_id: job.user_id.clone(),
created_at: job.created_at.to_rfc3339(),
started_at: job.started_at.map(|dt| dt.to_rfc3339()),
completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
elapsed_secs,
project_dir: Some(job.project_dir.clone()),
browse_url: Some(format!("/projects/{}/", browse_id)),
job_mode: {
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
mode.filter(|m| m != "worker")
},
transitions,
}));
} }
Err((StatusCode::NOT_FOUND, "Job not found".to_string())) Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
@@ -1198,35 +1198,35 @@ async fn jobs_cancel_handler(
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
// Try sandbox job cancellation, scoped to the authenticated user. // Try sandbox job cancellation, scoped to the authenticated user.
if let Some(ref store) = state.store { if let Some(ref store) = state.store
if let Ok(Some(job)) = store.get_sandbox_job(job_id).await { && let Ok(Some(job)) = store.get_sandbox_job(job_id).await
if job.user_id != state.user_id { {
return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); if job.user_id != state.user_id {
} return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
if job.status == "running" || job.status == "creating" {
// Stop the container if we have a job manager.
if let Some(ref jm) = state.job_manager {
if let Err(e) = jm.stop_job(job_id).await {
tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
}
}
store
.update_sandbox_job_status(
job_id,
"failed",
Some(false),
Some("Cancelled by user"),
None,
Some(chrono::Utc::now()),
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
return Ok(Json(serde_json::json!({
"status": "cancelled",
"job_id": job_id,
})));
} }
if job.status == "running" || job.status == "creating" {
// Stop the container if we have a job manager.
if let Some(ref jm) = state.job_manager
&& let Err(e) = jm.stop_job(job_id).await
{
tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
}
store
.update_sandbox_job_status(
job_id,
"failed",
Some(false),
Some("Cancelled by user"),
None,
Some(chrono::Utc::now()),
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
return Ok(Json(serde_json::json!({
"status": "cancelled",
"job_id": job_id,
})));
} }
Err((StatusCode::NOT_FOUND, "Job not found".to_string())) Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
@@ -1334,14 +1334,13 @@ async fn jobs_prompt_handler(
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
// Verify user owns this job. // Verify user owns this job.
if let Some(ref store) = state.store { if let Some(ref store) = state.store
if !store && !store
.sandbox_job_belongs_to_user(job_id, &state.user_id) .sandbox_job_belongs_to_user(job_id, &state.user_id)
.await .await
.unwrap_or(false) .unwrap_or(false)
{ {
return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
}
} }
let content = body let content = body
+4 -4
View File
@@ -107,10 +107,10 @@ async fn list_settings(
println!(); println!();
for (key, value) in all { for (key, value) in all {
if let Some(ref f) = filter { if let Some(ref f) = filter
if !key.starts_with(f) { && !key.starts_with(f)
continue; {
} continue;
} }
let display_value = if value.len() > 60 { let display_value = if value.len() > 60 {
+63 -66
View File
@@ -420,11 +420,11 @@ async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result<String> {
// Simple TOML parsing for [package] name // Simple TOML parsing for [package] name
for line in content.lines() { for line in content.lines() {
let line = line.trim(); let line = line.trim();
if line.starts_with("name") { if line.starts_with("name")
if let Some((_, value)) = line.split_once('=') { && let Some((_, value)) = line.split_once('=')
let name = value.trim().trim_matches('"').trim_matches('\''); {
return Ok(name.to_string()); let name = value.trim().trim_matches('"').trim_matches('\'');
} return Ok(name.to_string());
} }
} }
@@ -488,10 +488,10 @@ async fn list_tools(dir: Option<PathBuf>, verbose: bool) -> anyhow::Result<()> {
if has_caps { if has_caps {
let caps_path = path.with_extension("capabilities.json"); let caps_path = path.with_extension("capabilities.json");
if let Ok(content) = fs::read_to_string(&caps_path).await { if let Ok(content) = fs::read_to_string(&caps_path).await
if let Ok(caps) = CapabilitiesFile::from_json(&content) { && let Ok(caps) = CapabilitiesFile::from_json(&content)
print_capabilities_summary(&caps); {
} print_capabilities_summary(&caps);
} }
} }
println!(); println!();
@@ -604,16 +604,16 @@ fn print_capabilities_summary(caps: &CapabilitiesFile) {
} }
} }
if let Some(ref secrets) = caps.secrets { if let Some(ref secrets) = caps.secrets
if !secrets.allowed_names.is_empty() { && !secrets.allowed_names.is_empty()
parts.push(format!("secrets: {}", secrets.allowed_names.len())); {
} parts.push(format!("secrets: {}", secrets.allowed_names.len()));
} }
if let Some(ref ws) = caps.workspace { if let Some(ref ws) = caps.workspace
if !ws.allowed_prefixes.is_empty() { && !ws.allowed_prefixes.is_empty()
parts.push("workspace: read".to_string()); {
} parts.push("workspace: read".to_string());
} }
if !parts.is_empty() { if !parts.is_empty() {
@@ -650,30 +650,30 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
} }
} }
if let Some(ref secrets) = caps.secrets { if let Some(ref secrets) = caps.secrets
if !secrets.allowed_names.is_empty() { && !secrets.allowed_names.is_empty()
println!(" Secrets (existence check only):"); {
for name in &secrets.allowed_names { println!(" Secrets (existence check only):");
println!(" {}", name); for name in &secrets.allowed_names {
} println!(" {}", name);
} }
} }
if let Some(ref tool_invoke) = caps.tool_invoke { if let Some(ref tool_invoke) = caps.tool_invoke
if !tool_invoke.aliases.is_empty() { && !tool_invoke.aliases.is_empty()
println!(" Tool aliases:"); {
for (alias, real_name) in &tool_invoke.aliases { println!(" Tool aliases:");
println!(" {} -> {}", alias, real_name); for (alias, real_name) in &tool_invoke.aliases {
} println!(" {} -> {}", alias, real_name);
} }
} }
if let Some(ref ws) = caps.workspace { if let Some(ref ws) = caps.workspace
if !ws.allowed_prefixes.is_empty() { && !ws.allowed_prefixes.is_empty()
println!(" Workspace read prefixes:"); {
for prefix in &ws.allowed_prefixes { println!(" Workspace read prefixes:");
println!(" {}", prefix); for prefix in &ws.allowed_prefixes {
} println!(" {}", prefix);
} }
} }
} }
@@ -752,37 +752,36 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
} }
// Check for environment variable // Check for environment variable
if let Some(ref env_var) = auth.env_var { if let Some(ref env_var) = auth.env_var
if let Ok(token) = std::env::var(env_var) { && let Ok(token) = std::env::var(env_var)
if !token.is_empty() { && !token.is_empty()
println!(" Found {} in environment.", env_var); {
println!(); println!(" Found {} in environment.", env_var);
println!();
// Validate if endpoint is provided // Validate if endpoint is provided
if let Some(ref validation) = auth.validation_endpoint { if let Some(ref validation) = auth.validation_endpoint {
print!(" Validating token..."); print!(" Validating token...");
std::io::stdout().flush()?; std::io::stdout().flush()?;
match validate_token(&token, validation, &auth.secret_name).await { match validate_token(&token, validation, &auth.secret_name).await {
Ok(()) => { Ok(()) => {
println!(""); println!("");
} }
Err(e) => { Err(e) => {
println!(""); println!("");
println!(" Validation failed: {}", e); println!(" Validation failed: {}", e);
println!(); println!();
println!(" Falling back to manual entry..."); println!(" Falling back to manual entry...");
return auth_tool_manual(&secrets_store, &user_id, &auth).await; return auth_tool_manual(&secrets_store, &user_id, &auth).await;
}
}
} }
// Save the token
save_token(&secrets_store, &user_id, &auth, &token).await?;
print_success(display_name);
return Ok(());
} }
} }
// Save the token
save_token(&secrets_store, &user_id, &auth, &token).await?;
print_success(display_name);
return Ok(());
} }
// Check for OAuth configuration // Check for OAuth configuration
@@ -923,9 +922,9 @@ async fn auth_tool_oauth(
reader.read_line(&mut request_line).await?; reader.read_line(&mut request_line).await?;
// Parse GET /callback?code=xxx HTTP/1.1 // Parse GET /callback?code=xxx HTTP/1.1
if let Some(path) = request_line.split_whitespace().nth(1) { if let Some(path) = request_line.split_whitespace().nth(1)
if path.starts_with("/callback") { && path.starts_with("/callback")
if let Some(query) = path.split('?').nth(1) { && let Some(query) = path.split('?').nth(1) {
for param in query.split('&') { for param in query.split('&') {
let parts: Vec<&str> = param.splitn(2, '=').collect(); let parts: Vec<&str> = param.splitn(2, '=').collect();
if parts.len() == 2 && parts[0] == "code" { if parts.len() == 2 && parts[0] == "code" {
@@ -962,8 +961,6 @@ async fn auth_tool_oauth(
return Err(anyhow::anyhow!("Authorization denied by user")); return Err(anyhow::anyhow!("Authorization denied by user"));
} }
} }
}
}
let response = "HTTP/1.1 404 Not Found\r\n\r\n"; let response = "HTTP/1.1 404 Not Found\r\n\r\n";
let _ = socket.write_all(response.as_bytes()).await; let _ = socket.write_all(response.as_bytes()).await;
+14 -14
View File
@@ -107,13 +107,13 @@ impl TunnelConfig {
let public_url = optional_env("TUNNEL_URL")? let public_url = optional_env("TUNNEL_URL")?
.or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty())); .or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty()));
if let Some(ref url) = public_url { if let Some(ref url) = public_url
if !url.starts_with("https://") { && !url.starts_with("https://")
return Err(ConfigError::InvalidValue { {
key: "TUNNEL_URL".to_string(), return Err(ConfigError::InvalidValue {
message: "must start with https:// (webhooks require HTTPS)".to_string(), key: "TUNNEL_URL".to_string(),
}); message: "must start with https:// (webhooks require HTTPS)".to_string(),
} });
} }
Ok(Self { public_url }) Ok(Self { public_url })
@@ -806,13 +806,13 @@ impl SecretsConfig {
let enabled = master_key.is_some(); let enabled = master_key.is_some();
if let Some(ref key) = master_key { if let Some(ref key) = master_key
if key.expose_secret().len() < 32 { && key.expose_secret().len() < 32
return Err(ConfigError::InvalidValue { {
key: "SECRETS_MASTER_KEY".to_string(), return Err(ConfigError::InvalidValue {
message: "must be at least 32 bytes for AES-256-GCM".to_string(), key: "SECRETS_MASTER_KEY".to_string(),
}); message: "must be at least 32 bytes for AES-256-GCM".to_string(),
} });
} }
Ok(Self { Ok(Self {
+5 -6
View File
@@ -144,12 +144,11 @@ impl SuccessEvaluator for RuleBasedEvaluator {
// Check for critical errors // Check for critical errors
for action in actions.iter().filter(|a| !a.success) { for action in actions.iter().filter(|a| !a.success) {
if let Some(ref error) = action.error { if let Some(ref error) = action.error
if error.to_lowercase().contains("critical") && (error.to_lowercase().contains("critical")
|| error.to_lowercase().contains("fatal") || error.to_lowercase().contains("fatal"))
{ {
issues.push(format!("Critical error in {}: {}", action.tool_name, error)); issues.push(format!("Critical error in {}: {}", action.tool_name, error));
}
} }
} }
+27 -27
View File
@@ -490,13 +490,13 @@ impl ExtensionManager {
} }
// Check Content-Length header before downloading the full body // Check Content-Length header before downloading the full body
if let Some(len) = response.content_length() { if let Some(len) = response.content_length()
if len as usize > MAX_WASM_SIZE { && len as usize > MAX_WASM_SIZE
return Err(ExtensionError::InstallFailed(format!( {
"WASM binary too large ({} bytes, max {} bytes)", return Err(ExtensionError::InstallFailed(format!(
len, MAX_WASM_SIZE "WASM binary too large ({} bytes, max {} bytes)",
))); len, MAX_WASM_SIZE
} )));
} }
let bytes = response let bytes = response
@@ -766,27 +766,27 @@ impl ExtensionManager {
}; };
// Check env var first // Check env var first
if let Some(ref env_var) = auth.env_var { if let Some(ref env_var) = auth.env_var
if let Ok(value) = std::env::var(env_var) { && let Ok(value) = std::env::var(env_var)
// Store the env var value as a secret {
let params = CreateSecretParams::new(&auth.secret_name, &value) // Store the env var value as a secret
.with_provider(name.to_string()); let params =
self.secrets CreateSecretParams::new(&auth.secret_name, &value).with_provider(name.to_string());
.create(&self.user_id, params) self.secrets
.await .create(&self.user_id, params)
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?; .await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
return Ok(AuthResult { return Ok(AuthResult {
name: name.to_string(), name: name.to_string(),
kind: ExtensionKind::WasmTool, kind: ExtensionKind::WasmTool,
auth_url: None, auth_url: None,
callback_type: None, callback_type: None,
instructions: None, instructions: None,
setup_url: None, setup_url: None,
awaiting_token: false, awaiting_token: false,
status: "authenticated".to_string(), status: "authenticated".to_string(),
}); });
}
} }
// Check if already authenticated // Check if already authenticated
+27 -27
View File
@@ -209,20 +209,20 @@ impl NearAiProvider {
data: Option<Vec<ModelEntry>>, data: Option<Vec<ModelEntry>>,
} }
if let Ok(resp) = serde_json::from_str::<ModelsResponse>(&response_text) { if let Ok(resp) = serde_json::from_str::<ModelsResponse>(&response_text)
if let Some(entries) = resp.models.or(resp.data) { && let Some(entries) = resp.models.or(resp.data)
let models: Vec<ModelInfo> = entries {
.into_iter() let models: Vec<ModelInfo> = entries
.filter_map(|e| { .into_iter()
e.get_name().map(|name| ModelInfo { .filter_map(|e| {
name, e.get_name().map(|name| ModelInfo {
provider: None, name,
}) provider: None,
}) })
.collect(); })
if !models.is_empty() { .collect();
return Ok(models); if !models.is_empty() {
} return Ok(models);
} }
} }
@@ -694,21 +694,21 @@ impl LlmProvider for NearAiProvider {
} }
} }
} }
} else if item.item_type == "function_call" { } else if item.item_type == "function_call"
if let (Some(name), Some(call_id)) = (&item.name, &item.call_id) { && let (Some(name), Some(call_id)) = (&item.name, &item.call_id)
// Parse arguments JSON string into Value {
let arguments = item // Parse arguments JSON string into Value
.arguments let arguments = item
.as_ref() .arguments
.and_then(|s| serde_json::from_str(s).ok()) .as_ref()
.unwrap_or(serde_json::Value::Object(Default::default())); .and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(serde_json::Value::Object(Default::default()));
tool_calls.push(ToolCall { tool_calls.push(ToolCall {
id: call_id.clone(), id: call_id.clone(),
name: name.clone(), name: name.clone(),
arguments, arguments,
}); });
}
} }
} }
+4 -4
View File
@@ -395,10 +395,10 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
if let (true, Some(calls)) = (msg.role == "assistant", &msg.tool_calls) { if let (true, Some(calls)) = (msg.role == "assistant", &msg.tool_calls) {
// Convert assistant tool_calls into descriptive text // Convert assistant tool_calls into descriptive text
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
if let Some(ref text) = msg.content { if let Some(ref text) = msg.content
if !text.is_empty() { && !text.is_empty()
parts.push(text.clone()); {
} parts.push(text.clone());
} }
for tc in calls { for tc in calls {
parts.push(format!( parts.push(format!(
+14 -15
View File
@@ -581,21 +581,20 @@ fn recover_tool_calls_from_content(
} }
// Try JSON first: {"name":"x","arguments":{}} // Try JSON first: {"name":"x","arguments":{}}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(inner) { if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(inner)
if let Some(name) = parsed.get("name").and_then(|v| v.as_str()) { && let Some(name) = parsed.get("name").and_then(|v| v.as_str())
if tool_names.contains(name) { && tool_names.contains(name)
let arguments = parsed {
.get("arguments") let arguments = parsed
.cloned() .get("arguments")
.unwrap_or(serde_json::Value::Object(Default::default())); .cloned()
calls.push(ToolCall { .unwrap_or(serde_json::Value::Object(Default::default()));
id: format!("recovered_{}", calls.len()), calls.push(ToolCall {
name: name.to_string(), id: format!("recovered_{}", calls.len()),
arguments, name: name.to_string(),
}); arguments,
continue; });
} continue;
}
} }
// Bare tool name (e.g. "<tool_call>tool_list</tool_call>") // Bare tool name (e.g. "<tool_call>tool_list</tool_call>")
+20 -22
View File
@@ -83,16 +83,16 @@ impl SessionManager {
}; };
// Try to load existing session synchronously during construction // Try to load existing session synchronously during construction
if let Ok(data) = std::fs::read_to_string(&manager.config.session_path) { if let Ok(data) = std::fs::read_to_string(&manager.config.session_path)
if let Ok(session) = serde_json::from_str::<SessionData>(&data) { && let Ok(session) = serde_json::from_str::<SessionData>(&data)
// We can't await here, so we use try_write {
if let Ok(mut guard) = manager.token.try_write() { // We can't await here, so we use try_write
*guard = Some(SecretString::from(session.session_token)); if let Ok(mut guard) = manager.token.try_write() {
tracing::info!( *guard = Some(SecretString::from(session.session_token));
"Loaded session token from {}", tracing::info!(
manager.config.session_path.display() "Loaded session token from {}",
); manager.config.session_path.display()
} );
} }
} }
@@ -356,8 +356,8 @@ impl SessionManager {
})?; })?;
// Parse GET /auth/callback?token=xxx&session_id=xxx&expires_at=xxx&is_new_user=xxx HTTP/1.1 // Parse GET /auth/callback?token=xxx&session_id=xxx&expires_at=xxx&is_new_user=xxx HTTP/1.1
if let Some(path) = request_line.split_whitespace().nth(1) { if let Some(path) = request_line.split_whitespace().nth(1)
if path.starts_with("/auth/callback") { && path.starts_with("/auth/callback") {
// Parse query parameters // Parse query parameters
if let Some(query) = path.split('?').nth(1) { if let Some(query) = path.split('?').nth(1) {
let mut token = None; let mut token = None;
@@ -453,7 +453,6 @@ impl SessionManager {
} }
} }
} }
}
// Not the callback we're looking for, send 404 // Not the callback we're looking for, send 404
let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"; let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
@@ -642,15 +641,14 @@ pub async fn create_session_manager(config: SessionConfig) -> Arc<SessionManager
let manager = SessionManager::new_async(config).await; let manager = SessionManager::new_async(config).await;
// Check for legacy env var and migrate if present and no file token // Check for legacy env var and migrate if present and no file token
if !manager.has_token().await { if !manager.has_token().await
if let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN") { && let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN")
if !token.is_empty() { && !token.is_empty()
tracing::info!("Migrating session token from NEARAI_SESSION_TOKEN env var to file"); {
manager.set_token(SecretString::from(token.clone())).await; tracing::info!("Migrating session token from NEARAI_SESSION_TOKEN env var to file");
if let Err(e) = manager.save_session(&token, None).await { manager.set_token(SecretString::from(token.clone())).await;
tracing::warn!("Failed to save migrated session: {}", e); if let Err(e) = manager.save_session(&token, None).await {
} tracing::warn!("Failed to save migrated session: {}", e);
}
} }
} }
+31 -31
View File
@@ -255,13 +255,13 @@ async fn main() -> anyhow::Result<()> {
let _ = dotenvy::dotenv(); let _ = dotenvy::dotenv();
// Enhanced first-run detection // Enhanced first-run detection
if !cli.no_onboard { if !cli.no_onboard
if let Some(reason) = check_onboard_needed().await { && let Some(reason) = check_onboard_needed().await
println!("Onboarding needed: {}", reason); {
println!(); println!("Onboarding needed: {}", reason);
let mut wizard = SetupWizard::new(); println!();
wizard.run().await?; let mut wizard = SetupWizard::new();
} wizard.run().await?;
} }
// Load bootstrap config (4 fields that must live on disk) // Load bootstrap config (4 fields that must live on disk)
@@ -801,13 +801,13 @@ async fn main() -> anyhow::Result<()> {
// Inject owner_id for Telegram so the bot only responds // Inject owner_id for Telegram so the bot only responds
// to the bound user account. // to the bound user account.
if channel_name == "telegram" { if channel_name == "telegram"
if let Some(owner_id) = config.channels.telegram_owner_id { && let Some(owner_id) = config.channels.telegram_owner_id
config_updates.insert( {
"owner_id".to_string(), config_updates.insert(
serde_json::json!(owner_id), "owner_id".to_string(),
); serde_json::json!(owner_id),
} );
} }
if !config_updates.is_empty() { if !config_updates.is_empty() {
@@ -898,23 +898,23 @@ async fn main() -> anyhow::Result<()> {
// Extract its routes for the unified server; the channel itself just // Extract its routes for the unified server; the channel itself just
// provides the mpsc stream. // provides the mpsc stream.
let mut webhook_server_addr: Option<std::net::SocketAddr> = None; let mut webhook_server_addr: Option<std::net::SocketAddr> = None;
if !cli.cli_only { if !cli.cli_only
if let Some(ref http_config) = config.channels.http { && let Some(ref http_config) = config.channels.http
let http_channel = HttpChannel::new(http_config.clone()); {
webhook_routes.push(http_channel.routes()); let http_channel = HttpChannel::new(http_config.clone());
let (host, port) = http_channel.addr(); webhook_routes.push(http_channel.routes());
webhook_server_addr = Some( let (host, port) = http_channel.addr();
format!("{}:{}", host, port) webhook_server_addr = Some(
.parse() format!("{}:{}", host, port)
.expect("HttpConfig host:port must be a valid SocketAddr"), .parse()
); .expect("HttpConfig host:port must be a valid SocketAddr"),
channels.add(Box::new(http_channel)); );
tracing::info!( channels.add(Box::new(http_channel));
"HTTP channel enabled on {}:{}", tracing::info!(
http_config.host, "HTTP channel enabled on {}:{}",
http_config.port http_config.host,
); http_config.port
} );
} }
// Start the unified webhook server if any routes were registered. // Start the unified webhook server if any routes were registered.
+10 -10
View File
@@ -339,16 +339,16 @@ async fn get_prompt_handler(
Path(job_id): Path<Uuid>, Path(job_id): Path<Uuid>,
) -> Result<(StatusCode, Json<serde_json::Value>), StatusCode> { ) -> Result<(StatusCode, Json<serde_json::Value>), StatusCode> {
let mut queue = state.prompt_queue.lock().await; let mut queue = state.prompt_queue.lock().await;
if let Some(prompts) = queue.get_mut(&job_id) { if let Some(prompts) = queue.get_mut(&job_id)
if let Some(prompt) = prompts.pop_front() { && let Some(prompt) = prompts.pop_front()
return Ok(( {
StatusCode::OK, return Ok((
Json(serde_json::json!({ StatusCode::OK,
"content": prompt.content, Json(serde_json::json!({
"done": prompt.done, "content": prompt.content,
})), "done": prompt.done,
)); })),
} ));
} }
// Return 204 with an empty body. The Json wrapper requires some value // Return 204 with an empty body. The Json wrapper requires some value
+38 -38
View File
@@ -229,17 +229,17 @@ impl ContainerJobManager {
.unwrap_or_else(|| PathBuf::from(".")) .unwrap_or_else(|| PathBuf::from("."))
.join(".ironclaw") .join(".ironclaw")
.join("projects"); .join("projects");
if let Ok(canonical_base) = projects_base.canonicalize() { if let Ok(canonical_base) = projects_base.canonicalize()
if !canonical.starts_with(&canonical_base) { && !canonical.starts_with(&canonical_base)
return Err(OrchestratorError::ContainerCreationFailed { {
job_id, return Err(OrchestratorError::ContainerCreationFailed {
reason: format!( job_id,
"project directory {} is outside allowed base {}", reason: format!(
canonical.display(), "project directory {} is outside allowed base {}",
canonical_base.display() canonical.display(),
), canonical_base.display()
}); ),
} });
} }
binds.push(format!("{}:/workspace:rw", canonical.display())); binds.push(format!("{}:/workspace:rw", canonical.display()));
env_vec.push("IRONCLAW_WORKSPACE=/workspace".to_string()); env_vec.push("IRONCLAW_WORKSPACE=/workspace".to_string());
@@ -442,36 +442,36 @@ impl ContainerJobManager {
let containers = self.containers.read().await; let containers = self.containers.read().await;
containers.get(&job_id).map(|h| h.container_id.clone()) containers.get(&job_id).map(|h| h.container_id.clone())
}; };
if let Some(cid) = container_id { if let Some(cid) = container_id
if !cid.is_empty() { && !cid.is_empty()
match connect_docker().await { {
Ok(docker) => { match connect_docker().await {
if let Err(e) = docker Ok(docker) => {
.stop_container( if let Err(e) = docker
&cid, .stop_container(
Some(bollard::container::StopContainerOptions { t: 5 }), &cid,
) Some(bollard::container::StopContainerOptions { t: 5 }),
.await )
{ .await
tracing::warn!(job_id = %job_id, error = %e, "Failed to stop completed container"); {
} tracing::warn!(job_id = %job_id, error = %e, "Failed to stop completed container");
if let Err(e) = docker
.remove_container(
&cid,
Some(bollard::container::RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
{
tracing::warn!(job_id = %job_id, error = %e, "Failed to remove completed container");
}
} }
Err(e) => { if let Err(e) = docker
tracing::warn!(job_id = %job_id, error = %e, "Failed to connect to Docker for container cleanup"); .remove_container(
&cid,
Some(bollard::container::RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
{
tracing::warn!(job_id = %job_id, error = %e, "Failed to remove completed container");
} }
} }
Err(e) => {
tracing::warn!(job_id = %job_id, error = %e, "Failed to connect to Docker for container cleanup");
}
} }
} }
self.token_store.revoke(job_id).await; self.token_store.revoke(job_id).await;
+4 -4
View File
@@ -147,10 +147,10 @@ impl LeakDetector {
// Build prefix matcher for patterns that start with a known prefix // Build prefix matcher for patterns that start with a known prefix
let mut prefixes = Vec::new(); let mut prefixes = Vec::new();
for (idx, pattern) in patterns.iter().enumerate() { for (idx, pattern) in patterns.iter().enumerate() {
if let Some(prefix) = extract_literal_prefix(pattern.regex.as_str()) { if let Some(prefix) = extract_literal_prefix(pattern.regex.as_str())
if prefix.len() >= 3 { && prefix.len() >= 3
prefixes.push((prefix, idx)); {
} prefixes.push((prefix, idx));
} }
} }
+6 -7
View File
@@ -494,10 +494,10 @@ impl ContainerRunner {
/// 3. `~/.docker/run/docker.sock` (Docker Desktop on macOS) /// 3. `~/.docker/run/docker.sock` (Docker Desktop on macOS)
pub async fn connect_docker() -> Result<Docker> { pub async fn connect_docker() -> Result<Docker> {
// First try bollard defaults (checks DOCKER_HOST, then /var/run/docker.sock) // First try bollard defaults (checks DOCKER_HOST, then /var/run/docker.sock)
if let Ok(docker) = Docker::connect_with_local_defaults() { if let Ok(docker) = Docker::connect_with_local_defaults()
if docker.ping().await.is_ok() { && docker.ping().await.is_ok()
return Ok(docker); {
} return Ok(docker);
} }
// Try Docker Desktop socket (macOS) // Try Docker Desktop socket (macOS)
@@ -507,10 +507,9 @@ pub async fn connect_docker() -> Result<Docker> {
let sock_str = desktop_sock.to_string_lossy(); let sock_str = desktop_sock.to_string_lossy();
if let Ok(docker) = if let Ok(docker) =
Docker::connect_with_socket(&sock_str, 120, bollard::API_DEFAULT_VERSION) Docker::connect_with_socket(&sock_str, 120, bollard::API_DEFAULT_VERSION)
&& docker.ping().await.is_ok()
{ {
if docker.ping().await.is_ok() { return Ok(docker);
return Ok(docker);
}
} }
} }
} }
+9 -9
View File
@@ -259,11 +259,11 @@ async fn handle_connect(
let decision = state.decider.decide(&network_req).await; let decision = state.decider.decide(&network_req).await;
if !decision.is_allowed() { if !decision.is_allowed()
if let NetworkDecision::Deny { reason } = decision { && let NetworkDecision::Deny { reason } = decision
tracing::info!("Proxy: blocked CONNECT {} - {}", host, reason); {
return error_response(StatusCode::FORBIDDEN, reason); tracing::info!("Proxy: blocked CONNECT {} - {}", host, reason);
} return error_response(StatusCode::FORBIDDEN, reason);
} }
tracing::debug!("Proxy: allowing CONNECT to {}", host); tracing::debug!("Proxy: allowing CONNECT to {}", host);
@@ -294,10 +294,10 @@ async fn forward_request(
// Copy headers (except hop-by-hop headers) // Copy headers (except hop-by-hop headers)
for (name, value) in req.headers() { for (name, value) in req.headers() {
if !is_hop_by_hop_header(name.as_str()) { if !is_hop_by_hop_header(name.as_str())
if let Ok(v) = value.to_str() { && let Ok(v) = value.to_str()
builder = builder.header(name.as_str(), v); {
} builder = builder.header(name.as_str(), v);
} }
} }
+4 -5
View File
@@ -109,12 +109,11 @@ impl NetworkPolicyDecider for DefaultPolicyDecider {
async fn decide(&self, request: &NetworkRequest) -> NetworkDecision { async fn decide(&self, request: &NetworkRequest) -> NetworkDecision {
// First check if the domain is allowed // First check if the domain is allowed
let validation = self.allowlist.is_allowed(&request.host); let validation = self.allowlist.is_allowed(&request.host);
if !validation.is_allowed() { if !validation.is_allowed()
if let crate::sandbox::proxy::allowlist::DomainValidationResult::Denied(reason) = && let crate::sandbox::proxy::allowlist::DomainValidationResult::Denied(reason) =
validation validation
{ {
return NetworkDecision::Deny { reason }; return NetworkDecision::Deny { reason };
}
} }
// Check if we need to inject credentials // Check if we need to inject credentials
+1 -1
View File
@@ -261,7 +261,7 @@ pub use platform::{delete_master_key, get_master_key, has_master_key, store_mast
/// Parse a hex string to bytes. /// Parse a hex string to bytes.
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, SecretError> { fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, SecretError> {
if hex.len() % 2 != 0 { if !hex.len().is_multiple_of(2) {
return Err(SecretError::KeychainError( return Err(SecretError::KeychainError(
"Invalid hex string length".to_string(), "Invalid hex string length".to_string(),
)); ));
+12 -12
View File
@@ -149,10 +149,10 @@ impl SecretsStore for PostgresSecretsStore {
let secret = row_to_secret(&r); let secret = row_to_secret(&r);
// Check expiration // Check expiration
if let Some(expires_at) = secret.expires_at { if let Some(expires_at) = secret.expires_at
if expires_at < Utc::now() { && expires_at < Utc::now()
return Err(SecretError::Expired); {
} return Err(SecretError::Expired);
} }
Ok(secret) Ok(secret)
@@ -272,10 +272,10 @@ impl SecretsStore for PostgresSecretsStore {
} }
// Simple glob: * matches any suffix // Simple glob: * matches any suffix
if let Some(prefix) = pattern.strip_suffix('*') { if let Some(prefix) = pattern.strip_suffix('*')
if secret_name.starts_with(prefix) { && secret_name.starts_with(prefix)
return Ok(true); {
} return Ok(true);
} }
} }
@@ -430,10 +430,10 @@ pub mod testing {
if pattern == secret_name { if pattern == secret_name {
return Ok(true); return Ok(true);
} }
if let Some(prefix) = pattern.strip_suffix('*') { if let Some(prefix) = pattern.strip_suffix('*')
if secret_name.starts_with(prefix) { && secret_name.starts_with(prefix)
return Ok(true); {
} return Ok(true);
} }
} }
Ok(false) Ok(false)
+23 -23
View File
@@ -252,32 +252,32 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
// Find the first message with a sender // Find the first message with a sender
for update in &body.result { for update in &body.result {
if let Some(ref msg) = update.message { if let Some(ref msg) = update.message
if let Some(ref from) = msg.from { && let Some(ref from) = msg.from
let display_name = from {
.username let display_name = from
.as_ref() .username
.map(|u| format!("@{}", u)) .as_ref()
.unwrap_or_else(|| from.first_name.clone()); .map(|u| format!("@{}", u))
.unwrap_or_else(|| from.first_name.clone());
print_success(&format!( print_success(&format!(
"Received message from {} (ID: {})", "Received message from {} (ID: {})",
display_name, from.id display_name, from.id
)); ));
// Acknowledge the update so it doesn't pile up // Acknowledge the update so it doesn't pile up
let ack_url = format!( let ack_url = format!(
"https://api.telegram.org/bot{}/getUpdates", "https://api.telegram.org/bot{}/getUpdates",
token.expose_secret() token.expose_secret()
); );
let _ = client let _ = client
.get(&ack_url) .get(&ack_url)
.query(&[("offset", &(update.update_id + 1).to_string())]) .query(&[("offset", &(update.update_id + 1).to_string())])
.send() .send()
.await; .await;
return Ok(Some(from.id)); return Ok(Some(from.id));
}
} }
} }
} }
+5 -4
View File
@@ -54,10 +54,11 @@ pub fn select_one(prompt: &str, options: &[&str]) -> io::Result<usize> {
} }
// Parse number // Parse number
if let Ok(num) = input.parse::<usize>() { if let Ok(num) = input.parse::<usize>()
if num >= 1 && num <= options.len() { && num >= 1
return Ok(num - 1); && num <= options.len()
} {
return Ok(num - 1);
} }
writeln!( writeln!(
+14 -15
View File
@@ -344,17 +344,17 @@ impl SetupWizard {
/// Step 3: NEAR AI authentication. /// Step 3: NEAR AI authentication.
async fn step_authentication(&mut self) -> Result<(), SetupError> { async fn step_authentication(&mut self) -> Result<(), SetupError> {
// Check if we already have a session // Check if we already have a session
if let Some(ref session) = self.session_manager { if let Some(ref session) = self.session_manager
if session.has_token().await { && session.has_token().await
print_info("Existing session found. Validating..."); {
match session.ensure_authenticated().await { print_info("Existing session found. Validating...");
Ok(()) => { match session.ensure_authenticated().await {
print_success("Session valid"); Ok(()) => {
return Ok(()); print_success("Session valid");
} return Ok(());
Err(e) => { }
print_info(&format!("Session invalid: {}. Re-authenticating...", e)); Err(e) => {
} print_info(&format!("Session invalid: {}. Re-authenticating...", e));
} }
} }
} }
@@ -642,11 +642,10 @@ impl SetupWizard {
&installed_names, &installed_names,
) )
.await? .await?
&& !installed.is_empty()
{ {
if !installed.is_empty() { print_success(&format!("Installed channels: {}", installed.join(", ")));
print_success(&format!("Installed channels: {}", installed.join(", "))); discovered_channels = discover_wasm_channels(&channels_dir).await;
discovered_channels = discover_wasm_channels(&channels_dir).await;
}
} }
// Determine if we need secrets context // Determine if we need secrets context
+27 -27
View File
@@ -326,20 +326,20 @@ impl TestHarness {
} }
// Verify expected output // Verify expected output
if let Some(ref expected) = test.expected_output { if let Some(ref expected) = test.expected_output
if &actual != expected { && &actual != expected
return TestResult { {
name: test.name.clone(), return TestResult {
passed: false, name: test.name.clone(),
duration, passed: false,
error: Some(format!( duration,
"Output mismatch:\nExpected: {}\nActual: {}", error: Some(format!(
serde_json::to_string_pretty(expected).unwrap_or_default(), "Output mismatch:\nExpected: {}\nActual: {}",
serde_json::to_string_pretty(&actual).unwrap_or_default() serde_json::to_string_pretty(expected).unwrap_or_default(),
)), serde_json::to_string_pretty(&actual).unwrap_or_default()
actual_output: Some(actual), )),
}; actual_output: Some(actual),
} };
} }
// Verify expected fields // Verify expected fields
@@ -357,19 +357,19 @@ impl TestHarness {
}; };
} }
if let Some(ref expected_value) = field.value { if let Some(ref expected_value) = field.value
if field_value != Some(expected_value) { && field_value != Some(expected_value)
return TestResult { {
name: test.name.clone(), return TestResult {
passed: false, name: test.name.clone(),
duration, passed: false,
error: Some(format!( duration,
"Field '{}' mismatch: expected {:?}, got {:?}", error: Some(format!(
field.path, expected_value, field_value "Field '{}' mismatch: expected {:?}, got {:?}",
)), field.path, expected_value, field_value
actual_output: Some(actual), )),
}; actual_output: Some(actual),
} };
} }
} }
} }
+6 -6
View File
@@ -54,12 +54,12 @@ fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
} }
// Check literal IP addresses // Check literal IP addresses
if let Ok(ip) = host.parse::<IpAddr>() { if let Ok(ip) = host.parse::<IpAddr>()
if is_disallowed_ip(&ip) { && is_disallowed_ip(&ip)
return Err(ToolError::NotAuthorized( {
"private or local IPs are not allowed".to_string(), return Err(ToolError::NotAuthorized(
)); "private or local IPs are not allowed".to_string(),
} ));
} }
// Resolve hostname and check all resolved IPs against the blocklist. // Resolve hostname and check all resolved IPs against the blocklist.
+12 -12
View File
@@ -157,18 +157,18 @@ impl CreateJobTool {
}); });
// Persist the job mode to DB // Persist the job mode to DB
if mode == JobMode::ClaudeCode { if mode == JobMode::ClaudeCode
if let Some(store) = self.store.clone() { && let Some(store) = self.store.clone()
let job_id_copy = job_id; {
tokio::spawn(async move { let job_id_copy = job_id;
if let Err(e) = store tokio::spawn(async move {
.update_sandbox_job_mode(job_id_copy, "claude_code") if let Err(e) = store
.await .update_sandbox_job_mode(job_id_copy, "claude_code")
{ .await
tracing::warn!(job_id = %job_id_copy, "Failed to set job mode: {}", e); {
} tracing::warn!(job_id = %job_id_copy, "Failed to set job mode: {}", e);
}); }
} });
} }
// Create the container job with the pre-determined job_id. // Create the container job with the pre-determined job_id.
+6 -6
View File
@@ -343,12 +343,12 @@ impl ShellTool {
// Use sandbox if configured; fail-closed (never silently fall through // Use sandbox if configured; fail-closed (never silently fall through
// to unsandboxed execution when sandbox was intended). // to unsandboxed execution when sandbox was intended).
if let Some(ref sandbox) = self.sandbox { if let Some(ref sandbox) = self.sandbox
if sandbox.is_initialized() || sandbox.config().enabled { && (sandbox.is_initialized() || sandbox.config().enabled)
return self {
.execute_sandboxed(sandbox, cmd, &cwd, timeout_duration) return self
.await; .execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
} .await;
} }
// Only execute directly when no sandbox was configured at all. // Only execute directly when no sandbox was configured at all.
+3 -5
View File
@@ -539,9 +539,9 @@ pub async fn wait_for_authorization_callback(
.map_err(|e| AuthError::Http(e.to_string()))?; .map_err(|e| AuthError::Http(e.to_string()))?;
// Parse GET /callback?code=xxx HTTP/1.1 // Parse GET /callback?code=xxx HTTP/1.1
if let Some(path) = request_line.split_whitespace().nth(1) { if let Some(path) = request_line.split_whitespace().nth(1)
if path.starts_with("/callback") { && path.starts_with("/callback")
if let Some(query) = path.split('?').nth(1) { && let Some(query) = path.split('?').nth(1) {
// Check for error first // Check for error first
if query.contains("error=") { if query.contains("error=") {
let response = "HTTP/1.1 400 Bad Request\r\n\r\nAuthorization denied"; let response = "HTTP/1.1 400 Bad Request\r\n\r\nAuthorization denied";
@@ -578,8 +578,6 @@ pub async fn wait_for_authorization_callback(
} }
} }
} }
}
}
let response = "HTTP/1.1 404 Not Found\r\n\r\n"; let response = "HTTP/1.1 404 Not Found\r\n\r\n";
let _ = socket.write_all(response.as_bytes()).await; let _ = socket.write_all(response.as_bytes()).await;
+35 -39
View File
@@ -184,10 +184,10 @@ impl McpClient {
} }
// Add Mcp-Session-Id header if we have a session // Add Mcp-Session-Id header if we have a session
if let Some(ref session_manager) = self.session_manager { if let Some(ref session_manager) = self.session_manager
if let Some(session_id) = session_manager.get_session_id(&self.server_name).await { && let Some(session_id) = session_manager.get_session_id(&self.server_name).await
req_builder = req_builder.header("Mcp-Session-Id", session_id); {
} req_builder = req_builder.header("Mcp-Session-Id", session_id);
} }
let response = req_builder let response = req_builder
@@ -199,29 +199,26 @@ impl McpClient {
if response.status() == reqwest::StatusCode::UNAUTHORIZED { if response.status() == reqwest::StatusCode::UNAUTHORIZED {
if attempt == 0 { if attempt == 0 {
// Try to refresh the token // Try to refresh the token
if let Some(ref secrets) = self.secrets { if let Some(ref secrets) = self.secrets
if let Some(ref config) = self.server_config { && let Some(ref config) = self.server_config
tracing::debug!( {
"MCP token expired, attempting refresh for '{}'", tracing::debug!(
self.server_name "MCP token expired, attempting refresh for '{}'",
); self.server_name
match refresh_access_token(config, secrets, &self.user_id).await { );
Ok(_) => { match refresh_access_token(config, secrets, &self.user_id).await {
tracing::info!( Ok(_) => {
"MCP token refreshed for '{}'", tracing::info!("MCP token refreshed for '{}'", self.server_name);
self.server_name // Continue to next iteration to retry with new token
); continue;
// Continue to next iteration to retry with new token }
continue; Err(e) => {
} tracing::debug!(
Err(e) => { "Token refresh failed for '{}': {}",
tracing::debug!( self.server_name,
"Token refresh failed for '{}': {}", e
self.server_name, );
e // Fall through to return auth error
);
// Fall through to return auth error
}
} }
} }
} }
@@ -245,16 +242,15 @@ impl McpClient {
/// Parse the HTTP response into an MCP response. /// Parse the HTTP response into an MCP response.
async fn parse_response(&self, response: reqwest::Response) -> Result<McpResponse, ToolError> { async fn parse_response(&self, response: reqwest::Response) -> Result<McpResponse, ToolError> {
// Extract session ID from response header // Extract session ID from response header
if let Some(ref session_manager) = self.session_manager { if let Some(ref session_manager) = self.session_manager
if let Some(session_id) = response && let Some(session_id) = response
.headers() .headers()
.get("Mcp-Session-Id") .get("Mcp-Session-Id")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
{ {
session_manager session_manager
.update_session_id(&self.server_name, Some(session_id.to_string())) .update_session_id(&self.server_name, Some(session_id.to_string()))
.await; .await;
}
} }
if !response.status().is_success() { if !response.status().is_success() {
@@ -316,11 +312,11 @@ impl McpClient {
/// This should be called once per session to establish capabilities. /// This should be called once per session to establish capabilities.
pub async fn initialize(&self) -> Result<InitializeResult, ToolError> { pub async fn initialize(&self) -> Result<InitializeResult, ToolError> {
// Check if already initialized // Check if already initialized
if let Some(ref session_manager) = self.session_manager { if let Some(ref session_manager) = self.session_manager
if session_manager.is_initialized(&self.server_name).await { && session_manager.is_initialized(&self.server_name).await
// Return cached/default capabilities {
return Ok(InitializeResult::default()); // Return cached/default capabilities
} return Ok(InitializeResult::default());
} }
// Ensure we have a session // Ensure we have a session
+4 -4
View File
@@ -96,10 +96,10 @@ impl ToolRegistry {
if let Ok(mut tools) = self.tools.try_write() { if let Ok(mut tools) = self.tools.try_write() {
tools.insert(name.clone(), tool); tools.insert(name.clone(), tool);
// Mark as built-in so it can't be shadowed later // Mark as built-in so it can't be shadowed later
if PROTECTED_TOOL_NAMES.contains(&name.as_str()) { if PROTECTED_TOOL_NAMES.contains(&name.as_str())
if let Ok(mut builtins) = self.builtin_names.try_write() { && let Ok(mut builtins) = self.builtin_names.try_write()
builtins.insert(name.clone()); {
} builtins.insert(name.clone());
} }
tracing::debug!("Registered tool: {}", name); tracing::debug!("Registered tool: {}", name);
} }
+16 -15
View File
@@ -209,10 +209,10 @@ impl EndpointPattern {
} }
// Check path prefix // Check path prefix
if let Some(ref prefix) = self.path_prefix { if let Some(ref prefix) = self.path_prefix
if !url_path.starts_with(prefix) { && !url_path.starts_with(prefix)
return false; {
} return false;
} }
// Check method // Check method
@@ -237,13 +237,14 @@ impl EndpointPattern {
} }
// Support wildcard: *.example.com matches sub.example.com // Support wildcard: *.example.com matches sub.example.com
if let Some(suffix) = self.host.strip_prefix("*.") { if let Some(suffix) = self.host.strip_prefix("*.")
if url_host.ends_with(suffix) && url_host.len() > suffix.len() { && url_host.ends_with(suffix)
// Ensure there's a dot before the suffix (or it's the whole thing) && url_host.len() > suffix.len()
let prefix = &url_host[..url_host.len() - suffix.len()]; {
if prefix.ends_with('.') || prefix.is_empty() { // Ensure there's a dot before the suffix (or it's the whole thing)
return true; let prefix = &url_host[..url_host.len() - suffix.len()];
} if prefix.ends_with('.') || prefix.is_empty() {
return true;
} }
} }
@@ -291,10 +292,10 @@ impl SecretsCapability {
if pattern == name { if pattern == name {
return true; return true;
} }
if let Some(prefix) = pattern.strip_suffix('*') { if let Some(prefix) = pattern.strip_suffix('*')
if name.starts_with(prefix) { && name.starts_with(prefix)
return true; {
} return true;
} }
} }
false false
+11 -10
View File
@@ -158,10 +158,10 @@ impl CredentialInjector {
if pattern == name { if pattern == name {
return true; return true;
} }
if let Some(prefix) = pattern.strip_suffix('*') { if let Some(prefix) = pattern.strip_suffix('*')
if name.starts_with(prefix) { && name.starts_with(prefix)
return true; {
} return true;
} }
} }
false false
@@ -214,12 +214,13 @@ fn host_matches_pattern(host: &str, pattern: &str) -> bool {
} }
// Support wildcard: *.example.com matches sub.example.com // Support wildcard: *.example.com matches sub.example.com
if let Some(suffix) = pattern.strip_prefix("*.") { if let Some(suffix) = pattern.strip_prefix("*.")
if host.ends_with(suffix) && host.len() > suffix.len() { && host.ends_with(suffix)
let prefix = &host[..host.len() - suffix.len()]; && host.len() > suffix.len()
if prefix.ends_with('.') || prefix.is_empty() { {
return true; let prefix = &host[..host.len() - suffix.len()];
} if prefix.ends_with('.') || prefix.is_empty() {
return true;
} }
} }
+7 -7
View File
@@ -259,13 +259,13 @@ impl near::agent::host::Host for StoreData {
// Check Content-Length header for early rejection of oversized responses. // Check Content-Length header for early rejection of oversized responses.
let max_response = max_response_bytes; let max_response = max_response_bytes;
if let Some(cl) = response.content_length() { if let Some(cl) = response.content_length()
if cl as usize > max_response { && cl as usize > max_response
return Err(format!( {
"Response body too large: {} bytes exceeds limit of {} bytes", return Err(format!(
cl, max_response "Response body too large: {} bytes exceeds limit of {} bytes",
)); cl, max_response
} ));
} }
// Read body with a size cap to prevent memory exhaustion. // Read body with a size cap to prevent memory exhaustion.
+9 -9
View File
@@ -326,15 +326,15 @@ impl ClaudeBridgeRuntime {
match serde_json::from_str::<ClaudeStreamEvent>(&line) { match serde_json::from_str::<ClaudeStreamEvent>(&line) {
Ok(event) => { Ok(event) => {
// Capture session_id from system init // Capture session_id from system init
if event.event_type == "system" { if event.event_type == "system"
if let Some(ref sid) = event.session_id { && let Some(ref sid) = event.session_id
session_id = Some(sid.clone()); {
tracing::info!( session_id = Some(sid.clone());
job_id = %self.config.job_id, tracing::info!(
session_id = %sid, job_id = %self.config.job_id,
"Captured Claude session ID" session_id = %sid,
); "Captured Claude session ID"
} );
} }
// Convert to our event payload and forward // Convert to our event payload and forward
+13 -13
View File
@@ -333,10 +333,10 @@ impl Workspace {
]; ];
for (path, header) in identity_files { for (path, header) in identity_files {
if let Ok(doc) = self.read(path).await { if let Ok(doc) = self.read(path).await
if !doc.content.is_empty() { && !doc.content.is_empty()
parts.push(format!("{}\n\n{}", header, doc.content)); {
} parts.push(format!("{}\n\n{}", header, doc.content));
} }
} }
@@ -345,15 +345,15 @@ impl Workspace {
let yesterday = today.pred_opt().unwrap_or(today); let yesterday = today.pred_opt().unwrap_or(today);
for date in [today, yesterday] { for date in [today, yesterday] {
if let Ok(doc) = self.daily_log(date).await { if let Ok(doc) = self.daily_log(date).await
if !doc.content.is_empty() { && !doc.content.is_empty()
let header = if date == today { {
"## Today's Notes" let header = if date == today {
} else { "## Today's Notes"
"## Yesterday's Notes" } else {
}; "## Yesterday's Notes"
parts.push(format!("{}\n\n{}", header, doc.content)); };
} parts.push(format!("{}\n\n{}", header, doc.content));
} }
} }
+5 -5
View File
@@ -201,11 +201,11 @@ pub fn reciprocal_rank_fusion(
.collect(); .collect();
// Normalize scores to 0-1 range // Normalize scores to 0-1 range
if let Some(max_score) = results.iter().map(|r| r.score).reduce(f32::max) { if let Some(max_score) = results.iter().map(|r| r.score).reduce(f32::max)
if max_score > 0.0 { && max_score > 0.0
for result in &mut results { {
result.score /= max_score; for result in &mut results {
} result.score /= max_score;
} }
} }
+4 -4
View File
@@ -302,10 +302,10 @@ async fn test_chat_completions_streaming() {
if data == "[DONE]" { if data == "[DONE]" {
continue; continue;
} }
if let Ok(chunk) = serde_json::from_str::<serde_json::Value>(data) { if let Ok(chunk) = serde_json::from_str::<serde_json::Value>(data)
if let Some(content) = chunk["choices"][0]["delta"]["content"].as_str() { && let Some(content) = chunk["choices"][0]["delta"]["content"].as_str()
full_content.push_str(content); {
} full_content.push_str(content);
} }
} }
} }