Compare commits

...
Author SHA1 Message Date
Elliot Slusky 410562409d fix: make websocket bridge race cancellation-safe 2026-08-10 11:04:12 -07:00
Ari 9498adc7c4 fix: close ws_bridge send loop on client disconnect
Previously the ws_bridge send loop blocked forever on queue.get(),
never learning the client left. When the server service stopped,
uvicorn waited for open WebSocket tasks until systemd SIGKILLed
after TimeoutStopSec=90s. Now each iteration races recv+send; a
completed receive means the client disconnected => break the loop.
2026-08-10 11:04:12 -07:00
github-actions[bot] ebf370595d chore: update clone traffic data [skip ci] 2026-08-10 07:26:44 +00:00
Elliot Slusky bcdbf13d02 test(tools): cover eager deep-research registration 2026-08-09 22:59:45 -07:00
Ari 3dc621618f fix(tools): eager-import scan_chunks and knowledge_sql at package load
Every other built-in tool is imported here specifically to fire its
@ToolRegistry.register() decorator at package-load time; these two
were missing, so their test_registered tests only passed when some
unrelated test (via agent_manager_routes.py, channels_cmd.py, or
deep_research_setup_cmd.py) happened to import the module first in
the same process. Under pytest-xdist that's worker-distribution
dependent, so adding an unrelated test file could flip either test
from pass to fail.
2026-08-09 22:59:45 -07:00
github-actions[bot] fd0b60fefc chore: update clone traffic data [skip ci] 2026-08-09 06:51:50 +00:00
github-actions[bot] 95a9857984 chore: update clone traffic data [skip ci] 2026-08-08 06:46:50 +00:00
github-actions[bot] f9c89308fc chore: update clone traffic data [skip ci] 2026-08-07 07:10:58 +00:00
Elliot Slusky 65d08e9d94 Fix proactive cron reconciliation and notifications 2026-08-06 13:40:00 -07:00
Loma 45717780fa Fix proactive agent cron duplicating on every server restart
register_cron() ran unconditionally on every 'jarvis serve' startup and
create_task() persists to scheduler.db, so each restart added another
copy of the daily proactive cron. On a real install 68 duplicates
accumulated; when due they fired back-to-back and monopolized the
single-slot local inference queue, stalling interactive chat.

register_cron() is now idempotent: an existing active task is reused and
surplus duplicates are cancelled. Also stop the notification channel
from calling connect() — a second getUpdates poll loop on the same bot
token makes Telegram return Conflict and kills the main listener.
2026-08-06 13:40:00 -07:00
goatoush 9da7c30880 Fixing text wrap for agent creator tool selector (#655) 2026-08-06 11:23:27 -07:00
github-actions[bot] 98e791f258 chore: update clone traffic data [skip ci] 2026-08-06 08:31:13 +00:00
Elliot Slusky b9e0928aef fix(telemetry): record cloud inference cost (#704) 2026-08-05 20:51:06 -07:00
Elliot Slusky 652a522e50 fix(config): deserialize skill source tables (#703) 2026-08-05 20:50:42 -07:00
github-actions[bot] ce1a9ce133 chore: update clone traffic data [skip ci] 2026-08-05 08:31:05 +00:00
github-actions[bot] ae45a4f67c chore: update clone traffic data [skip ci] 2026-08-04 08:31:51 +00:00
github-actions[bot] 697eed23d4 chore: update clone traffic data [skip ci] 2026-08-03 09:38:25 +00:00
github-actions[bot] 100595f8aa chore: update clone traffic data [skip ci] 2026-08-02 08:20:07 +00:00
github-actions[bot] dd03a55028 chore: update clone traffic data [skip ci] 2026-08-01 08:15:52 +00:00
github-actions[bot] a72218f99f chore: update clone traffic data [skip ci] 2026-07-31 08:45:27 +00:00
github-actions[bot] eaa76032d5 chore: update clone traffic data [skip ci] 2026-07-30 08:21:39 +00:00
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
28 changed files with 818 additions and 62 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "175,911",
"message": "185,599",
"color": "green",
"namedLogo": "git"
}
+16 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 175911,
"last_updated": "2026-07-28T08:29:18Z",
"total_clones": 185599,
"last_updated": "2026-08-10T07:26:44Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -124,6 +124,19 @@
"2026-07-24": 1118,
"2026-07-25": 928,
"2026-07-26": 740,
"2026-07-27": 799
"2026-07-27": 799,
"2026-07-28": 665,
"2026-07-29": 745,
"2026-07-30": 591,
"2026-07-31": 783,
"2026-08-01": 567,
"2026-08-02": 1248,
"2026-08-03": 724,
"2026-08-04": 708,
"2026-08-05": 647,
"2026-08-06": 604,
"2026-08-07": 624,
"2026-08-08": 706,
"2026-08-09": 1076
}
}
+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"
+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;
+3 -3
View File
@@ -23,7 +23,6 @@ import {
fetchAgentTrace,
fetchManagedAgent,
fetchAvailableTools,
saveToolCredentials,
fetchModels,
updateManagedAgent,
fetchRecommendedModel,
@@ -575,7 +574,7 @@ function ToolsPicker({
</div>
{/* Live description strip */}
<div
className="flex items-center gap-2 px-2.5 py-1.5"
className="flex items-start gap-2 px-2.5 py-1.5"
style={{
borderTop: '1px solid var(--color-border)',
background: 'var(--color-bg)',
@@ -609,10 +608,11 @@ function ToolsPicker({
</span>
)}
<span
className="truncate"
className="min-w-0 whitespace-normal break-words"
style={{
flex: 1,
color: 'var(--color-text-tertiary)',
lineHeight: 1.4,
}}
>
{hovered ? `${hint}` : hint}
+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',
},
+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..."
+99 -12
View File
@@ -37,6 +37,7 @@ called from your app startup:
from __future__ import annotations
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
@@ -56,6 +57,15 @@ from openjarvis.tools.approval_store import (
)
from openjarvis.tools.proactive_tools import get_store
logger = logging.getLogger(__name__)
_PROACTIVE_CRON_PROMPT = (
"Run the proactive agent: collect overnight data, execute approved actions, "
"notify pending approvals."
)
_PROACTIVE_TASK_KEY = "proactive-daily"
_PROACTIVE_TASK_KEY_FIELD = "openjarvis_task_key"
_SYSTEM_PROMPT = """You are a proactive personal assistant agent. You have already collected
data from the user's connected sources (email, messages, calendar). Your job is to:
@@ -252,14 +262,31 @@ def _build_notification_channel(channel_spec: str) -> Optional[Any]:
if ChannelRegistry.contains(channel_type):
channel_cls = ChannelRegistry.get(channel_type)
instance = channel_cls()
# Load credentials from config so the channel uses bot_token from
# config.toml rather than falling back to a bare env var.
try:
instance.connect()
from openjarvis.core.config import load_config
from openjarvis.system._channel_kwargs import build_channel_kwargs
_cfg = load_config()
_kwargs = build_channel_kwargs(_cfg.channel, channel_type)
except Exception:
pass
_kwargs = {}
instance = channel_cls(**_kwargs)
# Telegram.send() is self-contained, while connect() starts a
# getUpdates loop. A second loop for the same bot token conflicts
# with the server's main listener. Other channel implementations
# may initialize resources required by send() in connect(), so keep
# their established lifecycle intact.
if channel_type != "telegram":
instance.connect()
return instance
except Exception:
pass
logger.warning(
"Failed to build proactive notification channel %s",
channel_type,
exc_info=True,
)
return None
@@ -299,6 +326,7 @@ class ProactiveAgent(ToolUsingAgent):
self._notification_channel_id
)
self._notification_channel = notification_channel
self._notification_destination = self._notification_channel_id.partition(":")[2]
from openjarvis.tools.channel_tools import ChannelSendTool
from openjarvis.tools.digest_collect import DigestCollectTool
@@ -484,13 +512,13 @@ class ProactiveAgent(ToolUsingAgent):
# --- Step 5: Build and send notification ---
notification = self._build_notification(executed_results, pending_actions)
if notification and self._notification_channel_id:
if notification and self._notification_destination:
send_call = ToolCall(
id="proactive-notify-1",
name="channel_send",
arguments=json.dumps(
{
"channel": self._notification_channel_id,
"channel": self._notification_destination,
"content": notification,
}
),
@@ -592,15 +620,74 @@ def register_cron(
hours_back = hours_back or 24
timezone = timezone or "America/Los_Angeles"
metadata = {
"notification_channel_id": notification_channel_id,
"hours_back": hours_back,
"timezone": timezone,
_PROACTIVE_TASK_KEY_FIELD: _PROACTIVE_TASK_KEY,
}
# Match the stable key for tasks created by this version and the historical
# agent+prompt signature so existing installations are migrated on startup.
existing = [
task
for task in scheduler.list_tasks()
if task.status in {"active", "paused"}
and task.agent == "proactive"
and (
task.metadata.get(_PROACTIVE_TASK_KEY_FIELD) == _PROACTIVE_TASK_KEY
or (task.prompt == _PROACTIVE_CRON_PROMPT and task.schedule_type == "cron")
)
]
# A scheduler pause is an explicit user choice and must survive restart.
# Keep one deterministically and remove any active or paused duplicates.
paused = [task for task in existing if task.status == "paused"]
if paused:
keep = min(paused, key=lambda task: task.id)
_cancel_proactive_duplicates(scheduler, existing, keep=keep)
return keep
matching = [
task
for task in existing
if task.prompt == _PROACTIVE_CRON_PROMPT
and task.schedule_type == "cron"
and task.schedule_value == cron_expr
and task.context_mode == "isolated"
and task.metadata == metadata
]
if matching:
keep = min(matching, key=lambda task: task.id)
_cancel_proactive_duplicates(scheduler, existing, keep=keep)
return keep
# Configuration changed. Replace stale active tasks so the schedule and
# notification settings from config.toml take effect on this startup.
_cancel_proactive_duplicates(scheduler, existing)
return scheduler.create_task(
prompt="Run the proactive agent: collect overnight data, execute approved actions, notify pending approvals.",
prompt=_PROACTIVE_CRON_PROMPT,
schedule_type="cron",
schedule_value=cron_expr,
agent="proactive",
context_mode="isolated",
metadata={
"notification_channel_id": notification_channel_id,
"hours_back": hours_back,
"timezone": timezone,
},
metadata=metadata,
)
def _cancel_proactive_duplicates(
scheduler: Any, tasks: List[Any], *, keep: Optional[Any] = None
) -> None:
"""Cancel managed proactive tasks other than *keep*."""
for task in tasks:
if keep is not None and task.id == keep.id:
continue
try:
scheduler.cancel_task(task.id)
except Exception:
logger.warning(
"Failed to cancel duplicate proactive task %s",
task.id,
exc_info=True,
)
+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
+45 -9
View File
@@ -12,9 +12,18 @@ import os
import platform
import shutil
import subprocess
from dataclasses import dataclass, field
from dataclasses import dataclass, field, is_dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
Optional,
get_args,
get_origin,
get_type_hints,
)
from openjarvis.core.paths import (
ConfigurationError,
@@ -1710,10 +1719,16 @@ def _apply_toml_section(target: Any, section: Dict[str, Any]) -> None:
"""Overlay TOML key/value pairs onto a dataclass instance.
Recursively handles nested dicts when the target attribute is itself
a dataclass. Normalises TOML arrays to comma-separated strings — both
for dataclass fields annotated as ``str`` and for backward-compat
property setters that expect string input.
a dataclass, including dict entries in lists of dataclasses. Normalises
TOML arrays to comma-separated strings — both for dataclass fields annotated
as ``str`` and for backward-compat property setters that expect string input.
"""
try:
type_hints = get_type_hints(type(target))
except (NameError, TypeError):
# Some config types contain optional runtime-only forward references.
type_hints = {}
for key, value in section.items():
if hasattr(target, key):
if isinstance(value, dict):
@@ -1728,14 +1743,35 @@ def _apply_toml_section(target: Any, section: Dict[str, Any]) -> None:
# property setters (e.g. reward_weights, default_tools).
if isinstance(value, list):
is_str_field = False
item_dataclass = None
if hasattr(target, "__dataclass_fields__"):
field_obj = target.__dataclass_fields__.get(key)
if field_obj is not None and field_obj.type in ("str", str):
is_str_field = True
elif field_obj is None:
if field_obj is not None:
field_type = type_hints.get(key, field_obj.type)
type_args = get_args(field_type)
if (
get_origin(field_type) is list
and len(type_args) == 1
and is_dataclass(type_args[0])
):
item_dataclass = type_args[0]
elif field_obj.type in ("str", str):
is_str_field = True
else:
# Property, not a real field — normalise to string
is_str_field = True
if is_str_field:
if item_dataclass is not None:
converted = []
for item in value:
if isinstance(item, dict):
nested = item_dataclass()
_apply_toml_section(nested, item)
converted.append(nested)
else:
converted.append(item)
value = converted
elif is_str_field:
value = ",".join(str(v) for v in value)
setattr(target, key, value)
+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()
+30 -3
View File
@@ -79,14 +79,41 @@ def create_ws_router(event_bus: EventBus) -> Any:
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
loop = asyncio.get_running_loop()
clients[websocket] = (queue, loop)
recv: asyncio.Task | None = None
payload: asyncio.Task | None = None
disconnected = False
try:
recv = asyncio.create_task(websocket.receive())
payload = asyncio.create_task(queue.get())
while True:
payload = await queue.get()
await websocket.send_json(payload)
done, _ = await asyncio.wait(
{recv, payload}, return_when=asyncio.FIRST_COMPLETED
)
if recv in done:
# Starlette surfaces a disconnect message only when the app
# reads from the socket. Without this receive, the handler
# can stay parked on queue.get() after the client leaves.
message = await recv
if message.get("type") == "websocket.disconnect":
disconnected = True
break
recv = asyncio.create_task(websocket.receive())
if payload in done:
await websocket.send_json(payload.result())
payload = asyncio.create_task(queue.get())
except WebSocketDisconnect:
pass
disconnected = True
finally:
clients.pop(websocket, None)
pending = [task for task in (recv, payload) if task is not None]
for task in pending:
task.cancel()
cleanup = asyncio.gather(*pending, return_exceptions=True)
try:
await asyncio.shield(cleanup)
except asyncio.CancelledError:
if not disconnected:
raise
return router
@@ -212,6 +212,7 @@ class InstrumentedEngine(InferenceEngine):
completion_tokens=completion_tokens,
total_tokens=prompt_tok + completion_tokens,
latency_seconds=latency,
cost_usd=result.get("cost_usd", 0.0),
ttft=ttft,
throughput_tok_per_sec=throughput,
energy_per_output_token_joules=energy_per_output_token,
+10
View File
@@ -142,4 +142,14 @@ try:
except ImportError:
pass
try:
import openjarvis.tools.scan_chunks # noqa: F401
except ImportError:
pass
try:
import openjarvis.tools.knowledge_sql # noqa: F401
except ImportError:
pass
__all__ = ["BaseTool", "ToolExecutor", "ToolSpec"]
+131
View File
@@ -0,0 +1,131 @@
"""Regression tests for proactive scheduling and notification setup."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from openjarvis.agents.proactive_agent import (
_PROACTIVE_CRON_PROMPT,
_build_notification_channel,
register_cron,
)
from openjarvis.core.registry import ChannelRegistry
from openjarvis.scheduler.scheduler import TaskScheduler
from openjarvis.scheduler.store import SchedulerStore
@pytest.fixture()
def scheduler(tmp_path):
store = SchedulerStore(tmp_path / "scheduler.db")
scheduler = TaskScheduler(store)
yield scheduler
scheduler.stop()
store.close()
def _register(scheduler, *, schedule="0 5 * * *", channel="telegram:123"):
return register_cron(
scheduler,
notification_channel_id=channel,
cron_expr=schedule,
hours_back=24,
timezone="UTC",
)
class TestRegisterCron:
def test_reuses_exact_task_and_cancels_duplicates(self, scheduler):
first = _register(scheduler)
duplicate = scheduler.create_task(
_PROACTIVE_CRON_PROMPT,
"cron",
"0 5 * * *",
agent="proactive",
metadata=first.metadata,
)
returned = _register(scheduler)
assert returned.id in {first.id, duplicate.id}
assert [task.id for task in scheduler.list_tasks(status="active")] == [
returned.id
]
cancelled_id = scheduler.list_tasks(status="cancelled")[0].id
assert cancelled_id == ({first.id, duplicate.id} - {returned.id}).pop()
def test_replaces_task_when_configuration_changes(self, scheduler):
old = _register(scheduler, schedule="0 5 * * *", channel="telegram:old")
new = _register(scheduler, schedule="0 7 * * *", channel="telegram:new")
assert new.id != old.id
assert new.schedule_value == "0 7 * * *"
assert new.metadata["notification_channel_id"] == "telegram:new"
assert scheduler.list_tasks(status="cancelled")[0].id == old.id
def test_preserves_pause_across_restart(self, scheduler):
paused = _register(scheduler)
scheduler.pause_task(paused.id)
returned = _register(scheduler, schedule="0 7 * * *")
assert returned.id == paused.id
assert returned.status == "paused"
assert scheduler.list_tasks(status="active") == []
def test_migrates_legacy_tasks_without_stable_key(self, scheduler):
legacy = scheduler.create_task(
_PROACTIVE_CRON_PROMPT,
"cron",
"0 5 * * *",
agent="proactive",
metadata={
"notification_channel_id": "telegram:123",
"hours_back": 24,
"timezone": "UTC",
},
)
current = _register(scheduler)
assert current.id != legacy.id
assert current.metadata["openjarvis_task_key"] == "proactive-daily"
assert scheduler.list_tasks(status="cancelled")[0].id == legacy.id
class TestNotificationChannel:
def test_telegram_is_configured_without_starting_polling(self):
class FakeTelegram:
def __init__(self, **kwargs):
self.kwargs = kwargs
self.connect = MagicMock()
config = MagicMock()
with (
patch.object(ChannelRegistry, "contains", return_value=True),
patch.object(ChannelRegistry, "get", return_value=FakeTelegram),
patch("openjarvis.core.config.load_config", return_value=config),
patch(
"openjarvis.system._channel_kwargs.build_channel_kwargs",
return_value={"bot_token": "configured-token"},
),
):
channel = _build_notification_channel("telegram:123")
assert channel.kwargs == {"bot_token": "configured-token"}
channel.connect.assert_not_called()
def test_non_telegram_channel_keeps_connect_lifecycle(self):
class FakeChannel:
def __init__(self, **kwargs):
self.connect = MagicMock()
with (
patch.object(ChannelRegistry, "contains", return_value=True),
patch.object(ChannelRegistry, "get", return_value=FakeChannel),
):
channel = _build_notification_channel("twilio:15551234567")
channel.connect.assert_called_once_with()
+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):
+36 -1
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
from openjarvis.core.config import SkillsConfig, SkillSourceConfig
from pathlib import Path
from openjarvis.core.config import SkillsConfig, SkillSourceConfig, load_config
class TestSkillSourceConfig:
@@ -41,3 +43,36 @@ class TestSkillsConfigWithSources:
)
assert len(cfg.sources) == 2
assert cfg.sources[0].source == "hermes"
def test_loads_source_tables_as_config_objects(
self, tmp_path: Path, monkeypatch
) -> None:
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
toml_file = tmp_path / "config.toml"
toml_file.write_text(
"[[skills.sources]]\n"
'source = "hermes"\n'
'filter = { category = ["productivity"] }\n\n'
"[[skills.sources]]\n"
'source = "github"\n'
'url = "https://github.com/example/skill-library"\n'
"auto_update = true\n"
)
load_config.cache_clear()
try:
cfg = load_config(toml_file)
finally:
load_config.cache_clear()
assert cfg.skills.sources == [
SkillSourceConfig(
source="hermes",
filter={"category": ["productivity"]},
),
SkillSourceConfig(
source="github",
url="https://github.com/example/skill-library",
auto_update=True,
),
]
+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
+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
}
+101
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
import time
from types import SimpleNamespace
import pytest
@@ -60,3 +62,102 @@ class TestWSBridge:
time.sleep(0.05) # Let call_soon_threadsafe deliver to queue
data = ws.receive_json()
assert data["data"]["agent_id"] == "agent-A"
def test_client_disconnect_stops_handler(self, event_bus):
async def exercise():
from openjarvis.server.ws_bridge import create_ws_router
class FakeWebSocket:
app = SimpleNamespace(state=SimpleNamespace(api_key=""))
query_params = {}
headers = {}
async def accept(self):
pass
async def receive(self):
return {"type": "websocket.disconnect"}
endpoint = create_ws_router(event_bus).routes[0].endpoint
await asyncio.wait_for(endpoint(FakeWebSocket()), timeout=1)
asyncio.run(exercise())
def test_simultaneous_client_message_does_not_drop_event(self, event_bus):
async def exercise():
from openjarvis.server.ws_bridge import create_ws_router
class FakeWebSocket:
def __init__(self):
self.app = SimpleNamespace(state=SimpleNamespace(api_key=""))
self.query_params = {}
self.headers = {}
self.sent = []
self.receive_count = 0
self.disconnect = asyncio.Event()
async def accept(self):
pass
async def receive(self):
self.receive_count += 1
if self.receive_count == 1:
event_bus.publish(
EventType.AGENT_TICK_START, {"agent_id": "not-dropped"}
)
return {"type": "websocket.receive", "text": "client message"}
await self.disconnect.wait()
return {"type": "websocket.disconnect"}
async def send_json(self, payload):
self.sent.append(payload)
self.disconnect.set()
websocket = FakeWebSocket()
endpoint = create_ws_router(event_bus).routes[0].endpoint
await asyncio.wait_for(endpoint(websocket), timeout=1)
assert websocket.sent[0]["data"]["agent_id"] == "not-dropped"
asyncio.run(exercise())
def test_cancelling_handler_cleans_up_child_tasks(self, event_bus):
async def exercise():
from openjarvis.server.ws_bridge import create_ws_router
class FakeWebSocket:
def __init__(self):
self.app = SimpleNamespace(state=SimpleNamespace(api_key=""))
self.query_params = {}
self.headers = {}
self.receiving = asyncio.Event()
self.receive_cancelled = asyncio.Event()
async def accept(self):
pass
async def receive(self):
self.receiving.set()
try:
await asyncio.Event().wait()
finally:
self.receive_cancelled.set()
websocket = FakeWebSocket()
endpoint = create_ws_router(event_bus).routes[0].endpoint
handler = asyncio.create_task(endpoint(websocket))
await websocket.receiving.wait()
handler.cancel()
with pytest.raises(asyncio.CancelledError):
await handler
assert websocket.receive_cancelled.is_set()
assert not [
task
for task in asyncio.all_tasks()
if task is not asyncio.current_task() and not task.done()
]
asyncio.run(exercise())
@@ -71,6 +71,17 @@ class TestInstrumentedEngine:
assert record.prompt_tokens == 10
assert record.completion_tokens == 5
def test_generate_records_cost(self, mock_engine, bus):
mock_engine.generate.return_value["cost_usd"] = 0.0015
ie = InstrumentedEngine(mock_engine, bus)
messages = [Message(role=Role.USER, content="Hi")]
ie.generate(messages, model="test")
event = next(
e for e in bus.history if e.event_type == EventType.TELEMETRY_RECORD
)
assert event.data["record"].cost_usd == pytest.approx(0.0015)
def test_list_models_delegates(self, mock_engine, bus):
ie = InstrumentedEngine(mock_engine, bus)
assert ie.list_models() == ["test-model"]
+27
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import importlib
import subprocess
import sys
from openjarvis.core.registry import ToolRegistry
@@ -68,6 +69,10 @@ EXPECTED_TOOLS = {
"kg_add_relation",
"kg_query",
"kg_neighbors",
# knowledge_sql.py
"knowledge_sql",
# scan_chunks.py
"scan_chunks",
}
@@ -100,3 +105,25 @@ def test_all_builtin_tools_registered():
assert not missing, (
f"Tools not registered (missing import in __init__.py?): {sorted(missing)}"
)
def test_package_import_registers_deep_research_tools():
"""Registration must not depend on another module being imported first."""
result = subprocess.run(
[
sys.executable,
"-c",
(
"import openjarvis.tools; "
"from openjarvis.core.registry import ToolRegistry; "
"expected = {'knowledge_sql', 'scan_chunks'}; "
"missing = expected - set(ToolRegistry.keys()); "
"assert not missing, f'Missing tools: {sorted(missing)}'"
),
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr