Compare commits

...
Author SHA1 Message Date
Elliot SluskyandClaude Opus 4.8 560ec860df fix(docker): build native Rust extension into images (#590)
Build and install the mandatory openjarvis_rust wheel in the CPU, NVIDIA, ROCm, and sandbox Docker images. Rust 1.88 (matching the workspace MSRV / rust-toolchain.toml) and maturin are installed only in the builder stage, the module's import is verified during the build, and maturin is removed before the runtime artifacts are copied so build tooling never ships. The frontend leaderboard anon key is an optional empty-by-default build arg (post-#589), so default images cleanly disable the leaderboard. Adds static deployment coverage for the native build path. Closes #584.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:27:17 -07:00
Jon Saad-FalconandClaude Opus 4.8 e7c46c1985 fix(frontend): make Supabase anon key optional to unblock PyPI publishing (#589)
PyPI publishing had been broken since v1.0.3.dev851: #587 made VITE_SUPABASE_ANON_KEY a hard build-time requirement, but no such secret exists, so the frontend build aborted every publish run before the PyPI upload. Decouple package buildability from the leaderboard credential: a missing anon key now disables the savings leaderboard at runtime instead of failing the build, and auto-enables when the secret is provided. Verified: npm run build with the key unset succeeds; tsc + vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:15:06 -07:00
github-actions[bot] 00d1e39b6d chore: update clone traffic data [skip ci] 2026-06-24 07:14:44 +00:00
Elliot SluskyandClaude Opus 4.8 843375d6ef Fix Supabase frontend build env for release builds (#588)
Follow-up to #587. Pass VITE_SUPABASE_ANON_KEY into the frontend builds of both release paths: the PyPI publish workflow (wheel-bundled frontend) and the desktop tauri-action build (npm run build:tauri -> vite build). Kept strict: a missing/empty secret fails the release by design rather than shipping a placeholder key. Requires the VITE_SUPABASE_ANON_KEY repo secret to be set for releases to succeed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:32:13 -07:00
Elliot SluskyandClaude Opus 4.8 8d33cb58fa Fix secure cloud key storage and Supabase key config (#587)
Route desktop cloud-key saves/status through the OS credential store (keyring with per-platform native backends: apple-native / windows-native / sync-secret-service), migrate the legacy plaintext ~/.openjarvis/cloud-keys.env into it, remove browser localStorage persistence of provider keys, and push key updates to the running server via /v1/cloud/reload (legacy env-file fallback retained). Remove hardcoded Supabase anon JWTs from frontend/docs source and make VITE_SUPABASE_ANON_KEY a required build var. Adds libdbus-1-dev to the Linux desktop build and a CI build var. Closes #220.

NOTE (post-merge follow-ups, not covered by CI): add the VITE_SUPABASE_ANON_KEY repo secret with the rotated key (release/docs builds otherwise use a placeholder), rotate the previously-committed Supabase anon key, and run a desktop save->restart->read smoke test to confirm keychain persistence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:11:32 -07:00
github-actions[bot] e4c4bcbae3 chore: update clone traffic data [skip ci] 2026-06-23 07:18:13 +00:00
Jon Saad-Falcon 993c24c8b9 test(server): make TestTraceRecording hermetic (env-independent) (#583)
TestTraceRecording relied on the ambient ~/.openjarvis/config.toml leaving traces.enabled at its default, so it failed on any machine with traces disabled locally (passing in CI only because the runner has no config file). Pass an explicit traces-enabled config with a tmp db_path so the tests are environment-independent and parallel-safe under pytest -n auto. Relates to #582.
2026-06-22 19:12:10 -07:00
25 changed files with 1760 additions and 1183 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "128,077",
"message": "130,895",
"color": "green",
"namedLogo": "git"
}
+5 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 128077,
"last_updated": "2026-06-22T08:05:29Z",
"total_clones": 130895,
"last_updated": "2026-06-24T07:14:43Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -88,6 +88,8 @@
"2026-06-18": 1408,
"2026-06-19": 1350,
"2026-06-20": 1437,
"2026-06-21": 1426
"2026-06-21": 1426,
"2026-06-22": 1350,
"2026-06-23": 1468
}
}
+8 -2
View File
@@ -40,7 +40,8 @@ jobs:
libappindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev
libxdo-dev \
libdbus-1-dev
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -130,7 +131,8 @@ jobs:
libappindicator3-dev \
librsvg2-dev \
patchelf \
libxdo-dev
libxdo-dev \
libdbus-1-dev
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -252,6 +254,10 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
TAURI_CONFIG: '{"version":"${{ steps.release-info.outputs.tauri_version }}","bundle":{"externalBin":["binaries/ollama"]}}'
# tauri-action runs beforeBuildCommand (npm run build:tauri -> vite
# build), which requires this at build time (#587). Strict for
# releases: a missing/empty secret fails the build by design.
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
with:
projectPath: frontend
tauriScript: npx tauri
+5
View File
@@ -35,3 +35,8 @@ jobs:
- run: npm ci
- run: npx tsc --noEmit
- run: npm run build
env:
# Optional: when the secret is unset the build still succeeds and the
# leaderboard is disabled (see src/lib/supabase.ts). No placeholder,
# so a keyless CI build doesn't bake in a bogus anon key.
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
+2
View File
@@ -55,6 +55,8 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Build frontend and bundle into package
env:
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
run: |
set -euo pipefail
cd frontend
+27 -3
View File
@@ -4,16 +4,30 @@
# Stage 1: Build frontend SPA
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
# Public Supabase anon key for the savings leaderboard; empty by default so
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
# Stage 2: Build Python package
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
# Install dependencies from the committed lockfile (#567). `uv export --frozen`
@@ -24,11 +38,13 @@ WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
# Copy the source and the non-src force-include paths (see pyproject
# [tool.hatch.build.targets.wheel.force-include]) before building the project.
COPY src/ src/
COPY rust/ rust/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
@@ -36,7 +52,15 @@ COPY deploy/windows deploy/windows
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
# Install the project itself without re-resolving dependencies.
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust
# Stage 3: Runtime
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
+31 -4
View File
@@ -4,20 +4,37 @@
# Stage 1: Build frontend SPA
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
# Public Supabase anon key for the savings leaderboard; empty by default so
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
# Stage 2: Build Python package (NVIDIA CUDA 12.4)
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3 AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
python3 \
python3-dev \
python3-pip \
python3-venv && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
@@ -25,15 +42,25 @@ WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
COPY src/ src/
COPY rust/ rust/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust
# Stage 3: Runtime
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3
+31 -4
View File
@@ -4,20 +4,37 @@
# Stage 1: Build frontend SPA
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
# Public Supabase anon key for the savings leaderboard; empty by default so
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
# Stage 2: Build Python package (AMD ROCm 7.2)
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644 AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
python3 \
python3-dev \
python3-pip \
python3-venv && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
@@ -25,15 +42,25 @@ WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
COPY src/ src/
COPY rust/ rust/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust
# Stage 3: Runtime
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644
+39 -12
View File
@@ -7,20 +7,18 @@
# image digest is the integrity check, and the copy is architecture-agnostic.
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS node
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
# libstdc++6 + ca-certificates are the only runtime requirements of the Node
# binary copied below (the python slim image already provides libc/libgcc).
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
# Transplant the Node.js runtime from the official image. Both images are Debian
# bookworm, so the glibc/libstdc++ ABI matches.
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
@@ -31,12 +29,41 @@ WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN pip install --no-cache-dir uv && \
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
uv pip install --system --no-deps -r requirements.txt
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
COPY . .
# Install the project itself without re-resolving dependencies.
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust/target
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
# libstdc++6 + ca-certificates are the only runtime requirements of the Node
# binary copied below (the python slim image already provides libc/libgcc).
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local /usr/local
COPY --from=builder /app /app
# Transplant the Node.js runtime from the official image. Both images are Debian
# bookworm, so the glibc/libstdc++ ABI matches.
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
WORKDIR /app
LABEL openjarvis-sandbox=true
+3 -3
View File
@@ -1,9 +1,9 @@
(function () {
"use strict";
var SUPABASE_URL = "https://mtbtgpwzrbostweaanpr.supabase.co";
var SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c";
var SUPABASE_URL =
window.OPENJARVIS_SUPABASE_URL || "https://mtbtgpwzrbostweaanpr.supabase.co";
var SUPABASE_ANON_KEY = window.OPENJARVIS_SUPABASE_ANON_KEY || "";
var PAGE_SIZE = 50;
var allRows = [];
+1108 -1007
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -24,9 +24,22 @@ serde_json = "1"
reqwest = { version = "0.12", features = ["json", "multipart"] }
tokio = { version = "1", features = ["full"] }
# Cloud API keys are stored in the OS credential store via `keyring`. keyring v3
# enables NO backend by default — without an explicit per-platform feature it
# silently falls back to a non-persistent in-memory mock, so keys would not
# survive an app restart. Each desktop target opts into its native store.
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2"
dispatch = "0.2"
keyring = { version = "3", features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Blocking Secret Service backend (no internal async runtime, so it is safe to
# call from the tokio-driven Tauri commands). Needs libdbus-1-dev at build time.
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
[features]
default = ["custom-protocol"]
+162 -44
View File
@@ -1204,7 +1204,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
// additions aren't accidentally stripped.
prepare_subprocess_for_appimage(&mut cmd);
// Inject cloud API keys from ~/.openjarvis/cloud-keys.env
// Inject cloud API keys from secure desktop storage.
for (key, value) in read_cloud_keys() {
cmd.env(&key, &value);
}
@@ -1720,17 +1720,111 @@ async fn submit_savings(
// Cloud API key management
// ---------------------------------------------------------------------------
/// Path to the cloud keys file (~/.openjarvis/cloud-keys.env).
fn cloud_keys_path() -> std::path::PathBuf {
const SECURE_KEY_SERVICE: &str = "OpenJarvis Cloud Keys";
const MANAGED_CLOUD_KEY_NAMES: &[&str] = &[
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"OPENROUTER_API_KEY",
"MINIMAX_API_KEY",
"TAVILY_API_KEY",
];
/// Legacy path used by older desktop builds. New saves never write here.
fn legacy_cloud_keys_path() -> std::path::PathBuf {
let home = home_dir();
std::path::PathBuf::from(home)
.join(".openjarvis")
.join("cloud-keys.env")
}
/// Read cloud keys from disk and return as key=value pairs.
fn read_cloud_keys() -> Vec<(String, String)> {
let path = cloud_keys_path();
fn validate_cloud_key_name(key_name: &str) -> Result<(), String> {
let valid = !key_name.is_empty()
&& key_name.len() <= 128
&& key_name.ends_with("_API_KEY")
&& key_name
.chars()
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_');
if valid {
Ok(())
} else {
Err(format!("Invalid API key name: {}", key_name))
}
}
fn engine_api_key_name(engine: &str) -> String {
let normalized: String = engine
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
}
})
.collect();
let trimmed = normalized.trim_matches('_');
let engine_name = if trimmed.is_empty() {
CUSTOM_FALLBACK_ENGINE.to_ascii_uppercase()
} else {
trimmed.to_string()
};
format!("{}_API_KEY", engine_name)
}
fn managed_cloud_key_names() -> Vec<String> {
let mut names: Vec<String> = MANAGED_CLOUD_KEY_NAMES
.iter()
.map(|name| (*name).to_string())
.collect();
let cfg = read_inference_config();
if matches!(&cfg.kind, SourceKind::Custom) {
let engine = cfg.engine.unwrap_or_else(|| CUSTOM_FALLBACK_ENGINE.to_string());
let key_name = engine_api_key_name(&engine);
if validate_cloud_key_name(&key_name).is_ok() {
names.push(key_name);
}
}
names.sort();
names.dedup();
names
}
fn secure_store_get(key_name: &str) -> Result<Option<String>, String> {
validate_cloud_key_name(key_name)?;
let entry = keyring::Entry::new(SECURE_KEY_SERVICE, key_name)
.map_err(|err| format!("Failed to open secure key storage for {}: {}", key_name, err))?;
match entry.get_password() {
Ok(value) => Ok(Some(value)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Failed to read {} from secure key storage: {}", key_name, err)),
}
}
fn secure_store_set(key_name: &str, key_value: &str) -> Result<(), String> {
validate_cloud_key_name(key_name)?;
let entry = keyring::Entry::new(SECURE_KEY_SERVICE, key_name)
.map_err(|err| format!("Failed to open secure key storage for {}: {}", key_name, err))?;
if key_value.is_empty() {
return match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(format!(
"Failed to remove {} from secure key storage: {}",
key_name, err
)),
};
}
entry
.set_password(key_value)
.map_err(|err| format!("Failed to save {} in secure key storage: {}", key_name, err))
}
fn read_legacy_cloud_keys() -> Vec<(String, String)> {
let path = legacy_cloud_keys_path();
let mut keys = Vec::new();
if let Ok(contents) = std::fs::read_to_string(&path) {
for line in contents.lines() {
@@ -1746,47 +1840,68 @@ fn read_cloud_keys() -> Vec<(String, String)> {
keys
}
/// Save a single cloud API key to the keys file.
#[tauri::command]
async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), String> {
let path = cloud_keys_path();
// Ensure directory exists
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
fn migrate_legacy_cloud_keys() {
let path = legacy_cloud_keys_path();
if !path.exists() {
return;
}
// Read existing keys, update/add the one being saved
let mut keys: Vec<(String, String)> = read_cloud_keys()
let legacy_keys = read_legacy_cloud_keys();
if legacy_keys.is_empty() {
let _ = std::fs::remove_file(&path);
return;
}
let mut migrated_all = true;
for (key, value) in legacy_keys {
if value.is_empty() {
continue;
}
if secure_store_set(&key, &value).is_err() {
migrated_all = false;
}
}
if migrated_all {
let _ = std::fs::remove_file(path);
}
}
/// Read cloud keys from secure desktop storage and return key=value pairs.
fn read_cloud_keys() -> Vec<(String, String)> {
migrate_legacy_cloud_keys();
managed_cloud_key_names()
.into_iter()
.filter(|(k, _)| k != &key_name)
.collect();
if !key_value.is_empty() {
keys.push((key_name, key_value));
}
.filter_map(|key| match secure_store_get(&key) {
Ok(Some(value)) if !value.is_empty() => Some((key, value)),
_ => None,
})
.collect()
}
// Write back
let content: String = keys
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&path, content + "\n").map_err(|e| format!("Failed to save key: {}", e))?;
// Set permissions to owner-only (chmod 600)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
// Tell the running server to hot-reload its cloud engine so the user
// doesn't need to restart the app after entering an API key.
async fn reload_cloud_keys(keys: Vec<(String, String)>) {
let reload_url = format!("http://127.0.0.1:{}/v1/cloud/reload", JARVIS_PORT);
let key_map: serde_json::Map<String, serde_json::Value> = keys
.into_iter()
.map(|(key, value)| (key, serde_json::Value::String(value)))
.collect();
let _ = reqwest::Client::new()
.post(&reload_url)
.json(&serde_json::json!({ "keys": key_map }))
.timeout(std::time::Duration::from_secs(10))
.send()
.await;
}
/// Save a single cloud API key to secure desktop storage.
#[tauri::command]
async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), String> {
let key_value = key_value.trim().to_string();
secure_store_set(&key_name, &key_value)?;
// Tell the running server to hot-reload its cloud engine so the user
// doesn't need to restart the app after entering an API key.
reload_cloud_keys(vec![(key_name, key_value)]).await;
Ok(())
}
@@ -1794,10 +1909,13 @@ async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), Strin
/// Get which cloud providers have keys configured (without exposing values).
#[tauri::command]
async fn get_cloud_key_status() -> Result<serde_json::Value, String> {
let keys = read_cloud_keys();
let status: Vec<serde_json::Value> = keys
.iter()
.map(|(k, v)| serde_json::json!({ "key": k, "set": !v.is_empty() }))
migrate_legacy_cloud_keys();
let status: Vec<serde_json::Value> = managed_cloud_key_names()
.into_iter()
.map(|key| {
let set = matches!(secure_store_get(&key), Ok(Some(value)) if !value.is_empty());
serde_json::json!({ "key": key, "set": set })
})
.collect();
Ok(serde_json::json!(status))
}
@@ -1809,8 +1927,8 @@ async fn get_inference_source() -> Result<InferenceConfig, String> {
}
/// Persist the chosen inference source. `host` is normalized to a bare base
/// URL. For custom endpoints, an optional API key is stored in cloud-keys.env
/// under `<ENGINE>_API_KEY`. Applies on next app launch.
/// URL. For custom endpoints, an optional API key is stored in secure desktop
/// storage under `<ENGINE>_API_KEY`. Applies on next app launch.
#[tauri::command]
async fn set_inference_source(
kind: String,
@@ -1842,7 +1960,7 @@ async fn set_inference_source(
.engine
.clone()
.unwrap_or_else(|| CUSTOM_FALLBACK_ENGINE.to_string());
let key_name = format!("{}_API_KEY", engine.to_ascii_uppercase());
let key_name = engine_api_key_name(&engine);
// Save the key before persisting the config: if the key can't be
// written, surface it and DON'T record a custom source whose
// credential is missing (which would fail confusingly at runtime).
+74 -48
View File
@@ -1,7 +1,15 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { Search, Cpu, X, Download, Loader2, Trash2, Check, Cloud, Key, Eye, EyeOff } from 'lucide-react';
import { useAppStore } from '../lib/store';
import { pullModel, deleteModel, fetchModels, preloadModel, isTauri } from '../lib/api';
import {
pullModel,
deleteModel,
fetchModels,
preloadModel,
isTauri,
getCloudKeyStatus,
saveCloudKey,
} from '../lib/api';
/** Popular models that users can download from the catalogue. */
const CATALOGUE_MODELS = [
@@ -23,7 +31,6 @@ const CATALOGUE_MODELS = [
interface CloudProvider {
name: string;
envKey: string;
storageKey: string;
models: Array<{ id: string; desc: string }>;
}
@@ -31,7 +38,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
{
name: 'OpenAI',
envKey: 'OPENAI_API_KEY',
storageKey: 'openjarvis-openai-key',
models: [
{ id: 'gpt-4o', desc: 'GPT-4o — fast, multimodal' },
{ id: 'gpt-4o-mini', desc: 'GPT-4o Mini — cheap, fast' },
@@ -41,7 +47,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
{
name: 'Anthropic',
envKey: 'ANTHROPIC_API_KEY',
storageKey: 'openjarvis-anthropic-key',
models: [
{ id: 'claude-sonnet-4-6', desc: 'Claude Sonnet 4.6 — balanced' },
{ id: 'claude-opus-4-6', desc: 'Claude Opus 4.6 — most capable' },
@@ -51,7 +56,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
{
name: 'Google',
envKey: 'GEMINI_API_KEY',
storageKey: 'openjarvis-gemini-key',
models: [
{ id: 'gemini-2.5-pro', desc: 'Gemini 2.5 Pro — flagship' },
{ id: 'gemini-2.5-flash', desc: 'Gemini 2.5 Flash — fast' },
@@ -61,7 +65,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
{
name: 'OpenRouter',
envKey: 'OPENROUTER_API_KEY',
storageKey: 'openjarvis-openrouter-key',
models: [
{ id: 'openrouter/auto', desc: 'Auto — best model for the task' },
{ id: 'openrouter/anthropic/claude-sonnet-4', desc: 'Claude Sonnet 4 via OpenRouter' },
@@ -70,16 +73,6 @@ const CLOUD_PROVIDERS: CloudProvider[] = [
},
];
function getStoredKey(storageKey: string): string {
try { return localStorage.getItem(storageKey) || ''; } catch { return ''; }
}
function setStoredKey(storageKey: string, value: string): void {
try {
if (value) localStorage.setItem(storageKey, value);
else localStorage.removeItem(storageKey);
} catch {}
}
type Tab = 'installed' | 'catalogue' | 'cloud';
export function CommandPalette() {
@@ -92,11 +85,10 @@ export function CommandPalette() {
const [deleting, setDeleting] = useState<string | null>(null);
const [customModel, setCustomModel] = useState('');
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({});
const [apiKeys, setApiKeys] = useState<Record<string, string>>(() => {
const keys: Record<string, string> = {};
for (const p of CLOUD_PROVIDERS) keys[p.storageKey] = getStoredKey(p.storageKey);
return keys;
});
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const [cloudKeyStatus, setCloudKeyStatus] = useState<Record<string, boolean>>({});
const [cloudKeyError, setCloudKeyError] = useState<string | null>(null);
const [savingKey, setSavingKey] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const models = useAppStore((s) => s.models);
@@ -106,6 +98,20 @@ export function CommandPalette() {
const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen);
const installedIds = new Set(models.map((m) => m.id));
const desktopKeyStorage = isTauri();
const refreshCloudKeyStatus = useCallback(async () => {
if (!desktopKeyStorage) {
setCloudKeyStatus({});
return;
}
try {
setCloudKeyStatus(await getCloudKeyStatus());
setCloudKeyError(null);
} catch (e: any) {
setCloudKeyError(e?.message || 'Failed to read cloud key status');
}
}, [desktopKeyStorage]);
const filtered = tab === 'installed'
? (query
@@ -122,6 +128,10 @@ export function CommandPalette() {
inputRef.current?.focus();
}, []);
useEffect(() => {
void refreshCloudKeyStatus();
}, [refreshCloudKeyStatus]);
useEffect(() => {
setSelectedIdx(0);
}, [query, tab]);
@@ -210,24 +220,29 @@ export function CommandPalette() {
};
const handleSaveKey = async (provider: CloudProvider, value: string) => {
setStoredKey(provider.storageKey, value);
setApiKeys((prev) => ({ ...prev, [provider.storageKey]: value }));
const keyValue = value.trim();
setSavingKey(provider.envKey);
setCloudKeyError(null);
// Also save to Tauri backend so the server process picks up the key
if (isTauri()) {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('save_cloud_key', { keyName: provider.envKey, keyValue: value });
} catch {}
try {
await saveCloudKey(provider.envKey, keyValue);
setApiKeys((prev) => ({ ...prev, [provider.envKey]: '' }));
await refreshCloudKeyStatus();
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'info', category: 'model',
message: `${provider.name} API key ${keyValue ? 'saved' : 'removed'}. Refreshing model list...`,
});
await refreshModels();
} catch (e: any) {
setCloudKeyError(e?.message || `Failed to save ${provider.name} API key`);
} finally {
setSavingKey(null);
}
};
useAppStore.getState().addLogEntry({
timestamp: Date.now(), level: 'info', category: 'model',
message: `${provider.name} API key ${value ? 'saved' : 'removed'}. Refreshing model list…`,
});
// Refresh the model list so cloud models appear immediately.
await refreshModels();
const handleKeyBlur = (provider: CloudProvider) => {
const draft = apiKeys[provider.envKey] || '';
if (draft.trim()) void handleSaveKey(provider, draft);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -323,6 +338,11 @@ export function CommandPalette() {
<Check size={12} /> Downloaded {pullSuccess} successfully
</div>
)}
{tab === 'cloud' && cloudKeyError && (
<div className="px-4 py-2 text-xs" style={{ color: 'var(--color-error)', background: 'rgba(220,38,38,0.05)' }}>
{cloudKeyError}
</div>
)}
{/* Results */}
<div className="max-h-[400px] overflow-y-auto py-2">
@@ -431,13 +451,17 @@ export function CommandPalette() {
/* ── Cloud Models tab ── */
<div className="px-4 py-2">
<div className="text-[11px] mb-3" style={{ color: 'var(--color-text-tertiary)' }}>
Add your API keys to use cloud models. Keys are stored locally on your device only.
{desktopKeyStorage
? 'Add your API keys to use cloud models. Keys are stored in secure desktop storage.'
: 'Configure cloud provider keys in the server environment to use cloud models.'}
</div>
{CLOUD_PROVIDERS.map((provider) => {
const key = apiKeys[provider.storageKey] || '';
const hasKey = !!key;
const isVisible = showKeys[provider.storageKey];
const key = apiKeys[provider.envKey] || '';
const hasSavedKey = !!cloudKeyStatus[provider.envKey];
const hasKey = hasSavedKey || !!key.trim();
const isVisible = showKeys[provider.envKey];
const isSaving = savingKey === provider.envKey;
return (
<div key={provider.name} className="mb-4">
@@ -458,26 +482,28 @@ export function CommandPalette() {
<input
type={isVisible ? 'text' : 'password'}
value={key}
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider.storageKey]: e.target.value }))}
onBlur={() => handleSaveKey(provider, apiKeys[provider.storageKey] || '')}
placeholder={`${provider.envKey}`}
onChange={(e) => setApiKeys((prev) => ({ ...prev, [provider.envKey]: e.target.value }))}
onBlur={() => handleKeyBlur(provider)}
placeholder={hasSavedKey ? 'Saved in secure storage' : provider.envKey}
disabled={!desktopKeyStorage || isSaving}
className="flex-1 text-xs px-2 py-1.5 bg-transparent outline-none font-mono"
style={{ color: 'var(--color-text)' }}
/>
<button
onClick={() => setShowKeys((prev) => ({ ...prev, [provider.storageKey]: !prev[provider.storageKey] }))}
onClick={() => setShowKeys((prev) => ({ ...prev, [provider.envKey]: !prev[provider.envKey] }))}
className="px-2 cursor-pointer" style={{ color: 'var(--color-text-tertiary)' }}
>
{isVisible ? <EyeOff size={12} /> : <Eye size={12} />}
</button>
</div>
{hasKey && (
{hasSavedKey && (
<button
onClick={() => handleSaveKey(provider, '')}
disabled={isSaving}
className="px-2 py-1 rounded-lg text-[10px] cursor-pointer"
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)', opacity: isSaving ? 0.5 : 1 }}
>
Remove
{isSaving ? 'Saving' : 'Remove'}
</button>
)}
</div>
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import type React from 'react';
import { invoke } from '@tauri-apps/api/core';
import { LEADERBOARD_ENABLED, SUPABASE_ANON_KEY, SUPABASE_URL } from '../../lib/supabase';
// ---------------------------------------------------------------------------
// Types
@@ -279,9 +280,6 @@ function getOrCreateAnonId(): string {
return id;
}
const SUPABASE_URL = 'https://mtbtgpwzrbostweaanpr.supabase.co';
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c';
const REFRESH_INTERVAL_MS = 5000;
export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
@@ -318,15 +316,16 @@ export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
return () => clearInterval(timer);
}, [fetchData]);
// Share savings to Supabase when opted in and data changes
// Share savings to Supabase when opted in and data changes. Skipped entirely
// when no anon key was built in (leaderboard disabled).
useEffect(() => {
if (!optInEnabled || !displayName || !data) return;
if (!LEADERBOARD_ENABLED || !optInEnabled || !displayName || !data) return;
const dollarSavings = data.per_provider.reduce((s, p) => s + p.total_cost, 0);
const energySaved = data.per_provider.reduce((s, p) => s + (p.energy_wh || 0), 0);
const flopsSaved = data.per_provider.reduce((s, p) => s + (p.flops || 0), 0);
invoke('submit_savings', {
supabaseUrl: SUPABASE_URL,
supabaseKey: SUPABASE_KEY,
supabaseKey: SUPABASE_ANON_KEY,
payload: {
anon_id: anonId,
display_name: displayName,
+4 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Regression for #266: the frontend must send the local API key as a Bearer
// token on /v1 + /api requests, or `jarvis serve` with a key configured 401s
@@ -26,11 +26,14 @@ class MemoryStorage {
}
beforeEach(() => {
vi.resetModules();
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
new MemoryStorage();
});
afterEach(() => {
vi.unstubAllEnvs();
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
undefined;
});
+27 -4
View File
@@ -1,12 +1,10 @@
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
import { SUPABASE_ANON_KEY, SUPABASE_URL } from './supabase';
// ---------------------------------------------------------------------------
// Supabase config — safe to embed (RLS protects writes)
// Supabase config
// ---------------------------------------------------------------------------
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://mtbtgpwzrbostweaanpr.supabase.co';
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c';
declare global {
interface Window {
__TAURI_INTERNALS__?: unknown;
@@ -15,6 +13,31 @@ declare global {
export const isTauri = () => typeof window !== 'undefined' && !!window.__TAURI_INTERNALS__;
export type CloudKeyStatus = Record<string, boolean>;
export async function getCloudKeyStatus(): Promise<CloudKeyStatus> {
if (!isTauri()) return {};
try {
const { invoke } = await import('@tauri-apps/api/core');
const rows = await invoke<Array<{ key: string; set: boolean }>>('get_cloud_key_status');
return Object.fromEntries(rows.map((row) => [row.key, row.set]));
} catch (e: any) {
throw new Error(e?.message ?? e ?? 'Failed to read cloud key status');
}
}
export async function saveCloudKey(keyName: string, keyValue: string): Promise<void> {
if (!isTauri()) {
throw new Error('Cloud API keys can be saved in the desktop app only.');
}
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('save_cloud_key', { keyName, keyValue });
} catch (e: any) {
throw new Error(e?.message ?? e ?? 'Failed to save cloud key');
}
}
// Cached API base URL fetched from the Tauri backend at startup.
// This avoids hardcoding the port — the Rust backend is the single
// source of truth for JARVIS_PORT.
+11
View File
@@ -0,0 +1,11 @@
export const SUPABASE_URL =
import.meta.env.VITE_SUPABASE_URL || 'https://mtbtgpwzrbostweaanpr.supabase.co';
// The Supabase anon key is optional at build time. When it is unset the public
// savings leaderboard is disabled rather than failing the build — this keeps
// the `openjarvis` package and desktop app buildable without coupling
// publishability to a leaderboard credential. Set VITE_SUPABASE_ANON_KEY at
// build time (from a repo secret) to enable the leaderboard.
export const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY ?? '';
export const LEADERBOARD_ENABLED = SUPABASE_ANON_KEY.length > 0;
+115 -24
View File
@@ -19,9 +19,21 @@ import {
RefreshCw,
} from 'lucide-react';
import { useAppStore, type ThemeMode } from '../lib/store';
import { checkHealth, fetchSpeechHealth, getMemoryStats, getInferenceSource, setInferenceSource, type InferenceSource } from '../lib/api';
import {
checkHealth,
fetchSpeechHealth,
getMemoryStats,
getInferenceSource,
setInferenceSource,
getCloudKeyStatus,
saveCloudKey,
isTauri,
type InferenceSource,
} from '../lib/api';
import { isAutoUpdateDisabled, setAutoUpdateDisabled } from '../components/Desktop/UpdateChecker';
const CLOUD_KEY_STATUS_CHANGED = 'openjarvis-cloud-key-status-changed';
function OllamaModelList() {
const [models, setModels] = useState<Array<{ name: string; size: number }>>([]);
useEffect(() => {
@@ -44,32 +56,111 @@ function OllamaModelList() {
);
}
function ApiKeyInput({ storageKey, placeholder }: { storageKey: string; placeholder: string }) {
const [value, setValue] = useState(() => {
try { return localStorage.getItem(storageKey) || ''; } catch { return ''; }
});
function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: string }) {
const [value, setValue] = useState('');
const [saved, setSaved] = useState(false);
const save = (v: string) => {
setValue(v);
try { if (v) localStorage.setItem(storageKey, v); else localStorage.removeItem(storageKey); } catch {}
setSaved(true);
setTimeout(() => setSaved(false), 2000);
const [hasKey, setHasKey] = useState(false);
const [error, setError] = useState('');
const desktopKeyStorage = isTauri();
const refresh = useCallback(async () => {
if (!desktopKeyStorage) {
setHasKey(false);
return;
}
try {
const status = await getCloudKeyStatus();
setHasKey(!!status[keyName]);
} catch {
setHasKey(false);
}
}, [desktopKeyStorage, keyName]);
useEffect(() => {
void refresh();
window.addEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
return () => window.removeEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
}, [refresh]);
const save = async (v: string) => {
const next = v.trim();
if (!next) return;
setError('');
try {
await saveCloudKey(keyName, next);
setValue('');
setHasKey(true);
setSaved(true);
window.dispatchEvent(new Event(CLOUD_KEY_STATUS_CHANGED));
setTimeout(() => setSaved(false), 2000);
} catch (e: any) {
setError(e?.message || 'Failed to save API key');
}
};
const remove = async () => {
setError('');
try {
await saveCloudKey(keyName, '');
setValue('');
setHasKey(false);
setSaved(true);
window.dispatchEvent(new Event(CLOUD_KEY_STATUS_CHANGED));
setTimeout(() => setSaved(false), 2000);
} catch (e: any) {
setError(e?.message || 'Failed to remove API key');
}
};
return (
<div className="flex items-center gap-2">
<input type="password" value={value} onChange={e => save(e.target.value)} placeholder={placeholder}
<input
type="password"
value={value}
onChange={e => setValue(e.target.value)}
onBlur={() => { if (value.trim()) void save(value); }}
placeholder={hasKey ? 'Saved in secure storage' : placeholder}
disabled={!desktopKeyStorage}
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 && (
<button
onClick={() => void remove()}
className="px-2 py-1 rounded text-[10px] cursor-pointer"
style={{ color: 'var(--color-error)', border: '1px solid var(--color-error)' }}
>
Remove
</button>
)}
{saved && <span className="text-[10px]" style={{ color: 'var(--color-success)' }}>Saved</span>}
{error && <span className="text-[10px]" style={{ color: 'var(--color-error)' }}>{error}</span>}
</div>
);
}
function CloudProviderStatus({ label, storageKey }: { label: string; storageKey: string }) {
function CloudProviderStatus({ label, keyName }: { label: string; keyName: string }) {
const [hasKey, setHasKey] = useState(false);
const desktopKeyStorage = isTauri();
const refresh = useCallback(async () => {
if (!desktopKeyStorage) {
setHasKey(false);
return;
}
try {
const status = await getCloudKeyStatus();
setHasKey(!!status[keyName]);
} catch {
setHasKey(false);
}
}, [desktopKeyStorage, keyName]);
useEffect(() => {
try { setHasKey(!!localStorage.getItem(storageKey)); } catch { setHasKey(false); }
}, [storageKey]);
void refresh();
window.addEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
return () => window.removeEventListener(CLOUD_KEY_STATUS_CHANGED, refresh);
}, [refresh]);
return (
<span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<span style={{
@@ -424,10 +515,10 @@ export function SettingsPage() {
</div>
<SettingRow label="Cloud providers" description="Green dot means API key is configured">
<div className="flex flex-wrap gap-3">
<CloudProviderStatus label="OpenAI" storageKey="openjarvis-openai-key" />
<CloudProviderStatus label="Anthropic" storageKey="openjarvis-anthropic-key" />
<CloudProviderStatus label="Google" storageKey="openjarvis-gemini-key" />
<CloudProviderStatus label="OpenRouter" storageKey="openjarvis-openrouter-key" />
<CloudProviderStatus label="OpenAI" keyName="OPENAI_API_KEY" />
<CloudProviderStatus label="Anthropic" keyName="ANTHROPIC_API_KEY" />
<CloudProviderStatus label="Google" keyName="GEMINI_API_KEY" />
<CloudProviderStatus label="OpenRouter" keyName="OPENROUTER_API_KEY" />
</div>
</SettingRow>
</Section>
@@ -435,23 +526,23 @@ export function SettingsPage() {
{/* API Keys */}
<Section title="API Keys">
<SettingRow label="OpenAI" description="GPT-4, GPT-3.5, etc.">
<ApiKeyInput storageKey="openjarvis-openai-key" placeholder="sk-..." />
<ApiKeyInput keyName="OPENAI_API_KEY" placeholder="sk-..." />
</SettingRow>
<SettingRow label="Anthropic" description="Claude models">
<ApiKeyInput storageKey="openjarvis-anthropic-key" placeholder="sk-ant-..." />
<ApiKeyInput keyName="ANTHROPIC_API_KEY" placeholder="sk-ant-..." />
</SettingRow>
<SettingRow label="Google" description="Gemini models">
<ApiKeyInput storageKey="openjarvis-gemini-key" placeholder="AI..." />
<ApiKeyInput keyName="GEMINI_API_KEY" placeholder="AI..." />
</SettingRow>
<SettingRow label="OpenRouter" description="Multi-provider routing">
<ApiKeyInput storageKey="openjarvis-openrouter-key" placeholder="sk-or-..." />
<ApiKeyInput keyName="OPENROUTER_API_KEY" placeholder="sk-or-..." />
</SettingRow>
</Section>
{/* Tools */}
<Section title="Tools">
<SettingRow label="Web Search" description="SerpAPI or Tavily key for web search tool">
<ApiKeyInput storageKey="openjarvis-search-key" placeholder="API key..." />
<SettingRow label="Web Search" description="Tavily key for web search tool">
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." />
</SettingRow>
</Section>
+3 -1
View File
@@ -1,7 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_API_URL?: string;
readonly VITE_SUPABASE_URL?: string;
readonly VITE_SUPABASE_ANON_KEY?: string;
}
interface ImportMeta {
+3
View File
@@ -4,6 +4,9 @@ import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { VitePWA } from 'vite-plugin-pwa';
// VITE_SUPABASE_ANON_KEY is intentionally NOT required here: a missing key
// disables the savings leaderboard at runtime (see src/lib/supabase.ts) rather
// than failing the build, so the package/app stays publishable without it.
export default defineConfig({
resolve: {
alias: {
+4 -4
View File
@@ -1,8 +1,8 @@
"""Direct cloud API router — bypasses the engine system entirely.
Reads API keys from ~/.openjarvis/cloud-keys.env at request time so
it works even when the server was started without cloud keys in its
environment. Uses httpx directly so no cloud SDK packages are required.
Reads API keys from the process environment, with a legacy
~/.openjarvis/cloud-keys.env fallback for non-desktop/manual setups. Uses
httpx directly so no cloud SDK packages are required.
"""
from __future__ import annotations
@@ -38,7 +38,7 @@ _LOCAL_HF_ORGS = (
def _load_keys() -> dict[str, str]:
"""Read cloud-keys.env from disk every call so live updates are picked up."""
"""Read available cloud keys every call so live updates are picked up."""
keys: dict[str, str] = {}
# File first, then fall back to process environment
if _CLOUD_ENV_FILE.exists():
+28 -8
View File
@@ -848,14 +848,34 @@ async def reload_cloud_engine(request: Request):
"""
import os
# Re-read ~/.openjarvis/cloud-keys.env and update the running process env.
keys_path = get_config_dir() / "cloud-keys.env"
if keys_path.exists():
for raw_line in keys_path.read_text().splitlines():
line = raw_line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ[k.strip()] = v.strip()
submitted_keys: dict[str, str] | None = None
try:
body = await request.json()
raw_keys = body.get("keys") if isinstance(body, dict) else None
if isinstance(raw_keys, dict):
submitted_keys = {
str(k): str(v)
for k, v in raw_keys.items()
if str(k).endswith("_API_KEY")
}
except Exception:
submitted_keys = None
if submitted_keys is not None:
for key, value in submitted_keys.items():
if value:
os.environ[key] = value
else:
os.environ.pop(key, None)
else:
# Compatibility fallback for non-desktop/manual configurations.
keys_path = get_config_dir() / "cloud-keys.env"
if keys_path.exists():
for raw_line in keys_path.read_text().splitlines():
line = raw_line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ[k.strip()] = v.strip()
# Try to build a fresh CloudEngine.
try:
+29
View File
@@ -123,6 +123,35 @@ class TestDockerFiles:
assert "jarvis:" in content
assert "ollama:" in content
def test_dockerfiles_build_native_rust_extension(self):
build_dockerfiles = [
"Dockerfile",
"Dockerfile.gpu",
"Dockerfile.gpu.rocm",
"Dockerfile.sandbox",
]
required_markers = [
"rustup toolchain install 1.88",
"maturin build --release",
"rust/crates/openjarvis-python/Cargo.toml",
"/tmp/openjarvis-rust-wheel/*.whl",
"import openjarvis_rust",
]
for name in build_dockerfiles:
content = (DOCKER_DIR / name).read_text()
for marker in required_markers:
assert marker in content, (
f"{name}: missing native build marker {marker!r}"
)
for name in ["Dockerfile", "Dockerfile.gpu", "Dockerfile.gpu.rocm"]:
content = (DOCKER_DIR / name).read_text()
assert "COPY rust/ rust/" in content, f"{name}: rust workspace not copied"
assert content.index("COPY rust/ rust/") < content.index(
"maturin build --release"
), f"{name}: rust workspace copied after native build"
def test_systemd_service_exists(self):
assert (SYSTEMD_DIR / "openjarvis.service").is_file()
+22 -4
View File
@@ -790,8 +790,25 @@ class TestCreateApp:
# ---------------------------------------------------------------------------
def _traces_enabled_config(tmp_path):
"""A config with traces explicitly enabled, isolated to *tmp_path*.
``create_app`` only builds a trace store when ``config.traces.enabled`` is
true (server/app.py). Relying on the ambient ``load_config()`` made these
tests fail on any machine whose ``~/.openjarvis/config.toml`` disables
traces; pinning an explicit config + tmp db keeps them hermetic and
parallel-safe under ``pytest -n auto``.
"""
from openjarvis.core.config import JarvisConfig
cfg = JarvisConfig()
cfg.traces.enabled = True
cfg.traces.db_path = str(tmp_path / "traces.db")
return cfg
class TestTraceRecording:
def test_agent_completion_creates_trace(self):
def test_agent_completion_creates_trace(self, tmp_path):
"""A non-streaming agent completion records exactly one trace.
The collector is the single writer: it saves directly and also
@@ -810,9 +827,10 @@ class TestTraceRecording:
"test-model",
agent=agent,
bus=EventBus(record_history=False),
config=_traces_enabled_config(tmp_path),
)
store = app.state.trace_store
assert store is not None, "traces enabled by default → store should exist"
assert store is not None, "traces explicitly enabled → store should exist"
assert store.count() == 0
client = TestClient(app)
@@ -831,10 +849,10 @@ class TestTraceRecording:
assert trace.query == "What is 2+2?"
assert trace.result == "traced reply"
def test_streaming_completion_creates_trace(self):
def test_streaming_completion_creates_trace(self, tmp_path):
"""A streamed completion (no agent) records the assembled response."""
engine = _make_engine()
app = create_app(engine, "test-model")
app = create_app(engine, "test-model", config=_traces_enabled_config(tmp_path))
store = app.state.trace_store
assert store is not None
assert store.count() == 0