Compare commits

...
Author SHA1 Message Date
Jon Saad-FalconandClaude Opus 4.8 50993dfa4d fix(evals): honor --base-url/--api-key for first-party eval backends (#535)
`jarvis eval run --base-url ... --api-key ...` was silently dropped for
jarvis-direct/jarvis-agent (_build_backend only forwarded the flags to
hermes/openclaw) and ignored by terminalbench-native, which hardcoded
api_base="http://localhost:8000/v1". Worse, with --base-url set the
engine-discovery fallback silently substituted ANY healthy local engine
(observed: requested vllm + healthy endpoint at --base-url, got
OllamaEngine@localhost:11434 — the requested URL was never contacted).

Changes:
- _OpenAICompatibleEngine gains an api_key param (Bearer Authorization
  header on the httpx client; {ENGINE_ID}_API_KEY env fallback with
  hyphen-sanitized names; no header when unset).
- New non-registered OpenAICompatEngine + normalize_openai_base_url()
  (strips a single literal trailing "/v1" so request paths don't double).
- SystemBuilder.engine_instance() injects a pre-built engine; build()
  health-checks it and fails loudly naming the host instead of falling
  back to discovery. Discovery substitution after an explicit -e key now
  logs a warning.
- JarvisDirectBackend/JarvisAgentBackend accept base_url/api_key; on
  base_url they pin an OpenAICompatEngine to that endpoint with a
  fail-fast pre-flight (actionable error naming the URL and probe).
- _build_backend forwards base_url/api_key to first-party backends on
  the CLI path; _run_terminalbench_native receives --base-url as
  api_base (single /v1 suffix) and exports OPENAI_API_KEY around the
  in-process harness run (terminus-2 routes via LiteLLM).
- Suite TOML [backend.external] stays scoped to hermes/openclaw
  (suite_mode=True in the suite drivers) — first-party suite semantics
  are explicitly deferred. The config-host path is untouched.
- Help text updated on both CLI surfaces; KNOWN_BACKENDS now lists
  hermes/openclaw/terminalbench-native.

Fixes the eval-CLI endpoint gap reported by the downstream team.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:49:37 -07:00
github-actions[bot] 527f84f960 chore: update clone traffic data [skip ci] 2026-06-11 07:44:18 +00:00
8eaeb3a754 fix(windows): desktop backend spawn + model-aware engine selection (#533)
* fix(windows): desktop backend spawn (#531) + model-aware engine selection (#532)

Two runtime bugs found during end-to-end testing on a clean Windows 11
24H2 Azure VM.

#531 - Desktop "Failed to get response": run_jarvis_command spawned the
backend with .output(), which waits for the process to exit. `jarvis
serve` never exits, so the Tauri command hung forever (the Start button
never resolved); and it ran `uv run jarvis` with no cwd, so in a packaged
install -- where the cwd isn't the checkout -- `jarvis` wasn't found and
the server never started. Now: run from find_project_root(), and for
`serve` spawn detached (.spawn()), drain stderr, and poll /health for
readiness (mirrors start_backend); short commands keep .output().

The server layer itself was verified healthy on Windows (/health and
/v1/chat/completions both 200, localhost included) -- the fault was the
Tauri spawn path.

#532 - "OpenAI client not available" after reboot: when the local engine
is down, get_engine's fallback selected CloudEngine because health() is
True if ANY provider client exists -- without checking the resolved
model's provider has a client. A user with e.g. OPENROUTER_API_KEY and a
gpt-* model then hit the OpenAI path with no client. Add
CloudEngine.can_serve(model) (checks the specific provider client via the
same routing generate()/stream() use) + a default can_serve->True on the
base engine, and make get_engine model-aware so it skips an engine that
can't serve the model -- the user falls through to the helpful "no engine
available / start ollama" message instead.

Tests: engine discovery/cloud/model-matrix + cli serve/ask suites pass
(the one ask_e2e failure is a pre-existing version-banner flake, fails
identically on main). The Tauri crate couldn't be compiled locally (no
GTK/webkit sys-libs in this env); relies on CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(engine): cover model-aware engine selection + CloudEngine.can_serve (#532)

#533 added a `model` arg to get_engine and a can_serve() gate but shipped no
tests. Add them:
- get_engine skips a healthy engine that can't serve the requested model
  (the cloud-fallback-for-unservable-model case behind #532),
- model=None preserves the legacy model-agnostic selection,
- CloudEngine.can_serve gates on the per-provider client (gpt->OpenAI,
  claude->Anthropic, ...), verified empirically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
2026-06-10 19:52:36 -07:00
Robby ManihaniandClaude Opus 4.8 90b7d0cb9b fix(windows): installer encoding + Ollama readiness loop (#523)
Two bugs found during end-to-end testing on a clean Windows 11 24H2
Azure VM (closes #522). Both are dodged by the canonical `irm | iex`
one-liner but hit by the documented `-OutFile` fallback and any
non-interactive run.

1. Encoding. install.ps1 was UTF-8 without a BOM and contained em-dashes
   plus a box-drawing banner. Windows PowerShell 5.1 decodes BOM-less
   files with the legacy ANSI/OEM code page, mis-decoding the multi-byte
   sequences and desyncing the parser into cascading here-string parse
   errors. Converted the file to pure ASCII (em-dashes -> hyphens, banner
   -> ASCII art) so it parses no matter how it's read.

2. Ollama readiness loop. With $ErrorActionPreference='Stop', the probe
   `& $ollamaExe list 2>&1 | Out-Null` turned the daemon-not-up stderr
   into a terminating NativeCommandError, aborting the install on the
   first iteration and making the loop's own Start-Process serve retry +
   Write-Warn2 fallback dead code. Wrapped the probe in try/catch so it
   falls through to the self-start path as intended.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:15:57 -07:00
d7053c35d5 security: harden network-exposed surface (#509)
* security: harden network-exposed surface

Hardening for the network-reachable attack surface, prioritizing fixes
that are strong but do not change working local/loopback defaults.

- auth_middleware: constant-time API key comparison (secrets.compare_digest)
  for the HTTP path, and gate /metrics behind auth so operational counters
  are not readable unauthenticated. /health stays open.
- webhook_routes: fail closed when a channel's secret/token is unset. Twilio,
  BlueBubbles, WhatsApp (verify + inbound), and SendBlue now reject (403)
  instead of processing unsigned/unauthenticated input. Constant-time
  comparisons for BlueBubbles/SendBlue/WhatsApp verify token.
- http_request: follow redirects manually and re-run the SSRF check on every
  hop (capped at 5) so an allowed public URL cannot 30x-redirect to an
  internal/metadata address.
- api_routes /v1/memory/index: restrict indexing to OPENJARVIS_WORKSPACE roots
  when configured and refuse sensitive files (.env, keys, credentials).
- config.toml: default [server] host to 127.0.0.1 (loopback) with a comment
  on how to safely expose to a LAN (0.0.0.0 + API key).

Tests: new fail-closed webhook tests, /metrics auth tests, and SSRF
redirect block/follow tests; updated SendBlue tests for the new
secret-required behavior. Affected suites pass (95 tests), ruff clean.

* fix(http): keep SSRF redirect-following patchable via httpx.request

The manual redirect-following loop used a private httpx.Client, which
bypassed the `http_request.httpx.request` mock seam that consumers' tests
rely on (e.g. the twitter-bot GitHub-issue tests escaped to the real
network and 401'd). Issue each hop via module-level httpx.request with
follow_redirects=False instead — same per-hop SSRF re-check, restored
testability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:32:28 -07:00
03c5ec3e40 fix(chat): wire SystemPromptBuilder so persona files load in jarvis chat (fixes #458) (#497)
* fix(chat): wire SystemPromptBuilder so persona files load in jarvis chat (fixes #458)

* fix(chat): make `--persona none` actually disable persona files

This PR exposes `--persona none`, but SystemPromptBuilder._load_file read
empty paths as "." (Path("") -> ".") and raised IsADirectoryError, so the
documented opt-out crashed. Guard empty path_str so the "none" opt-out
(which _resolve_persona maps to empty file paths) cleanly injects no
persona. Adds an end-to-end regression test (building with persona
"none" must not raise). Also merges current main (branch was stale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:10:13 -07:00
36 changed files with 1465 additions and 133 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "107,695",
"message": "110,366",
"color": "green",
"namedLogo": "git"
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 107695,
"last_updated": "2026-06-10T07:32:26Z",
"total_clones": 110366,
"last_updated": "2026-06-11T07:44:18Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -75,6 +75,8 @@
"2026-06-05": 2127,
"2026-06-06": 2204,
"2026-06-07": 1174,
"2026-06-08": 2369
"2026-06-08": 2369,
"2026-06-09": 1361,
"2026-06-10": 1310
}
}
+6 -1
View File
@@ -106,6 +106,11 @@ enabled = true # Record traces for analysis
db_path = "~/.openjarvis/traces.db"
[server]
host = "0.0.0.0"
# Bind to loopback by default so the API is not exposed to the local network.
# To serve other devices on your LAN, set host = "0.0.0.0" AND set an API key
# (OPENJARVIS_API_KEY / `jarvis auth generate-key`) — startup refuses a
# non-loopback bind without a key. The "server" security profile also flips
# this to 0.0.0.0 intentionally.
host = "127.0.0.1"
port = 8000
agent = "native_openhands"
+30 -25
View File
@@ -5,12 +5,12 @@
.DESCRIPTION
Phase-1 of the native-Windows-support RFC (#298). Mirrors the
behavior of scripts/install/install.sh (the curl-pipe-bash installer
for Linux/WSL2/macOS) but for native Windows PowerShell no WSL,
for Linux/WSL2/macOS) but for native Windows PowerShell - no WSL,
no Docker, no MSYS2.
Steps:
1. Refuse non-Windows / Windows < 10.
2. Check Python 3.10 3.13 on PATH (3.14 has no numpy wheels yet,
2. Check Python 3.10 - 3.13 on PATH (3.14 has no numpy wheels yet,
see #432).
3. Check git on PATH.
4. Install uv (https://astral.sh/uv) if absent.
@@ -65,7 +65,7 @@ if (-not $Service -and $env:OPENJARVIS_SERVICE) { $Service = $true
if (-not $Force -and $env:OPENJARVIS_FORCE) { $Force = $true }
# ---------------------------------------------------------------------------
# Output helpers coloured but plain enough for Constrained Language Mode.
# Output helpers - coloured but plain enough for Constrained Language Mode.
# ---------------------------------------------------------------------------
function Write-Info ($msg) { Write-Host "[info] $msg" -ForegroundColor Cyan }
@@ -77,13 +77,13 @@ function Write-Fail ($msg) {
}
# ---------------------------------------------------------------------------
# Shared helpers winget bootstrap + PATH refresh
# Shared helpers - winget bootstrap + PATH refresh
# ---------------------------------------------------------------------------
# Pull the latest Machine + User PATH from the registry into the current
# PowerShell session. Tools installed by `winget install` (Python, git,
# Ollama, etc.) update the User PATH, but the running process inherits
# the parent shell's environment so without this refresh the just-
# the parent shell's environment - so without this refresh the just-
# installed tool stays invisible to subsequent `Get-Command` calls.
#
# CRITICAL: registry PATH entries can be REG_EXPAND_SZ (with literal
@@ -157,7 +157,7 @@ function Get-PythonCommand {
Write-Info "Checking Python (3.10 - 3.13)..."
$pythonExe = Get-PythonCommand
if (-not $pythonExe) {
Write-Info "Python not on PATH attempting auto-install via winget..."
Write-Info "Python not on PATH - attempting auto-install via winget..."
$pythonExe = Install-WithWinget -WingetId 'Python.Python.3.13' -CommandName 'python'
if (-not $pythonExe) {
Write-Fail @"
@@ -196,7 +196,7 @@ Write-Ok "Python $pyMajor.$pyMinor ($pythonExe)"
Write-Info "Checking git..."
$gitExe = (Get-Command git -ErrorAction SilentlyContinue).Source
if (-not $gitExe) {
Write-Info "git not on PATH attempting auto-install via winget..."
Write-Info "git not on PATH - attempting auto-install via winget..."
$gitExe = Install-WithWinget -WingetId 'Git.Git' -CommandName 'git'
if (-not $gitExe) {
Write-Fail @"
@@ -227,7 +227,7 @@ if (-not $uvExe) {
}
# The astral installer puts uv at %USERPROFILE%\.local\bin\uv.exe and
# adds that dir to the User PATH. The current process's PATH isn't
# refreshed automatically prepend the install dir so the rest of
# refreshed automatically - prepend the install dir so the rest of
# this script picks it up.
$uvDir = Join-Path $env:USERPROFILE '.local\bin'
if (Test-Path (Join-Path $uvDir 'uv.exe')) {
@@ -295,13 +295,13 @@ try {
Write-Ok "Dependencies installed"
# ---------------------------------------------------------------------------
# 7. Ollama install + start + wait for daemon
# 7. Ollama - install + start + wait for daemon
# ---------------------------------------------------------------------------
Write-Info "Checking Ollama..."
$ollamaExe = (Get-Command ollama -ErrorAction SilentlyContinue).Source
if (-not $ollamaExe) {
Write-Info " Ollama not on PATH downloading the official installer (~150 MB)..."
Write-Info " Ollama not on PATH - downloading the official installer (~150 MB)..."
$ollamaSetup = Join-Path $env:TEMP 'OllamaSetup.exe'
# SilentlyContinue is load-bearing in PS 5.1: the default progress
# bar renderer slows Invoke-WebRequest down 30x on large downloads
@@ -340,13 +340,18 @@ Write-Ok "Ollama ($ollamaExe)"
Write-Info "Waiting for Ollama daemon..."
$ollamaReady = $false
for ($i = 0; $i -lt 60; $i++) {
& $ollamaExe list 2>&1 | Out-Null
# 'ollama list' writes to stderr until the daemon is reachable; under
# $ErrorActionPreference='Stop' the 2>&1 merge surfaces that as a
# terminating NativeCommandError that would abort the whole install on
# the very first probe. Swallow it and rely on $LASTEXITCODE so the
# Start-Process serve fallback below actually runs (issue #522).
try { & $ollamaExe list 2>&1 | Out-Null } catch { }
if ($LASTEXITCODE -eq 0) {
$ollamaReady = $true
break
}
if ($i -eq 5) {
# Daemon clearly isn't auto-running start it ourselves. Ollama
# Daemon clearly isn't auto-running - start it ourselves. Ollama
# for Windows uses the tray app `ollama app.exe`; falling back to
# `ollama serve` works headless.
Start-Process -FilePath $ollamaExe -ArgumentList 'serve' -WindowStyle Hidden -ErrorAction SilentlyContinue
@@ -354,11 +359,11 @@ for ($i = 0; $i -lt 60; $i++) {
Start-Sleep -Seconds 1
}
if (-not $ollamaReady) {
Write-Warn2 "Ollama daemon didn't become ready in 60s. Continuing bg-orchestrator will retry later."
Write-Warn2 "Ollama daemon didn't become ready in 60s. Continuing - bg-orchestrator will retry later."
}
# ---------------------------------------------------------------------------
# 8. Pull a starter model (qwen3.5:2b ~1.5 GB)
# 8. Pull a starter model (qwen3.5:2b - ~1.5 GB)
# ---------------------------------------------------------------------------
$modelPullOk = $false
@@ -372,11 +377,11 @@ if ($ollamaReady) {
Write-Warn2 "ollama pull failed; the bg-orchestrator will retry once Ollama is reachable."
}
} else {
Write-Warn2 "Skipping model pull daemon wasn't ready."
Write-Warn2 "Skipping model pull - daemon wasn't ready."
}
# ---------------------------------------------------------------------------
# 9. jarvis.cmd shim so bare `jarvis` works in any new PowerShell
# 9. jarvis.cmd shim - so bare `jarvis` works in any new PowerShell
# ---------------------------------------------------------------------------
$binDir = Join-Path $installRoot 'bin'
@@ -387,7 +392,7 @@ if (-not (Test-Path $binDir)) {
}
# %~dp0 in a .cmd file resolves to the directory containing the script,
# so the shim is self-locating moving %LOCALAPPDATA%\OpenJarvis won't
# so the shim is self-locating - moving %LOCALAPPDATA%\OpenJarvis won't
# break it as long as the user moves the whole tree. `uv` is resolved
# from PATH at runtime (astral installer adds it to User PATH); avoids
# pinning to the install-time uv.exe path which can shift on uv updates.
@@ -400,7 +405,7 @@ uv run --project "%SRC%" jarvis %*
Set-Content -Path $shimPath -Value $shimContent -Encoding ASCII
# Add %LOCALAPPDATA%\OpenJarvis\bin to User PATH if it isn't already
# there. The current process won't see it until restart handled in the
# there. The current process won't see it until restart - handled in the
# final banner.
#
# Compare against the EXPANDED form: a previous install may have written
@@ -430,7 +435,7 @@ Write-Ok "jarvis shim installed at $shimPath"
$serviceScript = Join-Path $srcDir 'deploy\windows\jarvis-service.ps1'
$shouldInstallService = $false
# Pre-check admin if the user wants the service Register-ScheduledTask
# Pre-check admin if the user wants the service - Register-ScheduledTask
# requires elevation. We do this before the prompt so we don't ask "do
# you want the service?" only to fail with Access Denied after they say
# yes.
@@ -439,7 +444,7 @@ $isAdmin = ([Security.Principal.WindowsPrincipal] `
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if ($Service -and -not $isAdmin) {
Write-Fail "-Service was requested, but this PowerShell is not elevated. Register-ScheduledTask needs admin rights re-run from an elevated PowerShell, or drop -Service."
Write-Fail "-Service was requested, but this PowerShell is not elevated. Register-ScheduledTask needs admin rights - re-run from an elevated PowerShell, or drop -Service."
}
if ($Service) {
$shouldInstallService = $true
@@ -448,7 +453,7 @@ if ($Service) {
} elseif (-not $isAdmin) {
# Default to skip-with-explanation when we can't elevate, rather
# than prompting and then failing at Register-ScheduledTask.
Write-Warn2 "Skipping scheduled-task setup this PowerShell is not elevated."
Write-Warn2 "Skipping scheduled-task setup - this PowerShell is not elevated."
Write-Warn2 " Register-ScheduledTask requires admin. To install the service later:"
Write-Warn2 " Right-click PowerShell -> Run as administrator, then run:"
Write-Warn2 " powershell -ExecutionPolicy Bypass -File `"$serviceScript`" install"
@@ -464,7 +469,7 @@ if ($Service) {
$reply = Read-Host "Register OpenJarvis as a Windows scheduled task (auto-start at logon, loopback only)? [y/N]"
$shouldInstallService = ($reply -match '^[yY]')
} else {
Write-Warn2 "Non-interactive install skipping scheduled-task setup."
Write-Warn2 "Non-interactive install - skipping scheduled-task setup."
Write-Warn2 "To register the service later, run (from an elevated PowerShell):"
Write-Warn2 " powershell -ExecutionPolicy Bypass -File `"$serviceScript`" install"
}
@@ -487,9 +492,9 @@ if ($shouldInstallService) {
# ---------------------------------------------------------------------------
Write-Host ""
Write-Host " ┌──────────────────────────────────┐" -ForegroundColor Green
Write-Host " OpenJarvis install complete " -ForegroundColor Green
Write-Host " └──────────────────────────────────┘" -ForegroundColor Green
Write-Host " +----------------------------------+" -ForegroundColor Green
Write-Host " | OpenJarvis install complete |" -ForegroundColor Green
Write-Host " +----------------------------------+" -ForegroundColor Green
Write-Host ""
Write-Host " Repo: $srcDir"
+81 -11
View File
@@ -1540,19 +1540,89 @@ async fn fetch_models(api_url: String) -> Result<serde_json::Value, String> {
#[tauri::command]
async fn run_jarvis_command(args: Vec<String>) -> Result<String, String> {
let mut cmd_args = vec!["run".to_string(), "jarvis".to_string()];
cmd_args.extend(args);
let uv_bin = resolve_bin("uv");
let output = tokio::process::Command::new(&uv_bin)
.args(&cmd_args)
.output()
.await
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
let mut cmd_args = vec!["run".to_string(), "jarvis".to_string()];
cmd_args.extend(args.iter().cloned());
let mut cmd = tokio::process::Command::new(&uv_bin);
cmd.args(&cmd_args);
// Run from the project root so `uv run jarvis` resolves the OpenJarvis
// project regardless of the app's launch cwd. In a packaged install the
// cwd isn't the checkout, so without this `jarvis` isn't found and the
// backend never starts — the UI then shows "Failed to get response"
// (see #531).
if let Some(ref root) = find_project_root() {
cmd.current_dir(root);
}
let is_serve = args.first().map(|a| a.as_str() == "serve").unwrap_or(false);
if !is_serve {
// Short-lived command (e.g. `stop`, `status`): wait for it and return
// its captured output.
let output = cmd
.output()
.await
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
return if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
};
}
// `jarvis serve` is a long-running server that never exits. The old code
// used `.output()`, which waits for the process to exit and so hung this
// command forever — the "Start" button never resolved (#531). Spawn it
// detached instead, drain stderr (a full 4 KB Windows pipe can otherwise
// stall the child mid-startup, #309), and poll /health for readiness.
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to launch jarvis serve: {}", e))?;
let tail: StderrTail = Arc::new(Mutex::new(Vec::new()));
if let Some(stderr) = child.stderr.take() {
spawn_jarvis_stderr_drainer(stderr, tail.clone());
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
let url = format!("http://127.0.0.1:{}/health", JARVIS_PORT);
let deadline = tokio::time::Instant::now() + Duration::from_secs(120);
loop {
// Surface an early crash (bad venv, missing Rust ext, etc.) right away
// instead of waiting out the full readiness timeout.
if let Ok(Some(status)) = child.try_wait() {
let stderr = String::from_utf8_lossy(tail.lock().await.as_slice()).into_owned();
return Err(format!(
"jarvis serve exited (code {:?}) before becoming healthy:\n{}",
status.code(),
stderr.trim()
));
}
if let Ok(resp) = client.get(&url).send().await {
if resp.status().is_success() {
// Leave the server running (the Child is detached on drop —
// kill_on_drop defaults to false); `stop` tears it down.
return Ok(format!(
"jarvis serve is ready on http://127.0.0.1:{}",
JARVIS_PORT
));
}
}
if tokio::time::Instant::now() >= deadline {
return Err(format!(
"jarvis serve did not become healthy on port {} within 120s.",
JARVIS_PORT
));
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
+7 -1
View File
@@ -714,7 +714,13 @@ def ask(
register_builtin_models()
effective_engine_key = engine_key or config.intelligence.preferred_engine or None
resolved = get_engine(config, effective_engine_key)
# Pass the model we intend to run so engine selection can skip an engine
# that can't actually serve it (e.g. the cloud fallback when the local
# engine is down but only a non-OpenAI key is set — see #532). This is the
# -m flag or the configured default; when neither is set we leave it None
# and a model is chosen per-engine below.
selection_model = model_name or config.intelligence.default_model or None
resolved = get_engine(config, effective_engine_key, model=selection_model)
if resolved is None:
console.print(
"[red bold]No inference engine available.[/red bold]\n\n"
+43
View File
@@ -28,12 +28,22 @@ def _read_input(prompt: str = "You> ") -> Optional[str]:
@click.option("-a", "--agent", "agent_name", default=None, help="Agent type.")
@click.option("--tools", default=None, help="Comma-separated tool names.")
@click.option("--system", "system_prompt", default=None, help="Custom system prompt.")
@click.option(
"--persona",
"persona_name",
default=None,
help=(
"Named persona dir under ~/.openjarvis/personas/<name>/ "
"(overrides config). Pass 'none' to disable all persona files."
),
)
def chat(
engine_key: str | None,
model_name: str | None,
agent_name: str | None,
tools: str | None,
system_prompt: str | None,
persona_name: str | None,
) -> None:
"""Start an interactive multi-turn chat session.
@@ -48,6 +58,14 @@ def chat(
config = load_config()
import dataclasses as _dc
effective_mf = (
_dc.replace(config.memory_files, persona_name=persona_name)
if persona_name is not None
else config.memory_files
)
# Resolve engine
from openjarvis.engine import get_engine
from openjarvis.intelligence import register_builtin_models
@@ -121,6 +139,21 @@ def chat(
kwargs["interactive"] = True
kwargs["confirm_callback"] = _confirm
import inspect as _inspect
if (
"prompt_builder"
in _inspect.signature(agent_cls.__init__).parameters
):
from openjarvis.prompt.builder import SystemPromptBuilder
kwargs["prompt_builder"] = SystemPromptBuilder(
agent_template=config.agent.default_system_prompt or "",
memory_files_config=effective_mf,
system_prompt_config=config.system_prompt,
)
agent = agent_cls(engine, model, **kwargs)
except Exception as exc:
console.print(f"[yellow]Agent '{agent_key}' failed: {exc}[/yellow]")
@@ -147,6 +180,16 @@ def chat(
_notifications = NotificationDispatcher(get_status())
# Conversation state
if not system_prompt:
from openjarvis.prompt.builder import SystemPromptBuilder
builder = SystemPromptBuilder(
agent_template=config.agent.default_system_prompt or "",
memory_files_config=effective_mf,
system_prompt_config=config.system_prompt,
)
system_prompt = builder.build()
history: List[Message] = []
if system_prompt:
history.append(Message(role=Role.SYSTEM, content=system_prompt))
+1 -1
View File
@@ -332,7 +332,7 @@ def compose_bench(
for i, rc in enumerate(run_configs, 1):
console.print(f"\n[bold]Run {i}/{len(run_configs)}:[/bold] {rc.benchmark}")
try:
summary = _run_single(rc, console=console)
summary = _run_single(rc, console=console, suite_mode=True)
results_table.add_row(
rc.benchmark,
f"{summary.accuracy:.4f}",
+19 -5
View File
@@ -61,6 +61,12 @@ KNOWN_BENCHMARKS = {
KNOWN_BACKENDS = {
"jarvis-direct": "Engine-level inference (local or cloud)",
"jarvis-agent": "Agent-level inference with tool calling",
"hermes": "Real Hermes Agent (Nous Research) via subprocess",
"openclaw": "Real OpenClaw via Node subprocess",
"terminalbench-native": (
"TerminalBench V2.1 via terminal-bench Harness "
"(selected with -b terminalbench-native)"
),
}
@@ -146,7 +152,9 @@ def eval_list() -> None:
"base_url",
default=None,
help=(
"OpenAI-compat endpoint URL for hermes/openclaw backends "
"OpenAI-compatible endpoint for the model under eval. Required for "
"hermes/openclaw; for jarvis-direct/jarvis-agent/terminalbench-native "
"it bypasses engine discovery and targets this URL directly "
"(env: JARVIS_BACKEND_BASE_URL)."
),
)
@@ -154,7 +162,11 @@ def eval_list() -> None:
"--api-key",
"api_key",
default=None,
help=("API key for the hermes/openclaw endpoint (env: JARVIS_BACKEND_API_KEY)."),
help=(
"API key for the --base-url endpoint, sent as a Bearer token. "
"Required for hermes/openclaw; optional for first-party backends "
"(env: JARVIS_BACKEND_API_KEY)."
),
)
@click.option(
"--agent",
@@ -347,7 +359,7 @@ def eval_run(
f"{rc.benchmark} / {rc.model}"
)
try:
summary = _run_single(rc, console=console)
summary = _run_single(rc, console=console, suite_mode=True)
console.print(
f" [green]{summary.accuracy:.4f}[/green] "
f"({summary.correct}/{summary.scored_samples})"
@@ -399,8 +411,10 @@ def eval_run(
sheets_spreadsheet_id=sheets_spreadsheet_id,
sheets_worksheet=sheets_worksheet,
sheets_credentials_path=sheets_credentials_path,
# Spec §6.2 — for hermes/openclaw external backends. Falls back to env vars
# so users can also set JARVIS_BACKEND_BASE_URL/JARVIS_BACKEND_API_KEY.
# OpenAI-compatible endpoint for the model under eval. Required for
# hermes/openclaw (Spec §6.2); honored by first-party backends too on
# this CLI path. Falls back to env vars so users can also set
# JARVIS_BACKEND_BASE_URL/JARVIS_BACKEND_API_KEY.
base_url=base_url or os.environ.get("JARVIS_BACKEND_BASE_URL"),
api_key=api_key or os.environ.get("JARVIS_BACKEND_API_KEY"),
)
+7 -1
View File
@@ -146,7 +146,13 @@ def serve(
except Exception as exc:
logger.debug("Telemetry store init failed: %s", exc)
resolved = get_engine(config, engine_key)
# Select with the model we'll actually serve so an engine that can't
# serve it (e.g. the cloud fallback without the matching provider key) is
# skipped rather than chosen and failing per-request later (see #532).
selection_model = (
model_name or config.server.model or config.intelligence.default_model or None
)
resolved = get_engine(config, engine_key, model=selection_model)
if resolved is None:
console.print(
"[red bold]No inference engine available.[/red bold]\n\n"
+21 -5
View File
@@ -156,12 +156,26 @@ def discover_models(
def get_engine(
config: JarvisConfig, engine_key: str | None = None
config: JarvisConfig,
engine_key: str | None = None,
model: str | None = None,
) -> Tuple[str, InferenceEngine] | None:
"""Get a specific engine by key, or the default with fallback.
When *model* is given, an engine is selected only if it can actually
serve that model (``engine.can_serve(model)``). This stops the cloud
fallback from being chosen when the local engine is down for a model
whose provider client is missing, which otherwise surfaces as a confusing
"OpenAI client not available" instead of a helpful "start your local
engine" message (see #532). When *model* is ``None`` selection stays
model-agnostic (unchanged behaviour).
Returns ``(key, engine_instance)`` or ``None`` if no engine is available.
"""
def _usable(engine: InferenceEngine) -> bool:
return engine.health() and (model is None or engine.can_serve(model))
# Build an ordered list of keys to try, then fall back to full discovery.
keys_to_try: list[str] = []
if engine_key:
@@ -176,14 +190,16 @@ def get_engine(
continue
try:
engine = _make_engine(key, config)
if engine.health():
if _usable(engine):
return (key, engine)
except Exception as exc:
logger.debug("Engine %r health check failed: %s", key, exc)
# Fallback to any healthy engine
healthy = discover_engines(config)
return healthy[0] if healthy else None
# Fallback to the first healthy engine that can serve the model.
for key, engine in discover_engines(config):
if model is None or engine.can_serve(model):
return (key, engine)
return None
__all__ = ["discover_engines", "discover_models", "get_engine"]
+23 -4
View File
@@ -28,12 +28,31 @@ class _OpenAICompatibleEngine(InferenceEngine):
_default_host: str = "http://localhost:8000"
_api_prefix: str = "/v1"
def __init__(self, host: str | None = None, *, timeout: float = 600.0) -> None:
def __init__(
self,
host: str | None = None,
*,
api_key: str | None = None,
timeout: float = 600.0,
) -> None:
import os
env_key = f"{self.engine_id.upper()}_HOST"
self._host = (host or os.environ.get(env_key) or self._default_host).rstrip("/")
self._client = httpx.Client(base_url=self._host, timeout=timeout)
# Sanitize the engine id for env-var lookup ("openai-compat" ->
# "OPENAI_COMPAT_..."); shells cannot set hyphenated variable names.
env_prefix = self.engine_id.upper().replace("-", "_")
self._host = (
host or os.environ.get(f"{env_prefix}_HOST") or self._default_host
).rstrip("/")
# Bearer auth for endpoints started with e.g. ``vllm serve --api-key``.
# Setting it on the client covers generate/stream/stream_full/
# list_models/health alike; ``None`` keeps requests header-free.
self._api_key = api_key or os.environ.get(f"{env_prefix}_API_KEY") or None
headers = (
{"Authorization": f"Bearer {self._api_key}"} if self._api_key else None
)
self._client = httpx.Client(
base_url=self._host, timeout=timeout, headers=headers
)
# -- InferenceEngine interface ------------------------------------------
+11
View File
@@ -119,6 +119,17 @@ class InferenceEngine(ABC):
def health(self) -> bool:
"""Return ``True`` when the engine is reachable and healthy."""
def can_serve(self, model: str) -> bool:
"""Return ``True`` if this engine can serve *model*.
Defaults to ``True``: local engines accept any model id (whether a
specific model is *installed* is a separate concern from engine
selection). Engines that multiplex provider-specific clients (e.g.
the cloud engine) override this so selection can skip an engine whose
client for the model's provider isn't configured (see #532).
"""
return True
def close(self) -> None:
"""Release resources (HTTP clients, connections, threads, etc.)."""
+28
View File
@@ -1477,6 +1477,34 @@ class CloudEngine(InferenceEngine):
models.extend(_CODEX_MODELS)
return models
def _client_for_model(self, model: str) -> Any:
"""Return the provider client ``generate``/``stream`` will dispatch to
for *model* (mirrors the routing in those methods)."""
if _is_codex_model(model):
return self._codex_client
if _is_openrouter_model(model):
return self._openrouter_client
if _is_minimax_model(model):
return self._minimax_client
if _is_anthropic_model(model):
return self._anthropic_client
if _is_google_model(model):
return self._google_client
return self._openai_client
def can_serve(self, model: str) -> bool:
"""Return ``True`` only if the provider client for *model* exists.
``health()`` is ``True`` whenever *any* provider client is configured,
but a request for, say, a ``gpt-*`` model still needs the OpenAI
client specifically. Without this check the cloud engine gets picked
as a fallback (when the local engine is down) for a model it can't
serve, then dies at call time with "<provider> client not available"
instead of the user getting a helpful "start your local engine"
message (see #532).
"""
return self._client_for_model(model) is not None
def health(self) -> bool:
return (
self._openai_client is not None
+34 -1
View File
@@ -1,5 +1,7 @@
"""Data-driven registration of OpenAI-compatible inference engines."""
from __future__ import annotations
from openjarvis.core.registry import EngineRegistry
from openjarvis.engine._openai_compat import _OpenAICompatibleEngine
@@ -25,4 +27,35 @@ for _key, (_cls_name, _default_host, _api_prefix) in _ENGINES.items():
EngineRegistry.register(_key)(_cls)
globals()[_cls_name] = _cls
__all__ = [name for name, _, _ in _ENGINES.values()]
def normalize_openai_base_url(url: str) -> str:
"""Strip a single trailing ``/v1`` segment from a user-supplied base URL.
Users habitually pass ``http://host:8000/v1`` (the full OpenAI-compatible
prefix); the engine's ``_api_prefix`` re-appends ``/v1`` to every request
path, so a trailing copy would double up as ``/v1/v1``. Only a literal
trailing ``/v1`` is stripped proxy/gateway path prefixes are preserved.
"""
base = url.rstrip("/")
if base.endswith("/v1"):
base = base[: -len("/v1")]
return base
class OpenAICompatEngine(_OpenAICompatibleEngine):
"""Generic engine for an explicitly-provided OpenAI-compatible endpoint.
Deliberately NOT registered in ``EngineRegistry``: it is only ever
constructed with an explicit host (e.g. ``jarvis eval --base-url``), so
registering it would just add a useless localhost discovery probe and
interact with the per-test registry wipe.
"""
engine_id = "openai-compat"
_api_prefix = "/v1"
__all__ = [name for name, _, _ in _ENGINES.values()] + [
"OpenAICompatEngine",
"normalize_openai_base_url",
]
@@ -0,0 +1,52 @@
"""Shared helper for targeting an explicit OpenAI-compatible endpoint.
Used by the first-party eval backends (jarvis-direct, jarvis-agent) when
``--base-url`` is given: the eval must use exactly that endpoint, with no
silent fallback to whatever other engine discovery happens to find.
"""
from __future__ import annotations
import logging
from typing import Optional
logger = logging.getLogger(__name__)
def build_endpoint_engine(
base_url: str,
api_key: Optional[str] = None,
engine_key: Optional[str] = None,
):
"""Construct an :class:`OpenAICompatEngine` pinned to ``base_url``.
Pre-flight health-checks the endpoint and raises a loud, actionable
error when it is unreachable engine discovery is never consulted.
"""
from openjarvis.engine.openai_compat_engines import (
OpenAICompatEngine,
normalize_openai_base_url,
)
if engine_key:
logger.warning(
"Both an engine key (%r) and base_url (%r) were given; "
"base_url wins — targeting the endpoint directly.",
engine_key,
base_url,
)
host = normalize_openai_base_url(base_url)
engine = OpenAICompatEngine(host=host, api_key=api_key)
if not engine.health():
engine.close()
raise RuntimeError(
f"--base-url endpoint not reachable: {base_url} "
f"(GET {host}/v1/models failed). Is an OpenAI-compatible server "
"(e.g. `vllm serve`) running at that address? If it requires "
"authentication (HTTP 401), pass --api-key or set "
"JARVIS_BACKEND_API_KEY."
)
return engine
__all__ = ["build_endpoint_engine"]
+13 -1
View File
@@ -31,6 +31,8 @@ class JarvisAgentBackend(InferenceBackend):
max_turns: Optional[int] = None,
skills_enabled: bool = True,
overlay_dir: Optional[Path] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> None:
from openjarvis.system import SystemBuilder
@@ -40,7 +42,17 @@ class JarvisAgentBackend(InferenceBackend):
self._gpu_metrics = gpu_metrics
builder = SystemBuilder()
if engine_key:
if base_url:
# Explicit endpoint targeting (--base-url): pin the eval to
# exactly this OpenAI-compatible endpoint. Fails fast if it is
# unreachable; never falls back to a discovered engine.
from openjarvis.evals.backends._endpoint_util import (
build_endpoint_engine,
)
engine = build_endpoint_engine(base_url, api_key, engine_key)
builder.engine_instance(engine, key=engine_key or "openai-compat")
elif engine_key:
builder.engine(engine_key)
if model:
builder.model(model)
+13 -1
View File
@@ -24,6 +24,8 @@ class JarvisDirectBackend(InferenceBackend):
engine_key: Optional[str] = None,
telemetry: bool = False,
gpu_metrics: bool = False,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> None:
from openjarvis.system import SystemBuilder
@@ -31,7 +33,17 @@ class JarvisDirectBackend(InferenceBackend):
self._gpu_metrics = gpu_metrics
builder = SystemBuilder()
if engine_key:
if base_url:
# Explicit endpoint targeting (--base-url): pin the eval to
# exactly this OpenAI-compatible endpoint. Fails fast if it is
# unreachable; never falls back to a discovered engine.
from openjarvis.evals.backends._endpoint_util import (
build_endpoint_engine,
)
engine = build_endpoint_engine(base_url, api_key, engine_key)
builder.engine_instance(engine, key=engine_key or "openai-compat")
elif engine_key:
builder.engine(engine_key)
# Propagate gpu_metrics to the runtime config so SystemBuilder
# creates an EnergyMonitor / GpuMonitor for the InstrumentedEngine.
+111 -30
View File
@@ -183,14 +183,28 @@ def _build_backend(
max_turns: Optional[int] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
first_party_endpoint: bool = True,
):
"""Construct the appropriate backend.
For "hermes" and "openclaw" backends, ``base_url`` and ``api_key`` are
REQUIRED these foreign frameworks need an OpenAI-compatible endpoint
to send model calls to. Pass them via the eval config's
``[backend.external]`` section or env vars.
``base_url``/``api_key`` point at the OpenAI-compatible endpoint serving
the model under eval:
- For "hermes" and "openclaw" they are REQUIRED these foreign
frameworks always call out to an external endpoint.
- "jarvis-direct" and "jarvis-agent" honor them when
``first_party_endpoint`` is True (the CLI ``--base-url`` path): the
eval targets exactly that endpoint no engine-discovery fallback
and fails fast if it is unreachable. Suite mode passes
``first_party_endpoint=False`` so the suite TOML's
``[backend.external]`` section stays scoped to hermes/openclaw
(extending it to first-party backends is explicitly deferred).
"""
if not first_party_endpoint:
fp_base_url = fp_api_key = None
else:
fp_base_url, fp_api_key = base_url, api_key
if backend_name == "jarvis-agent":
from openjarvis.evals.backends.jarvis_agent import JarvisAgentBackend
@@ -202,6 +216,8 @@ def _build_backend(
gpu_metrics=gpu_metrics,
model=model,
max_turns=max_turns,
base_url=fp_base_url,
api_key=fp_api_key,
)
elif backend_name == "jarvis-direct":
from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend
@@ -210,6 +226,8 @@ def _build_backend(
engine_key=engine_key,
telemetry=telemetry,
gpu_metrics=gpu_metrics,
base_url=fp_base_url,
api_key=fp_api_key,
)
elif backend_name == "hermes":
from openjarvis.evals.backends.external import HermesBackend
@@ -656,8 +674,20 @@ def _build_trackers(config) -> list:
return trackers
def _run_terminalbench_native(config, console: Console) -> object:
"""Run TerminalBench V2.1 natively via terminal-bench Harness."""
def _run_terminalbench_native(
config,
console: Console,
*,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
) -> object:
"""Run TerminalBench V2.1 natively via terminal-bench Harness.
``base_url`` (from ``--base-url`` / JARVIS_BACKEND_BASE_URL) targets an
already-running OpenAI-compatible endpoint; when unset, the legacy local
vLLM default (http://localhost:8000/v1) is used.
"""
from openjarvis.engine.openai_compat_engines import normalize_openai_base_url
from openjarvis.evals.backends.terminalbench_native import (
TerminalBenchNativeBackend,
)
@@ -668,9 +698,16 @@ def _run_terminalbench_native(config, console: Console) -> object:
litellm_model = f"openai/{model}"
output_dir = getattr(config, "output_path", None) or "results/terminalbench-native/"
# Normalize to exactly one trailing "/v1" — LiteLLM's api_base wants the
# full OpenAI-compatible prefix, and users pass both forms of the URL.
if base_url:
api_base = normalize_openai_base_url(base_url) + "/v1"
else:
api_base = "http://localhost:8000/v1"
backend = TerminalBenchNativeBackend(
model=litellm_model,
api_base="http://localhost:8000/v1",
api_base=api_base,
temperature=config.temperature,
max_samples=config.max_samples,
output_dir=output_dir,
@@ -683,9 +720,25 @@ def _run_terminalbench_native(config, console: Console) -> object:
model_slug = re.sub(r"[^a-z0-9_-]", "-", model.lower().replace("/", "-"))
run_id = f"tb21-{model_slug}"
console.print(f" Running TerminalBench V2.1 natively: {model}")
console.print(f" API base: {api_base}")
console.print(f" Harness run_id: {run_id}")
results = backend.run_harness(run_id)
if api_key:
# terminus-2 routes model calls through LiteLLM with the "openai/"
# prefix, which reads OPENAI_API_KEY from the environment. The
# harness runs in-process, so set the var for the duration of the
# run and restore the previous value afterwards.
prev_key = os.environ.get("OPENAI_API_KEY")
os.environ["OPENAI_API_KEY"] = api_key
try:
results = backend.run_harness(run_id)
finally:
if prev_key is None:
os.environ.pop("OPENAI_API_KEY", None)
else:
os.environ["OPENAI_API_KEY"] = prev_key
else:
results = backend.run_harness(run_id)
# Convert BenchmarkResults to RunSummary
total = len(results.trial_results) if hasattr(results, "trial_results") else 0
@@ -711,18 +764,47 @@ def _run_terminalbench_native(config, console: Console) -> object:
)
def _run_single(config, console: Optional[Console] = None) -> object:
"""Run a single eval from a RunConfig and return the summary."""
def _run_single(
config,
console: Optional[Console] = None,
*,
suite_mode: bool = False,
) -> object:
"""Run a single eval from a RunConfig and return the summary.
``suite_mode=True`` (TOML-suite drivers) scopes ``config.base_url`` /
``config.api_key`` stamped from the suite's ``[backend.external]``
section onto every RunConfig to the hermes/openclaw backends only;
extending suite-level endpoint targeting to first-party backends is
explicitly deferred. The CLI single-run path (``suite_mode=False``)
honors ``--base-url``/``--api-key`` for every backend.
"""
from openjarvis.evals.core.runner import EvalRunner
if console is None:
console = Console()
_metadata = getattr(config, "metadata", None) or {}
base_url = (
getattr(config, "base_url", None)
or _metadata.get("base_url")
or os.environ.get("JARVIS_BACKEND_BASE_URL")
)
api_key = (
getattr(config, "api_key", None)
or _metadata.get("api_key")
or os.environ.get("JARVIS_BACKEND_API_KEY")
)
# TerminalBench V2.1 native: use terminal-bench Harness directly
if config.benchmark == "terminalbench-native":
return _run_terminalbench_native(config, console)
return _run_terminalbench_native(
config,
console,
base_url=None if suite_mode else base_url,
api_key=None if suite_mode else api_key,
)
_metadata = getattr(config, "metadata", None) or {}
eval_backend = _build_backend(
config.backend,
config.engine_key,
@@ -732,16 +814,9 @@ def _run_single(config, console: Optional[Console] = None) -> object:
gpu_metrics=getattr(config, "gpu_metrics", False),
model=config.model,
max_turns=getattr(config, "max_turns", None),
base_url=(
getattr(config, "base_url", None)
or _metadata.get("base_url")
or os.environ.get("JARVIS_BACKEND_BASE_URL")
),
api_key=(
getattr(config, "api_key", None)
or _metadata.get("api_key")
or os.environ.get("JARVIS_BACKEND_API_KEY")
),
base_url=base_url,
api_key=api_key,
first_party_endpoint=not suite_mode,
)
dataset = _build_dataset(config.benchmark)
# Inject engine config for benchmarks that run their own simulation
@@ -1070,7 +1145,7 @@ def _run_from_config(
f"Run {i}/{len(run_configs)}: {rc.benchmark} / {rc.model}",
)
try:
summary = _run_single(rc, console=console)
summary = _run_single(rc, console=console, suite_mode=True)
summaries.append(summary)
console.print(
f" [green]{summary.accuracy:.4f}[/green] "
@@ -1115,12 +1190,21 @@ def main():
@click.option(
"--base-url",
default=None,
help="OpenAI-compat endpoint for hermes/openclaw",
help=(
"OpenAI-compatible endpoint for the model under eval. Required for "
"hermes/openclaw; for jarvis-direct/jarvis-agent/terminalbench-native "
"it bypasses engine discovery and targets this URL directly "
"(env: JARVIS_BACKEND_BASE_URL)."
),
)
@click.option(
"--api-key",
default=None,
help="API key for hermes/openclaw endpoint",
help=(
"API key for the --base-url endpoint, sent as a Bearer token. "
"Required for hermes/openclaw; optional for first-party backends "
"(env: JARVIS_BACKEND_API_KEY)."
),
)
@click.option("-m", "--model", default=None, help="Model identifier")
@click.option(
@@ -1526,8 +1610,7 @@ def summarize(jsonl_path):
default=None,
type=click.Path(),
help=(
"Output JSONL path. Defaults to <jsonl>.reparsed when "
"--in-place is not set."
"Output JSONL path. Defaults to <jsonl>.reparsed when --in-place is not set."
),
)
@click.option(
@@ -1642,9 +1725,7 @@ def reparse_judge(jsonl_path, out_path, in_place, summary_out):
_json.dump(summary, f, indent=2)
old_cont = [float(s) for s in old_scores if s is not None]
old_acc = (
sum(1 for s in old_cont if s >= 0.5) / len(old_cont) if old_cont else 0.0
)
old_acc = sum(1 for s in old_cont if s >= 0.5) / len(old_cont) if old_cont else 0.0
old_mean = sum(old_cont) / len(old_cont) if old_cont else 0.0
new_mean = sum(cont) / len(cont) if cont else 0.0
mean_shift = new_mean - old_mean
+148
View File
@@ -4,6 +4,27 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch
import httpx
import pytest
import respx
def _mock_builder() -> MagicMock:
"""A SystemBuilder mock whose fluent methods chain like the real one."""
builder = MagicMock()
for method in (
"engine",
"engine_instance",
"model",
"agent",
"tools",
"telemetry",
"traces",
):
getattr(builder, method).return_value = builder
builder.build.return_value = MagicMock()
return builder
class TestJarvisDirectBackend:
@patch("openjarvis.system.SystemBuilder")
@@ -137,3 +158,130 @@ class TestJarvisAgentBackend:
assert result["content"] == "The answer is 4."
assert result["turns"] == 2
assert len(result["tool_results"]) == 1
class TestJarvisDirectBackendBaseUrl:
"""--base-url targeting for the jarvis-direct backend."""
@patch("openjarvis.system.SystemBuilder")
def test_base_url_injects_pinned_openai_compat_engine(self, mock_builder_cls):
from openjarvis.engine.openai_compat_engines import OpenAICompatEngine
from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend
mock_builder = _mock_builder()
mock_builder_cls.return_value = mock_builder
with respx.mock:
respx.get("http://127.0.0.1:18999/v1/models").mock(
return_value=httpx.Response(200, json={"data": []})
)
JarvisDirectBackend(base_url="http://127.0.0.1:18999/v1", api_key="sk-x")
mock_builder.engine_instance.assert_called_once()
injected = mock_builder.engine_instance.call_args[0][0]
assert isinstance(injected, OpenAICompatEngine)
# Trailing /v1 is normalized away so request paths don't double up.
assert injected._host == "http://127.0.0.1:18999"
assert injected._api_key == "sk-x"
# The discovery path must not be engaged at all.
mock_builder.engine.assert_not_called()
@patch("openjarvis.system.SystemBuilder")
def test_unreachable_base_url_fails_fast_naming_url(self, mock_builder_cls):
from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend
mock_builder = _mock_builder()
mock_builder_cls.return_value = mock_builder
with respx.mock:
respx.get("http://127.0.0.1:18998/v1/models").mock(
side_effect=httpx.ConnectError("connection refused")
)
with pytest.raises(RuntimeError, match=r"http://127\.0\.0\.1:18998"):
JarvisDirectBackend(base_url="http://127.0.0.1:18998")
# No silent engine substitution: the system is never built.
mock_builder.engine_instance.assert_not_called()
mock_builder.build.assert_not_called()
@patch("openjarvis.system.SystemBuilder")
def test_no_base_url_keeps_engine_key_path(self, mock_builder_cls):
from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend
mock_builder = _mock_builder()
mock_builder_cls.return_value = mock_builder
JarvisDirectBackend(engine_key="vllm")
mock_builder.engine.assert_called_with("vllm")
mock_builder.engine_instance.assert_not_called()
@patch("openjarvis.system.SystemBuilder")
def test_base_url_wins_over_engine_key(self, mock_builder_cls):
from openjarvis.evals.backends.jarvis_direct import JarvisDirectBackend
mock_builder = _mock_builder()
mock_builder_cls.return_value = mock_builder
with respx.mock:
respx.get("http://127.0.0.1:18999/v1/models").mock(
return_value=httpx.Response(200, json={"data": []})
)
JarvisDirectBackend(engine_key="vllm", base_url="http://127.0.0.1:18999")
mock_builder.engine.assert_not_called()
mock_builder.engine_instance.assert_called_once()
# The engine key is kept as the label for the injected engine.
assert mock_builder.engine_instance.call_args.kwargs["key"] == "vllm"
class TestJarvisAgentBackendBaseUrl:
"""--base-url targeting for the jarvis-agent backend."""
@patch("openjarvis.system.SystemBuilder")
def test_base_url_injects_pinned_openai_compat_engine(self, mock_builder_cls):
from openjarvis.engine.openai_compat_engines import OpenAICompatEngine
from openjarvis.evals.backends.jarvis_agent import JarvisAgentBackend
mock_builder = _mock_builder()
mock_builder_cls.return_value = mock_builder
with respx.mock:
respx.get("http://127.0.0.1:18999/v1/models").mock(
return_value=httpx.Response(200, json={"data": []})
)
JarvisAgentBackend(base_url="http://127.0.0.1:18999/v1", api_key="sk-x")
mock_builder.engine_instance.assert_called_once()
injected = mock_builder.engine_instance.call_args[0][0]
assert isinstance(injected, OpenAICompatEngine)
assert injected._host == "http://127.0.0.1:18999"
assert injected._api_key == "sk-x"
mock_builder.engine.assert_not_called()
@patch("openjarvis.system.SystemBuilder")
def test_unreachable_base_url_fails_fast_naming_url(self, mock_builder_cls):
from openjarvis.evals.backends.jarvis_agent import JarvisAgentBackend
mock_builder = _mock_builder()
mock_builder_cls.return_value = mock_builder
with respx.mock:
respx.get("http://127.0.0.1:18998/v1/models").mock(
side_effect=httpx.ConnectError("connection refused")
)
with pytest.raises(RuntimeError, match=r"http://127\.0\.0\.1:18998"):
JarvisAgentBackend(base_url="http://127.0.0.1:18998")
mock_builder.engine_instance.assert_not_called()
mock_builder.build.assert_not_called()
@patch("openjarvis.system.SystemBuilder")
def test_no_base_url_keeps_engine_key_path(self, mock_builder_cls):
from openjarvis.evals.backends.jarvis_agent import JarvisAgentBackend
mock_builder = _mock_builder()
mock_builder_cls.return_value = mock_builder
JarvisAgentBackend(engine_key="vllm")
mock_builder.engine.assert_called_with("vllm")
mock_builder.engine_instance.assert_not_called()
@@ -0,0 +1,190 @@
"""--base-url/--api-key forwarding through the eval CLI plumbing.
Covers the fix for the eval-CLI endpoint gap: the flags used to be silently
dropped for jarvis-direct/jarvis-agent and ignored by terminalbench-native
(which hardcoded api_base="http://localhost:8000/v1").
"""
from __future__ import annotations
import io
import os
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import click
import pytest
from rich.console import Console
from openjarvis.evals.cli import _build_backend, _run_terminalbench_native
from openjarvis.evals.core.types import RunConfig
def _quiet_console() -> Console:
return Console(file=io.StringIO())
def _tb_config(**overrides) -> RunConfig:
defaults = dict(
benchmark="terminalbench-native",
backend="jarvis-direct",
model="my-model",
max_samples=1,
max_workers=1,
temperature=0.2,
)
defaults.update(overrides)
return RunConfig(**defaults)
class TestBuildBackendForwardsEndpoint:
@patch("openjarvis.evals.backends.jarvis_direct.JarvisDirectBackend")
def test_jarvis_direct_receives_base_url_and_api_key(self, mock_cls):
_build_backend(
"jarvis-direct",
"vllm",
"orchestrator",
[],
base_url="http://node7:8123/v1",
api_key="sk-k",
)
kwargs = mock_cls.call_args.kwargs
assert kwargs["base_url"] == "http://node7:8123/v1"
assert kwargs["api_key"] == "sk-k"
@patch("openjarvis.evals.backends.jarvis_agent.JarvisAgentBackend")
def test_jarvis_agent_receives_base_url_and_api_key(self, mock_cls):
_build_backend(
"jarvis-agent",
"vllm",
"orchestrator",
["calculator"],
base_url="http://node7:8123/v1",
api_key="sk-k",
)
kwargs = mock_cls.call_args.kwargs
assert kwargs["base_url"] == "http://node7:8123/v1"
assert kwargs["api_key"] == "sk-k"
@patch("openjarvis.evals.backends.jarvis_direct.JarvisDirectBackend")
def test_suite_mode_scopes_endpoint_to_external_backends(self, mock_cls):
"""[backend.external] suite semantics stay hermes/openclaw-only:
first_party_endpoint=False must not forward to first-party."""
_build_backend(
"jarvis-direct",
"vllm",
"orchestrator",
[],
base_url="http://node7:8123/v1",
api_key="sk-k",
first_party_endpoint=False,
)
kwargs = mock_cls.call_args.kwargs
assert kwargs["base_url"] is None
assert kwargs["api_key"] is None
def test_hermes_still_requires_base_url_and_api_key(self):
with pytest.raises(click.UsageError, match="hermes"):
_build_backend("hermes", None, "orchestrator", [])
def test_openclaw_still_requires_base_url_and_api_key(self):
with pytest.raises(click.UsageError, match="openclaw"):
_build_backend("openclaw", None, "orchestrator", [])
class TestTerminalBenchNativeApiBase:
@patch("openjarvis.evals.backends.terminalbench_native.TerminalBenchNativeBackend")
def test_base_url_passed_through_as_api_base(self, mock_cls):
mock_backend = MagicMock()
mock_backend.run_harness.return_value = SimpleNamespace(trial_results=[])
mock_cls.return_value = mock_backend
_run_terminalbench_native(
_tb_config(),
_quiet_console(),
base_url="http://node7:8123/v1",
)
assert mock_cls.call_args.kwargs["api_base"] == "http://node7:8123/v1"
@patch("openjarvis.evals.backends.terminalbench_native.TerminalBenchNativeBackend")
def test_base_url_without_v1_gets_single_v1_suffix(self, mock_cls):
mock_backend = MagicMock()
mock_backend.run_harness.return_value = SimpleNamespace(trial_results=[])
mock_cls.return_value = mock_backend
_run_terminalbench_native(
_tb_config(),
_quiet_console(),
base_url="http://node7:8123",
)
assert mock_cls.call_args.kwargs["api_base"] == "http://node7:8123/v1"
@patch("openjarvis.evals.backends.terminalbench_native.TerminalBenchNativeBackend")
def test_default_api_base_unchanged_without_base_url(self, mock_cls):
mock_backend = MagicMock()
mock_backend.run_harness.return_value = SimpleNamespace(trial_results=[])
mock_cls.return_value = mock_backend
_run_terminalbench_native(_tb_config(), _quiet_console())
assert mock_cls.call_args.kwargs["api_base"] == "http://localhost:8000/v1"
@patch("openjarvis.evals.backends.terminalbench_native.TerminalBenchNativeBackend")
def test_api_key_exported_as_openai_api_key_during_run(self, mock_cls, monkeypatch):
"""terminus-2 reads OPENAI_API_KEY via LiteLLM; the var must be set
during harness.run() and restored afterwards."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
seen: dict = {}
def fake_run_harness(run_id):
seen["openai_api_key"] = os.environ.get("OPENAI_API_KEY")
return SimpleNamespace(trial_results=[])
mock_backend = MagicMock()
mock_backend.run_harness.side_effect = fake_run_harness
mock_cls.return_value = mock_backend
_run_terminalbench_native(
_tb_config(),
_quiet_console(),
base_url="http://node7:8123/v1",
api_key="sk-tb",
)
assert seen["openai_api_key"] == "sk-tb"
assert "OPENAI_API_KEY" not in os.environ # restored
@patch("openjarvis.evals.backends.terminalbench_native.TerminalBenchNativeBackend")
def test_preexisting_openai_api_key_restored(self, mock_cls, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-original")
mock_backend = MagicMock()
mock_backend.run_harness.return_value = SimpleNamespace(trial_results=[])
mock_cls.return_value = mock_backend
_run_terminalbench_native(
_tb_config(),
_quiet_console(),
base_url="http://node7:8123/v1",
api_key="sk-tb",
)
assert os.environ["OPENAI_API_KEY"] == "sk-original"
class TestRunSingleSuiteModeGating:
@patch("openjarvis.evals.cli._run_terminalbench_native")
def test_suite_mode_drops_endpoint_for_terminalbench(self, mock_tb):
from openjarvis.evals.cli import _run_single
mock_tb.return_value = SimpleNamespace(accuracy=0.0)
config = _tb_config(base_url="http://node7:8123/v1", api_key="sk-k")
_run_single(config, console=_quiet_console(), suite_mode=True)
assert mock_tb.call_args.kwargs["base_url"] is None
assert mock_tb.call_args.kwargs["api_key"] is None
@patch("openjarvis.evals.cli._run_terminalbench_native")
def test_cli_mode_forwards_endpoint_for_terminalbench(self, mock_tb):
from openjarvis.evals.cli import _run_single
mock_tb.return_value = SimpleNamespace(accuracy=0.0)
config = _tb_config(base_url="http://node7:8123/v1", api_key="sk-k")
_run_single(config, console=_quiet_console())
assert mock_tb.call_args.kwargs["base_url"] == "http://node7:8123/v1"
assert mock_tb.call_args.kwargs["api_key"] == "sk-k"
+5
View File
@@ -229,6 +229,11 @@ class SystemPromptBuilder:
)
def _load_file(self, path_str: str, max_chars: int) -> str:
# An empty path means "no file" (e.g. the persona "none" opt-out, which
# resolves to empty paths). Guard before Path("") — which becomes "." —
# so reading it does not raise IsADirectoryError.
if not path_str:
return ""
path = Path(path_str).expanduser()
if not path.exists():
return ""
+25
View File
@@ -285,14 +285,39 @@ async def memory_config(request: Request):
async def memory_index(req: MemoryIndexRequest, request: Request):
"""Index files from a path into memory."""
try:
import os
from pathlib import Path
from openjarvis.security.file_policy import is_sensitive_file
from openjarvis.tools.storage.ingest import ingest_path
target = Path(req.path).expanduser().resolve()
if not target.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {req.path}")
# Sandbox: when workspace roots are configured via OPENJARVIS_WORKSPACE
# (os.pathsep-separated), only allow indexing inside them. This endpoint
# must not become an arbitrary-filesystem read primitive over the API.
workspace = os.environ.get("OPENJARVIS_WORKSPACE", "").strip()
if workspace:
roots = [
Path(d).expanduser().resolve()
for d in workspace.split(os.pathsep)
if d.strip()
]
if not any(
target == root or root in target.parents for root in roots
):
raise HTTPException(
status_code=403,
detail="Path is outside the allowed workspace directories.",
)
# Never ingest sensitive files (.env, private keys, credentials, ...).
if target.is_file() and is_sensitive_file(target):
raise HTTPException(
status_code=403, detail="Refusing to index a sensitive file."
)
backend = _get_memory_backend(request)
if backend is None:
raise HTTPException(status_code=503, detail="Memory is not configured")
+16 -3
View File
@@ -33,7 +33,10 @@ class AuthMiddleware(BaseHTTPMiddleware):
status_code=401,
)
scheme, _, token = auth.partition(" ")
if scheme.lower() != "bearer" or token != self._api_key:
# Constant-time comparison to avoid leaking the key via timing.
if scheme.lower() != "bearer" or not secrets.compare_digest(
token, self._api_key
):
return JSONResponse(
{"detail": "Invalid API key"},
status_code=401,
@@ -42,8 +45,18 @@ class AuthMiddleware(BaseHTTPMiddleware):
@staticmethod
def _requires_auth(path: str) -> bool:
"""Only protect API routes, not the frontend UI or static assets."""
return path.startswith("/v1/") or path.startswith("/api/")
"""Protect API routes and operational metrics; leave the UI/health open.
``/metrics`` exposes request/token counters that should not be readable
by unauthenticated clients, so it is gated alongside ``/v1`` and
``/api``. ``/health`` stays open for liveness probes.
"""
return (
path.startswith("/v1/")
or path.startswith("/api/")
or path == "/metrics"
or path.startswith("/metrics/")
)
+45 -24
View File
@@ -113,7 +113,14 @@ def create_webhook_router(
signature = request.headers.get("X-Twilio-Signature", "")
url = str(request.url)
if twilio_auth_token and not _validate_twilio_signature(
# Fail closed: an unconfigured token means we cannot verify the sender,
# so reject rather than trust unsigned input.
if not twilio_auth_token:
logger.error(
"Twilio webhook rejected: TWILIO_AUTH_TOKEN not configured."
)
return Response("Webhook signature verification not configured", 403)
if not _validate_twilio_signature(
twilio_auth_token, url, params, signature
):
return Response("Invalid signature", status_code=403)
@@ -257,7 +264,13 @@ def create_webhook_router(
request: Request,
) -> Response:
auth = request.headers.get("Authorization", "")
if bluebubbles_password and auth != bluebubbles_password:
# Fail closed when no password is configured.
if not bluebubbles_password:
logger.error(
"BlueBubbles webhook rejected: password not configured."
)
return Response("Webhook authentication not configured", 403)
if not hmac.compare_digest(auth, bluebubbles_password):
return Response("Invalid password", status_code=403)
payload = await request.json()
@@ -292,7 +305,11 @@ def create_webhook_router(
token = request.query_params.get("hub.verify_token", "")
challenge = request.query_params.get("hub.challenge", "")
if mode == "subscribe" and token == whatsapp_verify_token:
# Fail closed: never echo the challenge if no verify token is set,
# otherwise an empty token would match an empty query value.
if not whatsapp_verify_token:
return Response("Forbidden", status_code=403)
if mode == "subscribe" and hmac.compare_digest(token, whatsapp_verify_token):
return PlainTextResponse(challenge)
return Response("Forbidden", status_code=403)
@@ -302,19 +319,23 @@ def create_webhook_router(
) -> Response:
body_bytes = await request.body()
# Verify signature
if whatsapp_app_secret:
signature = request.headers.get("X-Hub-Signature-256", "")
expected = (
"sha256="
+ hmac.new(
whatsapp_app_secret.encode(),
body_bytes,
hashlib.sha256,
).hexdigest()
# Fail closed: reject when no app secret is configured to verify HMAC.
if not whatsapp_app_secret:
logger.error(
"WhatsApp webhook rejected: app secret not configured."
)
if not hmac.compare_digest(signature, expected):
return Response("Invalid signature", status_code=403)
return Response("Webhook signature verification not configured", 403)
signature = request.headers.get("X-Hub-Signature-256", "")
expected = (
"sha256="
+ hmac.new(
whatsapp_app_secret.encode(),
body_bytes,
hashlib.sha256,
).hexdigest()
)
if not hmac.compare_digest(signature, expected):
return Response("Invalid signature", status_code=403)
payload = json.loads(body_bytes)
for entry in payload.get("entry", []):
@@ -349,16 +370,16 @@ def create_webhook_router(
# Get the SendBlue channel — may be passed at init or set later
sb = sendblue_channel or getattr(request.app.state, "sendblue_channel", None)
# Verify webhook secret if configured
if sb and sb.webhook_secret:
header_secret = request.headers.get("x-sendblue-secret", "")
if header_secret != sb.webhook_secret:
return Response("Invalid secret", status_code=403)
elif sb:
logger.warning(
"SendBlue webhook received without secret verification. "
"Set webhook_secret for HMAC validation."
# Fail closed: require a configured channel + webhook secret to verify
# the sender before processing any inbound message.
if sb is None or not getattr(sb, "webhook_secret", ""):
logger.error(
"SendBlue webhook rejected: webhook_secret not configured."
)
return Response("Webhook secret not configured", status_code=403)
header_secret = request.headers.get("x-sendblue-secret", "")
if not hmac.compare_digest(header_secret, sb.webhook_secret):
return Response("Invalid secret", status_code=403)
# Ignore outbound status callbacks
if payload.get("is_outbound", False):
+45 -1
View File
@@ -33,6 +33,8 @@ class SystemBuilder:
self._config = load_config()
self._engine_key: Optional[str] = None
self._engine_instance: Optional[InferenceEngine] = None
self._engine_instance_key: Optional[str] = None
self._model: Optional[str] = None
self._agent_name: Optional[str] = None
self._tool_names: Optional[List[str]] = None
@@ -50,6 +52,20 @@ class SystemBuilder:
self._engine_key = key
return self
def engine_instance(
self, engine: InferenceEngine, key: str = "openai-compat"
) -> SystemBuilder:
"""Inject a pre-built engine instance, bypassing engine discovery.
Used by callers that must target one exact endpoint (e.g.
``jarvis eval --base-url``). ``build()`` health-checks the instance
and raises a loud error if it is unreachable it never silently
substitutes a different discovered engine.
"""
self._engine_instance = engine
self._engine_instance_key = key
return self
def model(self, name: str) -> SystemBuilder:
self._model = name
return self
@@ -303,6 +319,23 @@ class SystemBuilder:
return system
def _resolve_engine(self, config: JarvisConfig):
# An explicitly injected engine instance always wins and is never
# silently replaced: when the caller pinned an endpoint (e.g.
# ``jarvis eval --base-url``) and it is down, substituting whatever
# other engine discovery finds would silently run against the wrong
# model server. Fail loudly instead.
if self._engine_instance is not None:
engine = self._engine_instance
key = self._engine_instance_key or "openai-compat"
if not engine.health():
host = getattr(engine, "_host", "<unknown host>")
raise RuntimeError(
f"Injected engine {key!r} is not reachable at {host}"
"is the endpoint running and serving GET /v1/models? "
"Refusing to fall back to engine discovery."
)
return engine, key
from openjarvis.engine._discovery import get_engine
pref = config.intelligence.preferred_engine
@@ -313,7 +346,18 @@ class SystemBuilder:
"No inference engine available. "
"Make sure an engine is running (e.g. ollama serve)."
)
return resolved[1], resolved[0]
resolved_key, engine = resolved
if self._engine_key and resolved_key != self._engine_key:
# get_engine() falls back to any healthy discovered engine; make
# the substitution visible when the caller asked for a specific
# engine (observed: requested vllm, silently got ollama@11434).
logger.warning(
"Requested engine %r is unavailable; using %r at %s instead",
self._engine_key,
resolved_key,
getattr(engine, "_host", "<unknown host>"),
)
return engine, resolved_key
def _resolve_model(self, config: JarvisConfig, engine: InferenceEngine) -> str:
if self._model:
+66 -7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import os
import time
import urllib.parse
from typing import Any
import httpx
@@ -21,6 +22,13 @@ _MAX_RESPONSE_BYTES = 1_048_576
_ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"})
# Cap redirect chains so a malicious server cannot loop us indefinitely.
_MAX_REDIRECTS = 5
class _SSRFRedirectError(Exception):
"""Raised when a redirect target fails the SSRF check."""
@ToolRegistry.register("http_request")
class HttpRequestTool(BaseTool):
@@ -136,13 +144,11 @@ class HttpRequestTool(BaseTool):
try:
t0 = time.time()
response = httpx.request(
method,
url,
headers=headers,
content=body,
timeout=float(timeout),
follow_redirects=True,
# Follow redirects manually so each hop is re-checked for SSRF — an
# allowed public URL must not be able to 30x-redirect us to an
# internal/metadata address.
response = self._request_following_redirects(
method, url, headers=headers, content=body, timeout=float(timeout)
)
elapsed_ms = (time.time() - t0) * 1000
@@ -178,6 +184,12 @@ class HttpRequestTool(BaseTool):
content=f"Request timed out after {timeout}s: {exc}",
success=False,
)
except _SSRFRedirectError as exc:
return ToolResult(
tool_name="http_request",
content=f"SSRF protection blocked redirect: {exc}",
success=False,
)
except httpx.RequestError as exc:
return ToolResult(
tool_name="http_request",
@@ -191,5 +203,52 @@ class HttpRequestTool(BaseTool):
success=False,
)
@staticmethod
def _request_following_redirects(
method: str,
url: str,
*,
headers: dict,
content: Any,
timeout: float,
) -> httpx.Response:
"""Issue the request, re-checking SSRF on every redirect hop.
httpx's built-in ``follow_redirects`` would chase a 30x ``Location``
without re-validating it, letting a public URL bounce us to an internal
host. We follow manually and run :func:`check_ssrf` on each target.
"""
current_url = url
current_method = method
body = content
# Use module-level ``httpx.request`` (not a private Client) so the SSRF
# re-check seam stays patchable by callers' tests, with redirects
# disabled so we control every hop ourselves.
for _ in range(_MAX_REDIRECTS + 1):
response = httpx.request(
current_method,
current_url,
headers=headers,
content=body,
timeout=timeout,
follow_redirects=False,
)
if response.status_code not in (301, 302, 303, 307, 308):
return response
location = response.headers.get("location", "")
if not location:
return response
# Resolve relative redirects against the URL we just fetched.
current_url = urllib.parse.urljoin(str(response.url), location)
ssrf_error = check_ssrf(current_url)
if ssrf_error:
raise _SSRFRedirectError(ssrf_error)
# Per RFC 7231, 301/302/303 turn the method into GET and drop
# the body (except for HEAD).
if response.status_code in (301, 302, 303) and current_method != "HEAD":
current_method = "GET"
body = None
raise _SSRFRedirectError(f"Exceeded maximum of {_MAX_REDIRECTS} redirects.")
__all__ = ["HttpRequestTool"]
+36
View File
@@ -437,3 +437,39 @@ class TestOpenRouterToolForwarding:
assert result["tool_calls"][0]["id"] == "call_1"
assert result["tool_calls"][0]["function"]["name"] == "get_weather"
assert result["tool_calls"][0]["function"]["arguments"] == '{"city": "NYC"}'
class TestCloudEngineCanServe:
"""#532: can_serve gates on the per-provider client, not just health().
health() is True whenever *any* provider client is configured, but a
request for a gpt-* model still needs the OpenAI client specifically so
engine selection must not pick the cloud engine for a model whose provider
client is missing.
"""
@staticmethod
def _engine(**clients: object) -> CloudEngine:
eng = CloudEngine.__new__(CloudEngine) # bypass real client init
for name in (
"_openai_client",
"_anthropic_client",
"_google_client",
"_openrouter_client",
"_minimax_client",
"_codex_client",
):
setattr(eng, name, clients.get(name))
return eng
def test_openai_only_serves_openai_models(self) -> None:
eng = self._engine(_openai_client=object())
assert eng.can_serve("gpt-4o") is True
assert eng.can_serve("claude-sonnet-4") is False
assert eng.can_serve("gemini-2.5-pro") is False
assert eng.can_serve("openrouter/openai/gpt-4o") is False
def test_anthropic_only_serves_anthropic_models(self) -> None:
eng = self._engine(_anthropic_client=object())
assert eng.can_serve("claude-sonnet-4") is True
assert eng.can_serve("gpt-4o") is False
+45
View File
@@ -174,6 +174,51 @@ class TestGetEngine:
assert result is not None
assert result[0] == "running"
def test_skips_engine_that_cannot_serve_model(self) -> None:
"""#532: a healthy engine that can't serve the requested model is
skipped for one that can this is what stops the cloud fallback being
chosen (when the local engine is down) for a model whose provider
client is missing.
"""
_reg("picky", "picky")
_reg("local", "local")
class _Picky(_FakeEngine):
def can_serve(self, model: str) -> bool:
return model == "servable"
cfg = JarvisConfig()
cfg.engine.default = "picky"
def _make(k, c): # noqa: ANN001
if k == "picky":
return _Picky(healthy=True)
return _FakeEngine(healthy=(k == "local"))
with mock.patch(
"openjarvis.engine._discovery._make_engine",
side_effect=_make,
):
# "picky" is healthy but cannot serve "other" -> fall back to "local"
result = get_engine(cfg, model="other")
assert result is not None
assert result[0] == "local"
def test_model_none_preserves_model_agnostic_selection(self) -> None:
"""model=None keeps the legacy behaviour: first healthy engine wins."""
_reg("primary", "primary")
cfg = JarvisConfig()
cfg.engine.default = "primary"
with mock.patch(
"openjarvis.engine._discovery._make_engine",
side_effect=lambda k, c: _FakeEngine(healthy=True), # noqa: ANN001
):
result = get_engine(cfg, model=None)
assert result is not None
assert result[0] == "primary"
class TestMiningSidecarEngineHandoff:
"""Engine discovery picks up (or ignores) a mining sidecar at runtime."""
+109
View File
@@ -0,0 +1,109 @@
"""API-key (Authorization header) support in the OpenAI-compat engine base."""
from __future__ import annotations
import httpx
import pytest
import respx
from openjarvis.core.types import Message, Role
from openjarvis.engine.openai_compat_engines import (
OpenAICompatEngine,
VLLMEngine,
normalize_openai_base_url,
)
_CHAT_RESPONSE = {
"choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
"model": "m",
}
class TestAuthorizationHeader:
def test_bearer_header_sent_when_api_key_set(self) -> None:
engine = OpenAICompatEngine(host="http://testhost:9000", api_key="sk-test")
with respx.mock:
route = respx.post("http://testhost:9000/v1/chat/completions").mock(
return_value=httpx.Response(200, json=_CHAT_RESPONSE)
)
engine.generate([Message(role=Role.USER, content="hi")], model="m")
assert route.calls.last.request.headers["Authorization"] == "Bearer sk-test"
def test_no_authorization_header_without_api_key(self) -> None:
engine = OpenAICompatEngine(host="http://testhost:9000")
with respx.mock:
route = respx.post("http://testhost:9000/v1/chat/completions").mock(
return_value=httpx.Response(200, json=_CHAT_RESPONSE)
)
engine.generate([Message(role=Role.USER, content="hi")], model="m")
assert "authorization" not in route.calls.last.request.headers
def test_health_check_sends_bearer_header(self) -> None:
engine = OpenAICompatEngine(host="http://testhost:9000", api_key="sk-test")
with respx.mock:
route = respx.get("http://testhost:9000/v1/models").mock(
return_value=httpx.Response(200, json={"data": []})
)
assert engine.health() is True
assert route.calls.last.request.headers["Authorization"] == "Bearer sk-test"
def test_env_var_fallback_sanitizes_hyphen(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# engine_id "openai-compat" must map to OPENAI_COMPAT_API_KEY —
# shells cannot set hyphenated env-var names.
monkeypatch.setenv("OPENAI_COMPAT_API_KEY", "sk-env")
engine = OpenAICompatEngine(host="http://testhost:9000")
with respx.mock:
route = respx.get("http://testhost:9000/v1/models").mock(
return_value=httpx.Response(200, json={"data": []})
)
engine.health()
assert route.calls.last.request.headers["Authorization"] == "Bearer sk-env"
def test_vllm_env_var_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("VLLM_API_KEY", "sk-vllm")
engine = VLLMEngine(host="http://testhost:8000")
with respx.mock:
route = respx.get("http://testhost:8000/v1/models").mock(
return_value=httpx.Response(200, json={"data": []})
)
engine.health()
assert route.calls.last.request.headers["Authorization"] == "Bearer sk-vllm"
def test_explicit_api_key_beats_env_var(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("OPENAI_COMPAT_API_KEY", "sk-env")
engine = OpenAICompatEngine(host="http://testhost:9000", api_key="sk-explicit")
assert engine._api_key == "sk-explicit"
class TestNormalizeOpenAIBaseUrl:
@pytest.mark.parametrize(
("url", "expected"),
[
("http://h:8000", "http://h:8000"),
("http://h:8000/", "http://h:8000"),
("http://h:8000/v1", "http://h:8000"),
("http://h:8000/v1/", "http://h:8000"),
("http://h:8000/gateway/v1", "http://h:8000/gateway"),
# Only a literal trailing "/v1" is stripped — never other paths.
("http://h:8000/v1x", "http://h:8000/v1x"),
("http://h:8000/v2", "http://h:8000/v2"),
],
)
def test_normalization(self, url: str, expected: str) -> None:
assert normalize_openai_base_url(url) == expected
def test_engine_requests_have_single_v1_prefix(self) -> None:
"""End to end: a user-supplied .../v1 URL must not produce /v1/v1."""
host = normalize_openai_base_url("http://testhost:9000/v1")
engine = OpenAICompatEngine(host=host)
with respx.mock:
route = respx.get("http://testhost:9000/v1/models").mock(
return_value=httpx.Response(200, json={"data": []})
)
assert engine.health() is True
assert route.calls.last.request.url.path == "/v1/models"
+20
View File
@@ -31,3 +31,23 @@ def test_named_persona_resolves_to_personas_dir():
def test_path_traversal_rejected(bad):
with pytest.raises(ValueError):
SystemPromptBuilder._resolve_persona(MemoryFilesConfig(persona_name=bad))
def test_none_persona_build_does_not_raise():
"""Regression (#497): `--persona none` resolves to empty file paths; building
the prompt must not raise IsADirectoryError when those empty paths are read
(Path("") is "." reading a directory raised before the empty-path guard).
"""
import dataclasses
from openjarvis.core.config import load_config
cfg = load_config()
mf = dataclasses.replace(cfg.memory_files, persona_name="none")
builder = SystemPromptBuilder(
agent_template=cfg.agent.default_system_prompt or "",
memory_files_config=mf,
system_prompt_config=cfg.system_prompt,
)
out = builder.build()
assert isinstance(out, str)
+80 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
@@ -327,6 +327,85 @@ class TestSystemBuilder:
assert builder._engine_key == "ollama"
class TestSystemBuilderEngineInstance:
"""Explicit engine injection (jarvis eval --base-url path)."""
@staticmethod
def _fake_engine(healthy: bool = True) -> MagicMock:
engine = MagicMock(
spec=["health", "can_serve", "generate", "list_models", "close"]
)
engine.health.return_value = healthy
engine._host = "http://127.0.0.1:18999"
return engine
def test_engine_instance_is_fluent(self):
builder = SystemBuilder(JarvisConfig())
engine = self._fake_engine()
result = builder.engine_instance(engine, key="my-endpoint")
assert result is builder
assert builder._engine_instance is engine
assert builder._engine_instance_key == "my-endpoint"
def test_resolve_engine_returns_injected_instance(self):
config = JarvisConfig()
engine = self._fake_engine(healthy=True)
builder = SystemBuilder(config).engine_instance(engine, key="endpoint")
resolved_engine, resolved_key = builder._resolve_engine(config)
assert resolved_engine is engine
assert resolved_key == "endpoint"
def test_unhealthy_injected_instance_raises_naming_host(self):
config = JarvisConfig()
engine = self._fake_engine(healthy=False)
builder = SystemBuilder(config).engine_instance(engine, key="endpoint")
with pytest.raises(RuntimeError, match=r"http://127\.0\.0\.1:18999"):
builder._resolve_engine(config)
def test_unhealthy_injected_instance_never_consults_discovery(self):
"""The observed failure mode: an explicit endpoint must NOT be
silently replaced by whatever other engine discovery finds."""
config = JarvisConfig()
engine = self._fake_engine(healthy=False)
builder = SystemBuilder(config).engine_instance(engine)
with patch("openjarvis.engine._discovery.get_engine") as mock_get_engine:
with pytest.raises(RuntimeError, match="Refusing to fall back"):
builder._resolve_engine(config)
mock_get_engine.assert_not_called()
def test_healthy_injected_instance_never_consults_discovery(self):
config = JarvisConfig()
engine = self._fake_engine(healthy=True)
builder = SystemBuilder(config).engine_instance(engine, key="endpoint")
with patch("openjarvis.engine._discovery.get_engine") as mock_get_engine:
resolved_engine, _ = builder._resolve_engine(config)
assert resolved_engine is engine
mock_get_engine.assert_not_called()
def test_build_wires_injected_engine(self):
"""build() must use the injected engine (possibly behind security
wrappers) instead of running discovery."""
config = JarvisConfig()
engine = self._fake_engine(healthy=True)
engine.list_models.return_value = ["stub-model"]
builder = (
SystemBuilder(config)
.engine_instance(engine, key="endpoint")
.model("stub-model")
.telemetry(False)
.traces(False)
)
system = builder.build()
try:
inner = system.engine
while hasattr(inner, "_engine"):
inner = inner._engine
assert inner is engine
assert system.engine_key == "endpoint"
finally:
system.close()
class TestJarvisSystemClose:
def test_close_with_scheduler_store(self):
engine = MagicMock()
+15
View File
@@ -28,6 +28,10 @@ def _make_app(api_key: str) -> FastAPI:
async def twilio_webhook():
return {"status": "received"}
@app.get("/metrics")
async def metrics():
return {"requests": 0}
return app
@@ -65,7 +69,18 @@ class TestAuthMiddleware:
resp = client.post("/webhooks/twilio")
assert resp.status_code == 200
def test_metrics_requires_auth(self, client):
resp = client.get("/metrics")
assert resp.status_code == 401
def test_metrics_accepts_valid_key(self, client):
resp = client.get(
"/metrics", headers={"Authorization": "Bearer oj_sk_test123"}
)
assert resp.status_code == 200
def test_no_key_configured_allows_all(self):
client = TestClient(_make_app(""))
resp = client.get("/v1/models")
assert resp.status_code == 200
assert client.get("/metrics").status_code == 200
+28 -2
View File
@@ -41,6 +41,9 @@ def sendblue_channel():
api_key_id="test_key",
api_secret_key="test_secret",
from_number="+15551234567",
# Webhooks now fail closed without a secret, so configure one and have
# the test client send the matching header by default.
webhook_secret="testsecret",
)
ch.connect()
return ch
@@ -61,7 +64,9 @@ def webhook_app(mock_bridge, sendblue_channel):
@pytest.fixture
def client(webhook_app):
return TestClient(webhook_app)
# Send the webhook secret by default so message-handling tests reach the
# bridge; fail-closed behavior is covered separately below.
return TestClient(webhook_app, headers={"x-sendblue-secret": "testsecret"})
# ---------------------------------------------------------------------------
@@ -169,7 +174,7 @@ class TestSendBlueWebhook:
app = FastAPI()
router = create_webhook_router(bridge=None, sendblue_channel=sendblue_channel)
app.include_router(router)
c = TestClient(app)
c = TestClient(app, headers={"x-sendblue-secret": "testsecret"})
resp = c.post(
"/webhooks/sendblue",
@@ -181,6 +186,27 @@ class TestSendBlueWebhook:
)
assert resp.status_code == 200
def test_no_secret_configured_is_rejected(self, mock_bridge):
"""Fail closed: a channel without a webhook_secret rejects all posts."""
from openjarvis.channels.sendblue import SendBlueChannel
from openjarvis.server.webhook_routes import create_webhook_router
ch = SendBlueChannel(
api_key_id="k", api_secret_key="s", from_number="+1555"
)
ch.connect()
app = FastAPI()
router = create_webhook_router(bridge=mock_bridge, sendblue_channel=ch)
app.include_router(router)
c = TestClient(app)
resp = c.post(
"/webhooks/sendblue",
json={"from_number": "+19127130720", "content": "Hi", "is_outbound": False},
)
assert resp.status_code == 403
mock_bridge.handle_incoming.assert_not_called()
# ---------------------------------------------------------------------------
# Health endpoint (requires agent_manager_routes)
+45
View File
@@ -213,3 +213,48 @@ class TestWhatsAppWebhook:
},
)
assert resp.status_code == 200
class TestWebhooksFailClosed:
"""When a channel's secret/token is unset, webhooks must reject (403)."""
def _client(self, mock_bridge, **kwargs):
app = FastAPI()
app.include_router(create_webhook_router(bridge=mock_bridge, **kwargs))
return TestClient(app)
def test_twilio_without_token_rejected(self, mock_bridge):
c = self._client(mock_bridge) # no twilio_auth_token
resp = c.post(
"/webhooks/twilio",
data={"From": "+15551234567", "Body": "hi", "MessageSid": "SM1"},
)
assert resp.status_code == 403
mock_bridge.handle_incoming.assert_not_called()
def test_bluebubbles_without_password_rejected(self, mock_bridge):
c = self._client(mock_bridge) # no bluebubbles_password
resp = c.post(
"/webhooks/bluebubbles",
json={"type": "new-message", "data": {}},
headers={"Authorization": "anything"},
)
assert resp.status_code == 403
def test_whatsapp_without_secret_rejected(self, mock_bridge):
c = self._client(mock_bridge) # no whatsapp_app_secret
resp = c.post(
"/webhooks/whatsapp",
content=b"{}",
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 403
def test_whatsapp_verify_without_token_rejected(self, mock_bridge):
c = self._client(mock_bridge) # no whatsapp_verify_token
resp = c.get(
"/webhooks/whatsapp",
params={"hub.mode": "subscribe", "hub.verify_token": "",
"hub.challenge": "x"},
)
assert resp.status_code == 403
+41 -4
View File
@@ -184,8 +184,9 @@ class TestHttpRequestTool:
"""Timeout should produce a clear error."""
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
with patch(
"openjarvis.tools.http_request.httpx.request",
with patch.object(
HttpRequestTool,
"_request_following_redirects",
side_effect=httpx.TimeoutException("timed out"),
):
result = tool.execute(url="https://slow.example.com", timeout=5)
@@ -196,14 +197,50 @@ class TestHttpRequestTool:
"""Connection error should produce a clear error."""
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
with patch(
"openjarvis.tools.http_request.httpx.request",
with patch.object(
HttpRequestTool,
"_request_following_redirects",
side_effect=httpx.ConnectError("Connection refused"),
):
result = tool.execute(url="https://down.example.com")
assert result.success is False
assert "Request error" in result.content
@respx.mock
def test_redirect_to_private_ip_blocked(self):
"""A redirect to an internal/metadata host must be re-checked + blocked."""
respx.get("https://public.example.com/start").mock(
return_value=httpx.Response(
302, headers={"location": "http://169.254.169.254/latest/"}
)
)
tool = HttpRequestTool()
# First check (initial URL) passes; the redirect target is blocked.
with patch(
"openjarvis.tools.http_request.check_ssrf",
side_effect=[None, "Blocked host: 169.254.169.254"],
):
result = tool.execute(url="https://public.example.com/start")
assert result.success is False
assert "SSRF protection blocked redirect" in result.content
@respx.mock
def test_safe_redirect_is_followed(self):
"""A redirect to another public URL is followed normally."""
respx.get("https://public.example.com/start").mock(
return_value=httpx.Response(
302, headers={"location": "https://public.example.com/final"}
)
)
respx.get("https://public.example.com/final").mock(
return_value=httpx.Response(200, text="done")
)
tool = HttpRequestTool()
with patch("openjarvis.tools.http_request.check_ssrf", return_value=None):
result = tool.execute(url="https://public.example.com/start")
assert result.success is True
assert "done" in result.content
def test_method_validation(self):
"""Invalid HTTP method should be rejected."""
tool = HttpRequestTool()