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"
|
name = "ironclaw"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.92"
|
||||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||||
authors = ["NEAR AI <[email protected]>"]
|
authors = ["NEAR AI <[email protected]>"]
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
+46
@@ -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,
|
# The image includes common development tools so workers can build software,
|
||||||
# run tests, and execute shell commands.
|
# run tests, and execute shell commands.
|
||||||
|
|
||||||
FROM rust:1.85-bookworm AS builder
|
FROM rust:1.92-bookworm AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -40,7 +40,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
ENV RUSTUP_HOME=/usr/local/rustup \
|
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||||
CARGO_HOME=/usr/local/cargo \
|
CARGO_HOME=/usr/local/cargo \
|
||||||
PATH=/usr/local/cargo/bin:$PATH
|
PATH=/usr/local/cargo/bin:$PATH
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.85.0 \
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.92.0 \
|
||||||
&& chmod -R a+r /usr/local/rustup /usr/local/cargo
|
&& chmod -R a+r /usr/local/rustup /usr/local/cargo
|
||||||
|
|
||||||
# Install Claude Code CLI (for claude-bridge mode)
|
# Install Claude Code CLI (for claude-bridge mode)
|
||||||
|
|||||||
@@ -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"
|
||||||
+41
-50
@@ -654,9 +654,9 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Restore response chain from conversation metadata
|
// Restore response chain from conversation metadata
|
||||||
if let Some(store) = self.store() {
|
if let Some(store) = self.store()
|
||||||
if let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await {
|
&& let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await
|
||||||
if let Some(rid) = metadata
|
&& let Some(rid) = metadata
|
||||||
.get("last_response_id")
|
.get("last_response_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(String::from)
|
.map(String::from)
|
||||||
@@ -666,8 +666,6 @@ impl Agent {
|
|||||||
.seed_response_chain(&thread_uuid.to_string(), rid);
|
.seed_response_chain(&thread_uuid.to_string(), rid);
|
||||||
tracing::debug!("Restored response chain for thread {}", thread_uuid);
|
tracing::debug!("Restored response chain for thread {}", thread_uuid);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert into session and register with session manager
|
// Insert into session and register with session manager
|
||||||
{
|
{
|
||||||
@@ -954,14 +952,13 @@ impl Agent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref resp) = response {
|
if let Some(ref resp) = response
|
||||||
if let Err(e) = store
|
&& let Err(e) = store
|
||||||
.add_conversation_message(thread_id, "assistant", resp)
|
.add_conversation_message(thread_id, "assistant", resp)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::warn!("Failed to persist assistant message: {}", e);
|
tracing::warn!("Failed to persist assistant message: {}", e);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1058,8 +1055,9 @@ impl Agent {
|
|||||||
// Check if interrupted
|
// Check if interrupted
|
||||||
{
|
{
|
||||||
let sess = session.lock().await;
|
let sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get(&thread_id) {
|
if let Some(thread) = sess.threads.get(&thread_id)
|
||||||
if thread.state == ThreadState::Interrupted {
|
&& thread.state == ThreadState::Interrupted
|
||||||
|
{
|
||||||
return Err(crate::error::JobError::ContextError {
|
return Err(crate::error::JobError::ContextError {
|
||||||
id: thread_id,
|
id: thread_id,
|
||||||
reason: "Interrupted".to_string(),
|
reason: "Interrupted".to_string(),
|
||||||
@@ -1067,7 +1065,6 @@ impl Agent {
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh tool definitions each iteration so newly built tools become visible
|
// Refresh tool definitions each iteration so newly built tools become visible
|
||||||
let tool_defs = self.tools().tool_definitions().await;
|
let tool_defs = self.tools().tool_definitions().await;
|
||||||
@@ -1140,20 +1137,21 @@ impl Agent {
|
|||||||
// Record tool calls in the thread
|
// Record tool calls in the thread
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
||||||
if let Some(turn) = thread.last_turn_mut() {
|
&& let Some(turn) = thread.last_turn_mut()
|
||||||
|
{
|
||||||
for tc in &tool_calls {
|
for tc in &tool_calls {
|
||||||
turn.record_tool_call(&tc.name, tc.arguments.clone());
|
turn.record_tool_call(&tc.name, tc.arguments.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Execute each tool (with approval checking)
|
// Execute each tool (with approval checking)
|
||||||
for tc in tool_calls {
|
for tc in tool_calls {
|
||||||
// Check if tool requires approval
|
// Check if tool requires approval
|
||||||
if let Some(tool) = self.tools().get(&tc.name).await {
|
if let Some(tool) = self.tools().get(&tc.name).await
|
||||||
if tool.requires_approval() {
|
&& tool.requires_approval()
|
||||||
|
{
|
||||||
// Check if auto-approved for this session
|
// Check if auto-approved for this session
|
||||||
let mut is_auto_approved = {
|
let mut is_auto_approved = {
|
||||||
let sess = session.lock().await;
|
let sess = session.lock().await;
|
||||||
@@ -1163,29 +1161,23 @@ impl Agent {
|
|||||||
// For shell commands, override auto-approval for
|
// For shell commands, override auto-approval for
|
||||||
// destructive patterns that should always require
|
// destructive patterns that should always require
|
||||||
// explicit per-invocation approval.
|
// explicit per-invocation approval.
|
||||||
if is_auto_approved && tc.name == "shell" {
|
if is_auto_approved
|
||||||
if let Some(cmd) = tc
|
&& tc.name == "shell"
|
||||||
|
&& let Some(cmd) = tc
|
||||||
.arguments
|
.arguments
|
||||||
.as_str()
|
.as_str()
|
||||||
.and_then(|s| {
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||||
serde_json::from_str::<serde_json::Value>(s).ok()
|
|
||||||
})
|
|
||||||
.and_then(|v| {
|
.and_then(|v| {
|
||||||
v.get("command")
|
v.get("command").and_then(|c| c.as_str().map(String::from))
|
||||||
.and_then(|c| c.as_str().map(String::from))
|
|
||||||
})
|
})
|
||||||
|
&& crate::tools::builtin::shell::requires_explicit_approval(&cmd)
|
||||||
{
|
{
|
||||||
if crate::tools::builtin::shell::requires_explicit_approval(
|
|
||||||
&cmd,
|
|
||||||
) {
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Shell command '{}' requires explicit approval despite auto-approve",
|
"Shell command '{}' requires explicit approval despite auto-approve",
|
||||||
cmd.chars().take(80).collect::<String>()
|
cmd.chars().take(80).collect::<String>()
|
||||||
);
|
);
|
||||||
is_auto_approved = false;
|
is_auto_approved = false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !is_auto_approved {
|
if !is_auto_approved {
|
||||||
// Need approval - store pending request and return
|
// Need approval - store pending request and return
|
||||||
@@ -1201,7 +1193,6 @@ impl Agent {
|
|||||||
return Ok(AgenticLoopResult::NeedApproval { pending });
|
return Ok(AgenticLoopResult::NeedApproval { pending });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
@@ -1230,8 +1221,9 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Ok(ref output) = tool_result {
|
if let Ok(ref output) = tool_result
|
||||||
if !output.is_empty() {
|
&& !output.is_empty()
|
||||||
|
{
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
@@ -1244,13 +1236,13 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Record result in thread
|
// Record result in thread
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
||||||
if let Some(turn) = thread.last_turn_mut() {
|
&& let Some(turn) = thread.last_turn_mut()
|
||||||
|
{
|
||||||
match &tool_result {
|
match &tool_result {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
turn.record_tool_result(serde_json::json!(output));
|
turn.record_tool_result(serde_json::json!(output));
|
||||||
@@ -1261,7 +1253,6 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If tool_auth returned awaiting_token, enter auth mode
|
// If tool_auth returned awaiting_token, enter auth mode
|
||||||
// and short-circuit: return the instructions directly so
|
// and short-circuit: return the instructions directly so
|
||||||
@@ -1640,8 +1631,9 @@ impl Agent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Verify request ID if provided
|
// Verify request ID if provided
|
||||||
if let Some(req_id) = request_id {
|
if let Some(req_id) = request_id
|
||||||
if req_id != pending.request_id {
|
&& req_id != pending.request_id
|
||||||
|
{
|
||||||
// Put it back and return error
|
// Put it back and return error
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||||
@@ -1651,7 +1643,6 @@ impl Agent {
|
|||||||
"Request ID mismatch. Use the correct request ID.",
|
"Request ID mismatch. Use the correct request ID.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if approved {
|
if approved {
|
||||||
// If always, add to auto-approved set
|
// If always, add to auto-approved set
|
||||||
@@ -1704,8 +1695,9 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Ok(ref output) = tool_result {
|
if let Ok(ref output) = tool_result
|
||||||
if !output.is_empty() {
|
&& !output.is_empty()
|
||||||
|
{
|
||||||
let _ = self
|
let _ = self
|
||||||
.channels
|
.channels
|
||||||
.send_status(
|
.send_status(
|
||||||
@@ -1718,7 +1710,6 @@ impl Agent {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Build context including the tool result
|
// Build context including the tool result
|
||||||
let mut context_messages = pending.context_messages;
|
let mut context_messages = pending.context_messages;
|
||||||
@@ -1726,8 +1717,9 @@ impl Agent {
|
|||||||
// Record result in thread
|
// Record result in thread
|
||||||
{
|
{
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
if let Some(thread) = sess.threads.get_mut(&thread_id)
|
||||||
if let Some(turn) = thread.last_turn_mut() {
|
&& let Some(turn) = thread.last_turn_mut()
|
||||||
|
{
|
||||||
match &tool_result {
|
match &tool_result {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
turn.record_tool_result(serde_json::json!(output));
|
turn.record_tool_result(serde_json::json!(output));
|
||||||
@@ -1738,7 +1730,6 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If tool_auth returned awaiting_token, enter auth mode and
|
// If tool_auth returned awaiting_token, enter auth mode and
|
||||||
// return instructions directly (skip agentic loop continuation).
|
// return instructions directly (skip agentic loop continuation).
|
||||||
@@ -2094,8 +2085,9 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist new job to database (fire-and-forget)
|
// Persist new job to database (fire-and-forget)
|
||||||
if let Some(store) = self.store() {
|
if let Some(store) = self.store()
|
||||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
&& let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||||
|
{
|
||||||
let store = store.clone();
|
let store = store.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = store.save_job(&ctx).await {
|
if let Err(e) = store.save_job(&ctx).await {
|
||||||
@@ -2103,7 +2095,6 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule for execution
|
// Schedule for execution
|
||||||
self.scheduler.schedule(job_id).await?;
|
self.scheduler.schedule(job_id).await?;
|
||||||
@@ -2182,12 +2173,12 @@ impl Agent {
|
|||||||
|
|
||||||
let mut output = String::from("Jobs:\n");
|
let mut output = String::from("Jobs:\n");
|
||||||
for job_id in jobs {
|
for job_id in jobs {
|
||||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||||
if ctx.user_id == user_id {
|
&& ctx.user_id == user_id
|
||||||
|
{
|
||||||
output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
|
output.push_str(&format!(" {} - {} ({:?})\n", job_id, ctx.title, ctx.state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,11 +103,10 @@ impl RoutineEngine {
|
|||||||
if let Trigger::Event {
|
if let Trigger::Event {
|
||||||
channel: Some(ch), ..
|
channel: Some(ch), ..
|
||||||
} = &routine.trigger
|
} = &routine.trigger
|
||||||
|
&& ch != &message.channel
|
||||||
{
|
{
|
||||||
if ch != &message.channel {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Regex match
|
// Regex match
|
||||||
if !re.is_match(&message.content) {
|
if !re.is_match(&message.content) {
|
||||||
|
|||||||
@@ -119,8 +119,9 @@ impl SelfRepair for DefaultSelfRepair {
|
|||||||
let mut stuck_jobs = Vec::new();
|
let mut stuck_jobs = Vec::new();
|
||||||
|
|
||||||
for job_id in stuck_ids {
|
for job_id in stuck_ids {
|
||||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||||
if ctx.state == JobState::Stuck {
|
&& ctx.state == JobState::Stuck
|
||||||
|
{
|
||||||
let stuck_duration = ctx
|
let stuck_duration = ctx
|
||||||
.started_at
|
.started_at
|
||||||
.map(|start| {
|
.map(|start| {
|
||||||
@@ -139,7 +140,6 @@ impl SelfRepair for DefaultSelfRepair {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
stuck_jobs
|
stuck_jobs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,12 +346,12 @@ impl Thread {
|
|||||||
let mut turn = Turn::new(turn_number, &msg.content);
|
let mut turn = Turn::new(turn_number, &msg.content);
|
||||||
|
|
||||||
// Check if next is assistant response
|
// Check if next is assistant response
|
||||||
if let Some(next) = iter.peek() {
|
if let Some(next) = iter.peek()
|
||||||
if next.role == crate::llm::Role::Assistant {
|
&& next.role == crate::llm::Role::Assistant
|
||||||
|
{
|
||||||
let response = iter.next().expect("peeked");
|
let response = iter.next().expect("peeked");
|
||||||
turn.complete(&response.content);
|
turn.complete(&response.content);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
self.turns.push(turn);
|
self.turns.push(turn);
|
||||||
turn_number += 1;
|
turn_number += 1;
|
||||||
|
|||||||
@@ -199,13 +199,13 @@ impl SessionManager {
|
|||||||
{
|
{
|
||||||
let sessions = self.sessions.read().await;
|
let sessions = self.sessions.read().await;
|
||||||
for user_id in &stale_users {
|
for user_id in &stale_users {
|
||||||
if let Some(session) = sessions.get(user_id) {
|
if let Some(session) = sessions.get(user_id)
|
||||||
if let Ok(sess) = session.try_lock() {
|
&& let Ok(sess) = session.try_lock()
|
||||||
|
{
|
||||||
stale_thread_ids.extend(sess.threads.keys());
|
stale_thread_ids.extend(sess.threads.keys());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Remove sessions
|
// Remove sessions
|
||||||
let count = {
|
let count = {
|
||||||
|
|||||||
+10
-11
@@ -93,28 +93,27 @@ impl SubmissionParser {
|
|||||||
// /thread <uuid> - switch thread
|
// /thread <uuid> - switch thread
|
||||||
if let Some(rest) = lower.strip_prefix("/thread ") {
|
if let Some(rest) = lower.strip_prefix("/thread ") {
|
||||||
let rest = rest.trim();
|
let rest = rest.trim();
|
||||||
if rest != "new" {
|
if rest != "new"
|
||||||
if let Ok(id) = Uuid::parse_str(rest) {
|
&& let Ok(id) = Uuid::parse_str(rest)
|
||||||
|
{
|
||||||
return Submission::SwitchThread { thread_id: id };
|
return Submission::SwitchThread { thread_id: id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// /resume <uuid> - resume from checkpoint
|
// /resume <uuid> - resume from checkpoint
|
||||||
if let Some(rest) = lower.strip_prefix("/resume ") {
|
if let Some(rest) = lower.strip_prefix("/resume ")
|
||||||
if let Ok(id) = Uuid::parse_str(rest.trim()) {
|
&& let Ok(id) = Uuid::parse_str(rest.trim())
|
||||||
|
{
|
||||||
return Submission::Resume { checkpoint_id: id };
|
return Submission::Resume { checkpoint_id: id };
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Try structured JSON approval (from web gateway's /api/chat/approval endpoint)
|
// Try structured JSON approval (from web gateway's /api/chat/approval endpoint)
|
||||||
if trimmed.starts_with('{') {
|
if trimmed.starts_with('{')
|
||||||
if let Ok(submission) = serde_json::from_str::<Submission>(trimmed) {
|
&& let Ok(submission) = serde_json::from_str::<Submission>(trimmed)
|
||||||
if matches!(submission, Submission::ExecApproval { .. }) {
|
&& matches!(submission, Submission::ExecApproval { .. })
|
||||||
|
{
|
||||||
return submission;
|
return submission;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Approval responses (simple yes/no/always for pending approvals)
|
// Approval responses (simple yes/no/always for pending approvals)
|
||||||
// These are short enough to check explicitly
|
// These are short enough to check explicitly
|
||||||
|
|||||||
+3
-3
@@ -227,12 +227,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for cancellation
|
// Check for cancellation
|
||||||
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await {
|
if let Ok(ctx) = self.context_manager().get_context(self.job_id).await
|
||||||
if ctx.state == JobState::Cancelled {
|
&& ctx.state == JobState::Cancelled
|
||||||
|
{
|
||||||
tracing::info!("Worker for job {} detected cancellation", self.job_id);
|
tracing::info!("Worker for job {} detected cancellation", self.job_id);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
iteration += 1;
|
iteration += 1;
|
||||||
if iteration > max_iterations {
|
if iteration > max_iterations {
|
||||||
|
|||||||
@@ -134,15 +134,15 @@ impl ChannelStoreData {
|
|||||||
if result.contains('{') && result.contains('}') {
|
if result.contains('{') && result.contains('}') {
|
||||||
// Only warn if it looks like an unresolved placeholder (not JSON braces)
|
// Only warn if it looks like an unresolved placeholder (not JSON braces)
|
||||||
let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
|
let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
|
||||||
if let Some(re) = brace_pattern {
|
if let Some(re) = brace_pattern
|
||||||
if re.is_match(&result) {
|
&& re.is_match(&result)
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
context = %context,
|
context = %context,
|
||||||
"String may contain unresolved credential placeholders"
|
"String may contain unresolved credential placeholders"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -338,14 +338,14 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
|
|
||||||
// Enforce max response body size to prevent memory exhaustion.
|
// Enforce max response body size to prevent memory exhaustion.
|
||||||
let max_response = max_response_bytes;
|
let max_response = max_response_bytes;
|
||||||
if let Some(cl) = response.content_length() {
|
if let Some(cl) = response.content_length()
|
||||||
if cl as usize > max_response {
|
&& cl as usize > max_response
|
||||||
|
{
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Response body too large: {} bytes exceeds limit of {} bytes",
|
"Response body too large: {} bytes exceeds limit of {} bytes",
|
||||||
cl, max_response
|
cl, max_response
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let body = response
|
let body = response
|
||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
@@ -1495,8 +1495,8 @@ impl WasmChannel {
|
|||||||
match result {
|
match result {
|
||||||
Ok(emitted_messages) => {
|
Ok(emitted_messages) => {
|
||||||
// Process any emitted messages
|
// Process any emitted messages
|
||||||
if !emitted_messages.is_empty() {
|
if !emitted_messages.is_empty()
|
||||||
if let Err(e) = Self::dispatch_emitted_messages(
|
&& let Err(e) = Self::dispatch_emitted_messages(
|
||||||
&channel_name,
|
&channel_name,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
&message_tx,
|
&message_tx,
|
||||||
@@ -1509,7 +1509,6 @@ impl WasmChannel {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
channel = %channel_name,
|
channel = %channel_name,
|
||||||
@@ -1738,8 +1737,9 @@ impl Channel for WasmChannel {
|
|||||||
*self.endpoints.write().await = endpoints;
|
*self.endpoints.write().await = endpoints;
|
||||||
|
|
||||||
// Start polling if configured
|
// Start polling if configured
|
||||||
if let Some(poll_config) = &config.poll {
|
if let Some(poll_config) = &config.poll
|
||||||
if poll_config.enabled {
|
&& poll_config.enabled
|
||||||
|
{
|
||||||
let interval = self
|
let interval = self
|
||||||
.capabilities
|
.capabilities
|
||||||
.validate_poll_interval(poll_config.interval_ms)
|
.validate_poll_interval(poll_config.interval_ms)
|
||||||
@@ -1754,7 +1754,6 @@ impl Channel for WasmChannel {
|
|||||||
|
|
||||||
self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx);
|
self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
channel = %self.name,
|
channel = %self.name,
|
||||||
|
|||||||
@@ -25,26 +25,24 @@ pub async fn auth_middleware(
|
|||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// Try Authorization header first (constant-time comparison)
|
// Try Authorization header first (constant-time comparison)
|
||||||
if let Some(auth_header) = headers.get("authorization") {
|
if let Some(auth_header) = headers.get("authorization")
|
||||||
if let Ok(value) = auth_header.to_str() {
|
&& let Ok(value) = auth_header.to_str()
|
||||||
if let Some(token) = value.strip_prefix("Bearer ") {
|
&& let Some(token) = value.strip_prefix("Bearer ")
|
||||||
if bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) {
|
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||||
|
{
|
||||||
return next.run(request).await;
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to query parameter for SSE EventSource (constant-time comparison)
|
// Fall back to query parameter for SSE EventSource (constant-time comparison)
|
||||||
if let Some(query) = request.uri().query() {
|
if let Some(query) = request.uri().query() {
|
||||||
for pair in query.split('&') {
|
for pair in query.split('&') {
|
||||||
if let Some(token) = pair.strip_prefix("token=") {
|
if let Some(token) = pair.strip_prefix("token=")
|
||||||
if bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) {
|
&& bool::from(token.as_bytes().ct_eq(auth.token.as_bytes()))
|
||||||
|
{
|
||||||
return next.run(request).await;
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
|
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -473,11 +473,11 @@ pub async fn chat_completions_handler(
|
|||||||
if let Some(mt) = req.max_tokens {
|
if let Some(mt) = req.max_tokens {
|
||||||
tool_req = tool_req.with_max_tokens(mt);
|
tool_req = tool_req.with_max_tokens(mt);
|
||||||
}
|
}
|
||||||
if let Some(ref tc) = req.tool_choice {
|
if let Some(ref tc) = req.tool_choice
|
||||||
if let Some(choice) = normalize_tool_choice(tc) {
|
&& let Some(choice) = normalize_tool_choice(tc)
|
||||||
|
{
|
||||||
tool_req = tool_req.with_tool_choice(choice);
|
tool_req = tool_req.with_tool_choice(choice);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let resp = llm
|
let resp = llm
|
||||||
.complete_with_tools(tool_req)
|
.complete_with_tools(tool_req)
|
||||||
@@ -591,11 +591,11 @@ async fn handle_streaming(
|
|||||||
if let Some(mt) = req.max_tokens {
|
if let Some(mt) = req.max_tokens {
|
||||||
tool_req = tool_req.with_max_tokens(mt);
|
tool_req = tool_req.with_max_tokens(mt);
|
||||||
}
|
}
|
||||||
if let Some(ref tc) = req.tool_choice {
|
if let Some(ref tc) = req.tool_choice
|
||||||
if let Some(choice) = normalize_tool_choice(tc) {
|
&& let Some(choice) = normalize_tool_choice(tc)
|
||||||
|
{
|
||||||
tool_req = tool_req.with_tool_choice(choice);
|
tool_req = tool_req.with_tool_choice(choice);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
LlmResult::WithTools(
|
LlmResult::WithTools(
|
||||||
llm.complete_with_tools(tool_req)
|
llm.complete_with_tools(tool_req)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+26
-27
@@ -525,13 +525,13 @@ pub async fn clear_auth_mode(state: &GatewayState) {
|
|||||||
if let Some(ref sm) = state.session_manager {
|
if let Some(ref sm) = state.session_manager {
|
||||||
let session = sm.get_or_create_session(&state.user_id).await;
|
let session = sm.get_or_create_session(&state.user_id).await;
|
||||||
let mut sess = session.lock().await;
|
let mut sess = session.lock().await;
|
||||||
if let Some(thread_id) = sess.active_thread {
|
if let Some(thread_id) = sess.active_thread
|
||||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
&& let Some(thread) = sess.threads.get_mut(&thread_id)
|
||||||
|
{
|
||||||
thread.pending_auth = None;
|
thread.pending_auth = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async fn chat_events_handler(
|
async fn chat_events_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
@@ -626,8 +626,9 @@ async fn chat_history_handler(
|
|||||||
// Verify the thread belongs to the authenticated user before returning any data.
|
// Verify the thread belongs to the authenticated user before returning any data.
|
||||||
// In-memory threads are already scoped by user via session_manager, but DB
|
// In-memory threads are already scoped by user via session_manager, but DB
|
||||||
// lookups could expose another user's conversation if the UUID is guessed.
|
// lookups could expose another user's conversation if the UUID is guessed.
|
||||||
if query.thread_id.is_some() {
|
if query.thread_id.is_some()
|
||||||
if let Some(ref store) = state.store {
|
&& let Some(ref store) = state.store
|
||||||
|
{
|
||||||
let owned = store
|
let owned = store
|
||||||
.conversation_belongs_to_user(thread_id, &state.user_id)
|
.conversation_belongs_to_user(thread_id, &state.user_id)
|
||||||
.await
|
.await
|
||||||
@@ -636,11 +637,11 @@ async fn chat_history_handler(
|
|||||||
return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Thread not found".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// For paginated requests (before cursor set), always go to DB
|
// For paginated requests (before cursor set), always go to DB
|
||||||
if before_cursor.is_some() {
|
if before_cursor.is_some()
|
||||||
if let Some(ref store) = state.store {
|
&& let Some(ref store) = state.store
|
||||||
|
{
|
||||||
let (messages, has_more) = store
|
let (messages, has_more) = store
|
||||||
.list_conversation_messages_paginated(thread_id, before_cursor, limit as i64)
|
.list_conversation_messages_paginated(thread_id, before_cursor, limit as i64)
|
||||||
.await
|
.await
|
||||||
@@ -655,11 +656,11 @@ async fn chat_history_handler(
|
|||||||
oldest_timestamp,
|
oldest_timestamp,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Try in-memory first (freshest data for active threads)
|
// Try in-memory first (freshest data for active threads)
|
||||||
if let Some(thread) = sess.threads.get(&thread_id) {
|
if let Some(thread) = sess.threads.get(&thread_id)
|
||||||
if !thread.turns.is_empty() {
|
&& !thread.turns.is_empty()
|
||||||
|
{
|
||||||
let turns: Vec<TurnInfo> = thread
|
let turns: Vec<TurnInfo> = thread
|
||||||
.turns
|
.turns
|
||||||
.iter()
|
.iter()
|
||||||
@@ -689,7 +690,6 @@ async fn chat_history_handler(
|
|||||||
oldest_timestamp: None,
|
oldest_timestamp: None,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to DB for historical threads not in memory (paginated)
|
// Fall back to DB for historical threads not in memory (paginated)
|
||||||
if let Some(ref store) = state.store {
|
if let Some(ref store) = state.store {
|
||||||
@@ -738,13 +738,13 @@ fn build_turns_from_db_messages(messages: &[crate::history::ConversationMessage]
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check if next message is an assistant response
|
// Check if next message is an assistant response
|
||||||
if let Some(next) = iter.peek() {
|
if let Some(next) = iter.peek()
|
||||||
if next.role == "assistant" {
|
&& next.role == "assistant"
|
||||||
|
{
|
||||||
let assistant_msg = iter.next().expect("peeked");
|
let assistant_msg = iter.next().expect("peeked");
|
||||||
turn.response = Some(assistant_msg.content.clone());
|
turn.response = Some(assistant_msg.content.clone());
|
||||||
turn.completed_at = Some(assistant_msg.created_at.to_rfc3339());
|
turn.completed_at = Some(assistant_msg.created_at.to_rfc3339());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Incomplete turn (user message without response)
|
// Incomplete turn (user message without response)
|
||||||
if turn.response.is_none() {
|
if turn.response.is_none() {
|
||||||
@@ -1126,8 +1126,9 @@ async fn jobs_detail_handler(
|
|||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||||
|
|
||||||
// Try sandbox job from DB first, scoped to the authenticated user.
|
// Try sandbox job from DB first, scoped to the authenticated user.
|
||||||
if let Some(ref store) = state.store {
|
if let Some(ref store) = state.store
|
||||||
if let Ok(Some(job)) = store.get_sandbox_job(job_id).await {
|
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
||||||
|
{
|
||||||
if job.user_id != state.user_id {
|
if job.user_id != state.user_id {
|
||||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
}
|
}
|
||||||
@@ -1185,7 +1186,6 @@ async fn jobs_detail_handler(
|
|||||||
transitions,
|
transitions,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||||
}
|
}
|
||||||
@@ -1198,18 +1198,19 @@ async fn jobs_cancel_handler(
|
|||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||||
|
|
||||||
// Try sandbox job cancellation, scoped to the authenticated user.
|
// Try sandbox job cancellation, scoped to the authenticated user.
|
||||||
if let Some(ref store) = state.store {
|
if let Some(ref store) = state.store
|
||||||
if let Ok(Some(job)) = store.get_sandbox_job(job_id).await {
|
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
||||||
|
{
|
||||||
if job.user_id != state.user_id {
|
if job.user_id != state.user_id {
|
||||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
}
|
}
|
||||||
if job.status == "running" || job.status == "creating" {
|
if job.status == "running" || job.status == "creating" {
|
||||||
// Stop the container if we have a job manager.
|
// Stop the container if we have a job manager.
|
||||||
if let Some(ref jm) = state.job_manager {
|
if let Some(ref jm) = state.job_manager
|
||||||
if let Err(e) = jm.stop_job(job_id).await {
|
&& let Err(e) = jm.stop_job(job_id).await
|
||||||
|
{
|
||||||
tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
|
tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
store
|
store
|
||||||
.update_sandbox_job_status(
|
.update_sandbox_job_status(
|
||||||
job_id,
|
job_id,
|
||||||
@@ -1227,7 +1228,6 @@ async fn jobs_cancel_handler(
|
|||||||
"job_id": job_id,
|
"job_id": job_id,
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||||
}
|
}
|
||||||
@@ -1334,15 +1334,14 @@ async fn jobs_prompt_handler(
|
|||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||||
|
|
||||||
// Verify user owns this job.
|
// Verify user owns this job.
|
||||||
if let Some(ref store) = state.store {
|
if let Some(ref store) = state.store
|
||||||
if !store
|
&& !store
|
||||||
.sandbox_job_belongs_to_user(job_id, &state.user_id)
|
.sandbox_job_belongs_to_user(job_id, &state.user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let content = body
|
let content = body
|
||||||
.get("content")
|
.get("content")
|
||||||
|
|||||||
+3
-3
@@ -107,11 +107,11 @@ async fn list_settings(
|
|||||||
println!();
|
println!();
|
||||||
|
|
||||||
for (key, value) in all {
|
for (key, value) in all {
|
||||||
if let Some(ref f) = filter {
|
if let Some(ref f) = filter
|
||||||
if !key.starts_with(f) {
|
&& !key.starts_with(f)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let display_value = if value.len() > 60 {
|
let display_value = if value.len() > 60 {
|
||||||
format!("{}...", &value[..57])
|
format!("{}...", &value[..57])
|
||||||
|
|||||||
+28
-31
@@ -420,13 +420,13 @@ async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result<String> {
|
|||||||
// Simple TOML parsing for [package] name
|
// Simple TOML parsing for [package] name
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.starts_with("name") {
|
if line.starts_with("name")
|
||||||
if let Some((_, value)) = line.split_once('=') {
|
&& let Some((_, value)) = line.split_once('=')
|
||||||
|
{
|
||||||
let name = value.trim().trim_matches('"').trim_matches('\'');
|
let name = value.trim().trim_matches('"').trim_matches('\'');
|
||||||
return Ok(name.to_string());
|
return Ok(name.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Could not extract package name from {}",
|
"Could not extract package name from {}",
|
||||||
@@ -488,12 +488,12 @@ async fn list_tools(dir: Option<PathBuf>, verbose: bool) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
if has_caps {
|
if has_caps {
|
||||||
let caps_path = path.with_extension("capabilities.json");
|
let caps_path = path.with_extension("capabilities.json");
|
||||||
if let Ok(content) = fs::read_to_string(&caps_path).await {
|
if let Ok(content) = fs::read_to_string(&caps_path).await
|
||||||
if let Ok(caps) = CapabilitiesFile::from_json(&content) {
|
&& let Ok(caps) = CapabilitiesFile::from_json(&content)
|
||||||
|
{
|
||||||
print_capabilities_summary(&caps);
|
print_capabilities_summary(&caps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
println!();
|
println!();
|
||||||
} else {
|
} else {
|
||||||
let caps_indicator = if has_caps { "✓" } else { "✗" };
|
let caps_indicator = if has_caps { "✓" } else { "✗" };
|
||||||
@@ -604,17 +604,17 @@ fn print_capabilities_summary(caps: &CapabilitiesFile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref secrets) = caps.secrets {
|
if let Some(ref secrets) = caps.secrets
|
||||||
if !secrets.allowed_names.is_empty() {
|
&& !secrets.allowed_names.is_empty()
|
||||||
|
{
|
||||||
parts.push(format!("secrets: {}", secrets.allowed_names.len()));
|
parts.push(format!("secrets: {}", secrets.allowed_names.len()));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref ws) = caps.workspace {
|
if let Some(ref ws) = caps.workspace
|
||||||
if !ws.allowed_prefixes.is_empty() {
|
&& !ws.allowed_prefixes.is_empty()
|
||||||
|
{
|
||||||
parts.push("workspace: read".to_string());
|
parts.push("workspace: read".to_string());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if !parts.is_empty() {
|
if !parts.is_empty() {
|
||||||
println!(" Perms: {}", parts.join(", "));
|
println!(" Perms: {}", parts.join(", "));
|
||||||
@@ -650,33 +650,33 @@ fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref secrets) = caps.secrets {
|
if let Some(ref secrets) = caps.secrets
|
||||||
if !secrets.allowed_names.is_empty() {
|
&& !secrets.allowed_names.is_empty()
|
||||||
|
{
|
||||||
println!(" Secrets (existence check only):");
|
println!(" Secrets (existence check only):");
|
||||||
for name in &secrets.allowed_names {
|
for name in &secrets.allowed_names {
|
||||||
println!(" {}", name);
|
println!(" {}", name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref tool_invoke) = caps.tool_invoke {
|
if let Some(ref tool_invoke) = caps.tool_invoke
|
||||||
if !tool_invoke.aliases.is_empty() {
|
&& !tool_invoke.aliases.is_empty()
|
||||||
|
{
|
||||||
println!(" Tool aliases:");
|
println!(" Tool aliases:");
|
||||||
for (alias, real_name) in &tool_invoke.aliases {
|
for (alias, real_name) in &tool_invoke.aliases {
|
||||||
println!(" {} -> {}", alias, real_name);
|
println!(" {} -> {}", alias, real_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref ws) = caps.workspace {
|
if let Some(ref ws) = caps.workspace
|
||||||
if !ws.allowed_prefixes.is_empty() {
|
&& !ws.allowed_prefixes.is_empty()
|
||||||
|
{
|
||||||
println!(" Workspace read prefixes:");
|
println!(" Workspace read prefixes:");
|
||||||
for prefix in &ws.allowed_prefixes {
|
for prefix in &ws.allowed_prefixes {
|
||||||
println!(" {}", prefix);
|
println!(" {}", prefix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure authentication for a tool.
|
/// Configure authentication for a tool.
|
||||||
async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
|
async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyhow::Result<()> {
|
||||||
@@ -752,9 +752,10 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for environment variable
|
// Check for environment variable
|
||||||
if let Some(ref env_var) = auth.env_var {
|
if let Some(ref env_var) = auth.env_var
|
||||||
if let Ok(token) = std::env::var(env_var) {
|
&& let Ok(token) = std::env::var(env_var)
|
||||||
if !token.is_empty() {
|
&& !token.is_empty()
|
||||||
|
{
|
||||||
println!(" Found {} in environment.", env_var);
|
println!(" Found {} in environment.", env_var);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
@@ -782,8 +783,6 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
|||||||
print_success(display_name);
|
print_success(display_name);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for OAuth configuration
|
// Check for OAuth configuration
|
||||||
if let Some(ref oauth) = auth.oauth {
|
if let Some(ref oauth) = auth.oauth {
|
||||||
@@ -923,9 +922,9 @@ async fn auth_tool_oauth(
|
|||||||
reader.read_line(&mut request_line).await?;
|
reader.read_line(&mut request_line).await?;
|
||||||
|
|
||||||
// Parse GET /callback?code=xxx HTTP/1.1
|
// Parse GET /callback?code=xxx HTTP/1.1
|
||||||
if let Some(path) = request_line.split_whitespace().nth(1) {
|
if let Some(path) = request_line.split_whitespace().nth(1)
|
||||||
if path.starts_with("/callback") {
|
&& path.starts_with("/callback")
|
||||||
if let Some(query) = path.split('?').nth(1) {
|
&& let Some(query) = path.split('?').nth(1) {
|
||||||
for param in query.split('&') {
|
for param in query.split('&') {
|
||||||
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
let parts: Vec<&str> = param.splitn(2, '=').collect();
|
||||||
if parts.len() == 2 && parts[0] == "code" {
|
if parts.len() == 2 && parts[0] == "code" {
|
||||||
@@ -962,8 +961,6 @@ async fn auth_tool_oauth(
|
|||||||
return Err(anyhow::anyhow!("Authorization denied by user"));
|
return Err(anyhow::anyhow!("Authorization denied by user"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
|
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
|
||||||
let _ = socket.write_all(response.as_bytes()).await;
|
let _ = socket.write_all(response.as_bytes()).await;
|
||||||
|
|||||||
+6
-6
@@ -107,14 +107,14 @@ impl TunnelConfig {
|
|||||||
let public_url = optional_env("TUNNEL_URL")?
|
let public_url = optional_env("TUNNEL_URL")?
|
||||||
.or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty()));
|
.or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty()));
|
||||||
|
|
||||||
if let Some(ref url) = public_url {
|
if let Some(ref url) = public_url
|
||||||
if !url.starts_with("https://") {
|
&& !url.starts_with("https://")
|
||||||
|
{
|
||||||
return Err(ConfigError::InvalidValue {
|
return Err(ConfigError::InvalidValue {
|
||||||
key: "TUNNEL_URL".to_string(),
|
key: "TUNNEL_URL".to_string(),
|
||||||
message: "must start with https:// (webhooks require HTTPS)".to_string(),
|
message: "must start with https:// (webhooks require HTTPS)".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self { public_url })
|
Ok(Self { public_url })
|
||||||
}
|
}
|
||||||
@@ -806,14 +806,14 @@ impl SecretsConfig {
|
|||||||
|
|
||||||
let enabled = master_key.is_some();
|
let enabled = master_key.is_some();
|
||||||
|
|
||||||
if let Some(ref key) = master_key {
|
if let Some(ref key) = master_key
|
||||||
if key.expose_secret().len() < 32 {
|
&& key.expose_secret().len() < 32
|
||||||
|
{
|
||||||
return Err(ConfigError::InvalidValue {
|
return Err(ConfigError::InvalidValue {
|
||||||
key: "SECRETS_MASTER_KEY".to_string(),
|
key: "SECRETS_MASTER_KEY".to_string(),
|
||||||
message: "must be at least 32 bytes for AES-256-GCM".to_string(),
|
message: "must be at least 32 bytes for AES-256-GCM".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
master_key,
|
master_key,
|
||||||
|
|||||||
@@ -144,14 +144,13 @@ impl SuccessEvaluator for RuleBasedEvaluator {
|
|||||||
|
|
||||||
// Check for critical errors
|
// Check for critical errors
|
||||||
for action in actions.iter().filter(|a| !a.success) {
|
for action in actions.iter().filter(|a| !a.success) {
|
||||||
if let Some(ref error) = action.error {
|
if let Some(ref error) = action.error
|
||||||
if error.to_lowercase().contains("critical")
|
&& (error.to_lowercase().contains("critical")
|
||||||
|| error.to_lowercase().contains("fatal")
|
|| error.to_lowercase().contains("fatal"))
|
||||||
{
|
{
|
||||||
issues.push(format!("Critical error in {}: {}", action.tool_name, error));
|
issues.push(format!("Critical error in {}: {}", action.tool_name, error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check job state
|
// Check job state
|
||||||
if job.state != crate::context::JobState::Completed
|
if job.state != crate::context::JobState::Completed
|
||||||
|
|||||||
@@ -490,14 +490,14 @@ impl ExtensionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check Content-Length header before downloading the full body
|
// Check Content-Length header before downloading the full body
|
||||||
if let Some(len) = response.content_length() {
|
if let Some(len) = response.content_length()
|
||||||
if len as usize > MAX_WASM_SIZE {
|
&& len as usize > MAX_WASM_SIZE
|
||||||
|
{
|
||||||
return Err(ExtensionError::InstallFailed(format!(
|
return Err(ExtensionError::InstallFailed(format!(
|
||||||
"WASM binary too large ({} bytes, max {} bytes)",
|
"WASM binary too large ({} bytes, max {} bytes)",
|
||||||
len, MAX_WASM_SIZE
|
len, MAX_WASM_SIZE
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = response
|
let bytes = response
|
||||||
.bytes()
|
.bytes()
|
||||||
@@ -766,11 +766,12 @@ impl ExtensionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check env var first
|
// Check env var first
|
||||||
if let Some(ref env_var) = auth.env_var {
|
if let Some(ref env_var) = auth.env_var
|
||||||
if let Ok(value) = std::env::var(env_var) {
|
&& let Ok(value) = std::env::var(env_var)
|
||||||
|
{
|
||||||
// Store the env var value as a secret
|
// Store the env var value as a secret
|
||||||
let params = CreateSecretParams::new(&auth.secret_name, &value)
|
let params =
|
||||||
.with_provider(name.to_string());
|
CreateSecretParams::new(&auth.secret_name, &value).with_provider(name.to_string());
|
||||||
self.secrets
|
self.secrets
|
||||||
.create(&self.user_id, params)
|
.create(&self.user_id, params)
|
||||||
.await
|
.await
|
||||||
@@ -787,7 +788,6 @@ impl ExtensionManager {
|
|||||||
status: "authenticated".to_string(),
|
status: "authenticated".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already authenticated
|
// Check if already authenticated
|
||||||
if self
|
if self
|
||||||
|
|||||||
+6
-6
@@ -209,8 +209,9 @@ impl NearAiProvider {
|
|||||||
data: Option<Vec<ModelEntry>>,
|
data: Option<Vec<ModelEntry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(resp) = serde_json::from_str::<ModelsResponse>(&response_text) {
|
if let Ok(resp) = serde_json::from_str::<ModelsResponse>(&response_text)
|
||||||
if let Some(entries) = resp.models.or(resp.data) {
|
&& let Some(entries) = resp.models.or(resp.data)
|
||||||
|
{
|
||||||
let models: Vec<ModelInfo> = entries
|
let models: Vec<ModelInfo> = entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
@@ -224,7 +225,6 @@ impl NearAiProvider {
|
|||||||
return Ok(models);
|
return Ok(models);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Try direct array format
|
// Try direct array format
|
||||||
if let Ok(entries) = serde_json::from_str::<Vec<ModelEntry>>(&response_text) {
|
if let Ok(entries) = serde_json::from_str::<Vec<ModelEntry>>(&response_text) {
|
||||||
@@ -694,8 +694,9 @@ impl LlmProvider for NearAiProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if item.item_type == "function_call" {
|
} else if item.item_type == "function_call"
|
||||||
if let (Some(name), Some(call_id)) = (&item.name, &item.call_id) {
|
&& let (Some(name), Some(call_id)) = (&item.name, &item.call_id)
|
||||||
|
{
|
||||||
// Parse arguments JSON string into Value
|
// Parse arguments JSON string into Value
|
||||||
let arguments = item
|
let arguments = item
|
||||||
.arguments
|
.arguments
|
||||||
@@ -710,7 +711,6 @@ impl LlmProvider for NearAiProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let finish_reason = if tool_calls.is_empty() {
|
let finish_reason = if tool_calls.is_empty() {
|
||||||
FinishReason::Stop
|
FinishReason::Stop
|
||||||
|
|||||||
@@ -395,11 +395,11 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
|
|||||||
if let (true, Some(calls)) = (msg.role == "assistant", &msg.tool_calls) {
|
if let (true, Some(calls)) = (msg.role == "assistant", &msg.tool_calls) {
|
||||||
// Convert assistant tool_calls into descriptive text
|
// Convert assistant tool_calls into descriptive text
|
||||||
let mut parts: Vec<String> = Vec::new();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
if let Some(ref text) = msg.content {
|
if let Some(ref text) = msg.content
|
||||||
if !text.is_empty() {
|
&& !text.is_empty()
|
||||||
|
{
|
||||||
parts.push(text.clone());
|
parts.push(text.clone());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for tc in calls {
|
for tc in calls {
|
||||||
parts.push(format!(
|
parts.push(format!(
|
||||||
"[Called tool `{}` with arguments: {}]",
|
"[Called tool `{}` with arguments: {}]",
|
||||||
|
|||||||
@@ -581,9 +581,10 @@ fn recover_tool_calls_from_content(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try JSON first: {"name":"x","arguments":{}}
|
// Try JSON first: {"name":"x","arguments":{}}
|
||||||
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(inner) {
|
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(inner)
|
||||||
if let Some(name) = parsed.get("name").and_then(|v| v.as_str()) {
|
&& let Some(name) = parsed.get("name").and_then(|v| v.as_str())
|
||||||
if tool_names.contains(name) {
|
&& tool_names.contains(name)
|
||||||
|
{
|
||||||
let arguments = parsed
|
let arguments = parsed
|
||||||
.get("arguments")
|
.get("arguments")
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -595,8 +596,6 @@ fn recover_tool_calls_from_content(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bare tool name (e.g. "<tool_call>tool_list</tool_call>")
|
// Bare tool name (e.g. "<tool_call>tool_list</tool_call>")
|
||||||
let name = inner.trim();
|
let name = inner.trim();
|
||||||
|
|||||||
+9
-11
@@ -83,8 +83,9 @@ impl SessionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Try to load existing session synchronously during construction
|
// Try to load existing session synchronously during construction
|
||||||
if let Ok(data) = std::fs::read_to_string(&manager.config.session_path) {
|
if let Ok(data) = std::fs::read_to_string(&manager.config.session_path)
|
||||||
if let Ok(session) = serde_json::from_str::<SessionData>(&data) {
|
&& let Ok(session) = serde_json::from_str::<SessionData>(&data)
|
||||||
|
{
|
||||||
// We can't await here, so we use try_write
|
// We can't await here, so we use try_write
|
||||||
if let Ok(mut guard) = manager.token.try_write() {
|
if let Ok(mut guard) = manager.token.try_write() {
|
||||||
*guard = Some(SecretString::from(session.session_token));
|
*guard = Some(SecretString::from(session.session_token));
|
||||||
@@ -94,7 +95,6 @@ impl SessionManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
manager
|
manager
|
||||||
}
|
}
|
||||||
@@ -356,8 +356,8 @@ impl SessionManager {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Parse GET /auth/callback?token=xxx&session_id=xxx&expires_at=xxx&is_new_user=xxx HTTP/1.1
|
// Parse GET /auth/callback?token=xxx&session_id=xxx&expires_at=xxx&is_new_user=xxx HTTP/1.1
|
||||||
if let Some(path) = request_line.split_whitespace().nth(1) {
|
if let Some(path) = request_line.split_whitespace().nth(1)
|
||||||
if path.starts_with("/auth/callback") {
|
&& path.starts_with("/auth/callback") {
|
||||||
// Parse query parameters
|
// Parse query parameters
|
||||||
if let Some(query) = path.split('?').nth(1) {
|
if let Some(query) = path.split('?').nth(1) {
|
||||||
let mut token = None;
|
let mut token = None;
|
||||||
@@ -453,7 +453,6 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Not the callback we're looking for, send 404
|
// Not the callback we're looking for, send 404
|
||||||
let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
|
let response = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n";
|
||||||
@@ -642,17 +641,16 @@ pub async fn create_session_manager(config: SessionConfig) -> Arc<SessionManager
|
|||||||
let manager = SessionManager::new_async(config).await;
|
let manager = SessionManager::new_async(config).await;
|
||||||
|
|
||||||
// Check for legacy env var and migrate if present and no file token
|
// Check for legacy env var and migrate if present and no file token
|
||||||
if !manager.has_token().await {
|
if !manager.has_token().await
|
||||||
if let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN") {
|
&& let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN")
|
||||||
if !token.is_empty() {
|
&& !token.is_empty()
|
||||||
|
{
|
||||||
tracing::info!("Migrating session token from NEARAI_SESSION_TOKEN env var to file");
|
tracing::info!("Migrating session token from NEARAI_SESSION_TOKEN env var to file");
|
||||||
manager.set_token(SecretString::from(token.clone())).await;
|
manager.set_token(SecretString::from(token.clone())).await;
|
||||||
if let Err(e) = manager.save_session(&token, None).await {
|
if let Err(e) = manager.save_session(&token, None).await {
|
||||||
tracing::warn!("Failed to save migrated session: {}", e);
|
tracing::warn!("Failed to save migrated session: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Arc::new(manager)
|
Arc::new(manager)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -255,14 +255,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let _ = dotenvy::dotenv();
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
// Enhanced first-run detection
|
// Enhanced first-run detection
|
||||||
if !cli.no_onboard {
|
if !cli.no_onboard
|
||||||
if let Some(reason) = check_onboard_needed().await {
|
&& let Some(reason) = check_onboard_needed().await
|
||||||
|
{
|
||||||
println!("Onboarding needed: {}", reason);
|
println!("Onboarding needed: {}", reason);
|
||||||
println!();
|
println!();
|
||||||
let mut wizard = SetupWizard::new();
|
let mut wizard = SetupWizard::new();
|
||||||
wizard.run().await?;
|
wizard.run().await?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Load bootstrap config (4 fields that must live on disk)
|
// Load bootstrap config (4 fields that must live on disk)
|
||||||
let bootstrap = ironclaw::bootstrap::BootstrapConfig::load();
|
let bootstrap = ironclaw::bootstrap::BootstrapConfig::load();
|
||||||
@@ -801,14 +801,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Inject owner_id for Telegram so the bot only responds
|
// Inject owner_id for Telegram so the bot only responds
|
||||||
// to the bound user account.
|
// to the bound user account.
|
||||||
if channel_name == "telegram" {
|
if channel_name == "telegram"
|
||||||
if let Some(owner_id) = config.channels.telegram_owner_id {
|
&& let Some(owner_id) = config.channels.telegram_owner_id
|
||||||
|
{
|
||||||
config_updates.insert(
|
config_updates.insert(
|
||||||
"owner_id".to_string(),
|
"owner_id".to_string(),
|
||||||
serde_json::json!(owner_id),
|
serde_json::json!(owner_id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if !config_updates.is_empty() {
|
if !config_updates.is_empty() {
|
||||||
channel_arc.update_config(config_updates).await;
|
channel_arc.update_config(config_updates).await;
|
||||||
@@ -898,8 +898,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Extract its routes for the unified server; the channel itself just
|
// Extract its routes for the unified server; the channel itself just
|
||||||
// provides the mpsc stream.
|
// provides the mpsc stream.
|
||||||
let mut webhook_server_addr: Option<std::net::SocketAddr> = None;
|
let mut webhook_server_addr: Option<std::net::SocketAddr> = None;
|
||||||
if !cli.cli_only {
|
if !cli.cli_only
|
||||||
if let Some(ref http_config) = config.channels.http {
|
&& let Some(ref http_config) = config.channels.http
|
||||||
|
{
|
||||||
let http_channel = HttpChannel::new(http_config.clone());
|
let http_channel = HttpChannel::new(http_config.clone());
|
||||||
webhook_routes.push(http_channel.routes());
|
webhook_routes.push(http_channel.routes());
|
||||||
let (host, port) = http_channel.addr();
|
let (host, port) = http_channel.addr();
|
||||||
@@ -915,7 +916,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
http_config.port
|
http_config.port
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Start the unified webhook server if any routes were registered.
|
// Start the unified webhook server if any routes were registered.
|
||||||
let mut webhook_server = if !webhook_routes.is_empty() {
|
let mut webhook_server = if !webhook_routes.is_empty() {
|
||||||
|
|||||||
@@ -339,8 +339,9 @@ async fn get_prompt_handler(
|
|||||||
Path(job_id): Path<Uuid>,
|
Path(job_id): Path<Uuid>,
|
||||||
) -> Result<(StatusCode, Json<serde_json::Value>), StatusCode> {
|
) -> Result<(StatusCode, Json<serde_json::Value>), StatusCode> {
|
||||||
let mut queue = state.prompt_queue.lock().await;
|
let mut queue = state.prompt_queue.lock().await;
|
||||||
if let Some(prompts) = queue.get_mut(&job_id) {
|
if let Some(prompts) = queue.get_mut(&job_id)
|
||||||
if let Some(prompt) = prompts.pop_front() {
|
&& let Some(prompt) = prompts.pop_front()
|
||||||
|
{
|
||||||
return Ok((
|
return Ok((
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
@@ -349,7 +350,6 @@ async fn get_prompt_handler(
|
|||||||
})),
|
})),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Return 204 with an empty body. The Json wrapper requires some value
|
// Return 204 with an empty body. The Json wrapper requires some value
|
||||||
// but the status code signals "nothing here".
|
// but the status code signals "nothing here".
|
||||||
|
|||||||
@@ -229,8 +229,9 @@ impl ContainerJobManager {
|
|||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
.join(".ironclaw")
|
.join(".ironclaw")
|
||||||
.join("projects");
|
.join("projects");
|
||||||
if let Ok(canonical_base) = projects_base.canonicalize() {
|
if let Ok(canonical_base) = projects_base.canonicalize()
|
||||||
if !canonical.starts_with(&canonical_base) {
|
&& !canonical.starts_with(&canonical_base)
|
||||||
|
{
|
||||||
return Err(OrchestratorError::ContainerCreationFailed {
|
return Err(OrchestratorError::ContainerCreationFailed {
|
||||||
job_id,
|
job_id,
|
||||||
reason: format!(
|
reason: format!(
|
||||||
@@ -240,7 +241,6 @@ impl ContainerJobManager {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
binds.push(format!("{}:/workspace:rw", canonical.display()));
|
binds.push(format!("{}:/workspace:rw", canonical.display()));
|
||||||
env_vec.push("IRONCLAW_WORKSPACE=/workspace".to_string());
|
env_vec.push("IRONCLAW_WORKSPACE=/workspace".to_string());
|
||||||
}
|
}
|
||||||
@@ -442,8 +442,9 @@ impl ContainerJobManager {
|
|||||||
let containers = self.containers.read().await;
|
let containers = self.containers.read().await;
|
||||||
containers.get(&job_id).map(|h| h.container_id.clone())
|
containers.get(&job_id).map(|h| h.container_id.clone())
|
||||||
};
|
};
|
||||||
if let Some(cid) = container_id {
|
if let Some(cid) = container_id
|
||||||
if !cid.is_empty() {
|
&& !cid.is_empty()
|
||||||
|
{
|
||||||
match connect_docker().await {
|
match connect_docker().await {
|
||||||
Ok(docker) => {
|
Ok(docker) => {
|
||||||
if let Err(e) = docker
|
if let Err(e) = docker
|
||||||
@@ -473,7 +474,6 @@ impl ContainerJobManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
self.token_store.revoke(job_id).await;
|
self.token_store.revoke(job_id).await;
|
||||||
|
|
||||||
tracing::info!(job_id = %job_id, "Completed worker container");
|
tracing::info!(job_id = %job_id, "Completed worker container");
|
||||||
|
|||||||
@@ -147,12 +147,12 @@ impl LeakDetector {
|
|||||||
// Build prefix matcher for patterns that start with a known prefix
|
// Build prefix matcher for patterns that start with a known prefix
|
||||||
let mut prefixes = Vec::new();
|
let mut prefixes = Vec::new();
|
||||||
for (idx, pattern) in patterns.iter().enumerate() {
|
for (idx, pattern) in patterns.iter().enumerate() {
|
||||||
if let Some(prefix) = extract_literal_prefix(pattern.regex.as_str()) {
|
if let Some(prefix) = extract_literal_prefix(pattern.regex.as_str())
|
||||||
if prefix.len() >= 3 {
|
&& prefix.len() >= 3
|
||||||
|
{
|
||||||
prefixes.push((prefix, idx));
|
prefixes.push((prefix, idx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let prefix_matcher = if !prefixes.is_empty() {
|
let prefix_matcher = if !prefixes.is_empty() {
|
||||||
let prefix_strings: Vec<&str> = prefixes.iter().map(|(s, _)| s.as_str()).collect();
|
let prefix_strings: Vec<&str> = prefixes.iter().map(|(s, _)| s.as_str()).collect();
|
||||||
|
|||||||
@@ -494,11 +494,11 @@ impl ContainerRunner {
|
|||||||
/// 3. `~/.docker/run/docker.sock` (Docker Desktop on macOS)
|
/// 3. `~/.docker/run/docker.sock` (Docker Desktop on macOS)
|
||||||
pub async fn connect_docker() -> Result<Docker> {
|
pub async fn connect_docker() -> Result<Docker> {
|
||||||
// First try bollard defaults (checks DOCKER_HOST, then /var/run/docker.sock)
|
// First try bollard defaults (checks DOCKER_HOST, then /var/run/docker.sock)
|
||||||
if let Ok(docker) = Docker::connect_with_local_defaults() {
|
if let Ok(docker) = Docker::connect_with_local_defaults()
|
||||||
if docker.ping().await.is_ok() {
|
&& docker.ping().await.is_ok()
|
||||||
|
{
|
||||||
return Ok(docker);
|
return Ok(docker);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Try Docker Desktop socket (macOS)
|
// Try Docker Desktop socket (macOS)
|
||||||
if let Some(home) = std::env::var_os("HOME") {
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
@@ -507,13 +507,12 @@ pub async fn connect_docker() -> Result<Docker> {
|
|||||||
let sock_str = desktop_sock.to_string_lossy();
|
let sock_str = desktop_sock.to_string_lossy();
|
||||||
if let Ok(docker) =
|
if let Ok(docker) =
|
||||||
Docker::connect_with_socket(&sock_str, 120, bollard::API_DEFAULT_VERSION)
|
Docker::connect_with_socket(&sock_str, 120, bollard::API_DEFAULT_VERSION)
|
||||||
|
&& docker.ping().await.is_ok()
|
||||||
{
|
{
|
||||||
if docker.ping().await.is_ok() {
|
|
||||||
return Ok(docker);
|
return Ok(docker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Err(SandboxError::DockerNotAvailable {
|
Err(SandboxError::DockerNotAvailable {
|
||||||
reason: "Socket not found: /var/run/docker.sock".to_string(),
|
reason: "Socket not found: /var/run/docker.sock".to_string(),
|
||||||
|
|||||||
@@ -259,12 +259,12 @@ async fn handle_connect(
|
|||||||
|
|
||||||
let decision = state.decider.decide(&network_req).await;
|
let decision = state.decider.decide(&network_req).await;
|
||||||
|
|
||||||
if !decision.is_allowed() {
|
if !decision.is_allowed()
|
||||||
if let NetworkDecision::Deny { reason } = decision {
|
&& let NetworkDecision::Deny { reason } = decision
|
||||||
|
{
|
||||||
tracing::info!("Proxy: blocked CONNECT {} - {}", host, reason);
|
tracing::info!("Proxy: blocked CONNECT {} - {}", host, reason);
|
||||||
return error_response(StatusCode::FORBIDDEN, reason);
|
return error_response(StatusCode::FORBIDDEN, reason);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
tracing::debug!("Proxy: allowing CONNECT to {}", host);
|
tracing::debug!("Proxy: allowing CONNECT to {}", host);
|
||||||
|
|
||||||
@@ -294,12 +294,12 @@ async fn forward_request(
|
|||||||
|
|
||||||
// Copy headers (except hop-by-hop headers)
|
// Copy headers (except hop-by-hop headers)
|
||||||
for (name, value) in req.headers() {
|
for (name, value) in req.headers() {
|
||||||
if !is_hop_by_hop_header(name.as_str()) {
|
if !is_hop_by_hop_header(name.as_str())
|
||||||
if let Ok(v) = value.to_str() {
|
&& let Ok(v) = value.to_str()
|
||||||
|
{
|
||||||
builder = builder.header(name.as_str(), v);
|
builder = builder.header(name.as_str(), v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Inject credentials if needed
|
// Inject credentials if needed
|
||||||
if let NetworkDecision::AllowWithCredentials {
|
if let NetworkDecision::AllowWithCredentials {
|
||||||
|
|||||||
@@ -109,13 +109,12 @@ impl NetworkPolicyDecider for DefaultPolicyDecider {
|
|||||||
async fn decide(&self, request: &NetworkRequest) -> NetworkDecision {
|
async fn decide(&self, request: &NetworkRequest) -> NetworkDecision {
|
||||||
// First check if the domain is allowed
|
// First check if the domain is allowed
|
||||||
let validation = self.allowlist.is_allowed(&request.host);
|
let validation = self.allowlist.is_allowed(&request.host);
|
||||||
if !validation.is_allowed() {
|
if !validation.is_allowed()
|
||||||
if let crate::sandbox::proxy::allowlist::DomainValidationResult::Denied(reason) =
|
&& let crate::sandbox::proxy::allowlist::DomainValidationResult::Denied(reason) =
|
||||||
validation
|
validation
|
||||||
{
|
{
|
||||||
return NetworkDecision::Deny { reason };
|
return NetworkDecision::Deny { reason };
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we need to inject credentials
|
// Check if we need to inject credentials
|
||||||
if let Some(mapping) = self.find_credential(&request.host) {
|
if let Some(mapping) = self.find_credential(&request.host) {
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ pub use platform::{delete_master_key, get_master_key, has_master_key, store_mast
|
|||||||
|
|
||||||
/// Parse a hex string to bytes.
|
/// Parse a hex string to bytes.
|
||||||
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, SecretError> {
|
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, SecretError> {
|
||||||
if hex.len() % 2 != 0 {
|
if !hex.len().is_multiple_of(2) {
|
||||||
return Err(SecretError::KeychainError(
|
return Err(SecretError::KeychainError(
|
||||||
"Invalid hex string length".to_string(),
|
"Invalid hex string length".to_string(),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -149,11 +149,11 @@ impl SecretsStore for PostgresSecretsStore {
|
|||||||
let secret = row_to_secret(&r);
|
let secret = row_to_secret(&r);
|
||||||
|
|
||||||
// Check expiration
|
// Check expiration
|
||||||
if let Some(expires_at) = secret.expires_at {
|
if let Some(expires_at) = secret.expires_at
|
||||||
if expires_at < Utc::now() {
|
&& expires_at < Utc::now()
|
||||||
|
{
|
||||||
return Err(SecretError::Expired);
|
return Err(SecretError::Expired);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(secret)
|
Ok(secret)
|
||||||
}
|
}
|
||||||
@@ -272,12 +272,12 @@ impl SecretsStore for PostgresSecretsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Simple glob: * matches any suffix
|
// Simple glob: * matches any suffix
|
||||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
if let Some(prefix) = pattern.strip_suffix('*')
|
||||||
if secret_name.starts_with(prefix) {
|
&& secret_name.starts_with(prefix)
|
||||||
|
{
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
@@ -430,12 +430,12 @@ pub mod testing {
|
|||||||
if pattern == secret_name {
|
if pattern == secret_name {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
if let Some(prefix) = pattern.strip_suffix('*')
|
||||||
if secret_name.starts_with(prefix) {
|
&& secret_name.starts_with(prefix)
|
||||||
|
{
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,8 +252,9 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
|
|||||||
|
|
||||||
// Find the first message with a sender
|
// Find the first message with a sender
|
||||||
for update in &body.result {
|
for update in &body.result {
|
||||||
if let Some(ref msg) = update.message {
|
if let Some(ref msg) = update.message
|
||||||
if let Some(ref from) = msg.from {
|
&& let Some(ref from) = msg.from
|
||||||
|
{
|
||||||
let display_name = from
|
let display_name = from
|
||||||
.username
|
.username
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -280,7 +281,6 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
print_error("Timed out waiting for a message. You can re-run setup to try again.");
|
print_error("Timed out waiting for a message. You can re-run setup to try again.");
|
||||||
print_info("Bot will accept messages from all users until owner is bound.");
|
print_info("Bot will accept messages from all users until owner is bound.");
|
||||||
|
|||||||
@@ -54,11 +54,12 @@ pub fn select_one(prompt: &str, options: &[&str]) -> io::Result<usize> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse number
|
// Parse number
|
||||||
if let Ok(num) = input.parse::<usize>() {
|
if let Ok(num) = input.parse::<usize>()
|
||||||
if num >= 1 && num <= options.len() {
|
&& num >= 1
|
||||||
|
&& num <= options.len()
|
||||||
|
{
|
||||||
return Ok(num - 1);
|
return Ok(num - 1);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
writeln!(
|
writeln!(
|
||||||
stdout,
|
stdout,
|
||||||
|
|||||||
+4
-5
@@ -344,8 +344,9 @@ impl SetupWizard {
|
|||||||
/// Step 3: NEAR AI authentication.
|
/// Step 3: NEAR AI authentication.
|
||||||
async fn step_authentication(&mut self) -> Result<(), SetupError> {
|
async fn step_authentication(&mut self) -> Result<(), SetupError> {
|
||||||
// Check if we already have a session
|
// Check if we already have a session
|
||||||
if let Some(ref session) = self.session_manager {
|
if let Some(ref session) = self.session_manager
|
||||||
if session.has_token().await {
|
&& session.has_token().await
|
||||||
|
{
|
||||||
print_info("Existing session found. Validating...");
|
print_info("Existing session found. Validating...");
|
||||||
match session.ensure_authenticated().await {
|
match session.ensure_authenticated().await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -357,7 +358,6 @@ impl SetupWizard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Create session manager if we don't have one
|
// Create session manager if we don't have one
|
||||||
let session = if let Some(ref s) = self.session_manager {
|
let session = if let Some(ref s) = self.session_manager {
|
||||||
@@ -642,12 +642,11 @@ impl SetupWizard {
|
|||||||
&installed_names,
|
&installed_names,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
|
&& !installed.is_empty()
|
||||||
{
|
{
|
||||||
if !installed.is_empty() {
|
|
||||||
print_success(&format!("Installed channels: {}", installed.join(", ")));
|
print_success(&format!("Installed channels: {}", installed.join(", ")));
|
||||||
discovered_channels = discover_wasm_channels(&channels_dir).await;
|
discovered_channels = discover_wasm_channels(&channels_dir).await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if we need secrets context
|
// Determine if we need secrets context
|
||||||
let needs_secrets = selected.contains(&1) || !selected_wasm_channels.is_empty();
|
let needs_secrets = selected.contains(&1) || !selected_wasm_channels.is_empty();
|
||||||
|
|||||||
@@ -326,8 +326,9 @@ impl TestHarness {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify expected output
|
// Verify expected output
|
||||||
if let Some(ref expected) = test.expected_output {
|
if let Some(ref expected) = test.expected_output
|
||||||
if &actual != expected {
|
&& &actual != expected
|
||||||
|
{
|
||||||
return TestResult {
|
return TestResult {
|
||||||
name: test.name.clone(),
|
name: test.name.clone(),
|
||||||
passed: false,
|
passed: false,
|
||||||
@@ -340,7 +341,6 @@ impl TestHarness {
|
|||||||
actual_output: Some(actual),
|
actual_output: Some(actual),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Verify expected fields
|
// Verify expected fields
|
||||||
if let Some(ref fields) = test.expected_fields {
|
if let Some(ref fields) = test.expected_fields {
|
||||||
@@ -357,8 +357,9 @@ impl TestHarness {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref expected_value) = field.value {
|
if let Some(ref expected_value) = field.value
|
||||||
if field_value != Some(expected_value) {
|
&& field_value != Some(expected_value)
|
||||||
|
{
|
||||||
return TestResult {
|
return TestResult {
|
||||||
name: test.name.clone(),
|
name: test.name.clone(),
|
||||||
passed: false,
|
passed: false,
|
||||||
@@ -372,7 +373,6 @@ impl TestHarness {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
TestResult {
|
TestResult {
|
||||||
name: test.name.clone(),
|
name: test.name.clone(),
|
||||||
|
|||||||
@@ -54,13 +54,13 @@ fn validate_url(url: &str) -> Result<reqwest::Url, ToolError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check literal IP addresses
|
// Check literal IP addresses
|
||||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
if let Ok(ip) = host.parse::<IpAddr>()
|
||||||
if is_disallowed_ip(&ip) {
|
&& is_disallowed_ip(&ip)
|
||||||
|
{
|
||||||
return Err(ToolError::NotAuthorized(
|
return Err(ToolError::NotAuthorized(
|
||||||
"private or local IPs are not allowed".to_string(),
|
"private or local IPs are not allowed".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve hostname and check all resolved IPs against the blocklist.
|
// Resolve hostname and check all resolved IPs against the blocklist.
|
||||||
// This prevents DNS rebinding where a hostname resolves to a private IP.
|
// This prevents DNS rebinding where a hostname resolves to a private IP.
|
||||||
|
|||||||
@@ -157,8 +157,9 @@ impl CreateJobTool {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Persist the job mode to DB
|
// Persist the job mode to DB
|
||||||
if mode == JobMode::ClaudeCode {
|
if mode == JobMode::ClaudeCode
|
||||||
if let Some(store) = self.store.clone() {
|
&& let Some(store) = self.store.clone()
|
||||||
|
{
|
||||||
let job_id_copy = job_id;
|
let job_id_copy = job_id;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = store
|
if let Err(e) = store
|
||||||
@@ -169,7 +170,6 @@ impl CreateJobTool {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Create the container job with the pre-determined job_id.
|
// Create the container job with the pre-determined job_id.
|
||||||
let _token = jm
|
let _token = jm
|
||||||
|
|||||||
@@ -343,13 +343,13 @@ impl ShellTool {
|
|||||||
|
|
||||||
// Use sandbox if configured; fail-closed (never silently fall through
|
// Use sandbox if configured; fail-closed (never silently fall through
|
||||||
// to unsandboxed execution when sandbox was intended).
|
// to unsandboxed execution when sandbox was intended).
|
||||||
if let Some(ref sandbox) = self.sandbox {
|
if let Some(ref sandbox) = self.sandbox
|
||||||
if sandbox.is_initialized() || sandbox.config().enabled {
|
&& (sandbox.is_initialized() || sandbox.config().enabled)
|
||||||
|
{
|
||||||
return self
|
return self
|
||||||
.execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
|
.execute_sandboxed(sandbox, cmd, &cwd, timeout_duration)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Only execute directly when no sandbox was configured at all.
|
// Only execute directly when no sandbox was configured at all.
|
||||||
let (output, code) = self.execute_direct(cmd, &cwd, timeout_duration).await?;
|
let (output, code) = self.execute_direct(cmd, &cwd, timeout_duration).await?;
|
||||||
|
|||||||
@@ -539,9 +539,9 @@ pub async fn wait_for_authorization_callback(
|
|||||||
.map_err(|e| AuthError::Http(e.to_string()))?;
|
.map_err(|e| AuthError::Http(e.to_string()))?;
|
||||||
|
|
||||||
// Parse GET /callback?code=xxx HTTP/1.1
|
// Parse GET /callback?code=xxx HTTP/1.1
|
||||||
if let Some(path) = request_line.split_whitespace().nth(1) {
|
if let Some(path) = request_line.split_whitespace().nth(1)
|
||||||
if path.starts_with("/callback") {
|
&& path.starts_with("/callback")
|
||||||
if let Some(query) = path.split('?').nth(1) {
|
&& let Some(query) = path.split('?').nth(1) {
|
||||||
// Check for error first
|
// Check for error first
|
||||||
if query.contains("error=") {
|
if query.contains("error=") {
|
||||||
let response = "HTTP/1.1 400 Bad Request\r\n\r\nAuthorization denied";
|
let response = "HTTP/1.1 400 Bad Request\r\n\r\nAuthorization denied";
|
||||||
@@ -578,8 +578,6 @@ pub async fn wait_for_authorization_callback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
|
let response = "HTTP/1.1 404 Not Found\r\n\r\n";
|
||||||
let _ = socket.write_all(response.as_bytes()).await;
|
let _ = socket.write_all(response.as_bytes()).await;
|
||||||
|
|||||||
+12
-16
@@ -184,11 +184,11 @@ impl McpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add Mcp-Session-Id header if we have a session
|
// Add Mcp-Session-Id header if we have a session
|
||||||
if let Some(ref session_manager) = self.session_manager {
|
if let Some(ref session_manager) = self.session_manager
|
||||||
if let Some(session_id) = session_manager.get_session_id(&self.server_name).await {
|
&& let Some(session_id) = session_manager.get_session_id(&self.server_name).await
|
||||||
|
{
|
||||||
req_builder = req_builder.header("Mcp-Session-Id", session_id);
|
req_builder = req_builder.header("Mcp-Session-Id", session_id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let response = req_builder
|
let response = req_builder
|
||||||
.send()
|
.send()
|
||||||
@@ -199,18 +199,16 @@ impl McpClient {
|
|||||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
if attempt == 0 {
|
if attempt == 0 {
|
||||||
// Try to refresh the token
|
// Try to refresh the token
|
||||||
if let Some(ref secrets) = self.secrets {
|
if let Some(ref secrets) = self.secrets
|
||||||
if let Some(ref config) = self.server_config {
|
&& let Some(ref config) = self.server_config
|
||||||
|
{
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"MCP token expired, attempting refresh for '{}'",
|
"MCP token expired, attempting refresh for '{}'",
|
||||||
self.server_name
|
self.server_name
|
||||||
);
|
);
|
||||||
match refresh_access_token(config, secrets, &self.user_id).await {
|
match refresh_access_token(config, secrets, &self.user_id).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::info!(
|
tracing::info!("MCP token refreshed for '{}'", self.server_name);
|
||||||
"MCP token refreshed for '{}'",
|
|
||||||
self.server_name
|
|
||||||
);
|
|
||||||
// Continue to next iteration to retry with new token
|
// Continue to next iteration to retry with new token
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -225,7 +223,6 @@ impl McpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return Err(ToolError::ExternalService(format!(
|
return Err(ToolError::ExternalService(format!(
|
||||||
"MCP server '{}' requires authentication. Run: ironclaw mcp auth {}",
|
"MCP server '{}' requires authentication. Run: ironclaw mcp auth {}",
|
||||||
self.server_name, self.server_name
|
self.server_name, self.server_name
|
||||||
@@ -245,8 +242,8 @@ impl McpClient {
|
|||||||
/// Parse the HTTP response into an MCP response.
|
/// Parse the HTTP response into an MCP response.
|
||||||
async fn parse_response(&self, response: reqwest::Response) -> Result<McpResponse, ToolError> {
|
async fn parse_response(&self, response: reqwest::Response) -> Result<McpResponse, ToolError> {
|
||||||
// Extract session ID from response header
|
// Extract session ID from response header
|
||||||
if let Some(ref session_manager) = self.session_manager {
|
if let Some(ref session_manager) = self.session_manager
|
||||||
if let Some(session_id) = response
|
&& let Some(session_id) = response
|
||||||
.headers()
|
.headers()
|
||||||
.get("Mcp-Session-Id")
|
.get("Mcp-Session-Id")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
@@ -255,7 +252,6 @@ impl McpClient {
|
|||||||
.update_session_id(&self.server_name, Some(session_id.to_string()))
|
.update_session_id(&self.server_name, Some(session_id.to_string()))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
@@ -316,12 +312,12 @@ impl McpClient {
|
|||||||
/// This should be called once per session to establish capabilities.
|
/// This should be called once per session to establish capabilities.
|
||||||
pub async fn initialize(&self) -> Result<InitializeResult, ToolError> {
|
pub async fn initialize(&self) -> Result<InitializeResult, ToolError> {
|
||||||
// Check if already initialized
|
// Check if already initialized
|
||||||
if let Some(ref session_manager) = self.session_manager {
|
if let Some(ref session_manager) = self.session_manager
|
||||||
if session_manager.is_initialized(&self.server_name).await {
|
&& session_manager.is_initialized(&self.server_name).await
|
||||||
|
{
|
||||||
// Return cached/default capabilities
|
// Return cached/default capabilities
|
||||||
return Ok(InitializeResult::default());
|
return Ok(InitializeResult::default());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we have a session
|
// Ensure we have a session
|
||||||
if let Some(ref session_manager) = self.session_manager {
|
if let Some(ref session_manager) = self.session_manager {
|
||||||
|
|||||||
@@ -96,11 +96,11 @@ impl ToolRegistry {
|
|||||||
if let Ok(mut tools) = self.tools.try_write() {
|
if let Ok(mut tools) = self.tools.try_write() {
|
||||||
tools.insert(name.clone(), tool);
|
tools.insert(name.clone(), tool);
|
||||||
// Mark as built-in so it can't be shadowed later
|
// Mark as built-in so it can't be shadowed later
|
||||||
if PROTECTED_TOOL_NAMES.contains(&name.as_str()) {
|
if PROTECTED_TOOL_NAMES.contains(&name.as_str())
|
||||||
if let Ok(mut builtins) = self.builtin_names.try_write() {
|
&& let Ok(mut builtins) = self.builtin_names.try_write()
|
||||||
|
{
|
||||||
builtins.insert(name.clone());
|
builtins.insert(name.clone());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
tracing::debug!("Registered tool: {}", name);
|
tracing::debug!("Registered tool: {}", name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,11 +209,11 @@ impl EndpointPattern {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check path prefix
|
// Check path prefix
|
||||||
if let Some(ref prefix) = self.path_prefix {
|
if let Some(ref prefix) = self.path_prefix
|
||||||
if !url_path.starts_with(prefix) {
|
&& !url_path.starts_with(prefix)
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check method
|
// Check method
|
||||||
if !self.methods.is_empty() {
|
if !self.methods.is_empty() {
|
||||||
@@ -237,15 +237,16 @@ impl EndpointPattern {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Support wildcard: *.example.com matches sub.example.com
|
// Support wildcard: *.example.com matches sub.example.com
|
||||||
if let Some(suffix) = self.host.strip_prefix("*.") {
|
if let Some(suffix) = self.host.strip_prefix("*.")
|
||||||
if url_host.ends_with(suffix) && url_host.len() > suffix.len() {
|
&& url_host.ends_with(suffix)
|
||||||
|
&& url_host.len() > suffix.len()
|
||||||
|
{
|
||||||
// Ensure there's a dot before the suffix (or it's the whole thing)
|
// Ensure there's a dot before the suffix (or it's the whole thing)
|
||||||
let prefix = &url_host[..url_host.len() - suffix.len()];
|
let prefix = &url_host[..url_host.len() - suffix.len()];
|
||||||
if prefix.ends_with('.') || prefix.is_empty() {
|
if prefix.ends_with('.') || prefix.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -291,12 +292,12 @@ impl SecretsCapability {
|
|||||||
if pattern == name {
|
if pattern == name {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
if let Some(prefix) = pattern.strip_suffix('*')
|
||||||
if name.starts_with(prefix) {
|
&& name.starts_with(prefix)
|
||||||
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,12 +158,12 @@ impl CredentialInjector {
|
|||||||
if pattern == name {
|
if pattern == name {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
if let Some(prefix) = pattern.strip_suffix('*')
|
||||||
if name.starts_with(prefix) {
|
&& name.starts_with(prefix)
|
||||||
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,14 +214,15 @@ fn host_matches_pattern(host: &str, pattern: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Support wildcard: *.example.com matches sub.example.com
|
// Support wildcard: *.example.com matches sub.example.com
|
||||||
if let Some(suffix) = pattern.strip_prefix("*.") {
|
if let Some(suffix) = pattern.strip_prefix("*.")
|
||||||
if host.ends_with(suffix) && host.len() > suffix.len() {
|
&& host.ends_with(suffix)
|
||||||
|
&& host.len() > suffix.len()
|
||||||
|
{
|
||||||
let prefix = &host[..host.len() - suffix.len()];
|
let prefix = &host[..host.len() - suffix.len()];
|
||||||
if prefix.ends_with('.') || prefix.is_empty() {
|
if prefix.ends_with('.') || prefix.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,14 +259,14 @@ impl near::agent::host::Host for StoreData {
|
|||||||
|
|
||||||
// Check Content-Length header for early rejection of oversized responses.
|
// Check Content-Length header for early rejection of oversized responses.
|
||||||
let max_response = max_response_bytes;
|
let max_response = max_response_bytes;
|
||||||
if let Some(cl) = response.content_length() {
|
if let Some(cl) = response.content_length()
|
||||||
if cl as usize > max_response {
|
&& cl as usize > max_response
|
||||||
|
{
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Response body too large: {} bytes exceeds limit of {} bytes",
|
"Response body too large: {} bytes exceeds limit of {} bytes",
|
||||||
cl, max_response
|
cl, max_response
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Read body with a size cap to prevent memory exhaustion.
|
// Read body with a size cap to prevent memory exhaustion.
|
||||||
let body = response
|
let body = response
|
||||||
|
|||||||
@@ -326,8 +326,9 @@ impl ClaudeBridgeRuntime {
|
|||||||
match serde_json::from_str::<ClaudeStreamEvent>(&line) {
|
match serde_json::from_str::<ClaudeStreamEvent>(&line) {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
// Capture session_id from system init
|
// Capture session_id from system init
|
||||||
if event.event_type == "system" {
|
if event.event_type == "system"
|
||||||
if let Some(ref sid) = event.session_id {
|
&& let Some(ref sid) = event.session_id
|
||||||
|
{
|
||||||
session_id = Some(sid.clone());
|
session_id = Some(sid.clone());
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
job_id = %self.config.job_id,
|
job_id = %self.config.job_id,
|
||||||
@@ -335,7 +336,6 @@ impl ClaudeBridgeRuntime {
|
|||||||
"Captured Claude session ID"
|
"Captured Claude session ID"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to our event payload and forward
|
// Convert to our event payload and forward
|
||||||
let payloads = stream_event_to_payloads(&event);
|
let payloads = stream_event_to_payloads(&event);
|
||||||
|
|||||||
@@ -333,20 +333,21 @@ impl Workspace {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (path, header) in identity_files {
|
for (path, header) in identity_files {
|
||||||
if let Ok(doc) = self.read(path).await {
|
if let Ok(doc) = self.read(path).await
|
||||||
if !doc.content.is_empty() {
|
&& !doc.content.is_empty()
|
||||||
|
{
|
||||||
parts.push(format!("{}\n\n{}", header, doc.content));
|
parts.push(format!("{}\n\n{}", header, doc.content));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Add today's memory context (last 2 days of daily logs)
|
// Add today's memory context (last 2 days of daily logs)
|
||||||
let today = Utc::now().date_naive();
|
let today = Utc::now().date_naive();
|
||||||
let yesterday = today.pred_opt().unwrap_or(today);
|
let yesterday = today.pred_opt().unwrap_or(today);
|
||||||
|
|
||||||
for date in [today, yesterday] {
|
for date in [today, yesterday] {
|
||||||
if let Ok(doc) = self.daily_log(date).await {
|
if let Ok(doc) = self.daily_log(date).await
|
||||||
if !doc.content.is_empty() {
|
&& !doc.content.is_empty()
|
||||||
|
{
|
||||||
let header = if date == today {
|
let header = if date == today {
|
||||||
"## Today's Notes"
|
"## Today's Notes"
|
||||||
} else {
|
} else {
|
||||||
@@ -355,7 +356,6 @@ impl Workspace {
|
|||||||
parts.push(format!("{}\n\n{}", header, doc.content));
|
parts.push(format!("{}\n\n{}", header, doc.content));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(parts.join("\n\n---\n\n"))
|
Ok(parts.join("\n\n---\n\n"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,13 +201,13 @@ pub fn reciprocal_rank_fusion(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Normalize scores to 0-1 range
|
// Normalize scores to 0-1 range
|
||||||
if let Some(max_score) = results.iter().map(|r| r.score).reduce(f32::max) {
|
if let Some(max_score) = results.iter().map(|r| r.score).reduce(f32::max)
|
||||||
if max_score > 0.0 {
|
&& max_score > 0.0
|
||||||
|
{
|
||||||
for result in &mut results {
|
for result in &mut results {
|
||||||
result.score /= max_score;
|
result.score /= max_score;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by minimum score
|
// Filter by minimum score
|
||||||
if config.min_score > 0.0 {
|
if config.min_score > 0.0 {
|
||||||
|
|||||||
@@ -302,13 +302,13 @@ async fn test_chat_completions_streaming() {
|
|||||||
if data == "[DONE]" {
|
if data == "[DONE]" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(chunk) = serde_json::from_str::<serde_json::Value>(data) {
|
if let Ok(chunk) = serde_json::from_str::<serde_json::Value>(data)
|
||||||
if let Some(content) = chunk["choices"][0]["delta"]["content"].as_str() {
|
&& let Some(content) = chunk["choices"][0]["delta"]["content"].as_str()
|
||||||
|
{
|
||||||
full_content.push_str(content);
|
full_content.push_str(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
assert!(
|
assert!(
|
||||||
full_content.contains("Stream test"),
|
full_content.contains("Stream test"),
|
||||||
"Expected reassembled content to contain 'Stream test', got: '{}'",
|
"Expected reassembled content to contain 'Stream test', got: '{}'",
|
||||||
|
|||||||
Reference in New Issue
Block a user