diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 1f8c7102..1942629e 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -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" diff --git a/frontend/src/lib/api.auth.test.ts b/frontend/src/lib/api.auth.test.ts index 196724ca..c78d2a9c 100644 --- a/frontend/src/lib/api.auth.test.ts +++ b/frontend/src/lib/api.auth.test.ts @@ -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(); // 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: {} }, + ); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 588e5c2b..7f1dcc29 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -885,6 +885,25 @@ export async function saveToolCredentials( if (!res.ok) throw new Error(`Failed: ${res.status}`); } +export async function fetchToolCredentialStatus( + toolName: string, +): Promise> { + 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 { + 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; diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index d5f53110..4b4568aa 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -23,7 +23,6 @@ import { fetchAgentTrace, fetchManagedAgent, fetchAvailableTools, - saveToolCredentials, fetchModels, updateManagedAgent, fetchRecommendedModel, diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index f53029d8..fc207f7e 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -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 */}
- +
diff --git a/scripts/quickstart.sh b/scripts/quickstart.sh index d48134af..de9225a6 100755 --- a/scripts/quickstart.sh +++ b/scripts/quickstart.sh @@ -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..." diff --git a/src/openjarvis/cli/serve.py b/src/openjarvis/cli/serve.py index ed95c35b..6c9e415a 100644 --- a/src/openjarvis/cli/serve.py +++ b/src/openjarvis/cli/serve.py @@ -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 diff --git a/src/openjarvis/core/credentials.py b/src/openjarvis/core/credentials.py index 7bb99495..68a1d138 100644 --- a/src/openjarvis/core/credentials.py +++ b/src/openjarvis/core/credentials.py @@ -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, []) diff --git a/src/openjarvis/server/agent_manager_routes.py b/src/openjarvis/server/agent_manager_routes.py index 44bd97d5..5b33491c 100644 --- a/src/openjarvis/server/agent_manager_routes.py +++ b/src/openjarvis/server/agent_manager_routes.py @@ -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 diff --git a/tests/cli/test_serve_single_build.py b/tests/cli/test_serve_single_build.py index 43c3cacc..1a0fddf8 100644 --- a/tests/cli/test_serve_single_build.py +++ b/tests/cli/test_serve_single_build.py @@ -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): diff --git a/tests/core/test_credentials.py b/tests/core/test_credentials.py index 2d8993fb..f8ea8578 100644 --- a/tests/core/test_credentials.py +++ b/tests/core/test_credentials.py @@ -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) diff --git a/tests/deployment/test_packaging.py b/tests/deployment/test_packaging.py index ef79aa70..3368bf85 100644 --- a/tests/deployment/test_packaging.py +++ b/tests/deployment/test_packaging.py @@ -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 diff --git a/tests/server/test_tools_endpoint.py b/tests/server/test_tools_endpoint.py index 4f061209..b7db42e2 100644 --- a/tests/server/test_tools_endpoint.py +++ b/tests/server/test_tools_endpoint.py @@ -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 + }