Compare commits

...
Author SHA1 Message Date
CurryrajandClaude Opus 5 ed01ab8c8d fix: proxy WebSocket upgrades to the API in dev (#692)
The Vite dev proxy forwards `/v1` as plain HTTP with no `ws: true`, so the
WebSocket upgrade for `/v1/agents/events` is never proxied. The socket does not
open, does not error, and does not close — it just sits silent — so every live
agent view is empty under `npm run dev` while working in a production build,
where the frontend is served from the same origin as the API.

That covers the per-agent live trace on the Agents page, which subscribes via
`useAgentEvents` (`frontend/src/lib/useAgentEvents.ts`).

The silence is what makes it costly: with no error to see, it reads as "no
events are being emitted" rather than "the transport never connected", so the
search starts on the server side.

Verified on Windows 11 with `jarvis start` running: before, a
`new WebSocket('ws://localhost:5173/v1/agents/events')` from the dev page never
fired open, error or close within 6s. After, it opens, and a real agent tick
delivers 8 events (agent_tick_start, inference_start/end, tool_call_start/end,
agent_tick_end).

`changeOrigin` is set alongside so the upgrade request carries the target's
host, which some setups require when the API is not on localhost.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:04:41 -07:00
CurryrajandClaude Opus 5 a65f663d2e fix: stop SessionStore treating ":memory:" as a file path (#684)
``SessionStore.__init__`` passed its ``db_path`` straight to
``secure_create()``, which touches the path and chmods it. ``:memory:`` is
a SQLite sentinel, not a filename, so this tried to create a file literally
named ``:memory:``.

On Windows ``:`` is illegal in a filename, so construction raised
``OSError: [Errno 22] Invalid argument: ':memory:'`` and the two
``tests/server/test_channel_bridge_deep_research.py`` tests failed there.
Elsewhere it succeeds and is merely wrong: it leaves a stray ``:memory:``
file in the working directory, and because ``Path(":memory:").parent`` is
``.``, ``secure_mkdir`` chmods the working directory itself to 0o700.

``KnowledgeStore``, ``TelemetryStore`` and ``TraceStore`` already guard this
exact case; ``SessionStore`` was the one store missing the check. Apply the
same guard, with the same comment.

Adds two regression tests: one that an in-memory store is usable, one that
constructing it creates no file. The second fails on every platform without
the fix, so the bug cannot silently return on Linux or macOS.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:44:18 -07:00
Elliot Slusky c6382f2473 fix web search setup in browser quickstart (#691) 2026-07-29 08:38:04 -07:00
github-actions[bot] 2922a2b154 chore: update clone traffic data [skip ci] 2026-07-29 08:35:42 +00:00
Elliot Slusky 403dec8e98 Merge pull request #687 from Curryraj/fix/windows-daemon-detach
fix: detach the daemon from its console on Windows
2026-07-28 23:34:01 -07:00
Elliot Slusky 2c7cf6118c test: make Windows daemon test cross-platform 2026-07-28 23:27:25 -07:00
CurryrajandClaude Opus 5 2bdd860b54 test: tolerate a 503 memory backend in the route-wiring tests (#685)
``TestMemoryRoutes.test_search`` and ``test_stats`` assert the status code
is in ``(200, 500)``. That list dates from the initial commit; #527 later
made the memory routes raise 503 when the native ``openjarvis_rust``
extension is missing, so both tests now fail on any checkout where the
extension has not been built — which is every contributor who has not run
``maturin develop``.

The failure is spurious: these two tests only check that the routes are
wired up, and their own comment ("May fail if SQLite not set up, that's
ok") says an unavailable backend is tolerated. 503 is exactly that case,
and it is already asserted deliberately in ``TestMemoryRustMissing``
directly below.

Add 503 to the tolerated set via a named constant, so the reason is stated
once rather than repeated as a bare literal.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:17:25 -07:00
Robby Manihani 81f1ffbb4f docs: document how to give an agent OS-level access (#689)
Someone asked in Discord how to give Jarvis access to their whole
machine and hit a confusing failure. With no ~/.openjarvis/config.toml,
tools.enabled and agent.tools both come back empty, and SystemBuilder
builds the agent with no tools at all. It reads like a permissions
problem but it's just missing config, and nothing in the docs points
you anywhere useful.

Adds docs/user-guide/system-access.md, covering the empty tool list as
the usual cause, what shell_exec and the file tools actually reach,
which entry points prompt for confirmation and which quietly
auto-approve, Full Disk Access on macOS and which process needs it, and
the fact that there's no computer use at all, so Accessibility and
Screen Recording grants buy you nothing on their own.

Also adds a full-system-access.toml example to copy from.

Fixes two config tables that describe security.enforce_tool_confirmation
as requiring confirmation before tools run. The loader accepts the key
but nothing on the execution path reads it, so anyone setting it gets
assurance they don't actually have.
2026-07-28 20:11:27 -07:00
github-actions[bot] 93fc7b9e77 chore: update clone traffic data [skip ci] 2026-07-28 08:29:18 +00:00
Jaiydaan RajandClaude Opus 5 9fc5b875d1 fix: detach the daemon from its console on Windows
``jarvis start`` spawned the server with ``start_new_session=True``. That is
POSIX-only — CPython's Windows ``_execute_child`` names the parameter
``unused_start_new_session`` and ignores it — so on Windows the server
inherited the launching console instead of detaching from it.

Closing that console, or logging off, therefore delivered CTRL_CLOSE_EVENT
to the server. Observed in the wild as the daemon dying overnight, with

    forrtl: error (200): program aborting due to window-CLOSE event

in server.log (the Fortran runtime under NumPy handles the event and
aborts). ``jarvis start`` looked like it worked: it printed a PID, wrote the
pid file and exited 0, and the server ran for as long as the console stayed
open. Registered as a log-on scheduled task, this means the machine comes
back up with no backend.

Pass DETACHED_PROCESS on Windows so the child gets no console at all, plus
CREATE_NEW_PROCESS_GROUP so a Ctrl-C in the parent console cannot reach it.
POSIX keeps start_new_session.

Verified by attaching to each spawned process with AttachConsole():
start_new_session=True attaches successfully (the child shares a console);
DETACHED_PROCESS fails with ERROR_INVALID_HANDLE (no console exists).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:17:06 +08:00
github-actions[bot] 08279e6b99 chore: update clone traffic data [skip ci] 2026-07-27 09:46:21 +00:00
github-actions[bot] a7c31e89b4 chore: update clone traffic data [skip ci] 2026-07-26 08:22:36 +00:00
github-actions[bot] 04014c658a chore: update clone traffic data [skip ci] 2026-07-25 08:02:21 +00:00
github-actions[bot] c1238d3e7e chore: update clone traffic data [skip ci] 2026-07-24 08:23:24 +00:00
github-actions[bot] 687e80a55a chore: update clone traffic data [skip ci] 2026-07-23 08:25:07 +00:00
github-actions[bot] b90fd01af2 chore: update clone traffic data [skip ci] 2026-07-22 08:24:05 +00:00
github-actions[bot] bbe7df7d33 chore: update clone traffic data [skip ci] 2026-07-21 08:23:44 +00:00
Arush WadhawanandElliot Slusky 9685b9b78f fix(telemetry): enable WAL and batch writes in TelemetryStore to avoid SQLITE_BUSY under concurrency (#597)
TelemetryStore opened SQLite without WAL, so concurrent readers (server, aggregator, dashboard) hitting the database under inference load raised SQLITE_BUSY, and every insert committed immediately, paying fsync on each record.

- Enable PRAGMA journal_mode=WAL with synchronous=NORMAL and busy_timeout=5000, matching TraceStore.
- Batch inserts in memory under a lock and flush via executemany() when a batch reaches batch_size (default 50), when a batch goes stale, on any read through the store, and on close().
- Run a background flusher thread (default 5s interval) so a partial batch written just before traffic stops still becomes visible to other connections; close() stops the thread with an ordering that prevents touching a closed connection.
- Tests cover batching deferral, read-triggered flushes, stale-batch flushes, and close() behavior.

Fixes #560

Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-07-20 14:11:10 -07:00
goatoush aa2d127de4 fix(frontend): remove jitter when scrolling up during chat autoscroll (#646)
The chat area re-armed autoscroll whenever the user was within 100px of the bottom, so scrolling up during a streaming response fought the incoming content ticks and produced jitter.

Autoscroll now disengages on any upward scroll (direction-based, no distance threshold), re-engages when scrolled back within 2px of the bottom (tolerating sub-pixel rounding at fractional zoom levels, where the at-bottom residual can reach 1px), and ignores sub-1px upward movement so macOS elastic-bounce settling does not disengage it. Sending a message pins the view to the bottom even if the user had scrolled up to read earlier messages.
2026-07-20 13:45:12 -07:00
github-actions[bot] 87f6238338 chore: update clone traffic data [skip ci] 2026-07-20 08:53:13 +00:00
github-actions[bot] 452bcc38cf chore: update clone traffic data [skip ci] 2026-07-19 08:12:04 +00:00
goatoush b35a4c8113 fix(desktop): preserve active chat when switching models (#648)
Switching models from the command palette called createConversation() on every change, creating a persisted empty "New chat" entry and pulling the user out of their active conversation. Because updateLastAssistant writes the visible messages array without checking the active conversation, a mid-stream switch could also clobber the new chat's view with the old conversation's messages.

Remove the conversation-creation side effect. Model switching now preserves the active chat (matching the pull-completion and delete-fallback paths, which already switched silently); the next request uses the newly selected model with the current conversation context. Preloading, loading state, and logging are unchanged.
2026-07-18 12:51:38 -07:00
github-actions[bot] f001e3b0ca chore: update clone traffic data [skip ci] 2026-07-18 07:44:44 +00:00
Cesar Schneider b6dba93ae5 fix: make pytest suite hermetic against local dev-machine state (#647)
Two test-isolation fixes: (1) an autouse conftest fixture sets OPENJARVIS_NO_UPDATE_CHECK=1 so the CLI's PyPI update-check banner (stderr, merged into CliRunner output) can never pollute JSON/CSV-parsing CLI tests on local runs; CI was already covered by CI=true. (2) test_dense.py's Ollama skip-guard now queries /api/tags and requires nomic-embed-text to be pulled instead of a bare TCP connect, so machines running Ollama without the embed model skip instead of erroring. The probe normalizes all documented OLLAMA_HOST forms (full URL, host:port, bare host) and the Ollama-backed tests construct DenseMemory against that same endpoint rather than the embedder's hard-coded localhost default, with unit tests covering the probe. Related: #645.
2026-07-17 13:21:36 -07:00
github-actions[bot] 3000116d18 chore: update clone traffic data [skip ci] 2026-07-17 08:05:34 +00:00
Elliot Slusky 9db21d37ef style: apply Ruff formatting to recently merged tests (#644)
Fix the Ruff formatter check on main by formatting tests changed in #639 and #640 with the repository's pinned Ruff 0.15.1. No behavior change.
2026-07-16 18:13:36 -07:00
Elliot Slusky 95480363b7 style(skills): wrap importer manifest parse call (#643)
Fix the Ruff E501 failure on main introduced during the #639 fix-up. The call was 89 characters against the repository's 88-character limit. No behavior change.
2026-07-16 18:07:44 -07:00
CurryrajandElliot Slusky 4419b76412 fix: catch ImportError in git tools, fall back to CLI when Rust ext missing (#636)
get_rust_module() was called outside the try block in GitStatusTool/GitDiffTool/GitLogTool.execute(), so on installs without the compiled openjarvis-rust extension (e.g. plain pip installs, where openjarvis-rust is a uv-only group since #624) the ImportError escaped uncaught instead of degrading. Move the call inside try and fall back to the git CLI via the existing _run_git helper on ImportError, matching the fallback git_log already had. Adds regression tests covering the fallback path for all three tools.

Co-authored-by: Elliot Slusky <elliot@slusky.com>
2026-07-16 17:59:25 -07:00
Jon Saad-FalconandClaude Opus 4.8 99bbc2054a Add arXiv badge to README (#642)
Add a red arXiv badge linking to the OpenJarvis paper (2605.17172) as
the first item in the header badge row, matching the style used on the
Intelligence-Per-Watt repo.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:42:20 -07:00
Jon Saad-FalconandClaude Opus 4.8 d5d8fddc94 Fix leaderboard savings lookup after provider-key rename (#635)
PR #634 renamed the Anthropic cost-comparison provider key
`claude-opus-4.6` -> `claude-fable-5` but missed one consumer:
App.tsx looks up the Anthropic entry by that key to compute the
`dollar_savings` value submitted to the leaderboard. After the rename
`per_provider.find(p => p.provider === 'claude-opus-4.6')` returned
undefined, so this path silently submitted dollar_savings = 0.

Point the lookup at the new key.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:33:38 -07:00
jaaaaorr06 cadb3e2ae6 feat(skills): enforce capability/trust-tier checks at install and run time (#639)
Wires the previously-dead skills/security.py checks into two places. SkillImporter.import_skill() classifies trust tier before writing to disk, refuses unreviewed skills requesting dangerous capabilities unless confirmed (confirm_dangerous=True, or --yes-dangerous on skill install/sync), and persists tier/capabilities to the .source sidecar. SkillExecutor.run() gains opt-in capability enforcement: allowed_capabilities=None (the default, used by all existing call sites) means no policy; passing a set blocks skills whose required_capabilities are not covered before any step runs. Bulk sync reports refused skills instead of silently skipping them. Both enforcement points are covered by tests.
2026-07-16 17:23:27 -07:00
45 changed files with 1366 additions and 149 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "159,322",
"message": "176,576",
"color": "green",
"namedLogo": "git"
}
+16 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 159322,
"last_updated": "2026-07-16T08:08:14Z",
"total_clones": 176576,
"last_updated": "2026-07-29T08:35:42Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -112,6 +112,19 @@
"2026-07-12": 1917,
"2026-07-13": 2102,
"2026-07-14": 2337,
"2026-07-15": 2362
"2026-07-15": 2362,
"2026-07-16": 2497,
"2026-07-17": 1773,
"2026-07-18": 1542,
"2026-07-19": 1445,
"2026-07-20": 1481,
"2026-07-21": 1528,
"2026-07-22": 1529,
"2026-07-23": 1209,
"2026-07-24": 1118,
"2026-07-25": 928,
"2026-07-26": 740,
"2026-07-27": 799,
"2026-07-28": 665
}
}
+1
View File
@@ -4,6 +4,7 @@
<p><i>Personal AI, On Personal Devices.</i></p>
<p>
<a href="https://arxiv.org/abs/2605.17172"><img src="https://img.shields.io/badge/arXiv-2605.17172-b31b1b.svg" alt="arXiv"></a>
<a href="https://openjarvis.stanford.edu/"><img src="https://img.shields.io/badge/project-OpenJarvis-blue" alt="Project"></a>
<a href="https://open-jarvis.github.io/OpenJarvis/"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python">
@@ -0,0 +1,39 @@
# Full system access: unrestricted shell and filesystem
# Copy to ~/.openjarvis/config.toml
#
# WARNING: shell_exec runs arbitrary commands as your user. No command
# allowlist, no denylist, no working-directory restriction. file_read and
# file_write aren't restricted to any directory either. Only enable what you
# actually want the agent to have. tools.enabled is the whole permission grant;
# there's no second allowlist to configure.
#
# On macOS, this config alone does not reach TCC-protected data (Messages,
# Mail, Photos, Safari). That requires Full Disk Access granted to the process
# hosting the backend. See docs/user-guide/system-access.md.
#
# Usage:
# jarvis ask "What's using the most disk space in my home directory?"
# jarvis chat # prompts before each shell_exec call
[engine]
default = "ollama"
[intelligence]
default_model = "qwen3.5:9b"
[agent]
default_agent = "orchestrator"
max_turns = 10
[tools]
enabled = [
"shell_exec",
"file_read",
"file_write",
"apply_patch",
"code_interpreter",
"git_status",
"git_diff",
"think",
"calculator",
]
+1 -1
View File
@@ -604,7 +604,7 @@ enforce_tool_confirmation = true
| `scan_output` | bool | `true` | Whether to scan model output. |
| `secret_scanner` | bool | `true` | Enable secret detection (API keys, tokens, passwords). |
| `pii_scanner` | bool | `true` | Enable PII detection (emails, SSNs, credit cards). |
| `enforce_tool_confirmation` | bool | `true` | Require confirmation before executing tools. |
| `enforce_tool_confirmation` | bool | `true` | Accepted but **not currently enforced**. Whether you get prompts depends on the entry point. See [System Access](../user-guide/system-access.md#confirmation-behaviour). |
!!! tip "Choosing a security mode"
Use `"warn"` during development to see what would be flagged without disrupting output.
+13
View File
@@ -135,6 +135,19 @@ cd OpenJarvis
This launches the backend API server and a React frontend at [http://localhost:5173](http://localhost:5173).
You get a ChatGPT-like interface with streaming responses, tool use, energy monitoring, and a telemetry dashboard — all running locally on your hardware.
Web search is available through the built-in DuckDuckGo fallback. To use
Tavily, add `TAVILY_API_KEY` under **Settings → Tools → Web Search** after the
app starts, or export it before starting quickstart:
```bash
export TAVILY_API_KEY="tvly-..."
./scripts/quickstart.sh
```
The script does not automatically source `.env` files. Run `source .env`
first if that is where you keep the key. Stop any existing OpenJarvis server
before restarting so it inherits the updated environment.
To stop all services, press ++ctrl+c++ in the terminal.
!!! tip "Environment variable"
+1 -1
View File
@@ -392,7 +392,7 @@ enforce_tool_confirmation = true
| `secret_scanner` | `bool` | `true` | Run `SecretScanner` on all text |
| `pii_scanner` | `bool` | `true` | Run `PIIScanner` on all text |
| `audit_log_path` | `str` | `~/.openjarvis/audit.db` | Path to the SQLite audit log |
| `enforce_tool_confirmation` | `bool` | `true` | Require explicit confirmation before tool execution |
| `enforce_tool_confirmation` | `bool` | `true` | Accepted by the loader but **not currently enforced**. See [System Access](system-access.md#confirmation-behaviour) for when prompts actually happen |
!!! tip "Start with warn, tighten later"
`mode = "warn"` is a good starting point. It lets you observe what patterns are being triggered without disrupting normal usage. Switch to `"redact"` once you are satisfied that the scanner isn't producing too many false positives for your workload.
+198
View File
@@ -0,0 +1,198 @@
# System Access
How to give an agent access to the machine it runs on, and where the real
limits are.
!!! warning
`shell_exec` runs arbitrary commands as your user. There is no command
allowlist, no denylist, and no sandbox unless you turn one on. An agent
holding this tool can do anything you can do from a terminal.
---
## Start here: you probably have no tools enabled
If the agent tells you it can't run commands or read files, that's usually not
a permissions problem. It means no tools were enabled in the first place.
Tools come from `tools.enabled`, falling back to `agent.tools`. Both default to
empty, and an empty value builds the agent with **zero tools**. Nothing is
enabled by default.
First check whether you have a config file at all:
```bash
cat ~/.openjarvis/config.toml
```
If it isn't there, that's your answer. Create it:
```toml
[engine]
default = "ollama"
[intelligence]
default_model = "qwen3.5:9b"
[agent]
default_agent = "orchestrator"
[tools]
enabled = ["shell_exec", "file_read", "file_write", "think"]
```
There's a fuller version at
`configs/openjarvis/examples/full-system-access.toml`.
Then confirm the list actually resolved:
```bash
python -c "from openjarvis.core.config import load_config; print(load_config().tools.enabled)"
```
---
## What the tools reach
| Tool | Scope |
|------|-------|
| `shell_exec` | Any command, as your user. 30s default timeout, 300s max, output capped at 100 KB per stream. |
| `file_read` | Any readable path. 1 MB cap. |
| `file_write` | Any writable path. 10 MB cap, can create parent directories. |
| `apply_patch` | Applies unified diffs to any path. |
| `code_interpreter` | Python in a subprocess, behind a coarse pattern blocklist. |
`file_read` and `file_write` take an `allowed_dirs` argument that limits them to
a set of directories, but no config key populates it. When it's empty every path
is allowed. If you want a filesystem jail today, use the container sandbox
instead of relying on these tools to enforce one.
### Sensitive filenames
`file_read` and `file_write` refuse names matching a short glob list: `.env`,
`*.pem`, `id_rsa`, `credentials.*` and a dozen or so others. It matches on the
filename only, not the path or the contents, and only those two tools consult
it. `shell_exec`, `apply_patch` and `code_interpreter` skip it entirely, so
`cat ~/.ssh/id_rsa` through `shell_exec` works fine. Treat it as protection
against fat fingers, not as a security boundary.
---
## Confirmation behaviour
`shell_exec`, `git_commit` and `agent_kill` are marked `requires_confirmation`.
What that translates to depends entirely on how you launched the agent:
| Entry point | Behaviour |
|-------------|-----------|
| `jarvis chat` | Prompts before each call. |
| `jarvis ask` | Auto-approves. |
| `jarvis agent ask` | Auto-approves. Pass `--no-yes` if you want prompts. |
| HTTP server, desktop app | Auto-approves. Tools you added to an agent's toolkit count as pre-approved. |
| Embedded via `SystemBuilder` | No callback is wired, so these tools fail closed. |
That last row catches people out. If `shell_exec` returns "requires
confirmation but no confirmation callback is available", you're constructing the
agent yourself and need to pass a `confirm_callback`.
!!! note "`enforce_tool_confirmation` doesn't do anything"
The config loader accepts `security.enforce_tool_confirmation`, but nothing
on the tool execution path reads it. Setting it won't change confirmation
behaviour anywhere. Use the table above instead.
---
## macOS: Full Disk Access
On macOS the operating system is the real boundary, not the config. Shell
access and ordinary file access start working as soon as you enable the tools.
TCC-protected data does not: Messages, Mail, Photos, Safari history, Contacts
and Calendar all stay locked, and no config key will change that.
Grant Full Disk Access to whichever process hosts the backend. Child processes
inherit it:
| How you run OpenJarvis | Grant access to |
|------------------------|-----------------|
| CLI (`jarvis ask`, `jarvis chat`) | Your terminal (Terminal, iTerm, Warp) |
| Desktop app | `OpenJarvis.app`, which spawns `jarvis serve` beneath it |
| launchd (`deploy/launchd/com.openjarvis.plist`) | The `jarvis` binary, as its own entry |
System Settings, then Privacy & Security, then Full Disk Access, then **+**.
A launchd daemon gets its own TCC context, so granting access to Terminal does
nothing for it. Add `/usr/local/bin/jarvis` separately.
To check whether the grant took:
```bash
head -c 16 ~/Library/Messages/chat.db >/dev/null 2>&1 \
&& echo "granted" || echo "denied"
```
Restart the host process after you change the setting.
### Driving Mac apps
AppleScript works through `shell_exec`:
```
osascript -e 'tell application "Music" to play'
```
macOS asks for Automation permission once per target app, the first time you
touch it.
---
## What you can't do
There's no computer use. OpenJarvis can't see your screen, move the pointer or
send keystrokes. No tool for it is registered and no input automation library
appears anywhere in the codebase, so granting Accessibility or Screen Recording
buys you nothing on its own.
The `click` and `type` actions you'll find are Playwright, scoped to a browser
page rather than the desktop.
Some of this is reachable through `shell_exec` if you bring the tooling
yourself. `screencapture` will take screenshots once you've granted Screen
Recording, and something like `cliclick` will move the pointer. That gets you
scripted actions. It doesn't get you an agent that looks at the screen and
works out where to click.
---
## Narrowing access
Access widens and narrows through `tools.enabled`. Drop entries to take
capabilities away. That list is the whole grant.
Two stronger isolation options exist. Both are off by default:
```toml
[sandbox]
enabled = true # run tools inside a container
runtime = "docker"
[security.capabilities]
enabled = true # RBAC over declared tool capabilities
policy_path = "~/.openjarvis/policy.yaml"
```
!!! note "Capabilities are open by default even once enabled"
`CapabilityPolicy` is built with `default_deny=False` and no config key
exposes that flag, so an agent with no explicit policy entry gets every
capability. Write entries for every agent you mean to restrict.
For anything untrusted, reach for `docker_shell_exec` and
`code_interpreter_docker` rather than the host-side versions.
---
## See also
- [Security](security.md) for scanners, the audit log and guardrails
- [Tools](tools.md) for the full registry
- [Code Assistant](code-assistant.md) for a narrower shell-enabled setup
- [External MCP Servers](mcp-external-servers.md) for capabilities OpenJarvis doesn't ship
+1 -1
View File
@@ -89,7 +89,7 @@ export default function App() {
setSavings(data);
if (optInEnabled && optInDisplayName && data) {
const claudeEntry = data.per_provider.find(
(p) => p.provider === 'claude-opus-4.6',
(p) => p.provider === 'claude-fable-5',
);
const dollarSavings = claudeEntry ? claudeEntry.total_cost : 0;
const energySaved = data.per_provider.reduce(
+23 -2
View File
@@ -22,6 +22,8 @@ export function ChatArea() {
const navigate = useNavigate();
const listRef = useRef<HTMLDivElement>(null);
const shouldAutoScroll = useRef(true);
const wasStreaming = useRef(false);
const lastScrollTop = useRef(0);
// Check if any data sources are connected
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
@@ -34,15 +36,34 @@ export function ChatArea() {
}, []);
useEffect(() => {
// Sending a message always pins the view to the bottom, even if the
// user had scrolled up to read earlier messages.
if (streamState.isStreaming && !wasStreaming.current) {
shouldAutoScroll.current = true;
}
wasStreaming.current = streamState.isStreaming;
if (shouldAutoScroll.current && listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages, streamState.content]);
}, [messages, streamState.content, streamState.isStreaming]);
const handleScroll = () => {
if (!listRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100;
const distance = scrollHeight - scrollTop - clientHeight;
const scrolledUp = scrollTop < lastScrollTop.current;
lastScrollTop.current = scrollTop;
if (scrolledUp && distance >= 1) {
// Any upward scroll away from the bottom stops autoscroll immediately,
// so streaming content never fights the user (no jitter). Sub-1px
// upward movement (elastic bounce settling at the bottom) is ignored.
shouldAutoScroll.current = false;
} else if (!scrolledUp) {
// Re-engage when scrolled back to the bottom. < 2 rather than < 1:
// at fractional zoom levels the at-bottom residual can reach 1px,
// which would otherwise leave autoscroll permanently disengaged.
shouldAutoScroll.current = distance < 2;
}
};
const isEmpty = messages.length === 0 && !streamState.isStreaming;
+1 -2
View File
@@ -149,8 +149,7 @@ export function CommandPalette() {
setCommandPaletteOpen(false);
if (modelId !== previousModel) {
const { createConversation, setModelLoading, addLogEntry } = useAppStore.getState();
createConversation(modelId);
const { setModelLoading, addLogEntry } = useAppStore.getState();
setModelLoading(true);
addLogEntry({ timestamp: Date.now(), level: 'info', category: 'model', message: `Switching to ${modelId}...` });
try {
+50
View File
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// authHeaders) that source the key and build the header.
const SETTINGS_KEY = 'openjarvis-settings';
const fetchMock = vi.fn<typeof fetch>();
// Minimal in-memory localStorage stub so the helpers can run under node
// (no jsdom dependency).
@@ -28,6 +29,8 @@ class MemoryStorage {
beforeEach(() => {
vi.resetModules();
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
fetchMock.mockReset();
globalThis.fetch = fetchMock;
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
new MemoryStorage();
});
@@ -86,3 +89,50 @@ describe('authHeaders', () => {
});
});
});
describe('tool credentials', () => {
it('reads credential status from the local server', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ TAVILY_API_KEY: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const { fetchToolCredentialStatus } = await freshApi();
await expect(fetchToolCredentialStatus('web_search')).resolves.toEqual({
TAVILY_API_KEY: true,
});
expect(fetchMock).toHaveBeenCalledWith(
'/v1/tools/web_search/credentials/status',
{ headers: {} },
);
});
it('saves a tool credential through the local server', async () => {
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
const { saveToolCredentials } = await freshApi();
await saveToolCredentials('web_search', {
TAVILY_API_KEY: 'tvly-test',
});
expect(fetchMock).toHaveBeenCalledWith('/v1/tools/web_search/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ TAVILY_API_KEY: 'tvly-test' }),
});
});
it('deletes a tool credential through the local server', async () => {
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
const { deleteToolCredential } = await freshApi();
await deleteToolCredential('web_search', 'TAVILY_API_KEY');
expect(fetchMock).toHaveBeenCalledWith(
'/v1/tools/web_search/credentials/TAVILY_API_KEY',
{ method: 'DELETE', headers: {} },
);
});
});
+19
View File
@@ -885,6 +885,25 @@ export async function saveToolCredentials(
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
export async function fetchToolCredentialStatus(
toolName: string,
): Promise<Record<string, boolean>> {
const res = await apiFetch(`/v1/tools/${toolName}/credentials/status`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return await res.json();
}
export async function deleteToolCredential(
toolName: string,
keyName: string,
): Promise<void> {
const res = await apiFetch(
`/v1/tools/${encodeURIComponent(toolName)}/credentials/${encodeURIComponent(keyName)}`,
{ method: 'DELETE' },
);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
}
export interface AgentTraceDetail {
id: string;
agent: string;
-1
View File
@@ -23,7 +23,6 @@ import {
fetchAgentTrace,
fetchManagedAgent,
fetchAvailableTools,
saveToolCredentials,
fetchModels,
updateManagedAgent,
fetchRecommendedModel,
+36 -9
View File
@@ -27,6 +27,9 @@ import {
setInferenceSource,
getCloudKeyStatus,
saveCloudKey,
fetchToolCredentialStatus,
saveToolCredentials,
deleteToolCredential,
isTauri,
type InferenceSource,
} from '../lib/api';
@@ -56,25 +59,37 @@ function OllamaModelList() {
);
}
function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: string }) {
function ApiKeyInput({
keyName,
placeholder,
toolName,
}: {
keyName: string;
placeholder: string;
toolName?: string;
}) {
const [value, setValue] = useState('');
const [saved, setSaved] = useState(false);
const [hasKey, setHasKey] = useState(false);
const [error, setError] = useState('');
const desktopKeyStorage = isTauri();
const serverToolStorage = !desktopKeyStorage && !!toolName;
const canManage = desktopKeyStorage || serverToolStorage;
const refresh = useCallback(async () => {
if (!desktopKeyStorage) {
if (!canManage) {
setHasKey(false);
return;
}
try {
const status = await getCloudKeyStatus();
const status = desktopKeyStorage
? await getCloudKeyStatus()
: await fetchToolCredentialStatus(toolName!);
setHasKey(!!status[keyName]);
} catch {
setHasKey(false);
}
}, [desktopKeyStorage, keyName]);
}, [canManage, desktopKeyStorage, keyName, toolName]);
useEffect(() => {
void refresh();
@@ -87,7 +102,13 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
if (!next) return;
setError('');
try {
await saveCloudKey(keyName, next);
if (desktopKeyStorage) {
await saveCloudKey(keyName, next);
} else if (toolName) {
await saveToolCredentials(toolName, { [keyName]: next });
} else {
return;
}
setValue('');
setHasKey(true);
setSaved(true);
@@ -101,7 +122,13 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
const remove = async () => {
setError('');
try {
await saveCloudKey(keyName, '');
if (desktopKeyStorage) {
await saveCloudKey(keyName, '');
} else if (toolName) {
await deleteToolCredential(toolName, keyName);
} else {
return;
}
setValue('');
setHasKey(false);
setSaved(true);
@@ -119,8 +146,8 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
value={value}
onChange={e => setValue(e.target.value)}
onBlur={() => { if (value.trim()) void save(value); }}
placeholder={hasKey ? 'Saved in secure storage' : placeholder}
disabled={!desktopKeyStorage}
placeholder={hasKey ? (desktopKeyStorage ? 'Saved in secure storage' : 'Saved by local server') : placeholder}
disabled={!canManage}
className="w-48 px-2 py-1 rounded text-xs"
style={{ background: 'var(--color-bg)', border: '1px solid var(--color-border)', color: 'var(--color-text)' }} />
{hasKey && (
@@ -542,7 +569,7 @@ export function SettingsPage() {
{/* Tools */}
<Section title="Tools">
<SettingRow label="Web Search" description="Tavily key for web search tool">
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." />
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." toolName="web_search" />
</SettingRow>
</Section>
+9 -1
View File
@@ -54,7 +54,15 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
'/v1': process.env.VITE_API_URL || 'http://localhost:8000',
// ws: true is required for the /v1/agents/events WebSocket. Without it
// Vite proxies the HTTP request but not the upgrade, so the socket never
// opens — no error, no close event, just silence — and every live agent
// view sits empty in dev while working in a production build.
'/v1': {
target: process.env.VITE_API_URL || 'http://localhost:8000',
changeOrigin: true,
ws: true,
},
'/health': process.env.VITE_API_URL || 'http://localhost:8000',
'/api': process.env.VITE_API_URL || 'http://localhost:8000',
},
+1
View File
@@ -196,6 +196,7 @@ nav:
- Telemetry: user-guide/telemetry.md
- Evaluations: user-guide/evaluations.md
- Benchmarks: user-guide/benchmarks.md
- System Access: user-guide/system-access.md
- Security: user-guide/security.md
- LLM-guided spec search: user-guide/llm-guided-spec-search.md
- Leaderboard: leaderboard.md
+10 -3
View File
@@ -148,7 +148,8 @@ fi
# ── 7. Install Python dependencies ──────────────────────────────────
info "Installing Python dependencies..."
uv sync --extra desktop --quiet 2>/dev/null || uv sync --extra desktop
uv sync --extra desktop --extra tools-search --quiet 2>/dev/null \
|| uv sync --extra desktop --extra tools-search
ok "Python dependencies installed"
# ── 7b. Build Rust extension ──────────────────────────────────────
@@ -164,11 +165,17 @@ ok "Frontend dependencies installed"
# ── 9. Start backend ────────────────────────────────────────────────
info "Starting backend API server on port 8000..."
if curl -sf http://localhost:8000/health &>/dev/null; then
fail "An OpenJarvis server is already running on port 8000. Stop it before re-running quickstart so updated environment variables are applied."
fi
uv run jarvis serve --port 8000 &>/dev/null &
CLEANUP_PIDS+=($!)
BACKEND_PID=$!
CLEANUP_PIDS+=("$BACKEND_PID")
sleep 3
if curl -sf http://localhost:8000/health &>/dev/null; then
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
fail "Backend exited during startup. Run 'uv run jarvis serve --port 8000' to see the error."
elif curl -sf http://localhost:8000/health &>/dev/null; then
ok "Backend running at http://localhost:8000"
else
warn "Backend may still be starting..."
+16 -2
View File
@@ -81,14 +81,28 @@ def start(
if agent_name:
cmd.extend(["--agent", agent_name])
# Start as background process
# Start as background process, fully detached from the launching terminal.
#
# ``start_new_session`` is POSIX-only: CPython's Windows ``_execute_child``
# names the parameter ``unused_start_new_session`` and ignores it. Relying
# on it there leaves the server sharing its parent's console, so closing
# that console — or logging off — delivers CTRL_CLOSE_EVENT and kills the
# daemon. DETACHED_PROCESS gives it no console at all; the new process
# group additionally stops a Ctrl-C in the parent reaching it.
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
log_fh = open(_LOG_FILE, "a") # noqa: SIM115
spawn_kwargs: dict = {}
if sys.platform == "win32":
spawn_kwargs["creationflags"] = (
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
)
else:
spawn_kwargs["start_new_session"] = True
proc = subprocess.Popen(
cmd,
stdout=log_fh,
stderr=log_fh,
start_new_session=True,
**spawn_kwargs,
)
_write_pid(proc.pid)
+6
View File
@@ -10,6 +10,7 @@ from rich.console import Console
from openjarvis.cli._banner import print_banner
from openjarvis.core.config import load_config
from openjarvis.core.credentials import inject_credentials
from openjarvis.core.events import EventBus
from openjarvis.core.paths import get_config_dir
from openjarvis.engine import (
@@ -122,6 +123,11 @@ def serve(
)
sys.exit(1)
# Tool credentials saved through the browser UI live in the OpenJarvis
# credential store. Restore them before engines and tools are constructed
# so availability checks and tool instances see the same environment.
inject_credentials()
config = load_config()
# Resolve host/port from CLI args or config
+38 -3
View File
@@ -199,7 +199,16 @@ def _get_resolver(source: str, url: str = ""):
default="",
help="Repo URL (required when source is 'github').",
)
def install(query: str, with_scripts: bool, force: bool, url: str):
@click.option(
"--yes-dangerous",
is_flag=True,
default=False,
help=(
"Confirm installing an unreviewed skill that requests dangerous "
"capabilities (shell/network-listen/filesystem-write)."
),
)
def install(query: str, with_scripts: bool, force: bool, url: str, yes_dangerous: bool):
"""Install a skill from a source.
Example: ``jarvis skill install hermes:apple-notes``
@@ -233,7 +242,12 @@ def install(query: str, with_scripts: bool, force: bool, url: str):
from openjarvis.skills.tool_translator import ToolTranslator
importer = SkillImporter(parser=SkillParser(), tool_translator=ToolTranslator())
result = importer.import_skill(matches[0], with_scripts=with_scripts, force=force)
result = importer.import_skill(
matches[0],
with_scripts=with_scripts,
force=force,
confirm_dangerous=yes_dangerous,
)
if result.success:
if result.skipped:
@@ -270,6 +284,15 @@ def install(query: str, with_scripts: bool, force: bool, url: str):
help="Import scripts/ directories.",
)
@click.option("--force", is_flag=True, default=False, help="Re-import existing skills.")
@click.option(
"--yes-dangerous",
is_flag=True,
default=False,
help=(
"Confirm installing unreviewed skills that request dangerous "
"capabilities (shell/network-listen/filesystem-write)."
),
)
def sync(
source: str,
category: str,
@@ -277,6 +300,7 @@ def sync(
search: str,
with_scripts: bool,
force: bool,
yes_dangerous: bool,
):
"""Bulk install + update from a source (or all configured sources)."""
console = Console()
@@ -343,9 +367,20 @@ def sync(
installed_count = 0
for resolved in skills_to_import:
r = importer.import_skill(resolved, with_scripts=with_scripts, force=force)
r = importer.import_skill(
resolved,
with_scripts=with_scripts,
force=force,
confirm_dangerous=yes_dangerous,
)
if r.success and not r.skipped:
installed_count += 1
elif not r.success and r.requires_confirmation:
console.print(
f" [yellow]Skipped {resolved.name}: requests dangerous "
f"capabilities {r.dangerous_capabilities} "
"(re-run with --yes-dangerous to install)[/yellow]"
)
console.print(f" Imported {installed_count}/{len(skills_to_import)} skills")
total_installed += installed_count
+41 -13
View File
@@ -67,6 +67,24 @@ def load_credentials(path: Path | None = None) -> dict[str, dict[str, str]]:
return tomllib.load(f)
def _validate_credential_key(tool_name: str, key: str) -> None:
allowed = TOOL_CREDENTIALS.get(tool_name, [])
if key not in allowed:
raise ValueError(f"Unknown credential key '{key}' for tool '{tool_name}'")
def _write_credentials(creds: dict[str, dict[str, str]], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
lines: list[str] = []
for section, kvs in creds.items():
lines.append(f"[{section}]")
for k, v in kvs.items():
lines.append(f'{k} = "{v}"')
lines.append("")
path.write_text("\n".join(lines))
os.chmod(path, 0o600)
def save_credential(
tool_name: str,
key: str,
@@ -75,9 +93,7 @@ def save_credential(
path: Path | None = None,
) -> None:
"""Save a single credential key, validate, write file, and set os.environ."""
allowed = TOOL_CREDENTIALS.get(tool_name, [])
if key not in allowed:
raise ValueError(f"Unknown credential key '{key}' for tool '{tool_name}'")
_validate_credential_key(tool_name, key)
stripped = value.strip()
if not stripped:
raise ValueError("Credential value must not be empty")
@@ -88,20 +104,32 @@ def save_credential(
if tool_name not in creds:
creds[tool_name] = {}
creds[tool_name][key] = stripped
p.parent.mkdir(parents=True, exist_ok=True)
lines: list[str] = []
for section, kvs in creds.items():
lines.append(f"[{section}]")
for k, v in kvs.items():
lines.append(f'{k} = "{v}"')
lines.append("")
p.write_text("\n".join(lines))
os.chmod(p, 0o600)
_write_credentials(creds, p)
os.environ[key] = stripped
def delete_credential(
tool_name: str,
key: str,
*,
path: Path | None = None,
) -> None:
"""Delete a persisted credential and remove it from the running process."""
_validate_credential_key(tool_name, key)
p = Path(path) if path else _default_path()
with _LOCK:
creds = load_credentials(path=p)
tool_creds = creds.get(tool_name)
if tool_creds is not None:
tool_creds.pop(key, None)
if not tool_creds:
creds.pop(tool_name, None)
_write_credentials(creds, p)
os.environ.pop(key, None)
def get_credential_status(tool_name: str) -> dict[str, bool]:
"""Return {KEY: bool} for each required key indicating if set in env."""
keys = TOOL_CREDENTIALS.get(tool_name, [])
+10 -2
View File
@@ -285,16 +285,17 @@ def build_tools_list() -> List[Dict[str, Any]]:
logger.debug("Could not instantiate tool %s: %s", name, exc)
spec = None
cred_keys = TOOL_CREDENTIALS.get(name, [])
has_fallback = bool(spec and spec.metadata.get("fallback"))
items.append(
{
"name": name,
"description": spec.description if spec else "",
"category": spec.category if spec else "",
"source": "tool",
"requires_credentials": len(cred_keys) > 0,
"requires_credentials": len(cred_keys) > 0 and not has_fallback,
"credential_keys": cred_keys,
"configured": (
all(bool(os.environ.get(k)) for k in cred_keys)
has_fallback or all(bool(os.environ.get(k)) for k in cred_keys)
if cred_keys
else True
),
@@ -2238,6 +2239,13 @@ def create_agent_manager_router(
saved.append(key)
return {"saved": saved}
@tools_router.delete("/{tool_name}/credentials/{key}")
def remove_tool_credential(tool_name: str, key: str):
from openjarvis.core.credentials import delete_credential
delete_credential(tool_name, key)
return {"deleted": key}
@tools_router.get("/{tool_name}/credentials/status")
def credential_status(tool_name: str):
from openjarvis.core.credentials import get_credential_status
+4 -2
View File
@@ -25,9 +25,11 @@ class SessionStore:
def __init__(self, db_path: str = "") -> None:
if not db_path:
db_path = str(get_config_dir() / "sessions.db")
from openjarvis.security.file_utils import secure_create
# Ensure the parent directory exists (skip for :memory:)
if db_path != ":memory:":
from openjarvis.security.file_utils import secure_create
secure_create(Path(db_path))
secure_create(Path(db_path))
self._db = sqlite3.connect(db_path, check_same_thread=False)
self._db.row_factory = sqlite3.Row
self._create_tables()
+40 -1
View File
@@ -5,10 +5,11 @@ from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional, Set
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.types import ToolCall, ToolResult
from openjarvis.skills.security import validate_capabilities
from openjarvis.skills.types import SkillManifest
from openjarvis.tools._stubs import ToolExecutor
@@ -37,10 +38,16 @@ class SkillExecutor:
tool_executor: ToolExecutor,
*,
bus: Optional[EventBus] = None,
allowed_capabilities: Optional[Set[str]] = None,
) -> None:
self._tool_executor = tool_executor
self._bus = bus
self._skill_resolver: Optional[SkillResolver] = None
# None means "no capability policy" — every skill runs, matching the
# behavior before capability enforcement existed. Pass a set (even an
# empty one) to enforce: skills whose required_capabilities are not a
# subset of it are blocked before any step runs.
self._allowed_capabilities: Optional[Set[str]] = allowed_capabilities
def set_skill_resolver(self, resolver: SkillResolver) -> None:
"""Register a callback used to delegate ``skill_name`` steps."""
@@ -53,6 +60,38 @@ class SkillExecutor:
initial_context: Optional[Dict[str, Any]] = None,
) -> SkillResult:
"""Execute all steps in a skill manifest."""
missing = (
validate_capabilities(manifest, self._allowed_capabilities)
if self._allowed_capabilities is not None
else []
)
if missing:
if self._bus:
self._bus.publish(
EventType.SKILL_EXECUTE_START,
{"skill": manifest.name, "steps": len(manifest.steps)},
)
self._bus.publish(
EventType.SKILL_EXECUTE_END,
{"skill": manifest.name, "success": False},
)
return SkillResult(
skill_name=manifest.name,
success=False,
step_results=[
ToolResult(
tool_name=manifest.name,
content=(
f"Blocked: skill '{manifest.name}' requires "
f"capabilities {missing} that were not granted "
"for this session."
),
success=False,
)
],
context=dict(initial_context or {}),
)
ctx: Dict[str, Any] = dict(initial_context or {})
all_results: List[ToolResult] = []
+46 -1
View File
@@ -25,6 +25,11 @@ import yaml
from openjarvis.core.paths import get_config_dir
from openjarvis.skills.parser import SkillParser
from openjarvis.skills.security import (
TrustTier,
classify_trust_tier,
has_dangerous_capabilities,
)
from openjarvis.skills.sources.base import ResolvedSkill
from openjarvis.skills.tool_translator import ToolTranslator
@@ -43,6 +48,9 @@ class ImportResult:
untranslated_tools: List[str] = field(default_factory=list)
scripts_imported: bool = False
warnings: List[str] = field(default_factory=list)
trust_tier: TrustTier = TrustTier.UNREVIEWED
dangerous_capabilities: List[str] = field(default_factory=list)
requires_confirmation: bool = False
class SkillImporter:
@@ -66,6 +74,7 @@ class SkillImporter:
*,
with_scripts: bool = False,
force: bool = False,
confirm_dangerous: bool = False,
) -> ImportResult:
"""Install *resolved* into ``<target_root>/<source>/<name>/``.
@@ -95,12 +104,45 @@ class SkillImporter:
try:
frontmatter, body = self._read_skill_md(source_md)
self._parser.parse_frontmatter(frontmatter, markdown_content=body)
manifest = self._parser.parse_frontmatter(
frontmatter, markdown_content=body
)
except Exception as exc:
result.success = False
result.warnings.append(f"Parse error: {exc}")
return result
# 1a. Classify trust and check for dangerous capabilities *before*
# writing anything to disk. Everything the importer handles comes from
# an external source (github/hermes/openclaw), so the BUNDLED and
# WORKSPACE tiers never apply here, and no resolver verifies index
# membership yet — a signature alone still classifies as UNREVIEWED.
# Community skills get no special treatment just because they came
# from a named source.
result.trust_tier = classify_trust_tier(
has_signature=bool(manifest.signature),
)
result.dangerous_capabilities = has_dangerous_capabilities(manifest)
if result.dangerous_capabilities and result.trust_tier == TrustTier.UNREVIEWED:
result.requires_confirmation = True
if not confirm_dangerous:
result.success = False
result.warnings.append(
"Refusing to install: this unreviewed skill requests "
f"dangerous capabilities {result.dangerous_capabilities}. "
"Re-run with confirm_dangerous=True (or `--yes-dangerous` "
"on the CLI) only if you trust the source and have "
"reviewed what it does."
)
return result
result.warnings.append(
"Installed with dangerous capabilities "
f"{result.dangerous_capabilities} — confirmed by caller. "
"This skill can run shell commands, open network listeners, "
"and/or write to the filesystem."
)
# 2. Translate tool references
translated_body, untranslated = self._translator.translate_markdown(body)
result.untranslated_tools = untranslated
@@ -180,6 +222,7 @@ class SkillImporter:
translated_str = ", ".join(f'"{t}"' for t in result.translated_tools)
missing_str = ", ".join(f'"{t}"' for t in result.untranslated_tools)
scripts_lower = "true" if result.scripts_imported else "false"
dangerous_str = ", ".join(f'"{c}"' for c in result.dangerous_capabilities)
content = (
f'source = "{resolved.source}:{resolved.name}"\n'
@@ -189,6 +232,8 @@ class SkillImporter:
f"translated_tools = [{translated_str}]\n"
f"missing_tools = [{missing_str}]\n"
f"scripts_imported = {scripts_lower}\n"
f'trust_tier = "{result.trust_tier.value}"\n'
f"dangerous_capabilities = [{dangerous_str}]\n"
)
(target_dir / ".source").write_text(content, encoding="utf-8")
+192 -82
View File
@@ -108,6 +108,13 @@ INSERT INTO telemetry (
)
"""
_INSERT_MINING = """\
INSERT INTO mining_stats (
recorded_at, provider_id, shares_submitted, shares_accepted, blocks_found,
hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
_MIGRATE_COLUMNS = [
("gpu_utilization_pct", "REAL NOT NULL DEFAULT 0.0"),
("gpu_memory_used_gb", "REAL NOT NULL DEFAULT 0.0"),
@@ -144,10 +151,35 @@ _MIGRATE_COLUMNS = [
class TelemetryStore:
"""Append-only SQLite store for inference telemetry records."""
"""Append-only SQLite store for inference telemetry records.
Writes are batched in memory and flushed to SQLite when a batch reaches
``batch_size``, when ``flush_interval_seconds`` elapses (a background
flusher thread guarantees this even with no further writes), on any read
through this store, and on ``close()``. Readers that open their OWN
connection to the database file (e.g. ``TelemetryAggregator``) therefore
see new rows within ``flush_interval_seconds`` at the latest; call
``flush()`` first for immediate visibility. Pass
``flush_interval_seconds=0`` to disable time-based flushing (batch-size
and read/close flushes still apply).
"""
def __init__(
self,
db_path: str | Path,
batch_size: int = 50,
flush_interval_seconds: float = 5.0,
) -> None:
if batch_size < 1:
raise ValueError("batch_size must be >= 1")
if flush_interval_seconds < 0:
raise ValueError("flush_interval_seconds must be >= 0")
def __init__(self, db_path: str | Path) -> None:
self._db_path = str(db_path)
if self._db_path != ":memory:":
from openjarvis.security.file_utils import secure_create
secure_create(Path(self._db_path))
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._lock = threading.Lock()
self._conn.execute("PRAGMA journal_mode=WAL")
@@ -158,6 +190,38 @@ class TelemetryStore:
self._conn.commit()
self._migrate_schema()
self._batch_size = batch_size
self._flush_interval_seconds = flush_interval_seconds
self._last_flush_time = time.monotonic()
self._telemetry_batch: list[tuple[Any, ...]] = []
self._mining_batch: list[tuple[Any, ...]] = []
self._closed = False
# Background flusher: without it, a partial batch written just before
# traffic stops would stay invisible to other connections until the
# NEXT write arrived (the stale check in ``_maybe_flush_unlocked``
# only runs inside record calls). Daemon so it never blocks exit.
self._stop_flusher = threading.Event()
self._flusher: threading.Thread | None = None
if flush_interval_seconds > 0:
self._flusher = threading.Thread(
target=self._flush_loop,
name="telemetry-store-flusher",
daemon=True,
)
self._flusher.start()
def _flush_loop(self) -> None:
"""Periodically flush pending batches until ``close()`` stops us."""
while not self._stop_flusher.wait(self._flush_interval_seconds):
with self._lock:
# ``close()`` sets the event BEFORE taking the lock, so seeing
# it unset here means the connection is still open.
if self._stop_flusher.is_set():
break
if self._telemetry_batch or self._mining_batch:
self._flush_unlocked()
def _migrate_schema(self) -> None:
"""Add new columns to existing databases (idempotent)."""
for col_name, col_def in _MIGRATE_COLUMNS:
@@ -171,54 +235,52 @@ class TelemetryStore:
def record(self, rec: TelemetryRecord) -> None:
"""Persist a single telemetry record."""
row = (
rec.timestamp,
rec.model_id,
rec.engine,
rec.agent,
rec.prompt_tokens,
rec.prompt_tokens_evaluated,
rec.completion_tokens,
rec.total_tokens,
rec.latency_seconds,
rec.ttft,
rec.cost_usd,
rec.energy_joules,
rec.power_watts,
rec.gpu_utilization_pct,
rec.gpu_memory_used_gb,
rec.gpu_temperature_c,
rec.throughput_tok_per_sec,
rec.prefill_latency_seconds,
rec.decode_latency_seconds,
rec.energy_method,
rec.energy_vendor,
rec.batch_id,
1 if rec.is_warmup else 0,
rec.cpu_energy_joules,
rec.gpu_energy_joules,
rec.dram_energy_joules,
rec.tokens_per_joule,
rec.energy_per_output_token_joules,
rec.throughput_per_watt,
rec.prefill_energy_joules,
rec.decode_energy_joules,
rec.mean_itl_ms,
rec.median_itl_ms,
rec.p90_itl_ms,
rec.p95_itl_ms,
rec.p99_itl_ms,
rec.std_itl_ms,
1 if rec.is_streaming else 0,
rec.token_counting_version,
rec.mining_session_id,
json.dumps(rec.metadata),
)
with self._lock:
self._conn.execute(
_INSERT,
(
rec.timestamp,
rec.model_id,
rec.engine,
rec.agent,
rec.prompt_tokens,
rec.prompt_tokens_evaluated,
rec.completion_tokens,
rec.total_tokens,
rec.latency_seconds,
rec.ttft,
rec.cost_usd,
rec.energy_joules,
rec.power_watts,
rec.gpu_utilization_pct,
rec.gpu_memory_used_gb,
rec.gpu_temperature_c,
rec.throughput_tok_per_sec,
rec.prefill_latency_seconds,
rec.decode_latency_seconds,
rec.energy_method,
rec.energy_vendor,
rec.batch_id,
1 if rec.is_warmup else 0,
rec.cpu_energy_joules,
rec.gpu_energy_joules,
rec.dram_energy_joules,
rec.tokens_per_joule,
rec.energy_per_output_token_joules,
rec.throughput_per_watt,
rec.prefill_energy_joules,
rec.decode_energy_joules,
rec.mean_itl_ms,
rec.median_itl_ms,
rec.p90_itl_ms,
rec.p95_itl_ms,
rec.p99_itl_ms,
rec.std_itl_ms,
1 if rec.is_streaming else 0,
rec.token_counting_version,
rec.mining_session_id,
json.dumps(rec.metadata),
),
)
self._conn.commit()
self._telemetry_batch.append(row)
self._maybe_flush_unlocked()
def record_mining_stats(self, stats: Any) -> None:
"""Persist one mining stats snapshot.
@@ -226,43 +288,70 @@ class TelemetryStore:
``stats`` is duck-typed to keep telemetry usable without importing the
optional mining package at module import time.
"""
row = (
time.time(),
stats.provider_id,
stats.shares_submitted,
stats.shares_accepted,
stats.blocks_found,
stats.hashrate,
stats.uptime_seconds,
stats.last_share_at,
stats.last_error,
stats.payout_target,
stats.fees_owed,
)
with self._lock:
self._conn.execute(
"""\
INSERT INTO mining_stats (
recorded_at, provider_id, shares_submitted, shares_accepted, blocks_found,
hashrate, uptime_seconds, last_share_at, last_error, payout_target, fees_owed
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
time.time(),
stats.provider_id,
stats.shares_submitted,
stats.shares_accepted,
stats.blocks_found,
stats.hashrate,
stats.uptime_seconds,
stats.last_share_at,
stats.last_error,
stats.payout_target,
stats.fees_owed,
),
)
self._conn.commit()
self._mining_batch.append(row)
self._maybe_flush_unlocked()
def flush(self) -> None:
"""Write all pending records to the database."""
with self._lock:
self._flush_unlocked()
def _flush_unlocked(self) -> None:
if self._telemetry_batch:
self._conn.executemany(_INSERT, self._telemetry_batch)
self._telemetry_batch.clear()
if self._mining_batch:
self._conn.executemany(_INSERT_MINING, self._mining_batch)
self._mining_batch.clear()
self._conn.commit()
self._last_flush_time = time.monotonic()
def _maybe_flush_unlocked(self) -> None:
"""Flush when the batch is full or has been pending too long."""
if not self._telemetry_batch and not self._mining_batch:
return
batch_full = (
len(self._telemetry_batch) >= self._batch_size
or len(self._mining_batch) >= self._batch_size
)
stale = (
self._flush_interval_seconds > 0
and time.monotonic() - self._last_flush_time >= self._flush_interval_seconds
)
if batch_full or stale:
self._flush_unlocked()
def list_recent(self, limit: int = 50) -> list[dict[str, Any]]:
"""Return recent telemetry rows as dictionaries."""
return self._select_dicts(
"SELECT * FROM telemetry ORDER BY timestamp DESC LIMIT ?",
(limit,),
)
with self._lock:
self._flush_unlocked()
return self._select_dicts_unlocked(
"SELECT * FROM telemetry ORDER BY timestamp DESC LIMIT ?",
(limit,),
)
def list_recent_mining_stats(self, limit: int = 50) -> list[dict[str, Any]]:
"""Return recent mining stats snapshots as dictionaries."""
return self._select_dicts(
"SELECT * FROM mining_stats ORDER BY recorded_at DESC LIMIT ?",
(limit,),
)
with self._lock:
self._flush_unlocked()
return self._select_dicts_unlocked(
"SELECT * FROM mining_stats ORDER BY recorded_at DESC LIMIT ?",
(limit,),
)
def subscribe_to_bus(self, bus: EventBus) -> None:
"""Subscribe to ``TELEMETRY_RECORD`` events on *bus*."""
@@ -277,15 +366,36 @@ INSERT INTO mining_stats (
logger.debug("Failed to record telemetry event: %s", exc)
def close(self) -> None:
"""Close the underlying SQLite connection."""
self._conn.close()
"""Flush pending records and close the underlying SQLite connection."""
# Set the stop event BEFORE taking the lock: a flusher iteration
# already waiting on the lock re-checks the event after acquiring it
# and exits instead of touching the closed connection.
self._stop_flusher.set()
with self._lock:
if self._closed:
return
self._flush_unlocked()
self._conn.close()
self._closed = True
if self._flusher is not None:
self._flusher.join(timeout=1.0)
self._flusher = None
# -- helpers for querying (used by tests) --------------------------------
def _fetchall(self, sql: str = "SELECT * FROM telemetry") -> list:
return self._conn.execute(sql).fetchall()
with self._lock:
self._flush_unlocked()
return self._conn.execute(sql).fetchall()
def _select_dicts(self, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
with self._lock:
self._flush_unlocked()
return self._select_dicts_unlocked(sql, params)
def _select_dicts_unlocked(
self, sql: str, params: tuple[Any, ...]
) -> list[dict[str, Any]]:
cur = self._conn.execute(sql, params)
columns = [desc[0] for desc in cur.description]
return [dict(zip(columns, row)) for row in cur.fetchall()]
+9 -3
View File
@@ -139,8 +139,8 @@ class GitStatusTool(BaseTool):
def execute(self, **params: Any) -> ToolResult:
repo_path = params.get("repo_path", ".")
_rust = get_rust_module()
try:
_rust = get_rust_module()
output = _rust.GitStatusTool().execute(repo_path)
return ToolResult(
tool_name="git_status",
@@ -148,6 +148,8 @@ class GitStatusTool(BaseTool):
success=True,
metadata={"returncode": 0},
)
except ImportError as exc:
logger.debug("Rust git_status fallback to CLI: %s", exc)
except Exception as exc:
return ToolResult(
tool_name="git_status",
@@ -155,6 +157,8 @@ class GitStatusTool(BaseTool):
success=False,
)
return _run_git(["git", "status", "--porcelain"], cwd=repo_path)
# ---------------------------------------------------------------------------
# GitDiffTool
@@ -208,9 +212,9 @@ class GitDiffTool(BaseTool):
staged = params.get("staged", False)
file_path = params.get("path")
_rust = get_rust_module()
if not staged and not file_path:
try:
_rust = get_rust_module()
output = _rust.GitDiffTool().execute(repo_path)
return ToolResult(
tool_name="git_diff",
@@ -218,6 +222,8 @@ class GitDiffTool(BaseTool):
success=True,
metadata={"returncode": 0},
)
except ImportError as exc:
logger.debug("Rust git_diff fallback to CLI: %s", exc)
except Exception as exc:
return ToolResult(
tool_name="git_diff",
@@ -371,8 +377,8 @@ class GitLogTool(BaseTool):
count = params.get("count", 10)
oneline = params.get("oneline", True)
_rust = get_rust_module()
try:
_rust = get_rust_module()
output = _rust.GitLogTool().execute(repo_path, count)
return ToolResult(
tool_name="git_log",
+78
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -79,3 +80,80 @@ class TestDaemonCommands:
result = CliRunner().invoke(cli, ["start"])
assert result.exit_code != 0
assert "already running" in result.output
class TestDaemonDetachment:
"""The spawned server must outlive the console that started it.
``start_new_session`` is POSIX-only CPython's Windows ``_execute_child``
names the parameter ``unused_start_new_session``. Relying on it there leaves
the server sharing its parent's console, so closing that console (or logging
off) delivers CTRL_CLOSE_EVENT and kills the daemon.
"""
@staticmethod
def _spawn_kwargs(platform: str) -> dict:
"""Return the kwargs ``start`` passes to Popen when spawning the server.
``load_config`` is stubbed because it shells out for GPU detection
patching Popen wholesale would otherwise break config loading before
the spawn is reached.
"""
with (
patch("openjarvis.cli.daemon_cmd._read_pid", return_value=None),
patch("openjarvis.cli.daemon_cmd._write_pid"),
patch("openjarvis.cli.daemon_cmd.load_config"),
patch("openjarvis.cli.daemon_cmd.sys.platform", platform),
patch("openjarvis.cli.daemon_cmd.subprocess.Popen") as popen,
patch("builtins.open", MagicMock()),
):
popen.return_value = MagicMock(pid=4321)
result = CliRunner().invoke(cli, ["start"])
assert result.exit_code == 0, result.output
spawns = [
c for c in popen.call_args_list if c.args and "serve" in c.args[0]
]
assert spawns, f"start did not spawn the server: {popen.call_args_list}"
return spawns[-1].kwargs
def test_windows_spawn_is_detached_from_the_console(self) -> None:
# These constants are only exported by ``subprocess`` on Windows.
# Supply their documented values so the simulated Windows branch is
# still exercised by the POSIX test job.
detached_process = getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
create_new_process_group = getattr(
subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200
)
with (
patch.object(
subprocess,
"DETACHED_PROCESS",
detached_process,
create=True,
),
patch.object(
subprocess,
"CREATE_NEW_PROCESS_GROUP",
create_new_process_group,
create=True,
),
):
kwargs = self._spawn_kwargs("win32")
flags = kwargs.get("creationflags", 0)
assert flags & detached_process, (
"server must be spawned with DETACHED_PROCESS on Windows, otherwise "
"closing the launching console kills it"
)
assert flags & create_new_process_group, (
"server must be in its own process group so Ctrl-C in the parent "
"console does not propagate to it"
)
assert not kwargs.get("start_new_session"), (
"start_new_session is ignored on Windows; it must not be relied on"
)
def test_posix_spawn_still_uses_start_new_session(self) -> None:
kwargs = self._spawn_kwargs("linux")
assert kwargs.get("start_new_session") is True
assert "creationflags" not in kwargs or kwargs["creationflags"] == 0
+3
View File
@@ -159,6 +159,8 @@ def test_serve_does_not_call_systembuilder_build(tmp_path, monkeypatch):
)
)
set_system_spy = MagicMock()
inject_spy = MagicMock()
monkeypatch.setattr(serve_mod, "inject_credentials", inject_spy)
result = _run_serve(
tmp_path,
@@ -169,6 +171,7 @@ def test_serve_does_not_call_systembuilder_build(tmp_path, monkeypatch):
assert result.exit_code == 0, result.output
build_spy.assert_not_called()
inject_spy.assert_called_once_with()
def test_executor_receives_required_system_attrs(tmp_path, monkeypatch):
+13
View File
@@ -30,6 +30,19 @@ from openjarvis.core.registry import (
)
@pytest.fixture(autouse=True)
def _no_update_check(monkeypatch: pytest.MonkeyPatch) -> None:
"""Never let the CLI's PyPI update-check nag run during tests.
``check_for_updates`` writes its banner to stderr, which ``CliRunner``
merges into ``result.output`` polluting JSON/CSV output of any test
that invokes a CLI command. It already self-disables when ``CI`` is
set, but that only helps in CI; locally (e.g. a dev with a stale
version-check cache and network access) it fires for real.
"""
monkeypatch.setenv("OPENJARVIS_NO_UPDATE_CHECK", "1")
@pytest.fixture(autouse=True)
def _clean_registries() -> None:
"""Ensure each test starts with empty registries and a fresh event bus."""
+25
View File
@@ -5,7 +5,9 @@ import os
import pytest
from openjarvis.core.credentials import (
delete_credential,
get_credential_status,
inject_credentials,
load_credentials,
save_credential,
)
@@ -54,3 +56,26 @@ def test_file_permissions(cred_path):
save_credential("web_search", "TAVILY_API_KEY", "tvly-x", path=cred_path)
mode = oct(cred_path.stat().st_mode & 0o777)
assert mode == "0o600"
def test_inject_credentials_restores_saved_value(cred_path, monkeypatch):
save_credential("web_search", "TAVILY_API_KEY", "tvly-persisted", path=cred_path)
monkeypatch.delenv("TAVILY_API_KEY")
inject_credentials(path=cred_path)
assert os.environ["TAVILY_API_KEY"] == "tvly-persisted"
def test_delete_credential_removes_file_value_and_env(cred_path, monkeypatch):
save_credential("web_search", "TAVILY_API_KEY", "tvly-delete", path=cred_path)
delete_credential("web_search", "TAVILY_API_KEY", path=cred_path)
assert load_credentials(path=cred_path) == {}
assert "TAVILY_API_KEY" not in os.environ
def test_delete_rejects_unknown_key(cred_path):
with pytest.raises(ValueError, match="Unknown credential key"):
delete_credential("web_search", "BOGUS_KEY", path=cred_path)
+7
View File
@@ -18,6 +18,7 @@ ROOT = Path(__file__).resolve().parent.parent.parent
PYPROJECT = ROOT / "pyproject.toml"
DESKTOP_LIB_RS = ROOT / "frontend" / "src-tauri" / "src" / "lib.rs"
WINDOWS_INSTALL_PS1 = ROOT / "deploy" / "windows" / "install.ps1"
QUICKSTART_SH = ROOT / "scripts" / "quickstart.sh"
def _pyproject() -> dict:
@@ -59,3 +60,9 @@ def test_windows_installer_syncs_the_native_group() -> None:
"the Windows installer must include `--group desktop-native` so "
"openjarvis_rust is built during source install."
)
def test_quickstart_installs_web_search_dependencies() -> None:
quickstart = QUICKSTART_SH.read_text()
assert "--extra tools-search" in quickstart
assert "already running on port 8000" in quickstart
+8 -2
View File
@@ -37,16 +37,22 @@ class TestAgentRoutes:
class TestMemoryRoutes:
# 503 is the documented response when the native ``openjarvis_rust``
# extension is absent from the venv (see TestMemoryRustMissing below).
# These tests are only asserting "the route is wired up", so a backend
# that cannot be built is tolerated the same way a 500 is.
_BACKEND_OPTIONAL = (200, 500, 503)
def test_search(self):
client = TestClient(_make_app())
resp = client.post("/v1/memory/search", json={"query": "test"})
# May fail if SQLite not set up, that's ok
assert resp.status_code in (200, 500)
assert resp.status_code in self._BACKEND_OPTIONAL
def test_stats(self):
client = TestClient(_make_app())
resp = client.get("/v1/memory/stats")
assert resp.status_code in (200, 500)
assert resp.status_code in self._BACKEND_OPTIONAL
class TestMemoryRustMissing:
+26
View File
@@ -115,3 +115,29 @@ class TestLastActiveChannel:
def test_returns_none_for_unknown_user(self, store):
assert store.get_last_active_channel("nobody") is None
class TestInMemoryDatabase:
"""``:memory:`` is a SQLite sentinel, not a path — it must not be created.
``secure_create`` treats it as a filename, which fails outright on Windows
(``:`` is illegal there) and litters the working directory elsewhere.
"""
def test_in_memory_store_is_usable(self):
s = SessionStore(db_path=":memory:")
try:
session = s.get_or_create("user1", "twilio")
assert session["sender_id"] == "user1"
finally:
s.close()
def test_in_memory_store_creates_no_file(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
before = set(tmp_path.iterdir())
s = SessionStore(db_path=":memory:")
try:
assert set(tmp_path.iterdir()) == before
assert not (tmp_path / ":memory:").exists()
finally:
s.close()
+48
View File
@@ -1,5 +1,7 @@
"""Tests for GET /v1/tools endpoint."""
from unittest.mock import MagicMock
import pytest
try:
@@ -45,3 +47,49 @@ def test_browser_meta_group():
names = {t["name"] for t in tools}
assert "browser" in names
assert "browser_navigate" not in names
def test_web_search_available_without_tavily_key(monkeypatch):
"""DuckDuckGo fallback keeps web search usable without Tavily."""
from openjarvis.server.agent_manager_routes import build_tools_list
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
tools = build_tools_list()
web_search = next(t for t in tools if t["name"] == "web_search")
assert web_search["configured"] is True
assert web_search["requires_credentials"] is False
def test_tool_credentials_browser_lifecycle(tmp_path, monkeypatch):
"""The browser API can save, report, and remove a Tavily key."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from openjarvis.server.agent_manager_routes import create_agent_manager_router
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "openjarvis-home"))
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
app = FastAPI()
tools_router = create_agent_manager_router(MagicMock())[3]
app.include_router(tools_router)
client = TestClient(app)
saved = client.post(
"/v1/tools/web_search/credentials",
json={"TAVILY_API_KEY": "tvly-browser-test"},
)
assert saved.status_code == 200
assert saved.json() == {"saved": ["TAVILY_API_KEY"]}
assert client.get("/v1/tools/web_search/credentials/status").json() == {
"TAVILY_API_KEY": True
}
deleted = client.delete(
"/v1/tools/web_search/credentials/TAVILY_API_KEY",
)
assert deleted.status_code == 200
assert deleted.json() == {"deleted": "TAVILY_API_KEY"}
assert client.get("/v1/tools/web_search/credentials/status").json() == {
"TAVILY_API_KEY": False
}
+68
View File
@@ -202,3 +202,71 @@ class TestImportSkill:
installed = target_root / "hermes" / "my-skill" / "SKILL.md"
assert "Original" in installed.read_text()
class TestDangerousCapabilityGate:
def _make_importer(self, tmp_path: Path) -> SkillImporter:
return SkillImporter(
parser=SkillParser(),
tool_translator=ToolTranslator(),
target_root=tmp_path / "skills",
)
def _make_resolved_with_caps(
self, tmp_path: Path, caps: list[str]
) -> ResolvedSkill:
src_dir = tmp_path / "source" / "my-skill"
src_dir.mkdir(parents=True)
caps_yaml = "".join(f" - {c}\n" for c in caps)
(src_dir / "SKILL.md").write_text(
"---\n"
"name: my-skill\n"
"description: A test skill\n"
f"required_capabilities:\n{caps_yaml}"
"---\n"
"Body"
)
return ResolvedSkill(
name="my-skill",
source="hermes",
path=src_dir,
category="testing",
description="A test skill",
commit="abc123",
)
def test_refuses_unreviewed_dangerous_skill(self, tmp_path: Path):
importer = self._make_importer(tmp_path)
resolved = self._make_resolved_with_caps(tmp_path, ["shell:execute"])
result = importer.import_skill(resolved)
assert not result.success
assert result.requires_confirmation
assert result.dangerous_capabilities == ["shell:execute"]
assert any("dangerous" in w.lower() for w in result.warnings)
# Nothing may be written to disk on refusal
assert not (tmp_path / "skills" / "hermes" / "my-skill").exists()
def test_confirm_dangerous_installs_and_records_tier(self, tmp_path: Path):
importer = self._make_importer(tmp_path)
resolved = self._make_resolved_with_caps(tmp_path, ["shell:execute"])
result = importer.import_skill(resolved, confirm_dangerous=True)
assert result.success
assert result.requires_confirmation
assert any("confirmed by caller" in w for w in result.warnings)
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
assert 'trust_tier = "unreviewed"' in content
assert 'dangerous_capabilities = ["shell:execute"]' in content
def test_benign_capabilities_need_no_confirmation(self, tmp_path: Path):
importer = self._make_importer(tmp_path)
resolved = self._make_resolved_with_caps(tmp_path, ["network:fetch"])
result = importer.import_skill(resolved)
assert result.success
assert not result.requires_confirmation
assert result.dangerous_capabilities == []
content = (tmp_path / "skills" / "hermes" / "my-skill" / ".source").read_text()
assert 'trust_tier = "unreviewed"' in content
assert "dangerous_capabilities = []" in content
+50
View File
@@ -148,6 +148,56 @@ class TestSkillExecutor:
assert EventType.SKILL_EXECUTE_END in event_types
class TestSkillExecutorCapabilities:
def _manifest(self):
return SkillManifest(
name="capskill",
required_capabilities=["network:fetch"],
steps=[
SkillStep(
tool_name="echo",
arguments_template='{"text": "hello"}',
output_key="result",
)
],
)
def test_no_policy_runs_capability_skills(self):
"""Default construction (no allowed_capabilities) must not enforce —
this is the pre-enforcement behavior every manager.py call site relies on."""
executor = SkillExecutor(ToolExecutor([EchoTool()]))
result = executor.run(self._manifest())
assert result.success
assert result.context.get("result") == "hello"
def test_policy_blocks_missing_capability(self):
executor = SkillExecutor(ToolExecutor([EchoTool()]), allowed_capabilities=set())
result = executor.run(self._manifest())
assert not result.success
assert len(result.step_results) == 1
assert "Blocked" in result.step_results[0].content
assert "network:fetch" in result.step_results[0].content
def test_policy_allows_granted_capability(self):
executor = SkillExecutor(
ToolExecutor([EchoTool()]),
allowed_capabilities={"network:fetch"},
)
result = executor.run(self._manifest())
assert result.success
assert result.context.get("result") == "hello"
def test_blocked_run_publishes_events(self):
bus = EventBus(record_history=True)
executor = SkillExecutor(
ToolExecutor([EchoTool()]), bus=bus, allowed_capabilities=set()
)
executor.run(self._manifest())
event_types = {e.event_type for e in bus.history}
assert EventType.SKILL_EXECUTE_START in event_types
assert EventType.SKILL_EXECUTE_END in event_types
class TestSkillStepExtended:
def test_step_with_skill_name(self):
step = SkillStep(skill_name="summarize", output_key="result")
+3
View File
@@ -164,6 +164,7 @@ class TestDerivedMetricsInStore:
throughput_per_watt=0.5,
)
store.record(rec)
store.flush()
agg = TelemetryAggregator(tmp_path / "test.db")
stats = agg.per_model_stats()
@@ -185,6 +186,8 @@ class TestDerivedMetricsInStore:
throughput_per_watt=1.0 * (i + 1),
)
)
store.flush()
agg = TelemetryAggregator(tmp_path / "test.db")
summary = agg.summary()
assert summary.avg_energy_per_output_token_joules > 0
+1
View File
@@ -345,6 +345,7 @@ class TestItlStorage:
)
)
store.flush()
agg = TelemetryAggregator(tmp_path / "test.db")
stats = agg.per_model_stats()
assert len(stats) == 1
+1
View File
@@ -222,6 +222,7 @@ class TestPhaseEnergyStorage:
)
)
store.flush()
agg = TelemetryAggregator(tmp_path / "test.db")
stats = agg.per_model_stats()
assert len(stats) == 1
+57
View File
@@ -178,3 +178,60 @@ class TestTelemetryRecordFields:
def test_tokens_per_joule_set(self):
rec = TelemetryRecord(timestamp=1.0, model_id="test", tokens_per_joule=80.0)
assert rec.tokens_per_joule == 80.0
def test_batching_delays_commit(self, tmp_path: Path) -> None:
db_path = tmp_path / "test.db"
# flush_interval_seconds=0 disables the background flusher so the
# buffered/flushed states below are deterministic, not a race.
store = TelemetryStore(db_path, batch_size=2, flush_interval_seconds=0)
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
store.record(rec)
# Should not be in DB yet because batch_size is 2 and we haven't flushed
# Need a separate connection to check because store._fetchall() calls flush()!
assert _count_rows_via_own_connection(db_path) == 0
# Hit batch size
store.record(rec)
assert _count_rows_via_own_connection(db_path) == 2
store.close()
def test_background_flush_makes_records_visible(self, tmp_path: Path) -> None:
# A partial batch must become visible to OTHER connections within the
# flush interval even if no further write ever arrives — the background
# flusher covers the "traffic stopped mid-batch" case that per-record
# stale checks cannot.
db_path = tmp_path / "test.db"
store = TelemetryStore(db_path, batch_size=50, flush_interval_seconds=0.05)
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
store.record(rec)
deadline = time.time() + 5.0
rows = 0
while time.time() < deadline:
rows = _count_rows_via_own_connection(db_path)
if rows:
break
time.sleep(0.02)
assert rows == 1
store.close()
def test_close_flushes_and_is_idempotent(self, tmp_path: Path) -> None:
db_path = tmp_path / "test.db"
store = TelemetryStore(db_path, batch_size=50, flush_interval_seconds=0)
rec = TelemetryRecord(timestamp=time.time(), model_id="m1", engine="e1")
store.record(rec)
store.close()
assert _count_rows_via_own_connection(db_path) == 1
store.close() # second close must be a no-op, not a ProgrammingError
def _count_rows_via_own_connection(db_path: Path) -> int:
"""Count telemetry rows through a separate connection (like the aggregator)."""
import contextlib
import sqlite3
# NB: sqlite3's ``with conn`` is a TRANSACTION context (it does not close);
# ``contextlib.closing`` actually closes the connection.
with contextlib.closing(sqlite3.connect(db_path)) as conn:
return len(conn.execute("SELECT * FROM telemetry").fetchall())
+107 -10
View File
@@ -14,9 +14,9 @@ skipped if the server is unreachable.
from __future__ import annotations
import os
import socket
from pathlib import Path
import httpx
import pytest
from openjarvis.tools.storage.dense import (
@@ -25,20 +25,58 @@ from openjarvis.tools.storage.dense import (
chunk_markdown,
dedupe_chunks,
)
from openjarvis.tools.storage.embeddings import OllamaEmbedder
_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "fixtures" / "docs"
_OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "localhost")
_OLLAMA_PORT = int(os.environ.get("OLLAMA_PORT", "11434"))
_EMBED_MODEL = "nomic-embed-text"
def _ollama_base_url() -> str:
"""Resolve the Ollama base URL from ``OLLAMA_HOST``.
The project documents ``OLLAMA_HOST`` as a full URL
(``http://<remote-ip>:11434``), but Ollama's own convention also
allows bare ``host`` / ``host:port`` forms accept all three so
the probe and the embedder under test agree on one endpoint.
"""
host = os.environ.get("OLLAMA_HOST", "")
if not host:
return "http://localhost:11434"
if host.startswith(("http://", "https://")):
return host.rstrip("/")
if ":" in host:
return f"http://{host}"
return f"http://{host}:11434"
def _ollama_up() -> bool:
"""True only if Ollama is reachable *and* the embed model is pulled.
A bare TCP connect isn't enough — a machine can run Ollama for chat
models without ever having pulled ``nomic-embed-text``, which makes
``/api/embed`` 404 instead of the tests skipping as intended.
"""
try:
with socket.create_connection((_OLLAMA_HOST, _OLLAMA_PORT), timeout=1.0):
return True
except OSError:
resp = httpx.get(f"{_ollama_base_url()}/api/tags", timeout=1.0)
resp.raise_for_status()
models = {m.get("model", "") for m in resp.json().get("models", [])}
return any(m.startswith(_EMBED_MODEL) for m in models)
except (httpx.HTTPError, ValueError):
return False
def _make_backend() -> DenseMemory:
"""DenseMemory wired to the same Ollama endpoint the probe checked.
``DenseMemory()`` alone would build an ``OllamaEmbedder`` with its
hard-coded localhost default, so a remote ``OLLAMA_HOST`` could pass
the probe and then have every test call the wrong server.
"""
return DenseMemory(
embedder=OllamaEmbedder(model=_EMBED_MODEL, base_url=_ollama_base_url())
)
ollama_required = pytest.mark.skipif(
not _ollama_up(),
reason=(
@@ -48,6 +86,65 @@ ollama_required = pytest.mark.skipif(
)
# ---------------------------------------------------------------------------
# Skip-guard unit tests (no Ollama required)
# ---------------------------------------------------------------------------
class _FakeTagsResponse:
def __init__(self, payload: dict) -> None:
self._payload = payload
def raise_for_status(self) -> None:
pass
def json(self) -> dict:
return self._payload
class TestOllamaProbe:
def test_base_url_accepts_all_documented_forms(self, monkeypatch):
cases = {
"http://remote:11434": "http://remote:11434",
"http://remote:11434/": "http://remote:11434",
"https://ollama.internal": "https://ollama.internal",
"remote:8080": "http://remote:8080",
"remote": "http://remote:11434",
}
for raw, expected in cases.items():
monkeypatch.setenv("OLLAMA_HOST", raw)
assert _ollama_base_url() == expected, raw
monkeypatch.delenv("OLLAMA_HOST", raising=False)
assert _ollama_base_url() == "http://localhost:11434"
def test_probe_false_when_server_down(self, monkeypatch):
def _refuse(url, timeout):
raise httpx.ConnectError("connection refused")
monkeypatch.setattr(httpx, "get", _refuse)
assert _ollama_up() is False
def test_probe_false_when_model_missing(self, monkeypatch):
monkeypatch.setattr(
httpx,
"get",
lambda url, timeout: _FakeTagsResponse({"models": [{"model": "llama3"}]}),
)
assert _ollama_up() is False
def test_probe_true_with_tagged_model_on_configured_url(self, monkeypatch):
monkeypatch.setenv("OLLAMA_HOST", "http://remote:9999")
seen = {}
def _get(url, timeout):
seen["url"] = url
return _FakeTagsResponse({"models": [{"model": "nomic-embed-text:latest"}]})
monkeypatch.setattr(httpx, "get", _get)
assert _ollama_up() is True
assert seen["url"] == "http://remote:9999/api/tags"
# ---------------------------------------------------------------------------
# Chunking unit tests (no Ollama required)
# ---------------------------------------------------------------------------
@@ -283,7 +380,7 @@ def indexed_backend():
if not _ollama_up():
pytest.skip("Ollama not reachable")
backend = DenseMemory()
backend = _make_backend()
md_files = sorted(_FIXTURE_DIR.glob("*.md"))
assert md_files, f"no fixtures at {_FIXTURE_DIR}"
@@ -452,7 +549,7 @@ def test_score_distribution_vs_threshold(indexed_backend, capsys):
class TestDenseMemoryAPI:
@ollama_required
def test_store_and_delete(self):
backend = DenseMemory()
backend = _make_backend()
doc_id = backend.store("the cat sat on the mat", source="a.txt")
assert backend.count() == 1
hits = backend.retrieve("where is the cat", top_k=1)
@@ -463,12 +560,12 @@ class TestDenseMemoryAPI:
@ollama_required
def test_empty_retrieve(self):
backend = DenseMemory()
backend = _make_backend()
assert backend.retrieve("anything", top_k=3) == []
@ollama_required
def test_clear(self):
backend = DenseMemory()
backend = _make_backend()
backend.store("foo")
backend.store("bar")
backend.clear()
+48
View File
@@ -537,3 +537,51 @@ class TestGitLogTool:
fn = tool.to_openai_function()
assert fn["type"] == "function"
assert fn["function"]["name"] == "git_log"
# ---------------------------------------------------------------------------
# CLI fallback when the Rust extension is missing
# ---------------------------------------------------------------------------
class TestCliFallbackWhenRustMissing:
"""When ``get_rust_module`` raises ImportError (extension not built,
e.g. a plain pip install), the read-only git tools must fall back to
the git CLI instead of letting the ImportError escape ``execute()``."""
def _patch_no_rust(self):
return patch(
"openjarvis.tools.git_tool.get_rust_module",
side_effect=ImportError("No module named 'openjarvis_rust'"),
)
def test_git_status_falls_back_to_cli(self, tmp_path):
_init_repo(tmp_path)
(tmp_path / "new_file.txt").write_text("hello")
with self._patch_no_rust():
result = GitStatusTool().execute(repo_path=str(tmp_path))
assert result.success is True
assert "new_file.txt" in result.content
def test_git_diff_falls_back_to_cli(self, tmp_path):
_init_repo(tmp_path)
(tmp_path / "README.md").write_text("# Modified\n")
with self._patch_no_rust():
result = GitDiffTool().execute(repo_path=str(tmp_path))
assert result.success is True
assert "README.md" in result.content
def test_git_log_falls_back_to_cli(self, tmp_path):
_init_repo(tmp_path)
with self._patch_no_rust():
result = GitLogTool().execute(repo_path=str(tmp_path))
assert result.success is True
assert "Initial commit" in result.content
def test_fallback_failure_is_a_tool_result_not_an_exception(self, tmp_path):
# Even when the fallback itself fails (not a git repo), the tool
# must return a failed ToolResult rather than raising.
with self._patch_no_rust():
result = GitStatusTool().execute(repo_path=str(tmp_path))
assert result.success is False
assert "not a git repository" in result.content
+1 -3
View File
@@ -70,9 +70,7 @@ def test_allows_select_with_keyword_substring(store: KnowledgeStore) -> None:
from openjarvis.tools.knowledge_sql import KnowledgeSQLTool
tool = KnowledgeSQLTool(store=store)
result = tool.execute(
query="SELECT author AS created_author FROM knowledge_chunks"
)
result = tool.execute(query="SELECT author AS created_author FROM knowledge_chunks")
assert result.success, result.content
assert "Alice" in result.content