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
Jon Saad-FalconandClaude Opus 4.8 4218258486 perf(serve): build the system once — drop duplicate SystemBuilder.build() (#263) (#529)
`jarvis serve` constructed every heavy component inline (engine discovery +
instrumentation, telemetry, memory, agent manager, per-agent tools) and then
called `SystemBuilder(config).build()` a second time inside the scheduler
block purely to feed `AgentExecutor.set_system()`. That second build
re-discovered and re-connected the engine, re-instrumented it, re-resolved
tools, re-opened the configured channel and re-created the agent manager —
~30-40s of fully redundant startup work (the headline remaining cost in #263
after engine probes were parallelised and the version check moved off the hot
path in #470).

Fix: assemble the executor's `JarvisSystem` from the components already built
inline instead of rebuilding from scratch. `AgentExecutor` only reads
`engine`, `model`, `config`, `memory_backend`, `tool_executor`,
`session_store` and `channel_backend` off the system; all are wired here. The
memory backend is now constructed just before the scheduler block (it was
built later) so the executor's system can reference it, and the primary
agent's resolved tool list is reused to build the scheduler's `ToolExecutor`
(preserving the MCP-discovered-tool pool the executor reads via
`tool_executor._tools`). `skill_manager` / the learning orchestrator are only
consumed by the orchestrator's `system.ask()` path, which the executor never
invokes, so they are intentionally omitted.

Tests: new `tests/cli/test_serve_single_build.py` patches
`SystemBuilder.build` and asserts it is never called during `jarvis serve`
startup, and that the executor still receives a system exposing
`tool_executor` / `session_store` / `memory_backend` (plus engine/model/config).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:10:10 -07:00
Jon Saad-FalconandClaude Opus 4.8 176ed3029b fix(channels): unify send() destination/reply contract (Discord #515/#516) (#528)
The channel `send()` contract was inconsistent across adapters. Almost
every adapter (Discord, Slack, email, WhatsApp, ...) treats the first
positional `channel` arg as the real DESTINATION id and `conversation_id`
as an optional reply/thread reference. Telegram alone treated
`conversation_id` as the destination (`chat_id = conversation_id or
channel`).

`JarvisSystem._on_channel_message` hard-coded the Telegram-shaped mapping
for ALL channels: `send(cm.channel, reply, conversation_id=cm.conversation_id)`.
Since inbound `ChannelMessage`s carry the channel TYPE label in `.channel`
("discord") and the real destination id in `.conversation_id`, this sent
the literal "discord" as the Discord channel id (HTTP 400
NUMBER_TYPE_COERCE, #515) and passed the channel id as a Discord
`message_reference` (MESSAGE_REFERENCE_UNKNOWN_MESSAGE, #516).

Fix: define and document ONE canonical contract on `BaseChannel.send` —
positional `channel` = destination id, `conversation_id` = inbound message
id used as a reply reference — and dispatch it from `_on_channel_message`
as `send(cm.conversation_id, reply, conversation_id=cm.message_id)`,
matching the already-fixed `ChannelAgent` path (#495/#459). Telegram's
`send()` is brought into line (destination = `channel`, with a
`reply_to_message_id` reply ref and a legacy `conversation_id`-only
fallback) so it keeps working unchanged.

The DiscordChannel `_gateway_loop` ChannelMessage shape locked by #495 is
untouched; `tests/agents/test_channel_agent.py` passes unchanged. The
stale `test_serve_channel_wiring.py` assertions (which encoded the old
buggy mapping from #94) are updated, and regression tests are added for
Discord (real channel id + correct message_reference), Telegram (chat id
+ reply ref + legacy fallback), and per-channel dispatch in
`_on_channel_message`.

Fixes #515
Fixes #516

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:10:06 -07:00
43 changed files with 1983 additions and 160 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;
}
}
+21 -1
View File
@@ -60,7 +60,27 @@ class BaseChannel(ABC):
conversation_id: str = "",
metadata: Dict[str, Any] | None = None,
) -> bool:
"""Send a message to a specific channel. Returns True on success."""
"""Send a message to a specific channel. Returns True on success.
Canonical send contract shared by **every** channel adapter:
``channel``
The DESTINATION identifier — the per-adapter native id of the
place the message goes (Discord/Slack channel id, Telegram chat
id, email recipient address, ...). This is *not* the channel
TYPE label. An incoming :class:`ChannelMessage` carries that
destination in its ``conversation_id`` field (``channel`` there
is only the type label such as ``"discord"``), so dispatch code
replying to a message must pass ``cm.conversation_id`` here.
``conversation_id``
An optional reply/thread reference — the native id of the
message being replied to (Discord ``message_reference``, Slack
``thread_ts``, Telegram ``reply_to_message_id``, email
``In-Reply-To``, ...). When replying to an inbound message this
should be ``cm.message_id``, never the channel id. Passing a
channel id here yields broken references (e.g. Discord
``MESSAGE_REFERENCE_UNKNOWN_MESSAGE``).
"""
@abstractmethod
def status(self) -> ChannelStatus:
+11 -1
View File
@@ -112,7 +112,15 @@ class TelegramChannel(BaseChannel):
_TELEGRAM_MAX_LEN = 4096
url = f"https://api.telegram.org/bot{self._token}/sendMessage"
chat_id = conversation_id or channel
# Canonical channel send contract (see BaseChannel.send): the first
# positional ``channel`` arg is the DESTINATION (the Telegram chat
# id). ``conversation_id`` is the inbound message id used as a
# reply/thread reference (``reply_to_message_id``). We fall back to
# ``conversation_id`` as the chat id only when ``channel`` is empty,
# for backwards compatibility with legacy callers that passed the
# chat id via ``conversation_id``.
chat_id = channel or conversation_id
reply_to = conversation_id if (channel and conversation_id) else ""
chunks = textwrap.wrap(
content,
width=_TELEGRAM_MAX_LEN,
@@ -126,6 +134,8 @@ class TelegramChannel(BaseChannel):
}
if self._parse_mode:
payload["parse_mode"] = self._parse_mode
if reply_to:
payload["reply_to_message_id"] = reply_to
resp = httpx.post(url, json=payload, timeout=10.0)
if resp.status_code >= 300:
+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"),
)
+80 -20
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"
@@ -261,6 +267,11 @@ def serve(
# Resolve agent
agent = None
agent_key = agent_name or config.server.agent
# Tool instances resolved for the primary agent are reused below to build
# the scheduler's ToolExecutor — avoiding a second full SystemBuilder.build()
# (which would re-discover the engine, re-resolve tools, re-open the channel,
# etc.). See the scheduler block near the bottom of this function (#263).
resolved_tools: list = []
if agent_key:
try:
import openjarvis.agents # noqa: F401
@@ -328,6 +339,8 @@ def serve(
if tools:
agent_kwargs["tools"] = tools
# Reuse these for the scheduler's ToolExecutor (#263).
resolved_tools = tools
if getattr(agent_cls, "accepts_tools", False):
agent_kwargs["max_turns"] = config.agent.max_turns
@@ -461,6 +474,24 @@ def serve(
# Create app
from openjarvis.server.app import create_app
# Set up memory backend for context injection. Built before the scheduler
# block so the executor's JarvisSystem can reference it (#263).
memory_backend = None
if config.agent.context_from_memory:
try:
import openjarvis.tools.storage # noqa: F401
from openjarvis.core.registry import MemoryRegistry
mem_key = config.memory.default_backend
if MemoryRegistry.contains(mem_key):
memory_backend = MemoryRegistry.create(
mem_key,
db_path=config.memory.db_path,
)
console.print(" Memory: [cyan]active[/cyan]")
except Exception as exc:
logger.debug("Memory backend init failed: %s", exc)
# Set up agent manager
agent_manager = None
if config.agent_manager.enabled:
@@ -500,9 +531,55 @@ def serve(
event_bus=bus,
trace_store=_trace_store,
)
from openjarvis.system import SystemBuilder
# Reuse the components already built inline above instead of a
# second full SystemBuilder.build() — the original double-build
# re-discovered the engine, re-instrumented it, re-resolved tools,
# re-opened the channel and re-created the agent manager, costing
# ~30-40s on top of an already-paid startup (#263). The executor
# only reads engine/model/config/memory_backend/tool_executor/
# session_store/channel_backend from the system (see
# AgentExecutor), all of which are wired here.
from openjarvis.sessions.session import SessionStore
from openjarvis.system import JarvisSystem
from openjarvis.tools._stubs import ToolExecutor
system = SystemBuilder(config).build()
_sched_session_store = None
if config.sessions.enabled:
try:
from pathlib import Path as _SchedPath
_sched_session_store = SessionStore(
db_path=_SchedPath(config.sessions.db_path).expanduser(),
max_age_hours=config.sessions.max_age_hours,
consolidation_threshold=(
config.sessions.consolidation_threshold
),
)
except Exception as exc:
logger.debug("Scheduler session store init failed: %s", exc)
_sched_tool_executor = (
ToolExecutor(resolved_tools, bus) if resolved_tools else None
)
system = JarvisSystem(
config=config,
bus=bus,
engine=engine,
engine_key=engine_name,
model=model_name,
agent=agent,
agent_name=agent_key or "",
tools=resolved_tools,
tool_executor=_sched_tool_executor,
memory_backend=memory_backend,
telemetry_store=telem_store,
trace_store=_trace_store,
session_store=_sched_session_store,
capability_policy=sec.capability_policy,
agent_manager=agent_manager,
agent_executor=executor,
)
executor.set_system(system)
agent_scheduler = AgentScheduler(
@@ -522,23 +599,6 @@ def serve(
except Exception as exc:
logger.debug("Agent scheduler init failed: %s", exc)
# Set up memory backend for context injection
memory_backend = None
if config.agent.context_from_memory:
try:
import openjarvis.tools.storage # noqa: F401
from openjarvis.core.registry import MemoryRegistry
mem_key = config.memory.default_backend
if MemoryRegistry.contains(mem_key):
memory_backend = MemoryRegistry.create(
mem_key,
db_path=config.memory.db_path,
)
console.print(" Memory: [cyan]active[/cyan]")
except Exception as exc:
logger.debug("Memory backend init failed: %s", exc)
# --- Channel Gateway: API key, sessions, ChannelBridge ---
import os as _os
+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:
+12 -2
View File
@@ -268,10 +268,20 @@ class JarvisSystem:
if reply:
try:
# Canonical channel send contract (see BaseChannel.send):
# the first positional arg is the DESTINATION id, and the
# `conversation_id=` kwarg is the inbound message id used as
# a reply/thread reference. ``cm.conversation_id`` holds the
# real per-adapter destination (Discord/Slack channel id,
# Telegram chat id, ...) while ``cm.channel`` is only the
# channel TYPE label ("discord", "telegram", ...). Passing
# the type label as the destination produced HTTP 400s
# (#515) and using the channel id as a reply reference
# produced MESSAGE_REFERENCE_UNKNOWN_MESSAGE (#516).
channel_bridge.send(
cm.channel,
cm.conversation_id,
reply,
conversation_id=cm.conversation_id,
conversation_id=getattr(cm, "message_id", ""),
)
except Exception:
logger.exception("Channel send error")
+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"]
+60
View File
@@ -130,3 +130,63 @@ class TestStatus:
ch = DiscordChannel()
ch.connect()
assert ch.status() == ChannelStatus.ERROR
class TestWireChannelEndToEnd:
"""Regression for #515/#516 — the full inbound→reply path through
JarvisSystem.wire_channel must call the real Discord REST API with the
numeric channel id (not "discord") and a message_reference equal to the
inbound message id (not the channel id).
"""
def test_reply_hits_real_channel_id_and_message_reference(self, tmp_path):
from openjarvis.channels._stubs import ChannelMessage
from openjarvis.core.config import JarvisConfig
from openjarvis.core.events import EventBus
from openjarvis.system import JarvisSystem
config = JarvisConfig()
config.sessions.db_path = str(tmp_path / "sessions.db")
from unittest.mock import MagicMock as _MM
system = JarvisSystem(
config=config,
bus=EventBus(record_history=False),
engine=_MM(),
engine_key="mock",
model="test-model",
agent_name="",
)
system.ask = _MM(return_value={"content": "pong"})
channel = DiscordChannel(bot_token="my-bot-token")
system.wire_channel(channel)
# Exactly the ChannelMessage shape DiscordChannel._gateway_loop emits:
# channel = "discord" (TYPE label), conversation_id = numeric channel
# id, message_id = numeric message id.
cm = ChannelMessage(
channel="discord",
sender="user-1",
content="hello",
message_id="111122223333444455",
conversation_id="987654321098765432",
)
mock_response = MagicMock()
mock_response.status_code = 200
with patch("httpx.post", return_value=mock_response) as mock_post:
# Invoke the handler wire_channel registered on the channel.
for handler in channel._handlers:
handler(cm)
mock_post.assert_called_once()
url = mock_post.call_args[0][0]
# #515: destination is the numeric channel id, not the "discord" label.
assert "discord.com/api/v10/channels/987654321098765432/messages" in url
assert "channels/discord/messages" not in url
payload = mock_post.call_args[1]["json"]
assert payload["content"] == "pong"
# #516: message_reference is the inbound message id, NOT the channel id.
assert payload["message_reference"] == {"message_id": "111122223333444455"}
assert payload["message_reference"]["message_id"] != "987654321098765432"
+35
View File
@@ -105,6 +105,41 @@ class TestSend:
event_types = [e.event_type for e in bus.history]
assert EventType.CHANNEL_MESSAGE_SENT in event_types
def test_send_uses_channel_as_chat_id_under_unified_contract(self):
"""Canonical contract (#515/#516): the first positional ``channel``
arg is the chat destination, and ``conversation_id`` is the inbound
message id used as ``reply_to_message_id`` not the chat id."""
ch = TelegramChannel(bot_token="123:ABC")
mock_response = MagicMock()
mock_response.status_code = 200
with patch("httpx.post", return_value=mock_response) as mock_post:
result = ch.send("12345678", "Reply!", conversation_id="55")
assert result is True
payload = mock_post.call_args[1]["json"]
# Destination is the chat id from the positional channel arg.
assert payload["chat_id"] == "12345678"
# conversation_id becomes the reply reference, not the chat id.
assert payload["reply_to_message_id"] == "55"
def test_send_legacy_conversation_id_only_still_targets_chat(self):
"""Backwards compatibility: a legacy caller passing the chat id via
``conversation_id`` (with an empty ``channel``) still delivers."""
ch = TelegramChannel(bot_token="123:ABC")
mock_response = MagicMock()
mock_response.status_code = 200
with patch("httpx.post", return_value=mock_response) as mock_post:
result = ch.send("", "Hello!", conversation_id="12345678")
assert result is True
payload = mock_post.call_args[1]["json"]
assert payload["chat_id"] == "12345678"
# When channel is empty, conversation_id is the chat id, so it must
# not also be used as a self-referential reply id.
assert "reply_to_message_id" not in payload
class TestStatus:
def test_no_token_connect_error(self):
+96 -4
View File
@@ -79,10 +79,14 @@ class TestWireChannelWithAgent:
system.ask.assert_called_once()
assert system.ask.call_args[0][0] == "ping"
# Canonical send contract (#515/#516): the destination is the real
# per-adapter id (carried in ChannelMessage.conversation_id), not the
# channel TYPE label, and the conversation_id kwarg is the inbound
# message id used as a reply reference, not the channel id.
mock_channel.send.assert_called_once_with(
"telegram",
"42",
"pong",
conversation_id="42",
conversation_id="1",
)
def test_session_store_created_lazily(self, tmp_path):
@@ -126,13 +130,101 @@ class TestWireChannelWithEngine:
handler = mock_channel.on_message.call_args[0][0]
handler(_make_channel_message(content="hi"))
# Canonical send contract (#515/#516): destination = real channel id
# (from ChannelMessage.conversation_id), reply ref = inbound message id.
mock_channel.send.assert_called_once_with(
"telegram",
"42",
"raw reply",
conversation_id="42",
conversation_id="1",
)
class TestWireChannelCanonicalContract:
"""Regression for #515/#516 — wire_channel must dispatch the canonical
send contract so each adapter receives the right destination/reply ids,
regardless of the channel TYPE label.
"""
def test_discord_uses_real_channel_id_not_type_label(self, tmp_path):
"""A Discord ChannelMessage (channel="discord" TYPE label,
conversation_id=<numeric channel id>, message_id=<numeric msg id>)
must reply to the numeric channel id, with the message id as the
reply reference never "discord" as destination (#515) and never the
channel id as the message reference (#516).
"""
system = _make_system(tmp_path=tmp_path)
system.ask = MagicMock(return_value={"content": "pong"})
mock_channel = MagicMock()
system.wire_channel(mock_channel)
handler = mock_channel.on_message.call_args[0][0]
cm = ChannelMessage(
channel="discord",
sender="user-1",
content="hello",
message_id="111122223333444455",
conversation_id="987654321098765432",
)
handler(cm)
args, kwargs = mock_channel.send.call_args
# Destination is the real Discord channel id, NOT the type label.
assert args[0] == "987654321098765432"
assert args[0] != "discord"
# Reply reference is the inbound message id, NOT the channel id.
assert kwargs["conversation_id"] == "111122223333444455"
assert kwargs["conversation_id"] != "987654321098765432"
def test_telegram_uses_chat_id_and_message_id(self, tmp_path):
"""Telegram must keep working under the unified contract: destination
is the chat id (conversation_id), reply ref is the message id."""
system = _make_system(tmp_path=tmp_path)
system.ask = MagicMock(return_value={"content": "pong"})
mock_channel = MagicMock()
system.wire_channel(mock_channel)
handler = mock_channel.on_message.call_args[0][0]
cm = ChannelMessage(
channel="telegram",
sender="42",
content="ping",
message_id="55",
conversation_id="12345678",
)
handler(cm)
mock_channel.send.assert_called_once_with(
"12345678",
"pong",
conversation_id="55",
)
def test_session_key_still_uses_conversation_id(self, tmp_path):
"""The fix must not change session isolation keying, which still uses
``<channel>:<conversation_id>``."""
system = _make_system(tmp_path=tmp_path)
system.ask = MagicMock(return_value={"content": "ok"})
mock_channel = MagicMock()
system.wire_channel(mock_channel)
handler = mock_channel.on_message.call_args[0][0]
cm = ChannelMessage(
channel="discord",
sender="u1",
content="hi",
message_id="msg-9",
conversation_id="chan-7",
)
handler(cm)
# Session was created under the channel:conversation_id key.
session = system.session_store.get_or_create("discord:chan-7")
assert any(m.content == "hi" for m in session.messages)
class TestWireChannelSessionIsolation:
"""Separate conversation_ids get independent sessions."""
+210
View File
@@ -0,0 +1,210 @@
"""Regression tests for #263 — ``jarvis serve`` must build the system once.
serve.py used to construct all heavy components inline and then call
``SystemBuilder(config).build()`` a second time inside the scheduler block,
re-discovering the engine, re-instrumenting it, re-resolving tools, re-opening
the channel and re-creating the agent manager ~30-40s of redundant work.
These tests pin the fix:
1. ``SystemBuilder.build`` is never called during ``jarvis serve`` startup
(the duplicate build is gone).
2. The ``AgentExecutor`` still receives a system exposing the attributes it
actually reads: ``tool_executor``, ``session_store``, ``memory_backend``,
plus ``engine`` / ``model`` / ``config``.
"""
from __future__ import annotations
import importlib
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from openjarvis.cli import cli
pytest.importorskip("fastapi")
pytest.importorskip("uvicorn")
# ``openjarvis.cli.serve`` as an attribute resolves to the click *command*
# (re-exported on the package); grab the real module to monkeypatch its globals.
serve_mod = importlib.import_module("openjarvis.cli.serve")
def _fake_engine() -> MagicMock:
engine = MagicMock()
engine.list_models.return_value = ["test-model"]
engine.health.return_value = True
engine.name = "mock"
return engine
def _repopulate_registries() -> None:
"""Re-run the @register decorators wiped by the autouse conftest fixture.
The tool/memory modules are import-cached, so a plain ``import`` inside
serve.py is a no-op after the registries are cleared per-test. Reload the
individual submodules so ToolRegistry/MemoryRegistry are populated exactly
as they would be on a fresh process otherwise serve would resolve an
empty tool list and no memory backend, masking the very wiring under test.
"""
import importlib
import sys
import openjarvis.agents # noqa: F401
import openjarvis.tools # noqa: F401
import openjarvis.tools.storage # noqa: F401
from openjarvis.core.registry import (
AgentRegistry,
MemoryRegistry,
ToolRegistry,
)
if not AgentRegistry.keys():
for mod_name in list(sys.modules):
if mod_name.startswith("openjarvis.agents.") and not mod_name.endswith(
"_stubs"
):
try:
importlib.reload(sys.modules[mod_name])
except Exception:
pass
if not ToolRegistry.keys():
for mod_name in list(sys.modules):
if (
mod_name.startswith("openjarvis.tools.")
and not mod_name.endswith("_stubs")
and not mod_name.endswith("agent_tools")
):
try:
importlib.reload(sys.modules[mod_name])
except Exception:
pass
if not MemoryRegistry.keys():
for mod_name in list(sys.modules):
if mod_name.startswith(
"openjarvis.tools.storage."
) and not mod_name.endswith("_stubs"):
try:
importlib.reload(sys.modules[mod_name])
except Exception:
pass
def _run_serve(tmp_path, monkeypatch, *, build_spy, set_system_spy):
"""Invoke ``jarvis serve`` with all heavy/blocking pieces stubbed out.
Returns the CliRunner result. The server is never actually started
(``uvicorn.run`` is a no-op) and no real engine is contacted.
"""
from openjarvis.core.config import JarvisConfig
_repopulate_registries()
config = JarvisConfig()
# Keep the scheduler block alive (it owns the executor wiring under test)
# while pointing every store at the temp dir.
config.agent_manager.enabled = True
config.agent_manager.db_path = str(tmp_path / "agents.db")
config.sessions.enabled = True
config.sessions.db_path = str(tmp_path / "sessions.db")
config.memory.db_path = str(tmp_path / "memory.db")
config.telemetry.enabled = False
config.traces.enabled = False
config.channel.enabled = False
config.skills.enabled = False
config.server.host = "127.0.0.1"
config.server.port = 8123
# Resolve a model without contacting a real engine / discovery.
config.intelligence.default_model = "test-model"
engine = _fake_engine()
monkeypatch.setattr(serve_mod, "load_config", lambda *a, **k: config)
monkeypatch.setattr(serve_mod, "get_engine", lambda *a, **k: ("mock", engine))
monkeypatch.setattr(serve_mod, "discover_engines", lambda *a, **k: {})
monkeypatch.setattr(serve_mod, "discover_models", lambda *a, **k: {})
# setup_security returns its own context; pass the engine straight through
# so we don't need real guardrails wired up.
sec = MagicMock()
sec.engine = engine
sec.capability_policy = None
sec.audit_logger = None
monkeypatch.setattr("openjarvis.security.setup_security", lambda *a, **k: sec)
with (
patch(
"openjarvis.system.builder.SystemBuilder.build",
build_spy,
),
patch(
"openjarvis.agents.executor.AgentExecutor.set_system",
set_system_spy,
),
patch("uvicorn.run", lambda *a, **k: None),
):
return CliRunner().invoke(cli, ["serve"], catch_exceptions=False)
def test_serve_does_not_call_systembuilder_build(tmp_path, monkeypatch):
"""The redundant second full build is gone (#263)."""
build_spy = MagicMock(
side_effect=AssertionError(
"SystemBuilder.build() must not run during `jarvis serve` startup "
"— it is the duplicate build #263 removed."
)
)
set_system_spy = MagicMock()
result = _run_serve(
tmp_path,
monkeypatch,
build_spy=build_spy,
set_system_spy=set_system_spy,
)
assert result.exit_code == 0, result.output
build_spy.assert_not_called()
def test_executor_receives_required_system_attrs(tmp_path, monkeypatch):
"""The executor still gets a system exposing the attributes it reads.
AgentExecutor reads engine/model/config/memory_backend/tool_executor/
session_store off ``self._system``; the de-dup must not strip any of them.
"""
build_spy = MagicMock()
captured: dict = {}
def _capture_set_system(self, system): # noqa: ANN001
captured["system"] = system
# Preserve real behaviour so the executor is usable afterwards.
self._system = system
result = _run_serve(
tmp_path,
monkeypatch,
build_spy=build_spy,
set_system_spy=_capture_set_system,
)
assert result.exit_code == 0, result.output
# Built once, from the inline components — not via SystemBuilder.build().
build_spy.assert_not_called()
system = captured.get("system")
assert system is not None, "executor.set_system was never called"
# Correctness constraint from the verifier: these must survive the de-dup.
assert system.tool_executor is not None
assert system.session_store is not None
assert system.memory_backend is not None
# And the basics the executor resolves engine/model from.
assert system.engine is not None
assert system.model == "test-model"
assert system.config is not None
+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()