mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.6
parent
bbb68f7490
commit
5df0d13b59
+1
-1
@@ -2,7 +2,7 @@
|
||||
name = "ironclaw"
|
||||
version = "0.1.3"
|
||||
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"
|
||||
authors = ["NEAR AI <[email protected]>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
+46
@@ -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
@@ -9,7 +9,7 @@
|
||||
# The image includes common development tools so workers can build software,
|
||||
# run tests, and execute shell commands.
|
||||
|
||||
FROM rust:1.85-bookworm AS builder
|
||||
FROM rust:1.92-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
@@ -40,7 +40,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
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
|
||||
|
||||
# Install Claude Code CLI (for claude-bridge mode)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+68
@@ -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
@@ -654,19 +654,17 @@ impl Agent {
|
||||
}
|
||||
|
||||
// Restore response chain from conversation metadata
|
||||
if let Some(store) = self.store() {
|
||||
if let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await {
|
||||
if let Some(rid) = metadata
|
||||
.get("last_response_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
{
|
||||
thread.last_response_id = Some(rid.clone());
|
||||
self.llm()
|
||||
.seed_response_chain(&thread_uuid.to_string(), rid);
|
||||
tracing::debug!("Restored response chain for thread {}", thread_uuid);
|
||||
}
|
||||
}
|
||||
if let Some(store) = self.store()
|
||||
&& let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await
|
||||
&& let Some(rid) = metadata
|
||||
.get("last_response_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
{
|
||||
thread.last_response_id = Some(rid.clone());
|
||||
self.llm()
|
||||
.seed_response_chain(&thread_uuid.to_string(), rid);
|
||||
tracing::debug!("Restored response chain for thread {}", thread_uuid);
|
||||
}
|
||||
|
||||
// Insert into session and register with session manager
|
||||
@@ -954,13 +952,12 @@ impl Agent {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ref resp) = response {
|
||||
if let Err(e) = store
|
||||
if let Some(ref resp) = response
|
||||
&& let Err(e) = store
|
||||
.add_conversation_message(thread_id, "assistant", resp)
|
||||
.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
|
||||
{
|
||||
let sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get(&thread_id) {
|
||||
if thread.state == ThreadState::Interrupted {
|
||||
return Err(crate::error::JobError::ContextError {
|
||||
id: thread_id,
|
||||
reason: "Interrupted".to_string(),
|
||||
}
|
||||
.into());
|
||||
if let Some(thread) = sess.threads.get(&thread_id)
|
||||
&& thread.state == ThreadState::Interrupted
|
||||
{
|
||||
return Err(crate::error::JobError::ContextError {
|
||||
id: thread_id,
|
||||
reason: "Interrupted".to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1140,11 +1137,11 @@ impl Agent {
|
||||
// Record tool calls in the thread
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
if let Some(turn) = thread.last_turn_mut() {
|
||||
for tc in &tool_calls {
|
||||
turn.record_tool_call(&tc.name, tc.arguments.clone());
|
||||
}
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
||||
&& let Some(turn) = thread.last_turn_mut()
|
||||
{
|
||||
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)
|
||||
for tc in tool_calls {
|
||||
// Check if tool requires approval
|
||||
if let Some(tool) = self.tools().get(&tc.name).await {
|
||||
if tool.requires_approval() {
|
||||
// Check if auto-approved for this session
|
||||
let mut is_auto_approved = {
|
||||
let sess = session.lock().await;
|
||||
sess.is_tool_auto_approved(&tc.name)
|
||||
if let Some(tool) = self.tools().get(&tc.name).await
|
||||
&& tool.requires_approval()
|
||||
{
|
||||
// Check if auto-approved for this session
|
||||
let mut is_auto_approved = {
|
||||
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
|
||||
// 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 });
|
||||
}
|
||||
return Ok(AgenticLoopResult::NeedApproval { pending });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1230,34 +1221,34 @@ impl Agent {
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Ok(ref output) = tool_result {
|
||||
if !output.is_empty() {
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::ToolResult {
|
||||
name: tc.name.clone(),
|
||||
preview: truncate_for_preview(output, 200),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Ok(ref output) = tool_result
|
||||
&& !output.is_empty()
|
||||
{
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::ToolResult {
|
||||
name: tc.name.clone(),
|
||||
preview: truncate_for_preview(output, 200),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Record result in thread
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
if let Some(turn) = thread.last_turn_mut() {
|
||||
match &tool_result {
|
||||
Ok(output) => {
|
||||
turn.record_tool_result(serde_json::json!(output));
|
||||
}
|
||||
Err(e) => {
|
||||
turn.record_tool_error(e.to_string());
|
||||
}
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
||||
&& let Some(turn) = thread.last_turn_mut()
|
||||
{
|
||||
match &tool_result {
|
||||
Ok(output) => {
|
||||
turn.record_tool_result(serde_json::json!(output));
|
||||
}
|
||||
Err(e) => {
|
||||
turn.record_tool_error(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1640,17 +1631,17 @@ impl Agent {
|
||||
};
|
||||
|
||||
// Verify request ID if provided
|
||||
if let Some(req_id) = request_id {
|
||||
if req_id != pending.request_id {
|
||||
// Put it back and return error
|
||||
let mut sess = session.lock().await;
|
||||
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.",
|
||||
));
|
||||
if let Some(req_id) = request_id
|
||||
&& req_id != pending.request_id
|
||||
{
|
||||
// Put it back and return error
|
||||
let mut sess = session.lock().await;
|
||||
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.",
|
||||
));
|
||||
}
|
||||
|
||||
if approved {
|
||||
@@ -1704,20 +1695,20 @@ impl Agent {
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Ok(ref output) = tool_result {
|
||||
if !output.is_empty() {
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::ToolResult {
|
||||
name: pending.tool_name.clone(),
|
||||
preview: truncate_for_preview(output, 200),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Ok(ref output) = tool_result
|
||||
&& !output.is_empty()
|
||||
{
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::ToolResult {
|
||||
name: pending.tool_name.clone(),
|
||||
preview: truncate_for_preview(output, 200),
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Build context including the tool result
|
||||
@@ -1726,15 +1717,15 @@ impl Agent {
|
||||
// Record result in thread
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
if let Some(turn) = thread.last_turn_mut() {
|
||||
match &tool_result {
|
||||
Ok(output) => {
|
||||
turn.record_tool_result(serde_json::json!(output));
|
||||
}
|
||||
Err(e) => {
|
||||
turn.record_tool_error(e.to_string());
|
||||
}
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
||||
&& let Some(turn) = thread.last_turn_mut()
|
||||
{
|
||||
match &tool_result {
|
||||
Ok(output) => {
|
||||
turn.record_tool_result(serde_json::json!(output));
|
||||
}
|
||||
Err(e) => {
|
||||
turn.record_tool_error(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2094,15 +2085,15 @@ impl Agent {
|
||||
}
|
||||
|
||||
// Persist new job to database (fire-and-forget)
|
||||
if let Some(store) = self.store() {
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
||||
let store = store.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = store.save_job(&ctx).await {
|
||||
tracing::warn!("Failed to persist new job {}: {}", job_id, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(store) = self.store()
|
||||
&& let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||
{
|
||||
let store = store.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = store.save_job(&ctx).await {
|
||||
tracing::warn!("Failed to persist new job {}: {}", job_id, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule for execution
|
||||
@@ -2182,10 +2173,10 @@ impl Agent {
|
||||
|
||||
let mut output = String::from("Jobs:\n");
|
||||
for job_id in jobs {
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
||||
if ctx.user_id == user_id {
|
||||
output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
|
||||
}
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||
&& ctx.user_id == user_id
|
||||
{
|
||||
output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,10 +103,9 @@ impl RoutineEngine {
|
||||
if let Trigger::Event {
|
||||
channel: Some(ch), ..
|
||||
} = &routine.trigger
|
||||
&& ch != &message.channel
|
||||
{
|
||||
if ch != &message.channel {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regex match
|
||||
|
||||
+18
-18
@@ -119,25 +119,25 @@ impl SelfRepair for DefaultSelfRepair {
|
||||
let mut stuck_jobs = Vec::new();
|
||||
|
||||
for job_id in stuck_ids {
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
||||
if ctx.state == JobState::Stuck {
|
||||
let stuck_duration = ctx
|
||||
.started_at
|
||||
.map(|start| {
|
||||
let now = Utc::now();
|
||||
let duration = now.signed_duration_since(start);
|
||||
Duration::from_secs(duration.num_seconds().max(0) as u64)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||
&& ctx.state == JobState::Stuck
|
||||
{
|
||||
let stuck_duration = ctx
|
||||
.started_at
|
||||
.map(|start| {
|
||||
let now = Utc::now();
|
||||
let duration = now.signed_duration_since(start);
|
||||
Duration::from_secs(duration.num_seconds().max(0) as u64)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
stuck_jobs.push(StuckJob {
|
||||
job_id,
|
||||
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
|
||||
stuck_duration,
|
||||
last_error: None,
|
||||
repair_attempts: ctx.repair_attempts,
|
||||
});
|
||||
}
|
||||
stuck_jobs.push(StuckJob {
|
||||
job_id,
|
||||
last_activity: ctx.started_at.unwrap_or(ctx.created_at),
|
||||
stuck_duration,
|
||||
last_error: None,
|
||||
repair_attempts: ctx.repair_attempts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -346,11 +346,11 @@ impl Thread {
|
||||
let mut turn = Turn::new(turn_number, &msg.content);
|
||||
|
||||
// Check if next is assistant response
|
||||
if let Some(next) = iter.peek() {
|
||||
if next.role == crate::llm::Role::Assistant {
|
||||
let response = iter.next().expect("peeked");
|
||||
turn.complete(&response.content);
|
||||
}
|
||||
if let Some(next) = iter.peek()
|
||||
&& next.role == crate::llm::Role::Assistant
|
||||
{
|
||||
let response = iter.next().expect("peeked");
|
||||
turn.complete(&response.content);
|
||||
}
|
||||
|
||||
self.turns.push(turn);
|
||||
|
||||
@@ -199,10 +199,10 @@ impl SessionManager {
|
||||
{
|
||||
let sessions = self.sessions.read().await;
|
||||
for user_id in &stale_users {
|
||||
if let Some(session) = sessions.get(user_id) {
|
||||
if let Ok(sess) = session.try_lock() {
|
||||
stale_thread_ids.extend(sess.threads.keys());
|
||||
}
|
||||
if let Some(session) = sessions.get(user_id)
|
||||
&& let Ok(sess) = session.try_lock()
|
||||
{
|
||||
stale_thread_ids.extend(sess.threads.keys());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-14
@@ -93,27 +93,26 @@ impl SubmissionParser {
|
||||
// /thread <uuid> - switch thread
|
||||
if let Some(rest) = lower.strip_prefix("/thread ") {
|
||||
let rest = rest.trim();
|
||||
if rest != "new" {
|
||||
if let Ok(id) = Uuid::parse_str(rest) {
|
||||
return Submission::SwitchThread { thread_id: id };
|
||||
}
|
||||
if rest != "new"
|
||||
&& let Ok(id) = Uuid::parse_str(rest)
|
||||
{
|
||||
return Submission::SwitchThread { thread_id: id };
|
||||
}
|
||||
}
|
||||
|
||||
// /resume <uuid> - resume from checkpoint
|
||||
if let Some(rest) = lower.strip_prefix("/resume ") {
|
||||
if let Ok(id) = Uuid::parse_str(rest.trim()) {
|
||||
return Submission::Resume { checkpoint_id: id };
|
||||
}
|
||||
if let Some(rest) = lower.strip_prefix("/resume ")
|
||||
&& let Ok(id) = Uuid::parse_str(rest.trim())
|
||||
{
|
||||
return Submission::Resume { checkpoint_id: id };
|
||||
}
|
||||
|
||||
// Try structured JSON approval (from web gateway's /api/chat/approval endpoint)
|
||||
if trimmed.starts_with('{') {
|
||||
if let Ok(submission) = serde_json::from_str::<Submission>(trimmed) {
|
||||
if matches!(submission, Submission::ExecApproval { .. }) {
|
||||
return submission;
|
||||
}
|
||||
}
|
||||
if trimmed.starts_with('{')
|
||||
&& let Ok(submission) = serde_json::from_str::<Submission>(trimmed)
|
||||
&& matches!(submission, Submission::ExecApproval { .. })
|
||||
{
|
||||
return submission;
|
||||
}
|
||||
|
||||
// Approval responses (simple yes/no/always for pending approvals)
|
||||
|
||||
+5
-5
@@ -227,11 +227,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
|
||||
// Check for cancellation
|
||||
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await {
|
||||
if ctx.state == JobState::Cancelled {
|
||||
tracing::info!("Worker for job {} detected cancellation", self.job_id);
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await
|
||||
&& ctx.state == JobState::Cancelled
|
||||
{
|
||||
tracing::info!("Worker for job {} detected cancellation", self.job_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
iteration += 1;
|
||||
|
||||
@@ -134,13 +134,13 @@ impl ChannelStoreData {
|
||||
if result.contains('{') && result.contains('}') {
|
||||
// Only warn if it looks like an unresolved placeholder (not JSON braces)
|
||||
let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
|
||||
if let Some(re) = brace_pattern {
|
||||
if re.is_match(&result) {
|
||||
tracing::warn!(
|
||||
context = %context,
|
||||
"String may contain unresolved credential placeholders"
|
||||
);
|
||||
}
|
||||
if let Some(re) = brace_pattern
|
||||
&& re.is_match(&result)
|
||||
{
|
||||
tracing::warn!(
|
||||
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.
|
||||
let max_response = max_response_bytes;
|
||||
if let Some(cl) = response.content_length() {
|
||||
if cl as usize > max_response {
|
||||
return Err(format!(
|
||||
"Response body too large: {} bytes exceeds limit of {} bytes",
|
||||
cl, max_response
|
||||
));
|
||||
}
|
||||
if let Some(cl) = response.content_length()
|
||||
&& cl as usize > max_response
|
||||
{
|
||||
return Err(format!(
|
||||
"Response body too large: {} bytes exceeds limit of {} bytes",
|
||||
cl, max_response
|
||||
));
|
||||
}
|
||||
let body = response
|
||||
.bytes()
|
||||
@@ -1495,8 +1495,8 @@ impl WasmChannel {
|
||||
match result {
|
||||
Ok(emitted_messages) => {
|
||||
// Process any emitted messages
|
||||
if !emitted_messages.is_empty() {
|
||||
if let Err(e) = Self::dispatch_emitted_messages(
|
||||
if !emitted_messages.is_empty()
|
||||
&& let Err(e) = Self::dispatch_emitted_messages(
|
||||
&channel_name,
|
||||
emitted_messages,
|
||||
&message_tx,
|
||||
@@ -1508,7 +1508,6 @@ impl WasmChannel {
|
||||
"Failed to dispatch emitted messages from poll"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
@@ -1738,22 +1737,22 @@ impl Channel for WasmChannel {
|
||||
*self.endpoints.write().await = endpoints;
|
||||
|
||||
// Start polling if configured
|
||||
if let Some(poll_config) = &config.poll {
|
||||
if poll_config.enabled {
|
||||
let interval = self
|
||||
.capabilities
|
||||
.validate_poll_interval(poll_config.interval_ms)
|
||||
.map_err(|e| ChannelError::StartupFailed {
|
||||
name: self.name.clone(),
|
||||
reason: e,
|
||||
})?;
|
||||
if let Some(poll_config) = &config.poll
|
||||
&& poll_config.enabled
|
||||
{
|
||||
let interval = self
|
||||
.capabilities
|
||||
.validate_poll_interval(poll_config.interval_ms)
|
||||
.map_err(|e| ChannelError::StartupFailed {
|
||||
name: self.name.clone(),
|
||||
reason: e,
|
||||
})?;
|
||||
|
||||
// Create shutdown channel for polling and store the sender to keep it alive
|
||||
let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
|
||||
*self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx);
|
||||
// Create shutdown channel for polling and store the sender to keep it alive
|
||||
let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
|
||||
*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!(
|
||||
|
||||
+10
-12
@@ -25,23 +25,21 @@ pub async fn auth_middleware(
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Try Authorization header first (constant-time comparison)
|
||||
if let Some(auth_header) = headers.get("authorization") {
|
||||
if let Ok(value) = auth_header.to_str() {
|
||||
if let Some(token) = value.strip_prefix("Bearer ") {
|
||||
if bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(auth_header) = headers.get("authorization")
|
||||
&& let Ok(value) = auth_header.to_str()
|
||||
&& let Some(token) = value.strip_prefix("Bearer ")
|
||||
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Fall back to query parameter for SSE EventSource (constant-time comparison)
|
||||
if let Some(query) = request.uri().query() {
|
||||
for pair in query.split('&') {
|
||||
if let Some(token) = pair.strip_prefix("token=") {
|
||||
if bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
if let Some(token) = pair.strip_prefix("token=")
|
||||
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,10 +473,10 @@ pub async fn chat_completions_handler(
|
||||
if let Some(mt) = req.max_tokens {
|
||||
tool_req = tool_req.with_max_tokens(mt);
|
||||
}
|
||||
if let Some(ref tc) = req.tool_choice {
|
||||
if let Some(choice) = normalize_tool_choice(tc) {
|
||||
tool_req = tool_req.with_tool_choice(choice);
|
||||
}
|
||||
if let Some(ref tc) = req.tool_choice
|
||||
&& let Some(choice) = normalize_tool_choice(tc)
|
||||
{
|
||||
tool_req = tool_req.with_tool_choice(choice);
|
||||
}
|
||||
|
||||
let resp = llm
|
||||
@@ -591,10 +591,10 @@ async fn handle_streaming(
|
||||
if let Some(mt) = req.max_tokens {
|
||||
tool_req = tool_req.with_max_tokens(mt);
|
||||
}
|
||||
if let Some(ref tc) = req.tool_choice {
|
||||
if let Some(choice) = normalize_tool_choice(tc) {
|
||||
tool_req = tool_req.with_tool_choice(choice);
|
||||
}
|
||||
if let Some(ref tc) = req.tool_choice
|
||||
&& let Some(choice) = normalize_tool_choice(tc)
|
||||
{
|
||||
tool_req = tool_req.with_tool_choice(choice);
|
||||
}
|
||||
LlmResult::WithTools(
|
||||
llm.complete_with_tools(tool_req)
|
||||
|
||||
+154
-155
@@ -525,10 +525,10 @@ pub async fn clear_auth_mode(state: &GatewayState) {
|
||||
if let Some(ref sm) = state.session_manager {
|
||||
let session = sm.get_or_create_session(&state.user_id).await;
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread_id) = sess.active_thread {
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.pending_auth = None;
|
||||
}
|
||||
if let Some(thread_id) = sess.active_thread
|
||||
&& let Some(thread) = sess.threads.get_mut(&thread_id)
|
||||
{
|
||||
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.
|
||||
// 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.
|
||||
if query.thread_id.is_some() {
|
||||
if let Some(ref store) = state.store {
|
||||
let owned = store
|
||||
.conversation_belongs_to_user(thread_id, &state.user_id)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !owned && !sess.threads.contains_key(&thread_id) {
|
||||
return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
|
||||
}
|
||||
if query.thread_id.is_some()
|
||||
&& let Some(ref store) = state.store
|
||||
{
|
||||
let owned = store
|
||||
.conversation_belongs_to_user(thread_id, &state.user_id)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
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
|
||||
if before_cursor.is_some() {
|
||||
if let Some(ref store) = state.store {
|
||||
let (messages, has_more) = store
|
||||
.list_conversation_messages_paginated(thread_id, before_cursor, limit as i64)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
if before_cursor.is_some()
|
||||
&& let Some(ref store) = state.store
|
||||
{
|
||||
let (messages, has_more) = store
|
||||
.list_conversation_messages_paginated(thread_id, before_cursor, limit as i64)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339());
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
return Ok(Json(HistoryResponse {
|
||||
thread_id,
|
||||
turns,
|
||||
has_more,
|
||||
oldest_timestamp,
|
||||
}));
|
||||
}
|
||||
let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339());
|
||||
let turns = build_turns_from_db_messages(&messages);
|
||||
return Ok(Json(HistoryResponse {
|
||||
thread_id,
|
||||
turns,
|
||||
has_more,
|
||||
oldest_timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
// Try in-memory first (freshest data for active threads)
|
||||
if let Some(thread) = sess.threads.get(&thread_id) {
|
||||
if !thread.turns.is_empty() {
|
||||
let turns: Vec<TurnInfo> = thread
|
||||
.turns
|
||||
.iter()
|
||||
.map(|t| TurnInfo {
|
||||
turn_number: t.turn_number,
|
||||
user_input: t.user_input.clone(),
|
||||
response: t.response.clone(),
|
||||
state: format!("{:?}", t.state),
|
||||
started_at: t.started_at.to_rfc3339(),
|
||||
completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
tool_calls: t
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| ToolCallInfo {
|
||||
name: tc.name.clone(),
|
||||
has_result: tc.result.is_some(),
|
||||
has_error: tc.error.is_some(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
if let Some(thread) = sess.threads.get(&thread_id)
|
||||
&& !thread.turns.is_empty()
|
||||
{
|
||||
let turns: Vec<TurnInfo> = thread
|
||||
.turns
|
||||
.iter()
|
||||
.map(|t| TurnInfo {
|
||||
turn_number: t.turn_number,
|
||||
user_input: t.user_input.clone(),
|
||||
response: t.response.clone(),
|
||||
state: format!("{:?}", t.state),
|
||||
started_at: t.started_at.to_rfc3339(),
|
||||
completed_at: t.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
tool_calls: t
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| ToolCallInfo {
|
||||
name: tc.name.clone(),
|
||||
has_result: tc.result.is_some(),
|
||||
has_error: tc.error.is_some(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(Json(HistoryResponse {
|
||||
thread_id,
|
||||
turns,
|
||||
has_more: false,
|
||||
oldest_timestamp: None,
|
||||
}));
|
||||
}
|
||||
return Ok(Json(HistoryResponse {
|
||||
thread_id,
|
||||
turns,
|
||||
has_more: false,
|
||||
oldest_timestamp: None,
|
||||
}));
|
||||
}
|
||||
|
||||
// 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
|
||||
if let Some(next) = iter.peek() {
|
||||
if next.role == "assistant" {
|
||||
let assistant_msg = iter.next().expect("peeked");
|
||||
turn.response = Some(assistant_msg.content.clone());
|
||||
turn.completed_at = Some(assistant_msg.created_at.to_rfc3339());
|
||||
}
|
||||
if let Some(next) = iter.peek()
|
||||
&& next.role == "assistant"
|
||||
{
|
||||
let assistant_msg = iter.next().expect("peeked");
|
||||
turn.response = Some(assistant_msg.content.clone());
|
||||
turn.completed_at = Some(assistant_msg.created_at.to_rfc3339());
|
||||
}
|
||||
|
||||
// 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()))?;
|
||||
|
||||
// Try sandbox job from DB first, scoped to the authenticated user.
|
||||
if let Some(ref store) = state.store {
|
||||
if 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()));
|
||||
}
|
||||
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,
|
||||
}));
|
||||
if let Some(ref store) = state.store
|
||||
&& 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()));
|
||||
}
|
||||
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()))
|
||||
@@ -1198,35 +1198,35 @@ async fn jobs_cancel_handler(
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Try sandbox job cancellation, scoped to the authenticated user.
|
||||
if let Some(ref store) = state.store {
|
||||
if 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.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 let Some(ref store) = state.store
|
||||
&& 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.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()))
|
||||
@@ -1334,14 +1334,13 @@ async fn jobs_prompt_handler(
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if let Some(ref store) = state.store {
|
||||
if !store
|
||||
if let Some(ref store) = state.store
|
||||
&& !store
|
||||
.sandbox_job_belongs_to_user(job_id, &state.user_id)
|
||||
.await
|
||||
.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
|
||||
|
||||
+4
-4
@@ -107,10 +107,10 @@ async fn list_settings(
|
||||
println!();
|
||||
|
||||
for (key, value) in all {
|
||||
if let Some(ref f) = filter {
|
||||
if !key.starts_with(f) {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref f) = filter
|
||||
&& !key.starts_with(f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let display_value = if value.len() > 60 {
|
||||
|
||||
+63
-66
@@ -420,11 +420,11 @@ async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result<String> {
|
||||
// Simple TOML parsing for [package] name
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("name") {
|
||||
if let Some((_, value)) = line.split_once('=') {
|
||||
let name = value.trim().trim_matches('"').trim_matches('\'');
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
if line.starts_with("name")
|
||||
&& let Some((_, value)) = line.split_once('=')
|
||||
{
|
||||
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 {
|
||||
let caps_path = path.with_extension("capabilities.json");
|
||||
if let Ok(content) = fs::read_to_string(&caps_path).await {
|
||||
if let Ok(caps) = CapabilitiesFile::from_json(&content) {
|
||||
print_capabilities_summary(&caps);
|
||||
}
|
||||
if let Ok(content) = fs::read_to_string(&caps_path).await
|
||||
&& let Ok(caps) = CapabilitiesFile::from_json(&content)
|
||||
{
|
||||
print_capabilities_summary(&caps);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
@@ -604,16 +604,16 @@ fn print_capabilities_summary(caps: &CapabilitiesFile) {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref secrets) = caps.secrets {
|
||||
if !secrets.allowed_names.is_empty() {
|
||||
parts.push(format!("secrets: {}", secrets.allowed_names.len()));
|
||||
}
|
||||
if let Some(ref secrets) = caps.secrets
|
||||
&& !secrets.allowed_names.is_empty()
|
||||
{
|
||||
parts.push(format!("secrets: {}", secrets.allowed_names.len()));
|
||||
}
|
||||
|
||||
if let Some(ref ws) = caps.workspace {
|
||||
if !ws.allowed_prefixes.is_empty() {
|
||||
parts.push("workspace: read".to_string());
|
||||
}
|
||||
if let Some(ref ws) = caps.workspace
|
||||
&& !ws.allowed_prefixes.is_empty()
|
||||
{
|
||||
parts.push("workspace: read".to_string());
|
||||
}
|
||||
|
||||
if !parts.is_empty() {
|
||||
@@ -650,30 +650,30 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref secrets) = caps.secrets {
|
||||
if !secrets.allowed_names.is_empty() {
|
||||
println!(" Secrets (existence check only):");
|
||||
for name in &secrets.allowed_names {
|
||||
println!(" {}", name);
|
||||
}
|
||||
if let Some(ref secrets) = caps.secrets
|
||||
&& !secrets.allowed_names.is_empty()
|
||||
{
|
||||
println!(" Secrets (existence check only):");
|
||||
for name in &secrets.allowed_names {
|
||||
println!(" {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref tool_invoke) = caps.tool_invoke {
|
||||
if !tool_invoke.aliases.is_empty() {
|
||||
println!(" Tool aliases:");
|
||||
for (alias, real_name) in &tool_invoke.aliases {
|
||||
println!(" {} -> {}", alias, real_name);
|
||||
}
|
||||
if let Some(ref tool_invoke) = caps.tool_invoke
|
||||
&& !tool_invoke.aliases.is_empty()
|
||||
{
|
||||
println!(" Tool aliases:");
|
||||
for (alias, real_name) in &tool_invoke.aliases {
|
||||
println!(" {} -> {}", alias, real_name);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref ws) = caps.workspace {
|
||||
if !ws.allowed_prefixes.is_empty() {
|
||||
println!(" Workspace read prefixes:");
|
||||
for prefix in &ws.allowed_prefixes {
|
||||
println!(" {}", prefix);
|
||||
}
|
||||
if let Some(ref ws) = caps.workspace
|
||||
&& !ws.allowed_prefixes.is_empty()
|
||||
{
|
||||
println!(" Workspace read prefixes:");
|
||||
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
|
||||
if let Some(ref env_var) = auth.env_var {
|
||||
if let Ok(token) = std::env::var(env_var) {
|
||||
if !token.is_empty() {
|
||||
println!(" Found {} in environment.", env_var);
|
||||
println!();
|
||||
if let Some(ref env_var) = auth.env_var
|
||||
&& let Ok(token) = std::env::var(env_var)
|
||||
&& !token.is_empty()
|
||||
{
|
||||
println!(" Found {} in environment.", env_var);
|
||||
println!();
|
||||
|
||||
// Validate if endpoint is provided
|
||||
if let Some(ref validation) = auth.validation_endpoint {
|
||||
print!(" Validating token...");
|
||||
std::io::stdout().flush()?;
|
||||
// Validate if endpoint is provided
|
||||
if let Some(ref validation) = auth.validation_endpoint {
|
||||
print!(" Validating token...");
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
match validate_token(&token, validation, &auth.secret_name).await {
|
||||
Ok(()) => {
|
||||
println!(" ✓");
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" ✗");
|
||||
println!(" Validation failed: {}", e);
|
||||
println!();
|
||||
println!(" Falling back to manual entry...");
|
||||
return auth_tool_manual(&secrets_store, &user_id, &auth).await;
|
||||
}
|
||||
}
|
||||
match validate_token(&token, validation, &auth.secret_name).await {
|
||||
Ok(()) => {
|
||||
println!(" ✓");
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" ✗");
|
||||
println!(" Validation failed: {}", e);
|
||||
println!();
|
||||
println!(" Falling back to manual entry...");
|
||||
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
|
||||
@@ -923,9 +922,9 @@ async fn auth_tool_oauth(
|
||||
reader.read_line(&mut request_line).await?;
|
||||
|
||||
// Parse GET /callback?code=xxx HTTP/1.1
|
||||
if let Some(path) = request_line.split_whitespace().nth(1) {
|
||||
if path.starts_with("/callback") {
|
||||
if let Some(query) = path.split('?').nth(1) {
|
||||
if let Some(path) = request_line.split_whitespace().nth(1)
|
||||
&& path.starts_with("/callback")
|
||||
&& let Some(query) = path.split('?').nth(1) {
|
||||
for param in query.split('&') {
|
||||
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
||||
if parts.len() == 2 && parts[0] == "code" {
|
||||
@@ -962,8 +961,6 @@ async fn auth_tool_oauth(
|
||||
return Err(anyhow::anyhow!("Authorization denied by user"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
|
||||
+14
-14
@@ -107,13 +107,13 @@ impl TunnelConfig {
|
||||
let public_url = optional_env("TUNNEL_URL")?
|
||||
.or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty()));
|
||||
|
||||
if let Some(ref url) = public_url {
|
||||
if !url.starts_with("https://") {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "TUNNEL_URL".to_string(),
|
||||
message: "must start with https:// (webhooks require HTTPS)".to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(ref url) = public_url
|
||||
&& !url.starts_with("https://")
|
||||
{
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "TUNNEL_URL".to_string(),
|
||||
message: "must start with https:// (webhooks require HTTPS)".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self { public_url })
|
||||
@@ -806,13 +806,13 @@ impl SecretsConfig {
|
||||
|
||||
let enabled = master_key.is_some();
|
||||
|
||||
if let Some(ref key) = master_key {
|
||||
if key.expose_secret().len() < 32 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "SECRETS_MASTER_KEY".to_string(),
|
||||
message: "must be at least 32 bytes for AES-256-GCM".to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(ref key) = master_key
|
||||
&& key.expose_secret().len() < 32
|
||||
{
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "SECRETS_MASTER_KEY".to_string(),
|
||||
message: "must be at least 32 bytes for AES-256-GCM".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
|
||||
@@ -144,12 +144,11 @@ impl SuccessEvaluator for RuleBasedEvaluator {
|
||||
|
||||
// Check for critical errors
|
||||
for action in actions.iter().filter(|a| !a.success) {
|
||||
if let Some(ref error) = action.error {
|
||||
if error.to_lowercase().contains("critical")
|
||||
|| error.to_lowercase().contains("fatal")
|
||||
{
|
||||
issues.push(format!("Critical error in {}: {}", action.tool_name, error));
|
||||
}
|
||||
if let Some(ref error) = action.error
|
||||
&& (error.to_lowercase().contains("critical")
|
||||
|| error.to_lowercase().contains("fatal"))
|
||||
{
|
||||
issues.push(format!("Critical error in {}: {}", action.tool_name, error));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-27
@@ -490,13 +490,13 @@ impl ExtensionManager {
|
||||
}
|
||||
|
||||
// Check Content-Length header before downloading the full body
|
||||
if let Some(len) = response.content_length() {
|
||||
if len as usize > MAX_WASM_SIZE {
|
||||
return Err(ExtensionError::InstallFailed(format!(
|
||||
"WASM binary too large ({} bytes, max {} bytes)",
|
||||
len, MAX_WASM_SIZE
|
||||
)));
|
||||
}
|
||||
if let Some(len) = response.content_length()
|
||||
&& len as usize > MAX_WASM_SIZE
|
||||
{
|
||||
return Err(ExtensionError::InstallFailed(format!(
|
||||
"WASM binary too large ({} bytes, max {} bytes)",
|
||||
len, MAX_WASM_SIZE
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
@@ -766,27 +766,27 @@ impl ExtensionManager {
|
||||
};
|
||||
|
||||
// Check env var first
|
||||
if let Some(ref env_var) = auth.env_var {
|
||||
if let Ok(value) = std::env::var(env_var) {
|
||||
// Store the env var value as a secret
|
||||
let params = CreateSecretParams::new(&auth.secret_name, &value)
|
||||
.with_provider(name.to_string());
|
||||
self.secrets
|
||||
.create(&self.user_id, params)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||
if let Some(ref env_var) = auth.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).with_provider(name.to_string());
|
||||
self.secrets
|
||||
.create(&self.user_id, params)
|
||||
.await
|
||||
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
||||
|
||||
return Ok(AuthResult {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::WasmTool,
|
||||
auth_url: None,
|
||||
callback_type: None,
|
||||
instructions: None,
|
||||
setup_url: None,
|
||||
awaiting_token: false,
|
||||
status: "authenticated".to_string(),
|
||||
});
|
||||
}
|
||||
return Ok(AuthResult {
|
||||
name: name.to_string(),
|
||||
kind: ExtensionKind::WasmTool,
|
||||
auth_url: None,
|
||||
callback_type: None,
|
||||
instructions: None,
|
||||
setup_url: None,
|
||||
awaiting_token: false,
|
||||
status: "authenticated".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already authenticated
|
||||
|
||||
+27
-27
@@ -209,20 +209,20 @@ impl NearAiProvider {
|
||||
data: Option<Vec<ModelEntry>>,
|
||||
}
|
||||
|
||||
if let Ok(resp) = serde_json::from_str::<ModelsResponse>(&response_text) {
|
||||
if let Some(entries) = resp.models.or(resp.data) {
|
||||
let models: Vec<ModelInfo> = entries
|
||||
.into_iter()
|
||||
.filter_map(|e| {
|
||||
e.get_name().map(|name| ModelInfo {
|
||||
name,
|
||||
provider: None,
|
||||
})
|
||||
if let Ok(resp) = serde_json::from_str::<ModelsResponse>(&response_text)
|
||||
&& let Some(entries) = resp.models.or(resp.data)
|
||||
{
|
||||
let models: Vec<ModelInfo> = entries
|
||||
.into_iter()
|
||||
.filter_map(|e| {
|
||||
e.get_name().map(|name| ModelInfo {
|
||||
name,
|
||||
provider: None,
|
||||
})
|
||||
.collect();
|
||||
if !models.is_empty() {
|
||||
return Ok(models);
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !models.is_empty() {
|
||||
return Ok(models);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,21 +694,21 @@ impl LlmProvider for NearAiProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if item.item_type == "function_call" {
|
||||
if let (Some(name), Some(call_id)) = (&item.name, &item.call_id) {
|
||||
// Parse arguments JSON string into Value
|
||||
let arguments = item
|
||||
.arguments
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
} else if item.item_type == "function_call"
|
||||
&& let (Some(name), Some(call_id)) = (&item.name, &item.call_id)
|
||||
{
|
||||
// Parse arguments JSON string into Value
|
||||
let arguments = item
|
||||
.arguments
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
|
||||
tool_calls.push(ToolCall {
|
||||
id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
arguments,
|
||||
});
|
||||
}
|
||||
tool_calls.push(ToolCall {
|
||||
id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
arguments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -395,10 +395,10 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
|
||||
if let (true, Some(calls)) = (msg.role == "assistant", &msg.tool_calls) {
|
||||
// Convert assistant tool_calls into descriptive text
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(ref text) = msg.content {
|
||||
if !text.is_empty() {
|
||||
parts.push(text.clone());
|
||||
}
|
||||
if let Some(ref text) = msg.content
|
||||
&& !text.is_empty()
|
||||
{
|
||||
parts.push(text.clone());
|
||||
}
|
||||
for tc in calls {
|
||||
parts.push(format!(
|
||||
|
||||
+14
-15
@@ -581,21 +581,20 @@ fn recover_tool_calls_from_content(
|
||||
}
|
||||
|
||||
// Try JSON first: {"name":"x","arguments":{}}
|
||||
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()) {
|
||||
if tool_names.contains(name) {
|
||||
let arguments = parsed
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", calls.len()),
|
||||
name: name.to_string(),
|
||||
arguments,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(inner)
|
||||
&& let Some(name) = parsed.get("name").and_then(|v| v.as_str())
|
||||
&& tool_names.contains(name)
|
||||
{
|
||||
let arguments = parsed
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
calls.push(ToolCall {
|
||||
id: format!("recovered_{}", calls.len()),
|
||||
name: name.to_string(),
|
||||
arguments,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bare tool name (e.g. "<tool_call>tool_list</tool_call>")
|
||||
|
||||
+20
-22
@@ -83,16 +83,16 @@ impl SessionManager {
|
||||
};
|
||||
|
||||
// Try to load existing session synchronously during construction
|
||||
if let Ok(data) = std::fs::read_to_string(&manager.config.session_path) {
|
||||
if 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() {
|
||||
*guard = Some(SecretString::from(session.session_token));
|
||||
tracing::info!(
|
||||
"Loaded session token from {}",
|
||||
manager.config.session_path.display()
|
||||
);
|
||||
}
|
||||
if let Ok(data) = std::fs::read_to_string(&manager.config.session_path)
|
||||
&& 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() {
|
||||
*guard = Some(SecretString::from(session.session_token));
|
||||
tracing::info!(
|
||||
"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
|
||||
if let Some(path) = request_line.split_whitespace().nth(1) {
|
||||
if path.starts_with("/auth/callback") {
|
||||
if let Some(path) = request_line.split_whitespace().nth(1)
|
||||
&& path.starts_with("/auth/callback") {
|
||||
// Parse query parameters
|
||||
if let Some(query) = path.split('?').nth(1) {
|
||||
let mut token = None;
|
||||
@@ -453,7 +453,6 @@ impl SessionManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not the callback we're looking for, send 404
|
||||
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;
|
||||
|
||||
// Check for legacy env var and migrate if present and no file token
|
||||
if !manager.has_token().await {
|
||||
if let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN") {
|
||||
if !token.is_empty() {
|
||||
tracing::info!("Migrating session token from NEARAI_SESSION_TOKEN env var to file");
|
||||
manager.set_token(SecretString::from(token.clone())).await;
|
||||
if let Err(e) = manager.save_session(&token, None).await {
|
||||
tracing::warn!("Failed to save migrated session: {}", e);
|
||||
}
|
||||
}
|
||||
if !manager.has_token().await
|
||||
&& let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN")
|
||||
&& !token.is_empty()
|
||||
{
|
||||
tracing::info!("Migrating session token from NEARAI_SESSION_TOKEN env var to file");
|
||||
manager.set_token(SecretString::from(token.clone())).await;
|
||||
if let Err(e) = manager.save_session(&token, None).await {
|
||||
tracing::warn!("Failed to save migrated session: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+31
-31
@@ -255,13 +255,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
// Enhanced first-run detection
|
||||
if !cli.no_onboard {
|
||||
if let Some(reason) = check_onboard_needed().await {
|
||||
println!("Onboarding needed: {}", reason);
|
||||
println!();
|
||||
let mut wizard = SetupWizard::new();
|
||||
wizard.run().await?;
|
||||
}
|
||||
if !cli.no_onboard
|
||||
&& let Some(reason) = check_onboard_needed().await
|
||||
{
|
||||
println!("Onboarding needed: {}", reason);
|
||||
println!();
|
||||
let mut wizard = SetupWizard::new();
|
||||
wizard.run().await?;
|
||||
}
|
||||
|
||||
// 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
|
||||
// to the bound user account.
|
||||
if channel_name == "telegram" {
|
||||
if let Some(owner_id) = config.channels.telegram_owner_id {
|
||||
config_updates.insert(
|
||||
"owner_id".to_string(),
|
||||
serde_json::json!(owner_id),
|
||||
);
|
||||
}
|
||||
if channel_name == "telegram"
|
||||
&& let Some(owner_id) = config.channels.telegram_owner_id
|
||||
{
|
||||
config_updates.insert(
|
||||
"owner_id".to_string(),
|
||||
serde_json::json!(owner_id),
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
// provides the mpsc stream.
|
||||
let mut webhook_server_addr: Option<std::net::SocketAddr> = None;
|
||||
if !cli.cli_only {
|
||||
if let Some(ref http_config) = config.channels.http {
|
||||
let http_channel = HttpChannel::new(http_config.clone());
|
||||
webhook_routes.push(http_channel.routes());
|
||||
let (host, port) = http_channel.addr();
|
||||
webhook_server_addr = Some(
|
||||
format!("{}:{}", host, port)
|
||||
.parse()
|
||||
.expect("HttpConfig host:port must be a valid SocketAddr"),
|
||||
);
|
||||
channels.add(Box::new(http_channel));
|
||||
tracing::info!(
|
||||
"HTTP channel enabled on {}:{}",
|
||||
http_config.host,
|
||||
http_config.port
|
||||
);
|
||||
}
|
||||
if !cli.cli_only
|
||||
&& let Some(ref http_config) = config.channels.http
|
||||
{
|
||||
let http_channel = HttpChannel::new(http_config.clone());
|
||||
webhook_routes.push(http_channel.routes());
|
||||
let (host, port) = http_channel.addr();
|
||||
webhook_server_addr = Some(
|
||||
format!("{}:{}", host, port)
|
||||
.parse()
|
||||
.expect("HttpConfig host:port must be a valid SocketAddr"),
|
||||
);
|
||||
channels.add(Box::new(http_channel));
|
||||
tracing::info!(
|
||||
"HTTP channel enabled on {}:{}",
|
||||
http_config.host,
|
||||
http_config.port
|
||||
);
|
||||
}
|
||||
|
||||
// Start the unified webhook server if any routes were registered.
|
||||
|
||||
+10
-10
@@ -339,16 +339,16 @@ async fn get_prompt_handler(
|
||||
Path(job_id): Path<Uuid>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), StatusCode> {
|
||||
let mut queue = state.prompt_queue.lock().await;
|
||||
if let Some(prompts) = queue.get_mut(&job_id) {
|
||||
if let Some(prompt) = prompts.pop_front() {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"content": prompt.content,
|
||||
"done": prompt.done,
|
||||
})),
|
||||
));
|
||||
}
|
||||
if let Some(prompts) = queue.get_mut(&job_id)
|
||||
&& let Some(prompt) = prompts.pop_front()
|
||||
{
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"content": prompt.content,
|
||||
"done": prompt.done,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
// Return 204 with an empty body. The Json wrapper requires some value
|
||||
|
||||
@@ -229,17 +229,17 @@ impl ContainerJobManager {
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ironclaw")
|
||||
.join("projects");
|
||||
if let Ok(canonical_base) = projects_base.canonicalize() {
|
||||
if !canonical.starts_with(&canonical_base) {
|
||||
return Err(OrchestratorError::ContainerCreationFailed {
|
||||
job_id,
|
||||
reason: format!(
|
||||
"project directory {} is outside allowed base {}",
|
||||
canonical.display(),
|
||||
canonical_base.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
if let Ok(canonical_base) = projects_base.canonicalize()
|
||||
&& !canonical.starts_with(&canonical_base)
|
||||
{
|
||||
return Err(OrchestratorError::ContainerCreationFailed {
|
||||
job_id,
|
||||
reason: format!(
|
||||
"project directory {} is outside allowed base {}",
|
||||
canonical.display(),
|
||||
canonical_base.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
binds.push(format!("{}:/workspace:rw", canonical.display()));
|
||||
env_vec.push("IRONCLAW_WORKSPACE=/workspace".to_string());
|
||||
@@ -442,36 +442,36 @@ impl ContainerJobManager {
|
||||
let containers = self.containers.read().await;
|
||||
containers.get(&job_id).map(|h| h.container_id.clone())
|
||||
};
|
||||
if let Some(cid) = container_id {
|
||||
if !cid.is_empty() {
|
||||
match connect_docker().await {
|
||||
Ok(docker) => {
|
||||
if let Err(e) = docker
|
||||
.stop_container(
|
||||
&cid,
|
||||
Some(bollard::container::StopContainerOptions { t: 5 }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
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");
|
||||
}
|
||||
if let Some(cid) = container_id
|
||||
&& !cid.is_empty()
|
||||
{
|
||||
match connect_docker().await {
|
||||
Ok(docker) => {
|
||||
if let Err(e) = docker
|
||||
.stop_container(
|
||||
&cid,
|
||||
Some(bollard::container::StopContainerOptions { t: 5 }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(job_id = %job_id, error = %e, "Failed to stop completed container");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(job_id = %job_id, error = %e, "Failed to connect to Docker for container cleanup");
|
||||
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) => {
|
||||
tracing::warn!(job_id = %job_id, error = %e, "Failed to connect to Docker for container cleanup");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.token_store.revoke(job_id).await;
|
||||
|
||||
@@ -147,10 +147,10 @@ impl LeakDetector {
|
||||
// Build prefix matcher for patterns that start with a known prefix
|
||||
let mut prefixes = Vec::new();
|
||||
for (idx, pattern) in patterns.iter().enumerate() {
|
||||
if let Some(prefix) = extract_literal_prefix(pattern.regex.as_str()) {
|
||||
if prefix.len() >= 3 {
|
||||
prefixes.push((prefix, idx));
|
||||
}
|
||||
if let Some(prefix) = extract_literal_prefix(pattern.regex.as_str())
|
||||
&& prefix.len() >= 3
|
||||
{
|
||||
prefixes.push((prefix, idx));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -494,10 +494,10 @@ impl ContainerRunner {
|
||||
/// 3. `~/.docker/run/docker.sock` (Docker Desktop on macOS)
|
||||
pub async fn connect_docker() -> Result<Docker> {
|
||||
// First try bollard defaults (checks DOCKER_HOST, then /var/run/docker.sock)
|
||||
if let Ok(docker) = Docker::connect_with_local_defaults() {
|
||||
if docker.ping().await.is_ok() {
|
||||
return Ok(docker);
|
||||
}
|
||||
if let Ok(docker) = Docker::connect_with_local_defaults()
|
||||
&& docker.ping().await.is_ok()
|
||||
{
|
||||
return Ok(docker);
|
||||
}
|
||||
|
||||
// Try Docker Desktop socket (macOS)
|
||||
@@ -507,10 +507,9 @@ pub async fn connect_docker() -> Result<Docker> {
|
||||
let sock_str = desktop_sock.to_string_lossy();
|
||||
if let Ok(docker) =
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,11 +259,11 @@ async fn handle_connect(
|
||||
|
||||
let decision = state.decider.decide(&network_req).await;
|
||||
|
||||
if !decision.is_allowed() {
|
||||
if let NetworkDecision::Deny { reason } = decision {
|
||||
tracing::info!("Proxy: blocked CONNECT {} - {}", host, reason);
|
||||
return error_response(StatusCode::FORBIDDEN, reason);
|
||||
}
|
||||
if !decision.is_allowed()
|
||||
&& let NetworkDecision::Deny { reason } = decision
|
||||
{
|
||||
tracing::info!("Proxy: blocked CONNECT {} - {}", host, reason);
|
||||
return error_response(StatusCode::FORBIDDEN, reason);
|
||||
}
|
||||
|
||||
tracing::debug!("Proxy: allowing CONNECT to {}", host);
|
||||
@@ -294,10 +294,10 @@ async fn forward_request(
|
||||
|
||||
// Copy headers (except hop-by-hop headers)
|
||||
for (name, value) in req.headers() {
|
||||
if !is_hop_by_hop_header(name.as_str()) {
|
||||
if let Ok(v) = value.to_str() {
|
||||
builder = builder.header(name.as_str(), v);
|
||||
}
|
||||
if !is_hop_by_hop_header(name.as_str())
|
||||
&& let Ok(v) = value.to_str()
|
||||
{
|
||||
builder = builder.header(name.as_str(), v);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,12 +109,11 @@ impl NetworkPolicyDecider for DefaultPolicyDecider {
|
||||
async fn decide(&self, request: &NetworkRequest) -> NetworkDecision {
|
||||
// First check if the domain is allowed
|
||||
let validation = self.allowlist.is_allowed(&request.host);
|
||||
if !validation.is_allowed() {
|
||||
if let crate::sandbox::proxy::allowlist::DomainValidationResult::Denied(reason) =
|
||||
if !validation.is_allowed()
|
||||
&& let crate::sandbox::proxy::allowlist::DomainValidationResult::Denied(reason) =
|
||||
validation
|
||||
{
|
||||
return NetworkDecision::Deny { reason };
|
||||
}
|
||||
{
|
||||
return NetworkDecision::Deny { reason };
|
||||
}
|
||||
|
||||
// Check if we need to inject credentials
|
||||
|
||||
@@ -261,7 +261,7 @@ pub use platform::{delete_master_key, get_master_key, has_master_key, store_mast
|
||||
|
||||
/// Parse a hex string to bytes.
|
||||
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(
|
||||
"Invalid hex string length".to_string(),
|
||||
));
|
||||
|
||||
+12
-12
@@ -149,10 +149,10 @@ impl SecretsStore for PostgresSecretsStore {
|
||||
let secret = row_to_secret(&r);
|
||||
|
||||
// Check expiration
|
||||
if let Some(expires_at) = secret.expires_at {
|
||||
if expires_at < Utc::now() {
|
||||
return Err(SecretError::Expired);
|
||||
}
|
||||
if let Some(expires_at) = secret.expires_at
|
||||
&& expires_at < Utc::now()
|
||||
{
|
||||
return Err(SecretError::Expired);
|
||||
}
|
||||
|
||||
Ok(secret)
|
||||
@@ -272,10 +272,10 @@ impl SecretsStore for PostgresSecretsStore {
|
||||
}
|
||||
|
||||
// Simple glob: * matches any suffix
|
||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
||||
if secret_name.starts_with(prefix) {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(prefix) = pattern.strip_suffix('*')
|
||||
&& secret_name.starts_with(prefix)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,10 +430,10 @@ pub mod testing {
|
||||
if pattern == secret_name {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
||||
if secret_name.starts_with(prefix) {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(prefix) = pattern.strip_suffix('*')
|
||||
&& secret_name.starts_with(prefix)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
|
||||
+23
-23
@@ -252,32 +252,32 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
|
||||
|
||||
// Find the first message with a sender
|
||||
for update in &body.result {
|
||||
if let Some(ref msg) = update.message {
|
||||
if let Some(ref from) = msg.from {
|
||||
let display_name = from
|
||||
.username
|
||||
.as_ref()
|
||||
.map(|u| format!("@{}", u))
|
||||
.unwrap_or_else(|| from.first_name.clone());
|
||||
if let Some(ref msg) = update.message
|
||||
&& let Some(ref from) = msg.from
|
||||
{
|
||||
let display_name = from
|
||||
.username
|
||||
.as_ref()
|
||||
.map(|u| format!("@{}", u))
|
||||
.unwrap_or_else(|| from.first_name.clone());
|
||||
|
||||
print_success(&format!(
|
||||
"Received message from {} (ID: {})",
|
||||
display_name, from.id
|
||||
));
|
||||
print_success(&format!(
|
||||
"Received message from {} (ID: {})",
|
||||
display_name, from.id
|
||||
));
|
||||
|
||||
// Acknowledge the update so it doesn't pile up
|
||||
let ack_url = format!(
|
||||
"https://api.telegram.org/bot{}/getUpdates",
|
||||
token.expose_secret()
|
||||
);
|
||||
let _ = client
|
||||
.get(&ack_url)
|
||||
.query(&[("offset", &(update.update_id + 1).to_string())])
|
||||
.send()
|
||||
.await;
|
||||
// Acknowledge the update so it doesn't pile up
|
||||
let ack_url = format!(
|
||||
"https://api.telegram.org/bot{}/getUpdates",
|
||||
token.expose_secret()
|
||||
);
|
||||
let _ = client
|
||||
.get(&ack_url)
|
||||
.query(&[("offset", &(update.update_id + 1).to_string())])
|
||||
.send()
|
||||
.await;
|
||||
|
||||
return Ok(Some(from.id));
|
||||
}
|
||||
return Ok(Some(from.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +54,11 @@ pub fn select_one(prompt: &str, options: &[&str]) -> io::Result<usize> {
|
||||
}
|
||||
|
||||
// Parse number
|
||||
if let Ok(num) = input.parse::<usize>() {
|
||||
if num >= 1 && num <= options.len() {
|
||||
return Ok(num - 1);
|
||||
}
|
||||
if let Ok(num) = input.parse::<usize>()
|
||||
&& num >= 1
|
||||
&& num <= options.len()
|
||||
{
|
||||
return Ok(num - 1);
|
||||
}
|
||||
|
||||
writeln!(
|
||||
|
||||
+14
-15
@@ -344,17 +344,17 @@ impl SetupWizard {
|
||||
/// Step 3: NEAR AI authentication.
|
||||
async fn step_authentication(&mut self) -> Result<(), SetupError> {
|
||||
// Check if we already have a session
|
||||
if let Some(ref session) = self.session_manager {
|
||||
if session.has_token().await {
|
||||
print_info("Existing session found. Validating...");
|
||||
match session.ensure_authenticated().await {
|
||||
Ok(()) => {
|
||||
print_success("Session valid");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
print_info(&format!("Session invalid: {}. Re-authenticating...", e));
|
||||
}
|
||||
if let Some(ref session) = self.session_manager
|
||||
&& session.has_token().await
|
||||
{
|
||||
print_info("Existing session found. Validating...");
|
||||
match session.ensure_authenticated().await {
|
||||
Ok(()) => {
|
||||
print_success("Session valid");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
print_info(&format!("Session invalid: {}. Re-authenticating...", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,11 +642,10 @@ impl SetupWizard {
|
||||
&installed_names,
|
||||
)
|
||||
.await?
|
||||
&& !installed.is_empty()
|
||||
{
|
||||
if !installed.is_empty() {
|
||||
print_success(&format!("Installed channels: {}", installed.join(", ")));
|
||||
discovered_channels = discover_wasm_channels(&channels_dir).await;
|
||||
}
|
||||
print_success(&format!("Installed channels: {}", installed.join(", ")));
|
||||
discovered_channels = discover_wasm_channels(&channels_dir).await;
|
||||
}
|
||||
|
||||
// Determine if we need secrets context
|
||||
|
||||
@@ -326,20 +326,20 @@ impl TestHarness {
|
||||
}
|
||||
|
||||
// Verify expected output
|
||||
if let Some(ref expected) = test.expected_output {
|
||||
if &actual != expected {
|
||||
return TestResult {
|
||||
name: test.name.clone(),
|
||||
passed: false,
|
||||
duration,
|
||||
error: Some(format!(
|
||||
"Output mismatch:\nExpected: {}\nActual: {}",
|
||||
serde_json::to_string_pretty(expected).unwrap_or_default(),
|
||||
serde_json::to_string_pretty(&actual).unwrap_or_default()
|
||||
)),
|
||||
actual_output: Some(actual),
|
||||
};
|
||||
}
|
||||
if let Some(ref expected) = test.expected_output
|
||||
&& &actual != expected
|
||||
{
|
||||
return TestResult {
|
||||
name: test.name.clone(),
|
||||
passed: false,
|
||||
duration,
|
||||
error: Some(format!(
|
||||
"Output mismatch:\nExpected: {}\nActual: {}",
|
||||
serde_json::to_string_pretty(expected).unwrap_or_default(),
|
||||
serde_json::to_string_pretty(&actual).unwrap_or_default()
|
||||
)),
|
||||
actual_output: Some(actual),
|
||||
};
|
||||
}
|
||||
|
||||
// Verify expected fields
|
||||
@@ -357,19 +357,19 @@ impl TestHarness {
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(ref expected_value) = field.value {
|
||||
if field_value != Some(expected_value) {
|
||||
return TestResult {
|
||||
name: test.name.clone(),
|
||||
passed: false,
|
||||
duration,
|
||||
error: Some(format!(
|
||||
"Field '{}' mismatch: expected {:?}, got {:?}",
|
||||
field.path, expected_value, field_value
|
||||
)),
|
||||
actual_output: Some(actual),
|
||||
};
|
||||
}
|
||||
if let Some(ref expected_value) = field.value
|
||||
&& field_value != Some(expected_value)
|
||||
{
|
||||
return TestResult {
|
||||
name: test.name.clone(),
|
||||
passed: false,
|
||||
duration,
|
||||
error: Some(format!(
|
||||
"Field '{}' mismatch: expected {:?}, got {:?}",
|
||||
field.path, expected_value, field_value
|
||||
)),
|
||||
actual_output: Some(actual),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +54,12 @@ fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
|
||||
}
|
||||
|
||||
// Check literal IP addresses
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
if is_disallowed_ip(&ip) {
|
||||
return Err(ToolError::NotAuthorized(
|
||||
"private or local IPs are not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
if let Ok(ip) = host.parse::<IpAddr>()
|
||||
&& is_disallowed_ip(&ip)
|
||||
{
|
||||
return Err(ToolError::NotAuthorized(
|
||||
"private or local IPs are not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Resolve hostname and check all resolved IPs against the blocklist.
|
||||
|
||||
+12
-12
@@ -157,18 +157,18 @@ impl CreateJobTool {
|
||||
});
|
||||
|
||||
// Persist the job mode to DB
|
||||
if mode == JobMode::ClaudeCode {
|
||||
if let Some(store) = self.store.clone() {
|
||||
let job_id_copy = job_id;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = store
|
||||
.update_sandbox_job_mode(job_id_copy, "claude_code")
|
||||
.await
|
||||
{
|
||||
tracing::warn!(job_id = %job_id_copy, "Failed to set job mode: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
if mode == JobMode::ClaudeCode
|
||||
&& let Some(store) = self.store.clone()
|
||||
{
|
||||
let job_id_copy = job_id;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = store
|
||||
.update_sandbox_job_mode(job_id_copy, "claude_code")
|
||||
.await
|
||||
{
|
||||
tracing::warn!(job_id = %job_id_copy, "Failed to set job mode: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create the container job with the pre-determined job_id.
|
||||
|
||||
@@ -343,12 +343,12 @@ impl ShellTool {
|
||||
|
||||
// Use sandbox if configured; fail-closed (never silently fall through
|
||||
// to unsandboxed execution when sandbox was intended).
|
||||
if let Some(ref sandbox) = self.sandbox {
|
||||
if sandbox.is_initialized() || sandbox.config().enabled {
|
||||
return self
|
||||
.execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
|
||||
.await;
|
||||
}
|
||||
if let Some(ref sandbox) = self.sandbox
|
||||
&& (sandbox.is_initialized() || sandbox.config().enabled)
|
||||
{
|
||||
return self
|
||||
.execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Only execute directly when no sandbox was configured at all.
|
||||
|
||||
@@ -539,9 +539,9 @@ pub async fn wait_for_authorization_callback(
|
||||
.map_err(|e| AuthError::Http(e.to_string()))?;
|
||||
|
||||
// Parse GET /callback?code=xxx HTTP/1.1
|
||||
if let Some(path) = request_line.split_whitespace().nth(1) {
|
||||
if path.starts_with("/callback") {
|
||||
if let Some(query) = path.split('?').nth(1) {
|
||||
if let Some(path) = request_line.split_whitespace().nth(1)
|
||||
&& path.starts_with("/callback")
|
||||
&& let Some(query) = path.split('?').nth(1) {
|
||||
// Check for error first
|
||||
if query.contains("error=") {
|
||||
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 _ = socket.write_all(response.as_bytes()).await;
|
||||
|
||||
+35
-39
@@ -184,10 +184,10 @@ impl McpClient {
|
||||
}
|
||||
|
||||
// Add Mcp-Session-Id header if we have a session
|
||||
if let Some(ref session_manager) = self.session_manager {
|
||||
if let Some(session_id) = session_manager.get_session_id(&self.server_name).await {
|
||||
req_builder = req_builder.header("Mcp-Session-Id", session_id);
|
||||
}
|
||||
if let Some(ref session_manager) = self.session_manager
|
||||
&& let Some(session_id) = session_manager.get_session_id(&self.server_name).await
|
||||
{
|
||||
req_builder = req_builder.header("Mcp-Session-Id", session_id);
|
||||
}
|
||||
|
||||
let response = req_builder
|
||||
@@ -199,29 +199,26 @@ impl McpClient {
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
if attempt == 0 {
|
||||
// Try to refresh the token
|
||||
if let Some(ref secrets) = self.secrets {
|
||||
if let Some(ref config) = self.server_config {
|
||||
tracing::debug!(
|
||||
"MCP token expired, attempting refresh for '{}'",
|
||||
self.server_name
|
||||
);
|
||||
match refresh_access_token(config, secrets, &self.user_id).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"MCP token refreshed for '{}'",
|
||||
self.server_name
|
||||
);
|
||||
// Continue to next iteration to retry with new token
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"Token refresh failed for '{}': {}",
|
||||
self.server_name,
|
||||
e
|
||||
);
|
||||
// Fall through to return auth error
|
||||
}
|
||||
if let Some(ref secrets) = self.secrets
|
||||
&& let Some(ref config) = self.server_config
|
||||
{
|
||||
tracing::debug!(
|
||||
"MCP token expired, attempting refresh for '{}'",
|
||||
self.server_name
|
||||
);
|
||||
match refresh_access_token(config, secrets, &self.user_id).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("MCP token refreshed for '{}'", self.server_name);
|
||||
// Continue to next iteration to retry with new token
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"Token refresh failed for '{}': {}",
|
||||
self.server_name,
|
||||
e
|
||||
);
|
||||
// Fall through to return auth error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,16 +242,15 @@ impl McpClient {
|
||||
/// Parse the HTTP response into an MCP response.
|
||||
async fn parse_response(&self, response: reqwest::Response) -> Result<McpResponse, ToolError> {
|
||||
// Extract session ID from response header
|
||||
if let Some(ref session_manager) = self.session_manager {
|
||||
if let Some(session_id) = response
|
||||
if let Some(ref session_manager) = self.session_manager
|
||||
&& let Some(session_id) = response
|
||||
.headers()
|
||||
.get("Mcp-Session-Id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
session_manager
|
||||
.update_session_id(&self.server_name, Some(session_id.to_string()))
|
||||
.await;
|
||||
}
|
||||
{
|
||||
session_manager
|
||||
.update_session_id(&self.server_name, Some(session_id.to_string()))
|
||||
.await;
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
@@ -316,11 +312,11 @@ impl McpClient {
|
||||
/// This should be called once per session to establish capabilities.
|
||||
pub async fn initialize(&self) -> Result<InitializeResult, ToolError> {
|
||||
// Check if already initialized
|
||||
if let Some(ref session_manager) = self.session_manager {
|
||||
if session_manager.is_initialized(&self.server_name).await {
|
||||
// Return cached/default capabilities
|
||||
return Ok(InitializeResult::default());
|
||||
}
|
||||
if let Some(ref session_manager) = self.session_manager
|
||||
&& session_manager.is_initialized(&self.server_name).await
|
||||
{
|
||||
// Return cached/default capabilities
|
||||
return Ok(InitializeResult::default());
|
||||
}
|
||||
|
||||
// Ensure we have a session
|
||||
|
||||
@@ -96,10 +96,10 @@ impl ToolRegistry {
|
||||
if let Ok(mut tools) = self.tools.try_write() {
|
||||
tools.insert(name.clone(), tool);
|
||||
// Mark as built-in so it can't be shadowed later
|
||||
if PROTECTED_TOOL_NAMES.contains(&name.as_str()) {
|
||||
if let Ok(mut builtins) = self.builtin_names.try_write() {
|
||||
builtins.insert(name.clone());
|
||||
}
|
||||
if PROTECTED_TOOL_NAMES.contains(&name.as_str())
|
||||
&& let Ok(mut builtins) = self.builtin_names.try_write()
|
||||
{
|
||||
builtins.insert(name.clone());
|
||||
}
|
||||
tracing::debug!("Registered tool: {}", name);
|
||||
}
|
||||
|
||||
@@ -209,10 +209,10 @@ impl EndpointPattern {
|
||||
}
|
||||
|
||||
// Check path prefix
|
||||
if let Some(ref prefix) = self.path_prefix {
|
||||
if !url_path.starts_with(prefix) {
|
||||
return false;
|
||||
}
|
||||
if let Some(ref prefix) = self.path_prefix
|
||||
&& !url_path.starts_with(prefix)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check method
|
||||
@@ -237,13 +237,14 @@ impl EndpointPattern {
|
||||
}
|
||||
|
||||
// Support wildcard: *.example.com matches sub.example.com
|
||||
if let Some(suffix) = self.host.strip_prefix("*.") {
|
||||
if url_host.ends_with(suffix) && url_host.len() > suffix.len() {
|
||||
// Ensure there's a dot before the suffix (or it's the whole thing)
|
||||
let prefix = &url_host[..url_host.len() - suffix.len()];
|
||||
if prefix.ends_with('.') || prefix.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if let Some(suffix) = self.host.strip_prefix("*.")
|
||||
&& url_host.ends_with(suffix)
|
||||
&& url_host.len() > suffix.len()
|
||||
{
|
||||
// Ensure there's a dot before the suffix (or it's the whole thing)
|
||||
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 {
|
||||
return true;
|
||||
}
|
||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
||||
if name.starts_with(prefix) {
|
||||
return true;
|
||||
}
|
||||
if let Some(prefix) = pattern.strip_suffix('*')
|
||||
&& name.starts_with(prefix)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
|
||||
@@ -158,10 +158,10 @@ impl CredentialInjector {
|
||||
if pattern == name {
|
||||
return true;
|
||||
}
|
||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
||||
if name.starts_with(prefix) {
|
||||
return true;
|
||||
}
|
||||
if let Some(prefix) = pattern.strip_suffix('*')
|
||||
&& name.starts_with(prefix)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
@@ -214,12 +214,13 @@ fn host_matches_pattern(host: &str, pattern: &str) -> bool {
|
||||
}
|
||||
|
||||
// Support wildcard: *.example.com matches sub.example.com
|
||||
if let Some(suffix) = pattern.strip_prefix("*.") {
|
||||
if host.ends_with(suffix) && host.len() > suffix.len() {
|
||||
let prefix = &host[..host.len() - suffix.len()];
|
||||
if prefix.ends_with('.') || prefix.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if let Some(suffix) = pattern.strip_prefix("*.")
|
||||
&& host.ends_with(suffix)
|
||||
&& host.len() > suffix.len()
|
||||
{
|
||||
let prefix = &host[..host.len() - suffix.len()];
|
||||
if prefix.ends_with('.') || prefix.is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -259,13 +259,13 @@ impl near::agent::host::Host for StoreData {
|
||||
|
||||
// Check Content-Length header for early rejection of oversized responses.
|
||||
let max_response = max_response_bytes;
|
||||
if let Some(cl) = response.content_length() {
|
||||
if cl as usize > max_response {
|
||||
return Err(format!(
|
||||
"Response body too large: {} bytes exceeds limit of {} bytes",
|
||||
cl, max_response
|
||||
));
|
||||
}
|
||||
if let Some(cl) = response.content_length()
|
||||
&& cl as usize > max_response
|
||||
{
|
||||
return Err(format!(
|
||||
"Response body too large: {} bytes exceeds limit of {} bytes",
|
||||
cl, max_response
|
||||
));
|
||||
}
|
||||
|
||||
// Read body with a size cap to prevent memory exhaustion.
|
||||
|
||||
@@ -326,15 +326,15 @@ impl ClaudeBridgeRuntime {
|
||||
match serde_json::from_str::<ClaudeStreamEvent>(&line) {
|
||||
Ok(event) => {
|
||||
// Capture session_id from system init
|
||||
if event.event_type == "system" {
|
||||
if let Some(ref sid) = event.session_id {
|
||||
session_id = Some(sid.clone());
|
||||
tracing::info!(
|
||||
job_id = %self.config.job_id,
|
||||
session_id = %sid,
|
||||
"Captured Claude session ID"
|
||||
);
|
||||
}
|
||||
if event.event_type == "system"
|
||||
&& let Some(ref sid) = event.session_id
|
||||
{
|
||||
session_id = Some(sid.clone());
|
||||
tracing::info!(
|
||||
job_id = %self.config.job_id,
|
||||
session_id = %sid,
|
||||
"Captured Claude session ID"
|
||||
);
|
||||
}
|
||||
|
||||
// Convert to our event payload and forward
|
||||
|
||||
+13
-13
@@ -333,10 +333,10 @@ impl Workspace {
|
||||
];
|
||||
|
||||
for (path, header) in identity_files {
|
||||
if let Ok(doc) = self.read(path).await {
|
||||
if !doc.content.is_empty() {
|
||||
parts.push(format!("{}\n\n{}", header, doc.content));
|
||||
}
|
||||
if let Ok(doc) = self.read(path).await
|
||||
&& !doc.content.is_empty()
|
||||
{
|
||||
parts.push(format!("{}\n\n{}", header, doc.content));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,15 +345,15 @@ impl Workspace {
|
||||
let yesterday = today.pred_opt().unwrap_or(today);
|
||||
|
||||
for date in [today, yesterday] {
|
||||
if let Ok(doc) = self.daily_log(date).await {
|
||||
if !doc.content.is_empty() {
|
||||
let header = if date == today {
|
||||
"## Today's Notes"
|
||||
} else {
|
||||
"## Yesterday's Notes"
|
||||
};
|
||||
parts.push(format!("{}\n\n{}", header, doc.content));
|
||||
}
|
||||
if let Ok(doc) = self.daily_log(date).await
|
||||
&& !doc.content.is_empty()
|
||||
{
|
||||
let header = if date == today {
|
||||
"## Today's Notes"
|
||||
} else {
|
||||
"## Yesterday's Notes"
|
||||
};
|
||||
parts.push(format!("{}\n\n{}", header, doc.content));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -201,11 +201,11 @@ pub fn reciprocal_rank_fusion(
|
||||
.collect();
|
||||
|
||||
// Normalize scores to 0-1 range
|
||||
if let Some(max_score) = results.iter().map(|r| r.score).reduce(f32::max) {
|
||||
if max_score > 0.0 {
|
||||
for result in &mut results {
|
||||
result.score /= max_score;
|
||||
}
|
||||
if let Some(max_score) = results.iter().map(|r| r.score).reduce(f32::max)
|
||||
&& max_score > 0.0
|
||||
{
|
||||
for result in &mut results {
|
||||
result.score /= max_score;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -302,10 +302,10 @@ async fn test_chat_completions_streaming() {
|
||||
if data == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(chunk) = serde_json::from_str::<serde_json::Value>(data) {
|
||||
if let Some(content) = chunk["choices"][0]["delta"]["content"].as_str() {
|
||||
full_content.push_str(content);
|
||||
}
|
||||
if let Ok(chunk) = serde_json::from_str::<serde_json::Value>(data)
|
||||
&& let Some(content) = chunk["choices"][0]["delta"]["content"].as_str()
|
||||
{
|
||||
full_content.push_str(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user