mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 00:47:52 +00:00
fix web search setup in browser quickstart (#691)
This commit is contained in:
@@ -135,6 +135,19 @@ cd OpenJarvis
|
|||||||
This launches the backend API server and a React frontend at [http://localhost:5173](http://localhost:5173).
|
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.
|
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.
|
To stop all services, press ++ctrl+c++ in the terminal.
|
||||||
|
|
||||||
!!! tip "Environment variable"
|
!!! tip "Environment variable"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
// authHeaders) that source the key and build the header.
|
// authHeaders) that source the key and build the header.
|
||||||
|
|
||||||
const SETTINGS_KEY = 'openjarvis-settings';
|
const SETTINGS_KEY = 'openjarvis-settings';
|
||||||
|
const fetchMock = vi.fn<typeof fetch>();
|
||||||
|
|
||||||
// Minimal in-memory localStorage stub so the helpers can run under node
|
// Minimal in-memory localStorage stub so the helpers can run under node
|
||||||
// (no jsdom dependency).
|
// (no jsdom dependency).
|
||||||
@@ -28,6 +29,8 @@ class MemoryStorage {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
|
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
|
||||||
|
fetchMock.mockReset();
|
||||||
|
globalThis.fetch = fetchMock;
|
||||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||||
new MemoryStorage();
|
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: {} },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -885,6 +885,25 @@ export async function saveToolCredentials(
|
|||||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
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 {
|
export interface AgentTraceDetail {
|
||||||
id: string;
|
id: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
fetchAgentTrace,
|
fetchAgentTrace,
|
||||||
fetchManagedAgent,
|
fetchManagedAgent,
|
||||||
fetchAvailableTools,
|
fetchAvailableTools,
|
||||||
saveToolCredentials,
|
|
||||||
fetchModels,
|
fetchModels,
|
||||||
updateManagedAgent,
|
updateManagedAgent,
|
||||||
fetchRecommendedModel,
|
fetchRecommendedModel,
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import {
|
|||||||
setInferenceSource,
|
setInferenceSource,
|
||||||
getCloudKeyStatus,
|
getCloudKeyStatus,
|
||||||
saveCloudKey,
|
saveCloudKey,
|
||||||
|
fetchToolCredentialStatus,
|
||||||
|
saveToolCredentials,
|
||||||
|
deleteToolCredential,
|
||||||
isTauri,
|
isTauri,
|
||||||
type InferenceSource,
|
type InferenceSource,
|
||||||
} from '../lib/api';
|
} 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 [value, setValue] = useState('');
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [hasKey, setHasKey] = useState(false);
|
const [hasKey, setHasKey] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const desktopKeyStorage = isTauri();
|
const desktopKeyStorage = isTauri();
|
||||||
|
const serverToolStorage = !desktopKeyStorage && !!toolName;
|
||||||
|
const canManage = desktopKeyStorage || serverToolStorage;
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!desktopKeyStorage) {
|
if (!canManage) {
|
||||||
setHasKey(false);
|
setHasKey(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const status = await getCloudKeyStatus();
|
const status = desktopKeyStorage
|
||||||
|
? await getCloudKeyStatus()
|
||||||
|
: await fetchToolCredentialStatus(toolName!);
|
||||||
setHasKey(!!status[keyName]);
|
setHasKey(!!status[keyName]);
|
||||||
} catch {
|
} catch {
|
||||||
setHasKey(false);
|
setHasKey(false);
|
||||||
}
|
}
|
||||||
}, [desktopKeyStorage, keyName]);
|
}, [canManage, desktopKeyStorage, keyName, toolName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -87,7 +102,13 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
|
|||||||
if (!next) return;
|
if (!next) return;
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await saveCloudKey(keyName, next);
|
if (desktopKeyStorage) {
|
||||||
|
await saveCloudKey(keyName, next);
|
||||||
|
} else if (toolName) {
|
||||||
|
await saveToolCredentials(toolName, { [keyName]: next });
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setValue('');
|
setValue('');
|
||||||
setHasKey(true);
|
setHasKey(true);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
@@ -101,7 +122,13 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
|
|||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await saveCloudKey(keyName, '');
|
if (desktopKeyStorage) {
|
||||||
|
await saveCloudKey(keyName, '');
|
||||||
|
} else if (toolName) {
|
||||||
|
await deleteToolCredential(toolName, keyName);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setValue('');
|
setValue('');
|
||||||
setHasKey(false);
|
setHasKey(false);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
@@ -119,8 +146,8 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={e => setValue(e.target.value)}
|
onChange={e => setValue(e.target.value)}
|
||||||
onBlur={() => { if (value.trim()) void save(value); }}
|
onBlur={() => { if (value.trim()) void save(value); }}
|
||||||
placeholder={hasKey ? 'Saved in secure storage' : placeholder}
|
placeholder={hasKey ? (desktopKeyStorage ? 'Saved in secure storage' : 'Saved by local server') : placeholder}
|
||||||
disabled={!desktopKeyStorage}
|
disabled={!canManage}
|
||||||
className="w-48 px-2 py-1 rounded text-xs"
|
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)' }} />
|
style={{ background: 'var(--color-bg)', border: '1px solid var(--color-border)', color: 'var(--color-text)' }} />
|
||||||
{hasKey && (
|
{hasKey && (
|
||||||
@@ -542,7 +569,7 @@ export function SettingsPage() {
|
|||||||
{/* Tools */}
|
{/* Tools */}
|
||||||
<Section title="Tools">
|
<Section title="Tools">
|
||||||
<SettingRow label="Web Search" description="Tavily key for web search tool">
|
<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>
|
</SettingRow>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -148,7 +148,8 @@ fi
|
|||||||
|
|
||||||
# ── 7. Install Python dependencies ──────────────────────────────────
|
# ── 7. Install Python dependencies ──────────────────────────────────
|
||||||
info "Installing 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"
|
ok "Python dependencies installed"
|
||||||
|
|
||||||
# ── 7b. Build Rust extension ──────────────────────────────────────
|
# ── 7b. Build Rust extension ──────────────────────────────────────
|
||||||
@@ -164,11 +165,17 @@ ok "Frontend dependencies installed"
|
|||||||
|
|
||||||
# ── 9. Start backend ────────────────────────────────────────────────
|
# ── 9. Start backend ────────────────────────────────────────────────
|
||||||
info "Starting backend API server on port 8000..."
|
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 &
|
uv run jarvis serve --port 8000 &>/dev/null &
|
||||||
CLEANUP_PIDS+=($!)
|
BACKEND_PID=$!
|
||||||
|
CLEANUP_PIDS+=("$BACKEND_PID")
|
||||||
sleep 3
|
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"
|
ok "Backend running at http://localhost:8000"
|
||||||
else
|
else
|
||||||
warn "Backend may still be starting..."
|
warn "Backend may still be starting..."
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from rich.console import Console
|
|||||||
|
|
||||||
from openjarvis.cli._banner import print_banner
|
from openjarvis.cli._banner import print_banner
|
||||||
from openjarvis.core.config import load_config
|
from openjarvis.core.config import load_config
|
||||||
|
from openjarvis.core.credentials import inject_credentials
|
||||||
from openjarvis.core.events import EventBus
|
from openjarvis.core.events import EventBus
|
||||||
from openjarvis.core.paths import get_config_dir
|
from openjarvis.core.paths import get_config_dir
|
||||||
from openjarvis.engine import (
|
from openjarvis.engine import (
|
||||||
@@ -122,6 +123,11 @@ def serve(
|
|||||||
)
|
)
|
||||||
sys.exit(1)
|
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()
|
config = load_config()
|
||||||
|
|
||||||
# Resolve host/port from CLI args or config
|
# Resolve host/port from CLI args or config
|
||||||
|
|||||||
@@ -67,6 +67,24 @@ def load_credentials(path: Path | None = None) -> dict[str, dict[str, str]]:
|
|||||||
return tomllib.load(f)
|
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(
|
def save_credential(
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
key: str,
|
key: str,
|
||||||
@@ -75,9 +93,7 @@ def save_credential(
|
|||||||
path: Path | None = None,
|
path: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Save a single credential key, validate, write file, and set os.environ."""
|
"""Save a single credential key, validate, write file, and set os.environ."""
|
||||||
allowed = TOOL_CREDENTIALS.get(tool_name, [])
|
_validate_credential_key(tool_name, key)
|
||||||
if key not in allowed:
|
|
||||||
raise ValueError(f"Unknown credential key '{key}' for tool '{tool_name}'")
|
|
||||||
stripped = value.strip()
|
stripped = value.strip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
raise ValueError("Credential value must not be empty")
|
raise ValueError("Credential value must not be empty")
|
||||||
@@ -88,20 +104,32 @@ def save_credential(
|
|||||||
if tool_name not in creds:
|
if tool_name not in creds:
|
||||||
creds[tool_name] = {}
|
creds[tool_name] = {}
|
||||||
creds[tool_name][key] = stripped
|
creds[tool_name][key] = stripped
|
||||||
|
_write_credentials(creds, p)
|
||||||
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)
|
|
||||||
|
|
||||||
os.environ[key] = stripped
|
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]:
|
def get_credential_status(tool_name: str) -> dict[str, bool]:
|
||||||
"""Return {KEY: bool} for each required key indicating if set in env."""
|
"""Return {KEY: bool} for each required key indicating if set in env."""
|
||||||
keys = TOOL_CREDENTIALS.get(tool_name, [])
|
keys = TOOL_CREDENTIALS.get(tool_name, [])
|
||||||
|
|||||||
@@ -285,16 +285,17 @@ def build_tools_list() -> List[Dict[str, Any]]:
|
|||||||
logger.debug("Could not instantiate tool %s: %s", name, exc)
|
logger.debug("Could not instantiate tool %s: %s", name, exc)
|
||||||
spec = None
|
spec = None
|
||||||
cred_keys = TOOL_CREDENTIALS.get(name, [])
|
cred_keys = TOOL_CREDENTIALS.get(name, [])
|
||||||
|
has_fallback = bool(spec and spec.metadata.get("fallback"))
|
||||||
items.append(
|
items.append(
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": spec.description if spec else "",
|
"description": spec.description if spec else "",
|
||||||
"category": spec.category if spec else "",
|
"category": spec.category if spec else "",
|
||||||
"source": "tool",
|
"source": "tool",
|
||||||
"requires_credentials": len(cred_keys) > 0,
|
"requires_credentials": len(cred_keys) > 0 and not has_fallback,
|
||||||
"credential_keys": cred_keys,
|
"credential_keys": cred_keys,
|
||||||
"configured": (
|
"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
|
if cred_keys
|
||||||
else True
|
else True
|
||||||
),
|
),
|
||||||
@@ -2238,6 +2239,13 @@ def create_agent_manager_router(
|
|||||||
saved.append(key)
|
saved.append(key)
|
||||||
return {"saved": saved}
|
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")
|
@tools_router.get("/{tool_name}/credentials/status")
|
||||||
def credential_status(tool_name: str):
|
def credential_status(tool_name: str):
|
||||||
from openjarvis.core.credentials import get_credential_status
|
from openjarvis.core.credentials import get_credential_status
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ def test_serve_does_not_call_systembuilder_build(tmp_path, monkeypatch):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
set_system_spy = MagicMock()
|
set_system_spy = MagicMock()
|
||||||
|
inject_spy = MagicMock()
|
||||||
|
monkeypatch.setattr(serve_mod, "inject_credentials", inject_spy)
|
||||||
|
|
||||||
result = _run_serve(
|
result = _run_serve(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
@@ -169,6 +171,7 @@ def test_serve_does_not_call_systembuilder_build(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
build_spy.assert_not_called()
|
build_spy.assert_not_called()
|
||||||
|
inject_spy.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
def test_executor_receives_required_system_attrs(tmp_path, monkeypatch):
|
def test_executor_receives_required_system_attrs(tmp_path, monkeypatch):
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import os
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from openjarvis.core.credentials import (
|
from openjarvis.core.credentials import (
|
||||||
|
delete_credential,
|
||||||
get_credential_status,
|
get_credential_status,
|
||||||
|
inject_credentials,
|
||||||
load_credentials,
|
load_credentials,
|
||||||
save_credential,
|
save_credential,
|
||||||
)
|
)
|
||||||
@@ -54,3 +56,26 @@ def test_file_permissions(cred_path):
|
|||||||
save_credential("web_search", "TAVILY_API_KEY", "tvly-x", path=cred_path)
|
save_credential("web_search", "TAVILY_API_KEY", "tvly-x", path=cred_path)
|
||||||
mode = oct(cred_path.stat().st_mode & 0o777)
|
mode = oct(cred_path.stat().st_mode & 0o777)
|
||||||
assert mode == "0o600"
|
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)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ ROOT = Path(__file__).resolve().parent.parent.parent
|
|||||||
PYPROJECT = ROOT / "pyproject.toml"
|
PYPROJECT = ROOT / "pyproject.toml"
|
||||||
DESKTOP_LIB_RS = ROOT / "frontend" / "src-tauri" / "src" / "lib.rs"
|
DESKTOP_LIB_RS = ROOT / "frontend" / "src-tauri" / "src" / "lib.rs"
|
||||||
WINDOWS_INSTALL_PS1 = ROOT / "deploy" / "windows" / "install.ps1"
|
WINDOWS_INSTALL_PS1 = ROOT / "deploy" / "windows" / "install.ps1"
|
||||||
|
QUICKSTART_SH = ROOT / "scripts" / "quickstart.sh"
|
||||||
|
|
||||||
|
|
||||||
def _pyproject() -> dict:
|
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 "
|
"the Windows installer must include `--group desktop-native` so "
|
||||||
"openjarvis_rust is built during source install."
|
"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
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Tests for GET /v1/tools endpoint."""
|
"""Tests for GET /v1/tools endpoint."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -45,3 +47,49 @@ def test_browser_meta_group():
|
|||||||
names = {t["name"] for t in tools}
|
names = {t["name"] for t in tools}
|
||||||
assert "browser" in names
|
assert "browser" in names
|
||||||
assert "browser_navigate" not 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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user